dev notes

유니크 제약 걸고 재조회하면 되잖아 — 같은 트랜잭션 안에서는 안 됩니다

2026-07-2333 min read
공유

테스트가 다 통과하던 코드#

IdP 로그인 연동을 붙이면서 JIT 프로비저닝을 하나 만들었습니다. 외부 인증 서버에서 토큰을 받아오면, 그 클레임에 대응하는 로컬 사용자 row가 우리 DB에 있는지 보고 없으면 그 자리에서 만들어 주는 흔한 구조입니다. 사용자 입장에선 최초 로그인 한 번에 계정이 자동으로 생기는 것처럼 보이고요.

여기서 신경 쓰이는 지점은 딱 하나, 첫 로그인이 거의 동시에 두 번 들어오는 경우였습니다. 같은 사람이 같은 순간에 두 요청을 흘리면 둘 다 "이 사용자 없네" 하고 판단한 뒤 둘 다 INSERT를 시도할 수 있습니다. 그래서 테이블에 (tenant, external_member_id) 유니크 제약을 걸어 두고, 서비스 코드는 이렇게 짰습니다. 하나가 제약에 걸려 터지면, 그건 다른 요청이 먼저 만들었다는 뜻이니 다시 조회해서 그 row를 돌려주면 된다는 발상이었죠.

java
@Service
@RequiredArgsConstructor
public class AuthUserService {
 
    private final LocalUserRepository localUserRepository;
 
    @Transactional
    public UserResponse resolveOrProvision(AuthPrincipal principal) {
        Instant now = Instant.now();
        LocalUser user = localUserRepository
                .findByTenantAndExternalMemberId(principal.tenant(), principal.externalMemberId())
                .map(existing -> {
                    existing.touch(now);
                    return existing;
                })
                .orElseGet(() -> provision(principal, now));
        return toResponse(user, principal);
    }
 
    private LocalUser provision(AuthPrincipal principal, Instant now) {
        try {
            return localUserRepository.saveAndFlush(
                    new LocalUser(principal.externalMemberId(), principal.accountId(), principal.tenant(), now));
        } catch (DataIntegrityViolationException raceLost) {
            return localUserRepository
                    .findByTenantAndExternalMemberId(principal.tenant(), principal.externalMemberId())
                    .orElseThrow(() -> raceLost);
        }
    }
 
    // toResponse(...) 생략
}

saveAndFlush로 즉시 flush를 때려서 유니크 위반을 그 자리에서 받고, DataIntegrityViolationException을 잡아 재조회한다. 경합에서 진 쪽(raceLost)은 이긴 쪽이 넣어 둔 row를 읽어 정상 응답으로 돌아간다. 논리적으로는 빈틈이 없어 보였습니다.

Testcontainers로 실제 MariaDB를 띄운 통합 테스트까지 붙였고, 백엔드 테스트 22건이 전부 초록이었습니다. 최초 프로비저닝, 재로그인 시 중복 생성 안 함, 같은 member_id라도 테넌트가 다르면 별도 생성. 이 정도면 커버가 됐다고 봤습니다.

문제는 저 테스트들이 전부 단일 스레드였다는 겁니다. 정작 catch 블록은 한 번도 밟히지 않은 채로요.

E2E에서 튀어나온 500#

기능이 얼추 완성되고 나서 dev에 떠 있는 실제 IdP를 그대로 물려 브라우저 E2E를 돌렸습니다. 라우트 가드에서 시작해 PKCE authorize, 호스티드 로그인, 콜백, 토큰 교환, JWKS 검증, 테넌트 검증을 거쳐 마지막에 /me를 그려 주는 전체 플로우를 사람이 로그인하듯 한 번 태워 본 겁니다.

/me 응답이 500으로 떨어졌습니다.

백엔드 로그를 ERROR로 긁어 보니 세 줄이 붙어서 나왔는데, 첫 줄까지는 예상한 그림이었습니다.

SQL Error: 1062, SQLState: 23000
(conn=15) Duplicate entry 'tenant-a-7161' for key 'uk_app_user_tenant_member'

SQL 1062, SQLState 23000. 유니크 제약 위반. 'tenant-a-7161'은 테넌트 tenant-a에 member 7161을 넣으려다 이미 있어서 걸린 값이고, uk_app_user_tenant_member는 내가 직접 건 그 제약입니다. 여기까지는 설계한 대로예요. 이 예외를 catch가 받아서 재조회로 흘러가야 정상입니다.

그런데 바로 다음 줄이 이상했습니다.

HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate,
but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure:
null id in com.example.auth.entity.LocalUser entry
(don't flush the Session after an exception occurs)

그 아래는 dispatcherServlet이 이 AssertionFailure를 그대로 못 삼키고 요청 처리를 실패시키면서 500으로 올려보낸 자국이었습니다. 스택 최상단은 이랬고요.

org.hibernate.AssertionFailure: null id in com.example.auth.entity.LocalUser entry
    (don't flush the Session after an exception occurs)
  at org.hibernate.event.internal.DefaultFlushEntityEventListener.checkId(...:86)
  at org.hibernate.event.internal.DefaultFlushEntityEventListener.getValues(...:182)
  at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(...:141)
  ...
  at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(...:138)

DataIntegrityViolationException은 잡혔습니다. 유니크 위반도 예상한 대로였고요. 그런데 그걸 잡아서 하려던 재조회가, 조회 결과가 없어서가 아니라 Hibernate가 던진 어설션 에러로 깨졌습니다. 재조회 쿼리는 그냥 SELECT 한 방인데 왜 여기서 null id가 튀어나오는 건지, 처음엔 도무지 연결이 안 됐습니다.

괄호 안에 답이 있었다#

붙잡고 볼 단서가 로그 두 줄뿐이었습니다. 메시지 앞머리 HHH000099은 Hibernate가 "이건 우리 버그일 수도 있지만 십중팔구 세션을 잘못 쓴 거다"라고 미리 깔아 두는 문구라 그 자체로는 정보가 별로 없습니다. 진짜는 맨 끝 괄호였습니다.

don't flush the Session after an exception occurs

예외가 난 뒤에는 세션을 flush하지 말라. 이 한 줄을 곧이곧대로 읽으면, 지금 어디선가 예외가 난 세션을 다시 flush하고 있다는 뜻이 됩니다. 그런데 내 catch 블록은 flush를 부른 적이 없어요. findByTenantAndExternalMemberId, 그냥 조회 메서드 하나를 호출했을 뿐입니다.

여기서 Hibernate의 기본 flush 모드가 걸립니다. AUTO 모드에서는 쿼리를 실행하기 직전, 그 쿼리 결과에 영향을 줄 수 있는 변경분이 세션에 떠 있으면 자동으로 flush를 먼저 돌립니다. 즉 내가 명시적으로 flush를 안 불러도, 재조회 SELECT를 날리는 순간 Hibernate가 "이 SELECT 전에 밀어 둘 게 있나" 하고 auto-flush를 시도합니다.

그럼 밀어 둘 게 뭐가 있었나. 바로 조금 전 saveAndFlush로 INSERT하려다 유니크 위반으로 튕긴 그 LocalUser 엔티티입니다. 이 엔티티는 여전히 persistence context 안에 관리 대상으로 남아 있습니다. INSERT가 DB에서 거부됐으니 IDENTITY 전략으로 채번됐어야 할 id는 여전히 null인 채로요.

스택 최상단 DefaultFlushEntityEventListener.checkId가 정확히 그 지점입니다. auto-flush가 이 엔티티를 다시 flush 대상으로 훑다가, 이미 저장을 시도했던 엔티티인데 id가 null인 모순 상태를 발견하고 null id in ... entry로 어설션을 터뜨린 겁니다.

그래서 순서를 되짚어 보면 이렇게 이어집니다. 유니크 위반으로 flush가 실패했고 → 실패한 엔티티는 id가 null인 채 세션에 남아 있고 → 그 세션에서 재조회를 하니 auto-flush가 그 엔티티를 다시 건드렸고 → checkId가 모순을 잡아 AssertionFailure. 괄호 속 경고문 그대로였습니다. 예외가 난 세션을, 재조회라는 이름으로 다시 flush하고 있었던 거죠.

그럼 재조회를 아예 못 하나#

원인은 잡혔는데, 그렇다면 애초에 같은 트랜잭션 안에서 재조회로 경합을 복구한다는 발상 자체가 성립하는지가 다음 물음이었습니다.

두 겹의 벽이 있었습니다. 하나는 방금 본 persistence context 오염입니다. 유니크 위반 예외를 던진 순간 세션에는 저 반쯤 죽은 엔티티가 남고, 그 세션으로는 안전하게 추가 쿼리를 못 날립니다. auto-flush가 언제든 그 엔티티를 다시 건드릴 수 있으니까요.

다른 하나는 스프링 트랜잭션 쪽입니다. @Transactional 안에서 예외가 밖으로 새어 나가지 않더라도, 그 예외가 프레임워크 경계를 스치는 순간 현재 트랜잭션에는 rollback-only 마크가 찍힐 수 있습니다. 한번 rollback-only가 되면 그 트랜잭션은 어떤 방식으로도 커밋될 수 없고, 끝에 가서는 UnexpectedRollbackException으로 마무리됩니다. 설령 세션 오염을 요령껏 피해서 재조회에 성공한다 해도, 결국 그 결과를 담은 트랜잭션 자체가 커밋을 못 하는 겁니다.

그러니까 같은 트랜잭션 안에서의 복구는 두 군데서 막혀 있었습니다. 세션은 더럽혀졌고, 트랜잭션은 이미 죽을 예약이 걸려 있고요. 재조회를 어떻게 예쁘게 짜느냐의 문제가 아니라, 재조회를 어느 트랜잭션에서 하느냐가 문제였습니다.

괄호 속 문장은 코드에 딱 한 곳뿐이었다#

두 번째 벽인 rollback-only는 스프링을 좀 만져 봤으면 익숙한 이야기라, 나를 계속 붙든 쪽은 첫 번째 벽이었습니다. 재조회 SELECT 한 방이 정말로 죽은 엔티티를 다시 flush한다는 게 말로는 그려지는데, 그림만으로는 찜찜해서 Hibernate 소스를 열어 봤습니다. 로컬 Gradle 캐시에 받아져 있던 6.6.18.Final sources jar 기준입니다.

먼저 로그의 그 괄호 문장을 코드베이스 전체에서 grep했더니, 딱 한 군데에서만 걸렸습니다. DefaultFlushEntityEventListener#checkId입니다.

java
// DefaultFlushEntityEventListener.java (6.6.18.Final), L85~L87
if ( id == null ) {
    throw new AssertionFailure( "null id in " + persister.getEntityName()
            + " entry (don't flush the Session after an exception occurs)" );
}

메서드 위에 붙은 주석은 "make sure user didn't mangle the id"입니다. flush 시점에 엔티티 id가 훼손되지 않았는지 검증하는 게 원래 목적인데, 여기서 id는 엔티티 객체의 필드가 아니라 persistence context가 그 엔티티에 대해 들고 있는 EntityEntry의 id예요. 그게 null이면 "저장을 시도했는데 id가 안 박힌 엔트리가 flush까지 흘러왔다"는 비정상 신호라 어설션을 던집니다.

그럼 재조회 SELECT가 어떻게 이 flush를 부르는가. auto-flush 경로를 따라 내려가 보면, 쿼리 실행 직전 SessionImpl#autoFlushIfRequired에 이런 가드가 있습니다.

java
// SessionImpl.java, autoFlushIfRequired (요지)
if ( !isTransactionInProgress() ) {
    // do not auto-flush while outside a transaction
    return false;
}

트랜잭션 밖이면 auto-flush를 아예 안 합니다. "같은 트랜잭션 안에서 재조회할 때만 터진다"던 감이 정확히 이 한 줄에 근거를 두고 있던 거죠. 그리고 flush 사이클이 세션의 엔티티들을 순회할 때 건너뛰는 상태는 LOADINGGONE 둘뿐입니다.

java
// AbstractFlushingEventListener.java, flushEntities (요지)
if ( status != Status.LOADING && status != Status.GONE ) {
    // ... onFlushEntity → getValues → checkId
}

문제의 엔티티는 이 두 상태 어디에도 안 걸립니다. 왜 안 걸리는지가 마지막 조각이었는데, AbstractSaveEventListener#performSaveOrReplicate가 그 답이었습니다. IDENTITY 전략은 id를 DB가 채번하니 persist 시점엔 id가 없고, Hibernate는 INSERT를 실행하기 전에 엔트리를 먼저 등록합니다.

java
// performSaveOrReplicate (요지)
final EntityEntry original = persistenceContext.addEntry(
        entity,
        Status.SAVING,   // 상태는 SAVING
        null, null,
        id,              // IDENTITY라 아직 null
        ...);

그리고 트랜잭션 안에서는 이 identity insert가 큐에 쌓이지 않고 그 자리에서 즉시 실행됩니다. 즉시 실행되는 그 INSERT가 유니크 제약에 걸려 예외를 던지면, 던지고 끝입니다. 방금 넣은 SAVING + id=null 엔트리를 도로 빼주는 catch나 보상 로직이 그 경로 어디에도 없어요. 예외는 변환돼 위로 재던져질 뿐, persistence context는 손대지 않은 채 남습니다.

이 지점이 나한테는 이 사건의 진짜 클라이맥스였습니다. 죽은 엔티티가 세션에 남는 건 Hibernate의 버그가 아니라, INSERT가 실패했을 때 그 엔트리를 되돌리는 보상이 애초에 설계에 없기 때문입니다. Hibernate의 계약은 "예외가 난 세션은 그냥 버려라"이고, checkId의 괄호 문장은 그 계약을 어긴 사람에게 보내는 경고였던 셈이죠. 내 catch 블록은 정확히 그 계약을 어기고 있었고요.

그러니 재조회를 좀 더 예쁘게 고쳐 쓸 여지 같은 건 없었습니다. 저 엔트리가 남아 있는 한, 같은 세션에서의 어떤 SELECT도 지뢰였습니다.

답은 자연스럽게 나왔습니다. 오염된 세션과 rollback-only 트랜잭션에서 빠져나와, 완전히 새로운 트랜잭션(곧 새 persistence context)에서 재조회를 하면 됩니다.

새 트랜잭션으로 재조회를 떼어내기#

수정은 resolveOrProvision에서 @Transactional을 걷어내고, 프로비저닝과 복구 재조회를 각각 독립된 트랜잭션으로 쪼개는 방향으로 갔습니다. 스프링에서 코드로 트랜잭션 경계를 긋는 도구가 TransactionTemplate이라, 이걸 주입해서 썼습니다.

java
@Service
@RequiredArgsConstructor
public class AuthUserService {
 
    private final LocalUserRepository localUserRepository;
    private final TransactionTemplate transactionTemplate;
 
    public UserResponse resolveOrProvision(AuthPrincipal principal) {
        Instant now = Instant.now();
        try {
            return transactionTemplate.execute(status -> resolveOrCreate(principal, now));
        } catch (DataIntegrityViolationException raceLost) {
            return transactionTemplate.execute(status -> localUserRepository
                    .findByTenantAndExternalMemberId(principal.tenant(), principal.externalMemberId())
                    .map(existing -> toResponse(existing, principal))
                    .orElseThrow(() -> raceLost));
        }
    }
 
    private UserResponse resolveOrCreate(AuthPrincipal principal, Instant now) {
        LocalUser user = localUserRepository
                .findByTenantAndExternalMemberId(principal.tenant(), principal.externalMemberId())
                .map(existing -> {
                    existing.touch(now);
                    return existing;
                })
                .orElseGet(() -> localUserRepository.saveAndFlush(
                        new LocalUser(principal.externalMemberId(), principal.accountId(), principal.tenant(), now)));
        return toResponse(user, principal);
    }
 
    // toResponse(...) 생략
}

핵심은 try 블록의 execute와 catch 블록의 execute가 서로 다른 트랜잭션이라는 점입니다. 첫 번째 execute가 유니크 위반으로 실패하면 그 트랜잭션은 거기서 롤백되고 세션도 그 트랜잭션과 함께 버려집니다. catch로 넘어와 두 번째 execute를 호출하면, 오염과 rollback-only를 짊어진 앞 트랜잭션과는 아무 상관 없는 새 트랜잭션, 새 persistence context가 열립니다. 재조회는 깨끗한 세션에서 돌기 때문에 auto-flush가 건드릴 죽은 엔티티가 애초에 없습니다.

코드에는 이 결론을 나중에 나도 헷갈리지 않으려고 못을 박아 뒀습니다. 제약 위반 뒤의 원래 세션은 못 쓰는 상태이고 트랜잭션은 rollback-only라, 같은 트랜잭션 안에서 복구를 시도하면 AssertionFailure로 실패한다 — 그러니 재조회는 반드시 새 트랜잭션에서 돌아야 한다는 주석입니다.

@Transactional(REQUIRES_NEW)가 아니라 이거였나#

이건 고치고 나서 돌아보며 정리한 이야기인데, 새 트랜잭션이 필요하다고 하면 보통 @Transactional(propagation = REQUIRES_NEW)가 먼저 떠오를 법합니다. 별도 메서드로 재조회를 빼고 거기에 REQUIRES_NEW를 붙이면 되지 않느냐는 거죠.

여기엔 스프링을 좀 써 본 사람이면 한 번쯤 데어 본 함정이 있습니다. @Transactional은 프록시 기반으로 동작하기 때문에, 같은 클래스 안에서 자기 자신의 메서드를 그냥 호출하면(self-invocation) 프록시를 안 거치고 원본 객체의 메서드가 바로 불립니다. 그러면 REQUIRES_NEW가 조용히 무시되고, 새 트랜잭션이 열리지 않은 채 바깥 트랜잭션에 그대로 얹혀 버립니다. 세션 오염을 피하겠다고 붙인 어노테이션이 아무 일도 안 하는, 겉보기엔 멀쩡한데 속으로는 안 먹는 코드가 되는 셈입니다.

이걸 제대로 쓰려면 재조회 로직을 별도 빈으로 분리해 주입받거나, 자기 자신을 프록시로 다시 주입하는 식의 우회가 필요합니다. 반면 TransactionTemplate은 프록시를 안 탑니다. execute를 부르는 순간 그 자리에서 새 트랜잭션 경계가 열리기 때문에, self-invocation이라는 변수 자체가 없습니다. 트랜잭션이 새로 열렸는지 여부가 어노테이션과 프록시의 상호작용에 숨지 않고 코드에 드러나 있다는 점도 이 상황에선 더 안심이 됐습니다.

프록시 경계가 예외 하나를 두고 얼마나 다르게 구는지는, 뒤에 나오는 격리 하네스에서 두 경우로 갈라 스무 번씩 돌려 확인했습니다. 유니크 위반을 잡지 않고 그대로 끝내는 @Transactional 메서드를 바깥에서 직접 부르면, 스프링 트랜잭션 인터셉터가 트랜잭션을 롤백하고 같은 DataIntegrityViolationException을 감싸지 않은 채 그대로 위로 던집니다. 스무 번 다 그렇게 전파됐고 UnexpectedRollbackException은 한 번도 안 나왔습니다. 그런데 그 메서드를 별도 빈으로 떼어 프록시 경계를 통과시키고, 바깥 빈에서 전파된 예외를 삼켜 정상 값을 반환하려 하면 결과가 뒤집힙니다. 스무 번 모두 UnexpectedRollbackException으로 끝났어요. 안쪽 메서드의 프록시가 예외 전파를 보고 물리 트랜잭션을 rollback-only로 찍어 두고, 바깥 프록시가 커밋을 시도하는 순간 그 마킹을 발견해 조용히 롤백해 버리기 때문입니다.

같은 로직이 프록시 경계를 어떻게 지나느냐 하나로 결과가 갈린 겁니다. self-invocation이 위험한 이유도 똑같아요. 프록시를 우회해 버리면 이 경계에서 일어나야 할 일들이 조용히 사라지니까요(그 우회 경로 자체는 이번 하네스가 처음부터 두 빈으로 설계돼 실측 대상은 아니었고, 알려진 함정으로만 짚어 둡니다). TransactionTemplate은 이 경계 문제를 애초에 안 만듭니다.

지금 와서 대안 지도를 그려보면#

솔직히 밝혀 두면, 당시엔 로그에서 원인을 잡자마자 TransactionTemplate 분리로 곧장 넘어갔습니다. 여러 대안을 표로 늘어놓고 트레이드오프를 저울질하는 과정을 실제로 밟진 않았어요. 그래서 이 절은 "그때 이걸 다 검토했다"가 아니라, 지금 되짚어 보며 같은 문제에 쓸 수 있는 선택지를 정리하는 쪽에 가깝습니다.

first-login 경합 하나를 막는 데에도 층위가 다른 카드가 몇 장 있습니다.

접근골자걸리는 지점
DB upsert (INSERT ... ON DUPLICATE KEY UPDATE)조회-분기-삽입을 DB의 원자적 한 방으로 대체JPA 매핑 밖의 네이티브 쿼리라 엔티티 생명주기와 겉돌고, DB 종속적
앱단 재시도 (@Retryable 등)위반 시 트랜잭션째 새로 열어 재시도재시도 경계도 결국 "새 트랜잭션"이라 이 글의 결론과 같은 전제 위에 섬
낙관적 락버전 컬럼으로 갱신 충돌 감지존재하지 않던 row를 동시에 만드는 INSERT 경합엔 결이 안 맞음
유니크 제약 + 새 트랜잭션 재조회DB에 진실을 맡기고 진 쪽만 다시 읽음 (채택안)복구 재조회를 반드시 별도 트랜잭션에서 해야 함

이렇게 늘어놓고 보면 upsert가 가장 근본적으로 경합 자체를 없애는 방향이긴 합니다. 조회와 삽입 사이의 틈을 DB에 위임해 아예 두 요청이 경합할 창을 안 주니까요. 다만 그러면 엔티티를 통한 저장 흐름과 응답 매핑을 다시 짜야 하고, 우리 경우엔 이미 유니크 제약과 재조회 구조가 서 있었기 때문에, 거기서 딱 하나 어긋난 부분 — 재조회의 트랜잭션 경계 — 만 바로잡는 게 가장 작은 수정이었습니다.

남아 있는 정직한 구멍#

여기까지 읽으면 깔끔하게 닫힌 이야기 같지만, 사실 인정하고 넘어가야 할 데가 몇 군데 있습니다.

먼저 이 500은 부하 테스트가 아니라 사람이 브라우저로 한 번 로그인해 본 단발 E2E에서 관측됐습니다. 동시 요청 스무 개를 쏴서 재현한 게 아니에요. 그런데 유니크 위반이 실제로 찍혔다는 건, 그 순간 물리적으로 거의 동시인 두 요청이 있었다는 뜻입니다. 프론트가 /me를 이중으로 호출했거나, 개발 모드의 이중 마운트, 혹은 리프레시가 겹치는 어떤 경로일 텐데 — 정확히 무엇이 두 요청을 만들었는지는 이번에 특정하지 못했습니다. 커밋 메시지엔 "동시 최초 로그인 경합"이라고 적었지만, 이건 로그의 유니크 위반과 증상으로부터 역으로 재구성한 시나리오이지 트리거를 직접 잡아낸 건 아닙니다.

그 다음으로, E2E가 통과했다고 해서 이 수정이 회귀 테스트로 지켜지는 건 아니었습니다. JIT 통합 테스트 세 개는 다 단일 스레드라 문제의 catch 경로를 한 번도 안 밟거든요. 두 스레드로 같은 키를 동시에 밀어 유니크 위반을 강제하고 복구가 새 트랜잭션에서 도는지까지 확인하는 경합 전용 테스트는 없었습니다. 그러니 fix의 근거가 자동화된 테스트가 아니라 dev E2E 한 번의 실증뿐이라는 게, 솔직히 계속 마음에 걸렸습니다. 수정 뒤 백엔드를 재기동하고 /me를 다시 방문했을 때 200으로 렌더된 것, 그게 확인의 전부였으니까요.

찜찜해서 격리 하네스를 만들었다#

정작 문제의 catch 경로가 자동 테스트로 한 번도 안 밟힌다는 게 걸려서, 프로덕션 코드와는 분리된 격리 하네스를 하나 짰습니다. 사내 코드를 참조하지 않고 사양만 보고 독립 구현한, LocalUser 엔티티 하나에 Before/After 두 서비스를 올린 작은 모듈입니다. 앞 절에서 프록시 경계를 갈라 본 것도 이 하네스고요. 환경은 Java 17, Spring Boot 3.5.3, Hibernate 6.6.18.Final, H2 in-memory입니다.

경합을 우연에 맡기지 않으려고 CountDownLatch(2)로 두 스레드가 반드시 "둘 다 findBy에서 빈 결과를 본 뒤에" INSERT로 진입하도록 강제했습니다. 이 훅은 분기나 예외 처리 순서를 건드리지 않아서, Before/After 패턴 자체는 그대로 둔 채 경합 타이밍만 결정적으로 만듭니다. 각 시나리오를 스무 번씩 돌렸습니다.

Before 패턴은 스무 번 모두 터졌습니다. 20/20, 플래키 한 번 없이요. 한 스레드는 INSERT에 성공하고 다른 스레드는 정확히 그 AssertionFailure: null id in ... entry (don't flush the Session after an exception occurs)를 뱉었는데, 스택트레이스가 프로덕션 로그와 프레임 이름·순서까지 겹쳤습니다. checkId:86getValues:182onFlushEntity:141로 시작해 autoFlushIfRequired까지 내려가는 그 사슬 그대로요. 프로덕션은 MariaDB, 하네스는 H2인데도 같은 스택이 나왔습니다.

바로 이게 확인하고 싶던 지점이었습니다. 이 실패는 SQL 방언이 아니라 Hibernate 세션 레벨에서 일어나기 때문에 DB가 뭐든 상관없다는 것. 소스를 읽고 세운 이 가설이, DB를 바꿔도 같은 스택으로 재현되면서 실측으로 뒷받침됐습니다.

After 패턴은 반대로 스무 번 모두 조용히 회복됐습니다. 두 스레드가 예외 없이 같은 id를 돌려받았고요. 승자가 첫 트랜잭션에서 INSERT/커밋에 성공하면, 패자는 유니크 위반을 잡아 그 트랜잭션을 롤백하고 새 트랜잭션에서 승자의 행을 읽어 옵니다. 호출자에겐 예외가 전혀 안 보였는데, 이건 프로덕션 E2E에서 /me가 200으로 뜬 것과 같은 결과입니다.

정직하게 캡션을 달아 두면, 하네스는 H2 in-memory라 프로덕션 MariaDB와 같은 DB가 아닙니다. 유니크 위반 시 각 DB가 던지는 원본 SQL 메시지도 다르고요. 그럼에도 스프링의 예외 변환 계층 위에서 애플리케이션이 받는 예외 타입은 같고, 문제의 메커니즘 자체가 세션 레벨이라 DB와 무관하게 재현됐습니다. 다만 latch로 타이밍을 강제한 재현이라 재현율 100%는 인위적이라는 점은 분명히 해두고 싶습니다. 실제 동시 요청이 이 타이밍으로 겹칠 확률이 얼마인지는 여전히 별도 부하 실험의 몫이고, 그건 이번에 하지 않았습니다.

그래서 앞서 고백한 두 공백 중 하나는 성격이 바뀌었습니다. "경합이 나면 정말 이 스택이 뜨는가"는 추정에서 실측으로 넘어왔고, "실제 트래픽에서 두 요청이 어떻게 겹쳤는가"는 여전히 특정하지 못한 채 남아 있습니다.

Hibernate 버전도 여기서 조금 좁혀졌습니다. 프로덕션은 ORM 버전을 BOM에 맡기고 있어서 로그만으로는 정확한 패치를 확인하지 못했는데, 같은 Spring Boot 3.5.3을 고정한 하네스에서 해석된 버전이 6.6.18.Final이었고 앞의 소스 분석도 그 버전 기준입니다. 프로덕션이 정확히 6.6.18인지까지 단정할 순 없지만, DefaultFlushEntityEventListener.checkId로 내려가는 경로는 적어도 제가 소스를 교차 확인한 6.6.13과 6.6.18에서 완전히 같았으니, 이야기의 뼈대가 패치 버전 하나에 좌우되진 않을 겁니다.

경합 코드를 짤 때 진짜 어려운 부분은 경합 그 자체가 아니라, 경합이 터진 뒤에도 프레임워크가 여전히 멀쩡히 살아 있다고 가정하는 데 있었습니다. 유니크 제약으로 진실은 DB가 지켜 줬지만, 그 위반을 받아 든 세션과 트랜잭션은 이미 정상이 아니었던 거죠. 로그 괄호 속 한 줄이 그걸 처음부터 말해 주고 있었고요.

Connected Notes