본문 바로가기
개발 경험 기록/데이터베이스

채용 과제 테스트 중 디지털 치료제 처방 코드 생성 시스템에서 팬텀 삽입 문제 해결하기

by 시니성 2025. 3. 24.

이번 글에서는 최근 채용 과제 테스트로 처방코드 생성 API 개발하던 도중 발견한 팬텀 삽입(Phantom Insert) 문제와 이를 데코레이터 패턴과 트랜잭션 격리 수준을 통해 해결한 경험을 공유하려고 합니다.

 

[ 채용 과제 리포지토리 ] - https://github.com/shiniseong/BeyondTest

문제 상황: 처방코드 중복 생성

채용 과제로 진행한 프로젝트에서 의료진이 환자에게 처방코드를 발급하는 API를 개발하고 있었습니다.
요구사항은 다음과 같았습니다.

  1. 처방코드는 영문자 대문자 4자, 숫자 4자로 구성된 총 8자리 랜덤 코드
  2. 처방코드는 중복되지 않아야 함
  3. 발급 이력이 데이터베이스에 저장되어야 함

처음에는 다음과 같이 간단하게 구현했습니다.

override suspend fun createPrescriptionCode(command: CreatePrescriptionCodeCommand): PrescriptionCode {
    while (true) {
        val code = PrescriptionCode.generateCodeValue()
        if (prescriptionCodeRepository.findByCode(code) == null) {
            //TODO 동시에 같은 코드가 생성되는 경우를 방지하기 위해 동시성 제어가 필요함
            return prescriptionCodeRepository.insert(command.toDomain(code))
        }
    }
}

코드는 작동했지만, 주석에서 볼 수 있듯이 동시성 문제가 있었습니다.
여러 사용자가 동시에 API를 호출했을 때 같은 코드를 중복으로 생성할 가능성이 있는 것이죠.

문제 원인 분석: 팬텀 삽입 문제

이 코드의 핵심 부분은 "확인 후 삽입(check-then-insert)" 패턴입니다.

  1. findByCode(code)로 코드가 존재하는지 확인
  2. 존재하지 않으면 insert(command.toDomain(code))로 삽입

이 패턴이 동시성 상황에서 문제를 일으키는 과정은 다음과 같습니다.

시각 t1: 트랜잭션 A가 코드 "ABCD1234"가 존재하는지 확인 → 존재하지 않음
시각 t2: 트랜잭션 B도 코드 "ABCD1234"가 존재하는지 확인 → 존재하지 않음
시각 t3: 트랜잭션 B가 코드 "ABCD1234" 삽입 후 커밋
시각 t4: 트랜잭션 A가 코드 "ABCD1234" 삽입 시도 → 중복 키 오류

이는 "팬텀 삽입(Phantom Insert)" 문제의 전형적인 예로, 데이터베이스 트랜잭션 격리 수준과 관련이 있습니다.

해결책 탐색: 여러 접근법 비교

동시성 문제를 해결하기 위한 몇 가지 방법을 검토했습니다.

  1. 유니크 제약 조건: 데이터베이스 레벨에서 유니크 제약 조건을 설정하고 예외 처리
    • 장점: 간단하게 구현 가능
    • 단점: 예외 발생 시 재시도 로직 필요, 적절한 예외 처리가 없을 경우엔 사용자 경험 저하로 이어짐
  2. 분산 락(Distributed Lock): Redis와 같은 외부 시스템을 사용한 락 구현 (잘 모르는 개념. 공부 필요)
    • 장점: 여러 서버에서도 동작 가능
    • 단점: 추가 인프라 필요, 구현 복잡성 증가
  3. 낙관적 락(Optimistic Locking): 버전 필드를 추가하여 충돌 감지
    • 장점: 락 대기 시간 없음
    • 단점: 새 레코드 생성에는 적합하지 않음
    왜 낙관적 락(버저닝)이 삽입에 적합하지 않은가?
    1. 버전 필드의 작동 방식: 낙관적 락은 이미 존재하는 레코드의 버전 번호를 증가시키며 작동합니다. 새 레코드는 아직 데이터베이스에 존재하지 않기 때문에 버전을 비교할 대상이 없습니다.
    2. 충돌 감지 메커니즘의 한계: 낙관적 락은 "이 레코드가 내가 마지막으로 읽은 이후 변경되었는가?"라는 질문에 답하는 방식으로 작동합니다. 하지만 처방코드 생성 문제는 "이 코드가 아직 존재하지 않는가?"를 확인해야 하는 문제입니다.
    3. 트랜잭션 경계 문제: 낙관적 락은 "읽고-수정하고-쓰기(read-modify-write)" 패턴에 효과적이지만, "확인 후 삽입(check-then-insert)" 패턴에는 적합하지 않습니다.
    4. 팬텀 삽입 방지 불가: 두 트랜잭션이 동시에 같은 코드가 존재하지 않음을 확인한 후 삽입을 시도할 때, 버전 정보가 없어 충돌을 감지할 수 없습니다.
    5. 데이터 부재 상태 관리 불가: 낙관적 락은 데이터가 있는 상태에서의 변경 충돌을 관리하지만, 데이터가 없는 상태(삽입 전)에서의 충돌은 관리할 수 없습니다.
  4. DB 트랜잭션 격리 수준 조정: SERIALIZABLE 격리 수준 사용
    • 장점: 데이터베이스 내장 기능으로 안정적
    • 단점: 성능 영향 가능성

여러 옵션을 검토한 결과, 처방코드 생성은 Read operation에 비해 고빈도 작업이 아니고 정확성이 중요하다고 판단하여 SERIALIZABLE 격리 수준을 사용하기로 결정했습니다.
특히 SERIALIZABLE 격리 수준은 "확인 후 삽입" 패턴에서 발생하는 팬텀 현상을 데이터베이스 자체적으로 방지할 수 있는 장점이 있습니다.

SERIALIZABLE 격리 수준과 범위 잠금(Range Lock)

SERIALIZABLE 격리 수준이 팬텀 삽입 문제를 효과적으로 해결하는 원리는 범위 잠금(Range Lock) 메커니즘 때문입니다.
이 개념을 처방코드 생성 시나리오에 적용하면 다음과 같이 작동합니다.

  1. 범위 잠금이란? 데이터베이스에서 특정 조건에 해당하는 데이터 범위를 잠그는 메커니즘으로, 실제 데이터뿐만 아니라 쿼리 조건 자체에 대한 잠금을 설정합니다.
  2. 작동 방식:
    • 의사 A의 트랜잭션이 findByCode("ABCD1234")로 처방코드를 확인할 때, 해당 코드에 대한 범위 잠금이 설정됩니다.
    • 이 잠금은 단순히 현재 존재하는 레코드만 잠그는 것이 아니라, code = 'ABCD1234'라는 조건 자체에 잠금을 설정합니다.
    • 따라서 아직 데이터베이스에 존재하지 않는 값에 대해서도 "이 값은 현재 다른 트랜잭션이 확인 중"이라는 표시가 됩니다.
  3. 효과:
    • 의사 B가 같은 코드 "ABCD1234"를 확인하고 삽입하려고 할 때, 의사 A의 트랜잭션이 완료될 때까지 대기하거나 충돌이 발생합니다.
    • 이는 "확인 후 삽입" 패턴에서 두 트랜잭션이 동시에 같은 코드를 삽입하려는 시도를 방지합니다.
  4. 일반적인 격리 수준과의 차이:
    • READ COMMITTED나 REPEATABLE READ 격리 수준에서는 이러한 범위 잠금이 적용되지 않아 팬텀 삽입이 발생할 수 있습니다.
    • SERIALIZABLE 격리 수준만이 이러한 범위 잠금을 통해 팬텀 문제를 완전히 해결합니다.

이러한 범위 잠금 메커니즘은 처방코드와 같이 중복되면 안 되는 값을 생성할 때 특히 유용합니다.
코드 생성 시점에 실제로 해당 레코드가 존재하지 않더라도, 다른 트랜잭션이 같은 값을 조회하고 있다는 사실을 데이터베이스가 인지하게 되어 안전한 삽입을 보장합니다.

구현 과정: Spring Transactional과 데코레이터 패턴

Spring에서 트랜잭션 격리 수준을 설정하는 가장 간단한 방법은 @Transactional 애노테이션을 사용하는 것입니다.
하지만 기존 서비스 로직을 수정하지 않고 트랜잭션 관리만 추가하고 싶었기에 데코레이터 패턴을 적용하기로 했습니다.

class TransactionalPrescriptionCodeWebService(
    private val delegateService: PrescriptionCodeWebUseCase
) : PrescriptionCodeWebUseCase by delegateService {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    override suspend fun createPrescriptionCode(command: CreatePrescriptionCodeCommand): PrescriptionCode =
        delegateService.createPrescriptionCode(command)
}

최종 해결책: 데코레이터 패턴과 SERIALIZABLE 격리 수준

최종적으로 다음과 같이 솔루션을 구현했습니다:

  1. 기존 서비스 로직은 그대로 유지
  2. 트랜잭션 관리를 위한 데코레이터 클래스 추가
  3. DI 설정에서 데코레이터 패턴 적용
// 트랜잭션 데코레이터
open class TransactionalPrescriptionCodeWebService(
    private val delegateService: PrescriptionCodeWebUseCase
) : PrescriptionCodeWebUseCase by delegateService {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    override suspend fun createPrescriptionCode(command: CreatePrescriptionCodeCommand): PrescriptionCode =
        delegateService.createPrescriptionCode(command)
}

// 부트스트랩 계층의 빈 설정
val applicationBeans = beans {
    // 원본 서비스 빈 등록
    bean {
        PrescriptionCodeWebService(ref())
    }

    // 데코레이터를 주 빈(primary)으로 등록
    bean<PrescriptionCodeWebUseCase>(isPrimary = true) {
        val delegate = ref<PrescriptionCodeWebService>()
        TransactionalPrescriptionCodeWebService(delegate)
    }
}

이 접근 방식의 장점은 다음과 같습니다:

  1. 관심사 분리: 비즈니스 로직과 트랜잭션 관리가 분리됨 -> 도메인 계층이 특정 기술에 종속 되지 않도록 함
  2. 단일 책임 원칙(SRP): 각 클래스가 하나의 책임에만 집중
  3. 개방/폐쇄 원칙(OCP): 기존 코드 수정 없이 기능 확장
  4. 동시성 문제 해결: 팬텀 삽입 문제가 SERIALIZABLE 격리 수준으로 해결됨

학습한 점

이번 경험을 통해 여러 가지를 배웠습니다.

  1. 동시성 문제 식별의 중요성: 코드 작성 시 동시성 문제를 미리 식별하는 것이 중요
  2. 적절한 격리 수준 선택: 모든 상황에 SERIALIZABLE이 필요한 것은 아니며, 상황에 맞는 선택 필요
  3. Kotlin과 Spring 통합 시 유의점: open 클래스 문제와 같은 세부사항 이해 필요
  4. 디자인 패턴의 실용적 적용: 데코레이터 패턴이 현실 문제 해결에 유용함
  5. 낙관적 락의 한계 이해: 데이터 삽입 상황에서는 낙관적 락이 적합하지 않음을 실제 경험으로 확인

후속 개선 아이디어

이 솔루션은 현재 요구사항을 충족하지만, 다음과 같은 개선 가능성도 있습니다:

  1. 분산 환경 고려: 서버가 여러 대인 경우 분산 락 방식 검토
  2. 코드 생성 알고리즘 개선: 충돌 가능성을 줄이는 알고리즘 고려
  3. 성능 모니터링: 장기적으로 SERIALIZABLE 격리 수준의 영향 모니터링
  4. Kotlin-spring 플러그인 설정 개선: 모듈 간 일관된 설정 유지

결론

데이터베이스 트랜잭션 격리 수준과 데코레이터 패턴의 조합은 팬텀 삽입 문제를 효과적으로 해결했습니다.
의료 시스템과 같이 데이터 정확성이 중요한 환경에서는 적절한 동시성 제어가 필수적입니다.

낙관적 락(버저닝)이 새 레코드 생성에 적합하지 않은 근본적인 한계를 이해하고, 상황에 맞는 해결책으로 SERIALIZABLE 격리 수준을 선택한 것이 이 문제를 효과적으로 해결하는 데 핵심이었습니다.

이 경험은 이론적인 지식이 실제 문제 해결에 어떻게 적용되는지 보여주는 좋은 사례였습니다.
트랜잭션 격리 수준 이론에 관한 정리 글을 링크로 달아두겠습니다. 이론적인 내용이 더 궁금하신 분들은 참고해주세요

 

2025.03.24 - [데이터베이스] - [트랜잭션 시리즈 - 1] 트랜잭션의 격리 수준 정리

 

[트랜잭션 시리즈 - 1] 트랜잭션의 격리 수준 정리

이번 글 에서는 트랜잭션의 4가지 격리 수준에 대해 정리해 보겠습니다.왜냐하면, 최근 봤던 기술 면접에서 제가 이 부분을 전혀 대답하지 못했었기 때문입니다. 따흑 ㅠㅠ이번 글 에서는 트랜

shin-e-dog.tistory.com

2025.03.24 - [데이터베이스] - [트랜잭션 시리즈 - 2] 트랜잭션 격리 수준별 대표적인 트러블 케이스 정리

 

[트랜잭션 시리즈 - 2] 트랜잭션 격리 수준별 대표적인 트러블 케이스 정리

1. Dirty Read (더티 리드)발생하는 격리 수준: READ UNCOMMITTEDREAD UNCOMMITTED는 가장 낮은 격리 수준으로, 다른 트랜잭션에서 커밋되지 않은 데이터를 읽을 수 있습니다.발생 원인READ UNCOMMITTED 격리 수준

shin-e-dog.tistory.com

 

728x90