[BE 설계 3단계] 기술 검토 및 스택 선정 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

기술 스택 선정 및 설계

목차

  1. 언어
  2. 프레임워크
  3. DB
  4. 인메모리 DB
  5. 동기·비동기 처리

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 전환을 검토한다.