[BE 설계 3단계] 기술 검토 및 스택 선정 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
기술 스택 선정 및 설계
목차
1. 언어
언어 비교
비교 대상: Java, Kotlin
비교 이유
- 둘 다 JVM 기반 언어이므로 동일한 JVM 환경에서 실행할 수 있다.
- 둘 다 Spring 생태계에서 널리 사용되므로 백엔드 개발에 활용할 수 있다.
Java
- 정적 타입 및 객체지향 언어
- 런타임에
NullPointerException이 발생할 위험이 있음- Virtual Thread를 통해 비동기 처리 작업 수행 가능
Kotlin
- 정적 타입 및 객체지향과 함수형 프로그래밍 지원
- 타입 시스템에서 nullable을 구분하여 null safety 제공
- 코루틴을 통해 비동기 작업의 생명주기를 세밀하게 관리 가능
채택 결과
Java
채택 이유
- 코루틴의 세밀한 관리 기능까지 별도로 운영할 필요성이 크지 않다. Virtual Thread를 사용하면 블로킹 I/O 중 플랫폼 스레드가 점유되는 문제를 해결할 수 있다.
- JDK 표준 기능만으로 프로젝트를 구성할 수 있다.
- Kotlin 사용 경험이 없어 언어 학습에 추가 비용이 발생한다.
버전 비교
비교 대상: Java 17, Java 21, Java 25
Java 17
- Platform Thread와 OS Thread가 1:1로 대응한다.
- 동시 요청이 많으면 OS Thread 비용과 Blocking I/O 대기 문제가 발생한다.
Java 21
- Virtual Thread 기반 Non-Blocking I/O를 통해 적은 OS Thread로 많은 동시 작업을 처리할 수 있다.
synchronized의 모니터가 캐리어 스레드에 종속되어 Virtual Thread가 캐리어 스레드에서 내려오지 못하는 Pinning 문제가 있다.
Java 24
- Virtual Thread의 Pinning 문제가 개선되었다.
Java 25
- 개선된 Virtual Thread를 제공하는 LTS 버전이다.
- 장기적인 안정성과 유지보수를 고려하여 채택한다.
채택 결과
Java 25
채택 이유
- 서비스에서 DB, S3, Redis, AI, 카카오 로그인 및 모빌리티 API 등 외부 시스템과의 상호작용이 많다.
- Virtual Thread 도입 이전 버전에서는 I/O 작업이 완료될 때까지 OS Thread가 점유되므로 동시 요청 수가 Tomcat Worker 수에 그대로 제한된다.
- Virtual Thread를 통해 I/O 대기 상태가 캐리어 스레드를 점유하지 않도록 한다. 이에 따라 특정 외부 서비스에서 지연이 발생하더라도 다른 요청을 정상적으로 받을 수 있다.
2. 프레임워크
채택 결과
Spring Boot
채택 이유
- IoC 컨테이너를 중심으로 프레임워크가 객체의 생성, 의존관계, 설정 및 생명주기를 대신 관리한다.
- DI를 통해 객체 사이의 결합도를 낮추고 테스트와 구현체 교체를 쉽게 할 수 있다.
- AOP를 통해 트랜잭션, 보안, 로깅 등 여러 비즈니스 로직에서 반복되는 공통 기능을 분리하여 적용할 수 있다.
Spring Framework 애플리케이션의 핵심 기능을 빠르게 구성하고 실행·운영할 수 있도록 자동화한 도구인 Spring Boot를 사용한다.
버전 비교
Java 25를 지원하는 Stable 버전인 Spring Boot 3.5, 4.0, 4.1을 비교한다.
| 구분 | Spring Boot 3.5 | Spring Boot 4.0 | Spring Boot 4.1 |
|---|---|---|---|
| 기반 Spring | 6.2 | 7.0 | 7.0 |
| Java | 17~25 | 17~25+ | 17~26 |
| Servlet / Tomcat | Servlet 6.0 / Tomcat 10.1 | Servlet 6.1 / Tomcat 11 | Servlet 6.1 / Tomcat 11 |
| 성격 | 3.x의 최신 안정 버전 | 4.x의 큰 세대교체 | 4.x 최신 기능 및 개선 |
| 마이그레이션 부담 | 낮음 | 높음 | 4.0에서 이미 이전했다면 상대적으로 낮음 |
채택 결과
Spring Framework 4.1.1
채택 이유
- Spring Boot 3세대에서 4세대로 업그레이드할 때 deprecated API 제거 등 기존 코드에 영향을 줄 수 있는 변경이 있다. 이후 운영 및 마이그레이션 부담을 고려하여 4.x 버전을 채택한다.
- 안정적인 버전, 충분한 레퍼런스 및 유지보수 여부를 고려하여 4.0과 4.1을 비교한다.
- 4.0에 최신 기능과 개선 사항을 반영한 버전으로 업그레이드가 권고되는 4.1.x를 우선 고려한다.
3. DB
채택 결과
MySQL
채택 이유
- MySQL의 기본 스토리지 엔진인 InnoDB는 ACID 트랜잭션을 보장하며 행 수준 잠금과 MVCC를 제공한다.
- 읽기 작업은 MVCC를 기반으로 처리한다. 잠금 없이 스냅샷을 읽으므로 쓰기 작업에 막히지 않으며, 쓰기 충돌은 락을 통해 제어한다.
- Spring Data JPA와의 호환성이 검증되었다.
- MySQL의 네이티브 공간 인덱스(Spatial Index)만으로 위치 기반 조회 요구사항을 충분히 처리할 수 있다고 판단했다.
InnoDB 특징
- 안정적인 트랜잭션 처리와 동시 요청이 많은 환경에 적합한 스토리지 엔진이다.
- 커밋과 롤백을 사용하는 트랜잭션, ACID 및 행 단위 잠금을 지원하여 동시 처리 성능을 높인다.
- MVCC를 통해 데이터의 여러 버전을 관리함으로써 읽기와 쓰기 간 충돌을 줄인다.
- 서버가 비정상 종료되면 Redo Log와 Undo Log를 활용하여 데이터를 복구하고 미완료 트랜잭션을 정리한다.
락 메커니즘
- InnoDB의 행 잠금은 기본적으로 **공유 락(Shared Lock)**과 **배타 락(Exclusive Lock)**으로 동작한다.
- 공유 락은 여러 트랜잭션이 같은 행을 읽는 것을 허용하지만 해당 행의 변경은 제한하며, 잠금 읽기에서 사용된다.
- 배타 락은 데이터를 변경하거나 변경을 전제로 조회할 때 획득한다. 다른 트랜잭션은 해당 행에 공유 락이나 배타 락을 획득할 수 없으며, 락이 해제될 때까지 대기한다.
동시성 메커니즘
- 읽기와 쓰기의 충돌은 MVCC로 회피하고, 쓰기와 쓰기의 충돌은 락을 통해 순차적으로 처리한다.
- 트랜잭션이 읽기를 시작할 때 생성된 스냅샷을 기준으로 요청을 수행한다.
- 읽기는 스냅샷을 보지만 쓰기는 최신 행에 적용되어 발생할 수 있는 갱신 손실 문제를 락 메커니즘으로 해결한다.
발생 가능한 동시성 문제
데드락
두 개 이상의 트랜잭션이 서로 상대방이 보유한 락이 해제되기를 기다리는 순환 대기 상태이다.
예시
트랜잭션 A가 1번 행을 잠근 뒤 2번 행의 락을 기다리고, 트랜잭션 B가 2번 행을 잠근 뒤 1번 행의 락을 기다리는 상황
회피 전략
- 접근 순서 통일: 여러 행을 변경할 때 PK 오름차순으로 접근한다.
- 트랜잭션을 짧게 구성하여 락 보유 시간을 줄인다.
- 적절한 인덱스를 사용하여 불필요한 인덱스 탐색에 따른 레코드 과다 잠금을 방지한다.
트랜잭션 격리 수준
InnoDB의 기본 트랜잭션 격리 수준은 REPEATABLE READ이다.
REPEATABLE READ
- 트랜잭션의 첫 번째 일반 조회에서 생성된 스냅샷을 이후 조회에서도 사용한다.
- 동일한 조건으로 범위 조회를 반복했을 때 다른 트랜잭션의 삽입이나 삭제로 조회 결과가 달라지는 Phantom Read 현상이 발생하지 않는다.
- 일반 읽기와 잠금 읽기를 함께 사용하면 서로 다른 데이터 시점을 볼 수 있으므로 주의해야 한다.
4. 인메모리 DB
채택 결과
Redis
채택 이유
- 다중 인스턴스 환경에서 로컬 캐시만으로는 RTR 재사용 감지가 어렵기 때문에 저장소가 인스턴스 외부에 있어야 한다.
- 혼잡도 스냅샷처럼 접속자 수에 비례하여 반복되는 조회를 MySQL Connection 점유, SQL Parsing 및 Plan 생성 없이 하나의
GET명령으로 처리할 수 있다. - 키별 TTL(세션 만료 및 Grace Period),
SET NX EX원자 명령, 패밀리 단위 폐기를 위한 Set 자료구조를 지원한다.
인메모리 DB 특징
- 버퍼 풀 메모리에 데이터를 캐싱하는 디스크 기반 DB(예: MySQL)와 달리, 인메모리 DB는 메모리에 실제 데이터를 저장한다.
- B+Tree 페이지, 행·열 및 Secondary Index 자료구조 대신 String, Hash, Set 등의 메모리 자료구조를 사용한다.
- 명령 처리가 단일 스레드 이벤트 루프에서 이루어지므로 락이나 MVCC가 없고, 명령 자체가 원자적이다.
- TTL이 키의 속성이므로 저장소에서 만료를 처리하며 별도의 정리 배치가 필요하지 않다.
인메모리 DB 사용 영역
혼잡도 최신 스냅샷
- 60초 주기마다 한 번 쓰고 모든 요청이 읽는 구조이며, 다중 인스턴스가 동일한 주기를 공유해야 한다.
- 요청마다 MySQL Connection을 점유하지 않고 인메모리 DB에서 빠르게 조회하기 위해 사용한다.
- 데이터가 유실되어도 다음 주기에 다시 산출할 수 있는 휘발성 허용 데이터이다.
세션
- RTR 기반 Refresh Token 재사용 감지는 과거 사실을 조회해야 하므로 Stateless 구조로 처리할 수 없다. 따라서 인스턴스 공용 저장소가 필요하다.
- 10초의 Grace Period 동안 회전된 토큰을 짧은 TTL로 유지하여 방금 사용된 토큰을 구분한다.
5. 동기·비동기 처리
비동기로 처리할 영역
- 미터기 OCR: 사진 기반 결제 금액 인식
- 송금 AI 판단: 계좌이체 캡처의 진위 및 완료 여부 판별
- 알림 발송
비동기 처리가 필요한 이유
미터기 OCR
외부 OCR API 처리에는 수 초에서 수십 초가 걸릴 수 있다. 요청 스레드가 처리 시간만큼 블로킹되면 트래픽이 몰릴 때 서버 스레드 풀이 고갈될 수 있다.
또한 단일 HTTP 요청을 오래 유지하면 모바일 네트워크 환경에서 타임아웃이 발생할 수 있다. 따라서 접수 후 즉시 응답하고 클라이언트가 결과를 폴링하는 방식으로 처리한다.
송금 AI 판단
정산 참여자는 서로 다른 시점에 독립적으로 이체 캡처를 제출하므로 전원 완료 시점을 미리 알 수 없다. 특정 사용자의 요청·응답 흐름 안에 전원 완료 여부 확인 로직을 포함하기 어렵다.
따라서 각 참여자의 완료 이벤트가 발생할 때마다 전체 상태를 다시 확인하는 이벤트 기반 구조가 필요하다.
알림 발송
FCM은 외부 서버 호출이므로 처리 시간과 성공 여부를 직접 통제할 수 없다. 다만 발송 전에 알림 저장이 완료되므로 발송에 실패하더라도 알림 자체가 유실되지는 않으며, 사용자는 알림함 API(GET /notifications)에서 확인할 수 있다.
카풀 요청·수락 및 정산 요청과 같이 저장의 동기 처리가 필요한 알림도 발송 단계는 비동기로 분리한다. 이에 따라 외부 FCM 서버 상태가 핵심 기능에 영향을 주지 않도록 한다.
선택한 비동기 기술 스택
미터기 OCR 및 송금 AI
Spring @Async + 직접 구현한 재시도 로직
- 현재는 별도 브로커인 RabbitMQ를 운영하는 부담이 이점보다 크다고 판단했다.
- 서버를 두 대 이상으로 Scale-out하는 시점에 작업 큐, 재시도·DLQ 및 Worker 분산을 지원하는 RabbitMQ로 전환할 예정이다.
알림
Spring @TransactionalEventListener(phase = AFTER_COMMIT) + @Async
- 저장 트랜잭션이 커밋된 직후 실행되도록 통일한다.
- FCM 호출 실패가 카풀 수락이나 정산 처리 등의 핵심 기능에 영향을 주지 않도록 분리한다.
- 현재는 미터기 OCR과 마찬가지로
@Async수준으로 충분하다고 판단하며, 트래픽 증가로 Scale-out할 때 같은 기준으로 RabbitMQ 전환을 검토한다.