포스트

[업무일지] 외부 등록 요청을 못 막은 유니크 제약

유니크 키에 컬럼을 하나 더해 (user_id)(user_id, account_num) 으로 넓혔습니다. 조건이 까다로워진 게 아니라 느슨해진 겁니다. 한 달 뒤 같은 테이블에서 중복 가입이 났고, 우리 테이블 insert 가 막힐 때 대행사 등록 요청은 이미 나가 있었습니다.

정산금을 회수할 때 셀러 계좌에서 자동이체로 출금합니다. 그러려면 그 계좌를 자동이체 대행사에 회원으로 먼저 등록합니다. 등록 결과는 우리 DB 테이블 하나에 들고 있습니다. MariaDB 10.x 환경이고, 설명을 위해 테이블명은 가명으로 씁니다 — cms_members 입니다. 실제 구조와 이름만 다릅니다.

한 셀러당 한 계좌라는 전제가 유니크 키에 박혀 있었다

이 테이블은 원래 용도가 하나였습니다. 특정 은행 한 곳과 제휴한 한도증액 상품의 계좌만 담았고, 셀러가 그 상품을 하나만 쓰니까 한 셀러당 row 하나였습니다. 그래서 유니크 키가 UNIQUE (user_id) 였습니다.

그 시점에는 정확한 제약이었습니다. 스키마가 사실을 그대로 표현하고 있었던 거죠.

같은 테이블에 두 번째 용도가 들어왔다

대행사를 새로 붙이면서 일반 회수 계좌도 이 테이블로 관리하게 됐습니다. 용도가 둘로 갈리니 구분 컬럼이 필요했습니다.

1
2
3
4
5
6
-- MariaDB 10.x
-- 기존: 한도증액 상품 계좌만 관리
-- 변경: 기존 + 일반 자동이체 계좌
ALTER TABLE cms_members
    ADD COLUMN limit_increase_yn ENUM('Y', 'N') NULL DEFAULT 'N'
        COMMENT '한도증액 계좌 여부' AFTER status;

컬럼 추가라 조용히 지나갔습니다. 문제는 그다음이었죠.

두 번째 계좌를 유니크 키가 막았다

한 셀러가 한도증액 계좌와 일반 계좌를 둘 다 가질 수 있게 되는 순간 UNIQUE (user_id) 가 걸립니다. 두 번째 등록에서 duplicate key 로 터집니다. 전제는 깨졌는데 제약만 남은 상태였습니다.

1
2
3
4
5
6
-- 기존 회원은 특정 은행 한 곳뿐이었으나, 타행도 가능해지므로 유니크 조건 변경
-- (user_id) --> (user_id, account_num)
ALTER TABLE cms_members DROP INDEX un_user_id;
DROP INDEX idx_user_id_account_num ON cms_members;
ALTER TABLE cms_members
    ADD CONSTRAINT un_user_id_account_num UNIQUE (user_id, account_num);

가운데 줄을 같이 넣은 이유가 있습니다. 원래 (user_id, account_num) 일반 인덱스가 따로 있었는데, 새 유니크 키가 같은 컬럼을 같은 순서로 갖고 있으니 조회에도 그대로 쓰입니다. MySQL 과 MariaDB 는 유니크 제약을 인덱스로 구현하거든요. 둘을 남기면 같은 B-tree 를 두 벌 유지하면서 쓰기마다 갱신 비용이 두 번 나가고, 옵티마이저가 고를 후보만 늘어납니다.

컬럼을 늘렸는데 막아주는 범위는 줄었다

여기서 감각을 한 번 잘못 잡았습니다. 유니크 키에 컬럼을 하나 더한 건 조건이 까다로워진 것처럼 보입니다. 실제로는 반대죠.

UNIQUE (user_id) 는 이 셀러의 두 번째 row 를 전부 막았습니다. UNIQUE (user_id, account_num) 은 계좌번호가 다르면 통과시킵니다. 막던 범위가 줄어든 겁니다.

넓힌 건 맞는 판단이었습니다. 다만 유니크 키는 어디까지나 우리 테이블에 같은 row 가 두 번 들어오는 것만 막아주는데, 저는 그 범위가 줄어든다는 걸 알면서도 “원래 이 제약이 대신 막아주던 것 중에 이제 애플리케이션으로 넘어오는 게 뭐냐”를 같이 묻지 않았습니다.

제약은 흐름 맨 끝에 있고 외부 호출은 그 앞에 있다

한 달쯤 뒤에 재계약 건에서 중복 가입이 나왔습니다. 먼저 분명히 해두면 이건 유니크 키를 넓혀서 생긴 일이 아닙니다. 터진 건 같은 계좌를 다시 등록한 케이스라 (user_id) 였어도 (user_id, account_num) 이어도 DB 는 똑같이 막습니다. 두 사건을 인과로 이어 붙이면 틀립니다. 이어져 있는 건 제 쪽이에요 — 둘 다 “유니크 키가 막아준다”에 기대고 있었고, 앞에서는 그 범위를 잘못 봤고 여기서는 시점을 잘못 봤습니다.

법인 셀러가 재계약을 하면 같은 계좌를 그대로 쓰는데, 그 계좌는 이미 대행사에 등록돼 있습니다. 등록 흐름은 이렇습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java 21, Spring Boot 3.2.x
@Transactional
public void processCmsRegistration(User user, WithdrawAccount account, ContractType type) {
    String trscSeqNo = generateTrscSeqNo();
    // ... 동의자료 파일 변환

    // 대행사 API 호출 — 여기서 대행사 쪽 등록이 끝난다.
    // 응답을 받아 cms_members 에 insert 하는 것도 이 안이라, 유니크 키는 여기서 걸린다.
    registerWithdrawAccount(cmsInfo);

    if (BankInfo.needWithdrawalCheck(account.getBankCode())) {
        processDepositWithdrawal(cmsInfo);   // 일부 은행은 1원 입출금 확인이 필요하다
    }

    accountRepository.completeCmsRegistration(account.getContractId());
}

같은 계좌를 두 번 등록하면 우리 테이블 insert 는 유니크 키에 걸려 막힙니다. 그런데 그 시점에 대행사 API 호출은 이미 끝나 있습니다. 우리 트랜잭션은 롤백되지만 대행사 쪽 등록은 롤백되지 않고, 1원 입출금 확인까지 갔으면 셀러 계좌에서 돈이 실제로 한 번 움직인 뒤에 우리 쪽만 없던 일이 되니까 대행사 원장과 우리 테이블이 어긋난 채로 남습니다. 코드가 스스로 되돌리는 경로는 없습니다.

유니크 제약은 우리 테이블을 지키는 장치입니다. 외부 시스템에 나간 요청은 지켜주지 않습니다. 그리고 제약이 걸리는 시점은 흐름의 마지막이라, 걸릴 때는 그 앞의 부수효과가 이미 다 일어난 뒤입니다.

진입 지점에 가드를 세웠다

그래서 흐름 맨 앞에서 막았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (checkAlreadyRegistered(user, account, type)) {
    updateAccountInfo(user.getUserId(), account.getAccountNumber(), account.getOwnerName());
    return;
}

// ...

private boolean checkAlreadyRegistered(User user, WithdrawAccount account, ContractType type) {
    // 재계약이면서 법인인 경우에만 이미 가입된 계좌가 존재할 수 있다
    if (ContractType.RECONTRACT != type || user.isPrivate()) {
        return false;
    }
    return cmsMemberRepository.existsByUserIdAndAccountNumAndStatus(
            user.getUserId(), account.getAccountNumber(), "Y");
}

조건을 좁게 잡은 게 여기서 한 판단입니다. 모든 등록 경로에서 조회를 한 번씩 더 하는 대신, 이미 등록된 계좌가 실제로 나올 수 있는 조합만 봤습니다. 재계약이고 법인일 때고요. 신규 계약은 계좌가 처음 들어오는 것이라 해당이 없고, 개인 셀러는 재계약 경로에서 기존 등록이 남아 있는 케이스가 나오지 않았습니다.

넓게 잡는 쪽이 안전해 보이지만 조건 없는 exists 조회는 등록 요청마다 한 번씩 늘어나고, 무엇보다 넓은 가드는 왜 있는지 나중에 아무도 모릅니다. 좁은 가드에는 주석 한 줄로 이유를 적을 수 있고요.

이미 등록돼 있으면 대행사 호출을 건너뛰고 우리 쪽 계좌 상태만 갱신하고 끝냅니다. 예외를 던지지 않았습니다. 셀러 입장에서 재계약은 정상 진행돼야 하고, 대행사에 이미 등록돼 있다는 건 실패가 아니라 이미 원하는 상태거든요.

맺음말

유니크 제약이 뭘 막아주는지를 저는 이제 범위와 시점 둘로 나눠서 봅니다. 범위는 우리 테이블까지고, 시점은 흐름의 마지막입니다. 그 밖과 그 앞에 부수효과가 있으면 제약은 아무것도 못 합니다.

포기한 것. 대행사 등록 호출을 트랜잭션 밖으로 빼는 게 원래 맞는 방향입니다. 외부 호출이 트랜잭션 안에 있으면 DB 커넥션을 대행사 응답 시간만큼 붙잡고 있고 롤백해도 외부 상태는 안 돌아오니까, 호출을 경계 밖으로 내보내고 실패했을 때 되돌리는 처리를 따로 붙이는 게 정석인데 이 경로 하나만 고치면 되는 게 아니라 등록·해지·변경이 다 같은 모양이라 범위가 커졌습니다. 그건 손대지 않았습니다.

남은 한계도 둘입니다. 가드가 재계약과 법인이라는 조합에만 있어서, 다른 경로에서 같은 상황이 생기면 또 걸립니다. 그리고 이미 등록된 계좌인지를 우리 테이블에서만 확인합니다. 대행사 쪽에는 있고 우리 쪽에는 없는 경우는 못 잡고요. 대행사 조회 API 를 붙이는 건 미뤘습니다.

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