여는 글

이번 문제는 지난 크리스마스에 생긴 일입니다. 헬스장에서 운동하다가 부장님 전화를 받고 회사에 뛰어 나갔는데요. ‘매입’이 이루어지지 않고 있다는 내용이었습니다. 일반적인 카드사라면 비영업일에는 매입을 하지 않기 때문에, 휴일에 ‘매입’이 되지 않는다고 회사로 출근한 상황이 이해 안되시겠지만 결제 대행사에서는 매입과 관련한 비즈니스가 조금은 다릅니다.

병목 현상

보여지는 문제는 병목 현상이었습니다. 한 건이 매입 생성 과정에서 NumberFormatException이 발생했고 이 한건으로 인해 모든 매입생성 대상건들이 생성되지 못하고 병목 현상이 발생했던 것인데요. 이 문제는 어쩌면 예전부터 발생이 예정되어있던 문제였습니다.

원인

문제의 근본적인 원인은 휴먼에러로, 잘못된 가맹점 설정이 원인이었습니다.

백오피스에서는 가맹점 정보를 엑셀 파일 업로드를 통해 일괄 등록할 수 있었는데, 이 과정에서 중요 정보에 오타가 발생했습니다. 잘못 입력된 값이 그대로 DB에 등록되었고, 매입 생성 시점에 해당 값을 BigDecimal로 변환하려다 NumberFormatException이 발생했던 것이었죠.

이런 잘못된 가맹점 설정은 앞으로도 발생할 수 있는 문제입니다. 이러한 문제를 예방을 위해 가맹점 조회 SQL 쿼리를 점검하고 생성에 필요한 값들의 검증을 추가해 방어적으로 코드를 구성할수도 있겠죠. 하지만, 이보다 더 먼저 해결해야할 것은 매입 생성 과정에서 에러가 발생하더라도 병목현상이 발생하지 않게 하는 것이라고 생각했습니다.

병목현상이 발생한 이유는 매입 생성 과정에 있습니다.

매입 생성을 위해 대상 거래건들을 조회하고 For문을 수행하기 시작하는데, 단건별로 작업을 수행하는 것이 아니라 단건별로 데이터를 가공한 후 한꺼번에 INSERT 배치 작업을 수행하는 형태로 이루어져있습니다.

따라서, For문 내에서 한 건이라도 실패하게 된다면 전체가 실패하는 셈이 되는 것이죠. 또한, 프레임워크나 스케줄러를 통해 실행되는 것이 아니라 Daemon Thread를 통해 Interval로 2초마다 실행되는 형태에 에러 발생 알람 기능도 에러 내역 관리 테이블도 없었기 때문에 담당자가 눈치챌때까지 실패하고 또 실패하고 무한 실패가 이루어집니다.

불안핑

매입 모듈을 분석하면서 발견한 잠재적 위험 요소들을 정리했습니다.

아키텍처

항목 문제점
동기 처리 단일 스레드로 실행되어 병렬 처리 불가
트랜잭션 경계 FDS 검증까지 포함된 넓은 트랜잭션 범위
장애 격리 단건 실패 시 전체 배치가 중단되는 구조

확장성

항목 문제점
처리 시간 2초 Interval 내 처리 완료를 보장하지 않음
FDS 의존성 룰 추가에 따른 지연 시간 증가에 대비되지 않음
성능 요구사항 개별 처리 전환 시 기존 배치 대비 성능 저하 가능성

운영

항목 문제점
에러 처리 예외 처리 코드 부재로 실패 지점 파악 어려움
재처리 에러 발생 시 재시도 대상건 식별 불가
모니터링 Alert 부재로 장애 인지까지 시간 지연

계획핑

잠재적 위험들을 정리했으니 어떻게 해결할 것인지 고민이 필요했습니다.

백오피스 입력 검증 강화 (가장 먼저)

근본 원인이 엑셀 업로드 시 잘못된 값이 그대로 등록된 것이었으므로, 가장 먼저 백오피스의 입력 검증 로직을 수정했습니다. 엑셀 파일 업로드 시 필수값 누락, 형식 오류, 범위 초과 등을 사전에 검증하여 잘못된 데이터가 DB에 저장되는 것 자체를 차단했습니다.

  • 형식 검증: 숫자 필드에 문자가 포함되어 있는지, BigDecimal 변환이 가능한 값인지 확인
  • 필수값 검증: 매입 생성에 필요한 필수 정보가 빈값인지 확인
  • 에러 리포트: 검증 실패 시 어느 행의 어느 컬럼이 문제인지 명확하게 표시

이렇게 하면 휴먼에러가 발생하더라도 업로드 시점에 즉시 피드백을 받을 수 있어, 잘못된 데이터가 시스템에 유입되는 것을 원천 차단할 수 있습니다.

하지만 이것만으로는 충분하지 않았습니다. 이미 등록된 레거시 데이터에도 문제가 있을 수 있고, 다른 경로로 데이터가 입력될 가능성도 있기 때문입니다. 따라서 매입 모듈 자체의 방어 로직도 함께 강화하기로 했습니다.

단일 스레드 유지

병렬 처리 도입은 고려하지 않았습니다.

  • 거래 순서 보장: 매입은 거래 시간 순서대로 처리되어야 함
  • 복잡도 증가 우려: 동시성 제어, 공유 자원 접근 등 추가 핸들링 필요
  • 판단: 현재 상황에서는 오버 엔지니어링

실행 지연시간 예방

기존 구조는 무한 루프 내에서 처리 후 2초를 대기하는 방식이었습니다. 배치 작업이 2초를 초과하면 주기가 불규칙해지고, 종료 시 진행 중인 작업이 중단될 위험이 있었습니다. 이 부분도 개선이 필요했습니다.

FDS 분리

매입/매입취소 과정 모두 FDS 검증이 포함되어 있었습니다. 일반적인 매입과정 업무는 승인/승인취소 거래가 FDS에서 탐지되면 매입을 중단하고 탐지 기록으로 남긴 뒤, RM(Risk Manager)과 같은 담당자가 직접 확인하여 해제하거나 매출 취소로 이어지는 흐름으로 알고 있습니다. 결제 대행사는 이러한 비즈니스와는 결이 조금 다릅니다. 이 비즈니스를 공개적으로 자세히 설명은 못드리지만…

아무튼 이 FDS를 매입 과정에서 분리하여 별도로 실행하기로 결정했습니다. 왜냐하면, FDS룰을 추가하고 복합 조건과 표준편차를 고려한 룰 수정 계획이 있었기 때문입니다. 이런 계획을 수행하려면 FDS가 매입 생성 과정에서 발생시킬 장애 전파라던지 지연시간 발생 문제를 격리시키는 것이 필요했습니다.

  • 트랜잭션 경계 축소: 매입 생성의 트랜잭션 범위를 줄여 장애 영향 최소화
  • 처리 시간 단축: FDS 지연이 매입 처리에 영향을 주지 않음
  • 확장성 확보: FDS 룰 추가에도 매입 처리 성능 유지

Mybatis 도입

이전 포스트에서 언급한 레거시 DAO 라이브러리를 제거하고 Mybatis를 도입하기로 했습니다.

  • 코드량: 매입 모듈의 코드량이 많지 않아 마이그레이션 부담 적음
  • 필요 기능: 뒤에서 설명할 Mybatis의 특정 기능이 필요했음

메일 알람 기능 추가

기존에는 텔레그램을 통해 모듈의 생사 여부(Health Check)만 알림이 왔고, 에러 발생에 대한 알람 기능은 부재했습니다. 이번 크리스마스 장애도 부장님이 우연히 발견하기 전까지 인지하지 못했던 이유입니다.

SMTP 서버를 활용한 메일 알람 기능을 추가하기로 했습니다.

  • 즉각적인 장애 인지: 에러 발생 즉시 담당자에게 메일 발송
  • 상세 정보 포함: 에러 메시지, 스택 트레이스, 실패 건 정보 포함
  • 알람 피로 방지: 동일 에러 반복 시 중복 발송 제한

장애 격리

앞서 설명한 단건 실패가 전체 배치를 중단시키는 현재 구조를 개선해야 했습니다. 시스템을 중단 시킬 수 있는 내용들을 최대한 정리해서 처리 단위의 격리를 견고히 하고자 했습니다.

해결핑

계획을 세웠으니 이제 실행할 차례입니다.

데몬 실행 구조 개선

기존의 무한 루프 + 고정 대기 방식에서 처리 완료 후 대기 패턴으로 변경했습니다. 매입 처리 작업을 먼저 실행하고, 완료된 후에 지정된 시간만큼 대기합니다. 이렇게 하면 작업 시간이 얼마나 걸리든 이전 작업이 완전히 끝난 후에 다음 대기가 시작됩니다.

추가로 현재 처리 중인지 여부를 추적하여, Graceful Shutdown 시 진행 중인 작업이 완료될 때까지 안전하게 대기할 수 있도록 했습니다. SIGTERM 신호가 들어오면 현재 처리 중인 매입 건이 완료된 후 이벤트 버스, Health Check 서버, 커넥션 풀까지 순차적으로 정리하고 종료됩니다.

아키텍처 변경

기존 3800줄짜리 단일 클래스를 역할별로 분리했습니다. 기존에는 하나의 클래스에서 데이터 조회, 검증, 매입 생성, 배치 INSERT, 예외 처리를 모두 담당했습니다. 이 구조에서는 한 곳의 문제가 전체에 영향을 미치고, 테스트와 유지보수가 어려웠습니다.

개선된 구조에서는 오케스트레이터 역할을 할 코어 클래스와, 매입 생성 서비스, 실패 건 격리 서비스, 사전 검증 유틸을 각각 담당하여 분리 했습니다. 각 컴포넌트가 단일 책임을 가지게 되어 문제 발생 시 영향 범위가 줄어들고, 개별 테스트가 가능해졌습니다.

flowchart LR
    subgraph Before["기존 구조"]
        A[대상 조회] --> B[For Loop]
        B --> C[데이터 가공]
        C --> D[List 추가]
        D --> E{모든 건 완료?}
        E -->|No| B
        E -->|Yes| F[일괄 INSERT]
        F --> G[한 건이라도 실패 시 전체 롤백]
    end
flowchart LR
    subgraph After["개선 구조"]
        A[대상 조회] --> B[For Loop]
        B --> C{검증 통과?}
        C -->|Yes| D[매입 처리]
        C -->|No| E[에러 테이블 격리]
        D --> F[배치 List 추가]
        E --> G[다음 건 계속]
        F --> H{모든 건 완료?}
        G --> H
        H -->|No| B
        H -->|Yes| I[검증 통과 건만 배치 INSERT]
    end

Fail-Fast 검증

NPE가 발생할 수 있는 지점을 사전에 검증하여 빠르게 실패시키는 방식을 적용했습니다. 가맹점 정보 그리고 필수값들의 유효성을 먼저 확인합니다.

간혹 NPE 방지를 위해 디폴트값을 사용하는 경우가 있는데(AI가 유독 이런 코드를 좋아함), 금융 시스템에서 NPE 방지를 위해 디폴트 값을 사용하는 것은 매우 위험하다고 생각했습니다. 예를 들어 수수료율이 null이거나 빈값일 때 0으로 처리하면 에러가 아니기 때문에 눈치도 못하고 정산시점에서나 알게 되는 금융 사고로 이어집니다. 차라리 검증 단계에서 명확히 실패시키고, 담당자가 확인 후 조치할 수 있는 구조가 더 안전하다고 판단했습니다.

검증 실패 시에는 모든 오류 사유를 수집하여 한 번에 예외로 던집니다. 이렇게 하면 “가맹점 정보 없음”, “수수료율 미설정” 등 여러 문제를 한 번에 파악할 수 있어 디버깅을 용이하게 만들었습니다.

에러 격리 테이블

매입 과정에서의 실패 건을 별도 테이블에 격리하여 재시도 및 추적이 가능하도록 했습니다. 에러 테이블에는 거래ID, 거래유형, 에러코드, 에러메시지, 거래 데이터 JSON, 재시도 횟수, 상태 등을 저장합니다.

재시도 횟수는 5회로 제한했습니다. 데몬이 2초 지연을 가지고 실행되므로 5회면 최소 10초 동안 재시도 기회가 주어집니다. 일반적인 DB나 네트워크 일시 장애는 수 초 내에 복구되기 때문에, 5회 이상 실패한다면 높은 확률로 데이터 자체의 문제입니다. 이 경우 상태를 변경하고 담당자에게 긴급 알림을 발송합니다.

MyBatis Batch 모드 활용

장애 격리가 안되었던 이유은 앞서 설명했듯이 조회된 대상 거래를 반복문을 통해 모두 가공하고 INSERT VALUES 구문을 이용해 일괄 삽입했기 때문입니다. 이런 BATCH INSERT 구조를 바꿀 수는 없었습니다. 왜냐하면, 대상거래를 반복문을 통해 단건별로 INSERT를 수행하면 오버헤드가 발생할것이 뻔했기 때문입니다. 따라서, 기존 배치 처리의 성능을 유지하면서 장애 격리를 적용하기 위해 MyBatis의 Batch 모드를 활용했습니다.

MyBatis의 기본 ExecutorType인 SIMPLE 모드는 매 실행마다 PreparedStatement를 생성하고 DB에 요청을 보냅니다. 100건을 처리하면 100번의 DB 라운드트립이 발생합니다. 반면 BATCH 모드는 Statement를 버퍼에 쌓아두었다가 commit 시점에 일괄 실행합니다. 100건을 처리해도 DB 라운드트립은 1회입니다. 네트워크 지연이 있는 환경에서는 이 차이가 극적으로 나타납니다.

핵심 설계는 검증 통과 건만 배치 리스트에 추가하는 것입니다. For 루프를 돌면서 각 건을 검증하고, 통과한 건만 배치 리스트에 추가합니다. 검증에 실패한 건은 에러 테이블로 격리하고 다음 건을 계속 처리합니다. 모든 건의 처리가 끝나면 검증을 통과한 건들만 배치 INSERT합니다.

이렇게 하면 배치 INSERT 전에 실패 가능성이 있는 건이 선제적으로 제거되어 배치 실패 확률을 최소화할 수 있습니다.

이중 보호 패턴

에러 처리 중에도 예외가 발생할 수 있습니다. 매입 처리에서 예외가 발생하면 에러 서비스를 호출하여 격리 테이블에 저장하고 메일을 발송하는데, 이 과정에서도 DB 연결 실패나 SMTP 오류 등이 발생할 수 있습니다.

이를 대비해 이중 try-catch 패턴을 적용했습니다. 외부 catch 블록에서 에러 처리를 시도하고, 에러 처리 자체가 실패하면 내부 catch 블록에서 로그만 남기고 다음 건을 계속 처리합니다. 에러 격리에 실패했다고 해서 전체 배치가 중단되어서는 안 되기 때문입니다.

같은 원리로 매입 성공 후 에러 테이블의 기존 실패 기록을 해결완료로 업데이트하는 작업도 별도의 try-catch로 감쌉니다. 이 정리 작업이 실패해도 이미 성공한 매입은 유지됩니다.

FDS 비동기 분리

계획대로 FDS를 매입 과정에서 완전히 분리했습니다. 기존에는 매입 생성 트랜잭션 안에서 FDS 검증까지 수행했기 때문에, FDS 로직에서 문제가 생기면 매입 자체가 롤백되는 구조였습니다.

개선된 구조에서는 이벤트 기반 비동기 처리 방식을 적용했습니다. 매입 처리가 완료되면 이벤트 버스에 완료 이벤트를 발행하고, FDS 핸들러가 이를 구독하여 별도 스레드에서 탐지 작업을 수행합니다.

이벤트 버스는 스레드 풀을 사용하여 비동기로 동작합니다. 큐가 가득 차면 호출자 스레드에서 직접 실행하는 정책을 적용해서 이벤트 유실을 방지했습니다. FDS 핸들러는 별도의 DB 세션을 사용하기 때문에 매입 트랜잭션과 완전히 독립적입니다.

이 구조의 핵심은 FDS 실패가 매입에 영향을 주지 않는다는 점입니다. FDS 탐지 중 예외가 발생해도 이미 커밋된 매입은 그대로 유지됩니다. 또한 매입 처리는 FDS 완료를 기다리지 않고 바로 다음 건을 처리할 수 있어 처리 속도도 개선되었습니다.

FDS 규칙은 인터페이스 기반으로 설계하여 새로운 규칙을 추가할 때 기존 코드 수정 없이 확장할 수 있도록 했습니다.

개선 효과

항목 Before After
단일 건 실패 시 전체 배치 롤백 해당 건만 격리
장애 인지 시간 담당자 발견까지 즉시 메일 알림
재처리 수동 분석 필요 에러 테이블에서 확인
원인 분석 로그 분석 에러 테이블 조회

퀘스천 마크

그럼 Mybatis의 BATCH 모드 버퍼는 몇 건까지 가능할까요. 건수 제한이 있어도 문제, 없어도 문제일텐데요. 건수 제한이 없다면 시스템의 하드웨어 리소스를 모두 차지해버리거나 관련한 문제로 또다시 크리티컬 문제가 발생할 것이고, 건수 제한이 있다면 청크 단위를 어떻게 나누어 처리할 것인지 또한 정해야 합니다.

결론부터 말하면, MyBatis 자체에는 배치 건수 제한이 없습니다. 버퍼에 계속 쌓다가 flushStatements()commit() 호출 시점에 한꺼번에 실행됩니다. 그래서 제한은 다른 곳에서 발생합니다.

JVM 힙 메모리: 배치에 추가된 각 Statement의 파라미터들이 메모리에 쌓입니다. 수십만 건을 한 번에 배치하면 OutOfMemoryError가 발생할 수 있습니다.

JDBC 드라이버: MySQL Connector/J의 경우 rewriteBatchedStatements=true 옵션을 켜야 진정한 배치 최적화가 적용됩니다. 이 옵션이 없으면 내부적으로 개별 실행과 다를 바 없습니다.

DB 서버 설정: MySQL/MariaDB의 max_allowed_packet 설정이 있습니다. 배치로 만들어진 SQL 패킷이 이 크기를 초과하면 에러가 발생합니다. 기본값이 4MB~64MB 정도인데, 대량 배치 시 이 제한에 걸릴 수 있습니다.

그래서 청크 단위 처리를 세팅했습니다. 일정 건수(보통 500~1000건)마다 flushStatements()를 호출하여 중간 실행합니다. 이렇게 하면 메모리 사용량을 일정하게 유지하면서도 배치의 성능 이점을 누릴 수 있습니다.

이번 매입 시스템에서는 2초 주기로 실행되기 때문에 한 번에 처리되는 건수가 보통 수십~수백 건 수준입니다. 따라서 별도 청크 처리 없이도 안전하게 동작하지만, 향후 처리량이 급증할 경우를 대비해 청크 처리 로직을 추가하게 되었습니다.

맺는 글

이번 크리스마스 장애는 단순한 NumberFormatException 하나가 전체 매입 처리를 마비시킨 사례였습니다. 근본적인 원인은 휴먼에러였지만, 이를 장애로 확대시킨 것은 아키텍처의 문제였습니다.

돌아보면 이번 개선의 핵심은 “빨리 실패하고, 부분 실패를 격리하고, 문제를 추적 가능하게 만드는 것”이었습니다.