실시간 정산/지급대행 배치 리팩토링기
5분 정산이 실제로 5분 뒤에 지급되도록 개선한 경험
여는 글
“5분 정산인데 왜 10분이 넘게 걸려요?”
운영팀에서 전달받은 가맹점 민원이었습니다. 실시간 정산과 지급대행 서비스를 함께 사용하는 가맹점에서 5분 정산주기로 설정해두었는데, 정산금이 지갑에 반영되길 기대하고 출금을 시도했더니 잔액부족으로 실패하는 일이 발생했습니다. 가맹점 입장에서는 “5분 정산”이라고 해서 5분 뒤에 정산금을 받을 거라 기대했는데, 실제로는 10분, 길게는 13분이 걸리니 당연히 민원이 들어올 수밖에 없었습니다.
“시스템 설계상 어쩔 수 없습니다.”라고 민원인을 달랬지만 이렇게 대답을 해야만 하는 운영팀도 그 사실을 전달하는 개발팀도 민망할 수밖에 없었죠.
이 글에서는 정산 배치 모듈을 리팩토링하여 어쩌면 당연해야 했던 “5분 정산이 실제로 5분 뒤에 완료”되도록 개선한 경험을 공유하려 합니다.
문제 분석 - 사이클 간극과 방어적 설정
기존 시스템 구조
기존 시스템은 역할이 명확히 분리된 두 개의 모듈로 구성되어 있었습니다.
| 모듈 | 역할 |
|---|---|
| 금융 VAN API 연동 지급 이체 모듈 | 실제 계좌 이체 처리 |
| 정산 배치 모듈 | 매입 데이터 집계 → 정산 데이터 생성, 정산 주기 조정, 지급대행 가맹점 계좌 잔액 증대 |
모듈 간 역할 분리는 적절했습니다. 문제는 다른 곳에 있었습니다.
첫 번째 문제: 모듈 간 사이클 불일치
각 모듈은 정각 실행이 아니라 “작업이 끝나면 다음 사이클” 방식으로 동작했습니다.
정산 배치: 시작 → 처리 → 종료 → 대기(5분) → 시작 → ...
지급 이체: 시작 → 처리 → 종료 → 대기(10초) → 시작 → ...
정산 배치 모듈이 12:00에 정산 데이터를 생성했다고 해도, 지급 이체 모듈이 12:00:05에 돌지, 12:00:30에 돌지, 12:01:00에 돌지 예측할 수 없었습니다.
두 번째 문제: 모듈 내부의 사이클 불일치
더 큰 문제는 정산 배치 모듈 내부에 있었습니다. 이 모듈에서 수행하는 두 가지 작업이 별도의 사이클로 실행되고 있었습니다.
- 정산 데이터 생성: 매입 데이터를 집계하여 정산 대상 생성
- 지급대행 가맹점 계좌 잔액 증대: 정산금을 가맹점 지갑에 반영
[정산 배치 모듈 내부]
정산 데이터 생성: 시작 → 처리 → 종료 → 대기 → ...
잔액 증대: 시작 → 처리 → 종료 → 대기 → ...
→ 하나의 모듈 안에서도 두 작업의 실행 시점이 엇갈림
방어적 설정의 결과
모듈 간 타이밍 불일치, 그리고 모듈 내부의 타이밍 불일치까지. 이 두 가지 불확실성이 겹치면서 정산 완료 시점을 예측할 수 없었습니다.
초기 개발 업체는 이 간극으로 발생할 수 있는 시스템적 리스크를 예방하기 위해, 정산 주기를 매우 방어적으로 설정했습니다. “혹시 모를 타이밍 문제”에 대비해 충분한 여유를 둔 것이죠.
[방어적 설정의 결과]
실제 처리 시간: 약 2~3분
방어적 버퍼: +5~10분
→ 5분 정산이 실제로는 10~13분 소요
비즈니스 관점에서 보면, 기술적 불확실성을 시간으로 때우는 방식이었습니다. 가맹점 입장에서는 “5분 정산”이라고 해서 가입했는데, 실제로는 10분 이상 기다려야 하는 상황이 된 것입니다.
해결 과정 - 사이클 간극 해소
운영팀과의 협의
먼저 운영팀과 대화했습니다. 핵심 질문은 “정산 데이터 생성과 잔액 증대가 왜 별도 사이클로 실행되어야 하는가?”였습니다.
확인 결과, 비즈니스 관점에서 분리되어야 할 이유는 없었습니다. 정산 데이터가 생성되면 바로 그 자리에서 잔액을 반영해도 되는 것이었습니다.
리팩토링 방향
문제의 핵심은 예측 불가능한 사이클 간극이었습니다. 이를 해소하기 위해 두 가지 방향으로 리팩토링을 진행했습니다.
정산 배치 모듈 리팩토링
기존에 별도 사이클로 실행되던 두 작업을 하나의 흐름으로 통합했습니다.
- 정산 데이터 생성(Make) → 잔액 증대(Settle) 순차 호출
- 하나의 실행 사이클 내에서 Make 완료 후 Settle이 실행되도록 구성
- “작업이 끝나면 다음 사이클” 방식에서 5분 간격 정각 실행 방식으로 변경
- 더 이상 “정산 데이터는 생성됐는데 잔액은 아직” 상황이 발생하지 않음
지급 이체 모듈 리팩토링
지급 이체 모듈은 역할 변경 없이, 대용량 트래픽 대비를 위한 병렬 처리 구조를 추가했습니다. (이 부분은 뒤에서 자세히 다룹니다.)
변경 후 흐름
[변경 후 흐름 - 지급대행 가맹점]
12:00:00 정각 - 정산 배치 모듈 실행
↓
Make 작업 실행 (정산 데이터 생성)
↓ (완료 후 즉시)
Settle 작업 실행 (잔액 증대)
↓
12:0X:XX - 가맹점 지갑에 잔액 반영 완료
→ 별도 사이클 대기 없이 하나의 흐름에서 순차 처리
→ 다음 실행은 12:05:00 정각
[변경 후 흐름 - 실시간 정산 가맹점]
1. 정산 배치 모듈 실행 → 정산 데이터 생성
2. 지급 이체 모듈 실행 → 금융 VAN API로 즉시 이체
3. 가맹점 계좌에 입금 완료
핵심은 사이클 간극을 없앤 것입니다. 정산 데이터가 생성되면 바로 그 다음 단계가 실행되도록 보장함으로써, 방어적 버퍼가 필요 없어졌습니다.
왜 5분 안에 완료될 수 있는가?
지급대행 가맹점의 경우, Make(정산 데이터 생성) → Settle(잔액 증대) 모두 내부 DB 작업입니다. 외부 API 호출이 없으므로 수초 내에 완료됩니다. 민원의 원인이었던 “사이클 대기 시간”만 제거하면 5분 정산이 실제로 5분 안에 완료될 수 있는 것입니다.
추가 개선 - 지급 이체 모듈의 대용량 트래픽 대비
정산 배치 모듈의 사이클 간극 문제를 해결하면서, 별도로 운영되는 지급 이체 모듈도 점검했습니다. 이 모듈은 실시간 정산만 사용하는 가맹점을 위해 금융 VAN API를 통해 실제 계좌 이체를 수행합니다.
단건 처리의 현실
금융 VAN API의 응답 시간이 생각보다 오래 걸린다는 걸 확인했습니다. 계좌이체 API 한 건당 최대 4초까지 소요되었습니다.
현재 트래픽에서는 문제가 없었지만, 만약 트래픽이 증가한다면?
100건 × 4초 = 400초 (약 6분 40초)
150건 × 4초 = 600초 (10분)
200건 × 4초 = 800초 (약 13분) → 5분 초과
5분 안에 처리를 완료하지 못하면 다음 사이클의 데이터와 겹치면서 지연이 누적될 위험이 있었습니다.
Thread Pool을 활용한 병렬 처리
이 문제를 해결하기 위해 Thread Pool을 활용한 병렬 처리를 설계했습니다.
고려사항
- Thread 수 제한: 서버 리소스 관리를 위해 동시 요청 수 제한
- 순서 보장 불필요: 개별 이체 건은 서로 독립적
- 실패 건 관리: 일부 실패해도 나머지는 정상 처리되어야 함
Thread Pool 크기 선정
해당 모듈이 운영되는 베어메탈 서버의 CPU 코어 수는 20개였습니다. VAN사에 확인한 결과 동시 요청수 제한이나 분당 제한은 없었습니다. 현재는 서버 리소스 관리 차원에서 보수적으로 10개의 Thread Pool을 구성했으며, 트래픽 증가 시 Thread 수를 늘릴 계획입니다.
병렬 처리 효과
[순차 처리]
100건 × 4초 = 400초 (약 6분 40초)
[병렬 처리 - 10개 Thread]
100건 ÷ 10 Thread × 4초 ≈ 40초
이론상 10배 빨라지지만, 실제로는 Thread 스위칭 오버헤드와 VAN API 서버의 동시 요청 처리 상황에 따라 달라질 수 있습니다. 그래도 순차 처리 대비 충분한 개선 효과를 얻을 수 있습니다.
Thread 관리 시 주의사항
병렬 처리를 도입하면서 정리한 Thread 관리 포인트입니다.
User Thread vs Daemon Thread
Java 애플리케이션은 모든 User Thread가 종료되어야 JVM이 종료됩니다. Daemon Thread는 User Thread 종료 시 함께 강제 종료됩니다.
정산 배치 데몬은 setDaemon(false)로 명시적으로 User Thread로 설정했습니다. 비즈니스 로직, 특히 금융 이체 같은 중요한 작업은 반드시 User Thread에서 처리해야 합니다. Daemon Thread에 위임하면 메인 Thread 종료 시 데이터 유실이 발생할 수 있습니다.
ExecutorService 정리
병렬 처리를 위해 ExecutorService를 사용한다면, 반드시 shutdown()과 awaitTermination()으로 정리해야 합니다. 정리하지 않으면 배치가 끝나도 JVM이 종료되지 않는 현상이 발생합니다.
활성 Thread 모니터링
배치 시작 시와 종료 전에 Thread.getAllStackTraces()로 활성 Thread를 확인하는 로직을 추가했습니다. 비정상 종료 시 원인 파악에 도움이 됩니다.
정산 배치 모듈 - 순차 실행과 트랜잭션 설계
트랜잭션 설계
정산 배치 모듈에서 정산 생성과 잔액 증가를 하나의 흐름으로 처리하면서, 트랜잭션 범위를 고민했습니다.
선택지
- 전체를 하나의 트랜잭션으로 묶기
- 건별로 트랜잭션 분리
기존 레거시 시스템은 Spring의 @Transactional 없이 개별 Connection을 직접 관리하고 있었습니다. 이 구조를 유지하면서 건별 트랜잭션을 선택했습니다.
이유
- 한 건 실패 시 해당 건만 재처리 가능
- 전체 롤백으로 인한 대량 재처리 방지
- 실패 건 추적 용이
실행 순서 보장과 정각 실행
정산 생성 후 잔액 증가가 순차적으로 실행되어야 합니다. 하나의 실행 사이클 내에서 Make → Settle 순서로 메소드를 호출하여, Make가 완료된 후에야 Settle이 시작되도록 구성했습니다.
또한 기존의 “작업 완료 후 대기” 방식을 5분 간격 정각 실행 방식으로 변경했습니다. 12:00, 12:05, 12:10처럼 정해진 시간에 실행되므로, 가맹점이 정산 완료 시점을 예측할 수 있게 되었습니다.
5분 초과 시 대응
만약 작업이 5분을 초과하면 어떻게 되는가? 이 질문에 대한 대비도 필요했습니다.
모니터링과 알림
- 각 사이클의 실행 시간을 로그로 기록
- 실행 시간이 임계값(예: 4분)을 초과하면 담당자에게 메일 알림 발송
- 장시간 지연 시 운영팀에도 알림
중복 실행 방지
정각 실행 방식에서는 이전 작업이 완료되지 않았는데 다음 정각이 되는 상황이 발생할 수 있습니다. 이를 방지하기 위해 실행 중 플래그를 사용하여 중복 실행을 막았습니다. 이전 작업이 진행 중이면 다음 사이클은 스킵하고 경고 로그를 남깁니다.
실패 건 재처리
건별 트랜잭션 구조이므로, 실패한 건은 다음 사이클에서 다시 처리 대상으로 조회됩니다. 별도의 재처리 로직 없이도 자연스럽게 재시도됩니다.
기술 선택 - 왜 Spring Batch가 아닌가?
이전 차액정산 프로젝트와의 차이
이전에 차액정산 시스템을 재구축할 때는 Spring Batch를 선택했습니다. Step 체이닝, 메타테이블, ExecutionContext 등의 기능이 필요했기 때문입니다.
하지만 이번에는 상황이 달랐습니다.
| 관점 | 차액정산 | 실시간 정산/지급대행 |
|---|---|---|
| 문제 | 신규 시스템 구축 | 사이클 간극 해소 |
| 범위 | 전체 아키텍처 설계 | 기존 모듈 내 순차 실행 보장 |
| 리스크 | 신규 구축이라 낮음 | 기존 시스템 영향도 검토 필요 |
ROI 계산
해결해야 할 문제는 “사이클 간극 해소와 타이밍 보장”이었지, “배치 프레임워크 현대화”가 아니었습니다.
Spring Batch를 도입하려면 메타테이블 추가, 기존 시스템과의 정합성 검증, 운영팀 교육 등이 필요했습니다. 반면 기존 Thread/Daemon 방식을 개선하면 기존 코드 기반으로 점진적 개선이 가능하고, 문제 발생 시 빠른 롤백이 가능했습니다.
성과
정량적 개선
| 지표 | 변경 전 | 변경 후 |
|---|---|---|
| 5분 정산 실제 완료 시간 | 10~13분 | 5분 |
| 관련 가맹점 민원 | 월 N건 | 감소 |
정성적 개선
- 예측 가능한 시스템: 정산 생성 즉시 잔액 반영으로 타이밍 불확실성 제거
- 방어적 버퍼 제거: 사이클 간극 해소로 과도한 대기 시간 불필요
- 확장 가능성: 병렬 처리 구조로 트래픽 증가에 대응 가능
맺는글
이번 작업에서 배운 점을 정리하면 다음과 같습니다.
1. 문제를 정확히 정의하라
처음 민원을 받았을 때 “배치 성능 최적화”를 떠올릴 수도 있었습니다. 하지만 실제 문제는 사이클 간극으로 인한 예측 불가능한 타이밍이었고, 이를 방어적으로 대응한 결과가 “5분 정산인데 10분 걸림”이었습니다.
2. 비즈니스 관점에서 질문하라
“정산 데이터 생성과 잔액 증대가 왜 별도 사이클이어야 하는가?” 이 질문이 해결의 실마리였습니다. 운영팀과 대화하면서 기술적 이유가 아닌 역사적 이유(초기 설계 당시 그렇게 구현됨)임을 확인할 수 있었습니다.
3. 방어적 설정보다 근본적 해결을
기존 시스템은 사이클 간극이라는 기술적 불확실성을 “시간 버퍼”로 때우고 있었습니다. 하지만 이는 문제를 숨기는 것이지 해결하는 것이 아닙니다. 사이클 간극 자체를 없앰으로써 방어적 버퍼가 불필요해졌습니다.
4. 미래를 대비하되 현재 문제에 집중하라
대용량 트래픽 대비 설계는 필요했지만, 당장 Spring Batch를 도입할 필요는 없었습니다. 기존 Thread/Daemon 방식으로 충분히 해결 가능했고, 이것이 더 낮은 리스크의 선택이었습니다.
기술 선택에서 중요한 건 “최신 기술인가”가 아니라 “이 문제를 해결하는 데 적합한가”입니다.