포스트

결제 모듈 리팩토링 — SharedMap을 전부 걷어냈다

결제 모듈 리팩토링 — SharedMap을 전부 걷어냈다

왜 이걸 하고 있는가

원래 이번 주말에는 LLM 로컬 구축 일기 #3을 쓸 예정이었다. 그런데 회사 결제 모듈 리팩토링이 머릿속에서 계속 걸렸다. LLM은 공부지만 이건 실무와 직결된 작업이다. 12월까지 기다리면 규모가 큰 만큼 시간이 부족해질 게 뻔하다.

그래서 우선순위를 바꿨다. LLM은 4월 11일이나 18일 주말에 이어서 할 예정이다.

우리 회사 결제 솔루션은 외부 업체에서 구매한 것이다.

그 업체가 제공한 lib을 기반으로 돌아가는데, 내부적으로 Map<String, Object>을 데이터 전달 수단으로 쓰고 있다. 그 위에 감싼 게 SharedMap이라는 유틸 클래스다.

문제는 프로젝트 전체가 이 lib에 종속되어 있다는 것이다. 비즈니스 로직부터 WebHook, VAN 통신, DB 저장까지 전부 SharedMap을 통해 데이터가 오간다. 구조를 바꾸려면 전체를 건드려야 한다.

회사에서는 12월부터 정식으로 리팩토링을 시작하라고 했다. 근데 그때 시작하면 늦는다. 규모가 크니까.

그래서 주말마다 틈틈이 미리 진행하고 있다. 테스트 프로젝트에서 구조를 잡아두고, 12월에 실제 코드에 적용할 때 삽질을 줄이려는 목적이다.


SharedMap이 뭐가 문제인가

SharedMapMap<String, Object>을 감싼 유틸 클래스다. 어디서든 아무 키로 값을 넣고 꺼낼 수 있다.

1
2
3
4
// SharedMap 사용 예시
dataMap.put("tmnId", "TMN00001");
dataMap.put("van", "VAN00001");
String tmnId = dataMap.getString("tmnId");

편리하지만 문제가 많다.

  • 오타를 컴파일러가 못 잡는다. "tmnId""tmndId"로 쓰면 null이 나오고, 런타임까지 가야 발견된다.
  • 어떤 키가 들어있는지 코드만 봐서는 모른다. 디버거를 찍거나 로그를 남기기 전까지는 내부 상태를 알 수 없다.
  • IDE 자동완성이 안 된다. 전부 문자열이니까.

결제 모듈에서 이런 코드가 수십 군데 있었다.


VO로 전환하면 뭐가 달라지나

같은 로직을 VO로 바꾸면 이렇게 된다.

1
2
3
4
5
6
7
8
9
// Before — Map 기반
dataMap.put("van", MapUtil.getString(vanMap, "van"));
dataMap.put("vanId", MapUtil.getString(vanMap, "vanId"));
String tmnId = dataMap.getString("tmnId");

// After — VO 기반
vanVO.getVan();
vanVO.getVanId();
String tmnId = webhookVO.getTmnId();

MapUtil.getString(map, "key")vo.getKey() map.put("key", value)vo.setKey(value)

패턴은 단순하다. 하지만 한 파일에 수십 군데씩 있으니 빠뜨리기 쉽다.


전환하면서 터지는 것들

VO로 전환하고 빌드를 돌리면 에러가 쏟아진다. 한 번에 100개 이상 나올 때도 있었다.

종류별로 정리하면 이렇다.

타입 불일치

1
incompatible types: Map<String,Object> cannot be converted to WebhookVO

Map을 받던 메서드가 VO를 받도록 시그니처가 바뀌면, 호출부가 전부 깨진다.

메서드 없음

1
cannot find symbol: method getString(String)

VO에는 getString()이 없다. getTmnId() 같은 getter를 써야 한다.

연쇄 반응

한 파일을 고치면 그 파일을 쓰는 다른 파일도 깨진다. 그 파일이 사용하는 Mapper의 반환 타입도 맞춰야 한다. 테스트 코드도 전부 수정해야 한다.

빌드 → 수정 → 빌드를 반복하면서 하나씩 잡아나갔다.


에러코드도 문자열이었다

Map을 정리하면서 또 하나 눈에 들어온 게 있었다.

1
2
3
4
// 이런 코드가 파일마다 반복되고 있었다
ResultUtil.getResult("0000", "정상", "정상승인");
ResultUtil.getResult("9999", "승인실패", vanMsg);
ResultUtil.getResult("9999", "시스템오류", "시스템오류");

에러코드도 문자열로 관리하고 있으니 Map과 같은 문제다. "0000""000"으로 쓰면? 컴파일러는 모른다.

그래서 enum으로 통합했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 에러코드 enum 예시
public enum ResultCode {
    PAY_SUCCESS         ("0000", "정상",     "정상승인"),
    PAY_FAIL            ("9999", "승인실패",  "승인실패"),
    CANCEL_FULL         ("0000", "정상",     "전액취소"),
    CANCEL_FAIL         ("9999", "취소실패",  "취소실패"),
    COMM_ERROR          ("9999", "통신오류",  "통신오류");

    private final String code;
    private final String msg;
    private final String advanceMsg;

    // toResult(), toResultWithVanCode() 등 팩토리 메서드 제공
}

사용은 이렇게 바뀐다.

1
2
3
4
5
6
7
8
// Before
response.result = ResultUtil.getResult("0000", "정상", "정상승인");

// After
response.result = ResultCode.PAY_SUCCESS.toResult();

// VAN 실패 시 — VAN사 응답코드를 그대로 담는다
response.result = ResultCode.PAY_FAIL.toResultWithVanCode("E001", "잔액부족");

IDE에서 자동완성이 되고, 오타를 컴파일러가 잡아준다.


부분취소 로직 개선

오늘의 가장 까다로운 작업이었다.

NumberFormatException

부분취소 실패 시 PG사 응답에 취소 회차 값이 빈 문자열로 온다. 그걸 그대로 Integer.parseInt("") 해버려서 터졌다.

1
java.lang.NumberFormatException: For input string: ""

실패 응답에는 취소 회차가 안 오는 게 정상이다. 빈 문자열 체크를 추가해서 해결했다.

잔금 기반 취소 구분 자동 판단

PG사 부분취소 전문에는 취소 구분 코드가 있다. 부분취소인지, 나머지 잔금 전체를 취소하는 건지를 구분해서 보내야 한다.

예를 들어 1,004원 결제 후 500원을 부분취소하면 잔금은 504원이다. 그 다음 504원을 취소하면 이건 “나머지 전체취소”로 보내야 한다.

이걸 수동으로 구분하면 실수가 생긴다. 잔금을 계산해서 자동으로 판단하도록 만들었다.

1
2
3
4
5
6
// 예시 — 잔금 기반 자동 판단
long refundedAmt = getSuccessRefundSum(rootTrxId);  // 이미 취소된 금액 합계
long balance = originalAmount + refundedAmt;          // 잔금 (refundedAmt는 음수)
boolean isRemainingAll = (cancelAmount >= balance);

String cancelType = isRemainingAll ? CANCEL_TYPE_REMAINING : CANCEL_TYPE_PARTIAL;
상황원거래이미 취소잔금이번 취소구분
첫 부분취소1,004원0원1,004원500원부분취소
잔금 전체1,004원500원504원504원나머지전체취소
두 번째 부분1,004원500원504원200원부분취소

취소순번 관리

부분취소마다 순번을 매겨야 하는데, 기존에는 전부 0으로 들어가고 있었다.

규칙을 정리하면 이렇다.

1
2
3
첫 번째 취소        → cancelNumber = 0
성공할 때마다        → +1 증가
실패건              → 최근 성공 순번 + 1 (재시도 시 같은 순번 사용)

성공건만 합산하는 쿼리를 만들어서 해결했다.

1
2
3
4
5
6
7
-- 예시 쿼리
SELECT IFNULL(SUM(refund_amount), 0) AS total_refunded,
       IFNULL(MAX(cancel_number), 0) AS last_cancel_number,
       COUNT(1) AS success_count
  FROM TRX_REFUND
 WHERE root_trx_id = #{trxId}
   AND result_code = '0000'

IntelliJ에서 삽질한 것

서버 실행 시 mybatis-config.xml을 못 찾는 에러가 나왔다.

1
MyBatis initialization failed: Could not find resource mybatis-config.xml

원인은 설정 파일이 있는 conf/ 폴더가 클래스패스에 포함되어 있지 않았기 때문이다.

해결은 간단했다.

1
2
파일 → 프로젝트 구조 → 모듈 → 소스 탭
conf 폴더 선택 → "리소스" 버튼 클릭 → 적용

이걸로 30분을 날렸다. 클래스패스 문제는 에러 메시지가 명확해서 원인을 알면 바로 풀리는데, 모르면 한참 헤맨다.


마무리

Map을 VO로 바꾸는 건 기술적으로 어려운 작업이 아니다. getString("key")getKey(), put("key", value)setKey(value). 패턴은 단순하다.

어려운 건 빠뜨리지 않는 것이다.

한 파일을 고치면 연쇄적으로 다른 파일이 깨진다. 그 연쇄를 끝까지 따라가는 인내가 필요한 작업이다.

회사에서 12월에 시작하라고 한 건 이유가 있다. 규모가 크니까. 근데 그래서 더 미리 해둬야 한다고 생각한다.

지금 테스트 프로젝트에서 구조를 잡아두면, 12월에 실제 코드에 적용할 때 “이 패턴은 이미 해봤으니까” 하고 넘어갈 수 있다.

1
2
리팩토링은 코드를 위한 게 아니다.
미래의 삽질을 줄이기 위한 예행연습이다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.