[업무일지] 호출 순서에 기대던 유니크 키와 재계약 계좌 중복 가드
유니크 키에 컬럼을 하나 더해
(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) 은 계좌번호가 다르면 통과시킵니다. 막던 범위가 줄어든 겁니다.
재계약은 같은 계좌를 처음부터 다시 등록하려 했다
한 달쯤 뒤 법인 셀러의 재계약 등록이 에러로 끝나는 버그를 고쳤습니다. 먼저 분명히 해두면 이건 유니크 키를 넓혀서 생긴 문제가 아닙니다. 같은 계좌를 다시 넣는 케이스라 (user_id) 였어도 (user_id, account_num) 이어도 DB 는 똑같이 막습니다.
법인 셀러가 재계약을 하면 같은 계좌를 그대로 씁니다. 그 계좌는 이미 대행사에도 우리 테이블에도 등록돼 있는데, 등록 흐름은 그걸 모르고 처음부터 다시 가입시키려 했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java 17, Spring Boot 3.2.x
@Transactional
public void processCmsRegistration(User user, WithdrawAccount account, ContractType type) {
// 회원 행을 먼저 저장한다. ID 가 IDENTITY 라 save() 시점에 INSERT 가 바로 나가고,
// 같은 (user_id, account_num) 이 이미 있으면 여기서 유니크 키에 걸린다.
CmsMember member = cmsMemberRepository.save(CmsMember.pending(user, account));
// ... 동의자료 파일 변환
registerWithdrawAccount(member); // 대행사 등록 API 호출
if (BankInfo.needWithdrawalCheck(account.getBankCode())) {
processDepositWithdrawal(member); // 일부 은행은 1원 입출금 확인이 필요하다
}
accountRepository.completeCmsRegistration(account.getContractId());
}
같은 계좌를 다시 등록하면 첫 저장이 유니크 키에 걸리고, 트랜잭션이 롤백되면서 재계약 등록이 에러로 끝납니다. 대행사 호출은 그 뒤라 외부로는 아무것도 나가지 않습니다.
그런데 이 계좌는 이미 원하는 상태입니다. 에러는 이미 된 일을 모르고 다시 하려다 나는 거죠.
진입 지점에 가드를 세웠다
그래서 흐름 맨 앞에서 알아보게 했습니다.
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 조회는 등록 요청마다 한 번씩 늘어나고, 무엇보다 넓은 가드는 왜 있는지 나중에 아무도 모릅니다. 좁은 가드에는 주석 한 줄로 이유를 적을 수 있고요.
이미 등록돼 있으면 대행사 호출을 건너뛰고 우리 쪽 계좌 정보만 갱신하고 끝냅니다. 예외를 던지지 않았습니다. 셀러 입장에서 재계약은 정상 진행돼야 하고, 대행사에 이미 등록돼 있으면 더 할 일이 없거든요.
외부 호출은 여전히 트랜잭션 안에 있다
외부로 중복 등록이 나가지 않은 건 회원 행 INSERT 가 대행사 호출보다 앞서 나가서입니다. ID 전략이 IDENTITY 라 save() 가 곧바로 INSERT 를 보내는 덕인데, 누가 저장을 호출 뒤로 옮기거나 ID 전략을 바꿔 INSERT 가 커밋 시점 flush 로 밀리면, 그때는 대행사 등록이 끝난 뒤에야 키가 걸립니다. 이 보호는 코드 순서에 기대고 있습니다.
대행사 등록 호출을 트랜잭션 밖으로 빼는 게 원래 맞는 방향이라고 봅니다. 트랜잭션 안에 있으면 DB 커넥션을 대행사 응답 시간만큼 붙잡고, 롤백해도 외부 상태는 안 돌아오니까요. 그런데 등록·해지·변경이 다 같은 모양이라 이 경로만 고칠 수 없었고, 손대지 않았습니다.
가드가 재계약과 법인이라는 조합에만 있다는 한계도 남았습니다. 다른 경로에서 같은 상황이 생기면 또 걸립니다. 그리고 이미 등록된 계좌인지를 우리 테이블에서만 확인하니, 대행사 쪽에는 있고 우리 쪽에는 없는 경우는 못 잡습니다. 대행사 조회 API 를 붙이는 건 미뤘습니다.