포스트

ClassCastException 하나가 이중 송금을 만든 날

ClassCastException 하나가 이중 송금을 만든 날

들어가며

배포하고 한두 시간 지났을 때 장애 알림이 떴다. 로그를 까보니 ClassCastException. 이게 뭔가 싶었는데, 결과적으로 이중 송금까지 이어졌다. 그 과정을 정리해본다.


사건의 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
08:21 - 서버 배포 완료 (펌뱅킹 개발 건 포함)

09:04 - 가맹점 → 이체(송금) API 요청
         └─ 당사 처리 완료 → 성공 응답 반환

10:04 - 가맹점 → 이체결과 조회 API 요청
         └─ ClassCastException 발생
         └─ resultCd: "9999", status: "실패" 응답 반환

10:04 ~ 10:47
       - 가맹점, 조회 실패를 이체 실패로 오인
         └─ 동일 건 재이체 요청 → 이중 송금 발생

10:47 - 장애 인지 → 즉시 내부 공유 + 롤백 처리

실제 오류 로그

1
2
3
4
5
6
7
8
[TRNS_RSLT] 시스템 예외 발생
{
  "발생위치": ["TransferResultController.java:100 (getTransferResult)"],
  "type": "ClassCastException",
  "message": "com.example.firmbanking.transfer.dto.TransferResultRequest$FirmBanking
              cannot be cast to
              com.example.firmbanking.transfer.dto.TransferResultRequest"
}

가맹점이 받은 응답은 이랬다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "result": {
    "resultCd": "9999",
    "resultMsg": "처리실패"
  },
  "firmBanking": {
    "trackId": "FB20260410ABCD",
    "status": "실패",
    "trxId": "",
    "trxDate": "",
    "bankCd": "",
    "accountNo": "",
    "depositor": "",
    "amount": 0
  }
}

trxId가 공백이고 amount가 0이면, 가맹점 입장에서는 당연히 이체가 실패했다고 볼 수밖에 없다.


왜 이중 송금이 됐나

핵심은 이체 API와 조회 API가 별개라는 점이다.

상황의미
이체 API에서 에러이체 자체가 실패한 것
조회 API에서 에러조회 기능이 고장난 것. 이체 결과와는 무관

이체는 09:04에 이미 성공 응답을 받은 상태였다. 그런데 10:04에 조회 API가 ClassCastException으로 터지면서 "처리실패" 응답이 내려갔고, 가맹점은 이걸 이체 실패로 받아들였다.

1
2
3
4
이체 요청 -> 성공 (돈 나감)
이체 조회 -> 시스템 오류 (조회 기능 고장)
가맹점 판단 -> "이체 실패했나?" -> 재이체
결과 -> 이중 송금

근본 원인

1. ClassCastException

이번 펌뱅킹 개발 건에서 TransferResultRequest 안에 FirmBanking이라는 중첩 클래스를 새로 만들었다. 그런데 컨트롤러에서 상위 타입으로 캐스팅하는 코드를 안 바꿨다.

1
2
3
4
5
6
7
// 변경 전 DTO 구조
public class TransferResultRequest {
    private String trackId;
    private String trxId;
    private int amount;
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
// 변경 후 DTO 구조 (펌뱅킹 개발 건에서 중첩 클래스로 분리)
public class TransferResultRequest {

    @Getter
    @NoArgsConstructor
    public static class FirmBanking {
        private String trackId;
        private String trxId;
        private int amount;
        // ...
    }
}
1
2
3
4
5
6
// 문제 코드 - FirmBanking을 상위 타입으로 잘못 캐스팅
@PostMapping("/api/v1/transfer/result")
public ResponseEntity<?> getTransferResult(@RequestBody Object body) {
    TransferResultRequest request = (TransferResultRequest) body; // ClassCastException 발생
    // ...
}
1
2
3
4
5
6
7
// 수정 후 - 중첩 클래스 타입으로 직접 받도록 변경
@PostMapping("/api/v1/transfer/result")
public ResponseEntity<?> getTransferResult(
        @RequestBody TransferResultRequest.FirmBanking request) {
    log.info("이체결과 조회 요청 - trackId: {}", request.getTrackId());
    // ...
}

솔직히 배포 전 테스트에서 잡혔어야 할 버그였다.

2. 멱등성 처리 미적용

재이체 요청이 들어왔을 때 “이미 처리된 건”인지 판단하는 로직이 아예 없었다. 멱등성 키가 있었으면 두 번째 요청은 중복으로 걸러졌을 텐데.

3. 에러 응답 코드가 하나뿐

조회 API의 에러 코드가 9999 처리실패 딱 하나였다. “시스템 오류”인지 “이체 실패”인지 구분하는 코드 자체가 없으니 가맹점 입장에서는 구분할 방법이 없었다.


조치한 것들

  1. 장애 인지하자마자 바로 배포 소스 롤백해서 조회 API 정상화
  2. 내부 담당자한테 즉시 공유
  3. TransferResultController 캐스팅 오류 원인 분석
  4. 가맹점이랑 이중 송금 건 확인하고 환수 처리 방안 협의

재발 방지를 위해

1. ClassCastException 수정

Object로 받아서 강제 캐스팅하는 코드가 문제의 시작이었다. TransferResultRequest.FirmBanking 타입을 컨트롤러에서 직접 받도록 고쳤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 수정 전
@PostMapping("/api/v1/transfer/result")
public ResponseEntity<?> getTransferResult(@RequestBody Object body) {
    TransferResultRequest request = (TransferResultRequest) body; // ClassCastException
    // ...
}

// 수정 후
@PostMapping("/api/v1/transfer/result")
public ResponseEntity<?> getTransferResult(
        @RequestBody TransferResultRequest.FirmBanking request) {
    log.info("이체결과 조회 요청 - trackId: {}", request.getTrackId());
    // ...
}

2. 에러 응답 코드 세분화

1
2
3
4
5
6
7
8
9
10
public enum TransferResultCode {

    SUCCESS("0000", "처리성공"),
    TRANSFER_FAILED("0001", "이체실패"),     // 재이체 가능
    SYSTEM_ERROR("9999", "시스템오류"),      // 재이체 금지, 재조회 요청
    DUPLICATE_REQUEST("9001", "중복요청");   // 멱등성 키 중복

    private final String code;
    private final String message;
}
1
2
3
4
5
6
7
8
// 시스템 오류  응답 예시 (개선 후)
{
  "result": {
    "resultCd": "9999",
    "resultMsg": "시스템오류",
    "guideMsg": "재이체 요청을 중단하고 관리자에게 문의하세요."
  }
}

가맹점이 코드만 보고 “재이체 해도 되는 건지 아닌지” 판단할 수 있어야 한다.

돌이켜보면 에러 코드 세분화가 이체 요청이나 성명조회 쪽에는 되어 있었는데, 정작 이체결과 조회 쪽은 전혀 안 되어 있었다. 시스템 오류든 이체 실패든 전부 9999 처리실패 하나로 내려가고 있었고, 이게 가맹점이 잘못 판단하게 된 직접적인 원인이었다.


배운 것

결국 시스템 오류와 비즈니스 실패는 다른 에러 코드로 내려줘야 한다는 거다.

ClassCastException 하나가 터졌고, 조회 API가 9999 처리실패를 내려보냈고, 가맹점은 그걸 이체 실패로 읽고 재이체를 날렸다. 흐름 자체는 단순한데, 에러 코드가 “시스템 오류”와 “이체 실패”로 나뉘어 있었으면 가맹점은 재이체 대신 문의를 했을 거다. 타입 하나 잘못 쓴 게 이중 송금까지 간 이유가 여기에 있다.


이후 상황

가맹점 쪽에서 로그를 따로 공유해주진 않았는데, 다행히 잔액이 음수로 떨어지는 상황은 없었다고 한다. 큰 피해 없이 넘어간 건 다행이었다.

근데 솔직히 이번 건은 운이 좋았던 거라고 생각한다. 잔액 음수가 안 났다고 이중 송금이 없었다고 단정할 수 없고, 같은 구조로 장애가 또 나면 같은 결과를 보장할 수도 없다. 에러 코드 세분화랑 타입 수정은 꼭 해야 할 작업이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.