포스트

[업무일지] 외부 입금 데이터를 우리 도메인에 앉히며 생긴 빈 자리

회수 리스크가 큰 셀러는 정산금을 우리가 발급한 가상계좌로 직접 받게 만들었습니다. 입금 스캔 배치와 회수 처리까지 붙였고요. 그런데 은행 입금 내역에는 어느 쇼핑몰 정산금인지가 없습니다. 돈은 쇼핑몰별 지갑으로 관리하고 있었는데요. 지갑 키가 (셀러, 쇼핑몰) 둘인데 셀러만 확정되고 쇼핑몰이 빕니다. 그 자리에 매직 넘버를 넣었고요. 그 값을 알아야 하는 코드가 네 곳으로 늘었습니다.

셀러에게 쇼핑몰 정산금을 미리 주고, 쇼핑몰 정산일에 회수하는 서비스입니다. 회수가 안 되면 그대로 손실이고요.

회수는 원래 셀러 계좌에서 자동이체로 빼옵니다. 그런데 셀러가 정산금을 받고 나서 계좌를 비워두면 손을 쓸 방법이 마땅치 않아서, 회수 리스크가 큰 셀러에게는 우리가 발급한 가상계좌를 쇼핑몰 정산 수취계좌로 등록하게 하고 정산금이 셀러를 거치지 않고 우리 계좌로 바로 들어오게 만들었습니다. 회수가 확실해지거든요. 이 상태는 어드민에서 켜고 끕니다.

Java 21, Spring Boot 3.2.x, MariaDB 10.x 환경이고 레거시 회수 배치는 Node.js 입니다. 설명을 위해 테이블·컬럼·상수명은 전부 가명으로 씁니다. 특히 대행사 규격은 우리 것이 아니라서 실제 이름을 옮기지 않고 형태만 남겼습니다.

대행사 스키마를 우리가 만들고, 거기에 우리 컬럼을 얹었다

가상계좌는 발급 대행사가 만들고 관리합니다. 그런데 연동 방식이 API 가 아니었습니다. 대행사 규격대로 DB 를 만들어두면 대행사가 거기에 입금 내역을 넣어주는 방식이었어요.

그래서 우리가 대행사 스키마를 생성했습니다. 컬럼 이름도 규격 그대로입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- MariaDB 10.x
-- 대행사 규격이라 그대로 옮기지 않는다. 형태만 남기고 이름은 전부 바꿔 적는다.
CREATE TABLE `ext`.`DEPOSIT_LOG` (
    `INST_CODE` CHAR(8)  NOT NULL,          -- 기관 코드
    `TRAN_DATE` CHAR(8)  NOT NULL,          -- 거래일자. DATE 가 아니라 8자 문자열
    `TRAN_TIME` CHAR(6)  NOT NULL,          -- 거래시각. 역시 문자열
    `ACCT_NO`   CHAR(16) DEFAULT NULL,      -- 입금된 가상계좌 번호
    `SENDER_NM` CHAR(20) DEFAULT NULL,      -- 입금자명
    `AMOUNT`    DECIMAL(13, 0) DEFAULT '0',
    `TRAN_SEQ`  CHAR(7)  NOT NULL,
    PRIMARY KEY (`INST_CODE`, `TRAN_DATE`, `TRAN_SEQ`)
);

-- 회수 처리 상태를 남기려고 우리가 따로 붙인 컬럼
ALTER TABLE `ext`.`DEPOSIT_LOG`
    ADD COLUMN `PROCESSED_YN` CHAR(1) DEFAULT 'N';

마지막 ALTER 가 판단이 들어간 지점입니다. 대행사 규격에는 “우리가 이 입금을 처리했는지”를 담을 자리가 없습니다. 스캔 배치는 주기적으로 도는데 이미 처리한 건을 또 집어오면 안 되니까, 그 표시가 어딘가에는 있어야 했습니다. 우리 쪽에 처리 이력 테이블을 따로 두고 조인하는 방법이 있었는데 대행사 테이블에 컬럼을 하나 붙이는 쪽을 골랐습니다. 대행사는 이 테이블에 넣기만 하고 우리는 그 컬럼만 고치니까 쓰는 주체가 겹치지 않고, 미처리 건을 조인 없이 인덱스로 바로 뽑을 수 있어서 스캔 배치가 단순해지거든요.

대가는 명확합니다. 우리 상태가 남의 규격 안에 살고 있습니다. 대행사가 이 테이블을 재생성하거나 규격을 바꾸면 PROCESSED_YN 은 조용히 사라지고, 그러면 처리한 입금을 다시 처리합니다.

셀러는 알 수 있는데 쇼핑몰을 알 수 없었다

여기가 이 작업에서 제일 오래 붙잡은 지점입니다.

이 서비스의 돈은 쇼핑몰별 지갑으로 관리합니다. 셀러 하나가 오픈마켓 A 지갑, 소셜커머스 B 지갑을 따로 갖고, 정산금이 들어오면 그 쇼핑몰 지갑에 쌓이고 회수도 그 지갑에서 빠집니다. 지갑을 찾는 키가 (셀러, 쇼핑몰) 두 개인 거죠.

셀러는 바로 나옵니다. 입금된 계좌번호로 우리 쪽 동의계좌를 찾으면 그게 어느 셀러 것인지 확정되거든요.

1
2
3
4
5
6
7
8
// Java 21, Spring Boot 3.2.x
String accountNumber = deposit.getAcctNo();           // 입금된 가상계좌 번호

AgreementAccount account = agreementAccountRepository
        .findByAccountNumberAndIssuedYn(accountNumber, "Y")
        .orElseThrow(() -> new BizException("발급 계좌를 찾을 수 없습니다."));

Long userId = account.getUserId();                    // 여기까지는 추정이 없다

문제는 나머지 절반입니다. 가상계좌는 셀러 단위로 하나 발급되는데 그 셀러는 쇼핑몰을 여러 개 씁니다. 입금 레코드에는 이 돈이 어느 쇼핑몰 정산금인지가 없고요. 입금자명 필드가 있긴 한데 쇼핑몰이 보내는 이름이 제각각이라 그걸로 채널을 특정할 수가 없습니다.

지갑 키 두 개 중 하나는 확정되고 하나는 비어 있는 상태입니다. 그런데 지갑 조회는 두 개를 다 요구합니다.

1
2
3
4
5
6
7
8
private static final Long EXT_WALLET_CHANNEL_ID = 99999L;   // 어느 쇼핑몰도 아닌 지갑

Wallet wallet = walletRepository
        .findByUserIdAndChannelId(userId, EXT_WALLET_CHANNEL_ID)
        .orElseGet(() -> walletRepository.save(Wallet.ofNew(userId, EXT_WALLET_CHANNEL_ID)));

walletHistoryRepository.save(WalletHistory.ofNew(wallet.getId(), EXT_VA_B, IN, depositAmount));
wallet.addRemainAmount(depositAmount);

빈 자리에 “쇼핑몰 없음”을 뜻하는 값을 넣었습니다. 스키마를 바꾸지 않고 기존 지갑 구조를 그대로 쓰면서 모른다는 걸 표현한 겁니다.

경계값 하나가 여러 곳으로 퍼졌다

이 선택의 값을 며칠 뒤에 치렀습니다. 기존 코드에서 “정상 경로”를 판별하던 조건이 전부 흔들리거든요.

레거시 회수 배치는 채널 아이디가 0 보다 크면 일반 쇼핑몰로 봤는데, 0 은 먼저 붙어 있던 다른 대행사의 가상계좌였습니다. 예외가 이미 하나 있었던 거죠. 제가 두 번째를 추가한 겁니다.

1
2
3
// 레거시 회수 배치 (Node.js)
- if (walInfo.CHANNEL_ID > 0) {
+ if (walInfo.CHANNEL_ID > 0 && walInfo.CHANNEL_ID < 99999) {

같은 모양의 가드가 두 군데, 값을 직접 비교해 분기하는 곳이 두 군데 더 생겼습니다. 경계값 하나를 아는 코드가 네 곳이 된 겁니다. 그중 하나가 회수한 돈을 어느 기관으로 출금 처리할지 정하는 부분인데, 삼항 중첩이던 게 분기 다섯 개로 자라서 if-else 로 풀어야 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
let withdrawAgency;
if (walInfo.CHANNEL_ID === 0) {
    withdrawAgency = EXT_VA_A;            // 먼저 붙어 있던 대행사 가상계좌
} else if (walInfo.CHANNEL_ID === 99999) {
    withdrawAgency = EXT_VA_B;            // 이번에 붙인 대행사 가상계좌
} else if (target.IS_AGENCY_B === 'Y' && target.LIMIT_INCREASE_YN === 'Y') {
    withdrawAgency = PARTNER_BANK;        // 제휴 은행 한도증액 회원
} else if (target.IS_AGENCY_B === 'Y') {
    withdrawAgency = AGENCY_B;
} else {
    withdrawAgency = AGENCY_A;
}

경계값을 상수로 뽑아도 이 분기가 사라지지는 않습니다. 지갑 모델에 “쇼핑몰 없음”이라는 경우가 생겼다는 사실 자체가 분기의 원인이니까요. 제대로 고치려면 지갑에 종류 컬럼을 두고 채널 아이디로 종류를 판별하는 걸 그만둬야 하는데, 그건 레거시 배치와 조회 쿼리를 같이 손대는 작업이라 범위 밖으로 뒀습니다.

지금 남은 형태가 안 좋은 건 압니다. 99999 가 자바 상수와 JS 리터럴로 두 언어에 나뉘어 있고, 둘을 묶어주는 건 아무것도 없습니다.

입금 스캔은 5분 전까지만 본다

입금을 가져오는 배치는 이렇게 생겼습니다.

1
2
3
4
5
6
7
// 입금 취소가 뒤늦게 들어올 수 있어 5분 전까지만 조회한다
LocalDateTime searchDateTime = LocalDateTime.now().minusMinutes(5);
String tranDate = searchDateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String tranTime = searchDateTime.format(DateTimeFormatter.ofPattern("HHmmss"));

List<ExtDeposit> deposits = depositRepository
        .findAllByProcessedYnAndTranDateAndTranTimeLessThanOrderByTranTime("N", tranDate, tranTime);

5분을 일부러 늦게 봅니다. 입금 직후에 취소가 들어오는 경우가 있어서 방금 들어온 건을 바로 회수 처리하면 취소된 돈으로 상환한 셈이 되고, 그 상환은 이미 선정산 잔액을 줄여놨으니 되돌리는 일이 따로 생기거든요. 회수를 5분 늦추는 쪽을 골랐습니다.

처리는 건별 이벤트로 넘깁니다. 발행하는 쪽이 이미 미처리 건만 골라 넘기지만 소비하는 쪽에서 처리 여부 컬럼을 한 번 더 확인하는데, 이벤트는 중복 소비될 수 있고 그때 지갑 잔액이 두 번 늘면 셀러 상환액이 실제 입금과 어긋나기 때문입니다. 멱등을 이벤트 전달에 기대지 않고 행 상태로 보장한 겁니다. 건별 발행에 try-catch 를 둔 것도 같은 이유고요 — 한 건이 실패해도 나머지는 처리됩니다.

맺음말

이 작업에서 실제로 어려웠던 건 연동도 배치도 아니었습니다. 기존 도메인 모델이 조용히 깔고 있던 전제를 찾는 것이었어요. “모든 돈에는 출처 쇼핑몰이 있다”는 문서에도 주석에도 없었고, 새 유입 경로를 붙이기 시작하니까 그 전제를 조용히 참조하고 있던 코드들이 조건문 하나씩으로 드러났습니다.

포기한 건 하나입니다. 지갑에 종류 컬럼을 두고 채널 아이디로 종류를 판별하는 걸 걷어내는 리팩터링을 안 했습니다. 그렇게 하면 매직 넘버도 분기도 같이 사라지는데, 레거시 배치와 조회 쿼리를 같이 열어야 해서 이번 범위에서는 과하다고 봤습니다.

남은 한계는 셋입니다. 경계값 99999 가 두 언어에 흩어져 있고 공유되지 않습니다. 출금 기관 분기가 다섯 개라 대행사가 하나 더 붙으면 여섯 개가 됩니다. 그리고 우리 처리 상태를 담은 컬럼이 대행사 규격 안에 있어서, 대행사가 테이블을 다시 만들면 처리 이력이 사라집니다. 셋 중에 먼저 아플 건 세 번째라고 보고 있습니다.

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