본문 바로가기
📝 일상/Meet up 후기

토스 메이커스 컨퍼런스 2025 재밌게 들은거 정리 =3

by Jinseong Hwang 2025. 7. 28.

후끈한 현장의 열기 🔥

 

현장결제 서비스의 분산 트랜잭션 관리학 개론

문제 상황

분산 시스템 환경에서 여러 API를 호출하여 하나의 작업을 완료해야 할 때, 각 API 호출은,

  • 성공(SUCCESS), 실패(FAILURE), 또는 알 수 없음(UNKNOWN) 세 가지 상태를 가질 수 있다.
  • 특히 타임아웃, 네트워크 유실 등으로 인해 UNKNOWN 상태가 발생하면 전체 트랜잭션의 일관성을 보장하기 어렵다.

해결을 위한 접근법

  1. 복잡한 트랜잭션 구조를 명확하게 표현할 모델이 필요하다.
  2. 누가, 어떻게 데이터 일관성을 책임질지 정의해야 한다.
  3. 일관성을 언제까지 보장할 것인지 결정해야 한다.

1. 트리(Tree) 구조를 이용한 표현 모델

복잡한 분산 트랜잭션을 직관적으로 표현하기 위해 트리 구조를 도입

  • 하나의 결제 승인 요청은 여러 하위 작업(Subtask)으로 구성된 트리로 모델링할 수 있다.
  • 예를 들어 '토스 결제 승인'이라는 최상위 노드는 '토스 할인', '토스 포인트', '토스 머니'와 같은 자식 노드를 가진다.
  • '토스 머니' 노드는 다시 '충전'과 '사용'이라는 자식 노드를 가질 수 있다.
  • 각 노드의 상태(SUCCESS, FAIL, UNKNOWN)를 파악하고, 자식 노드의 상태를 종합하여 부모 노드의 상태를 결정한다.

2. 일관성 보장: 각 노드의 보상 정책

일관성은 각 노드가 스스로 책임지는 구조를 사용한다.

  • 보상 트랜잭션 (Compensation Transaction): 특정 작업의 변경 사항을 취소하는 연산. (ex 사용 ← 취소)
  • 각 노드는 자신의 상태와 하위 노드의 상태를 바탕으로, 전체 트랜잭션을 성공으로 이끌지(재시도 등) 또는 실패로 처리할지(보상 트랜잭션 실행)를 결정하는 정책을 가진다.

방법 1 (성공으로 맞추기)

실패한 하위 트랜잭션(ex. 토스포인트 사용)을 재시도하여 전체 성공을 유도한다.

방법 2 (실패로 맞추기)

이미 성공한 다른 하위 트랜잭션들(ex. 토스할인, 토스머니)에 대해 보상 트랜잭션을 실행하여 전체를 실패 상태로 만든다.

3. 최종적 일관성 (Eventual Consistency) 추구

API 타임 내 (트랜잭션 내에서) 즉시 일관성을 보장하는 것은 어렵다. 최대한 빠른 시간 내에 최종적 일관성을 추구하는 것을 목표로 한다. UNKNOWN 상태의 트랜잭션이 발생하면, 그 상태가 확실해질 때까지 기다린 후 보상 정책에 따라 처리하여 결국에는 데이터 정합성을 맞춘다.

 

수천개의 API/Batch 서버를 하나의 설정 체계로 관리하기

문제점

수많은 API와 Batch 서버를 운영하면서 각 서버의 Java 버전, 힙 메모리(Xms, Xmx) 설정 등이 유사하지만 조금씩 달라 관리에 어려움이 있었다. 모든 서버에 공통 환경 변수를 적용하거나 특정 서버 그룹에만 설정을 다르게 적용하는 등의 작업이 실수하기 쉬웠고 시스템이 커질수록 점점 복잡해진다는 문제가 있었다.

 

아래 3가지 방법을 단계적으로 적용해서 문제를 개선했다.

해결1: 오버레이 아키텍처 및 템플릿 패턴

  • 설정을 여러 계층으로 나누어 겹겹이 쌓고, 가장 구체적인 설정을 채택하는 오버레이 아키텍처를 도입했다.
  • 이 과정에서 발생하는 설정 중복 문제는 템플릿 패턴을 활용하여 yml 설정 파일을 완성하는 방식으로 해결했다.
  • 클러스터 이름을 기준으로 조건부 설정을 적용하여 유연성을 확보했다.

해결2: 배치(Batch) 솔루션

  • Airflow, K8S CronJob, Jenkins 등 여러 도구를 검토했다.
  • 위험도가 높은 배치 작업일수록 단순한 솔루션이 최고라는 판단하에 Jenkins를 선택했다.
    • 단순 Jenkins 사용은 GitOps를 적용하기 어려운 점이 있었다.
    • 개발자가 설정만 선언하면 DevOps가 모든 배포 과정을 처리하는 구조를 목표로 했다.
    • 이를 위해 Jenkins의 기본 기능을 Groovy 스크립트로 처리할 수 있게 하는 Job DSL 플러그인 어댑터를 직접 구현했다.

해결3: Dynamic Provisioning

  • 문제: 노드 1개에 프로세스 1개를 할당하면 리소스가 낭비되고, 여러 개를 할당하면 메모리 부족(OOM) 위험이 있었다.
  • 해결: 배치 1개당 노드 1개를 동적으로 할당하는 Dynamic Provisioning 방식을 도입했다. EC2 인스턴스를 Serverless처럼 사용하고 작업이 끝나면 반납하여 리소스 효율성과 안정성을 모두 확보했다.

 

확장성과 회복탄력성을 갖춘 결제 시스템 만들기

기존 시스템의 문제

기존 결제 원장(Oracle DB)은 결제, 정산 등 여러 도메인이 하나의 공통 테이블을 사용해 결합도가 매우 높았다. 이로 인해 구조가 명확하지 않고 확장성에 한계가 있었다.

새로운 시스템 설계 및 마이그레이션

구조 개선

  • 카프카(Kafka) 메시지 기반으로 도메인을 분리하여 디커플링했다.
  • 확장성을 위해 결제 수단과 결제 승인 정보를 테이블로 분리했다.
  • 새로운 원장은 MySQL로 구성했다. 그 이유는,
    • Oracle과 분리해 독립적인 인프라 구성으로 안정성을 높일 수 있다.
    • 팀원 모두에게 익숙해 유지보수가 용이하다.
    • 오픈소스 생태계가 활성화되어 있다.

마이그레이션 전략

  • 기존 원장에 먼저 데이터를 저장하고, 비동기 스레드풀을 이용해 신규 원장에 이중으로 적재했다.
  • 복제 지연(Replication Lag)을 고려하여 배치 작업은 5분 지연시켜 실행했다.
  • 마이그레이션 서버를 별도로 구성하고, 동일 범위 조회는 로컬 캐시를 활용하여 네트워크 부하를 줄였다.

장애 발생 및 해결

성공적으로 마이그레이션 된 듯 했으나…

장애 원인

  • 특정 조회 쿼리에서 인덱스를 제대로 타지 못하고 풀스캔이 발생하여 DB에 높은 부하가 생겼다. 데이터가 쌓이면서 옵티마이저가 잘못된 실행 계획을 선택한 것이 원인이었다.

장애 해결

  • 긴급 조치로 쿼리 인덱스 힌트를 사용하여 옵티마이저가 올바른 인덱스를 사용하도록 강제했다.

후속 대응

  • DB 부하 등으로 발생할 수 있는 신규/구 원장 간 데이터 불일치 문제를 해결하기 위해 데이터를 보정하는 배치를 개발했다.
  • 결제 서버에서 이벤트 발행이 누락되는 경우를 막기 위해 아웃박스 패턴을 도입했다.
  • 로깅 시스템 자체의 장애에 대비하기 위해 Logback의 fallbackAppender를 적용했다.
  • 메시지를 안전하게 중복 처리할 수 있도록 메시지 헤더에 멱등성 키(Idempotency Key)를 추가했다.

 

성장하는 엔터프라이즈를 위한 인터널 서비스 제공 전략

문제1: 복잡한 프로세스와 소통 비용

  • 제품 기획부터 배포까지의 과정(기획 → 사전검토 → 개발 → 사후검토 → 배포)은 수많은 팀원 간의 소통이 필요했고, 이는 빠른 속도를 방해하는 요소였다.
  • 보안 검토, 권한 요청, 계약 관리 등 반복적인 업무를 처리하기 위한 소통 비용이 컸다.

해결1: 중앙화된 인터널 시스템

  • 이러한 소통 비용을 줄이고 프로세스를 단순화하기 위해, 중앙에서 요청을 받아 처리해 주는 오케스트레이터 모델의 인터널 시스템을 구축했다.
  • 마치 카프카로 디커플링하듯, 사용자가 요청만 보내면 인터널 시스템이 알아서 후속 작업을 처리하는 구조로 구성했다.

 

문제2: 계열사 확장과 인프라 복잡성

  • 토스뱅크, 토스증권 등 계열사가 늘어나면서 각각의 격리된 인터널 서비스가 필요해졌다.
  • 단순히 같은 시스템을 n번 배포하는 것이 아니라, DB, ELK, Kafka, CI/CD 등 모든 인프라를 계열사별로 독립적으로 구성해야 했기 때문에 복잡성이 매우 컸다.

해결2: IaC 활용

  • Terraform과 ArgoCD를 도입하여 인프라를 코드로 관리하고 선언적으로 찍어낼 수 있는 환경을 구축했다.
  • 공통 인프라 구성은 모두 동일한 파일을 사용하여 중복을 최소화했다. 계열사별로 달라져야 하는 설정(ex. DB 주소)은 Input Parameter받아서 설정했다.
  • ArgoCD를 통해 각 계열사의 리소스를 효율적으로 관리했다.

 

문제3: 파편화된 서비스 주소

  • 인터널 서비스가 많아지면서 사용자들이 기억하고 관리해야 할 주소(URL) 또한 너무 많아졌다.

해결3: 통합 API 게이트웨이

  • 사용자가 단 하나의 서비스에만 접근하면 되도록 구조를 변경했다.
  • 이 통합 서비스가 내부적으로 내부용(Internal) API와 외부용(Toss) API를 모두 호출하여 결과를 조합해 준다.
  • 이를 통해 각 API의 트래픽이 서로에게 영향을 주지 않으면서도, 사용자는 마치 하나의 서비스를 이용하는 것과 같은 편리함을 누리게 되었다.

 

레거시 정산 개편기: 신규 시스템 투입 여정부터 대규모 배치 운영 노하우까지

레거시 시스템의 한계와 해결책

문제1: 비즈니스 로직의 쿼리 종속성

  • 수수료 계산, 환전, 계약 조회 등 핵심 비즈니스 로직이 복잡한 한방 쿼리에 모두 녹아있어 수정 및 파악이 어려웠다.

해결1: 쿼리 분리와 로직의 코드화

  • JOIN, UNION ALL 등으로 얽혀있는 거대 쿼리를 최소 기능 단위로 계속 분할했다.
  • 분리된 쿼리는 데이터 조회 역할만 남기고, 비즈니스 로직은 애플리케이션 코드로 이전하여 명확성과 유지보수성을 확보했다.

문제2: 데이터 모델링의 한계

  • 초기부터 집계된 결과를 데이터베이스에 저장하는 방식으로 인해, 문제 발생 시 원인 추적이 거의 불가능했다.

해결2: 정규화, 파티셔닝, 그리고 CQRS

  • 테이블을 분리하여 정규화하고, 데이터가 어떤 계산 정책으로 만들어졌는지 계산 정책을 레코드에 함께 저장하여 추적하기 쉽게 했다.
  • 대용량 데이터는 거래 일시 기준으로 파티셔닝하여 성능을 개선했다.
  • 조회 전용 테이블을 별도로 두어 조회 성능을 최적화했다.

배치 시스템 성능 개선 노하우

문제: Spring Batch의 성능 한계

  • 대규모 거래 데이터를 처리하면서 정산 데이터 생성까지의 시간이 급격히 늘어났다.
  • Spring Batch의 과도한 I/O와 싱글 스레드 기반 동작이 처리량의 병목 지점이었다. (Multi-threaded step은 Thread-safe를 보장하기 어려움)

해결: I/O 최적화 및 병렬 처리

  • 배치 전처리 단계에서 필요한 데이터를 미리 캐시에 구축하여 I/O를 최소화했다.
  • ItemReaderItemProcessor로 데이터를 1건씩 넘기는 구조를 Items Wrapper로 개선하여 여러 건을 묶어 전달함으로써 오버헤드를 줄였다.
  • 정산 결과를 저장할 때 Bulk Insert를 적용하여 DB I/O를 획기적으로 줄였다.
  • 외부 API 호출이 필요한 경우, 병렬로 호출하여 전체 대기 시간을 단축했다.

안정적인 운영을 위한 배포 전략과 스케줄링

신뢰성 확보 전략

  • 운영 환경에 투입하기 전, 레거시 시스템과 신규 시스템에서 동시에 정산 데이터를 생성했다.
  • 두 시스템의 결과가 동일하면 신규 시스템의 결과를 사용하고, 다르면 레거시 결과를 사용하며 실패 케이스를 로깅했다.
  • 이 방식으로 카나리 배포했고, 특정 가맹점 그룹의 오류율이 안정적인 것을 확인하며 점진적으로 가맹점 단위로 100% 신규 시스템으로 전환했다.

Job 스케줄러 선정 및 고도화

  • 초기엔 AWS K8S CronJob을 고려했으나, 데이터 센터와 AWS 리젼 간의 RTT로 인한 성능 저하, '한 번만 실행'을 보장하기 어려운 점 때문에 Jenkins를 최종 선택했다.
  • Jenkins 선택 이유
    • 정산 데이터는 상태 유지가 중요하며(Stateful), 파이프라인 선언을 통한 정교한 워크플로우 정의와 다양한 플러그인이 장점이었다.
  • Jenkins 단점 극복
    • Dynamic Provisioning → 잡 1개당 노드 1개를 할당하는 방식으로 서버 자원을 스케일 아웃하여 고가용성과 효율성 문제를 해결했다.
    • Job DSL로 모든 설정을 코드로 관리하여 휴먼 에러를 방지하고 일관성을 유지했다.
    • OOM, 멀티스레딩 환경에서의 스레드 경합 등을 탐지할 수 있도록 배치 모니터링 도구를 강화했다.

 

‘주식 모으기’ 서비스로 살펴보는 대용량 트래픽 처리 노하우

배경 상황

토스증권의 '주식모으기'는 사용자가 소수점 단위로 원하는 주식을 정기적으로 매수할 수 있도록 지원하는 서비스다. 특히 해외주식 매매 트래픽은 미국 증시 개장과 같은 특정 시간에 주문이 집중되는 뚜렷한 특성을 보여, 시스템의 병목 현상을 가중시키는 요인이다. 물리적으로 거리가 먼 해외(ex. 미국) 증권 서버와 통신은 시간이 오래 걸린다. 만약 주문 1건을 처리하는 데 300ms가 소요된다고 가정하면, 100만 건의 주문을 모두 처리하기 위해서는 약 83시간이라는 비현실적인 시간이 필요하다.

서비스가 성장함에 따라 일일 주문량이 급격히 늘어났는데, 다음과 같은 방법들로 개선했다.

1. 병렬 처리와 트래픽 제어를 통한 초기 대응

문제

  • 서비스가 성장하며 일일 주문 수가 2만 건에서 50만 건으로 급증했다.
  • 미국 증시 개장 직후 짧은 시간에 주문이 몰리면서 외부 브로커 시스템에 과부하가 걸렸고, 이로 인해 전체 주문 처리가 연쇄적으로 지연되는 문제가 발생했다.

코루틴(Coroutine)을 활용한 DB 작업 병렬화

  • 순차적으로 처리되던 DB 조회 및 주문 생성 작업을 병렬로 처리해 성능을 개선하기 위해 코틀린 코루틴을 도입했다.
  • DB 작업은 스레드를 점유하는 블로킹 작업이므로, 리소스 고갈로 인한 타임아웃을 방지하고자 코루틴 스레드 풀의 크기(30)와 DB 커넥션 풀의 크기(30)를 동일하게 설정하여 안정성을 확보했다.

> 왜? 이유는?

  • 코루틴은 경량 스레드로 알려져 있지만, JDBC API와 같이 호출 스레드를 블록시키는 라이브러리를 사용할 경우, 해당 코루틴이 실행되는 실제 플랫폼 스레드는 작업이 완료될 때까지 점유되고 대기 상태에 빠진다. DB 작업은 HikariCP로부터 DB 커넥션을 할당받아야만 수행할 수 있으므로, 각각의 병렬 DB 작업(코루틴)은 스레드 1개와 DB 커넥션 1개를 동시에 필요로 한다.
  • 만약 스레드 풀의 크기가 커넥션 풀의 크기보다 크다면(ex. 스레드 40개, 커넥션 30개), 최대 40개의 스레드가 동시에 DB 작업을 요청할 수 있다. 이때 먼저 실행된 30개의 스레드가 커넥션을 모두 점유하고 나면, 나머지 10개의 스레드는 가용한 커넥션이 없어 커넥션 풀의 타임아웃이 발생할 때까지 무작정 대기하게 된다. 만약 앞선 작업들이 60초(가정) 내에 커넥션을 반납하지 않으면, 대기하던 스레드들은 결국 ConnectionTimeoutException을 발생시키며 실패한다. 이는 시스템 리소스의 비효율적인 사용과 예측 불가능한 오류를 초래한다. 따라서 블로킹 리소스(DB 커넥션)를 사용하는 작업의 동시 실행 스레드 수를 해당 리소스의 가용량(커넥션 풀 사이즈)과 일치시키는 것은, 리소스 경쟁으로 인한 타임아웃을 원천적으로 방지하고 시스템을 안정적으로 운영하기 위한 매우 합리적이고 보수적인 설계 원칙이다.

@Async를 통한 외부 API 호출 비동기화

  • 외부 브로커 API 호출처럼 응답 대기 시간이 긴 I/O-Bound 작업의 효율을 높이기 위해 @Async 어노테이션을 사용해 비동기 방식으로 전환했다.
  • I/O 대기 시간 동안 CPU를 최대한 활용해 전체 처리량을 높이기 위해, API 호출 전용 스레드 풀의 크기는 300개로 공격적으로 크게 설정했다.
    • 다른 태스크에 영향을 미치지 않게 하기 위해 스레드 풀을 분리했다. (bulk head)
  • 이 구조에서 코루틴(스레드 30개)은 DB에서 읽어온 주문 처리 '작업'을 생성하여 비동기 스레드 풀(스레드 300개)에 던져주는 역할을 한다. 이를 통해 최대 300개의 API 호출을 동시에 '진행 중'인 상태로 만들 수 있으며, 이론적으로 TPS는 최대 600까지(300/0.5s) 상승할 수 있는 잠재력을 갖게 되었다.

트래픽 분산 및 제어

  • 전체 주문의 약 3분의 1을 차지하는 '주식모으기' 주문을 정규장 시작 15분 뒤에 실행하도록 조정하여, 피크 시간대의 부하를 완화했다. 가장 간단하면서도 확실한 방법!
  • 외부 브로커 시스템을 보호하기 위해 Resilience4J의 RateLimiter를 도입, API 호출 속도를 안정적으로 제어했다. 배치 잡 실행 시 파라미터로 원하는 TPS 값을 받아 RateLimiter를 동적으로 생성함으로써, 외부 시스템의 상태에 따라 유연하게 요청량을 조절할 수 있는 기반을 마련했다. 하지만 결국 미국 브로커의 처리량 한계로 인해, 시스템의 잠재적 성능과 무관하게 최대 200 TPS로 호출량을 제한해야만 했다.

2. '풀링(pooling) 주문' 아키텍처로의 패러다임 전환

문제

  • 서비스가 다시 3배 성장해 일일 주문량이 150만 건에 달했고, 외부 브로커의 사정으로 허용 TPS가 오히려 150으로 감소하면서 처리 시간이 2시간 40분까지 늘어났다.
  • 외부 시스템의 성능에 시스템 전체가 종속되는 근본적인 한계를 극복할 필요가 있었다.

풀링 주문(Pooling Order)

  • 수많은 소수점 주문을 종목별로 합산하여 하나의 큰 주문으로 '풀링'해 브로커에 전달하는 아이디어를 도입했다.
  • 이 전략으로 외부로 보내는 주문 수가 200만 건에서 약 3,000건으로 획기적으로 감소했으며, 부하가 큰 체결 분배 작업은 통제 가능한 내부 시스템에서 처리하게 되어 시스템 확장성에 대한 제어권을 되찾아왔다.

Kafka 기반 체결 분배 시스템

  • 풀링 주문이 체결되면 Kafka로 이벤트를 발행하고, 다수의 컨슈머가 이를 받아 병렬로 분배하는 비동기 아키텍처를 구축했다.
  • 메시지 중복 처리를 막기 위한 멱등성과 데이터 정합성을 지키기 위한 동시성 제어(분산락)를 신중하게 고려했다.
    • 체결 분배 시, 매번 ‘미체결 소수점 주문 조회’만 해서 이미 처리 된 주문은 다음 조회 시 나오지 않게 했다. 이렇게 멱등성을 구현해서 중복 체결 문제를 예방했다.
    • 동일한 소수점 주문 목록에 접근해 분배를 처리할 수 있기 때문에 데이터 정합성을 위해 분산락을 사용했다.

성능 병목 발견과 트랜잭션 분리

  • 초기 풀링 아키텍처는 여러 주문의 상태를 한 번에 업데이트하는 DB 트랜잭션이 병목 지점이었다 (TPS 400)
  • 이 문제를 해결하기 위해, 무거운 트랜잭션을 '분배'와 '체결'이라는 두 개의 작고 빠른 단계로 분리했다. '분배' 컨슈머는 계산 후 개별 체결 이벤트를 Kafka로 발행하고, 다수의 '체결' 컨슈머가 이 이벤트를 병렬로 처리해 DB를 업데이트하는 구조다.
  • 이 트랜잭션 분리 기법을 통해 TPS를 3000까지 극적으로 끌어올렸다.

3. 고성능 시스템의 부작용과 동적 제어

문제

  • 그러나.. 3000 TPS라는 압도적인 성능은 후행 시스템(마진 계산, 알림 등)의 처리 속도를 초과하여 Kafka 토픽에 메시지가 쌓이는 LAG 현상을 유발했다.
  • 대량의 DB 업데이트로 인해 CDC(데이터 복제 파이프라인)에 지연이 발생하는 등 시스템 전체에 부담을 주었다.

동적 TPS 제어 시스템 구축

  • 시스템 전체의 안정성을 위해 이벤트 발행 속도를 실시간으로 조절할 수 있는 동적 제어 시스템을 구축했다.
  • 모든 서버 인스턴스가 동일한 TPS 설정을 공유하도록 Redis를 중앙 저장소로 사용했다.
  • 매번 Redis를 조회할 때 발생하는 부하(Thundering Herd 문제)를 막기 위해, 각 인스턴스에 짧은 TTL(10초)을 가진 로컬 캐시를 두어 효율과 실시간성을 모두 잡았다.
  • TPS 제어로 인해 Kafka 컨슈머의 작업이 길어져 비정상으로 오인되는 것을 막기 위해, max.poll.interval.ms 설정을 충분히 길게 조정하여 무한 리밸런싱에 빠지는 것을 방지했다.

 

스프링 서버 애플리케이션 구동 시간 줄이기

문제

Active-Standby로 이중화된 DC 환경에서 서비스 전환 시, 분 단위의 서버 구동 시간은 전체 장애 시간에 큰 영향을 준다. 특히 24시간 중 23시간 이상 실행해야 배치 리소스를 효율적으로 쓰는 것인데, 1분씩만 걸린다 하더라도 하루에 수백번 실행되는 배치 인스턴스에게는 치명적이다. Spring Boot 3.2.0 버전부터 두드러진 이 문제를 해결하기 위해 진행했던 최적화 과정을 공유한다.

원인 분석: 프로파일링으로 병목 지점 찾기

정확한 원인 분석을 위해 async-profiler를 사용하여 애플리케이션 구동 과정을 분석했다.

  • 코드 수정이 필요 없는 Agent 방식을 채택하고, 시작 시간 측정에 적합한 wall-clock profiling 모드를 사용했다.
  • Flame Graph와 Spring Boot Actuator의 startup 엔드포인트가 생성하는 JFR 파일을 통해 분석한 결과, 아래 두 가지가 주요 병목 지점임을 확인했다.
    • Hibernate Entity Scanning: 클래스패스 전체를 스캔하여 @Entity 어노테이션을 찾는 과정에서 많은 시간이 소요된다.
    • /static 정적 리소스 로딩: 정적 리소스를 로딩하는 과정에서도 예상보다 긴 시간이 걸렸다.

단계별 최적화 과정

1단계: Executable JAR를 사용하지 않고 Hibernate Entity Scanning 비활성화

가장 큰 병목점인 두 문제를 해결하기 위해 실행 방식을 변경했다.

  • 기존의 단일 파일 배포 방식(Executable JAR) 대신, JAR 파일의 압축을 풀어 배포하고 Hibernate Entity Scanning 기능을 비활성화했다.
  • 이 조치만으로 구동 시간이 약 65초에서 50초로 15초가량 단축되는 극적인 효과를 보았다. 압축을 푼 배포로 인한 Docker 이미지 크기 증가는 미미했다.

2단계: Docker Layered JAR를 통한 빌드/배포 개선

JAR 압축을 푼 김에 Docker 이미지 빌드 효율화를 추가로 진행했다.

  • Spring Boot가 제공하는 Layered JAR 기능을 활용하여 Docker 이미지를 빌드했다. 이는 의존성(dependencies), 리소스(resources), 애플리케이션 클래스를 각각 다른 레이어에 분리하여 저장하는 방식이다.
  • 코드 변경이 없을 때, 변하지 않는 의존성 레이어는 캐시를 재사용하게 된다. 이로 인해 Docker 이미지 빌드 속도가 개선되고, 이미지 저장소에서 이미지를 받아올 때 네트워크 대역폭 사용량도 크게 줄일 수 있었다.

3단계: CDS(Class Data Sharing) 적용으로 최종 최적화

마지막으로 JVM 레벨의 최적화 기법인 CDS를 적용했다.

  • CDS(Class Data Sharing)는 JVM이 시작될 때마다 반복적으로 수행하는 클래스 로딩, 파싱, 검증 작업을 미리 처리하여 공유 아카이브(.jsa 파일)로 만들어두는 기술이다. 애플리케이션 실행 시 이 아카이브를 메모리에 매핑하여 구동 속도를 높인다.
  • 구동 시간이 50초에서 48초로 추가 단축되었고, 여러 JVM이 클래스 메타데이터를 공유하게 되면서 Metaspace 메모리 사용량 또한 크게 감소하는 부가적인 효과를 얻었다.