최근 이벤트 기반 아키텍처(EDA)와 Kafka를 공부하면서 가장 자주 등장하는 패턴 중 하나가 Transactional Outbox 패턴이었다.

특히 핀테크 도메인에서는 결제, 정산, 송금 같은 데이터가 이벤트로 확산될 때 정합성이 굉장히 중요하기 때문에 이 패턴을 이해해둘 필요가 있다고 느꼈다.

DB 저장과 Kafka 발행은 왜 문제가 될까?

보통 주문 생성이나 결제 승인 같은 상황에서

  • DB에 데이터를 저장하고
  • Kafka로 이벤트를 발행해서
  • 다른 서비스들이 해당 이벤트를 처리하는 구조를 많이 사용한다.

간단히 보면 이런 코드가 될 수 있다.

@Transactional public void pay() {
  paymentRepository.save(payment);
  kafkaProducer.send("payment-approved", event); 
}

하지만 여기서 문제가 발생할 수 있다.

케이스 1. DB는 저장됐는데 Kafka 발행이 실패하면?

  • 결제 정보는 DB에 저장되었지만
  • 이벤트는 Kafka로 전달되지 못한다

결과적으로 다른 서비스(정산/알림)가 결제 완료를 인지하지 못한다.

→ 이벤트 유실(Event Loss)

케이스 2. Kafka는 발행됐는데 DB 트랜잭션이 롤백되면?

  • 이벤트는 전파됐지만
  • 실제 결제 데이터는 DB에 존재하지 않는다

→ 잘못된 이벤트 발행(Phantom Event)

 

즉, 핵심 문제는 DB 트랜잭션과 Kafka 발행을 하나의 원자적 작업으로 묶기 어렵다 는 점이었다.

Transactional Outbox 패턴의 핵심 아이디어

Transactional Outbox 패턴은 Kafka로 바로 이벤트를 보내지 않고 “보낼 이벤트”를 DB에 먼저 기록해두는 방식이다.

 

즉 서비스 로직에서는

  • 비즈니스 데이터 저장
  • Outbox 테이블에 이벤트 저장

까지만 수행한다.

@Transactional public void pay() { 
    paymentRepository.save(payment);
    outboxRepository.save( new OutboxEvent("PaymentApproved", payload)); 
}
 
이렇게 하면 DB 커밋이 성공했다면

Outbox에도 반드시 이벤트가 남게 된다.

Kafka 발행은 누가 담당할까?

이후 Kafka 발행은 서비스 로직이 아니라 별도의 Outbox Publisher가 담당한다.

Publisher는 주기적으로 Outbox 테이블을 읽고

  • status = PENDING 인 이벤트를 조회
  • Kafka로 발행
  • 성공 시 status = SENT로 변경
public void publish() { 
    List<Event> events = outboxRepository.findPending(); 
    
    for (Event e : events) { 
          kafkaProducer.send(e.payload()); e.markSent(); 
    } 
}
 
Kafka 장애가 발생하더라도
  • 이벤트는 Outbox에 남아있고
  • Kafka가 복구되면 다시 발행할 수 있다.

따라서 “유실”이 아니라 “지연”이 된다.

Consumer는 그대로인데, 멱등성이 중요해진다

Outbox 패턴에서 Publisher는 재시도를 수행할 수 있기 때문에 Kafka 메시지는 중복 발행될 가능성이 있다.

그래서 Consumer에서는 Inbox 테이블을 두고 이미 처리한 이벤트인지 확인하는 방식이 흔하다.

if (inboxRepository.exists(eventId)) { return; // 중복 이벤트 skip } 
processBusinessLogic(); inboxRepository.save(eventId);

정리하면

  • Outbox는 Producer의 이벤트 유실 방지
  • Inbox는 Consumer의 중복 처리 방지

역할을 가진다.

핀테크 개발자로 성장하기 위해
결제 · PG · 정산 도메인 이해를 목적으로 정리한 아티클 목록입니다.

PG · 전자결제 기본 개념

카드 결제

정산 · 취소 · 환불 구조

이커머스 · 서비스 기획 관점의 결제

개발자 관점 결제 시스템 이해

PG · 결제 시장 동향 / 참고 자료

정리 기준

  • 핀테크 개발자로서 결제·정산 도메인 이해를 목표로 큐레이션
  • 실제 실무 경험이 없더라도 구조와 개념을 설명할 수 있도록 학습 목적

Node.js, PHP, Java로 분산된 서버를 Spring Boot 기반으로 통합한 실전 사례. 올라핀테크 백엔드팀의 서버 통합과 이벤트 기반 아키텍처 전환 기록.

빠르게 성장하는 스타트업, 그리고 함께 커진 과제

올라는 이커머스 셀러를 위한 선정산 서비스로 빠르게 성장해왔어요. 빠른 성장은 좋은 신호지만, 기술팀에게는 새로운 과제를 안겨주기도 했죠. 초기에는 빠른 개발과 검증을 위해 상황에 맞는 다양한 기술을 선택했어요. 어떤 서비스는 Node.js로, 어떤 서비스는 PHP로, 또 다른 서비스는 Java로 개발되었고, 각 기술은 당시의 요구사항에 맞는 최선의 선택이었습니다.

분산된_서버_구조와_운영_복잡성_올라핀테크

당시 서버 구성

  • Node.js 서버: 셀러가 직접 사용하는 핵심 API 서버
  • Node.js 배치 서버: 선정산 금액 회수, 실시간(분단위) 대사 등 금액 정합성 검증을 위한 백그라운드 처리
  • PHP 서버: 서버 사이드 렌더링 방식의 어드민 서버 API와 일부 셀러 API 공존
  • Java 서버들: 여러 버전의 JDK 로 분산된 서비스들

각 서버는 독립적으로 동작했고, 필요에 따라 서버 간 통신으로 데이터를 주고받는 구조였어요.

점점 복잡해지는 환경

하지만 서비스가 성장하면서 몇 가지 어려움이 생기기 시작했어요. 여러 언어와 프레임워크가 섞여 있다 보니, 하나의 기능을 개선하려 해도 여러 프로젝트를 오가며 확인해야 했죠.
특정 기능이 어느 서버에 있는지 찾는 일이 잦아졌고, 신규 팀원이 합류했을 때 온보딩에도 시간이 오래 걸렸어요.

더 큰 문제는 팀 구성의 변화였어요. Node.js와 PHP에 익숙한 개발자는 팀을 떠났고, Java 개발자들로 팀이 재편성되었죠. 하지만 서버는 여전히 여러 언어로 나뉘어 있었습니다.
여러 서버가 독립적으로 동작하다 보니 서비스 전체를 관통하는 일관된 관리가 어려웠고, 셀러분들이 사용하는 기능의 응답 속도나 안정성을 개선하고 싶어도 여러 시스템을 동시에 손봐야 하는 구조였어요.

MSA와_모놀리식_아키텍처_선택의_기로_올라핀테크

MSA? 모놀리식? 우리의 선택

이런 상황에서 우리는 고민했어요. “이대로는 안 되겠다. 시스템을 재정비할 때가 왔다.” 가장 먼저 마주한 질문은 이거였어요.
“MSA로 갈 것인가, 모놀리식으로 통합할 것인가?”

 

당시 많은 회사들이 MSA(마이크로서비스 아키텍처)로 전환하고 있었고, 우리도 그 방향을 검토했어요. 각 도메인을 독립적인 서비스로 분리하면 확장성도 좋고, 기술 스택도 자유롭게 선택할 수 있으니까요. 하지만 우리 상황에서는 오히려 역효과일 수 있었습니다. 이미 여러 서버로 분산되어 관리 복잡도가 높았는데, MSA로 가면 그 복잡도가 더 올라갈 수 있었죠. 서비스 간 통신, 분산 트랜잭션, 모니터링 등 고려해야 할 것들이 너무 많았어요.
그래서 우리는 모놀리식 통합을 선택했습니다. 셀러가 사용하는 클라이언트 서버를 하나의 견고한 모놀리식 애플리케이션으로 만들자.
일단 복잡도를 낮추고, 안정성을 확보한 뒤에 필요하면 그때 분리하자. 이게 우리의 결론이었어요.

왜 Java였을까

팀원 전원이 Java 개발자였고, Spring Boot 생태계에 익숙했어요. 새로운 기술을 배우는 것보다 우리가 잘 다룰 수 있는 도구로 빠르게 개선하는 게 현실적이었죠. 핀테크 서비스는 안정성이 생명이에요. Spring의 성숙한 생태계와 검증된 보안 기능은 우리에게 큰 장점이었습니다. 기존 Java 서버들도 여러 JDK 버전에 걸쳐 있었는데, 이번 기회에 JDK 21 Spring Boot 최신 버전으로 기술 스택을 현대화하기로 했어요.

단계적으로 진행하자

단번에 모든 걸 바꿀 수는 없었어요. 서비스는 계속 운영되어야 했습니다. 셀러분들이 불편을 느껴서는 안 되었으니까요.

통합 계획

  1. Node.js 모놀리식 + 구형 Java + PHP 일부 API → Spring 통합
  2. Node.js 배치 서버 → 이벤트 기반 분리
    (PHP 어드민 서버는 현재도 운영 중)

핵심 서비스부터 차근차근 옮기되, 각 단계마다 충분한 검증을 거치기로 했어요.

5개월의 여정

Phase 1: Node.js 모놀리식 + 구형 Java + PHP 일부 API → Spring 통합

서버_통합을_위한_아키텍처_재설계_전략_올라핀테크

첫 번째 도전은 Node.js, 구형 Java 그리고 PHP 서버의 일부 셀러 API를 하나의 Spring 애플리케이션으로 통합하는 작업이었어요.

최종 아키텍처

  • Spring 모놀리식 서버: Node.js, 구형 Java, PHP 일부 API 통합
  • JDK 21 + Spring Boot 최신 버전
  • Gradle 빌드 도구

동시에 온프레미스에서 AWS 클라우드로의 이전도 함께 진행했어요. 서버 통합과 인프라 전환을 동시에 하는 건 쉽지 않은 결정이었지만, 한 번에 끝내자는 마음으로 시작했죠.

가장 어려웠던 것: 로직 분석

기존 다양한 언어로 구성된 비즈니스 로직을 Java로 옮기는 작업, 생각보다 훨씬 어려웠어요. 코드만 봐서는 “왜 이렇게 작성했을까?” 싶은 부분들이 많았고, 문서나 히스토리가 남아있지 않아 의도를 파악하기 어려웠죠. 기존 개발자분들도 남아 있지 않은 상태였고요.

결국 이 작업은 코드를 옮기는 일이 아니라, 이미 운영 중인 서비스를 다시 이해하고 재정의하는 과정에 가까웠어요. 그래서 저희는 서둘러 마이그레이션하기보다, 현재 서비스가 실제로 어떻게 동작하고 있는지를 하나씩 확인하는 방식으로 접근했습니다.

그래서 우리가 한 건:

  • 1단계: 소스 기반 현재 로직 분석
    - 
    실제 API 호출 로그를 분석하며 동작 패턴 파악
    - 코드 흐름을 따라가며 핵심 비즈니스 로직 추출
    - “이 코드가 실제로 하는 일”을 먼저 이해
  • 2단계: CX팀, 기획팀과 함께 재정의
    - 주기적인 미팅을 통해 “원래 의도”와 “현재 동작”을 대조
    - 
    CX팀: “실제 업무에서는 이렇게 처리해요”
    - 기획팀: “원래 기획 의도는 이거였어요”
    - 개발팀: “그럼 코드는 이렇게 개선할 수 있어요”
  • 3단계: Testcontainers로 안전하게 검증
    - 
    Docker Testcontainers를 활용한 독립적인 테스트 환경 구축
    - 외부 금융 API는 Mock으로, DB 저장/조회는 실제 컨테이너로 검증

이 과정은 단일 API 하나를 옮기는 데에도 많은 확인과 합의가 필요했어요. 로그를 통해 동작을 확인하고, 유관 부서와 의도를 맞추고, 테스트로 검증하는 작업을 모든 핵심 도메인에 반복해야 했죠.

특히 금액과 직결된 핀테크 서비스 특성상, 작은 차이도 오류로 이어질 수 있었기 때문에 “빠르게 옮기자”보다 “절대 틀리지 않게 이해하자”를 우선순위로 두었습니다.

@Test
void 출금_요청시_외부API는_Mock_DB저장은_실제_검증() {
    // Given: 외부 금융 API Mock 설정
    when(bankApi.requestTransfer(any()))
        .thenReturn(new TransferResponse("SUCCESS", "TXN123"));
    
    // Given: 출금 가능한 정산금이 이미 존재 (테스트 데이터 준비)
    Settlement settlement = settlementRepository.save(
        new Settlement(123L, 1_000_000L)
    );
    
    // When: 출금 요청 (실제 검증 대상)
    withdrawalService.request(settlement.getId(), 500_000L);
    
    // Then: 실제 DB에 Withdrawal이 제대로 저장되었는지 검증
    Withdrawal saved = withdrawalRepository
        .findBySettlementId(settlement.getId())
        .orElseThrow();
    
    assertThat(saved.getRequestAmount()).isEqualTo(500_000L);
    assertThat(saved.getStatus()).isEqualTo(WithdrawalStatus.COMPLETED);
}

시간은 오래 걸렸지만, 확실히 검증하고 넘어가는 게 중요했어요.

Phase 2: 이벤트 기반 아키텍처로 의존성 분리

모놀리식 통합도 중요했지만, Node.js 배치 서버는 별도 과제였어요.
하나의 배치 서버에 정산금 회수, 실시간(분단위) 대사 등 수십 개의 배치 작업이 모여 있었어요. 긴급하게 특정 기능을 수정해야 할 때가 가장 난감했죠. 배치 작업이 진행 중일 때는 서버를 재시작할 수 없었으니까요. 자칫하면 정산 금액이 잘못 계산되거나, 회수 데이터 처리가 누락될 수 있었어요. 그래서 배포할 때마다 이런 상황이 반복되었습니다.

  • “정산금 회수 배치 돌아가는 중… 30분 후에 배포”
  • “아, 대사 배치도 곧 시작하네… 1시간은 더 기다려야 할 듯”

긴급 이슈가 터져도 배치 로그를 보며 타이밍을 맞춰 배포해야 하는 상황. 이건 분명 개선이 필요했어요.

이벤트_기반_아키텍처와_비동기_처리_구조_올라핀테크

이벤트 기반 아키텍처로 전환

[이전 구조]
배치 서버 (모든 배치가 한 곳에 존재하여 배포 시 타이밍 잡아야 함)

[변경 후 구조]
이벤트 발행용 서버 → 이벤트 발행
                    ↓
                 이벤트 큐
                    ↓
           이벤트 구독용 서버 (배치 처리)

이제 배포가 자유로워졌어요

결과적으로

  • 긴급 배포가 필요해도 배치 타이밍을 기다릴 필요 없음
  • 정산 처리 중에도 발행 서버 배포 가능
  • 이벤트 큐가 버퍼 역할을 해서 트래픽 급증에도 안정적
  • 특정 배치만 수정하려면 구독 서버만 배포하면 됨

달라진 것들

서버_통합_이후_안정성과_확장성_성과_올라핀테크

#1 서비스 확장성과 안정성

이벤트 기반 아키텍처 덕분에 모놀리식 서버와 배치 서버가 독립적으로 동작하게 되었어요. 한쪽에 문제가 생겨도 다른 쪽은 정상 동작하는 구조가 되었죠.
여러 서버에 흩어져 있던 로그와 메트릭을 하나로 모니터링할 수 있게 되면서, 문제가 생겼을 때 원인을 찾는 시간이 크게 줄어들었습니다.

#2 셀러 경험의 개선

기술적 개선은 결국 사용자 경험으로 이어졌어요.
여러 서버를 거치던 API 호출이 하나의 시스템 안에서 처리되면서 응답 시간이 개선되었어요. 셀러분들이 정산금을 조회하거나 출금을 요청할 때 더 빠른 경험을 제공할 수 있게 되었죠.

#3 개발 생산성 향상

신규 팀원이 합류했을 때, 이제는 하나의 기술 스택만 익히면 됩니다. 여러 프로젝트를 오가며 혼란스러워하던 모습은 이제 찾아볼 수 없죠.
새로운 기능을 개발할 때도 하나의 프로젝트 안에서 작업이 완료되어, 여러 서버에 코드를 나눠 작성하고 배포하던 번거로움이 사라졌습니다.

전환 과정에서 함께 개선한 것들

Node.js와 PHP 코드를 Java로 옮기면서 단순 변환을 넘어 여러 부분을 개선했어요.

쿼리 튜닝
기존 코드를 분석하다 보니 성능 지연을 일으키는 쿼리들이 보였어요. WHERE 조건에 사용되는 컬럼에 인덱스를 추가하고, 불필요한 JOIN을 제거 및 애플리케이션에서 처리 등으로 개선하였어요.

테스트 환경 구축
Docker Testcontainers를 활용해 독립적인 테스트 환경을 만들었어요. 특히 출금 기능처럼 외부 금융 API 연동이 필요한 경우, Mock 테스트만으로는 안정성을 보장하기 어려웠어요. 실제 DB에 저장하고 조회하는 통합 테스트를 통해 데이터 정합성까지 함께 검증했죠.
이미 다른 방식으로 대체되었는데 코드만 남아 있던 기능들도 많았어요. 과감히 제거하면서 코드베이스를 정리할 수 있었죠. 단순히 언어만 바꾼 게 아니라, 누적된 기술 부채를 정리하는 기회가 되었습니다.

AWS와 동시 작업
서버 통합과 AWS 이전을 동시에 진행하다 보니, 어느 쪽에서 문제가 생긴 건지 파악하기 어려울 때가 있었어요.
각 단계를 명확히 나눴어요. 먼저 로컬과 개발 환경에서 Java 전환을 완전히 검증한 뒤, AWS로 배포하는 순서로 진행했죠. 한 번에 너무 많은 변수를 두지 않으려 노력했습니다.

돌아보며

5개월의 여정을 돌아보면, 쉽지 않은 선택이었지만 확실히 나아졌어요.
하나씩 검증하며 단계적으로 진행한 것이 성공의 열쇠였습니다. 한 번에 모든 걸 바꾸려 했다면 실패했을 거예요.
히스토리 없는 코드를 분석하며 문서화의 중요성을 뼈저리게 느꼈어요. 이제 우리는 모든 주요 의사결정과 비즈니스 로직을 문서로 남기고 있습니다.
그리고 무엇보다, 이벤트 기반 아키텍처로 전환하면서 서버 간 의존성을 낮춘 것이 가장 큰 수확이었어요. 향후 서비스가 더 성장하더라도, 필요한 부분만 분리해서 확장할 수 있는 유연한 구조를 만들었죠.

아직 진행 중인 이야기

현재 Node.js 모놀리식 서버, 구형 Java 서버, 그리고 PHP 일부 API의 Spring 통합과 Node.js 배치 서버의 이벤트 기반 전환이 완료되어, AWS 환경에서 안정적으로 운영 중이에요.
PHP 어드민 서버는 현재도 운영 중이며, 향후 서비스 요구사항에 따라 추가 통합 여부를 검토할 예정입니다.

완벽한 시스템은 없습니다. 하지만 우리는 계속 나아갈 수 있어요.
더 나은 서비스를 위한 기술팀의 끊임없는 도전, 그리고 아직 진행형이지만 확실한 변화를 만들고 있습니다.

 

회사에서 JDK 17 버전에서 21로 변경하며 Virtual Thread를 도입하기 위해 학습한 내용을 기록하였습니다.

Virtual Thread 개념 정리

Java 19에서 프리뷰로 도입되고 Java 21에서 정식 출시된 Virtual Thread(가상 스레드)에 대해 학습하였습니다. 기존 Platform Thread와 비교하면 상당한 차이가 있었습니다.

 

기존 Platform Thread의 한계점

  • 스레드당 약 2MB의 메모리 사용
  • 생성 및 삭제 비용이 높음
  • 일반적으로 수십~수백 개 정도만 생성 가능

Virtual Thread의 개선점

  • 스레드당 약 10KB로 매우 가벼움
  • 수십만~수백만 개까지 생성 가능
  • I/O 대기 시간을 효율적으로 활용

동작 원리 학습

Virtual Thread는 Platform Thread 위에서 동작하며, 핵심은 Mount/Unmount 메커니즘입니다.

동작 흐름:
1. Virtual Thread A가 Platform Thread 1에서 실행 (Mount 상태)
2. DB 조회 등 I/O 작업 시작
3. Virtual Thread A가 Platform Thread에서 분리 (Unmount)
4. Platform Thread 1이 다른 Virtual Thread B를 실행
5. I/O 완료 시 Virtual Thread A가 다시 Platform Thread에 연결되어 실행 재개

핵심 용어

  • 마운트(Mount): Virtual Thread가 Platform Thread에서 실행되는 상태
  • 언마운트(Unmount): I/O 블로킹 시 Platform Thread에서 분리되어 대기하는 상태
  • Carrier Thread: Virtual Thread를 실제로 실행하는 Platform Thread

I/O 바운드 작업에서의 효과

Virtual Thread는 I/O 집약적인 작업에서 뛰어난 성능을 보입니다.

public void processUserData() {
    // 1. DB 사용자 조회 - I/O 대기 시 언마운트 발생
    User user = userRepository.findById(userId);
    
    // 2. 외부 API 호출 - I/O 대기 시 언마운트 발생
    ApiResponse response = externalApiClient.call(user.getId());
    
    // 3. 파일 저장 - I/O 대기 시 언마운트 발생
    fileService.saveUserData(user, response);
}

위와 같은 코드에서는 각 I/O 작업마다 Virtual Thread가 언마운트되어 Platform Thread가 다른 Virtual Thread를 실행할 수 있게 됩니다.

참고로 Thread.sleep()도 Virtual Thread에서는 언마운트됩니다.

주의해야 할 제약사항

synchronized 블록 사용 시 문제점

학습 중 가장 중요하다고 판단한 부분입니다.

// Virtual Thread 효과가 제한되는 경우
public void inefficientMethod() {
    synchronized (lock) {
        // Virtual Thread가 Platform Thread를 계속 점유
        // 언마운트되지 않아 비효율적
        expensiveOperation();
    }
}

// Virtual Thread에 적합한 방식
private final ReentrantLock lock = new ReentrantLock();

public void efficientMethod() {
    lock.lock();
    try {
        // Virtual Thread 언마운트 가능
        expensiveOperation();
    } finally {
        lock.unlock();
    }
}

synchronized는 JVM 모니터 락으로 OS 레벨에서 스레드를 블로킹하기 때문에 언마운트가 불가능합니다.

CPU 바운드 작업의 한계

public void cpuIntensiveTask() {
    for (int i = 0; i < 1000000; i++) {
        // CPU 연산 중심 작업에서는 언마운트 포인트가 없음
        Math.sin(i) * Math.cos(i);
    }
}

CPU 집약적인 작업은 Virtual Thread를 많이 생성해도 CPU 코어 수의 제약을 받습니다.

Spring Boot 환경에서의 적용

설정은 매우 간단합니다.

# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 설정만으로 Spring의 요청 처리 스레드가 Virtual Thread로 변경됩니다.

실제 적용 예시

@RestController
public class UserController {
    
    @GetMapping("/users/{id}")
    public UserDetailResponse getUserDetail(@PathVariable Long id) {
        // 전체 메서드가 Virtual Thread에서 실행
        
        // 사용자 정보 조회 - 언마운트 발생
        User user = userService.findById(id);
        
        // 권한 정보 조회 - 언마운트 발생
        List<Permission> permissions = permissionService.findByUserId(id);
        
        // 외부 프로필 이미지 조회 - 언마운트 발생
        String profileImageUrl = profileService.getImageUrl(user.getEmail());
        
        return UserDetailResponse.of(user, permissions, profileImageUrl);
    }
}

CPU 작업 혼합 시 최적화 방안

보고서 생성과 같이 CPU 계산이 포함된 경우, CPU 작업만 별도 Thread Pool로 분리하는 것이 효과적입니다.

@Configuration
@EnableAsync
public class ThreadPoolConfig {
    
    @Bean("cpuExecutor")
    public ExecutorService cpuExecutor() {
        return Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );
    }
}

@Service
public class ReportService {
    
    @Autowired
    @Qualifier("cpuExecutor")
    private ExecutorService cpuExecutor;
    
    public CompletableFuture<ReportResponse> generateReport(ReportRequest request) {
        return CompletableFuture
            // I/O 작업: 데이터 수집 (Virtual Thread)
            .supplyAsync(() -> dataService.collectData(request))
            
            // CPU 작업: 복잡한 계산 (Platform Thread Pool)
            .thenApplyAsync(data -> processCalculation(data), cpuExecutor)
            
            // I/O 작업: 결과 저장 (Virtual Thread)
            .thenApply(result -> dataService.save(result));
    }
}

도입 시 고려사항

DB Connection Pool 용량 검토

Virtual Thread로 동시 처리량이 증가하면 DB 커넥션 부족 현상이 발생할 수 있습니다. 기존 스레드 풀 크기에 맞춰 설정된 커넥션 풀을 재검토해야 합니다.

외부 API Rate Limiting 대비

동시 호출량 급증으로 외부 서비스에서 Rate Limit에 걸릴 가능성이 있습니다. Circuit Breaker 패턴이나 Rate Limiter 도입을 검토해야 합니다.

로그 시스템 영향도 분석

처리량 증가에 따른 로그 급증으로 다음과 같은 문제가 발생할 수 있습니다:

  • 로그 파일 크기 급증
  • 로그 I/O 병목 현상
  • 디스크 용량 부족

메모리 사용 패턴 변화

  • Direct Memory 사용량 증가 가능성
  • Young Generation GC 빈도 변화
  • 기존 JVM 튜닝 파라미터 재검토 필요

도입 적합성 판단 기준

적극 도입 권장 환경

  • 웹 서버, REST API 서비스
  • 마이크로서비스 아키텍처
  • I/O 집약적 배치 처리
  • 실시간 통신 서버

신중한 검토 필요 환경

  • CPU 집약적 애플리케이션
  • synchronized 사용이 많은 레거시 코드
  • 엄격한 성능 요구사항이 있는 시스템

도입 전 체크포인트

  • 현재 병목 지점이 I/O 중심인지 확인
  • DB Connection Pool 여유분 검토
  • 외부 API 의존성 및 제약사항 파악
  • 기존 코드의 synchronized 사용량 분석
  • 모니터링 체계의 Virtual Thread 대응 가능성 확인

학습 결론

Virtual Thread는 I/O 바운드 작업이 많은 웹 애플리케이션에서 상당한 성능 향상을 기대할 수 있는 기술입니다.

특히 설정이 간단하고 기존 코드 변경이 최소화되어 도입 부담이 적습니다.

다만 synchronized 블록의 제약사항이나 의존 시스템에 미치는 영향을 충분히 검토해야 합니다.

매일 오후 6시에 멈추는 지급, 24시간 자동화 시스템으로

2024년 프로젝트 회고

 

올라핀테크에 합류하고 가장 먼저 맡은 프로젝트가 지급 자동화였습니다. CX팀이 매 정각마다 반복하던 지급 작업을 24시간 자동화 시스템으로 전환하고, PHP, Node.js, Java로 분산되어 있던 지급 로직을 통합하며, 병렬 처리와 체계적인 예외 처리로 안정성을 확보한 과정을 기록합니다.


입사 당시 지급 시스템

입사 당시 지급 시스템은 이랬습니다:

  • CX팀 지급 담당자가 매 정각마다 수기로 처리
  • 평일 9~18시에만 지급 가능
  • 6시 이후 신청? 다음날까지 대기
  • 주말은 12시쯤 한 번만 일괄 처리

"이걸 24시간 자동화할 수 있을까?"

처음 하는 핀테크 업무였습니다. 이전까지는 금융과 무관한 개발을 해왔기에, "한 푼의 오차도 허용되지 않는다"는 게 어떤 의미인지 새롭게 배우게 된 프로젝트였습니다.

CX팀 지급 담당자의 업무

올라 서비스는 이커머스 셀러분들에게 정산금을 선지급하는 서비스입니다. 쇼핑몰 정산일을 기다릴 필요 없이, 판매 직후 바로 자금을 받아 사용할 수 있습니다.

하지만 서비스 초기, 이 지급 프로세스는 사람이 직접 처리했습니다.

CX팀 지급 담당자의 업무

매 정각마다:

  1. 지급 신청 확인
  2. 셀러 정산 계좌 검증
  3. 신용 상태 수기 확인
  4. 쇼핑몰 시스템 접속해서 정산금 확인
  5. 수기로 지급 처리
  6. 지급 완료 기록
  7. 방어로직에 의해 지급 대기로 빠진 건의 사유 확인 및 처리

이 과정을 평일 9시부터 18시까지, 매 정각 반복했습니다.

문제는 오후 6시 이후였습니다.

6시 이후에 지급을 신청한 셀러분들은 다음날 아침까지 기다려야 했습니다. 금요일 저녁에 신청하면? 주말 12시쯤 일괄 처리되기 전까지, 하루 이상을 기다려야 했습니다.

셀러분들 입장에서는:

  • 저녁에 급하게 자금이 필요한데 → 다음날까지 대기
  • 주말에 광고비가 필요한데 → 토요일 낮까지 대기

CX팀 입장에서는:

  • 매 정각마다 수십 건의 지급 신청을 하나씩 검증
  • 계좌번호 확인, 신용 상태 체크, 쇼핑몰 정산금 확인
  • 방어로직에 걸린 건들의 사유 하나씩 확인
  • 사람이 하는 일이라 실수의 위험도 존재
  • 반복 작업에 리소스 소진

자동화가 절실했던 이유

시간 제약의 벽

가장 큰 문제는 시간 제약이었습니다. 이커머스는 24시간 돌아가는데, 지급은 특정 시간대에만 가능하다는 건 모순이었습니다.

분산된 시스템, 복잡한 흐름

더 큰 문제는 지급 로직이 여러 서버에 흩어져 있었다는 점입니다.

기존 v1 지급 시스템:

  • PHP 서버: 지급 신청 접수 및 일부 검증
  • Node.js 서버: 쇼핑몰 크롤링 및 정산금 확인
  • Java 서버: 실제 지급 처리 및 기록

서버 간 통신으로 데이터를 주고받다 보니 어느 단계에서 문제가 생겼는지 추적이 어려웠고, 트랜잭션 처리가 없어 데이터 정합성 위험이 있었습니다.

개발 과정

1단계: Admin API 서버로 통합

PHP, Node.js, 구형 Java에 분산되어 있던 지급 API를 Admin API 서버로 통합했습니다.

[이전]
PHP → Node.js → Java → 데이터 정합성 문제

[통합 후]
Spring Admin API 서버
   ↓
트랜잭션으로 안전하게 관리

이제 하나의 트랜잭션 안에서 모든 지급 과정이 처리되니, 중간에 문제가 생기면 자동으로 롤백되어 데이터가 꼬일 일이 없어졌습니다.

2단계: 자동 검증 시스템

사람이 하던 검증 작업을 시스템이 자동으로 처리하도록 설계했습니다.

자동 검증 항목

  • ✅ 셀러의 정산 계좌 유효성 확인
  • ✅ 신용 상태 자동 체크
  • ✅ 지급 가능 금액 실시간 계산
  • ✅ 중복 신청 방지
  • ✅ 쇼핑몰 정산금 자동 확인 (크롤링)
  • ✅ 방어로직 통과 여부 자동 판단

가장 중요한 건 "판단"까지 자동화한 것이었습니다. 단순히 데이터를 보여주는 게 아니라, "지급 가능", "지급 대기", "지급 불가"를 시스템이 판단하도록 만들었습니다. 이를 위해 CX팀, 기획팀과 주기적으로 미팅을 하며 수많은 예외 케이스를 정의했습니다.

3단계: 동기에서 비동기 병렬 처리로

신청 쇼핑몰의 계정 검증 단계에서 Node.js에서는 비동기로 구현되어 있었지만, Java로 전환하면서 일단 동기 방식으로 구현했습니다. 그러다 보니 처리 시간이 길어지는 문제가 있었습니다.

CompletableFuture를 활용한 병렬 처리로 개선

private void processAsynchronousCrawlTasks(
        List<PaymentDto.UserScmInfo> crawlTargetUserScmInfoList,
        Predicate<PaymentDto.UserScmInfo> isParallel,
        List<CompletableFuture<Void>> futures) {
    
    crawlTargetUserScmInfoList.stream()
        .filter(isParallel)
        .forEach(userScmInfo -> {
            CrawlParamDto.CheckScmsRequest param = getCrawlParamDto(userScmInfo);
            
            CompletableFuture<Void> future = CompletableFuture
                .supplyAsync(() -> crawlComposite.checkScms(param))
                .thenAccept(scmCrawlDocument -> 
                    updatePaymentValidation(userScmInfo, true, scmCrawlDocument))
                .exceptionally(ex -> {
                    log.error("비동기 크롤 오류 발생 fsDataId = {}", 
                        userScmInfo.getFsDataId());
                    return exceptionHandle(userScmInfo, ex);
                });
            
            futures.add(future);
        });
}

이제 5개 신청이 들어와도 약 1분이면 처리가 완료됩니다.

4단계: 예외 처리 체계화

외부 쇼핑몰 시스템과 연동하다 보면 예상치 못한 상황이 많이 발생합니다.

지급 단계 정의
public enum PaymentStep {
    INIT_FROM_BATCH,      // 1. 배치에서 초기화
    INIT_FROM_MANUAL,     // 2. 수동 초기화
    DATA_VALIDATION,      // 3. 데이터 검증
    USER_VALIDATION,      // 4. 사용자 검증
    CRAWL_VALIDATION,     // 5. 쇼핑몰 크롤링 검증
    FUND_REQUEST,         // 6. 자금 요청
    PAYMENT,              // 7. 지급 처리
    EXCEPTION,            // 8. 예외 처리
    COMPLETION            // 9. 완료
}

Custom Exception 설계

// 크롤링 대기 - 일시적 상황 (재시도 가능)
public class CrawlWaitException extends RuntimeException {
    private final CrawlError crawlError;
    private final ScmCrawlDocument scmCrawlDocument;
}

// 크롤링 실패 - 처리 불가 (수동 확인 필요)
public class CrawlFailureException extends RuntimeException {
    private final CrawlError crawlError;
    private final ScmCrawlDocument scmCrawlDocument;
}

// 지급 대기
public class PaymentWaitException extends RuntimeException {
    private final PaymentErrorDto.PaymentWait paymentWait;
    private final PaymentStep step;
}

// 지급 실패
public class PaymentFailureException extends RuntimeException {
    private final PaymentErrorDto.PaymentFailure paymentFailure;
    private final PaymentStep step;
}

CX팀, 기획팀과 함께 수십 개의 예외 케이스를 논의하며 각 상황에 맞는 예외 처리 전략을 수립했습니다.

예외별 처리 전략

public void processPayment(PaymentEvent event) {
    Long fsDataId = event.getFsDataId();
    String keyId = String.valueOf(fsDataId);
    
    try {
        // Step 1: 데이터 검증
        PaymentDto.RequestInfo requestInfo = paymentValidator.validateDataState(fsDataId);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.success(keyId, PAYMENT, PaymentStep.DATA_VALIDATION, requestInfo));
        
        // Step 2: 셀러 상태 검증
        paymentUserValidator.validate(requestInfo);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.success(keyId, PAYMENT, PaymentStep.USER_VALIDATION, requestInfo));
        
        // Step 3: 쇼핑몰 크롤링 검증
        List<PaymentDto.UserScmInfo> userScmInfoList = paymentCrawlService.crawl(fsDataId);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.success(keyId, PAYMENT, PaymentStep.CRAWL_VALIDATION, userScmInfoList));
        
        // Step 4: 자금 요청
        boolean isFundRequestSuccess = fundRequestHandler.handle(requestInfo);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.success(keyId, PAYMENT, PaymentStep.FUND_REQUEST, requestInfo));
        
        // Step 5: 지급
        paymentService.request(requestInfo);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.success(keyId, PAYMENT, PaymentStep.COMPLETION, requestInfo));
        
    } catch (PaymentWaitException e) {
        // 일시적 문제 - 롤백 X, 대기 상태로 전환
        log.warn("PaymentWaitException: {}", e.getPaymentWait().getErrMsg(), e);
        paymentHandler.handleWait(e.getPaymentWait());
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.failure(keyId, PAYMENT, e.getStep(), 
                e.getPaymentWait().getErrMsg(), PaymentErrorCode.PAYMENT_WAIT.getCode()));
        // 스케줄러가 나중에 재시도
        
    } catch (PaymentFailureException e) {
        // 처리 불가 - 실패 상태로 전환, CX팀 확인 필요
        log.warn("PaymentFailureException: {}", e.getPaymentFailure().getErrMsg(), e);
        paymentHandler.handleFailed(e.getPaymentFailure());
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.failure(keyId, PAYMENT, e.getStep(), 
                e.getPaymentFailure().getErrMsg(), PaymentErrorCode.PAYMENT_FAILURE.getCode()));
        
    } catch (Exception e) {
        // 예상치 못한 오류 - 예외 처리
        log.error("지급 처리 중 예기치 않은 오류 발생: {}", e.getMessage(), e);
        paymentHandler.handleException(fsDataId);
        workFlowStepEventProducer.produce(
            WorkFlowStepEvent.exception(keyId, PAYMENT, 
                e.getMessage(), PaymentErrorCode.PAYMENT_EXCEPTION.getCode()));
        
        // Slack 알림 발송
        if (!(e instanceof BizException)) {
            sendPaymentErrorNotification(fsDataId, e);
        }
        throw e;
    }
}

어느 단계에서 어떤 이유로 문제가 생겼는지 workFlowStepEventProducer를 통해 이벤트를 발행하고, 이를 기반으로 모니터링과 대응이 훨씬 쉬워졌습니다.

5단계: 모든 예외 케이스에 테스트 코드

핀테크 특성상, "이 정도면 되겠지"는 없었습니다. CX팀, 기획팀과 함께 정의한 모든 예외 케이스에 테스트 코드를 작성했습니다.

@Test
void 쇼핑몰_점검중_지급대기_처리() {
    when(crawler.crawl(anyLong()))
        .thenThrow(new ShoppingMallMaintenanceException());
    
    paymentService.processPayment(paymentId);
    
    Payment payment = paymentRepository.findById(paymentId).orElseThrow();
    assertThat(payment.getStatus()).isEqualTo(PaymentStatus.WAITING);
}

"이 상황에서는 이렇게 동작해야 한다"는 명세가 곧 테스트 코드가 되었습니다.


기술적으로 어려웠던 것들

CompletableFuture와 JPA의 충돌

병렬 처리를 도입하면서 예상치 못한 문제를 만났습니다. JPA의 영속성 컨텍스트는 ThreadLocal 기반이라, 비동기 스레드에서는 EntityManager에 접근할 수 없었습니다.

해결 방법: 작업 분리

핵심은 "영속성 컨텍스트가 필요한 작업"과 "독립적인 작업"을 명확히 분리하는 것이었습니다.

@Transactional
public List<PaymentDto.UserScmInfo> crawl(Long fsDataId) {
    // 1. 메인 스레드: JPA 작업 (조회)
    List<PaymentDto.UserScmInfo> allUserScmInfoList = 
        fsPaymentRepository.findUserScmDetails(fsDataId);
    
    List<PaymentDto.UserScmInfo> crawlTargetList = allUserScmInfoList.stream()
        .filter(info -> !info.getCrawlStatus().equals("Y"))
        .toList();
    
    // 2. 비동기: 크롤링만 처리 (JPA 작업 없음)
    List<CompletableFuture<Void>> futures = new ArrayList<>();
    crawlTargetList.forEach(userScmInfo -> {
        CompletableFuture<Void> future = CompletableFuture
            .supplyAsync(() -> crawlComposite.checkScms(getCrawlParamDto(userScmInfo)))
            .thenAccept(result -> 
                updatePaymentValidation(userScmInfo, true, result))
            .exceptionally(ex -> exceptionHandle(userScmInfo, ex));
        
        futures.add(future);
    });
    
    // 3. 모든 크롤링 완료 대기
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    
    // 4. 메인 스레드: JPA 결과 저장
    // updatePaymentValidation() 내부에서 JPA 저장 처리
    
    return allUserScmInfoList;
}

private void updatePaymentValidation(
        PaymentDto.UserScmInfo userScmInfo, 
        boolean resultFlag, 
        ScmCrawlDocument scmCrawlDocument) {
    
    Long fsDataDetailId = userScmInfo.getFsDataDetailId();
    FsDataDetail fsDataDetail = getFsDataDetail(fsDataDetailId);
    
    String crawlStatus = resultFlag ? "Y" : "N";
    String resultMessage = resultFlag ? "" : scmCrawlDocument.getResultMessage();
    
    // JPA 저장 (메인 스레드에서 실행되도록 설계)
    recordFsDataDetailInfo(fsDataDetail, crawlStatus, resultMessage);
    recordPaymentValidationInfo(fsDataDetailId, crawlStatus, resultMessage, 
        userScmInfo.getFsDataId());
}

트랜잭션 경계 관리

트랜잭션을 어디서 시작하고 끝내야 할지, 어떤 예외에서 롤백해야 할지 판단하기 어려웠습니다.

고민했던 지점들:

  1. CrawlWaitException은 롤백해야 하나? → 일시적 상황이므로 롤백 X, 현재 상태 유지하고 나중에 재시도 → 외부 금융사에 자금요청이 완료된 경우도 롤백 X
  2. 크롤링 중 예외가 발생하면? → 신청은 유효하므로 롤백 X, 대기 상태로 전환
  3. 여러 건을 동시에 처리할 때 일부만 실패하면? → 각 지급 건을 독립된 트랜잭션으로 처리, 실패해도 다른 건에 영향 X

우아한형제들의 트랜잭션 관련 글을 참고하며 실전에 적용했습니다.


달라진 것들

CX팀 리소스 78.1% 절감

가장 큰 변화는 CX팀의 업무 부담이 대폭 줄어든 점입니다.

이전

  • 평일 9~18시, 매 정각마다 지급 작업
  • 수십 건을 하나씩 검증
  • 방어로직에 걸린 건들의 사유 확인
  • 사람의 실수 가능성

현재

  • 시스템이 24시간 자동 처리
  • CX팀은 예외 상황만 확인
  • 정확한 처리, 실수 제로

실제로 지급 관련 CX팀 업무 시간이 78.1% 절감되었습니다.

셀러 경험 개선

셀러분들 입장에서도 달라진 게 많습니다.

시간 제약 해소

이전에는 평일 9~18시만 처리되어 저녁이나 주말에 신청하면 하루 이상을 기다려야 했습니다. 현재는 24시간 언제든 신청할 수 있으며, 신청 최대 1시간 이내에 지급이 완료됩니다.

처리 속도 개선

평균 지급시간이 70.6% 단축되었습니다. 이전에는 8.99시간이 걸렸던 것이 현재는 2.64시간으로 줄어들었습니다.

영업시간 외 신청 비중 증가

오픈 전에는 영업시간(1016시)에 63.23%의 신청이 집중되었지만, 오픈 후에는 저녁 시간대(1723시) 신청이 16.20%에서 18.90%로, 전체적으로 영업시간 외 신청 비율이 증가했습니다. 사용자들이 시간에 구애받지 않고 더 유연하게 서비스를 이용할 수 있게 되었다는 증거입니다.

안정성 확보

트랜잭션 처리와 예외 처리 체계화로 데이터 정합성 문제가 거의 사라졌습니다.

이전: 서버 간 통신 중 실패 → 데이터 꼬임, 중복 지급/누락 지급 발생 가능

현재: 단일 트랜잭션으로 안전하게 관리, 예외 발생 시 자동 롤백 또는 대기, 단계별 STEP으로 정확한 추적

돌아보며

지급 자동화 프로젝트를 돌아보면, 단순히 "자동화를 만든 것" 이상의 의미가 있었습니다.

핀테크는 안정성이 생명

처음 하는 핀테크 업무였기에, "한 푼의 오차도 허용되지 않는다"는 게 어떤 의미인지 몸으로 배웠습니다. 첫 배포 후 하루 종일 모니터링만 했던 날, "빠르게 만들자"보다 "절대 틀리지 않게 만들자"가 우선이라는 걸 뼈저리게 느꼈습니다.

그 노력의 결과, 2025년 1월 올라 서비스는 누적 지급액 1조 원을 돌파했습니다. 안정적인 자동화 시스템이 없었다면 불가능했을 성장입니다.

CX팀, 기획팀과의 긴밀한 협업

코드를 짜기 전, 수십 번의 미팅을 했습니다. "이 상황에서는 어떻게 판단해야 하는가"를 하나하나 정의해나갔습니다. CX팀이 실제로 어떻게 판단하는지, 어떤 케이스들이 있는지 알지 못했다면, 아무리 완벽한 코드를 짜도 소용없었을 것입니다.

모든 예외 케이스에 테스트 코드

"이 정도면 되겠지"는 없었습니다. 쇼핑몰 점검 중, 계좌 오류, 크롤링 타임아웃, 네트워크 일시 오류... 각 상황에서 시스템이 어떻게 동작해야 하는지 명세가 곧 테스트 코드가 되었습니다.

기술적 성장

CompletableFuture와 JPA의 충돌을 해결하며 비동기 프로그래밍에 대한 이해가 깊어졌습니다. 트랜잭션 경계 관리도 실전에서 부딪히며 체득한 지식들입니다.

무엇보다, CX팀이 왜 힘들어하는지, 셀러분들이 뭘 불편해하는지 이해하지 못했다면, 기술적으로 완벽한 코드를 짜도 의미 없었을 것입니다. 오픈 후 커뮤니티를 통해 셀러분들의 만족도를 보며 개발자로서 뿌듯함을 느꼈습니다.

참고 자료

시작하며

Node.js 서버에서 금전적인 행위를 처리하는 배치 작업이 종료(Shutdown)될 때 안전하게 종료되지 않아 특정 유저의 출금이 두 번 발생하는 문제가 종종 발생했다. 원인을 분석해보니, Shutdown 시점에 출금 처리는 되었으나 데이터 처리까지 완료되지 않아, 다음 배치 작업에서 중복 출금이 발생하는 문제였다. 이를 해결하기 위해 Graceful Shutdown을 도입하는 과정에서 배운 점들을 기록하였다.

Graceful Shutdown이란?

직역하면 우아한 종료로, 애플리케이션이 완전히 종료되기 전에 진행 중인 모든 작업을 마무리하고, 리소스를 해제하며 데이터의 무결성을 유지한 상태로 안전하게 종료되는 과정을 의미한다.

Node-schedule 사용

Node.js에서 스케줄업무를 하는 라이브러리는 크게 Node-schedule 과 Node-cron이 있다. 현재 프로젝트에서는 Node-schedule 버전 1.3.3 을 사용중이다. 

 

※ 버전 확인 명령어

npm list node-schedule

 

만약 node-schedule의 버전을 2.x대 를 사용중이라면 기본 제공하는 gracefulShutdown() 메서드를 사용하여 안전한 종료가 가능하다. 하지만 1.x 대 버전을 사용한다면 gracefulShutdown() 메서드를 지원하지 않기때문에 메서드를 만들어 사용할수 있다.

gracefulShutdown 구현

Graceful Shutdown을 위해 작업 관리 리스트를 만들어 scheduledJobs와 runningJobs 배열에 작업을 추가, 삭제하는 구조로 관리한다.

1. 스케줄 관리 함수

const scheduledJobs = [];
let runningJobs = [];

const manageGracefulShutdownJob = (interval, jobFunction) => {
    let job = Schedule.scheduleJob(interval, async () => {
        const promise = jobFunction();
        runningJobs.push(promise);
        try {
            await promise;
        } finally {
            runningJobs = runningJobs.filter(job => job !== promise);
        }
    });
    scheduledJobs.push(job);
};

 

manageGracefulShutdownJob이 실행시 scheduleJobs 변수에 push 하여 관리대상으로 등록하고, 실행시 promise 객체를 반환하여 runningJobs에 push한다. 이후 await으로 해당 실행이 종료된 이후 runningJobs에서 해제를 시켜줌으로서, 현재실행중이 스케줄 잡을 관리한다.

manageGracefulShutdownJob('실행 주기', 메서드명);

2. Graceful Shutdown 적용

SIGINT 신호가 발생하면 모든 스케줄 작업을 취소하고, 실행 중인 작업이 완료될 때까지 대기한다.

process.on('SIGINT', async () => {
    console.log('Graceful shutdown in progress start');
    for (const job of scheduledJobs) {
        job.cancel();
    }
    console.log('All scheduled jobs cancelled');

    await Promise.all(runningJobs);

    console.log('All running jobs completed');
    process.exit(0);
});

 

위 코드를 통해 Graceful Shutdown을 구현할 수 있다. 그러나 개발 서버에서 테스트 시 SIGINT 신호가 발생하면 즉시 종료되는 현상을 확인했다. 이는 PM2의 kill_timeout 기본 설정이 1.6초로, 해당 시간동안 종료되지 않으면 SIGKILL로 변환되어 강제 종료되기 때문이다. kill_timeout을 1시간으로 늘려 테스트한 결과, 정상적으로 Graceful Shutdown이 작동했다.

참고 링크

https://github.com/node-schedule/node-schedule?tab=readme-ov-file#graceful-shutdown

https://pm2.keymetrics.io/docs/usage/signals-clean-restart/

시작하며

Readable Code: 읽기 좋은 코드를 작성하는 사고법 강의를 보고 정리한내용으로,

회사의 서비스 개선을 준비하는 단계에서 우리팀에 적용해보면 좋겠다. 라는 생각이 들어 정리한 내용입니다.

 

이름짓기

1. 단수와 복수를 구분하기

  • ~(e)s를 붙여 변수, 클래스 등 단수인지, 복수인지 구분

2. 이름줄이지 않기

  • 관용어처럼 많은 사람들이 자주사용하는 줄임말 정도만 사용하고, 무분별한 줄임말은 자제 또한, 줄임말이 이해될 수 있는 것은 문맥때문이기에 문맥을 잘 활용
  • ex) column -> col, latitude -> lat, longitude -> lon

 

3. 은어 / 방언 사용하지 않기

  • 일부 팀원 / 현재의 우리팀만 아는 용어금지
    • 새로운 사람이 팀에 합류했을때 용어를 이해하기 힘들다.
  • 도메인 용어 사용하기
    • 팀단위의 도메인 용어를 먼저 정의하는 과정이 필요하다.

4. 좋은 코드를 보고 습득하기

  • 비슷한 상황에서 자주사용하는 단어, 개념 습득하기

메서드와 추상화

하나의 메서드는 하나의 주제만을 가져야한다.

예시로 메서드의 이름은 추상적이며, 내용은 구체화하여 작성하여야한다.

왼쪽은 좋은예시로 메서드 이름은 추상적이며 내용은 구체적이지만, 반면 오른쪽은 하나의 메서드내에 여러개의 구체적인 내용이 포함되어

내용을 유추하기가 힘들다.

만약 이럴경우 여러개의 메서드로 분리하여 메서드당 하나의책임으로 추상화할 수 있다.

매직 넘버, 매직 스트링

  • 의미를 갖고 있으나, 상수로 추출되지 않은 숫자, 문자열 등
  • 상수 추출로 이름을 짓고 의미를 부여함으로써 가독성, 유지보수성이 향상된다.

사고의 Depth 줄이기: 중첩 분기문, 중첩 반복문

  • 보이는 Depth를 줄이는데에 급급한것이 아니라, 추상화를 통한 사고 과정의 Depth를 줄이는것이 중요
  • 2중 중첩구조로 표현하는것이 사고하는데에 더 도움이 된다고 판단한다면, 메서드 분리보다 그대로 놔두는것이 더나은 선택일 수 있다.
  • 때로는 메서드를 분리하는것이 더 혼선을 줄 수도 있다.

 

시작하며

개발자라면 누구나 CI/CD라는 용어를 들어봤을 것입니다. 저도 AWS 프리티어를 이용해 간단히 CI/CD 파이프라인을 구축해본 경험만 있고, 사내에서는 이미 구축된 시스템을 사용해왔기 때문에 직접적인 경험은 부족했습니다. 이 글에서는 사내에서 신규 프로젝트의 CI/CD 파이프라인을 구축한 경험을 공유하고자 합니다. 저처럼 경험이 부족한 분들에게 간접적인 경험을 통해 조금이나마 도움이 되길 바랍니다.

 

실제 구축하면서 여러 차례 Git Action Failure가 발생하여 구글링을 통해 해결하였으며, 모든 설정 파일을 완벽하게 외우지는 못하지만, 대략적인 그림이 그려진다면 다른 환경에서도 작업이 가능할 것이라 생각됩니다.

먼저 CI/CD란 무엇인가? 

대표적인 CI/CD 툴로는 Jenkins, GitHub Actions, Travis CI 등이 있으며, 필자는 Git Action을 선택하였습니다.

  • Git Action 장점
    • GitHub Actions는 GitHub와 통합되어 있어 별도의 서버 설치가 필요 없습니다.
    • GitHub Actions는 간편한 설정과 강력한 기능을 제공하여 빠르게 CI/CD 파이프라인을 구축할 수 있습니다.

그 외에 Docker 컨테이너 관리를 위해 AWS의 ECR을 선택하였고, 안전한 관리를 위해 Kubernetes 클러스터 서비스인 EKS를 활용하였습니다.

  • ECR의 장점
    • AWS 내에서 Docker 컨테이너 이미지를 저장하고 관리하기 위한 서비스로, AWS의 다른 서비스들과의 통합성이 뛰어납니다.
  • EKS의 장점
    • EKS는 고가용성, 확장성, 보안성 등의 특징을 제공하여 안정적인 운영 환경을 제공합니다.

필자는 위 장점들 때문에 해당 기술스택을 사용하였습니다.

1. Git Secret 설정하기

Git Repository의 Settings에서 GitHub Actions에 필요한 환경 변수를 설정할 수 있습니다. 민감한 정보를 보호하기 위해, 환경 변수는 Secret에 Key-Value 형태로 등록합니다.

  • 환경 변수 등록
    • 시크릿의 이름(Key)과 값을 입력합니다.
    • Secret을 등록후 Value 값을 확인할 수는 없다.

이렇게 등록된 시크릿은 GitHub Actions 워크플로우에서 참조하며, 민감한 정보가 노출되지 않도록 보호됩니다.

 

2. DockerFile 작성

Git Action을 통해 Docker Image를 만들 DockerFile을 작성합니다. 아래는 예제 Dockerfile입니다.

# 빌드 스테이지
FROM adoptopenjdk/openjdk8 AS build

# 애플리케이션 파일 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY lib/nsso-agent-15.jar .
COPY src src
COPY lib lib

# 권한 설정 및 빌드
RUN chmod +x ./gradlew
RUN ./gradlew bootJar

# 실행 스테이지
FROM adoptopenjdk/openjdk8:latest
LABEL maintainer="myProject"
VOLUME /tmp

# 환경 변수 설정
ARG SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-dev}
ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-dev}
RUN echo ${SPRING_PROFILES_ACTIVE}

# 빌드된 JAR 파일 복사
COPY --from=build build/libs/*.jar app.jar

# 타임존 설정
ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 사용자 및 권한 설정
RUN groupadd -r springboot && useradd -r -g springboot springboot
RUN mkdir -p /logs/sso && chown -R springboot:springboot /logs/sso

# 사용자 변경
USER springboot

# 포트 설정
EXPOSE 17010

# 애플리케이션 실행
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]
  • 실행 스테이지
    • 빌드된 JAR 파일을 실행 환경으로 복사하고, 환경 변수 및 타임존 설정을 수행합니다.
    • 애플리케이션을 실행할 사용자와 권한을 설정한 후, JAR 파일을 실행합니다.

이 Dockerfile을 사용하여 GitHub Actions에서 Docker 이미지를 빌드하고, 이를 Kubernetes 클러스터에 배포할 수 있습니다.

3. Kubernetes File 작성

애플리케이션을 Kubernetes 클러스터에 배포하기 위한 YAML 파일을 작성합니다. 

Deployment 파일 (deployment.yaml)

apiVersion: v1
kind: Namespace
metadata:
  namespace: myProject
  name: myProject
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: myProject
  name: myProject
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myProject
  template:
    metadata:
      labels:
        app: myProject
    spec:
      containers:
        - name: myProject
          image: kustomization-eks-repository
          imagePullPolicy: Always
          ports:
            - containerPort: 17010
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: dev
            - name: AWS_REGION
              value: ap-northeast-2

Kustomization 파일 (kustomization.yaml)

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
images:
  - name: kustomization-eks-repository

 

Service 파일 (service.yaml)

apiVersion: v1
kind: Service
metadata:
  namespace: myProject
  name: myProject
spec:
  selector:
    app: myProject
  ports:
    - port: 80
      targetPort: 17010
      name: http
  • Namespace
    • Namespace 리소스를 정의하여 myProject라는 네임스페이스를 생성합니다.
  • Deployment
    • 애플리케이션이 실행될 컨테이너 이미지를 지정하고, 환경 변수를 설정합니다.
    • 클러스터 내에서 애플리케이션의 복제본 수(replicas)를 정의합니다.
  • Kustomization
    • deployment.yaml과 service.yaml 파일을 리소스로 포함하고, 이미지 태그를 latest로 설정합니다.
  • Service:
    • service.yaml 파일에서 Service 리소스를 정의하여 클러스터 외부에서 애플리케이션에 접근할 수 있도록 합니다.
    • selector를 사용하여 Deployment와 연결하고, 포트 매핑을 설정합니다.

이렇게 작성된 Kubernetes 파일들을 사용하여, 애플리케이션을 클러스터에 배포하고 관리할 수 있습니다.

4. Git Action 파일작성

├── .github
│   └── workflows
│       └── pipeline-push-dev.yaml
name: DEV - Deploy to Amazon EKS

on:
  push:
    branches: [ dev ]
    paths-ignore:
      - ".github/**"

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
  EKS_CLUSTER: ${{ secrets.DEV_EKS_CLUSTER_NAME }}
  ECR_REPOSITORY: ${{ secrets.DEV_ECR_REPOSITORY }}
  APP_NAME:  myProject  # Application 이름. Image TAG Prefix로 사용 됨
  AWS_REGION: ap-northeast-2 # AWS EKS & ECR이 위치한 AWS Region
  DEPLOYMENT_NAME:  myProject # Kubernetes Deployment 명
  EKS_NAMESPACE: myProject
  YAML_ENV: kustomize/dev

jobs:
  build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest

    steps:
      # 소스 가져오기
      - name: Checkout
        uses: actions/checkout@v2

      # AWS credentials 설정
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # AWS ECR 로그인
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # sha 난수 생성
      - name: Short sha
        run: echo "short_sha=`echo ${{ github.sha }} | cut -c1-8`" >> $GITHUB_ENV

      # Docker 빌드 및 ECR로 Push 진행
      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
          IMAGE_TAG: DEV_${{ env.APP_NAME }}
          SHORT_SHA: ${{ env.short_sha }}
        run: |-
          pwd
          ls -al
          docker build -t ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA} -f Dockerfile_DEV .
          docker push ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA}
          docker tag ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA} ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_latest
          docker push ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_latest
          echo "::set-output name=image::${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}_${SHORT_SHA}"

  deploy:
    needs: build
    name: Deploy to DEV Environment
    runs-on: ubuntu-latest

    steps:
      # 소스 가져오기
      - name: Checkout
        uses: actions/checkout@v2

      # AWS credentials 설정
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      # AWS ECR 로그인
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # sha 난수 생성
      - name: Short sha
        run: echo "short_sha=`echo ${{ github.sha }} | cut -c1-8`" >> $GITHUB_ENV

      # EKS 배포를 위한 Kubeconfig 설정
      - name: Setup kubeconfig
        id: setup-kubeconfig
        env:
          AWS_REGION: ${{ env.AWS_REGION }}
          EKS_CLUSTER: ${{ env.EKS_CLUSTER }}
        run: |-
          aws eks --region ${AWS_REGION} update-kubeconfig --name ${EKS_CLUSTER}

      # EKS로 배포
      - name: Deploy to DEV Environment
        id: deploy-eks
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
          IMAGE_TAG: DEV_${{ env.APP_NAME }}
          SHORT_SHA: ${{ env.short_sha }}
          EKS_NAMESPACE: ${{ env.EKS_NAMESPACE }}
        run: |-
          cd $YAML_ENV

 

GitHub Actions 파일을 작성하여 CI/CD 파이프라인을 자동화합니다. 필자는 GitHub Actions 템플릿이 아닌 직접 작성한 Git Action 파일을 사용하여 배포를 수행하는 작업을 구성했습니다. dev 브랜치에 푸시될 때 작업이 수행되며, Docker 이미지를 빌드하고 Amazon ECR에 푸시한 후 Kubernetes 클러스터에 배포합니다.

의도는 "메시징"이다. 훌륭하고 성장 가능한 시스템을 만들기 위한 핵심은 모듈 내부의 속성과 행동이 어떤가보다는 모듈이 어떻게 커뮤니케이션하는가에 달려 있다.

 

자율적인 책임

자율적인 객체란 스스로 정한 원칙에 따라 판단하고 스스로의 의지를 기반으로 행동하는 객체다. 타인이 정한 규칙이나 명령에 따라 판단하고 행동하는 객체는 자율적인 객체라고 부르기 어렵다.

객체가 어떤 행동을 하는 유일한 이유는 다른 객체로부터 요청을 수신했기 때문이다. 요청을 처리하기 위해 객체가 수행하는 행동을 책임이라고 한다. 따라서 자율적인 객체란 스스로의 의자와 판단에 따라 각자 맡은 책임을 수행하는 객체를 의미한다.

적절한 책임이 자율적인 객체를 낳고, 자율적인 객체들이 모여 유연하고 단순한 협력을 낳는다. 따라서 협력에 참여하는 객체가 얼마나 자율적인지가 전체 애플리케이션의 품질을 결정한다.

 

왕은 목격자인 모자장수에 "증언하라" 라는 요청을 전송한다. 모자장수가 재판이라는 협력에 참여하기위해서는 왕의 요청을 적절하게 처리한 후 응답해야한다. 요청은 수신자의 책임을 암시하므로 모자 장수는 재판이라는 협력에 참여하기 위해 '증언할' 책임을 진다. 왕은 모자 장수에게 '증언하라'라는 자신의 요청에 반응해 책임을 완수할 수만 있다면 어떤 방법으로 증언하는지에 관해서는 싱겨을 쓰지않으며, 스스로의 의자와 판단에 따라 자유롭게 선택할 수 있다.

반면 왕이 모자 장수가 증언하는데 필요한 행동을 좀더 상세히 요청한다고 가정해보자 '목격했던 장면을 떠올리고', '떠오르는 기억을 시간 순서대로 재구성'한 후 '말로 간결하게 표현' 해야 하는 책임을 떠안게 된다.

 

협력의 결과로 모자장수가 왕의 요청을 받아 자신이 목격한것을 증언하게 된다는점에서는 동일하다. 하지만 모자 장수에게 주어진 권한에는 큰 차이가 있다. 

첫번째 모자장수에게 할당된 '증언하라' 라는 책임은 그 자체로 모자 장수의 자율성을 충분히 보장 할 수 있을 정도로 포괄적이고 추상적이면서도 모자 장수가 해야 할일을 명확하게 지시하고 있다. 반면 두번째 협력에서 모자 장수에게 할당된 좀 더 상세한 수준의 책임들은 모자 장수의 자율성을 제한한다.

자율적인 책임의 특징은 객체가 '어떻게' 해야하는가가 아니라 '무엇을' 해야하는가를 요청한다. 무엇은 수신한 객체의 책임이다.

 

책임이라는 말 속에는 어떤 행동을 수행한다는 의미가 포함되어 있다. 객체가  자신에게 할당된 책임을 수행하도록 만드는것은 외부에서 전달되는 요청이다. 객체가 다른 객체에게 접근할 수 있는 유일한 방법은 요청을 전송하는 것뿐이다. 그리고 이 요청을 메시지라고 한다. 메시지는 객체로 하여금 자신의 책임, 즉 행동을 수행하게 만드는 유일한 방법이다. 

 

+ Recent posts