diff --git a/README.md b/README.md index 2a034cff62..d6c666a6b3 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,11 @@ - `Waiting` — 그 슬롯을 기다리는 줄. 순번을 가진다. - `Waitings` — 대기들의 일급 컬렉션. 순번 부여·중복 검증·재정렬 같은 비즈니스 규칙을 소유한다. -예약이 취소되면 슬롯이 비고, 첫 대기가 예약으로 승격된다. -`reservation` 테이블에는 `UNIQUE(date, time_id, theme_id)`, `waiting` 테이블에는 `UNIQUE(date, time_id, theme_id, name)` 제약을 둔다. +예약이 취소되면 슬롯이 비고, 첫 대기가 예약으로 승격된다. +reservation 테이블에는 UNIQUE(date, time_id, theme_id), waiting 테이블에는 UNIQUE(date, time_id, theme_id, name)(같은 사람 중복 대기 방어)과 +UNIQUE(date, time_id, theme_id, order_index)(순번 충돌 방어)를 둔다. 전자는 모델이 바뀌어도 남는 도메인 불변식, 후자는 저장된 순번 모델에 종속된 제약이다. + + > 통합 모델(status 컬럼)·절충 모델(FK 참조) 대신 분리 모델을 택한 자세한 근거는 PR 본문에 포함. @@ -184,3 +187,35 @@ - [x] 예약 페이지(`reservations.html`)에 예약/대기 상태 렌더링 - [x] 내 예약 페이지(`my-reservations.html`) 예약/대기 상태 렌더링 - [x] 각 케이스 요구사항 테스트 추가 + +## 트랜잭션 경계와 그 근거 + +두 흐름을 각각 하나의 트랜잭션으로 묶는다. +묶는 기준은 "부분 실패 시 어떤 깨진 상태가 남는가"로 도출. +어느 단계가 실패해도 데이터 모순 + 사용자에게 보이는 깨진 약속이 남으면, 그 작업들은 함께여야 한다. + +### 예약 취소 → 자동 승격 (`deleteByOwner` , 두 테이블 4종 변경) + +[1] reservation DELETE(취소자) → [2] reservation INSERT(승격자) → [3] waiting DELETE(승격된 1번) → [4] order_index UPDATE×N(나머지 +당기기) + +- **[2] 실패([1]만 성공)** — 취소자 예약은 사라졌는데 승격이 안 됨 → 슬롯이 텅 빔. 1번 대기자는 목록엔 "대기 1번"인데 슬롯은 예약 가능 상태 → 누구나 새로 예약해 기존 1번을 추월. 가장 + 망가지는 시나리오. +- **[3] 실패([1,2] 성공)** — 승격자가 예약이 됐는데 그의 대기 ROW가 안 지워짐 → 예약자이면서 동시에 대기 1번. 내 목록에 같은 슬롯이 두 줄로 중복 노출. +- **[4] 실패([1,2,3] 성공)** — 승격은 됐지만 뒷순번이 안 당겨짐 → 순번에 구멍(1 없이 2,3만). 이후 그 슬롯 대기 신청이 충돌(500). + +→ 셋 다 데이터 모순 + 깨진 약속이므로 4종을 한 경계로 묶는다. + +### 대기 취소 → 순번 재정렬 (`cancelByOwner` · waiting 한 테이블 2종 변경) + +[1] waiting DELETE(취소 대상) → [2] order_index UPDATE×N(뒤 순번 당기기) + +- **[2] 실패([1]만 성공)** — 대기는 빠졌는데 재정렬이 안 됨 → 순번에 구멍([1,2,3]에서 2 취소 시 [1,3]). 3번이던 사람이 계속 "대기 3번"인데 앞엔 1명뿐 → 표시 순번과 실제 줄 + 길이 불일치. 이후 그 슬롯 신청이 충돌(500). + +→ 2종을 한 경계로 묶는다. + +### 검증 + +이 깨진 상태들은 각 단계가 실패할 때 생기지만 **모두 하나의 `@Transactional` 경계가 막는다.** 따라서 검증은 쌓인 변경이 가장 많은 [4](재정렬) 실패 하나로 대표하고(같은 회귀를 중복 검증하지 +않음), 나머지 끝 상태는 이 문서로 남긴다. → `ReservationPromotionTransactionTest`, `WaitingCancelTransactionTest`. diff --git a/build.gradle b/build.gradle index 13da279d3d..1caea5b59b 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'io.jsonwebtoken:jjwt-api:0.11.2' diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000000..869b554f6b --- /dev/null +++ b/docs/00-overview.md @@ -0,0 +1,166 @@ +# 00 · 미션 개요 & 학습 방향 + +> JPA 전환 + 예약 대기 미션의 큰 그림. 이 문서는 큰 그림을 잡는 단계에서 작성하고, 단계가 진행되며 갱신한다. +> 🖊️ 가이드 블록(이 인용문들)은 초안용. 완성 시 지워도 됨. + +--- + +## 메타 + +| 항목 | 값 | +|--------------|---------------------------------------| +| 저장소 | `softmoca/spring-roomescape-waiting` | +| 시작 브랜치 | `step2` (JdbcTemplate 방탈출 코드) | +| 작업 브랜치 | `step3-jpa` | +| 단일 PR 마감 | 06-18(목) 18:00 | +| JPA 설계 토론 | 06-19(금) 10:40~11:30, 강의장 | +| 자기점검·사후설문 마감 | 06-22(월) 18:00 | +| 커밋 표기 | `[1단계] 매핑 변환` 식 단계 prefix (PR 본문 경계용) | + +--- + +## 이 미션의 본질 + +새 라이브러리 사용법 암기가 아니라 **내 어노테이션 한 줄이 만드는 실제 SQL을 추적**하는 미션. JPA가 자동화한 만큼 감춰진 SQL을, 내 JDBC 코드를 객체 그래프로 옮기며 다시 눈에 보이게 만든다. " +내가 쓴 SQL = 나가는 SQL"이던 JDBC의 투명성을 자동화와 맞바꾼 비용을 손으로 만진다. + +> 자동으로 무엇이 일어나는지 모르면 의도와 다른 SQL이 나가도 그 사실 자체를 모른다. 무엇이 어디로 옮겨가고 무엇이 새로 생기는지 **내 코드 위에서** 본다. + +## 학습 목표 (LMS) + +- JdbcTemplate ↔ JPA 표현력 차이를 내 코드 위에서 비교 +- 영속성 컨텍스트(1차 캐시·dirty checking·쓰기 지연·flush·fetch) 직접 관찰 + 발행 SQL 추적 +- 연관관계 결정(단/양방향·페치·cascade·orphanRemoval)의 근거·한계 설명 +- N+1 발견 + fetch join·`@EntityGraph`·JPQL 비교 +- 트랜잭션 경계·동시성 의식하며 도메인 로직(자동 승인) 위치 결정 + +--- + +## 진행 방식 & 1주 흐름 + +1주 미션, 별도 OT 없음. **사전학습 → 0~4단계 본인 페이스 → PR → JPA 설계 토론 → 자기점검** 순. + +| 일 | 활동 | +|------------------|-------------------------------------| +| 월 | LMS·사전학습 통독 → 방탈출 코드 동작 확인 → 0단계 시작 | +| 화~목 | 0~4단계 본인 페이스 (커밋·푸시) | +| **목 18:00** | **PR 제출 (마감)** | +| 금 | JPA 설계 토론 (10:40~11:30, 강의장) | +| 토~일 | 자기점검 + LMS+ 산출물 작성 | +| **다음 주 월 18:00** | **자기점검 + LMS+ 산출물 마감** | + +- **단계 권장 종료선 없음** — 가는 만큼 본다. 4단계까지 못 가도 PR 본문에 "어디까지 만들고 무엇이 남았는지" 적으면 됨. +- 깊이·분량·양식·페이스는 자율. 운영 기본 틀은 둘뿐: **① 첫 PR 1회(목 18:00)** **② 자기점검+사후설문(다음 주 월 18:00)**. + +### PR은 1회만 + +5단계로 나뉘지만 PR은 종료 시 1회. 단계별로 커밋·푸시(`[1단계]…` prefix)하고 마지막에 통합 PR. PR 본문 = 0~4단계 도달 지점의 **결정·SQL·한계 통합 정리**. 막힘은 짧은 사이클 +채널(기록·동료)에 흘려보낸다. + +### 산출물 + +| 단계 | 시점 | 항목 | +|---------|---------|-------------------------------------| +| 사전 설문 | 시작 전 | 도달 상태·JPA 경험·목표·마친 후 모습 (↓ 내 답변) | +| 단계별 응답 | 단계마다 | 0~4단계 자기진단·확인 과제 폼 총 16개 (도달한 단계까지) | +| 첫 PR 1회 | 목 18:00 | 통합 PR (발행 SQL·망설인 결정·흔들린 장면이 토론 재료) | +| 자기점검 | 월 18:00 | 4영역(매핑/설계 판단/피드백 순환/종합) — 회고용 | +| 사후 설문 | 자기점검 직후 | 사전설문과 비교하며 작성 | +| AI 인터뷰 | 종료 후 | 별도 안내 | + +> **API 명세는 고정.** 명세를 바꾸면 JDBC↔JPA SQL 비교가 흐려진다. + +--- + +## 코치가 보는 3차원 (이 docs의 설계 축) + +| 차원 | 무엇을 보나 | 이 docs의 어디서 회수되나 | +|----------------|-----------------------------|-----------------------------| +| **A — 매핑 정확성** | 어노테이션 → 발행 SQL을 추적했는가 | 각 단계 `SQL 관찰 로그` (예측 vs 실제) | +| **B — 설계 판단** | 대안 비교·근거·한계를 말할 수 있는가 | 각 단계 `결정 기록` + `06 설계 토론` | +| **C — 피드백 순환** | SQL 로그·예외·테스트·리뷰·토론을 살려 썼는가 | 각 단계 `피드백 채널 신호` | + +> 🖊️ 각 단계 문서의 결정·인사이트 끝에 `(A)`/`(B)`/`(C)` 태그를 달아두면, 06(토론 결정 3개)·07(자기점검 4영역)·단일 PR 본문이 자동으로 조립된다. (인증인가 미션에서 06이 step +> 문서를 당겨왔던 그 흐름) + +--- + +## 내 출발점 + +### 사전설문 (시작 전 기록 — 사후설문과 비교용) + +**Q. JPA 경험은?** +토이 프로젝트에서 2년 이상. + +**Q. 미션 목표(얻고 싶은 것)?** +우테코 오기 전에는 JPA의 추상화를 전혀 의식하지 않고 "편한 스택"으로만 썼다. N+1도 "해결법을 외운" 수준이었지, 내 코드 어느 지점에서 왜 터지는지는 보지 못했다. 그렇게 쓰는 건 — 내 어노테이션 한 +줄이 어떤 SQL을 던지는지조차 모른 채 '잘 돌아가니 괜찮다'고 믿는 위험한 상태다. 이번 미션에서 얻고 싶은 건 그 자동화의 비용을 직접 만져보고, **내가 작성한 매핑·연관관계 결정이 실제 어떤 SQL·동작을 +만드는지 설명할 수 있는 힘**, 그리고 그 SQL을 근거로 연관관계·페치 전략 결정의 **한계까지 말할 수 있게** 되는 것. + +**Q. 마친 후 바라는 모습?** +어노테이션·연관관계 매핑을 볼 때 "JPA가 알아서 해준다"로 넘기지 않고 **발행할 SQL의 모양이 먼저 떠오르는** 상태. N+1을 "해결법을 아는 문제"가 아니라 "내 코드 어느 줄에서 터지는지 보이는 문제"로 +보고, LAZY·추가 쿼리가 생기는 지점을 직접 추적. 단/양방향·EAGER vs LAZY·fetch join vs `@EntityGraph`·트랜잭션 경계를 관행이 아니라 **각 선택이 만들 SQL과 한계 근거로 +** 설명. + +### 코드 상태 (step2) + +- 헥사고날 (`adapter/persistence` ↔ `domain/repository` 포트 ↔ `application` ↔ `adapter/web`) +- **member 테이블 없음** — 예약자는 `name VARCHAR` 컬럼 (별도 객체 아님) +- reservation / waiting **분리 모델**, `UNIQUE(date, time_id, theme_id, name)` +- 조회 핵심 경로는 **3중 조인 SQL 직접 박음** (reservation INNER JOIN reservation_time INNER JOIN theme) +- `waiting.order_index` = FIFO 순번을 저장해 둔 materialized 파생값 + +--- + +## 0단계 — 변경/유지 경계 + +**바꿀 것**: `build.gradle`(jdbc→data-jpa) · `adapter/persistence`의 Jdbc 구현 4개(RowMapper·KeyHolder·수기 SQL 제거) · `domain` 객체 +매핑(`@Entity`/`@Id`/`@GeneratedValue`/`@ManyToOne`, protected 기본 생성자 추가) · UPDATE 메서드(dirty checking 검토) · +`application.properties`/DDL 설정. + +**유지할 것**: `adapter/web` 컨트롤러·DTO(API 명세 고정) · `application` 서비스·`@Transactional` 경계 · `exception` 계층·도메인 규칙/정책 · 분리 +모델 + UNIQUE 제약 · `data.sql`. + +**경계의 핵심 결정 (확정 대기)**: 도메인 객체에 직접 `@Entity` vs 순수 도메인 + 별도 JPA 엔티티 분리. → 이번 미션은 **영속성 컨텍스트 관찰(1-3)을 살리기 위해 직접 매핑** 쪽으로 +잠정. 엔티티를 분리해 경계에서 변환하면 entity가 즉시 detach되어 dirty checking·LAZY가 가려지기 때문. (헥사고날 순수성 일부 양보 = 의도된 범위 한정, PR에 명시) + +> 🖊️ 이 결정은 1단계 진입 시 최종 확정. 확정 후 이 문단 갱신. + +--- + +## 대화 로드맵 (대화 단위 ↔ 산출물) + +| 대화 | 산출물 | +|----------|------------------------------| +| 학습 방향·개념 | `00-overview`, `01-concepts` | +| 1단계 | `02-step1-jpa-mapping` | +| 2단계 | `03-step2-my-reservations` | +| 3단계 | `04-step3-waiting` | +| 4단계 | `05-step4-waiting-admin` | +| 토론 준비 | `06-design-discussion` | +| 자기점검·PR | `07-self-check` | + +> 단계 진입 시: 해당 step 문서를 먼저 열어 **"전환 전(JDBC)" 확정 → 구현 → "전환 후·SQL 관찰" 채움** 순으로 진행. 대화가 바뀌어도 docs를 읽고 시작하면 흐름이 안 끊긴다. + +## docs 구조 + +``` +docs/ +├── 00-overview.md # (이 문서) 개요·학습 방향·차원 매핑·로드맵·0단계 경계 +├── 01-concepts.md # 사전학습: 영상·4질문 가설·키워드 정의·코드 다시 읽기·참조 매뉴얼 +├── 02-step1-jpa-mapping.md # 1단계: 1-1 매핑 / 1-2 연관관계 / 1-3 영속성 컨텍스트 관찰 +├── 03-step2-my-reservations.md # 2단계: 내 예약 목록 (메서드 쿼리 vs JPQL) +├── 04-step3-waiting.md # 3단계: 3-1 N+1·fetch join / 3-2 JPQL rank +├── 05-step4-waiting-admin.md # 4단계: 어드민·자동 승인·트랜잭션 경계·flush 순서 +├── 06-design-discussion.md # JPA 설계 토론 준비 (결정 3개 + 한계) +└── 07-self-check.md # 자기점검 4영역 + 단일 PR 본문 종합 +``` + +(별도 `decisions/` ADR 폴더 없음 — 결정 기록은 각 step 문서에 인라인. 인증인가 때 분리 폴더가 비어버린 교훈.) + +## 진행 원칙 + +- **도달한 만큼을 본다** — 미완은 부끄러워하지 말고 정확히 적는다(미완 정확 기록 = 핵심 신호). +- **단일 PR** — 단계별 커밋·푸시, 마지막에 통합 PR 1회. +- **API 명세 고정** — 명세를 바꾸면 JDBC↔JPA SQL 비교가 흐려진다. +- **문서량 자랑 경계** — SQL 관찰 로그는 두껍게, 산문은 짧게. diff --git a/docs/01-concepts.md b/docs/01-concepts.md new file mode 100644 index 0000000000..02ca39c27e --- /dev/null +++ b/docs/01-concepts.md @@ -0,0 +1,250 @@ +# 01 · 사전학습 — 개념 & 코드 다시 읽기 + +> 미션 시작 전 가설. **"이게 답이었어?"를 미션 끝에 비교할 기준점.** 여기 적힌 SQL/동작 예측은 1단계 이후 실제 발행 SQL로 검증한다. +> "미션1" = 현재 `step2` 브랜치에 누적된 JDBC 방탈출 코드 전체. / 출발점(사전설문)은 `00-overview §내 출발점` 참고. + +--- + +## 학습 테스트 (참조 매뉴얼 — 막힐 때만) + +- `spring-data-jpa-1` — Entity·Repository·기본 CRUD → **1단계 매핑(1-1)** +- `spring-data-jpa-2` — 연관관계·JPQL → **1단계 연관관계(1-2) + 3단계 JPQL(3-2)** + +처음부터 끝까지 따라하지 않고, 막히는 지점에서 참조 매뉴얼처럼 편다. + +--- + +## 1. 이번 사이클에 답할 4질문 (가설) + +### Q1. JPA는 무엇을 자동화하고, 그 대가로 무엇을 감추는가 + +**자동화하는 것**: SQL 문자열 생성과 객체↔행 매핑(RowMapper·KeyHolder·SimpleJdbcInsert 보일러플레이트 소멸), 연관 데이터 접근을 SQL join이 아니라 객체 참조로( +`reservation.getTime().getStartAt()`), 그리고 영속성 컨텍스트가 주는 세 가지 — 변경 감지(save 없이 필드 수정만으로 UPDATE), 1차 캐시(같은 트랜잭션 내 동일 id 재조회 +시 SELECT 생략), 쓰기 지연(INSERT/UPDATE를 모아 flush 시점 일괄). + +**그 대가로 감추는 것**: 한마디로 **"내가 쓴 SQL = 나가는 SQL"이라는 JDBC의 투명성**. ① 언제 어떤 SQL이 나가는지가 코드 표면에서 사라져 의도와 다른 쿼리(N+1)가 나가도 모르고, ② +flush 시점이 암묵적이라 "내가 부른 적 없는 SQL"이 발생하며, ③ LAZY 프록시는 컴파일 타임엔 안 보이다 트랜잭션 밖 접근에서 터지고, ④ 영속성 컨텍스트의 생명주기(트랜잭션 경계)가 코드에 명시되지 +않는다. 정리하면 **투명성을 자동화와 맞바꾼 것**이고, 이 미션은 그 맞바꾼 비용을 발행 SQL로 다시 눈에 보이게 만드는 작업. + +### Q2. 영속성 컨텍스트가 켜져 있다는 걸 어느 시점에 의식해야 하는가 + +의식해야 할 시점: **`@Transactional` 경계**(여기서 컨텍스트가 열리고 종료 시 flush+닫힘), **entity 필드를 수정하는 순간**(save 없이도 commit 때 UPDATE → 의도치 +않은 UPDATE 방지), **같은 트랜잭션에서 같은 걸 두 번 조회할 때**(두 번째는 캐시라 DB 최신값이 아닐 수 있음), **JPQL/native 실행 직전**(쓰기 지연분 강제 flush), **트랜잭션이 +끝난 뒤 LAZY 필드 접근**(LazyInit). + +한 문장으로 압축하면 — **"지금 이 entity가 영속(managed)인가 준영속(detached)인가."** 이게 dirty checking이 먹히느냐, LAZY가 초기화되느냐를 가른다. 내 +`delete-before-insert` 승급 흐름에서 read-your-own-writes를 트랜잭션 안에서 보장받는 게 정확히 이 의식이고(Cycle 2에서 이미 만진 감각), JPA에서 그 감각이 어디로 +옮겨붙는지 비교하면 좋다. reads를 트랜잭션-free로 두는 결정을 했다면 컨텍스트가 더 짧게 열렸다 닫혀 LAZY 접근 가능 구간이 좁아지는 부작용도 함께 본다. + +### Q3. SQL join을 객체 그래프로 옮기면 부담이 어디로 옮겨가는가 + +JDBC에선 join을 SQL에 박아 한 번에 가져왔고, 부담은 **"SQL을 직접 짜는 손의 수고"**였지만 쿼리 횟수는 명시적·예측 가능했다. 객체 그래프로 옮기면 코드(`getTheme().getName()`) +는 깨끗해지는 대신 부담이 **보이지 않는 곳으로** 이동한다. + +목록 N개를 가져온 뒤 각 연관 객체에 접근하면 추가 SELECT가 N번 나가는 **N+1**이 생기고, 부담은 "쿼리 작성"에서 **"fetch 전략 결정"**(EAGER/LAZY, fetch join, +`@EntityGraph`)으로 바뀐다. 다시 fetch join으로 합치면 **row 중복(카테시안) 처리** 부담이 새로 붙는다. 즉 join의 부담이 사라진 게 아니라 **"쿼리를 쓰는 손" → "쿼리가 언제 +몇 번 나가는지 추적하는 눈 + 전략 결정"**으로 형태가 바뀐 것. 미션 3-1이 내 `reservations-mine`(예약 N + 대기 M, 각각 theme·time 참조)에서 이걸 정확히 재현한다. + +### Q4. 어노테이션 한 줄이 만드는 실제 SQL을 추적할 수 있는가 + +yes/no보다 **현재 위치를 정직하게 찍는 답**. 추적 도구는 `show-sql`·`format_sql`·`org.hibernate.SQL` 로거(+`BasicBinder`로 바인딩 파라미터)가 기본. + +지금 떠오르는 것 / 안 떠오르는 것: **단일 entity CRUD는 떠오른다** — `@GeneratedValue(IDENTITY)`면 id를 알아야 하니 INSERT가 즉시 발행돼 쓰기 지연이 부분 무력화, +`@Column(nullable/length)`는 DDL에 반영. 반면 **연관관계 + flush 순서 + cascade가 얽히면 예측이 흔들린다** — `@ManyToOne` 기본 EAGER가 join이냐 별도 +select냐, 양방향에서 어느 쪽이 FK UPDATE를 치느냐, cascade 전파 시 SQL 순서 — 관찰해야 보이는 영역. + +→ 시작 시점의 정직한 자기 위치: **"단일 CRUD는 추적 가능, 연관·flush 순서·cascade는 아직 예측 불가 → 이 갭을 메우는 게 이번 미션의 차원 A."** + +--- + +## 2. 사전학습 키워드 (정의 + 내 코드) + +### 패러다임 차이 + +**ORM (Object-Relational Mapping)** + +- 정의: 객체(클래스)와 관계형 테이블을 자동 매핑해, SQL 대신 객체 조작으로 영속성을 다루는 기술. JPA는 표준 명세, Hibernate는 구현체. +- 내 코드: `RowMapper`를 손수 짜서 `ResultSet` ↔ 객체를 변환하던 자리를 ORM이 자동화한다. + +**객체-관계 임피던스 불일치** + +- 정의: 객체 모델(상속·연관·참조 방향)과 관계형 모델(테이블·FK·집합)의 표현 방식이 근본적으로 달라 생기는 간극. +- 내 코드: `reservation.getTime()`(객체 참조)과 `time_id`(FK 컬럼)가 같은 관계를 다르게 표현하는 것이 이 불일치의 실물. + +**영속성 (Persistence)** + +- 정의: 객체 상태를 프로그램 종료 후에도 유지되도록 DB 같은 영구 저장소에 보관하는 것. +- 내 코드: 예약을 insert해 DB에 남기는 모든 동작이 영속화. JPA는 이걸 객체 생명주기로 추상화한다. + +### 핵심 개념 + +**영속성 컨텍스트** + +- 정의: entity를 보관·관리하는 1차 메모리 공간. 트랜잭션 범위에서 살아 있고, 1차 캐시·dirty checking·쓰기 지연이 전부 여기서 일어난다. +- 내 코드: JDBC엔 없던 개념. 미션 1-3 관찰 과제가 이 공간의 동작을 직접 들여다보는 자리. + +**1차 캐시** + +- 정의: 영속성 컨텍스트 안에 entity를 id 기준으로 저장해, 같은 트랜잭션에서 동일 id 재조회 시 DB를 안 치고 캐시에서 반환. +- 내 코드: JDBC면 `findById` 두 번 = SELECT 두 번. JPA는 두 번째 SELECT가 생략된다(= 비교 관찰 포인트). + +**dirty checking (변경 감지)** + +- 정의: 영속 상태 entity의 필드를 수정하면, 별도 update 호출 없이 commit/flush 시점에 스냅샷과 비교해 UPDATE 자동 발행. +- 내 코드: `update reservation set ...`을 직접 짜서 `JdbcTemplate.update(...)` 호출하던 자리가 통째로 사라진다. + +**쓰기 지연 (Transactional Write-behind)** + +- 정의: `save` 시 INSERT/UPDATE를 즉시 보내지 않고 쓰기 지연 저장소에 모았다가 flush 시점에 일괄 발행. +- 내 코드: JDBC는 `update()` 즉시 발행이었지만 JPA는 시점이 뒤로 밀린다(단, IDENTITY 키는 예외 — 즉시 INSERT). + +**flush** + +- 정의: 영속성 컨텍스트의 변경분을 DB에 동기화(SQL 발행). 트랜잭션 commit·JPQL 실행 직전·명시적 호출 시 발생. +- 내 코드: "내가 부른 적 없는 SQL이 언제 나가는가"의 답. flush ≠ commit(트랜잭션 종료)을 구분하는 게 핵심. + +### 매핑 + +**@Entity** + +- 정의: 클래스를 JPA가 관리하는 영속 객체로 지정. 기본 생성자 필요, 테이블과 매핑된다. +- 내 코드: `Reservation`·`Theme`·`ReservationTime`·`Waiting` 도메인 클래스에 부여. (member는 step2에 없음 — 예약자는 `name VARCHAR`) + +**@Id** + +- 정의: entity의 기본 키(PK) 필드를 지정. +- 내 코드: 각 테이블의 `id` 컬럼. JDBC에선 RowMapper에서 `rs.getLong("id")`로 읽던 자리. + +**@GeneratedValue** + +- 정의: PK 생성 전략 지정. `IDENTITY`는 DB의 auto_increment에 위임(INSERT 후 키 확인). +- 내 코드: `KeyHolder`/`SimpleJdbcInsert`로 생성 키를 받아오던 코드가 이걸로 대체. + +**@Column** + +- 정의: 필드↔컬럼 매핑 세부(name, nullable, length, unique 등) 지정. 미지정 시 필드명 규칙 자동 매핑. +- 내 코드: SQL/DDL 문자열에 박혀 있던 컬럼 제약이 entity 어노테이션으로 옮겨오는 자리. + +**@Table** + +- 정의: entity가 매핑될 테이블 이름·제약(uniqueConstraints 등) 지정. 미지정 시 클래스명 기준. +- 내 코드: 분리 모델의 `UNIQUE(date, time_id, theme_id, name)` 복합 유니크를 여기에 선언 가능. + +### 연관관계 + +**@ManyToOne** + +- 정의: N:1 관계 매핑. 여러 entity가 하나를 참조(FK 보유 쪽). 기본 fetch는 EAGER. +- 내 코드: `Reservation` → `Theme`/`ReservationTime` 참조(member 없음). FK 컬럼 + join이 이걸로 표현된다. + +**@OneToMany** + +- 정의: 1:N 관계 매핑. 하나가 여러 entity를 컬렉션으로 보유. 기본 fetch는 LAZY. +- 내 코드: 단방향 시작이라 거의 안 씀. `Theme`가 `List`을 들면 등장(필요 전엔 안 만듦). + +**단/양방향** + +- 정의: 한쪽만 참조를 들면 단방향, 양쪽이 서로 참조 필드를 들면 양방향. 양방향은 그래프 탐색이 양쪽으로 가능. +- 내 코드: 미션 1-2가 "단방향 시작, 필요 생기면 양방향". 시도→후퇴 사이클 기록이 차원 B의 도달점. + +**연관관계 주인 (Owning side)** + +- 정의: 양방향에서 FK를 실제 관리(INSERT/UPDATE)하는 쪽. 보통 `@JoinColumn`을 가진 N쪽, 반대편은 `mappedBy`로 읽기 전용. +- 내 코드: 양방향을 안 쓰면 등장 안 함. 양방향 시도 시 "누가 FK를 치는가"를 정해야 하는 지점. + +**cascade (영속성 전이)** + +- 정의: 부모 entity의 영속 작업(persist/remove 등)을 연관 자식에게 전파. `CascadeType`으로 범위 지정. +- 내 코드: 미션이 "필요해질 때까지 적용 금지" 명시. 적용 시 PR에 근거를 적어야 하는 신중 영역. + +**orphanRemoval (고아 객체 제거)** + +- 정의: 부모 컬렉션에서 자식 참조가 끊기면 그 자식 row를 DELETE. cascade REMOVE보다 좁은 "연관 끊김 = 삭제". +- 내 코드: 대기 취소·승급으로 `Waiting`이 컬렉션에서 빠질 때 자동 삭제 후보지만, 미션은 보수적 미적용 권장. + +### 페치 + +**EAGER (즉시 로딩)** + +- 정의: entity 조회 시 연관 entity를 즉시 함께 로딩(주로 join 또는 추가 select). `@ManyToOne` 기본값. +- 내 코드: `findById(reservation)` 한 번에 time·theme까지 끌려온다. 편하지만 불필요한 조회·N+1의 원인이 되기도. + +**LAZY (지연 로딩)** + +- 정의: 연관 entity를 실제 접근 시점까지 로딩을 미루고 프록시로 대체. `@OneToMany` 기본값. +- 내 코드: 트랜잭션 밖에서 LAZY 필드 접근 시 `LazyInitializationException` — 미션 1-3에서 만나는 경계 신호. + +**fetch join** + +- 정의: JPQL에서 연관 entity를 한 번의 join 쿼리로 함께 조회. N+1을 한 방에 해소하는 대표 수단. +- 내 코드: `reservations-mine`에서 예약·대기 + theme·time을 묶어 가져올 때. row 중복(distinct) 처리 부담이 새로 생긴다. + +**@EntityGraph** + +- 정의: 어떤 연관을 함께 로딩할지 어노테이션으로 선언해, JPQL 없이 fetch join 효과를 내는 방법. +- 내 코드: fetch join을 `@Query` 없이 repository 메서드 위 선언으로 처리. 미션 3-1에서 fetch join과 비교. + +### 쿼리 + +**JPQL** + +- 정의: 테이블이 아니라 entity·필드를 대상으로 작성하는 객체지향 쿼리. JPA가 실제 SQL로 번역해 발행. +- 내 코드: 대기 N번째 순번 계산처럼 메서드 이름 쿼리로 못 푸는 복잡 조회. 미션 3-2의 서브쿼리 COUNT. + +**Native Query** + +- 정의: DB 방언 그대로의 실제 SQL을 직접 작성해 실행. JPQL로 표현 못 하는 DB 고유 기능·튜닝에 사용. +- 내 코드: JDBC 시절 직접 쓰던 SQL과 가장 가깝다. 마지막 탈출구로 남지만 객체 추상화 이점은 포기. + +--- + +## 3. 미션1 코드 다시 읽기 (step2 실측) + +### ① JdbcTemplate이 다루는 SQL은 몇 종류인가 + +- **INSERT** — 4곳 모두. `KeyHolder`/`GeneratedKeyHolder`로 생성 키를 받아오는 패턴(`save`). +- **SELECT (단일 테이블)** — `ReservationTime`·`Theme`의 `findAll`/`findById`. +- **SELECT + 3중 조인** — `Reservation`의 `findAll`/`findById`/`findByName`, `Waiting`도 동일하게 `reservation_time` + `theme` + 조인. 조회 핵심 경로는 거의 다 조인. +- **SELECT EXISTS** — `Reservation`에만 5개(`existsByDateAndTimeAndTheme`/`existsBySlotAndName`/`existsByTimeId`/ + `existsByThemeId`/`...ExcludingId`). +- **UPDATE** — `reservation`의 date/time_id(예약 변경), `waiting`의 order_index(순번 재배치). 2종. +- **DELETE** — 4곳 모두 `deleteById`. +- **부가** — `ReservationTime.findAvailable`의 `NOT IN(서브쿼리)`, `Theme.findPopularBetween`의 + `JOIN + COUNT + GROUP BY + LIMIT` 집계. + +→ **CRUD 4종이 기본, SELECT가 단일/조인/EXISTS/집계로 분화.** + +### ② 도메인↔테이블 1:1인가 + +**테이블↔도메인 클래스는 1:1** (테이블 4개 = 클래스 4개). 단 단서 셋: + +- 조회 결과↔객체는 **1:N 테이블 조립** — `Reservation` 한 객체를 만들 때 RowMapper가 `reservation` row + 조인된 `reservation_time`·`theme` 컬럼을 + 읽어 `ReservationTime.withId(...)`·`Theme.withId(...)`를 손으로 조립. 클래스↔테이블은 1:1이지만 "하나의 객체 그래프"는 3개 테이블에서 온다. +- **`name`은 객체가 아니다** — 예약자가 `Member` 같은 별도 도메인/테이블이 아니라 `VARCHAR` 컬럼. 이 자리만 매핑이 "값(String)" 수준. +- `waiting.order_index`도 별도 객체가 아닌 `Waiting`의 한 필드(materialized 파생값)로 1:1 매핑. + +### ③ join을 박았나, 두 번 조회했나 + +**전부 join을 SQL에 직접 박음.** `time_id`로 `ReservationTimeRepository.findById`를 따로 부르는 "두 번 조회"가 아니라, +`INNER JOIN reservation_time ... INNER JOIN theme ...`를 한 쿼리에 넣고 RowMapper에서 조립. Reservation·Waiting·Theme(인기) 모두 동일. + +→ JPA 전환의 핵심 대비점: 나는 **"한 쿼리 조인"을 명시적으로 선택**해뒀는데, JPA로 가면 `@ManyToOne`이 그 조인을 감추며 **기본 N+1 위험**으로 전환. 한 쿼리 조인을 유지하려면 +fetch join/`@EntityGraph`로 *다시 명시* 필요(미션 3-1). + +### ④ 객체 그래프로 옮기면 사라질 코드 + +- **RowMapper 전체** — `ReservationTime.withId(...)`/`Theme.withId(...)` 손수 조립이 `@ManyToOne` 매핑으로 대체. + `getTime().getStartAt()`이 그냥 됨. +- **컬럼 별칭 곡예** — `r.id AS reservation_id`, `th.name AS theme_name` 같은 조인 컬럼 충돌 회피가 통째로 사라짐. +- **INSERT 보일러플레이트** — `PreparedStatement` + `GeneratedKeyHolder` + `ps.setXXX`가 `save()` + `@GeneratedValue(IDENTITY)` + 로. +- **명시적 UPDATE SQL** — `updateDateAndTime`·`updateOrderIndex`가 dirty checking으로 대체(트랜잭션 안 필드 수정). +- **EXISTS 쿼리들** — `existsBy...`는 메서드 이름 쿼리로 자동 생성 가능. + +**함정**: `INNER JOIN` 문자열은 사라져도 조인의 부담은 fetch 전략 결정으로 이동. `order_index`의 `updateOrderIndex`는 dirty checking으로 사라지지만 * +*FIFO 순번 재계산 로직**(bookkeeping)은 남거나 → **JPQL rank 계산으로 갈아탈지**의 설계 재결정(미션 3-2). + +--- + +> 🖊️ 이 문서가 02~05단계의 "전환 전" 기준선. 단계 진입 시 여기 ③④를 펴서 시작. diff --git a/docs/02-step1-jpa-mapping.md b/docs/02-step1-jpa-mapping.md new file mode 100644 index 0000000000..fc431c7e8c --- /dev/null +++ b/docs/02-step1-jpa-mapping.md @@ -0,0 +1,519 @@ +# 02 · 1단계 — JPA 전환 (매핑 / 연관관계 / 영속성 컨텍스트) + +> 이 미션에서 **분량 최대** 단계. 1-1 매핑 → 1-2 연관관계 → 1-3 관찰의 3중 구조. +> 커밋: `[1단계] ...` / 모델: 시작 전 `01-concepts §3`(코드 다시 읽기) 펴기. +> ✅ **1-1 완료** (Theme · ReservationTime). 발행 SQL은 Boot 3.4.4 / Hibernate 6 / H2 격리 실측. + +--- + +## 들어가기 전 자기진단 (진입 시점 가설) + +**Q. 본인 코드의 Repository에서 가장 자주 등장하는 SQL 패턴은?** +> (가설) 코드베이스 전체의 시그니처 패턴은 **SELECT + 3중 조인**(reservation·waiting 조회 핵심 경로)이고, 그 위에 모든 `save`가 **INSERT + KeyHolder**로 통일돼 있다. EXISTS는 `Reservation`에 5개 몰려 있다(`01-concepts §3①`). 다만 1-1에서 먼저 만지는 `Theme`·`ReservationTime`은 조인이 없는 **단일 테이블 SELECT + INSERT(KeyHolder)** 서브셋이라, 조인 노이즈 없이 기본 CRUD의 어노테이션→SQL 매핑부터 본다. + +**Q. 객체 참조로 옮겼을 때 더 자연스러워지는 곳은?** +> (가설) 가장 큰 자연스러움은 **조인 조립부** — RowMapper가 조인된 행을 `ReservationTime.withId(...)`·`Theme.withId(...)`로 손수 조립하던 자리가 `reservation.getTime().getStartAt()` 참조로 바뀐다(본격 등장은 연관관계가 생기는 1-2). 1-1의 독립 클래스에서도 작게는 `save()`의 `KeyHolder→getKey()→withId(...)` 재조립이 **`save()`가 영속 엔티티를 그대로 반환**하는 형태로 사라지고, 단순 ROW_MAPPER도 소멸한다. + +--- + +## 1-1. 매핑 변환 (독립 클래스: Theme, ReservationTime) + +### 요구사항 (LMS) + +- `build.gradle`: `spring-boot-starter-jdbc` → `spring-boot-starter-data-jpa` +- `@Entity`·`@Id`·`@GeneratedValue(strategy = IDENTITY)` +- `JpaRepository` 작성, Jdbc 기반 Repository 제거, `KeyHolder`/`SimpleJdbcInsert` 잔재 제거 +- `application.properties`: `show-sql` / `hibernate.format_sql` / `ddl-auto` / 바인딩 로깅 + +### 전환 전 (JDBC) + +`JdbcThemeRepository.save` — 직접 INSERT + KeyHolder로 생성 키 회수 후 객체 재조립: +```java +String sql = "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)"; +KeyHolder keyHolder = new GeneratedKeyHolder(); +jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); + ps.setString(1, theme.getName()); + ps.setString(2, theme.getDescription()); + ps.setString(3, theme.getThumbnailUrl()); + return ps; +}, keyHolder); +Long id = keyHolder.getKey().longValue(); +return Theme.withId(id, theme.getName(), theme.getDescription(), theme.getThumbnailUrl()); +``` +- ROW_MAPPER: `rs.getLong("id")`/`rs.getString(...)` → `Theme.withId(...)` 손수 조립. +- `ReservationTime.save`도 동형: `INSERT INTO reservation_time (start_at) VALUES (?)` + KeyHolder, `ps.setTime(1, Time.valueOf(...))`. +- 도메인은 불변(`final` 필드) + private 생성자 + 검증 + `create`(id=null)/`withId` 정적 팩토리. + +### 전환 후 (JPA) + +**엔티티** — `final` 제거, protected 기본 생성자 추가, 팩토리·검증은 유지: +```java +@Entity +public class ReservationTime { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) // 컬럼명은 네이밍 전략에 위임: startAt -> start_at + private LocalTime startAt; + + protected ReservationTime() {} // JPA 전용(리플렉션 진입로) + private ReservationTime(Long id, LocalTime startAt) { validate(startAt); ... } + public static ReservationTime create(LocalTime startAt) { return new ReservationTime(null, startAt); } + public static ReservationTime withId(Long id, LocalTime startAt) { ... } // Jdbc(예약/대기) RowMapper가 아직 사용 +} +``` +`Theme`도 동형 — `@Column(nullable=false, length=30)`(name), `@Column(nullable=false)`(description/thumbnailUrl, 컬럼명은 전략에 위임). + +**왜 이렇게 되나** +- **protected 기본 생성자**: JPA는 행을 객체로 되살릴 때 리플렉션으로 빈 객체를 만든 뒤 필드를 채운다. 인자 없는 생성자가 필수, `protected`로 외부 오용 차단. +- **`final` 제거**: JPA가 리플렉션으로 필드(특히 `id`)를 나중에 써넣어야 한다. 매핑 필드의 불변을 포기 — 이것이 "도메인 직접 매핑"의 대가. +- **`@GeneratedValue(IDENTITY)`**: H2 `AUTO_INCREMENT`에 위임. IDENTITY는 생성 키를 알아야 하므로 **INSERT를 즉시 발행**(쓰기 지연이 부분 무력화) → 1-3 관찰 ③의 복선. +- **컬럼명 전략 위임**: `CamelCaseToUnderscoresNamingStrategy`로 `startAt→start_at`, `thumbnailUrl→thumbnail_url`(실측 검증). `@Column`은 제약(nullable/length/unique)만 명시. + +**Repository — 포트 순수 유지 + JPA 어댑터 (설계 결정, 아래 결정 기록 참조)** +- 도메인 포트 `ThemeRepository`·`ReservationTimeRepository`는 **Spring import 0**로 유지. +- `*JpaRepository extends JpaRepository`(Spring Data) 신설 → CRUD 자동. +- `*RepositoryAdapter`가 포트를 구현, CRUD는 Spring Data에 위임. `JdbcThemeRepository`·`JdbcReservationTimeRepository`(+슬라이스 테스트 2) 삭제. + +### 발행 SQL 실측 (Boot 3.4.4 / Hibernate 6 / H2) + +**Hibernate 생성 DDL** (`ddl-auto=create-drop` 격리 관찰): +```sql +create table reservation_time ( + start_at time(6) not null unique, + id bigint generated by default as identity, + primary key (id) +); +create table theme ( + id bigint generated by default as identity, + name varchar(30) not null, + description varchar(255) not null, + thumbnail_url varchar(255) not null, + primary key (id) +); +``` +**save() INSERT + 바인딩**: +``` +insert into reservation_time (start_at, id) values (?, default) + binding parameter (1:TIME) <- [16:00] -> id 회수 + +insert into theme (description, name, thumbnail_url, id) values (?, ?, ?, default) + binding parameter (1:VARCHAR) <- [설명입니다] + binding parameter (2:VARCHAR) <- [탈출] + binding parameter (3:VARCHAR) <- [https://img/x.jpg] -> id 회수 +``` + +### 비교 관찰 포인트 (관찰 ①②③) + +| 관찰 | 예측 | 실제 | +|---|---|---| +| ① 시작 시 발행 DDL 차이 (schema.sql vs JPA 자동) | identity 구문·unique 이름·thumbnail_url 네이밍이 다를 수 있다 | 구조(컬럼·PK·NOT NULL)는 손글씨와 동일. 차이 4가지: ⓐ `id bigint **generated by default as identity**`(AUTO_INCREMENT 아님, SQL 표준 구문), ⓑ `start_at **time(6)** not null **unique**` — `TIME`→`time(6)`(소수초 정밀도), unique는 **인라인·무명**(예측한 자동 생성 이름이 DDL 표면엔 없음), ⓒ `thumbnail_url`(네이밍 전략 적중), ⓓ `varchar(30)`/`varchar(255)`·nullable 일치 | +| ② 재시작 데이터 보존 | create-drop+mem이라 비보존 | 비보존. 부팅마다 drop→create→(data.sql) 재적재, id 시퀀스도 리셋 | +| ③ 컬럼명·타입을 entity로만 제어 | 대부분 가능 | name·type·length·nullable·unique·컬럼명까지 어노테이션만으로 제어 확인. **단 제약 이름·INSERT 컬럼 순서·`time(6)` 정밀도는 제어 밖**(Hibernate가 결정) | + +### 확인 과제 + +**Q. 예약 생성 시 콘솔 INSERT SQL이 방탈출 미션(JDBC)과 어떻게 같고 다른가?** (1-1 Theme/ReservationTime 실측 근거; 예약 자체는 연관관계라 1-2에서 재확인) +> (예측) 컬럼·VALUES 동일, id는 생략, 바인딩 로깅만 다름. +> (실제) 대상 테이블·값의 의미는 같으나 **세 가지가 다르다**. +> ① **id가 컬럼 목록에 포함**되어 `default`로 발행 — `insert into theme (description, name, thumbnail_url, id) values (?,?,?,default)`. JDBC는 id를 안 적고 `KeyHolder`로 회수했지만 Hibernate 6은 id를 컬럼에 넣고 `default` 키워드로 DB identity에 위임한다(Hibernate 5는 생략했음). "내가 안 쓴 컬럼이 SQL에 등장" — 감춰진 SQL의 대표 사례. +> ② **컬럼 순서가 알파벳순(id 마지막)** — JDBC 손글씨는 `(name, description, thumbnail_url)`, JPA는 `(description, name, thumbnail_url, id)`. 선언 순서도 손글씨 순서도 아니다. (DDL의 컬럼 순서는 또 선언 순서라 DDL≠DML 정렬.) +> ③ **IDENTITY라 save 시점 즉시 INSERT**(쓰기 지연 무력화) + `binding parameter (1:VARCHAR) <- [값]`로 각 `?`가 타입과 함께 따로 로깅. JDBC에선 내가 안 찍으면 SQL·바인딩이 안 보였다. + +### 예측 vs 실제 — 갭 분석 + +- **적중**: `generated by default as identity`(AUTO_INCREMENT 아님), `thumbnail_url`(네이밍 전략), `varchar(30)`/`varchar(255)`·nullable, IDENTITY 즉시 INSERT, 바인딩이 `(n:TYPE) <- [값]`로 분리 로깅, 재시작 비보존. (A) +- **빗나감/놓침** — 진짜 학습 신호 3개: + 1. **id가 INSERT에 포함**된다(예측: 생략). Hibernate 6 동작. (A) + 2. **INSERT 컬럼 순서 = 알파벳순(id 마지막)**, 선언/손글씨 순서 아님. (A) + 3. **unique 제약이 인라인 무명**(예측: 자동 생성 이름). 제약 이름·컬럼 순서·`time(6)` 정밀도는 어노테이션 제어 밖 — "entity로 다 제어"엔 경계가 있다. (A) + +### 결정 기록 (차원 B) + +- **도메인 객체에 직접 `@Entity`** (0단계 확정) — 순수 도메인 + 별도 JPA 엔티티 분리 대신 직접 매핑. 근거: 1-3 영속성 컨텍스트 관찰(dirty checking·LAZY)을 살리려면 entity가 managed 상태로 살아 있어야 함. 대가: 매핑 필드 `final` 포기 + protected 생성자. **양보의 범위 한정** — 팩토리(`create`/`withId`)·생성자 검증은 유지해 "불변을 통째로 버린 게 아니라 JPA 진입로 하나를 추가로 연 것". (B) +- **포트 순수 유지 + JPA 어댑터** (← 지난 안내의 "포트를 JpaRepository로 확장" 권고에서 **변경**). 변경 근거: `findPopularBetween`(theme+reservation 집계)·`findAvailable`(reservation 안티조인)이 **아직 엔티티가 아닌 `reservation`을 참조**해 JPQL 불가. 포트를 Spring Data로 확장하면 이 둘을 native+projection 꼼수로 욱여넣어야 한다. 포트를 순수하게 두고 어댑터가 CRUD는 JPA에 위임, 교차 테이블 쿼리는 검증된 기존 SQL을 **과도기로 유지**하면 헥사고날 순수성을 지키며 컴파일도 유지. 한계: 어댑터 위임 보일러플레이트 + 과도기 SQL 잔존. 다음 정의: 1-2에서 `reservation`이 엔티티가 되면 두 쿼리를 JPQL로 승격. (B) +- **`findAvailable`은 native, `findPopularBetween`은 JdbcTemplate** (둘 다 과도기). native는 엔티티(`ReservationTime`) 매핑으로 결과를 받지만 projection 생성자는 native가 못 만들어서 인기 테마는 기존 `POPULAR_ROW_MAPPER`를 유지. (B) +- **`ddl-auto=none` + schema.sql 전체 권위** (과도기) — 요구사항은 `create-drop`이지만, reservation·waiting이 아직 비-JPA `@JdbcTest` 슬라이스에서 schema.sql 테이블을 필요로 한다. DDL 소유권을 Hibernate(theme·reservation_time)와 schema.sql(reservation·waiting)로 쪼개면 **Hibernate가 없는 슬라이스가 깨진다**(아래 차원 C). 그래서 부분 마이그레이션 동안엔 schema.sql을 단일 권위로 두고 `ddl-auto=none`, Hibernate 생성 DDL은 격리 관찰로 확보. 4개 테이블이 모두 엔티티가 되는 **1-2 종료 시 create-drop으로 전환**. (B) + +### 테스트 변경 사항 (차원 C) + +- **삭제**: `JdbcThemeRepositoryTest`·`JdbcReservationTimeRepositoryTest`(@JdbcTest 슬라이스) — 대상 클래스(Jdbc 구현)가 제거되어 더 이상 컴파일 대상이 아님. JPA 어댑터 슬라이스 테스트는 `reservation`이 엔티티가 되어 `findAvailable`/`findPopular`를 슬라이스에서 검증 가능한 1-2 이후로 미룸. +- **그대로 통과**: `ThemeServiceTest`·`ReservationTimeServiceTest`는 포트(인터페이스)에 `@Autowired`라 구현 교체에 영향 없음. 인수·통합·예약/대기 슬라이스 포함 **144개 전부 green**. +- **깨졌다 고친 것**: 처음에 "Hibernate가 theme·reservation_time DDL 생성 + schema.sql은 reservation·waiting만" 구성으로 시도 → `@JdbcTest`(Hibernate 없음)와 `@SpringBootTest`(schema.sql이 Hibernate보다 먼저 실행, defer 기본 false)에서 `reservation`의 FK가 아직 없는 `reservation_time`을 참조 → `ScriptStatementFailedException`(63개 실패). 원인: **DDL 소유권 분할이 비-JPA 슬라이스와 양립 불가**. 수정: schema.sql 전체 복구 + `ddl-auto=none`(main·test 양쪽). (C) + +### 피드백 채널 신호 (차원 C) + +- **SQL 로그에서 의외였던 것**: INSERT에 `id ... default`가 등장(안 쓴 컬럼), 컬럼이 알파벳순, DDL의 unique가 무명 인라인. +- **예외가 먼저 알려준 것**: `ScriptStatementFailedException` — "schema.sql과 Hibernate DDL은 별개 메커니즘이고, 소유권을 쪼개면 Hibernate 없는 슬라이스가 깨진다"를 컴파일/부팅 실패가 먼저 가르쳐 줌. DDL 스크립트 추출 옵션(`jakarta…scripts.action`)도 실제 스키마 생성을 가로채 data.sql을 빈 DB에 떨어뜨림(관찰 도구 세팅 교훈). + +### 막힌 지점 → 다음 정의 + +- 막힘: 교차 테이블 쿼리(`findPopularBetween`·`findAvailable`)가 미엔티티 `reservation`을 참조 → JPQL 불가, DDL 소유권 분할 → 슬라이스 붕괴. +- 다음 정의: 1-2에서 `Reservation`(+`Waiting`)을 `@Entity`로 전환 → ① 두 쿼리를 JPQL로 승격, ② schema.sql 제거 + `ddl-auto=create-drop` 전환, ③ JPA 어댑터 슬라이스 테스트(@DataJpaTest) 도입. + +### 얻은 인사이트 + +- (A) "id는 INSERT에서 빠진다"는 직관이 틀렸다 — Hibernate 6은 id를 `default`로 넣는다. 안 쓴 컬럼이 SQL에 나타나는 게 자동화의 비용. +- (A) 제약 이름·INSERT 컬럼 순서·`time(6)` 정밀도는 어노테이션 제어 밖 — "entity로 다 제어"의 경계. +- (B) 직접 매핑은 불변(`final`)을 포기시키지만, 팩토리·검증을 남겨 양보 범위를 한정할 수 있다. +- (B) 포트 설계는 "엔티티화가 어디까지 됐나"에 종속된다 — 미엔티티 테이블을 참조하는 쿼리가 포트의 결합 방식(확장 vs 어댑터)을 좌우했다. +- (C) `ddl-auto`(Hibernate)와 schema.sql(Boot SQL init)은 별개 메커니즘. 부분 마이그레이션에선 한쪽을 단일 권위로 두지 않으면 슬라이스가 깨진다. + +### 이 단계가 회수한 차원 + +- A: DDL/INSERT 실측으로 어노테이션→SQL 추적(id default·알파벳 순서·무명 unique·time(6) 발견). +- B: 직접 @Entity 양보 한정 / 포트 순수+어댑터(권고 변경 근거) / ddl-auto=none 과도기 / native·JdbcTemplate 과도기. +- C: 슬라이스 붕괴 예외가 DDL 소유권 원칙을 가르침 / 슬라이스 테스트 삭제·144 green. + +## 1-2. 연관관계 매핑 (Reservation·Waiting → Theme, ReservationTime) + +> ⚠️ step2엔 member 없음 → `Reservation`·`Waiting`은 `Theme`·`ReservationTime`만 `@ManyToOne`. 예약자 `name`은 String 유지(member 도입은 2단계 판단). +> ✅ **1-2 완료.** 이 단계가 **게이트** — 네 테이블이 모두 엔티티가 되면서 1-1의 과도기 군더더기(native·JdbcTemplate·`ddl-auto=none`·schema.sql)가 전부 증발하고, JPQL 승격·`create-drop` 전환이 가능해졌다. + +### 들어가기 전 자기진단 (진입 가설) + +**Q. `@ManyToOne`을 붙이면 DB엔 무엇이 생기나? 객체 참조는 어떤 SQL로 컴파일되나?** +> (가설) `time`·`theme` 객체 참조는 DB에선 결국 `time_id`·`theme_id` FK 컬럼으로 떨어지고, `@JoinColumn(name="time_id")`가 그 이름을 고정한다. INSERT 시엔 객체의 `getId()`가 FK 자리에 바인딩될 것이다. 조회 시 EAGER면 처음부터 join, LAZY면 접근 시점 추가 SELECT. + +**Q. 1-1에서 못 올린 교차 쿼리(`findPopular`·`findAvailable`)는 이제 JPQL로 깔끔해지나?** +> (가설) `reservation`이 엔티티가 됐으니 JPQL이 가능. `findAvailable`은 `not in (select r.time.id ...)` 안티조인, `findPopular`는 `Theme`·`Reservation` 세타조인 + `group by`로 표현될 것. limit은 메서드 이름/Pageable로. + +### 요구사항 (LMS) + +- `@ManyToOne` + `@JoinColumn(name="..._id")`로 객체 참조 / **단방향 시작**, 양방향은 필요 생기면 +- 양방향 시 연관관계 주인 명시 + 무한 직렬화 검토 / `cascade`·`orphanRemoval`은 필요 전 미적용 + +### 전환 전 (JDBC) + +`JdbcReservationRepository` — 3중 조인 SELECT + 손수 RowMapper 조립 + KeyHolder save: +```java +// 조회: reservation INNER JOIN reservation_time INNER JOIN theme, 별칭으로 평탄화 +private static final RowMapper ROW_MAPPER = (rs, n) -> { + ReservationTime time = ReservationTime.withId(rs.getLong("time_id"), rs.getTime("time_start_at").toLocalTime()); + Theme theme = Theme.withId(rs.getLong("theme_id"), rs.getString("theme_name"), ...); + return Reservation.withId(rs.getLong("reservation_id"), ..., time, theme); // 조인된 행을 손으로 재조립 +}; +// save: INSERT + KeyHolder, FK는 객체에서 getId()를 꺼내 수동 바인딩 +ps.setLong(3, reservation.getTime().getId()); +ps.setLong(4, reservation.getTheme().getId()); +``` +- `Waiting`도 동형(+ `order_index`, `updateOrderIndex` bulk SQL). +- 핵심: **조인된 평탄한 행 → 객체 그래프**를 RowMapper가 손으로 조립. 이 수작업이 JPA로 옮겨가며 사라지는 게 1-2의 본질. + +### 전환 후 (JPA) + +**엔티티 — 단방향 `@ManyToOne`**: +```java +@Entity +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"date", "time_id", "theme_id"})) +public class Reservation { + @Id @GeneratedValue(strategy = IDENTITY) private Long id; + @Column(nullable = false) private LocalDate date; + + @ManyToOne(optional = false) @JoinColumn(name = "time_id") + private ReservationTime time; // 객체 참조 — DB엔 time_id FK + @ManyToOne(optional = false) @JoinColumn(name = "theme_id") + private Theme theme; + // protected 기본 생성자 + 팩토리(create/withId/promote) + 검증 + dateTime() 보존 +} +``` +- `Waiting`도 동형 + `@Column(name="order_index")`, `@Table`에 복합 UNIQUE **2개**(`+order_index`, `+name`). + +**왜 이렇게 되나** +- **`@ManyToOne(optional=false) @JoinColumn(name="time_id")`**: 객체 참조 `time`을 DB의 `time_id` FK 컬럼에 매핑. `optional=false` → `NOT NULL`. RowMapper의 손조립이 사라지고 Hibernate가 join→객체 그래프를 자동 복원. +- **`@Table(uniqueConstraints)`**: schema.sql을 지우면 손글씨 UNIQUE도 사라지므로, "분리 모델의 마지막 방어선"인 복합 UNIQUE를 엔티티 메타데이터로 이전. (분리 reservation/waiting이라야 성립하던 그 제약.) +- **fetch 기본값(EAGER) 유지**: `@ManyToOne`은 기본 EAGER. 일부러 `fetch=LAZY`를 안 붙였다 — 1-3 관찰⑤(기본값)·3-1(N+1)에서 **기본값이 만드는 SQL을 먼저 본 뒤** 튜닝하려고. "기본값→문제→해결" 아크를 위해 의도적으로 비워둠. + +**리포지토리 — 포트 순수 유지 + 어댑터, 파생 쿼리 + @Modifying + JPQL** +- 포트 17개 메서드(Reservation 11 + Waiting 6)를 Spring Data로 이전: + - CRUD는 `JpaRepository` 내장 / exists·findBy는 **파생 쿼리**(프로퍼티 경로 탐색): + - `existsByDateAndTime_IdAndTheme_Id` — 밑줄 `Time_Id`로 `time.id` 탐색(FK 컬럼 직격, 조인 불필요) + - `existsByDateAndTime_IdAndTheme_IdAndIdNot` — "자기 자신 제외(id != ?)"를 `IdNot` 키워드로 + - `findByNameOrderByDateAscTime_StartAtAsc` — `Time_StartAt`로 `time.startAt` 정렬 + - 부분 갱신은 **`@Modifying` bulk JPQL**: `updateDateAndTime`(예약 변경), `updateOrderIndex`(순번). `clearAutomatically=true, flushAutomatically=true`로 영속성 컨텍스트와 동기화. +- 1-1 과도기 교차쿼리 **JPQL 승격** (reservation이 엔티티가 됨): + - `findAvailable`: native → JPQL `not in (select r.time.id ...)` + - `findPopularBetween`: JdbcTemplate → JPQL 생성자 표현식 `new PopularThemeProjection(t, count(r))` + 세타조인 + `group by` + Pageable(limit) + +### 발행 DDL 실측 — 객체 참조가 무엇으로 컴파일되나 + +`@ManyToOne @JoinColumn` + `@Table` → Hibernate DDL: +```sql +create table reservation ( + date date not null, + id bigint generated by default as identity, + theme_id bigint not null, + time_id bigint not null, + name varchar(30) not null, + primary key (id), + unique (date, time_id, theme_id) -- @Table 복합 UNIQUE 보존 +); +-- 별도 ALTER: +-- foreign key (theme_id) references theme +-- foreign key (time_id) references reservation_time + +create table waiting ( + ... order_index integer not null, ..., + unique (date, time_id, theme_id, order_index), -- @Table UNIQUE 2개 + unique (date, time_id, theme_id, name) +); +``` +**관찰**: ⓐ `@ManyToOne @JoinColumn(name="time_id")` → **`time_id bigint not null` 컬럼 + `foreign key references reservation_time` 제약**. 객체 참조가 FK 컬럼+FK 제약으로 컴파일됨이 눈에 보임. ⓑ `@Table(uniqueConstraints)` → 복합 UNIQUE 3개 전부 생성(schema.sql 없이도 분리 모델의 방어선 유지). ⓒ 컬럼 순서는 또 알파벳순(1-1과 동일). + +### 발행 SQL 실측 — 런타임 (show-sql + bind, 격리 관찰) + +**save (INSERT)** — 객체 참조의 FK가 바인딩되는 모양: +``` +insert into reservation (date, name, theme_id, time_id, id) values (?, ?, ?, ?, default) + binding (1:DATE) <- [2099-01-01] + binding (2:VARCHAR) <- [관찰자] + binding (3:BIGINT) <- [1] ← theme.getId() + binding (4:BIGINT) <- [1] ← time.getId() +``` +→ 객체 `theme`/`time`이 FK `theme_id`/`time_id` 자리에 **각자의 id로 바인딩**. 가설 적중. + +**findById (EAGER, id로 조회)** — 한 방에 join fetch: +```sql +select r1_0.id, r1_0.date, r1_0.name, + t1_0.id, t1_0.description, t1_0.name, t1_0.thumbnail_url, -- theme 컬럼 전부 + t2_0.id, t2_0.start_at -- time 컬럼 전부 +from reservation r1_0 +join theme t1_0 on t1_0.id = r1_0.theme_id +join reservation_time t2_0 on t2_0.id = r1_0.time_id +where r1_0.id = ? +``` +→ **EAGER 두 연관을 단일 쿼리로 join fetch.** 이후 `getTime().getStartAt()`·`getTheme().getName()` 접근 시 **추가 SELECT 0** (이미 로딩됨). + +**findByName (파생 쿼리)** — 여기서 N+1이 새어나옴: +```sql +-- ① 메인: ORDER BY time.start_at 위해 reservation_time만 조인, SELECT은 reservation 컬럼 + FK id만 +select r1_0.id, r1_0.date, r1_0.name, r1_0.theme_id, r1_0.time_id +from reservation r1_0 join reservation_time t1_0 on t1_0.id=r1_0.time_id +where r1_0.name=? order by r1_0.date, t1_0.start_at +-- ② theme를 따로: select ... from theme where id=? +-- ③ time을 따로: select ... from reservation_time where id=? +``` +→ **distinct 3쿼리**(메인 + EAGER theme + EAGER time). 결과 1건이라 1+2지만, **N건이면 1 + N×2 = 전형적 N+1.** 파생/HQL 쿼리는 EAGER 연관을 자동 join fetch 하지 **않고** 행마다 보조 SELECT로 끌어온다. ← **3-1 N+1·fetch join 관찰의 씨앗이 1-2에서 먼저 터짐.** + +**findAvailable (JPQL 안티조인 → SQL)**: +```sql +select rt1_0.id, rt1_0.start_at from reservation_time rt1_0 +where rt1_0.id not in ( + select r1_0.time_id from reservation r1_0 where r1_0.date=? and r1_0.theme_id=? +) +``` +→ JPQL `r.time.id`가 조인 없이 FK 컬럼 `r1_0.time_id`로 직격. native 꼼수 제거 성공. + +**findPopular (JPQL 세타조인+집계 → SQL)**: +```sql +select t1_0.id, t1_0.description, t1_0.name, t1_0.thumbnail_url, count(r1_0.id) +from theme t1_0, reservation r1_0 +where r1_0.theme_id=t1_0.id and r1_0.date>=? and r1_0.date **덤 관찰**: `show-sql=true`와 `logging.level.org.hibernate.SQL=DEBUG`를 둘 다 켜서 **모든 쿼리가 두 번 로깅**됨(처음 select 수를 2배로 세게 만든 함정). 하나로 충분. + +### 확인 과제 + +**Q. `findById(reservationId).getTime().getStartAt()`이 발행하는 SQL은?** +> (예측) EAGER면 처음부터 join, LAZY면 getStartAt() 시점 추가 SELECT. +> (실제) **EAGER라 `findById`가 reservation+theme+reservation_time을 단일 쿼리로 join fetch**(위 SQL). 따라서 `getTime().getStartAt()` 접근 시점엔 **추가 SQL 0** — 이미 메모리에 로딩돼 있음. LAZY였다면 접근 순간 `select ... from reservation_time where id=?`가 한 번 더 나갔을 것(1-3 ⑥에서 LazyInit과 함께 관찰). + +### N+1 예고 (→ 3-1) + +같은 EAGER 연관인데 **진입 경로에 따라 SQL이 갈렸다**: `findById`(id 조회)는 join fetch 1쿼리, `findByName`(파생 쿼리)은 메인 + 연관별 보조 SELECT(N+1). "EAGER면 항상 join"이 아니라 **"by-id는 join fetch, 쿼리는 보조 SELECT"**가 Hibernate의 실제 규칙. 3-1에서 `reservations-mine`(예약 N + 대기 M)을 DTO 변환할 때 이 N+1을 본격 재현하고 fetch join/`@EntityGraph`로 닫는다. + +### flush 순서 발견 (→ 4단계) + +전환 직후 **승급 흐름 테스트 1개가 `ConstraintViolationException`으로 실패**. 원인 추적: +- `deleteByOwner`: `reservationRepository.deleteById(id)`(취소) → 첫 대기 `save(Reservation.promote(first))`(승급). +- JDBC에선 DELETE가 즉시 실행됐지만, JPA `deleteById`(em.remove)는 **flush까지 지연**. 반면 `@GeneratedValue(IDENTITY)`의 `save`는 키 회수를 위해 **즉시 INSERT**. +- 결과: 옛 예약이 아직 안 지워진 채 **같은 슬롯에 새 예약 INSERT** → `UNIQUE(date,time_id,theme_id)` 충돌. JPA 쓰기지연은 flush 시 **INSERT→UPDATE→DELETE 순서 규칙**을 갖기 때문(내 의도 순서 delete-before-insert와 정반대). +- 수정: `ReservationRepositoryAdapter.deleteById`에서 `deleteById` 후 `flush()` 호출 → DELETE를 DB에 먼저 보내 **delete-before-insert(JDBC의 즉시 DELETE 의미) 복원** → green. +- 이건 4단계가 "예측-실제 갭이 가장 크게 날 자리"로 예고한 **flush 순서 관찰의 실물**. 4단계에서 자동 승인 트랜잭션 경계와 함께 깊게 다룬다. + +### 결정 기록 (차원 B) + +- **단방향 `@ManyToOne` 선택** — Theme→Reservations 역참조를 쓰는 쿼리가 0이다. 양방향은 주인 관리(`mappedBy`)·무한 직렬화 위험·생명주기 결합만 늘림. **명시적 비결정**: 양방향·`cascade`·`orphanRemoval` 미적용(필요 시 PR 근거). LMS의 "양방향 시도→후퇴 사이클"은 — 시도해 본 결과 **얻는 것 없이 비용만 늘어 즉시 후퇴**가 정직한 흔적. (B) +- **fetch 기본값(EAGER) 유지** — 일부러 LAZY를 안 붙임. 근거: 1-3·3-1에서 기본값이 만드는 SQL(특히 N+1)을 먼저 관찰한 뒤 튜닝하는 "기본값→문제→해결" 학습 아크. 한계: 그동안 N+1이 실재(위 findByName) → 3-1까지 의도적으로 안고 감. (B) +- **부분 갱신은 `@Modifying` bulk(현재) vs dirty checking(1-3 예고)** — `updateDateAndTime`/`updateOrderIndex`를 bulk JPQL로(JDBC 즉시 UPDATE 의미 보존, projection 없는 단순 갱신). dirty checking으로 가려면 도메인에 변경 메서드가 필요한데, 그건 1-3 ①에서 "load+mutate vs bulk"를 비교하며 재결정. (B) +- **`updateDateAndTime`의 time 참조** — bulk JPQL `set r.time = :time`에 `getReferenceById(timeId)`(식별자 프록시)를 넘김. 실제 SELECT 없이 FK만 갱신. (B) + +### 테스트 변경 사항 (차원 C) + +- **삭제**: `JdbcReservationRepositoryTest`·`JdbcWaitingRepositoryTest`(@JdbcTest 슬라이스) — 대상 Jdbc 구현 제거로 무효화. 이로써 1-1·1-2에 걸쳐 구 Jdbc 슬라이스 4종 정리 완료. JPA 어댑터 슬라이스(@DataJpaTest)는 후속 도입 후보. +- **그대로 통과**: 서비스 테스트는 포트 의존이라 무영향. `@MockitoSpyBean`이 포트(`WaitingRepository`)를 감싸므로 트랜잭션 롤백 테스트도 어댑터 빈을 그대로 spy → 유지. +- **깨졌다 고친 것**: 승급 흐름 1건이 flush 순서로 `ConstraintViolation`(위) → `deleteById` 후 `flush()`로 수정. 전체 **134개 green**(1-1·1-2 합산 슬라이스 정리 반영). + +### 피드백 채널 신호 (차원 C) + +- **예외가 먼저 가르친 것**: `ConstraintViolationException` 한 줄이 "JPA flush는 INSERT→DELETE로 재정렬한다 → delete-before-insert는 명시적 flush로만 보장된다"를 알려줌. 내가 SQL 순서를 직접 짜던 JDBC의 통제권을 자동화에 내준 비용. +- **SQL 로그가 보여준 의외**: 같은 EAGER인데 by-id는 join fetch·쿼리는 보조 SELECT(N+1) / 이중 로깅(설정 둘 켜짐). + +### 막힌 지점 → 다음 정의 + +- 막힘: ① EAGER + 파생 쿼리에서 N+1이 실재 ② 승급의 flush 순서. +- 다음 정의: ① 3-1에서 N+1을 `reservations-mine`로 재현 → fetch join/`@EntityGraph`로 닫고 row 중복 처리 비교. ② 4단계에서 flush 순서를 자동 승인 트랜잭션 경계와 함께 정식 관찰. ③ 1-3에서 dirty checking·1차캐시·LazyInit을 이제 살아있는 연관관계 위에서 관찰. + +### 인사이트 + +- (A) 객체 참조 `@ManyToOne`은 DB에서 **FK 컬럼 + FK 제약**으로 컴파일된다(DDL 실측). INSERT는 객체의 id를 FK에 바인딩. +- (A) "EAGER면 항상 join"은 거짓 — **by-id는 join fetch, 파생/HQL 쿼리는 연관별 보조 SELECT(N+1).** 진입 경로가 SQL을 가른다. +- (A) JPQL은 `r.time.id`를 조인 없이 FK로 직격하고, 세타조인·`group by`·Pageable을 표준 SQL(`fetch first ? rows only`)로 컴파일 — 1-1 과도기 native/JdbcTemplate가 전부 불필요해짐. +- (B) flush 순서(INSERT→UPDATE→DELETE)는 내 의도 순서와 충돌할 수 있다. delete-before-insert는 **명시적 flush로만** 보장된다. +- (C) 복합 UNIQUE를 `@Table`로 이전해 schema.sql 없이도 분리 모델의 방어선을 유지 — DB 제약은 여전히 마지막 방어선. + +### 이 단계가 회수한 차원 + +- A: @ManyToOne→FK 컬럼+제약 / by-id join fetch vs 쿼리 N+1 / JPQL 안티조인·세타조인·Pageable→SQL 실측. +- B: 단방향 선택 + 양방향·cascade 명시적 비결정(시도→후퇴) / fetch 기본값 유지(아크) / @Modifying vs dirty checking(1-3 예고) / flush 순서 수정. +- C: ConstraintViolation이 flush 순서 규칙을 가르침 / 이중 로깅 발견 / 슬라이스 4종 정리·134 green. + +--- + +## 1-3. 영속성 컨텍스트 관찰 ⭐ 차원 A 핵심 + +> ✅ **관찰 완료** (Boot 3.4.4 / Hibernate 6 / H2). `TransactionTemplate`으로 tx 경계를 직접 통제하며 6개를 4필드(시도/예측/실제/왜)로 캡처. +> ⚠️ **장치 주의**: 우리 도메인(Theme/Reservation/Waiting)은 **세터 없는 불변 스타일**이라 dirty checking ①은 자연 트리거가 없다 → 리플렉션으로 필드를 강제 변경해 *메커니즘만* 관찰. 이 "트리거 부재" 자체가 1-2에서 미뤄둔 `@Modifying vs dirty checking` 결정의 답이 된다(① 끝). +> 관찰 러너(`_Obs.java`)는 푸시하지 않음 — 관찰 도구일 뿐 프로덕션 코드 변경 0. + +### ① dirty checking + +``` +시도: @Transactional 안에서 em.find(Theme,1) → 리플렉션으로 name 변경 → save/persist 미호출 → 블록 종료(commit) +예측: commit 시점에 UPDATE 자동 발행? +실제: 발행됨. 블록 종료(commit) 시 UPDATE 한 방. +왜: 영속성 컨텍스트가 load 시점에 엔티티 스냅샷을 떠 두고, flush(=commit 직전)에 현재 상태와 비교해 변경 감지 → UPDATE 합성. +``` +발행 SQL: +```sql +update theme set description=?, name=?, thumbnail_url=? where id=? +``` +**관찰 포인트**: 변경한 건 `name` 하나인데 **전 컬럼을 UPDATE**(`description`·`thumbnail_url`까지). `@DynamicUpdate` 무적용 기본값 — Hibernate는 변경 컬럼만 추리지 않고 전 컬럼 SET. + +**→ 1-2 미결 결정 종결 (@Modifying bulk vs dirty checking)**: 우리 도메인은 세터가 없어 dirty checking의 **자연 트리거가 0**(리플렉션 없인 발동 불가). 게다가 발동돼도 full-column UPDATE. 반면 `@Modifying` bulk JPQL(`updateDateAndTime`/`updateOrderIndex`)은 ⓐ 불변 도메인과 충돌 없고 ⓑ 타깃 컬럼만 갱신 ⓒ managed 상태·tx 없이도 동작. **결론: @Modifying bulk 유지 확정.** (B) + +### ② 1차 캐시 + +``` +시도: 같은 @Transactional 안에서 em.find(Theme,1) 두 번 +예측: 두 번째는 SELECT 생략? +실제: 2번째 SELECT 0. 그리고 t1 == t2 (동일 인스턴스). +왜: 영속성 컨텍스트가 (엔티티타입,id)로 1차 캐시. 첫 find가 DB→캐시 적재, 둘째는 캐시 반환. 같은 tx 내 동일성(identity) 보장. +``` +첫 find만 `select ... from theme where id=?` 1회, 둘째는 무발행. **동일성 보장**은 dirty checking·병합 안전성의 토대. + +### ③ 쓰기 지연 (write-behind) + +``` +시도: @Transactional 안에서 em.persist(ReservationTime.create(23:00)) 후 즉시 마커 +예측: INSERT가 flush/commit까지 지연? (단 IDENTITY는 즉시일 것) +실제: persist() 호출 "그 순간" 즉시 INSERT. +왜: @GeneratedValue(IDENTITY)는 식별자를 DB가 발급 → persist가 엔티티를 managed로 만들려면 id가 필요 → INSERT를 미룰 수 없음. +``` +→ **1-1 관찰 ③의 복선 회수** + **1-2 flush 순서 버그의 뿌리 확증**: "IDENTITY save는 즉시 INSERT"가 바로 승급 흐름에서 옛 예약 DELETE(지연)보다 새 예약 INSERT(즉시)가 앞서 UNIQUE 충돌을 낸 원인이었다. SEQUENCE/TABLE 전략이었다면 INSERT도 지연돼 충돌이 안 났을 수도(전략이 동시성·flush 순서까지 바꾼다). + +### ④ flush 시점 + +``` +시도: @Transactional 안에서 Theme를 dirty로 만든 뒤, JPQL `select count(t) from Theme t` 실행 +예측: 쿼리 실행 전에 변경분 동기화(flush)? +실제: JPQL 직전에 UPDATE(auto-flush) → 그 다음 count SELECT. +왜: FlushMode.AUTO 기본 — 쿼리가 변경분을 반영한 결과를 보도록 쿼리 실행 전에 flush. +``` +발행 순서: +```sql +update theme set description=?, name=?, thumbnail_url=? where id=? -- auto-flush +select count(t1_0.id) from theme t1_0 -- 그 다음 쿼리 +``` +→ flush 트리거 3종(commit / 명시적 `flush()` / JPQL 실행 전 auto-flush) 중 **JPQL-before auto-flush**를 실측. 1-2에서 `deleteById` 후 명시적 `flush()`를 넣은 것도 이 flush 통제의 일종. + +### ⑤ fetch 기본값 (@ManyToOne) + +``` +시도: em.clear() 후 em.find(Reservation, id) — 기본 fetch로 조회 +예측: @ManyToOne 기본 EAGER → 처음부터 조인 +실제: 단일 SELECT가 theme + reservation_time 동시 join fetch +왜: @ManyToOne 기본값이 EAGER. find(by-id)는 EAGER 연관을 조인으로 한 번에. +``` +```sql +select r1_0.id, r1_0.date, r1_0.name, + t1_0.id, t1_0.description, t1_0.name, t1_0.thumbnail_url, + t2_0.id, t2_0.start_at +from reservation r1_0 join theme t1_0 on ... join reservation_time t2_0 on ... +``` +**단, 이 모델엔 `@OneToMany`가 없다**(단방향 `@ManyToOne`만 — 1-2 결정의 결과) → "@OneToMany는 LAZY 기본"을 직접 대조할 대상이 없음. 대신 1-2의 **"by-id는 join fetch / 파생·HQL 쿼리는 연관별 보조 SELECT(N+1)"** 발견과 함께 봐야 ⑤가 완성된다 — EAGER는 *언제나 조인*이 아니라 *진입 경로에 따라 조인 또는 보조 SELECT*. + +### ⑥ LazyInitializationException + +``` +시도 a: EAGER 연관(reservation.time)을 tx 종료(detach) 후 접근 +시도 b: em.getReference(Theme,1) 프록시를 tx 밖에서 getName() 접근 +예측: 컨텍스트 닫힌 후 미초기화 프록시 접근 → 예외? +실제 a: 정상. getStartAt() == 10:00, 추가 SQL 0. +실제 b: LazyInitializationException. +왜: EAGER는 load 시점에 이미 초기화 완료 → detach 후에도 값이 메모리에 있음. getReference는 미초기화 프록시 → 접근 순간 초기화를 시도하지만 컨텍스트가 닫혀 DB 접근 불가 → 예외. +``` +**→ reads 트랜잭션-free 결정과의 연결(이 미션의 핵심 고리)**: 지금은 모든 연관이 EAGER라 **tx 밖 읽기가 안전**(detach된 엔티티의 연관 접근 OK). 그런데 3-1에서 N+1을 잡으려 `@ManyToOne(fetch=LAZY)`로 바꾸는 순간, **트랜잭션-free read는 LazyInit 위험으로 돌변**한다 — 컨트롤러/직렬화 시점에 LAZY 연관을 건드리면 ⑥(b)와 똑같은 예외. 그때 선택지는 fetch join / `@EntityGraph` / (반대편 극단)OSIV. ⑥은 **그 미래 위험의 예고편**이고, "reads를 tx-free로 둔다"는 결정이 LAZY 전환과 충돌할 좌표를 미리 찍어 둔 것. + +### 결정 기록 (차원 B) + +- **`@Modifying` bulk 유지 확정** — ① 결과. 불변 도메인(세터 0) + 타깃 컬럼 갱신 + tx-독립. dirty checking은 메커니즘으로만 이해하고 채택하지 않음. (B) +- **reads 트랜잭션-free 유지(현재) + LAZY 전환 시 재검토 예약** — ⑥ 결과. EAGER인 현재는 안전하나, 3-1 LAZY 전환 시 fetch join/`@EntityGraph`로 read 경로를 명시적으로 채워야 함. "지금 안 하는 것"을 좌표와 함께 기록. (B) + +### 테스트 변경 사항 (차원 C) + +- 관찰 단계라 **프로덕션/테스트 코드 변경 0**(관찰 러너 `_Obs.java`는 푸시 안 함). 1-1·1-2에서 정리된 **134개 green** 그대로 유지. + +### 피드백 채널 신호 (차원 C) + +- **SQL 로그가 가르친 것**: dirty checking은 full-column UPDATE(@DynamicUpdate 없음) / auto-flush가 JPQL 앞에 UPDATE를 끼워넣음 / IDENTITY persist의 즉시 INSERT가 1-2 flush 버그의 뿌리. +- **예외가 가르친 것**: `LazyInitializationException`(getReference 프록시, tx 밖) — "초기화 안 된 프록시 + 닫힌 컨텍스트"의 조합. EAGER인 지금은 안 나지만 LAZY 전환 시 read 경로 전체가 후보가 됨. + +### 막힌 지점 → 다음 정의 + +- 막힘: 없음(6개 관찰 모두 예측-검증 성공). +- 다음 정의: ⑤⑥ → 3-1(N+1 재현·LAZY 전환·fetch join/@EntityGraph) / ③④ → 4단계(flush 순서·자동 승인 트랜잭션 경계) / ① → 갱신은 @Modifying로 일관. + +### 얻은 인사이트 + +- (A) dirty checking은 load 스냅샷↔flush 비교로 UPDATE를 *합성*하며, 기본은 full-column UPDATE. (A) +- (A) 1차 캐시는 (타입,id) 기준 동일성까지 보장 — 같은 tx 두 번째 조회는 무발행. (A) +- (A) IDENTITY는 쓰기지연을 무력화(persist=즉시 INSERT) → flush 순서 문제의 근원. (A) +- (A) auto-flush(FlushMode.AUTO)가 JPQL 앞에 변경분을 밀어넣어 쿼리 일관성을 맞춘다. (A) +- (B) 불변 도메인에선 dirty checking이 dormant → @Modifying이 자연스러운 갱신 경로. (B) +- (B) EAGER는 tx-free read를 안전하게 하지만, N+1을 잡으러 LAZY로 가면 같은 read가 위험해진다 — fetch 전략과 트랜잭션 경계는 한 묶음. (B) + +### 이 단계가 회수한 차원 + +- A: dirty checking UPDATE·1차캐시 무발행·IDENTITY 즉시 INSERT·auto-flush 순서·EAGER 조인·LazyInit 실측(6/6). +- B: @Modifying 유지 확정 / reads tx-free + LAZY 전환 시 재검토. +- C: full-column UPDATE·auto-flush·LazyInit을 로그/예외가 직접 가르침 / 코드 변경 0·134 green 유지. + +--- + +## 1단계 종합 — 회수 차원 총괄 (1-1 + 1-2 + 1-3) + +> PR 본문(07)·토론(06)으로 끌어올릴 1단계의 결론을 한자리에. + +**차원 A (매핑 정확성 — 어노테이션→발행 SQL)** +- 엔티티: `@GeneratedValue(IDENTITY)`→`generated by default as identity` + persist 즉시 INSERT / 컬럼 알파벳순·id가 `default`로 INSERT에 포함 / `time(6)`·무명 인라인 unique. +- 연관: `@ManyToOne @JoinColumn`→FK 컬럼+FK 제약 / `@Table`→복합 UNIQUE 보존 / EAGER는 **by-id 조인 vs 쿼리 보조 SELECT(N+1)**로 갈림. +- 컨텍스트: dirty checking(full-column UPDATE)·1차캐시(무발행·동일성)·쓰기지연(IDENTITY 무력화)·auto-flush(JPQL 전)·LazyInit(getReference 프록시). +- JPQL: 안티조인·세타조인·생성자 표현식·`Pageable→fetch first ? rows only`. + +**차원 B (설계 판단 — 대안·근거·한계)** +- 도메인 직접 `@Entity`(불변 일부 양보, 팩토리·검증 유지) / 포트 순수 + JPA 어댑터(권고에서 변경) / 단방향 `@ManyToOne`(양방향·cascade 명시적 비결정) / fetch 기본값 유지(기본값→문제→해결 아크) / `@Modifying` bulk 유지(dirty checking 대신) / reads tx-free(LAZY 전환 시 재검토). + +**차원 C (피드백 순환 — 로그·예외·테스트·리뷰)** +- `ScriptStatementFailedException`이 DDL 소유권 분할 불가를 가르침 / `ConstraintViolation`이 flush 순서 규칙을 가르침 / `LazyInitializationException`이 프록시·컨텍스트 수명을 가르침 / 구 Jdbc 슬라이스 4종 정리·**134 green**. + +**다음 단계로 넘긴 복선** +- N+1(findByName/EAGER) → **3-1** fetch join/@EntityGraph +- flush 순서/IDENTITY 즉시 INSERT → **4단계** 자동 승인 트랜잭션 경계 +- 예약자 `name` String 유지 vs member 도입 → **2단계** 판단 +- reads tx-free + LAZY 전환 위험 → **3-1** read 경로 명시 diff --git a/docs/03-step2-my-reservations.md b/docs/03-step2-my-reservations.md new file mode 100644 index 0000000000..29b699f1a4 --- /dev/null +++ b/docs/03-step2-my-reservations.md @@ -0,0 +1,130 @@ +# 03 · 2단계 — 내 예약 목록 조회 + +> **쿼리 메서드 도입의 벽** 단계. 단일 API라 1단계보다 가볍다. (관찰표 없음 — 이 단계가 만드는 건 "쿼리 작성 방식 결정"이 핵심) +> ✅ **상태**: 기능은 1-2에서 `findByName` 파생 쿼리를 만들 때 **이미 충족**됨(`GET /reservations-mine` 동작). 그래서 2단계는 *코드 추가*가 아니라 **결정 명시 + mine 경로 N+1 관찰 + 기록**이 본체. "이미 됐다"를 정직히 적는 것이 이 단계의 신호. + +--- + +## 들어가기 전 자기진단 + +**Q. 내 예약 목록 조회를 풀 때, 메서드 이름 쿼리(`findByMemberId`)와 JPQL(`@Query`) 중 어느 쪽이 먼저 떠오르나? 그 직감의 근거는?** +> (직감) **메서드 이름 쿼리가 먼저**. 조건이 `name` 하나 + 정렬뿐이라 `findByNameOrderBy...`로 끝난다. JPQL은 fetch join·집계·다중 엔티티 결합처럼 *표현력*이 필요할 때 꺼내는 카드. 근거: 메서드 이름 쿼리는 "조건이 단순할 때 가장 적은 코드", JPQL은 "이름으로 못 적는 걸 적을 때". + +--- + +## 요구사항 (LMS) + +- `GET /reservations-mine` — 인증된 사용자의 예약(+대기) 목록 + +> ⚠️ **내 코드 특수 상황**: step2엔 member가 없고, "내 예약"은 `ReservationRepository.findByNameOrderByDateAscTimeAsc(name)` 즉 **이름 기준**으로 풀려 있음. 그래서 이 단계의 진짜 결정은 두 갈래(아래 결정 1). + +--- + +## 현황 — 기능은 1-2에서 이미 동작 (전환 전/후 통합) + +`GET /reservations-mine?name=` 흐름: +``` +UserReservationController.myList(name) + → ReservationService.findMyReservationsAndWaitings(name) // @Transactional 아님(reads tx-free) + → reservationRepository.findByNameOrderByDateAscTimeAsc(name) // 예약 + → waitingRepository.findByName(name) // 대기 + → 두 리스트를 date, time.startAt 으로 in-memory 병합 정렬 + → MyReservationResponse.from(...) // status=RESERVED/WAITING, waitingOrder(대기만) +``` + +**전환 전 (JDBC)**: `findByName...`은 reservation ⨝ reservation_time ⨝ theme 3중 조인 SELECT + RowMapper 손조립(1-2 "전환 전"과 동형). → **1-2에서 이미 파생 쿼리로 대체**됨. + +**전환 후 (JPA)**: 메서드 이름 파생 쿼리로 동작. +```java +// ReservationJpaRepository +List findByNameOrderByDateAscTime_StartAtAsc(String name); +// WaitingJpaRepository +List findByNameOrderByDateAscTime_StartAtAsc(String name); +``` +- `Time_StartAt` 밑줄 경로로 `time.startAt` 정렬을 메서드 이름에 인코딩. +- 예약·대기를 각각 조회 후, 서비스에서 `Comparator.comparing(date).thenComparing(time.startAt)`로 **in-memory 병합**(두 엔티티를 한 쿼리로 합칠 수 없어서). + +--- + +## 발행 SQL 관찰 — mine 경로의 N+1 (예약 2 + 대기 2, name="모카") + +mine 한 번 호출에 **distinct 8 SELECT**(show-sql·SQL 로거 이중 로깅이라 로그엔 16): +``` +[예약 sub-path · tx1] + ① reservation(main) -- ORDER BY time.start_at 위해 reservation_time 조인, SELECT은 예약 컬럼 + FK id만 + ② theme -- 1회만! 두 예약이 같은 theme → 1차 캐시가 dedup + ③ reservation_time (t1) -- 행별 보조 SELECT + ④ reservation_time (t2) +[대기 sub-path · tx2] + ⑤ waiting(main) -- 동형(reservation_time 조인 + name 조건 + 정렬) + ⑥ theme -- 재로드! tx1과 다른 tx라 1차 캐시 비공유 + ⑦ reservation_time (t1) + ⑧ reservation_time (t2) +``` +main 쿼리 형태(예약·대기 동형): +```sql +select w1_0.id, w1_0.date, w1_0.name, w1_0.order_index, w1_0.theme_id, w1_0.time_id +from waiting w1_0 join reservation_time t1_0 on t1_0.id=w1_0.time_id +where w1_0.name=? order by w1_0.date, t1_0.start_at +``` + +**관찰 포인트 (이 단계의 핵심 A)** +- **N+1 실물**: EAGER `@ManyToOne` + 파생 쿼리 → main + 연관별 보조 SELECT. 1-2의 단건 N+1이 mine에선 예약·대기 **두 갈래**로 나타남. +- **1차 캐시의 N+1 완화**: 두 예약이 공유하는 `theme`는 같은 tx 안에서 **1회만** 로드(②). 즉 naive `1 + 2N`이 아니라 *공유 연관은 dedup*. 반면 서로 다른 `time`(t1·t2)은 행별로 각각. +- **reads 트랜잭션-free의 청구서**: 예약·대기가 **별도의 tx-free 호출**이라 1차 캐시를 공유 못 함 → `theme`가 tx2에서 **재로드**(⑥). **트랜잭션 경계가 캐시 범위를 가른다**는 것이 mine 경로에서 눈에 보임 — 1-3 ②(1차 캐시)·1-3 결정(reads tx-free)의 직접적 귀결. +- **정렬이 두 군데**: 각 sub-path 내부는 DB `ORDER BY`(date, time.start_at), 예약+대기를 합칠 때는 in-memory 병합 정렬. 두 엔티티를 한 쿼리로 못 합치니 최종 정렬은 메모리. + +> fetch 전략은 3-1에서 본격 관찰. 여기선 **N+1이 실재함**까지만 확정(3-1 fetch join이면 sub-path당 1쿼리 → 총 2쿼리로 축소). + +--- + +## 확인 과제 + +**Q. 메서드 이름 쿼리·JPQL 중 어느 것을 썼나?** +> **메서드 이름 파생 쿼리**(`findByNameOrderByDateAscTime_StartAtAsc`). 조건이 `name` 단일 + 정렬뿐이라 이름 쿼리로 충분, JPQL을 꺼낼 이유가 없었다. + +**Q. 그 결정의 한계는?** +> ① **조건이 늘면 메서드 이름 폭발** — 이미 `existsByDateAndTime_IdAndTheme_IdAndIdNot`처럼 이름이 길어지는 징후. ② **fetch join을 표현 못 함** → 위 N+1을 못 막음. 3-1에서 `@Query`(fetch join) 또는 `@EntityGraph`로 보강 필요. ③ **두 엔티티(예약·대기)를 한 쿼리로 못 합침** → in-memory 병합에 의존. + +--- + +## 결정 기록 (인라인) + +### 결정 1 — name 유지 vs member 도입 + +- **선택한 것**: 예약자 `name`(String) **유지**. +- **비교한 대안**: `Member` 엔티티 + FK 도입 → `findByMemberId`(LMS 명세에 더 가까움). +- **비교 기준**: 0단계 **"처음부터 다시 구현 금지"** — 이 미션의 목적은 JdbcTemplate 베이스라인과 1:1로 비교하며 JPA를 배우는 것이고, member 도입은 도메인 재설계라 **비교 축 자체를 흐린다**. 게다가 1-2·1-3을 모두 name 기준으로 일관되게 진행해 옴. +- **한계 / 다음 망가질 지점**: `name`이 사실상 식별자라 **동명이인 구분 불가**. 실서비스라면 member가 필수. 이 한계는 "학습용 베이스라인 보존"과 의식적으로 맞바꾼 것 — 미션 종료 후 member 도입이 자연스러운 다음 리팩터링. **(B)** + +### 결정 2 — 메서드 이름 쿼리 vs JPQL + +- **선택한 것**: 메서드 이름 파생 쿼리. +- **비교한 대안**: `@Query` JPQL. +- **비교 기준**: 단일 조건 + 정렬엔 파생 쿼리가 **최소 코드**. JPQL은 fetch join·집계처럼 이름으로 못 적는 게 필요할 때. +- **한계**: **fetch join 불가 → N+1**(위 관찰). 3-1에서 JPQL fetch join 또는 `@EntityGraph`로 승격 예정. **(B)** + +## 테스트 변경 사항 (차원 C) + +- **코드 변경 0 → 깨진 테스트 0**. 1-2의 **134개 green** 그대로 유지. `/reservations-mine` 인수 테스트가 기존에 경로를 이미 커버(예약+대기 혼합 응답·status·waitingOrder 검증). + +## 피드백 채널 신호 (차원 C) + +- **SQL 로그가 보여준 것**: mine 1회에 8쿼리(N+1) / 1차 캐시가 공유 theme를 dedup / **tx 경계가 캐시 범위를 가름**(예약 tx와 대기 tx가 theme를 따로 로드). "성능 문제"가 아니라 *구조가 만든 쿼리 수*를 눈으로 확인. + +## 막힌 지점 → 다음 정의 + +- 막힘: 예약·대기 각 sub-path의 N+1. +- 다음 정의: **3-1**에서 fetch join / `@EntityGraph`로 sub-path당 1쿼리화 + (fetch join 시) row 중복(distinct) 처리 비교. mine은 3-1 N+1 재현의 **입구**. + +## 얻은 인사이트 + +- (A) mine N+1은 1차 캐시로 *공유 연관*이 dedup되지만, **tx 경계 밖에선 재로드** — 캐시 범위 = 트랜잭션 범위. reads tx-free 결정이 여기서 비용으로 드러남. (A) +- (B) `name` 유지 = 학습 베이스라인 보존 ↔ 동명이인 불가의 맞교환 / 메서드 쿼리 = 최소 코드 ↔ fetch join 불가의 맞교환. 두 결정 모두 "지금의 단순함"을 산 것. (B) +- (C) 코드를 한 줄도 안 바꿔도, "이미 충족됨 + 그 한계(N+1)"를 정직히 기록하는 것이 이 단계가 만드는 신호. (C) + +## 이 단계가 회수한 차원 + +- A: mine 경로 N+1 실측 + 1차 캐시 dedup + tx 경계가 캐시 범위를 가름. +- B: name 유지 / 메서드 쿼리 결정과 각각의 한계(동명이인·fetch join 불가). +- C: 코드 변경 0·134 green / N+1을 3-1로 넘기는 좌표 명시. diff --git a/docs/04-step3-waiting.md b/docs/04-step3-waiting.md new file mode 100644 index 0000000000..9d52696c50 --- /dev/null +++ b/docs/04-step3-waiting.md @@ -0,0 +1,166 @@ +# 04 · 3단계 — 예약 대기 (N+1 / JPQL) + +> **학습 절정.** 3-1 N+1·fetch join / 3-2 JPQL의 2중 관찰 구조. +> ⚠️ **내 코드 특수 상황**: `Waiting` 엔티티·`order_index`·중복 방지(UNIQUE)·name 기준 조회가 **이미 JDBC로 구현됨** → 1-2에서 JPA 전환 완료. 그래서 이 단계는 "기능 만들기"가 아니라 **fetch 전략으로 N+1 닫기 + order_index ↔ JPQL rank 재결정**이 핵심. +> ✅ **상태**: 3-1 코드 푸시(EAGER→LAZY + @EntityGraph), 3-2 결정(order_index 유지) 완료. 전체 green. + +--- + +## 들어가기 전 자기진단 + +**Q. 예약 대기 도메인을 별도 엔티티로 만들지, 기존 `Reservation`에 status 컬럼만 추가할지 — 첫 직감과 근거는?** +> **이미 결정됨**: `Waiting` 별도 엔티티 + 분리 테이블. 근거 보강 — 분리 모델이라야 한 슬롯에 대해 **예약 UNIQUE(date,time_id,theme_id)** 와 **대기 UNIQUE(date,time_id,theme_id,name)·(…,order_index)** 가 *각각* 성립한다. 단일 테이블 + status ENUM이면 "예약은 슬롯당 1, 대기는 슬롯당 N(이름·순번 유일)"이라는 **서로 다른 유일성 규칙을 한 테이블에서 못 나눈다**. 분리는 제약의 표현력을 위한 것. **(B)** + +--- + +## 요구사항 (LMS) + +- `POST /waitings` — 예약 대기 요청 / `DELETE /waitings/{id}` — 취소 +- `GET /reservations-mine` 응답에 대기 목록 포함 (status = "N번째 예약대기") +- 같은 테마·날짜·시간 중복 예약 방지 / (심화) 내 대기가 몇 번째인지 + +## 전환 전 (JDBC) → 전환 후 (JPA) — 1-2에서 완료 + +- **전환 전**: `JdbcWaitingRepository` = save(INSERT+KeyHolder) / findBySlot·findByName(3중 조인 SELECT + RowMapper) / `updateOrderIndex`(bulk UPDATE). +- **전환 후**(1-2): `@Entity Waiting` + 단방향 `@ManyToOne`(time/theme) + `@Table` 복합 UNIQUE 2개 + `WaitingJpaRepository`(파생 쿼리 `findByDateAndTime_IdAndTheme_IdOrderByOrderIndexAsc`·`findByNameOrderByDateAscTime_StartAtAsc` + `@Modifying updateOrderIndex`). 상세는 `02-step1 §1-2`. +- 이 단계(3-1)에서 그 위에 **fetch 전략을 바꾼다**. + +--- + +## 3-1. N+1과 fetch join 본격 비교 (관찰 과제 2) ⭐ 차원 A + +### 관찰 시나리오 + +`reservations-mine`(예약 N + 대기 M)을 가져와 각 `getTheme()`·`getTime()`을 DTO 변환할 때 SQL이 몇 번 나가나? fetch join / `@EntityGraph`로 묶으면? row 중복은? + +### 관찰 A — N+1 (전략 없음 = EAGER) + +``` +시도 코드: @ManyToOne 기본(EAGER) + 파생 쿼리(findByName) + DTO 변환 +예측: 목록 1 + (N+M)회 추가 SELECT +실제: 예약2 + 대기2 → distinct 8 SELECT +왜 다른가: 파생/HQL 쿼리는 EAGER 연관을 자동 join fetch 하지 않고 행마다 보조 SELECT +``` +``` +[예약 sub-path · tx1] reservation(main, ORDER BY 위해 reservation_time 조인) + → theme (1회: 1차 캐시가 공유 theme dedup) + → reservation_time (t1), reservation_time (t2) ← 행별 +[대기 sub-path · tx2] waiting(main) → theme (재로드: 새 tx) → reservation_time ×2 +``` +- **1차 캐시가 N+1을 부분 완화**: 같은 tx 안 공유 `theme`는 1회만(naive 1+2N 아님). 서로 다른 `time`은 행별. +- **reads tx-free의 청구서**: 예약·대기가 별도 tx-free 호출 → 캐시 비공유 → `theme` 재로드. **tx 경계가 캐시 범위를 가른다**(1-3 ②·결정과 직결). + +### 중간 단계 — EAGER→LAZY 전환의 청구서 (1-3 ⑥ 예고 실현) + +`@ManyToOne(fetch=LAZY)`로 바꾼 직후: +``` +ReservationServiceTest 3건 FAILED — org.hibernate.LazyInitializationException + (findMyReservationsAndWaitings는 @Transactional 아님 = reads tx-free) +``` +- DTO 변환이 컨텍스트 닫힌 뒤 LAZY 연관을 건드려 예외 → **1-3 ⑥(getReference 프록시 tx 밖 접근)이 실제 read 경로에서 재현**. "reads를 tx-free로 둔다"는 결정이 LAZY와 충돌하는 좌표가 현실화. +- **`findAll`(admin)은 안 깨짐**: **OSIV(`spring.jpa.open-in-view` 기본 on)** 가 HTTP 요청 동안 영속성 컨텍스트를 뷰까지 열어 둬 LAZY를 늦게라도 로드. 단 **OSIV는 LazyInit만 가리고 N+1은 못 가린다**(서비스 직접 호출 테스트엔 OSIV 없음 → 노출). → "왜 어디는 깨지고 어디는 안 깨지나"의 답이 OSIV. + +### 관찰 B — fetch join / @EntityGraph + +``` +시도 코드: @EntityGraph(attributePaths={"time","theme"}) + on ReservationJpaRepository.findByName...·findAll / WaitingJpaRepository.findByName... +예측: 한 join 쿼리로 합쳐짐 +실제: mine 경로 8 → 2 SELECT (예약 fetch-join 1 + 대기 fetch-join 1) +왜 다른가: @EntityGraph가 연관을 join fetch로 끌어와 보조 SELECT 제거. LazyInit도 해소. +``` +발행 SQL(예약, 대기 동형): +```sql +select r1_0.id, r1_0.date, r1_0.name, + t2_0.id, t2_0.description, t2_0.name, t2_0.thumbnail_url, -- theme + r1_0.time_id, t1_0.id, t1_0.start_at -- time +from reservation r1_0 +join reservation_time t1_0 on t1_0.id=r1_0.time_id +join theme t2_0 on t2_0.id=r1_0.theme_id +where r1_0.name=? order by r1_0.date, t1_0.start_at +``` +- **row 중복(distinct/Set) 처리는?** → **불필요**. `@ManyToOne`(to-one) fetch join은 1행→1연관이라 행이 늘지 않는다. row 중복(곱집합)은 **to-many 컬렉션 fetch join**의 문제(거기선 `distinct`/`Set` + 페이징 충돌까지). 단방향 to-one만 쓰는 이 모델은 그 함정을 안 만남. +- `optional=false`라 **INNER JOIN**으로 발행(optional이면 LEFT JOIN). + +### 두 SQL 나란히 (N+1 ↔ fetch join) + +| | 관찰 A (EAGER, 전략 없음) | 관찰 B (@EntityGraph) | +|---|---|---| +| mine SELECT 수 | **8** (예약 4 + 대기 4) | **2** (예약 1 + 대기 1) | +| 형태 | main + 연관별 보조 SELECT | 단일 INNER JOIN fetch | +| tx-free read | (EAGER라 동작) / LAZY면 LazyInit | 정상(연관 미리 로드) | +| row 중복 | 없음 | 없음(to-one) | + +--- + +## 3-2. JPQL 본격 — N번째 대기 계산 ⭐ 차원 A·B + +### 관찰 — order_index 읽기(A) vs JPQL 상관 서브쿼리 COUNT(B) + +같은 슬롯에 대기 3명(철수=1, 영희=2, 모카=3) 심고 "모카의 순번"을 두 길로: +- **A. 저장된 `order_index` 읽기**: `findByName` 결과의 `getOrderIndex()` = **3**. (컬럼 SELECT 한 번.) +- **B. JPQL 상관 서브쿼리 COUNT**: 같은 슬롯에서 `id <` 인 대기 수 = **2**(앞에 철수·영희) → 3번째. + +LMS 힌트(memberId)를 내 모델(name)에 맞춰 실행한 JPQL → **발행 SQL**: +```sql +select w1_0.id, + (select count(w2_0.id) + from waiting w2_0 + where w2_0.theme_id = w1_0.theme_id + and w2_0.date = w1_0.date + and w2_0.time_id = w1_0.time_id + and w2_0.id < w1_0.id) +from waiting w1_0 +where w1_0.name = ? +``` +→ JPQL `w2.theme = w.theme`가 FK 컬럼 `theme_id` 비교로, 상관 서브쿼리가 **행마다** 같은 슬롯의 선행 대기를 COUNT. + +### 확인 과제 + +**Q. JPQL이 발행하는 SQL은?** +> 위 상관 서브쿼리 COUNT(메인 `waiting` 1행마다 `id<` 조건의 서브 SELECT). 엔티티 경로(`w2.theme`)가 FK(`theme_id`)로 컴파일됨. + +**Q. 메서드 이름 쿼리로 왜 못 풀었나? (1줄)** +> **상관 서브쿼리 COUNT + 자기 자신과의 비교(`w2.id < w.id`, 같은 슬롯 조건)** 는 파생 메서드 이름 문법 밖 — 파생은 *단일 엔티티의 속성 조건·정렬*만 인코딩하고, **같은 테이블을 자기참조로 집계**하는 표현이 없다. + +### 결정 기록 — order_index 유지 vs JPQL rank 계산 + +- **선택한 것**: `order_index` **유지** (비교 후 유지). +- **비교한 대안**: ⓐ materialized `order_index` 유지 / ⓑ 조회 시 JPQL COUNT 계산 / ⓒ 혼합. +- **비교 기준**: + - **order_index 유지(채택)**: 읽기가 trivial(컬럼 SELECT) + **`UNIQUE(date,time_id,theme_id,order_index)` 가 슬롯당 위치 중복을 막는 DB 최종 방어선**(insert-uniqueness 경쟁 방어) + 승급(`promote`)·`Waitings` FIFO 정책과 이미 통합. **대가**: 부기(`reorderAfterRemoval`로 취소 시 재번호) + order_index 할당 동시성. + - **JPQL rank**: **부기 0**(취소 시 *자가 치유* — 선행 `id` 수가 자동 감소) + order_index 컬럼/제약 불필요. **대가**: 행별 상관 서브쿼리 COUNT + 위치가 `id` 순서에서 *암묵* 파생 + **위치 UNIQUE 제약을 못 검**. +- **한계 / 다음 망가질 지점**: order_index 유지의 부기는 **동시 취소 시 재번호 경쟁**(같은 슬롯 다중 행 전환 → 비관적 락 후보, 백로그). JPQL rank로 가면 그 부기가 통째로 사라지지만 슬롯당 위치 UNIQUE 방어를 잃는다. +- **시도→후퇴 흔적**: JPQL rank를 **관찰·실행**(위 SQL 확보)했으나, ① `UNIQUE(order_index)`가 insert-uniqueness 경쟁의 마지막 방어선이고 ② 승급이 order_index에 의존하며 ③ green 모델을 미션 중 갈아엎는 리스크 때문에 **유지로 후퇴**. JPQL rank는 미션 후 "부기 제거" 리팩터링 **1순위 후보**로 기록. **(B)** + +--- + +## 테스트 변경 사항 (차원 C) + +- **3-1**: `@ManyToOne` EAGER→LAZY 직후 `ReservationServiceTest` 3건이 `LazyInitializationException`(reads tx-free의 mine 경로) → `@EntityGraph`(time/theme)로 해소. 최종 **전체 green(134)**. +- **3-2**: 코드 변경 0(order_index 유지). JPQL rank는 **관찰만**(임시 쿼리, 푸시 안 함) — 결정의 비교 자료로 SQL만 확보. + +## 피드백 채널 신호 (차원 C) + +- **N+1을 먼저 알려준 채널**: SQL 로그(mine 1회 8쿼리). "성능 문제"가 아니라 *구조가 만든 쿼리 수*가 눈에 보임. +- **예외가 가르친 것**: `LazyInitializationException`이 "reads tx-free + LAZY"의 충돌을 가르침. +- **의외**: **OSIV가 LazyInit은 가리지만 N+1은 못 가린다** — 같은 LAZY인데 HTTP(admin findAll)는 통과, 서비스 직접 호출(mine)은 예외. 차이의 정체가 OSIV. + +## 막힌 지점 → 다음 정의 + +- 막힘: order_index 부기의 **동시 취소 재번호 경쟁**(같은 슬롯 다중 행 전환). +- 다음 정의: **4단계** 자동 승인 트랜잭션 경계(승급 flush 순서, 1-2에서 먼저 터진 그 지점) + (백로그) 비관적 락으로 재번호 경쟁 차단. + +## 얻은 인사이트 + +- (A) N+1은 진입 경로(EAGER + 파생/HQL 쿼리)에서 나고, `@EntityGraph`(to-one) fetch join이 **단일 INNER JOIN·row 중복 없이** 닫는다(8→2). (A) +- (A) LAZY 전환은 reads tx-free와 충돌해 LazyInit을 낳고, **OSIV는 LazyInit만 가린다**(N+1은 잔존). (A) +- (B) `order_index`(저장) vs JPQL rank(조회 계산)는 **"부기+UNIQUE 방어 ↔ 무부기+자가치유"** 의 맞교환. 유지를 택한 건 UNIQUE 방어선·승급 통합·미션 안정성 때문. (B) +- (B) 파생 메서드 쿼리는 상관 서브쿼리 집계를 표현 못 함 → 거기서부터 JPQL의 영역. (B) +- (C) "관찰만 하고 채택하지 않은 대안(JPQL rank)"을 SQL과 함께 남기는 게 시도→후퇴의 정직한 기록. (C) + +## 이 단계가 회수한 차원 + +- A: mine N+1 8→2(fetch join 실측) + LAZY/OSIV/LazyInit 관계 + JPQL 상관 서브쿼리 rank SQL. +- B: order_index 유지(비교 후) + fetch 전략(EAGER→LAZY+@EntityGraph) + 파생 쿼리 표현 한계. +- C: LazyInit→@EntityGraph 수정·green(134) / JPQL rank 관찰만 / 동시성 백로그 명시. diff --git a/docs/05-step4-waiting-admin.md b/docs/05-step4-waiting-admin.md new file mode 100644 index 0000000000..1bac906218 --- /dev/null +++ b/docs/05-step4-waiting-admin.md @@ -0,0 +1,129 @@ +# 05 · 4단계 — 예약 대기 관리 (자동 승인 / 트랜잭션 경계) + +> **설계 판단의 벽.** 어드민 대기 관리 + 예약 취소 시 자동 승인. 이 단계가 만드는 고유 관찰 = **flush 순서**. +> ⚠️ **내 코드 특수 상황**: `delete-before-insert` 승급 흐름 + `@Transactional`이 **이미 JDBC로 구현됨** → 1-2에서 JPA로 전환하며 flush 순서 버그도 이미 고침. 그래서 이 단계는 그 흐름의 **flush 순서를 정식 관찰 + 트랜잭션 경계 결정**이 핵심. +> ✅ **상태**: 자동 승인 채택. 코드 변경 0(승급·`flush()` 픽스는 1-2). 전체 green(134). **도달한 만큼을 본다** — 자동까지 완료. + +--- + +## 들어가기 전 자기진단 + +**Q. 자동 승인 로직을 어디에 둘 것인가? (Service 메서드 / 별도 도메인 이벤트 / 기타) 첫 직감과 근거는?** +> (직감) Service의 `@Transactional` 메서드(`deleteByOwner`) 안. 도메인 이벤트로 분리하면 핸들러가 별도 트랜잭션(`@TransactionalEventListener(AFTER_COMMIT)`)이 되기 쉽고, 그러면 **취소만 커밋된 뒤 승급이 실패하면 빈 슬롯**이 남아 원자성이 깨진다 → 한 트랜잭션 유지. **(B)** + +**Q. 트랜잭션 경계는 어디까지 굳혀야 한다고 보나?** +> (직감) 취소+승급을 **한 트랜잭션**. delete-before-insert(취소 DELETE → 같은 슬롯 승급 INSERT)가 한 경계 안이라야 read-your-own-writes로 안전. 단, 경계가 *원자성*은 줘도 *flush 순서*는 따로(③). + +--- + +## 요구사항 (LMS) + +- **어드민**: 대기 목록 조회·취소 / **승인 (택1)**: 수동 / 자동(취소 시 다음 대기를 예약으로 전환) → **자동 채택**. + +## 전환 전 (JDBC) → 전환 후 (JPA) + +- **전환 전**: 취소 시 같은 슬롯의 다음 `order_index` 대기를 찾아 예약으로 전환. JDBC가 DELETE를 **즉시 실행**해 delete-before-insert를 손으로 보장. +- **전환 후**(1-2): 같은 흐름을 엔티티 조작으로 — +```java +@Transactional +public void deleteByOwner(Long id, String name) { + Reservation reservation = findByIdAndName(id, name); + reservationPolicy.validateCancellable(reservation.dateTime()); + reservationRepository.deleteById(id); // 취소 (어댑터에서 deleteById 후 flush()) + promoteFirstWaitingIfExists(reservation); // 승급 +} +// promote: findBySlot → save(Reservation.promote(first)) → waiting deleteById → reorder(updateOrderIndex) +``` + +--- + +## 본질 신호 — 자동 승인 시 만나야 할 것 (LMS) + +### ① 트랜잭션 경계 + +- `deleteByOwner` **한 `@Transactional`** 안에 취소+승급 전부 → **원자성**(승급 실패 시 취소까지 롤백, all-or-nothing). 도메인 이벤트(AFTER_COMMIT)로 분리하면 취소만 커밋·승급 실패 시 빈 슬롯 → 일관성 붕괴. **한 경계 유지.** +- **핵심**: 트랜잭션 경계는 *원자성*을 주지만 ***flush 순서는 보장하지 않는다*** (③). **(B)** + +### ② 동시성 (명시적 비결정) + +- 같은 슬롯에 두 사용자가 동시에 취소·승급하면 `order_index` 재번호 경쟁 / 중복 승급 가능. +- **의도적 deferred**: 이번 사이클은 *JdbcTemplate→JPA 전환*이 범위. 락/격리는 **Level 3 동시성 아크**(레이스 재현 → 비관적 락 → 격리 수준 비교)로 분리해 백로그에 명시. 현재는 `UNIQUE(date,time_id,theme_id)`·`UNIQUE(...,order_index)`가 **최종 방어선**으로 최악(중복 예약)은 DB가 막음. "지금 안 하는 이유"를 결정으로 남김. **(B)** + +### ③ flush 순서 ⭐ 차원 A (이 단계 고유 관찰) + +``` +시도 코드: 한 @Transactional에서 취소(reservation delete) + 승급(reservation insert + waiting delete + reorder) +예측: delete-before-insert가 SQL에서 보장되나? JPA 기본 flush는 INSERT→UPDATE→DELETE인데? +실제: 아래 순서 (deleteByOwner 한 번) +왜 다른가: 액션큐 기본 순서(INSERT→UPDATE→DELETE)면 ②INSERT가 ①DELETE보다 먼저 → 같은 슬롯 UNIQUE 충돌 +``` +발행 순서: +``` +SELECT reservation -- findByIdAndName (R 로드) +SELECT reservation_time -- LAZY time (validateCancellable → dateTime()) +DELETE reservation -- ① 취소 (deleteById + 어댑터의 flush()) ← 먼저! +SELECT waiting -- findBySlot (같은 슬롯 대기들) +INSERT reservation -- ② 승급 (Reservation.promote, IDENTITY save = 즉시) +DELETE waiting -- ③ 승급된 대기 제거 +UPDATE waiting -- ④ 남은 대기 재번호(reorderAfterRemoval) +``` +- **핵심 인사이트**: **트랜잭션 경계(원자성) ≠ flush 순서(쓰기 발행 순서).** 한 `@Transactional` 안이어도 순서는 **JPA 액션큐 규칙(INSERT→UPDATE→DELETE)**을 따른다. 내 의도 순서(delete-before-insert)는 **`deleteById` 후 명시적 `flush()`로만** 강제된다(1-2에서 `ConstraintViolation`으로 터지고 이 픽스로 해결한 그 현상). +- `@GeneratedValue(IDENTITY)`의 **즉시 INSERT**(1-3 ③)가 충돌을 악화 — INSERT를 flush까지 못 미루므로, 명시 flush로 DELETE를 앞세우지 않으면 곧장 부딪힌다. + +--- + +## 확인 과제 + +**Q. 자동 승인 로직의 위치는?** +> **자동 승인 채택** — Service의 단일 `@Transactional` 메서드 `deleteByOwner`(취소 → `promoteFirstWaitingIfExists`) 안. 수동(승인 버튼) 아님. + +**Q. 그 결정의 한계는? 트랜잭션 경계·동시성·일관성 중 가장 약한 것은?** +> **동시성**이 가장 약하다(의도적 deferred). 단일 사용자 기준 경계(원자성)·일관성(delete-before-insert flush)은 견고하나, **다중 사용자 동시 취소·승급**은 미보호 — `UNIQUE` 제약이 *최악(중복 예약)*만 막고, 재번호 경쟁·중복 승급 시도는 락 없이는 못 막는다. **(B)** + +--- + +## 결정 기록 (인라인) + +### 결정 — 자동 승인 위치 & 트랜잭션 경계 + +- **선택한 것**: 자동 승인 / Service 단일 `@Transactional`(`deleteByOwner`) 안 + `deleteById` 후 명시적 `flush()`로 delete-before-insert 강제. +- **비교한 대안**: Service 한 메서드(채택) / 도메인 이벤트(AFTER_COMMIT 분리 트랜잭션) / 분리 트랜잭션. +- **비교 기준**: 원자성(취소+승급 all-or-nothing) + flush 순서 통제. 도메인 이벤트 분리는 원자성이 깨지고(취소만 커밋), flush 순서 통제권도 흩어짐. +- **한계 / 가장 약한 축**: 동시성(다중 사용자). +- **명시적 비결정(동시성·락 deferred 근거)**: JPA 전환이 이번 사이클 범위. 락/격리는 Level 3 동시성 아크로 분리(레이스 재현→비관적 락→격리 비교)해 백로그에 명시. **(B)** + +## 테스트 변경 사항 (차원 C) + +- **코드 변경 0**(자동 승인·`flush()` 픽스는 1-2). 승급 **롤백 테스트**(`@MockitoSpyBean` fault injection on `WaitingRepository` *포트*)는 어댑터를 spy하므로 LAZY/`@EntityGraph` 전환에도 **무영향 → green(134)**. 이 테스트가 1-2에서 먼저 `ConstraintViolation`으로 깨졌다 `flush()`로 고쳐진 그 테스트(02 문서 참조). + +## 피드백 채널 신호 (차원 C) + +- **flush 순서를 SQL 로그가 직접 보여줌**: `DELETE(reservation) → INSERT(reservation) → DELETE(waiting) → UPDATE(waiting)`. +- **의외였던 것**: *트랜잭션 경계가 발행 순서를 정하지 않는다* — 순서는 액션큐가 정하고, 내 의도는 명시 flush로만 강제. JDBC에서 당연했던 "내가 쓴 순서대로 실행"이 JPA에선 보장이 아니었다. + +## 막힌 지점 → 다음 정의 + +- 막힘: 동시 취소·승급의 **재번호 경쟁 / 중복 승급**. +- 다음 정의: Level 3 동시성 아크 — 레이스 재현 → 비관적 락(같은 슬롯 다중 행 전환) → 격리 수준 비교. (이번 미션 범위 밖, 백로그.) + +## 얻은 인사이트 + +- (A) 자동 승인의 발행 순서는 `DELETE→INSERT→DELETE→UPDATE`이며, 이 delete-before-insert는 **트랜잭션 경계가 아니라 명시적 flush**가 만든다. (A) +- (B) **트랜잭션 경계(원자성) ≠ flush 순서(가시성 순서)** — 둘은 다른 축. 원자성은 `@Transactional`이, 순서는 flush가. (B) +- (B) 동시성은 명시적으로 *지금 다루지 않는다*고 적는 것이 결정 — UNIQUE가 최악만 방어함을 함께 기록. (B) + +--- + +## 📌 단일 PR 본문 재료 (07로 합침) + +- **도달 지점(한 줄)**: `step2`의 JdbcTemplate 기반 방탈출 예약/대기 시스템을 **헥사고날 구조를 유지한 채 JPA로 전환 완료**(1~4단계) — 엔티티 매핑·단방향 `@ManyToOne`·영속성 컨텍스트 6관찰·N+1 fetch join·순번 결정·자동 승인 flush 순서까지. +- **시작/작업 브랜치·범위**: 시작 `step2` → 작업 `step3-jpa`. 건드린 범위 = `adapter/persistence`(어댑터·JpaRepository) + `domain`(엔티티 4종에 매핑 어노테이션) + 설정(`create-drop`·로깅). **API 명세·도메인 정책·예외 계층은 불변**(재구현 금지 준수). +- **망설인 결정 1~2**: ① **fetch 전략** — EAGER 기본값을 1-2/1-3 내내 일부러 유지(관찰용)했다가 3-1에서 LAZY+`@EntityGraph`로 전환(기본값→문제→해결). ② **순번** — `order_index` 유지 vs JPQL rank, 둘 다 실행 후 유지로 후퇴(UNIQUE 방어선·승급 통합). +- **발행 SQL 발췌(가장 강한 예측-실제 갭)**: ⓐ `findByName`의 N+1(같은 EAGER인데 by-id는 join fetch, 쿼리는 연관별 보조 SELECT) → `@EntityGraph`로 8→2. ⓑ 자동 승인 flush 순서(DELETE-before-INSERT는 명시 flush로만). ⓒ IDENTITY persist=즉시 INSERT. +- **흔들린 한 장면**: LAZY 전환 직후 `ReservationServiceTest` 3건이 `LazyInitializationException` — 1-3 ⑥에서 "reads tx-free + LAZY면 터진다"고 예고한 그 좌표가 실제 read 경로에서 터지고, `@EntityGraph`로 닫은 순간. OSIV가 admin 경로만 가려준 것도 그때 드러남. + +## 이 단계가 회수한 차원 + +- A: 자동 승인 flush 순서(DELETE→INSERT→DELETE→UPDATE) 실측 / 트랜잭션 경계≠flush 순서. +- B: 단일 `@Transactional`+명시 flush 결정 / 동시성 deferred 명시. +- C: 코드 변경 0·green(134) / flush 순서를 로그가 가르침 / 승급 롤백 테스트 유지. diff --git a/docs/06-design-discussion.md b/docs/06-design-discussion.md new file mode 100644 index 0000000000..8af16f9c26 --- /dev/null +++ b/docs/06-design-discussion.md @@ -0,0 +1,78 @@ +# 06 · JPA 설계 토론 준비 + +> 06/19(금) 10:40~11:30, 강의장. **결정 공유 ~30분(3명 ~10분/4명 ~7분) → 한계 묻기 ~15분 → 마무리 ~5분.** +> 동료는 "그 결정의 한계는? 다른 선택하면 무엇이 망가지나?"만 묻는다. 평가 아님. +> 🖊️ 이 문서는 각 step 문서의 `(B)` 태그 결정에서 **3개를 골라** 채운다. + +--- + +## 준비물 체크 + +- [ ] 목 18:00 제출 PR 본문 (= 07의 PR 재료) +- [ ] 아래 결정 3개 + +--- + +## 결정 후보 (step 문서에서 끌어올 풀) + +> 🖊️ 이 중 가장 망설인 3개 선택. + +- 도메인 객체 직접 `@Entity` vs 순수 도메인 + 별도 엔티티 분리 (02 §0단계 경계) +- 단/양방향 + cascade 시도→후퇴 (02 §1-2) +- name 유지 vs member 도입 (03) +- 메서드 이름 쿼리 vs JPQL (03) +- **order_index 유지 vs JPQL rank 계산** (04 §3-2) ← 내 코드 고유, 강력 후보 +- N+1 → fetch join vs `@EntityGraph` (04 §3-1) +- 자동 승인 위치 & 트랜잭션 경계 & flush 순서 (05) + +--- + +## 결정 #1 — _제목_ + +- 선택한 것: +- 비교한 대안: +- 선택의 비교 기준: +- 이 선택의 한계 / 다음에 망가질 지점: +- 동료에게 묻고 싶은 것(1개): + +## 결정 #2 — _제목_ + +- 선택한 것: +- 비교한 대안: +- 선택의 비교 기준: +- 한계 / 망가질 지점: +- 묻고 싶은 것(1개): + +## 결정 #3 — _제목_ + +- 선택한 것: +- 비교한 대안: +- 선택의 비교 기준: +- 한계 / 망가질 지점: +- 묻고 싶은 것(1개): + +--- + +## 가장 이야기하고 싶은 선택 1개 + +> 🖊️ 인증인가 06처럼 5~6줄로 압축. (후보: order_index↔JPQL rank, 또는 flush 순서) + +``` +나는 ___을 선택했다. 다른 후보는 ___였다. +이유는 ___였고, 그 비용이 ___에서 가장 선명하게 드러났다. +``` + +## 다른 사람에게 묻고 싶은 점 + +- JPQL rank로 간 사람 / order_index로 간 사람: +- 양방향을 쓴 사람: +- 자동 승인을 도메인 이벤트로 분리한 사람: + +--- + +## 토론 후 마무리 (~5분, 자기점검 핵심 재료) + +``` +다음에 같은 결정을 한다면 나는 ___을 바꾼다. +동료에게 받은 가장 날카로운 한계 지적: ___ +``` diff --git a/docs/07-self-check.md b/docs/07-self-check.md new file mode 100644 index 0000000000..9a288d5d49 --- /dev/null +++ b/docs/07-self-check.md @@ -0,0 +1,91 @@ +# 07 · 자기점검 & 단일 PR 본문 + +> 채점 아닌 자기 인식. JPA 자체에 집중. **JPA 설계 토론 후(금) → 토·일·월 작성 → 06-22(월) 18:00 마감.** 작성 전: PR 본문 + 토론 한계 지적 + 기록 훑기. +> 🖊️ 한 줄짜리 답이 나오는 칸 = 가장 약한 곳 신호. 그 칸부터 기록을 펴서 SQL 발췌 1개를 떠올린다. + +--- + +## 1. JPA 매핑 정확성 (차원 A) + +| 질문 | 답 | +|-----------------------------------------------------------|---| +| 가장 자신 있게 설명할 매핑 결정 1개 + 그 발행 SQL은? | | +| "예측 SQL"과 "실제 SQL"이 다른 순간 — 어디, 왜? | | +| `@ManyToOne` 기본 fetch vs `@OneToMany` 기본 fetch 차이를 직접 봤나? | | +| 지금 어노테이션 한 줄 보고 SQL 모양 떠오르나? 떠오르는/안 떠오르는 어노테이션 구분 | | + +## 2. 설계 판단 (차원 B) + +| 질문 | 답 | +|----------------------------------------------------------------------|---| +| 가장 오래 망설인 결정은? | | +| 비교한 대안 2개+ 와 비교 기준은? | | +| 그 선택의 가장 큰 단점·한계는? | | +| 결정을 바꾼(롤백) 적 — 어떤 신호 때문에? | | +| JdbcTemplate 시절 결정과 **같은 문제를 다르게 푼 지점**은? (예: order_index↔JPQL rank) | | +| 자동 승인 시도했다면 — 트랜잭션 경계·동시성·일관성 중 가장 약한 것은? | | + +## 3. 피드백 순환 (차원 C) + +| 질문 | 답 | +|--------------------------------------------|---| +| 가장 자주 켠 채널? (SQL 로그/테스트/PR 리뷰/토론/예외) | | +| 그 채널에서 가장 의외였던 발견 1개는? | | +| LazyInit·N+1·의도치 않은 추가 쿼리 중 만난 것 — 어떻게 대응? | | +| PR에 "결정 질문"을 먼저 적은 횟수 + 응답 활용은? | | +| 토론에서 받은 한계 지적 1개 + 코드 반영 흔적은? | | + +## 4. 종합 + +| 질문 | 답 | +|------------------------------------------|---| +| JdbcTemplate 대비 **사고가 가장 크게 흔들린 한 장면**은? | | +| "JPA의 ___ 부분은 아직 흐릿하다"라고 적을 곳은? | | +| 다음에 JPA 다시 만나면 가장 먼저 시도할 1개는? | | + +--- + +## 5. LMS+ 산출물 (요약 제출 — 가장 강한 결정·한계 1~2개만) + +| 항목 | 메모 | +|--------------------------------------------|------------| +| 사전 설문(시작 전) — 목표·선택 이유·마친 후 모습 | (01 §0 참고) | +| 사후 산출물 — 자기점검에서 끌어올 핵심 1~2개 (어떤 결정·SQL 발췌) | | +| 사후 설문 — 시도·달성도·다음 학습·코치 피드백 요청 | | + +## 사후 설문 답 준비 + +- 목표 달성도(잘된/아쉬운): +- 어디까지 도달 / 가장 오래 머문 곳: +- 4영역 중 가장 약한 영역 + 근거: +- 다음에 가장 먼저 시도할 1개: +- 코치 피드백 요청: + +--- + +## 📌 단일 PR 본문 (목 18:00 제출본) + +> 🖊️ 05 §PR 재료 + 각 step `(A/B/C)` 태그를 합쳐 완성. 권장 구성 그대로. + +``` +## 0단계 — 범위 +시작 브랜치: step2 / 작업 브랜치: step3-jpa +건드린 범위: (adapter/persistence + domain 매핑 / 유지: web·application·분리 모델) + +## 단계별 도달 지점 +- 1단계: 매핑·연관관계·영속성 컨텍스트 관찰 → +- 2단계: 내 예약 목록 → +- 3단계: 예약 대기 N+1·JPQL → +- 4단계: 자동 승인·flush 순서 → (도달/미완 정확히) + +## 발행 SQL 발췌 (예측-실제 갭이 가장 큰 1~2개) + +## 망설인 결정 1~2개 + +## 흔들린 한 장면 + +## 명시적 비결정 (동시성·락 = Level 3 범위) +``` + +**Q. PR URL**: _(작성)_ +**Q. 어디까지 도달(한 줄)**: _(작성)_ diff --git a/src/main/java/roomescape/adapter/persistence/JdbcReservationRepository.java b/src/main/java/roomescape/adapter/persistence/JdbcReservationRepository.java deleted file mode 100644 index e10d139e8a..0000000000 --- a/src/main/java/roomescape/adapter/persistence/JdbcReservationRepository.java +++ /dev/null @@ -1,215 +0,0 @@ -package roomescape.adapter.persistence; - -import java.sql.Date; -import java.sql.PreparedStatement; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.Reservation; -import roomescape.domain.ReservationTime; -import roomescape.domain.Theme; -import roomescape.domain.repository.ReservationRepository; - -@Repository -public class JdbcReservationRepository implements ReservationRepository { - - private final JdbcTemplate jdbcTemplate; - - private static final RowMapper ROW_MAPPER = (rs, rowNum) -> { - ReservationTime time = ReservationTime.withId( - rs.getLong("time_id"), - rs.getTime("time_start_at").toLocalTime() - ); - Theme theme = Theme.withId( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_description"), - rs.getString("theme_thumbnail") - ); - return Reservation.withId( - rs.getLong("reservation_id"), - rs.getString("reservation_name"), - rs.getDate("reservation_date").toLocalDate(), - time, - theme - ); - }; - - public JdbcReservationRepository(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public List findAll() { - String sql = """ - SELECT - r.id AS reservation_id, - r.name AS reservation_name, - r.date AS reservation_date, - t.id AS time_id, - t.start_at AS time_start_at, - th.id AS theme_id, - th.name AS theme_name, - th.description AS theme_description, - th.thumbnail_url AS theme_thumbnail - FROM reservation r - INNER JOIN reservation_time t ON r.time_id = t.id - INNER JOIN theme th ON r.theme_id = th.id - """; - - return jdbcTemplate.query(sql, ROW_MAPPER); - } - - @Override - public Reservation save(Reservation reservation) { - String sql = "INSERT INTO reservation (name, date, time_id, theme_id) VALUES (?, ?, ?, ?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); - ps.setString(1, reservation.getName()); - ps.setDate(2, Date.valueOf(reservation.getDate())); - ps.setLong(3, reservation.getTime().getId()); - ps.setLong(4, reservation.getTheme().getId()); - return ps; - }, keyHolder); - - Long id = keyHolder.getKey().longValue(); - return Reservation.withId( - id, - reservation.getName(), - reservation.getDate(), - reservation.getTime(), - reservation.getTheme() - ); - } - - @Override - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM reservation WHERE id = ?", id); - } - - @Override - public boolean existsByDateAndTimeAndTheme(LocalDate date, Long timeId, Long themeId) { - String sql = """ - SELECT EXISTS ( - SELECT 1 FROM reservation - WHERE date = ? AND time_id = ? AND theme_id = ? - ) - """; - Boolean result = jdbcTemplate.queryForObject( - sql, Boolean.class, Date.valueOf(date), timeId, themeId - ); - return Boolean.TRUE.equals(result); - } - - @Override - public boolean existsBySlotAndName(LocalDate date, Long timeId, Long themeId, String name) { - String sql = """ - SELECT EXISTS ( - SELECT 1 FROM reservation - WHERE date = ? AND time_id = ? AND theme_id = ? AND name = ? - ) - """; - Boolean result = jdbcTemplate.queryForObject( - sql, Boolean.class, Date.valueOf(date), timeId, themeId, name - ); - return Boolean.TRUE.equals(result); - } - - - @Override - public boolean existsByTimeId(Long timeId) { - String sql = """ - SELECT EXISTS ( - SELECT 1 FROM reservation WHERE time_id = ? - ) - """; - Boolean result = jdbcTemplate.queryForObject(sql, Boolean.class, timeId); - return Boolean.TRUE.equals(result); - } - - @Override - public List findByNameOrderByDateAscTimeAsc(String name) { - String sql = """ - SELECT - r.id AS reservation_id, - r.name AS reservation_name, - r.date AS reservation_date, - t.id AS time_id, - t.start_at AS time_start_at, - th.id AS theme_id, - th.name AS theme_name, - th.description AS theme_description, - th.thumbnail_url AS theme_thumbnail - FROM reservation r - INNER JOIN reservation_time t ON r.time_id = t.id - INNER JOIN theme th ON r.theme_id = th.id - WHERE r.name = ? - ORDER BY r.date ASC, t.start_at ASC - """; - - return jdbcTemplate.query(sql, ROW_MAPPER, name); - } - - - @Override - public Optional findById(Long id) { - String sql = """ - SELECT - r.id AS reservation_id, - r.name AS reservation_name, - r.date AS reservation_date, - t.id AS time_id, - t.start_at AS time_start_at, - th.id AS theme_id, - th.name AS theme_name, - th.description AS theme_description, - th.thumbnail_url AS theme_thumbnail - FROM reservation r - INNER JOIN reservation_time t ON r.time_id = t.id - INNER JOIN theme th ON r.theme_id = th.id - WHERE r.id = ? - """; - - return jdbcTemplate.query(sql, ROW_MAPPER, id).stream().findFirst(); - } - - @Override - public boolean existsByDateAndTimeAndThemeExcludingId( - LocalDate date, Long timeId, Long themeId, Long excludeId) { - String sql = """ - SELECT EXISTS ( - SELECT 1 FROM reservation - WHERE date = ? AND time_id = ? AND theme_id = ? AND id != ? - ) - """; - Boolean result = jdbcTemplate.queryForObject( - sql, Boolean.class, Date.valueOf(date), timeId, themeId, excludeId - ); - return Boolean.TRUE.equals(result); - } - - @Override - public void updateDateAndTime(Long id, LocalDate date, Long timeId) { - String sql = "UPDATE reservation SET date = ?, time_id = ? WHERE id = ?"; - jdbcTemplate.update(sql, Date.valueOf(date), timeId, id); - } - - @Override - public boolean existsByThemeId(Long themeId) { - String sql = """ - SELECT EXISTS ( - SELECT 1 FROM reservation WHERE theme_id = ? - ) - """; - Boolean result = jdbcTemplate.queryForObject(sql, Boolean.class, themeId); - return Boolean.TRUE.equals(result); - } - -} diff --git a/src/main/java/roomescape/adapter/persistence/JdbcReservationTimeRepository.java b/src/main/java/roomescape/adapter/persistence/JdbcReservationTimeRepository.java deleted file mode 100644 index 3abc50b968..0000000000 --- a/src/main/java/roomescape/adapter/persistence/JdbcReservationTimeRepository.java +++ /dev/null @@ -1,82 +0,0 @@ -package roomescape.adapter.persistence; - -import java.sql.Date; -import java.sql.PreparedStatement; -import java.sql.Time; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.ReservationTime; -import roomescape.domain.repository.ReservationTimeRepository; - -@Repository -public class JdbcReservationTimeRepository implements ReservationTimeRepository { - - private final JdbcTemplate jdbcTemplate; - - private static final RowMapper ROW_MAPPER = (rs, rowNum) -> ReservationTime.withId( - rs.getLong("id"), - rs.getTime("start_at").toLocalTime() - ); - - - public JdbcReservationTimeRepository(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public List findAll() { - return jdbcTemplate.query( - "SELECT id, start_at FROM reservation_time", - ROW_MAPPER - ); - } - - @Override - public Optional findById(Long id) { - List result = jdbcTemplate.query( - "SELECT id, start_at FROM reservation_time WHERE id = ?", - ROW_MAPPER, - id - ); - return result.stream().findFirst(); - } - - @Override - public ReservationTime save(ReservationTime time) { - String sql = "INSERT INTO reservation_time (start_at) VALUES (?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); - ps.setTime(1, Time.valueOf(time.getStartAt())); - return ps; - }, keyHolder); - - Long id = keyHolder.getKey().longValue(); - return ReservationTime.withId(id, time.getStartAt()); - } - - @Override - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM reservation_time WHERE id = ?", id); - } - - @Override - public List findAvailable(LocalDate date, Long themeId) { - String sql = """ - SELECT id, start_at - FROM reservation_time - WHERE id NOT IN ( - SELECT time_id FROM reservation - WHERE date = ? AND theme_id = ? - ) - """; - return jdbcTemplate.query(sql, ROW_MAPPER, Date.valueOf(date), themeId); - } -} diff --git a/src/main/java/roomescape/adapter/persistence/JdbcThemeRepository.java b/src/main/java/roomescape/adapter/persistence/JdbcThemeRepository.java deleted file mode 100644 index bbd4995f84..0000000000 --- a/src/main/java/roomescape/adapter/persistence/JdbcThemeRepository.java +++ /dev/null @@ -1,103 +0,0 @@ -package roomescape.adapter.persistence; - -import java.sql.Date; -import java.sql.PreparedStatement; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.Theme; -import roomescape.domain.repository.ThemeRepository; -import roomescape.domain.repository.projection.PopularThemeProjection; - -@Repository -public class JdbcThemeRepository implements ThemeRepository { - - private final JdbcTemplate jdbcTemplate; - - private static final RowMapper ROW_MAPPER = (rs, rowNum) -> - Theme.withId( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getString("thumbnail_url") - ); - - private static final RowMapper POPULAR_ROW_MAPPER = (rs, rowNum) -> - new PopularThemeProjection( - Theme.withId( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getString("thumbnail_url") - ), - rs.getLong("reservation_count") - ); - - - public JdbcThemeRepository(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public List findAll() { - return jdbcTemplate.query( - "SELECT id, name, description, thumbnail_url FROM theme", - ROW_MAPPER - ); - } - - @Override - public Optional findById(Long id) { - List result = jdbcTemplate.query( - "SELECT id, name, description, thumbnail_url FROM theme WHERE id = ?", - ROW_MAPPER, - id - ); - return result.stream().findFirst(); - } - - @Override - public Theme save(Theme theme) { - String sql = "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); - ps.setString(1, theme.getName()); - ps.setString(2, theme.getDescription()); - ps.setString(3, theme.getThumbnailUrl()); - return ps; - }, keyHolder); - - Long id = keyHolder.getKey().longValue(); - return Theme.withId(id, theme.getName(), theme.getDescription(), theme.getThumbnailUrl()); - } - - @Override - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM theme WHERE id = ?", id); - } - - @Override - public List findPopularBetween(LocalDate from, LocalDate to, int limit) { - String sql = """ - SELECT t.id, t.name, t.description, t.thumbnail_url, - COUNT(r.id) AS reservation_count - FROM theme t - INNER JOIN reservation r ON t.id = r.theme_id - WHERE r.date >= ? - AND r.date < ? - GROUP BY t.id, t.name, t.description, t.thumbnail_url - ORDER BY reservation_count DESC - LIMIT ? - """; - return jdbcTemplate.query(sql, POPULAR_ROW_MAPPER, - Date.valueOf(from), Date.valueOf(to), limit); - } - -} diff --git a/src/main/java/roomescape/adapter/persistence/JdbcWaitingRepository.java b/src/main/java/roomescape/adapter/persistence/JdbcWaitingRepository.java deleted file mode 100644 index 4341656d73..0000000000 --- a/src/main/java/roomescape/adapter/persistence/JdbcWaitingRepository.java +++ /dev/null @@ -1,124 +0,0 @@ -package roomescape.adapter.persistence; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.ReservationTime; -import roomescape.domain.Theme; -import roomescape.domain.Waiting; -import roomescape.domain.repository.WaitingRepository; - -@Repository -public class JdbcWaitingRepository implements WaitingRepository { - - private final JdbcTemplate jdbcTemplate; - - public JdbcWaitingRepository(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - private final RowMapper rowMapper = (rs, rowNum) -> { - ReservationTime time = ReservationTime.withId( - rs.getLong("time_id"), - rs.getTime("start_at").toLocalTime() - ); - Theme theme = Theme.withId( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("description"), - rs.getString("thumbnail_url") - ); - return Waiting.withId( - rs.getLong("id"), - rs.getString("name"), - LocalDate.parse(rs.getString("date")), - time, - theme, - rs.getInt("order_index") - ); - }; - - @Override - public Waiting save(Waiting w) { - String sql = """ - INSERT INTO waiting (name, date, time_id, theme_id, order_index) - VALUES (?, ?, ?, ?, ?) - """; - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, w.getName()); - ps.setString(2, w.getDate().toString()); - ps.setLong(3, w.getTime().getId()); - ps.setLong(4, w.getTheme().getId()); - ps.setInt(5, w.getOrderIndex()); - return ps; - }, keyHolder); - - Long id = keyHolder.getKey().longValue(); - return Waiting.withId(id, w.getName(), w.getDate(), - w.getTime(), w.getTheme(), w.getOrderIndex()); - } - - @Override - public List findBySlot(LocalDate date, Long timeId, Long themeId) { - String sql = """ - SELECT w.id, w.name, w.date, w.order_index, - t.id AS time_id, t.start_at, - th.id AS theme_id, th.name AS theme_name, th.description, th.thumbnail_url - FROM waiting w - JOIN reservation_time t ON w.time_id = t.id - JOIN theme th ON w.theme_id = th.id - WHERE w.date = ? AND w.time_id = ? AND w.theme_id = ? - ORDER BY w.order_index ASC - """; - return jdbcTemplate.query(sql, rowMapper, date.toString(), timeId, themeId); - } - - @Override - public Optional findById(Long id) { - String sql = """ - SELECT w.id, w.name, w.date, w.order_index, - t.id AS time_id, t.start_at, - th.id AS theme_id, th.name AS theme_name, th.description, th.thumbnail_url - FROM waiting w - JOIN reservation_time t ON w.time_id = t.id - JOIN theme th ON w.theme_id = th.id - WHERE w.id = ? - """; - return jdbcTemplate.query(sql, rowMapper, id).stream().findFirst(); - } - - @Override - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM waiting WHERE id = ?", id); - } - - @Override - public void updateOrderIndex(Long id, int newOrderIndex) { - jdbcTemplate.update("UPDATE waiting SET order_index = ? WHERE id = ?", newOrderIndex, id); - } - - @Override - public List findByName(String name) { - String sql = """ - SELECT w.id, w.name, w.date, w.order_index, - t.id AS time_id, t.start_at, - th.id AS theme_id, th.name AS theme_name, th.description, th.thumbnail_url - FROM waiting w - JOIN reservation_time t ON w.time_id = t.id - JOIN theme th ON w.theme_id = th.id - WHERE w.name = ? - ORDER BY w.date ASC, t.start_at ASC - """; - return jdbcTemplate.query(sql, rowMapper, name); - } - -} diff --git a/src/main/java/roomescape/adapter/persistence/ReservationJpaRepository.java b/src/main/java/roomescape/adapter/persistence/ReservationJpaRepository.java new file mode 100644 index 0000000000..618c5e9051 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ReservationJpaRepository.java @@ -0,0 +1,35 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.domain.Reservation; +import roomescape.domain.ReservationTime; + +public interface ReservationJpaRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"time", "theme"}) + List findAll(); + + boolean existsByDateAndTime_IdAndTheme_Id(LocalDate date, Long timeId, Long themeId); + + boolean existsByDateAndTime_IdAndTheme_IdAndName(LocalDate date, Long timeId, Long themeId, String name); + + boolean existsByDateAndTime_IdAndTheme_IdAndIdNot(LocalDate date, Long timeId, Long themeId, Long excludeId); + + boolean existsByTime_Id(Long timeId); + + boolean existsByTheme_Id(Long themeId); + + @EntityGraph(attributePaths = {"time", "theme"}) + List findByNameOrderByDateAscTime_StartAtAsc(String name); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Reservation r set r.date = :date, r.time = :time where r.id = :id") + void updateDateAndTime(@Param("id") Long id, @Param("date") LocalDate date, @Param("time") ReservationTime time); +} diff --git a/src/main/java/roomescape/adapter/persistence/ReservationRepositoryAdapter.java b/src/main/java/roomescape/adapter/persistence/ReservationRepositoryAdapter.java new file mode 100644 index 0000000000..7fa5005cb2 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ReservationRepositoryAdapter.java @@ -0,0 +1,81 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import roomescape.domain.Reservation; +import roomescape.domain.repository.ReservationRepository; + +@Repository +public class ReservationRepositoryAdapter implements ReservationRepository { + + private final ReservationJpaRepository jpaRepository; + private final ReservationTimeJpaRepository timeJpaRepository; + + public ReservationRepositoryAdapter(ReservationJpaRepository jpaRepository, + ReservationTimeJpaRepository timeJpaRepository) { + this.jpaRepository = jpaRepository; + this.timeJpaRepository = timeJpaRepository; + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } + + @Override + public Reservation save(Reservation reservation) { + return jpaRepository.save(reservation); + } + + @Override + public void deleteById(Long id) { + // delete-before-insert 보장: JPA 쓰기지연은 flush 시 INSERT→UPDATE→DELETE로 재정렬한다. + // 승급 흐름(취소→같은 슬롯 대기 승격)에서 IDENTITY save가 즉시 INSERT되므로, 옇 예약 DELETE를 + // 먼저 flush하지 않으면 UNIQUE(date,time_id,theme_id) 충돌. JDBC의 즉시 DELETE 의미를 복원. + jpaRepository.deleteById(id); + jpaRepository.flush(); + } + + @Override + public boolean existsByDateAndTimeAndTheme(LocalDate date, Long timeId, Long themeId) { + return jpaRepository.existsByDateAndTime_IdAndTheme_Id(date, timeId, themeId); + } + + @Override + public boolean existsBySlotAndName(LocalDate date, Long timeId, Long themeId, String name) { + return jpaRepository.existsByDateAndTime_IdAndTheme_IdAndName(date, timeId, themeId, name); + } + + @Override + public boolean existsByTimeId(Long timeId) { + return jpaRepository.existsByTime_Id(timeId); + } + + @Override + public List findByNameOrderByDateAscTimeAsc(String name) { + return jpaRepository.findByNameOrderByDateAscTime_StartAtAsc(name); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public boolean existsByDateAndTimeAndThemeExcludingId(LocalDate date, Long timeId, Long themeId, Long excludeId) { + return jpaRepository.existsByDateAndTime_IdAndTheme_IdAndIdNot(date, timeId, themeId, excludeId); + } + + @Override + public void updateDateAndTime(Long id, LocalDate date, Long timeId) { + // 과도기 의미 보존: bulk update. time 은 식별자 프록시로 참조. + jpaRepository.updateDateAndTime(id, date, timeJpaRepository.getReferenceById(timeId)); + } + + @Override + public boolean existsByThemeId(Long themeId) { + return jpaRepository.existsByTheme_Id(themeId); + } +} diff --git a/src/main/java/roomescape/adapter/persistence/ReservationTimeJpaRepository.java b/src/main/java/roomescape/adapter/persistence/ReservationTimeJpaRepository.java new file mode 100644 index 0000000000..224704fb53 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ReservationTimeJpaRepository.java @@ -0,0 +1,21 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.domain.ReservationTime; + +public interface ReservationTimeJpaRepository extends JpaRepository { + + // Reservation 이 엔티티가 되어 native -> JPQL 안티조인으로 승격. + @Query(""" + select rt from ReservationTime rt + where rt.id not in ( + select r.time.id from Reservation r + where r.date = :date and r.theme.id = :themeId + ) + """) + List findAvailable(@Param("date") LocalDate date, @Param("themeId") Long themeId); +} diff --git a/src/main/java/roomescape/adapter/persistence/ReservationTimeRepositoryAdapter.java b/src/main/java/roomescape/adapter/persistence/ReservationTimeRepositoryAdapter.java new file mode 100644 index 0000000000..17373d8cb1 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ReservationTimeRepositoryAdapter.java @@ -0,0 +1,43 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import roomescape.domain.ReservationTime; +import roomescape.domain.repository.ReservationTimeRepository; + +@Repository +public class ReservationTimeRepositoryAdapter implements ReservationTimeRepository { + + private final ReservationTimeJpaRepository jpaRepository; + + public ReservationTimeRepositoryAdapter(ReservationTimeJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public ReservationTime save(ReservationTime time) { + return jpaRepository.save(time); + } + + @Override + public void deleteById(Long id) { + jpaRepository.deleteById(id); + } + + @Override + public List findAvailable(LocalDate date, Long themeId) { + return jpaRepository.findAvailable(date, themeId); + } +} diff --git a/src/main/java/roomescape/adapter/persistence/ThemeJpaRepository.java b/src/main/java/roomescape/adapter/persistence/ThemeJpaRepository.java new file mode 100644 index 0000000000..0e31c2d6cd --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ThemeJpaRepository.java @@ -0,0 +1,25 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.domain.Theme; +import roomescape.domain.repository.projection.PopularThemeProjection; + +public interface ThemeJpaRepository extends JpaRepository { + + // Reservation 이 엔티티가 되어 JPQL 집계로 승격(과도기 JdbcTemplate 제거). limit 은 Pageable 로. + @Query(""" + select new roomescape.domain.repository.projection.PopularThemeProjection(t, count(r)) + from Theme t, Reservation r + where r.theme = t and r.date >= :from and r.date < :to + group by t + order by count(r) desc + """) + List findPopularBetween(@Param("from") LocalDate from, + @Param("to") LocalDate to, + Pageable pageable); +} diff --git a/src/main/java/roomescape/adapter/persistence/ThemeRepositoryAdapter.java b/src/main/java/roomescape/adapter/persistence/ThemeRepositoryAdapter.java new file mode 100644 index 0000000000..a88f4d4899 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/ThemeRepositoryAdapter.java @@ -0,0 +1,45 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import roomescape.domain.Theme; +import roomescape.domain.repository.ThemeRepository; +import roomescape.domain.repository.projection.PopularThemeProjection; + +@Repository +public class ThemeRepositoryAdapter implements ThemeRepository { + + private final ThemeJpaRepository jpaRepository; + + public ThemeRepositoryAdapter(ThemeJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public Theme save(Theme theme) { + return jpaRepository.save(theme); + } + + @Override + public void deleteById(Long id) { + jpaRepository.deleteById(id); + } + + @Override + public List findPopularBetween(LocalDate from, LocalDate to, int limit) { + return jpaRepository.findPopularBetween(from, to, PageRequest.of(0, limit)); + } +} diff --git a/src/main/java/roomescape/adapter/persistence/WaitingJpaRepository.java b/src/main/java/roomescape/adapter/persistence/WaitingJpaRepository.java new file mode 100644 index 0000000000..af89e740df --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/WaitingJpaRepository.java @@ -0,0 +1,22 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import roomescape.domain.Waiting; + +public interface WaitingJpaRepository extends JpaRepository { + + List findByDateAndTime_IdAndTheme_IdOrderByOrderIndexAsc(LocalDate date, Long timeId, Long themeId); + + @EntityGraph(attributePaths = {"time", "theme"}) + List findByNameOrderByDateAscTime_StartAtAsc(String name); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Waiting w set w.orderIndex = :orderIndex where w.id = :id") + void updateOrderIndex(@Param("id") Long id, @Param("orderIndex") int orderIndex); +} diff --git a/src/main/java/roomescape/adapter/persistence/WaitingRepositoryAdapter.java b/src/main/java/roomescape/adapter/persistence/WaitingRepositoryAdapter.java new file mode 100644 index 0000000000..6bdaff1ec6 --- /dev/null +++ b/src/main/java/roomescape/adapter/persistence/WaitingRepositoryAdapter.java @@ -0,0 +1,48 @@ +package roomescape.adapter.persistence; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import roomescape.domain.Waiting; +import roomescape.domain.repository.WaitingRepository; + +@Repository +public class WaitingRepositoryAdapter implements WaitingRepository { + + private final WaitingJpaRepository jpaRepository; + + public WaitingRepositoryAdapter(WaitingJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Waiting save(Waiting waiting) { + return jpaRepository.save(waiting); + } + + @Override + public List findBySlot(LocalDate date, Long timeId, Long themeId) { + return jpaRepository.findByDateAndTime_IdAndTheme_IdOrderByOrderIndexAsc(date, timeId, themeId); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public void deleteById(Long id) { + jpaRepository.deleteById(id); + } + + @Override + public void updateOrderIndex(Long id, int newOrderIndex) { + jpaRepository.updateOrderIndex(id, newOrderIndex); + } + + @Override + public List findByName(String name) { + return jpaRepository.findByNameOrderByDateAscTime_StartAtAsc(name); + } +} diff --git a/src/main/java/roomescape/application/ReservationService.java b/src/main/java/roomescape/application/ReservationService.java index baae3a2212..8aa58472a1 100644 --- a/src/main/java/roomescape/application/ReservationService.java +++ b/src/main/java/roomescape/application/ReservationService.java @@ -4,24 +4,27 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import roomescape.application.dto.command.ReservationCreateCommand; +import roomescape.application.dto.command.ReservationUpdateCommand; +import roomescape.application.dto.result.MyReservationResult; +import roomescape.application.dto.result.ReservationResult; import roomescape.domain.Reservation; +import roomescape.domain.ReservationDateTime; import roomescape.domain.ReservationTime; import roomescape.domain.Theme; import roomescape.domain.Waiting; import roomescape.domain.Waitings; import roomescape.domain.policy.ReservationPolicy; -import roomescape.exception.client.BusinessRuleViolationException; -import roomescape.exception.client.ResourceNotFoundException; -import roomescape.exception.server.DataInconsistencyException; import roomescape.domain.repository.ReservationRepository; import roomescape.domain.repository.ReservationTimeRepository; import roomescape.domain.repository.ThemeRepository; import roomescape.domain.repository.WaitingRepository; -import roomescape.application.dto.result.MyReservationResult; -import roomescape.application.dto.command.ReservationCreateCommand; -import roomescape.application.dto.result.ReservationResult; -import roomescape.application.dto.command.ReservationUpdateCommand; +import roomescape.exception.client.BusinessRuleViolationException; +import roomescape.exception.client.ResourceNotFoundException; +import roomescape.exception.server.DataInconsistencyException; @Service public class ReservationService { @@ -53,6 +56,7 @@ public List findAll() { .toList(); } + @Transactional public ReservationResult create(ReservationCreateCommand command) { ReservationTime time = findTimeOrThrow(command.getTimeId()); Theme theme = findThemeOrThrow(command.getThemeId()); @@ -71,6 +75,7 @@ public ReservationResult create(ReservationCreateCommand command) { return ReservationResult.from(saved); } + @Transactional public void delete(Long id) { reservationRepository.deleteById(id); } @@ -93,12 +98,10 @@ public List findMyReservationsAndWaitings(String name) { return results; } + @Transactional public void deleteByOwner(Long id, String name) { Reservation reservation = findByIdAndName(id, name); - reservationPolicy.validateCancellable( - reservation.getDate(), - reservation.getTime().getStartAt() - ); + reservationPolicy.validateCancellable(reservation.dateTime()); reservationRepository.deleteById(id); promoteFirstWaitingIfExists(reservation); @@ -111,27 +114,31 @@ private void promoteFirstWaitingIfExists(Reservation canceled) { canceled.getTheme().getId() )); - waitings.firstWaiting().ifPresent(first -> { - Reservation promoted = Reservation.promote(first); - reservationRepository.save(promoted); + Optional firstWaiting = waitings.firstWaiting(); + if (firstWaiting.isEmpty()) { + return; + } - waitingRepository.deleteById(first.getId()); + Waiting first = firstWaiting.get(); + reservationRepository.save(Reservation.promote(first)); + waitingRepository.deleteById(first.getId()); + reorderRemaining(waitings, first.getOrderIndex()); + } - for (Waiting w : waitings.reorderAfterRemoval(first.getOrderIndex())) { - waitingRepository.updateOrderIndex(w.getId(), w.getOrderIndex()); - } - }); + private void reorderRemaining(Waitings waitings, int removedOrder) { + for (Waiting w : waitings.reorderAfterRemoval(removedOrder)) { + waitingRepository.updateOrderIndex(w.getId(), w.getOrderIndex()); + } } + @Transactional public ReservationResult updateByOwner(ReservationUpdateCommand command) { Reservation reservation = findByIdAndName(command.getId(), command.getName()); - reservationPolicy.validateUpdatable( - reservation.getDate(), - reservation.getTime().getStartAt() - ); + reservationPolicy.validateUpdatable(reservation.dateTime()); ReservationTime newTime = findTimeOrThrow(command.getTimeId()); - reservationPolicy.validateUpdateTarget(command.getDate(), newTime.getStartAt()); + reservationPolicy.validateUpdateTarget( + ReservationDateTime.of(command.getDate(), newTime.getStartAt())); validateNotDuplicatedExcludingSelf(command, reservation.getTheme().getId()); reservationRepository.updateDateAndTime(command.getId(), command.getDate(), command.getTimeId()); diff --git a/src/main/java/roomescape/application/ReservationTimeService.java b/src/main/java/roomescape/application/ReservationTimeService.java index ec4a28b8df..29ae3bf735 100644 --- a/src/main/java/roomescape/application/ReservationTimeService.java +++ b/src/main/java/roomescape/application/ReservationTimeService.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import roomescape.domain.ReservationTime; import roomescape.exception.client.BusinessRuleViolationException; import roomescape.domain.repository.ReservationRepository; @@ -28,12 +29,14 @@ public List findAll() { .toList(); } + @Transactional public ReservationTimeResult create(ReservationTimeCreateCommand command) { ReservationTime time = ReservationTime.create(command.getStartAt()); ReservationTime saved = reservationTimeRepository.save(time); return ReservationTimeResult.from(saved); } + @Transactional public void delete(Long id) { validateNotInUse(id); reservationTimeRepository.deleteById(id); diff --git a/src/main/java/roomescape/application/ThemeService.java b/src/main/java/roomescape/application/ThemeService.java index 289940ea7d..1e6375c882 100644 --- a/src/main/java/roomescape/application/ThemeService.java +++ b/src/main/java/roomescape/application/ThemeService.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import roomescape.domain.Theme; import roomescape.domain.policy.PopularThemePolicy; import roomescape.exception.client.BusinessRuleViolationException; @@ -36,12 +37,14 @@ public List findAll() { .toList(); } + @Transactional public ThemeResult create(ThemeCreateCommand command) { Theme theme = Theme.create(command.getName(), command.getDescription(), command.getThumbnail()); Theme saved = themeRepository.save(theme); return ThemeResult.from(saved); } + @Transactional public void delete(Long id) { validateNotInUse(id); themeRepository.deleteById(id); diff --git a/src/main/java/roomescape/application/WaitingService.java b/src/main/java/roomescape/application/WaitingService.java index c0909b7626..1ce04d84a3 100644 --- a/src/main/java/roomescape/application/WaitingService.java +++ b/src/main/java/roomescape/application/WaitingService.java @@ -1,6 +1,7 @@ package roomescape.application; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import roomescape.application.dto.command.WaitingCreateCommand; import roomescape.application.dto.result.WaitingResult; import roomescape.domain.ReservationTime; @@ -32,6 +33,7 @@ public WaitingService(WaitingRepository waitingRepository, this.themeRepository = themeRepository; } + @Transactional public WaitingResult create(WaitingCreateCommand command) { ReservationTime time = findTimeOrThrow(command.getTimeId()); Theme theme = findThemeOrThrow(command.getThemeId()); @@ -51,7 +53,7 @@ public WaitingResult create(WaitingCreateCommand command) { return WaitingResult.from(saved); } - //TODO @Transactional은 사이클 2에서 다루는 내용이므로 사이클 1에서는 다루지 않음. + @Transactional public void cancelByOwner(Long id, String name) { Waiting waiting = waitingRepository.findById(id) .filter(w -> w.isOwnedBy(name)) @@ -100,4 +102,3 @@ private void validateReservationExists(WaitingCreateCommand command) { } - diff --git a/src/main/java/roomescape/config/ClockConfig.java b/src/main/java/roomescape/config/ClockConfig.java new file mode 100644 index 0000000000..07240882b8 --- /dev/null +++ b/src/main/java/roomescape/config/ClockConfig.java @@ -0,0 +1,13 @@ +package roomescape.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/java/roomescape/domain/Reservation.java b/src/main/java/roomescape/domain/Reservation.java index 0552d1773a..fec1e602d3 100644 --- a/src/main/java/roomescape/domain/Reservation.java +++ b/src/main/java/roomescape/domain/Reservation.java @@ -1,17 +1,45 @@ package roomescape.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; import roomescape.domain.exception.InvalidDomainException; import roomescape.domain.policy.ReservationPolicy; +@Entity +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"date", "time_id", "theme_id"})) public class Reservation { private static final int MAX_NAME_LENGTH = 30; - private final Long id; - private final String name; - private final LocalDate date; - private final ReservationTime time; - private final Theme theme; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = MAX_NAME_LENGTH) + private String name; + + @Column(nullable = false) + private LocalDate date; + + // 단방향 @ManyToOne. 3-1에서 EAGER→LAZY 전환(N+1 제거) + 조회 경로는 @EntityGraph fetch join. + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "time_id") + private ReservationTime time; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "theme_id") + private Theme theme; + + protected Reservation() { + } private Reservation(Long id, String name, LocalDate date, ReservationTime time, Theme theme) { validate(name, date, time, theme); @@ -25,7 +53,7 @@ private Reservation(Long id, String name, LocalDate date, ReservationTime time, public static Reservation create(String name, LocalDate date, ReservationTime time, Theme theme, ReservationPolicy policy) { - policy.validateCreatable(date, time.getStartAt()); + policy.validateCreatable(ReservationDateTime.of(date, time.getStartAt())); return new Reservation(null, name, date, time, theme); } @@ -38,6 +66,10 @@ public static Reservation promote(Waiting w) { return new Reservation(null, w.getName(), w.getDate(), w.getTime(), w.getTheme()); } + public ReservationDateTime dateTime() { + return ReservationDateTime.of(date, time.getStartAt()); + } + private static void validate(String name, LocalDate date, ReservationTime time, Theme theme) { validateName(name); validateDate(date); diff --git a/src/main/java/roomescape/domain/ReservationDateTime.java b/src/main/java/roomescape/domain/ReservationDateTime.java new file mode 100644 index 0000000000..c89d95a6b5 --- /dev/null +++ b/src/main/java/roomescape/domain/ReservationDateTime.java @@ -0,0 +1,31 @@ +package roomescape.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import roomescape.domain.exception.InvalidDomainException; + +public class ReservationDateTime { + + private final LocalDate date; + private final LocalTime time; + + private ReservationDateTime(LocalDate date, LocalTime time) { + if (date == null) { + throw new InvalidDomainException("예약 시점의 날짜는 비어 있을 수 없습니다."); + } + if (time == null) { + throw new InvalidDomainException("예약 시점의 시간은 비어 있을 수 없습니다."); + } + this.date = date; + this.time = time; + } + + public static ReservationDateTime of(LocalDate date, LocalTime time) { + return new ReservationDateTime(date, time); + } + + public boolean startsAtOrBefore(LocalDateTime moment) { + return !date.atTime(time).isAfter(moment); + } +} diff --git a/src/main/java/roomescape/domain/ReservationTime.java b/src/main/java/roomescape/domain/ReservationTime.java index 9a0371bb4f..fcf4662ee6 100644 --- a/src/main/java/roomescape/domain/ReservationTime.java +++ b/src/main/java/roomescape/domain/ReservationTime.java @@ -1,11 +1,26 @@ package roomescape.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import java.time.LocalTime; import roomescape.domain.exception.InvalidDomainException; +@Entity public class ReservationTime { - private final Long id; - private final LocalTime startAt; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 컬럼명은 네이밍 전략에 위임: startAt -> start_at. UNIQUE(start_at) 유지. + @Column(nullable = false, unique = true) + private LocalTime startAt; + + protected ReservationTime() { + } private ReservationTime(Long id, LocalTime startAt) { validate(startAt); diff --git a/src/main/java/roomescape/domain/Theme.java b/src/main/java/roomescape/domain/Theme.java index 366d1ff5d1..ebcd2fbecb 100644 --- a/src/main/java/roomescape/domain/Theme.java +++ b/src/main/java/roomescape/domain/Theme.java @@ -1,16 +1,33 @@ package roomescape.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import roomescape.domain.exception.InvalidDomainException; +@Entity public class Theme { private static final int MAX_NAME_LENGTH = 30; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - private final Long id; - private final String name; - private final String description; - private final String thumbnailUrl; + @Column(nullable = false, length = MAX_NAME_LENGTH) + private String name; + + @Column(nullable = false) + private String description; + + // 컬럼명은 CamelCaseToUnderscoresNamingStrategy 에 위임: thumbnailUrl -> thumbnail_url (관찰로 검증) + @Column(nullable = false) + private String thumbnailUrl; + + protected Theme() { + } private Theme(Long id, String name, String description, String thumbnailUrl) { validate(name, description, thumbnailUrl); @@ -72,5 +89,4 @@ public String getDescription() { public String getThumbnailUrl() { return thumbnailUrl; } - } diff --git a/src/main/java/roomescape/domain/Waiting.java b/src/main/java/roomescape/domain/Waiting.java index c9b0bbd0c7..5ca955f557 100644 --- a/src/main/java/roomescape/domain/Waiting.java +++ b/src/main/java/roomescape/domain/Waiting.java @@ -1,17 +1,49 @@ package roomescape.domain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; import roomescape.domain.exception.InvalidDomainException; +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"date", "time_id", "theme_id", "order_index"}), + @UniqueConstraint(columnNames = {"date", "time_id", "theme_id", "name"}) +}) public class Waiting { private static final int MAX_NAME_LENGTH = 30; - private final Long id; - private final String name; - private final LocalDate date; - private final ReservationTime time; - private final Theme theme; - private final int orderIndex; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = MAX_NAME_LENGTH) + private String name; + + @Column(nullable = false) + private LocalDate date; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "time_id") + private ReservationTime time; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "theme_id") + private Theme theme; + + @Column(name = "order_index", nullable = false) + private int orderIndex; + + protected Waiting() { + } private Waiting(Long id, String name, LocalDate date, ReservationTime time, Theme theme, int orderIndex) { diff --git a/src/main/java/roomescape/domain/policy/FutureOnlyPolicy.java b/src/main/java/roomescape/domain/policy/FutureOnlyPolicy.java index bd06b4af8b..f995e1d53f 100644 --- a/src/main/java/roomescape/domain/policy/FutureOnlyPolicy.java +++ b/src/main/java/roomescape/domain/policy/FutureOnlyPolicy.java @@ -1,10 +1,9 @@ package roomescape.domain.policy; import java.time.Clock; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import org.springframework.stereotype.Component; +import roomescape.domain.ReservationDateTime; import roomescape.exception.client.BusinessRuleViolationException; @Component @@ -12,39 +11,33 @@ public class FutureOnlyPolicy implements ReservationPolicy { private final Clock clock; - public FutureOnlyPolicy() { - this.clock = Clock.systemDefaultZone(); + public FutureOnlyPolicy(Clock clock) { + this.clock = clock; } @Override - public void validateCreatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜, 시간으로는 예약할 수 없습니다."); - } + public void validateCreatable(ReservationDateTime when) { + rejectIfNotFuture(when, "지나간 날짜, 시간으로는 예약할 수 없습니다."); } @Override - public void validateCancellable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 취소할 수 없습니다."); - } + public void validateCancellable(ReservationDateTime when) { + rejectIfNotFuture(when, "이미 지난 예약은 취소할 수 없습니다."); } @Override - public void validateUpdatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 변경할 수 없습니다."); - } + public void validateUpdatable(ReservationDateTime when) { + rejectIfNotFuture(when, "이미 지난 예약은 변경할 수 없습니다."); } @Override - public void validateUpdateTarget(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜, 시간으로는 변경할 수 없습니다."); - } + public void validateUpdateTarget(ReservationDateTime when) { + rejectIfNotFuture(when, "지나간 날짜, 시간으로는 변경할 수 없습니다."); } - private boolean isPast(LocalDateTime dateTime) { - return !dateTime.isAfter(LocalDateTime.now(clock)); + private void rejectIfNotFuture(ReservationDateTime when, String message) { + if (when.startsAtOrBefore(LocalDateTime.now(clock))) { + throw new BusinessRuleViolationException(message); + } } } diff --git a/src/main/java/roomescape/domain/policy/RecentWeekPopularPolicy.java b/src/main/java/roomescape/domain/policy/RecentWeekPopularPolicy.java index 8119ba6cb6..e965558720 100644 --- a/src/main/java/roomescape/domain/policy/RecentWeekPopularPolicy.java +++ b/src/main/java/roomescape/domain/policy/RecentWeekPopularPolicy.java @@ -12,8 +12,8 @@ public class RecentWeekPopularPolicy implements PopularThemePolicy { private final Clock clock; - public RecentWeekPopularPolicy() { - this.clock = Clock.systemDefaultZone(); + public RecentWeekPopularPolicy(Clock clock) { + this.clock = clock; } @Override diff --git a/src/main/java/roomescape/domain/policy/ReservationPolicy.java b/src/main/java/roomescape/domain/policy/ReservationPolicy.java index 8d060ef6f2..f18a09ca96 100644 --- a/src/main/java/roomescape/domain/policy/ReservationPolicy.java +++ b/src/main/java/roomescape/domain/policy/ReservationPolicy.java @@ -1,14 +1,13 @@ package roomescape.domain.policy; -import java.time.LocalDate; -import java.time.LocalTime; +import roomescape.domain.ReservationDateTime; public interface ReservationPolicy { - void validateCreatable(LocalDate date, LocalTime time); + void validateCreatable(ReservationDateTime when); - void validateCancellable(LocalDate date, LocalTime time); + void validateCancellable(ReservationDateTime when); - void validateUpdatable(LocalDate date, LocalTime time); // 기존 예약이 과거인지 + void validateUpdatable(ReservationDateTime when); // 기존 예약 시점이 과거인지 - void validateUpdateTarget(LocalDate date, LocalTime time);// 새 날짜, 시간이 과거인지 + void validateUpdateTarget(ReservationDateTime when); // 변경하려는 새 시점이 과거인지 } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8d1f1332d5..f820c54ed4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,3 +2,14 @@ spring.datasource.url=jdbc:h2:mem:database spring.h2.console.enabled=true spring.h2.console.path=/h2-console spring.datasource.driver-class-name=org.h2.Driver + +# 4개 테이블 모두 @Entity 가 되어 Hibernate 가 전체 DDL 소유 (schema.sql 제거). +spring.jpa.hibernate.ddl-auto=create-drop +# Hibernate DDL 생성 후 data.sql 시드 실행 +spring.jpa.defer-datasource-initialization=true + +# 발행 SQL + 바인딩 추적 +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.orm.jdbc.bind=TRACE diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 2e40c4c0e5..0000000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,45 +0,0 @@ -drop table if exists waiting; -drop table if exists reservation; -drop table if exists reservation_time; -drop table if exists theme; - -CREATE TABLE reservation_time ( - id BIGINT NOT NULL AUTO_INCREMENT, - start_at TIME NOT NULL, - PRIMARY KEY (id), - UNIQUE (start_at) -); - -CREATE TABLE theme ( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(30) NOT NULL , - description VARCHAR(255) NOT NULL , - thumbnail_url VARCHAR(255) NOT NULL, - PRIMARY KEY (id) -); - - -CREATE TABLE reservation ( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(30) NOT NULL, - date DATE NOT NULL, - time_id BIGINT NOT NULL, - theme_id BIGINT NOT NULL, - PRIMARY KEY (id), - FOREIGN KEY (time_id) REFERENCES reservation_time (id), - FOREIGN KEY (theme_id) REFERENCES theme (id), - UNIQUE (date, time_id, theme_id) -); - -CREATE TABLE waiting ( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(30) NOT NULL, - date DATE NOT NULL, - time_id BIGINT NOT NULL, - theme_id BIGINT NOT NULL, - order_index INT NOT NULL, - PRIMARY KEY (id), - FOREIGN KEY (time_id) REFERENCES reservation_time (id), - FOREIGN KEY (theme_id) REFERENCES theme (id), - UNIQUE (date, time_id, theme_id, order_index) -); diff --git a/src/test/java/roomescape/adapter/persistence/JdbcReservationRepositoryTest.java b/src/test/java/roomescape/adapter/persistence/JdbcReservationRepositoryTest.java deleted file mode 100644 index ad296d5191..0000000000 --- a/src/test/java/roomescape/adapter/persistence/JdbcReservationRepositoryTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package roomescape.adapter.persistence; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.Reservation; -import roomescape.domain.repository.ReservationRepository; - -/** - * JdbcReservationRepository 슬라이스 테스트 (@JdbcTest). - * - *

검증 대상: 직접 작성한 비단순 SQL의 정확성. - *

    - *
  • findByNameOrderByDateAscTimeAsc — 날짜·시간 다중 정렬 + 이름 필터 + 조인 매핑
  • - *
  • existsByDateAndTimeAndThemeExcludingId — 변경 시 "자기 자신은 충돌에서 제외"하는 id != ? 조건
  • - *
  • existsBySlotAndName / existsByTimeId / existsByThemeId — 존재 여부 쿼리의 참/거짓 경계
  • - *
  • updateDateAndTime — 부분 갱신이 조회에 반영되는가
  • - *
- * - *

save/findById의 해피 패스는 서비스 통합·인수에서 자연히 거쳐가므로 여기서 두껍게 검증하지 않는다. - * "id != ?"의 자기 제외 같은 미묘한 조건은 SQL을 실제 실행해야만 잡혀서, 이 슬라이스의 고유 가치다. - */ -@JdbcTest -@Import(JdbcReservationRepository.class) -class JdbcReservationRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private ReservationRepository reservationRepository; - - private static final LocalDate DATE = LocalDate.of(2050, 12, 31); - - private Long timeId10; - private Long timeId11; - private Long themeId; - - @BeforeEach - void setUp() { - timeId10 = insertTime(LocalTime.of(10, 0)); - timeId11 = insertTime(LocalTime.of(11, 0)); - themeId = insertTheme("테마A"); - } - - @Nested - @DisplayName("findByNameOrderByDateAscTimeAsc — 내 예약을 날짜·시간 순으로") - class FindByName { - - @Test - @DisplayName("해당 이름의 예약만, 날짜 ASC·시간 ASC로 반환한다") - void 이름_조회_정렬() { - // 같은 이름의 예약을 날짜·시간 뒤섞어 insert - insertReservation("브라운", DATE.plusDays(1), timeId10); - insertReservation("브라운", DATE, timeId11); - insertReservation("브라운", DATE, timeId10); - insertReservation("콘", DATE, timeId10, insertTheme("테마B")); // 다른 사람 (필터 검증) - - List result = reservationRepository.findByNameOrderByDateAscTimeAsc("브라운"); - - assertThat(result).hasSize(3); - // DATE 10:00 → DATE 11:00 → DATE+1 10:00 - assertThat(result.get(0).getDate()).isEqualTo(DATE); - assertThat(result.get(0).getTime().getStartAt()).isEqualTo(LocalTime.of(10, 0)); - assertThat(result.get(1).getDate()).isEqualTo(DATE); - assertThat(result.get(1).getTime().getStartAt()).isEqualTo(LocalTime.of(11, 0)); - assertThat(result.get(2).getDate()).isEqualTo(DATE.plusDays(1)); - } - - @Test - @DisplayName("해당 이름의 예약이 없으면 빈 리스트") - void 빈_결과() { - assertThat(reservationRepository.findByNameOrderByDateAscTimeAsc("없는사람")).isEmpty(); - } - } - - @Nested - @DisplayName("중복 검사 쿼리") - class ExistsQueries { - - @Test - @DisplayName("existsByDateAndTimeAndTheme — 슬롯에 예약이 있으면 true") - void 슬롯_존재() { - insertReservation("브라운", DATE, timeId10); - - assertThat(reservationRepository.existsByDateAndTimeAndTheme(DATE, timeId10, themeId)).isTrue(); - assertThat(reservationRepository.existsByDateAndTimeAndTheme(DATE, timeId11, themeId)).isFalse(); - } - - @Test - @DisplayName("existsBySlotAndName — 그 슬롯에 그 이름의 예약이 있으면 true") - void 슬롯_이름_존재() { - insertReservation("브라운", DATE, timeId10); - - assertThat(reservationRepository.existsBySlotAndName(DATE, timeId10, themeId, "브라운")).isTrue(); - assertThat(reservationRepository.existsBySlotAndName(DATE, timeId10, themeId, "콘")).isFalse(); - } - - @Test - @DisplayName("existsByDateAndTimeAndThemeExcludingId — 자기 자신은 충돌에서 제외한다") - void 자기_제외() { - Long myId = insertReservation("브라운", DATE, timeId10); - - // 자기 id를 제외하면 그 슬롯에 다른 예약이 없으므로 false - assertThat(reservationRepository - .existsByDateAndTimeAndThemeExcludingId(DATE, timeId10, themeId, myId)).isFalse(); - - // 같은 테마·날짜의 다른 시간(11:00)에 콘의 예약을 넣으면, - // 11:00 슬롯 기준으로 내 id(10:00 예약)를 제외해도 콘이 남아 true - insertReservation("콘", DATE, timeId11); - assertThat(reservationRepository - .existsByDateAndTimeAndThemeExcludingId(DATE, timeId11, themeId, myId)).isTrue(); - } - - @Test - @DisplayName("existsByTimeId / existsByThemeId — 참조 존재 여부") - void 참조_존재() { - insertReservation("브라운", DATE, timeId10); - - assertThat(reservationRepository.existsByTimeId(timeId10)).isTrue(); - assertThat(reservationRepository.existsByTimeId(timeId11)).isFalse(); - assertThat(reservationRepository.existsByThemeId(themeId)).isTrue(); - } - } - - @Nested - @DisplayName("updateDateAndTime") - class Update { - - @Test - @DisplayName("날짜·시간을 갱신하면 조회 시 새 값이 반영된다") - void 부분_갱신_반영() { - Long id = insertReservation("브라운", DATE, timeId10); - - reservationRepository.updateDateAndTime(id, DATE.plusDays(1), timeId11); - - Reservation updated = reservationRepository.findById(id).orElseThrow(); - assertThat(updated.getDate()).isEqualTo(DATE.plusDays(1)); - assertThat(updated.getTime().getStartAt()).isEqualTo(LocalTime.of(11, 0)); - } - } - - // --- given 헬퍼 (이 슬라이스는 ReservationTestHelper 빈을 로드하지 않으므로 로컬 헬퍼) --- - - private Long insertTime(LocalTime startAt) { - jdbcTemplate.update("INSERT INTO reservation_time (start_at) VALUES (?)", startAt); - return jdbcTemplate.queryForObject( - "SELECT id FROM reservation_time WHERE start_at = ?", Long.class, startAt); - } - - private Long insertTheme(String name) { - jdbcTemplate.update( - "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)", - name, "설명", "url"); - return jdbcTemplate.queryForObject( - "SELECT id FROM theme WHERE name = ?", Long.class, name); - } - - private Long insertReservation(String name, LocalDate date, Long timeId) { - return insertReservation(name, date, timeId, themeId); - } - - private Long insertReservation(String name, LocalDate date, Long timeId, Long themeId) { - jdbcTemplate.update( - "INSERT INTO reservation (name, date, time_id, theme_id) VALUES (?, ?, ?, ?)", - name, date, timeId, themeId); - return jdbcTemplate.queryForObject( - "SELECT id FROM reservation WHERE name = ? AND date = ? AND time_id = ? AND theme_id = ?", - Long.class, name, date, timeId, themeId); - } -} diff --git a/src/test/java/roomescape/adapter/persistence/JdbcReservationTimeRepositoryTest.java b/src/test/java/roomescape/adapter/persistence/JdbcReservationTimeRepositoryTest.java deleted file mode 100644 index 648da14987..0000000000 --- a/src/test/java/roomescape/adapter/persistence/JdbcReservationTimeRepositoryTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package roomescape.adapter.persistence; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.ReservationTime; -import roomescape.domain.repository.ReservationTimeRepository; - -/** - * JdbcReservationTimeRepository 슬라이스 테스트 (@JdbcTest). - * - *

검증 대상: findAvailable의 NOT IN 서브쿼리. - * "특정 날짜·테마에 이미 예약된 시간을 제외하고 남은 시간만" 반환하는 비단순 쿼리다. - * 서브쿼리의 날짜·테마 조건이 정확한지는 SQL을 실제 실행해야만 잡힌다. - * - *

findAll/save/deleteById, UNIQUE(start_at) 제약 같은 건 서비스·인수에서 거쳐가므로 - * 여기서는 findAvailable의 필터링 경계에 집중한다. - */ -@JdbcTest -@Import(JdbcReservationTimeRepository.class) -class JdbcReservationTimeRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private ReservationTimeRepository reservationTimeRepository; - - private static final LocalDate DATE = LocalDate.of(2050, 12, 31); - - private Long time10; - private Long time11; - private Long themeId; - - @BeforeEach - void setUp() { - time10 = insertTime(LocalTime.of(10, 0)); - time11 = insertTime(LocalTime.of(11, 0)); - themeId = insertTheme("테마A"); - } - - @Nested - @DisplayName("findAvailable — 예약되지 않은 시간만") - class Available { - - @Test - @DisplayName("해당 날짜·테마에 예약된 시간은 제외된다") - void 예약된_시간_제외() { - insertReservation("브라운", DATE, time10, themeId); // 10:00 예약됨 - - List available = reservationTimeRepository.findAvailable(DATE, themeId); - - assertThat(available).extracting(ReservationTime::getStartAt) - .containsExactlyInAnyOrder(LocalTime.of(11, 0)); // 11:00만 남음 - } - - @Test - @DisplayName("다른 날짜의 예약은 영향을 주지 않는다 (날짜 경계)") - void 다른_날짜_무관() { - insertReservation("브라운", DATE.minusDays(1), time10, themeId); // 다른 날짜에 10:00 예약 - - List available = reservationTimeRepository.findAvailable(DATE, themeId); - - // DATE 기준으로는 10:00도 11:00도 모두 가능 - assertThat(available).extracting(ReservationTime::getStartAt) - .containsExactlyInAnyOrder(LocalTime.of(10, 0), LocalTime.of(11, 0)); - } - - @Test - @DisplayName("다른 테마의 예약은 영향을 주지 않는다 (테마 경계)") - void 다른_테마_무관() { - Long themeB = insertTheme("테마B"); - insertReservation("브라운", DATE, time10, themeB); // 테마B의 10:00 예약 - - // 테마A 기준으로는 10:00도 여전히 가능 - List availableA = reservationTimeRepository.findAvailable(DATE, themeId); - - assertThat(availableA).extracting(ReservationTime::getStartAt) - .containsExactlyInAnyOrder(LocalTime.of(10, 0), LocalTime.of(11, 0)); - } - - @Test - @DisplayName("예약이 없으면 등록된 모든 시간이 가능하다") - void 예약_없으면_전부() { - List available = reservationTimeRepository.findAvailable(DATE, themeId); - - assertThat(available).hasSize(2); - } - } - - // --- given 헬퍼 --- - - private Long insertTime(LocalTime startAt) { - jdbcTemplate.update("INSERT INTO reservation_time (start_at) VALUES (?)", startAt); - return jdbcTemplate.queryForObject( - "SELECT id FROM reservation_time WHERE start_at = ?", Long.class, startAt); - } - - private Long insertTheme(String name) { - jdbcTemplate.update( - "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)", - name, "설명", "url"); - return jdbcTemplate.queryForObject( - "SELECT id FROM theme WHERE name = ?", Long.class, name); - } - - private void insertReservation(String name, LocalDate date, Long timeId, Long themeId) { - jdbcTemplate.update( - "INSERT INTO reservation (name, date, time_id, theme_id) VALUES (?, ?, ?, ?)", - name, date, timeId, themeId); - } -} diff --git a/src/test/java/roomescape/adapter/persistence/JdbcThemeRepositoryTest.java b/src/test/java/roomescape/adapter/persistence/JdbcThemeRepositoryTest.java deleted file mode 100644 index 86d01336ac..0000000000 --- a/src/test/java/roomescape/adapter/persistence/JdbcThemeRepositoryTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package roomescape.adapter.persistence; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.repository.ThemeRepository; -import roomescape.domain.repository.projection.PopularThemeProjection; - -/** - * JdbcThemeRepository 슬라이스 테스트 (@JdbcTest). - * - *

검증 대상: findPopularBetween의 집계 SQL. 이건 단순 CRUD가 아니라 - * INNER JOIN + COUNT + GROUP BY + ORDER BY DESC + LIMIT + 날짜 범위(>= from, < to)가 얽힌 비단순 쿼리라, 그 자체로 검증 가치가 있다. (네 - * "Repository 테스트 필요한가" 정리의 핵심 사례) - * - *

특히 날짜 경계가 까다롭다: from은 포함(>=), to는 제외(<). 이 경계는 SQL을 실제 실행해야 잡힌다. - * findAll/save/deleteById 같은 단순 동작은 서비스·인수에서 거쳐가므로 여기서 두껍게 검증하지 않는다. - */ -@JdbcTest -@Import(JdbcThemeRepository.class) -class JdbcThemeRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private ThemeRepository themeRepository; - - private static final LocalDate FROM = LocalDate.of(2026, 5, 2); - private static final LocalDate TO = LocalDate.of(2026, 5, 9); // 집계 범위: [FROM, TO) - - private Long timeId; - - @BeforeEach - void setUp() { - timeId = insertTime(LocalTime.of(10, 0)); - } - - @Nested - @DisplayName("findPopularBetween — 기간 내 예약 건수 집계") - class Popular { - - @Test - @DisplayName("예약 건수 내림차순으로 정렬되고, 건수가 함께 담긴다") - void 건수_내림차순() { - Long popular = insertTheme("인기"); - Long less = insertTheme("덜인기"); - // 인기 테마에 2건, 덜인기에 1건 (모두 기간 내) - insertReservation("a", LocalDate.of(2026, 5, 3), popular); - insertReservation("b", LocalDate.of(2026, 5, 4), popular); - insertReservation("c", LocalDate.of(2026, 5, 3), less); - - List result = themeRepository.findPopularBetween(FROM, TO, 10); - - assertThat(result).extracting(p -> p.getTheme().getName()) - .containsExactly("인기", "덜인기"); - assertThat(result.get(0).getReservationCount()).isEqualTo(2L); - assertThat(result.get(1).getReservationCount()).isEqualTo(1L); - } - - @Test - @DisplayName("[날짜 경계] from은 포함(>=), to는 제외(<)된다") - void 날짜_경계() { - Long onFrom = insertTheme("from당일"); - Long onTo = insertTheme("to당일"); - Long before = insertTheme("from이전"); - - insertReservation("a", FROM, onFrom); // 경계 시작 → 포함되어야 - insertReservation("b", TO, onTo); // 경계 끝 → 제외되어야 - insertReservation("c", FROM.minusDays(1), before); // 범위 이전 → 제외 - - List result = themeRepository.findPopularBetween(FROM, TO, 10); - - assertThat(result).extracting(p -> p.getTheme().getName()) - .containsExactly("from당일"); // onFrom만 집계됨 - } - - @Test - @DisplayName("[LIMIT] 상위 N개만 반환한다") - void 상위_N개() { - // 테마i가 i건의 예약을 갖도록 구성. 같은 슬롯(date,time,theme)엔 UNIQUE 제약이 있고 - // start_at에도 UNIQUE가 있으므로, 예약마다 서로 다른 시간 슬롯을 명시적으로 만들어 충돌을 피한다. - int hour = 11; - for (int themeNo = 1; themeNo <= 3; themeNo++) { - Long theme = insertTheme("테마" + themeNo); - for (int c = 0; c < themeNo; c++) { // 테마1=1건, 테마2=2건, 테마3=3건 - Long uniqueTime = insertTime(LocalTime.of(hour++, 0)); // 매번 다른 시각 - insertReservation("u" + themeNo + "_" + c, LocalDate.of(2026, 5, 3), - uniqueTime, theme); - } - } - - List result = themeRepository.findPopularBetween(FROM, TO, 2); - - assertThat(result).hasSize(2); // 3개 중 상위 2개만 - assertThat(result.get(0).getTheme().getName()).isEqualTo("테마3"); // 3건으로 최다 - } - } - - // --- given 헬퍼 --- - - private Long insertTime(LocalTime startAt) { - jdbcTemplate.update("INSERT INTO reservation_time (start_at) VALUES (?)", startAt); - return jdbcTemplate.queryForObject( - "SELECT id FROM reservation_time WHERE start_at = ?", Long.class, startAt); - } - - private Long insertTheme(String name) { - jdbcTemplate.update( - "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)", - name, "설명", "url"); - return jdbcTemplate.queryForObject( - "SELECT id FROM theme WHERE name = ?", Long.class, name); - } - - private void insertReservation(String name, LocalDate date, Long themeId) { - insertReservation(name, date, timeId, themeId); - } - - private void insertReservation(String name, LocalDate date, Long timeId, Long themeId) { - jdbcTemplate.update( - "INSERT INTO reservation (name, date, time_id, theme_id) VALUES (?, ?, ?, ?)", - name, date, timeId, themeId); - } -} diff --git a/src/test/java/roomescape/adapter/persistence/JdbcWaitingRepositoryTest.java b/src/test/java/roomescape/adapter/persistence/JdbcWaitingRepositoryTest.java deleted file mode 100644 index bebfc4db2c..0000000000 --- a/src/test/java/roomescape/adapter/persistence/JdbcWaitingRepositoryTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package roomescape.adapter.persistence; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.Waiting; -import roomescape.domain.repository.WaitingRepository; - -/** - * JdbcWaitingRepository 슬라이스 테스트 (@JdbcTest). - * - *

검증 대상: "우리가 직접 작성한 SQL"의 정확성. 이건 Service 통합 테스트의 해피 패스로는 - * 충분히 검증되지 않는다(통합은 save·findBySlot을 한 경로로만 스쳐 간다). 컬럼명 오타, - * 파라미터 바인딩 순서, ORDER BY 정렬, 조인 매핑 같은 건 SQL을 실제로 실행해야 잡힌다. - * - *

왜 @SpringBootTest가 아니라 @JdbcTest인가: 이 검증에 서비스·웹 계층은 필요 없다. - * JdbcTemplate과 DataSource만 있으면 된다. 슬라이스로 좁히면 컨텍스트가 가볍고 빠르며, - * 실패 시 "쿼리 문제"로 원인이 바로 좁혀진다. - * - *

주의: @JdbcTest는 @Repository 빈을 자동 스캔하지 않으므로 @Import로 명시한다. - * schema.sql은 클래스패스에 있으면 슬라이스가 자동 적용한다(임베디드 H2). - * - *

@JdbcTest는 기본적으로 각 테스트를 트랜잭션으로 감싸 자동 롤백한다. - * 여기서는 단일 쿼리의 정확성만 보고 트랜잭션 경계를 검증하지 않으므로 롤백 격리로 충분하다. - * (트랜잭션 경계 검증이 필요한 건 Service 통합 테스트 쪽이고, 거기선 롤백을 쓰지 않는다.) - */ -@JdbcTest -@Import(JdbcWaitingRepository.class) -class JdbcWaitingRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - @Autowired - private WaitingRepository waitingRepository; - - private static final LocalDate DATE = LocalDate.of(2050, 12, 31); - - private Long timeId; - private Long themeId; - - @BeforeEach - void setUp() { - jdbcTemplate.update("INSERT INTO reservation_time (start_at) VALUES (?)", LocalTime.of(10, 0)); - timeId = jdbcTemplate.queryForObject( - "SELECT id FROM reservation_time WHERE start_at = ?", Long.class, LocalTime.of(10, 0)); - - jdbcTemplate.update( - "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)", - "테마A", "설명", "url"); - themeId = jdbcTemplate.queryForObject( - "SELECT id FROM theme WHERE name = ?", Long.class, "테마A"); - } - - @Nested - @DisplayName("save") - class Save { - - @Test - @DisplayName("저장하면 발급된 id를 가진 대기를 반환한다") - void 저장_후_id_발급() { - Waiting saved = waitingRepository.save( - Waiting.create("콘", DATE, - roomescape.domain.ReservationTime.withId(timeId, LocalTime.of(10, 0)), - roomescape.domain.Theme.withId(themeId, "테마A", "설명", "url"), - 1)); - - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getName()).isEqualTo("콘"); - assertThat(saved.getOrderIndex()).isEqualTo(1); - } - } - - @Nested - @DisplayName("findBySlot — 같은 슬롯의 대기를 순번 오름차순으로") - class FindBySlot { - - @Test - @DisplayName("같은 슬롯의 대기만, order_index 오름차순으로 반환한다") - void 슬롯_조회_정렬() { - // 일부러 순번을 뒤섞어 insert한다 — ORDER BY가 진짜 동작하는지 보기 위함 - insertWaiting("핀", DATE, 3); - insertWaiting("콘", DATE, 1); - insertWaiting("모카", DATE, 2); - - List result = waitingRepository.findBySlot(DATE, timeId, themeId); - - assertThat(result).extracting(Waiting::getName) - .containsExactly("콘", "모카", "핀"); // 순번 오름차순 - assertThat(result).extracting(Waiting::getOrderIndex) - .containsExactly(1, 2, 3); - } - - @Test - @DisplayName("다른 날짜의 대기는 포함되지 않는다 (필터링 경계)") - void 다른_슬롯_제외() { - insertWaiting("콘", DATE, 1); - insertWaiting("모카", DATE.plusDays(1), 1); // 다른 날짜 - - List result = waitingRepository.findBySlot(DATE, timeId, themeId); - - assertThat(result).extracting(Waiting::getName).containsExactly("콘"); - } - - @Test - @DisplayName("대기가 없으면 빈 리스트를 반환한다 (빈 결과 경계)") - void 빈_결과() { - assertThat(waitingRepository.findBySlot(DATE, timeId, themeId)).isEmpty(); - } - } - - @Nested - @DisplayName("findByName — 내 대기를 날짜·시간 순으로") - class FindByName { - - @Test - @DisplayName("해당 이름의 대기만, 날짜·시간 오름차순으로 반환한다") - void 이름_조회_정렬() { - jdbcTemplate.update("INSERT INTO reservation_time (start_at) VALUES (?)", LocalTime.of(11, 0)); - Long timeId11 = jdbcTemplate.queryForObject( - "SELECT id FROM reservation_time WHERE start_at = ?", Long.class, LocalTime.of(11, 0)); - - // 같은 이름, 날짜·시간을 뒤섞어 insert - insertWaitingWithTime("콘", DATE.plusDays(1), timeId, 1); - insertWaitingWithTime("콘", DATE, timeId11, 1); - insertWaitingWithTime("콘", DATE, timeId, 1); - insertWaiting("모카", DATE, 2); // 다른 사람 (필터 검증) - - List result = waitingRepository.findByName("콘"); - - assertThat(result).hasSize(3); - // DATE 10:00 → DATE 11:00 → DATE+1 10:00 순 - assertThat(result.get(0).getDate()).isEqualTo(DATE); - assertThat(result.get(0).getTime().getStartAt()).isEqualTo(LocalTime.of(10, 0)); - assertThat(result.get(1).getDate()).isEqualTo(DATE); - assertThat(result.get(1).getTime().getStartAt()).isEqualTo(LocalTime.of(11, 0)); - assertThat(result.get(2).getDate()).isEqualTo(DATE.plusDays(1)); - } - } - - @Nested - @DisplayName("updateOrderIndex / deleteById / findById") - class UpdateAndDelete { - - @Test - @DisplayName("순번을 갱신하면 조회 시 새 순번이 반영된다") - void 순번_갱신() { - insertWaiting("콘", DATE, 3); - Long id = waitingRepository.findBySlot(DATE, timeId, themeId).get(0).getId(); - - waitingRepository.updateOrderIndex(id, 1); - - assertThat(waitingRepository.findById(id)).isPresent(); - assertThat(waitingRepository.findById(id).get().getOrderIndex()).isEqualTo(1); - } - - @Test - @DisplayName("삭제하면 findById가 비어 있다") - void 삭제() { - insertWaiting("콘", DATE, 1); - Long id = waitingRepository.findBySlot(DATE, timeId, themeId).get(0).getId(); - - waitingRepository.deleteById(id); - - assertThat(waitingRepository.findById(id)).isEmpty(); - } - } - - // --- given 헬퍼 (이 슬라이스는 ReservationTestHelper 빈을 로드하지 않으므로 로컬 헬퍼 사용) --- - - private void insertWaiting(String name, LocalDate date, int order) { - insertWaitingWithTime(name, date, timeId, order); - } - - private void insertWaitingWithTime(String name, LocalDate date, Long timeId, int order) { - jdbcTemplate.update( - "INSERT INTO waiting (name, date, time_id, theme_id, order_index) VALUES (?, ?, ?, ?, ?)", - name, date, timeId, themeId, order); - } -} diff --git a/src/test/java/roomescape/adapter/web/AdminReservationControllerTest.java b/src/test/java/roomescape/adapter/web/AdminReservationControllerTest.java index 54974268aa..68bd1e7d3e 100644 --- a/src/test/java/roomescape/adapter/web/AdminReservationControllerTest.java +++ b/src/test/java/roomescape/adapter/web/AdminReservationControllerTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.ReservationService; @@ -32,7 +32,7 @@ class AdminReservationControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private ReservationService reservationService; @Nested diff --git a/src/test/java/roomescape/adapter/web/AdminReservationTimeControllerTest.java b/src/test/java/roomescape/adapter/web/AdminReservationTimeControllerTest.java index 8a753d4183..a28659171c 100644 --- a/src/test/java/roomescape/adapter/web/AdminReservationTimeControllerTest.java +++ b/src/test/java/roomescape/adapter/web/AdminReservationTimeControllerTest.java @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.ReservationTimeService; @@ -32,7 +32,7 @@ class AdminReservationTimeControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private ReservationTimeService reservationTimeService; @Nested diff --git a/src/test/java/roomescape/adapter/web/AdminThemeControllerTest.java b/src/test/java/roomescape/adapter/web/AdminThemeControllerTest.java index 6f36b82c93..3a200c5fc5 100644 --- a/src/test/java/roomescape/adapter/web/AdminThemeControllerTest.java +++ b/src/test/java/roomescape/adapter/web/AdminThemeControllerTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.ThemeService; @@ -24,7 +24,7 @@ * AdminThemeController 슬라이스 테스트 (@WebMvcTest). * *

고유 책임: 테마 생성 요청 본문의 @Valid 검증(@NotBlank)과, 서비스 예외의 상태코드 변환. - * 서비스는 @MockBean으로 대체한다. + * 서비스는 @MockitoBean으로 대체한다. */ @WebMvcTest(AdminThemeController.class) class AdminThemeControllerTest { @@ -32,7 +32,7 @@ class AdminThemeControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private ThemeService themeService; @Nested diff --git a/src/test/java/roomescape/adapter/web/UserReservationControllerTest.java b/src/test/java/roomescape/adapter/web/UserReservationControllerTest.java index e2e2832958..9336768340 100644 --- a/src/test/java/roomescape/adapter/web/UserReservationControllerTest.java +++ b/src/test/java/roomescape/adapter/web/UserReservationControllerTest.java @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.junit.jupiter.api.BeforeEach; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.ReservationService; @@ -35,7 +35,7 @@ class UserReservationControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private ReservationService reservationService; @BeforeEach diff --git a/src/test/java/roomescape/adapter/web/UserThemeControllerTest.java b/src/test/java/roomescape/adapter/web/UserThemeControllerTest.java index 804557d659..962ac952c8 100644 --- a/src/test/java/roomescape/adapter/web/UserThemeControllerTest.java +++ b/src/test/java/roomescape/adapter/web/UserThemeControllerTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.ReservationTimeService; import roomescape.application.ThemeService; @@ -26,7 +26,7 @@ * date 타입 오류(abc) → MethodArgumentTypeMismatchException → 400. * 이 흐름은 HTTP 파라미터 바인딩 단계라 서비스 직접 호출로는 검증되지 않는, 이 슬라이스의 고유 가치다. * - *

이 컨트롤러는 ThemeService와 ReservationTimeService 둘 다 의존하므로 @MockBean 2개가 필요하다. + *

이 컨트롤러는 ThemeService와 ReservationTimeService 둘 다 의존하므로 @MockitoBean 2개가 필요하다. */ @WebMvcTest(UserThemeController.class) class UserThemeControllerTest { @@ -34,10 +34,10 @@ class UserThemeControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private ThemeService themeService; - @MockBean + @MockitoBean private ReservationTimeService reservationTimeService; @Nested diff --git a/src/test/java/roomescape/adapter/web/UserWaitingControllerTest.java b/src/test/java/roomescape/adapter/web/UserWaitingControllerTest.java index e676b68932..72c6bf762c 100644 --- a/src/test/java/roomescape/adapter/web/UserWaitingControllerTest.java +++ b/src/test/java/roomescape/adapter/web/UserWaitingControllerTest.java @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import roomescape.application.WaitingService; @@ -33,7 +33,7 @@ *

  • 정상 생성 시 201과 응답 형식
  • *
  • 서비스가 던진 예외가 GlobalExceptionHandler를 거쳐 약속된 상태코드+{"message":...}로 변환되는가
  • * - * 서비스는 @MockBean으로 대체한다 — 여기서 "유스케이스가 진짜 동작하는가"는 검증하지 않는다. + * 서비스는 @MockitoBean으로 대체한다 — 여기서 "유스케이스가 진짜 동작하는가"는 검증하지 않는다. * 그건 WaitingServiceTest(통합)의 책임이다. * *

    학습 메모: 이 슬라이스가 "인수 테스트로 흡수해도 되는데 왜 따로 두나"의 사례다. @@ -48,7 +48,7 @@ class UserWaitingControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private WaitingService waitingService; @Nested diff --git a/src/test/java/roomescape/application/ReservationPromotionTransactionTest.java b/src/test/java/roomescape/application/ReservationPromotionTransactionTest.java new file mode 100644 index 0000000000..6a83f345fd --- /dev/null +++ b/src/test/java/roomescape/application/ReservationPromotionTransactionTest.java @@ -0,0 +1,60 @@ +package roomescape.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willThrow; + +import java.time.LocalDate; +import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import roomescape.domain.repository.WaitingRepository; +import roomescape.support.ServiceIntegrationTest; + +@DisplayName("예약 취소 → 자동 승격 트랜잭션 경계") +class ReservationPromotionTransactionTest extends ServiceIntegrationTest { + + @Autowired + private ReservationService reservationService; + + // @MockBean이 아니라 @MockitoSpyBean: 실제 빈을 감싸 다른 write는 진짜 H2로 가고 + // updateOrderIndex만 던지게 한다 → "진짜 쓰인 것들이 롤백되는가"를 검증할 수 있다. + @MockitoSpyBean + private WaitingRepository waitingRepository; + + /** + * 부분 실패의 끝 상태는 셋이다 — [2]슬롯 텅 빔 / [3]예약·대기 이중 존재 / [4]순번 구멍. 하지만 셋 다 같은 @Transactional 경계 하나가 막는 같은 회귀라, 변경이 가장 많이 + * 쌓인 [4](재정렬)를 대표로 검증한다. 지점별 예외 처리(예: 예약금 승격 실패)가 생겨 [2]가 [4]와 다른 회귀가 되면 그때 분리한다. + */ + @Test + @DisplayName("[대표 검증·4단계 재정렬] 마지막 단계가 실패하면 취소·승격·대기삭제가 모두 롤백된다") + void 자동승격_여러단계변경_중간실패_전체롤백() { + // given: 예약 브라운 + 대기 [콘:1, 모카:2, 핀:3] + LocalDate date = LocalDate.of(2050, 12, 31); + Long timeId = fixture.insertTime(LocalTime.of(10, 0)); + Long themeId = fixture.insertTheme("테마A"); + Long brownId = fixture.insertReservation("브라운", date, timeId, themeId); + Long conId = fixture.insertWaiting("콘", date, timeId, themeId, 1); + Long mochaId = fixture.insertWaiting("모카", date, timeId, themeId, 2); + Long pinId = fixture.insertWaiting("핀", date, timeId, themeId, 3); + + // [4] 재정렬 UPDATE에 결함 주입 — 쌓인 변경이 가장 많은 지점 = 대표 케이스 + willThrow(new RuntimeException("재정렬 실패 주입")) + .given(waitingRepository).updateOrderIndex(anyLong(), anyInt()); + + // when: 브라운이 본인 예약 취소 → 자동 승격 → [4]에서 실패 + assertThatThrownBy(() -> reservationService.deleteByOwner(brownId, "브라운")) + .isInstanceOf(RuntimeException.class); + + // then: 전부 롤백 + assertThat(fixture.findReservationOwner(date, timeId, themeId)).isEqualTo("브라운"); // 취소 안 됨 + assertThat(fixture.existsWaiting(conId)).isTrue(); // 콘 승격 안 됨 + assertThat(fixture.findWaitingOrder(conId)).isEqualTo(1); // 순번 그대로 + assertThat(fixture.findWaitingOrder(mochaId)).isEqualTo(2); + assertThat(fixture.findWaitingOrder(pinId)).isEqualTo(3); + } +} diff --git a/src/test/java/roomescape/application/ReservationServiceTest.java b/src/test/java/roomescape/application/ReservationServiceTest.java index a775b0b73c..fe3c484880 100644 --- a/src/test/java/roomescape/application/ReservationServiceTest.java +++ b/src/test/java/roomescape/application/ReservationServiceTest.java @@ -31,12 +31,28 @@ *

  • 여러 repository가 협력하는 시나리오(예약 취소 → 첫 대기 자동 승격) — 결과를 DB로 확인한다.
  • * * + *

    검증 대상: "서비스만의 협력 책임" — 슬라이스(SQL)나 도메인 단위로 내려갈 수 없는 것. + * 예외 전환, 여러 Repository 조율, 다중 도메인 협력(자동 승격), 병합 및 정렬. + * + *

    이 파일은 두 가지 검증 모드를 의식적으로 섞어 쓴다. 각 블록·테스트에 어떤 모드인지 표시한다: + *

      + *
    • 모드 A (분기 전부) — 회귀가 서비스 안의 로직(if-throw·조율·다중 도메인)이라 다른 자리로 + * 내려가지 않고, 분기 결과가 서로 의미 있게 다를 때. 의미 있는 분기를 모두 검증한다. + * (CancelAndPromote, MyReservationsAndWaitings, Update, 그리고 Create의 사전 조회 실패 두 분기)
    • + *
    • 모드 B (대표 한 케이스) — 같은 회귀를 슬라이스가 더 싸게 이미 사주고 있고, 서비스가 하는 + * 일이 "그 검증을 흐름에서 호출하는 협력" 자체일 때. 대표 한 케이스로 연결만 확인한다. + * (Create의 중복 검사·정책 연결)
    • + *
    + * "빠짐없이 다 짠다"가 목표가 아니라 "각 회귀를 어디서 가장 싸게 보호할지 정하고 그 결정을 + * 드러낸다"가 목표다. + * *

    시간 결정성: 과거/미래의 의미가 흔들리지 않도록 @Import(FixedClockConfig)로 * 고정 시계(2026-05-13 12:00) 기반 정책을 @Primary 주입한다. * (과거 거부 "규칙 자체"는 FutureOnlyPolicyTest가 단위로 검증했으므로, 여기서는 그 규칙이 * 서비스 흐름에 연결됐는지만 본다.) * *

    자동 승격의 "원자성"(중간 실패 시 전체 롤백)은 이 사이클에서 검증하지 않는다. + * * @Transactional이 코드에 들어오는 사이클 2의 검증 대상이다. 여기서는 정상 흐름의 "결과"만 본다. */ @Import(FixedClockConfig.class) @@ -61,15 +77,33 @@ void setUpSlot() { class Create { @Test - @DisplayName("미래 슬롯에 정상 예약이 생성된다") - void 정상_생성() { - assertThatCode(() -> reservationService.create( - new ReservationCreateCommand("브라운", FUTURE, timeId, themeId))) - .doesNotThrowAnyException(); + @DisplayName("[모드 B·정책 통과] 미래 시점 예약은 생성되고 입력한 날짜·시간이 반영된다") + void 미래_예약_생성() { + ReservationResult created = reservationService.create( + new ReservationCreateCommand("브라운", FUTURE, timeId, themeId)); + + // 정책 연결의 "통과 측" — FUTURE는 FixedClock 기준 미래라 정책이 통과시켜야 한다. + // (거부 측은 과거_거부가 본다. 규칙의 5개 경계 자체는 FutureOnlyPolicyTest가 단위로 본다.) + assertThat(created.getDate()).isEqualTo(FUTURE); + assertThat(created.getTime().getStartAt()).isEqualTo(LocalTime.of(10, 0)); } + @Test - @DisplayName("[상태 의존] 같은 날짜+시간+테마에 이미 예약이 있으면 거부된다") + @DisplayName("[모드 B·정책 거부] 과거 날짜는 정책에 의해 거부된다") + void 과거_거부() { + // 정책 연결의 "거부 측". 과거 거부 규칙 자체의 경계는 FutureOnlyPolicyTest가 단위로 검증했으므로, + // 여기서는 그 규칙이 서비스 흐름에 연결됐는지만 본다. + LocalDate yesterday = FixedClockConfig.TODAY.minusDays(1); + + assertThatThrownBy(() -> reservationService.create( + new ReservationCreateCommand("브라운", yesterday, timeId, themeId))) + .isInstanceOf(BusinessRuleViolationException.class) + .hasMessage("지나간 날짜, 시간으로는 예약할 수 없습니다."); + } + + @Test + @DisplayName("[모드 B·상태 의존] 같은 날짜+시간+테마에 이미 예약이 있으면 거부된다") void 중복_예약_거부() { reservationService.create(new ReservationCreateCommand("브라운", FUTURE, timeId, themeId)); @@ -80,8 +114,11 @@ class Create { } @Test - @DisplayName("같은 날짜+시간이라도 테마가 다르면 각각 허용된다") + @DisplayName("[모드 B·대표] 같은 날짜+시간이라도 테마가 다르면 허용된다") void 다른_테마_허용() { + // 중복 검사가 (날짜·시간·테마) 세 축을 본다는 SQL 정확성은 JdbcReservationRepositoryTest가 + // 더 싸게 검증한다. 여기서는 "서비스가 슬롯 전체로 중복 검사를 호출한다"는 협력만 테마 축을 + // 대표로 확인한다. 날짜·시간 축을 따로 두지 않는 건 같은 회귀를 슬라이스가 사주기 때문(의식적 생략). Long themeB = fixture.insertTheme("테마B"); reservationService.create(new ReservationCreateCommand("브라운", FUTURE, timeId, themeId)); @@ -90,31 +127,37 @@ class Create { .doesNotThrowAnyException(); } - @Test - @DisplayName("[시간 규칙 연결] 과거 날짜는 정책에 의해 거부된다") - void 과거_거부() { - LocalDate yesterday = FixedClockConfig.TODAY.minusDays(1); + // [모드 A] 사전 조회 실패는 시간/테마 두 분기가 서로 다른 Repository 조회이고 서로 다른 메시지를 + // 주므로(다른 사용자 경험) 각각 명시한다. 둘 다 서비스의 if-throw 전환이라 슬라이스로 못 내려온다. + @Test + @DisplayName("[모드 A·분기] 존재하지 않는 시간 ID → 404성 예외") + void 존재하지_않는_시간() { assertThatThrownBy(() -> reservationService.create( - new ReservationCreateCommand("브라운", yesterday, timeId, themeId))) - .isInstanceOf(BusinessRuleViolationException.class) - .hasMessage("지나간 날짜, 시간으로는 예약할 수 없습니다."); + new ReservationCreateCommand("브라운", FUTURE, 9999L, themeId))) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessage("존재하지 않는 시간입니다."); } @Test - @DisplayName("존재하지 않는 테마 ID → 404성 예외") + @DisplayName("[모드 A·분기] 존재하지 않는 테마 ID → 404성 예외") void 존재하지_않는_테마() { assertThatThrownBy(() -> reservationService.create( new ReservationCreateCommand("브라운", FUTURE, timeId, 9999L))) .isInstanceOf(ResourceNotFoundException.class) .hasMessage("존재하지 않는 테마입니다."); } + + } @Nested @DisplayName("예약 취소 → 첫 대기 자동 승격") class CancelAndPromote { + // [모드 A] 자동 승격은 여러 Repository·여러 도메인이 한 흐름에서 협력한다(예약 DELETE → 대기 + // DELETE → 예약 INSERT 승격 → 대기 순번 UPDATE). 슬라이스로 절대 못 내려오는 서비스 고유 협력이라 + // 각 협력 결과를 결과 단언별로 검증한다. @Test @DisplayName("예약 취소 시 첫 대기가 예약으로 승격되고 나머지 순번이 당겨진다") void 자동_승격() { @@ -158,8 +201,9 @@ class CancelAndPromote { @DisplayName("내 예약+대기 조회 (findMyReservationsAndWaitings)") class MyReservationsAndWaitings { - // 이 메서드는 단순 위임이 아니다. 예약 목록과 대기 목록을 각각 조회해 한 리스트로 합치고, - // 날짜·시간으로 다시 정렬하는 로직(results.sort)이 서비스 안에 있다. 그 병합·정렬을 검증한다. + // [모드 A] 단순 위임이 아니다. 예약 목록과 대기 목록을 각각 조회해 한 리스트로 합치고, + // 날짜·시간으로 다시 정렬하는 로직(results.sort)이 서비스 안에 있다. 슬라이스로 못 내려오는 + // 병합·정렬 협력이라 의미 있는 분기(구분/정렬/순번/빈 목록)를 각각 검증한다. @Test @DisplayName("예약과 대기가 status로 구분되어 함께 조회된다") @@ -236,8 +280,9 @@ class MyReservationsAndWaitings { @DisplayName("예약 변경 (updateByOwner)") class Update { - // 변경은 분기가 많은 유스케이스다. 각 분기가 "시스템 상태(이미 저장된 예약/시간)"에 의존하므로 - // 도메인 단위가 아니라 서비스 통합으로 검증한다. (시간 과거 판정만 정책이 담당) + // [모드 A] 변경은 분기가 많은 유스케이스다. 각 분기가 "시스템 상태(이미 저장된 예약/시간)"에 의존하며 + // 서비스 안의 if-throw·조율(정책·충돌·조회)이라 슬라이스로 못 내려오고 도메인 단위테스트도 못내려오니 + // 서비스 통합에서 분기마다 다른 예외/결과를 주므로(다른 사용자 경험) 일곱 분기를 각각 검증한다. @Test @DisplayName("본인의 미래 예약을 다른 날짜·시간으로 변경할 수 있다") @@ -301,7 +346,7 @@ class Update { } @Test - @DisplayName("[시간 규칙 연결] 이미 지난 예약은 변경할 수 없다") + @DisplayName("[분기, 시간 규칙 연결] 이미 지난 예약은 변경할 수 없다") void 지난_예약_변경_거부() { // 고정 시계(TODAY) 기준 과거에 직접 예약을 심는다 LocalDate past = FixedClockConfig.TODAY.minusDays(1); @@ -314,7 +359,7 @@ class Update { } @Test - @DisplayName("[시간 규칙 연결] 변경하려는 시간이 과거면 거부된다") + @DisplayName("[분기,시간 규칙 연결] 변경하려는 시간이 과거면 거부된다") void 변경_대상이_과거_거부() { Long reservationId = fixture.insertReservation("브라운", FUTURE, timeId, themeId); LocalDate past = FixedClockConfig.TODAY.minusDays(1); @@ -322,7 +367,7 @@ class Update { assertThatThrownBy(() -> reservationService.updateByOwner( new ReservationUpdateCommand(reservationId, "브라운", past, timeId))) .isInstanceOf(BusinessRuleViolationException.class) - .hasMessage("지나간 날짜·시간으로는 변경할 수 없습니다."); + .hasMessage("지나간 날짜, 시간으로는 변경할 수 없습니다."); } } } diff --git a/src/test/java/roomescape/application/ReservationTimeServiceTest.java b/src/test/java/roomescape/application/ReservationTimeServiceTest.java index 25af6e56a0..c6bbfc85d7 100644 --- a/src/test/java/roomescape/application/ReservationTimeServiceTest.java +++ b/src/test/java/roomescape/application/ReservationTimeServiceTest.java @@ -1,29 +1,32 @@ package roomescape.application; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.LocalDate; import java.time.LocalTime; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import roomescape.application.dto.result.ReservationTimeResult; import roomescape.exception.client.BusinessRuleViolationException; import roomescape.support.ServiceIntegrationTest; /** * ReservationTimeService 통합 테스트. * - *

    검증 대상: + *

    검증 대상: 삭제 거부 정책 — 예약이 존재하는 시간은 삭제할 수 없다. + * 이건 서비스만의 협력이다: Repository가 준 existsByTimeId(boolean)를 보고 서비스가 예외로 전환하는 의사결정. 슬라이스는 existsByTimeId의 참/거짓만 보고, 인수는 HTTP + * 상태만 본다. "boolean → 예외 전환"이라는 결정 자체는 이 자리에서만 검증된다. + * + *

    여기서 검증하지 않는 것(의식적 제외): *

      - *
    • 삭제 거부: 예약이 존재하는 시간은 삭제할 수 없다 (existsByTimeId 상태에 의존)
    • - *
    • 예약 가능 시간 조회: 특정 날짜·테마에 이미 예약된 시간은 목록에서 빠진다 (findAvailable의 NOT IN)
    • + *
    • findAvailable — NOT IN의 정확성·테마/날짜 경계·빈 상태는 JdbcReservationTimeRepositoryTest가 + * 더 싸게(@JdbcTest) 그리고 더 넓게(날짜 경계까지) 검증한다. 서비스가 더하는 건 DTO 매핑 + * (stream.map) 한 줄뿐이라, 같은 회귀를 두 자리에서 사는 잉여였다 → 슬라이스에 일임하고 뺐다.
    • + *
    • create·findAll — 도메인 생성 + Repository 호출 + DTO 변환의 단순 위임이라 서비스만의 협력 + * 책임이 없다. 객체 검증은 ReservationTimeTest가, SQL·응답 형태는 슬라이스·인수가 잡는다.
    • *
    - * 둘 다 "이미 저장된 예약" 상태에 의존하므로 실제 H2로 검증한다. * *

    시간 규칙은 미래/과거와 무관한 순수 상태 의존이라 FixedClockConfig가 필요 없다. * (시점 판정은 ReservationService의 책임이고, 여기서는 시간 슬롯의 가용성만 본다.) @@ -61,55 +64,4 @@ class DeletePolicy { } } - @Nested - @DisplayName("예약 가능 시간 조회 (findAvailable)") - class Available { - - @Test - @DisplayName("해당 날짜·테마에 이미 예약된 시간은 가능 목록에서 빠진다") - void 예약된_시간_제외() { - Long time10 = fixture.insertTime(LocalTime.of(10, 0)); - Long time11 = fixture.insertTime(LocalTime.of(11, 0)); - Long themeId = fixture.insertTheme("테마A"); - // 10:00은 이미 예약됨 → 11:00만 가능해야 함 - fixture.insertReservation("브라운", DATE, time10, themeId); - - List available = - reservationTimeService.findAvailable(DATE, themeId); - - // 10:00은 예약되어 빠지고 11:00만 남는다 (정렬 비의존으로 검증) - assertThat(available).extracting(ReservationTimeResult::getStartAt) - .containsExactlyInAnyOrder(LocalTime.of(11, 0)); - } - - @Test - @DisplayName("같은 시간이라도 다른 테마에는 여전히 예약 가능하다") - void 다른_테마는_가능() { - Long time10 = fixture.insertTime(LocalTime.of(10, 0)); - Long themeA = fixture.insertTheme("테마A"); - Long themeB = fixture.insertTheme("테마B"); - // 테마A의 10:00만 예약 - fixture.insertReservation("브라운", DATE, time10, themeA); - - // 테마B는 10:00이 여전히 가능 - List availableB = - reservationTimeService.findAvailable(DATE, themeB); - - assertThat(availableB).extracting(ReservationTimeResult::getStartAt) - .contains(LocalTime.of(10, 0)); - } - - @Test - @DisplayName("예약이 하나도 없으면 등록된 모든 시간이 가능하다") - void 예약_없으면_전부_가능() { - fixture.insertTime(LocalTime.of(10, 0)); - fixture.insertTime(LocalTime.of(11, 0)); - Long themeId = fixture.insertTheme("테마A"); - - List available = - reservationTimeService.findAvailable(DATE, themeId); - - assertThat(available).hasSize(2); - } - } } diff --git a/src/test/java/roomescape/application/ThemeServiceTest.java b/src/test/java/roomescape/application/ThemeServiceTest.java index 470a916d16..9db5b50555 100644 --- a/src/test/java/roomescape/application/ThemeServiceTest.java +++ b/src/test/java/roomescape/application/ThemeServiceTest.java @@ -20,15 +20,23 @@ /** * ThemeService 통합 테스트. * - *

    검증 대상은 두 가지로, 둘 다 "시스템 상태(이미 저장된 예약)에 의존하는 규칙"이라 실제 H2로 검증한다: + *

    검증 대상은 두 가지로, 둘 다 "서비스만의 협력 책임"이라 실제 H2로 검증한다: *

      - *
    • 삭제 거부: 예약이 존재하는 테마는 삭제할 수 없다 (existsByThemeId 상태에 의존)
    • - *
    • 인기 테마 집계: 최근 7일 예약 건수로 정렬 (집계 쿼리 + 날짜 경계)
    • + *
    • 삭제 거부 (예외 전환): existsByThemeId 결과를 보고 예외를 던지는 의사결정
    • + *
    • 인기 테마 (정책-Repository 조율): 정책에서 today, from, to, limit을 받아 Repository에 정확히 전달
    • *
    * *

    인기 테마는 "오늘"의 의미가 흔들리면 집계 범위가 달라지므로 @Import(FixedPopularPolicyConfig)로 - * today를 2026-05-09로 고정한다. 집계 SQL "자체"의 정확성은 Repository 슬라이스가 따로 책임지고, - * 여기서는 "서비스가 정책의 기간으로 집계를 호출해 결과를 만든다"는 흐름을 본다. + * today를 2026-05-09로 고정한다. + * + *

    슬라이스(JdbcThemeRepositoryTest)와의 자리 분담: + *

      + *
    • 슬라이스: 집계 SQL "자체"의 정확성 (INNER JOIN, COUNT, ORDER BY, 부등호 경계 등)
    • + *
    • 여기: 서비스가 정책 객체에서 받은 값(today, from, to, limit)을 Repository에 정확히 넘기는 조율
    • + *
    + * 즉 SQL이 깨지면 슬라이스가, "from과 to를 바꿔 전달"·"today 외의 기준으로 호출"·"limit 누락" 같은 + * 조율 회귀가 발생하면 여기가 잡는다. 슬라이스가 못 보고 인수도 정책 깨짐과 분리해 보지 못하는, + * 서비스 자리만의 고유 책임이다. */ @Import(FixedPopularPolicyConfig.class) class ThemeServiceTest extends ServiceIntegrationTest { diff --git a/src/test/java/roomescape/application/WaitingCancelTransactionTest.java b/src/test/java/roomescape/application/WaitingCancelTransactionTest.java new file mode 100644 index 0000000000..ffc6cde189 --- /dev/null +++ b/src/test/java/roomescape/application/WaitingCancelTransactionTest.java @@ -0,0 +1,52 @@ +package roomescape.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willThrow; + +import java.time.LocalDate; +import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import roomescape.domain.repository.WaitingRepository; +import roomescape.support.ServiceIntegrationTest; + +@DisplayName("대기 취소 → 순번 재정렬 트랜잭션 경계") +class WaitingCancelTransactionTest extends ServiceIntegrationTest { + + @Autowired + private WaitingService waitingService; + + @MockitoSpyBean + private WaitingRepository waitingRepository; + + @Test + @DisplayName("대기 취소 후 순번 재정렬이 실패하면 취소(삭제)까지 전부 롤백된다") + void 대기취소_재정렬_중간실패_전체롤백() { + // given: 대기 [콘:1, 모카:2, 핀:3] + LocalDate date = LocalDate.of(2050, 12, 31); + Long timeId = fixture.insertTime(LocalTime.of(10, 0)); + Long themeId = fixture.insertTheme("테마A"); + Long conId = fixture.insertWaiting("콘", date, timeId, themeId, 1); + Long mochaId = fixture.insertWaiting("모카", date, timeId, themeId, 2); + Long pinId = fixture.insertWaiting("핀", date, timeId, themeId, 3); + + // [2] 재정렬 UPDATE에서 폭발 주입 (모카 취소 → 핀 3→2 당기는 그 UPDATE) + willThrow(new RuntimeException("재정렬 실패 주입")) + .given(waitingRepository).updateOrderIndex(anyLong(), anyInt()); + + // when: 모카가 본인 대기 취소 → 삭제 후 재정렬에서 실패 + assertThatThrownBy(() -> waitingService.cancelByOwner(mochaId, "모카")) + .isInstanceOf(RuntimeException.class); + + // then: 전부 롤백 — 모카 대기 그대로, 순번도 그대로 + assertThat(fixture.existsWaiting(mochaId)).isTrue(); // 삭제가 롤백됨 + assertThat(fixture.findWaitingOrder(conId)).isEqualTo(1); + assertThat(fixture.findWaitingOrder(mochaId)).isEqualTo(2); + assertThat(fixture.findWaitingOrder(pinId)).isEqualTo(3); // 당겨지지 않음 + } +} diff --git a/src/test/java/roomescape/application/WaitingServiceMockTest.java b/src/test/java/roomescape/application/WaitingServiceMockTest.java index c1b4b73a6b..130a934dcc 100644 --- a/src/test/java/roomescape/application/WaitingServiceMockTest.java +++ b/src/test/java/roomescape/application/WaitingServiceMockTest.java @@ -12,6 +12,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -28,31 +29,65 @@ * WaitingService Mock 단위 테스트 — "비교본". * *

    이 파일은 WaitingServiceTest(통합 흡수본)와 같은 분기를 Mockito로 검증한다. - * 목적은 "이 분기들에 Mock 단위 테스트가 필요한가"를 두 방식의 코드를 직접 비교해 판단하는 것이다. - * git diff WaitingServiceTest WaitingServiceMockTest 로 나란히 놓고 보면 차이가 드러난다. + * 목적은 "이 분기들에 Mock 단위 테스트가 필요한가"를 두 방식의 코드를 직접 비교해 판단하는 것이다. git diff WaitingServiceTest WaitingServiceMockTest 로 나란히 + * 놓고 보면 차이가 드러난다. * *

    === 통합 흡수본과 비교했을 때 관찰 포인트 === * *

    1. 빨라진다. 스프링 컨텍스트도 H2도 안 뜬다. new WaitingService(...)로 직접 만든다. - * 분기 하나를 검증하는 데 시간·테마·예약을 DB에 까는 준비가 사라지고, given(...).willReturn(...) - * 한 줄로 "이 상태였다 치고"가 끝난다. + * 분기 하나를 검증하는 데 시간·테마·예약을 DB에 까는 준비가 사라지고, given(...).willReturn(...) 한 줄로 "이 상태였다 치고"가 끝난다. * *

    2. 위험이 생긴다. existsByDateAndTimeAndTheme(...)가 true/false를 "리턴한다 치고" 검증하는데, - * 그 SQL이 진짜로 그렇게 동작한다는 보장은 이 테스트 어디에도 없다. Mock이 거짓말하면 이 검증은 - * 통째로 무의미해진다. (그래서 토론 규칙 3: Mock으로 Service를 검증하면 Repository 자체는 - * 별도 통합/슬라이스 테스트로 증명해야 한다 → JdbcWaitingRepositoryTest가 그 역할.) + * 그 SQL이 진짜로 그렇게 동작한다는 보장은 이 테스트 어디에도 없다. Mock이 거짓말하면 이 검증은 통째로 무의미해진다. (그래서 토론 규칙 3: Mock으로 Service를 검증하면 Repository + * 자체는 별도 통합/슬라이스 테스트로 증명해야 한다 → JdbcWaitingRepositoryTest가 그 역할.) * *

    3. 무엇을 검증하지 "않는가"가 핵심이다. 아래 테스트들은 의도적으로 verify(...)로 - * 호출 순서를 확인하지 않는다. assertThatThrownBy로 "입력(상태) → 올바른 예외"만 본다. - * verify로 "existsBy가 정확히 1번 불렸는지"까지 박으면, 구현을 조금만 바꿔도 깨지는 - * 리팩터링 내성 약한 테스트가 된다(토론에서 경계한 바로 그 형태). - * 즉 같은 Mock 테스트라도 "분기 로직의 단위 검증"으로 쓰면 살고, "호출 명세 검증"으로 쓰면 죽는다. + * 호출 순서를 확인하지 않는다. assertThatThrownBy로 "입력(상태) → 올바른 예외"만 본다. verify로 "existsBy가 정확히 1번 불렸는지"까지 박으면, 구현을 조금만 바꿔도 깨지는 + * 리팩터링 내성 약한 테스트가 된다(토론에서 경계한 바로 그 형태). 즉 같은 Mock 테스트라도 "분기 로직의 단위 검증"으로 쓰면 살고, "호출 명세 검증"으로 쓰면 죽는다. * - *

    결론 도출은 학습자의 몫: 이 두 파일을 비교한 뒤 - * "이 분기들은 통합으로 충분한가, Mock 단위가 값을 더하는가"를 스스로 정한다. - * (예: 분기가 더 많아지고 조합이 폭발하면 Mock 단위의 속도 이점이 커지고, - * 분기가 적고 상태 의존이 강하면 통합 하나로 충분할 수 있다.) + *

    === 결론 도출: 네 축으로 판단했다 === + * + *

    축 1 — 셋업 비용: 통합은 분기마다 시간·테마·예약/대기를 DB에 깐다(분기 5개면 5번). + * Mock은 given(...).willReturn(...) 한 줄. 분기·상태 조합이 폭발하면 Mock이 압도적으로 가볍고, 분기가 적고 셋업이 단순하면 통합도 부담이 작다. + * + *

    축 2 — 실행 비용: 통합은 풀 컨텍스트 + H2(수백 ms), Mock은 컨텍스트 없음(수십 ms). + * CI 누적이 큰 규모부터 체감되며, 사이클 1 규모에선 결정적 단점은 아니다. + * + *

    축 3 — 회귀 안전성의 종류: 통합은 분기 로직 + SQL 정확성 + DTO 매핑 + 빈 협력을 한 번에 + * 보장한다. Mock은 분기 로직만 보장하고 SQL은 "리턴한다 치고"라 보장하지 않는다. Mock으로 가려면 Repository SQL을 슬라이스(JdbcWaitingRepositoryTest)로 반드시 + * 짝지어야 한다. + * + *

    축 4 — 핀포인트 vs 리팩토링 친화성: 통합은 결과 검증이라 깨졌을 때 원인 추적이 약하지만 + * 구현을 바꿔도 결과만 같으면 통과한다. Mock은 분기 핀포인트가 강하지만 verify로 호출 명세를 박으면 리팩토링에 취약하다. 이 파일은 verify를 쓰지 않아 그 취약성을 피했다. + * + *

    판단 가이드: 분기 많고 조합 폭발 → Mock 가치↑ / 분기 적고 상태 의존 강함 → 통합으로 충분 / + * SQL 회귀가 결정적 → 통합 우선 또는 슬라이스 짝짓기 / 여러 도메인이 흐름으로 협력 → 통합 우선 / 외부 시스템 호출·격리 어려운 의존 → Mock 필수. + * + *

    === WaitingService.create에 적용한 결론 (사이클 1: B) === + *

      + *
    • 분기 5개 — 중간. 셋업 폭발 "직전"이지만 아직 폭발은 아니다.
    • + *
    • fixture가 한 줄 셋업이라 통합 준비 비용이 작다.
    • + *
    • 다른 세 서비스(Reservation/ReservationTime/Theme)가 모두 통합 — 여기만 Mock이면 비대칭.
    • + *
    • 슬라이스가 SQL을 책임져(하지만 100프로 책임진다는 것 조차 아직 확신을 못느껴 찜찜) Mock도 정당화되지만, 위 일관성·낮은 셋업 비용이 더 크다.
    • + *
    + * → 통합(WaitingServiceTest)을 메인으로 두고, 이 Mock 비교본은 학습 기록으로 park한다. + * + *

    === 사이클 2 재검토 결과 (park 유지) === + * + * @Transactional이 들어온 뒤 두 기준으로 다시 봤다. (1) 분기/조합 폭발? — create 분기는 5개 그대로다. 사이클 2의 트랜잭션 작업은 cancelByOwner/deleteByOwner에 + * 경계를 준 것이고, create는 단일 INSERT라 손대지 않았다. 분기 상황이 안 변했으니 분기 검증은 여전히 통합(WaitingServiceTest)이 메인이다. (2) 결과로 안 보이는 협력? — + * 도착했다. 다만 그 협력(트랜잭션 롤백)을 검증한 도구는 이 파일의 "create 분기 순수 mock"이 아니라 spy 결함 주입이다. → ReservationPromotionTransactionTest / + * WaitingCancelTransactionTest 가 @MockitoSpyBean으로 한 메서드만 던지게 해 롤백을 증명한다. "Mock 가치가 살아나는 자리"라는 사이클 1의 예측은 맞았지만, 그 자리는 + * create 분기가 아니라 cancel/delete의 경계였고 도구도 full mock이 아니라 spy였다. + *

    + * 결론: 승격 사유가 없어 park 유지. 이 비교가 끝내 가른 것은 "Mock의 적정 범위"다 — 결함 주입(경계 증명)엔 적정, 분기 단위 검증엔 이 규모에선 통합이 메인. 다음 재검토 트리거: 알림 + * 전송·외부 시스템 호출 등 "결과로 안 보이는 협력"이 create/promote 흐름 자체에 들어올 때(통합으로 흉내 내기 어려운 의존이 생겨 Mock 필요성이 올라가는 시점). + *

    + *

    + * 트랜잭션 경계는 "결과로 안 보이는 협력"의 전형이라 Mock 가치가 결정적으로 살아날 수 있는 자리다. + * @Transactional을 넣으며 "분기/조합이 폭발하는가, 결과로 안 보이는 협력이 생기는가"를 다시 보고, 그때 이 park를 풀지(= Mock 단위를 메인 검증의 하나로 승격할지) 다시 결정한다. */ +@Disabled("Mock vs 통합 학습 비교 + 사이클 2 재검토 완료(park 유지). 활성 검증은 WaitingServiceTest(통합). 다음 트리거=알림/외부 호출 도입 시") class WaitingServiceMockTest { private static final LocalDate FUTURE = LocalDate.of(2050, 12, 31); @@ -66,8 +101,7 @@ class WaitingServiceMockTest { waitingRepository, reservationRepository, reservationTimeRepository, themeRepository); /** - * 모든 분기 테스트의 공통 전제: 시간·테마는 존재한다고 가정한다. - * (존재하지 않는 경우는 별도 테스트에서 다룬다) + * 모든 분기 테스트의 공통 전제: 시간·테마는 존재한다고 가정한다. (존재하지 않는 경우는 별도 테스트에서 다룬다) */ private void givenTimeAndThemeExist() { given(reservationTimeRepository.findById(anyLong())) diff --git a/src/test/java/roomescape/application/WaitingServiceTest.java b/src/test/java/roomescape/application/WaitingServiceTest.java index 9d5e7253fa..9b699aac1e 100644 --- a/src/test/java/roomescape/application/WaitingServiceTest.java +++ b/src/test/java/roomescape/application/WaitingServiceTest.java @@ -17,20 +17,18 @@ import roomescape.support.ServiceIntegrationTest; /** - * WaitingService 통합 테스트 — "분기 흡수본". + * WaitingService 통합 테스트 — create/cancel 분기의 메인 검증. * - *

    이 파일은 마찰 2의 (B) 선택을 구현한다: WaitingService.create()의 분기들 - * (이미 본인이 예약한 슬롯 거부 / 예약 없는 슬롯 거부 / 중복 대기 거부)을 - * Mock 없이 실제 H2 통합 테스트로 검증한다. + *

    WaitingService.create()와 cancelByOwner()의 분기들(예약 없는 슬롯 거부 / 본인 예약 슬롯 거부 / + * 중복 대기 거부 / 시간·테마 부재 / 대기 취소·재정렬)을 Mock 없이 실제 H2 통합으로 검증한다. 이 분기들은 "이미 저장된 예약/대기" 상태에 의존하는 비즈니스 규칙이라, 실제 DB로 검증하면 분기 + * 로직과 그 상태 의존이 한 흐름에서 함께 동작함을 보장받는다. (모드 A: 분기가 서비스 안의 if-throw 로직이고 분기마다 다른 사용자 경험을 주므로 분기 전부를 본다.) * - *

    커밋 9의 WaitingServiceMockTest(같은 분기를 Mock 단위로)와 대조하기 위한 기준선이다. - * 이 파일을 먼저 작성해 "통합으로 흡수했을 때 무엇이 들고, 무엇이 무거운가"를 체감한 뒤, Mock 버전과 diff로 비교하며 "Mock 서비스 단위 테스트가 정말 필요한가"를 판단한다. + *

    이 자리가 "메인"인 이유(마찰 2의 (B) 결론): 같은 분기를 Mock 단위로 짠 WaitingServiceMockTest와 + * 직접 비교한 결과, 분기 5개는 아직 셋업이 폭발하지 않고 fixture가 한 줄 셋업이라 통합 준비 비용이 작으며, 다른 세 서비스가 모두 통합이라 일관성 가치가 크다. 그래서 통합을 메인으로 둔다. Mock + * 비교본은 학습 기록으로 park했다(@Disabled). 자세한 4축 판단 근거는 그 파일 주석에 있다. (사이클 2에서 트랜잭션이 들어와 "결과로 안 보이는 협력"이 생기면 Mock 필요성을 다시 본다.) * - *

    검증 시선: 이 분기들은 "시스템 상태(이미 저장된 예약/대기)에 의존하는 비즈니스 규칙"이다. - * Mock으로 existsBy...의 결과를 흉내 내면 "그 SQL이 진짜 그렇게 동작한다"는 보장이 없다. 실제 DB로 검증하면 그 보장까지 함께 얻는다. (토론 규칙 3) - * - *

    관찰 포인트(학습용): create의 한 분기를 검증하려면 given에서 매번 - * 시간·테마·예약을 깔아야 한다. 이 "준비 비용"이 분기 수만큼 반복되는 게 통합 흡수본의 특징이다. + *

    관찰 포인트(학습용): create 한 분기를 검증하려면 매번 시간·테마·예약을 깔아야 한다. 이 "준비 + * 비용"이 분기 수만큼 반복되는 게 통합의 특징이고, 분기가 폭발하면 그때 Mock의 셋업 이점이 커진다. */ class WaitingServiceTest extends ServiceIntegrationTest { diff --git a/src/test/java/roomescape/domain/ReservationDateTimeTest.java b/src/test/java/roomescape/domain/ReservationDateTimeTest.java new file mode 100644 index 0000000000..9c41ae3b22 --- /dev/null +++ b/src/test/java/roomescape/domain/ReservationDateTimeTest.java @@ -0,0 +1,54 @@ +package roomescape.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * ReservationDateTime 값 객체 단위 테스트. + * + *

    보호 대상: "내 시점이 기준 시각보다 이전이거나 같은가"라는 순수 비교의 정확성, 특히 경계. + * 이 비교는 예전에 FutureOnlyPolicy 안에 묻혀 있어 경계를 보려면 정책을 거쳐야 했다. 값 객체로 끌어내면서 경계 정밀도(같은 순간은 포함된다 등)를 DB·스프링·시계·정책 없이 입력만으로 + * 검증한다. + * + *

    "이 결과를 위반으로 볼지"와 "지금을 무엇으로 볼지(Clock)"는 정책의 책임이라 여기서 다루지 않는다. + */ +class ReservationDateTimeTest { + + private static final LocalDate DAY = LocalDate.of(2026, 5, 13); + private static final LocalDateTime MOMENT = DAY.atTime(LocalTime.of(12, 0)); + + @Test + @DisplayName("기준보다 이전 날짜면 이전이거나 같다") + void 이전_날짜() { + assertThat(ReservationDateTime.of(DAY.minusDays(1), LocalTime.of(12, 0)).startsAtOrBefore(MOMENT)).isTrue(); + } + + @Test + @DisplayName("같은 날 이전 시각이면 이전이거나 같다 (시각까지 비교한다)") + void 같은날_이전_시각() { + assertThat(ReservationDateTime.of(DAY, LocalTime.of(10, 0)).startsAtOrBefore(MOMENT)).isTrue(); + } + + @Test + @DisplayName("정확히 같은 순간은 이전이거나 같다 (경계)") + void 동일_순간() { + assertThat(ReservationDateTime.of(DAY, LocalTime.of(12, 0)).startsAtOrBefore(MOMENT)).isTrue(); + } + + @Test + @DisplayName("같은 날 이후 시각이면 이전이거나 같지 않다") + void 같은날_이후_시각() { + assertThat(ReservationDateTime.of(DAY, LocalTime.of(15, 0)).startsAtOrBefore(MOMENT)).isFalse(); + } + + @Test + @DisplayName("기준보다 이후 날짜면 이전이거나 같지 않다") + void 이후_날짜() { + assertThat(ReservationDateTime.of(DAY.plusDays(1), LocalTime.of(0, 1)).startsAtOrBefore(MOMENT)).isFalse(); + } +} diff --git a/src/test/java/roomescape/domain/ReservationTest.java b/src/test/java/roomescape/domain/ReservationTest.java index 10733a6ef7..b2629a8ef8 100644 --- a/src/test/java/roomescape/domain/ReservationTest.java +++ b/src/test/java/roomescape/domain/ReservationTest.java @@ -6,23 +6,36 @@ import static roomescape.support.Fixtures.theme; import static roomescape.support.Fixtures.time; +import java.time.Clock; import java.time.LocalDate; import java.time.LocalTime; +import java.time.ZoneId; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import roomescape.domain.exception.InvalidDomainException; +import roomescape.domain.policy.FutureOnlyPolicy; import roomescape.domain.policy.ReservationPolicy; +import roomescape.exception.client.BusinessRuleViolationException; /** * Reservation 도메인 단위 테스트. * - *

    보호 대상: 도메인 객체의 정합성(자기 자신과 맺은 약속). 외부 의존 없이 입력만으로 결정된다. + *

    보호 대상: 도메인 객체의 정합성(자기 자신과 맺은 약속)과 자기 시점의 합성. + * 외부 의존(DB·스프링) 없이 입력만으로 결정된다. * - *

    주의 깊게 볼 설계 결정: "과거 시점 거부"는 여기서 검증하지 않는다. - * 그 규칙은 Reservation 자신이 아니라 주입된 ReservationPolicy가 책임지기 때문이다. - * 따라서 과거 거부 규칙의 정확성은 FutureOnlyPolicyTest(정책 단위 테스트)가 검증한다. - * 여기서는 create()가 정책에 "위임하긴 하는가"만 협력 관점에서 가볍게 확인한다. + *

    역할 분담(중요): + *

      + *
    • 시점 비교(이후인가)의 경계 정확성 → ReservationDateTimeTest (값 객체 단위)
    • + *
    • 과거 거부 규칙의 연산별 메시지·판정 → FutureOnlyPolicyTest (정책 단위)
    • + *
    • create가 정책 결과를 객체 생성에 반영하는 연결 → 여기 (정책 협력)
    • + *
    + * + *

    정책 협력은 수동 스텁이 아니라 실제 정책(FutureOnlyPolicy)을 고정 Clock과 함께 주입해 + * 결과(예외/생성 성공)로 검증한다. 이전엔 RecordingPolicy로 "호출했는가"(행위)를 봤는데 그건 + * 런던파에 가까웠다. 고전파를 일관되게 따르려 "거부 시점이면 생성이 실패한다"는 결과만 본다. + * 같은 정책이 FutureOnlyPolicyTest에도 쓰이지만 보호 대상이 다르다 — 거기선 규칙의 경계·메시지를, + * 여기선 그 결과가 create에 연결되는지를 본다. */ class ReservationTest { @@ -96,29 +109,55 @@ class NullValidation { } @Nested - @DisplayName("정책 협력") - class PolicyCollaboration { + @DisplayName("예약 시점 노출") + class DateTime { @Test - @DisplayName("create()는 정책의 생성 검증에 위임한다") - void create는_정책에_위임한다() { - RecordingPolicy policy = new RecordingPolicy(); + @DisplayName("dateTime()은 예약의 날짜+시작시각을 시점으로 합성한다") + void 시점_합성() { + Reservation reservation = Reservation.withId( + 1L, "브라운", LocalDate.of(2050, 12, 31), time(1, LocalTime.of(10, 0)), theme(1)); + + // 시점이 2050-12-31 10:00임을 행위로 확인 - getter로 들여다보지 않는다 + assertThat(reservation.dateTime() + .startsAtOrBefore(LocalDate.of(2050, 12, 31).atTime(9, 59))).isFalse(); + assertThat(reservation.dateTime() + .startsAtOrBefore(LocalDate.of(2050, 12, 31).atTime(10, 1))).isTrue(); + } + } + + + @Nested + @DisplayName("정책 협력 (생성,삭제,업데이트)결과 검증") + class PolicyCollaboration { + // + private final Clock dateIsPast = fixedAt(DATE.plusYears(1)); + private final Clock dateIsFuture = fixedAt(DATE.minusYears(1)); - Reservation.create("브라운", DATE, time(1), theme(1), policy); + @Test + @DisplayName("정책이 허용하는 시점이면 객체가 생성된다") + void 허용_시점_생성_성공() { + ReservationPolicy policy = new FutureOnlyPolicy(dateIsFuture); - // 호출 순서를 검증하지 않는다 — "위임했다"는 사실만 본다. - // 과거 거부 규칙의 정확성은 정책 단위 테스트가 책임진다. - assertThat(policy.creatableCalled).isTrue(); + assertThatCode(() -> Reservation.create("브라운", DATE, time(1), theme(1), policy)) + .doesNotThrowAnyException(); } @Test - @DisplayName("정책이 거부하면 객체는 생성되지 않는다") - void 정책_거부시_생성_안됨() { - ReservationPolicy rejecting = new RejectingPolicy(); + @DisplayName("정책이 거부하는 시점이면 객체 생성이 실패한다") + void 거부_시점_생성_실패() { + ReservationPolicy policy = new FutureOnlyPolicy(dateIsPast); + + assertThatThrownBy(() -> Reservation.create("브라운", DATE, time(1), theme(1), policy)) + .isInstanceOf(BusinessRuleViolationException.class); + // 메시지·경계는 여기서 보지 않는다 — FutureOnlyPolicyTest의 책임이다. + // 여기서는 "정책 결과가 생성 성공/실패로 반영된다"는 연결만 본다. + } - assertThatThrownBy(() -> Reservation.create("브라운", DATE, time(1), theme(1), rejecting)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("정책 거부"); + private Clock fixedAt(LocalDate date) { + return Clock.fixed( + date.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); } } @@ -140,45 +179,4 @@ class Promotion { } } - // --- 테스트 전용 정책 스텁 (Mock 라이브러리 없이 의도를 드러내는 수동 스텁) --- - - private static class RecordingPolicy implements ReservationPolicy { - boolean creatableCalled = false; - - @Override - public void validateCreatable(LocalDate date, LocalTime time) { - creatableCalled = true; - } - - @Override - public void validateCancellable(LocalDate date, LocalTime time) { - } - - @Override - public void validateUpdatable(LocalDate date, LocalTime time) { - } - - @Override - public void validateUpdateTarget(LocalDate date, LocalTime time) { - } - } - - private static class RejectingPolicy implements ReservationPolicy { - @Override - public void validateCreatable(LocalDate date, LocalTime time) { - throw new IllegalStateException("정책 거부"); - } - - @Override - public void validateCancellable(LocalDate date, LocalTime time) { - } - - @Override - public void validateUpdatable(LocalDate date, LocalTime time) { - } - - @Override - public void validateUpdateTarget(LocalDate date, LocalTime time) { - } - } } diff --git a/src/test/java/roomescape/domain/policy/FutureOnlyPolicyTest.java b/src/test/java/roomescape/domain/policy/FutureOnlyPolicyTest.java index 45677bd597..1c774e01c5 100644 --- a/src/test/java/roomescape/domain/policy/FutureOnlyPolicyTest.java +++ b/src/test/java/roomescape/domain/policy/FutureOnlyPolicyTest.java @@ -5,12 +5,12 @@ import java.time.Clock; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import roomescape.domain.ReservationDateTime; import roomescape.exception.client.BusinessRuleViolationException; /** @@ -19,145 +19,91 @@ *

    이 미션 테스트 재작성에서 가장 중요한 이동이다. * 기존 ReservationPolicyStepTest는 "어제는 거부 / 오늘 지난 시간은 거부 / 내일은 허용"을 * 풀 스프링 컨텍스트 + 실제 H2 + insertTime까지 깔고 검증했다. - * 하지만 이 규칙은 "외부 상태에 의존하지 않고 입력(날짜·시간)과 현재 시각만으로 결정"된다. - * 따라서 DB 없이 검증할 수 있고, 그래야 한다. (토론 규칙 3: DB 없이 되는 건 DB 없이, 1000배 빠르다.) + * 하지만 이 규칙은 "외부 상태에 의존하지 않고 입력(날짜·시간)과 현재 시각만으로 결정"된다. 따라서 DB 없이 검증할 수 있고, 그래야 한다. 그래야 "과거/현재/미래"의 의미가 테스트 실행 시점에 흔들리지 + * 않는다. * - *

    시간 결정성: 시스템 시계 대신 고정 Clock(2026-05-13 12:00)을 주입한다. - * 그래야 "과거/현재/미래"의 의미가 테스트 실행 시점에 흔들리지 않는다. + *

    역할(재조정됨): "시점 비교(이후인가)"의 경계 정확성은 이제 ReservationDateTimeTest가 가진다. + * 이 테스트는 그 비교 위에서 정책이 내리는 판정만 본다. + * + *

    구성은 "무엇을 보호하느냐"로 묶는다(연산별 nest로 쪼개지 않는다): + *

      + *
    • 연산마다 다른 것 = 메시지뿐 → RejectionMessages: 생성/취소/변경/변경대상을 대등하게 본다
    • + *
    • 네 연산이 공유하는 것 = 허용·경계 → AllowAndBoundary: 대표(생성)로 한 번만 본다
    • + *
    * - *

    설계상의 마찰 기록: 운영 FutureOnlyPolicy는 생성자에서 Clock.systemDefaultZone()을 - * 내부에 박아버려 시계 주입이 불가능하다. 그래서 여기서는 운영 정책과 "동일한 규칙"을 갖되 - * 시계만 주입 가능한 테스트 전용 정책으로 규칙을 검증한다. - * → 이는 "테스트하기 좋은 구조"라는 관점에서 운영 코드 개선 신호다: - * FutureOnlyPolicy도 Clock을 생성자 주입으로 받게 하면, 이 테스트가 운영 클래스를 - * 직접 검증할 수 있다. (개선은 별도 결정으로 남겨둠 — 지금은 테스트만 재작성) + *

    시간 결정성: 시스템 시계 대신 고정 Clock(2026-05-13 12:00)을 주입한다. */ class FutureOnlyPolicyTest { private static final LocalDate TODAY = LocalDate.of(2026, 5, 13); private static final LocalTime NOW = LocalTime.of(12, 0); + private static final Clock FIXED_CLOCK = Clock.fixed( + TODAY.atTime(NOW).atZone(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); - private final ReservationPolicy policy = new TestablePolicy(fixedClockAt(TODAY, NOW)); + private final ReservationPolicy policy = new FutureOnlyPolicy(FIXED_CLOCK); @Nested - @DisplayName("생성 가능 시점 검증") - class Creatable { - - @Test - @DisplayName("어제 날짜는 거부된다") - void 어제_거부() { - assertThatThrownBy(() -> policy.validateCreatable(TODAY.minusDays(1), NOW)) - .isInstanceOf(BusinessRuleViolationException.class) - .hasMessage("지나간 날짜, 시간으로는 예약할 수 없습니다."); - } + @DisplayName("위반 시 연산별 메시지") + class RejectionMessages { @Test - @DisplayName("오늘이지만 이미 지난 시간(10:00, 현재 12:00)은 거부된다") - void 오늘_지난시간_거부() { - assertThatThrownBy(() -> policy.validateCreatable(TODAY, LocalTime.of(10, 0))) + @DisplayName("생성: 과거 시점 거부 — 생성 전용 메시지") + void 생성_거부() { + assertThatThrownBy(() -> policy.validateCreatable(past())) .isInstanceOf(BusinessRuleViolationException.class) .hasMessage("지나간 날짜, 시간으로는 예약할 수 없습니다."); } @Test - @DisplayName("정확히 현재 시각(경계)은 '이후'가 아니므로 거부된다") - void 현재시각_거부() { - assertThatThrownBy(() -> policy.validateCreatable(TODAY, NOW)) - .isInstanceOf(BusinessRuleViolationException.class); - } - - @Test - @DisplayName("오늘이지만 미래 시간(15:00, 현재 12:00)은 허용된다") - void 오늘_미래시간_허용() { - assertThatCode(() -> policy.validateCreatable(TODAY, LocalTime.of(15, 0))) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("내일 날짜는 허용된다") - void 내일_허용() { - assertThatCode(() -> policy.validateCreatable(TODAY.plusDays(1), LocalTime.of(0, 1))) - .doesNotThrowAnyException(); - } - } - - @Nested - @DisplayName("취소/변경 가능 시점 검증 (각각 메시지가 다르다)") - class CancelAndUpdate { - - @Test - @DisplayName("이미 지난 예약 취소는 거부 — 취소 전용 메시지") + @DisplayName("취소: 과거 시점 거부 — 취소 전용 메시지") void 취소_거부() { - assertThatThrownBy(() -> policy.validateCancellable(TODAY.minusDays(1), NOW)) + assertThatThrownBy(() -> policy.validateCancellable(past())) .isInstanceOf(BusinessRuleViolationException.class) .hasMessage("이미 지난 예약은 취소할 수 없습니다."); } @Test - @DisplayName("이미 지난 예약 변경은 거부 — 변경 전용 메시지") + @DisplayName("변경: 과거 시점 거부 — 변경 전용 메시지") void 변경_거부() { - assertThatThrownBy(() -> policy.validateUpdatable(TODAY.minusDays(1), NOW)) + assertThatThrownBy(() -> policy.validateUpdatable(past())) .isInstanceOf(BusinessRuleViolationException.class) .hasMessage("이미 지난 예약은 변경할 수 없습니다."); } @Test - @DisplayName("변경 대상 시간이 과거면 거부 — 변경 대상 전용 메시지") - void 변경대상_과거_거부() { - assertThatThrownBy(() -> policy.validateUpdateTarget(TODAY.minusDays(1), NOW)) + @DisplayName("변경 대상: 과거 시점 거부 — 변경 대상 전용 메시지") + void 변경대상_거부() { + assertThatThrownBy(() -> policy.validateUpdateTarget(past())) .isInstanceOf(BusinessRuleViolationException.class) - .hasMessage("지나간 날짜·시간으로는 변경할 수 없습니다."); + .hasMessage("지나간 날짜, 시간으로는 변경할 수 없습니다."); } } - private static Clock fixedClockAt(LocalDate date, LocalTime time) { - return Clock.fixed( - date.atTime(time).atZone(ZoneId.systemDefault()).toInstant(), - ZoneId.systemDefault() - ); - } - - /** - * 운영 FutureOnlyPolicy와 동일한 규칙. 시계만 주입받는다. - * (운영 클래스가 Clock 주입을 지원하면 이 스텁은 제거되고 운영 클래스를 직접 테스트하게 된다.) - */ - private static class TestablePolicy implements ReservationPolicy { - private final Clock clock; - - TestablePolicy(Clock clock) { - this.clock = clock; - } - - @Override - public void validateCreatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜, 시간으로는 예약할 수 없습니다."); - } - } + @Nested + @DisplayName("허용/경계 (네 연산 공통)") + class AllowAndBoundary { - @Override - public void validateCancellable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 취소할 수 없습니다."); - } + @Test + @DisplayName("미래 시점은 허용된다") + void 미래_허용() { + assertThatCode(() -> policy.validateCreatable(future())) + .doesNotThrowAnyException(); } - @Override - public void validateUpdatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 변경할 수 없습니다."); - } + @Test + @DisplayName("정확히 지금은 거부된다 — 엄격히 이후여야 한다") + void 현재시각_거부() { + assertThatThrownBy(() -> policy.validateCreatable(ReservationDateTime.of(TODAY, NOW))) + .isInstanceOf(BusinessRuleViolationException.class); } + } - @Override - public void validateUpdateTarget(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜·시간으로는 변경할 수 없습니다."); - } - } + private static ReservationDateTime past() { + return ReservationDateTime.of(TODAY.minusDays(1), NOW); + } - private boolean isPast(LocalDateTime dateTime) { - return !dateTime.isAfter(LocalDateTime.now(clock)); - } + private static ReservationDateTime future() { + return ReservationDateTime.of(TODAY.plusDays(1), NOW); } } diff --git a/src/test/java/roomescape/support/FixedClockConfig.java b/src/test/java/roomescape/support/FixedClockConfig.java index 0db9ad756d..e69cf6ff79 100644 --- a/src/test/java/roomescape/support/FixedClockConfig.java +++ b/src/test/java/roomescape/support/FixedClockConfig.java @@ -2,14 +2,13 @@ import java.time.Clock; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import roomescape.domain.policy.FutureOnlyPolicy; import roomescape.domain.policy.ReservationPolicy; -import roomescape.exception.client.BusinessRuleViolationException; /** * 통합/인수 테스트에서 시간을 결정적으로 만들기 위한 고정 Clock 정책 설정. @@ -17,12 +16,14 @@ *

    기존에는 이 @TestConfiguration이 ReservationPolicyStepTest와 ErrorResponseStepTest에 * 그대로 복붙되어 있었다. 시간 통제 전략이 파일마다 달라지는 원인이었으므로 한 곳에 모은다. * - *

    @Import(FixedClockConfig.class)로 끌어다 쓰면, 운영용 FutureOnlyPolicy(시스템 시계) 대신 - * 고정 시계(2026-05-13 12:00) 기반 정책이 @Primary로 주입된다. + *

    @Import(FixedClockConfig.class)로 끌어 쓰면, 운영 FutureOnlyPolicy가 시스템 시계 대신 고정 시계(2026-05-13 12:00)를 받은 정책 인스턴스로 + * (우선)@Primary 주입된다. 운영 정책 클래스를 그대로 * 쓰므로 "테스트가 검증하는 규칙 = 운영이 실제로 쓰는 규칙"이 보장된다. * - *

    "과거/오늘 지난 시간/미래"의 의미가 실행 시점에 따라 흔들리지 않으므로, 시간 의존 케이스를 - * 안정적으로 검증할 수 있다. (단, 과거 거부 "규칙 자체"는 도메인 단위 테스트 FutureOnlyPolicyTest가 - * 더 빠르게 검증한다. 여기서는 그 규칙이 HTTP/서비스 흐름에 연결됐는지를 본다.) + *

    Clock 자체가 아니라 "정책 빈"을 @Primary로 두는 이유: FixedPopularPolicyConfig도 자기 * 기준일(2026-05-09)로 시계를 고정하는데, + * 두 설정이 각각(우선)@Primary Clock을 등록하면 한 컨텍스트에 * 함께 올라올 때 충돌한다. 시계 고정을 정책 빈 단위로 캡슐화하면 그 충돌이 구조적으로 사라진다. + * + *

    "과거/오늘 지난 시간/미래"의 의미가 실행 시점에 따라 흔들리지 않으므로, 시간 의존 케이스를 안정적으로 검증할 수 있다. + * (단, 과거 거부 "규칙 자체"는 도메인 단위 테스트 FutureOnlyPolicyTest가 더 빠르게 검증한다. 여기서는 그 규칙이 HTTP/서비스 흐름에 연결됐는지를 본다.) */ @TestConfiguration public class FixedClockConfig { @@ -37,51 +38,6 @@ public ReservationPolicy fixedReservationPolicy() { TODAY.atTime(NOW).atZone(ZoneId.systemDefault()).toInstant(), ZoneId.systemDefault() ); - return new FixedClockPolicy(fixed); - } - - /** - * 운영용 FutureOnlyPolicy와 동일한 규칙을 갖되, 시계만 외부에서 주입받는 테스트 전용 정책. - * (운영 FutureOnlyPolicy는 생성자에서 systemDefaultZone을 박아버려서 시계 주입이 불가하다.) - */ - static class FixedClockPolicy implements ReservationPolicy { - - private final Clock clock; - - FixedClockPolicy(Clock clock) { - this.clock = clock; - } - - @Override - public void validateCreatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜, 시간으로는 예약할 수 없습니다."); - } - } - - @Override - public void validateCancellable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 취소할 수 없습니다."); - } - } - - @Override - public void validateUpdatable(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("이미 지난 예약은 변경할 수 없습니다."); - } - } - - @Override - public void validateUpdateTarget(LocalDate date, LocalTime time) { - if (isPast(date.atTime(time))) { - throw new BusinessRuleViolationException("지나간 날짜·시간으로는 변경할 수 없습니다."); - } - } - - private boolean isPast(LocalDateTime dateTime) { - return !dateTime.isAfter(LocalDateTime.now(clock)); - } + return new FutureOnlyPolicy(fixed); } } diff --git a/src/test/java/roomescape/support/FixedPopularPolicyConfig.java b/src/test/java/roomescape/support/FixedPopularPolicyConfig.java index 7981482510..b361bc645f 100644 --- a/src/test/java/roomescape/support/FixedPopularPolicyConfig.java +++ b/src/test/java/roomescape/support/FixedPopularPolicyConfig.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import roomescape.domain.policy.PopularThemePolicy; +import roomescape.domain.policy.RecentWeekPopularPolicy; /** * 인기 테마 집계의 시간 기준(오늘)을 고정하기 위한 테스트 설정. @@ -14,9 +15,12 @@ *

    findPopular는 "오늘로부터 최근 7일"을 집계한다. 시스템 시계를 쓰면 "어떤 예약이 집계 범위에 * 드는가"가 테스트 실행 날짜마다 달라져 결과가 흔들린다. 그래서 today를 2026-05-09로 고정한다. * - *

    운영 RecentWeekPopularPolicy는 생성자에서 systemDefaultZone을 박아 시계 주입이 불가하므로, - * 동일 규칙을 갖되 시계만 주입받는 TestRecentWeekPopularPolicy를 @Primary로 올린다. - * (FixedClockConfig가 ReservationPolicy를 고정하는 것과 같은 패턴이다.) + *

    운영 RecentWeekPopularPolicy에 고정 Clock을 주입한 인스턴스를 @Primary로 올린다. + * 운영 정책 클래스를 그대로 쓰므로(시계만 고정), 테스트가 검증하는 집계 기준이 운영이 실제로 쓰는 기준과 동일함이 보장된다. + * + *

    Clock 자체가 아니라 "정책 빈"을 @Primary로 두는 이유: FixedClockConfig도 자기 기준시각(2026-05-13 12:00)으로 시계를 고정하는데, + * 두 설정이 각각 @Primary Clock을 등록하면 한 컨텍스트에 함께 올라올 때 충돌한다. 시계 고정을 정책 빈 단위로 캡슐화하면 그 충돌이 구조적으로 사라진다. FixedClockConfig가 + * ReservationPolicy를 고정하는 것과 같은 패턴이다. */ @TestConfiguration public class FixedPopularPolicyConfig { @@ -30,6 +34,6 @@ public PopularThemePolicy fixedPopularThemePolicy() { TODAY.atStartOfDay(ZoneId.systemDefault()).toInstant(), ZoneId.systemDefault() ); - return new TestRecentWeekPopularPolicy(fixed); + return new RecentWeekPopularPolicy(fixed); } } diff --git a/src/test/java/roomescape/support/TestRecentWeekPopularPolicy.java b/src/test/java/roomescape/support/TestRecentWeekPopularPolicy.java deleted file mode 100644 index 935897d830..0000000000 --- a/src/test/java/roomescape/support/TestRecentWeekPopularPolicy.java +++ /dev/null @@ -1,41 +0,0 @@ -package roomescape.support; - -import java.time.Clock; -import java.time.LocalDate; -import roomescape.domain.policy.PopularThemePolicy; - -/** - * 운영 RecentWeekPopularPolicy와 동일한 규칙(최근 7일, 최대 10개)을 갖되, 시계만 주입받는 버전. - * FixedPopularPolicyConfig가 고정 Clock과 함께 사용한다. - */ -public class TestRecentWeekPopularPolicy implements PopularThemePolicy { - - private static final int PERIOD_DAYS = 7; - private static final int LIMIT = 10; - - private final Clock clock; - - public TestRecentWeekPopularPolicy(Clock clock) { - this.clock = clock; - } - - @Override - public LocalDate today() { - return LocalDate.now(clock); - } - - @Override - public LocalDate from(LocalDate today) { - return today.minusDays(PERIOD_DAYS); - } - - @Override - public LocalDate to(LocalDate today) { - return today; - } - - @Override - public int limit() { - return LIMIT; - } -} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 8f01fa108e..0bcf60d66f 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,7 +1,11 @@ spring.datasource.url=jdbc:h2:mem:database spring.h2.console.enabled=true spring.h2.console.path=/h2-console -# data.sql ????: ? ???? @BeforeEach?? ?? ???? ?? ?? -#TODO ??? ?? ???? ??? + +# data.sql 비적재: 테스트는 각 @BeforeEach 에서 직접 시드 spring.sql.init.mode=embedded spring.sql.init.data-locations= + +# 전체 테이블 엔티티화 → Hibernate 가 DDL 소유 +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.defer-datasource-initialization=true