포스트

[업무일지] 출처 쇼핑몰이 없는 입금을 지갑에 넣으며 퍼진 매직 넘버

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

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

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

Java 17, Spring Boot 3.3.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
-- 대행사 규격이라 그대로 옮기지 않는다. 이름은 전부 바꾸고 컬럼 길이는 생략했다(실제 DDL 아님).
CREATE TABLE `ext`.`DEPOSIT_LOG` (
    `INST_CODE` CHAR  NOT NULL,             -- 기관 코드
    `TRAN_DATE` CHAR  NOT NULL,             -- 거래일자. DATE 가 아니라 문자열
    `TRAN_TIME` CHAR  NOT NULL,             -- 거래시각. 역시 문자열
    `ACCT_NO`   CHAR  DEFAULT NULL,         -- 입금된 가상계좌 번호
    `SENDER_NM` CHAR  DEFAULT NULL,         -- 입금자명
    `AMOUNT`    DECIMAL DEFAULT '0',
    `TRAN_SEQ`  CHAR  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 17, Spring Boot 3.3.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 를 둔 건 한 건이 실패해도 나머지를 처리하려는 것이고요.

지갑 모델이 깔고 있던 전제

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

본문에 적은 한계 중 먼저 아플 건 대행사 테이블 안의 처리 컬럼이라고 봅니다.

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