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 제약 부재: 관련 컬럼에 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