1. 레벨2가 시작되면서 드디어 Spring을 시작했다.
레벨2를 시작하면서 오랜만에 Spring Boot를 사용하게 되었다. 바인딩 어노테이션들을 쓰려고 하다보니 어떤 용도였었는지 기억하지 못해서 헤매는 일도 있었던 것 같다. 이전까지 Spring Boot를 사용하면서 단순히 "이걸 붙이면 이렇게 동작하는 어노테이션" 정도로만 생각하고, 입력과 결과만 보는 식으로 사용해왔었다. 하지만, 이번 레벨2가 시작되면서는 조금 다르게 학습하는 습관을 가지려고 했다.
왜 이 어노테이션을 쓰고, 이 어노테이션이 어떻게 동작해서 이렇게 작동하는지 알면서 사용하고 싶었다. 그 과정에서 @Component와 @Configuration의 깊은 차이에 대해서도 알아볼 수 있었다.
@Component와 @Configuration의 진짜 차이
이전에는 막연하게 @Component는 단순히 빈(Bean)으로 등록할 때 쓰는 것이고, @Configuration은 설정 파일이나 Secret Key 같은 환경 변수를 인식시키는 용도로만 생각했었다. 하지만 공부해보니 두 어노테이션은 싱글톤(Singleton) 보장과 빈 프리워킹(Bean Lite Mode vs Full Mode) 관점에서 명확한 차이가 있었다.
- @Component (Lite Mode): 클래스를 스프링 빈으로 등록한다. 이 안에서 @Bean을 사용해 또 다른 빈을 정의할 수는 있지만, 메서드를 직접 호출할 때 매번 새로운 객체가 생성되어 싱글톤이 깨질 수 있다.
- @Configuration (Full Mode): 스프링 컨테이너가 해당 클래스를 CGLIB 프록시로 감싼다. 덕분에 설정 클래스 내부의 @Bean 메서드가 서로를 호출하더라도, 항상 스프링 컨테이너에 등록된 동일한 싱글톤 빈을 반환하도록 보장해 준다. 즉, 빈 끼리의 의존성 주입을 안전하게 관리하는 용도이다.
proxyBeanMethods = false의 발견과 Clock 도입
여기서 한 걸음 더 나아가, 만약 @Bean 메서드들끼리 서로 호출하며 의존성을 주입할 일이 없다면 굳이 무거운 CGLIB 프록시 객체를 만들 필요가 없다는 것을 알게 되었다. 이때 사용하는 옵션이 바로 proxyBeanMethods = false이다. 프록시 생성을 건너뛰기 때문에 애플리케이션 구동 속도와 메모리 측면에서 이점을 챙길 수 있다.
이 개념을 적용해 프로젝트의 시간 관리를 위한 TimeConfig를 아래와 같이 작성했다.
package roomescape.common.config;
import java.time.Clock;
import java.time.ZoneId;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Seoul"));
}
}
왜 LocalDateTime 대신 Clock을 빈으로 등록했을까?
비즈니스 로직에서 현재 시간을 다룰 때 흔히 LocalDateTime.now()를 사용한다. 하지만 이 방식은 테스트하기 매우 까다로운 코드를 만든다. 코드가 실행되는 매 순간 '현재 시간'이 달라지기 때문에, 특정 시간에만 성공하거나 실패하는 불확실한 테스트(Flaky Test)가 되기 쉽다.
이를 해결하기 위해 Clock을 스프링 빈으로 주입받아 사용하도록 구조를 변경했다.
- 실제 구동 환경에서는 TimeConfig에 설정된 대로 Asia/Seoul 기준의 실시간 Clock이 주입된다.
- 테스트 환경에서는 Clock.fixed()를 이용해 특정 시간으로 고정된 Clock을 Mock 빈으로 주입할 수 있다.
결과적으로 proxyBeanMethods = false를 통해 프록시 오버헤드 없이 깔끔하게 독립적인 Clock 빈을 등록할 수 있었고, 시간 의존성이 있는 비즈니스 로직도 시간에 구애받지 않고 언제든 동일한 결과를 보장하는 결정론적인 테스트를 작성할 수 있게 되었다.
2. 학습법
이전까지는 딱 이렇다 할 명확한 학습법 없이 주먹구구식으로 공부해 왔었다. 하지만 이번 레벨2는 새로운 기술을 배우는 것을 넘어, 나에게 맞는 학습법을 정리하고 직접 실천해 보는 소중한 기간이었다. 이를 위해 두 가지 학습 방법론을 참고했다.
PIFSR 학습법
1. Problem-Driven
→ 목적과 문제에서 시작하라.
- “이걸 왜 배우는가?”를 먼저 정의
- 실제 문제를 해결하는 방향으로 학습
👉 예시
- 로그인 안 됨 → 세션/토큰 학습
- 성능 느림 → 렌더링/캐싱 학습
2. i+1
→ 문제를 쪼개고, 딱 한 단계 어려운 것만 도전하라.
- 너무 쉽지도, 너무 어렵지도 않게 (comfort zone 살짝 밖)
- 한 번에 다 이해하려 하지 말고, 한 단계씩
👉 예시
- SOLID 전체가 아니라, 한 원칙만 먼저 적용
- 클린코드 규칙 한번에 모두 적용하지 않고, 하나씩 나눠서 적용
3. Feedback Loop
→ 바로 해보고, 실패하고, 피드백을 반복하라.
- Do → Fail → Feedback → Iterate
- Output 먼저 내라 (Output First)
- 입력만 하고 출력을 통한 실패/피드백이 없다면 학습이 아니다
👉 예시
- 강의만 1시간 보지 말고, 보면서 직접 코드 작성
- 문제 풀고 틀린 뒤, 해설 보고 다시 풀기
4. Structural Thinking
→ 연결하고 구조로 이해하라.
- 조각이 아니라 흐름, 관계, 역할로 본다
- 구조화하려 하면 자연스럽게 이해된다
- 마인드맵, 컨셉맵, 마크다운 heading 정리, depth 정리 등 활용
👉 예시
- http://google.com 입력 → DNS → 서버 → 응답 흐름으로 이해
- JS vs TS 관계
- 이벤트 루프 vs 메인 스레드 관계 정리
5. Resourceful
→ 가장 빠른 방법을 위해 모든 자원을 활용하라.
- 유튜브, 책, 사람, AI 가리지 말고 활용
- 책을 1페이지부터 끝까지 읽지 않고, 필요한 모든 자원을 활용하여 비순차적으로 학습
👉 예시
- 이미 타입스크립트를 잘하는 크루에게 학습 방향을 물어본다.
- 여러 자료를 비교하며 가장 빠르게 이해되는 방식 선택
나선형 학습
나선형 학습이란?
나선형 학습(Spiral Learning)은 같은 주제를 시간 간격을 두고 반복하되, 매번 더 깊고 넓게 다루는 교육과정 설계 방식이다. Jerome Bruner가 1960년 저서 The Process of Education에서 제안했다.
"나선(Spiral)" 이라는 이름이 핵심을 설명한다. 나선은 같은 중심축을 계속 돌아오지만, 돌아올 때마다 높이가 올라가 있다. 학습도 같은 주제를 다시 만나지만, 이전보다 한 층 위에서 만난다.
왜 나선형 학습인가?
하나의 주제를 완전히 끝내고 다음으로 넘어가는 선형(Linear) 방식은, 처음 배울 때 맥락이 없어서 왜 필요한지 모른 채 외우게 된다.
Bruner의 답: "어떤 주제든 적절한 형태로 단순화하면 어느 단계에서든 가르칠 수 있다. 처음엔 직관적으로 가볍게 만나게 하고, 경험이 쌓인 뒤 같은 주제를 형식적으로 다시 다루면 된다."
위의 두 가지 학습법을 뼈대 삼아 지금까지의 내 방식을 돌아보았다. 생각보다 나는 이미 '주어진 문제를 기반으로 모든 자원(블로그, 유튜브, 인강 등)을 활용해 답을 찾는' PIFSR과 유사한 방식을 꽤 잘 실천하고 있었다.
하지만 아쉬움이 하나 있었다. 바로 가장 정확한 정보의 원천인 '공식 문서'를 외면하고 있었다는 점이다. 그동안 공식 문서는 너무 방대한 양과 읽기 어려운 영어로만 가득 찬 장벽이라 단정 짓고 회피해 왔었다.
이번 레벨2에서는 이 약점을 극복하기 위해 학습법을 튜닝했다. 처음부터 완벽하게 읽으려 하지 않고, 나선형 학습과 피드백 루프를 적용해 점진적으로 접근했다.
- 1단계: AI를 활용한 선택 독서 처음에는 AI에게 필요한 내용이 담긴 공식 문서 페이지를 찾게 한 뒤, 이를 바탕으로 문서를 읽었다. 하지만 한 페이지만 해도 여전히 내용이 너무 많아 전부 소화하기에는 무리가 있었다.
- 2단계: '예시 코드' 중심의 방사형 독서 읽는 방식을 완전히 바꿨다. 문서 전체를 읽는 대신, 찾고자 하는 개념의 '예시 코드'를 먼저 찾았다. 그리고 그 코드를 중심으로 주변의 맥락과 설명을 읽어 나갔다.
결과는 성공적이었다. 예시 코드라는 명확한 타깃을 중심으로 읽다 보니 학습 속도가 눈에 띄게 빨라졌다. 공식 문서를 통해 뼈대를 잡은 후, AI와 다른 블로그 글, 책 등을 추가로 활용해 살을 붙여나가니 개념의 깊이와 사용법을 훨씬 정확하게 이해할 수 있었다.
3. 복원할 수 있는 데이터: Soft Delete의 한계와 이력 테이블 분리
방탈출 예약 미션을 진행하면서 '예약 취소'를 구현하는 요구사항을 마주했다. "만약 사용자가 실수를 하거나 운영 중에 데이터 복원이 필요해진다면 어떻게 대응해야 하지?"라는 의문이 들었고, 데이터베이스에서 레코드를 영구히 지워버리는 물리 삭제(Hard Delete) 대신 삭제 상태만 표시하는 Soft Delete 도입을 고민하게 되었다.
JPA의 @SQLDelete와 @Where 어노테이션을 활용하면 애플리케이션 레벨에서 이를 쉽게 구현할 수 있었지만, 곧바로 데이터베이스 레벨에서 치명적인 문제에 직면했다. 바로 '유니크 제약조건(Unique Constraint)'과의 충돌이었다.
예약 시스템의 핵심은 동일한 시간에 하나의 예약만 존재해야 한다는 것이다. 단순히 is_deleted 필드만 두고 유니크 키를 묶으면, 동일한 시간에 누군가 예약을 했다가 취소하고 다른 누군가 다시 예약을 시도할 때 제약조건 위배 에러가 터진다. 이를 해결하기 위해 복잡한 인덱스를 구성할 수도 있었지만, 서비스가 커질수록 삭제된 가짜 데이터가 계속 쌓여 살아있는 원본 데이터의 조회 성능까지 갉아먹는 구조 자체를 피하고 싶었다.
그래서 데이터의 상태를 억지로 한 곳에서 관리하지 않고, 원본 테이블과 삭제된 이력을 관리하는 이력 테이블(History Table)을 물리적으로 완전히 분리하는 아키텍처를 선택했다.
// 삭제(취소)된 예약의 스냅샷을 보관하는 이력 테이블
@Entity
@Table(name = "reservation_history")
public class ReservationHistory {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long originalReservationId;
private LocalDate date;
private LocalTime time;
private String name;
private LocalDateTime deletedAt;
// ... 정적 팩토리 메서드 생략 ...
}
예약이 취소되면 원본 테이블에서는 데이터를 깔끔하게 물리 삭제(Hard Delete)하여 유니크 제약조건을 해방시키고, 동시에 이력 테이블에 스냅샷을 저장하여 데이터 복원력을 확보했다.
4. 대기(Pending) 상태 도입과 도메인의 완전한 분리
이력 테이블을 분리하며 모든 문제가 해결된 줄 알았으나, 곧이어 '대기(Pending)' 상태라는 새로운 요구사항을 마주했다. 동일한 시간에 '확정' 예약은 단 1건이어야 하지만, '대기' 예약은 여러 건이 존재할 수 있어야 했다.
만약 확정 예약과 대기 예약을 하나의 Reservation 테이블에서 관리하려고 하면 딜레마에 빠진다. 확정 예약을 보호하기 위해 걸어둔 빡빡한 유니크 제약조건이 대기자를 받을 수 없게 만들기 때문이다. 이를 해결하기 위해 MySQL의 함수형 인덱스(Functional Index)로 확정 상태일 때만 유니크 키가 작동하도록 우회하는 꼼수도 생각해보았다.
하지만 근본적으로 "생명주기와 성격이 완전히 다른 두 데이터를 하나의 테이블에 욱여넣는 것이 맞는 설계일까?"라는 의문이 들었다. 확정 예약은 절대 겹쳐서는 안 되는 핵심 자산이고, 대기 예약은 취소 자리를 기다리는 큐(Queue)다.
결국 이력 테이블을 분리했던 것처럼, 대기 예약 역시 별도의 테이블로 완전히 분리하기로 결정했다. 이로써 시스템은 확정(Active), 대기(Pending), 이력(History)이라는 3개의 명확한 도메인 테이블을 갖추게 되었다.
// 1. 확정 예약 테이블 (슬롯당 무조건 1개 보장)
@Entity
@Table(
name = "reservation",
uniqueConstraints = {@UniqueConstraint(name = "uk_slot_id", columnNames = {"slot_id"})}
)
public class Reservation {
// ... 생략 ...
@Version
private Long version; // 낙관적 락을 위한 필드
}
// 2. 대기 예약 테이블 (슬롯 중복은 허용하되, 한 유저의 중복 대기만 방지)
@Entity
@Table(
name = "pending_reservation",
uniqueConstraints = {@UniqueConstraint(name = "uk_slot_user", columnNames = {"slot_id", "user_id"})}
)
public class PendingReservation {
// ... 생략 ...
}
5. 동시성 제어: 유니크 제약조건과 낙관적 락(Optimistic Lock)의 조화
테이블을 명확한 역할로 분리하고 나니, 그토록 나를 괴롭히던 동시성 문제(Race Condition)를 상황에 맞게 매우 직관적으로 해결할 수 있었다. 동시성 충돌은 크게 '생성(INSERT) 시점'과 '수정(UPDATE) 시점' 두 가지로 나뉘었다.
생성(INSERT) 충돌 방지: DB 유니크 제약조건 활용
두 명의 사용자가 0.001초 차이로 동일한 빈자리를 동시에 예약하려고 시도한다고 가정해 보자. 둘 다 '빈자리'로 인식하고 확정 예약을 DB에 INSERT 하려 할 것이다.
이러한 '없는 데이터를 동시에 생성'하는 상황에서는 DB 레벨의 유니크 제약조건이 가장 완벽하고 성능 좋은 락(Lock) 역할을 한다. 분리된 Reservation 테이블에 걸려있는 UNIQUE(slot_id) 덕분에 단 한 명만 INSERT에 성공하고, 다른 한 명은 DataIntegrityViolationException을 맞고 튕겨 나간다.
애플리케이션 계층에서는 이 날것의 DB 에러를 잡아 사용자에게 "이미 예약이 완료되었습니다. 대기로 이동하시겠습니까?"라는 친절한 비즈니스 예외(ReservationInUseException)로 전환(Exception Translation)하여 응답했다.
수정(UPDATE) 갱신 손실 방지: 낙관적 락(@Version) 적용
그렇다면 애플리케이션 레벨의 '락(Lock)'은 언제 필요할까? 바로 '이미 존재하는 데이터를 동시에 수정'할 때 발생하는 갱신 손실(Lost Update)을 막기 위해서다.
예를 들어, 관리자가 특정 예약을 '취소' 처리하는 정확히 같은 순간에, 사용자가 해당 예약의 요청사항을 변경하려고 시도한다고 가정해 보자. 관리자의 취소 처리가 덮어씌워지거나, 취소된 예약에 변경 사항이 반영되는 치명적인 데이터 오염이 발생할 수 있다.
이를 방지하기 위해 Reservation 엔티티에 @Version 어노테이션을 추가하여 낙관적 락(Optimistic Lock)을 도입했다. 방탈출 예약의 특성상 하나의 확정된 예약을 동시에 수정하려고 경합하는 일은 매우 드물기 때문에, 무거운 비관적 락(Pessimistic Lock) 대신 충돌이 발생했을 때만 예외를 터뜨리는 낙관적 락이 도메인에 훨씬 적합했다.
만약 동시에 수정을 시도하여 버전이 어긋나면 JPA는 ObjectOptimisticLockingFailureException을 발생시킨다. 파사드(Facade) 계층에서 이를 캐치하여 "데이터가 이미 변경되었습니다. 새로고침 후 다시 시도해 주세요"라는 알림을 제공함으로써, 무결점의 데이터 정합성을 유지할 수 있게 되었다.
6. 복잡한 상태 전이 제어: 시간 변경과 대기자 연쇄 승격
테이블을 명확한 역할(확정, 대기, 이력)로 분리하고 동시성 제어까지 마쳤지만, 방탈출 예약 도메인에는 아직 가장 까다로운 비즈니스 요구사항이 남아있었다. 바로 '예약 시간 변경' 시 발생하는 연쇄적인 상태 전이(State Transition)다.
단순히 예약 날짜나 시간을 바꾸는 것은 일반적인 게시판의 UPDATE 쿼리와는 차원이 다른 문제였다. 시간 변경은 필연적으로 기존 슬롯의 공백과 새로운 슬롯으로의 진입을 동시에 발생시키며, 다음과 같은 복잡한 경우의 수를 만들어낸다.
- 대기 예약의 시간 변경 (Pending -> Active 승격 가능성)
- 대기 중인 사용자가 다른 날짜/시간으로 예약을 변경했는데, 마침 그 슬롯이 비어있다면?
- 사용자는 대기(Pending) 큐에서 삭제되고, 즉시 확정(Active) 테이블로 승격(Promote)되어야 한다.
- 확정 예약의 시간 변경 (빈자리 발생 및 연쇄 승격)
- 확정 예약자가 다른 시간대로 예약을 옮긴다면?
- 사용자가 원래 차지하고 있던 기존 슬롯에 빈자리가 생기게 된다. 따라서 기존 슬롯의 대기열(Pending Queue)을 확인하여 가장 오래 기다린 대기 1번 사용자를 자동으로 확정(Active) 테이블로 연쇄 승격시켜야 한다.
이처럼 시간 변경 이벤트 하나가 확정 테이블과 대기 테이블 양쪽의 레코드를 삭제(DELETE)하고 생성(INSERT)하는 복잡한 데이터 조작을 요구했다.
Facade 패턴을 통한 트랜잭션 오케스트레이션
이 복잡한 과정을 해결하기 위해 개별 도메인 서비스(ActiveReservationService, PendingReservationService)가 서로를 직접 호출하여 의존성이 꼬이는(Circular Dependency) 것을 방지해야 했다.
대신, 전체 비즈니스 흐름을 지휘하는 파사드(Facade) 객체인 ReservationManager를 도입하여 여러 도메인 서비스의 조합을 하나의 원자적인 작업(Atomic Operation)으로 묶어냈다.
@Service
@RequiredArgsConstructor
public class ReservationManager {
private final ActiveReservationService activeReservationService;
private final PendingReservationService pendingReservationService;
// ... 생략 ...
@Transactional
public ReservationInfo changeReservation(final Long id, final ReservationChangeCommand command) {
// ... (새로운 슬롯 정보 조회 생략) ...
// 1. 대기 예약자가 시간을 변경하는 경우
if (pendingReservationService.existsById(id)) {
boolean isSlotFull = activeReservationService.existsBySlotId(slot.getId());
return changePendingReservation(id, command, slot, isSlotFull);
}
// 2. 확정 예약자가 시간을 변경하는 경우
if (activeReservationService.existsById(id)) {
boolean isSlotFull = activeReservationService.existsBySlotId(slot.getId(), id);
return changeActiveReservation(id, command, slot, isSlotFull);
}
throw new ReservationNotFoundException("해당 예약을 찾을 수 없습니다.");
}
private ReservationInfo changeActiveReservation(final Long id, final ReservationChangeCommand command,
final TimeSlot slot, final boolean isSlotFull) {
// 1차 방어: 이동하려는 슬롯이 이미 꽉 찼다면 대기 상태로 전환
if (isSlotFull) {
return fallbackToPending(id, command, slot);
}
Long oldSlotId = activeReservationService.getSlotId(id);
ReservationInfo changedInfo;
try {
// 2차 방어: 빈자리여서 변경을 시도함
changedInfo = activeReservationService.change(id, slot, command.name());
} catch (ReservationInUseException e) {
// 찰나의 동시성 충돌로 변경 실패 시, 예외를 잡아서 대기로 전환
return fallbackToPending(id, command, slot);
}
// 핵심 로직: 시간이 성공적으로 변경되어 기존 슬롯이 비었다면, 연쇄 승격 트리거
if (!oldSlotId.equals(slot.getId())) {
promoteNextPending(oldSlotId);
}
return changedInfo;
}
// ... (changePendingReservation 로직 생략) ...
private ReservationInfo fallbackToPending(final Long id, final ReservationChangeCommand command, final TimeSlot slot) {
Long oldSlotId = activeReservationService.cancel(id, command.name());
promoteNextPending(oldSlotId); // 취소된 자리에 누군가 채워넣음
return pendingReservationService.transferReservation(id, slot, command.toCreateCommand());
}
private void promoteNextPending(final Long oldSlotId) {
pendingReservationService.popNextPendingAndPromote(oldSlotId)
.ifPresent(activeReservationService::savePromoted);
}
}
이 코드를 완성했을 때, 비즈니스 로직의 흐름은 빈틈없이 완벽해 보였다. 특히 changeActiveReservation 내부의 try-catch 문은 동시성 충돌까지 고려한 최후의 방어선이었다. 조회를 통과했더라도 찰나의 순간에 다른 사용자가 자리를 선점하면 에러를 잡아내어 깔끔하게 대기열(fallbackToPending)로 전환하는 구조였기 때문이다.
그리고 이 모든 로직을 @Transactional 하나로 묶어두었으니, 데이터의 정합성도 알아서 지켜질 것이라 생각했었다. 하지만 바로 이 '단일 트랜잭션(@Transactional)에 대한 맹신'이 곧이어 터질 에러의 원흉이 되었다.
7. 트랜잭션 전파의 함정과 REQUIRES_NEW를 통한 격리
확정 예약 변경 시 동시성 충돌이 발생하면, 내가 의도했던 대로 catch 블록을 타고 대기 상태로 정상 전환되는 것이 아니라 UnexpectedRollbackException이 터지면서 전체 트랜잭션이 장렬하게 롤백되어 버렸다.
대기 상태로 잘 넘어가게 짰는데 대체 왜 전체가 롤백된 것일까? 원인은 스프링의 기본 트랜잭션 전파 속성(REQUIRED)과 JPA의 특성에 있었다.
예외를 잡아도 전체가 롤백되는 이유: Rollback-Only 마킹
파사드 객체인 ReservationManager의 진입점에 @Transactional이 걸려있으므로 거대한 하나의 '물리적 트랜잭션'이 시작된다. 그 안에서 activeReservationService.change()가 호출되면 동일한 물리 트랜잭션에 '논리적 트랜잭션'으로 합류(Join)한다.
이때 내부 로직(change)에서 동시성 충돌로 인한 DB 예외가 발생하면, 스프링의 트랜잭션 AOP 프록시는 해당 물리 트랜잭션 전체에 "이 트랜잭션은 복구 불가능한 에러가 났으니 무조건 롤백되어야 함(Rollback-Only)"이라는 마크를 찍어버린다.
개발자가 바깥쪽인 Manager 클래스에서 try-catch로 예외를 잡아먹고 정상 흐름(fallbackToPending)으로 복구하려 해도 소용이 없다. 외부 로직이 성공적으로 끝나 트랜잭션을 커밋(Commit)하려는 시점에, 스프링이 앞서 찍혀있는 Rollback-Only 마크를 발견하고는 UnexpectedRollbackException을 던지며 모든 작업을 무효화시켜 버리기 때문이다.
REQUIRES_NEW를 통한 트랜잭션의 물리적 분리
나의 비즈니스 의도(동시성 실패 시 대기로 전환)를 그대로 유지하면서 이 롤백의 함정을 피하려면, "실패할 가능성이 있는 작업(change)을 원본 트랜잭션과 아예 남남으로 물리적 분리"를 해야 했다.
이를 위해 ActiveReservationService의 change 메서드 트랜잭션 전파 속성을 REQUIRES_NEW로 변경했다.
@Service
public class ActiveReservationService {
// 부모 트랜잭션에 합류하지 않고, 무조건 새로운 물리적 트랜잭션을 생성하여 실행!
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ReservationInfo change(Long id, TimeSlot slot, String name) {
// ... 실제 엔티티 수정 로직 ...
}
}
이렇게 하면 문제가 해결된다.
- ReservationManager가 외부 트랜잭션(A)을 시작한다.
- change() 메서드가 호출되면 스프링은 A를 잠깐 멈추고, 새로운 커넥션을 가져와 새로운 내부 트랜잭션(B)을 시작한다.
- 찰나의 동시성 충돌로 쿼리가 실패하면, 내부 트랜잭션(B)만 롤백되고 Rollback-Only 마크도 B에만 찍힌다.
- 예외가 던져지고 외부 트랜잭션(A)이 이를 catch한다. 외부 트랜잭션(A)에는 롤백 마크가 없으므로 여전히 건강한 상태다.
- 안전하게 fallbackToPending()을 호출하여 대기열로 데이터를 밀어 넣고, 외부 트랜잭션(A)은 성공적으로 커밋(Commit)된다.
결과적으로 동시성 충돌이라는 극단적인 상황 속에서도 REQUIRES_NEW를 통해 트랜잭션의 경계를 분리해 냄으로써, '확정 실패 시 대기 전환'이라는 복잡한 요구사항을 달성할 수 있었다. 프레임워크가 제공하는 추상화 뒤에 숨겨진 물리/논리 트랜잭션의 생명주기를 체감하고 통제하게 된 결정적 계기였다.
8. 테스트 계층과 동시성 테스트
설계가 아무리 완벽해 보여도, 실제 트래픽이 쏟아지는 환경에서 코드로 증명해 내지 못한다면 그것은 반쪽짜리 아키텍처에 불과하다.
지금까지 구축한 '유니크 제약조건을 통한 동시성 제어'와 '파사드(Facade) 계층의 트랜잭션 롤백'이 의도한 대로 동작하는지 검증하기 위해, ExecutorService, CountDownLatch 그리고 @MockitoSpyBean을 활용한 통합 테스트를 작성했다.
가장 먼저 확인해야 할 것은 "동일한 빈자리에 수십 명의 요청이 동시에 도달했을 때, 단 1명만 확정되고 나머지는 안전하게 대기로 넘어가는가?" 였다.
이러한 레이스 컨디션(Race Condition)을 테스트하기 위해 ExecutorService로 20개의 스레드를 생성했다. 하지만 단순히 스레드 풀에 작업을 밀어 넣는 것만으로는 부족했다. 스레드 생성 속도 차이로 인해 미세하게 순차 실행이 될 수 있기 때문이다. '동시 접근(Thundering Herd)'을 시뮬레이션하기 위해 CountDownLatch를 도입했다.
@Test
@DisplayName("20명의 사용자가 동시에 같은 예약을 시도하면 1명만 확정(ACTIVE)되고 19명은 대기(PENDING) 상태가 된다.")
void concurrentAddReservationTest() throws InterruptedException {
int threadCount = 20;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
// 스레드 대기, 시작, 종료를 통제하기 위한 래치 설정
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
readyLatch.countDown(); // 스레드 준비 완료 보고
startLatch.await(); // 메인 스레드의 출발 신호 대기 (모두가 같은 출발선에 섬)
manager.addReservation(createCommand(username, time.getId()));
} finally {
doneLatch.countDown();
}
});
}
readyLatch.await(); // 20개 스레드가 모두 준비될 때까지 대기
startLatch.countDown(); // 탕! 20개 스레드 동시 출발
doneLatch.await(); // 모든 스레드 작업이 끝날 때까지 대기
// 검증: 정확히 1건만 ACTIVE, 19건은 PENDING
List<ReservationInfo> allReservations = manager.getReservations();
assertThat(activeCount).isEqualTo(1);
assertThat(pendingCount).isEqualTo(19);
}
CountDownLatch(1)인 startLatch를 통해 20개의 스레드를 출발선에 대기시켰다가 오차 없이 동시에 출발시켰다. 분리된 DB 테이블의 유니크 제약조건과 애플리케이션의 예외 전환(Exception Translation) 로직이 맞물리면서, 단 1명만 확정(Active) 테이블에 저장되고 나머지 19명은 안정적으로 대기(Pending) 테이블로 전환됨을 확인했다.
다음으로 검증해야 할 것은 파사드 객체(ReservationManager)의 트랜잭션 정합성이었다. "대기자를 확정으로 승격하는 연쇄 로직 중, 앞의 작업(취소, 대기열 pop)은 성공했는데 마지막 작업(확정 테이블 insert)에서 에러가 발생한다면 전체가 롤백되어 데이터가 보존되는가?"
이를 테스트하기 위해서는 로직 중간에 의도적으로 런타임 에러를 발생시켜야 했다. 이때 유용하게 활용한 것이 스프링 부트 환경에서 실제 빈의 동작은 유지하면서 원하는 메서드만 가로채서 조작(Stubbing)할 수 있는 @MockitoSpyBean이다.
@SpringBootTest
public class ReservationPromoteTest {
// ... 생략 ...
// 실제 객체의 동작을 감시하고 조작하기 위해 SpyBean 주입
@MockitoSpyBean
private ActiveReservationService activeReservationService;
@Test
@DisplayName("승격 중 삽입이 실패하면, 삭제되었던 대기자 데이터는 롤백되어 유지되어야 한다.")
void promotionFailRollbackTest() {
// 확정자 1명, 대기자 1명 사전 세팅 완료
// SpyBean을 이용해 savePromoted 호출 시 강제로 예외 발생 조작!
doThrow(new RuntimeException("DB 삽입 중 알 수 없는 에러 발생!"))
.when(activeReservationService).savePromoted(any());
// 확정 예약을 취소시켜 연쇄 승격 로직 트리거
ReservationCancelCommand cancelCommand = new ReservationCancelCommand(activeCommand.name());
// 예외가 정상적으로 터지는지 검증
assertThatThrownBy(() -> reservationManager.cancelReservation(activeReservation.id(), cancelCommand))
.isInstanceOf(RuntimeException.class);
// 핵심 검증: 트랜잭션이 롤백되어 대기자(Pending) 큐에서 빠져나갔던 1명이 그대로 남아있어야 함
long pendingCount = pendingReservationRepository.findAll().size();
assertThat(pendingCount).isEqualTo(1);
}
}
doThrow()를 통해 승격 저장(savePromoted) 직전에 고의로 데이터베이스 장애 상황을 모의(Mocking)했다. 검증 결과, 예외가 터지기 전 이미 실행되었던 '기존 예약 물리 삭제'와 '대기열 Pop' 작업이 단일 트랜잭션에 의해 롤백(Rollback) 되었다. 대기자 데이터 증발이라는 치명적인 버그를 애플리케이션 아키텍처 레벨에서 차단했음을 테스트 코드로 증명해 낸 것이다.
이러한 깊이 있는 동시성/트랜잭션 테스트를 구축하면서, 비로소 내가 설계한 아키텍처에 대한 확신을 가질 수 있었다. 데이터 생명주기에 대한 단순한 호기심으로 시작했던 Soft Delete 고민이, 3개의 도메인 테이블 분리와 트랜잭션 전파 격리(REQUIRES_NEW), 그리고 이를 검증하는 테스트 코드 작성에 이르기까지 깊고 넓은 성장의 밑거름이 되어 주었다.
'우아한테크벨로 > Level2' 카테고리의 다른 글
| 토스페이먼츠 연동과 Rate Limit, Timeout (0) | 2026.06.23 |
|---|---|
| 『오브젝트』 저자 조영호 개발자님 초청 강연 회고 (0) | 2026.05.10 |
