diff --git a/build.gradle b/build.gradle index aeaee3cb94..8849aab41e 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ configurations { dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' diff --git a/docs/README.md b/docs/README.md index 7dc0e8f45f..02f42930bd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,238 +1,422 @@ -# Domain +# JPA 선택 미션 학습 로그 -## 예약 대기 +## 0단계 시작 상태 -- [x] 이름, 날짜, 시간, 테마, 생성시간을 가진다. +- 시작 브랜치: `miniminjae92` +- 작업 브랜치: `jpa-migration` +- 동작 확인: `./gradlew test` 통과 -# Service +이번 선택 미션은 기존 방탈출 미션 코드를 처음부터 다시 구현하지 않고, JdbcTemplate 기반 저장소 코드를 JPA로 전환하는 것이 목적이다. 따라서 Controller API, Service의 비즈니스 규칙, 예외 처리 흐름, 관리자 토큰 기반 접근 제어, 현재 패키지 구조는 최대한 유지한다. -- [x] 이미 다른 사용자에 의해 예약된 슬롯(날짜+시간+테마)에 **대기를 신청**할 수 있다. -- [x] 같은 사용자가 같은 슬롯에 **중복 대기할 수 없다**. +전환 대상은 JdbcTemplate 기반 Repository 구현체, 직접 작성한 SQL, parameter binding, RowMapper이다. 단순 CRUD는 Spring Data JPA Repository로 전환하고, 예약 가능 여부 조회, 인기 테마 조회, 예약대기 순번 조회처럼 조건이 복잡한 쿼리는 기존 SQL의 의미를 보존하면서 JPQL이나 별도 조회 전략을 검토한다. -# Repository +## 사전 학습 정리 -## WaitingReservationRepository +### JDBC, JdbcTemplate, JPA의 역할 -- [x] 이름, 날짜, 테마, 시간으로 예약 대기를 생성할 수 있다. -- [x] 가장 먼저 신청한 예약 대기를 가져온다. (예약 취소 삭제 로직) -- [x] 같은 이름, 날짜, 테마, 시간으로 예약 대기를 생성할 수 없다. (유니크 키 걸기) - - 이름, 날짜, 테마, 시간를 유니크로 - - 이유: 상태까지 하면 한 사용자가 예약 완료, 예약 대기 둘 다 가능하기 때문 -- [x] 대기 취소를 하면 정상 삭제한다. -- [x] 사용자 이름으로 예약 대기 목록 리스트를 가져온다. +JDBC는 Java 애플리케이션이 DBMS와 통신하기 위한 표준 API이다. Java 애플리케이션은 JDBC API를 사용하고, JDBC Driver가 SQL과 파라미터를 DBMS 프로토콜에 맞는 바이트 흐름으로 변환해 DBMS와 통신한다. -# 사이클2 기능 목록 +JdbcTemplate은 JDBC의 반복 작업을 줄여준다. Connection 관리, Statement 실행, 예외 변환 같은 부분은 도와주지만, SQL 작성, 파라미터 바인딩, ResultSet을 도메인 객체로 바꾸는 RowMapper는 개발자가 직접 작성해야 한다. -## 예약 대기 자동 승인 +JPA는 객체와 관계형 데이터베이스의 매핑 규칙을 선언하면, 그 규칙을 바탕으로 SQL 생성, 바인딩, 결과 매핑을 자동화한다. 대신 실제 SQL이 언제 어떤 형태로 실행되는지 숨겨질 수 있으므로, 실행 SQL과 트랜잭션 경계를 의식해야 한다. -- [x] 사용자가 예약을 취소하면 같은 슬롯의 가장 오래된 예약 대기를 예약으로 자동 승격한다. -- [x] 사용자가 예약을 변경하면 기존 슬롯의 가장 오래된 예약 대기를 예약으로 자동 승격한다. -- [x] 예약 대기가 예약으로 승격되면 승격된 예약 대기를 삭제한다. -- [x] 예약 대기 승격 후 같은 슬롯의 남은 예약 대기 순번을 조회 시 재계산한다. -- [x] 예약 대기를 취소하면 같은 슬롯의 남은 예약 대기 순번을 조회 시 재계산한다. +### 핵심 키워드 -## 트랜잭션 경계 +- ORM: 객체와 관계형 데이터베이스 테이블을 매핑하는 기술 +- 객체-관계 임피던스 불일치: 객체는 참조와 행위를 중심으로 표현하고, RDB는 테이블과 외래 키를 중심으로 표현해서 생기는 구조적 차이 +- 영속성 컨텍스트: 엔티티를 저장하고 관리하는 논리적인 공간 +- 1차 캐시: 같은 트랜잭션 안에서 이미 조회한 엔티티를 메모리에서 다시 사용할 수 있게 하는 저장소 +- Dirty Checking: 트랜잭션 종료 시점에 엔티티 변경사항을 감지해 UPDATE SQL을 생성하는 기능 +- 쓰기 지연: INSERT, UPDATE, DELETE SQL을 즉시 실행하지 않고 모아두었다가 flush 시점에 DB와 동기화하는 방식 +- Flush: 영속성 컨텍스트의 변경 내용을 DB에 반영하는 동기화 작업 +- 연관관계의 주인: 양방향 관계에서 실제 외래 키 변경 권한을 가지는 쪽 +- LAZY: 연관 객체를 실제 사용할 때 조회하는 지연 로딩 전략 +- Fetch Join: 연관 객체를 한 번의 JPQL 조회에서 함께 가져오기 위한 쿼리 전략 -- [x] 예약 취소, 예약 생성, 승격된 예약 대기 삭제를 하나의 트랜잭션으로 처리한다. - - 이유: 예약만 삭제되거나 예약 대기만 삭제되면 빈 슬롯과 대기 상태가 불일치하기 때문이다. -- [x] 예약 변경, 기존 슬롯의 예약 생성, 승격된 예약 대기 삭제를 하나의 트랜잭션으로 처리한다. - - 이유: 예약 변경으로 기존 슬롯이 비는 순간 대기 승격까지 함께 완료되어야 사용자에게 일관된 상태를 제공할 수 있기 때문이다. -- [x] 예약 대기 취소는 단일 예약 대기 삭제로 처리하며 예약 승격 트랜잭션과 분리한다. - - 이유: 예약 대기 취소는 예약 슬롯을 비우는 행동이 아니므로 예약 생성이나 예약 삭제와 함께 묶을 작업이 없기 때문이다. +## 사전 질문 답변 -## 테스트 +### JPA는 무엇을 자동화하고 무엇을 감추는가? -- [x] 사용자가 예약을 취소하면 가장 오래된 예약 대기가 예약으로 승격되는지 검증한다. -- [x] 사용자가 예약을 변경하면 기존 슬롯의 가장 오래된 예약 대기가 예약으로 승격되는지 검증한다. -- [x] 예약 대기가 승격되면 남은 예약 대기 순번이 조회 시 재계산되는지 검증한다. -- [x] 예약 대기를 취소하면 남은 예약 대기 순번이 조회 시 재계산되는지 검증한다. -- [x] 예약 취소/변경 중 일부 작업이 실패하면 전체 작업이 롤백되는지 통합 테스트로 검증한다. -- [x] 사용자 예약 취소/변경 컨트롤러의 요청과 응답을 슬라이스 테스트로 검증한다. +JPA는 반복적인 CRUD SQL 작성, 파라미터 바인딩, 조회 결과를 객체로 변환하는 매핑 작업을 자동화한다. 대신 실제 SQL의 형태와 실행 시점이 코드에서 바로 보이지 않는다. 이 때문에 N+1 문제, 의도하지 않은 JOIN, 예상보다 늦거나 빠른 flush를 추적할 수 있어야 한다. -# 정책 +### 영속성 컨텍스트는 언제 의식해야 하는가? -## 예약 마감 +트랜잭션 경계를 의식해야 한다. 영속성 컨텍스트는 보통 트랜잭션과 함께 동작하므로, 엔티티를 조회하고 변경하는 시점, flush가 발생하는 시점, 트랜잭션 밖에서 LAZY 연관 객체에 접근하는 시점을 주의해야 한다. -- 사용자 예약 신청, 변경, 취소와 사용자 예약 대기 신청은 예약 시작 10분 전보다 이전에만 가능하다. -- 예약 시작 10분 전부터는 해당 슬롯의 사용자 예약 신청, 변경, 취소와 사용자 예약 대기 신청을 허용하지 않는다. -- 사용자 예약 대기 취소는 예약 슬롯을 비우거나 예약 승격을 발생시키지 않으므로 마감 정책을 적용하지 않는다. -- 관리자 예약 생성, 변경, 삭제는 운영 목적의 강제 조작으로 보고 예약 마감 정책을 적용하지 않는다. +### SQL JOIN을 객체 그래프로 옮기면 부담은 어디로 가는가? + +기존에는 SQL JOIN과 RowMapper가 연관 데이터를 한 번에 조회하고 객체로 조립했다. JPA에서는 `reservation.getTime().getStartAt()`처럼 객체 그래프로 접근할 수 있지만, 연관관계 매핑과 fetch 전략을 잘못 잡으면 DB 조회 횟수와 애플리케이션 메모리 사용량이 늘어날 수 있다. + +### 어노테이션 한 줄이 만드는 실제 SQL을 추적할 수 있는가? + +추적할 수 있어야 한다. `spring.jpa.show-sql`, SQL logging, parameter binding logging 등을 사용해 JPA가 생성한 SQL과 바인딩 값을 확인할 계획이다. JPA 전환 후에도 기존 JdbcTemplate SQL과 의미가 같은지 비교할 기준으로 사용한다. + +## 기존 코드 회고 + +### 현재 구조 + +현재 코드는 예약, 예약날짜, 예약시간, 테마, 예약대기 기능을 중심으로 Controller, Service, Repository 계층이 분리되어 있다. 일반 사용자 인증/인가는 없고, 예약과 예약대기 조회는 `name` 요청 값을 기준으로 처리한다. 관리자 기능은 `/admin/**` 경로에 대해 `X-ADMIN-TOKEN` 기반 인터셉터로 접근을 제한한다. + +주요 도메인 객체는 `Reservation`, `ReservationDate`, `ReservationTime`, `Theme`, `WaitingReservation`이다. `ReservationSlot`과 `ReservationSlotResolver`는 날짜, 시간, 테마 조합의 예약 가능 여부를 판단하는 역할을 한다. + +### Repository 코드에서 확인한 점 + +기존 Repository는 INSERT, SELECT, UPDATE, DELETE SQL을 모두 직접 다룬다. 특히 조회 쿼리에서는 예약, 날짜, 시간, 테마 테이블을 JOIN해서 하나의 ResultSet으로 가져온 뒤 RowMapper에서 도메인 객체를 조립한다. + +예약대기 쪽은 더 복잡하다. 같은 슬롯에서 가장 오래된 대기를 찾거나, 사용자 이름으로 예약대기 목록과 순번을 조회할 때 SQL의 정렬, JOIN, 윈도우 함수에 많이 의존한다. 이 부분은 JPA 전환 시 단순 CRUD처럼 기계적으로 바꾸기 어렵고, JPQL이나 별도 조회 전략을 신중히 선택해야 한다. + +테이블과 도메인 객체도 완전히 1:1은 아니다. DB의 `reservation` 테이블은 `date_id`, `time_id`, `theme_id` 외래 키를 가지지만, Java의 `Reservation` 객체는 `ReservationDate`, `ReservationTime`, `Theme` 객체를 직접 참조한다. 이 차이가 JPA 연관관계 매핑에서 가장 먼저 다뤄야 할 지점이다. + +## 기존 정책 로그 + +### 예약 대기 + +- 이미 예약된 슬롯에만 예약대기를 신청할 수 있다. +- 같은 사용자는 같은 날짜, 시간, 테마 조합에 중복 대기할 수 없다. +- 예약대기는 이름, 날짜, 시간, 테마, 생성시간을 가진다. +- 예약대기 순번은 컬럼으로 저장하지 않고 조회 시 계산한다. +- 순번은 같은 날짜, 시간, 테마 슬롯 안에서 생성시간과 id 순서로 결정한다. + +### 자동 승인 + +- 사용자가 예약을 취소하면 같은 슬롯의 가장 오래된 예약대기를 예약으로 자동 승격한다. +- 사용자가 예약을 변경하면 기존 슬롯의 가장 오래된 예약대기를 예약으로 자동 승격한다. +- 예약대기가 예약으로 승격되면 승격된 예약대기는 삭제한다. +- 예약대기가 승격되거나 취소되면 남은 대기 순번은 다음 조회 시 재계산한다. + +### 트랜잭션 경계 + +- 예약 취소, 예약 생성, 승격된 예약대기 삭제는 하나의 트랜잭션으로 처리한다. +- 예약 변경, 기존 슬롯의 예약 생성, 승격된 예약대기 삭제는 하나의 트랜잭션으로 처리한다. +- 중간에 일부 작업만 성공하면 빈 슬롯과 대기 상태가 불일치할 수 있으므로 전체 롤백이 필요하다. +- 예약대기 취소는 단일 대기 삭제로 처리하며 예약 승격 트랜잭션과 분리한다. + +### 예약 마감 + +- 사용자 예약 신청, 변경, 취소와 사용자 예약대기 신청은 예약 시작 10분 전까지만 허용한다. +- 예약대기 취소는 슬롯을 비우거나 예약 승격을 발생시키지 않으므로 마감 정책을 적용하지 않는다. +- 관리자 예약 생성, 변경, 삭제는 운영 목적의 강제 조작으로 보고 마감 정책을 적용하지 않는다. - 예약 가능 여부는 날짜만 보지 않고 예약 날짜와 예약 시작 시간을 합친 일시를 기준으로 판단한다. -- 예시: 10:00 예약은 09:49:59까지 요청할 수 있고 09:50:00부터 요청할 수 없다. -## 사용자 조회 +## 테스트 전략 + +단위 테스트와 통합 테스트를 나누는 기준은 외부 의존성을 대체하고 한 객체의 책임만 검증하는지 여부이다. JUnit과 테스트 더블을 사용해 특정 객체의 책임만 확인하면 단위 테스트로 보고, Spring Context, DB, HTTP 요청 처리, 트랜잭션처럼 여러 구성요소가 함께 동작하면 통합 테스트로 본다. + +### Controller + +Controller의 역할은 HTTP 요청을 받고, 요청 값을 검증한 뒤 Service에 위임하고, 적절한 상태 코드와 응답을 반환하는 것이다. Controller 테스트에서는 MockMvc 또는 RestAssured를 사용해 요청 파라미터와 요청 바디 검증, 응답 상태 코드, JSON 응답 형태, Service 호출 흐름을 확인한다. + +### Service + +Service의 역할은 도메인 객체와 Repository를 조합해 비즈니스 규칙을 지키는 것이다. 서비스 단위 테스트에서는 실제 DB 대신 메모리 기반 Fake Repository를 사용한다. 이를 통해 SQL이나 저장 방식에 의존하지 않고 예약 생성, 중복 예약 방지, 마감된 예약 생성/수정/취소 제한, 이름 기반 조회 같은 비즈니스 규칙을 검증한다. + +예약 취소/수정 시 예약대기가 승격되고 실패하면 롤백되는 흐름처럼 여러 저장소와 트랜잭션이 함께 동작해야 하는 부분은 서비스 통합 테스트로 확인한다. + +### Domain + +Domain의 역할은 핵심 규칙과 유효성을 스스로 지키는 것이다. 도메인 테스트에서는 예약자 이름, 날짜, 시간, 테마, 예약대기 생성 조건처럼 객체가 생성될 때 지켜야 하는 규칙과 예약 가능 시간이 지났는지 판단하는 로직을 검증한다. + +### Repository + +Repository의 역할은 도메인 객체를 DB에 저장하고 조회하며, SQL 결과를 객체로 올바르게 변환하는 것이다. Repository 테스트에서는 `@JdbcTest`와 H2 DB를 사용해 실제 SQL, parameter binding, RowMapper가 의도대로 동작하는지 확인한다. 특히 예약대기 순번 조회, 특정 슬롯의 가장 오래된 대기 조회, 중복 제약처럼 SQL에 의존하는 로직을 검증한다. + +JPA 전환 후에는 이 Repository 테스트와 서비스 통합 테스트가 기존 동작 보존 여부를 확인하는 주요 기준이 된다. + +## JPA 전환 시 확인할 점 + +- 기존 SQL이 하던 일을 JPA 매핑, Repository 메서드, JPQL 중 어디로 옮길지 구분한다. +- 단순 CRUD와 복잡한 조회를 같은 방식으로 처리하려 하지 않는다. +- `Reservation`이 `ReservationDate`, `ReservationTime`, `Theme`를 참조하는 구조를 JPA 연관관계로 어떻게 표현할지 먼저 결정한다. +- `WaitingReservation`의 순번 계산은 저장 값이 아니라 파생 값이므로 조회 전략을 별도로 검토한다. +- 예약 취소/변경과 예약대기 승격은 트랜잭션 경계가 깨지면 안 된다. +- JPA가 생성한 SQL을 로그로 확인하고, 기존 JdbcTemplate SQL의 의미와 비교한다. +- 전환 후에도 `./gradlew test`로 전체 자동화 테스트를 통과시키는 것을 기준으로 삼는다. + +## 영속성 컨텍스트 관찰 기록 + +### 1. Dirty Checking + +- 시도한 코드: `@Transactional` 범위 안에서 `reservation.changeSlot(newDate, newTime)`만 호출하고 `save()`는 호출하지 않았다. +- 예측: `save()`를 안 했으니 `UPDATE`가 안 나갈 수도 있다고 생각했다. +- 실제: `flush()` 시점에 `update reservation set ... where id=?`가 발생했다. +- 이유: 조회된 엔티티는 영속 상태라서, JPA가 트랜잭션 안에서 변경 전후를 비교해 변경을 자동 반영한다. + +### 2. 1차 캐시 + +- 시도한 코드: 같은 트랜잭션에서 `findById(reservationId)`를 두 번 호출했다. +- 예측: `SELECT`가 두 번 나갈 수도 있다고 생각했다. +- 실제: 첫 번째만 `SELECT`가 발생했고, 두 번째는 SQL이 생략됐다. `first == second`도 `true`였다. +- 이유: 같은 영속성 컨텍스트 안에서는 같은 id의 엔티티를 1차 캐시에 보관한다. + +### 3. 쓰기 지연 + +- 시도한 코드: `reservationRepository.save(...)` 후 `flush()` 전후 SQL을 봤다. +- 예측: `INSERT`가 `flush`나 `commit`까지 지연될 것이라고 생각했다. +- 실제: 현재 엔티티가 `GenerationType.IDENTITY`라서 `save()` 직후 `INSERT`가 바로 발생했다. +- 이유: `IDENTITY` 전략은 DB가 id를 만들어 주므로, Hibernate가 id를 얻기 위해 `INSERT`를 먼저 실행해야 한다. + +### 4. Flush 시점 + +- 시도한 코드: 엔티티 필드를 변경한 뒤 JPQL `select r from Reservation r`을 실행했다. +- 예측: JPQL `SELECT`만 나갈 것이라고 생각했다. +- 실제: JPQL 실행 직전에 `UPDATE`가 먼저 나가고, 그 다음 `SELECT`가 실행됐다. +- 이유: JPQL 결과가 DB 상태와 어긋나지 않도록 Hibernate가 쿼리 실행 전에 자동 flush를 수행한다. + +### 5. Fetch 기본값 + +- 시도한 코드: `Reservation` 조회 후 `reservation.getTime().getStartAt()`에 접근했다. +- 예측: `@ManyToOne` 기본값은 `EAGER`라서 처음 조회 때 같이 가져올 수 있다고 생각했다. +- 실제: 현재 코드는 `@ManyToOne(fetch = FetchType.LAZY)`로 명시되어 있어 예약 조회 시에는 `reservation`만 조회되고, `time` 접근 시 추가 `SELECT`가 발생했다. +- 이유: JPA 기본값은 `ManyToOne = EAGER`, `OneToMany = LAZY`지만, 현재 매핑은 명시적으로 `LAZY`를 선택했기 때문이다. + +### 6. LazyInitializationException + +- 시도한 코드: 트랜잭션 안에서 `Reservation`만 조회하고, 트랜잭션 밖에서 `reservation.getTime().getStartAt()`을 호출했다. +- 예측: `LAZY` 필드라서 접근 시 조회가 발생할 것이라고 생각했다. +- 실제: `org.hibernate.LazyInitializationException: no session`이 발생했다. +- 이유: 트랜잭션이 끝나며 영속성 컨텍스트가 닫혔고, 닫힌 뒤에는 LAZY 프록시를 초기화할 수 없다. + +## 내 예약 목록 조회 전략 기록 + +### 배경 + +2단계 요구사항은 화면에 보여줄 유효한 내 예약 목록을 조회하는 것이다. 여기서 유효한 예약은 지난 예약을 제외한 예약이다. 단순히 `findByName`으로 예약을 모두 가져온 뒤 Java 코드에서 필터링할 수도 있지만, 유효 여부는 예약 날짜와 예약 시간이 결정한다. 이 값들은 DB가 이미 가지고 있으므로 DB에서 먼저 조건을 걸어 조회하는 편이 더 적절하다고 판단했다. + +### JPQL을 선택한 이유 + +조회 조건은 단순히 이름이 같은 예약을 찾는 것이 아니었다. 오늘 이후 예약이거나, 오늘 예약이라면 현재 시간 이후인 예약만 조회해야 했다. + +```text +date > currentDate +or (date = currentDate and time > currentTime) +``` + +이 조건을 메서드 이름 쿼리로 표현하면 `Or` 조건 때문에 이름이 길어지고, 괄호로 묶이는 조건의 의도가 잘 드러나지 않는다고 느꼈다. 그래서 복잡한 조건은 JPQL의 조건식으로 표현하는 편이 더 읽기 쉽다고 판단했다. + +### EntityGraph를 함께 사용한 이유 + +JPQL을 사용한다고 N+1 문제가 자동으로 해결되는 것은 아니다. 현재 `Reservation`은 `ReservationDate`, `ReservationTime`, `Theme`를 `LAZY`로 참조한다. 그런데 응답 DTO를 만들 때는 날짜, 시간, 테마 정보가 모두 필요하다. -- 사용자 예약 조회와 사용자 예약 대기 조회의 기본 응답은 예약 시작 시각이 지나지 않은 항목만 포함한다. -- 예약 시작 10분 전 이후라도 예약 시작 전이라면 조회 응답에는 포함한다. -- 지난 예약과 지난 예약 대기는 별도 조회 요구사항이 생기기 전까지 기본 응답에 포함하지 않는다. +그래서 조회 조건은 JPQL에 두고, 응답 생성에 필요한 연관 객체 로딩 범위는 `@EntityGraph(attributePaths = {"date", "time", "theme"})`로 분리했다. -## 자동 승인 선택 +```java +@EntityGraph(attributePaths = {"date", "time", "theme"}) +@Query(""" + select r + from Reservation r + where r.name = :name + and (r.date.playDay > :currentDate + or (r.date.playDay = :currentDate and r.time.startAt > :currentTime)) + order by r.date.playDay, r.time.startAt + """) +List findUpcomingByName( + @Param("name") String name, + @Param("currentDate") LocalDate currentDate, + @Param("currentTime") LocalTime currentTime +); +``` + +`join fetch`도 N+1을 줄일 수 있지만, 이번에는 조건과 로딩 책임을 분리해서 읽을 수 있는 `@EntityGraph`가 더 적절하다고 봤다. JPQL은 유효한 예약을 찾는 조건에 집중하고, EntityGraph는 DTO 변환에 필요한 객체 그래프를 어디까지 로딩할지 표현한다. + +### 한계 + +JPQL과 `@EntityGraph`는 모두 문자열 기반이다. 엔티티 필드명이 변경되어도 Java 컴파일러가 바로 잡아주지 못하고, 실행 시점이나 쿼리 검증 시점에 오류를 발견할 수 있다. + +또한 페이징이 필요해지면 다시 점검해야 한다. 현재처럼 `ManyToOne` 연관 객체를 함께 로딩하는 정도는 비교적 안전하지만, `OneToMany` 같은 컬렉션을 함께 로딩하면 row 중복과 count query 문제가 생길 수 있다. 페이징이 들어오면 실제 SQL과 count query를 확인하면서 조회 방식을 다시 판단해야 한다. + +## 예약 대기 N+1 / EntityGraph 관찰 기록 + +### 배경 + +3단계에서 `WaitingReservation`도 JPA 엔티티로 전환했다. `Reservation`과 일관되게 `ReservationDate`, `ReservationTime`, `Theme`는 `@ManyToOne(fetch = FetchType.LAZY)`로 참조하도록 했다. + +처음에는 예약 대기 목록을 조회한 뒤 응답 DTO를 만들 때 `getDate().getPlayDay()`, `getTime().getStartAt()`, `getTheme().getName()`에 접근하므로 N+1이 발생할 수 있다고 봤다. 따라서 순번 계산 조건은 JPQL에 두고, 응답 생성에 필요한 연관 객체는 `@EntityGraph(attributePaths = {"date", "time", "theme"})`로 함께 로딩하도록 했다. + +### 시도한 코드 + +```java +@EntityGraph(attributePaths = {"date", "time", "theme"}) +@Query(""" + select new roomescape.domain.waitingreservation.dto.WaitingReservationWithRank( + w, + ( + select count(w2) + 1 + from WaitingReservation w2 + where w2.date = w.date + and w2.time = w.time + and w2.theme = w.theme + and ( + w2.createdAt < w.createdAt + or (w2.createdAt = w.createdAt and w2.id < w.id) + ) + ) + ) + from WaitingReservation w + where w.name = :name + order by w.date.playDay, w.time.startAt, w.id + """) +List findAllByNameWithRank(@Param("name") String name); +``` + +### 예측 SQL + +`waiting_reservation`을 기준으로 조회하되, 순번은 같은 날짜, 시간, 테마 슬롯 안에서 현재 대기보다 먼저 생성된 row 수를 `count` 서브쿼리로 계산할 것이라고 예상했다. 또한 `@EntityGraph` 때문에 DTO 변환에 필요한 `reservation_date`, `reservation_time`, `theme`도 함께 조회될 것이라고 봤다. + +### 실제 SQL + +테스트 로그에서 확인한 Hibernate SQL은 다음과 같았다. + +```sql +select + wr1_0.id, + wr1_0.created_at, + d3_0.id, + d3_0.play_day, + wr1_0.name, + t5_0.id, + t5_0.content, + t5_0.name, + t5_0.url, + t6_0.id, + t6_0.start_at, + (select + (count(wr2_0.id)+1) + from + waiting_reservation wr2_0 + where + wr2_0.date_id=wr1_0.date_id + and wr2_0.time_id=wr1_0.time_id + and wr2_0.theme_id=wr1_0.theme_id + and ( + wr2_0.created_at today or (date = today and time > now)` 형태라 메서드 이름 쿼리로 표현하면 의도가 흐려진다고 봤다. + - JPQL은 "어떤 예약을 조회할 것인가"에 집중하고, EntityGraph는 "어디까지 객체 그래프를 로딩할 것인가"에 집중하도록 나누고 싶었다. + - 나중에 로딩 범위만 바뀐다면 JPQL 조건을 건드리지 않고 EntityGraph만 조정할 수 있다. + - 같은 로딩 범위가 반복된다면 EntityGraph 쪽이 재사용하기 좋다고 느꼈다. + - `join`과 `join fetch`가 한 쿼리 안에 섞이면 조건을 위한 조인인지 로딩을 위한 조인인지 계속 구분해서 읽어야 해서 피로도가 있다고 생각했다. + +- 이 선택의 한계 / 다음에 망가질 수 있는 지점: + - EntityGraph도 문자열 기반이라 필드명 변경을 컴파일 시점에 잡지 못한다. + - 실제 SQL이 코드에 직접 드러나는 `join fetch`보다 생성 SQL을 로그로 확인해야 한다. + - 페이징이나 컬렉션 로딩이 들어오면 row 중복과 count query 문제를 다시 확인해야 한다. + +- 동료에게 묻고 싶은 것: + - 조건과 로딩 책임을 분리하는 장점이 `join fetch`의 명시성을 포기할 만큼 충분하다고 보는지 궁금하다. + +### 결정 #2: 예약 대기 모델링 + +- 선택한 것: + - `Reservation.status`로 통합하지 않고 `WaitingReservation`을 별도 엔티티로 유지했다. + +- 비교한 대안: + - `Reservation`에 `status` 컬럼을 추가해 예약과 대기를 하나의 테이블/엔티티로 관리하는 방식 + - 부분 유니크 제약으로 예약과 대기의 중복 조건을 다르게 관리하는 방식 + +- 선택의 비교 기준: + - 현재 요구사항에서의 구현 비용 + - 예약과 예약 대기의 유니크 제약 차이 + - 예약 대기 정책이 앞으로 독립적으로 확장될 가능성 + - 분리했을 때 따라오는 동시성/일관성 문제를 감당할 수 있는지 + +- 선택의 근거: + - 처음에는 `status` 컬럼으로 통합하는 방식이 자연스럽다고 생각했다. 예약과 예약 대기는 같은 테마, 날짜, 시간을 기준으로 동작하기 때문이다. + - 하지만 예약은 같은 슬롯에 하나만 존재해야 하고, 예약 대기는 같은 슬롯이어도 사용자 이름이 다르면 여러 명이 존재할 수 있다. 이 유니크 제약 차이 때문에 별도 엔티티를 유지했다. + - 현재 요구사항만 보면 `status` 통합이 더 단순했을 수 있다고 생각한다. + - 다만 이미 별도 도메인으로 구현되어 있었고, 앞으로 예약 대기만의 필드나 정책이 늘어나면 분리된 구조가 요구사항 변화에 더 유리할 수 있다고 봤다. + - 특히 분리 구조에서는 동시성이나 일관성 문제가 따라오지만, 그 문제에 대한 대비가 갖춰진다면 장기적으로는 예약 대기를 독립된 도메인으로 다루는 편이 확장에는 유리할 수 있다고 생각했다. + +- 이 선택의 한계 / 다음에 망가질 수 있는 지점: + - 현재 요구사항 수준에서는 엔티티 분리가 과한 선택일 수 있다. + - 예약과 예약 대기가 별도 데이터로 움직이므로, 빈 슬롯인데 대기만 남는 상태나 동시성 문제가 생길 수 있다. + - 예약 취소, 대기 신청, 자동 승격이 동시에 일어나는 상황을 충분히 방어하지 못하면 일관성이 깨질 수 있다. + +- 동료에게 묻고 싶은 것: + - 현재 요구사항처럼 예약과 대기가 거의 같은 필드를 공유한다면 `status` 통합이 더 나은 선택인지, 아니면 유니크 제약 차이만으로도 별도 엔티티를 둘 근거가 충분한지 궁금하다. + +### 결정 #3: 자동 승격 트랜잭션 + +- 선택한 것: + - 예약 취소/수정 시 `ReservationService` 안에서 가장 오래된 예약 대기를 자동 승격한다. + - 예약 삭제, 대기 조회, 예약 생성, 승격된 대기 삭제를 하나의 트랜잭션으로 처리했다. + +- 비교한 대안: + - 예약 취소와 대기 승격을 분리해서 처리하는 방식 + - 비동기 이벤트로 예약 취소 이후 대기 승격을 후속 처리하는 방식 + - 실패 시 보상 트랜잭션으로 복구하는 방식 + +- 선택의 비교 기준: + - 예약 도메인의 데이터 정합성 + - 사용자 경험 + - 구현 복잡도 + - 실패 상황을 내가 설명하고 복구할 수 있는지 -- 예약 취소/변경으로 빈 슬롯이 생기면 가장 오래 기다린 예약 대기를 예약으로 자동 승격한다. -- 수동 승인 대신 자동 승인을 선택한 이유는 빈 슬롯이 생겼을 때 사용자 개입 없이 가장 오래 기다린 사용자의 예약으로 전환하는 것이 사용자 기대에 가깝다고 판단했기 때문이다. -- 자동 승격은 사용자 예약 취소와 사용자 예약 변경에 적용한다. +- 선택의 근거: + - 자동 승격 실패 때문에 예약 취소가 실패하는 UX는 좋지 않다고 느꼈다. + - 하지만 예약 취소와 대기 승격을 분리하면, 예약은 취소됐는데 대기는 승격되지 않거나, 예약과 대기 상태가 어긋나는 데이터 정합성 문제가 발생할 수 있다고 봤다. + - 단순 조회수처럼 조금 틀려도 되는 데이터가 아니라, 실제 예약과 관련된 비즈니스 요구사항이므로 정합성이 더 중요하다고 판단했다. + - 비동기 이벤트나 보상 트랜잭션 같은 키워드는 들어봤지만, 아직 적용 방법과 실패 대응 방식을 명확히 알지 못한다. + - 그래서 이번 구현에서는 내가 설명할 수 있고, 가장 단순하게 정합성을 지킬 수 있는 하나의 트랜잭션 방식을 선택했다. -## 예약 대기 순번 +- 이 선택의 한계 / 다음에 망가질 수 있는 지점: + - 자동 승격 실패가 예약 취소 실패로 전파될 수 있어 UX가 좋지 않을 수 있다. + - 동시 요청 상황에서 같은 대기가 중복 승격되거나, 예약 취소와 대기 신청이 엇갈리는 문제를 아직 충분히 방어하지 못했다. + - 알림이나 외부 서비스가 붙으면 하나의 트랜잭션 안에 묶기 어려워질 수 있다. -- 예약 대기 순번은 별도 컬럼으로 저장하지 않고 조회 시 계산한다. -- 순번은 같은 날짜, 시간, 테마 슬롯 안에서 생성 시간과 id 순서로 결정되는 파생 값이다. -- 예약 대기가 승격되거나 취소되어 삭제되면 다음 조회에서 남은 예약 대기의 순번을 다시 계산해 반환한다. -- 순번을 저장하지 않는 이유는 저장된 순번과 실제 예약 대기 목록 사이의 불일치 가능성을 줄이기 위해서다. - -# 테스트 전략 - -## 단위 테스트 - -- JUnit 러너 안에서 JVM 메모리만 사용해 검증한다. -- 협력 객체는 mock, stub, fake 등 테스트 더블로 대체할 수 있다. -- 서비스 정책과 도메인 규칙은 단위 테스트에서 우선 검증한다. - -## 통합 테스트 - -- Spring context, 실제 DB, JdbcTemplate, 트랜잭션, HTTP 서버를 사용하는 테스트를 통합 테스트로 본다. -- 예약 취소/변경과 예약 대기 승격처럼 여러 저장소 변경이 하나의 트랜잭션으로 묶이는 흐름은 통합 테스트로 검증한다. -- 중간 실패 시 롤백되는지는 실제 트랜잭션 경계를 확인해야 하므로 통합 테스트로 검증한다. - -## 컨트롤러 테스트 - -- 컨트롤러 테스트는 기본적으로 `@WebMvcTest` 기반 슬라이스 테스트로 작성한다. -- 컨트롤러에서는 HTTP method/path, 요청 body 검증, 응답 status/body, 서비스 호출 위임을 검증한다. - -## 계층별 테스트 예시 - -- 도메인 테스트: 생성/검증 규칙 -- 저장소 테스트: SQL, 순번 계산, 조회/삭제 -- 서비스 단위 테스트: 정책 분기, 예외 조건 -- 서비스 통합 테스트: 예약 취소/변경과 대기 승격, 트랜잭션 롤백 -- 컨트롤러 테스트: HTTP 요청/응답, validation, service 호출 위임, 예외 응답 매핑 -- HTTP 서버 통합 테스트: 전체 wiring 확인이 필요한 대표 happy path - -# API - -- [x] 예약 대기 신청 - - **`POST /waiting-reservations`** - - - 설명: 예약 대기 생성 - - 요청 본문 - - ```json - { - "name": "쿠키", - "dateId": 1, - "timeId": 2, - "themeId": 3 - } - ``` - - - 응답 `201 Created` - - ```json - { - "id": 29, - "name": "쿠키", - "date": "2026-05-01", - "time": "11:00", - "theme": { - "name": "청춘물", - "content": "학교 배경인 테마 입니다.", - "url": "/themes/youth" - }, - "createdAt": "2026-05-26T11:00:55" - } - ``` - - - 에러 처리 - - [x] 중복 예약 대기 신청: 409 Conflict - - [x] 예약 가능한 시간에 대기 신청: 409 Conflict - - [x] 존재하지 않는 date/time/theme: 404 Not Found - - [x] 요청 값 누락/형식 오류: 400 Bad Request - -- [x] 예약 대기 취소 - - **`DELETE /waiting-reservations/{id}`** - - - 설명: 사용자 본인 예약 대기 취소 - - 응답 `204 No Content` - - 정책 - - 예약 대기 취소는 예약 승격을 발생시키지 않는다. - - 같은 슬롯의 남은 예약 대기 순번은 다음 조회 시 재계산된다. - -- [x] 예약 대기 목록 조회 - - **`GET /waiting-reservations?name={name}`** - - - 설명: 사용자 이름으로 예약 대기 목록 조회 - - 응답 `200 OK` - - ```json - [ - { - "id": 1, - "name": "고래", - "date": "2026-05-05", - "time": { - "id": 1, - "startAt": "10:00" - }, - "theme": { - "id": 1, - "name": "공포", - "content": "오금이 저리는 공포입니다.", - "url": "/themes/scary" - }, - "rank": 1, - "createdAt": "2026-05-26T11:00:55" - }, - { - "id": 2, - "name": "고래", - "date": "2026-06-05", - "time": { - "id": 2, - "startAt": "11:00" - }, - "theme": { - "id": 1, - "name": "공포", - "content": "오금이 저리는 공포입니다.", - "url": "/themes/scary" - }, - "rank": 2, - "createdAt": "2026-05-26T11:00:55" - } - ] - ``` - - - 에러 처리 - - [x] name이 비어있는 경우: 400 Bad Request - -- [x] 사용자 예약 취소 - - **`DELETE /reservations/{id}`** - - - 설명: 사용자가 본인의 예약을 취소한다. - - 응답 `204 No Content` - - 정책 - - 같은 슬롯에 예약 대기가 있으면 가장 오래된 예약 대기를 예약으로 자동 승격한다. - - 예약 삭제, 예약 생성, 승격된 예약 대기 삭제는 하나의 트랜잭션으로 처리한다. - - 같은 슬롯에 예약 대기가 없으면 예약만 삭제한다. - -- [x] 사용자 예약 변경 - - **`PATCH /reservations/{id}`** - - - 설명: 사용자가 본인의 예약 날짜와 시간을 변경한다. - - 요청 본문 - - ```json - { - "dateId": 2, - "timeId": 3 - } - ``` - - - 응답 `200 OK` - - 정책 - - 예약이 다른 슬롯으로 변경되면 기존 슬롯의 가장 오래된 예약 대기를 예약으로 자동 승격한다. - - 예약 변경, 기존 슬롯의 예약 생성, 승격된 예약 대기 삭제는 하나의 트랜잭션으로 처리한다. +- 동료에게 묻고 싶은 것: + - 예약 도메인처럼 정합성이 중요한 경우에도 사용자 요청을 먼저 성공시키고 후속 승격을 비동기로 처리하는 게 더 나은지, 그렇다면 중간 상태와 실패 복구를 어떻게 설계하는지 궁금하다. diff --git "a/docs/jpa-mission/0\353\213\250\352\263\204-\352\270\260\353\263\270-\354\275\224\353\223\234-\354\244\200\353\271\204.md" "b/docs/jpa-mission/0\353\213\250\352\263\204-\352\270\260\353\263\270-\354\275\224\353\223\234-\354\244\200\353\271\204.md" new file mode 100644 index 0000000000..7879aa98e9 --- /dev/null +++ "b/docs/jpa-mission/0\353\213\250\352\263\204-\352\270\260\353\263\270-\354\275\224\353\223\234-\354\244\200\353\271\204.md" @@ -0,0 +1,32 @@ +## 3. 0단계 - 기본 코드 준비하기 + +평가 대기 + +제출 완료2026. 6. 17. 제출 + +# 0단계: 기본 코드 준비 + +`spring-roomescape-waiting` 저장소의 **본인 브랜치**에 방탈출 미션에서 작업한 코드가 그대로 있습니다. 이 코드 위에 JPA 전환을 시작합니다. **0단계는 UI 동작 확인을 하지 않습니다** — 1단계가 끝나야 UI 연동이 완료됩니다. + +> **들어가기 전 자기 진단** + +Q. 본인 브랜치의 방탈출 미션 코드는 동작 가능한 상태인가요? 본인 코드의 구조를 한눈에 설명할 수 있나요? + +현재 전체 테스트가 통과하고 정상 작동하는 상태입니다. 코드는 예약, 예약 날짜, 예약 시간, 테마, 예약 대기 기능을 중심으로 Controller, Service, Repository 계층이 분리되어 있습니다. 일반 사용자 인증/인가는 없고, 예약과 예약 대기 조회는 name 요청 값을 기준으로 처리합니다. 다만 관리자 기능은 /admin/\*\* 경로에 대해 토큰 기반 인터셉터로 접근을 제한합니다. DB 접근은 JdbcTemplate, 직접 작성한 SQL, parameter binding, RowMapper를 사용하는 Repository 구현체가 담당합니다. 주요 도메인 객체는 Reservation, ReservationDate, ReservationTime, Theme, WaitingReservation이며, ReservationSlot과 ReservationSlotResolver가 날짜/시간/테마 조합의 예약 가능 여부를 판단하는 역할을 합니다. 저는 단위 테스트와 통합 테스트를 나누는 기준을 JUnit과 테스트 더블로 특정 객체의 책임만 확인하면 단위 테스트로 보았고, 그 이외의 여러 구성 요소의 통합 검증을 목적으로 하는 것들은 통합 테스트로 보았습니다. 그리고 단위 테스트를 최우선으로 하고, 필요한 경우에만 통합 테스트를 진행하는 것을 목표로 했습니다. Domain은 객체의 핵심 규칙과 유효성을 지키는 역할이라고 보고 핵심 규칙과 불변식을 단위 테스트했습니다. Repository는 DB 저장/조회와 SQL 결과를 객체로 변환하는 역할이라고 보고 해당 부분을 통합 테스트로 검증했습니다. Service는 도메인과 Repository를 조합해 비즈니스 규칙을 지키는 역할이라고 보았습니다. 그래서 Fake Repository를 사용해 DB 없이 예약 생성, 중복 방지, 마감된 예약 제한 같은 규칙을 검증했고, 예약 대기 승격과 롤백처럼 여러 저장소와 트랜잭션이 필요한 흐름은 통합 테스트로 확인했습니다. Controller는 HTTP 요청을 검증하고 Service에 위임한 뒤 상태 코드와 응답을 반환하는 역할이라고 생각했습니다. 그래서 MockMvc나 RestAssured로 요청 검증, 응답 상태 코드, JSON 응답을 확인했습니다. + +## 시작점 + +이 선택미션은 `spring-roomescape-waiting`의 **본인 브랜치**에서 시작합니다. 자기 손으로 만든 SQL이 객체 그래프로 어떻게 옮겨지는지 **직접 비교**하는 자리가 이 미션의 핵심입니다. + +## 0단계 요구사항 +- 본인 브랜치에서 작업할 새 브랜치를 따고 시작합니다. 1단계 이후의 변환 diff가 깔끔하게 보입니다. +- 처음부터 다시 구현하지 않습니다. 이 미션이 보려는 "내 방탈출 미션 SQL이 JPA로 어떻게 옮겨지는가"의 비교 대상이 흐려집니다. +- 동작 확인: 본인 브랜치의 방탈출 미션 동작이 그대로 유지되는지 확인합니다. + +> 0단계는 푸시 후 PR을 열지 않습니다. 최종 PR(미션 종료 시) 본문에 0단계의 **시작 브랜치·작업 브랜치**와 **이번 미션에서 건드릴 범위**를 한 단락으로 적습니다. + +## 확인 과제 + +Q. 방탈출 미션 코드 중 JPA 전환에서 건드릴 부분과 그대로 둘 부분의 경계를 적어주세요. 무엇을 바꿀 예정이고 무엇은 유지할 예정인가요? + +JPA 전환에서는 JdbcTemplate 기반 Repository 구현체, 직접 작성한 SQL, RowMapper를 중심으로 변경할 예정입니다. 단순 CRUD는 Spring Data JPA Repository로 전환하고, 예약 가능 여부 조회, 인기 테마 조회, 예약 대기 순번 조회처럼 조건이 복잡한 쿼리는 기존 SQL의 의미를 보존하면서 JPQL이나 별도 조회 전략을 검토하겠습니다. JPA 전환 과정에서는 기존 Repository 테스트와 서비스 통합 테스트를 기준으로 동작이 유지되는지 확인하겠습니다. Controller API 스펙, Service의 기존 비즈니스 규칙, 예외 처리 흐름, 관리자 토큰 기반 접근 제어, 현재 패키지 구조는 유지하겠습니다. 이번 단계에서는 사용자 인증/인가 구조를 새로 만들거나 UI 연동을 확인하지 않고, 기존 기능이 유지되는 범위 안에서 DB 접근 방식을 JPA로 바꾸는 데 집중하겠습니다. diff --git "a/docs/jpa-mission/1\353\213\250\352\263\204-JPA-\354\240\204\355\231\230.md" "b/docs/jpa-mission/1\353\213\250\352\263\204-JPA-\354\240\204\355\231\230.md" new file mode 100644 index 0000000000..58e0f528b1 --- /dev/null +++ "b/docs/jpa-mission/1\353\213\250\352\263\204-JPA-\354\240\204\355\231\230.md" @@ -0,0 +1,98 @@ +## 4. 1단계 - JPA 전환 + +평가 대기 + +제출 완료2026. 6. 21. 제출 + +# 1단계: JPA 전환 + +JdbcTemplate Repository를 JPA Repository로 전면 교체하고, 도메인 간 연관관계를 객체 그래프로 표현하며, 영속성 컨텍스트의 동작을 직접 관찰합니다. + +이 미션에서 분량이 가장 큰 단계입니다. 매핑·연관관계·영속성 컨텍스트가 한꺼번에 등장하니 페이스를 잡을 때 가장 무거운 단계로 의식하면 좋습니다. + +> **들어가기 전 자기 진단** + +Q. 본인 코드의 Repository에서 가장 자주 등장하는 SQL 패턴은 무엇인가요? + +Theme, ReservationTime, ReservationDate처럼 독립적인 도메인은 insert, select by id, find all, delete, exists 같은 단순 SQL이 자주 등장했습니다. 반면 Reservation과 WaitingReservation은 reservation\_date, reservation\_time, theme 테이블을 JOIN해서 한 번에 조회한 뒤, RowMapper에서 ReservationDate, ReservationTime, Theme 객체로 직접 조립하는 패턴이 반복됩니다. + +Q. 객체 참조로 옮겼을 때 더 자연스러워지는 곳은 어디인가요? + +객체 참조로 옮겼을 때 가장 자연스러워지는 곳은 Reservation이 ReservationDate, ReservationTime, Theme를 참조하는 부분입니다. 기존 DB 테이블은 date\_id, time\_id, theme\_id 외래 키만 가지고 있지만, Java 도메인 객체는 이미 날짜, 시간, 테마를 객체로 들고 있어 연관관계 매핑하면 reservation.getTime().getStartAt(), reservation.getTheme().getName()처럼 객체 그래프로 접근하는 방식이 더 자연스러워집니다. +* * * + +## 1-1. 매핑 변환 + +다른 클래스에 의존하지 않는 클래스부터 시작합니다 — `Theme`, `ReservationTime` 등. + +### 요구사항 +- `build.gradle`: `spring-boot-starter-jdbc` → `spring-boot-starter-data-jpa` 대체 +- `@Entity`, `@Id`, `@GeneratedValue(strategy = IDENTITY)` 부여 +- `JpaRepository` 인터페이스 작성, 기존 JdbcTemplate 기반 Repository 제거 +- `KeyHolder`, `SimpleJdbcInsert` 같은 JdbcTemplate 잔재 제거 +- `application.properties` 권장 설정: + +```properties +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.ddl-auto=create-drop +spring.jpa.defer-datasource-initialization=true +``` + +> **양쪽 시도 시 비교 관찰 포인트**: ① 시작 시 발행되는 DDL의 차이 ② 재시작 시 데이터 보존 여부 ③ 컬럼명·타입을 entity로만 제어할 수 있는지. 이 셋이 안 보이면 그냥 양쪽 다 돌려봤을 뿐 차이는 못 봤다는 신호입니다. + +### 확인 과제 + +Q. 예약 생성 시 콘솔에 찍히는 INSERT SQL이 방탈출 미션과 어떻게 같고 어떻게 다른가요? + +\`\`\` Hibernate: insert into reservation (date\_id, name, theme\_id, time\_id, id) \`\`\` 같은 점은 \`reservation\` 테이블에 예약자 이름, 날짜 id, 시간 id, 테마 id를 저장한다는 점입니다. 둘 다 DB에는 외래 키 값이 들어갑니다. 다른 점은 컬럼 순서가 기존 SQL의 \`name, date\_id, time\_id, theme\_id\`와 다르게 \`date\_id, name, theme\_id, time\_id, id\`처럼 찍혔고, \`id\`는 \`IDENTITY\` 전략에 따라 DB가 생성하도록 \`default\`로 처리된 것 입니다. 또 하나의 차이는 쓰기 지연입니다. JDBC에서는 예약 삭제 후 같은 슬롯의 대기를 승격할 때 DELETE가 즉시 실행되어 기존 슬롯이 바로 비었습니다. JPA에서는 DELETE와 INSERT가 영속성 컨텍스트에 모였다가 flush 시점에 동기화되면서, Hibernate 내부 ActionQueue에 따라서 INSERT가 먼저 처리될 수 있고, 실제로 발생하여 UNIQUE 제약에 걸렸습니다. 그래서 예약 취소/변경 후 대기 승격 전에 \`reservationRepository.flush()\`를 호출해 기존 슬롯을 먼저 DB에 반영하도록 조정했습니다. +* * * + +## 1-2. 연관관계 매핑 + +다른 클래스에 의존하는 클래스에 연관관계를 매핑합니다. 예: `Reservation`은 `Member`, `Theme`, `ReservationTime`을 참조합니다. + +### 요구사항 +- `@ManyToOne` + `@JoinColumn(name = "..._id")`로 객체 참조 +- **단방향으로 시작**합니다. 양방향이 필요한 이유가 생기면 그때 추가합니다. +- 양방향 시도 시 **연관관계 주인** 명시, 무한 직렬화 가능성 검토. +- `cascade`, `orphanRemoval`은 **필요해질 때까지 적용하지 않습니다**. 적용한다면 PR 본문에 그 근거를 적습니다. + +> 양방향 또는 cascade를 한 번 시도했다가 단방향/제거로 후퇴하는 사이클을 의식적으로 한 번 굴려봅니다. 시도와 후퇴를 기록에 남기는 것이 차원 B(설계 판단)의 도달점입니다. + +### 확인 과제 + +Q. \`findById(reservationId).getTime().getStartAt()\`이 발행하는 SQL을 적어주세요. + +Hibernate: select r1\_0.id,r1\_0.date\_id,r1\_0.name,r1\_0.theme\_id,r1\_0.time\_id from reservation r1\_0 where r1\_0.id=? Hibernate: select rt1\_0.id,rt1\_0.start\_at from reservation\_time rt1\_0 where rt1\_0.id=? +* * * + +## 1-3. 영속성 컨텍스트 관찰 + +코드를 추가하기보다 **관찰**합니다. JPA가 자동으로 무엇을 하는지 직접 봅니다. + +**이 미션의 본질 6개 중 영속성 컨텍스트가 가장 깊이 박히는 자리입니다.** + +| 관찰 대상 | 어떤 코드로 확인하는가 | 무엇을 본다 | +| --- | --- | --- | +| **dirty checking** | `@Transactional` 메서드에서 entity 필드 수정 후 save 미호출 | commit 시점에 UPDATE 자동 발행 | +| **1차 캐시** | 같은 트랜잭션에서 `findById` 두 번 호출 | 두 번째 SELECT 생략 (1차 캐시 적중) | +| **쓰기 지연** | `save` 호출 후 `flush` 전·후의 DB 상태 비교 | INSERT가 commit/flush 시점에 일괄 발행 | +| **flush 시점** | 명시적 `flush` 호출 / 트랜잭션 종료 / JPQL 실행 직전 | 영속성 컨텍스트 → DB 동기화 트리거 | +| **fetch 기본값** | `@ManyToOne` vs `@OneToMany` 무명시 시 | EAGER vs LAZY 차이 | +| `LazyInitializationException` | 트랜잭션 밖에서 LAZY 필드 접근 | 영속성 컨텍스트 닫힌 후 프록시 미초기화 | + +### 관찰 과제 1: 영속성 컨텍스트 신호 캡처 + +위 6개를 직접 만들어 기록에 남길수록 영속성 컨텍스트가 손에 잡힙니다. 관찰을 적을 때 다음 4가지가 함께 있으면 미션 끝난 후 가장 강한 회상 재료가 됩니다. + +``` +1. 시도한 코드 +2. 예측한 SQL/동작 +3. 실제 SQL/동작 +4. 왜 다른가 +``` + +예측과 실제 사이의 갭이 보이는 순간이 본 미션의 핵심 학습 신호입니다. + +> 관찰 과제 2(N+1과 fetch join 비교)는 3단계에서 본격적으로 등장합니다. 1단계에서는 LazyInit을 만나는 것으로도 영속성 컨텍스트의 경계가 보입니다. diff --git "a/docs/jpa-mission/2\353\213\250\352\263\204-\353\202\264-\354\230\210\354\225\275-\353\252\251\353\241\235-\354\241\260\355\232\214-\352\270\260\353\212\245.md" "b/docs/jpa-mission/2\353\213\250\352\263\204-\353\202\264-\354\230\210\354\225\275-\353\252\251\353\241\235-\354\241\260\355\232\214-\352\270\260\353\212\245.md" new file mode 100644 index 0000000000..ce734391f0 --- /dev/null +++ "b/docs/jpa-mission/2\353\213\250\352\263\204-\353\202\264-\354\230\210\354\225\275-\353\252\251\353\241\235-\354\241\260\355\232\214-\352\270\260\353\212\245.md" @@ -0,0 +1,30 @@ +## 5. 2단계 - 내 예약 목록 + +평가 대기 + +제출 완료2026. 6. 22. 제출 + +# 2단계: 내 예약 목록 조회 기능 + +내 예약 목록 조회 API를 추가합니다. **쿼리 메서드 도입의 벽**을 만나는 단계입니다. + +> **들어가기 전 자기 진단** + +Q. 내 예약 목록 조회를 풀 때, 메서드 이름 쿼리(\`findByMemberId\`)와 JPQL(\`@Query\`) 중 어느 쪽이 먼저 떠오르나요? 그 직감의 근거는 무엇인가요? + +처음에는 단순히 \`findByName\`으로 예약을 조회한 뒤 Java 코드에서 지난 예약을 제외하는 방식도 떠올릴 수 있다고 생각했습니다. 하지만 요구사항은 화면에 유효한 예약 목록만 보여주는 것이고, 유효 여부는 예약 날짜와 시간처럼 DB가 이미 가지고 있는 값으로 판단할 수 있습니다. 그래서 모든 예약을 가져와 애플리케이션에서 필터링하기보다는 DB에서 먼저 조건을 걸어 가져오는 편이 더 적절하다고 판단했습니다. 다만 이 조회는 단순히 이름으로만 찾는 문제가 아니었습니다. 오늘 이후 예약이거나, 오늘 예약이라면 현재 시간 이후인 예약만 조회해야 하므로 날짜와 시간 조건이 함께 필요했습니다. 또한 응답을 만들 때 예약의 날짜, 시간, 테마에 접근하므로 LAZY 연관관계에서 N+1이 발생할 수 있다는 점도 함께 고려해야 했습니다. 그래서 지금 기준으로는 조건의 의도를 명확히 드러내기 위해 JPQL이 먼저 떠오릅니다. 다만 JPQL을 쓴다고 N+1이 자동으로 해결되는 것은 아니므로, 응답 생성에 필요한 연관 객체는 \`@EntityGraph\`로 함께 로딩하는 방향이 더 적절하다고 생각합니다. + +## 요구사항 + +### API +- `GET /reservations-mine` — 본인의 예약 목록 + +### 확인 과제 + +Q. 메서드 이름 쿼리·JPQL 중 어느 것을 썼나요? + +저는 JPQL(\`@Query\`)과 \`@EntityGraph\`를 함께 사용했습니다. JPQL은 예약 날짜가 오늘 이후이거나, 오늘 예약이라면 현재 시간 이후인 예약만 조회한다는 조건을 명확하게 표현하기 위해 사용했습니다. 메서드 이름 쿼리로도 어느 정도 표현할 수 있지만, 날짜와 시간 조건이 함께 묶이는 순간 메서드명이 길어지고 의도가 흐려진다고 느꼈습니다. 반면 연관 엔티티 로딩은 JPQL의 \`join fetch\`가 아니라 \`@EntityGraph\`로 분리했습니다. 이 API의 응답에는 날짜, 시간, 테마 정보가 필요하므로 해당 연관 객체를 함께 로딩해야 합니다. 하지만 저는 조회 조건과 로딩 범위를 한 JPQL 안에 모두 넣기보다는, JPQL은 조건을 표현하고 \`@EntityGraph\`는 필요한 객체 그래프를 표현하도록 나누는 편이 동작 의도, 변경에 강한 구조, 가독성 측면에서 더 낫다고 판단했습니다. + +Q. 그 결정의 한계는 무엇인가요? + +이 결정의 한계는 JPQL과 \`@EntityGraph\`가 모두 문자열 기반이라는 점입니다. 엔티티 필드명이 변경되면 Java 코드처럼 컴파일 시점에 바로 잡히지 않고, 실행 시점이나 쿼리 검증 시점에 오류를 발견할 수 있습니다. 그럼에도 JPQL과 \`@EntityGraph\`를 함께 사용한 이유는 역할을 분리해서 읽을 수 있기 때문입니다. 복잡한 조건은 메서드 이름처럼 선형적인 구조보다 JPQL의 괄호와 조건식으로 표현하는 편이 이해하기 쉽다고 생각했습니다. 반면 N+1을 줄이기 위한 로딩 범위는 \`@EntityGraph\`로 분리하면, 조회 조건과 객체 로딩 책임을 따로 볼 수 있어 가독성이 좋아진다고 판단했습니다. 페이징이 필요해질 경우에는 다시 확인이 필요합니다. 현재처럼 \`ManyToOne\` 연관 객체를 함께 로딩하는 정도는 비교적 안전하지만, \`OneToMany\` 같은 컬렉션을 함께 로딩하면 row 중복과 count query 문제가 생길 수 있습니다. 따라서 페이징이 들어오면 실제 SQL과 count query를 확인하면서 조회 방식을 다시 점검해야 한다고 생각합니다. diff --git "a/docs/jpa-mission/3\353\213\250\352\263\204-\354\230\210\354\225\275-\353\214\200\352\270\260-\352\270\260\353\212\245.md" "b/docs/jpa-mission/3\353\213\250\352\263\204-\354\230\210\354\225\275-\353\214\200\352\270\260-\352\270\260\353\212\245.md" new file mode 100644 index 0000000000..10729fff34 --- /dev/null +++ "b/docs/jpa-mission/3\353\213\250\352\263\204-\354\230\210\354\225\275-\353\214\200\352\270\260-\352\270\260\353\212\245.md" @@ -0,0 +1,78 @@ +## 6. 3단계 - 예약 대기 + +평가 대기 + +제출 완료2026. 6. 22. 제출 + +# 3단계: 예약 대기 기능 + +예약 대기 요청·취소, 내 예약 목록 포함, 중복 방지, N번째 대기 표시. + +**이 미션의 학습 절정 — N+1과 JPQL을 새 도메인 위에서 본격적으로 만집니다.** + +> **들어가기 전 자기 진단** + +Q. 예약 대기 도메인을 별도 엔티티로 만들지, 기존 \`Reservation\`에 status 컬럼만 추가할지 — 첫 직감은 어느 쪽인가요? 그 직감의 근거는 무엇인가요? + +처음에는 \`Reservation\`에 \`status\` 컬럼을 추가하는 방식으로 접근했습니다. 예약과 예약 대기는 같은 테마·날짜·시간을 기준으로 동작하기 때문에 하나의 예약 흐름으로 볼 수 있다고 생각했기 때문입니다. 하지만 구현을 생각해보니 유니크 제약이 달랐습니다. 예약은 같은 테마·날짜·시간 슬롯이 중복되면 안 되지만, 예약 대기는 같은 슬롯이어도 사용자 이름이 다르면 여러 명이 대기할 수 있어야 합니다. 이 차이 때문에 예약 대기를 별도 도메인으로 분리하는 쪽을 생각했습니다. 부분 유니크 제약으로도 해결할 수 있다는 것을 알게 되었지만, DB마다 지원 방식이나 세부 동작이 달라질 수 있다고 판단했습니다. 그래서 당시에는 DB 의존적인 제약보다 도메인을 분리하는 방식이 더 명확하다고 봤습니다. 다만 지금 다시 생각해보면 현재 요구사항 수준에서는 \`status\`로 하나의 예약 안에서 다루는 방식이 더 적절했을 수 있다고 생각합니다. 예약 대기만의 다른 필드나 독립적인 정책이 더 도출된다면 분리하는 것이 맞지만, 지금처럼 예약과 대기가 거의 같은 정보를 공유한다면 하나의 모델로 관리하는 편이 구현 비용과 관리 비용 측면에서 더 낫다고 판단합니다. + +## 요구사항 + +### API +- `POST /waitings` — 예약 대기 요청 +- `DELETE /waitings/{id}` — 예약 대기 취소 +- `GET /reservations-mine` 응답에 예약 대기 목록 함께 포함 (status="N번째 예약대기") + +### 도메인 규칙 +- 같은 테마·날짜·시간에 **중복 예약 방지** +- **심화**: 내 예약 대기가 몇 번째인지 표시 +* * * + +## 3-1. N+1과 fetch join 본격 비교 (관찰 과제 2) + +새 도메인(`Waiting`)이 `Member`·`Theme`·`ReservationTime`을 모두 참조하면서 N+1이 자연스럽게 등장합니다. + +### 관찰 시나리오 +- `GET /reservations-mine`에서 본인 예약 N개 + 본인 대기 M개를 가져온 뒤, 각 항목의 `getTheme().getName()`·`getTime().getStartAt()`을 응답 DTO로 변환할 때 SQL이 몇 번 나가는가? +- 같은 작업을 fetch join 또는 `@EntityGraph`로 묶었을 때 SQL이 어떻게 달라지는가? +- join이 합쳐지면 row 중복은 어떻게 처리되는가? + +### 기록에 남길 것 + +두 SQL을 나란히 붙이는 것이 N+1과 fetch join의 차이를 가장 정직하게 보여줍니다. 다음 4가지가 함께 있으면 미션 끝난 후 회상 재료가 됩니다. + +``` +1. 시도한 코드 +2. 예측 SQL +3. 실제 SQL +4. 왜 다른가 +``` +* * * + +## 3-2. JPQL 본격 + +N번째 대기 계산은 메서드 이름 쿼리로 풀리지 않습니다. JPQL을 씁니다. + +```sql +SELECT new ...WaitingWithRank( + w, + (SELECT COUNT(w2) FROM Waiting w2 + WHERE w2.theme = w.theme + AND w2.date = w.date + AND w2.time = w.time + AND w2.id < w.id)) +FROM Waiting w +WHERE w.memberId = :memberId +``` + +LMS 힌트를 그대로 쓰거나 본인 작성으로 풀어도 됩니다. + +## 확인 과제 + +Q. JPQL이 발행하는 SQL을 적어주세요. + +예약 대기 순번 계산은 같은 테마·날짜·시간 슬롯 안에서 나보다 먼저 생성된 대기의 개수를 세고, 그 값에 1을 더하는 방식으로 풀었습니다. \`\`\`sql select wr1\_0.id, wr1\_0.created\_at, d3\_0.id, d3\_0.play\_day, wr1\_0.name, t5\_0.id, t5\_0.content, t5\_0.name, t5\_0.url, t6\_0.id, t6\_0.start\_at, (select (count(wr2\_0.id)+1) from waiting\_reservation wr2\_0 where wr2\_0.date\_id=wr1\_0.date\_id and wr2\_0.time\_id=wr1\_0.time\_id and wr2\_0.theme\_id=wr1\_0.theme\_id and ( wr2\_0.created\_at **들어가기 전 자기 진단**\*\***도달한 만큼을 봅니다.** 시간이 부족해 4단계까지 못 갔다면 PR 본문에 "어디까지 만들고 무엇이 남았는지"를 남깁니다. 수동 승인까지만 구현하고 자동 승인을 미완성으로 남기는 것도 한 선택입니다. + +Q. 자동 승인 로직을 어디에 둘 것인가요? (Service 메서드 한 줄 / 별도 도메인 이벤트 / 기타) 첫 직감과 그 직감의 근거를 적어주세요. + +처음에는 예약 취소와 예약 대기 자동 승격을 하나의 트랜잭션에서 처리해야 한다고 생각했습니다. 예약이 취소되어 슬롯이 비면 바로 다음 대기를 예약으로 승격해야 데이터가 일관된다고 봤기 때문입니다. 하지만 구현을 생각하면서 두 작업의 관심사가 다르다는 것을 느꼈습니다. 예약 취소는 사용자가 자신의 예약을 취소하는 행위이고, 예약 대기 승격은 빈 슬롯에 대해 다음 대기자를 예약으로 전환하는 후속 정책입니다. 이 둘을 강하게 묶으면 한쪽 실패가 다른 쪽 사용자 경험까지 망칠 수 있다고 생각했습니다. 예를 들어 예약 대기 승격 과정에서 문제가 생겨 예약 취소 자체가 실패하면, 사용자는 자신의 예약 취소가 왜 실패했는지 납득하기 어려울 수 있습니다. 이 문제를 줄이려면 예약 취소는 먼저 완료하고, 대기 승격은 비동기 이벤트 같은 후속 처리로 분리하는 방법을 생각해볼 수 있다고 느꼈습니다. 하지만 아직 그 방식을 어떻게 적용해야 하는지, 적용했을 때 생기는 중간 상태·재시도·중복 승격·동시성 문제를 어떻게 대비해야 하는지 명확히 알지 못합니다. 그래서 이번 구현에서는 단순성과 데이터 일관성을 우선해 \`ReservationService\`의 하나의 트랜잭션 안에서 처리했습니다. + +Q. 트랜잭션 경계는 어디까지 굳혀야 한다고 보나요? + +현재 구현에서는 예약 취소, 기존 예약 삭제, 다음 대기 조회, 예약 생성, 승격된 대기 삭제를 하나의 트랜잭션으로 묶었습니다. 이렇게 하면 빈 슬롯과 대기 상태가 중간에 어긋나지 않는다는 장점이 있습니다. 일부만 성공해서 예약은 취소됐는데 대기는 그대로 남거나, 예약은 생성됐는데 대기가 삭제되지 않는 상태를 막을 수 있습니다. 하지만 지금 생각으로는 이 경계가 항상 좋은 것은 아니라고 봅니다. 예약 취소와 대기 승격은 연결된 정책이지만 관심사는 다릅니다. 특히 알림이나 외부 API 호출처럼 실패 가능성이 높은 작업이 추가되면, 하나의 큰 트랜잭션 안에 묶는 방식은 사용자 경험과 장애 격리 측면에서 약해질 수 있습니다. 비동기 이벤트로 후속 처리를 분리하는 방법도 생각해보게 되었지만, 아직 적용 방법과 그로 인해 생기는 문제를 해결하는 방식까지 이해하지 못했습니다. 예약 취소 직후 대기 승격 전까지의 중간 상태, 승격 실패 시 재시도, 중복 승격 방지, 동시성 제어 같은 문제가 함께 따라올 것 같습니다. 그래서 이번 단계에서는 이 문제를 인식하는 데 그쳤고, 현재 코드에서는 단순성과 데이터 일관성을 우선해 하나의 서비스 트랜잭션 안에서 처리했습니다. + +## 요구사항 + +### 어드민 기능 +- 어드민이 **대기 목록 조회·취소** + +### 예약 대기 승인 — 수동 또는 자동 (둘 중 하나 선택) +- **수동**: 예약 대기 관리 페이지에서 승인 버튼 +- **자동**: 예약 취소 발생 시 우선순위 다음 대기를 자동 예약으로 전환 + +## 본질 신호 — 자동 승인을 한다면 만나야 할 것들 + +자동 승인을 시도한다면 다음 신호들을 의식적으로 보세요: +- **트랜잭션 경계**: `@Transactional` 한 메서드 안에서 처리할 것인가 vs 분리할 것인가? +- **동시성**: 같은 자리에서 두 사용자가 동시에 취소·승인 시 일관성은? +- **flush 순서**: 영속성 컨텍스트가 `Reservation`과 `Waiting` 두 도메인을 함께 바꿀 때, 어느 entity가 먼저 flush되는가? + +> 만난 신호를 기록에 남기면 자기점검과 JPA 설계 토론에서 그 신호가 가장 큰 결정 공유 재료가 됩니다. 깊이를 어디까지 갈지는 자율 — 다만 신호를 그냥 지나치면 4단계가 단순 기능 구현에 머무릅니다. + +## 확인 과제 + +Q. 자동 승인 로직의 위치는 어디인가요? (수동 승인까지만 구현했다면 "수동까지만"으로 답해주세요) + +자동 승인 로직은 \`ReservationService\` 안에 두었습니다. 예약 취소나 예약 수정으로 기존 슬롯이 비는 흐름 안에서 가장 오래된 예약 대기를 찾아 예약으로 승격시키는 방식입니다. 실제 처리는 \`promoteOldestWaiting\` 메서드에서 하고, 예약 생성과 승격된 대기 삭제까지 같은 서비스 트랜잭션 안에서 처리합니다. 이렇게 둔 이유는 자동 승격이 예약과 예약 대기 두 도메인을 함께 바꾸는 작업이기 때문입니다. 예약 취소 또는 수정으로 빈 슬롯이 생기고, 그 슬롯의 1순위 대기를 예약으로 전환한 뒤, 승격된 대기를 삭제해야 합니다. 따라서 일단은 애플리케이션 서비스 계층에서 두 Repository를 조율하는 것이 가장 단순하고 명확하다고 판단했습니다. 비동기 이벤트로 후속 처리를 분리하는 방법도 생각해볼 수 있지만, 아직 적용 방법과 그때 발생하는 중간 상태, 재시도, 중복 승격, 동시성 문제를 어떻게 해결해야 하는지 명확히 알지 못합니다. 그래서 이번 구현에서는 단순성과 데이터 일관성을 우선해 \`ReservationService\`의 트랜잭션 안에 자동 승인 로직을 두었습니다. + +Q. 그 결정의 한계는 무엇인가요? 트랜잭션 경계·동시성·일관성 중 어느 것이 가장 약한가요? + +가장 약한 지점은 동시성과 실패 시 사용자 경험이라고 생각합니다. 현재 구현은 하나의 트랜잭션 안에서 처리하므로 단일 요청 안의 DB 일관성은 비교적 지킬 수 있습니다. 하지만 여러 요청이 동시에 들어오는 상황까지 충분히 방어하고 있는지는 확신하기 어렵습니다. 예를 들어 예약 취소와 예약 대기 신청이 같은 슬롯에 대해 거의 동시에 들어오면 어떤 순서로 처리되는지에 따라 상태가 어긋날 수 있다고 봅니다. 정확한 실패 케이스를 아직 모두 정리하지는 못했지만, 예약은 사라졌는데 예약 대기는 남아 있거나, 빈 슬롯인데 대기만 존재하는 식의 상태가 생길 가능성을 의심하고 있습니다. 특히 예약과 예약 대기가 하나의 상태 모델이 아니라 별도 데이터로 움직이기 때문에, 동시성 상황에서 두 데이터의 관계를 더 신중하게 보호해야 한다고 느꼈습니다. 또한 자동 승격 과정에서 문제가 생기면 예약 취소 자체가 실패할 수 있습니다. 사용자 입장에서는 예약을 취소하려는 요청이 대기 승격 실패 때문에 실패하는 경험이 될 수 있어서 좋지 않습니다. 비동기 이벤트로 분리하면 이런 UX 문제를 줄일 수 있을 것 같지만, 그 경우에는 중간 상태, 재시도, 중복 승격 방지 같은 문제를 해결해야 합니다. 현재는 그 해결 방법을 명확히 모르기 때문에 이번 구현에서는 단순한 트랜잭션 방식을 선택했고, 동시성과 실패 경험을 한계로 남겼습니다. + +> 단계가 일찍 끝났다면 — 부수 도구(Querydsl 등)로 넘어가는 것도 한 선택이지만, 본인 코드의 이전 단계 결정 1개를 다시 만지는 쪽이 이 미션이 보려는 JPA 본질에 가깝습니다. 같은 깊이에서 폭을 늘리는 심화. +* * * + +## PR 제출 (목 18:00 마감) + +본 미션의 단일 산출물입니다. **0~4단계를 통합한 PR 1회**를 본인의 `spring-roomescape-waiting` 저장소에서 엽니다. + +> **도달한 만큼을 봅니다.** 4단계까지 못 갔어도 PR 본문에 "어디까지 만들고 무엇이 남았는지"가 적혀 있으면 충분합니다. 미완을 부끄러워하지 마세요 — 미완을 정확히 적는 것이 이번 미션의 핵심 신호입니다. + +> **PR 본문 권장 구성**: 단계별 도달 지점 / 발행 SQL 발췌 / 망설인 결정 1~2개 / 흔들린 한 장면. JPA 설계 토론에서 동료들에게 결정 공유 재료가 됩니다. + +Q. 본인 PR URL을 적어주세요. (예: \`https://github.com/woowacourse/spring-roomescape-waiting/pull/123\`) + +https://github.com/woowacourse/spring-roomescape-waiting/pull/590 + +Q. PR에서 어디까지 도달했나요? 한 줄로 적어주세요. (예: "3단계까지 + 4단계 수동 승인까지", "4단계 자동 승인 미완성") + +0단계입니다. diff --git "a/docs/jpa-mission/JPA-\354\204\244\352\263\204-\355\206\240\353\241\240.md" "b/docs/jpa-mission/JPA-\354\204\244\352\263\204-\355\206\240\353\241\240.md" new file mode 100644 index 0000000000..a41e9fa784 --- /dev/null +++ "b/docs/jpa-mission/JPA-\354\204\244\352\263\204-\355\206\240\353\241\240.md" @@ -0,0 +1,48 @@ +## 8. JPA 설계 토론 + +# JPA 설계 토론 + +학습법 코칭이 아니라 **JPA 설계 결정**을 동료들과 함께 들여다보는 자리입니다. 미션은 혼자 진행하지만, 결정의 한계는 혼자 보기 어렵기 때문에 동료의 시선이 가장 정직한 회수 채널이 됩니다. + +**일시**: 06/19 (금) 10시 40분 ~ 11시 30분 (약 50분, PR 제출 다음 날) +**장소**: 강의장 (오프라인) + +## 그룹 구성 + +| 운영 방식 | +| --- | +| 3~4명씩 자율로 그룹 편성 | + +> 그룹은 앉은 자리를 기반으로 평소 대화를 많이 하지 않았던 크루들과 진행해 주세요. + +## 진행 방식 (약 50분) + +| 활동 | 시간 | 내용 | +| --- | --- | --- | +| 결정 공유 | ~30분 | 각자 자기 코드의 핵심 결정 3개 공유. 인원별 1인당 시간 분배 — 3명: ~10분 / 4명: ~7분 | +| 한계 묻기 | ~15분 | 동료는 "그 결정의 한계는?", "다른 선택을 했을 때 무엇이 망가지나?"만 묻습니다 | +| 마무리 | ~5분 | "다음에 같은 결정을 한다면 무엇을 바꿀까" 1개씩 적습니다. 이 메모가 자기점검의 핵심 재료가 됩니다 | + +## 토론 톤 +- 동료의 코드를 **평가하는 자리가 아닙니다.** 결정의 근거와 한계를 함께 들여다보는 자리입니다. +- 동료에게 **"정답"을 주려고 하지 않아도 됩니다.** 본인이 다음에 시도할 변경은 본인이 정합니다. +- 답이 안 떠오르는 결정이 있다면 동료에게 먼저 물어봐도 됩니다. 그게 토론이 시작되는 지점입니다. + +## 준비물 +- **본인 PR 본문** (목 18 : 00에 제출한 PR. "내가 망설인 결정"이 이미 준비된 재료) +- **결정 공유 양식** 3개 정도 (아래 참고) + +## 결정 공유 — 참고 양식 + +각자 3개 정도의 결정을 준비해 옵니다. 다음 항목이 함께 있으면 동료가 한계를 묻기 쉽습니다. + +```markdown +## 결정 #N (예: 자동 승인 로직 위치) +- 선택한 것: +- 비교한 대안: +- 선택의 비교 기준: +- 이 선택의 한계 / 다음에 망가질 수 있는 지점: +- 동료에게 묻고 싶은 것 (1개): +``` + +> 준비 시점: PR 제출(목 18시 00분) 직후. PR 본문의 "내가 망설인 결정"이 이미 준비된 재료가 됩니다. diff --git "a/docs/jpa-mission/\354\202\254\354\240\204\355\225\231\354\212\265.md" "b/docs/jpa-mission/\354\202\254\354\240\204\355\225\231\354\212\265.md" new file mode 100644 index 0000000000..8ceca9600a --- /dev/null +++ "b/docs/jpa-mission/\354\202\254\354\240\204\355\225\231\354\212\265.md" @@ -0,0 +1,50 @@ +## 2. 사전학습 + +# 사전학습 + +미션을 시작하기 전, **혼자 읽고 정리하는 단계**입니다. 머릿속에 "JPA가 푸는 문제"의 윤곽을 만드는 것이 목적입니다. 깊이는 미션 진행 중에 만들어집니다. + +## 미리 보고 오기 +- [\[코즈의 ORM vs SQL Mapper vs JDBC\](https://youtu.be/mezbxKGu68Y)](https://youtu.be/mezbxKGu68Y) +- [\[아마찌의 ORM vs SQL Mapper vs JDBC\](https://youtu.be/VTqqZSuSdOk)](https://youtu.be/VTqqZSuSdOk) + +## 이번 사이클에서 답할 질문 + +영상을 본 후 다음 4가지 질문에 본인이 답해봅니다. 정답을 찾는 것이 아니라, 미션을 시작할 때 "이게 답이었어?"를 비교할 기준점을 만드는 것입니다. +- JPA는 JdbcTemplate에 비해 **무엇을 자동화**하고, 그 대가로 **무엇을 감추는가**? +- 영속성 컨텍스트가 켜져 있다는 것을 코드의 어느 시점에 의식해야 하는가? +- 미션1에서 SQL로 풀었던 join을, 객체 그래프로 옮기면 어디에 부담이 옮겨가는가? +- 어노테이션 한 줄이 만드는 **실제 SQL**을 추적할 수 있는가? + +## 사전 학습 키워드 + +키워드를 검색해 1줄 정의를 만들고, "이게 미션1 어디에 해당하는가?"를 메모합니다. + +| 목적 | 키워드 | +| --- | --- | +| 패러다임 차이 | ORM, 객체-관계 임피던스 불일치, 영속성 | +| 핵심 개념 | 영속성 컨텍스트, 1차 캐시, dirty checking, 쓰기 지연, flush | +| 매핑 | `@Entity`, `@Id`, `@GeneratedValue`, `@Column`, `@Table` | +| 연관관계 | `@ManyToOne`, `@OneToMany`, 단/양방향, 연관관계 주인, cascade, orphanRemoval | +| 페치 | EAGER, LAZY, fetch join, `@EntityGraph` | +| 쿼리 | JPQL, Native Query | + +> **키워드 사용법**: 키워드 검색 → 1줄 정의 → "이게 미션1 어디에 해당하는가?"를 메모. 깊이는 미션 중에 만들어집니다. + +## 학습 테스트 (참조 매뉴얼) + +[스프링 학습 테스트 저장소](https://github.com/cho-log/spring-learning-test)의 두 모듈이 본 미션과 직접 맞물립니다. +- `spring-data-jpa-1` — Entity·Repository·기본 CRUD → 1단계 매핑 +- `spring-data-jpa-2` — 연관관계·JPQL → 1단계 연관관계 + 3단계 JPQL + +미션 진행 중 막히는 지점에서 **참조 매뉴얼처럼** 활용합니다. 처음부터 끝까지 따라할 필요 없습니다. + +## 미션1 Repository 코드 다시 읽기 + +본격 시작 전에 **본인의 미션1 4단계 코드**를 한 번 다시 읽고 다음 메모를 남깁니다. +- `JdbcTemplate`이 직접 다루는 SQL은 몇 종류인가? (insert/select/update/delete/join) +- 도메인 객체와 테이블의 관계가 1 : 1인가? +- 연관 데이터를 가져올 때 join을 SQL에 박아두었나, 두 번 조회했나? +- 만약 이걸 객체 그래프(`reservation.getTime().getStartAt()`)로 표현한다면 어느 코드가 사라질까? + +> 이 메모가 **0단계·1단계의 시작점**이 됩니다. 학습 로그 첫 줄로 남기면 좋습니다. diff --git "a/docs/jpa-mission/\354\204\244\353\254\270.md" "b/docs/jpa-mission/\354\204\244\353\254\270.md" new file mode 100644 index 0000000000..0119d828f6 --- /dev/null +++ "b/docs/jpa-mission/\354\204\244\353\254\270.md" @@ -0,0 +1,68 @@ +## 10. 사후 설문 + +제출 필요 + +# 설문 + +> 📌 **응답 시점 안내** +> +> - **사후 설문**: 자기점검 작성 후(다음 주 월 마감). 자기점검 4영역을 다 채운 직후 그 자리에서 응답하면 가장 정확합니다. +> +> 동시 공개되어 있지만, **시점을 지켜야 본인의 변화를 정확히 측정**할 수 있어요. 사전 응답을 적어둔 자리에 사후가 함께 보입니다. +* * * + +## 사후 설문 + +미션 종료 후, 자기점검 4영역을 작성한 직후 응답해주세요. 그 자리에서 떠오른 그대로 적는 게 가장 정확합니다. + +Q. 사전 설문에서 적은 미션 목표를 얼마나 달성했나요? 잘 된 부분과 아쉬운 부분을 적어주세요. + +사전 목표였던 "JPA가 무엇을 자동화하고 왜 사용하는지 체감하기"는 어느 정도 달성했다고 생각합니다. 처음에는 JPA가 단순히 SQL 작성을 줄여주는 도구라고만 막연하게 생각했습니다. 미션을 진행하면서 JPA가 SQL 작성, 파라미터 바인딩, 객체 조립을 자동화해준다는 점은 체감했습니다. + +하지만 그 대가로 실제 SQL 실행 시점, fetch 전략, 영속성 컨텍스트, flush 순서를 계속 의식해야 한다는 것도 알게 되었습니다. 특히 예약 취소 후 대기 승격 과정에서 UNIQUE 제약 충돌을 만나며, JPA에서는 메서드 호출 순서와 실제 SQL 실행 순서가 항상 같지 않다는 점을 강하게 느꼈습니다. + +잘 된 부분은 JPQL, EntityGraph, LAZY, flush 같은 키워드를 내 코드의 실제 상황과 연결해 설명할 수 있게 된 점입니다. 아쉬운 부분은 아직 cascade, orphanRemoval, 동시성 제어, 비동기 이벤트와 트랜잭션 분리 같은 부분은 실제 SQL과 설계 기준까지 명확히 설명하기 어렵다는 점입니다. + +526/5000자 + +Q. 0~4단계 중 어디까지 도달했나요? 그리고 어디서 가장 오래 머물렀나요? + +4단계 자동 승인까지 도달했습니다. JdbcTemplate 기반 Repository를 JPA Repository로 전환했고, `Reservation`, `ReservationDate`, `ReservationTime`, `Theme`, `WaitingReservation`을 JPA 엔티티로 매핑했습니다. 내 예약 목록 조회와 예약 대기 순번 조회에서는 JPQL과 `@EntityGraph`를 사용했고, 예약 취소/수정 시 같은 슬롯의 가장 오래된 예약 대기를 자동 승격하도록 구현했습니다. + +가장 오래 머문 지점은 2단계와 4단계였습니다. 2단계에서는 복잡한 조회 조건을 JPQL로 표현할지, N+1 대응을 `join fetch`로 할지 `@EntityGraph`로 할지 오래 고민했습니다. 최종적으로 JPQL은 조회 조건에 집중하고, EntityGraph는 로딩 범위를 표현하도록 분리했습니다. + +4단계에서는 자동 승격을 하나의 트랜잭션으로 처리할지, 비동기 이벤트 같은 후속 처리로 분리할지 고민했습니다. 사용자 경험만 보면 분리하고 싶었지만, 후속 처리 실패 시 예약과 대기 상태를 어떻게 일관되게 유지할지 아직 설명하기 어려워 이번에는 단순성과 데이터 정합성을 우선해 하나의 트랜잭션 안에서 처리했습니다. + +552/5000자 + +Q. 자기점검 4영역(매핑 정확성 / 설계 판단 / 피드백 순환 / 종합) 중 본인이 가장 약하다고 느낀 영역은 어디인가요? 그 근거는? + +가장 약하다고 느낀 영역은 매핑 정확성입니다. 미션을 진행하며 `@Id`, `@GeneratedValue`, `@ManyToOne`, `@JoinColumn`, `@Table`, `@EntityGraph`가 어떤 DB 개념과 연결되는지는 조금 보이기 시작했습니다. 하지만 어노테이션 한 줄을 보고 실제 SQL이 구체적으로 바로 떠오르는 수준은 아직 아닙니다. + +특히 `cascade`, `orphanRemoval`, `OneToMany` 컬렉션 연관관계, count query는 아직 흐릿합니다. 부모 엔티티의 작업을 연관 엔티티에 전파하거나, 관계에서 제거된 자식을 삭제한다는 개념은 알겠지만, 실제 도메인에서 언제 적용해야 하고 어떤 SQL이 발생하며 잘못 적용하면 어떤 데이터 문제가 생기는지는 아직 구체적으로 설명하기 어렵습니다. + +설계 판단은 인터뷰와 기록을 통해 어느 정도 언어화할 수 있었지만, 그 판단의 근거가 되는 매핑과 SQL을 더 정확히 추적하는 능력은 아직 부족하다고 느꼈습니다. + +427/5000자 + +Q. 다음에 JPA를 다시 만난다면 가장 먼저 시도하고 싶은 것 1개는 무엇인가요? + +가장 먼저 시도하고 싶은 것은 JPA의 책임에 해당하는 테스트가 무엇인지 직접 구분해서 작성해보는 것입니다. + +JdbcTemplate을 사용할 때는 Repository가 SQL 작성, 파라미터 바인딩, RowMapper 조립을 직접 책임졌기 때문에 Repository 테스트의 목적이 비교적 명확했습니다. 하지만 JPA로 전환하면 `save`, `findById`, `delete` 같은 기본 제공 메서드는 내가 다시 테스트할 대상이 아니라는 생각이 들었습니다. + +대신 내가 선언한 엔티티 매핑, 유니크 제약, 직접 작성한 JPQL, `@EntityGraph`가 실제 SQL과 로딩 결과로 어떻게 이어지는지를 확인하는 테스트가 필요하다고 느꼈습니다. 다음에는 단순 CRUD가 아니라, JPA에서 내가 책임지는 매핑과 조회 의도를 검증하는 테스트를 먼저 시도해보고 싶습니다. + +382/5000자 + +Q. 코치에게 전하고 싶은 피드백이 있다면 자유롭게 적어주세요. + +이번 미션은 JPA를 처음 접하는 입장에서 단순히 기능을 구현하는 것보다, 내가 작성한 코드가 실제 어떤 SQL로 이어지는지 계속 확인하게 만든 점이 좋았습니다. 특히 JdbcTemplate에서 직접 작성하던 SQL과 RowMapper가 JPA에서는 엔티티 매핑, fetch 전략, 영속성 컨텍스트로 옮겨간다는 점을 체감할 수 있었습니다. + +다만 처음에는 어떤 것을 Repository 테스트로 확인해야 하고, 어떤 것은 JPA가 보장한다고 보고 넘어가도 되는지 기준이 흐릿했습니다. Spring Data JPA 기본 메서드는 다시 테스트하지 않는 것이 자연스럽다고 느꼈지만, 내가 선언한 매핑, JPQL, EntityGraph, 유니크 제약은 어느 계층에서 어떤 방식으로 확인하는 것이 좋은지 아직 기준이 명확하지 않습니다. + +코치에게는 `@EntityGraph`와 `join fetch` 선택 기준, 자동 승격 같은 후속 처리를 하나의 트랜잭션으로 묶는 판단의 한계, 그리고 JPA Repository 테스트 범위에 대해 피드백을 받고 싶습니다. + +471/5000자 + +저장 후 제출 버튼을 눌러 최종 제출하세요. 제출 후에는 수정할 수 없습니다. diff --git "a/docs/jpa-mission/\354\236\220\352\270\260\354\240\220\352\262\200,-\354\202\254\355\233\204\354\204\244\353\254\270.md" "b/docs/jpa-mission/\354\236\220\352\270\260\354\240\220\352\262\200,-\354\202\254\355\233\204\354\204\244\353\254\270.md" new file mode 100644 index 0000000000..b1c2c04178 --- /dev/null +++ "b/docs/jpa-mission/\354\236\220\352\270\260\354\240\220\352\262\200,-\354\202\254\355\233\204\354\204\244\353\254\270.md" @@ -0,0 +1,92 @@ +## 9. 자기점검, 사후질문 + +# 자기점검, 사후설문 + +미션이 끝날 때 한 번 작성합니다. **채점이 아니라 자기 인식을 위한 자리**입니다. + +이 자기점검은 **JPA 자체에 집중**합니다. JPA를 만지며 무엇을 보았고 무엇을 결정했고 어떤 채널로 검증했는지만 묻습니다. + +## 사용 안내 + +| 항목 | 권장 | +| --- | --- | +| 작성 시점 | JPA 설계 토론 후 (금) → 토·일·다음 주 월 작성 → **다음 주 월 18 : 00 마감** | +| 작성 시간 | 40~60분 | +| 작성 전 준비 | 본인 PR 본문 + JPA 설계 토론에서 받은 한계 지적 + 본인 기록을 한 번 훑는다. 토론에서 받은 한계 지적이 피드백 순환의 재료. | +| 답안 길이 | 자율. 한 줄짜리 답이 나오는 칸은 본인이 가장 약한 곳을 짚는 신호로 쓸 수 있습니다 | +* * * + +## 1. JPA 매핑 정확성 점검 + +> "내 코드가 발행하는 SQL을 나는 얼마나 알고 있는가?" + +| 질문 | 답 | +| --- | --- | +| 가장 자신 있게 설명할 수 있는 매핑 결정 1개와 그 결정이 발행하는 SQL은? | 가장 자신 있게 설명할 수 있는 매핑 결정은 `WaitingReservation`을 JPA 엔티티로 전환하고, `ReservationDate`, `ReservationTime`, `Theme`를 `LAZY`로 참조하게 한 뒤, 예약 대기 순번 조회에서 `@EntityGraph`를 사용한 결정입니다. 예약 대기 순번은 단순 조회가 아니라 같은 테마·날짜·시간 슬롯 안에서 나보다 먼저 생성된 대기의 개수를 세는 문제였습니다. 그래서 JPQL의 `count + 1` 서브쿼리로 순번을 계산했습니다. 또 응답 DTO를 만들 때 날짜, 시간, 테마 정보가 필요하므로, LAZY 연관관계를 그대로 두면 DTO 변환 과정에서 N+1이 발생할 수 있습니다. 이를 막기 위해 해당 조회에 `@EntityGraph(attributePaths = {"date", "time", "theme"})`를 적용했습니다. 실제 SQL에서는 `waiting_reservation`을 기준으로 조회하면서, 같은 슬롯의 앞선 대기를 세는 `count + 1` 서브쿼리가 발행됐고, `reservation_date`, `reservation_time`, `theme`도 함께 join되는 것을 확인했습니다. | +| 미션 중 "예측한 SQL"과 "실제 발행된 SQL"이 다른 순간이 있었는가? 어디였고 왜 달랐는가? | 예측한 SQL과 실제 발행 SQL이 달랐던 순간은 두 가지가 기억납니다. 첫 번째는 예약 취소 후 예약 대기를 자동 승격하는 흐름이었습니다. 처음에는 예약을 삭제한 뒤 같은 슬롯의 대기를 예약으로 저장하면 DELETE 이후 INSERT 순서로 SQL이 실행될 것이라고 생각했습니다. 하지만 JPA에서는 변경 작업이 영속성 컨텍스트에 모였다가 flush 시점에 반영되고, Hibernate의 실행 순서에 따라 INSERT가 먼저 나가면서 기존 예약의 UNIQUE 제약과 충돌할 수 있었습니다. 그래서 예약 삭제 후 `reservationRepository.flush()`를 호출해 기존 슬롯이 먼저 DB에 반영되도록 조정했습니다. 두 번째는 `@ManyToOne(fetch = LAZY)` 접근이었습니다. 처음에는 `Reservation`을 조회하면 날짜, 시간, 테마도 함께 조립될 수 있다고 생각했지만, 실제로는 `reservation`만 먼저 조회되고 `getTime().getStartAt()`처럼 연관 객체의 실제 값에 접근할 때 추가 SELECT가 발생했습니다. LAZY 연관관계는 id를 가진 프록시로 들고 있다가 실제 필드 접근 시 초기화된다는 점을 확인했습니다. | +| `@ManyToOne`의 fetch 기본값과 `@OneToMany`의 fetch 기본값이 만든 차이를 직접 본 적이 있는가? | 기본값 자체를 직접 비교 실험하지는 못했습니다. 다만 `@ManyToOne`의 기본값이 `EAGER`이고, `@OneToMany`의 기본값이 `LAZY`라는 것을 알게 되었습니다. 처음에는 `ManyToOne`의 기본값이 EAGER라는 점이 이상하다고 느꼈지만, 단일 참조 객체라는 점을 생각하면 완전히 부자연스러운 선택은 아니라고 생각했습니다. `ManyToOne`은 join해도 row 수가 늘어나지 않고, 참조 객체가 함께 필요한 경우도 많기 때문입니다. 다만 EAGER는 특정 조회가 아니라 매핑 전체에 적용되는 전역 규칙입니다. 모든 예약 조회에서 날짜, 시간, 테마 상세 정보가 필요한 것은 아니므로, 이번 미션에서는 `ManyToOne`도 LAZY로 명시했습니다. 필요한 조회에서는 `@EntityGraph`를 사용해 로딩 범위를 명확히 지정하는 편이 더 안전하다고 판단했습니다. | +| 미션을 마친 지금, 어노테이션 한 줄을 보고도 그게 만들 SQL의 모양이 떠오르는가? 떠오르는 어노테이션 / 안 떠오르는 어노테이션을 구분해본다. | 어노테이션을 보고 SQL이 구체적으로 바로 떠오르는 수준까지는 아직 아닙니다. 다만 잘 모르는 상태에서 지금은 다음 정도로 정리하고 있습니다. `@Entity`는 이 클래스가 테이블과 매핑되는 엔티티라는 선언이고, DDL 자동 생성 시 테이블 생성 대상이 됩니다. `@Id`는 PK 컬럼입니다. `@GeneratedValue(strategy = IDENTITY)`는 id 생성을 DB에 맡기는 방식이고, 그래서 insert 시점이 앞당겨질 수 있습니다. `@ManyToOne`은 FK를 가진 쪽의 객체 참조이고, SQL로는 보통 FK 컬럼과 join 조건으로 연결됩니다. `@JoinColumn`은 FK 컬럼명을 지정합니다. `@Table`은 테이블 이름이나 unique 제약 같은 테이블 수준 설정입니다. `@EntityGraph`는 이번 조회에서 어떤 연관 엔티티까지 함께 로딩할지 지정하는 로딩 계획으로 이해했습니다. `cascade`는 부모 엔티티에 수행한 persist/remove 같은 작업을 연관 엔티티에도 전파할지 정하는 옵션이고, `orphanRemoval`은 부모와의 관계에서 제거된 자식을 DB에서도 삭제할지 정하는 옵션으로 이해했습니다. 아직 `cascade`, `orphanRemoval`, 컬렉션 연관관계, count query는 실제 SQL까지 연결하는 데 흐릿함이 남아 있습니다. | + +> 한 줄짜리 답이 나온다면 — 기록을 다시 펴서 발행 SQL 발췌 1개를 떠올려보세요. 그 발췌가 다음에 JPA를 만질 때 가장 강한 회상 재료가 됩니다. +* * * + +## 2. 설계 판단 점검 + +> "왜 이렇게 만들었는가? 다른 길은 무엇이었는가?" + +| 질문 | 답 | +| --- | --- | +| 가장 오래 망설였던 결정은? (단/양방향 / EAGER vs LAZY / cascade 범위 / fetch join vs `@EntityGraph` / JPQL vs 메서드 쿼리 / 예약 대기 도메인 분리 방식 / 자동 승인 로직 위치 등) | 가장 오래 망설였던 결정은 두 가지입니다. 첫 번째는 N+1을 줄이기 위해 `join fetch`를 사용할지 `@EntityGraph`를 사용할지였습니다. 이 결정은 제가 중요하게 보는 소프트웨어 품질 기준과 연결되어 있었습니다. 저는 코드가 올바르게 동작해야 하고, 변경에 강한 구조여야 하며, 읽기 좋아야 한다고 생각합니다. `join fetch`도 N+1을 줄일 수 있지만, 조회 조건과 로딩 범위가 한 JPQL 안에 섞이면 읽는 사람이 조인의 목적을 계속 구분해야 한다고 느꼈습니다. 그래서 JPQL은 조회 조건과 순번 계산에 집중하고, 로딩 범위는 `@EntityGraph`로 분리하는 편이 가독성과 변경 대응 측면에서 더 낫다고 판단했습니다. 두 번째는 예약 취소/수정 시 예약 대기 자동 승격을 하나의 트랜잭션 안에서 처리할지, 비동기 이벤트 같은 후속 처리로 분리할지였습니다. 자동 승격 실패 때문에 예약 취소가 실패하는 사용자 경험은 좋지 않다고 느꼈습니다. 하지만 비동기 처리로 분리했을 때 후속 처리가 실패하거나 늦게 처리되면 예약과 대기 상태를 어떻게 일관되게 유지할지 아직 명확히 설명할 수 없었습니다. 그래서 이번 구현에서는 데이터 정합성을 가장 단순하게 보장할 수 있는 하나의 트랜잭션 방식을 선택했습니다. | +| 그 결정에서 비교한 대안 2개 이상과 비교 기준은? | 첫 번째 결정에서는 `join fetch`, `@EntityGraph`, DTO projection을 비교했습니다. 비교 기준은 올바른 동작, 변경에 강한 구조, 가독성이었습니다. `join fetch`는 SQL 의도가 직접 드러나는 장점이 있지만 조회 조건과 로딩 범위가 한 쿼리 안에 섞입니다. `@EntityGraph`는 실제 SQL을 로그로 확인해야 하지만, 조건과 로딩 책임을 분리해 읽을 수 있다는 장점이 있었습니다. DTO projection은 조회 화면에는 효율적일 수 있지만, 이번 단계에서는 엔티티 그래프와 N+1을 관찰하는 학습 목적이 더 컸습니다. 두 번째 결정에서는 하나의 트랜잭션에서 자동 승격을 처리하는 방식과 비동기 이벤트로 후속 처리하는 방식을 비교했습니다. 비교 기준은 데이터 정합성, 사용자 경험, 구현 복잡도, 실패 상황을 내가 설명할 수 있는지였습니다. 비동기 이벤트는 사용자 경험과 장애 격리 측면에서 장점이 있을 수 있지만, 후속 처리 실패 시 예약과 대기 상태를 어떻게 일관되게 유지할지 아직 설명하기 어려웠습니다. 그래서 이번에는 하나의 트랜잭션에서 처리하는 방식을 선택했습니다. | +| 그 선택의 가장 큰 단점·한계는? | 가장 큰 한계는 자동 승격을 하나의 트랜잭션 안에 묶은 결정이라고 생각합니다. 이 선택은 단일 요청 안에서 데이터 정합성을 지키기 쉽다는 장점이 있지만, 자동 승격 실패가 예약 취소 실패로 이어질 수 있습니다. 사용자 입장에서는 예약 취소를 요청했는데 내부의 대기 승격 실패 때문에 취소가 실패하는 경험이 될 수 있습니다. 또한 동시성 상황에서도 충분히 안전한지 확신하기 어렵습니다. 예약 취소, 예약 대기 신청, 자동 승격이 같은 슬롯에서 동시에 일어나면 예약과 대기 상태가 어긋날 수 있다고 의심하고 있습니다. 나중에 알림이나 외부 서비스 연동이 붙으면 하나의 트랜잭션 안에 묶는 방식은 더 부담스러워질 것 같습니다. | +| 미션 중 한 번이라도 결정을 바꾼(롤백한) 적이 있는가? 어떤 신호 때문이었는가? | 결정을 바꾼 지점은 두 가지가 있습니다. 첫 번째는 예약 대기 모델링입니다. 처음에는 `Reservation.status`로 예약과 대기를 하나의 모델에서 다루는 방식이 자연스럽다고 생각했습니다. 하지만 예약은 같은 슬롯에 하나만 존재해야 하고, 예약 대기는 같은 슬롯이어도 사용자 이름이 다르면 여러 명이 존재할 수 있어서 유니크 제약이 다르다는 점을 보았습니다. 이 신호 때문에 별도 `WaitingReservation`을 유지하는 쪽으로 구현했습니다. 다만 미션을 진행하며 다시 생각해보니 현재 요구사항만 보면 `status` 통합도 더 단순했을 수 있다고 판단하게 되었습니다. 두 번째는 삭제 방식입니다. 처음에는 예약 대기 삭제를 `deleteById` 결과 count로 확인하는 방식으로 생각했습니다. 하지만 JPA Repository로 전환하고 나니 `Reservation`과 일관되게 `findById`로 존재 여부를 검증한 뒤 `delete(entity)`를 호출하는 방식이 더 자연스럽다고 느꼈습니다. 그래서 예약 대기 취소도 같은 방식으로 변경했습니다. 또 JdbcTemplate을 사용할 때와 JPA를 사용할 때 Repository 테스트의 초점이 달라진다고 느꼈습니다. JdbcTemplate Repository에서는 SQL 작성, 파라미터 바인딩, ResultSet을 도메인 객체로 조립하는 RowMapper, 생성 키 추출, 삭제 row count 확인을 직접 구현했습니다. 따라서 Repository 테스트에서는 내가 작성한 SQL이 원하는 데이터를 조회하는지, 파라미터가 올바르게 바인딩되는지, RowMapper가 연관 객체까지 제대로 조립하는지 확인하는 것이 중요했습니다. 반면 JPA Repository로 전환한 뒤에는 `save`, `findById`, `delete` 같은 기본 제공 메서드를 내가 직접 구현하지 않습니다. 이 메서드들은 Spring Data JPA와 Hibernate가 제공하는 기능이므로, 이를 다시 테스트하는 것은 우선순위가 낮다고 느꼈습니다. 대신 JPA Repository에서는 내가 직접 작성한 JPQL, `@EntityGraph`를 적용한 조회, 메서드 이름 쿼리로 표현한 조건, 엔티티 매핑과 유니크 제약이 의도한 SQL과 DB 구조로 이어지는지를 확인하는 것이 더 중요하다고 생각했습니다. 즉 JdbcTemplate에서는 직접 작성한 SQL과 매핑 코드를 테스트하고, JPA에서는 내가 선언한 매핑과 조회 의도가 실제 SQL로 어떻게 변환되는지를 확인해야 한다고 정리했습니다. | +| 방탈출 미션 JdbcTemplate 시절의 결정과 이번 JPA 결정 중에서, **같은 문제를 다르게 푼 지점**이 있다면? | 첫 번째는 연관 데이터 조회입니다. JdbcTemplate 시절에는 예약을 조회할 때 SQL에서 `reservation_date`, `reservation_time`, `theme`를 직접 join하고, RowMapper에서 `ReservationDate`, `ReservationTime`, `Theme` 객체를 직접 조립했습니다. JPA로 전환한 뒤에는 `Reservation`이 `@ManyToOne`으로 날짜, 시간, 테마를 참조하게 하고, 기본적으로는 LAZY로 둔 뒤 필요한 조회에서 `@EntityGraph`로 함께 로딩하도록 바꿨습니다. 즉 예전에는 SQL과 RowMapper가 객체 그래프를 직접 만들었고, 지금은 엔티티 매핑과 fetch 전략이 그 책임을 나눠 갖게 되었습니다. 두 번째는 삭제와 생성의 반영 시점입니다. JdbcTemplate에서는 DELETE SQL을 호출하면 바로 DB에 반영된다고 생각할 수 있었습니다. 하지만 JPA에서는 삭제와 생성이 영속성 컨텍스트에 모였다가 flush 시점에 DB와 동기화됩니다. 예약 취소 후 같은 슬롯의 대기를 승격할 때, 삭제보다 INSERT가 먼저 처리되면 UNIQUE 제약과 충돌할 수 있다는 것을 보았습니다. 그래서 JPA에서는 단순히 메서드 호출 순서만 볼 것이 아니라 flush 시점과 실제 SQL 실행 순서까지 의식해야 한다는 점이 달랐습니다. | +| 자동 승인을 시도했다면 — 트랜잭션 경계·동시성·일관성 중 어느 것이 가장 약하다고 느꼈는가? | 가장 약한 지점은 동시성이라고 느꼈습니다. 현재 구현은 하나의 트랜잭션 안에서 예약 삭제, 대기 조회, 예약 생성, 대기 삭제를 처리하므로 단일 요청 안의 일관성은 비교적 지킬 수 있습니다. 하지만 같은 슬롯에 대해 예약 취소, 예약 대기 신청, 자동 승격이 거의 동시에 들어오는 상황까지 충분히 방어하고 있는지는 확신하기 어렵습니다. 특히 예약과 예약 대기가 하나의 상태 모델이 아니라 별도 데이터로 움직이기 때문에, 동시성 상황에서 두 데이터의 관계가 어긋날 수 있다고 의심하고 있습니다. 락이나 재시도 전략을 명확히 적용하지 않았기 때문에, 이 부분이 가장 약한 지점이라고 생각합니다. 또한 자동 승격 실패가 예약 취소 실패로 이어질 수 있어 사용자 경험 측면의 한계도 있습니다. | + +> 한 줄짜리 답이 나온다면 — 단계별 커밋 메시지·기록에 이미 망설인 흔적이 적혀 있을 가능성이 큽니다. 그것을 정리하면 PR 본문의 "내가 망설인 결정"이 됩니다. +* * * + +## 3. 피드백 순환 점검 + +> "JPA가 보내준 신호를 나는 얼마나 받아냈는가?" + +| 질문 | 답 | +| --- | --- | +| 미션 중 가장 자주 켠 피드백 채널은? (SQL 로그 / 테스트 / PR 리뷰 / JPA 설계 토론 / 예외 메시지) | 가장 자주 켠 피드백 채널은 JPA 설계 토론과 AI 인터뷰였습니다. 처음에는 JPA 개념과 선택지가 흐릿해서, 혼자 SQL 로그만 보는 것보다 내가 한 결정을 말로 풀어보고 한계를 질문받는 과정이 더 많이 필요했습니다. `join fetch`와 `@EntityGraph`, `status` 통합과 `WaitingReservation` 분리, 자동 승격 트랜잭션과 비동기 이벤트 같은 결정은 대화를 통해 기준을 세웠습니다. 그 다음으로 많이 본 채널은 SQL 로그입니다. JPQL이 실제 어떤 SQL로 변환되는지, `@EntityGraph`가 연관 엔티티를 함께 조회하는지, LAZY 접근 시 추가 SELECT가 발생하는지를 확인했습니다. 세 번째는 예외 메시지였습니다. 특히 예약 취소 후 대기 승격 과정에서 flush 순서와 UNIQUE 제약 충돌을 보며, JPA에서는 메서드 호출 순서와 실제 SQL 실행 순서가 다를 수 있다는 점을 알게 되었습니다. 테스트는 마지막 피드백 채널이었습니다. 주로 변경 후 기존 동작이 유지되는지, 예약 대기 승격과 롤백 흐름이 깨지지 않는지 확인하는 용도로 사용했습니다. | +| 그 채널에서 가장 의외였던 발견 1개는? | 가장 의외였던 발견은 N+1이 발생하는 지점이었습니다. 처음에는 `Reservation`이 `date`, `time`, `theme`를 객체로 가지고 있으니 예약을 조회하면 응답 DTO를 만들 때 필요한 값들도 자연스럽게 사용할 수 있을 것이라고 생각했습니다. 하지만 `LAZY` 연관관계에서는 예약 목록을 먼저 조회한 뒤, DTO 변환 과정에서 `getTheme().getName()`이나 `getTime().getStartAt()`에 접근할 때 추가 SELECT가 발생할 수 있다는 것을 알게 되었습니다. 또 의외였던 점은 `EAGER`라고 해서 항상 join 한 번으로 해결되는 것이 아니라는 점입니다. `EAGER`는 연관 엔티티를 반드시 로딩하라는 뜻이지, 반드시 join으로 가져오라는 뜻은 아니었습니다. 따라서 EAGER도 추가 SELECT를 만들 수 있고, N+1 문제에서 자유롭지 않을 수 있다는 점이 새로웠습니다. 이후에는 N+1을 줄이려면 단순히 fetch 모드를 바꾸는 것이 아니라, 해당 조회에서 필요한 객체 그래프를 `join fetch`나 `@EntityGraph`로 명시해야 한다고 이해했습니다. | +| `LazyInitializationException`, N+1, 의도치 않은 추가 쿼리 등 "JPA가 먼저 알려준 문제" 중 만난 것이 있는가? 어떻게 대응했는가? | JPA가 먼저 알려준 문제로 가장 강하게 남은 것은 flush 순서와 UNIQUE 제약 충돌이었습니다. 예약 취소 후 같은 슬롯의 예약 대기를 승격할 때, 코드상으로는 예약 삭제 후 예약 생성 순서로 작성했지만 실제 SQL 실행 순서는 제가 예상한 것과 달랐습니다. 삭제가 DB에 먼저 반영되기 전에 같은 슬롯의 예약 INSERT가 실행되면서 UNIQUE 제약 충돌이 발생할 수 있었습니다. 이 문제를 통해 JPA에서는 메서드 호출 순서와 실제 SQL 실행 순서가 항상 같지 않고, flush 시점을 의식해야 한다는 것을 알게 되었습니다. 대응으로 예약 삭제 후 `reservationRepository.flush()`를 호출해 기존 슬롯 삭제를 먼저 DB에 반영하도록 했습니다. 두 번째는 `LazyInitializationException`입니다. 이 문제는 실제 운영 흐름에서 우연히 만났다기보다, 트랜잭션 밖에서 LAZY 연관 객체에 접근하는 테스트를 의도적으로 만들며 확인했습니다. 영속성 컨텍스트가 닫힌 뒤에는 프록시를 초기화할 수 없다는 점을 알게 되었고, 필요한 데이터는 트랜잭션 안에서 DTO로 변환하거나 조회 시점에 EntityGraph로 함께 로딩해야 한다고 이해했습니다. 세 번째는 N+1입니다. 처음에는 이 문제가 어디서 발생하는지 잘 몰랐습니다. 하지만 예약이나 예약 대기 목록을 조회한 뒤 DTO 변환 과정에서 `getTheme().getName()`, `getTime().getStartAt()`처럼 LAZY 연관 객체에 접근하면 추가 SELECT가 반복될 수 있다는 것을 알게 되었습니다. 이후 내 예약 목록 조회와 예약 대기 순번 조회에는 `@EntityGraph`를 적용해 필요한 연관 객체를 함께 로딩하도록 대응했습니다. | +| PR 본문에 "특정 결정에 대한 질문"을 먼저 적은 횟수는? 그 질문들의 응답은 어떻게 활용했는가? | PR 본문에 특정 결정에 대한 질문을 먼저 남기지는 못했습니다. 대신 AI 인터뷰를 통해 질문을 만들고, 그 질문에 답하면서 docs와 README에 결정과 한계를 정리했습니다. 특히 `join fetch`와 `@EntityGraph` 중 무엇을 선택할지, 예약 대기를 `status`로 통합할지 별도 엔티티로 둘지, 자동 승격을 하나의 트랜잭션으로 처리할지 비동기 이벤트로 분리할지에 대한 질문을 인터뷰로 다뤘습니다. 그 과정에서 나온 답변을 문서화했고, 일부는 코드 결정에도 반영했습니다. 예를 들어 N+1 대응은 `@EntityGraph`로 정리했고, 예약 대기 삭제는 `Reservation`과 일관되게 `findById` 후 `delete(entity)`로 바꾸었습니다. | +| JPA 설계 토론에서 동료에게 받은 한계 지적 1개와 그것이 코드에 반영된 흔적은? | | + +> 한 줄짜리 답이 나온다면 — SQL 로그 / 테스트 / PR 리뷰 / JPA 설계 토론 / 예외 메시지 5개 채널을 떠올려보면 됩니다. 한 채널에서라도 발견 1개를 끌어내면 그게 다음 사이클의 시작점이 됩니다. +* * * + +## 4. 종합 + +| 질문 | 답 | +| --- | --- | +| JdbcTemplate 시절 코드와 비교했을 때, 이번 미션에서 **사고가 가장 크게 흔들린 한 장면**은? | JdbcTemplate 시절과 비교했을 때 사고가 크게 흔들린 장면은 세 가지입니다. 첫 번째는 메서드 호출 순서와 실제 SQL 실행 순서가 다를 수 있다는 점입니다. JdbcTemplate을 사용할 때는 DELETE SQL을 호출하면 바로 DB에 반영된다고 생각하기 쉬웠습니다. 하지만 JPA에서는 변경 작업이 영속성 컨텍스트에 모였다가 flush 시점에 DB와 동기화됩니다. 예약 취소 후 같은 슬롯의 대기를 승격할 때, 코드상으로는 삭제 후 생성이었지만 실제 SQL 실행 순서 때문에 UNIQUE 제약 충돌이 발생할 수 있다는 것을 보며 flush 시점과 SQL 실행 순서를 의식하게 되었습니다. 두 번째는 객체 그래프를 탐색하는 코드가 SQL 개수를 늘릴 수 있다는 점입니다. `reservation.getTheme().getName()` 같은 코드는 Java 코드만 보면 단순한 객체 접근처럼 보입니다. 하지만 LAZY 연관관계에서는 이 접근이 추가 SELECT를 발생시킬 수 있고, 목록 조회에서는 N+1 문제로 이어질 수 있다는 것을 알게 되었습니다. 세 번째는 테스트 책임이 달라진 점입니다. JdbcTemplate 시절에는 Repository가 SQL, 파라미터 바인딩, RowMapper 조립을 직접 책임졌기 때문에 Repository 테스트의 의미가 컸습니다. JPA로 전환한 뒤에는 기본 CRUD 메서드보다 내가 선언한 매핑, 직접 작성한 JPQL, EntityGraph, flush 시점, 트랜잭션 흐름을 확인하는 쪽으로 테스트 관점이 옮겨가야 한다고 느꼈습니다. | +| 이 미션 이후 "JPA의 \_\_\_ 부분은 아직 흐릿하다"라고 솔직히 적을 곳이 있다면? | 미션 이후에도 흐릿한 부분은 세 가지입니다. 첫 번째는 비동기 이벤트와 트랜잭션 분리입니다. 예약 취소와 예약 대기 승격을 하나의 트랜잭션으로 묶는 방식의 한계는 느꼈지만, 이를 비동기 이벤트로 분리했을 때 어떻게 안전하게 처리해야 하는지는 아직 명확하지 않습니다. 후속 처리가 실패하거나 늦게 처리될 때 예약과 대기 상태를 어떻게 일관되게 유지할지 더 학습이 필요합니다. 두 번째는 동시성 제어입니다. 같은 슬롯에 대해 예약 취소, 예약 대기 신청, 자동 승격이 동시에 들어오는 경우 어떤 문제가 생길 수 있는지 의심은 하지만, 이를 락, 재시도, 보상 트랜잭션 같은 방식으로 어떻게 해결해야 하는지는 아직 흐릿합니다. 세 번째는 `cascade`와 `orphanRemoval`입니다. 부모 엔티티의 저장/삭제 작업을 연관 엔티티에 전파하거나, 관계에서 제거된 자식을 삭제하는 옵션이라는 정도는 이해했습니다. 하지만 실제 도메인에서 언제 적용해야 하고, 어떤 SQL이 발생하며, 잘못 적용하면 어떤 데이터 손상이 생길 수 있는지는 아직 구체적으로 설명하기 어렵습니다. | +| 다음에 JPA를 다시 만난다면 가장 먼저 시도하고 싶은 1개는? | 가장 먼저 시도하고 싶은 것은 JPA의 책임에 해당하는 테스트가 무엇인지 직접 구분해서 작성해보는 것입니다. JdbcTemplate을 사용할 때는 Repository가 SQL 작성, 파라미터 바인딩, RowMapper 조립을 직접 책임졌기 때문에 Repository 테스트의 목적이 비교적 명확했습니다. 하지만 JPA로 전환하면 `save`, `findById`, `delete` 같은 기본 제공 메서드는 내가 다시 테스트할 대상이 아니라는 생각이 들었습니다. 대신 내가 선언한 엔티티 매핑, 유니크 제약, 직접 작성한 JPQL, `@EntityGraph`가 실제 SQL과 로딩 결과로 어떻게 이어지는지를 확인하는 테스트가 필요하다고 느꼈습니다. 다음에는 단순 CRUD가 아니라, JPA에서 내가 책임지는 매핑과 조회 의도를 검증하는 테스트를 먼저 시도해보고 싶습니다. | + +> 한 줄짜리 답이 나온다면 — "흐릿하다고 적을 곳"부터 채워보세요. 흐릿함을 인지하는 것 자체가 다음 학습의 시작점이 됩니다. +* * * + +## 5. LMS+ 산출물 + +> 본 자기점검은 본인 회고용이고, LMS+에는 별도 산출물을 제출합니다. 코치는 PR + JPA 설계 토론 + 자기점검과 함께 LMS+ 산출물의 내용으로 다음 세 차원을 살펴봅니다. +> +> - **차원 A — JPA 매핑 정확성**: 내 어노테이션이 만드는 SQL을 추적했는가 +> - **차원 B — 설계 판단**: 대안과 비교한 근거·한계를 말할 수 있는가 +> - **차원 C — 피드백 순환**: SQL 로그·예외·동료 시선 같은 채널을 살려 썼는가 + +| 항목 | 답 준비 메모 | +| --- | --- | +| 사전 설문 (미션 시작 전) — 미션 목표·선택 이유·마친 후 모습 | 사전에는 JPA를 처음 다루는 상태였고, JPA가 무엇을 자동화하는지와 왜 사용하는지 체감하는 것이 목표였습니다. 미션을 마친 지금은 JPA가 단순히 SQL을 없애주는 도구가 아니라, 객체와 테이블의 매핑, 영속성 컨텍스트, fetch 전략, flush 시점까지 함께 이해해야 하는 기술이라는 것을 알게 되었습니다. 특히 내가 작성한 어노테이션과 Repository 메서드가 실제 어떤 SQL로 이어지는지 확인하는 것이 중요하다고 느꼈습니다. | +| 사후 산출물 — 본 자기점검에서 끌어올 핵심 1~2개 (어떤 결정·어떤 SQL 발췌를 옮길지) | 후보는 네 가지입니다. 첫 번째는 flush 순서와 UNIQUE 제약 충돌입니다. JPA가 메서드 호출 순서대로 SQL을 바로 실행하지 않고, 영속성 컨텍스트와 flush 시점에 따라 SQL 실행 순서가 달라질 수 있다는 것을 체감했습니다. 예약 취소 후 대기 승격 과정에서 이를 만났고, `reservationRepository.flush()`로 기존 예약 삭제를 먼저 DB에 반영하도록 대응했습니다. 두 번째는 JPQL + `@EntityGraph` 결정입니다. 복잡한 조회 조건은 JPQL로 표현하고, N+1 대응을 위한 로딩 범위는 EntityGraph로 분리했습니다. 세 번째는 자동 승격 트랜잭션의 한계입니다. 데이터 정합성을 위해 하나의 트랜잭션을 선택했지만, 승격 실패가 예약 취소 실패로 이어질 수 있고 동시성 상황도 충분히 방어하지 못할 수 있다고 느꼈습니다. 네 번째는 JdbcTemplate과 JPA Repository의 테스트 책임 차이입니다. JdbcTemplate에서는 SQL과 RowMapper를 테스트했지만, JPA에서는 기본 CRUD보다 매핑, JPQL, EntityGraph, flush 의도를 확인하는 테스트가 더 중요하다고 생각하게 되었습니다. 실제 사후 설문에는 1번 flush 순서와 UNIQUE 제약 충돌, 2번 JPQL + EntityGraph 결정을 중심으로 옮길 예정입니다. | +| 사후 설문 — 시도, 달성도, 다음 학습, 코치 피드백 요청 | 이번 미션에서는 JdbcTemplate 기반 Repository를 JPA Repository로 전환하고, 엔티티 매핑, 연관관계, JPQL, EntityGraph, 영속성 컨텍스트의 flush 시점을 관찰했습니다. 사전 목표였던 "JPA가 무엇을 자동화하고 왜 사용하는지 체감하기"는 어느 정도 달성했다고 생각합니다. JPA가 SQL 작성과 객체 조립을 줄여주지만, 그 대신 실제 SQL 실행 시점, fetch 전략, 영속성 컨텍스트를 계속 의식해야 한다는 것을 알게 되었습니다. 다음 학습에서는 JPA에서 내가 책임져야 하는 테스트가 무엇인지 더 분명히 하고 싶습니다. 특히 기본 CRUD가 아니라 엔티티 매핑, JPQL, EntityGraph, flush 시점이 의도대로 동작하는지 검증하는 테스트를 작성해보고 싶습니다. 코치에게는 `@EntityGraph`와 `join fetch` 선택 기준, 자동 승격 같은 후속 처리를 하나의 트랜잭션으로 묶는 판단의 한계, 그리고 JPA Repository 테스트 범위에 대해 피드백을 받고 싶습니다. | + +> **자기점검과 LMS+ 산출물의 관계**: 자기점검은 풍부히 풀어 쓰는 자리, LMS+는 핵심을 요약 제출하는 자리. 자기점검의 답을 그대로 붙이지 않고, **가장 강한 결정·가장 정직한 한계 1~2개만 골라** LMS+로 옮깁니다. +* * * + +> **미션 종료 후 AI 인터뷰** (별도 안내): 위 답을 외우려 하지 말고, 결정의 **신호 출처**(SQL 로그·예외 메시지·JPA 설계 토론 코멘트)를 다시 떠올려두는 것이 준비입니다. diff --git a/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java b/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java deleted file mode 100644 index 4283885d35..0000000000 --- a/src/main/java/roomescape/domain/reservation/JdbcReservationRepository.java +++ /dev/null @@ -1,252 +0,0 @@ -package roomescape.domain.reservation; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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.reservationdate.ReservationDate; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; - -@Repository -@RequiredArgsConstructor -public class JdbcReservationRepository implements ReservationRepository { - - private static final String INSERT_SQL = "insert into reservation(name, date_id, time_id, theme_id) values (?, ?, ?, ?)"; - private static final String FIND_ALL_SQL = - """ - select r.id, r.name, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from reservation r - join reservation_date rd on r.date_id = rd.id - join reservation_time rt on r.time_id = rt.id - join theme th on r.theme_id = th.id - order by r.id - """; - private static final String COUNT_BY_TIME_ID_SQL = - """ - select count(*) - from reservation - where time_id = ? - """; - private static final String COUNT_BY_RESERVATION_DATE_ID_SQL = - """ - select count(*) - from reservation - where date_id = ? - """; - private static final String DELETE_BY_ID_SQL = "delete from reservation where id = ?"; - private static final String FIND_BY_THEME_AND_DATE_SQL = - """ - select time_id - from reservation - where theme_id = ? and date_id = ? - """; - - private static final String COUNT_BY_THEME_ID_SQL = - """ - select count(*) - from reservation - where theme_id = ? - """; - ; - - private static final String FIND_BY_NAME_SQL = - """ - select r.id, r.name, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from reservation r - join reservation_date rd on r.date_id = rd.id - join reservation_time rt on r.time_id = rt.id - join theme th on r.theme_id = th.id - where r.name = ? - order by rd.play_day - """; - - private static final String FIND_UPCOMING_BY_NAME_SQL = - """ - select r.id, r.name, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from reservation r - join reservation_date rd on r.date_id = rd.id - join reservation_time rt on r.time_id = rt.id - join theme th on r.theme_id = th.id - where r.name = ? - and (rd.play_day > ? or (rd.play_day = ? and rt.start_at > ?)) - order by rd.play_day, rt.start_at - """; - - private static final String FIND_BY_ID_SQL = - """ - select r.id, r.name, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from reservation r - join reservation_date rd on r.date_id = rd.id - join reservation_time rt on r.time_id = rt.id - join theme th on r.theme_id = th.id - where r.id = ? - """; - - private static final String UPDATE_DATE_TIME_SQL = - """ - update reservation - set date_id = ?, time_id = ? - where id = ? - """; - - private static final String EXIST_BY_DATE_TIME_THEME_SQL = - """ - select exists( - select 1 - from reservation - where date_id = ? and time_id = ? and theme_id = ? - ); - """; - - private final JdbcTemplate jdbcTemplate; - - @Override - public Reservation save(Reservation reservation) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, reservation.getName()); - ps.setLong(2, reservation.getDate().getId()); - ps.setLong(3, reservation.getTime().getId()); - ps.setLong(4, reservation.getTheme().getId()); - return ps; - }, keyHolder); - long id = extractId(keyHolder); - return Reservation.of( - id, - reservation.getName(), - reservation.getDate(), - reservation.getTime(), - reservation.getTheme() - ); - } - - @Override - public List findAll() { - return jdbcTemplate.query(FIND_ALL_SQL, reservationRowMapper()); - } - - @Override - public int deleteById(Long id) { - return jdbcTemplate.update(DELETE_BY_ID_SQL, id); - } - - @Override - public int countByTimeId(Long timeId) { - Integer count = jdbcTemplate.queryForObject(COUNT_BY_TIME_ID_SQL, Integer.class, timeId); - if (count == null) { - return 0; - } - return count; - } - - @Override - public int countByReservationDateId(Long dateId) { - Integer count = jdbcTemplate.queryForObject(COUNT_BY_RESERVATION_DATE_ID_SQL, Integer.class, dateId); - if (count == null) { - return 0; - } - return count; - } - - @Override - public List findReservedTimes(Long themeId, Long dateId) { - return jdbcTemplate.query(FIND_BY_THEME_AND_DATE_SQL, reservationTimeIdRowMapper(), themeId, dateId); - } - - @Override - public int countByThemeId(Long themeId) { - Integer count = jdbcTemplate.queryForObject(COUNT_BY_THEME_ID_SQL, Integer.class, themeId); - if (count == null) { - return 0; - } - return count; - } - - @Override - public List findByName(String name) { - return jdbcTemplate.query(FIND_BY_NAME_SQL, reservationRowMapper(), name); - } - - @Override - public List findUpcomingByName(String name, LocalDate currentDate, LocalTime currentTime) { - String currentDateValue = currentDate.toString(); - return jdbcTemplate.query( - FIND_UPCOMING_BY_NAME_SQL, - reservationRowMapper(), - name, - currentDateValue, - currentDateValue, - currentTime.toString() - ); - } - - @Override - public Optional findById(Long id) { - return jdbcTemplate.query(FIND_BY_ID_SQL, reservationRowMapper(), id) - .stream() - .findFirst(); - } - - @Override - public int updateReservation(Long id, Long dateId, Long timeId) { - return jdbcTemplate.update(UPDATE_DATE_TIME_SQL, dateId, timeId, id); - } - - @Override - public boolean existsByDateIdAndTimeIdAndThemeId(Long dateId, Long timeId, Long themeId) { - return jdbcTemplate.queryForObject(EXIST_BY_DATE_TIME_THEME_SQL, Boolean.class, dateId, timeId, themeId); - } - - private RowMapper reservationRowMapper() { - return (rs, rowNum) -> Reservation.of( - rs.getLong("id"), - rs.getString("name"), - ReservationDate.of( - rs.getLong("date_id"), - LocalDate.parse(rs.getString("play_day"))), - ReservationTime.of( - rs.getLong("time_id"), - LocalTime.parse(rs.getString("start_at")) - ), - Theme.of( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_content"), - rs.getString("theme_url") - ) - ); - } - - private RowMapper reservationTimeIdRowMapper() { - return (rs, rowNum) -> rs.getLong("time_id"); - } - - private long extractId(KeyHolder keyHolder) { - if (keyHolder.getKey() == null) { - throw new IllegalStateException("생성 키를 조회할 수 없습니다."); - } - return keyHolder.getKey().longValue(); - } -} diff --git a/src/main/java/roomescape/domain/reservation/Reservation.java b/src/main/java/roomescape/domain/reservation/Reservation.java index 64b434f2fc..f794f74040 100644 --- a/src/main/java/roomescape/domain/reservation/Reservation.java +++ b/src/main/java/roomescape/domain/reservation/Reservation.java @@ -1,6 +1,17 @@ package roomescape.domain.reservation; +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 lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; @@ -10,13 +21,27 @@ import roomescape.support.exception.ThemeErrorCode; @Getter +@Entity +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"date_id", "time_id", "theme_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Reservation { - private final Long id; - private final String name; - private final ReservationDate date; - private final ReservationTime time; - private final Theme theme; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "date_id") + private ReservationDate date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id") + private ReservationTime time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") + private Theme theme; private Reservation( Long id, @@ -67,6 +92,17 @@ public static Reservation of( ); } + public void changeSlot(ReservationDate date, ReservationTime time) { + if (date == null) { + throw new RoomescapeException(ReservationErrorCode.INVALID_RESERVATION_DATE); + } + if (time == null) { + throw new RoomescapeException(ReservationTimeErrorCode.INVALID_RESERVATION_TIME); + } + this.date = date; + this.time = time; + } + private static void validate(String name, ReservationDate date, ReservationTime time, Theme theme) { if (name == null || name.isBlank()) { throw new RoomescapeException(ReservationErrorCode.INVALID_RESERVATION_NAME); diff --git a/src/main/java/roomescape/domain/reservation/ReservationRepository.java b/src/main/java/roomescape/domain/reservation/ReservationRepository.java index 3a268115e2..308bca1f48 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationRepository.java +++ b/src/main/java/roomescape/domain/reservation/ReservationRepository.java @@ -3,31 +3,43 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; -import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface ReservationRepository { - - Reservation save(Reservation reservation); - - List findAll(); - - int deleteById(Long id); +public interface ReservationRepository extends JpaRepository { int countByTimeId(Long timeId); - int countByReservationDateId(Long dateId); + int countByDateId(Long dateId); - List findReservedTimes(Long themeId, Long dateId); + List findByThemeIdAndDateId(Long themeId, Long dateId); + + default List findReservedTimes(Long themeId, Long dateId) { + return findByThemeIdAndDateId(themeId, dateId).stream() + .map(reservation -> reservation.getTime().getId()) + .toList(); + } int countByThemeId(Long id); List findByName(String name); - List findUpcomingByName(String name, LocalDate currentDate, LocalTime currentTime); - - Optional findById(Long id); - - int updateReservation(Long id, Long dateId, Long timeId); + @EntityGraph(attributePaths = {"date", "time", "theme"}) + @Query(""" + select r + from Reservation r + where r.name = :name + and (r.date.playDay > :currentDate + or (r.date.playDay = :currentDate and r.time.startAt > :currentTime)) + order by r.date.playDay, r.time.startAt + """) + List findUpcomingByName( + @Param("name") String name, + @Param("currentDate") LocalDate currentDate, + @Param("currentTime") LocalTime currentTime + ); boolean existsByDateIdAndTimeIdAndThemeId(Long dateId, Long timeId, Long themeId); } diff --git a/src/main/java/roomescape/domain/reservation/ReservationService.java b/src/main/java/roomescape/domain/reservation/ReservationService.java index 193cf2dc8a..7845f530df 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationService.java +++ b/src/main/java/roomescape/domain/reservation/ReservationService.java @@ -18,7 +18,6 @@ import roomescape.support.exception.ReservationDateErrorCode; import roomescape.support.exception.ReservationErrorCode; import roomescape.support.exception.RoomescapeException; -import roomescape.support.exception.RoomescapeErrorCode; @Service @RequiredArgsConstructor @@ -50,11 +49,10 @@ public List getReservationsByName(String name) { .toList(); } + @Transactional public void deleteReservation(Long id) { - int deletedCount = reservationRepository.deleteById(id); - if (deletedCount == 0) { - throw new RoomescapeException(ReservationErrorCode.RESERVATION_NOT_FOUND); - } + Reservation reservation = getReservation(id); + reservationRepository.delete(reservation); } @Transactional @@ -63,6 +61,7 @@ public void cancelReservation(Long id) { validateReservableDate(reservation); deleteReservationOrThrow(id); + reservationRepository.flush(); promoteOldestWaiting(ReservationSlot.from(reservation)); } @@ -85,9 +84,10 @@ public ReservationResponse updateReservation(Long id, @Valid ReservationUpdateRe validateReservableDate(newSlot); validateNotDuplicated(newSlot); - updateReservationOrThrow(id, request); + reservation.changeSlot(newSlot.date(), newSlot.time()); + reservationRepository.flush(); promoteOldestWaiting(currentSlot); - return ReservationResponse.from(getReservation(id)); + return ReservationResponse.from(reservation); } private void promoteOldestWaiting(ReservationSlot slot) { @@ -107,28 +107,12 @@ private void promoteOldestWaiting(ReservationSlot slot) { waitingReservation.getTime(), waitingReservation.getTheme() )); - deleteWaitingReservationOrThrow(waitingReservation.getId()); + waitingReservationRepository.delete(waitingReservation); } private void deleteReservationOrThrow(Long id) { - int deletedCount = reservationRepository.deleteById(id); - if (deletedCount == 0) { - throw new RoomescapeException(RoomescapeErrorCode.DATA_CONSISTENCY_VIOLATION); - } - } - - private void deleteWaitingReservationOrThrow(Long id) { - int deletedCount = waitingReservationRepository.deleteById(id); - if (deletedCount == 0) { - throw new RoomescapeException(RoomescapeErrorCode.DATA_CONSISTENCY_VIOLATION); - } - } - - private void updateReservationOrThrow(Long id, ReservationUpdateRequest request) { - int updatedCount = reservationRepository.updateReservation(id, request.dateId(), request.timeId()); - if (updatedCount == 0) { - throw new RoomescapeException(RoomescapeErrorCode.DATA_CONSISTENCY_VIOLATION); - } + Reservation reservation = getReservation(id); + reservationRepository.delete(reservation); } private Reservation getReservation(Long id) { diff --git a/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java b/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java deleted file mode 100644 index a8a93f863c..0000000000 --- a/src/main/java/roomescape/domain/reservationdate/JdbcReservationDateRepository.java +++ /dev/null @@ -1,83 +0,0 @@ -package roomescape.domain.reservationdate; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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; - -@Repository -@RequiredArgsConstructor -public class JdbcReservationDateRepository implements ReservationDateRepository { - - private static final String INSERT_SQL = "insert into reservation_date(play_day) values (?)"; - private static final String FIND_BY_ID_SQL = "select id, play_day from reservation_date where id = ?"; - private static final String FIND_ALL_SQL = "select id, play_day from reservation_date order by id"; - private static final String DELETE_BY_ID_SQL = "delete from reservation_date where id = ?"; - private static final String EXISTS_BY_PLAY_DAY_SQL = - """ - select exists( - select 1 - from reservation_date - where play_day = ? - ) - """; - - private final JdbcTemplate jdbcTemplate; - - @Override - public Optional findById(Long id) { - List result = jdbcTemplate.query(FIND_BY_ID_SQL, reservationDateRowMapper(), id); - return result.stream().findFirst(); - } - - @Override - public List findAll() { - return jdbcTemplate.query(FIND_ALL_SQL, reservationDateRowMapper()); - } - - @Override - public ReservationDate save(ReservationDate reservationDate) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, reservationDate.getPlayDay().toString()); - return ps; - }, keyHolder); - long id = extractId(keyHolder); - return ReservationDate.of( - id, - reservationDate.getPlayDay() - ); - } - - @Override - public int deleteById(Long id) { - return jdbcTemplate.update(DELETE_BY_ID_SQL, id); - } - - @Override - public boolean existsByPlayDay(LocalDate playDay) { - return jdbcTemplate.queryForObject(EXISTS_BY_PLAY_DAY_SQL, Boolean.class, playDay); - } - - private RowMapper reservationDateRowMapper() { - return (rs, rowNum) -> ReservationDate.of( - rs.getLong("id"), - LocalDate.parse(rs.getString("play_day")) - ); - } - - private long extractId(KeyHolder keyHolder) { - if (keyHolder.getKey() == null) { - throw new IllegalStateException("생성 키를 조회할 수 없습니다."); - } - return keyHolder.getKey().longValue(); - } -} diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDate.java b/src/main/java/roomescape/domain/reservationdate/ReservationDate.java index 06e9086d98..ee2225b1bd 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDate.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDate.java @@ -1,15 +1,25 @@ package roomescape.domain.reservationdate; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import java.time.LocalDate; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.support.exception.ReservationDateErrorCode; import roomescape.support.exception.RoomescapeException; @Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ReservationDate { - private final Long id; - private final LocalDate playDay; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private LocalDate playDay; private ReservationDate(Long id, LocalDate playDay) { validate(playDay); diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java b/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java index 7b92ee282e..03a843d344 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDateRepository.java @@ -1,18 +1,9 @@ package roomescape.domain.reservationdate; import java.time.LocalDate; -import java.util.List; -import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; -public interface ReservationDateRepository { - - Optional findById(Long id); - - List findAll(); - - ReservationDate save(ReservationDate reservationDate); - - int deleteById(Long id); +public interface ReservationDateRepository extends JpaRepository { boolean existsByPlayDay(LocalDate playDay); } diff --git a/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java b/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java index 0c8af34b05..e64dd75f8d 100644 --- a/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java +++ b/src/main/java/roomescape/domain/reservationdate/ReservationDateService.java @@ -36,13 +36,11 @@ public ReservationDateCreationResponse createReservationDate(ReservationDateCrea } public void deleteReservationDate(Long id) { - if (reservationRepository.countByReservationDateId(id) > 0) { + ReservationDate reservationDate = findById(id); + if (reservationRepository.countByDateId(id) > 0) { throw new RoomescapeException(ReservationDateErrorCode.RESERVATION_DATE_IN_USE); } - int deletedCount = reservationDateRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("이미 삭제된 날짜의 삭제 요청이 들어왔습니다. dateId={}", id); - } + reservationDateRepository.delete(reservationDate); } public List getAllAvailableReservationDate() { diff --git a/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java b/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java deleted file mode 100644 index 84845cbe37..0000000000 --- a/src/main/java/roomescape/domain/reservationtime/JdbcReservationTimeRepository.java +++ /dev/null @@ -1,83 +0,0 @@ -package roomescape.domain.reservationtime; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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; - -@Repository -@RequiredArgsConstructor -public class JdbcReservationTimeRepository implements ReservationTimeRepository { - - private static final String INSERT_SQL = "insert into reservation_time(start_at) values (?)"; - private static final String FIND_ALL_SQL = "select id, start_at from reservation_time order by id"; - private static final String FIND_BY_ID_SQL = "select id, start_at from reservation_time where id = ?"; - private static final String DELETE_BY_ID_SQL = "delete from reservation_time where id = ?"; - private static final String EXISTS_BY_START_AT_SQL = - """ - select exists( - select 1 - from reservation_time - where start_at = ? - ) - """; - - private final JdbcTemplate jdbcTemplate; - - @Override - public ReservationTime save(ReservationTime reservationTime) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, reservationTime.getFormattedStartAt()); - return ps; - }, keyHolder); - long id = extractId(keyHolder); - return ReservationTime.of( - id, - reservationTime.getStartAt() - ); - } - - @Override - public List findAll() { - return jdbcTemplate.query(FIND_ALL_SQL, reservationTimeRowMapper()); - } - - @Override - public Optional findById(Long id) { - List result = jdbcTemplate.query(FIND_BY_ID_SQL, reservationTimeRowMapper(), id); - return result.stream().findFirst(); - } - - @Override - public int deleteById(Long id) { - return jdbcTemplate.update(DELETE_BY_ID_SQL, id); - } - - @Override - public boolean existsByStartAt(LocalTime startAt) { - return jdbcTemplate.queryForObject(EXISTS_BY_START_AT_SQL, Boolean.class, startAt); - } - - private RowMapper reservationTimeRowMapper() { - return (rs, rowNum) -> ReservationTime.of( - rs.getLong("id"), - LocalTime.parse(rs.getString("start_at")) - ); - } - - private long extractId(KeyHolder keyHolder) { - if (keyHolder.getKey() == null) { - throw new IllegalStateException("생성 키를 조회할 수 없습니다."); - } - return keyHolder.getKey().longValue(); - } -} diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTime.java b/src/main/java/roomescape/domain/reservationtime/ReservationTime.java index 6e2158e69f..bff1f65574 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTime.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTime.java @@ -1,18 +1,28 @@ package roomescape.domain.reservationtime; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.support.exception.ReservationTimeErrorCode; import roomescape.support.exception.RoomescapeException; @Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ReservationTime { private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); - private final Long id; - private final LocalTime startAt; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private LocalTime startAt; private ReservationTime(Long id, LocalTime startAt) { validate(startAt); diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java b/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java index 93af6a08d9..8aca06a4ca 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTimeRepository.java @@ -3,16 +3,9 @@ import java.time.LocalTime; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; -public interface ReservationTimeRepository { - - ReservationTime save(ReservationTime reservationTime); - - List findAll(); - - Optional findById(Long id); - - int deleteById(Long id); +public interface ReservationTimeRepository extends JpaRepository { boolean existsByStartAt(LocalTime startAt); } diff --git a/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java b/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java index ad1cc80e49..1d8a8ae8a9 100644 --- a/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java +++ b/src/main/java/roomescape/domain/reservationtime/ReservationTimeService.java @@ -41,13 +41,12 @@ public List getAllReservationTime() { } public void deleteReservationTime(Long id) { + ReservationTime reservationTime = reservationTimeRepository.findById(id) + .orElseThrow(() -> new RoomescapeException(ReservationTimeErrorCode.RESERVATION_TIME_NOT_EXIST)); if (reservationRepository.countByTimeId(id) > 0) { throw new RoomescapeException(ReservationTimeErrorCode.RESERVATION_TIME_IN_USE); } - int deletedCount = reservationTimeRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("이미 삭제된 예약 시간 삭제 요청이 들어왔습니다. timeId={}", id); - } + reservationTimeRepository.delete(reservationTime); } public List getReservationTimeAvailability(Long themeId, Long dateId) { @@ -61,11 +60,6 @@ public List getReservationTimeAvailability( .toList(); } - public ReservationTime findById(Long id) { - return reservationTimeRepository.findById(id) - .orElseThrow(() -> new RoomescapeException(ReservationTimeErrorCode.RESERVATION_TIME_NOT_EXIST)); - } - private Set getReservedTimeIds(Long themeId, Long dateId) { List reservedTimeIds = reservationRepository.findReservedTimes(themeId, dateId); return new HashSet<>(reservedTimeIds); diff --git a/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java b/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java deleted file mode 100644 index 304bb3b2aa..0000000000 --- a/src/main/java/roomescape/domain/theme/JdbcThemeRepository.java +++ /dev/null @@ -1,95 +0,0 @@ -package roomescape.domain.theme; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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.support.exception.RoomescapeErrorCode; -import roomescape.support.exception.RoomescapeException; - -@Repository -@RequiredArgsConstructor -public class JdbcThemeRepository implements ThemeRepository { - - private static final String FIND_ALL_SQL = "select id, name, content, url from theme order by id"; - private static final String FIND_BY_ID_SQL = "select id, name, content, url from theme where id = ?"; - private static final String INSERT_SQL = "insert into theme(name, content, url) values (?, ?, ?)"; - private static final String DELETE_BY_ID_SQL = "delete from theme where id = ?"; - private static final String FIND_POPULAR_THEMES_SQL = - """ - select th.id, th.name, th.content, th.url - from theme th - join reservation r on th.id = r.theme_id - join reservation_date rd on r.date_id = rd.id - where rd.play_day between ? and ? - group by th.id - order by count(r.id) desc, th.id asc - limit ? - """; - - private final JdbcTemplate jdbcTemplate; - - @Override - public Optional findById(Long id) { - List result = jdbcTemplate.query(FIND_BY_ID_SQL, themeRowMapper(), id); - return result.stream().findFirst(); - } - - @Override - public List findAll() { - return jdbcTemplate.query(FIND_ALL_SQL, themeRowMapper()); - } - - @Override - public Theme save(Theme theme) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, theme.getName()); - ps.setString(2, theme.getContent()); - ps.setString(3, theme.getUrl()); - return ps; - }, keyHolder); - long id = extractId(keyHolder); - return Theme.of( - id, - theme.getName(), - theme.getContent(), - theme.getUrl() - ); - } - - @Override - public int deleteById(Long id) { - return jdbcTemplate.update(DELETE_BY_ID_SQL, id); - } - - @Override - public List findPopularThemes(int rankLimit, LocalDate startDay, LocalDate endDay) { - return jdbcTemplate.query(FIND_POPULAR_THEMES_SQL, themeRowMapper(), startDay, endDay, rankLimit); - } - - private RowMapper themeRowMapper() { - return ((rs, rowNum) -> Theme.of( - rs.getLong("id"), - rs.getString("name"), - rs.getString("content"), - rs.getString("url") - )); - } - - private long extractId(KeyHolder keyHolder) { - if (keyHolder.getKey() == null) { - throw new RoomescapeException(RoomescapeErrorCode.INVALID_GENERATED_KEY); - } - return keyHolder.getKey().longValue(); - } - -} diff --git a/src/main/java/roomescape/domain/theme/Theme.java b/src/main/java/roomescape/domain/theme/Theme.java index 438fd8316d..184c33865f 100644 --- a/src/main/java/roomescape/domain/theme/Theme.java +++ b/src/main/java/roomescape/domain/theme/Theme.java @@ -1,16 +1,26 @@ package roomescape.domain.theme; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.support.exception.RoomescapeException; import roomescape.support.exception.ThemeErrorCode; @Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Theme { - private final Long id; - private final String name; - private final String content; - private final String url; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + private String content; + private String url; private Theme(Long id, String name, String content, String url) { validate(name, content, url); diff --git a/src/main/java/roomescape/domain/theme/ThemeController.java b/src/main/java/roomescape/domain/theme/ThemeController.java index a4bf32666f..9afebccbb0 100644 --- a/src/main/java/roomescape/domain/theme/ThemeController.java +++ b/src/main/java/roomescape/domain/theme/ThemeController.java @@ -1,20 +1,10 @@ package roomescape.domain.theme; -import jakarta.servlet.http.HttpServletRequest; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import roomescape.admin.AdminRequestValidator; -import roomescape.domain.theme.dto.AdminThemeResponse; -import roomescape.domain.theme.dto.ThemeCreationRequest; -import roomescape.domain.theme.dto.ThemeCreationResponse; import roomescape.domain.theme.dto.ThemeRankResponse; import roomescape.domain.theme.dto.ThemeResponse; diff --git a/src/main/java/roomescape/domain/theme/ThemeRepository.java b/src/main/java/roomescape/domain/theme/ThemeRepository.java index 0e6a9e6a34..4b255b7efd 100644 --- a/src/main/java/roomescape/domain/theme/ThemeRepository.java +++ b/src/main/java/roomescape/domain/theme/ThemeRepository.java @@ -1,18 +1,26 @@ package roomescape.domain.theme; import java.time.LocalDate; -import java.util.Optional; 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; -public interface ThemeRepository { +public interface ThemeRepository extends JpaRepository { - Optional findById(Long id); - - List findAll(); - - Theme save(Theme theme); - - int deleteById(Long id); - - List findPopularThemes(int rankLimit, LocalDate startDay, LocalDate endDay); + @Query(value = """ + select th.id, th.name, th.content, th.url + from theme th + join reservation r on th.id = r.theme_id + join reservation_date rd on r.date_id = rd.id + where rd.play_day between :startDay and :endDay + group by th.id + order by count(r.id) desc, th.id asc + limit :rankLimit + """, nativeQuery = true) + List findPopularThemes( + @Param("rankLimit") int rankLimit, + @Param("startDay") LocalDate startDay, + @Param("endDay") LocalDate endDay + ); } diff --git a/src/main/java/roomescape/domain/theme/ThemeService.java b/src/main/java/roomescape/domain/theme/ThemeService.java index 256c12821c..80767510b0 100644 --- a/src/main/java/roomescape/domain/theme/ThemeService.java +++ b/src/main/java/roomescape/domain/theme/ThemeService.java @@ -38,13 +38,12 @@ public ThemeCreationResponse createTheme(ThemeCreationRequest request) { } public void deleteTheme(Long id) { + Theme theme = themeRepository.findById(id) + .orElseThrow(() -> new RoomescapeException(ThemeErrorCode.THEME_NOT_EXIST)); if (reservationRepository.countByThemeId(id) > 0) { throw new RoomescapeException(ThemeErrorCode.THEME_IN_USE); } - int deletedCount = themeRepository.deleteById(id); - if (deletedCount == 0) { - log.warn("삭제할 테마가 존재하지 않습니다. themeId = {}", id); - } + themeRepository.delete(theme); } public List getAllTheme() { @@ -62,9 +61,4 @@ public List getThemeRank() { .map(ThemeRankResponse::from) .toList(); } - - public Theme findById(Long id) { - return themeRepository.findById(id) - .orElseThrow(() -> new RoomescapeException(ThemeErrorCode.THEME_NOT_EXIST)); - } } diff --git a/src/main/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepository.java b/src/main/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepository.java deleted file mode 100644 index 40019ee794..0000000000 --- a/src/main/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepository.java +++ /dev/null @@ -1,218 +0,0 @@ -package roomescape.domain.waitingreservation; - -import java.sql.PreparedStatement; -import java.sql.Statement; -import java.sql.Timestamp; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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.reservationdate.ReservationDate; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; -import roomescape.domain.waitingreservation.dto.WaitingReservationWithRank; - -@Repository -@RequiredArgsConstructor -public class JdbcWaitingReservationRepository implements WaitingReservationRepository { - - private static final String INSERT_SQL = "insert into waiting_reservation(name, date_id, time_id, theme_id, created_at) values (?, ?, ?, ?, ?)"; - - private static final String EXIST_BY_NAME_DATE_TIME_THEME_SQL = - """ - select exists( - select 1 - from waiting_reservation - where name = ? and date_id = ? and time_id = ? and theme_id = ? - ); - """; - - private static final String FIND_OLDEST_BY_SLOT_SQL = - """ - select wr.id, wr.name, wr.created_at, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from waiting_reservation wr - join reservation_date rd on wr.date_id = rd.id - join reservation_time rt on wr.time_id = rt.id - join theme th on wr.theme_id = th.id - where wr.date_id = ? and wr.time_id = ? and wr.theme_id = ? - order by wr.created_at asc, wr.id asc - limit 1 - """; - - private static final String FIND_ALL_BY_NAME_WITH_RANK_SQL = - """ - select ranked.id, ranked.name, ranked.created_at, ranked.waiting_rank, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from ( - select wr.id, wr.name, wr.date_id, wr.time_id, wr.theme_id, wr.created_at, - row_number() over ( - partition by wr.date_id, wr.time_id, wr.theme_id - order by wr.created_at asc, wr.id asc - ) as waiting_rank - from waiting_reservation wr - ) ranked - join reservation_date rd on ranked.date_id = rd.id - join reservation_time rt on ranked.time_id = rt.id - join theme th on ranked.theme_id = th.id - where ranked.name = ? - order by rd.play_day asc, rt.start_at asc, ranked.id asc - """; - - private static final String FIND_UPCOMING_BY_NAME_WITH_RANK_SQL = - """ - select ranked.id, ranked.name, ranked.created_at, ranked.waiting_rank, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url - from ( - select wr.id, wr.name, wr.date_id, wr.time_id, wr.theme_id, wr.created_at, - row_number() over ( - partition by wr.date_id, wr.time_id, wr.theme_id - order by wr.created_at asc, wr.id asc - ) as waiting_rank - from waiting_reservation wr - join reservation_date rd on wr.date_id = rd.id - join reservation_time rt on wr.time_id = rt.id - where rd.play_day > ? or (rd.play_day = ? and rt.start_at > ?) - ) ranked - join reservation_date rd on ranked.date_id = rd.id - join reservation_time rt on ranked.time_id = rt.id - join theme th on ranked.theme_id = th.id - where ranked.name = ? - order by rd.play_day asc, rt.start_at asc, ranked.id asc - """; - - private static final String DELETE_BY_ID_SQL = "delete from waiting_reservation where id = ?"; - - private static final String FIND_BY_ID_SQL = - """ - select wr.id, wr.name, - rd.id as date_id, rd.play_day, - rt.id as time_id, rt.start_at, - th.id as theme_id, th.name as theme_name, th.content as theme_content, th.url as theme_url, - wr.created_at - from waiting_reservation wr - join reservation_date rd on wr.date_id = rd.id - join reservation_time rt on wr.time_id = rt.id - join theme th on wr.theme_id = th.id - where wr.id = ? - """; - private final JdbcTemplate jdbcTemplate; - - @Override - public WaitingReservation save(WaitingReservation waitingReservation) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, waitingReservation.getName()); - ps.setLong(2, waitingReservation.getDate().getId()); - ps.setLong(3, waitingReservation.getTime().getId()); - ps.setLong(4, waitingReservation.getTheme().getId()); - ps.setTimestamp(5, Timestamp.valueOf(waitingReservation.getCreatedAt())); - return ps; - }, keyHolder); - long id = extractId(keyHolder); - return WaitingReservation.of( - id, - waitingReservation.getName(), - waitingReservation.getDate(), - waitingReservation.getTime(), - waitingReservation.getTheme(), - waitingReservation.getCreatedAt() - ); - } - - @Override - public boolean existsByNameAndDateIdAndTimeIdAndThemeId(String name, long dateId, long timeId, long themeId) { - return jdbcTemplate.queryForObject(EXIST_BY_NAME_DATE_TIME_THEME_SQL, Boolean.class, name, dateId, timeId, - themeId); - } - - @Override - public Optional findOldestBySlot(long dateId, long timeId, long themeId) { - return jdbcTemplate.query(FIND_OLDEST_BY_SLOT_SQL, waitingReservationRowMapper(), dateId, timeId, themeId) - .stream() - .findFirst(); - } - - @Override - public List findAllByNameWithRank(String name) { - return jdbcTemplate.query(FIND_ALL_BY_NAME_WITH_RANK_SQL, waitingReservationWithRankRowMapper(), name); - } - - @Override - public List findUpcomingByNameWithRank( - String name, - LocalDate currentDate, - LocalTime currentTime - ) { - String currentDateValue = currentDate.toString(); - return jdbcTemplate.query( - FIND_UPCOMING_BY_NAME_WITH_RANK_SQL, - waitingReservationWithRankRowMapper(), - currentDateValue, - currentDateValue, - currentTime.toString(), - name - ); - } - - @Override - public int deleteById(Long id) { - return jdbcTemplate.update(DELETE_BY_ID_SQL, id); - } - - @Override - public Optional findById(Long id) { - return jdbcTemplate.query(FIND_BY_ID_SQL, waitingReservationRowMapper(), id) - .stream() - .findFirst(); - } - - private RowMapper waitingReservationWithRankRowMapper() { - return (rs, rowNum) -> new WaitingReservationWithRank( - waitingReservationRowMapper().mapRow(rs, rowNum), - rs.getLong("waiting_rank") - ); - } - - private RowMapper waitingReservationRowMapper() { - return (rs, rowNum) -> WaitingReservation.of( - rs.getLong("id"), - rs.getString("name"), - ReservationDate.of( - rs.getLong("date_id"), - LocalDate.parse(rs.getString("play_day")) - ), - ReservationTime.of( - rs.getLong("time_id"), - LocalTime.parse(rs.getString("start_at")) - ), - Theme.of( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_content"), - rs.getString("theme_url") - ), - rs.getTimestamp("created_at").toLocalDateTime() - ); - } - - private long extractId(KeyHolder keyHolder) { - if (keyHolder.getKey() == null) { - throw new IllegalStateException("생성 키를 조회할 수 없습니다."); - } - return keyHolder.getKey().longValue(); - } -} diff --git a/src/main/java/roomescape/domain/waitingreservation/WaitingReservation.java b/src/main/java/roomescape/domain/waitingreservation/WaitingReservation.java index eece7e79f8..a4f400a722 100644 --- a/src/main/java/roomescape/domain/waitingreservation/WaitingReservation.java +++ b/src/main/java/roomescape/domain/waitingreservation/WaitingReservation.java @@ -1,7 +1,18 @@ package roomescape.domain.waitingreservation; +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.LocalDateTime; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; import roomescape.domain.reservationdate.ReservationDate; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; @@ -9,14 +20,29 @@ import roomescape.support.exception.WaitingReservationErrorCode; @Getter +@Entity +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"name", "date_id", "time_id", "theme_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class WaitingReservation { - private final Long id; - private final String name; - private final ReservationDate date; - private final ReservationTime time; - private final Theme theme; - private final LocalDateTime createdAt; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "date_id") + private ReservationDate date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id") + private ReservationTime time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") + private Theme theme; + + private LocalDateTime createdAt; private WaitingReservation(Long id, String name, ReservationDate date, ReservationTime time, Theme theme, LocalDateTime createdAt) { diff --git a/src/main/java/roomescape/domain/waitingreservation/WaitingReservationRepository.java b/src/main/java/roomescape/domain/waitingreservation/WaitingReservationRepository.java index 243e90d26d..316fcce9a6 100644 --- a/src/main/java/roomescape/domain/waitingreservation/WaitingReservationRepository.java +++ b/src/main/java/roomescape/domain/waitingreservation/WaitingReservationRepository.java @@ -4,25 +4,78 @@ import java.time.LocalTime; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import roomescape.domain.waitingreservation.dto.WaitingReservationWithRank; -public interface WaitingReservationRepository { +public interface WaitingReservationRepository extends JpaRepository { - WaitingReservation save(WaitingReservation waitingReservation); + boolean existsByNameAndDateIdAndTimeIdAndThemeId(String name, Long dateId, Long timeId, Long themeId); - boolean existsByNameAndDateIdAndTimeIdAndThemeId(String name, long dateId, long timeId, long themeId); + @EntityGraph(attributePaths = {"date", "time", "theme"}) + Optional findFirstByDateIdAndTimeIdAndThemeIdOrderByCreatedAtAscIdAsc( + Long dateId, + Long timeId, + Long themeId + ); - Optional findOldestBySlot(long dateId, long timeId, long themeId); + default Optional findOldestBySlot(long dateId, long timeId, long themeId) { + return findFirstByDateIdAndTimeIdAndThemeIdOrderByCreatedAtAscIdAsc(dateId, timeId, themeId); + } - List findAllByNameWithRank(String name); + @EntityGraph(attributePaths = {"date", "time", "theme"}) + @Query(""" + select new roomescape.domain.waitingreservation.dto.WaitingReservationWithRank( + w, + ( + select count(w2) + 1 + from WaitingReservation w2 + where w2.date = w.date + and w2.time = w.time + and w2.theme = w.theme + and ( + w2.createdAt < w.createdAt + or (w2.createdAt = w.createdAt and w2.id < w.id) + ) + ) + ) + from WaitingReservation w + where w.name = :name + order by w.date.playDay, w.time.startAt, w.id + """) + List findAllByNameWithRank(@Param("name") String name); + @EntityGraph(attributePaths = {"date", "time", "theme"}) + @Query(""" + select new roomescape.domain.waitingreservation.dto.WaitingReservationWithRank( + w, + ( + select count(w2) + 1 + from WaitingReservation w2 + where w2.date = w.date + and w2.time = w.time + and w2.theme = w.theme + and ( + w2.createdAt < w.createdAt + or (w2.createdAt = w.createdAt and w2.id < w.id) + ) + ) + ) + from WaitingReservation w + where w.name = :name + and (w.date.playDay > :currentDate + or (w.date.playDay = :currentDate and w.time.startAt > :currentTime)) + order by w.date.playDay, w.time.startAt, w.id + """) List findUpcomingByNameWithRank( + @Param("name") String name, + @Param("currentDate") LocalDate currentDate, + @Param("currentTime") LocalTime currentTime ); - int deleteById(Long id); - - Optional findById(Long id); } diff --git a/src/main/java/roomescape/domain/waitingreservation/WaitingReservationService.java b/src/main/java/roomescape/domain/waitingreservation/WaitingReservationService.java index b71acc31f7..7261cb88b9 100644 --- a/src/main/java/roomescape/domain/waitingreservation/WaitingReservationService.java +++ b/src/main/java/roomescape/domain/waitingreservation/WaitingReservationService.java @@ -70,10 +70,8 @@ private void validateReservableDate(ReservationSlot slot) { } public void cancelWaitingReservation(Long id) { - int deletedCount = waitingReservationRepository.deleteById(id); - if (deletedCount == 0) { - throw new RoomescapeException(WaitingReservationErrorCode.WAITING_RESERVATION_NOT_FOUND); - } + WaitingReservation waitingReservation = getWaitingReservation(id); + waitingReservationRepository.delete(waitingReservation); } public List getWaitingReservationsWithRankByName(String name) { @@ -82,4 +80,9 @@ public List getWaitingReservationsWithRankBy .map(WaitingReservationWithRankResponse::from) .toList(); } + + private WaitingReservation getWaitingReservation(Long id) { + return waitingReservationRepository.findById(id) + .orElseThrow(() -> new RoomescapeException(WaitingReservationErrorCode.WAITING_RESERVATION_NOT_FOUND)); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dec90cad05..244884e37c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,5 +5,13 @@ spring: path: /h2-console datasource: url: jdbc:h2:mem:database + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true 팅token: ${ADMIN_TOKEN:sanwhale0192} diff --git a/src/test/java/roomescape/domain/reservation/JdbcReservationRepositoryTest.java b/src/test/java/roomescape/domain/reservation/JdbcReservationRepositoryTest.java deleted file mode 100644 index 6643889b3b..0000000000 --- a/src/test/java/roomescape/domain/reservation/JdbcReservationRepositoryTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package roomescape.domain.reservation; - -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.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.reservationdate.ReservationDate; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; - -@JdbcTest -class JdbcReservationRepositoryTest { - - private static final long DATE_ID = 101L; - private static final long TIME_ID = 201L; - private static final long THEME_ID = 301L; - private static final LocalDate PLAY_DAY = LocalDate.of(2026, 5, 15); - private static final LocalTime START_AT = LocalTime.of(10, 0); - - @Autowired - private JdbcTemplate jdbcTemplate; - - private ReservationRepository reservationRepository; - - private ReservationDate reservationDate; - private ReservationTime reservationTime; - private Theme theme; - - @BeforeEach - void setUp() { - reservationRepository = new JdbcReservationRepository(jdbcTemplate); - - jdbcTemplate.update("insert into reservation_date(id, play_day) values (?, ?)", DATE_ID, PLAY_DAY.toString()); - reservationDate = ReservationDate.of(DATE_ID, PLAY_DAY); - - jdbcTemplate.update("insert into reservation_time(id, start_at) values (?, ?)", TIME_ID, START_AT.toString()); - reservationTime = ReservationTime.of(TIME_ID, START_AT); - - jdbcTemplate.update("insert into theme(id, name, content, url) values (?, ?, ?, ?)", - THEME_ID, "테마", "설명", "url"); - theme = Theme.of(THEME_ID, "테마", "설명", "url"); - } - - @Test - @DisplayName("예약을 저장한다.") - void save() { - Reservation reservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - Reservation saved = reservationRepository.save(reservation); - - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getName()).isEqualTo("테스터"); - } - - @Test - @DisplayName("모든 예약을 조회한다.") - void findAll() { - int beforeSize = reservationRepository.findAll().size(); - Reservation reservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - reservationRepository.save(reservation); - - List reservations = reservationRepository.findAll(); - - assertThat(reservations).hasSize(beforeSize + 1); - } - - @Test - @DisplayName("ID로 예약을 삭제한다.") - void deleteById() { - Reservation reservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - Reservation saved = reservationRepository.save(reservation); - int beforeSize = reservationRepository.findAll().size(); - - int deletedCount = reservationRepository.deleteById(saved.getId()); - - assertThat(deletedCount).isEqualTo(1); - assertThat(reservationRepository.findAll()).hasSize(beforeSize - 1); - assertThat(reservationRepository.findById(saved.getId())).isEmpty(); - } - - @Test - @DisplayName("이름으로 예약을 조회한다.") - void findByName() { - Reservation reservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - reservationRepository.save(reservation); - - List reservations = reservationRepository.findByName("테스터"); - - assertThat(reservations).hasSize(1); - assertThat(reservations.get(0).getName()).isEqualTo("테스터"); - } - - @Test - @DisplayName("이름으로 예약 시작 시각이 지나지 않은 예약을 조회한다.") - void findUpcomingByName() { - Reservation pastReservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - reservationRepository.save(pastReservation); - - ReservationDate futureDate = ReservationDate.of(102L, LocalDate.of(2026, 5, 15)); - ReservationTime futureTime = ReservationTime.of(202L, LocalTime.of(10, 1)); - Theme futureTheme = Theme.of(302L, "미래테마", "설명", "url"); - jdbcTemplate.update("insert into reservation_date(id, play_day) values (?, ?)", - futureDate.getId(), futureDate.getPlayDay().toString()); - jdbcTemplate.update("insert into reservation_time(id, start_at) values (?, ?)", - futureTime.getId(), futureTime.getStartAt().toString()); - jdbcTemplate.update("insert into theme(id, name, content, url) values (?, ?, ?, ?)", - futureTheme.getId(), futureTheme.getName(), futureTheme.getContent(), futureTheme.getUrl()); - reservationRepository.save(Reservation.createWithoutId("테스터", futureDate, futureTime, futureTheme)); - - List reservations = reservationRepository.findUpcomingByName( - "테스터", - LocalDate.of(2026, 5, 15), - LocalTime.of(10, 0) - ); - - assertThat(reservations).singleElement() - .extracting(reservation -> reservation.getTime().getStartAt()) - .isEqualTo(LocalTime.of(10, 1)); - } - - @Test - @DisplayName("날짜, 시간, 테마가 중복되는 예약이 있는지 확인한다.") - void existsByDateIdAndTimeIdAndThemeId() { - Reservation reservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); - reservationRepository.save(reservation); - - boolean exists = reservationRepository.existsByDateIdAndTimeIdAndThemeId( - reservationDate.getId(), reservationTime.getId(), theme.getId()); - - assertThat(exists).isTrue(); - } -} diff --git a/src/test/java/roomescape/domain/reservation/JpaPersistenceContextObservationTest.java b/src/test/java/roomescape/domain/reservation/JpaPersistenceContextObservationTest.java new file mode 100644 index 0000000000..b372cd38f8 --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/JpaPersistenceContextObservationTest.java @@ -0,0 +1,199 @@ +package roomescape.domain.reservation; + +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import roomescape.domain.reservationdate.ReservationDate; +import roomescape.domain.reservationdate.ReservationDateRepository; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.reservationtime.ReservationTimeRepository; +import roomescape.domain.theme.Theme; +import roomescape.domain.theme.ThemeRepository; + +@DataJpaTest +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class JpaPersistenceContextObservationTest { + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private ReservationDateRepository reservationDateRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate tx; + + @BeforeEach + void setUp() { + tx = new TransactionTemplate(transactionManager); + } + + @Test + @DisplayName("dirty checking: save 없이 엔티티 필드 변경만으로 UPDATE가 발생하는지 관찰한다.") + void observeDirtyChecking() { + Long reservationId = createReservation("dirty-checking", LocalDate.of(2026, 6, 20), LocalTime.of(16, 0)); + + tx.executeWithoutResult(status -> { + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); + ReservationDate newDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 6, 21)) + ); + ReservationTime newTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(17, 0)) + ); + + // 관찰 포인트: + // - 아래 코드는 reservationRepository.save(reservation)를 호출하지 않는다. + // - 그런데 flush 시점에 reservation의 date_id, time_id 변경을 감지해 UPDATE가 발생하는지 콘솔을 본다. + reservation.changeSlot(newDate, newTime); + + System.out.println("\n[dirty checking] flush 직전에 UPDATE SQL이 아직 보이지 않는지 확인"); + reservationRepository.flush(); + System.out.println("[dirty checking] flush 직후 reservation UPDATE SQL이 찍혔는지 확인\n"); + }); + } + + @Test + @DisplayName("1차 캐시: 같은 트랜잭션에서 같은 엔티티를 두 번 조회하면 SELECT가 한 번만 발생하는지 관찰한다.") + void observeFirstLevelCache() { + Long reservationId = createReservation("first-cache", LocalDate.of(2026, 6, 20), LocalTime.of(16, 0)); + + tx.executeWithoutResult(status -> { + System.out.println("\n[first-level cache] 첫 번째 findById: reservation SELECT가 찍혀야 한다."); + Reservation first = reservationRepository.findById(reservationId).orElseThrow(); + + System.out.println("[first-level cache] 두 번째 findById: 같은 트랜잭션 1차 캐시 때문에 SELECT가 생략되는지 본다."); + Reservation second = reservationRepository.findById(reservationId).orElseThrow(); + + System.out.println("[first-level cache] 같은 Java 객체 참조인가? " + (first == second) + "\n"); + }); + } + + @Test + @DisplayName("쓰기 지연: IDENTITY 전략에서 INSERT가 언제 발생하는지 관찰한다.") + void observeWriteBehindWithIdentityStrategy() { + tx.executeWithoutResult(status -> { + ReservationDate date = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 6, 20)) + ); + ReservationTime time = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(16, 0)) + ); + Theme theme = themeRepository.save( + Theme.createWithoutId("쓰기 지연", "IDENTITY 관찰", "/themes/write-behind") + ); + + System.out.println("\n[write-behind] Reservation save 호출 직전"); + reservationRepository.save(Reservation.createWithoutId("write-behind", date, time, theme)); + System.out.println("[write-behind] Reservation save 호출 직후"); + + // 관찰 포인트: + // - 일반적인 쓰기 지연 설명은 INSERT가 flush/commit까지 지연된다고 설명한다. + // - 하지만 현재 엔티티들은 IDENTITY 전략을 사용하므로 DB가 id를 생성해야 한다. + // - 그래서 Hibernate가 id를 얻기 위해 save 시점에 INSERT를 바로 실행하는지 콘솔에서 확인한다. + // - 이 차이가 "전략에 따라 쓰기 지연 관찰 결과가 달라진다"는 핵심이다. + System.out.println("[write-behind] flush 호출 직전: INSERT가 이미 보였는지 확인"); + reservationRepository.flush(); + System.out.println("[write-behind] flush 호출 직후: 추가 INSERT가 또 찍히는지 확인\n"); + }); + } + + @Test + @DisplayName("flush 시점: JPQL 실행 직전에 변경 내용이 DB와 동기화되는지 관찰한다.") + void observeFlushBeforeJpql() { + Long reservationId = createReservation("flush-before-jpql", LocalDate.of(2026, 6, 20), LocalTime.of(16, 0)); + + tx.executeWithoutResult(status -> { + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); + ReservationDate newDate = reservationDateRepository.save( + ReservationDate.createWithoutId(LocalDate.of(2026, 6, 22)) + ); + ReservationTime newTime = reservationTimeRepository.save( + ReservationTime.createWithoutId(LocalTime.of(18, 0)) + ); + + reservation.changeSlot(newDate, newTime); + + System.out.println("\n[flush before JPQL] JPQL 실행 직전"); + entityManager.createQuery("select r from Reservation r", Reservation.class) + .getResultList(); + System.out.println("[flush before JPQL] JPQL 직전에 reservation UPDATE가 먼저 flush됐는지 확인\n"); + }); + } + + @Test + @DisplayName("fetch: ManyToOne LAZY에서 연관 필드 접근 시 추가 SELECT가 발생하는지 관찰한다.") + void observeLazyManyToOneFetch() { + Long reservationId = createReservation("lazy-fetch", LocalDate.of(2026, 6, 20), LocalTime.of(16, 0)); + + tx.executeWithoutResult(status -> { + entityManager.clear(); + + System.out.println("\n[fetch LAZY] findById: reservation SELECT만 먼저 찍히는지 본다."); + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); + + System.out.println("[fetch LAZY] getTime().getStartAt(): reservation_time SELECT가 이 시점에 추가로 찍히는지 본다."); + reservation.getTime().getStartAt(); + + // 관찰 포인트: + // - JPA의 @ManyToOne 기본값은 EAGER다. + // - 현재 코드는 의도적으로 fetch = LAZY를 지정했다. + // - 그래서 예약 조회 시 join으로 time을 바로 가져오지 않고, 실제 time 필드 접근 시 SELECT가 발생한다. + System.out.println("[fetch LAZY] ManyToOne 기본값은 EAGER지만, 현재 매핑은 명시적으로 LAZY다.\n"); + }); + } + + @Test + @DisplayName("LazyInitializationException: 트랜잭션 밖에서 LAZY 필드 접근 시 예외가 발생하는지 관찰한다.") + void observeLazyInitializationException() { + Long reservationId = createReservation("lazy-exception", LocalDate.of(2026, 6, 20), LocalTime.of(16, 0)); + + Reservation reservationOutsideTransaction = tx.execute(status -> { + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); + + // 관찰 포인트: + // - 여기서는 time 필드에 접근하지 않는다. + // - 트랜잭션이 끝난 뒤 밖에서 접근해야 영속성 컨텍스트가 닫힌 상태를 볼 수 있다. + return reservation; + }); + + System.out.println("\n[lazy exception] 트랜잭션 종료 후 getTime().getStartAt() 접근"); + try { + reservationOutsideTransaction.getTime().getStartAt(); + System.out.println("[lazy exception] 예외가 발생하지 않았다면, 이미 프록시가 초기화됐거나 설정을 다시 확인해야 한다.\n"); + } catch (RuntimeException exception) { + System.out.println("[lazy exception] 발생한 예외 타입: " + exception.getClass().getName()); + System.out.println("[lazy exception] 메시지: " + exception.getMessage() + "\n"); + } + } + + private Long createReservation(String name, LocalDate playDay, LocalTime startAt) { + return tx.execute(status -> { + ReservationDate date = reservationDateRepository.save(ReservationDate.createWithoutId(playDay)); + ReservationTime time = reservationTimeRepository.save(ReservationTime.createWithoutId(startAt)); + Theme theme = themeRepository.save(Theme.createWithoutId(name + " 테마", "설명", "/themes/" + name)); + Reservation reservation = reservationRepository.save(Reservation.createWithoutId(name, date, time, theme)); + return reservation.getId(); + }); + } +} diff --git a/src/test/java/roomescape/domain/reservation/JpaReservationRepositoryTest.java b/src/test/java/roomescape/domain/reservation/JpaReservationRepositoryTest.java new file mode 100644 index 0000000000..b466e6bb3d --- /dev/null +++ b/src/test/java/roomescape/domain/reservation/JpaReservationRepositoryTest.java @@ -0,0 +1,90 @@ +package roomescape.domain.reservation; + +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.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import roomescape.domain.reservationdate.ReservationDate; +import roomescape.domain.reservationdate.ReservationDateRepository; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.reservationtime.ReservationTimeRepository; +import roomescape.domain.theme.Theme; +import roomescape.domain.theme.ThemeRepository; + +@DataJpaTest +class JpaReservationRepositoryTest { + + private static final LocalDate PLAY_DAY = LocalDate.of(2026, 5, 15); + private static final LocalTime START_AT = LocalTime.of(10, 0); + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private ReservationDateRepository reservationDateRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private TestEntityManager entityManager; + + private ReservationDate reservationDate; + private ReservationTime reservationTime; + private Theme theme; + + @BeforeEach + void setUp() { + reservationDate = reservationDateRepository.save(ReservationDate.createWithoutId(PLAY_DAY)); + reservationTime = reservationTimeRepository.save(ReservationTime.createWithoutId(START_AT)); + theme = themeRepository.save(Theme.createWithoutId("테마", "설명", "url")); + } + + @Test + @DisplayName("이름으로 예약 시작 시각이 지나지 않은 예약을 조회한다.") + void findUpcomingByName() { + Reservation pastReservation = Reservation.createWithoutId("테스터", reservationDate, reservationTime, theme); + reservationRepository.save(pastReservation); + + ReservationDate futureDate = ReservationDate.of(102L, LocalDate.of(2026, 5, 15)); + ReservationTime futureTime = reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 1))); + Theme futureTheme = themeRepository.save(Theme.createWithoutId("미래테마", "설명", "url")); + futureDate = reservationDateRepository.save(ReservationDate.createWithoutId(futureDate.getPlayDay())); + reservationRepository.save(Reservation.createWithoutId("테스터", futureDate, futureTime, futureTheme)); + + List reservations = reservationRepository.findUpcomingByName( + "테스터", + LocalDate.of(2026, 5, 15), + LocalTime.of(10, 0) + ); + + assertThat(reservations).singleElement() + .extracting(reservation -> reservation.getTime().getStartAt()) + .isEqualTo(LocalTime.of(10, 1)); + } + + @Test + @DisplayName("예약 조회 후 시간 필드 접근 시 발생하는 SQL을 관찰한다.") + void observeLazyLoadingSqlWhenAccessTime() { + Reservation reservation = reservationRepository.save( + Reservation.createWithoutId("쿠키", reservationDate, reservationTime, theme) + ); + entityManager.flush(); + entityManager.clear(); + + Reservation found = reservationRepository.findById(reservation.getId()).orElseThrow(); + LocalTime startAt = found.getTime().getStartAt(); + + assertThat(startAt).isEqualTo(START_AT); + } +} diff --git a/src/test/java/roomescape/domain/reservation/ReservationServiceIntegrationTest.java b/src/test/java/roomescape/domain/reservation/ReservationServiceIntegrationTest.java index a2b3ed7b32..a13a3cb57a 100644 --- a/src/test/java/roomescape/domain/reservation/ReservationServiceIntegrationTest.java +++ b/src/test/java/roomescape/domain/reservation/ReservationServiceIntegrationTest.java @@ -200,10 +200,11 @@ void setUp() { ); doThrow(new IllegalStateException("예약 대기 삭제 실패")) .when(waitingReservationRepository) - .deleteById(firstWaiting.getId()); + .delete(argThat(waiting -> waiting.getId().equals(firstWaiting.getId()))); assertThatThrownBy(() -> reservationService.cancelReservation(cancelledReservation.getId())) - .isInstanceOf(IllegalStateException.class); + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("예약 대기 삭제 실패"); verify(reservationRepository).save(argThat(reservation -> isPromotedReservation(reservation, cancelledSlot))); assertThat(reservationRepository.findById(cancelledReservation.getId())).isPresent(); @@ -231,7 +232,7 @@ void setUp() { ); doThrow(new IllegalStateException("예약 대기 삭제 실패")) .when(waitingReservationRepository) - .deleteById(firstWaiting.getId()); + .delete(argThat(waiting -> waiting.getId().equals(firstWaiting.getId()))); assertThatThrownBy(() -> reservationService.updateReservation( updatedReservation.getId(), @@ -239,7 +240,8 @@ void setUp() { newSlot.date().getId(), newSlot.time().getId() ) - )).isInstanceOf(IllegalStateException.class); + )).isInstanceOf(RuntimeException.class) + .hasMessageContaining("예약 대기 삭제 실패"); verify(reservationRepository).save(argThat(reservation -> isPromotedReservation(reservation, cancelledSlot))); Reservation rollbackedReservation = reservationRepository.findById(updatedReservation.getId()).orElseThrow(); diff --git a/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java b/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java index 733d85c67d..b3ccb9f488 100644 --- a/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java +++ b/src/test/java/roomescape/domain/reservation/ReservationServiceTest.java @@ -2,13 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.time.Clock; import java.time.LocalDate; import java.time.LocalTime; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,9 +25,7 @@ import roomescape.domain.reservationtime.ReservationTimeRepository; import roomescape.domain.theme.Theme; import roomescape.domain.theme.ThemeRepository; -import roomescape.domain.waitingreservation.WaitingReservation; import roomescape.domain.waitingreservation.WaitingReservationRepository; -import roomescape.domain.waitingreservation.dto.WaitingReservationWithRank; import roomescape.support.exception.ReservationDateErrorCode; import roomescape.support.exception.ReservationErrorCode; import roomescape.support.exception.RoomescapeException; @@ -34,19 +35,23 @@ class ReservationServiceTest { private static final Clock CLOCK = Clock.systemDefaultZone(); private ReservationService reservationService; - private FakeReservationRepository reservationRepository; - private FakeReservationDateRepository reservationDateRepository; - private FakeReservationTimeRepository reservationTimeRepository; - private FakeThemeRepository themeRepository; - private FakeWaitingReservationRepository waitingReservationRepository; + private ReservationRepository reservationRepository; + private ReservationDateRepository reservationDateRepository; + private ReservationTimeRepository reservationTimeRepository; + private ThemeRepository themeRepository; + private WaitingReservationRepository waitingReservationRepository; @BeforeEach void setUp() { - reservationRepository = new FakeReservationRepository(); - reservationDateRepository = new FakeReservationDateRepository(); - reservationTimeRepository = new FakeReservationTimeRepository(); - themeRepository = new FakeThemeRepository(); - waitingReservationRepository = new FakeWaitingReservationRepository(); + reservationRepository = mock(ReservationRepository.class); + reservationDateRepository = mock(ReservationDateRepository.class); + reservationTimeRepository = mock(ReservationTimeRepository.class); + themeRepository = mock(ThemeRepository.class); + configureReservationRepository(reservationRepository); + configureReservationDateRepository(reservationDateRepository); + configureReservationTimeRepository(reservationTimeRepository); + configureThemeRepository(themeRepository); + waitingReservationRepository = mock(WaitingReservationRepository.class); reservationService = new ReservationService( reservationRepository, @@ -56,6 +61,146 @@ void setUp() { ); } + private void configureReservationRepository(ReservationRepository repository) { + List reservations = new ArrayList<>(); + long[] idCounter = {1L}; + when(repository.save(any(Reservation.class))).thenAnswer(invocation -> { + Reservation reservation = invocation.getArgument(0); + Reservation saved = Reservation.of( + idCounter[0]++, + reservation.getName(), + reservation.getDate(), + reservation.getTime(), + reservation.getTheme() + ); + reservations.add(saved); + return saved; + }); + when(repository.findAll()).thenAnswer(invocation -> reservations); + doAnswer(invocation -> { + Reservation target = invocation.getArgument(0); + reservations.removeIf(reservation -> reservation.getId().equals(target.getId())); + return null; + }).when(repository).delete(any(Reservation.class)); + when(repository.countByTimeId(any(Long.class))).thenAnswer(invocation -> { + Long timeId = invocation.getArgument(0); + return (int) reservations.stream() + .filter(reservation -> reservation.getTime().getId().equals(timeId)) + .count(); + }); + when(repository.countByDateId(any(Long.class))).thenAnswer(invocation -> { + Long dateId = invocation.getArgument(0); + return (int) reservations.stream() + .filter(reservation -> reservation.getDate().getId().equals(dateId)) + .count(); + }); + when(repository.findReservedTimes(any(Long.class), any(Long.class))).thenAnswer(invocation -> { + Long themeId = invocation.getArgument(0); + Long dateId = invocation.getArgument(1); + return reservations.stream() + .filter(reservation -> reservation.getTheme().getId().equals(themeId) + && reservation.getDate().getId().equals(dateId)) + .map(reservation -> reservation.getTime().getId()) + .toList(); + }); + when(repository.countByThemeId(any(Long.class))).thenAnswer(invocation -> { + Long themeId = invocation.getArgument(0); + return (int) reservations.stream() + .filter(reservation -> reservation.getTheme().getId().equals(themeId)) + .count(); + }); + when(repository.findByName(any(String.class))).thenAnswer(invocation -> { + String name = invocation.getArgument(0); + return reservations.stream() + .filter(reservation -> reservation.getName().equals(name)) + .toList(); + }); + when(repository.findUpcomingByName(any(String.class), any(LocalDate.class), any(LocalTime.class))) + .thenAnswer(invocation -> { + String name = invocation.getArgument(0); + LocalDate currentDate = invocation.getArgument(1); + LocalTime currentTime = invocation.getArgument(2); + return reservations.stream() + .filter(reservation -> reservation.getName().equals(name)) + .filter(reservation -> reservation.getDate().getPlayDay().isAfter(currentDate) + || (reservation.getDate().getPlayDay().isEqual(currentDate) + && reservation.getTime().getStartAt().isAfter(currentTime))) + .toList(); + }); + when(repository.findById(any(Long.class))).thenAnswer(invocation -> { + Long id = invocation.getArgument(0); + return reservations.stream() + .filter(reservation -> reservation.getId().equals(id)) + .findFirst(); + }); + when(repository.existsByDateIdAndTimeIdAndThemeId(any(Long.class), any(Long.class), any(Long.class))) + .thenAnswer(invocation -> { + Long dateId = invocation.getArgument(0); + Long timeId = invocation.getArgument(1); + Long themeId = invocation.getArgument(2); + return reservations.stream() + .anyMatch(reservation -> reservation.getDate().getId().equals(dateId) + && reservation.getTime().getId().equals(timeId) + && reservation.getTheme().getId().equals(themeId)); + }); + } + + private void configureReservationDateRepository(ReservationDateRepository repository) { + List dates = new ArrayList<>(); + long[] idCounter = {1L}; + when(repository.save(any(ReservationDate.class))).thenAnswer(invocation -> { + ReservationDate reservationDate = invocation.getArgument(0); + ReservationDate saved = ReservationDate.of(idCounter[0]++, reservationDate.getPlayDay()); + dates.add(saved); + return saved; + }); + when(repository.findById(any(Long.class))).thenAnswer(invocation -> { + Long id = invocation.getArgument(0); + return dates.stream().filter(date -> date.getId().equals(id)).findFirst(); + }); + when(repository.findAll()).thenAnswer(invocation -> dates); + when(repository.existsByPlayDay(any(LocalDate.class))).thenAnswer(invocation -> { + LocalDate playDay = invocation.getArgument(0); + return dates.stream().anyMatch(date -> date.getPlayDay().equals(playDay)); + }); + } + + private void configureReservationTimeRepository(ReservationTimeRepository repository) { + List times = new ArrayList<>(); + long[] idCounter = {1L}; + when(repository.save(any(ReservationTime.class))).thenAnswer(invocation -> { + ReservationTime reservationTime = invocation.getArgument(0); + ReservationTime saved = ReservationTime.of(idCounter[0]++, reservationTime.getStartAt()); + times.add(saved); + return saved; + }); + when(repository.findById(any(Long.class))).thenAnswer(invocation -> { + Long id = invocation.getArgument(0); + return times.stream().filter(time -> time.getId().equals(id)).findFirst(); + }); + when(repository.findAll()).thenAnswer(invocation -> times); + when(repository.existsByStartAt(any(LocalTime.class))).thenAnswer(invocation -> { + LocalTime startAt = invocation.getArgument(0); + return times.stream().anyMatch(time -> time.getStartAt().equals(startAt)); + }); + } + + private void configureThemeRepository(ThemeRepository repository) { + List themes = new ArrayList<>(); + long[] idCounter = {1L}; + when(repository.save(any(Theme.class))).thenAnswer(invocation -> { + Theme theme = invocation.getArgument(0); + Theme saved = Theme.of(idCounter[0]++, theme.getName(), theme.getContent(), theme.getUrl()); + themes.add(saved); + return saved; + }); + when(repository.findById(any(Long.class))).thenAnswer(invocation -> { + Long id = invocation.getArgument(0); + return themes.stream().filter(theme -> theme.getId().equals(id)).findFirst(); + }); + when(repository.findAll()).thenAnswer(invocation -> themes); + } + @Test @DisplayName("예약을 생성한다.") void createReservation() { @@ -308,239 +453,4 @@ void updateClosedReservation() { .hasMessageContaining(ReservationDateErrorCode.RESERVATION_DATE_NOT_ALLOWED.getMessage()); } - private static class FakeReservationRepository implements ReservationRepository { - - private final List reservations = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Reservation save(Reservation reservation) { - Reservation saved = Reservation.of(idCounter++, reservation.getName(), reservation.getDate(), - reservation.getTime(), reservation.getTheme()); - reservations.add(saved); - return saved; - } - - @Override - public List findAll() { - return reservations; - } - - @Override - public int deleteById(Long id) { - boolean removed = reservations.removeIf(r -> r.getId().equals(id)); - return removed ? 1 : 0; - } - - @Override - public int countByTimeId(Long timeId) { - return (int) reservations.stream().filter(r -> r.getTime().getId().equals(timeId)).count(); - } - - @Override - public int countByReservationDateId(Long dateId) { - return (int) reservations.stream().filter(r -> r.getDate().getId().equals(dateId)).count(); - } - - @Override - public List findReservedTimes(Long themeId, Long dateId) { - return reservations.stream() - .filter(r -> r.getTheme().getId().equals(themeId) && r.getDate().getId().equals(dateId)) - .map(r -> r.getTime().getId()) - .toList(); - } - - @Override - public int countByThemeId(Long id) { - return (int) reservations.stream().filter(r -> r.getTheme().getId().equals(id)).count(); - } - - @Override - public List findByName(String name) { - return reservations.stream().filter(r -> r.getName().equals(name)).toList(); - } - - @Override - public List findUpcomingByName(String name, LocalDate currentDate, LocalTime currentTime) { - return findByName(name).stream() - .filter(r -> r.getDate().getPlayDay().isAfter(currentDate) - || (r.getDate().getPlayDay().isEqual(currentDate) - && r.getTime().getStartAt().isAfter(currentTime))) - .toList(); - } - - @Override - public Optional findById(Long id) { - return reservations.stream().filter(r -> r.getId().equals(id)).findFirst(); - } - - @Override - public int updateReservation(Long id, Long dateId, Long timeId) { - Optional target = findById(id); - if (target.isPresent()) { - Reservation existing = target.get(); - ReservationDate updatedDate = ReservationDate.of(dateId, existing.getDate().getPlayDay().plusDays(1)); - ReservationTime updatedTime = ReservationTime.of(timeId, LocalTime.now()); - - Reservation updated = Reservation.of(id, existing.getName(), updatedDate, updatedTime, - existing.getTheme()); - reservations.remove(existing); - reservations.add(updated); - return 1; - } - return 0; - } - - @Override - public boolean existsByDateIdAndTimeIdAndThemeId(Long dateId, Long timeId, Long themeId) { - return reservations.stream().anyMatch( - r -> r.getDate().getId().equals(dateId) && r.getTime().getId().equals(timeId) && r.getTheme() - .getId() - .equals(themeId)); - } - } - - private static class FakeWaitingReservationRepository implements WaitingReservationRepository { - - @Override - public WaitingReservation save(WaitingReservation waitingReservation) { - return waitingReservation; - } - - @Override - public boolean existsByNameAndDateIdAndTimeIdAndThemeId(String name, long dateId, long timeId, long themeId) { - return false; - } - - @Override - public Optional findOldestBySlot(long dateId, long timeId, long themeId) { - return Optional.empty(); - } - - @Override - public List findAllByNameWithRank(String name) { - return List.of(); - } - - @Override - public List findUpcomingByNameWithRank( - String name, - LocalDate currentDate, - LocalTime currentTime - ) { - return List.of(); - } - - @Override - public int deleteById(Long id) { - return 0; - } - - @Override - public Optional findById(Long id) { - return Optional.empty(); - } - } - - private static class FakeReservationDateRepository implements ReservationDateRepository { - - private final List dates = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { - return dates.stream().filter(d -> d.getId().equals(id)).findFirst(); - } - - @Override - public List findAll() { - return dates; - } - - @Override - public ReservationDate save(ReservationDate reservationDate) { - ReservationDate saved = ReservationDate.of(idCounter++, reservationDate.getPlayDay()); - dates.add(saved); - return saved; - } - - @Override - public int deleteById(Long id) { - dates.removeIf(d -> d.getId().equals(id)); - return 1; - } - - @Override - public boolean existsByPlayDay(LocalDate playDay) { - return dates.stream().anyMatch(d -> d.getPlayDay().equals(playDay)); - } - } - - private static class FakeReservationTimeRepository implements ReservationTimeRepository { - - private final List times = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { - return times.stream().filter(t -> t.getId().equals(id)).findFirst(); - } - - @Override - public ReservationTime save(ReservationTime reservationTime) { - ReservationTime saved = ReservationTime.of(idCounter++, reservationTime.getStartAt()); - times.add(saved); - return saved; - } - - @Override - public List findAll() { - return times; - } - - @Override - public int deleteById(Long id) { - times.removeIf(t -> t.getId().equals(id)); - return 1; - } - - @Override - public boolean existsByStartAt(LocalTime startAt) { - return times.stream().anyMatch(t -> t.getStartAt().equals(startAt)); - } - } - - private static class FakeThemeRepository implements ThemeRepository { - - private final List themes = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { - return themes.stream().filter(t -> t.getId().equals(id)).findFirst(); - } - - @Override - public List findAll() { - return themes; - } - - @Override - public Theme save(Theme theme) { - Theme saved = Theme.of(idCounter++, theme.getName(), theme.getContent(), theme.getUrl()); - themes.add(saved); - return saved; - } - - @Override - public int deleteById(Long id) { - themes.removeIf(t -> t.getId().equals(id)); - return 1; - } - - @Override - public List findPopularThemes(int rankLimit, LocalDate startDay, LocalDate endDay) { - return List.of(); - } - } } diff --git a/src/test/java/roomescape/domain/reservationdate/JdbcReservationDateRepositoryTest.java b/src/test/java/roomescape/domain/reservationdate/JdbcReservationDateRepositoryTest.java deleted file mode 100644 index 4a96cec924..0000000000 --- a/src/test/java/roomescape/domain/reservationdate/JdbcReservationDateRepositoryTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package roomescape.domain.reservationdate; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.jdbc.core.JdbcTemplate; - -@JdbcTest -class JdbcReservationDateRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - private ReservationDateRepository reservationDateRepository; - - @BeforeEach - void setUp() { - reservationDateRepository = new JdbcReservationDateRepository(jdbcTemplate); - } - - @Test - @DisplayName("날짜를 저장한다.") - void save() { - ReservationDate date = ReservationDate.createWithoutId(LocalDate.parse("2026-05-15")); - ReservationDate saved = reservationDateRepository.save(date); - - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getPlayDay()).isEqualTo(LocalDate.parse("2026-05-15")); - } - - @Test - @DisplayName("모든 날짜를 조회한다.") - void findAll() { - int beforeSize = reservationDateRepository.findAll().size(); - reservationDateRepository.save(ReservationDate.createWithoutId(LocalDate.parse("2026-05-15"))); - - List dates = reservationDateRepository.findAll(); - - assertThat(dates).hasSize(beforeSize + 1); - } - - @Test - @DisplayName("ID로 날짜를 삭제한다.") - void deleteById() { - ReservationDate saved = reservationDateRepository.save( - ReservationDate.createWithoutId(LocalDate.parse("2026-05-15"))); - int beforeSize = reservationDateRepository.findAll().size(); - - int deletedCount = reservationDateRepository.deleteById(saved.getId()); - - assertThat(deletedCount).isEqualTo(1); - assertThat(reservationDateRepository.findAll()).hasSize(beforeSize - 1); - } - - @Test - @DisplayName("특정 날짜가 존재하는지 확인한다.") - void existsByPlayDay() { - LocalDate playDay = LocalDate.parse("2026-05-15"); - reservationDateRepository.save(ReservationDate.createWithoutId(playDay)); - - boolean exists = reservationDateRepository.existsByPlayDay(playDay); - - assertThat(exists).isTrue(); - } -} diff --git a/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java b/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java index f9a3e5b551..875f136edd 100644 --- a/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java +++ b/src/test/java/roomescape/domain/reservationdate/ReservationDateServiceTest.java @@ -2,15 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationRepository; import roomescape.domain.reservationdate.dto.ReservationDateCreationRequest; import roomescape.domain.reservationdate.dto.ReservationDateCreationResponse; @@ -20,13 +23,13 @@ class ReservationDateServiceTest { private ReservationDateService reservationDateService; - private FakeReservationDateRepository reservationDateRepository; - private FakeReservationRepository reservationRepository; + private ReservationDateRepository reservationDateRepository; + private ReservationRepository reservationRepository; @BeforeEach void setUp() { - reservationDateRepository = new FakeReservationDateRepository(); - reservationRepository = new FakeReservationRepository(); + reservationDateRepository = mock(ReservationDateRepository.class); + reservationRepository = mock(ReservationRepository.class); reservationDateService = new ReservationDateService(reservationRepository, reservationDateRepository); } @@ -34,29 +37,35 @@ void setUp() { @DisplayName("예약 날짜를 생성한다.") void createReservationDate() { ReservationDateCreationRequest request = new ReservationDateCreationRequest(LocalDate.now().plusDays(1)); + when(reservationDateRepository.existsByPlayDay(request.playDay())).thenReturn(false); + when(reservationDateRepository.save(any(ReservationDate.class))) + .thenReturn(ReservationDate.of(1L, request.playDay())); ReservationDateCreationResponse response = reservationDateService.createReservationDate(request); assertThat(response.playDay()).isEqualTo(request.playDay()); - assertThat(reservationDateRepository.findAll()).hasSize(1); + verify(reservationDateRepository).save(any(ReservationDate.class)); } @Test @DisplayName("중복된 날짜 생성 시 예외가 발생한다.") void createDuplicateDate() { LocalDate playDay = LocalDate.now().plusDays(1); - reservationDateService.createReservationDate(new ReservationDateCreationRequest(playDay)); + when(reservationDateRepository.existsByPlayDay(playDay)).thenReturn(true); assertThatThrownBy( () -> reservationDateService.createReservationDate(new ReservationDateCreationRequest(playDay))) .isInstanceOf(RoomescapeException.class); + verify(reservationDateRepository, never()).save(any(ReservationDate.class)); } @Test @DisplayName("오늘 이후의 날짜만 조회한다.") void getAllAvailableReservationDate() { - reservationDateRepository.save(ReservationDate.createWithoutId(LocalDate.now().minusDays(1))); - reservationDateRepository.save(ReservationDate.createWithoutId(LocalDate.now().plusDays(1))); + when(reservationDateRepository.findAll()).thenReturn(List.of( + ReservationDate.of(1L, LocalDate.now().minusDays(1)), + ReservationDate.of(2L, LocalDate.now().plusDays(1)) + )); List responses = reservationDateService.getAllAvailableReservationDate(); @@ -67,113 +76,12 @@ void getAllAvailableReservationDate() { @Test @DisplayName("사용 중인 날짜를 삭제하려 하면 예외가 발생한다.") void deleteInUseDate() { - ReservationDate date = reservationDateRepository.save( - ReservationDate.createWithoutId(LocalDate.now().plusDays(1))); - reservationRepository.setCount(1); + ReservationDate date = ReservationDate.of(1L, LocalDate.now().plusDays(1)); + when(reservationDateRepository.findById(date.getId())).thenReturn(Optional.of(date)); + when(reservationRepository.countByDateId(date.getId())).thenReturn(1); assertThatThrownBy(() -> reservationDateService.deleteReservationDate(date.getId())) .isInstanceOf(RoomescapeException.class); - } - - private static class FakeReservationDateRepository implements ReservationDateRepository { - - private final List dates = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { - return dates.stream().filter(d -> d.getId().equals(id)).findFirst(); - } - - @Override - public List findAll() { - return dates; - } - - @Override - public ReservationDate save(ReservationDate reservationDate) { - ReservationDate saved = ReservationDate.of(idCounter++, reservationDate.getPlayDay()); - dates.add(saved); - return saved; - } - - @Override - public int deleteById(Long id) { - return dates.removeIf(d -> d.getId().equals(id)) ? 1 : 0; - } - - @Override - public boolean existsByPlayDay(LocalDate playDay) { - return dates.stream().anyMatch(d -> d.getPlayDay().equals(playDay)); - } - } - - private static class FakeReservationRepository implements ReservationRepository { - - private int count = 0; - - public void setCount(int count) { - this.count = count; - } - - @Override - public int countByReservationDateId(Long dateId) { - return count; - } - - @Override - public Reservation save(Reservation r) { - return null; - } - - @Override - public List findAll() { - return null; - } - - @Override - public int deleteById(Long id) { - return 0; - } - - @Override - public int countByTimeId(Long id) { - return 0; - } - - @Override - public List findReservedTimes(Long themeId, Long dateId) { - return null; - } - - @Override - public int countByThemeId(Long id) { - return 0; - } - - @Override - public List findByName(String name) { - return null; - } - - @Override - public List findUpcomingByName(String name, LocalDate currentDate, java.time.LocalTime currentTime) { - return null; - } - - @Override - public Optional findById(Long id) { - return Optional.empty(); - } - - @Override - public int updateReservation(Long id, Long d, Long t) { - return 0; - } - - @Override - public boolean existsByDateIdAndTimeIdAndThemeId(Long d, Long t, Long th) { - return false; - } + verify(reservationDateRepository, never()).delete(date); } } diff --git a/src/test/java/roomescape/domain/reservationtime/JdbcReservationTimeRepositoryTest.java b/src/test/java/roomescape/domain/reservationtime/JdbcReservationTimeRepositoryTest.java deleted file mode 100644 index 1c493dc649..0000000000 --- a/src/test/java/roomescape/domain/reservationtime/JdbcReservationTimeRepositoryTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package roomescape.domain.reservationtime; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.jdbc.core.JdbcTemplate; - -@JdbcTest -class JdbcReservationTimeRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - private ReservationTimeRepository reservationTimeRepository; - - @BeforeEach - void setUp() { - reservationTimeRepository = new JdbcReservationTimeRepository(jdbcTemplate); - } - - @Test - @DisplayName("시간을 저장한다.") - void save() { - ReservationTime time = ReservationTime.createWithoutId(LocalTime.of(10, 0)); - ReservationTime saved = reservationTimeRepository.save(time); - - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getStartAt()).isEqualTo(LocalTime.of(10, 0)); - } - - @Test - @DisplayName("모든 시간을 조회한다.") - void findAll() { - int beforeSize = reservationTimeRepository.findAll().size(); - reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 0))); - - List times = reservationTimeRepository.findAll(); - - assertThat(times).hasSize(beforeSize + 1); - } - - @Test - @DisplayName("ID로 시간을 삭제한다.") - void deleteById() { - ReservationTime saved = reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 0))); - int beforeSize = reservationTimeRepository.findAll().size(); - - int deletedCount = reservationTimeRepository.deleteById(saved.getId()); - - assertThat(deletedCount).isEqualTo(1); - assertThat(reservationTimeRepository.findAll()).hasSize(beforeSize - 1); - } - - @Test - @DisplayName("특정 시간이 존재하는지 확인한다.") - void existsByStartAt() { - LocalTime startAt = LocalTime.of(10, 0); - reservationTimeRepository.save(ReservationTime.createWithoutId(startAt)); - - boolean exists = reservationTimeRepository.existsByStartAt(startAt); - - assertThat(exists).isTrue(); - } -} diff --git a/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java b/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java index cbffb631b8..5f33705d45 100644 --- a/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java +++ b/src/test/java/roomescape/domain/reservationtime/ReservationTimeServiceTest.java @@ -2,15 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.LocalTime; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationRepository; import roomescape.domain.reservationtime.dto.ReservationTimeAvailabilityResponse; import roomescape.domain.reservationtime.dto.TimeCreationRequest; @@ -20,44 +23,52 @@ class ReservationTimeServiceTest { private ReservationTimeService reservationTimeService; - private FakeReservationTimeRepository reservationTimeRepository; - private FakeReservationRepository reservationRepository; + private ReservationRepository reservationRepository; + private ReservationTimeRepository reservationTimeRepository; @BeforeEach void setUp() { - reservationTimeRepository = new FakeReservationTimeRepository(); - reservationRepository = new FakeReservationRepository(); - reservationTimeService = new ReservationTimeService(reservationTimeRepository, reservationRepository); + reservationRepository = mock(ReservationRepository.class); + reservationTimeRepository = mock(ReservationTimeRepository.class); + reservationTimeService = new ReservationTimeService( + reservationTimeRepository, + reservationRepository + ); } @Test @DisplayName("예약 시간을 생성한다.") void createReservationTime() { TimeCreationRequest request = new TimeCreationRequest(LocalTime.of(10, 0)); + when(reservationTimeRepository.existsByStartAt(request.startAt())).thenReturn(false); + when(reservationTimeRepository.save(any(ReservationTime.class))) + .thenReturn(ReservationTime.of(1L, request.startAt())); TimeCreationResponse response = reservationTimeService.createReservationTime(request); assertThat(response.startAt()).isEqualTo(request.startAt()); - assertThat(reservationTimeRepository.findAll()).hasSize(1); + verify(reservationTimeRepository).save(any(ReservationTime.class)); } @Test @DisplayName("중복된 시간 생성 시 예외가 발생한다.") void createDuplicateTime() { LocalTime startAt = LocalTime.of(10, 0); - reservationTimeService.createReservationTime(new TimeCreationRequest(startAt)); + when(reservationTimeRepository.existsByStartAt(startAt)).thenReturn(true); assertThatThrownBy(() -> reservationTimeService.createReservationTime(new TimeCreationRequest(startAt))) .isInstanceOf(RoomescapeException.class); + verify(reservationTimeRepository, never()).save(any(ReservationTime.class)); } @Test @DisplayName("특정 테마와 날짜의 예약 가능 시간을 조회한다.") void getReservationTimeAvailability() { // given - ReservationTime time1 = reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 0))); - ReservationTime time2 = reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(11, 0))); - reservationRepository.addReservedTime(time1.getId()); + ReservationTime time1 = ReservationTime.of(1L, LocalTime.of(10, 0)); + ReservationTime time2 = ReservationTime.of(2L, LocalTime.of(11, 0)); + when(reservationTimeRepository.findAll()).thenReturn(List.of(time1, time2)); + when(reservationRepository.findReservedTimes(1L, 1L)).thenReturn(List.of(time1.getId())); // when List responses = reservationTimeService.getReservationTimeAvailability(1L, @@ -74,118 +85,12 @@ void getReservationTimeAvailability() { @Test @DisplayName("사용 중인 시간을 삭제하려 하면 예외가 발생한다.") void deleteInUseTime() { - ReservationTime time = reservationTimeRepository.save(ReservationTime.createWithoutId(LocalTime.of(10, 0))); - reservationRepository.setCount(1); + ReservationTime time = ReservationTime.of(1L, LocalTime.of(10, 0)); + when(reservationTimeRepository.findById(time.getId())).thenReturn(Optional.of(time)); + when(reservationRepository.countByTimeId(time.getId())).thenReturn(1); assertThatThrownBy(() -> reservationTimeService.deleteReservationTime(time.getId())) .isInstanceOf(RoomescapeException.class); - } - - private static class FakeReservationTimeRepository implements ReservationTimeRepository { - - private final List times = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { - return times.stream().filter(t -> t.getId().equals(id)).findFirst(); - } - - @Override - public List findAll() { - return times; - } - - @Override - public ReservationTime save(ReservationTime reservationTime) { - ReservationTime saved = ReservationTime.of(idCounter++, reservationTime.getStartAt()); - times.add(saved); - return saved; - } - - @Override - public int deleteById(Long id) { - return times.removeIf(t -> t.getId().equals(id)) ? 1 : 0; - } - - @Override - public boolean existsByStartAt(LocalTime startAt) { - return times.stream().anyMatch(t -> t.getStartAt().equals(startAt)); - } - } - - private static class FakeReservationRepository implements ReservationRepository { - - private final List reservedTimeIds = new ArrayList<>(); - private int count = 0; - - public void setCount(int count) { - this.count = count; - } - - public void addReservedTime(Long timeId) { - reservedTimeIds.add(timeId); - } - - @Override - public int countByTimeId(Long timeId) { - return count; - } - - @Override - public List findReservedTimes(Long themeId, Long dateId) { - return reservedTimeIds; - } - - // 나머지 미사용 메서드 - @Override - public Reservation save(Reservation r) { - return null; - } - - @Override - public List findAll() { - return null; - } - - @Override - public int deleteById(Long id) { - return 0; - } - - @Override - public int countByReservationDateId(Long id) { - return 0; - } - - @Override - public int countByThemeId(Long id) { - return 0; - } - - @Override - public List findByName(String name) { - return null; - } - - @Override - public List findUpcomingByName(String name, java.time.LocalDate currentDate, LocalTime currentTime) { - return null; - } - - @Override - public Optional findById(Long id) { - return Optional.empty(); - } - - @Override - public int updateReservation(Long id, Long d, Long t) { - return 0; - } - - @Override - public boolean existsByDateIdAndTimeIdAndThemeId(Long d, Long t, Long th) { - return false; - } + verify(reservationTimeRepository, never()).delete(time); } } diff --git a/src/test/java/roomescape/domain/theme/JdbcThemeRepositoryTest.java b/src/test/java/roomescape/domain/theme/JdbcThemeRepositoryTest.java deleted file mode 100644 index 9c88c4831e..0000000000 --- a/src/test/java/roomescape/domain/theme/JdbcThemeRepositoryTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package roomescape.domain.theme; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.jdbc.core.JdbcTemplate; - -@JdbcTest -class JdbcThemeRepositoryTest { - - @Autowired - private JdbcTemplate jdbcTemplate; - - private ThemeRepository themeRepository; - - @BeforeEach - void setUp() { - themeRepository = new JdbcThemeRepository(jdbcTemplate); - } - - @Test - @DisplayName("테마를 저장한다.") - void save() { - Theme theme = Theme.createWithoutId("테마", "설명", "url"); - Theme saved = themeRepository.save(theme); - - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getName()).isEqualTo("테마"); - } - - @Test - @DisplayName("모든 테마를 조회한다.") - void findAll() { - int beforeSize = themeRepository.findAll().size(); - themeRepository.save(Theme.createWithoutId("테마", "설명", "url")); - - List themes = themeRepository.findAll(); - - assertThat(themes).hasSize(beforeSize + 1); - } - - @Test - @DisplayName("ID로 테마를 삭제한다.") - void deleteById() { - Theme saved = themeRepository.save(Theme.createWithoutId("테마", "설명", "url")); - int beforeSize = themeRepository.findAll().size(); - - int deletedCount = themeRepository.deleteById(saved.getId()); - - assertThat(deletedCount).isEqualTo(1); - assertThat(themeRepository.findAll()).hasSize(beforeSize - 1); - } -} diff --git a/src/test/java/roomescape/domain/theme/ThemeServiceTest.java b/src/test/java/roomescape/domain/theme/ThemeServiceTest.java index 348826db2c..fbb79ac5fc 100644 --- a/src/test/java/roomescape/domain/theme/ThemeServiceTest.java +++ b/src/test/java/roomescape/domain/theme/ThemeServiceTest.java @@ -2,31 +2,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationRepository; import roomescape.domain.theme.dto.ThemeCreationRequest; import roomescape.domain.theme.dto.ThemeCreationResponse; -import roomescape.domain.theme.dto.ThemeResponse; import roomescape.support.exception.RoomescapeException; class ThemeServiceTest { private ThemeService themeService; - private FakeThemeRepository themeRepository; - private FakeReservationRepository reservationRepository; + private ThemeRepository themeRepository; + private ReservationRepository reservationRepository; @BeforeEach void setUp() { - themeRepository = new FakeThemeRepository(); - reservationRepository = new FakeReservationRepository(); + themeRepository = mock(ThemeRepository.class); + reservationRepository = mock(ReservationRepository.class); themeService = new ThemeService(themeRepository, reservationRepository); } @@ -34,68 +37,44 @@ void setUp() { @DisplayName("테마를 생성한다.") void createTheme() { ThemeCreationRequest request = new ThemeCreationRequest("테마", "설명", "url"); + when(themeRepository.save(any(Theme.class))) + .thenReturn(Theme.of(1L, "테마", "설명", "url")); ThemeCreationResponse response = themeService.createTheme(request); assertThat(response.name()).isEqualTo("테마"); - assertThat(themeRepository.findAll()).hasSize(1); } @Test @DisplayName("사용 중인 테마를 삭제하려 하면 예외가 발생한다.") void deleteInUseTheme() { - Theme theme = themeRepository.save(Theme.createWithoutId("테마", "설명", "url")); - reservationRepository.setCount(1); + Theme theme = Theme.of(1L, "테마", "설명", "url"); + when(themeRepository.findById(theme.getId())).thenReturn(Optional.of(theme)); + when(reservationRepository.countByThemeId(theme.getId())).thenReturn(1); assertThatThrownBy(() -> themeService.deleteTheme(theme.getId())) .isInstanceOf(RoomescapeException.class); + + verify(themeRepository, never()).delete(theme); + } + + @Test + @DisplayName("존재하지 않는 테마를 삭제하려 하면 예외가 발생한다.") + void deleteNotFoundTheme() { + when(themeRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> themeService.deleteTheme(1L)) + .isInstanceOf(RoomescapeException.class); } @Test @DisplayName("인기 테마 순위를 조회한다.") void getThemeRank() { - themeRepository.save(Theme.createWithoutId("테마1", "설명", "url")); + when(themeRepository.findPopularThemes(anyInt(), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(Theme.of(1L, "테마1", "설명", "url"))); var responses = themeService.getThemeRank(); assertThat(responses).isNotNull(); } - - private static class FakeThemeRepository implements ThemeRepository { - private final List themes = new ArrayList<>(); - private Long idCounter = 1L; - - @Override - public Optional findById(Long id) { return themes.stream().filter(t -> t.getId().equals(id)).findFirst(); } - @Override - public List findAll() { return themes; } - @Override - public Theme save(Theme theme) { - Theme saved = Theme.of(idCounter++, theme.getName(), theme.getContent(), theme.getUrl()); - themes.add(saved); - return saved; - } - @Override - public int deleteById(Long id) { return themes.removeIf(t -> t.getId().equals(id)) ? 1 : 0; } - @Override - public List findPopularThemes(int limit, LocalDate start, LocalDate end) { return themes; } - } - - private static class FakeReservationRepository implements ReservationRepository { - private int count = 0; - public void setCount(int count) { this.count = count; } - @Override public int countByThemeId(Long id) { return count; } - - @Override public Reservation save(Reservation r) { return null; } - @Override public List findAll() { return null; } - @Override public int deleteById(Long id) { return 0; } - @Override public int countByTimeId(Long id) { return 0; } - @Override public int countByReservationDateId(Long id) { return 0; } - @Override public List findReservedTimes(Long themeId, Long dateId) { return null; } - @Override public List findByName(String name) { return null; } - @Override public List findUpcomingByName(String name, LocalDate currentDate, java.time.LocalTime currentTime) { return null; } - @Override public Optional findById(Long id) { return Optional.empty(); } - @Override public int updateReservation(Long id, Long d, Long t) { return 0; } - @Override public boolean existsByDateIdAndTimeIdAndThemeId(Long d, Long t, Long th) { return false; } - } } diff --git a/src/test/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepositoryTest.java b/src/test/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepositoryTest.java deleted file mode 100644 index b83d6f04a3..0000000000 --- a/src/test/java/roomescape/domain/waitingreservation/JdbcWaitingReservationRepositoryTest.java +++ /dev/null @@ -1,231 +0,0 @@ -package roomescape.domain.waitingreservation; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.reservationdate.ReservationDate; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; -import roomescape.domain.waitingreservation.dto.WaitingReservationWithRank; - -@JdbcTest -class JdbcWaitingReservationRepositoryTest { - - private static final long DATE_ID = 101L; - private static final long TIME_ID = 201L; - private static final long THEME_ID = 301L; - private static final LocalDate PLAY_DAY = LocalDate.of(2026, 5, 10); - private static final LocalTime START_AT = LocalTime.of(10, 0); - - @Autowired - private JdbcTemplate jdbcTemplate; - - private WaitingReservationRepository waitingReservationRepository; - private ReservationDate date; - private ReservationTime time; - private Theme theme; - - @BeforeEach - void setUp() { - waitingReservationRepository = new JdbcWaitingReservationRepository(jdbcTemplate); - - jdbcTemplate.update("insert into reservation_date(id, play_day) values (?, ?)", DATE_ID, PLAY_DAY.toString()); - jdbcTemplate.update("insert into reservation_time(id, start_at) values (?, ?)", TIME_ID, START_AT.toString()); - jdbcTemplate.update( - "insert into theme(id, name, content, url) values (?, ?, ?, ?)", - THEME_ID, "공포", "테마 내용", "/themes/scary" - ); - - date = ReservationDate.of(DATE_ID, PLAY_DAY); - time = ReservationTime.of(TIME_ID, START_AT); - theme = Theme.of(THEME_ID, "공포", "테마 내용", "/themes/scary"); - } - - @Test - void 같은_이름_날짜_테마_시간으로_예약_대기를_생성할_수_없다() { - WaitingReservation waitingReservation = waiting("이산", LocalDateTime.of(2026, 5, 9, 10, 0)); - waitingReservationRepository.save(waitingReservation); - - assertThatThrownBy(() -> waitingReservationRepository.save(waitingReservation)) - .isInstanceOf(DuplicateKeyException.class); - } - - @Test - void 가장_먼저_신청한_예약_대기를_가져온다() { - waitingReservationRepository.save(waiting("이산", LocalDateTime.of(2026, 5, 7, 10, 0))); - waitingReservationRepository.save(waiting("고래", LocalDateTime.of(2026, 5, 8, 10, 0))); - waitingReservationRepository.save(waiting("보예", LocalDateTime.of(2026, 5, 9, 10, 0))); - - WaitingReservation oldest = waitingReservationRepository.findOldestBySlot( - date.getId(), - time.getId(), - theme.getId() - ).orElseThrow(); - - assertThat(oldest.getName()).isEqualTo("이산"); - assertThat(oldest.getDate().getId()).isEqualTo(DATE_ID); - assertThat(oldest.getDate().getPlayDay()).isEqualTo(PLAY_DAY); - assertThat(oldest.getTime().getId()).isEqualTo(TIME_ID); - assertThat(oldest.getTime().getStartAt()).isEqualTo(START_AT); - assertThat(oldest.getTheme().getId()).isEqualTo(THEME_ID); - assertThat(oldest.getTheme().getName()).isEqualTo("공포"); - assertThat(oldest.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 5, 7, 10, 0)); - } - - @Test - void 예약_대기가_없으면_가장_먼저_신청한_예약_대기를_조회할_수_없다() { - assertThat(waitingReservationRepository.findOldestBySlot( - date.getId(), - time.getId(), - theme.getId() - ) - ).isEmpty(); - } - - @Test - void 특정_슬롯에서_가장_먼저_신청한_예약_대기를_가져온다() { - Slot otherSlot = insertSlot( - 102L, LocalDate.of(2026, 5, 11), - 202L, LocalTime.of(11, 0), - 302L, "스릴러" - ); - waitingReservationRepository.save(waiting("다른슬롯", otherSlot, LocalDateTime.of(2026, 5, 6, 10, 0))); - waitingReservationRepository.save(waiting("이산", LocalDateTime.of(2026, 5, 7, 10, 0))); - waitingReservationRepository.save(waiting("고래", LocalDateTime.of(2026, 5, 8, 10, 0))); - - WaitingReservation oldest = waitingReservationRepository - .findOldestBySlot(DATE_ID, TIME_ID, THEME_ID) - .orElseThrow(); - - assertThat(oldest.getName()).isEqualTo("이산"); - } - - @Test - void 사용자_이름으로_예약_대기_목록을_조회하면_각_슬롯의_순번을_반환한다() { - waitingReservationRepository.save(waiting("고래", LocalDateTime.of(2026, 5, 7, 10, 0))); - waitingReservationRepository.save(waiting("이산", LocalDateTime.of(2026, 5, 8, 10, 0))); - - Slot secondSlot = insertSlot( - 102L, LocalDate.of(2026, 5, 11), - 202L, LocalTime.of(11, 0), - 302L, "스릴러" - ); - waitingReservationRepository.save(waiting("이산", secondSlot, LocalDateTime.of(2026, 5, 7, 11, 0))); - waitingReservationRepository.save(waiting("브리", secondSlot, LocalDateTime.of(2026, 5, 8, 11, 0))); - - Slot thirdSlot = insertSlot( - 103L, LocalDate.of(2026, 5, 12), - 203L, LocalTime.of(13, 0), - 303L, "미스터리" - ); - waitingReservationRepository.save(waiting("나무", thirdSlot, LocalDateTime.of(2026, 5, 7, 12, 0))); - waitingReservationRepository.save(waiting("고래", thirdSlot, LocalDateTime.of(2026, 5, 8, 12, 0))); - waitingReservationRepository.save(waiting("이산", thirdSlot, LocalDateTime.of(2026, 5, 9, 12, 0))); - - List waitings = waitingReservationRepository.findAllByNameWithRank("이산"); - - assertThat(waitings).hasSize(3); - assertThat(waitings).extracting(result -> result.waitingReservation().getName()) - .containsOnly("이산"); - assertThat(waitings).extracting(result -> result.waitingReservation().getDate().getId()) - .containsExactly(DATE_ID, 102L, 103L); - assertThat(waitings).extracting(WaitingReservationWithRank::rank) - .containsExactly(2L, 1L, 3L); - } - - @Test - void 대기_취소를_하면_정상_삭제한다() { - WaitingReservation actual = waitingReservationRepository.save( - waiting("고래", LocalDateTime.of(2026, 5, 7, 10, 0))); - - waitingReservationRepository.deleteById(actual.getId()); - - assertThat(waitingReservationRepository.findById(actual.getId())).isEmpty(); - } - - @Test - void 예약_대기를_취소하면_같은_슬롯의_남은_예약_대기_순번이_재계산된다() { - WaitingReservation whale = waitingReservationRepository.save(waiting("고래", LocalDateTime.of(2026, 5, 7, 10, 0))); - waitingReservationRepository.save(waiting("이산", LocalDateTime.of(2026, 5, 8, 10, 0))); - waitingReservationRepository.save(waiting("보예", LocalDateTime.of(2026, 5, 9, 10, 0))); - - waitingReservationRepository.deleteById(whale.getId()); - - assertThat(waitingReservationRepository.findAllByNameWithRank("이산")) - .singleElement() - .extracting(WaitingReservationWithRank::rank) - .isEqualTo(1L); - - assertThat(waitingReservationRepository.findAllByNameWithRank("보예")) - .singleElement() - .extracting(WaitingReservationWithRank::rank) - .isEqualTo(2L); - } - - @Test - void 이름으로_예약_시작_시각이_지나지_않은_예약_대기와_순번을_조회한다() { - waitingReservationRepository.save(waiting("이산", LocalDateTime.of(2026, 5, 7, 10, 0))); - - Slot futureSlot = insertSlot( - 102L, LocalDate.of(2026, 5, 10), - 202L, LocalTime.of(10, 1), - 302L, "미래" - ); - waitingReservationRepository.save(waiting("고래", futureSlot, LocalDateTime.of(2026, 5, 7, 10, 0))); - waitingReservationRepository.save(waiting("이산", futureSlot, LocalDateTime.of(2026, 5, 8, 10, 0))); - - List waitings = waitingReservationRepository.findUpcomingByNameWithRank( - "이산", - LocalDate.of(2026, 5, 10), - LocalTime.of(10, 0) - ); - - assertThat(waitings).singleElement() - .extracting(result -> result.waitingReservation().getTime().getStartAt()) - .isEqualTo(LocalTime.of(10, 1)); - assertThat(waitings).singleElement() - .extracting(WaitingReservationWithRank::rank) - .isEqualTo(2L); - } - - private WaitingReservation waiting(String name, LocalDateTime createdAt) { - return WaitingReservation.createWithoutId(name, date, time, theme, createdAt); - } - - private WaitingReservation waiting(String name, Slot slot, LocalDateTime createdAt) { - return WaitingReservation.createWithoutId(name, slot.date(), slot.time(), slot.theme(), createdAt); - } - - private Slot insertSlot(long dateId, LocalDate playDay, long timeId, LocalTime startAt, long themeId, - String themeName) { - jdbcTemplate.update("insert into reservation_date(id, play_day) values (?, ?)", dateId, playDay.toString()); - jdbcTemplate.update("insert into reservation_time(id, start_at) values (?, ?)", timeId, startAt.toString()); - jdbcTemplate.update( - "insert into theme(id, name, content, url) values (?, ?, ?, ?)", - themeId, themeName, "테마 내용", "/themes/" + themeId - ); - return new Slot( - ReservationDate.of(dateId, playDay), - ReservationTime.of(timeId, startAt), - Theme.of(themeId, themeName, "테마 내용", "/themes/" + themeId) - ); - } - - private record Slot( - ReservationDate date, - ReservationTime time, - Theme theme - ) { - } - -} diff --git a/src/test/java/roomescape/domain/waitingreservation/WaitingReservationServiceTest.java b/src/test/java/roomescape/domain/waitingreservation/WaitingReservationServiceTest.java index e2c4baf4a7..9a41ef8db5 100644 --- a/src/test/java/roomescape/domain/waitingreservation/WaitingReservationServiceTest.java +++ b/src/test/java/roomescape/domain/waitingreservation/WaitingReservationServiceTest.java @@ -141,7 +141,7 @@ void setUp() { @Test void 존재하지_않는_예약_대기를_취소하면_예외가_발생한다() { - when(waitingReservationRepository.deleteById(999L)).thenReturn(0); + when(waitingReservationRepository.findById(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> waitingReservationService.cancelWaitingReservation(999L)) .isInstanceOf(RoomescapeException.class) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e8572baac2..87b4bcb8b1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -2,3 +2,12 @@ spring: sql: init: data-locations: + jpa: + properties: + hibernate: + format_sql: true + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace