donghakim.dev — zsh
← ls ../blog

트랜잭션 안의 느린 외부 호출이 중복검사를 무력화할 때

커밋되지 않은 INSERT 는 다음 요청의 SELECT 에 보이지 않습니다

backend · cover image

트래픽이 몰린 날 오후, 소셜 로그인 신규 가입에서 한 사용자에게 회원 계정이 4개 생겼습니다. 부작용이 하나 더 있었습니다. 같은 소셜 ID 가 여러 계정에 연동되자, 단일 결과를 기대하는 로그인 조회가 TooManyResultsException 을 던지며 로그인 자체가 실패했습니다. 중복으로 가입된 사용자는 로그인도 못 하는 상태가 된 겁니다.

가입 코드에는 중복검사가 분명히 있었습니다.

@Transactional
public AuthUser join(JoinFormRQ rq) {
    DuplicateUser dup = mapper.selectDuplicateLoginId(rq.getLoginId());
    if (dup != null) {
        throw new BaseException(DUPLICATE_USER);
    }
    // ... INSERT (회원/상세/소셜연동/약관) ...
}

동시성 race 가 아니었습니다

처음에는 더블클릭 같은 동시 요청을 의심했습니다. 그런데 로그를 보니 같은 payload 의 제출 간격이 30~40초였습니다. 수백 ms 의 race 가 아니라, 사람이 응답을 기다리다 포기하고 다시 누른 간격입니다.

계정을 만든 스레드 하나를 추적해 보면 이런 타임라인이 나옵니다.

00.0s  selectDuplicateLoginId → 0건   (33초 전에 만들어진 직전 계정이 안 보임)
00.2s  채번 + INSERT 5건 완료
00.2s  포인트 적립 API 동기 호출 (외부 서비스)
58.3s  ← 500 응답 (외부 서비스가 장애 상태)
58.5s  예외 catch, 가입은 계속 진행 → 그제서야 커밋

가입 트랜잭션이 @Transactional 인데, 그 안에서 포인트 적립을 동기 호출하고 있었습니다. 하필 그날 포인트 서비스가 커넥션 풀 고갈로 응답을 58초 만에 500 으로 돌려줬고, 예외는 catch 되어 가입은 성공 처리됐지만 트랜잭션은 외부 호출이 끝날 때까지 열린 채 커밋되지 않았습니다.

여기서 중복검사가 무너집니다. selectDuplicateLoginId 는 평범한 SELECT 라 커밋된 행만 봅니다. 직전 요청의 INSERT 는 이미 실행됐지만 아직 커밋 전이니, 후속 요청의 중복검사에는 0건입니다. 트랜잭션이 80초 열려 있는 동안 사용자가 몇 번을 다시 제출하든 전부 통과합니다. 30~40초 간격의 순차 재제출이 뚫린 이유입니다.

정리하면 층이 네 겹이었습니다.

  1. 트리거 — 외부 API 의 느린 실패(~58초)가 가입 트랜잭션을 ~80초 동안 열어 둠
  2. 중복검사 무력화 — SELECT 는 커밋된 행만 보므로, 커밋 전 구간에서는 몇 번이든 통과
  3. 멱등성 없음 — 응답이 안 오니 사용자는 재제출하고, 요청마다 새 회원번호가 채번됨
  4. 최후 방어선 부재 — 관련 컬럼에 DB UNIQUE 제약이 없음

DB UNIQUE 를 걸면 되지 않나

4번을 보고 바로 UNIQUE 를 걸고 싶어지는데, 걸기 전에 운영 데이터를 조사해 봤습니다. 결론부터 말하면 걸 수 없었습니다.

  • 탈퇴가 soft delete 인데 소셜 연동 행을 해제하지 않아서, 탈퇴 후 같은 소셜로 재가입하면 정상 흐름에서 중복 행이 생깁니다. 이 패턴이 이미 수십 건 쌓여 있었습니다.
  • 비회원(게스트) 예약과 회원 가입이 같은 이메일을 공유하는 것도 정상 흐름입니다.
  • 전화번호 기준으로 동일인 다계정을 세어 보니 158건이 이미 존재했고, 이번 장애 이전부터 상시로 생기고 있었습니다.

이 상태에서 UNIQUE 를 추가하면 인덱스 생성이 실패하거나, 성공하더라도 정상 재가입이 막힙니다. 데이터가 이미 오염된 뒤에는 제약이라는 선택지가 사라져 있었습니다. 기존 중복은 고객센터 인입 건마다 개별 정리하기로 하고, 코드에서 새 중복의 생성을 막는 쪽으로 방향을 잡았습니다.

픽스 — 외부 호출을 커밋 이후로

핵심은 트랜잭션이 DB write 만 담고 즉시 커밋되게 만드는 것입니다. 포인트 적립과 가입 메일은 실패해도 가입 자체는 유효한 best-effort 성 호출이라, Spring 의 TransactionSynchronization 으로 커밋 이후로 옮겼습니다.

// DB write 들을 마친 뒤, 트랜잭션 안에서 등록만 해 둡니다
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        afterJoinCommit(user, isHashCode, cstmrNo);  // 포인트 적립 + 가입 메일
    }
});
return user;  // 이 직후 커밋 → afterCommit 은 커밋이 성공했을 때만 실행

트랜잭션이 0.2초에 커밋되니, 재제출 시점에는 직전 계정이 이미 커밋되어 있어 중복검사에 걸립니다. 외부 서비스가 아무리 느려도 가입 트랜잭션과는 무관해집니다.

부수 효과도 하나 있었습니다. 기존 코드는 커밋 전에 포인트를 적립했기 때문에, 이후 단계에서 롤백되면 계정은 없는데 포인트만 적립되는 불일치가 가능했습니다. 커밋 성공 후에만 적립하니 이 경로도 함께 닫혔습니다.

옮길 때의 판단 기준은 이렇게 정리했습니다.

외부 호출 성격위치
실패 시 롤백돼야 하는 필수 로직트랜잭션 안 유지 (타임아웃은 짧게)
실패해도 본 처리가 유효한 best-effortafterCommit 으로 분리
외부 과금이 먼저 일어나는 구조분리로는 부족, 보상 로직까지 설계 재검토

남은 것

  • afterCommit 도 요청 스레드에서 동기로 돕니다. 커넥션과 트랜잭션은 즉시 풀리지만, 외부가 느리면 HTTP 응답 지연 자체는 남습니다. 응답까지 빨라야 하면 @Async 를 얹어야 합니다.
  • 진짜 동시(수백 ms) 이중클릭은 트랜잭션이 짧아져도 원리상 뚫릴 수 있습니다. 실측된 재제출 간격이 수십 초라 이번 조치로 충분했지만, 필요해지면 가입 식별자 단위의 락을 검토하기로 했습니다.

진단 시그니처

  • 중복검사가 있는데 중복 데이터가 생긴다 + 생성 간격이 수십 초 → 동시성보다 긴 트랜잭션 을 먼저 의심
  • @Transactional 메서드 안의 외부 HTTP 호출 → 외부 지연 시간만큼 커밋이 밀리고, 그 구간의 SELECT 중복검사는 전부 무력화
  • SELECT-then-INSERT 방식의 유일성 보장은 트랜잭션이 짧을 때만 그럭저럭 동작하는 것이지 보장이 아님 — 보장은 UNIQUE 제약인데, 그건 데이터가 깨끗할 때만 걸 수 있음
#aftercommit#connection-pool#duplicate#mysql#signup#spring#transaction#troubleshooting