크리스마스에 터진 매입 장애, 아키텍처로 해결하다
빨리 실패하고, 격리하고, 추적 가능하게
여는 글
이번 문제는 지난 크리스마스에 생긴 일입니다. 헬스장에서 운동하다가 부장님 전화를 받고 회사에 뛰어 나갔는데요. ‘매입’이 이루어지지 않고 있다는 내용이었습니다.
일반적인 카드사라면 비영업일에는 매입을 하지 않기 때문에, 휴일에 ‘매입’이 되지 않는다고 회사로 출근한 상황이 이해 안되시겠지만 결제 대행사에서는 매입과 관련한 비즈니스가 조금은 다릅니다.
매입내역을 생성하는 모듈에서는 세가지 비즈니스가 합쳐져 있는데, [매입->FDS->결제 결과 통지]였습니다. 이렇다 보니 매입이 되지 않는 다는 건 가맹점에게 결제 결과를 통지가 나가지 않는다는 것이기 때문에, 가맹점으로부터 심각한 민원이 발생할 수 밖에 없었죠.
장애 상황과 원인
보여지는 문제는 병목 현상이었습니다. 매입대상 중 한 거래건이 매입 생성 과정에서 NumberFormatException이 발생했고 이 건으로 인해 모든 매입 생성 대상건들이 생성되지 못하고 병목 현상이 발생했던 것인데요.
문제의 근본적인 원인은 휴먼에러로, 잘못된 가맹점 설정이 원인이었습니다.
백오피스에서는 가맹점 정보를 엑셀 파일 업로드를 통해 일괄 등록할 수 있었는데, 이 과정에서 중요 정보에 오타가 발생했습니다. 잘못 입력된 값이 그대로 DB에 등록되었고, 매입 생성 시점에 해당 값을 BigDecimal로 변환하려다 NumberFormatException이 발생했던 것이었죠.
하지만 근본 원인이 휴먼에러라 해도, 그것을 전체 시스템 장애로 확대시킨 것은 아키텍처의 문제였습니다. 이 문제는 어쩌면 예전부터 발생이 예정되어 있던 문제였습니다.
문제 분석: 왜 단건 실패가 전체 장애로 이어졌는가
매입 모듈을 분석하면서 발견한 문제들을 정리했습니다.
세 가지 비즈니스의 결합
모듈의 이름도 역할도 ‘매입데이터 생성’이지만, 실제로는 세 개의 비즈니스가 하나의 프로세스에 묶여 있었습니다. 매입데이터를 생성하고, FDS 데이터를 생성하고, 모든 INSERT가 끝나고 나서야 결제 결과 통지 데이터가 생성됩니다.
각 비즈니스가 순차적으로 실행되어야 할 이유는 전혀 없었습니다. 매입데이터와 FDS는 모두 승인데이터를 바라보기 때문에, 매입 과정에서 FDS를 같이 생성할 필요가 없습니다. 결제 결과 통지 역시 ‘매입 결과 통지’가 아닌 ‘결제 결과 통지’이므로, 승인/승인취소 데이터를 기반으로 생성되어야 하는데 매입데이터를 가지고 생성된다는 것은 옳지 않은 설계였습니다.
이 결합 구조 때문에, 매입 생성에서 예외가 발생하면 FDS와 결제 결과 통지까지 전부 중단되는 상황이 발생했습니다.
장애 격리 부재
매입데이터 생성을 위해 대상 거래건들을 조회하고 for문을 수행하는데, 단건별로 INSERT하는 것이 아니라 단건별로 데이터를 가공한 후 한꺼번에 배치 INSERT를 수행하는 구조였습니다. 따라서, for문 내에서 한 건이라도 실패하면 전체가 실패하는 셈이 됩니다.
또한 매입데이터는 INSERT VALUES 구문으로 다건 INSERT 되지만, 지급대행 서비스와 강제취소 관련 로직은 단건별로 INSERT가 필수적인 구조였습니다. 다건 처리와 단건 처리가 하나의 트랜잭션에 혼재하면서, 트랜잭션 범위 설정에 어려움이 있었습니다.
트랜잭션 관리 부재
해당 모듈은 레거시 DAO 라이브러리를 사용 중이었기 때문에 트랜잭션 범위 설정이 불가능했고, 롤백 보장도 되지 않았습니다. 이 문제는 이전 포스트에서 다룬 것과 동일한 문제입니다.
데몬 실행 구조의 문제
기존 구조는 무한 루프 내에서 처리 후 2초를 대기하는 방식이었습니다.
// 기존 데몬 구조
while (true) {
process(); // 처리 시간이 얼마나 걸릴지 알 수 없음
Thread.sleep(2000);
}
배치 작업이 2초를 초과하면 주기가 불규칙해지고, 종료 시 진행 중인 작업이 중단될 위험이 있었습니다. 에러 발생 시에도 별도의 알람이나 에러 내역 관리 테이블이 없었기 때문에, 담당자가 우연히 발견하기 전까지 무한 실패가 반복되었습니다.
기존 아키텍처
flowchart TB
subgraph DAEMON["매입 모듈 (Daemon Thread, 2초 간격)"]
direction TB
A["매입 대상 조회"] --> B["for문 시작"]
B --> C["단건 데이터 가공"]
C --> D["FDS 데이터 생성"]
D --> E{"에러 발생?"}
E -->|Yes| F["❌ 전체 실패 — 병목 발생"]
E -->|No| G["다음 건 처리"]
G --> C
G --> H["배치 INSERT (매입 + FDS)"]
H --> I["결제 결과 통지 생성"]
end
F -.->|"알람 없음\n무한 반복 실패"| A
style F fill:#ff6b6b,color:#fff
해결 과정
백오피스 입력 검증 강화
근본 원인이 엑셀 업로드 시 잘못된 값이 그대로 등록된 것이었으므로, 가장 먼저 백오피스의 입력 검증 로직을 수정했습니다. 엑셀 파일 업로드 시 필수값 누락, 형식 오류, 범위 초과 등을 사전에 검증하여 잘못된 데이터가 DB에 저장되는 것 자체를 차단했습니다.
형식 검증으로는 숫자 필드에 문자가 포함되어 있는지, BigDecimal 변환이 가능한 값인지를 확인했습니다. 필수값 검증으로는 매입 생성에 필요한 필수 정보가 빈값인지를 확인했습니다. 검증 실패 시에는 어느 행의 어느 컬럼이 문제인지 명확하게 표시하는 에러 리포트를 제공합니다.
이렇게 하면 휴먼에러가 발생하더라도 업로드 시점에 즉시 피드백을 받을 수 있어, 잘못된 데이터가 시스템에 유입되는 것을 원천 차단할 수 있습니다.
하지만 이것만으로는 충분하지 않았습니다. 이미 등록된 레거시 데이터에도 문제가 있을 수 있고, 다른 경로로 데이터가 입력될 가능성도 있기 때문입니다. 따라서 매입 모듈 자체의 방어 로직도 함께 강화해야 했습니다.
비즈니스 분리: 매입 / FDS / 결제 결과 통지
세 개의 비즈니스를 매입 모듈로부터 분리하여 각각 독립적으로 실행하도록 변경했습니다.
flowchart LR
SRC["승인/승인취소\n데이터"]
subgraph ACQ["매입 모듈"]
A1["매입 대상 조회"] --> A2["Fail-Fast 검증"] --> A3["매입데이터 생성"]
end
subgraph FDS["FDS 모듈"]
F1["FDS 대상 조회"] --> F2["FDS 데이터 생성"]
end
subgraph NTF["결제 결과 통지 모듈"]
N1["통지 대상 조회"] --> N2["결제 결과 통지 데이터 생성"]
end
SRC --> ACQ
SRC --> FDS
SRC --> NTF
매입과 FDS는 동일한 승인데이터를 바라보므로 독립 실행이 가능하고, 결제 결과 통지는 승인/승인취소 데이터를 직접 참조하도록 변경하여 매입 완료에 대한 의존성을 제거했습니다. 이제 매입 모듈에서 장애가 발생해도 결제 결과 통지는 정상적으로 처리됩니다.
MyBatis 도입과 트랜잭션 관리
이전 포스트에서 다룬 레거시 DAO 라이브러리를 제거하고 MyBatis를 도입했습니다. 매입 모듈의 코드량이 많지 않아 마이그레이션 부담도 없었고, 프로젝트를 Spring으로 마이그레이션하여 @Transactional 어노테이션을 통한 트랜잭션 관리가 가능해졌습니다.
데몬 실행 구조 개선
기존의 무한 루프 + 고정 대기 방식에서, 처리 완료 후 대기 패턴으로 변경했습니다.
public class AcquisitionDaemon {
private volatile boolean running = true;
private volatile boolean processing = false;
public void start() {
while (running) {
try {
processing = true;
process();
} catch (Exception e) {
log.error("매입 처리 중 예상치 못한 에러", e);
} finally {
processing = false;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
public void shutdown() {
running = false;
// 처리 중인 작업이 완료될 때까지 대기
while (processing) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
// 리소스 정리: 커넥션 풀, Health Check 서버 등
cleanup();
}
}
processing 플래그를 통해 현재 처리 중인지 여부를 추적하여, Graceful Shutdown 시 진행 중인 작업이 완료될 때까지 안전하게 대기할 수 있도록 했습니다. SIGTERM 신호가 들어오면 현재 처리 중인 매입 건이 완료된 후 커넥션 풀, Health Check 서버까지 순차적으로 정리하고 종료됩니다.
Fail-Fast 검증
NPE가 발생할 수 있는 지점을 사전에 검증하여 빠르게 실패시키는 방식을 적용했습니다. 별도의 Validator 클래스를 두어 매입 생성 전에 가맹점 정보와 필수값들의 유효성을 검증합니다.
간혹 NPE 방지를 위해 디폴트값을 사용하는 경우가 있는데, 금융 시스템에서 이는 매우 위험합니다. 예를 들어 수수료율이 null이거나 빈값일 때 0으로 처리하면 에러가 아니기 때문에 아무도 눈치채지 못하고, 정산 시점에서야 알게 되는 금융 사고로 이어집니다. 차라리 검증 단계에서 명확히 실패시키고 담당자가 확인 후 조치할 수 있는 구조가 더 안전합니다.
검증 실패 시에는 모든 오류 사유를 수집하여 한 번에 예외로 던집니다. 이렇게 하면 “가맹점 정보 없음”, “수수료율 미설정” 등 여러 문제를 한 번에 파악할 수 있어 디버깅이 용이해집니다.
에러 격리와 재시도
매입 과정에서의 실패 건을 별도 테이블에 격리하여 재시도 및 추적이 가능하도록 했습니다.
@Service
public class AcquisitionService {
@Autowired private AcquisitionValidator validator;
@Autowired private AcquisitionErrorService errorService;
@Transactional(rollbackFor = Exception.class)
public void processOne(AcquisitionTarget target) {
validator.validate(target);
// 매입 데이터 생성 로직
acquisitionMapper.insert(target);
}
public void processAll(List<AcquisitionTarget> targets) {
for (AcquisitionTarget target : targets) {
try {
processOne(target);
// 매입 성공 시, 기존 실패 기록이 있으면 해결완료 처리
tryResolveError(target);
} catch (Exception e) {
// 에러 격리: 실패 건을 별도 테이블에 저장
tryIsolateError(target, e);
}
}
}
private void tryIsolateError(AcquisitionTarget target, Exception e) {
try {
errorService.isolate(target, e);
} catch (Exception errorEx) {
// 에러 격리 자체가 실패해도 다음 건 계속 처리
log.error("에러 격리 실패 [거래ID: {}]", target.getTxId(), errorEx);
}
}
private void tryResolveError(AcquisitionTarget target) {
try {
errorService.resolveIfExists(target.getTxId());
} catch (Exception resolveEx) {
// 정리 작업 실패해도 이미 성공한 매입은 유지
log.error("에러 해결처리 실패 [거래ID: {}]", target.getTxId(), resolveEx);
}
}
}
에러 격리 테이블에는 거래ID, 거래유형, 에러코드, 에러메시지, 거래 데이터 JSON, 재시도 횟수, 상태 등을 저장합니다. 에러 격리 INSERT는 메인 트랜잭션과 별도의 트랜잭션(REQUIRES_NEW)으로 처리하여, 매입 롤백과 무관하게 실패 기록이 반드시 남도록 했습니다.
재시도 횟수는 5회로 제한했습니다. 데몬이 2초 지연을 가지고 실행되므로 5회면 최소 10초 동안 재시도 기회가 주어집니다. 일반적인 DB나 네트워크 일시 장애는 수 초 내에 복구되기 때문에, 5회 이상 실패한다면 높은 확률로 데이터 자체의 문제입니다. 이 경우 상태를 FAILED로 변경하고 담당자에게 긴급 알림을 발송합니다.
코드에서 눈여겨볼 부분은 이중 try-catch 패턴입니다. processAll에서 매입 처리 예외를 catch한 후 에러 격리를 시도하는데, 이 에러 격리 자체가 실패할 수도 있습니다(DB 연결 실패, SMTP 오류 등). 에러 격리에 실패했다고 해서 전체 배치가 중단되어서는 안 되기 때문에, 로그만 남기고 다음 건을 계속 처리합니다. 같은 원리로 매입 성공 후 기존 실패 기록을 해결완료로 업데이트하는 작업도 별도의 try-catch로 감쌉니다. 이 정리 작업이 실패해도 이미 성공한 매입은 유지됩니다.
메일 알람
기존에는 텔레그램을 통해 모듈의 생사 여부(Health Check)만 알림이 왔고, 에러 발생에 대한 알람 기능은 부재했습니다. 이번 크리스마스 장애도 부장님이 우연히 발견하기 전까지 인지하지 못했던 이유입니다.
SMTP 서버를 활용한 메일 알람 기능을 추가했습니다. 에러 발생 즉시 담당자에게 메일을 발송하되, 에러 메시지, 스택 트레이스, 실패 건 정보를 포함합니다. 동일 에러가 반복 발생할 때는 중복 발송을 제한하여 알람 피로를 방지했습니다.
맺는 글
이번 크리스마스 장애는 단순한 NumberFormatException 하나가 전체 매입 처리를 마비시킨 사례였습니다. 근본적인 원인은 휴먼에러였지만, 이를 장애로 확대시킨 것은 아키텍처의 문제였습니다.
핵심 해결 포인트
| 문제 | 원인 | 해결 |
|---|---|---|
| 잘못된 데이터 유입 | 엑셀 업로드 시 검증 부재 | 백오피스 입력 검증 강화 |
| 단건 실패 → 전체 장애 | 장애 격리 없는 배치 구조 | 단건별 try-catch + 에러 격리 테이블 |
| 매입 장애 → 결제 통지 중단 | 세 가지 비즈니스가 하나의 프로세스에 결합 | 매입 / FDS / 결제 결과 통지 독립 분리 |
| 트랜잭션 관리 불가 | 레거시 DAO 라이브러리 | MyBatis 도입 + @Transactional |
| 장애 인지 불가 | 에러 알람 부재 | SMTP 메일 알람 + 에러 격리 테이블 추적 |
| Graceful Shutdown 불가 | 무한 루프 + 고정 대기 구조 | 처리 완료 후 대기 + processing 플래그 |
얻은 교훈
돌아보면 이번 개선의 핵심은 세 가지로 요약됩니다.
빨리 실패하기. 금융 시스템에서 디폴트값으로 에러를 숨기는 것은 사고의 시작입니다. Fail-Fast 검증으로 잘못된 데이터는 처리 전에 걸러내고, 명확한 에러 메시지로 원인을 즉시 파악할 수 있게 했습니다.
부분 실패를 격리하기. 단건의 실패가 전체를 멈추지 않도록, 실패 건은 별도 트랜잭션으로 격리 테이블에 기록하고 나머지는 계속 처리합니다. 에러 처리 자체가 실패하는 상황까지 대비한 이중 보호 패턴으로 어떤 상황에서도 배치가 중단되지 않도록 했습니다.
문제를 추적 가능하게 만들기. 에러가 발생하면 즉시 알림이 오고, 에러 격리 테이블에서 실패 이력과 재시도 횟수, 상태를 추적할 수 있습니다. 더 이상 “우연히 발견하기 전까지 아무도 모르는” 상황은 없습니다.