이번 글에서는 최근 채용 과제 테스트로 처방코드 생성 API 개발하던 도중 발견한 팬텀 삽입(Phantom Insert) 문제와 이를 데코레이터 패턴과 트랜잭션 격리 수준을 통해 해결한 경험을 공유하려고 합니다.
[ 채용 과제 리포지토리 ] - https://github.com/shiniseong/BeyondTest
문제 상황: 처방코드 중복 생성
채용 과제로 진행한 프로젝트에서 의료진이 환자에게 처방코드를 발급하는 API를 개발하고 있었습니다.
요구사항은 다음과 같았습니다.
- 처방코드는 영문자 대문자 4자, 숫자 4자로 구성된 총 8자리 랜덤 코드
- 처방코드는 중복되지 않아야 함
- 발급 이력이 데이터베이스에 저장되어야 함
처음에는 다음과 같이 간단하게 구현했습니다.
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)" 패턴입니다.
findByCode(code)
로 코드가 존재하는지 확인- 존재하지 않으면
insert(command.toDomain(code))
로 삽입
이 패턴이 동시성 상황에서 문제를 일으키는 과정은 다음과 같습니다.
시각 t1: 트랜잭션 A가 코드 "ABCD1234"가 존재하는지 확인 → 존재하지 않음
시각 t2: 트랜잭션 B도 코드 "ABCD1234"가 존재하는지 확인 → 존재하지 않음
시각 t3: 트랜잭션 B가 코드 "ABCD1234" 삽입 후 커밋
시각 t4: 트랜잭션 A가 코드 "ABCD1234" 삽입 시도 → 중복 키 오류
이는 "팬텀 삽입(Phantom Insert)" 문제의 전형적인 예로, 데이터베이스 트랜잭션 격리 수준과 관련이 있습니다.
해결책 탐색: 여러 접근법 비교
동시성 문제를 해결하기 위한 몇 가지 방법을 검토했습니다.
- 유니크 제약 조건: 데이터베이스 레벨에서 유니크 제약 조건을 설정하고 예외 처리
- 장점: 간단하게 구현 가능
- 단점: 예외 발생 시 재시도 로직 필요, 적절한 예외 처리가 없을 경우엔 사용자 경험 저하로 이어짐
- 분산 락(Distributed Lock): Redis와 같은 외부 시스템을 사용한 락 구현 (잘 모르는 개념. 공부 필요)
- 장점: 여러 서버에서도 동작 가능
- 단점: 추가 인프라 필요, 구현 복잡성 증가
- 낙관적 락(Optimistic Locking): 버전 필드를 추가하여 충돌 감지
- 장점: 락 대기 시간 없음
- 단점: 새 레코드 생성에는 적합하지 않음
- 버전 필드의 작동 방식: 낙관적 락은 이미 존재하는 레코드의 버전 번호를 증가시키며 작동합니다. 새 레코드는 아직 데이터베이스에 존재하지 않기 때문에 버전을 비교할 대상이 없습니다.
- 충돌 감지 메커니즘의 한계: 낙관적 락은 "이 레코드가 내가 마지막으로 읽은 이후 변경되었는가?"라는 질문에 답하는 방식으로 작동합니다. 하지만 처방코드 생성 문제는 "이 코드가 아직 존재하지 않는가?"를 확인해야 하는 문제입니다.
- 트랜잭션 경계 문제: 낙관적 락은 "읽고-수정하고-쓰기(read-modify-write)" 패턴에 효과적이지만, "확인 후 삽입(check-then-insert)" 패턴에는 적합하지 않습니다.
- 팬텀 삽입 방지 불가: 두 트랜잭션이 동시에 같은 코드가 존재하지 않음을 확인한 후 삽입을 시도할 때, 버전 정보가 없어 충돌을 감지할 수 없습니다.
- 데이터 부재 상태 관리 불가: 낙관적 락은 데이터가 있는 상태에서의 변경 충돌을 관리하지만, 데이터가 없는 상태(삽입 전)에서의 충돌은 관리할 수 없습니다.
- DB 트랜잭션 격리 수준 조정: SERIALIZABLE 격리 수준 사용
- 장점: 데이터베이스 내장 기능으로 안정적
- 단점: 성능 영향 가능성
여러 옵션을 검토한 결과, 처방코드 생성은 Read operation에 비해 고빈도 작업이 아니고 정확성이 중요하다고 판단하여 SERIALIZABLE 격리 수준을 사용하기로 결정했습니다.
특히 SERIALIZABLE 격리 수준은 "확인 후 삽입" 패턴에서 발생하는 팬텀 현상을 데이터베이스 자체적으로 방지할 수 있는 장점이 있습니다.
SERIALIZABLE 격리 수준과 범위 잠금(Range Lock)
SERIALIZABLE 격리 수준이 팬텀 삽입 문제를 효과적으로 해결하는 원리는 범위 잠금(Range Lock) 메커니즘 때문입니다.
이 개념을 처방코드 생성 시나리오에 적용하면 다음과 같이 작동합니다.
- 범위 잠금이란? 데이터베이스에서 특정 조건에 해당하는 데이터 범위를 잠그는 메커니즘으로, 실제 데이터뿐만 아니라 쿼리 조건 자체에 대한 잠금을 설정합니다.
- 작동 방식:
- 의사 A의 트랜잭션이
findByCode("ABCD1234")
로 처방코드를 확인할 때, 해당 코드에 대한 범위 잠금이 설정됩니다. - 이 잠금은 단순히 현재 존재하는 레코드만 잠그는 것이 아니라,
code = 'ABCD1234'
라는 조건 자체에 잠금을 설정합니다. - 따라서 아직 데이터베이스에 존재하지 않는 값에 대해서도 "이 값은 현재 다른 트랜잭션이 확인 중"이라는 표시가 됩니다.
- 의사 A의 트랜잭션이
- 효과:
- 의사 B가 같은 코드 "ABCD1234"를 확인하고 삽입하려고 할 때, 의사 A의 트랜잭션이 완료될 때까지 대기하거나 충돌이 발생합니다.
- 이는 "확인 후 삽입" 패턴에서 두 트랜잭션이 동시에 같은 코드를 삽입하려는 시도를 방지합니다.
- 일반적인 격리 수준과의 차이:
- 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 격리 수준
최종적으로 다음과 같이 솔루션을 구현했습니다:
- 기존 서비스 로직은 그대로 유지
- 트랜잭션 관리를 위한 데코레이터 클래스 추가
- 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)
}
}
이 접근 방식의 장점은 다음과 같습니다:
- 관심사 분리: 비즈니스 로직과 트랜잭션 관리가 분리됨 -> 도메인 계층이 특정 기술에 종속 되지 않도록 함
- 단일 책임 원칙(SRP): 각 클래스가 하나의 책임에만 집중
- 개방/폐쇄 원칙(OCP): 기존 코드 수정 없이 기능 확장
- 동시성 문제 해결: 팬텀 삽입 문제가 SERIALIZABLE 격리 수준으로 해결됨
학습한 점
이번 경험을 통해 여러 가지를 배웠습니다.
- 동시성 문제 식별의 중요성: 코드 작성 시 동시성 문제를 미리 식별하는 것이 중요
- 적절한 격리 수준 선택: 모든 상황에 SERIALIZABLE이 필요한 것은 아니며, 상황에 맞는 선택 필요
- Kotlin과 Spring 통합 시 유의점:
open
클래스 문제와 같은 세부사항 이해 필요 - 디자인 패턴의 실용적 적용: 데코레이터 패턴이 현실 문제 해결에 유용함
- 낙관적 락의 한계 이해: 데이터 삽입 상황에서는 낙관적 락이 적합하지 않음을 실제 경험으로 확인
후속 개선 아이디어
이 솔루션은 현재 요구사항을 충족하지만, 다음과 같은 개선 가능성도 있습니다:
- 분산 환경 고려: 서버가 여러 대인 경우 분산 락 방식 검토
- 코드 생성 알고리즘 개선: 충돌 가능성을 줄이는 알고리즘 고려
- 성능 모니터링: 장기적으로 SERIALIZABLE 격리 수준의 영향 모니터링
- 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
'개발 경험 기록 > 데이터베이스' 카테고리의 다른 글
PRIMARY KEY, 칼럼의 속성일까 테이블의 속성일까? (0) | 2025.01.17 |
---|