2026-05-13
개요
검수 시스템에서 동일 상품에 대해 검수 task가 중복 생성되는 버그를 마주했습니다.
처음에는 단순 레이스 컨디션으로 보였지만, 파보니 pessimistic lock을 이미 걸어둔 상태에서도 발생하는 미묘한 패턴이었습니다. 바로 TOCTOU(Time-of-Check to Time-of-Use) 레이스 컨디션입니다.
이 글에서는 TOCTOU가 무엇인지 간단히 소개하고, 우리 검수 도메인에서 왜 lock을 걸었는데도 문제가 발생했는지, 어떻게 수정했는지를 정리합니다.
본문
시스템 구조와 두 이벤트
이해를 위해 도메인의 두 개념을 먼저 정리합니다.
•
task: 검수자가 처리해야 하는 검수 작업 단위입니다. 한 상품에 대한 한 번의 검수가 task 하나입니다.
•
productInspection: 상품의 검수 상태를 들고 있는 엔티티입니다. 상품 자체가 아니라, "이 상품에 대해 검수가 필요한가"를 추적하는 보조 엔티티입니다. NEEDS_INSPECTION(검수 필요)
INSPECTION_COMPLETED(검수 완료) 두 상태를 오갑니다. 이 상태가 "이 상품에 새 task를 만들어야 하는가"를 결정합니다.
카테고리 검수 시스템은 MongoDB CDC에서 흘러나온 두 종류의 Kafka 이벤트로 동작합니다.
•
결제 이벤트 처리 서비스: 결제가 발생한 상품의 productInspection이 NEEDS_INSPECTION 상태면 task를 새로 만들고, productInspection을 INSPECTION_COMPLETED로 lock합니다 (이후 결제 이벤트에서는 task를 중복 생성하지 않도록 막아주는 용도)
•
상품명 변경 이벤트 처리 서비스: 상품명이 바뀌면 다시 검수해야 하므로, productInspection을 NEEDS_INSPECTION으로 unlock해 다음 결제 시 재검수하도록 되돌립니다
Prod 환경 Kafka consumer concurrency가 5로 설정돼 있어, 동일 상품의 두 이벤트가 거의 동시에 다른 스레드에서 처리될 수 있습니다. 바로 이 구간에서 중복이 터졌습니다.
TOCTOU란 무엇인가
TOCTOU는 "검증한 시점(Time of Check)"과 "그 결과로 행동하는 시점(Time of Use)" 사이에 외부에서 상태가 바뀌면 검증이 무효화되는 패턴입니다.
고전적으로는 보안 분야에서 자주 언급됩니다. UNIX에서 access()로 파일 권한을 확인한 뒤 open()으로 열기 직전에 공격자가 심볼릭 링크를 조작해 권한이 필요한 파일로 바꿔치는 공격이 대표적이죠. 검증은 이전 상태에 대해 통과했지만, 실제 행동은 바뀐 상태에서 일어납니다.
DB 동시성 세계에서도 똑같이 나타납니다. 일반적으로는 lock 없이 select-then-update할 때 발생하지만, 우리 사례처럼 lock을 걸어도 lock 획득 이전에 check하면 똑같이 발생합니다.
문제 시퀀스
문제가 된 시퀀스는 아래와 같습니다. Thread A는 결제 이벤트를, Thread B는 상품명 변경 이벤트를 처리합니다.
Thread A (결제 이벤트) Thread B (상품명 변경 이벤트)
──────────────────────── ──────────────────────────
canUnlock() → true (task 미커밋 → 미발견)
findWithLock → NEEDS_INSPECTION (lock 획득)
createTask (TASK-1)
productInspection.lock() → INSPECTION_COMPLETED
commit (lock 해제)
findWithLock → INSPECTION_COMPLETED (lock 획득)
productInspection.unlock() → NEEDS_INSPECTION 으로 되돌림
commit
(다른 결제 이벤트 도착)
findWithLock → NEEDS_INSPECTION
createTask (TASK-2) ← 중복!
JavaScript
복사
Thread B가 lock 획득 이전에 하는 canUnlock() 체크는 그 시점에 task가 아직 커밋되지 않은 상태의 데이터를 읽어 true를 반환합니다. 그 후 Thread A가 task를 만들고 productInspection을 lock해도, Thread B는 이미 "unlock해도 된다"는 잘못된 결정을 손에 쥐고 있었습니다. lock 획득 자체가 늦을 수 있어도, 그 결정은 이미 stale한 상태의 검증 결과에서 나온 겁니다.
왜 lock을 잡았는데도 문제가 됐을까
이 지점이 제일 헷갈렸던 부분입니다. findWithLockByProductId()에 @Lock(PESSIMISTIC_WRITE)가 붙어 있었는데 왜 안 되었을까.
답은 pessimistic lock의 보호 범위입니다.
•
lock은 lock 획득 시점 ~ 트랜잭션 종료 사이의 데이터만 보호합니다
•
lock 획득 이전에 수행한 다른 쿼리는 별도의 read라 다른 트랜잭션의 커밋을 못 보는 stale read일 수 있습니다
•
즉, lock으로 보호받고 싶었던 검증을 lock 밖에 둔 게 모순이었죠
lock을 걸었다고 안심하지 말 것. 보호받고 싶은 check가 lock 획득 이후에 있는지 항상 확인해야 합니다. lock 이전의 검증은 그 자체로 심볼릭 링크 공격과 동일한 구조입니다.
Before / After
코드 변경은 동작 순서 한 줄을 옮긴 게 전부입니다.
// Before: lock 획득 이전에 검증 (TOCTOU 윈도우 존재)
@Transactional
override fun execute(event: TitleChangedEvent) {
if (!canUnlock(event.productId)) return // ← stale read 가능
val productInspection = productInspectionRepository
.findWithLockByProductId(event.productId)
// ...
productInspection.unlock()
}
Kotlin
복사
// After: lock 획득 이후에 검증 (lock 보유 상태에서 최신 상태를 읽음)
@Transactional
override fun execute(event: TitleChangedEvent) {
val productInspection = productInspectionRepository
.findWithLockByProductId(event.productId)
// ...
// lock 획득 후 canUnlock 검증하여 TOCTOU 레이스 컨디션 방지
if (!canUnlock(event.productId)) return
productInspection.unlock()
}
Kotlin
복사
코드로는 canUnlock() 호출 위치만 옮긴 게 전부이지만, 의미는 완전히 달라집니다. lock을 보유한 상태에서 canUnlock()을 부르면, Thread A가 만든 진행 중 task를 제대로 관찰하여 false를 반환하고 unlock을 스킵합니다.
테스트로 재현
단위 테스트에서는 "findWithLockByProductId()가 INSPECTION_COMPLETED 상태의 productInspection을 반환하는 상황"을 mock으로 재현했습니다. 이 경우 canUnlock()이 false를 반환해 unlock이 스킵되어야 합니다.
// given: lock을 잡았더니 이미 완료 상태인 productInspection이 돌아온다
val lockedInspection = productInspection(status = INSPECTION_COMPLETED)
every { repository.findWithLock(productId) } returns lockedInspection
// when
service.handle(event)
// then: canUnlock이 false를 반환해 unlock이 스킵되어야 함
verify(exactly = 0) { repository.save(any()) }
Kotlin
복사
Before의 테스트는 "canUnlock() == false이면 findWithLock을 호출하지 않는다"를 검증했었는데, 이게 바로 잠재해 있던 TOCTOU의 증거였습니다. After에서는 lock을 먼저 잡고 그 결과를 보고 검증하므로 테스트 기대치도 "lock 획득 후 canUnlock으로 스킵"으로 바뀌었습니다.
교훈
이 버그에서 얻은 교훈을 일반화된 원칙과 우리 사례 두 측면으로 정리합니다.
•
check-then-act는 동시성 환경에서 항상 위험합니다. check과 act 사이에 틈이 있으면 다른 주체가 그 틈을 파고들 수 있다고 가정해야 합니다.
•
lock을 추가했다고 안심하면 안 됩니다. lock의 보호 범위는 "획득한 이후"라, 검증 로직이 lock 이전에 있으면 lock 하나 없는 것과 똑같습니다.
•
단일 스레드 테스트로는 TOCTOU를 잡을 수 없습니다. 목 기반 단위 테스트도 순서적으로 흐르므로 재현이 안 됩니다. 코드 리뷰에서 "check-then-act가 동시성 경계를 넘는가"를 의식적으로 찾는 게 필수입니다.
•
방어선을 여러 계층에 둘 것. 우리는 pessimistic lock + 검증 순서 수정 + DB unique constraint(product_id) 세 계층을 겹쳐 둡니다. 한 계층이 뚫려도 다음 계층이 막아주도록.
TOCTOU는 운영체제, 파일 시스템, 데이터베이스, 분산 시스템 어디에서도 등장합니다. "확인하고 나서 행동하는 구조"가 보이면 둘 사이에 외부가 끼어들 수 있는가를 먼저 의심해보세요.

