VARCHAR(255)로 등록하고 VARCHAR(100)에 넣으려던 날
들어가며
송금 60건이 VAN사를 통해 정상 처리됐는데, DB에는 기록이 하나도 없었다. 돈은 나갔는데 우리 시스템에는 흔적이 없는 상황. 원인을 추적해보니 노티 URL 컬럼 길이 문제였다.
사건의 흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 운영팀 → 가맹점 노티 URL 등록 (VARCHAR(255) 테이블에 저장)
2. 가맹점 → 송금 API 60건 요청
3. 서버 → A사 VAN API 호출 → 60건 전부 이체 성공
4. 서버 → DB 저장 시작 (@Transactional 내부)
├─ 로그 테이블 INSERT ← 성공
├─ 거래내역 테이블 INSERT ← 성공
├─ 메인계좌 테이블 INSERT ← 성공
└─ 노티 테이블 INSERT ← 실패 (URL 컬럼 길이 초과)
5. @Transactional 롤백 → 위 INSERT 전부 취소
6. 결과: VAN에서는 60건 이체 완료, DB에는 기록 0건
왜 터졌나
컬럼 길이 불일치
가맹점 노티 URL을 등록하는 테이블과 실제 노티를 보낼 때 사용하는 테이블의 컬럼 길이가 달랐다.
| 구분 | 컬럼 길이 |
|---|---|
| 웹훅 등록 테이블 | VARCHAR(255) |
| 노티 발송 테이블 | VARCHAR(100) |
등록은 255자까지 되는데, 실제로 쓰는 쪽은 100자까지만 받는 구조였다.
결제와 송금의 차이
결제 쪽은 노티를 다중으로 보낼 수 있게 되어 있다. URL이 길어도 여러 건으로 나뉘어 들어가니 문제가 안 된다. 송금 쪽은 다중 노티를 지원하지 않는다. URL 하나가 통째로 들어가야 한다.
운영팀이 결제 쪽 기준으로 URL을 등록했다. 결제에서는 잘 동작했으니 문제를 인지할 수가 없었다. 그런데 송금 쪽에서 같은 URL을 그대로 가져다 노티 테이블에 넣으려니 100자를 초과해서 터진 거다.
@Transactional이 전부 날려버렸다
송금 처리 흐름이 하나의 @Transactional로 묶여 있었다.
1
2
3
4
5
6
7
8
9
@Transactional
public ResponseEntity<?> processTransfer(...) {
// 1. VAN API 호출 (외부 시스템 - 트랜잭션 밖)
// 2. 로그 INSERT
// 3. 거래내역 INSERT
// 4. 메인계좌 INSERT
// 5. 노티 INSERT ← 여기서 에러
// 6. 응답 반환
}
VAN API 호출은 외부 시스템이라 트랜잭션과 무관하다. 이미 돈은 나간 상태다. 그 다음 DB INSERT들이 순서대로 실행되다가, 마지막 노티 INSERT에서 컬럼 길이 에러가 터졌다. @Transactional이니까 당연히 전체 롤백. 로그, 거래내역, 메인계좌 전부 날아갔다.
60건 모두 같은 가맹점, 같은 URL이었으니 60건 전부 같은 이유로 롤백됐다.
롤백이 이중 송금으로 이어졌다
@Transactional 롤백은 DB만 되돌린다. VAN사에서 이미 처리된 이체는 되돌릴 수 없다. 문제는 롤백되면서 가맹점에 송금 실패 응답이 내려갔다는 거다.
가맹점 입장에서는 실패 응답을 받았으니 당연히 재요청을 보냈다. 실제로는 돈이 나간 건데 실패라고 했으니, 같은 건을 다시 보내면 이중 송금이 된다.
1
2
3
4
5
6
1. 가맹점 → 송금 요청
2. VAN 이체 성공 (돈 나감)
3. 노티 INSERT 실패 → @Transactional 롤백
4. 가맹점 ← "송금 실패" 응답
5. 가맹점 → 동일 건 재요청
6. VAN 이체 또 성공 → 이중 송금
60건 중 상당수가 이렇게 이중 송금됐다. 가맹점이 잘못한 게 아니다. 실패라고 응답했으니 재요청하는 게 정상적인 흐름이다. 시스템이 거짓 응답을 내려보낸 셈이다.
복구
돈은 이미 나갔고 DB 기록만 없는 상태다. VAN사 응답 로그는 남아 있었으니, 거기서 데이터를 추출해서 DB에 다시 넣어야 했다.
왜 기존 코드로 안 되나
기존 송금 흐름을 그대로 타면 노티 INSERT가 또 실행된다. 같은 에러가 또 터질 수 있다. 그리고 기존 코드는 VAN API를 호출하는 로직까지 포함하고 있다. 이미 이체된 건을 또 호출하면 이중 송금이 된다.
복구 전용 로직이 필요했다.
복구 전용 매퍼 분리
기존 매퍼를 건드리지 않고 recovery 전용 매퍼와 XML을 따로 만들었다.
1
2
3
4
5
6
7
8
9
recovery/
├── controller/RecoveryController.java
├── service/RecoveryTransferService.java
├── dto/RecoveryTransferRequest.java
└── mapper/
├── RecoveryLogMapper.java
├── RecoveryTrMapper.java
├── RecoveryMainAccTrMapper.java
└── RecoveryMerchantMapper.java
1
2
3
4
5
resources/mapper/recovery/
├── RecoveryLogMapper.xml
├── RecoveryTrMapper.xml
├── RecoveryMainAccTrMapper.xml
└── RecoveryMerchantMapper.xml
핵심: 노티 INSERT 제거
복구 매퍼에서는 노티 INSERT를 아예 뺐다. 에러의 원인이었던 부분을 복구 흐름에서 제거한 거다.
노티 레코드는 별도 데몬 서비스가 거래내역 테이블을 폴링해서 자동 생성한다. 거래내역 레코드만 복원해두면 노티는 알아서 따라온다.
복구 건 식별
모든 복구 레코드에 regId="BATCH"를 넣었다. 정상 거래와 복구 거래를 구분할 수 있어야 하니까.
1
2
3
regId = "BATCH"
regDay = 복구 실행일
regDate = 복구 실행 시각
복구 데이터 소스
VAN사 응답이 로그에 남아 있었다. 거기서 원거래번호, 금액, 계좌정보, VAN 거래번호 등을 추출해서 JSON으로 정리한 뒤 복구 API에 넣었다.
시퀀스는 원본 값을 그대로 사용했다. 새로 채번하면 기존 흐름과 매칭이 안 되니까.
재발 방지
1. 등록 시점에 길이 검증
URL을 등록할 때 실제 사용처의 컬럼 길이(100)를 기준으로 검증해야 한다. 등록 테이블이 255까지 된다고 255자를 다 허용하면 안 된다.
2. 노티 테이블 컬럼 길이 확장
근본적으로는 노티 테이블의 URL 컬럼을 등록 테이블과 맞추는 게 맞다. 등록할 수 있는 값이 저장은 안 되는 구조 자체가 문제다.
3. 노티 생성 모듈 분리
노티 INSERT가 송금 트랜잭션 안에 있었던 게 근본 원인이다. 노티 에러 하나 때문에 핵심 거래 기록이 전부 날아가는 구조는 위험하다. 노티 데이터 생성은 별도 모듈로 이관해서 송금 트랜잭션과 완전히 분리했다.
배운 것
등록은 되는데 사용은 안 되는 구조가 제일 위험하다. 입력 시점에서 에러가 나면 바로 잡을 수 있는데, 한참 뒤 런타임에서 터지면 이미 되돌릴 수 없는 상태가 되어 있다.
이번 건이 딱 그랬다. URL 등록할 때는 아무 문제 없었고, 결제 쪽에서도 잘 동작했다. 운영팀이 잘못한 게 아니라 시스템이 잘못된 값을 허용한 거다. 검증은 데이터가 들어오는 시점에, 실제로 쓰이는 기준으로 해야 한다.