포스트

지급대행 시스템을 처음부터 혼자 만들면서 배운 것들

지급대행 시스템을 처음부터 혼자 만들면서 배운 것들

시작하게 된 이유

회사에 지급대행 시스템이 필요하다는 얘기가 나왔다. 기존에 없던 시스템이었고 누가 처음부터 만들어야 하는 상황이었는데, 그게 나한테 떨어졌다.

기획서 같은 것도 없었고 참고할 기존 코드도 없었다. 그나마 잡혀있던 건 “가맹점이 송금 요청을 보내면 우리 쪽에서 VAN사 API를 호출해 실제 이체를 처리한다” 정도의 큰 그림뿐이었다.


시스템 구조

가맹점과 VAN사 사이에서 중계하는 역할이다.

1
가맹점 → 지급대행 서버 → VAN사 API → 이체 처리

API는 크게 네 개다.

1
2
3
4
송금 API          → VAN사에 실제 이체 요청
실명조회 API      → 수취 계좌 실명 확인
잔액조회 API      → 지급 계좌 잔액 확인
이체결과조회 API  → 우리 DB에서 거래 결과 조회

이체결과조회는 VAN사를 호출하지 않는다. 그냥 우리 DB에 있는 거래 내역을 읽어서 돌려주는 정도의 단순 조회다.


설계하면서 결정한 것들

VAN사는 늘어날 수 있다

처음 연동하게 된 VAN사는 두 곳이었다. 한 곳은 자체 API를 열어두는 방식이었고, 다른 한 곳은 펌뱅킹 방식이었다. 호출 방식도 요청/응답 구조도 달랐다.

나중에 VAN사가 붙을 때마다 기존 코드를 계속 건드려야 하는 구조는 아무래도 위험해 보였다. 그래서 Strategy 패턴으로 가기로 했다.

1
2
3
4
public interface VanTransferStrategy {
    VanType getVanType();
    VanTransferCallResult buildCallAndMap(...);
}

VAN사마다 이 인터페이스를 구현하고 @Component로 등록해두면 VanStrategyResolver가 VAN 타입 보고 알맞은 전략을 골라준다.

덕분에 새 VAN사가 붙어도 기존 코드는 크게 손댈 일이 없었다.

공통 흐름과 VAN 전용 로직을 분리했다

송금 처리에는 VAN사마다 다른 부분과, 어느 VAN사든 공통인 부분이 있다.

1
2
공통: 로그 저장 → 거래내역 저장 → 수수료 처리 → 응답 생성
VAN 전용: 요청 DTO 생성 → API 호출 → 응답 파싱

공통 흐름은 TransferOrchestrator라는 클래스로 따로 빼서 처리하도록 했다. Controller는 요청 받고 응답 내려주는 정도만, VAN 호출은 Strategy가 하고, 그 사이 흐름은 Orchestrator가 엮는 식이다.

이렇게 해두니 나중에 VAN사를 추가할 때도 공통 흐름 쪽은 거의 건드릴 일이 없었다.

VAN 자격증명은 DB에서 관리한다

가맹점마다 연결된 VAN사가 다르고 VAN사 접속 정보도 제각각이다. 이걸 application.yml 같은 환경파일에 몰아넣으면 VAN사가 늘어날 때마다 파일이 계속 복잡해진다.

그래서 환경파일에는 DB 접속 정보나 서버 포트 같은 시스템 단위 설정만 두고, VAN사 자격증명은 DB에 가맹점별로 저장해서 관리하기로 했다.

HTTP 상태코드는 무조건 200

인증 헤더 검증에서 실패하면 401을 내려준다. 그 외에는 성공이든 실패든 전부 200으로 통일했다.

어차피 가맹점 쪽도 응답 바디의 결과 코드를 보고 처리해야 한다. 그럼 HTTP 상태코드로 또 다른 분기를 만들어 둘 이유가 없다고 봤다.

1
2
0000 → 성공
9999 → 실패

거래 상태는 enum으로 관리한다.

1
2
3
4
5
6
7
8
9
public enum TranStatus {
    REGISTERED("10", "등록"),
    WAITING   ("20", "대기"),
    REQUESTING("30", "요청중"),
    SUCCESS   ("00", "완료"),
    TIMEOUT   ("70", "타임아웃"),
    HOLD      ("80", "보류"),
    FAIL      ("99", "실패");
}

타임아웃은 우리가 직접 내려준다

VAN사마다 자체 타임아웃 기준이 있긴 한데, 그걸 그대로 따라가면 가맹점 입장에서 언제까지 기다려야 할지 예측이 안 된다.

그래서 우리 쪽 기준 시간을 따로 두고, 그 시간을 넘기면 VAN사 응답이 늦게 오든 말든 타임아웃 코드를 먼저 내려주도록 했다.

로그 추적은 requestId로

멀티스레드 환경에서 요청이 여러 개 동시에 들어오면 로그가 섞여서 특정 요청만 따라가기 어려워진다. 그래서 인터셉터 단에서 요청마다 requestId를 하나씩 발급해서 MDC에 넣어뒀다.

1
MDC.put("requestId", requestInfo.getRequestId());

덕분에 스레드가 아무리 섞여도 이 ID로 필터링하면 한 요청 흐름을 따라갈 수 있다.


이중 출금이 터졌다

서비스 오픈 후 50분쯤 지났을 때 이중 출금이 발생했다.

이 시스템은 전자지갑 형태로, 가맹점마다 잔액이 따로 관리된다. 가맹점이 송금 요청을 보내면 잔액에서 먼저 차감하고 그 다음에 VAN사로 이체를 태우는 방식이다.

한번 성공 응답이 나간 요청은 거기서 끝나야 정상인데, 이게 또 한번 실행되는 상황이 생겼다. 흐름을 복기해보면 이랬다.

1
2
3
4
5
6
7
8
1. 가맹점 → 송금 API 요청
2. 우리 서버 → VAN사 이체 처리 완료, 가맹점에 성공 응답 반환
3. 가맹점 → 이체결과조회 API 요청 (원거래번호 + 요청일자)
4. 우리 서버 → 가맹점 인증 검증 통과 → DB 조회 중 Cast 에러 발생
5. 우리 서버 → "처리실패" 응답 반환
6. 가맹점 → 송금 자체가 실패한 것으로 판단
7. 가맹점 → 송금 API 재요청
8. 이중 출금 발생

원인은 이체결과조회에서 Map → VO 변환할 때 생긴 Cast 에러였다.

진짜 문제는 에러 응답 쪽이었다. 당시엔 “이체가 실패했다”랑 “조회 중 시스템 오류가 났다”가 같은 응답 코드로 내려가고 있어서, 가맹점 쪽에서는 구분할 방법이 없었다.

이 일 있고 나서 시스템 내부 오류용 코드를 따로 만들어서 비즈니스 실패랑 시스템 오류를 응답만 보고도 구분할 수 있게 바꿨다.

이체결과조회는 VAN사를 호출하지도 않고, 거래 데이터가 DB에 따로 저장되는 것도 아니다. 그냥 조회만 하는 API라서 테스트 범위에서 빠져있었다. 지금 다시 보면 당연히 넣었어야 했는데, 그땐 그게 안 보였다.

롤백 배포 후 가맹점에 상황 알리고 수동 처리로 마무리했다.


마무리

혼자 처음부터 만들다 보니 자연스럽게 “왜 이렇게 했는지” 설명할 수 있는 결정들이 쌓였다. Strategy 패턴, HTTP 200으로 통일한 응답, DB에서 관리하는 VAN 자격증명, 직접 내려주는 타임아웃 같은 것들. 당시에 제일 많이 고민했던 부분들이라 지금도 누가 물어보면 답은 어떻게든 나온다.

대신 덜 고민한 부분은 이중 출금으로 한번 호되게 맞았다. 단순 조회라고 테스트에서 뺀 게 결국 사고로 돌아왔다.

참고할 게 하나도 없는 상태에서 시작한다는 건 솔직히 좀 막막했다. 그래도 하나하나 내가 결정했다는 점은 부담은 됐지만 돌아보니 꽤 괜찮은 경험이었다.

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