diff --git a/.gitignore b/.gitignore index 460ce1b511..26c3c4f6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +.DS_Store .gradle build/ !gradle/wrapper/gradle-wrapper.jar @@ -37,4 +38,4 @@ out/ .vscode/ -/docs/cycle1_record.md \ No newline at end of file +/docs/cycle1_record.md diff --git a/README.md b/README.md index 148f03f31c..879648f463 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,51 @@ - [x] 중간 순번의 대기가 취소되면 뒤 순번이 당겨져 조회된다. - [x] 대기 상태의 시간은 사용자 화면에서 대기 취소가 가능하다. - [x] 대기 취소 후 해당 시간은 다시 대기 신청 가능한 예약 불가능 상태로 표시된다. - - -### 사이클2 작업 -- ~~[ ] 예약자가 예약을 취소할 때 해당 예약의 대기자가 없으면 예약을 삭제한다.~~ -- ~~[ ] 예약자가 예약을 취소할 때 해당 예약의 대기자가 있으면 첫 번째 대기자를 예약자로 승격한다.~~ -- ~~[ ] 첫 번째 대기자가 예약자로 승격되면 해당 대기 데이터는 삭제된다.~~ -- ~~[ ] 예약 취소와 대기자 승격은 하나의 트랜잭션으로 처리한다.~~ -- ~~[ ] 대기 신청, 중복 대기 방지, 순번 산정 과정에서 동시성 문제가 발생하지 않도록 제어한다.~~ -- ~~[ ] 같은 사람이 더블 클릭 등으로 동시에 대기 신청해도 중복 대기가 생성되지 않는다.~~ + +### 사이클2 기능 구현 리스트 + +- [X] `slot` 테이블을 추가하고 날짜, 테마, 시간 조합이 유니크하도록 제약을 건다. +- [X] `reservation` 테이블이 기존 `date`, `theme_id`, `time_id` 대신 `slot_id`만 참조하도록 스키마를 변경한다. +- [X] `reservation_waiting` 테이블이 `reservation_id` 대신 `slot_id`를 참조하도록 스키마를 변경한다. +- [X] `ReservationSlot`을 식별자를 가진 도메인 객체로 정리한다. +- [X] 슬롯을 조회하고 관리자/초기 데이터에서 생성할 수 있는 Repository를 추가한다. +- [X] 예약 Repository가 `slot_id` 기준으로 예약을 저장, 조회, 삭제하도록 변경한다. +- [X] 대기 Repository가 `slot_id` 기준으로 대기를 저장, 조회, 삭제하도록 변경한다. +- [X] 예약 생성 흐름에서 날짜, 테마, 시간으로 기존 `Slot`을 조회하고, 존재하는 슬롯만 예약이 참조하도록 변경한다. +- [X] Repository는 DB 무결성 예외를 저장소 예외로 감싸고, Service가 유스케이스 문맥에 맞는 예외로 변환하도록 변경한다. +- [X] 예약 생성 시 `reservation.slot_id` UNIQUE 제약 위반을 예약 불가능 충돌 응답으로 변환한다. +- [X] 대기 생성 흐름에서 예약 row가 아니라 `Slot`을 기준으로 대기 가능 여부와 중복 대기를 판단하도록 변경한다. +- [X] 예약 가능 시간 조회가 슬롯과 예약 존재 여부를 기준으로 예약 가능/대기 가능 상태를 계산하도록 변경한다. +- [X] 내 예약/대기 히스토리 조회 SQL이 예약과 대기 모두 `Slot`을 통해 날짜, 테마, 시간을 조회하도록 변경한다. +- [X] 예약 취소 시 해당 슬롯의 첫 번째 대기를 찾는다. + - [X] 대기가 없으면 예약 삭제 실행 + - [X] 첫 번째 대기가 있으면 기존 예약을 삭제하고 같은 슬롯에 새 예약을 생성한 뒤 승인된 대기 row를 삭제한다. + - [X] 예약 취소와 대기 전환을 하나의 트랜잭션으로 묶는다. +- [X] 현재 기능 범위에서는 예약 취소 후 대기 전환 흐름에 비관적 락을 도입하지 않기로 결정한다. + - [X] 빈번한 동시 예약 생성은 `reservation.slot_id` UNIQUE 제약으로 처리한다. + - [X] 빈번한 동시 대기 신청은 `reservation_waiting(slot_id, name)` UNIQUE 제약과 조회 시 순번 계산으로 처리한다. + - [X] 수동 승인, 관리자 강제 승격 등 같은 슬롯 변경 유스케이스가 늘어나면 슬롯 row 기준 비관적 락을 도입한다. +- [X] Repository 테스트 fixture를 슬롯 기반 데이터 생성 방식으로 변경한다. +- [X] 서비스 테스트와 API 테스트에서 예약/대기 생성, 취소, 히스토리 조회 기대값을 슬롯 구조에 맞게 수정한다. +- [X] 동시 예약 생성은 UNIQUE 제약으로 하나만 성공하고 나머지는 충돌 처리되는지 검증한다. +- [X] 동시 대기 신청은 UNIQUE 제약으로 같은 이름의 중복 대기만 충돌 처리되는지 검증한다. + +### 사이클1 머지 당시 리뷰 반영 사항 리스트 + +- [X] `Reservation`의 static 편의 메서드를 제거하고 필요한 객체에게 직접 물어보도록 정리한다. + - `ReservationSlot` 객체가 있는 흐름에서는 `slot.isPast(...)`로 묻는다. 슬롯 객체 없이 화면에 표시할 날짜와 시간만 있는 흐름에서는 화면 조립 코드에서 `LocalDateTime`으로 조합해 과거 여부를 판단, `Reservation`은 자신이 가진 `slot`에 대해 `reservation.isPast(...)`처럼 묻는 메서드만 남긴다. + +- [X] `Reservation` 생성 경로를 줄이고 불변식 검증 위치를 정리한다. + - 예약은 `ReservationSlot`을 받아 생성하도록 정리, 날짜/테마/시간 조합으로 슬롯을 만드는 책임은 서비스나 테스트 fixture 쪽에서 처리하고, `Reservation`은 이름, 슬롯, 생성 시각을 기준으로 자신의 불변식만 검증 + +- [X] `ReservationWaitingLine`의 `Map` 사용 이유를 검토하고 표현 계층 요구가 도메인에 들어왔는지 확인한다. + - 대기 줄은 요청 시각과 대기 ID 기준으로 정렬된 `List`를 보관하도록 정리, `waitingId -> sequence` 형태의 `Map`은 제거한다. 도메인에서는 `indexOf`, `containsName`, `isEmpty`를 제공하고, 화면 표시용 1-based 순번 계산은 히스토리 조회 조립 객체에서 처리 + +- [X] `ReservationRepository`가 `Slot` 식별자를 중심으로 상호작용하도록 유지한다. + - 예약과 대기는 `date/theme/time` 조합이 아니라 `ReservationSlot`을 기준으로 조회, 슬롯 식별자를 중심으로 상호작용하도록 유지 + +- [X] `ReservationSlot`의 static 메서드를 객체에게 묻는 방식으로 바꿀 수 있는지 검토한다. + - `ReservationSlot`의 static 상태 판단 메서드는 제거, 슬롯 객체가 있는 흐름에서는 `slot.isPast(...)`로 묻고, 슬롯 객체 없이 화면에 표시할 날짜와 시간만 있는 흐름에서는 화면 조립 코드에서 `LocalDateTime`으로 조합해 과거 여부를 판단 ## API 명세 diff --git a/build.gradle b/build.gradle index c0ed8e9b3b..d9c0c1b647 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ repositories { 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' diff --git a/docs/cycle2-record.md b/docs/cycle2_experiment_record.md similarity index 100% rename from docs/cycle2-record.md rename to docs/cycle2_experiment_record.md diff --git a/docs/cycle2_record.md b/docs/cycle2_record.md new file mode 100644 index 0000000000..8fda4f08f5 --- /dev/null +++ b/docs/cycle2_record.md @@ -0,0 +1,359 @@ +# 사이클2 설계 고민 기록 + +## 고민 포인트 1. 예약 취소 시 대기 기능 구현에서 '예약 전환'을 구현하기 위한 데이터 구조 + +예약 취소 시 대기 1번을 예약으로 전환하는 기능을 구현하려고 한다. + +현재 구조에서는 `ReservationSlot`이 코드 레벨의 값 객체로만 존재한다. DB 테이블에는 별도의 슬롯 테이블이 없고, 예약은 `date`, `theme_id`, `time_id` 조합으로 슬롯을 표현한다. 대기는 현재 `reservation_id`를 FK로 가지고 있어 특정 예약 row에 연결된다. + +이 구조에서 예약 취소와 대기 전환을 구현할 때 두 가지 선택지를 고민했다. + +### 선택지 1: 현재 구조 유지 + +현재 구조를 유지하면 대기는 계속 `reservation_id`를 바라본다. 예약 취소 후 대기 1번을 예약으로 전환할 때는 기존 예약을 삭제하고 새 예약을 생성한 뒤, 남은 대기들의 FK를 새 예약으로 갱신해야 한다. + +순번은 DB에 저장하지 않고 조회 시 계산하고 있으므로, 대기 1번이 삭제되면 남은 대기들의 순번은 다음 조회에서 자연스럽게 다시 계산된다. 따라서 별도의 순번 재정렬 UPDATE는 필요하지 않다. + +#### 특징 + +- 기존 테이블 구조를 유지할 수 있다. +- 예약, 대기, 히스토리 조회 쿼리의 변경 범위가 작다. +- 대기 수를 50개 미만으로 제한한다면 남은 대기 FK 갱신 비용은 크지 않을 가능성이 높다. +- 예약 취소가 아주 빈번한 작업이 아니라면 DB 비용도 정책적으로 감당 가능한 수준으로 볼 수 있다. +- 다만 대기가 "슬롯"이 아니라 "기존 예약"을 바라보므로, 예약 row가 바뀔 때 남은 대기들의 참조도 함께 바뀐다. +- 슬롯 자체를 대표하는 DB row가 없기 때문에 비관적 락을 슬롯 단위로 명확하게 적용하기 어렵다. + +### 선택지 2: 슬롯 테이블 도입 + +슬롯을 별도 테이블로 만들면 예약과 대기가 모두 같은 `slot_id`를 바라볼 수 있다. + +이 경우 예약이 취소되고 대기 1번이 새 예약으로 전환되어도 남은 대기들은 계속 같은 슬롯을 참조한다. +따라서 남은 대기들의 FK를 새 예약으로 갱신할 필요가 없다. 예약으로 전환된 대기 row만 삭제하면 된다. + +#### 특징 + +- 예약과 대기를 모두 "특정 슬롯에 대한 상태"로 표현할 수 있어 도메인 관점으로 해석할 때 더 자연스럽다. +- 기존 예약이 삭제되고 새 예약이 생성되어도 남은 대기들의 참조가 수정될 일이 없다. +- 슬롯 row를 기준으로 비관적 락을 적용할 수 있어 같은 슬롯의 예약 취소, 대기 승인, 대기 신청 흐름을 더 확실하게 직렬화할 수 있다. +- 동시성 문제가 많이 발생하는 도메인(방탈출 예약)임을 고려하면 더 설득력 있는 구조다. +- 대신 테이블 구조 변경이 필요하다. +- 예약 생성, 대기 생성, 예약 취소, 대기 전환, 히스토리 조회 쿼리와 테스트 전반의 수정 비용이 커진다. + + +### 현재 판단 + +현재 미션 범위와 구현 비용을 우선하면 기존 구조를 유지해도 합리적이다. 대기 수를 50개 미만으로 제한하고, 예약 취소가 빈번하지 않다는 정책을 둔다면 남은 대기 FK 갱신 비용은 감당 가능한 수준으로 보인다. + +하지만 도메인 모델의 안정성과 확장성을 우선하면 슬롯 테이블을 도입하는 쪽이 더 이상적이다. + +대기는 특정 예약 row를 기다리는 것보다 특정 날짜, 테마, 시간 슬롯을 기다리는 것으로 보는 편이 도메인 적으로도 자연스럽다. + +또한 동시성 문제가 많이 발생하는 도메인이라고 가정하면 슬롯 row를 기준으로 비관적 락을 적용할 수 있는 구조가 더 명확하다. + +#### 선택 기준 +- 미션 범위 안에서 구현 비용을 줄이고 정책적 제약으로 충분히 설명한다면 현재 구조를 유지 +- 대기 승인 기능을 핵심 도메인 로직으로 보고, 동시성 제어와 장기적인 확장성을 더 중요하게 본다면 슬롯 테이블을 도입 + +## 고민 포인트 2. 슬롯을 DB 테이블로 승격할 때의 수정 비용 + +슬롯을 테이블로 만들면 예약과 대기가 모두 같은 슬롯을 바라볼 수 있어 모델은 더 자연스러워진다. 하지만 현재 구조에서는 변경 비용이 꽤 클 것으로 예상된다. + +### 수정 비용이 클 것으로 예상되는 부분 + +#### DB 스키마 + +현재는 `reservation`이 `date`, `theme_id`, `time_id`를 직접 가지고 있고, `reservation_waiting`은 `reservation_id`를 FK로 가진다. + +슬롯 테이블을 도입하면 `reservation_slot` 같은 테이블이 추가되고, 예약과 대기는 모두 `slot_id`를 바라보도록 바뀐다. 이때 기존 `reservation.date`, `reservation.theme_id`, `reservation.time_id`의 역할이 `reservation_slot`로 이동한다. + +수정 비용이 큰 이유는 단순히 컬럼 하나를 추가하는 변경이 아니라, 예약과 대기의 참조 방향 자체가 바뀌기 때문이다. + +#### Repository SQL + +현재 Repository SQL은 대부분 `reservation`을 중심으로 조인한다. 특히 대기 조회는 `reservation_waiting -> reservation -> theme/reservation_time` 흐름으로 필요한 정보를 가져온다. + +슬롯이 테이블로 분리되면 조회 흐름이 `reservation_waiting -> reservation_slot -> theme/reservation_time` 또는 `reservation -> reservation_slot -> theme/reservation_time` 형태로 바뀐다. 히스토리 조회처럼 예약과 대기를 합쳐 보여주는 SQL도 함께 수정해야 한다. + +수정 비용이 큰 이유는 슬롯 정보가 여러 SQL에 암묵적으로 퍼져 있기 때문이다. + +#### 예약 생성과 대기 생성 흐름 + +현재 예약 생성은 `date`, `theme`, `time` 조합으로 중복 예약을 확인한 뒤 예약 row를 만든다. 대기 생성은 같은 조합의 예약 row를 찾고, 그 예약에 대기 row를 연결한다. + +슬롯을 도입하면 예약과 대기 모두 먼저 슬롯을 찾거나 만들어야 한다. 대기는 더 이상 예약 row를 기다리는 것이 아니라 슬롯을 기다리는 형태가 된다. + +수정 비용이 큰 이유는 현재 서비스 흐름이 "슬롯"보다 "예약 row"를 중심으로 구성되어 있기 때문이다. + +#### 예약 취소와 대기 전환 흐름 + +현재 구조를 유지한다면 예약 취소 후 대기 1번을 새 예약으로 전환할 때, 기존 예약을 삭제하고 새 예약을 생성한 뒤 남은 대기들의 `reservation_id`를 새 예약으로 옮겨야 한다. + +슬롯을 도입하면 남은 대기들은 계속 같은 `slot_id`를 바라보므로 FK 갱신이 필요 없다. 예약으로 전환된 대기 row만 삭제하면 된다. + +이 지점은 슬롯 테이블 도입의 장점이지만, 동시에 현재 구조의 변경 비용이 드러나는 부분이다. 현재 대기는 실제로 슬롯을 기다리는 요구사항을 가지고 있지만, DB 구조에서는 예약 row를 기다리고 있다. + +#### 히스토리 조회 + +현재 내 예약/대기 조회는 예약 테이블과 대기 테이블을 `UNION ALL`로 합치고, 대기 쪽에서는 `reservation_id`를 통해 예약의 날짜, 테마, 시간을 가져온다. + +슬롯을 도입하면 대기 이력은 예약 row가 아니라 슬롯을 통해 날짜, 테마, 시간을 가져와야 한다. 예약 이력도 슬롯을 통해 같은 정보를 가져오게 된다. + +수정 비용이 큰 이유는 히스토리 조회가 현재 테이블 구조에 강하게 의존하고 있기 때문이다. + +#### 테스트 전반 + +Repository 테스트, API 테스트, 서비스 테스트 fixture는 현재 `reservation`과 `reservation_waiting.reservation_id` 구조를 직접 사용한다. 테스트 데이터 생성 SQL도 이 구조에 맞춰져 있다. + +슬롯 테이블을 도입하면 테스트 fixture에서 먼저 슬롯을 만들고, 예약과 대기를 그 슬롯에 연결해야 한다. + +수정 비용이 큰 이유는 테스트가 실제 DB 구조를 보호하는 역할을 하고 있기 때문이다. 이 비용은 나쁜 비용이라기보다, 영속성 구조 변경에 따라 자연스럽게 발생하는 비용이다. + +### 변경 비용의 원인 + +이번 변경 비용의 핵심 원인은 **슬롯이라는 개념이 코드 일부에는 있지만, DB 관계와 서비스 흐름에서는 아직 중심 개념이 아니기 때문이다.** + +현재 코드에는 `ReservationSlot` 값 객체가 있어 도메인 코드 일부에서는 날짜, 테마, 시간을 묶어 표현한다. 하지만 DB에서는 슬롯이 별도 row로 존재하지 않고, 대기도 슬롯이 아니라 예약 row를 바라본다. + +그래서 슬롯을 테이블로 승격하려는 순간 다음 차이가 드러난다. + +- 코드 레벨에서는 슬롯을 값으로 표현한다. +- DB 레벨에서는 슬롯이 독립된 참조 대상이 아니다. +- 서비스 흐름에서는 대기 생성과 대기 전환이 예약 row 중심으로 흘러간다. + +즉 변경 비용이 큰 이유는 단순히 코드가 많아서가 아니라, 도메인의 중심 개념을 "예약"에서 "슬롯"으로 옮기는 변경이기 때문이다. + +### 설계 연습 관점에서 기억할 점 + +손볼 곳이 많아지는 것 자체가 곧 설계 실패를 의미하지는 않는다. 요구사항이 도메인의 중심 개념을 바꾸는 수준이라면 큰 변경은 자연스럽다. + +다만 손볼 곳이 많은 이유를 구분해야 한다. + +- 테이블 구조가 바뀌어서 자연스럽게 수정되는 비용인가? +- 아니면 중요한 도메인 개념이 여러 계층에 흩어져 있었기 때문에 생긴 비용인가? + +이번 경우에는 둘 다 존재한다. 스키마와 SQL 수정은 자연스러운 비용이다. 하지만 대기가 실제로는 슬롯을 기다리는데 코드와 DB에서는 예약 row를 기다리는 형태였다는 점은 다음 설계에서 기억해야 할 신호다. + +다음부터 비슷한 도메인을 설계할 때는 다음 질문을 먼저 던져본다. + +- 사용자가 실제로 기다리는 대상은 무엇인가? +- 여러 객체가 함께 바라보는 기준점이 있는가? +- 그 기준점은 단순 값 객체로 충분한가, 아니면 DB에서 독립된 row로 존재해야 하는가? +- 동시성 제어가 필요하다면 어떤 row를 잠글 수 있는가? +- 특정 row가 삭제되거나 새로 생성될 때, 다른 데이터들의 참조가 흔들리는가? + +이번 기록의 결론은 슬롯 테이블을 반드시 도입해야 한다는 것이 아니다. 현재 미션 범위와 정책 제약을 고려하면 기존 구조를 유지하는 선택도 가능하다. 다만 슬롯을 테이블로 승격하면 수정 비용은 커지지만, 예약과 대기를 같은 슬롯에 대한 상태로 다룰 수 있고, 동시성 제어의 기준점도 더 명확해진다. + +## 고민 포인트 3. 예약 생성과 대기 전환의 동시성 처리 경계 + +슬롯 테이블을 도입하고 비관적 락을 사용한다면, 같은 슬롯에 대한 모든 요청을 직렬화할 수도 있다. 예를 들어 예약 생성, 대기 신청, 예약 취소 후 대기 전환을 모두 슬롯 row 기준으로 잠그면 각 요청은 항상 최신 슬롯 상태를 보고 판단할 수 있다. + +하지만 이 방식은 모든 요청을 같은 락 규칙 안에 넣기 때문에 처리 흐름은 명확해지는 대신 성능 비용이 생긴다. 같은 슬롯에 요청이 몰리면 예약 생성까지 대기해야 하고, 락을 잡는 트랜잭션이 많아질수록 대기 시간과 타임아웃 가능성도 커진다. + +### 예약과 대기는 요청 의도가 다르다 + +고민하면서 예약과 대기는 서로 다른 성격의 데이터라는 점을 다시 보게 되었다. + +예약을 시도한 사용자는 "이 슬롯이 예약 가능한 상태"라고 보고 예약을 요청한다. 반면 대기를 시도한 사용자는 "이미 예약된 슬롯이고, 예약이 취소되면 들어갈 수 있다"는 것을 알고 대기 요청을 한다. + +즉 두 요청은 원하는 조건 자체가 다르다. + +- 예약 요청: 지금 예약 가능한 슬롯을 차지하고 싶다. +- 대기 요청: 지금은 예약되어 있으므로, 취소될 때 순번에 따라 들어가고 싶다. + +따라서 예약 생성이 실패했다고 해서 그 요청을 자동으로 대기 데이터 생성으로 이어가는 것은 부자연스럽다. 예약 실패와 대기 신청은 같은 사용자의 연속 행동일 수는 있지만, 도메인적으로는 서로 다른 의도를 가진 별도 요청으로 보는 편이 자연스럽다. + +### 선택지 비교 + +#### 선택지 1: 예약 생성은 유니크 제약으로 방어하고, 대기 전환은 트랜잭션으로 처리 + +예약 생성은 `reservation.slot_id`에 UNIQUE 제약을 두고, 동시에 같은 슬롯에 예약 생성 요청이 들어오면 하나만 성공하고 나머지는 충돌로 처리한다. + +예약 취소 후 대기 1번을 예약으로 전환하는 흐름은 여러 데이터가 함께 바뀌므로 하나의 트랜잭션으로 묶는다. + +이 방식에서는 예약 생성 요청이 실패했을 때 자동으로 대기로 전환하지 않는다. 사용자는 예약 실패 안내를 보고, 대기를 원한다면 별도로 대기 신청을 해야 한다. + +장점은 예약 생성 흐름이 단순하고, 동시성 제어를 실제로 빈번하게 발생할 수 있는 요청에 맞게 나눌 수 있다는 점이다. 예약 생성은 유니크 제약으로, 대기 신청은 유니크 제약과 조회 시 순번 계산으로 처리한다. 예약 취소 후 대기 전환은 원자성이 중요하므로 트랜잭션으로 묶는다. + +#### 선택지 2: 예약 생성과 대기 전환을 모두 슬롯 락으로 직렬화 + +예약 생성도 슬롯 row를 먼저 잠근 뒤 현재 예약 존재 여부를 확인하고 예약을 생성한다. 예약 취소 후 대기 전환도 같은 슬롯 row를 먼저 잠근다. + +이 방식은 같은 슬롯의 상태 판단을 모두 직렬화하므로 흐름은 가장 명확하다. 하지만 예약 생성처럼 단순한 요청까지 락 대기 대상이 되고, 같은 슬롯에 요청이 몰릴 때 처리량이 떨어질 수 있다. + +또한 여러 흐름에서 비관적 락을 사용하게 되므로 모든 트랜잭션이 같은 순서로 락을 잡도록 규칙을 강하게 지켜야 한다. 예를 들어 항상 `slot -> reservation -> reservation_waiting` 순서로 접근해야 한다. 이 규칙이 깨지면 데드락 가능성이 커진다. + +### 결정 + +현재 판단은 선택지 1에 가깝다. + +예약 생성은 UNIQUE 제약으로 최종 중복을 방어하고, 제약 위반이 발생하면 예약 불가능 상태로 안내한다. 예약 생성이 실패했다고 해서 자동으로 대기 데이터를 만들지는 않는다. + +예약 취소 후 대기 1번을 예약으로 전환하는 흐름은 하나의 작업 단위로 본다. 이 흐름에서는 기존 예약 삭제, 새 예약 생성, 승인된 대기 삭제가 함께 성립해야 하므로 트랜잭션으로 묶는다. + +다만 현재 기능 범위에서는 비관적 락을 우선 도입하지 않는다. + +현재 도메인에서 빈번하게 발생할 가능성이 높은 동시성은 예약 생성과 대기 신청이다. 예약 생성은 `reservation.slot_id` UNIQUE 제약으로 하나만 성공하도록 방어할 수 있고, 대기 신청은 `reservation_waiting(slot_id, name)` UNIQUE 제약으로 같은 이름의 중복 대기를 막을 수 있다. 대기 순번은 저장하지 않고 조회 시 계산하므로, 대기 신청 시점에 순번 재정렬을 위한 락도 필요하지 않다. + +예약 취소 후 대기 승격은 기존 예약 삭제, 새 예약 생성, 승인된 대기 삭제가 함께 일어나야 하므로 트랜잭션으로 원자성은 보장한다. 하지만 같은 예약을 동시에 취소하는 상황은 현재 사용자 흐름에서 빈번하다고 보기 어렵다. 따라서 현재 단계에서는 비관적 락을 적용하지 않고, 트랜잭션과 DB 제약으로 우선 정합성을 관리한다. + +추후 수동 승인, 관리자 강제 승격, 다중 취소 요청 처리처럼 같은 슬롯의 상태를 동시에 변경하는 유스케이스가 늘어나면 슬롯 row를 기준으로 비관적 락을 도입한다. + +이렇게 결정한 이유는 다음과 같다. + +- 예약과 대기는 요청 의도가 다르다. +- 예약 실패를 대기 생성으로 자동 연결하는 것은 도메인 흐름상 부자연스럽다. +- 예약 생성은 UNIQUE 제약으로 중복 생성 문제를 막을 수 있다. +- 대기 전환은 여러 데이터를 함께 바꾸는 상태 전이이므로 트랜잭션이 필요하다. +- 현재 기능 범위에서 같은 예약 취소가 동시에 발생할 가능성은 낮으므로 비관적 락은 우선 도입하지 않는다. +- 비관적 락은 수동 승인, 관리자 강제 승격처럼 같은 슬롯을 변경하는 유스케이스가 늘어났을 때 도입한다. + +따라서 이번 기능에서는 모든 슬롯 관련 요청을 무조건 직렬화하지 않는다. 빈번한 동시성은 UNIQUE 제약으로 방어하고, 복잡한 상태 전이인 "예약 취소 후 대기 전환"은 트랜잭션으로 묶어 원자성을 보장한다. + +### 데드락 우려와 대응 방향 + +비관적 락을 여러 흐름에 넓게 적용하면 데드락 가능성이 생길 수 있다. 특히 서로 다른 트랜잭션이 같은 슬롯의 데이터를 다루면서 락을 잡는 순서가 다르면 문제가 된다. + +예를 들어 예약 생성 흐름이 `reservation`을 먼저 건드린 뒤 `slot`에 접근하고, 예약 취소 후 대기 전환 흐름이 `slot`을 먼저 잠근 뒤 `reservation`을 삭제하거나 생성한다면 서로가 가진 락을 기다리는 상황이 생길 수 있다. + +이번 결정에서는 예약 생성 흐름을 비관적 락 대상에서 제외한다. 예약 생성은 `reservation.slot_id` UNIQUE 제약으로 중복 생성을 막고, 제약 위반이 발생하면 예약 불가능 상태로 안내한다. 따라서 예약 생성이 슬롯 row를 잠그고 대기하는 흐름에 들어가지 않으므로 데드락 경우의 수를 줄일 수 있다. + +또한 현재 단계에서는 예약 취소 후 대기 전환 흐름에도 비관적 락을 적용하지 않는다. 이 흐름은 트랜잭션으로 묶어 중간 실패를 막되, 같은 예약을 동시에 취소하는 상황의 발생 가능성이 낮다고 판단한다. + +나중에 같은 슬롯을 동시에 변경하는 유스케이스가 늘어나 비관적 락을 도입한다면 다음 규칙을 지킨다. + +- 항상 `slot` row를 먼저 잠근다. +- 이후 접근 순서는 `slot -> reservation -> reservation_waiting`으로 고정한다. +- 트랜잭션 안에서는 DB 상태 변경만 처리하고, 알림이나 로그 같은 부수 효과는 분리한다. + +현재 단계에서는 트랜잭션과 DB 제약으로 필요한 정합성을 먼저 확보한다. 이후 같은 슬롯을 동시에 변경하는 유스케이스가 늘어나 비관적 락을 도입한다면, 위 규칙으로 락 순서를 고정해 데드락 가능성을 줄인다. + +### 예약 취소와 대기 신청이 동시에 일어나는 경우 + +예약 취소로 첫 번째 대기가 예약으로 승격되는 순간에, 다른 사용자가 같은 슬롯에 새로 대기를 신청하는 상황도 생각할 수 있다. + +현재 정책에서는 대기 신청은 "이미 예약된 슬롯에 대해 취소를 기다리기 위한 요청"이다. 따라서 대기 신청 시점에 해당 슬롯에 예약이 존재해야 한다. + +예약 취소와 대기 신청이 동시에 들어오면 요청 처리 순서에 따라 결과가 달라질 수 있다. + +- 대기 신청이 먼저 예약 존재를 확인하면 대기 신청은 성공할 수 있다. +- 예약 취소와 첫 대기 승격이 먼저 끝나면 새 예약자가 생긴 상태이므로 이후 대기 신청도 다시 "예약된 슬롯에 대한 대기"로 성공할 수 있다. +- 아주 짧은 순간에 기존 예약 삭제 후 새 예약 생성 전 상태를 읽는다면 예약이 없다고 보고 대기 신청이 실패할 수 있다. + +이 문제를 완전히 없애려면 예약 취소 후 대기 전환과 대기 신청을 같은 슬롯 row 기준으로 직렬화해야 한다. 하지만 현재 단계에서는 이 상황이 핵심 사용자 흐름에서 빈번하다고 보지 않는다. 또한 대기 신청이 실패하더라도 사용자는 다시 조회한 뒤 대기 신청을 재시도할 수 있다. + +따라서 현재 구현에서는 예약 취소 후 대기 전환은 트랜잭션으로 묶어 중간 실패를 막고, 대기 신청은 예약 존재 여부와 `reservation_waiting(slot_id, name)` UNIQUE 제약으로 처리한다. 예약 취소와 대기 신청의 완전한 직렬화는 수동 승인, 관리자 강제 승격, 대량 동시 요청처럼 같은 슬롯 상태를 동시에 변경하는 요구가 커졌을 때 비관적 락과 함께 도입한다. + +### 트랜잭션 롤백 테스트를 보류한 이유 + +예약 취소 후 대기 전환은 다음 세 변경이 함께 성립해야 한다. + +- 기존 예약 삭제 +- 첫 번째 대기 이름으로 같은 슬롯에 새 예약 생성 +- 승인된 대기 row 삭제 + +따라서 이 흐름은 하나의 트랜잭션으로 묶었다. + +다만 현재 단계에서는 중간 실패를 일부러 발생시켜 롤백을 검증하는 테스트는 작성하지 않았다. 이유는 현재 구현에서 자연스럽게 실패를 유도할 수 있는 지점이 많지 않기 때문이다. + +기존 예약을 삭제한 뒤 같은 슬롯에 새 예약을 생성하므로 `reservation.slot_id` UNIQUE 제약은 보통 실패 지점이 되지 않는다. 승인된 대기 row 삭제도 단순 `DELETE`이고, 해당 row를 참조하는 다른 테이블이 없어 DB 제약 위반을 자연스럽게 만들기 어렵다. + +테스트 전용 Fake나 Mock으로 Repository 중간 호출에서 예외를 던지게 만들면 실패 주입은 가능하다. 하지만 Fake는 실제 DB 트랜잭션 롤백을 검증하지 못하고, Repository Mock은 이번 테스트 전략에서 피하기로 한 방향이다. Spring 테스트에서 Spy나 테스트 전용 Bean으로 실패를 주입하는 방법도 있지만, 현재 미션 범위에서는 테스트 장치의 복잡도가 검증 이득보다 커질 수 있다고 판단했다. + +그래서 이번 단계에서는 정상 전환 통합 테스트로 결과 상태를 검증하고, 트랜잭션 경계는 코드에 명시하는 수준으로 둔다. 추후 외부 부수효과, 실패 가능한 협력자, 또는 더 복잡한 상태 전이가 추가되어 자연스러운 실패 지점이 생기면 롤백 테스트를 보강한다. + +## N+1 쿼리 문제를 알게 된 지점 + +이번 리뷰에서 `findAvailableTimes()`가 쿼리를 몇 번 실행하는지 확인해보라는 코멘트를 받았다. + +처음에는 단순히 슬롯 목록을 가져온 뒤 각 슬롯마다 예약이 있는지 확인하는 흐름이라고 생각했다. 하지만 이 구조가 N+1 쿼리 문제가 될 수 있다는 것을 이번에 알게 되었다. + +N+1 쿼리는 먼저 목록을 조회하는 쿼리 1번을 실행한 뒤, 그 목록의 각 항목마다 추가 쿼리 N번이 반복되는 상황을 말한다. + +현재 코드에서는 다음 흐름이 그 예시였다. + +```java +reservationSlotRepository.findByDateAndTheme(date, themeService.getById(themeId)) + .stream() + .filter(slot -> reservationRepository.findBySlot(slot).isEmpty()) + .filter(slot -> !slot.isPast(requestedAt)) + .map(ReservationSlot::getTime) + .toList(); +``` + +여기서 `findByDateAndTheme()`가 해당 날짜와 테마의 슬롯 목록을 한 번 조회한다. 이후 각 슬롯마다 `reservationRepository.findBySlot(slot)`을 호출한다. + +따라서 슬롯이 10개라면 슬롯 목록 조회 1번 + 예약 여부 확인 10번으로 총 11번의 쿼리가 발생할 수 있다. 슬롯이 많아질수록 쿼리 수도 같이 늘어난다. + +이번 문제의 핵심은 예약 가능 시간 조회의 목적이 "특정 날짜와 테마에서 아직 예약되지 않은 슬롯을 찾는 것"이라는 점이다. + +처음에는 Repository에서 예약이 없는 슬롯을 한 번에 조회하는 방법을 생각했다. 하지만 다시 보면 "예약이 없는 슬롯"은 곧 "예약 가능한 슬롯"이라는 유스케이스 판단에 가깝다. 이 판단을 Repository에 넣으면 Repository가 단순 컬렉션 조회를 넘어 예약 가능 정책을 알게 된다. + +지금까지 Repository를 컬렉션처럼 쓰려고 했던 방향을 고려하면, Repository에는 필요한 컬렉션을 조회하는 책임만 두고 예약 가능 여부 판단은 서비스에서 하는 편이 더 자연스럽다. + +해결 방향은 다음과 같다. + +- `ReservationSlotRepository`는 특정 날짜와 테마의 슬롯 목록을 한 번 조회한다. +- `ReservationRepository`는 같은 날짜와 테마의 예약 목록을 한 번 조회한다. +- 서비스는 예약 목록에서 예약된 `slotId`를 `Set`으로 만든 뒤, 슬롯 목록에서 해당 id를 제외한다. + +이렇게 하면 쿼리는 슬롯 목록 조회 1번 + 예약 목록 조회 1번으로 고정된다. 동시에 "예약 가능한 슬롯"이라는 판단은 서비스에 남고, Repository는 조건에 맞는 컬렉션을 반환하는 역할을 유지할 수 있다. + +## 트랜잭션을 바라보는 기준 변화 + +처음에는 트랜잭션을 주로 동시성 처리를 위한 도구로 이해하고 있었다. 그래서 예약 생성 흐름에 `@Transactional`을 붙일지 판단할 때도, "동시 예약 생성 경합을 막을 수 있는가"를 중심으로 생각했다. + +그 기준에서는 예약 생성에 트랜잭션을 붙이는 효과가 크지 않다고 판단했다. 예약 생성은 최종적으로 `reservation` row 하나를 추가하는 흐름이고, 같은 슬롯에 대한 동시 예약 생성은 `reservation.slot_id` UNIQUE 제약이 최종적으로 막아주기 때문이다. 또한 기본 격리 수준의 트랜잭션만으로는 조회 후 저장 사이에 다른 요청이 먼저 저장하는 경쟁 상태를 막을 수 없다고 보았다. + +하지만 리뷰를 통해 트랜잭션은 동시성 제어만을 위한 장치가 아니라, 하나의 유스케이스를 일관된 작업 단위로 선언하는 역할도 한다는 점을 알게 되었다. + +예약 생성은 단순히 `INSERT` 한 번을 호출하는 메서드가 아니라 다음 흐름을 하나로 수행한다. + +- 테마 조회 +- 예약 시간 조회 +- 관리자가 열어둔 슬롯 조회 +- 같은 슬롯의 기존 예약 여부 확인 +- 예약 도메인 생성 +- 예약 저장 + +현재 데이터 변경이 하나뿐이더라도, 이 메서드는 "예약 생성"이라는 하나의 작업 단위다. 이후 검증이나 부가 데이터 변경이 추가될 가능성도 있다. 이때 트랜잭션 경계가 명시되어 있지 않으면, 새로운 변경이 추가되었을 때 일부만 반영되는 구조가 될 수 있다. + +따라서 예약 생성 메서드에도 `@Transactional`을 추가했다. 이 결정은 동시 예약 생성 문제를 트랜잭션으로 해결하겠다는 의미가 아니다. 동시 중복 예약의 최종 방어는 여전히 DB UNIQUE 제약이 담당한다. 여기서의 트랜잭션은 예약 생성 유스케이스를 하나의 작업 단위로 명시하기 위한 경계다. + +이번 변경을 통해 트랜잭션 경계를 판단할 때 다음 기준을 추가로 가지게 되었다. + +- 동시성 제어가 필요한가? +- 여러 데이터 변경이 함께 성립해야 하는가? +- 하나의 유스케이스로서 앞으로 변경이 추가되어도 같은 작업 단위로 유지되어야 하는가? + +앞으로는 트랜잭션을 "동시성 처리 도구"로만 보지 않고, 유스케이스의 작업 단위를 드러내는 설계 요소로도 함께 판단한다. + +## 요청 시각의 기준 위치 변경 + +기존에는 서비스 내부에서 `LocalDateTime.now()`를 호출해 예약 생성, 예약 취소, 예약 변경, 대기 생성, 예약 가능 시간 조회의 기준 시각을 만들었다. + +처음에는 서비스가 도메인 검증을 수행하므로 서비스 안에서 현재 시각을 만들어도 자연스럽다고 생각했다. 하지만 리뷰를 통해 `요청 시각`이 정확히 무엇을 의미하는지 다시 보게 되었다. + +요청 시각은 "서비스 메서드 어느 지점을 지나는 시각"이라기보다, 사용자의 요청을 처리하기 시작한 기준 시각에 가깝다. 그런데 서비스 내부에서 `now()`를 직접 호출하면 다음 문제가 생긴다. + +- 같은 요청 안에서도 메서드마다 기준 시각이 조금씩 달라질 수 있다. +- 서비스 테스트가 실제 현재 시각에 의존해 불안정해질 수 있다. +- 과거/미래 판단을 테스트하려면 시간을 직접 제어하기 어렵다. +- 서비스가 현재 시각을 생성하는 책임까지 가지게 되어, 입력을 받아 유스케이스를 수행하는 책임이 흐려진다. + +그래서 요청을 받는 경계인 컨트롤러에서 `LocalDateTime.now()`를 한 번 만들고, 서비스에는 `requestedAt`을 파라미터로 전달하도록 변경했다. + +변경 후 서비스는 더 이상 현재 시각을 직접 생성하지 않는다. 대신 컨트롤러가 넘긴 요청 기준 시각을 바탕으로 다음 판단을 수행한다. + +- 신규 예약이 과거 슬롯인지 확인 +- 예약 취소/수정 가능 여부 확인 +- 대기 생성 가능 여부 확인 +- 예약 가능 시간 조회에서 이미 지난 슬롯 제외 + +이 변경으로 서비스 테스트에서도 기준 시각을 직접 넘길 수 있게 되었다. 예를 들어 `2026-08-05T12:00:00`을 요청 시각으로 고정하면, 현재 실제 날짜와 무관하게 예약 가능/불가능 판단을 검증할 수 있다. + +앞으로 시간에 의존하는 로직을 만들 때는 다음 기준을 따른다. + +- "현재 시각"이 필요한 위치가 어디인지 먼저 정한다. +- 사용자 요청을 기준으로 판단하는 시각은 컨트롤러 같은 요청 경계에서 생성한다. +- 서비스와 도메인은 전달받은 기준 시각으로 판단한다. +- 테스트에서는 기준 시각을 직접 넘겨 시간 의존성을 제어한다. diff --git a/docs/jpa_mission.md b/docs/jpa_mission.md new file mode 100644 index 0000000000..114f5fcddf --- /dev/null +++ b/docs/jpa_mission.md @@ -0,0 +1,504 @@ +# JPA Mission + +## 1단계 + +### 테마 + +- 테마 전체 조회 +```sql +SELECT * FROM theme +``` + +- 테마 단건 조회 +```sql +SELECT * FROM theme WHERE id = ? +``` + +- 테마 저장 +```sql +INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?) +``` + +- 테마 삭제 +```sql +DELETE FROM theme WHERE id = ? +``` + +- 테마 이름 존재 여부 확인 +```sql +SELECT EXISTS (SELECT 1 FROM theme WHERE name = ?) +``` + +- 최근 기간 기준 인기 테마 조회 +```sql +SELECT t.id, + t.name, + t.description, + t.thumbnail_url +FROM reservation r +INNER JOIN reservation_slot s ON r.slot_id = s.id +INNER JOIN theme t ON s.theme_id = t.id +WHERE s.date >= ? AND s.date <= ? +GROUP BY t.id, t.name, t.description, t.thumbnail_url +ORDER BY COUNT(*) DESC, t.id ASC +LIMIT ? +``` + +### 예약 시간 + +- 예약 시간 전체 조회 +```sql +SELECT rt.id, + rt.start_at +FROM reservation_time AS rt +ORDER BY rt.start_at +``` + +- 예약 시간 단건 조회 +```sql +SELECT rt.id, + rt.start_at +FROM reservation_time AS rt +WHERE rt.id = ? +``` + +- 예약 시간 저장 +```sql +INSERT INTO reservation_time (start_at) VALUES (?) +``` + +- 예약 시간 삭제 +```sql +DELETE FROM reservation_time WHERE id = ? +``` + +- 시작 시간 존재 여부 확인 +```sql +SELECT EXISTS (SELECT 1 FROM reservation_time WHERE start_at = ?) +``` + +### 예약 슬롯 + +- 슬롯 전체 조회 +```sql +SELECT rs.id, + rs.date, + rs.time_id, + rt.start_at, + rs.theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_slot AS rs +INNER JOIN reservation_time AS rt ON rs.time_id = rt.id +INNER JOIN theme AS t ON rs.theme_id = t.id +``` + +- 슬롯 단건 조회 +```sql +SELECT rs.id, + rs.date, + rs.time_id, + rt.start_at, + rs.theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_slot AS rs +INNER JOIN reservation_time AS rt ON rs.time_id = rt.id +INNER JOIN theme AS t ON rs.theme_id = t.id +WHERE rs.id = ? +``` + +- 날짜와 테마로 슬롯 조회 +```sql +SELECT rs.id, + rs.date, + rs.time_id, + rt.start_at, + rs.theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_slot AS rs +INNER JOIN reservation_time AS rt ON rs.time_id = rt.id +INNER JOIN theme AS t ON rs.theme_id = t.id +WHERE rs.date = ? AND rs.theme_id = ? +``` + +- 날짜, 테마, 시간으로 슬롯 조회 +```sql +SELECT rs.id, + rs.date, + rs.time_id, + rt.start_at, + rs.theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_slot AS rs +INNER JOIN reservation_time AS rt ON rs.time_id = rt.id +INNER JOIN theme AS t ON rs.theme_id = t.id +WHERE rs.date = ? AND rs.time_id = ? AND rs.theme_id = ? +``` + +- 슬롯 저장 +```sql +INSERT INTO reservation_slot (date, theme_id, time_id) VALUES (?, ?, ?) +``` + +### 예약 + +- 예약 전체 조회 +```sql +SELECT r.id, + r.name AS reservation_name, + r.slot_id, + s.date, + r.created_at, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +``` + +- 예약 단건 조회 +```sql +SELECT r.id, + r.name AS reservation_name, + r.slot_id, + s.date, + r.created_at, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +WHERE r.id = ? +``` + +- 슬롯으로 예약 조회 +```sql +SELECT r.id, + r.name AS reservation_name, + r.slot_id, + s.date, + r.created_at, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +WHERE s.date = ? AND s.theme_id = ? AND s.time_id = ? +``` + +- 날짜와 테마로 예약 조회 +```sql +SELECT r.id, + r.name AS reservation_name, + r.slot_id, + s.date, + r.created_at, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +WHERE s.date = ? AND s.theme_id = ? +``` + +- 같은 시간 예약 존재 여부 확인 +```sql +SELECT COUNT(1) +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +WHERE s.time_id = ? +``` + +- 예약 저장 +```sql +INSERT INTO reservation (name, slot_id, created_at) VALUES (?, ?, ?) +``` + +- 예약 수정 +```sql +UPDATE reservation +SET slot_id = ? +WHERE id = ? +``` + +- 예약 삭제 +```sql +DELETE FROM reservation WHERE id = ? +``` + +### 예약 대기 + +- 예약 대기 저장 +```sql +INSERT INTO reservation_waiting (slot_id, name, requested_at) VALUES (?, ?, ?) +``` + +- 대기 단건 조회 +```sql +SELECT rw.id, + rw.name AS waiting_name, + rw.requested_at, + rw.slot_id, + s.date, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_waiting AS rw +INNER JOIN reservation_slot AS s ON rw.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +WHERE rw.id = ? +``` + +- 슬롯별 대기열 조회 +```sql +SELECT rw.id, + rw.name AS waiting_name, + rw.requested_at, + rw.slot_id, + s.date, + rt.id AS time_id, + rt.start_at, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url +FROM reservation_waiting AS rw +INNER JOIN reservation_slot AS s ON rw.slot_id = s.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +INNER JOIN theme AS t ON s.theme_id = t.id +WHERE rw.slot_id = ? +``` + +- 예약 대기 삭제 +```sql +DELETE FROM reservation_waiting WHERE id = ? +``` + +### 히스토리 + +- 이름 기준 예약/대기 내역 조회 +```sql +SELECT 'RESERVATION' AS status, + r.id AS reservation_id, + NULL AS waiting_id, + r.name AS history_name, + s.date, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url, + rt.id AS time_id, + rt.start_at, + CAST(NULL AS TIMESTAMP) AS requested_at +FROM reservation AS r +INNER JOIN reservation_slot AS s ON r.slot_id = s.id +INNER JOIN theme AS t ON s.theme_id = t.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +WHERE r.name = ? + +UNION ALL + +SELECT 'WAITING' AS status, + r.id AS reservation_id, + rw.id AS waiting_id, + rw.name AS history_name, + s.date, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url, + rt.id AS time_id, + rt.start_at, + rw.requested_at +FROM reservation_waiting AS rw +INNER JOIN reservation_slot AS s ON rw.slot_id = s.id +INNER JOIN reservation AS r ON r.slot_id = s.id +INNER JOIN theme AS t ON s.theme_id = t.id +INNER JOIN reservation_time AS rt ON s.time_id = rt.id +WHERE rw.name = ? +ORDER BY date, start_at, status +``` + +- 예약별 대기 순서 조회 +```sql +SELECT r.id AS reservation_id, + rw.slot_id, + rw.id AS waiting_id, + rw.requested_at +FROM reservation_waiting AS rw +INNER JOIN reservation AS r ON rw.slot_id = r.slot_id +WHERE r.id IN (?, ?, ...) +``` + +### 1단계 관찰 메모 + +JPA 전환 후에는 시작 시점에 엔티티 정의를 기준으로 DDL이 다시 생성되고, `create-drop` 설정에서는 재시작 시 데이터가 남지 않는다. 컬럼명과 제약은 대부분 엔티티 어노테이션으로 맞출 수 있지만, `created_at` 같은 기본값은 별도 컬럼 정의가 필요했다. 즉, 엔티티만으로 스키마를 모두 통제하는지, 아니면 실제 테이블 정의를 함께 신경 써야 하는지를 비교해 볼 수 있는 단계다. + +`schema.sql`은 Hibernate가 생성한 DDL과 충돌해서 제거했고, 초기 데이터는 `data.sql` 기준으로 유지했다. + +연관관계는 현재 흐름에 필요한 만큼만 단방향으로 남겼다. 양방향과 `cascade`까지 붙여보는 방향도 검토했지만, 저장/조회 책임이 repository adapter와 서비스 계층에 이미 분리되어 있어 역참조와 소유권 전파가 오히려 복잡도를 늘린다고 판단해 채택하지 않았다. + +### 영속성 컨텍스트 관찰 + +관찰 테스트는 [`PersistenceContextObservationTest`](../src/test/java/roomescape/persistence/PersistenceContextObservationTest.java)로 분리했다. + +- `dirty_checking_updates_without_save` + - 시도한 코드: `@Transactional` 안에서 `ReservationJpaEntity#setSlot()`만 호출하고 `save()`는 호출하지 않음 + - 예측한 동작: `save()`를 안 했으니 DB는 바뀌지 않을 것이라 예상 + - 실제 SQL/동작: 커밋 시점에 `UPDATE reservation SET slot_id = ? WHERE id = ?`가 자동으로 반영됨 + - 왜 다른가: JPA가 managed entity의 변경을 추적하고, 커밋 시점에 dirty checking으로 UPDATE를 실행하기 때문이다 + +- `first_level_cache_hits_same_entity_in_same_transaction` + - 시도한 코드: 같은 트랜잭션에서 `entityManager.find(ReservationJpaEntity.class, id)`를 두 번 호출 + - 예측한 동작: 두 번째 조회도 다시 SELECT를 날릴 것이라 예상 + - 실제 SQL/동작: 첫 번째 조회만 SQL이 나가고, 두 번째 조회는 추가 SELECT 없이 같은 managed instance를 반환함 + - 왜 다른가: 같은 식별자는 1차 캐시에 저장되어 있어서, 같은 영속성 컨텍스트 안에서는 재조회 비용이 사라진다 + +- `write_behind_persists_on_flush_not_before` + - 시도한 코드: ID를 직접 가진 테스트 전용 엔티티를 `persist()` 한 뒤 flush 전후 DB 상태를 JDBC로 확인 + - 예측한 동작: `persist()` 직후에도 DB에 바로 1건이 들어갔을 것이라 예상 + - 실제 SQL/동작: flush 전에는 0건, `entityManager.flush()` 후에는 1건이 보임 + - 왜 다른가: JPA는 변경 내용을 영속성 컨텍스트에 먼저 쌓아두고, flush 시점에 DB로 밀어 넣는다 + +- `jpql_query_triggers_flush_before_execution` + - 시도한 코드: `persist()` 직후 JPQL `select count(...)` 실행 + - 예측한 동작: JPQL은 단순 조회라서 방금 저장한 데이터가 안 보일 수도 있다고 예상 + - 실제 SQL/동작: JPQL 실행 직전에 flush가 발생해서 count가 1로 조회됨 + - 왜 다른가: JPQL 실행 전에는 영속성 컨텍스트와 DB를 맞추기 위해 flush가 트리거될 수 있기 때문이다 + +- `fetch_defaults_follow_jpa_annotation_defaults` + - 시도한 코드: 테스트용 probe 필드의 어노테이션 기본값을 reflection으로 확인 + - 예측한 동작: 연관관계 기본 fetch가 모두 같을 것이라 막연히 예상 + - 실제 SQL/동작: `@ManyToOne` 기본 fetch는 `EAGER`, `@OneToMany` 기본 fetch는 `LAZY` + - 왜 다른가: JPA는 연관관계 방향과 성격에 따라 기본 fetch 정책이 다르다 + +- `lazy_initialization_exception_occurs_outside_transaction` + - 시도한 코드: 트랜잭션 안에서 찾은 `ReservationJpaEntity`를 트랜잭션 밖에서 연관 필드 접근 + - 예측한 동작: 이미 찾은 객체니까 트랜잭션 밖에서도 계속 접근 가능할 것이라 예상 + - 실제 SQL/동작: `LazyInitializationException` 발생 + - 왜 다른가: 영속성 컨텍스트가 닫힌 뒤에는 LAZY 프록시를 더 이상 초기화할 수 없기 때문이다 + +보충 메모: +- 현재 `ReservationJpaRepository.findById()`는 `@EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"})`를 써서, 서비스/레포지토리 경로에서는 `findById(reservationId).getTime().getStartAt()`처럼 접근해도 추가 SELECT가 늘지 않도록 해두었다. +- 반면 `entityManager.find()`로 직접 읽는 실험에서는 LAZY 경계와 `LazyInitializationException`을 볼 수 있다. + +## 2단계 +### `GET /reservations-mine` — 본인의 예약 목록 구현 + +- 컨트롤러 및 서비스 추가 + - 자유롭게 추가, 수정, 제거 등을 위한 유지보수를 고려할 때 별도 컨트롤러와 서비스로 구현하는게 좋다고 판단 + - `Reservation`과 같은 데이터 소스를 쓰더라도 응답의 의미와 목적이 다르다. + - 지금의 서비스 분리는 “도메인 분리”가 아니라 조회 책임 분리 + + + + + + +## 3단계 + +### 3-1. N+1과 fetch join 본격 비교 + +`reservations-mine`의 현재 서비스는 `ReservationRepository.findByName`를 통해 엔티티를 읽는 경로로 맞춰 두었고, 그 위에서 N+1과 fetch join 차이를 관찰했다. 실험 코드는 [`MineQueryObservationTest`](../src/test/java/roomescape/persistence/MineQueryObservationTest.java)로 분리했다. + +- 시도한 코드: `ReservationJpaEntity`와 `ReservationWaitingJpaEntity`를 `select ... where name = :name`으로 읽은 뒤, 각 항목에서 `getSlot().getTheme().getName()`과 `getSlot().getTime().getStartAt()`을 DTO 값으로 변환 +- 예측 SQL: 예약 2건 + 대기 2건을 기준으로 하면 조회 2번에 더해, 각 항목마다 `slot`, `theme`, `time`이 따로 따라와서 총 14번 정도의 statement가 나갈 것으로 예상 +- 실제 SQL/동작: 이 샌드박스에서는 Gradle 실행이 막혀 직접 수치를 캡처하지 못했다. 대신 [`MineQueryObservationTest`](../src/test/java/roomescape/persistence/MineQueryObservationTest.java)가 statistics 기준으로 prepare statement 14회를 검증하도록 작성되어 있다. 루트 목록 2번 이후, 각 항목마다 slot/theme/time을 각각 추가 조회하는 형태를 확인하는 테스트다 +- 왜 다른가: LAZY 연관을 그대로 따라가면 루트 1번 + 연관마다 추가 SELECT가 이어진다. 반대로 `join fetch`를 쓰면 처음부터 함께 가져와서 루트 2번으로 끝난다 + +대표적인 SQL 모양은 아래처럼 갈라졌다. + +```sql +-- fetch 없는 경우 +SELECT ... FROM reservation r WHERE r.name = ? +SELECT ... FROM reservation_slot s WHERE s.id = ? +SELECT ... FROM theme t WHERE t.id = ? +SELECT ... FROM reservation_time rt WHERE rt.id = ? + +-- fetch join인 경우 +SELECT ... +FROM reservation r +JOIN reservation_slot s ON r.slot_id = s.id +JOIN theme t ON s.theme_id = t.id +JOIN reservation_time rt ON s.time_id = rt.id +WHERE r.name = ? +``` + +fetch join 쪽은 `distinct`를 붙여 루트 엔티티 중복을 안전하게 정리했다. 이 구조는 to-one 연관만 묶는 경우라 row 중복이 크게 부각되지는 않았고, 핵심 차이는 “추가 SELECT가 사라진다”는 점이었다. `@EntityGraph`도 같은 fetch plan을 주면 같은 방향으로 동작한다는 점까지는 이 관찰로 충분히 추론할 수 있었다. + +### 3-2. JPQL 본격 + +대기 순번 계산은 단순 메서드 이름 쿼리로는 풀기 어렵기 때문에 JPQL 서브쿼리로 처리했다. 관련 코드는 [`ReservationWaitingJpaRepository`](../src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaRepository.java)와 [`WaitingWithRank`](../src/main/java/roomescape/repository/reservationwaiting/jpa/WaitingWithRank.java)에 있다. + +- 시도한 코드: 같은 슬롯에 대기 3건을 넣고, 가운데 대기만 `findAllWithRankByName(name)`으로 조회 +- 예측 SQL: 같은 슬롯에서 앞에 몇 명이 있는지를 세려면 `COUNT` 서브쿼리가 들어간 JPQL이 필요할 것이라 예상 +- 실제 SQL/동작: 이 샌드박스에서는 JPQL 실행 결과를 직접 캡처하지 못했지만, 같은 슬롯에 3건의 대기를 넣었을 때 가운데 대기의 rank가 2가 되도록 테스트를 작성했다. 앞선 대기 1건을 세어서 현재 대기를 2번째로 계산하는 방식이다 +- 왜 다른가: 이름/날짜/시간 같은 평면 조건만으로는 순번을 표현할 수 없다. 정렬 기준과 누적 개수가 함께 필요해서, JPQL 서브쿼리처럼 “데이터를 계산하는 질의”로 푸는 편이 자연스럽다 + +실제 JPQL은 다음처럼 정리했다. + +```jpql +select new roomescape.repository.reservationwaiting.jpa.WaitingWithRank( + w.id, + w.slot.id, + w.name, + w.requestedAt, + (select count(w2) + 1 + from ReservationWaitingJpaEntity w2 + where w2.slot = w.slot + and ( + w2.requestedAt < w.requestedAt + or (w2.requestedAt = w.requestedAt and w2.id < w.id) + ) + ) +) +from ReservationWaitingJpaEntity w +where w.name = :name +order by w.slot.date, w.slot.theme.id, w.slot.time.id, w.requestedAt, w.id +``` + +이 단계에서의 결론은 분명했다. 조회 결과를 조합하는 책임이 Java 쪽으로 넘어오면 N+1이 드러나고, 순번 계산처럼 파생값이 필요하면 JPQL이 자연스럽게 들어온다. 반대로 fetch join은 “조회할 연관을 미리 정해 두는 방식”이라 N+1을 줄이는 도구로 쓸 수 있었다. + + +## 4단계 + +### 예약 대기 관리 JPA 전환 + +4단계에서는 자동 승인 로직을 새로 만들기보다, 이미 동작하던 자동 승격 흐름을 JPA 엔티티/레포지토리 기준으로 유지하는 쪽에 초점을 맞췄다. 어드민은 [`ReservationWaitingAdminPageController`](../src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingAdminPageController.java)에서 대기 목록을 조회하고 삭제할 수 있고, 승인은 예약 취소 시 [`ReservationService.cancel()`](../src/main/java/roomescape/service/reservation/ReservationService.java) 안에서 자동으로 이어진다. + +- 트랜잭션 경계: 취소/승격은 `ReservationService`의 public 메서드에 붙은 `@Transactional` 안에서 한 번에 처리되도록 두었다. 예약 삭제, 승격 예약 저장, 대기 삭제를 한 서비스 흐름으로 묶어야 중간 상태가 외부에 노출되지 않는다. +- 동시성: 같은 슬롯에서 동시에 취소와 승격이 들어오면 중복 예약 위험이 있다. 그래서 저장 단계에서는 JPA unique constraint와 `PersistenceConflictException` 변환을 그대로 활용해 DB가 최종 충돌을 막도록 두었고, 서비스는 그 예외를 도메인 에러로 바꿔 돌려준다. +- flush 순서: `deleteReservation()` -> `savePromotedReservation()` -> `reservationWaitingRepository.delete()` 순서로 호출되도록 유지했다. 각 레포지토리 메서드가 `flush()`까지 수행하므로, 예약 슬롯을 먼저 비운 다음 승격 예약을 넣고 마지막에 대기를 지우는 순서가 실제 DB 반영 순서로도 드러난다. + +관리자용 대기 목록은 `ReservationWaitingRepository.findAll()`을 JPA로 옮기고, `@EntityGraph`로 `slot`, `theme`, `time`을 함께 읽도록 맞췄다. 덕분에 목록 조회 시에도 `ReservationWaitingJpaEntity.toDomain()` 과정에서 연관 필드 때문에 추가 SELECT가 다시 늘어나는 상황을 피할 수 있었다. diff --git a/docs/pr-review/cycle2-pr-review-1.md b/docs/pr-review/cycle2-pr-review-1.md new file mode 100644 index 0000000000..11f84a313f --- /dev/null +++ b/docs/pr-review/cycle2-pr-review-1.md @@ -0,0 +1,91 @@ +# Cycle 2 - PR Review 1 수정 사항 + +PR: https://github.com/woowacourse/spring-roomescape-waiting/pull/511 + +--- + +## ✅ 리팩토링 할 것 목록 + +### 대기 줄 도메인 + +- [x] **1. `ReservationWaitingLine.sequenceOf()`가 1부터 시작하는 순번을 반환하는 책임이 맞는지 검토한다.** + - 받은 리뷰: `ReservationWaitingLine.sequenceOf()`에서 `index + 1`을 반환하는 것이 표현 계층의 약속인지 확인하라. 찾지 못했을 때 예외를 던지는 방식도 함께 고민하라. + - 판단 기준: 대기 줄 내부의 위치와 사용자에게 보여줄 순번은 구분되어야 한다. 대기 줄 도메인은 줄 안에서의 위치를 계산하고, 1부터 시작하는 표시용 순번은 응답 조립 책임에 가깝다고 판단한다. + - 해결 내용: `ReservationWaitingLine`은 0-based 위치를 `OptionalInt`로 반환하는 `indexOf()`만 제공하도록 변경했다. 히스토리 응답에 필요한 1-based 순번 변환은 히스토리 응답 조립 전용 객체인 `MyWaitingLines.sequenceOf()`에서 처리하도록 분리했다. + +- [x] **2. `ReservationWaitingLine`이 `ReservationWaitingOrder`를 따로 보관해야 하는지 검토한다.** + - 받은 리뷰: `ReservationWaitingLine`이 어떤 검증을 가질 수 있는지 고민하라. `ReservationWaitingOrder`를 별도로 둔 이유를 설명하고, `ReservationWaiting`을 필드로 가질 수 없는지 검토하라. + - 판단 기준: 대기 줄의 검증은 개별 값 검증이 아니라 "하나의 줄에 속한 대기들이 같은 슬롯을 기다리는가", "순번 계산이 가능한 저장된 대기들인가" 같은 컬렉션 불변식이어야 한다. 기존 구조는 실제 대기 줄과 히스토리 조회 순번 계산을 하나의 객체에 몰아넣어 책임이 섞였다고 판단한다. + - 해결 내용: `ReservationWaitingLine`은 실제 `ReservationWaiting` 목록을 보관하는 도메인 객체로 좁혔다. 히스토리 조회에서 `MyWaitingOrder`를 기반으로 표시 순번을 계산하는 책임은 `MyWaitingLines`로 분리했다. 대기 줄 생성 시 저장되지 않은 대기가 포함되거나 서로 다른 슬롯의 대기가 하나의 줄로 묶이면 예외를 던지도록 컬렉션 불변식도 추가했다. + +- [x] **3. `ReservationWaitingOrder.name`이 `null`이 될 수 있는 구조를 제거한다.** + - 받은 리뷰: `ReservationWaitingOrder.name`이 `null`이 될 수 있는 구조인지 확인하라. + - 판단 기준: 이 문제는 `ReservationWaitingLine`을 히스토리 순번 계산에 재사용하면서 생긴 파생 문제다. + - 해결 내용: `ReservationWaitingLine`과 `MyWaitingLines`의 책임을 분리하면서 `ReservationWaitingOrder`를 제거했다. 이에 따라 `name == null`을 허용하던 구조도 함께 사라졌다. + +- [x] **4. 대기 순서 규칙을 SQL과 도메인 중 한 곳으로 모은다.** + - 받은 리뷰: `findFirstBySlot`의 `ORDER BY requested_at, id`와 `ReservationWaitingLine`의 정렬 기준이 중복되어 있다. 대기 순서 규칙의 주인을 한 곳으로 모아라. + - 판단 기준: 대기 순서는 도메인 규칙에 가까운 개념이다. SQL과 도메인에 같은 정렬 기준이 중복되면 변경 시 불일치가 생길 수 있다. + - 해결 내용: `ReservationWaitingRepository.findFirstBySlot()`을 제거하고, Repository는 `findLineBySlot()`으로 해당 슬롯의 대기 목록만 조회하도록 정리했다. 첫 번째 대기 선택은 `ReservationWaitingLine.first()`에서 처리하도록 변경해 요청 시각과 ID 기준 정렬 규칙을 도메인 객체에 모았다. + +### 슬롯 도메인과 테이블 승격 + +- [x] **5. `ReservationSlot.createNew`, `ReservationSlot.of`의 의미를 생성자로 정리한다.** + - 받은 리뷰: `ReservationSlot.of`가 어떤 의미인지 명확하지 않다. 정적 메서드로 나누기보다 생성자를 다르게 호출하는 방식도 검토하라. + - 판단 기준: 기존 `createNew`는 아직 저장되지 않은 슬롯, `of`는 DB 식별자가 있는 슬롯을 의미했다. 하지만 정적 팩토리 이름을 따로 두는 것보다, 식별자가 없는 생성자와 식별자가 있는 생성자를 다르게 호출하는 편이 저장 전 객체와 저장소에서 복원한 객체의 차이를 더 단순하게 드러낸다고 판단한다. + - 해결 내용: `ReservationSlot.createNew(...)`, `ReservationSlot.of(...)` 정적 팩토리를 제거했다. 새로 열거나 조회 조건으로 사용할 저장 전 슬롯은 `new ReservationSlot(date, theme, time)`으로 생성하고, Repository에서 DB row를 복원할 때는 `new ReservationSlot(id, date, theme, time)` 생성자를 사용하도록 변경했다. + +- [x] **6. `ReservationSlot`을 DB row로 둘 만큼 실체가 있는 개념인지 다시 정리한다.** + - 받은 리뷰: `ReservationSlot`이 예약/대기처럼 사용자 요청에 따라 생성되는 데이터인지 확인하라. DB row로 쌓일 만큼 가치 있는 개념인지 근거를 정리하라. + - 판단 기준: `ReservationSlot`은 `date`, `theme`, `time` 조합의 중복 제거만을 위한 요소가 아니다. 사용자가 특정 날짜/테마/시간에 예약하고 싶어도 관리자가 해당 슬롯을 열어두지 않으면 예약 가능한 단위로 의미가 없다고 판단한다. + - 해결 내용: `ReservationSlot`을 "관리자가 열어둔 예약 가능 단위"로 정의했다. 관리자 슬롯 생성 API와 관리자 슬롯 생성 페이지를 추가해 슬롯 생성 생명주기를 코드에 드러냈다. 사용자 예약/대기 요청에서는 슬롯을 새로 만들지 않고, 관리자가 미리 연 슬롯을 찾아 연결하는 방향을 유지한다. + +- [x] **7. `findExistingSlot()` 흐름이 슬롯의 존재 의미를 잘 드러내는지 검토한다.** + - 받은 리뷰: 슬롯 개념이 DB에 들어오면서 혼란이 생긴다. `reservationSlotRepository.save(...)`가 실제로 호출되는지, 슬롯이 언제 생성되는지 드러내라. + - 판단 기준: 사용자 예약/대기 요청에서 슬롯을 새로 저장하지 않는 것은 의도한 정책이다. 혼란의 핵심은 `findExistingSlot()` 자체보다 `reservationSlotRepository.save(...)`가 호출되는 명확한 생성 유스케이스가 없었다는 점이라고 판단한다. + - 해결 내용: 관리자 슬롯 생성 API와 페이지를 추가해 `reservationSlotRepository.save(...)`가 관리자 슬롯 오픈 흐름에서 호출되도록 했다. 이로써 슬롯은 관리자에 의해 생성되고, 사용자 흐름에서는 이미 열린 슬롯을 조회해서 사용하는 구조가 되었다. + +### 서비스 책임과 작업 단위 + +- [x] **8. 예약 취소 시 대기 승급 로직의 책임 위치를 검토한다.** + - 받은 리뷰: 예약 취소 시 대기자가 승급되는 로직이 누구의 책임인지 정리하라. 다른 응용 계층이 등장했을 때 private 메서드가 복사되지 않도록 책임 위치를 고민하라. + - 판단 기준: 예약 취소와 대기 승급은 같은 슬롯의 상태를 바꾸는 하나의 작업 단위다. 다만 현재 요구사항 안에서는 별도 응용 서비스로 재사용되는 지점이 아직 없으므로, 서비스를 추가하는 것보다 `ReservationService`의 트랜잭션 흐름 안에서 읽히도록 두는 편이 단순하다고 판단한다. 대신 첫 대기 선택과 승급 예약 생성처럼 도메인 객체가 말할 수 있는 책임은 도메인으로 옮긴다. + - 해결 내용: `ReservationCancellationService`를 제거하고 예약 취소/승급 흐름을 `ReservationService`의 트랜잭션 메서드 안으로 되돌렸다. 첫 번째 대기 선택은 `ReservationWaitingLine.first()`가 담당하고, 대기자가 예약으로 승급될 때 생성되는 예약은 `ReservationWaiting.toReservation()`으로 만들도록 정리했다. + +- [x] **9. 리뷰 반영으로 구조가 더 복잡해지지 않았는지 점검한다.** + - 받은 리뷰: private 메서드를 다른 객체로 옮기는 과정에서 구조가 더 복잡해지는 방향을 경계하라. Factory나 별도 Cancel Service가 정말 필요한 상황인지 검토하라. + - 판단 기준: 별도 객체는 역할이 분명할 때만 유지한다. `ReservationFactory`는 도메인 객체 생성과 예외 변환만 감싸고 있어 독립된 빈으로 둘 만큼의 책임이 부족하다고 판단한다. `ReservationCancellationService` 역시 현재 단계에서는 실제 재사용 지점이 없고, 오히려 흐름을 따라가기 어렵게 만들 수 있다고 판단한다. + - 해결 내용: `ReservationFactory`와 `ReservationCancellationService`를 제거했다. 예약 생성/변경 시 필요한 예외 변환은 해당 유스케이스를 처리하는 `ReservationService` 내부에 남겼고, 대기 승급 예약 생성은 `ReservationWaiting.toReservation()`으로 옮겨 도메인 객체가 자신의 상태를 기반으로 예약을 만들 수 있도록 했다. + +- [x] **10. 예약 생성 흐름에 트랜잭션이 필요한지 전체적으로 확인한다.** + - 받은 리뷰: 예약 생성 메서드에 트랜잭션이 불필요한지 전체적으로 확인하라. + - 판단 기준: 예약 생성은 테마/시간/슬롯 조회, 중복 예약 조회, 예약 저장을 수행하지만 실제 데이터 변경은 `reservation` INSERT 하나다. 읽기와 쓰기 사이에 같은 슬롯 예약이 먼저 생성될 수는 있으나, 이 경우는 `reservation.slot_id` UNIQUE 제약이 최종적으로 막는다. 트랜잭션을 붙여도 기본 격리 수준에서는 조회 이후 저장 전까지의 경쟁 상태를 막지 못하므로, 단순히 `@Transactional`을 붙이는 것만으로 추가 정합성이 생기지는 않는다고 판단한다. + - 해결 내용: 예약 생성 메서드에는 별도 트랜잭션을 추가하지 않는다. 동시 예약 생성은 DB UNIQUE 제약과 예외 변환으로 처리하고, 이 동작은 동시성 테스트로 보호한다. 여러 데이터를 함께 변경하는 예약 취소/대기 승급 흐름만 트랜잭션으로 묶는 현재 기준을 유지한다. + +- [x] **11. `LocalDateTime.now()` 호출 시점을 요청 기준으로 통일한다.** + - 받은 리뷰: 현재 시각이 요청이 온 시각인지, 각 메서드가 `now()`를 호출한 시각인지 전체적으로 확인하라. + - 판단 기준: 하나의 요청 안에서 여러 번 `now()`를 호출하면 예약 가능 검증, 생성 시각, 수정 가능 검증의 기준 시각이 미세하게 달라질 수 있다. + - 해결 내용: `ReservationService`의 예약 생성, 취소, 수정 흐름에서는 public 메서드 초입에서 `requestedAt`을 한 번 만들고 검증, 슬롯 변경, 대기 승급 예약 생성까지 같은 값을 전달하도록 수정했다. `ReservationTimeService.findAvailableTimes()`와 예약 페이지 슬롯 상태 조립도 목록 처리 중 반복해서 `now()`를 호출하지 않도록 기준 시각을 한 번만 잡도록 정리했다. + +### 조회 성능과 Repository 사용 + +- [x] **12. 예약 가능 시간 조회에서 N+1 쿼리가 발생하는지 확인한다.** + - 받은 리뷰: `findAvailableTimes`에서 쿼리가 몇 번 발생하는지 살펴보라. + - 판단 기준: 기존에는 슬롯 목록을 조회한 뒤 각 슬롯마다 `reservationRepository.findBySlot(slot)`을 호출하므로 슬롯 수만큼 추가 쿼리가 발생할 수 있었다. 다만 "예약 가능한 슬롯"은 Repository가 알아야 할 저장소 조건이라기보다 서비스 유스케이스에서 판단할 정책에 가깝다고 판단한다. + - 해결 내용: Repository에는 날짜/테마에 맞는 슬롯 목록과 예약 목록을 각각 조회하는 역할만 두고, 서비스에서 두 컬렉션을 비교해 예약 가능한 시간을 판단하도록 변경했다. 이로써 쿼리 수는 슬롯 수와 무관하게 슬롯 목록 조회 1번, 예약 목록 조회 1번으로 고정된다. + +- [x] **13. 예약 시간 삭제 시 모든 예약을 가져오는 방식이 적절한지 검토한다.** + - 받은 리뷰: 예약 시간 삭제 가능 여부를 확인하기 위해 모든 예약을 가져올 필요가 있는지 검토하라. + - 판단 기준: 전체 예약을 메모리로 가져와 필터링하는 것은 데이터가 늘어날수록 비용이 커진다. 삭제 가능 여부는 DB에서 존재 여부만 확인하는 쪽이 효율적이다. + - 해결 내용: `ReservationTimeService.deleteById()`에서 `reservationRepository.findAll()`로 전체 예약을 조회해 필터링하던 방식을 제거했다. 삭제 대상 예약 시간을 먼저 조회한 뒤, `ReservationRepository.existsByTime()`으로 해당 시간이 예약에 사용 중인지 DB에서 존재 여부만 확인하도록 변경했다. + +### 기타 도메인 검증 + +- [x] **14. `Reservation`에서 검증용으로만 쓰는 값 객체를 필드로 가질 수 있는지 검토한다.** + - 받은 리뷰: 이름 값 객체를 검증용으로만 사용하는지 확인하라. 도메인 필드로 가질 수는 없는지 검토하라. + - 판단 기준: 값 객체가 단순 검증 도구가 아니라 도메인 의미를 가진다면 필드로 보관하는 편이 일관적이다. 다만 응답/저장 계층에서 문자열이 반복적으로 필요해지는 비용도 고려해야 한다. + - 해결 내용: `ReservationWaiting`과 동일하게 `Reservation`도 `ReservationName`을 필드로 가지도록 변경했다. 응답 DTO와 Repository에서는 문자열 값이 필요하므로 `getName()`은 기존처럼 `String`을 반환하되, 도메인 내부의 상태는 값 객체로 보관하도록 정리했다. + +## 동시성 판단 메모 + +리뷰어가 남긴 동시성 관련 리뷰는 특정 코드 수정보다 판단 기준을 꺼내보라는 성격에 가깝다고 정리한다. 이번 구현에서는 예약 생성 중복과 같은 이름 대기 중복처럼 DB 제약으로 명확히 막을 수 있는 경합은 UNIQUE 제약으로 처리했다. 예약 취소 후 대기 승급은 여러 데이터 변경이 함께 성립해야 하므로 트랜잭션으로 묶었다. 다만 예약 취소 중 대기 신청/대기 취소가 동시에 일어나는 상황을 완전히 직렬화하지는 않았다. 이 부분은 현재 미션의 트래픽과 민감도를 고려해 정책적으로 허용하고, 추후 수동 승인이나 관리자 강제 승급처럼 같은 슬롯의 상태를 동시에 바꾸는 유스케이스가 늘어나면 슬롯 row 기준 비관적 락을 검토한다. diff --git a/docs/pr-review/cycle2-pr-review-2.md b/docs/pr-review/cycle2-pr-review-2.md new file mode 100644 index 0000000000..f57f5be90c --- /dev/null +++ b/docs/pr-review/cycle2-pr-review-2.md @@ -0,0 +1,85 @@ +# Cycle 2 - PR Review 2 수정 사항 + +PR: https://github.com/woowacourse/spring-roomescape-waiting/pull/511 + +--- + +## ✅ 리팩토링 할 것 목록 + +### 값 객체와 도메인 불변식 + +- [x] **1. `ReservationName` 값 객체의 동등성 기준을 정의한다.** + - 받은 리뷰: `ReservationName`이 값 객체라면 `equals`, `hashCode`를 적용할 수 있는지 확인하라. + - 판단 기준: 값 객체는 식별자가 아니라 값 자체로 동등성을 판단하는 객체다. 현재 `Reservation.hasName()`처럼 내부 문자열 값을 직접 꺼내 비교하는 흐름이 있다면, 값 객체의 동등성 책임이 충분히 표현되지 않은 상태일 수 있다. + - 해결 방향: `ReservationName`에 `equals`, `hashCode`를 구현하고, 이름 비교가 필요한 도메인 로직에서는 문자열 비교 대신 값 객체 비교를 사용하도록 정리한다. + - 리뷰 코멘트: 말씀해주신 대로 값 객체라면 값 자체로 동등성을 판단하는 것이 자연스럽다고 생각해 `ReservationName`에 `equals`, `hashCode`를 추가했습니다. `Reservation.hasName()`도 문자열 값을 직접 꺼내 비교하지 않고 `ReservationName`끼리 비교하도록 수정했습니다. + +- [x] **2. `Reservation.PAST_RESERVATION_MESSAGE`가 public 상수로 열려 있어야 하는지 검토한다.** + - 받은 리뷰: `PAST_RESERVATION_MESSAGE`가 public으로 열려 있는 이유를 확인하라. 이 문자열만 필드로 정의한 이유와 테스트 때문에 프로덕션 코드가 바뀐 것은 아닌지 확인하라. + - 판단 기준: 도메인은 외부 테스트나 응답 메시지 편의를 위해 내부 메시지를 공개할 필요가 없다. 예외 메시지 자체를 테스트하기 위해 프로덕션 코드의 접근 범위를 넓혔다면 테스트가 구현 세부사항에 과하게 의존하고 있는 신호일 수 있다. + - 해결 방향: 메시지 상수의 접근 범위를 낮추거나 상수 자체를 제거한다. 테스트는 정확한 문자열보다 예외 타입이나 도메인 행위 실패 여부를 검증하도록 조정한다. + - 리뷰 코멘트: public 상수로 열어둔 이유는 서비스에서 과거 예약 예외를 구분하고 테스트에서 메시지를 검증하기 위해서였는데, 말씀해주신 것처럼 도메인 내부 메시지를 외부 편의를 위해 공개한 형태였던 것 같습니다. 문자열 비교 대신 `PastReservationException` 타입으로 의미를 구분하도록 바꾸고, 메시지 상수는 private으로 숨겼습니다. 테스트도 구체 문자열에 의존하지 않도록 수정했습니다. + +- [x] **3. `Reservation`의 생성 경로별 검증 차이를 불변식 관점에서 다시 정리한다.** + - 받은 리뷰: 어떤 생성자는 `id`를 검증하고, 어떤 생성자는 예약 가능 여부만 검증한다. `Reservation` 객체의 불변식이 무엇인지, 어떤 생성 경로에서도 일관되게 지켜지는지 확인하라. + - 판단 기준: `Reservation`에는 두 종류의 규칙이 섞여 있었다. 첫째, 모든 예약이 항상 만족해야 하는 공통 불변식이다. 이름, 슬롯, 생성 시각은 비어 있으면 안 되고, 저장소에서 복원된 예약이라면 id도 있어야 한다. 둘째, "사용자가 새 예약을 만든다"는 유스케이스에서만 필요한 정책이다. 새 예약은 과거 슬롯에 만들 수 없다. + - 문제점: 기존에는 id가 있는 생성자와 id가 없는 생성자의 검증 차이가 생성자 오버로딩에 숨어 있었다. 그래서 이 차이가 "저장소에서 복원한 예약"과 "신규 예약 생성"이라는 의도된 맥락 차이인지, 단순히 검증이 빠진 것인지 코드만 보고 알기 어려웠다. 특히 대기 승급에서 새 예약을 만들 때 과거 슬롯이면 막히는 것이 의도된 정책인지 우연히 발생한 결과인지도 명확하지 않았다. + - 해결 방향: 저장소에서 복원된 예약은 `new Reservation(id, ...)` 생성자로 공통 불변식만 검증한다. 반면 신규 예약 생성은 `Reservation.reserve(...)`라는 도메인 행위 이름을 가진 생성 경로로 분리하고, 여기에서 과거 슬롯 예약 불가 정책을 검증한다. 대기 승급도 새 예약을 만드는 행위이므로 `ReservationWaiting.toReservation()`에서 `Reservation.reserve(...)`를 사용하게 한다. + - 리뷰 코멘트: 말씀해주신 내용을 보고 `Reservation`의 불변식과 신규 예약 생성 정책이 생성자 오버로딩 안에 섞여 있어 의도가 흐려졌다고 판단했습니다. 그래서 모든 예약이 공통으로 가져야 하는 값 검증은 저장소 복원용 생성자에서 보장하고, 사용자가 새 예약을 만드는 정책은 `Reservation.reserve(...)`라는 이름의 생성 경로로 분리했습니다. 대기 승급 역시 새 예약을 만드는 흐름이므로 같은 `reserve` 경로를 타도록 정리했습니다. 이를 통해 생성 경로마다 검증이 다른 이유가 저장소 복원과 신규 예약 생성의 맥락 차이로 드러나도록 수정했습니다. + +- [x] **4. `ReservationName`을 받는 생성자를 둘지 검토한다.** + - 받은 리뷰: `ReservationName`을 받는 부생성자를 만들어도 좋겠다. + - 판단 기준: `Reservation` 내부 필드가 `ReservationName`이라면 생성자에서 매번 `String`을 받아 값 객체로 변환하는 방식은 불필요한 변환을 반복하게 만든다. 특히 다른 도메인 객체가 이미 검증된 `ReservationName`을 가지고 있다면 이를 그대로 전달할 수 있어야 한다. + - 해결 방향: 외부 입력을 받는 경로는 `String` 생성자를 유지하되, 도메인 내부 협력에서는 `ReservationName`을 직접 받는 생성자 또는 정적 생성 경로를 제공할 수 있는지 검토한다. + - 리뷰 코멘트: `Reservation`이 내부적으로 `ReservationName`을 필드로 가지고 있으면서도 생성 시에는 항상 `String`으로만 받다 보니, 이미 검증된 값 객체를 다시 문자열로 풀어 전달하는 흐름이 생길 수 있었습니다. `ReservationName`을 받는 생성자를 추가하고 기존 `String` 생성자는 값 객체 생성 후 위임하도록 정리했습니다. + +### 슬롯 도메인과 테이블 승격 + +- [x] **5. `ReservationSlot` 테이블 승격이 요구사항으로부터 나온 설계인지 다시 검토한다.** + - 받은 리뷰: `theme`, `time`은 이미 관리자가 설정하는 요구사항이 있다. 슬롯 테이블이 요구사항으로부터 파생된 것인지, 테이블 생성을 정당화하기 위해 요구사항을 만든 것인지 구분하라. + - 판단 기준: `date-time-theme` 조합이 예약과 대기에서 공유된다는 사실만으로 별도 테이블 분리의 근거가 되지는 않는다. 테이블 분리는 중복 제거보다 생명주기, 정책, 변경 가능성, 독립적인 식별 필요성이 있을 때 더 설득력이 생긴다. + - 해결 방향: `ReservationSlot`을 유지한다면 "관리자가 특정 날짜/테마/시간을 예약 가능 단위로 연다"는 생명주기가 실제 요구사항에 자연스럽게 연결되는지 설명한다. 그렇지 않다면 슬롯 테이블 없이 `date-theme-time` 조합을 기준으로 예약과 대기를 묶는 설계가 더 단순한지 비교한다. + +- [x] **6. `ReservationWaiting`이 `Reservation`이 아니라 `ReservationSlot`을 참조하도록 바꿀지 검토한다.** + - 받은 리뷰: 테이블을 별도로 분리했는데 `Waiting`이 `Slot`을 보지 않고 `Reservation`을 보는 이유를 확인하라. 실제로는 `reservation.getSlot()`을 꺼내 쓰고 있다. + - 판단 기준: 대기가 기다리는 대상이 "특정 예약 row"인지 "특정 슬롯"인지가 핵심이다. 예약 취소 후 새 예약이 생성되는 구조라면 대기가 기존 예약 row를 참조하는 모델은 참조 갱신 문제를 만든다. + - 해결 방향: 대기의 대상이 슬롯이라면 `ReservationWaiting`이 `ReservationSlot`을 직접 참조하도록 모델을 조정한다. 이 경우 예약 존재 여부 검증은 서비스나 더 큰 도메인 객체가 담당하고, 예약 취소/승급 시 대기 row의 FK를 옮기는 문제를 제거할 수 있는지 확인한다. + - 리뷰 코멘트: 말씀해주신 것처럼 슬롯을 별도 테이블로 둔 현재 구조에서는 대기가 특정 예약 row를 바라보는 것보다 슬롯을 직접 바라보는 편이 더 자연스럽다고 판단했습니다. `ReservationWaiting`이 `ReservationSlot`을 필드로 가지도록 변경했고, Repository 조회도 예약 테이블 조인 없이 슬롯 정보만 복원하도록 정리했습니다. 다만 "예약이 있어야 대기할 수 있다", "이미 예약한 사람은 대기할 수 없다"는 유스케이스 검증은 예약 조회가 필요하므로 서비스에서 확인하도록 유지했습니다. + +### 트랜잭션과 시각 의존성 + +- [x] **7. 예약 생성 흐름의 트랜잭션 경계를 명시할지 다시 검토한다.** + - 받은 리뷰: 현재는 단일 저장처럼 보여도 향후 검증 로직이나 부가 작업이 추가될 수 있다. `save`가 하나의 예약 생성 작업이라면 트랜잭션 경계를 명시하는 것이 더 안전하지 않은지 확인하라. + - 판단 기준: 트랜잭션은 단순히 INSERT 원자성만을 위한 장치가 아니라 하나의 유스케이스를 일관된 작업 단위로 드러내는 역할도 한다. 현재 코드가 단일 변경만 수행하더라도, 예약 생성이라는 유스케이스 경계를 명시하는 편이 이후 변경에 더 안전할 수 있다. + - 해결 내용: 예약 생성 서비스 메서드에 `@Transactional`을 추가했다. 중복 예약 동시성은 여전히 UNIQUE 제약이 최종 방어선이라는 점을 유지하되, 트랜잭션 도입 이유는 "현재 동시성 해결"이 아니라 "예약 생성 유스케이스의 작업 단위 명시"로 정리했다. + - 리뷰 코멘트: 처음에는 트랜잭션을 주로 동시성 제어 관점에서 생각해서, 예약 생성은 UNIQUE 제약으로 충분하다고 판단했습니다. 그런데 말씀해주신 것처럼 트랜잭션은 단순히 동시성 처리나 INSERT 원자성만이 아니라 하나의 유스케이스를 일관된 작업 단위로 드러내는 의미도 있다고 이해했습니다. 그래서 예약 생성 흐름에도 `@Transactional`을 명시하도록 수정했습니다. 중복 예약에 대한 최종 방어선은 여전히 DB UNIQUE 제약으로 유지했습니다. + +- [x] **8. 요청 시각 생성 책임을 서비스 밖으로 옮길지 검토한다.** + - 받은 리뷰: `요청 시각`은 해당 메서드를 지날 때인지, 컨트롤러에서 요청을 받았을 때인지 확인하라. 서비스에서 시각을 생성하면 테스트에 용이한 구조인지 확인하라. + - 판단 기준: 서비스 내부에서 `LocalDateTime.now()`를 직접 호출하면 테스트가 현재 시각에 의존한다. 또한 요청 시각의 의미가 "HTTP 요청이 들어온 시점"인지 "서비스 로직이 실행된 시점"인지 모호해진다. + - 해결 방향: 컨트롤러에서 요청 시각을 생성해 서비스로 전달하거나, `Clock`을 주입해 서비스가 현재 시각을 얻도록 변경하는 방식을 비교한다. 테스트 용이성과 계층 책임을 기준으로 선택한다. + - 리뷰 코멘트: 말씀해주신 내용을 보고 `요청 시각`은 서비스 메서드를 지나는 순간보다 요청을 처리하기 시작한 경계의 시각에 가깝다고 판단했습니다. 그래서 서비스 내부의 `LocalDateTime.now()` 호출을 제거하고, 컨트롤러와 페이지 조립 경계에서 `requestedAt`을 생성해 서비스로 전달하도록 수정했습니다. 덕분에 서비스 테스트에서도 기준 시각을 직접 넘길 수 있어 시간 의존성을 더 명확히 제어할 수 있게 되었습니다. + +### 히스토리 순번 계산과 대기 줄 표현 + +- [x] **9. `MyWaitingLines`의 책임을 표현 계층과 도메인 계층 사이에서 다시 정리한다.** + - 받은 리뷰: `MyWaitingLines`의 책임이 무엇인지 확인하라. 표현 계층이라면 정렬 로직이 필요한지, 도메인 계층이라면 `ReservationWaitingLine`과 무엇이 다른지 확인하라. 대기 순서가 바뀌면 코드가 몇 군데 변경되는지도 점검하라. + - 판단 기준: `MyWaitingLines`는 히스토리 응답을 만들기 위해 대기 순번을 계산하는 객체다. 그러나 대기 순서 규칙을 직접 가지고 있다면 도메인 규칙이 히스토리 조회 전용 객체에 중복될 수 있다. + - 해결 방향: 대기 순서 규칙은 `ReservationWaitingLine` 또는 더 큰 도메인 객체 한 곳에 모은다. `MyWaitingLines`는 히스토리 조회 결과를 응답에 필요한 형태로 조립하는 책임만 남기거나, 히스토리 전용 계산 객체로 유지한다면 도메인 순서 규칙과 중복되지 않도록 의존 방향을 조정한다. + - 리뷰 코멘트: 기존에는 `ReservationWaitingLine`에서 1-based 순번을 제거한 뒤 `MyWaitingLines`를 만들었지만, 다시 보니 `MyWaitingLines`가 정렬 규칙까지 직접 알고 있어 대기 순서 규칙이 두 곳에 생긴 상태였습니다. 대기 순서 자체는 첫 번째 대기 승급에도 쓰이는 도메인 규칙이라고 판단해 `ReservationWaitingLine`에 모으고, `MyWaitingLines`는 `ReservationWaitingLine.indexOf()`에 위임한 뒤 히스토리 응답에 필요한 표시 순번으로 `+1`만 하도록 정리했습니다. + +### 더 큰 도메인 모델 검토 + +- [x] **10. 예약 취소 가능 여부와 대기 승급 흐름을 더 큰 도메인 객체로 내릴 수 있는지 검토한다.** + - 받은 리뷰: 예약 취소 가능 여부와 대기 승급은 도메인에서 지켜야 하는 내용일 수 있다. 서비스에 private 메서드로 남아 있으면 다른 응용 계층에서 같은 로직이 복제될 수 있다. + - 판단 기준: 리뷰의 핵심은 단순히 private 메서드를 줄이라는 것이 아니라, "예약 취소 시 첫 대기가 승급된다"는 요구사항 규칙이 특정 서비스의 절차 코드에만 머무르고 있다는 점이었다. 저장소 조회와 영속화는 서비스 책임이지만, 확정 예약과 대기 줄을 함께 보고 취소 결과를 결정하는 일은 슬롯 기준 도메인 규칙으로 볼 수 있다. + - 해결 내용: `ReservationSlotBooking`을 추가해 하나의 슬롯에 대한 확정 예약과 대기 줄을 함께 다루도록 했다. 이 객체는 예약과 대기 줄이 같은 슬롯인지 검증하고, 취소 시 대기가 없으면 예약만 취소하고 대기가 있으면 첫 대기를 예약으로 승급시키는 `ReservationCancellation` 결과를 만든다. `ReservationService`는 이 결과를 받아 저장소에 반영하는 역할만 수행하도록 정리했다. + - 리뷰 코멘트: 말씀해주신 내용을 보고 서비스의 private 메서드가 단순 분리가 아니라, "예약 취소 시 첫 대기를 승급한다"는 도메인 규칙을 서비스가 직접 쥐고 있는 신호라고 이해했습니다. 그래서 저장소 조회와 반영은 서비스에 남기되, 확정 예약과 대기 줄을 함께 보고 취소 결과를 결정하는 `ReservationSlotBooking`을 추가했습니다. 이제 서비스는 `ReservationCancellation` 결과에 따라 기존 예약 삭제, 승급 예약 저장, 승급된 대기 삭제를 반영하고, 취소/승급 판단은 도메인 객체가 담당하도록 정리했습니다. + +### 입력 타입과 검증 위치 + +- [x] **11. `ReservationSlotService.open()`의 식별자 타입을 primitive로 바꿀 수 있는지 검토한다.** + - 받은 리뷰: `open`의 인자가 primitive type이었다면 `validateReferenceIds`는 불필요하지 않았을지 확인하라. + - 판단 기준: 컨트롤러 요청 DTO에서 `@NotNull`로 검증된 식별자가 서비스로 넘어온다면, 서비스에서 null 검증을 반복하는 것은 뷰 계층 검증과 중복될 수 있다. 서비스 메서드가 `long`을 받으면 null 가능성 자체가 사라진다. + - 해결 내용: `ReservationSlotService.open()`의 `themeId`, `timeId`를 `long`으로 변경하고 서비스의 `validateReferenceIds`를 제거했다. API 요청은 DTO의 `@NotNull` 검증을 통과한 뒤 서비스로 전달되고, 관리자 페이지 폼 요청처럼 `@RequestParam(required = false)`로 null이 들어올 수 있는 경로는 컨트롤러에서 검증하도록 옮겼다. + - 리뷰 코멘트: 말씀해주신 대로 서비스까지 `Long`을 받다 보니 null 검증 책임이 서비스에 남아 있었고, 이 검증이 비즈니스 규칙인지 요청 값 검증인지 모호해졌던 것 같습니다. 외부 입력의 null 검증은 DTO 또는 컨트롤러 경계에서 처리하고, 서비스는 `long themeId`, `long timeId`를 받아 참조 조회와 슬롯 생성 규칙에 집중하도록 수정했습니다. diff --git a/src/main/java/roomescape/controller/HomePageController.java b/src/main/java/roomescape/controller/HomePageController.java new file mode 100644 index 0000000000..fc7bed9ad7 --- /dev/null +++ b/src/main/java/roomescape/controller/HomePageController.java @@ -0,0 +1,13 @@ +package roomescape.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomePageController { + + @GetMapping("/") + public String home() { + return "redirect:/pages/user/reservations"; + } +} diff --git a/src/main/java/roomescape/controller/reservation/ReservationAdminPageController.java b/src/main/java/roomescape/controller/reservation/ReservationAdminPageController.java index 5aa939ab43..6252180039 100644 --- a/src/main/java/roomescape/controller/reservation/ReservationAdminPageController.java +++ b/src/main/java/roomescape/controller/reservation/ReservationAdminPageController.java @@ -1,5 +1,6 @@ package roomescape.controller.reservation; +import java.time.LocalDateTime; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -41,7 +42,7 @@ public String deleteReservation( final RedirectAttributes redirectAttributes ) { try { - reservationService.deleteById(id); + reservationService.deleteById(id, LocalDateTime.now()); } catch (ApiException exception) { redirectAttributes.addAttribute("errorCode", exception.getCode()); return "redirect:/pages/admin/reservations"; diff --git a/src/main/java/roomescape/controller/reservation/ReservationController.java b/src/main/java/roomescape/controller/reservation/ReservationController.java index b27dca5b96..ccf3ee81b8 100644 --- a/src/main/java/roomescape/controller/reservation/ReservationController.java +++ b/src/main/java/roomescape/controller/reservation/ReservationController.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import java.net.URI; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -43,7 +44,8 @@ public ResponseEntity createReservation( reservationRequest.name(), reservationRequest.date(), reservationRequest.themeId(), - reservationRequest.timeId() + reservationRequest.timeId(), + LocalDateTime.now() )); return ResponseEntity.created(URI.create("/reservations/" + reservation.id())) @@ -55,7 +57,7 @@ public ResponseEntity deleteReservation( @PathVariable final Long id, @RequestParam final String name ) { - reservationService.deleteByIdAndName(id, name); + reservationService.deleteByIdAndName(id, name, LocalDateTime.now()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/roomescape/controller/reservation/ReservationPageController.java b/src/main/java/roomescape/controller/reservation/ReservationPageController.java index 7ef62d690b..3c455504cf 100644 --- a/src/main/java/roomescape/controller/reservation/ReservationPageController.java +++ b/src/main/java/roomescape/controller/reservation/ReservationPageController.java @@ -1,6 +1,7 @@ package roomescape.controller.reservation; import java.time.LocalDate; +import java.time.LocalDateTime; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -46,6 +47,7 @@ public String getReservationPage( @RequestParam(required = false) final String errorCode, final Model model ) { + LocalDateTime requestedAt = LocalDateTime.now(); ThemeResponse selectedTheme = null; String resolvedErrorCode = errorCode; @@ -63,7 +65,8 @@ public String getReservationPage( reservationName, period, limit, - resolvedErrorCode + resolvedErrorCode, + requestedAt ); return "reservation/list"; @@ -78,12 +81,14 @@ public String createReservation( @RequestParam(required = false) final Long timeId, final RedirectAttributes redirectAttributes ) { + LocalDateTime requestedAt = LocalDateTime.now(); try { reservationService.save( name, date, requireId(themeId, "themeId는 필수입니다."), - requireId(timeId, "timeId는 필수입니다.") + requireId(timeId, "timeId는 필수입니다."), + requestedAt ); addReservationNameAttribute(redirectAttributes, name); } catch (ApiException exception) { @@ -124,12 +129,14 @@ public String createReservationWaiting( @RequestParam(required = false) final Long timeId, final RedirectAttributes redirectAttributes ) { + LocalDateTime requestedAt = LocalDateTime.now(); try { reservationWaitingService.save( name, date, requireId(themeId, "themeId는 필수입니다."), - requireId(timeId, "timeId는 필수입니다.") + requireId(timeId, "timeId는 필수입니다."), + requestedAt ); addReservationNameAttribute(redirectAttributes, name); addThemeIdAttribute(redirectAttributes, themeId); @@ -212,8 +219,9 @@ public String deleteReservation( @RequestParam(required = false) final String reservationName, final RedirectAttributes redirectAttributes ) { + LocalDateTime requestedAt = LocalDateTime.now(); try { - reservationService.deleteByIdAndName(id, reservationName); + reservationService.deleteByIdAndName(id, reservationName, requestedAt); } catch (ApiException exception) { return redirectReservationPageWithError( redirectAttributes, @@ -253,12 +261,14 @@ public String updateReservation( @RequestParam(required = false) final Long timeId, final RedirectAttributes redirectAttributes ) { + LocalDateTime requestedAt = LocalDateTime.now(); try { reservationService.updateByIdAndName( id, reservationName, date, - requireId(timeId, "timeId는 필수입니다.") + requireId(timeId, "timeId는 필수입니다."), + requestedAt ); } catch (ApiException exception) { return redirectReservationPageWithError( diff --git a/src/main/java/roomescape/controller/reservation/ReservationPageModelAssembler.java b/src/main/java/roomescape/controller/reservation/ReservationPageModelAssembler.java index de77354f53..cd53661506 100644 --- a/src/main/java/roomescape/controller/reservation/ReservationPageModelAssembler.java +++ b/src/main/java/roomescape/controller/reservation/ReservationPageModelAssembler.java @@ -13,7 +13,6 @@ import roomescape.controller.reservationtime.dto.ReservationTimeSlotResponse; import roomescape.controller.reservationtime.dto.ReservationTimeSlotStatus; import roomescape.controller.theme.dto.ThemeResponse; -import roomescape.domain.reservation.Reservation; import roomescape.exception.ErrorCode; import roomescape.service.history.MyHistoryService; import roomescape.service.reservationtime.ReservationTimeService; @@ -53,7 +52,8 @@ public void populateReservationPage( final String reservationName, final int period, final int limit, - final String errorCode + final String errorCode, + final LocalDateTime requestedAt ) { model.addAttribute("themes", themeService.getAll().stream() .map(ThemeResponse::from) @@ -76,7 +76,8 @@ public void populateReservationPage( selectedThemeId, selectedTheme, selectedDate, - myHistories + myHistories, + requestedAt )); model.addAttribute("myHistories", myHistories); } @@ -85,13 +86,15 @@ private List getAvailableTimes( final Long selectedThemeId, final ThemeResponse selectedTheme, final LocalDate selectedDate, - final List myHistories + final List myHistories, + final LocalDateTime requestedAt ) { if (selectedTheme == null || selectedDate == null) { return List.of(); } - Set availableTimeIds = reservationTimeService.findAvailableTimes(selectedDate, selectedThemeId).stream() + Set availableTimeIds = reservationTimeService.findAvailableTimes(selectedDate, selectedThemeId, requestedAt) + .stream() .map(reservationTime -> reservationTime.getId()) .collect(Collectors.toSet()); @@ -99,7 +102,12 @@ private List getAvailableTimes( .map(reservationTime -> new ReservationTimeSlotResponse( reservationTime.getId(), reservationTime.getStartAt(), - resolveSlotStatus(selectedDate, reservationTime.getStartAt(), availableTimeIds.contains(reservationTime.getId())), + resolveSlotStatus( + selectedDate, + reservationTime.getStartAt(), + availableTimeIds.contains(reservationTime.getId()), + requestedAt + ), findWaitingId(myHistories, selectedDate, selectedThemeId, reservationTime.getId()) )) .toList(); @@ -108,9 +116,10 @@ private List getAvailableTimes( private ReservationTimeSlotStatus resolveSlotStatus( final LocalDate selectedDate, final LocalTime startAt, - final boolean reservable + final boolean reservable, + final LocalDateTime requestedAt ) { - if (Reservation.isPast(selectedDate, startAt, LocalDateTime.now())) { + if (LocalDateTime.of(selectedDate, startAt).isBefore(requestedAt)) { return ReservationTimeSlotStatus.PAST; } diff --git a/src/main/java/roomescape/controller/reservationmine/ReservationMineController.java b/src/main/java/roomescape/controller/reservationmine/ReservationMineController.java new file mode 100644 index 0000000000..8802448682 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationmine/ReservationMineController.java @@ -0,0 +1,28 @@ +package roomescape.controller.reservationmine; + +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import roomescape.controller.reservation.dto.ReservationResponse; +import roomescape.service.reservationmine.ReservationMineService; + +@RestController +@RequestMapping("/reservations-mine") +public class ReservationMineController { + + private final ReservationMineService reservationMineService; + + public ReservationMineController(final ReservationMineService reservationMineService) { + this.reservationMineService = reservationMineService; + } + + @GetMapping + public ResponseEntity> getReservationsMine( + @RequestParam(required = false) final String name + ) { + return ResponseEntity.ok(reservationMineService.getAllByName(name)); + } +} diff --git a/src/main/java/roomescape/controller/reservationmine/ReservationMinePageController.java b/src/main/java/roomescape/controller/reservationmine/ReservationMinePageController.java new file mode 100644 index 0000000000..8dc71541c8 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationmine/ReservationMinePageController.java @@ -0,0 +1,32 @@ +package roomescape.controller.reservationmine; + +import java.util.List; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import roomescape.controller.reservation.dto.ReservationResponse; +import roomescape.service.reservationmine.ReservationMineService; + +@Controller +@RequestMapping("/pages/user/reservations-mine") +public class ReservationMinePageController { + + private final ReservationMineService reservationMineService; + + public ReservationMinePageController(final ReservationMineService reservationMineService) { + this.reservationMineService = reservationMineService; + } + + @GetMapping + public String getReservationsMine( + @RequestParam(required = false) final String name, + final Model model + ) { + List reservations = reservationMineService.getAllByName(name); + model.addAttribute("reservationName", name); + model.addAttribute("reservations", reservations); + return "reservation/mine-list"; + } +} diff --git a/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminController.java b/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminController.java new file mode 100644 index 0000000000..47ea7a4e6a --- /dev/null +++ b/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminController.java @@ -0,0 +1,46 @@ +package roomescape.controller.reservationslot; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import roomescape.controller.reservationslot.dto.ReservationSlotCreateRequest; +import roomescape.controller.reservationslot.dto.ReservationSlotResponse; +import roomescape.service.reservationslot.ReservationSlotService; + +@RestController +@RequestMapping("/admin/reservation-slots") +public class ReservationSlotAdminController { + private final ReservationSlotService reservationSlotService; + + public ReservationSlotAdminController(final ReservationSlotService reservationSlotService) { + this.reservationSlotService = reservationSlotService; + } + + @GetMapping + public ResponseEntity> getReservationSlots() { + List slots = reservationSlotService.getAll().stream() + .map(ReservationSlotResponse::from) + .toList(); + return ResponseEntity.ok(slots); + } + + @PostMapping + public ResponseEntity openReservationSlot( + @Valid @RequestBody final ReservationSlotCreateRequest request + ) { + ReservationSlotResponse slot = ReservationSlotResponse.from(reservationSlotService.open( + request.date(), + request.themeId(), + request.timeId() + )); + + return ResponseEntity.created(URI.create("/admin/reservation-slots/" + slot.id())) + .body(slot); + } +} diff --git a/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminPageController.java b/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminPageController.java new file mode 100644 index 0000000000..312716ae30 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationslot/ReservationSlotAdminPageController.java @@ -0,0 +1,98 @@ +package roomescape.controller.reservationslot; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import roomescape.controller.reservationtime.dto.ReservationTimeResponse; +import roomescape.controller.reservationslot.dto.ReservationSlotResponse; +import roomescape.controller.theme.dto.ThemeResponse; +import roomescape.exception.ApiException; +import roomescape.exception.ErrorCode; +import roomescape.exception.InvalidInputException; +import roomescape.service.reservationtime.ReservationTimeService; +import roomescape.service.reservationslot.ReservationSlotService; +import roomescape.service.theme.ThemeService; + +@Controller +@RequestMapping("/pages/admin/reservation-slots") +public class ReservationSlotAdminPageController { + private final ReservationSlotService reservationSlotService; + private final ThemeService themeService; + private final ReservationTimeService reservationTimeService; + + public ReservationSlotAdminPageController( + final ReservationSlotService reservationSlotService, + final ThemeService themeService, + final ReservationTimeService reservationTimeService + ) { + this.reservationSlotService = reservationSlotService; + this.themeService = themeService; + this.reservationTimeService = reservationTimeService; + } + + @GetMapping + public String getReservationSlotAdminPage( + @RequestParam(required = false) final String errorCode, + final Model model + ) { + model.addAttribute("slots", reservationSlotService.getAll().stream() + .map(ReservationSlotResponse::from) + .toList()); + model.addAttribute("themes", themeService.getAll().stream() + .map(ThemeResponse::from) + .toList()); + model.addAttribute("reservationTimes", reservationTimeService.getAll().stream() + .map(ReservationTimeResponse::from) + .toList()); + model.addAttribute("errorCode", errorCode); + return "reservationslot/list"; + } + + @PostMapping + public String openReservationSlot( + @RequestParam(required = false) final String date, + @RequestParam(required = false) final Long themeId, + @RequestParam(required = false) final Long timeId, + final RedirectAttributes redirectAttributes + ) { + try { + validateReferenceIds(themeId, timeId); + reservationSlotService.open(parseDate(date), themeId, timeId); + } catch (ApiException exception) { + redirectAttributes.addAttribute("errorCode", exception.getCode()); + return "redirect:/pages/admin/reservation-slots"; + } catch (Exception exception) { + redirectAttributes.addAttribute("errorCode", ErrorCode.INTERNAL_SERVER_ERROR.getCode()); + return "redirect:/pages/admin/reservation-slots"; + } + + return "redirect:/pages/admin/reservation-slots"; + } + + private void validateReferenceIds(final Long themeId, final Long timeId) { + if (themeId == null || timeId == null) { + throw new InvalidInputException(ErrorCode.INVALID_INPUT, "테마와 예약 시간은 필수입니다."); + } + } + + private LocalDate parseDate(final String date) { + if (date == null || date.isBlank()) { + return null; + } + + try { + return LocalDate.parse(date); + } catch (DateTimeParseException exception) { + throw new InvalidInputException( + ErrorCode.INVALID_INPUT, + "날짜 형식이 올바르지 않습니다. yyyy-MM-dd 형식이어야 합니다." + ); + } + } +} diff --git a/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotCreateRequest.java b/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotCreateRequest.java new file mode 100644 index 0000000000..4bda1e11f1 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotCreateRequest.java @@ -0,0 +1,18 @@ +package roomescape.controller.reservationslot.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record ReservationSlotCreateRequest( + @NotNull(message = "예약 날짜는 필수입니다.") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate date, + + @NotNull(message = "themeId는 필수입니다.") + Long themeId, + + @NotNull(message = "timeId는 필수입니다.") + Long timeId +) { +} diff --git a/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotResponse.java b/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotResponse.java new file mode 100644 index 0000000000..0d596c3d01 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationslot/dto/ReservationSlotResponse.java @@ -0,0 +1,22 @@ +package roomescape.controller.reservationslot.dto; + +import java.time.LocalDate; +import roomescape.controller.reservationtime.dto.ReservationTimeResponse; +import roomescape.controller.theme.dto.ThemeResponse; +import roomescape.domain.reservationslot.ReservationSlot; + +public record ReservationSlotResponse( + Long id, + LocalDate date, + ThemeResponse theme, + ReservationTimeResponse time +) { + public static ReservationSlotResponse from(final ReservationSlot slot) { + return new ReservationSlotResponse( + slot.getId(), + slot.getDate(), + ThemeResponse.from(slot.getTheme()), + ReservationTimeResponse.from(slot.getTime()) + ); + } +} diff --git a/src/main/java/roomescape/controller/reservationtime/ReservationTimeController.java b/src/main/java/roomescape/controller/reservationtime/ReservationTimeController.java index ebf2902b03..82399c310e 100644 --- a/src/main/java/roomescape/controller/reservationtime/ReservationTimeController.java +++ b/src/main/java/roomescape/controller/reservationtime/ReservationTimeController.java @@ -1,6 +1,7 @@ package roomescape.controller.reservationtime; import jakarta.validation.Valid; +import java.time.LocalDateTime; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -28,7 +29,11 @@ public ResponseEntity> getAvailableTimes( @Valid @ModelAttribute final AvailableReservationTimeRequest availableReservationTimeRequest ) { return ResponseEntity.ok() - .body(reservationTimeService.findAvailableTimes(availableReservationTimeRequest.date(), themeId).stream() + .body(reservationTimeService.findAvailableTimes( + availableReservationTimeRequest.date(), + themeId, + LocalDateTime.now() + ).stream() .map(ReservationTimeResponse::from) .toList()); } diff --git a/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingAdminPageController.java b/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingAdminPageController.java new file mode 100644 index 0000000000..d084b32c93 --- /dev/null +++ b/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingAdminPageController.java @@ -0,0 +1,55 @@ +package roomescape.controller.reservationwaiting; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import roomescape.controller.reservationwaiting.dto.ReservationWaitingResponse; +import roomescape.exception.ApiException; +import roomescape.exception.ErrorCode; +import roomescape.service.reservationwaiting.ReservationWaitingService; + +@Controller +@RequestMapping("/pages/admin/waitings") +public class ReservationWaitingAdminPageController { + + private final ReservationWaitingService reservationWaitingService; + + public ReservationWaitingAdminPageController(final ReservationWaitingService reservationWaitingService) { + this.reservationWaitingService = reservationWaitingService; + } + + @GetMapping + public String getReservationWaitingAdminPage( + @RequestParam(required = false) final String errorCode, + final Model model + ) { + model.addAttribute("waitings", reservationWaitingService.getAll().stream() + .map(ReservationWaitingResponse::from) + .toList()); + model.addAttribute("errorCode", errorCode); + return "reservationwaiting/admin-list"; + } + + @PostMapping("/{id}/delete") + public String deleteReservationWaiting( + @PathVariable final Long id, + final RedirectAttributes redirectAttributes + ) { + try { + reservationWaitingService.deleteById(id); + } catch (ApiException exception) { + redirectAttributes.addAttribute("errorCode", exception.getCode()); + return "redirect:/pages/admin/waitings"; + } catch (Exception exception) { + redirectAttributes.addAttribute("errorCode", ErrorCode.INTERNAL_SERVER_ERROR.getCode()); + return "redirect:/pages/admin/waitings"; + } + + return "redirect:/pages/admin/waitings"; + } +} diff --git a/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingController.java b/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingController.java index cf2b3a6e6f..c411c7659c 100644 --- a/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingController.java +++ b/src/main/java/roomescape/controller/reservationwaiting/ReservationWaitingController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import java.net.URI; +import java.time.LocalDateTime; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,7 +34,8 @@ public ResponseEntity createReservationWaiting( reservationWaitingRequest.name(), reservationWaitingRequest.date(), reservationWaitingRequest.themeId(), - reservationWaitingRequest.timeId() + reservationWaitingRequest.timeId(), + LocalDateTime.now() )); return ResponseEntity.created(URI.create("/waitings/" + reservationWaiting.id())) diff --git a/src/main/java/roomescape/controller/reservationwaiting/dto/ReservationWaitingResponse.java b/src/main/java/roomescape/controller/reservationwaiting/dto/ReservationWaitingResponse.java index 5e0575d1e2..37714051dd 100644 --- a/src/main/java/roomescape/controller/reservationwaiting/dto/ReservationWaitingResponse.java +++ b/src/main/java/roomescape/controller/reservationwaiting/dto/ReservationWaitingResponse.java @@ -1,21 +1,21 @@ package roomescape.controller.reservationwaiting.dto; import java.time.LocalDateTime; -import roomescape.controller.reservation.dto.ReservationResponse; +import roomescape.controller.reservationslot.dto.ReservationSlotResponse; import roomescape.domain.reservationwaiting.ReservationWaiting; public record ReservationWaitingResponse( Long id, String name, LocalDateTime requestedAt, - ReservationResponse reservationResponse + ReservationSlotResponse slotResponse ) { public static ReservationWaitingResponse from(final ReservationWaiting reservationWaiting) { return new ReservationWaitingResponse( reservationWaiting.getId(), reservationWaiting.getName(), reservationWaiting.getRequestedAt(), - ReservationResponse.from(reservationWaiting.getReservation()) + ReservationSlotResponse.from(reservationWaiting.getSlot()) ); } } diff --git a/src/main/java/roomescape/domain/reservation/PastReservationException.java b/src/main/java/roomescape/domain/reservation/PastReservationException.java new file mode 100644 index 0000000000..4c7ee70142 --- /dev/null +++ b/src/main/java/roomescape/domain/reservation/PastReservationException.java @@ -0,0 +1,8 @@ +package roomescape.domain.reservation; + +public class PastReservationException extends IllegalArgumentException { + + public PastReservationException(final String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/domain/reservation/Reservation.java b/src/main/java/roomescape/domain/reservation/Reservation.java index d5b2f5b5e4..3543667e82 100644 --- a/src/main/java/roomescape/domain/reservation/Reservation.java +++ b/src/main/java/roomescape/domain/reservation/Reservation.java @@ -2,79 +2,80 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.Objects; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.reservationwaiting.ReservationWaiting; +import roomescape.domain.reservationwaiting.ReservationWaitingLine; import roomescape.domain.theme.Theme; public class Reservation { - public static final String PAST_RESERVATION_MESSAGE = "과거 날짜와 시간으로는 예약을 할 수 없습니다."; + private static final String PAST_RESERVATION_MESSAGE = "과거 날짜와 시간으로는 예약을 할 수 없습니다."; + private static final String PAST_CANCEL_MESSAGE = "이미 지난 예약은 취소할 수 없습니다."; + private static final String PAST_UPDATE_MESSAGE = "이미 지난 예약은 변경할 수 없습니다."; + private static final String DIFFERENT_SLOT_MESSAGE = "예약과 대기 줄의 슬롯이 일치하지 않습니다."; private final Long id; - private final String name; + private final ReservationName name; private final ReservationSlot slot; private final LocalDateTime createdAt; - private Reservation( + public Reservation( final Long id, final String name, final ReservationSlot slot, final LocalDateTime createdAt ) { - ReservationName reservationName = ReservationName.from(name); + this(id, ReservationName.from(name), slot, createdAt); + } + + public Reservation( + final Long id, + final ReservationName name, + final ReservationSlot slot, + final LocalDateTime createdAt + ) { + validateId(id); + validateName(name); + validateSlot(slot); validate(createdAt); this.id = id; - this.name = reservationName.value(); + this.name = name; this.slot = slot; this.createdAt = createdAt; } - public static Reservation createNew(final String name, final LocalDate date, final Theme theme, final ReservationTime time) { - return createNew(name, new ReservationSlot(date, theme, time), LocalDateTime.now()); - } - - public static Reservation createNew( + public static Reservation reserve( final String name, - final LocalDate date, - final Theme theme, - final ReservationTime time, + final ReservationSlot slot, final LocalDateTime standardDateTime ) { - return createNew(name, new ReservationSlot(date, theme, time), standardDateTime); + return reserve(ReservationName.from(name), slot, standardDateTime); } - public static Reservation createNew( - final String name, + public static Reservation reserve( + final ReservationName name, final ReservationSlot slot, final LocalDateTime standardDateTime ) { + validateName(name); validateReservable(slot, standardDateTime); - return new Reservation(null, name, slot, standardDateTime); - } - - public static Reservation of(final Long id, final String name, final LocalDate date, final Theme theme, final ReservationTime time) { - return of(id, name, new ReservationSlot(date, theme, time), LocalDateTime.now()); + validate(standardDateTime); + return new Reservation(name, slot, standardDateTime); } - public static Reservation of( - final Long id, - final String name, - final LocalDate date, - final Theme theme, - final ReservationTime time, - final LocalDateTime createdAt - ) { - return of(id, name, new ReservationSlot(date, theme, time), createdAt); - } - - public static Reservation of( - final Long id, - final String name, + private Reservation( + final ReservationName name, final ReservationSlot slot, final LocalDateTime createdAt ) { - validateId(id); - return new Reservation(id, name, slot, createdAt); + validateName(name); + validateSlot(slot); + validate(createdAt); + this.id = null; + this.name = name; + this.slot = slot; + this.createdAt = createdAt; } public Reservation withId(final Long id) { @@ -82,58 +83,84 @@ public Reservation withId(final Long id) { return new Reservation(id, this.name, this.slot, this.createdAt); } - public Reservation withDateAndTime(final LocalDate date, final ReservationTime time) { - return new Reservation(this.id, this.name, new ReservationSlot(date, this.slot.theme(), time), this.createdAt); + public Reservation withSlot( + final ReservationSlot slot, + final LocalDateTime standardDateTime + ) { + validateReservable(slot, standardDateTime); + return new Reservation(this.id, this.name, slot, this.createdAt); } - public Reservation withDateAndTime( - final LocalDate date, - final ReservationTime time, - final LocalDateTime standardDateTime + public ReservationCancellationResult cancel( + final ReservationWaitingLine waitingLine, + final LocalDateTime requestedAt ) { - ReservationSlot changedSlot = new ReservationSlot(date, this.slot.theme(), time); - validateReservable(changedSlot, standardDateTime); - return new Reservation(this.id, this.name, changedSlot, this.createdAt); + validateWaitingLine(waitingLine); + + return waitingLine.first() + .map(waiting -> promote(waiting, requestedAt)) + .orElseGet(() -> ReservationCancellationResult.withoutPromotion(this)); } public boolean hasName(final String name) { - return this.name.equals(ReservationName.from(name).value()); + return this.name.equals(ReservationName.from(name)); } public boolean isPast(final LocalDateTime standardDateTime) { return slot.isPast(standardDateTime); } - public static boolean isReservable( - final LocalDate date, - final ReservationTime time, - final LocalDateTime standardDateTime - ) { - return ReservationSlot.isReservable(date, time, standardDateTime); - } - - public static boolean isPast( - final LocalDate date, - final ReservationTime time, - final LocalDateTime standardDateTime - ) { - return ReservationSlot.isPast(date, time, standardDateTime); + public void validateCancelable(final LocalDateTime standardDateTime) { + if (isPast(standardDateTime)) { + throw new PastReservationException(PAST_CANCEL_MESSAGE); + } } - public static boolean isPast( - final LocalDate date, - final LocalTime startAt, - final LocalDateTime standardDateTime - ) { - return ReservationSlot.isPast(date, startAt, standardDateTime); + public void validateUpdatable(final LocalDateTime standardDateTime) { + if (isPast(standardDateTime)) { + throw new PastReservationException(PAST_UPDATE_MESSAGE); + } } private static void validateReservable( final ReservationSlot slot, final LocalDateTime standardDateTime ) { + validateSlot(slot); if (slot.isPast(standardDateTime)) { - throw new IllegalArgumentException(PAST_RESERVATION_MESSAGE); + throw new PastReservationException(PAST_RESERVATION_MESSAGE); + } + } + + private ReservationCancellationResult promote( + final ReservationWaiting waiting, + final LocalDateTime requestedAt + ) { + return ReservationCancellationResult.withPromotion( + this, + waiting, + waiting.toReservation(requestedAt) + ); + } + + private void validateWaitingLine(final ReservationWaitingLine waitingLine) { + if (waitingLine == null) { + throw new IllegalArgumentException("대기 줄은 비어있으면 안됩니다."); + } + if (!waitingLine.isEmpty() && !waitingLine.isForSlot(this.slot)) { + throw new IllegalArgumentException(DIFFERENT_SLOT_MESSAGE); + } + } + + private static void validateSlot(final ReservationSlot slot) { + if (slot == null) { + throw new IllegalArgumentException("예약 슬롯은 비어있으면 안됩니다."); + } + } + + private static void validateName(final ReservationName name) { + if (name == null) { + throw new IllegalArgumentException("예약자 이름은 필수입니다."); } } @@ -143,7 +170,7 @@ private static void validateId(final Long id) { } } - private void validate(final LocalDateTime createdAt) { + private static void validate(final LocalDateTime createdAt) { if (createdAt == null) { throw new IllegalArgumentException("예약 생성 시각은 비어있으면 안됩니다."); } @@ -168,19 +195,19 @@ public Long getId() { } public String getName() { - return this.name; + return this.name.value(); } public LocalDate getDate() { - return this.slot.date(); + return this.slot.getDate(); } public Theme getTheme() { - return this.slot.theme(); + return this.slot.getTheme(); } public ReservationTime getTime() { - return this.slot.time(); + return this.slot.getTime(); } public ReservationSlot getSlot() { diff --git a/src/main/java/roomescape/domain/reservation/ReservationCancellationResult.java b/src/main/java/roomescape/domain/reservation/ReservationCancellationResult.java new file mode 100644 index 0000000000..1ed779e411 --- /dev/null +++ b/src/main/java/roomescape/domain/reservation/ReservationCancellationResult.java @@ -0,0 +1,45 @@ +package roomescape.domain.reservation; + +import java.util.Optional; +import roomescape.domain.reservationwaiting.ReservationWaiting; + +public class ReservationCancellationResult { + + private final Reservation cancelledReservation; + private final ReservationWaiting promotedWaiting; + private final Reservation promotedReservation; + + private ReservationCancellationResult( + final Reservation cancelledReservation, + final ReservationWaiting promotedWaiting, + final Reservation promotedReservation + ) { + this.cancelledReservation = cancelledReservation; + this.promotedWaiting = promotedWaiting; + this.promotedReservation = promotedReservation; + } + + public static ReservationCancellationResult withoutPromotion(final Reservation cancelledReservation) { + return new ReservationCancellationResult(cancelledReservation, null, null); + } + + public static ReservationCancellationResult withPromotion( + final Reservation cancelledReservation, + final ReservationWaiting promotedWaiting, + final Reservation promotedReservation + ) { + return new ReservationCancellationResult(cancelledReservation, promotedWaiting, promotedReservation); + } + + public Reservation cancelledReservation() { + return cancelledReservation; + } + + public Optional promotedWaiting() { + return Optional.ofNullable(promotedWaiting); + } + + public Optional promotedReservation() { + return Optional.ofNullable(promotedReservation); + } +} diff --git a/src/main/java/roomescape/domain/reservation/ReservationName.java b/src/main/java/roomescape/domain/reservation/ReservationName.java index 377021c262..d02b05d617 100644 --- a/src/main/java/roomescape/domain/reservation/ReservationName.java +++ b/src/main/java/roomescape/domain/reservation/ReservationName.java @@ -1,5 +1,7 @@ package roomescape.domain.reservation; +import java.util.Objects; + public class ReservationName { private static final int MAX_LENGTH = 10; @@ -18,6 +20,20 @@ public String value() { return value; } + @Override + public boolean equals(final Object o) { + if (!(o instanceof ReservationName)) { + return false; + } + ReservationName that = (ReservationName) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + private void validate(final String name) { if (name == null || name.isBlank()) { throw new IllegalArgumentException("예약자 이름은 필수입니다."); diff --git a/src/main/java/roomescape/domain/reservation/ReservationSlot.java b/src/main/java/roomescape/domain/reservation/ReservationSlot.java deleted file mode 100644 index cd8afc53ec..0000000000 --- a/src/main/java/roomescape/domain/reservation/ReservationSlot.java +++ /dev/null @@ -1,56 +0,0 @@ -package roomescape.domain.reservation; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; - -public record ReservationSlot( - LocalDate date, - Theme theme, - ReservationTime time -) { - - public ReservationSlot { - if (date == null) { - throw new IllegalArgumentException("날짜는 비어있을 수 없습니다."); - } - - if (theme == null) { - throw new IllegalArgumentException("테마는 비어있으면 안됩니다."); - } - - if (time == null) { - throw new IllegalArgumentException("시간은 비어있으면 안됩니다."); - } - } - - public boolean isPast(final LocalDateTime standardDateTime) { - return isPast(date, time, standardDateTime); - } - - public static boolean isReservable( - final LocalDate date, - final ReservationTime time, - final LocalDateTime standardDateTime - ) { - return !isPast(date, time, standardDateTime); - } - - public static boolean isPast( - final LocalDate date, - final ReservationTime time, - final LocalDateTime standardDateTime - ) { - return isPast(date, time.getStartAt(), standardDateTime); - } - - public static boolean isPast( - final LocalDate date, - final LocalTime startAt, - final LocalDateTime standardDateTime - ) { - return LocalDateTime.of(date, startAt).isBefore(standardDateTime); - } -} diff --git a/src/main/java/roomescape/domain/reservationslot/ReservationSlot.java b/src/main/java/roomescape/domain/reservationslot/ReservationSlot.java new file mode 100644 index 0000000000..3c801ac15d --- /dev/null +++ b/src/main/java/roomescape/domain/reservationslot/ReservationSlot.java @@ -0,0 +1,74 @@ +package roomescape.domain.reservationslot; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; + +public class ReservationSlot { + + private final Long id; + private final LocalDate date; + private final Theme theme; + private final ReservationTime time; + + public ReservationSlot(final LocalDate date, final Theme theme, final ReservationTime time) { + validate(date, theme, time); + this.id = null; + this.date = date; + this.theme = theme; + this.time = time; + } + + public ReservationSlot( + final Long id, + final LocalDate date, + final Theme theme, + final ReservationTime time + ) { + validateId(id); + validate(date, theme, time); + this.id = id; + this.date = date; + this.theme = theme; + this.time = time; + } + + public boolean isPast(final LocalDateTime standardDateTime) { + return LocalDateTime.of(date, time.getStartAt()).isBefore(standardDateTime); + } + + private static void validateId(final Long id) { + if (id == null) { + throw new IllegalArgumentException("Id는 비어있을 수 없습니다."); + } + } + + private static void validate(final LocalDate date, final Theme theme, final ReservationTime time) { + if (date == null) { + throw new IllegalArgumentException("예약 날짜는 비어있으면 안됩니다."); + } + if (theme == null) { + throw new IllegalArgumentException("테마는 비어있으면 안됩니다."); + } + if (time == null) { + throw new IllegalArgumentException("예약 시간은 비어있으면 안됩니다."); + } + } + + public Long getId() { + return id; + } + + public LocalDate getDate() { + return date; + } + + public Theme getTheme() { + return theme; + } + + public ReservationTime getTime() { + return time; + } +} diff --git a/src/main/java/roomescape/domain/reservationwaiting/ReservationWaiting.java b/src/main/java/roomescape/domain/reservationwaiting/ReservationWaiting.java index 39c8439f6e..22c1e25788 100644 --- a/src/main/java/roomescape/domain/reservationwaiting/ReservationWaiting.java +++ b/src/main/java/roomescape/domain/reservationwaiting/ReservationWaiting.java @@ -4,55 +4,56 @@ import java.util.Objects; import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationslot.ReservationSlot; public class ReservationWaiting { public static final String PAST_WAITING_MESSAGE = "지난 예약에는 대기를 생성할 수 없습니다."; private final Long id; - private final Reservation reservation; + private final ReservationSlot slot; private final ReservationName name; private final LocalDateTime requestedAt; private ReservationWaiting( final Long id, - final Reservation reservation, + final ReservationSlot slot, final String name, final LocalDateTime requestedAt ) { this.id = id; - this.reservation = reservation; + this.slot = slot; this.name = ReservationName.from(name); this.requestedAt = requestedAt; validateWaitable(); } public static ReservationWaiting createNew( - final Reservation reservation, + final ReservationSlot slot, final String name, final LocalDateTime requestedAt ) { - return new ReservationWaiting(null, reservation, name, requestedAt); + return new ReservationWaiting(null, slot, name, requestedAt); } public static ReservationWaiting of( final Long id, - final Reservation reservation, + final ReservationSlot slot, final String name, final LocalDateTime requestedAt ) { - return new ReservationWaiting(id, reservation, name, requestedAt); + return new ReservationWaiting(id, slot, name, requestedAt); } public ReservationWaiting withId(final Long id) { - return ReservationWaiting.of(id, this.reservation, this.name.value(), this.requestedAt); + return ReservationWaiting.of(id, this.slot, this.name.value(), this.requestedAt); } public Long getId() { return id; } - public Reservation getReservation() { - return reservation; + public ReservationSlot getSlot() { + return slot; } public String getName() { @@ -63,12 +64,19 @@ public boolean hasName(final String name) { return this.name.value().equals(ReservationName.from(name).value()); } + public Reservation toReservation(final LocalDateTime requestedAt) { + return Reservation.reserve(name.value(), slot, requestedAt); + } + public LocalDateTime getRequestedAt() { return requestedAt; } private void validateWaitable() { - if (reservation.isPast(requestedAt)) { + if (slot == null) { + throw new IllegalArgumentException("예약 슬롯은 비어있으면 안됩니다."); + } + if (slot.isPast(requestedAt)) { throw new IllegalArgumentException(PAST_WAITING_MESSAGE); } } diff --git a/src/main/java/roomescape/domain/reservationwaiting/ReservationWaitingLine.java b/src/main/java/roomescape/domain/reservationwaiting/ReservationWaitingLine.java index 722d5f76e8..8b345572e6 100644 --- a/src/main/java/roomescape/domain/reservationwaiting/ReservationWaitingLine.java +++ b/src/main/java/roomescape/domain/reservationwaiting/ReservationWaitingLine.java @@ -3,71 +3,118 @@ import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.IntStream; +import java.util.Optional; +import java.util.OptionalInt; import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationslot.ReservationSlot; public class ReservationWaitingLine { - private final Map sequencesByWaitingId; - private final Set names; + public static final String UNSAVED_WAITING_MESSAGE = "저장되지 않은 대기는 대기 줄에 포함될 수 없습니다."; + public static final String DIFFERENT_SLOT_MESSAGE = "서로 다른 슬롯의 대기를 하나의 대기 줄로 묶을 수 없습니다."; + private static final Comparator WAITING_ORDER = Comparator + .comparing(ReservationWaitingOrder::requestedAt) + .thenComparing(ReservationWaitingOrder::waitingId); - public ReservationWaitingLine(final List orders) { - List sortedOrders = orders.stream() - .sorted(Comparator.comparing(ReservationWaitingOrder::requestedAt) - .thenComparing(ReservationWaitingOrder::waitingId)) - .toList(); + private final List waitings; - this.sequencesByWaitingId = IntStream.range(0, sortedOrders.size()) - .boxed() - .collect(Collectors.toMap( - index -> sortedOrders.get(index).waitingId(), - index -> index + 1 - )); - this.names = sortedOrders.stream() - .map(ReservationWaitingOrder::name) - .filter(Objects::nonNull) - .map(name -> ReservationName.from(name).value()) - .collect(Collectors.toSet()); + public ReservationWaitingLine(final List waitings) { + validate(waitings); + this.waitings = waitings.stream() + .sorted((first, second) -> WAITING_ORDER.compare(toOrder(first), toOrder(second))) + .toList(); } public static ReservationWaitingLine fromWaitings(final List waitings) { - return new ReservationWaitingLine(waitings.stream() - .map(waiting -> new ReservationWaitingOrder( - waiting.getId(), - waiting.getRequestedAt(), - waiting.getName() - )) - .toList()); + return new ReservationWaitingLine(waitings); } public boolean isEmpty() { - return sequencesByWaitingId.isEmpty(); + return waitings.isEmpty(); + } + + public boolean isForSlot(final ReservationSlot slot) { + if (waitings.isEmpty()) { + return true; + } + + Long slotId = slot.getId(); + return waitings.stream() + .map(waiting -> waiting.getSlot().getId()) + .allMatch(waitingSlotId -> Objects.equals(waitingSlotId, slotId)); } public boolean containsName(final ReservationName name) { - return names.contains(name.value()); + return waitings.stream() + .map(ReservationWaiting::getName) + .anyMatch(name.value()::equals); } - public int sequenceOf(final long waitingId) { - Integer sequence = sequencesByWaitingId.get(waitingId); + public Optional first() { + return waitings.stream() + .findFirst(); + } + + public OptionalInt indexOf(final long waitingId) { + return indexOf(waitings.stream() + .map(ReservationWaitingLine::toOrder) + .toList(), waitingId); + } + + public static OptionalInt indexOf(final List orders, final long waitingId) { + validateOrders(orders); + List sortedOrders = orders.stream() + .sorted(WAITING_ORDER) + .toList(); - if (sequence == null) { - throw new IllegalArgumentException("대기 순번을 찾을 수 없습니다."); + for (int index = 0; index < sortedOrders.size(); index++) { + if (sortedOrders.get(index).waitingId() == waitingId) { + return OptionalInt.of(index); + } } - return sequence; + return OptionalInt.empty(); + } + + private void validate(final List waitings) { + validateOrders(waitings.stream() + .map(ReservationWaitingLine::toOrder) + .toList()); + } + + private static void validateOrders(final List orders) { + if (orders.isEmpty()) { + return; + } + + Long slotId = orders.get(0).slotId(); + for (ReservationWaitingOrder order : orders) { + validateSaved(order); + validateSameSlot(slotId, order); + } + } + + private static void validateSaved(final ReservationWaitingOrder order) { + if (order.waitingId() == null || order.slotId() == null) { + throw new IllegalArgumentException(UNSAVED_WAITING_MESSAGE); + } + } + + private static void validateSameSlot(final Long slotId, final ReservationWaitingOrder order) { + if (!Objects.equals(slotId, order.slotId())) { + throw new IllegalArgumentException(DIFFERENT_SLOT_MESSAGE); + } + } + + private static ReservationWaitingOrder toOrder(final ReservationWaiting waiting) { + ReservationSlot slot = waiting.getSlot(); + return new ReservationWaitingOrder(waiting.getId(), slot.getId(), waiting.getRequestedAt()); } public record ReservationWaitingOrder( - long waitingId, - LocalDateTime requestedAt, - String name + Long waitingId, + Long slotId, + LocalDateTime requestedAt ) { - public ReservationWaitingOrder(final long waitingId, final LocalDateTime requestedAt) { - this(waitingId, requestedAt, null); - } } } diff --git a/src/main/java/roomescape/exception/ErrorCode.java b/src/main/java/roomescape/exception/ErrorCode.java index ac251d6292..1760723c40 100644 --- a/src/main/java/roomescape/exception/ErrorCode.java +++ b/src/main/java/roomescape/exception/ErrorCode.java @@ -11,6 +11,7 @@ public enum ErrorCode { RESERVATION_DUPLICATED, RESERVATION_NOT_FOUND, RESERVATION_HAS_WAITING, + RESERVATION_SLOT_DUPLICATED, RESERVATION_TIME_DUPLICATED, RESERVATION_TIME_IN_USE, RESERVATION_TIME_NOT_FOUND, diff --git a/src/main/java/roomescape/repository/PersistenceConflictException.java b/src/main/java/roomescape/repository/PersistenceConflictException.java new file mode 100644 index 0000000000..ef0e2d74c2 --- /dev/null +++ b/src/main/java/roomescape/repository/PersistenceConflictException.java @@ -0,0 +1,8 @@ +package roomescape.repository; + +public class PersistenceConflictException extends RuntimeException { + + public PersistenceConflictException(final Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/roomescape/repository/history/JdbcMyHistoryRepository.java b/src/main/java/roomescape/repository/history/JdbcMyHistoryRepository.java deleted file mode 100644 index bf2e000b34..0000000000 --- a/src/main/java/roomescape/repository/history/JdbcMyHistoryRepository.java +++ /dev/null @@ -1,142 +0,0 @@ -package roomescape.repository.history; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.stereotype.Repository; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; - -@Repository -public class JdbcMyHistoryRepository implements MyHistoryRepository { - - private static final RowMapper historyRowMapper = (resultSet, rowNumber) -> new MyHistory( - resultSet.getLong("reservation_id"), - getNullableLong(resultSet, "waiting_id"), - resultSet.getString("status"), - resultSet.getString("history_name"), - resultSet.getDate("date").toLocalDate(), - mapTheme(resultSet), - mapReservationTime(resultSet), - getNullableLocalDateTime(resultSet, "requested_at") - ); - - private static final RowMapper waitingOrderRowMapper = (resultSet, rowNumber) -> new MyWaitingOrder( - resultSet.getLong("reservation_id"), - resultSet.getLong("waiting_id"), - resultSet.getTimestamp("requested_at").toLocalDateTime() - ); - - private final JdbcTemplate jdbcTemplate; - - public JdbcMyHistoryRepository(final JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public List findByUserName(final String name) { - String sql = """ - SELECT 'RESERVATION' AS status, - r.id AS reservation_id, - NULL AS waiting_id, - r.name AS history_name, - r.date, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url, - rt.id AS time_id, - rt.start_at, - CAST(NULL AS TIMESTAMP) AS requested_at - FROM reservation AS r - INNER JOIN theme AS t ON r.theme_id = t.id - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - WHERE r.name = ? - - UNION ALL - - SELECT 'WAITING' AS status, - r.id AS reservation_id, - rw.id AS waiting_id, - rw.name AS history_name, - r.date, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url, - rt.id AS time_id, - rt.start_at, - rw.requested_at - FROM reservation_waiting AS rw - INNER JOIN reservation AS r ON rw.reservation_id = r.id - INNER JOIN theme AS t ON r.theme_id = t.id - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - WHERE rw.name = ? - ORDER BY date, start_at, status - """; - - return jdbcTemplate.query(sql, historyRowMapper, name, name); - } - - @Override - public List findWaitingOrdersByReservationIds(final List reservationIds) { - if (reservationIds.isEmpty()) { - return List.of(); - } - - String placeholders = String.join(",", Collections.nCopies(reservationIds.size(), "?")); - String sql = """ - SELECT reservation_id, - id AS waiting_id, - requested_at - FROM reservation_waiting - WHERE reservation_id IN (%s) - """.formatted(placeholders); - - return jdbcTemplate.query(sql, waitingOrderRowMapper, reservationIds.toArray()); - } - - private static Theme mapTheme(final ResultSet resultSet) throws SQLException { - return Theme.of( - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("description"), - resultSet.getString("thumbnail_url") - ); - } - - private static ReservationTime mapReservationTime(final ResultSet resultSet) throws SQLException { - return ReservationTime.of( - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - } - - private static Long getNullableLong(final ResultSet resultSet, final String columnName) throws SQLException { - long value = resultSet.getLong(columnName); - - if (resultSet.wasNull()) { - return null; - } - - return value; - } - - private static LocalDateTime getNullableLocalDateTime( - final ResultSet resultSet, - final String columnName - ) throws SQLException { - Timestamp value = resultSet.getTimestamp(columnName); - - if (value == null) { - return null; - } - - return value.toLocalDateTime(); - } -} diff --git a/src/main/java/roomescape/repository/history/JpaMyHistoryRepositoryAdapter.java b/src/main/java/roomescape/repository/history/JpaMyHistoryRepositoryAdapter.java new file mode 100644 index 0000000000..5c2a0940a2 --- /dev/null +++ b/src/main/java/roomescape/repository/history/JpaMyHistoryRepositoryAdapter.java @@ -0,0 +1,198 @@ +package roomescape.repository.history; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.math.BigInteger; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; + +@Repository +@Primary +public class JpaMyHistoryRepositoryAdapter implements MyHistoryRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional(readOnly = true) + public List findByUserName(final String name) { + String sql = """ + SELECT 'RESERVATION' AS status, + r.id AS reservation_id, + NULL AS waiting_id, + r.name AS history_name, + s.date, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url, + rt.id AS time_id, + rt.start_at, + CAST(NULL AS TIMESTAMP) AS requested_at + FROM reservation r + INNER JOIN reservation_slot s ON r.slot_id = s.id + INNER JOIN theme t ON s.theme_id = t.id + INNER JOIN reservation_time rt ON s.time_id = rt.id + WHERE r.name = :name + + UNION ALL + + SELECT 'WAITING' AS status, + r.id AS reservation_id, + rw.id AS waiting_id, + rw.name AS history_name, + s.date, + t.id AS theme_id, + t.name AS theme_name, + t.description, + t.thumbnail_url, + rt.id AS time_id, + rt.start_at, + rw.requested_at + FROM reservation_waiting rw + INNER JOIN reservation_slot s ON rw.slot_id = s.id + INNER JOIN reservation r ON r.slot_id = s.id + INNER JOIN theme t ON s.theme_id = t.id + INNER JOIN reservation_time rt ON s.time_id = rt.id + WHERE rw.name = :name + ORDER BY date, start_at, status + """; + + @SuppressWarnings("unchecked") + List rows = entityManager.createNativeQuery(sql) + .setParameter("name", name) + .getResultList(); + + return rows.stream() + .map(this::toMyHistory) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List findWaitingOrdersByReservationIds(final List reservationIds) { + if (reservationIds.isEmpty()) { + return List.of(); + } + + String placeholders = String.join(",", Collections.nCopies(reservationIds.size(), "?")); + String sql = """ + SELECT r.id AS reservation_id, + rw.slot_id, + rw.id AS waiting_id, + rw.requested_at + FROM reservation_waiting rw + INNER JOIN reservation r ON rw.slot_id = r.slot_id + WHERE r.id IN (%s) + """.formatted(placeholders); + + var query = entityManager.createNativeQuery(sql); + for (int i = 0; i < reservationIds.size(); i++) { + query.setParameter(i + 1, reservationIds.get(i)); + } + + @SuppressWarnings("unchecked") + List rows = query.getResultList(); + + return rows.stream() + .map(this::toMyWaitingOrder) + .toList(); + } + + private MyHistory toMyHistory(final Object[] row) { + return new MyHistory( + toLong(row[1]), + toNullableLong(row[2]), + (String) row[0], + (String) row[3], + toLocalDate(row[4]), + Theme.of( + toLong(row[5]), + (String) row[6], + (String) row[7], + (String) row[8] + ), + ReservationTime.of( + toLong(row[9]), + toLocalTime(row[10]) + ), + toNullableLocalDateTime(row[11]) + ); + } + + private MyWaitingOrder toMyWaitingOrder(final Object[] row) { + return new MyWaitingOrder( + toLong(row[0]), + toLong(row[1]), + toLong(row[2]), + toLocalDateTime(row[3]) + ); + } + + private Long toLong(final Object value) { + if (value instanceof Long longValue) { + return longValue; + } + if (value instanceof BigInteger bigInteger) { + return bigInteger.longValue(); + } + if (value instanceof Number number) { + return number.longValue(); + } + throw new IllegalArgumentException("지원하지 않는 숫자 타입입니다."); + } + + private Long toNullableLong(final Object value) { + if (value == null) { + return null; + } + return toLong(value); + } + + private LocalDate toLocalDate(final Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + throw new IllegalArgumentException("지원하지 않는 날짜 타입입니다."); + } + + private java.time.LocalTime toLocalTime(final Object value) { + if (value instanceof java.time.LocalTime localTime) { + return localTime; + } + if (value instanceof Time time) { + return time.toLocalTime(); + } + throw new IllegalArgumentException("지원하지 않는 시간 타입입니다."); + } + + private LocalDateTime toLocalDateTime(final Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime; + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime(); + } + throw new IllegalArgumentException("지원하지 않는 날짜시간 타입입니다."); + } + + private LocalDateTime toNullableLocalDateTime(final Object value) { + return toLocalDateTime(value); + } +} diff --git a/src/main/java/roomescape/repository/history/MyWaitingOrder.java b/src/main/java/roomescape/repository/history/MyWaitingOrder.java index a222d4d143..1c5c8c954f 100644 --- a/src/main/java/roomescape/repository/history/MyWaitingOrder.java +++ b/src/main/java/roomescape/repository/history/MyWaitingOrder.java @@ -4,6 +4,7 @@ public record MyWaitingOrder( Long reservationId, + Long slotId, Long waitingId, LocalDateTime requestedAt ) { diff --git a/src/main/java/roomescape/repository/reservation/JdbcReservationRepository.java b/src/main/java/roomescape/repository/reservation/JdbcReservationRepository.java deleted file mode 100644 index 8c9d099d6b..0000000000 --- a/src/main/java/roomescape/repository/reservation/JdbcReservationRepository.java +++ /dev/null @@ -1,178 +0,0 @@ -package roomescape.repository.reservation; - -import java.sql.Date; -import java.sql.PreparedStatement; -import java.sql.Timestamp; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.reservation.Reservation; -import roomescape.domain.reservation.ReservationSlot; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.theme.Theme; - -@Repository -public class JdbcReservationRepository implements ReservationRepository { - - private static final RowMapper reservationRowMapper = (resultSet, rowNum) -> { - Theme theme = Theme.of( - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("description"), - resultSet.getString("thumbnail_url") - ); - - ReservationTime reservationTime = ReservationTime.of( - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - - return Reservation.of( - resultSet.getLong("id"), - resultSet.getString("reservation_name"), - resultSet.getDate("date").toLocalDate(), - theme, - reservationTime, - resultSet.getTimestamp("created_at").toLocalDateTime() - ); - }; - - private final JdbcTemplate jdbcTemplate; - - public JdbcReservationRepository(final JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public List findAll() { - String sql = """ - SELECT r.id, - r.name AS reservation_name, - r.date, - r.created_at, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url - FROM reservation AS r - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - INNER JOIN theme AS t ON r.theme_id = t.id - """; - - return jdbcTemplate.query(sql, reservationRowMapper); - } - - @Override - public Optional findById(final long id) { - String sql = """ - SELECT r.id, - r.name AS reservation_name, - r.date, - r.created_at, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url - FROM reservation AS r - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - INNER JOIN theme AS t ON r.theme_id = t.id - WHERE r.id = ? - """; - - return jdbcTemplate.query(sql, reservationRowMapper, id) - .stream() - .findFirst(); - } - - @Override - public Optional findBySlot(final ReservationSlot slot) { - String sql = """ - SELECT r.id, - r.name AS reservation_name, - r.date, - r.created_at, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url - FROM reservation AS r - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - INNER JOIN theme AS t ON r.theme_id = t.id - WHERE r.date = ? AND r.theme_id = ? AND r.time_id = ? - """; - - return jdbcTemplate.query( - sql, - reservationRowMapper, - Date.valueOf(slot.date()), - slot.theme().getId(), - slot.time().getId() - ) - .stream() - .findFirst(); - } - - @Override - public void delete(final Reservation reservation) { - String sql = "DELETE FROM reservation WHERE id = ?"; - jdbcTemplate.update(sql, reservation.getId()); - } - - @Override - public Reservation save(final Reservation reservation) { - if (reservation.getId() == null) { - return insert(reservation); - } - - return update(reservation); - } - - private Reservation insert(final Reservation reservation) { - String sql = "INSERT INTO reservation (name, date, theme_id, time_id, created_at) VALUES (?, ?, ?, ?, ?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement preparedStatement = connection.prepareStatement(sql, new String[]{"id"}); - preparedStatement.setString(1, reservation.getName()); - preparedStatement.setDate(2, Date.valueOf(reservation.getDate())); - preparedStatement.setLong(3, reservation.getTheme().getId()); - preparedStatement.setLong(4, reservation.getTime().getId()); - preparedStatement.setTimestamp(5, Timestamp.valueOf(reservation.getCreatedAt())); - return preparedStatement; - }, keyHolder); - - Number key = keyHolder.getKey(); - if (key == null) { - throw new IllegalStateException("[ERROR] 예약 ID를 생성하지 못했습니다."); - } - - return reservation.withId(key.longValue()); - } - - private Reservation update(final Reservation reservation) { - String sql = """ - UPDATE reservation - SET date = ?, time_id = ? - WHERE id = ? - """; - - jdbcTemplate.update( - sql, - Date.valueOf(reservation.getDate()), - reservation.getTime().getId(), - reservation.getId() - ); - - return reservation; - } -} diff --git a/src/main/java/roomescape/repository/reservation/JpaReservationRepositoryAdapter.java b/src/main/java/roomescape/repository/reservation/JpaReservationRepositoryAdapter.java new file mode 100644 index 0000000000..ece5b30e33 --- /dev/null +++ b/src/main/java/roomescape/repository/reservation/JpaReservationRepositoryAdapter.java @@ -0,0 +1,106 @@ +package roomescape.repository.reservation; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.reservation.jpa.ReservationJpaEntity; +import roomescape.repository.reservation.jpa.ReservationJpaRepository; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaRepository; + +@Repository +@Primary +public class JpaReservationRepositoryAdapter implements ReservationRepository { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSlotJpaRepository reservationSlotJpaRepository; + + public JpaReservationRepositoryAdapter( + final ReservationJpaRepository reservationJpaRepository, + final ReservationSlotJpaRepository reservationSlotJpaRepository + ) { + this.reservationJpaRepository = reservationJpaRepository; + this.reservationSlotJpaRepository = reservationSlotJpaRepository; + } + + @Override + public List findAll() { + return reservationJpaRepository.findAll().stream() + .map(ReservationJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findById(final long id) { + return reservationJpaRepository.findById(id) + .map(ReservationJpaEntity::toDomain); + } + + @Override + public Optional findBySlot(final ReservationSlot slot) { + return reservationJpaRepository.findBySlot_DateAndSlot_Theme_IdAndSlot_Time_Id( + slot.getDate(), + slot.getTheme().getId(), + slot.getTime().getId() + ) + .map(ReservationJpaEntity::toDomain); + } + + @Override + public List findByName(ReservationName name) { + return reservationJpaRepository.findByName(name.value()).stream() + .map(ReservationJpaEntity::toDomain) + .toList(); + } + + + @Override + public List findByDateAndTheme(final LocalDate date, final Theme theme) { + return reservationJpaRepository.findAllBySlot_DateAndSlot_Theme_Id(date, theme.getId()).stream() + .map(ReservationJpaEntity::toDomain) + .toList(); + } + + @Override + public boolean existsByTime(final ReservationTime time) { + return reservationJpaRepository.existsBySlot_Time_Id(time.getId()); + } + + @Override + @Transactional + public Reservation save(final Reservation reservation) { + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.getReferenceById( + reservation.getSlot().getId() + ); + + try { + ReservationJpaEntity saved = reservationJpaRepository.saveAndFlush( + ReservationJpaEntity.from(reservation, slot) + ); + return saved.toDomain(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + @Transactional + public void delete(final Reservation reservation) { + try { + reservationJpaRepository.deleteById(reservation.getId()); + reservationJpaRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } +} diff --git a/src/main/java/roomescape/repository/reservation/ReservationRepository.java b/src/main/java/roomescape/repository/reservation/ReservationRepository.java index 0f93d31c33..672c987c93 100644 --- a/src/main/java/roomescape/repository/reservation/ReservationRepository.java +++ b/src/main/java/roomescape/repository/reservation/ReservationRepository.java @@ -1,9 +1,13 @@ package roomescape.repository.reservation; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import roomescape.domain.reservation.Reservation; -import roomescape.domain.reservation.ReservationSlot; +import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; public interface ReservationRepository { @@ -13,6 +17,12 @@ public interface ReservationRepository { Optional findBySlot(ReservationSlot slot); + List findByName(ReservationName name); + + List findByDateAndTheme(LocalDate date, Theme theme); + + boolean existsByTime(ReservationTime time); + Reservation save(Reservation reservation); void delete(Reservation reservation); diff --git a/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaEntity.java b/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaEntity.java new file mode 100644 index 0000000000..c2139f7bf4 --- /dev/null +++ b/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaEntity.java @@ -0,0 +1,85 @@ +package roomescape.repository.reservation.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservation.ReservationName; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; + +@Entity +@Table(name = "reservation") +public class ReservationJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "slot_id", nullable = false, unique = true) + private ReservationSlotJpaEntity slot; + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamp default current_timestamp") + private LocalDateTime createdAt; + + protected ReservationJpaEntity() { + } + + private ReservationJpaEntity( + final Long id, + final String name, + final ReservationSlotJpaEntity slot, + final LocalDateTime createdAt + ) { + this.id = id; + this.name = name; + this.slot = slot; + this.createdAt = createdAt; + } + + public static ReservationJpaEntity from( + final Reservation reservation, + final ReservationSlotJpaEntity slot + ) { + return new ReservationJpaEntity( + reservation.getId(), + reservation.getName(), + slot, + reservation.getCreatedAt() + ); + } + + public Reservation toDomain() { + return new Reservation(id, ReservationName.from(name), slot.toDomain(), createdAt); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public ReservationSlotJpaEntity getSlot() { + return slot; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setSlot(final ReservationSlotJpaEntity slot) { + this.slot = slot; + } +} diff --git a/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaRepository.java b/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaRepository.java new file mode 100644 index 0000000000..302c5ead8d --- /dev/null +++ b/src/main/java/roomescape/repository/reservation/jpa/ReservationJpaRepository.java @@ -0,0 +1,33 @@ +package roomescape.repository.reservation.jpa; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationJpaRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findAll(); + + @Override + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + Optional findById(Long id); + + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findByName(String name); + + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + Optional findBySlot_DateAndSlot_Theme_IdAndSlot_Time_Id( + LocalDate date, + Long themeId, + Long timeId + ); + + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findAllBySlot_DateAndSlot_Theme_Id(LocalDate date, Long themeId); + + boolean existsBySlot_Time_Id(Long timeId); +} diff --git a/src/main/java/roomescape/repository/reservationslot/JpaReservationSlotRepositoryAdapter.java b/src/main/java/roomescape/repository/reservationslot/JpaReservationSlotRepositoryAdapter.java new file mode 100644 index 0000000000..94aa99e79a --- /dev/null +++ b/src/main/java/roomescape/repository/reservationslot/JpaReservationSlotRepositoryAdapter.java @@ -0,0 +1,85 @@ +package roomescape.repository.reservationslot; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.theme.Theme; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaEntity; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaRepository; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaRepository; +import roomescape.repository.theme.jpa.ThemeJpaEntity; +import roomescape.repository.theme.jpa.ThemeJpaRepository; + +@Repository +@Primary +public class JpaReservationSlotRepositoryAdapter implements ReservationSlotRepository { + + private final ReservationSlotJpaRepository reservationSlotJpaRepository; + private final ThemeJpaRepository themeJpaRepository; + private final ReservationTimeJpaRepository reservationTimeJpaRepository; + + public JpaReservationSlotRepositoryAdapter( + final ReservationSlotJpaRepository reservationSlotJpaRepository, + final ThemeJpaRepository themeJpaRepository, + final ReservationTimeJpaRepository reservationTimeJpaRepository + ) { + this.reservationSlotJpaRepository = reservationSlotJpaRepository; + this.themeJpaRepository = themeJpaRepository; + this.reservationTimeJpaRepository = reservationTimeJpaRepository; + } + + @Override + public List findAll() { + return reservationSlotJpaRepository.findAll().stream() + .map(ReservationSlotJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findById(final long slotId) { + return reservationSlotJpaRepository.findById(slotId) + .map(ReservationSlotJpaEntity::toDomain); + } + + @Override + public Optional findBySlot(final ReservationSlot reservationSlot) { + return reservationSlotJpaRepository.findByDateAndTheme_IdAndTime_Id( + reservationSlot.getDate(), + reservationSlot.getTheme().getId(), + reservationSlot.getTime().getId() + ) + .map(ReservationSlotJpaEntity::toDomain); + } + + @Override + public List findByDateAndTheme(final LocalDate date, final Theme theme) { + return reservationSlotJpaRepository.findAllByDateAndTheme_Id(date, theme.getId()).stream() + .map(ReservationSlotJpaEntity::toDomain) + .toList(); + } + + @Override + @Transactional + public ReservationSlot save(final ReservationSlot reservationSlot) { + ThemeJpaEntity theme = themeJpaRepository.getReferenceById(reservationSlot.getTheme().getId()); + ReservationTimeJpaEntity time = reservationTimeJpaRepository.getReferenceById( + reservationSlot.getTime().getId() + ); + + try { + ReservationSlotJpaEntity saved = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from(reservationSlot, theme, time) + ); + return saved.toDomain(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } +} diff --git a/src/main/java/roomescape/repository/reservationslot/ReservationSlotRepository.java b/src/main/java/roomescape/repository/reservationslot/ReservationSlotRepository.java new file mode 100644 index 0000000000..a745692fd5 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationslot/ReservationSlotRepository.java @@ -0,0 +1,20 @@ +package roomescape.repository.reservationslot; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.theme.Theme; + +public interface ReservationSlotRepository { + + List findAll(); + + Optional findById(long slotId); + + Optional findBySlot(ReservationSlot reservationSlot); + + List findByDateAndTheme(LocalDate date, Theme theme); + + ReservationSlot save(ReservationSlot reservationSlot); +} diff --git a/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaEntity.java b/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaEntity.java new file mode 100644 index 0000000000..81ce7f074d --- /dev/null +++ b/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaEntity.java @@ -0,0 +1,92 @@ +package roomescape.repository.reservationslot.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaEntity; +import roomescape.repository.theme.jpa.ThemeJpaEntity; + +@Entity +@Table( + name = "reservation_slot", + uniqueConstraints = @UniqueConstraint(columnNames = {"date", "theme_id", "time_id"}) +) +public class ReservationSlotJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate date; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "theme_id", nullable = false) + private ThemeJpaEntity theme; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "time_id", nullable = false) + private ReservationTimeJpaEntity time; + + protected ReservationSlotJpaEntity() { + } + + private ReservationSlotJpaEntity( + final Long id, + final LocalDate date, + final ThemeJpaEntity theme, + final ReservationTimeJpaEntity time + ) { + this.id = id; + this.date = date; + this.theme = theme; + this.time = time; + } + + public static ReservationSlotJpaEntity from( + final ReservationSlot reservationSlot, + final ThemeJpaEntity theme, + final ReservationTimeJpaEntity time + ) { + return new ReservationSlotJpaEntity( + reservationSlot.getId(), + reservationSlot.getDate(), + theme, + time + ); + } + + public ReservationSlot toDomain() { + return new ReservationSlot( + id, + date, + theme.toDomain(), + time.toDomain() + ); + } + + public Long getId() { + return id; + } + + public LocalDate getDate() { + return date; + } + + public ThemeJpaEntity getTheme() { + return theme; + } + + public ReservationTimeJpaEntity getTime() { + return time; + } +} diff --git a/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaRepository.java b/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaRepository.java new file mode 100644 index 0000000000..ccf3a2bca6 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationslot/jpa/ReservationSlotJpaRepository.java @@ -0,0 +1,28 @@ +package roomescape.repository.reservationslot.jpa; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationSlotJpaRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"theme", "time"}) + List findAll(); + + @Override + @EntityGraph(attributePaths = {"theme", "time"}) + Optional findById(Long id); + + @EntityGraph(attributePaths = {"theme", "time"}) + List findAllByDateAndTheme_Id(LocalDate date, Long themeId); + + @EntityGraph(attributePaths = {"theme", "time"}) + Optional findByDateAndTheme_IdAndTime_Id( + LocalDate date, + Long themeId, + Long timeId + ); +} diff --git a/src/main/java/roomescape/repository/reservationtime/JdbcReservationTimeRepository.java b/src/main/java/roomescape/repository/reservationtime/JdbcReservationTimeRepository.java deleted file mode 100644 index 2717838d3a..0000000000 --- a/src/main/java/roomescape/repository/reservationtime/JdbcReservationTimeRepository.java +++ /dev/null @@ -1,88 +0,0 @@ -package roomescape.repository.reservationtime; - -import java.sql.PreparedStatement; -import java.sql.Time; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.reservationtime.ReservationTime; - -@Repository -public class JdbcReservationTimeRepository implements ReservationTimeRepository { - - private static final String RESERVATION_TIME_BASE_SELECT = """ - SELECT rt.id, - rt.start_at - FROM reservation_time AS rt - """; - - private static final RowMapper reservationTimeRowMapper = (resultSet, rowNum) -> - ReservationTime.of( - resultSet.getLong("id"), - resultSet.getTime("start_at").toLocalTime() - ); - - private final JdbcTemplate jdbcTemplate; - - public JdbcReservationTimeRepository(final JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public ReservationTime save(final ReservationTime reservationTime) { - String sql = "INSERT INTO reservation_time (start_at) VALUES (?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement preparedStatement = connection.prepareStatement(sql, new String[]{"id"}); - preparedStatement.setTime(1, Time.valueOf(reservationTime.getStartAt())); - return preparedStatement; - }, keyHolder); - - Number key = keyHolder.getKey(); - if (key == null) { - throw new IllegalStateException("[ERROR] 예약 ID를 생성하지 못했습니다."); - } - - return reservationTime.withId(key.longValue()); - } - - @Override - public List findAll() { - String sql = RESERVATION_TIME_BASE_SELECT + " ORDER BY rt.start_at"; - - return jdbcTemplate.query(sql, reservationTimeRowMapper); - } - - @Override - public Optional findById(final long timeId) { - String sql = RESERVATION_TIME_BASE_SELECT + " WHERE rt.id = ?"; - - return jdbcTemplate.query(sql, reservationTimeRowMapper, timeId) - .stream() - .findFirst(); - } - - @Override - public int deleteById(final long timeId) { - String sql = "DELETE FROM reservation_time WHERE id = ?"; - return jdbcTemplate.update(sql, timeId); - } - - @Override - public boolean existsByStartAt(final LocalTime startAt) { - String sql = "SELECT EXISTS (SELECT 1 FROM reservation_time WHERE start_at = ?)"; - - return Boolean.TRUE.equals(jdbcTemplate.queryForObject( - sql, - Boolean.class, - Time.valueOf(startAt) - )); - } - -} diff --git a/src/main/java/roomescape/repository/reservationtime/JpaReservationTimeRepositoryAdapter.java b/src/main/java/roomescape/repository/reservationtime/JpaReservationTimeRepositoryAdapter.java new file mode 100644 index 0000000000..711ce179e9 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationtime/JpaReservationTimeRepositoryAdapter.java @@ -0,0 +1,73 @@ +package roomescape.repository.reservationtime; + +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaEntity; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaRepository; + +@Repository +@Primary +public class JpaReservationTimeRepositoryAdapter implements ReservationTimeRepository { + + private final ReservationTimeJpaRepository reservationTimeJpaRepository; + + public JpaReservationTimeRepositoryAdapter( + final ReservationTimeJpaRepository reservationTimeJpaRepository + ) { + this.reservationTimeJpaRepository = reservationTimeJpaRepository; + } + + @Override + public List findAll() { + return reservationTimeJpaRepository.findAll().stream() + .map(ReservationTimeJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findById(final long timeId) { + return reservationTimeJpaRepository.findById(timeId) + .map(ReservationTimeJpaEntity::toDomain); + } + + @Override + @Transactional + public int deleteById(final long timeId) { + if (!reservationTimeJpaRepository.existsById(timeId)) { + return 0; + } + + try { + reservationTimeJpaRepository.deleteById(timeId); + reservationTimeJpaRepository.flush(); + return 1; + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + @Transactional + public ReservationTime save(final ReservationTime reservationTime) { + try { + ReservationTimeJpaEntity saved = reservationTimeJpaRepository.saveAndFlush( + ReservationTimeJpaEntity.from(reservationTime) + ); + return saved.toDomain(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + public boolean existsByStartAt(final LocalTime startAt) { + return reservationTimeJpaRepository.existsByStartAt(startAt); + } +} diff --git a/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaEntity.java b/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaEntity.java new file mode 100644 index 0000000000..009a158280 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaEntity.java @@ -0,0 +1,46 @@ +package roomescape.repository.reservationtime.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalTime; +import roomescape.domain.reservationtime.ReservationTime; + +@Entity +@Table(name = "reservation_time") +public class ReservationTimeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "start_at", nullable = false, unique = true) + private LocalTime startAt; + + protected ReservationTimeJpaEntity() { + } + + private ReservationTimeJpaEntity(final Long id, final LocalTime startAt) { + this.id = id; + this.startAt = startAt; + } + + public static ReservationTimeJpaEntity from(final ReservationTime reservationTime) { + return new ReservationTimeJpaEntity(reservationTime.getId(), reservationTime.getStartAt()); + } + + public ReservationTime toDomain() { + return ReservationTime.of(id, startAt); + } + + public Long getId() { + return id; + } + + public LocalTime getStartAt() { + return startAt; + } +} diff --git a/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaRepository.java b/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaRepository.java new file mode 100644 index 0000000000..88d07d994b --- /dev/null +++ b/src/main/java/roomescape/repository/reservationtime/jpa/ReservationTimeJpaRepository.java @@ -0,0 +1,9 @@ +package roomescape.repository.reservationtime.jpa; + +import java.time.LocalTime; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationTimeJpaRepository extends JpaRepository { + + boolean existsByStartAt(LocalTime startAt); +} diff --git a/src/main/java/roomescape/repository/reservationwaiting/JdbcReservationWaitingRepository.java b/src/main/java/roomescape/repository/reservationwaiting/JdbcReservationWaitingRepository.java deleted file mode 100644 index b4f48155b4..0000000000 --- a/src/main/java/roomescape/repository/reservationwaiting/JdbcReservationWaitingRepository.java +++ /dev/null @@ -1,140 +0,0 @@ -package roomescape.repository.reservationwaiting; - -import java.sql.PreparedStatement; -import java.sql.Timestamp; -import java.util.Optional; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.reservation.Reservation; -import roomescape.domain.reservationtime.ReservationTime; -import roomescape.domain.reservationwaiting.ReservationWaiting; -import roomescape.domain.reservationwaiting.ReservationWaitingLine; -import roomescape.domain.theme.Theme; - -@Repository -public class JdbcReservationWaitingRepository implements ReservationWaitingRepository { - private static final RowMapper reservationWaitingRowMapper = (resultSet, rowNumber) -> { - Theme theme = Theme.of( - resultSet.getLong("theme_id"), - resultSet.getString("theme_name"), - resultSet.getString("description"), - resultSet.getString("thumbnail_url") - ); - - ReservationTime reservationTime = ReservationTime.of( - resultSet.getLong("time_id"), - resultSet.getTime("start_at").toLocalTime() - ); - - Reservation reservation = Reservation.of( - resultSet.getLong("reservation_id"), - resultSet.getString("reservation_name"), - resultSet.getDate("date").toLocalDate(), - theme, - reservationTime, - resultSet.getTimestamp("created_at").toLocalDateTime() - ); - - return ReservationWaiting.of( - resultSet.getLong("id"), - reservation, - resultSet.getString("waiting_name"), - resultSet.getTimestamp("requested_at").toLocalDateTime() - ); - }; - - private final JdbcTemplate jdbcTemplate; - - public JdbcReservationWaitingRepository(final JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public ReservationWaiting save(final ReservationWaiting reservationWaiting) { - String sql = "INSERT INTO reservation_waiting (reservation_id, name, requested_at) VALUES (?, ?, ?) "; - - KeyHolder keyHolder = new GeneratedKeyHolder(); - - jdbcTemplate.update(connection -> { - PreparedStatement preparedStatement = connection.prepareStatement(sql, new String[]{"id"}); - preparedStatement.setLong(1, reservationWaiting.getReservation().getId()); - preparedStatement.setString(2, reservationWaiting.getName()); - preparedStatement.setTimestamp(3, Timestamp.valueOf(reservationWaiting.getRequestedAt())); - return preparedStatement; - }, keyHolder); - - Number key = keyHolder.getKey(); - if (key == null) { - throw new IllegalStateException("[ERROR] 대기 ID를 생성하지 못했습니다."); - } - - return reservationWaiting.withId(key.longValue()); - } - - @Override - public Optional findById(final Long id) { - String sql = """ - SELECT rw.id, - rw.name AS waiting_name, - rw.requested_at, - r.id AS reservation_id, - r.name AS reservation_name, - r.date, - r.created_at, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url - FROM reservation_waiting AS rw - INNER JOIN reservation AS r ON rw.reservation_id = r.id - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - INNER JOIN theme AS t ON r.theme_id = t.id - WHERE rw.id = ? - """; - - return jdbcTemplate.query(sql, reservationWaitingRowMapper, id) - .stream() - .findFirst(); - } - - @Override - public ReservationWaitingLine findLineByReservation(final Reservation reservation) { - String sql = """ - SELECT rw.id, - rw.name AS waiting_name, - rw.requested_at, - r.id AS reservation_id, - r.name AS reservation_name, - r.date, - r.created_at, - rt.id AS time_id, - rt.start_at, - t.id AS theme_id, - t.name AS theme_name, - t.description, - t.thumbnail_url - FROM reservation_waiting AS rw - INNER JOIN reservation AS r ON rw.reservation_id = r.id - INNER JOIN reservation_time AS rt ON r.time_id = rt.id - INNER JOIN theme AS t ON r.theme_id = t.id - WHERE rw.reservation_id = ? - """; - - return ReservationWaitingLine.fromWaitings(jdbcTemplate.query( - sql, - reservationWaitingRowMapper, - reservation.getId() - )); - } - - @Override - public void delete(final ReservationWaiting reservationWaiting) { - String sql = "DELETE FROM reservation_waiting WHERE id = ?"; - jdbcTemplate.update(sql, reservationWaiting.getId()); - } -} diff --git a/src/main/java/roomescape/repository/reservationwaiting/JpaReservationWaitingRepositoryAdapter.java b/src/main/java/roomescape/repository/reservationwaiting/JpaReservationWaitingRepositoryAdapter.java new file mode 100644 index 0000000000..0e0ea5d3b4 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationwaiting/JpaReservationWaitingRepositoryAdapter.java @@ -0,0 +1,79 @@ +package roomescape.repository.reservationwaiting; + +import java.util.Optional; +import java.util.List; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationwaiting.ReservationWaiting; +import roomescape.domain.reservationwaiting.ReservationWaitingLine; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.reservationwaiting.jpa.ReservationWaitingJpaEntity; +import roomescape.repository.reservationwaiting.jpa.ReservationWaitingJpaRepository; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaRepository; + +@Repository +@Primary +public class JpaReservationWaitingRepositoryAdapter implements ReservationWaitingRepository { + + private final ReservationWaitingJpaRepository reservationWaitingJpaRepository; + private final ReservationSlotJpaRepository reservationSlotJpaRepository; + + public JpaReservationWaitingRepositoryAdapter( + final ReservationWaitingJpaRepository reservationWaitingJpaRepository, + final ReservationSlotJpaRepository reservationSlotJpaRepository + ) { + this.reservationWaitingJpaRepository = reservationWaitingJpaRepository; + this.reservationSlotJpaRepository = reservationSlotJpaRepository; + } + + @Override + public List findAll() { + return reservationWaitingJpaRepository.findAllByOrderBySlot_DateAscSlot_Theme_IdAscSlot_Time_IdAscRequestedAtAscIdAsc() + .stream() + .map(ReservationWaitingJpaEntity::toDomain) + .toList(); + } + + @Override + @Transactional + public ReservationWaiting save(final ReservationWaiting reservationWaiting) { + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.getReferenceById( + reservationWaiting.getSlot().getId() + ); + + try { + ReservationWaitingJpaEntity saved = reservationWaitingJpaRepository.saveAndFlush( + ReservationWaitingJpaEntity.from(reservationWaiting, slot) + ); + return saved.toDomain(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + public Optional findById(final Long id) { + return reservationWaitingJpaRepository.findById(id) + .map(ReservationWaitingJpaEntity::toDomain); + } + + @Override + public ReservationWaitingLine findLineBySlot(final ReservationSlot slot) { + return ReservationWaitingLine.fromWaitings( + reservationWaitingJpaRepository.findAllBySlot_IdOrderByRequestedAtAscIdAsc(slot.getId()).stream() + .map(ReservationWaitingJpaEntity::toDomain) + .toList() + ); + } + + @Override + @Transactional + public void delete(final ReservationWaiting reservationWaiting) { + reservationWaitingJpaRepository.deleteById(reservationWaiting.getId()); + reservationWaitingJpaRepository.flush(); + } +} diff --git a/src/main/java/roomescape/repository/reservationwaiting/ReservationWaitingRepository.java b/src/main/java/roomescape/repository/reservationwaiting/ReservationWaitingRepository.java index 567c73da44..7a6ee79385 100644 --- a/src/main/java/roomescape/repository/reservationwaiting/ReservationWaitingRepository.java +++ b/src/main/java/roomescape/repository/reservationwaiting/ReservationWaitingRepository.java @@ -1,13 +1,15 @@ package roomescape.repository.reservationwaiting; +import java.util.List; import java.util.Optional; -import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.reservationwaiting.ReservationWaitingLine; public interface ReservationWaitingRepository { + List findAll(); ReservationWaiting save(ReservationWaiting reservationWaiting); Optional findById(Long id); - ReservationWaitingLine findLineByReservation(Reservation reservation); + ReservationWaitingLine findLineBySlot(ReservationSlot slot); void delete(ReservationWaiting reservationWaiting); } diff --git a/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaEntity.java b/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaEntity.java new file mode 100644 index 0000000000..ce1f784325 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaEntity.java @@ -0,0 +1,85 @@ +package roomescape.repository.reservationwaiting.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationwaiting.ReservationWaiting; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; + +@Entity +@Table( + name = "reservation_waiting", + uniqueConstraints = @UniqueConstraint(columnNames = {"slot_id", "name"}) +) +public class ReservationWaitingJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "slot_id", nullable = false) + private ReservationSlotJpaEntity slot; + + @Column(nullable = false) + private String name; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + protected ReservationWaitingJpaEntity() { + } + + private ReservationWaitingJpaEntity( + final Long id, + final ReservationSlotJpaEntity slot, + final String name, + final LocalDateTime requestedAt + ) { + this.id = id; + this.slot = slot; + this.name = name; + this.requestedAt = requestedAt; + } + + public static ReservationWaitingJpaEntity from( + final ReservationWaiting reservationWaiting, + final ReservationSlotJpaEntity slot + ) { + return new ReservationWaitingJpaEntity( + reservationWaiting.getId(), + slot, + reservationWaiting.getName(), + reservationWaiting.getRequestedAt() + ); + } + + public ReservationWaiting toDomain() { + return ReservationWaiting.of(id, slot.toDomain(), name, requestedAt); + } + + public Long getId() { + return id; + } + + public ReservationSlotJpaEntity getSlot() { + return slot; + } + + public String getName() { + return name; + } + + public LocalDateTime getRequestedAt() { + return requestedAt; + } +} diff --git a/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaRepository.java b/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaRepository.java new file mode 100644 index 0000000000..27dbec8c30 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationwaiting/jpa/ReservationWaitingJpaRepository.java @@ -0,0 +1,46 @@ +package roomescape.repository.reservationwaiting.jpa; + +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 ReservationWaitingJpaRepository extends JpaRepository { + + @Override + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findAll(); + + @Override + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + Optional findById(Long id); + + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findAllBySlot_IdOrderByRequestedAtAscIdAsc(Long slotId); + + @EntityGraph(attributePaths = {"slot", "slot.theme", "slot.time"}) + List findAllByOrderBySlot_DateAscSlot_Theme_IdAscSlot_Time_IdAscRequestedAtAscIdAsc(); + + @Query(""" + select new roomescape.repository.reservationwaiting.jpa.WaitingWithRank( + w.id, + w.slot.id, + w.name, + w.requestedAt, + (select count(w2) + 1 + from ReservationWaitingJpaEntity w2 + where w2.slot = w.slot + and ( + w2.requestedAt < w.requestedAt + or (w2.requestedAt = w.requestedAt and w2.id < w.id) + ) + ) + ) + from ReservationWaitingJpaEntity w + where w.name = :name + order by w.slot.date, w.slot.theme.id, w.slot.time.id, w.requestedAt, w.id + """) + List findAllWithRankByName(@Param("name") String name); +} diff --git a/src/main/java/roomescape/repository/reservationwaiting/jpa/WaitingWithRank.java b/src/main/java/roomescape/repository/reservationwaiting/jpa/WaitingWithRank.java new file mode 100644 index 0000000000..a848e63d07 --- /dev/null +++ b/src/main/java/roomescape/repository/reservationwaiting/jpa/WaitingWithRank.java @@ -0,0 +1,12 @@ +package roomescape.repository.reservationwaiting.jpa; + +import java.time.LocalDateTime; + +public record WaitingWithRank( + Long waitingId, + Long slotId, + String name, + LocalDateTime requestedAt, + Long rank +) { +} diff --git a/src/main/java/roomescape/repository/theme/JdbcThemeRepository.java b/src/main/java/roomescape/repository/theme/JdbcThemeRepository.java deleted file mode 100644 index 3024ab4d85..0000000000 --- a/src/main/java/roomescape/repository/theme/JdbcThemeRepository.java +++ /dev/null @@ -1,115 +0,0 @@ -package roomescape.repository.theme; - -import java.sql.PreparedStatement; -import java.sql.Date; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.domain.theme.Theme; -import roomescape.exception.ConflictException; -import roomescape.exception.ErrorCode; - -@Repository -public class JdbcThemeRepository implements ThemeRepository { - - private static final RowMapper themeRowMapper = (resultSet, rowNum) -> - Theme.of( - resultSet.getLong("id"), - resultSet.getString("name"), - resultSet.getString("description"), - resultSet.getString("thumbnail_url") - ); - - private final JdbcTemplate jdbcTemplate; - - public JdbcThemeRepository(final JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - - @Override - public List findAll() { - String sql = "SELECT * FROM theme"; - return jdbcTemplate.query(sql, themeRowMapper); - } - - @Override - public Optional findById(final long id) { - String sql = "SELECT * FROM theme WHERE id = ?"; - return jdbcTemplate.query(sql, themeRowMapper, id) - .stream() - .findFirst(); - } - - @Override - public int deleteById(final long id) { - String sql = "DELETE FROM theme WHERE id = ?"; - try { - return jdbcTemplate.update(sql, id); - } catch (DataIntegrityViolationException exception) { - throw new ConflictException(ErrorCode.THEME_IN_USE, "이미 예약된 테마는 삭제할 수 없습니다."); - } - } - - @Override - public Theme save(final Theme theme) { - String sql = "INSERT INTO theme (name, description, thumbnail_url) VALUES (?, ?, ?)"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - - try { - jdbcTemplate.update(connection -> { - PreparedStatement preparedStatement = connection.prepareStatement(sql, new String[]{"id"}); - preparedStatement.setString(1, theme.getName()); - preparedStatement.setString(2, theme.getDescription()); - preparedStatement.setString(3, theme.getThumbnailUrl()); - return preparedStatement; - }, keyHolder); - } catch (DataIntegrityViolationException exception) { - throw new ConflictException(ErrorCode.THEME_NAME_DUPLICATED, "테마 이름 중복은 불가능합니다."); - } - - Number key = keyHolder.getKey(); - if (key == null) { - throw new IllegalStateException("[ERROR] 테마 ID를 생성하지 못했습니다."); - } - - return theme.withId(key.longValue()); - } - - @Override - public boolean existsByName(final String name) { - String sql = "SELECT EXISTS (SELECT 1 FROM theme WHERE name = ?)"; - - return Boolean.TRUE.equals(jdbcTemplate.queryForObject( - sql, - Boolean.class, - name - )); - } - - @Override - public List findPopularThemes(final int period, final int limit) { - String sql = """ - SELECT t.id, - t.name, - t.description, - t.thumbnail_url - FROM reservation r - INNER JOIN theme t ON r.theme_id = t.id - WHERE r.date >= ? AND r.date <= ? - GROUP BY t.id, t.name, t.description, t.thumbnail_url - ORDER BY COUNT(*) DESC, t.id ASC - LIMIT ? - """; - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(period); - - return jdbcTemplate.query(sql, themeRowMapper, Date.valueOf(start), Date.valueOf(end), limit); - } -} diff --git a/src/main/java/roomescape/repository/theme/JpaThemeRepositoryAdapter.java b/src/main/java/roomescape/repository/theme/JpaThemeRepositoryAdapter.java new file mode 100644 index 0000000000..f0347c735d --- /dev/null +++ b/src/main/java/roomescape/repository/theme/JpaThemeRepositoryAdapter.java @@ -0,0 +1,79 @@ +package roomescape.repository.theme; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import roomescape.domain.theme.Theme; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.theme.jpa.ThemeJpaEntity; +import roomescape.repository.theme.jpa.ThemeJpaRepository; + +@Repository +@Primary +public class JpaThemeRepositoryAdapter implements ThemeRepository { + + private final ThemeJpaRepository themeJpaRepository; + + public JpaThemeRepositoryAdapter(final ThemeJpaRepository themeJpaRepository) { + this.themeJpaRepository = themeJpaRepository; + } + + @Override + public List findAll() { + return themeJpaRepository.findAll().stream() + .map(ThemeJpaEntity::toDomain) + .toList(); + } + + @Override + public Optional findById(final long id) { + return themeJpaRepository.findById(id) + .map(ThemeJpaEntity::toDomain); + } + + @Override + @Transactional + public int deleteById(final long id) { + if (!themeJpaRepository.existsById(id)) { + return 0; + } + + try { + themeJpaRepository.deleteById(id); + themeJpaRepository.flush(); + return 1; + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + @Transactional + public Theme save(final Theme theme) { + try { + ThemeJpaEntity saved = themeJpaRepository.saveAndFlush(ThemeJpaEntity.from(theme)); + return saved.toDomain(); + } catch (DataIntegrityViolationException exception) { + throw new PersistenceConflictException(exception); + } + } + + @Override + public boolean existsByName(final String name) { + return themeJpaRepository.existsByName(name); + } + + @Override + public List findPopularThemes(final int period, final int limit) { + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(period); + + return themeJpaRepository.findPopularThemes(start, end, limit).stream() + .map(ThemeJpaEntity::toDomain) + .toList(); + } +} diff --git a/src/main/java/roomescape/repository/theme/jpa/ThemeJpaEntity.java b/src/main/java/roomescape/repository/theme/jpa/ThemeJpaEntity.java new file mode 100644 index 0000000000..45667260c8 --- /dev/null +++ b/src/main/java/roomescape/repository/theme/jpa/ThemeJpaEntity.java @@ -0,0 +1,71 @@ +package roomescape.repository.theme.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import roomescape.domain.theme.Theme; + +@Entity +@Table(name = "theme") +public class ThemeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private String description; + + @Column(name = "thumbnail_url", nullable = false) + private String thumbnailUrl; + + protected ThemeJpaEntity() { + } + + private ThemeJpaEntity( + final Long id, + final String name, + final String description, + final String thumbnailUrl + ) { + this.id = id; + this.name = name; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + } + + public static ThemeJpaEntity from(final Theme theme) { + return new ThemeJpaEntity( + theme.getId(), + theme.getName(), + theme.getDescription(), + theme.getThumbnailUrl() + ); + } + + public Theme toDomain() { + return Theme.of(id, name, description, thumbnailUrl); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } +} diff --git a/src/main/java/roomescape/repository/theme/jpa/ThemeJpaRepository.java b/src/main/java/roomescape/repository/theme/jpa/ThemeJpaRepository.java new file mode 100644 index 0000000000..711e0dc06c --- /dev/null +++ b/src/main/java/roomescape/repository/theme/jpa/ThemeJpaRepository.java @@ -0,0 +1,28 @@ +package roomescape.repository.theme.jpa; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ThemeJpaRepository extends JpaRepository { + + boolean existsByName(String name); + + @Query(value = """ + SELECT t.* + FROM reservation r + INNER JOIN reservation_slot s ON r.slot_id = s.id + INNER JOIN theme t ON s.theme_id = t.id + WHERE s.date >= :startDate AND s.date <= :endDate + GROUP BY t.id, t.name, t.description, t.thumbnail_url + ORDER BY COUNT(*) DESC, t.id ASC + LIMIT :limit + """, nativeQuery = true) + List findPopularThemes( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("limit") int limit + ); +} diff --git a/src/main/java/roomescape/service/history/MyWaitingLines.java b/src/main/java/roomescape/service/history/MyWaitingLines.java index 939d0f9a28..0815c83a3d 100644 --- a/src/main/java/roomescape/service/history/MyWaitingLines.java +++ b/src/main/java/roomescape/service/history/MyWaitingLines.java @@ -4,40 +4,41 @@ import java.util.Map; import java.util.stream.Collectors; import roomescape.domain.reservationwaiting.ReservationWaitingLine; +import roomescape.domain.reservationwaiting.ReservationWaitingLine.ReservationWaitingOrder; import roomescape.repository.history.MyWaitingOrder; public class MyWaitingLines { - private final Map waitingLinesByReservationId; + private final Map> waitingOrdersByReservationId; - private MyWaitingLines(final Map waitingLinesByReservationId) { - this.waitingLinesByReservationId = waitingLinesByReservationId; + private MyWaitingLines(final Map> waitingOrdersByReservationId) { + this.waitingOrdersByReservationId = waitingOrdersByReservationId; } public static MyWaitingLines from(final List waitingOrders) { - Map waitingLinesByReservationId = waitingOrders.stream() + Map> waitingOrdersByReservationId = waitingOrders.stream() .collect(Collectors.groupingBy( MyWaitingOrder::reservationId, - Collectors.collectingAndThen( - Collectors.toList(), - MyWaitingLines::toWaitingLine - ) + Collectors.mapping(MyWaitingLines::toWaitingOrder, Collectors.toList()) )); - return new MyWaitingLines(waitingLinesByReservationId); + return new MyWaitingLines(waitingOrdersByReservationId); } public int sequenceOf(final long reservationId, final long waitingId) { - return waitingLinesByReservationId.get(reservationId) - .sequenceOf(waitingId); + return ReservationWaitingLine.indexOf( + waitingOrdersByReservationId.getOrDefault(reservationId, List.of()), + waitingId + ) + .orElseThrow(() -> new IllegalArgumentException("대기 순번을 찾을 수 없습니다.")) + + 1; } - private static ReservationWaitingLine toWaitingLine(final List waitingOrders) { - return new ReservationWaitingLine(waitingOrders.stream() - .map(order -> new ReservationWaitingLine.ReservationWaitingOrder( - order.waitingId(), - order.requestedAt() - )) - .toList()); + private static ReservationWaitingOrder toWaitingOrder(final MyWaitingOrder waitingOrder) { + return new ReservationWaitingOrder( + waitingOrder.waitingId(), + waitingOrder.slotId(), + waitingOrder.requestedAt() + ); } } diff --git a/src/main/java/roomescape/service/reservation/ReservationService.java b/src/main/java/roomescape/service/reservation/ReservationService.java index 092e1af9ba..6a44c544c5 100644 --- a/src/main/java/roomescape/service/reservation/ReservationService.java +++ b/src/main/java/roomescape/service/reservation/ReservationService.java @@ -4,16 +4,22 @@ import java.time.LocalDateTime; import java.util.List; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationName; -import roomescape.domain.reservation.ReservationSlot; +import roomescape.domain.reservation.PastReservationException; +import roomescape.domain.reservation.ReservationCancellationResult; +import roomescape.domain.reservationwaiting.ReservationWaitingLine; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; import roomescape.exception.ConflictException; import roomescape.exception.ErrorCode; import roomescape.exception.InvalidInputException; import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.PersistenceConflictException; import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; import roomescape.repository.reservationwaiting.ReservationWaitingRepository; import roomescape.service.reservationtime.ReservationTimeService; import roomescape.service.theme.ThemeService; @@ -21,17 +27,20 @@ @Service public class ReservationService { private final ReservationRepository reservationRepository; + private final ReservationSlotRepository reservationSlotRepository; private final ReservationWaitingRepository reservationWaitingRepository; private final ReservationTimeService reservationTimeService; private final ThemeService themeService; public ReservationService( final ReservationRepository reservationRepository, + final ReservationSlotRepository reservationSlotRepository, final ReservationWaitingRepository reservationWaitingRepository, final ReservationTimeService reservationTimeService, final ThemeService themeService ) { this.reservationRepository = reservationRepository; + this.reservationSlotRepository = reservationSlotRepository; this.reservationWaitingRepository = reservationWaitingRepository; this.reservationTimeService = reservationTimeService; this.themeService = themeService; @@ -41,31 +50,45 @@ public List getAll() { return reservationRepository.findAll(); } - public Reservation save(final String name, final LocalDate date, final long themeId, final long timeId) { + @Transactional + public Reservation save( + final String name, + final LocalDate date, + final long themeId, + final long timeId, + final LocalDateTime requestedAt + ) { Theme theme = themeService.getById(themeId); ReservationTime reservationTime = reservationTimeService.getById(timeId); - ReservationSlot slot = new ReservationSlot(date, theme, reservationTime); - Reservation nonIdReservation = createNewReservation(name, slot); + ReservationSlot candidateSlot = new ReservationSlot(date, theme, reservationTime); - if (reservationRepository.findBySlot(slot).isPresent()) { + if (reservationRepository.findBySlot(candidateSlot).isPresent()) { throw new ConflictException(ErrorCode.RESERVATION_DUPLICATED, "동일한 시기에 예약을 할 수 없습니다."); } - return reservationRepository.save(nonIdReservation); + ReservationSlot savedSlot = findExistingSlot(candidateSlot); + Reservation reservation = createReservation(name, savedSlot, requestedAt); + + try { + return reservationRepository.save(reservation); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.RESERVATION_DUPLICATED, "동일한 시기에 예약을 할 수 없습니다."); + } } - public void deleteById(final long id) { + @Transactional + public void deleteById(final long id, final LocalDateTime requestedAt) { Reservation reservation = reservationRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException( ErrorCode.RESERVATION_NOT_FOUND, "삭제된 예약 데이터가 없습니다." )); - validateNoWaiting(reservation); - reservationRepository.delete(reservation); + cancel(reservation, requestedAt); } - public void deleteByIdAndName(final long id, final String name) { + @Transactional + public void deleteByIdAndName(final long id, final String name, final LocalDateTime requestedAt) { ReservationName lookupName = ReservationName.from(name); Reservation reservation = reservationRepository.findById(id) @@ -81,16 +104,16 @@ public void deleteByIdAndName(final long id, final String name) { ); } - validateCancelable(reservation); - validateNoWaiting(reservation); - reservationRepository.delete(reservation); + validateCancelable(reservation, requestedAt); + cancel(reservation, requestedAt); } public Reservation updateByIdAndName( final long id, final String name, final LocalDate date, - final long timeId + final long timeId, + final LocalDateTime requestedAt ) { ReservationName lookupName = ReservationName.from(name); @@ -107,40 +130,59 @@ public Reservation updateByIdAndName( ); } - validateUpdatable(reservation); + validateUpdatable(reservation, requestedAt); ReservationTime reservationTime = reservationTimeService.getById(timeId); - Reservation updatedReservation = updateReservationDateAndTime(reservation, date, reservationTime); + ReservationSlot candidateSlot = new ReservationSlot(date, reservation.getTheme(), reservationTime); + Reservation candidateReservation = changeReservationSlot(reservation, candidateSlot, requestedAt); - if (reservationRepository.findBySlot(updatedReservation.getSlot()) + if (reservationRepository.findBySlot(candidateReservation.getSlot()) .filter(conflict -> !conflict.equals(reservation)) .isPresent()) { throw new ConflictException(ErrorCode.RESERVATION_DUPLICATED, "동일한 시기에 예약을 할 수 없습니다."); } - return reservationRepository.save(updatedReservation); - } + ReservationSlot savedSlot = findExistingSlot(candidateReservation.getSlot()); + Reservation updatedReservation = changeReservationSlot(candidateReservation, savedSlot, requestedAt); - private void validateCancelable(final Reservation reservation) { - if (reservation.isPast(LocalDateTime.now())) { - throw new ConflictException( - ErrorCode.PAST_RESERVATION_CANNOT_BE_CANCELLED, - "이미 지난 예약은 취소할 수 없습니다." - ); + try { + return reservationRepository.save(updatedReservation); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.RESERVATION_DUPLICATED, "이미 예약된 시간으로 변경할 수 없습니다."); } } - private void validateUpdatable(final Reservation reservation) { - if (reservation.isPast(LocalDateTime.now())) { - throw new ConflictException( - ErrorCode.PAST_RESERVATION_CANNOT_BE_UPDATED, - "이미 지난 예약은 변경할 수 없습니다." - ); + private ReservationSlot findExistingSlot(final ReservationSlot candidateSlot) { + return reservationSlotRepository.findBySlot(candidateSlot) + .orElseThrow(() -> new ResourceNotFoundException( + ErrorCode.RESOURCE_NOT_FOUND, + "예약 가능한 슬롯이 없습니다." + )); + } + + private void cancel(final Reservation reservation, final LocalDateTime requestedAt) { + ReservationWaitingLine waitingLine = reservationWaitingRepository.findLineBySlot(reservation.getSlot()); + ReservationCancellationResult cancellation = reservation.cancel(waitingLine, requestedAt); + + deleteReservation(cancellation.cancelledReservation()); + cancellation.promotedReservation().ifPresent(this::savePromotedReservation); + cancellation.promotedWaiting().ifPresent(reservationWaitingRepository::delete); + } + + private void savePromotedReservation(final Reservation promotedReservation) { + try { + reservationRepository.save(promotedReservation); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.RESERVATION_DUPLICATED, "동일한 시기에 예약을 할 수 없습니다."); + } catch (IllegalArgumentException exception) { + throw toInvalidInputException(exception); } } - private void validateNoWaiting(final Reservation reservation) { - if (!reservationWaitingRepository.findLineByReservation(reservation).isEmpty()) { + private void deleteReservation(final Reservation reservation) { + try { + reservationRepository.delete(reservation); + } catch (PersistenceConflictException exception) { throw new ConflictException( ErrorCode.RESERVATION_HAS_WAITING, "예약 대기가 존재하는 예약은 바로 삭제할 수 없습니다." @@ -148,34 +190,57 @@ private void validateNoWaiting(final Reservation reservation) { } } - private Reservation createNewReservation( + private Reservation createReservation( final String name, - final ReservationSlot slot + final ReservationSlot slot, + final LocalDateTime requestedAt ) { try { - return Reservation.createNew(name, slot, LocalDateTime.now()); + return Reservation.reserve(name, slot, requestedAt); } catch (IllegalArgumentException exception) { throw toInvalidInputException(exception); } } - private Reservation updateReservationDateAndTime( + private Reservation changeReservationSlot( final Reservation reservation, - final LocalDate date, - final ReservationTime reservationTime + final ReservationSlot slot, + final LocalDateTime requestedAt ) { try { - return reservation.withDateAndTime(date, reservationTime, LocalDateTime.now()); + return reservation.withSlot(slot, requestedAt); } catch (IllegalArgumentException exception) { throw toInvalidInputException(exception); } } private InvalidInputException toInvalidInputException(final IllegalArgumentException exception) { - if (Reservation.PAST_RESERVATION_MESSAGE.equals(exception.getMessage())) { + if (exception instanceof PastReservationException) { return new InvalidInputException(ErrorCode.RESERVATION_DATE_TIME_IN_PAST, exception.getMessage()); } return new InvalidInputException(ErrorCode.INVALID_INPUT, exception.getMessage()); } + + private void validateCancelable(final Reservation reservation, final LocalDateTime requestedAt) { + try { + reservation.validateCancelable(requestedAt); + } catch (PastReservationException exception) { + throw new ConflictException( + ErrorCode.PAST_RESERVATION_CANNOT_BE_CANCELLED, + exception.getMessage() + ); + } + } + + private void validateUpdatable(final Reservation reservation, final LocalDateTime requestedAt) { + try { + reservation.validateUpdatable(requestedAt); + } catch (PastReservationException exception) { + throw new ConflictException( + ErrorCode.PAST_RESERVATION_CANNOT_BE_UPDATED, + exception.getMessage() + ); + } + } } diff --git a/src/main/java/roomescape/service/reservationmine/ReservationMineService.java b/src/main/java/roomescape/service/reservationmine/ReservationMineService.java new file mode 100644 index 0000000000..58c92a9cf7 --- /dev/null +++ b/src/main/java/roomescape/service/reservationmine/ReservationMineService.java @@ -0,0 +1,27 @@ +package roomescape.service.reservationmine; + +import java.util.List; +import org.springframework.stereotype.Service; +import roomescape.controller.reservation.dto.ReservationResponse; +import roomescape.domain.reservation.ReservationName; +import roomescape.repository.reservation.ReservationRepository; + +@Service +public class ReservationMineService { + + private final ReservationRepository reservationRepository; + + public ReservationMineService(final ReservationRepository reservationRepository) { + this.reservationRepository = reservationRepository; + } + + public List getAllByName(final String name) { + if (name == null || name.isBlank()) { + return List.of(); + } + + return reservationRepository.findByName(ReservationName.from(name)).stream() + .map(ReservationResponse::from) + .toList(); + } +} diff --git a/src/main/java/roomescape/service/reservationslot/ReservationSlotService.java b/src/main/java/roomescape/service/reservationslot/ReservationSlotService.java new file mode 100644 index 0000000000..0c6b7a13d4 --- /dev/null +++ b/src/main/java/roomescape/service/reservationslot/ReservationSlotService.java @@ -0,0 +1,61 @@ +package roomescape.service.reservationslot; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.stereotype.Service; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.exception.ConflictException; +import roomescape.exception.ErrorCode; +import roomescape.exception.InvalidInputException; +import roomescape.repository.PersistenceConflictException; +import roomescape.repository.reservationslot.ReservationSlotRepository; +import roomescape.service.reservationtime.ReservationTimeService; +import roomescape.service.theme.ThemeService; + +@Service +public class ReservationSlotService { + private final ReservationSlotRepository reservationSlotRepository; + private final ThemeService themeService; + private final ReservationTimeService reservationTimeService; + + public ReservationSlotService( + final ReservationSlotRepository reservationSlotRepository, + final ThemeService themeService, + final ReservationTimeService reservationTimeService + ) { + this.reservationSlotRepository = reservationSlotRepository; + this.themeService = themeService; + this.reservationTimeService = reservationTimeService; + } + + public List getAll() { + return reservationSlotRepository.findAll(); + } + + public ReservationSlot open(final LocalDate date, final long themeId, final long timeId) { + Theme theme = themeService.getById(themeId); + ReservationTime time = reservationTimeService.getById(timeId); + ReservationSlot slot = createSlot(date, theme, time); + + if (reservationSlotRepository.findBySlot(slot).isPresent()) { + throw new ConflictException(ErrorCode.RESERVATION_SLOT_DUPLICATED, "이미 열린 예약 슬롯입니다."); + } + + try { + return reservationSlotRepository.save(slot); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.RESERVATION_SLOT_DUPLICATED, "이미 열린 예약 슬롯입니다."); + } + } + + private ReservationSlot createSlot(final LocalDate date, final Theme theme, final ReservationTime time) { + try { + return new ReservationSlot(date, theme, time); + } catch (IllegalArgumentException exception) { + throw new InvalidInputException(ErrorCode.INVALID_INPUT, exception.getMessage()); + } + } + +} diff --git a/src/main/java/roomescape/service/reservationtime/ReservationTimeService.java b/src/main/java/roomescape/service/reservationtime/ReservationTimeService.java index 49fa0625d0..b3b9cba869 100644 --- a/src/main/java/roomescape/service/reservationtime/ReservationTimeService.java +++ b/src/main/java/roomescape/service/reservationtime/ReservationTimeService.java @@ -5,14 +5,19 @@ import java.time.LocalTime; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; import roomescape.exception.ConflictException; import roomescape.exception.ErrorCode; import roomescape.exception.InvalidInputException; import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.PersistenceConflictException; import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; import roomescape.repository.reservationtime.ReservationTimeRepository; import roomescape.service.theme.ThemeService; @@ -21,15 +26,18 @@ public class ReservationTimeService { private final ReservationTimeRepository reservationTimeRepository; private final ReservationRepository reservationRepository; + private final ReservationSlotRepository reservationSlotRepository; private final ThemeService themeService; public ReservationTimeService( final ReservationTimeRepository reservationTimeRepository, final ReservationRepository reservationRepository, + final ReservationSlotRepository reservationSlotRepository, final ThemeService themeService ) { this.reservationTimeRepository = reservationTimeRepository; this.reservationRepository = reservationRepository; + this.reservationSlotRepository = reservationSlotRepository; this.themeService = themeService; } @@ -45,33 +53,45 @@ public ReservationTime save(final LocalTime startAt) { throw new ConflictException(ErrorCode.RESERVATION_TIME_DUPLICATED, "같은 시간을 추가할 수 없습니다."); } - return reservationTimeRepository.save(reservationTime); + try { + return reservationTimeRepository.save(reservationTime); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.RESERVATION_TIME_DUPLICATED, "같은 시간을 추가할 수 없습니다."); + } } - public List findAvailableTimes(final LocalDate date, final long themeId) { - themeService.getById(themeId); - Set reservedTimeIds = reservationRepository.findAll().stream() - .filter(reservation -> reservation.getDate().equals(date)) - .filter(reservation -> reservation.getTheme().getId().equals(themeId)) - .map(reservation -> reservation.getTime().getId()) - .collect(java.util.stream.Collectors.toSet()); + public List findAvailableTimes( + final LocalDate date, + final long themeId, + final LocalDateTime requestedAt + ) { + Theme theme = themeService.getById(themeId); + Set reservedSlotIds = reservationRepository.findByDateAndTheme(date, theme) + .stream() + .map(Reservation::getSlot) + .map(ReservationSlot::getId) + .collect(Collectors.toSet()); - return reservationTimeRepository.findAll().stream() - .filter(reservationTime -> !reservedTimeIds.contains(reservationTime.getId())) - .filter(reservationTime -> Reservation.isReservable( - date, - reservationTime, - LocalDateTime.now() - )) + return reservationSlotRepository.findByDateAndTheme(date, theme) + .stream() + .filter(slot -> !reservedSlotIds.contains(slot.getId())) + .filter(slot -> !slot.isPast(requestedAt)) + .map(ReservationSlot::getTime) .toList(); } public void deleteById(final long timeId) { - if (reservationRepository.findAll().stream() - .anyMatch(reservation -> reservation.getTime().getId().equals(timeId))) { + ReservationTime reservationTime = getById(timeId); + if (reservationRepository.existsByTime(reservationTime)) { + throw new ConflictException(ErrorCode.RESERVATION_TIME_IN_USE, "이미 예약된 시간은 삭제할 수 없습니다."); + } + + int affectedRowCount; + try { + affectedRowCount = reservationTimeRepository.deleteById(timeId); + } catch (PersistenceConflictException exception) { throw new ConflictException(ErrorCode.RESERVATION_TIME_IN_USE, "이미 예약된 시간은 삭제할 수 없습니다."); } - int affectedRowCount = reservationTimeRepository.deleteById(timeId); if (affectedRowCount <= 0) { throw new ResourceNotFoundException(ErrorCode.RESERVATION_TIME_NOT_FOUND, "삭제된 시간 데이터가 없습니다."); diff --git a/src/main/java/roomescape/service/reservationwaiting/ReservationWaitingService.java b/src/main/java/roomescape/service/reservationwaiting/ReservationWaitingService.java index 4f5d59af12..276088b345 100644 --- a/src/main/java/roomescape/service/reservationwaiting/ReservationWaitingService.java +++ b/src/main/java/roomescape/service/reservationwaiting/ReservationWaitingService.java @@ -2,17 +2,21 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import roomescape.domain.reservation.Reservation; import roomescape.domain.reservation.ReservationName; -import roomescape.domain.reservation.ReservationSlot; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.reservationwaiting.ReservationWaitingLine; import roomescape.exception.ConflictException; import roomescape.exception.ErrorCode; import roomescape.exception.InvalidInputException; import roomescape.exception.ResourceNotFoundException; +import roomescape.repository.PersistenceConflictException; import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; import roomescape.repository.reservationwaiting.ReservationWaitingRepository; import roomescape.service.reservationtime.ReservationTimeService; import roomescape.service.theme.ThemeService; @@ -21,43 +25,76 @@ public class ReservationWaitingService { private final ReservationWaitingRepository reservationWaitingRepository; private final ReservationRepository reservationRepository; + private final ReservationSlotRepository reservationSlotRepository; private final ThemeService themeService; private final ReservationTimeService reservationTimeService; public ReservationWaitingService( final ReservationRepository reservationRepository, final ReservationWaitingRepository reservationWaitingRepository, + final ReservationSlotRepository reservationSlotRepository, final ThemeService themeService, final ReservationTimeService reservationTimeService ) { this.reservationRepository = reservationRepository; this.reservationWaitingRepository = reservationWaitingRepository; + this.reservationSlotRepository = reservationSlotRepository; this.themeService = themeService; this.reservationTimeService = reservationTimeService; } - public ReservationWaiting save(final String name, final LocalDate date, final long themeId, final long timeId) { + @Transactional + public ReservationWaiting save( + final String name, + final LocalDate date, + final long themeId, + final long timeId, + final LocalDateTime requestedAt + ) { ReservationName waitingName = ReservationName.from(name); ReservationSlot slot = new ReservationSlot( date, themeService.getById(themeId), reservationTimeService.getById(timeId) ); - Reservation reservation = reservationRepository.findBySlot(slot) + ReservationSlot savedSlot = findExistingSlot(slot); + Reservation reservation = reservationRepository.findBySlot(savedSlot) .orElseThrow(() -> new ResourceNotFoundException( ErrorCode.RESERVATION_NOT_FOUND, "예약 정보가 없으면 대기 생성이 불가능합니다." )); - validateWaitableName(reservation, waitingName); + validateWaitableName(savedSlot, reservation, waitingName); - LocalDateTime requestedAt = LocalDateTime.now(); + ReservationWaiting nonIdReservationWaiting = createNewWaiting(savedSlot, waitingName, requestedAt); + try { + return reservationWaitingRepository.save(nonIdReservationWaiting); + } catch (PersistenceConflictException exception) { + throw new ConflictException( + ErrorCode.RESERVATION_WAITING_DUPLICATED, + "이미 같은 예약에 대기 중입니다." + ); + } + } - ReservationWaiting nonIdReservationWaiting = createNewWaiting(reservation, waitingName, requestedAt); - return reservationWaitingRepository.save(nonIdReservationWaiting); + @Transactional(readOnly = true) + public List getAll() { + return reservationWaitingRepository.findAll(); } - private void validateWaitableName(final Reservation reservation, final ReservationName waitingName) { + private ReservationSlot findExistingSlot(final ReservationSlot slot) { + return reservationSlotRepository.findBySlot(slot) + .orElseThrow(() -> new ResourceNotFoundException( + ErrorCode.RESERVATION_NOT_FOUND, + "예약 정보가 없으면 대기 생성이 불가능합니다." + )); + } + + private void validateWaitableName( + final ReservationSlot slot, + final Reservation reservation, + final ReservationName waitingName + ) { if (reservation.getName().equals(waitingName.value())) { throw new ConflictException( ErrorCode.RESERVATION_WAITING_DUPLICATED, @@ -65,7 +102,7 @@ private void validateWaitableName(final Reservation reservation, final Reservati ); } - ReservationWaitingLine waitingLine = reservationWaitingRepository.findLineByReservation(reservation); + ReservationWaitingLine waitingLine = reservationWaitingRepository.findLineBySlot(slot); if (waitingLine.containsName(waitingName)) { throw new ConflictException( @@ -76,17 +113,18 @@ private void validateWaitableName(final Reservation reservation, final Reservati } private ReservationWaiting createNewWaiting( - final Reservation reservation, + final ReservationSlot slot, final ReservationName waitingName, final LocalDateTime requestedAt ) { try { - return ReservationWaiting.createNew(reservation, waitingName.value(), requestedAt); + return ReservationWaiting.createNew(slot, waitingName.value(), requestedAt); } catch (IllegalArgumentException exception) { throw new InvalidInputException(ErrorCode.RESERVATION_DATE_TIME_IN_PAST, exception.getMessage()); } } + @Transactional public void deleteByIdAndName(final Long waitingId, final String name) { ReservationName waitingName = ReservationName.from(name); ReservationWaiting reservationWaiting = reservationWaitingRepository.findById(waitingId) @@ -104,4 +142,15 @@ public void deleteByIdAndName(final Long waitingId, final String name) { reservationWaitingRepository.delete(reservationWaiting); } + + @Transactional + public void deleteById(final Long waitingId) { + ReservationWaiting reservationWaiting = reservationWaitingRepository.findById(waitingId) + .orElseThrow(() -> new ResourceNotFoundException( + ErrorCode.RESERVATION_WAITING_NOT_FOUND, + "삭제된 대기 데이터가 없습니다." + )); + + reservationWaitingRepository.delete(reservationWaiting); + } } diff --git a/src/main/java/roomescape/service/theme/ThemeService.java b/src/main/java/roomescape/service/theme/ThemeService.java index 1366d1620b..702f985bf6 100644 --- a/src/main/java/roomescape/service/theme/ThemeService.java +++ b/src/main/java/roomescape/service/theme/ThemeService.java @@ -7,6 +7,7 @@ import roomescape.exception.InvalidInputException; import roomescape.exception.ResourceNotFoundException; import roomescape.domain.theme.Theme; +import roomescape.repository.PersistenceConflictException; import roomescape.repository.reservation.ReservationRepository; import roomescape.repository.theme.ThemeRepository; @@ -36,7 +37,11 @@ public Theme save(final String name, final String description, final String thum throw new ConflictException(ErrorCode.THEME_NAME_DUPLICATED, "테마 이름 중복은 불가능합니다."); } - return themeRepository.save(nonIdTheme); + try { + return themeRepository.save(nonIdTheme); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.THEME_NAME_DUPLICATED, "테마 이름 중복은 불가능합니다."); + } } public List getAll() { @@ -54,7 +59,12 @@ public void deleteById(final long themeId) { throw new ConflictException(ErrorCode.THEME_IN_USE, "이미 예약된 테마는 삭제할 수 없습니다."); } - int affectedRowCount = themeRepository.deleteById(themeId); + int affectedRowCount; + try { + affectedRowCount = themeRepository.deleteById(themeId); + } catch (PersistenceConflictException exception) { + throw new ConflictException(ErrorCode.THEME_IN_USE, "이미 예약된 테마는 삭제할 수 없습니다."); + } if (affectedRowCount <= 0) { throw new ResourceNotFoundException(ErrorCode.THEME_NOT_FOUND, "삭제된 테마 데이터가 없습니다."); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b63619d739..687bceb891 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,7 @@ spring.h2.console.enabled=true spring.h2.console.path=/h2-console spring.datasource.url=jdbc:h2:mem:database +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.defer-datasource-initialization=true diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 606ea236d3..ee7b7dad39 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -21,36 +21,70 @@ VALUES ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 3; -INSERT INTO reservation (id, name, date, theme_id, time_id) +INSERT INTO reservation_slot (id, date, theme_id, time_id) VALUES - (1, '브라운', DATE '2026-05-05', 1, 1), - (2, '코니', DATE '2026-05-04', 1, 1), - (3, '샐리', DATE '2026-05-03', 1, 2), - (4, '문', DATE '2026-05-02', 1, 2), - (5, '제시카', DATE '2026-05-01', 1, 1), - - (6, '제임스', DATE '2026-05-05', 2, 1), - (7, '레오', DATE '2026-05-04', 2, 1), - (8, '루카', DATE '2026-05-03', 2, 2), - (9, '앤디', DATE '2026-05-02', 2, 2), - - (10, '레너드', DATE '2026-05-05', 3, 1), - (11, '초코', DATE '2026-05-04', 3, 1), - (12, '브이', DATE '2026-05-03', 3, 2), - - (13, '에디', DATE '2026-05-05', 4, 1), - (14, '리아', DATE '2026-05-04', 4, 2), - - (15, '마크', DATE '2026-05-05', 5, 1), - (16, '니나', DATE '2026-05-04', 5, 2), - - (17, '제이', DATE '2026-05-05', 6, 1), - (18, '하나', DATE '2026-05-05', 7, 1), - (19, '오웬', DATE '2026-05-05', 8, 1), - (20, '소라', DATE '2026-05-05', 9, 1), - (21, '태오', DATE '2026-05-05', 10, 1), - - (22, '미래예약1', DATE '2026-05-12', 1, 1), - (23, '미래예약2', DATE '2026-05-12', 2, 1); + (1, DATE '2026-05-05', 1, 1), + (2, DATE '2026-05-04', 1, 1), + (3, DATE '2026-05-03', 1, 2), + (4, DATE '2026-05-02', 1, 2), + (5, DATE '2026-05-01', 1, 1), + + (6, DATE '2026-05-05', 2, 1), + (7, DATE '2026-05-04', 2, 1), + (8, DATE '2026-05-03', 2, 2), + (9, DATE '2026-05-02', 2, 2), + + (10, DATE '2026-05-05', 3, 1), + (11, DATE '2026-05-04', 3, 1), + (12, DATE '2026-05-03', 3, 2), + + (13, DATE '2026-05-05', 4, 1), + (14, DATE '2026-05-04', 4, 2), + + (15, DATE '2026-05-05', 5, 1), + (16, DATE '2026-05-04', 5, 2), + + (17, DATE '2026-05-05', 6, 1), + (18, DATE '2026-05-05', 7, 1), + (19, DATE '2026-05-05', 8, 1), + (20, DATE '2026-05-05', 9, 1), + (21, DATE '2026-05-05', 10, 1), + + (22, DATE '2026-05-12', 1, 1), + (23, DATE '2026-05-12', 2, 1); + +ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 24; + +INSERT INTO reservation (id, name, slot_id) +VALUES + (1, '브라운', 1), + (2, '코니', 2), + (3, '샐리', 3), + (4, '문', 4), + (5, '제시카', 5), + + (6, '제임스', 6), + (7, '레오', 7), + (8, '루카', 8), + (9, '앤디', 9), + + (10, '레너드', 10), + (11, '초코', 11), + (12, '브이', 12), + + (13, '에디', 13), + (14, '리아', 14), + + (15, '마크', 15), + (16, '니나', 16), + + (17, '제이', 17), + (18, '하나', 18), + (19, '오웬', 19), + (20, '소라', 20), + (21, '태오', 21), + + (22, '미래예약1', 22), + (23, '미래예약2', 23); ALTER TABLE reservation ALTER COLUMN id RESTART WITH 24; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 12793e083e..0000000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,52 +0,0 @@ -CREATE TABLE theme -( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - description VARCHAR(255) NOT NULL, - thumbnail_url VARCHAR(255) NOT NULL, - PRIMARY KEY (id), - UNIQUE (name) -); - -CREATE TABLE reservation_time -( - id BIGINT NOT NULL AUTO_INCREMENT, - start_at TIME NOT NULL, - PRIMARY KEY (id), - UNIQUE (start_at) -); - -CREATE TABLE reservation -( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - date DATE NOT NULL, - theme_id BIGINT NOT NULL, - time_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - FOREIGN KEY (theme_id) REFERENCES theme (id), - FOREIGN KEY (time_id) REFERENCES reservation_time (id), - UNIQUE (date, theme_id, time_id) -); - -CREATE TABLE reservation_waiting -( - id BIGINT NOT NULL AUTO_INCREMENT, - reservation_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - requested_at TIMESTAMP NOT NULL, - PRIMARY KEY (id), - FOREIGN KEY (reservation_id) REFERENCES reservation (id), - UNIQUE (reservation_id, name) -); - -CREATE INDEX idx_reservation_name ON reservation (name); - -CREATE INDEX idx_reservation_theme_id ON reservation (theme_id); - -CREATE INDEX idx_reservation_time_id ON reservation (time_id); - -CREATE INDEX idx_reservation_waiting_name ON reservation_waiting (name); - -CREATE INDEX idx_reservation_waiting_sequence ON reservation_waiting (reservation_id, requested_at, id); diff --git a/src/main/resources/templates/reservation/admin-list.html b/src/main/resources/templates/reservation/admin-list.html index 9be5013bb1..4022fc3fd5 100644 --- a/src/main/resources/templates/reservation/admin-list.html +++ b/src/main/resources/templates/reservation/admin-list.html @@ -122,7 +122,9 @@ 관리자 홈 테마 관리 시간 관리 + 슬롯 관리 예약 관리 + 대기 관리 사용자 예약 페이지 diff --git a/src/main/resources/templates/reservation/list.html b/src/main/resources/templates/reservation/list.html index 31572d7cdd..a01110ac98 100644 --- a/src/main/resources/templates/reservation/list.html +++ b/src/main/resources/templates/reservation/list.html @@ -346,6 +346,7 @@

예약 페이지

diff --git a/src/main/resources/templates/reservation/mine-list.html b/src/main/resources/templates/reservation/mine-list.html new file mode 100644 index 0000000000..fca5f1ab39 --- /dev/null +++ b/src/main/resources/templates/reservation/mine-list.html @@ -0,0 +1,178 @@ + + + + + + 내 예약 목록 + + + +
+
+ GET /reservations-mine +

내 예약 목록 조회

+

이름으로 조회되는 예약만 보여주는 화면입니다. 인증이 붙기 전에는 이름을 대신 입력해서 흐름을 확인할 수 있습니다.

+
+ + + +
+
+ + +
+ +
+
+ 쿠다 +
+ 2026-08-19 + / + 미술관의 밤 + / + 10:00 +
+
+
+ +
+ 입력한 이름으로 조회된 예약이 없습니다. +
+ +
+ 이름을 입력하면 예약만 조회됩니다. 대기 내역은 기존 기록 화면에서 확인할 수 있습니다. +
+
+
+ + diff --git a/src/main/resources/templates/reservationslot/list.html b/src/main/resources/templates/reservationslot/list.html new file mode 100644 index 0000000000..a9f8832957 --- /dev/null +++ b/src/main/resources/templates/reservationslot/list.html @@ -0,0 +1,246 @@ + + + + + + 예약 슬롯 관리 + + + +
+ + +
+ ADMIN PAGE +

예약 슬롯 관리

+

관리자가 예약 가능한 날짜, 테마, 시간을 열어두면 사용자는 열린 슬롯에 대해서만 예약하거나 대기할 수 있습니다.

+
+ + + +
+
+

슬롯 열기

+
+ + + + +
+
+ +
+

열린 슬롯

+
+
+ 2026-08-06 +
+ 미술관의 밤 + / + 10:00 +
+ #1 +
+
+

아직 열린 슬롯이 없습니다.

+
+
+
+ + + diff --git a/src/main/resources/templates/reservationtime/list.html b/src/main/resources/templates/reservationtime/list.html index 130a53c1db..ed4455f1ae 100644 --- a/src/main/resources/templates/reservationtime/list.html +++ b/src/main/resources/templates/reservationtime/list.html @@ -134,7 +134,9 @@ 관리자 홈 테마 관리 예약 관리 + 대기 관리 시간 관리 + 슬롯 관리 사용자 예약 페이지 diff --git a/src/main/resources/templates/reservationwaiting/admin-list.html b/src/main/resources/templates/reservationwaiting/admin-list.html new file mode 100644 index 0000000000..85020f3857 --- /dev/null +++ b/src/main/resources/templates/reservationwaiting/admin-list.html @@ -0,0 +1,190 @@ + + + + + + 대기 관리 + + + +
+ + +
+ ADMIN PAGE +

대기 관리

+

현재 등록된 대기 목록을 확인하고 관리자 권한으로 삭제할 수 있습니다. 승인은 예약 취소 시 자동으로 처리됩니다.

+
+ + + +
+

대기 목록

+
+
+
+ 브라운 + + 2026-08-06 / 미술관의 밤 / 10:00 / 2026-08-05T12:00 + +
+
+ +
+
+
+

등록된 대기가 없습니다.

+
+
+ + + diff --git a/src/main/resources/templates/theme/list.html b/src/main/resources/templates/theme/list.html index eb002761fd..152fe13b49 100644 --- a/src/main/resources/templates/theme/list.html +++ b/src/main/resources/templates/theme/list.html @@ -139,8 +139,10 @@
@@ -188,6 +190,7 @@

테마 목록

시간 관리 + 슬롯 관리
diff --git a/src/test/java/roomescape/concurrency/UniqueConstraintConcurrencyTest.java b/src/test/java/roomescape/concurrency/UniqueConstraintConcurrencyTest.java new file mode 100644 index 0000000000..b4a05980ff --- /dev/null +++ b/src/test/java/roomescape/concurrency/UniqueConstraintConcurrencyTest.java @@ -0,0 +1,181 @@ +package roomescape.concurrency; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +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.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.exception.ConflictException; +import roomescape.exception.ErrorCode; +import roomescape.repository.reservationslot.ReservationSlotRepository; +import roomescape.service.reservation.ReservationService; +import roomescape.service.reservationtime.ReservationTimeService; +import roomescape.service.reservationwaiting.ReservationWaitingService; +import roomescape.service.theme.ThemeService; + +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:unique-constraint-concurrency") +class UniqueConstraintConcurrencyTest { + + private static final int REQUEST_COUNT = 5; + private static final LocalDateTime REQUESTED_AT = LocalDateTime.parse("2026-08-05T12:00:00"); + + @Autowired + private ReservationService reservationService; + + @Autowired + private ReservationWaitingService reservationWaitingService; + + @Autowired + private ReservationSlotRepository reservationSlotRepository; + + @Autowired + private ThemeService themeService; + + @Autowired + private ReservationTimeService reservationTimeService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + clearTables(); + } + + @Test + @DisplayName("동시에 같은 슬롯에 예약하면 하나만 성공하고 나머지는 충돌 처리된다") + void createReservationConcurrently() throws Exception { + Theme theme = themeService.save("미술관의 밤", "추리 테마", "https://example.com/theme.png"); + ReservationTime time = reservationTimeService.save(LocalTime.parse("10:00")); + LocalDate date = LocalDate.parse("2026-08-06"); + reservationSlotRepository.save(new ReservationSlot(date, theme, time)); + + List results = runConcurrently(REQUEST_COUNT, index -> + reservationService.save("예약자" + index, date, theme.getId(), time.getId(), REQUESTED_AT) + ); + + assertThat(results).filteredOn(throwable -> throwable == null).hasSize(1); + assertThat(countConflict(results, ErrorCode.RESERVATION_DUPLICATED)).isEqualTo(REQUEST_COUNT - 1); + assertThat(countRows("reservation")).isOne(); + } + + @Test + @DisplayName("동시에 같은 이름으로 대기하면 하나만 성공하고 나머지는 충돌 처리된다") + void createReservationWaitingWithSameNameConcurrently() throws Exception { + Theme theme = themeService.save("미술관의 밤", "추리 테마", "https://example.com/theme.png"); + ReservationTime time = reservationTimeService.save(LocalTime.parse("10:00")); + LocalDate date = LocalDate.parse("2026-08-06"); + reservationSlotRepository.save(new ReservationSlot(date, theme, time)); + reservationService.save("예약자", date, theme.getId(), time.getId(), REQUESTED_AT); + + List results = runConcurrently(REQUEST_COUNT, index -> + reservationWaitingService.save("아루", date, theme.getId(), time.getId(), REQUESTED_AT) + ); + + assertThat(results).filteredOn(throwable -> throwable == null).hasSize(1); + assertThat(countConflict(results, ErrorCode.RESERVATION_WAITING_DUPLICATED)).isEqualTo(REQUEST_COUNT - 1); + assertThat(countRows("reservation_waiting")).isOne(); + } + + @Test + @DisplayName("동시에 서로 다른 이름으로 대기하면 모두 성공한다") + void createReservationWaitingWithDifferentNamesConcurrently() throws Exception { + Theme theme = themeService.save("미술관의 밤", "추리 테마", "https://example.com/theme.png"); + ReservationTime time = reservationTimeService.save(LocalTime.parse("10:00")); + LocalDate date = LocalDate.parse("2026-08-06"); + reservationSlotRepository.save(new ReservationSlot(date, theme, time)); + reservationService.save("예약자", date, theme.getId(), time.getId(), REQUESTED_AT); + + List results = runConcurrently(REQUEST_COUNT, index -> + reservationWaitingService.save("대기자" + index, date, theme.getId(), time.getId(), REQUESTED_AT) + ); + + assertThat(results).allMatch(throwable -> throwable == null); + assertThat(countRows("reservation_waiting")).isEqualTo(REQUEST_COUNT); + } + + private List runConcurrently(final int requestCount, final ConcurrentOperation operation) + throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(requestCount); + CountDownLatch ready = new CountDownLatch(requestCount); + CountDownLatch start = new CountDownLatch(1); + + try { + List> futures = IntStream.range(0, requestCount) + .mapToObj(index -> executorService.submit(() -> { + ready.countDown(); + start.await(); + try { + operation.run(index); + return null; + } catch (Throwable throwable) { + return throwable; + } + })) + .toList(); + + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + return futures.stream() + .map(this::getResult) + .toList(); + } finally { + executorService.shutdownNow(); + } + } + + private Throwable getResult(final Future future) { + try { + return future.get(5, TimeUnit.SECONDS); + } catch (Exception exception) { + return exception; + } + } + + private long countConflict(final List results, final ErrorCode errorCode) { + return results.stream() + .filter(ConflictException.class::isInstance) + .map(ConflictException.class::cast) + .filter(exception -> exception.getCode().equals(errorCode.getCode())) + .count(); + } + + private int countRows(final String tableName) { + return jdbcTemplate.queryForObject("SELECT count(1) FROM " + tableName, Integer.class); + } + + private void clearTables() { + jdbcTemplate.update("DELETE FROM reservation_waiting"); + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); + } + + @FunctionalInterface + private interface ConcurrentOperation { + void run(int index); + } +} diff --git a/src/test/java/roomescape/domain/ReservationCancellationResultTest.java b/src/test/java/roomescape/domain/ReservationCancellationResultTest.java new file mode 100644 index 0000000000..3135130f46 --- /dev/null +++ b/src/test/java/roomescape/domain/ReservationCancellationResultTest.java @@ -0,0 +1,95 @@ +package roomescape.domain; + +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.DisplayName; +import org.junit.jupiter.api.Test; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservation.ReservationCancellationResult; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.reservationwaiting.ReservationWaiting; +import roomescape.domain.reservationwaiting.ReservationWaitingLine; +import roomescape.domain.theme.Theme; + +class ReservationCancellationResultTest { + private static final LocalDateTime REQUESTED_AT = LocalDateTime.parse("2026-08-05T12:00:00"); + + @Test + @DisplayName("대기가 없으면 예약만 취소된다") + void cancelWithoutWaiting() { + Reservation reservation = createReservation(1L, "쿠다", LocalTime.parse("10:00")); + + ReservationCancellationResult cancellation = reservation.cancel( + new ReservationWaitingLine(List.of()), + REQUESTED_AT + ); + + assertThat(cancellation.cancelledReservation()).isEqualTo(reservation); + assertThat(cancellation.promotedWaiting()).isEmpty(); + assertThat(cancellation.promotedReservation()).isEmpty(); + } + + @Test + @DisplayName("대기가 있으면 첫 번째 대기가 예약으로 승급된다") + void cancelWithWaiting() { + Reservation reservation = createReservation(1L, "쿠다", LocalTime.parse("10:00")); + ReservationWaiting firstWaiting = ReservationWaiting.of( + 1L, + reservation.getSlot(), + "아루", + LocalDateTime.parse("2026-08-05T11:00:00") + ); + ReservationWaiting secondWaiting = ReservationWaiting.of( + 2L, + reservation.getSlot(), + "도기", + LocalDateTime.parse("2026-08-05T11:01:00") + ); + + ReservationCancellationResult cancellation = reservation.cancel( + new ReservationWaitingLine(List.of(secondWaiting, firstWaiting)), + REQUESTED_AT + ); + + assertThat(cancellation.cancelledReservation()).isEqualTo(reservation); + assertThat(cancellation.promotedWaiting()).contains(firstWaiting); + assertThat(cancellation.promotedReservation()) + .get() + .extracting(Reservation::getName) + .isEqualTo("아루"); + } + + @Test + @DisplayName("예약과 대기 줄의 슬롯이 다르면 하나의 예약 상태로 묶을 수 없다") + void rejectDifferentSlot() { + Reservation reservation = createReservation(1L, "쿠다", LocalTime.parse("10:00")); + Reservation otherReservation = createReservation(2L, "아루", LocalTime.parse("11:00")); + ReservationWaiting waiting = ReservationWaiting.of( + 1L, + otherReservation.getSlot(), + "도기", + LocalDateTime.parse("2026-08-05T11:00:00") + ); + + assertThatThrownBy(() -> reservation.cancel( + new ReservationWaitingLine(List.of(waiting)), + REQUESTED_AT + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("예약과 대기 줄의 슬롯이 일치하지 않습니다."); + } + + private Reservation createReservation(final Long slotId, final String name, final LocalTime startAt) { + Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); + ReservationTime time = ReservationTime.of(slotId, startAt); + ReservationSlot slot = new ReservationSlot(slotId, LocalDate.parse("2026-08-06"), theme, time); + + return new Reservation(slotId, name, slot, LocalDateTime.parse("2026-08-05T10:00:00")); + } +} diff --git a/src/test/java/roomescape/domain/ReservationSlotTest.java b/src/test/java/roomescape/domain/ReservationSlotTest.java new file mode 100644 index 0000000000..402b5cf9ae --- /dev/null +++ b/src/test/java/roomescape/domain/ReservationSlotTest.java @@ -0,0 +1,54 @@ +package roomescape.domain; + +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; + +class ReservationSlotTest { + + @DisplayName("슬롯 시간이 기준 시각보다 이전이면 과거 슬롯이다") + @Test + void isPast() { + ReservationSlot slot = new ReservationSlot( + LocalDate.parse("2026-03-08"), + createTheme(), + ReservationTime.createNew(LocalTime.parse("10:00")) + ); + + boolean past = slot.isPast(LocalDateTime.parse("2026-03-08T10:01:00")); + + assertThat(past).isTrue(); + } + + @DisplayName("예약 날짜 null 예외") + @Test + void validateDate_ThrowsException() { + Theme theme = createTheme(); + ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); + + assertThatThrownBy(() -> new ReservationSlot(null, theme, time)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("예약 시간 null 예외") + @Test + void validateTime_ThrowsException() { + Theme theme = createTheme(); + LocalDate date = LocalDate.parse("2026-03-08"); + + assertThatThrownBy(() -> new ReservationSlot(date, theme, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private Theme createTheme() { + return Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); + } +} diff --git a/src/test/java/roomescape/domain/ReservationTest.java b/src/test/java/roomescape/domain/ReservationTest.java index ff21b74377..bc8aecd221 100644 --- a/src/test/java/roomescape/domain/ReservationTest.java +++ b/src/test/java/roomescape/domain/ReservationTest.java @@ -12,6 +12,8 @@ import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservation.ReservationName; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; @@ -22,12 +24,10 @@ class ReservationTest { void createReservation_Success() { // given String name = "쿠다"; - LocalDate date = LocalDate.parse("2026-08-06"); - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); - ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); + ReservationSlot slot = createSlot(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); // when & then - assertThatCode(() -> Reservation.createNew(name, date, theme, time)) + assertThatCode(() -> Reservation.reserve(name, slot, LocalDateTime.now())) .doesNotThrowAnyException(); } @@ -37,38 +37,35 @@ void createReservation_Success() { @ValueSource(strings = {" ", " "}) void validateName_ThrowsException(String name) { // given - LocalDate date = LocalDate.parse("2026-03-08"); - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); - ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); + ReservationSlot slot = createSlot(LocalDate.parse("2026-03-08"), LocalTime.parse("10:00")); // when & then - assertThatThrownBy(() -> Reservation.createNew(name, date, theme, time)) + assertThatThrownBy(() -> Reservation.reserve(name, slot, LocalDateTime.now())) .isInstanceOf(IllegalArgumentException.class); } - @DisplayName("예약 날짜 null 예외") + @DisplayName("예약자 이름은 값 객체 기준으로 정규화된다") @Test - void validateDate_ThrowsException() { + void normalizeName() { // given - String name = "쿠다"; - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); - ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); + ReservationSlot slot = createSlot(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); - // when & then - assertThatThrownBy(() -> Reservation.createNew(name, null, theme, time)) - .isInstanceOf(IllegalArgumentException.class); + // when + Reservation reservation = Reservation.reserve(" 쿠다 ", slot, LocalDateTime.now()); + + // then + assertThat(reservation.getName()).isEqualTo("쿠다"); + assertThat(reservation.hasName("쿠다")).isTrue(); } - @DisplayName("예약 시간 null 예외") + @DisplayName("예약 슬롯 null 예외") @Test - void validateTime_ThrowsException() { + void validateSlot_ThrowsException() { // given String name = "쿠다"; - LocalDate date = LocalDate.parse("2026-03-08"); - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); // when & then - assertThatThrownBy(() -> Reservation.createNew(name, date, theme, null)) + assertThatThrownBy(() -> Reservation.reserve(name, null, LocalDateTime.now())) .isInstanceOf(IllegalArgumentException.class); } @@ -77,25 +74,31 @@ void validateTime_ThrowsException() { void createPastDateTime_ThrowsException() { // given String name = "쿠다"; - LocalDate date = LocalDate.parse("2026-03-08"); - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); - ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); + ReservationSlot slot = createSlot(LocalDate.parse("2026-03-08"), LocalTime.parse("10:00")); LocalDateTime standardDateTime = LocalDateTime.parse("2026-03-08T10:01:00"); // when & then - assertThatThrownBy(() -> Reservation.createNew(name, date, theme, time, standardDateTime)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(Reservation.PAST_RESERVATION_MESSAGE); + assertThatThrownBy(() -> Reservation.reserve(name, slot, standardDateTime)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("예약 이름은 값 객체의 동등성 기준으로 비교한다") + @Test + void hasNameByValueObjectEquality() { + // given + ReservationSlot slot = createSlot(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); + Reservation reservation = Reservation.reserve(ReservationName.from(" 쿠다 "), slot, LocalDateTime.now()); + + // when & then + assertThat(reservation.hasName("쿠다")).isTrue(); } @DisplayName("예약 시각이 기준 시각보다 이전이면 과거 예약이다") @Test void isPastReservation() { // given - LocalDate date = LocalDate.parse("2026-03-08"); - Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); - ReservationTime time = ReservationTime.createNew(LocalTime.parse("10:00")); - Reservation reservation = Reservation.of(1L, "쿠다", date, theme, time); + ReservationSlot slot = createSlot(LocalDate.parse("2026-03-08"), LocalTime.parse("10:00")); + Reservation reservation = new Reservation(1L, "쿠다", slot, LocalDateTime.now()); // when boolean past = reservation.isPast(LocalDateTime.parse("2026-03-08T10:01:00")); @@ -103,4 +106,10 @@ void isPastReservation() { // then assertThat(past).isTrue(); } + + private ReservationSlot createSlot(final LocalDate date, final LocalTime time) { + Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); + ReservationTime reservationTime = ReservationTime.createNew(time); + return new ReservationSlot(date, theme, reservationTime); + } } diff --git a/src/test/java/roomescape/domain/ReservationWaitingTest.java b/src/test/java/roomescape/domain/ReservationWaitingTest.java index 5a7d8eb706..14f37ad5c5 100644 --- a/src/test/java/roomescape/domain/ReservationWaitingTest.java +++ b/src/test/java/roomescape/domain/ReservationWaitingTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.reservationwaiting.ReservationWaitingLine; @@ -24,7 +25,7 @@ void createNew() { Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); assertThatCode(() -> ReservationWaiting.createNew( - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )).doesNotThrowAnyException(); @@ -36,7 +37,7 @@ void createNewPastReservation() { Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); assertThatThrownBy(() -> ReservationWaiting.createNew( - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-06T10:01:00") )) @@ -50,13 +51,13 @@ void equalsById() { Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); ReservationWaiting waiting = ReservationWaiting.of( 1L, - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") ); ReservationWaiting sameIdWaiting = ReservationWaiting.of( 1L, - reservation, + reservation.getSlot(), "다른이름", LocalDateTime.parse("2026-08-05T12:01:00") ); @@ -69,12 +70,12 @@ void equalsById() { void notEqualsWithoutId() { Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); ReservationWaiting waiting = ReservationWaiting.createNew( - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") ); ReservationWaiting sameValuesWaiting = ReservationWaiting.createNew( - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") ); @@ -83,32 +84,115 @@ void notEqualsWithoutId() { } @Test - @DisplayName("예약 대기 줄은 요청 시각과 ID 순서로 순번을 계산한다") - void sequenceOf() { + @DisplayName("예약 대기 줄은 요청 시각과 ID 순서로 위치를 계산한다") + void indexOf() { + Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); + ReservationWaitingLine waitingLine = new ReservationWaitingLine(List.of( - new ReservationWaitingLine.ReservationWaitingOrder( + ReservationWaiting.of( 3L, + reservation.getSlot(), + "아루3", LocalDateTime.parse("2026-08-05T12:01:00") ), - new ReservationWaitingLine.ReservationWaitingOrder( + ReservationWaiting.of( 2L, + reservation.getSlot(), + "아루2", LocalDateTime.parse("2026-08-05T12:00:00") ), - new ReservationWaitingLine.ReservationWaitingOrder( + ReservationWaiting.of( 1L, + reservation.getSlot(), + "아루1", LocalDateTime.parse("2026-08-05T12:00:00") ) )); - assertThat(waitingLine.sequenceOf(1L)).isOne(); - assertThat(waitingLine.sequenceOf(2L)).isEqualTo(2); - assertThat(waitingLine.sequenceOf(3L)).isEqualTo(3); + assertThat(waitingLine.indexOf(1L)).hasValue(0); + assertThat(waitingLine.indexOf(2L)).hasValue(1); + assertThat(waitingLine.indexOf(3L)).hasValue(2); + assertThat(waitingLine.indexOf(4L)).isEmpty(); + } + + @Test + @DisplayName("예약 대기 줄은 요청 시각과 ID 순서로 첫 번째 대기를 찾는다") + void first() { + Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); + ReservationWaiting firstWaiting = ReservationWaiting.of( + 1L, + reservation.getSlot(), + "아루1", + LocalDateTime.parse("2026-08-05T12:00:00") + ); + + ReservationWaitingLine waitingLine = new ReservationWaitingLine(List.of( + ReservationWaiting.of( + 3L, + reservation.getSlot(), + "아루3", + LocalDateTime.parse("2026-08-05T12:01:00") + ), + ReservationWaiting.of( + 2L, + reservation.getSlot(), + "아루2", + LocalDateTime.parse("2026-08-05T12:00:00") + ), + firstWaiting + )); + + assertThat(waitingLine.first()).contains(firstWaiting); + } + + @Test + @DisplayName("예약 대기 줄은 저장되지 않은 대기를 포함할 수 없다") + void rejectUnsavedWaiting() { + Reservation reservation = createReservation(LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); + ReservationWaiting unsavedWaiting = ReservationWaiting.createNew( + reservation.getSlot(), + "아루", + LocalDateTime.parse("2026-08-05T12:00:00") + ); + + assertThatThrownBy(() -> new ReservationWaitingLine(List.of(unsavedWaiting))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ReservationWaitingLine.UNSAVED_WAITING_MESSAGE); + } + + @Test + @DisplayName("예약 대기 줄은 서로 다른 슬롯의 대기를 함께 포함할 수 없다") + void rejectDifferentSlotWaitings() { + Reservation reservation = createReservation(1L, LocalDate.parse("2026-08-06"), LocalTime.parse("10:00")); + Reservation otherReservation = createReservation(2L, LocalDate.parse("2026-08-06"), LocalTime.parse("11:00")); + + assertThatThrownBy(() -> new ReservationWaitingLine(List.of( + ReservationWaiting.of( + 1L, + reservation.getSlot(), + "아루1", + LocalDateTime.parse("2026-08-05T12:00:00") + ), + ReservationWaiting.of( + 2L, + otherReservation.getSlot(), + "아루2", + LocalDateTime.parse("2026-08-05T12:01:00") + ) + ))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(ReservationWaitingLine.DIFFERENT_SLOT_MESSAGE); } private Reservation createReservation(final LocalDate date, final LocalTime time) { + return createReservation(1L, date, time); + } + + private Reservation createReservation(final Long slotId, final LocalDate date, final LocalTime time) { Theme theme = Theme.of(1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); ReservationTime reservationTime = ReservationTime.of(1L, time); + ReservationSlot slot = new ReservationSlot(slotId, date, theme, reservationTime); - return Reservation.of(1L, "쿠다", date, theme, reservationTime); + return new Reservation(1L, "쿠다", slot, date.minusDays(1).atStartOfDay()); } } diff --git a/src/test/java/roomescape/history/MyHistoriesTest.java b/src/test/java/roomescape/history/MyHistoriesTest.java index 85e774171b..a1306ab587 100644 --- a/src/test/java/roomescape/history/MyHistoriesTest.java +++ b/src/test/java/roomescape/history/MyHistoriesTest.java @@ -72,6 +72,7 @@ private MyWaitingOrder waitingOrder( final String requestedAt ) { return new MyWaitingOrder( + reservationId, reservationId, waitingId, LocalDateTime.parse(requestedAt) diff --git a/src/test/java/roomescape/history/MyHistoryApiTest.java b/src/test/java/roomescape/history/MyHistoryApiTest.java index 7ca07f2aa4..ec954f6556 100644 --- a/src/test/java/roomescape/history/MyHistoryApiTest.java +++ b/src/test/java/roomescape/history/MyHistoryApiTest.java @@ -58,10 +58,12 @@ void getHistoriesByName() throws Exception { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation_waiting"); jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } @@ -92,25 +94,30 @@ private void createReservation( final long timeId ) { jdbcTemplate.update( - "INSERT INTO reservation (id, name, date, theme_id, time_id) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO reservation_slot (id, date, theme_id, time_id) VALUES (?, ?, ?, ?)", id, - name, date, themeId, timeId ); + jdbcTemplate.update( + "INSERT INTO reservation (id, name, slot_id) VALUES (?, ?, ?)", + id, + name, + id + ); } private void createReservationWaiting( final long id, - final long reservationId, + final long slotId, final String name, final String requestedAt ) { jdbcTemplate.update( - "INSERT INTO reservation_waiting (id, reservation_id, name, requested_at) VALUES (?, ?, ?, ?)", + "INSERT INTO reservation_waiting (id, slot_id, name, requested_at) VALUES (?, ?, ?, ?)", id, - reservationId, + slotId, name, requestedAt ); diff --git a/src/test/java/roomescape/history/MyWaitingLinesTest.java b/src/test/java/roomescape/history/MyWaitingLinesTest.java index 2ca6c6c18d..845033f6d9 100644 --- a/src/test/java/roomescape/history/MyWaitingLinesTest.java +++ b/src/test/java/roomescape/history/MyWaitingLinesTest.java @@ -33,6 +33,7 @@ private MyWaitingOrder waitingOrder( final String requestedAt ) { return new MyWaitingOrder( + reservationId, reservationId, waitingId, LocalDateTime.parse(requestedAt) diff --git a/src/test/java/roomescape/persistence/MineQueryObservationTest.java b/src/test/java/roomescape/persistence/MineQueryObservationTest.java new file mode 100644 index 0000000000..deb726c133 --- /dev/null +++ b/src/test/java/roomescape/persistence/MineQueryObservationTest.java @@ -0,0 +1,316 @@ +package roomescape.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.reservationwaiting.ReservationWaiting; +import roomescape.domain.theme.Theme; +import roomescape.repository.reservation.jpa.ReservationJpaEntity; +import roomescape.repository.reservation.jpa.ReservationJpaRepository; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaEntity; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaRepository; +import roomescape.repository.reservationwaiting.jpa.ReservationWaitingJpaEntity; +import roomescape.repository.reservationwaiting.jpa.ReservationWaitingJpaRepository; +import roomescape.repository.reservationwaiting.jpa.WaitingWithRank; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaRepository; +import roomescape.repository.theme.jpa.ThemeJpaEntity; +import roomescape.repository.theme.jpa.ThemeJpaRepository; + +@SpringBootTest +class MineQueryObservationTest { + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private ThemeJpaRepository themeJpaRepository; + + @Autowired + private ReservationTimeJpaRepository reservationTimeJpaRepository; + + @Autowired + private ReservationSlotJpaRepository reservationSlotJpaRepository; + + @Autowired + private ReservationJpaRepository reservationJpaRepository; + + @Autowired + private ReservationWaitingJpaRepository reservationWaitingJpaRepository; + + @Test + void mine_list_without_fetch_join_triggers_lazy_selects_for_each_item() { + String ownerName = uniqueName("mine-n1"); + createMineFixture(ownerName, 2, 2); + + Statistics statistics = statistics(); + statistics.clear(); + + new TransactionTemplate(transactionManager).execute(status -> { + List reservations = entityManager.createQuery( + "select r from ReservationJpaEntity r where r.name = :name order by r.id", + ReservationJpaEntity.class + ) + .setParameter("name", ownerName) + .getResultList(); + + List waitings = entityManager.createQuery( + "select w from ReservationWaitingJpaEntity w where w.name = :name order by w.id", + ReservationWaitingJpaEntity.class + ) + .setParameter("name", ownerName) + .getResultList(); + + List dtoValues = Stream.concat( + reservations.stream().map(this::toDtoValue), + waitings.stream().map(this::toDtoValue) + ).toList(); + + assertThat(dtoValues).hasSize(4); + return null; + }); + + assertThat(statistics.getPrepareStatementCount()).isEqualTo(14L); + } + + @Test + void mine_list_with_fetch_join_collapses_to_two_selects() { + String ownerName = uniqueName("mine-fetch"); + createMineFixture(ownerName, 2, 2); + + Statistics statistics = statistics(); + statistics.clear(); + + new TransactionTemplate(transactionManager).execute(status -> { + List reservations = entityManager.createQuery(""" + select distinct r + from ReservationJpaEntity r + join fetch r.slot s + join fetch s.theme + join fetch s.time + where r.name = :name + order by r.id + """, ReservationJpaEntity.class + ) + .setParameter("name", ownerName) + .getResultList(); + + List waitings = entityManager.createQuery(""" + select distinct w + from ReservationWaitingJpaEntity w + join fetch w.slot s + join fetch s.theme + join fetch s.time + where w.name = :name + order by w.id + """, ReservationWaitingJpaEntity.class + ) + .setParameter("name", ownerName) + .getResultList(); + + List dtoValues = Stream.concat( + reservations.stream().map(this::toDtoValue), + waitings.stream().map(this::toDtoValue) + ).toList(); + + assertThat(dtoValues).hasSize(4); + return null; + }); + + assertThat(statistics.getPrepareStatementCount()).isEqualTo(2L); + } + + @Test + void jpql_subquery_can_calculate_waiting_rank() { + String ownerName = uniqueName("mine-rank"); + createWaitingRankFixture(ownerName); + + List results = reservationWaitingJpaRepository.findAllWithRankByName(ownerName); + + assertThat(results).hasSize(1); + assertThat(results.get(0).rank()).isEqualTo(2L); + } + + private void createMineFixture(final String ownerName, final int reservationCount, final int waitingCount) { + new TransactionTemplate(transactionManager).execute(status -> { + for (int index = 0; index < reservationCount; index++) { + createReservation(ownerName, index); + } + + for (int index = 0; index < waitingCount; index++) { + createWaiting(ownerName, reservationCount + index); + } + + return null; + }); + } + + private void createWaitingRankFixture(final String ownerName) { + new TransactionTemplate(transactionManager).execute(status -> { + ThemeJpaEntity theme = themeJpaRepository.saveAndFlush( + ThemeJpaEntity.from(Theme.createNew( + ownerName + "-rank-theme", + "rank-description", + "rank-thumbnail" + )) + ); + ReservationTimeJpaEntity time = reservationTimeJpaRepository.saveAndFlush( + ReservationTimeJpaEntity.from( + ReservationTime.createNew(LocalTime.of(13, 30)) + ) + ); + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from( + new ReservationSlot( + LocalDate.of(2027, 11, 1), + theme.toDomain(), + time.toDomain() + ), + theme, + time + ) + ); + + createWaitingOnSlot(slot, "before-" + ownerName, LocalDateTime.of(2027, 9, 1, 14, 0)); + createWaitingOnSlot(slot, ownerName, LocalDateTime.of(2027, 9, 1, 14, 1)); + createWaitingOnSlot(slot, "after-" + ownerName, LocalDateTime.of(2027, 9, 1, 14, 2)); + + return null; + }); + } + + private ReservationJpaEntity createReservation(final String ownerName, final int offset) { + ThemeJpaEntity theme = themeJpaRepository.saveAndFlush( + ThemeJpaEntity.from(Theme.createNew( + ownerName + "-theme-" + offset, + "description-" + offset, + "thumbnail-" + offset + )) + ); + ReservationTimeJpaEntity time = reservationTimeJpaRepository.saveAndFlush( + ReservationTimeJpaEntity.from( + ReservationTime.createNew(LocalTime.of(10, 10 + offset)) + ) + ); + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from( + new ReservationSlot( + LocalDate.of(2027, 10, 1).plusDays(offset), + theme.toDomain(), + time.toDomain() + ), + theme, + time + ) + ); + return reservationJpaRepository.saveAndFlush( + ReservationJpaEntity.from( + Reservation.reserve( + ownerName, + slot.toDomain(), + LocalDateTime.of(2027, 9, 1, 12, 0).plusMinutes(offset) + ), + slot + ) + ); + } + + private ReservationWaitingJpaEntity createWaiting(final String ownerName, final int offset) { + ThemeJpaEntity theme = themeJpaRepository.saveAndFlush( + ThemeJpaEntity.from(Theme.createNew( + ownerName + "-theme-" + offset, + "description-" + offset, + "thumbnail-" + offset + )) + ); + ReservationTimeJpaEntity time = reservationTimeJpaRepository.saveAndFlush( + ReservationTimeJpaEntity.from( + ReservationTime.createNew(LocalTime.of(11, 10 + offset)) + ) + ); + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from( + new ReservationSlot( + LocalDate.of(2027, 10, 20).plusDays(offset), + theme.toDomain(), + time.toDomain() + ), + theme, + time + ) + ); + + ReservationWaiting waiting = ReservationWaiting.createNew( + slot.toDomain(), + ownerName, + LocalDateTime.of(2027, 9, 1, 13, 0).plusMinutes(offset) + ); + + return reservationWaitingJpaRepository.saveAndFlush( + ReservationWaitingJpaEntity.from(waiting, slot) + ); + } + + private ReservationWaitingJpaEntity createWaitingOnSlot( + final ReservationSlotJpaEntity slot, + final String ownerName, + final LocalDateTime requestedAt + ) { + ReservationWaiting waiting = ReservationWaiting.createNew( + slot.toDomain(), + ownerName, + requestedAt + ); + + return reservationWaitingJpaRepository.saveAndFlush( + ReservationWaitingJpaEntity.from(waiting, slot) + ); + } + + private String toDtoValue(final ReservationJpaEntity reservation) { + return reservation.getSlot().getTheme().getName() + ":" + reservation.getSlot().getTime().getStartAt(); + } + + private String toDtoValue(final ReservationWaitingJpaEntity waiting) { + return waiting.getSlot().getTheme().getName() + ":" + waiting.getSlot().getTime().getStartAt(); + } + + private Statistics statistics() { + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + return statistics; + } + + private synchronized String uniqueName(final String prefix) { + return prefix + "-" + nextSequence(); + } + + private static long sequence; + + private static synchronized long nextSequence() { + return sequence++; + } +} diff --git a/src/test/java/roomescape/persistence/PersistenceContextObservationTest.java b/src/test/java/roomescape/persistence/PersistenceContextObservationTest.java new file mode 100644 index 0000000000..2da322ca6a --- /dev/null +++ b/src/test/java/roomescape/persistence/PersistenceContextObservationTest.java @@ -0,0 +1,236 @@ +package roomescape.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import org.hibernate.LazyInitializationException; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.repository.reservation.jpa.ReservationJpaEntity; +import roomescape.repository.reservation.jpa.ReservationJpaRepository; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaEntity; +import roomescape.repository.reservationtime.jpa.ReservationTimeJpaRepository; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaEntity; +import roomescape.repository.reservationslot.jpa.ReservationSlotJpaRepository; +import roomescape.repository.theme.jpa.ThemeJpaEntity; +import roomescape.repository.theme.jpa.ThemeJpaRepository; + +@SpringBootTest +class PersistenceContextObservationTest { + + private static long sequence; + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ThemeJpaRepository themeJpaRepository; + + @Autowired + private ReservationTimeJpaRepository reservationTimeJpaRepository; + + @Autowired + private ReservationSlotJpaRepository reservationSlotJpaRepository; + + @Autowired + private ReservationJpaRepository reservationJpaRepository; + + @Test + void dirty_checking_updates_without_save() { + Long reservationId = createReservation("dc"); + Long nextSlotId = new TransactionTemplate(transactionManager).execute(status -> { + ReservationJpaEntity reservation = entityManager.find(ReservationJpaEntity.class, reservationId); + ThemeJpaEntity theme = reservation.getSlot().getTheme(); + ReservationTimeJpaEntity time = reservation.getSlot().getTime(); + + ReservationSlotJpaEntity nextSlot = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from( + new ReservationSlot( + LocalDate.of(2026, 8, 20), + theme.toDomain(), + time.toDomain() + ), + theme, + time + ) + ); + + reservation.setSlot(nextSlot); + return nextSlot.getId(); + }); + + Integer slotId = jdbcTemplate.queryForObject( + "select slot_id from reservation where id = ?", + Integer.class, + reservationId + ); + + assertThat(slotId).isEqualTo(nextSlotId.intValue()); + } + + @Test + void first_level_cache_hits_same_entity_in_same_transaction() { + Long reservationId = createReservation("cc"); + SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class); + Statistics statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + new TransactionTemplate(transactionManager).execute(status -> { + ReservationJpaEntity first = entityManager.find(ReservationJpaEntity.class, reservationId); + ReservationJpaEntity second = entityManager.find(ReservationJpaEntity.class, reservationId); + + assertThat(first).isSameAs(second); + return null; + }); + + assertThat(statistics.getPrepareStatementCount()).isEqualTo(1L); + } + + @Test + void write_behind_persists_on_flush_not_before() { + long current = nextSequence(); + String probeName = "write-behind-theme-" + current; + + new TransactionTemplate(transactionManager).execute(status -> { + entityManager.persist(new WriteBehindProbeEntity(current, probeName)); + + Integer beforeFlush = jdbcTemplate.queryForObject( + "select count(1) from write_behind_probe where name = ?", + Integer.class, + probeName + ); + + entityManager.flush(); + + Integer afterFlush = jdbcTemplate.queryForObject( + "select count(1) from write_behind_probe where name = ?", + Integer.class, + probeName + ); + + assertThat(beforeFlush).isZero(); + assertThat(afterFlush).isOne(); + return null; + }); + } + + @Test + void jpql_query_triggers_flush_before_execution() { + long current = nextSequence(); + String probeName = "jpql-flush-theme-" + current; + + new TransactionTemplate(transactionManager).execute(status -> { + entityManager.persist(new WriteBehindProbeEntity(current, probeName)); + + Long count = entityManager.createQuery( + "select count(t) from WriteBehindProbeEntity t where t.name = :name", + Long.class + ) + .setParameter("name", probeName) + .getSingleResult(); + + assertThat(count).isEqualTo(1L); + return null; + }); + } + + @Test + void lazy_initialization_exception_occurs_outside_transaction() { + Long reservationId = createReservation("lz"); + ReservationJpaEntity detachedReservation = new TransactionTemplate(transactionManager).execute(status -> + entityManager.find(ReservationJpaEntity.class, reservationId) + ); + + assertThatThrownBy(() -> detachedReservation.getSlot().getTime().getStartAt()) + .isInstanceOf(LazyInitializationException.class); + } + + @Test + void fetch_defaults_follow_jpa_annotation_defaults() throws Exception { + Field manyToOneField = FetchDefaultProbe.class.getDeclaredField("manyToOne"); + Field oneToManyField = FetchDefaultProbe.class.getDeclaredField("oneToMany"); + + assertThat(manyToOneField.getAnnotation(ManyToOne.class).fetch()) + .isEqualTo(jakarta.persistence.FetchType.EAGER); + assertThat(oneToManyField.getAnnotation(OneToMany.class).fetch()) + .isEqualTo(jakarta.persistence.FetchType.LAZY); + } + + private Long createReservation(final String reservationNamePrefix) { + return new TransactionTemplate(transactionManager).execute(status -> { + long current = nextSequence(); + String reservationName = reservationNamePrefix + "-" + current; + LocalDate date = LocalDate.of(2027, 8, 19).plusDays(current); + + ThemeJpaEntity theme = themeJpaRepository.findAll().get(0); + ReservationTimeJpaEntity time = reservationTimeJpaRepository.findAll().get(0); + ReservationSlotJpaEntity slot = reservationSlotJpaRepository.saveAndFlush( + ReservationSlotJpaEntity.from( + new ReservationSlot( + date, + theme.toDomain(), + time.toDomain() + ), + theme, + time + ) + ); + ReservationJpaEntity reservation = reservationJpaRepository.saveAndFlush( + ReservationJpaEntity.from( + Reservation.reserve( + reservationName, + slot.toDomain(), + LocalDateTime.of(2027, 8, 18, 12, 0).plusDays(current) + ), + slot + ) + ); + return reservation.getId(); + }); + } + + private synchronized long nextSequence() { + return sequence++; + } + + private static class FetchDefaultProbe { + @ManyToOne + @JoinColumn(name = "many_to_one_id") + private Object manyToOne; + + @OneToMany + private List oneToMany; + } + +} diff --git a/src/test/java/roomescape/persistence/WriteBehindProbeEntity.java b/src/test/java/roomescape/persistence/WriteBehindProbeEntity.java new file mode 100644 index 0000000000..9e334dbdd6 --- /dev/null +++ b/src/test/java/roomescape/persistence/WriteBehindProbeEntity.java @@ -0,0 +1,23 @@ +package roomescape.persistence; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "write_behind_probe") +public class WriteBehindProbeEntity { + + @Id + private Long id; + + private String name; + + protected WriteBehindProbeEntity() { + } + + public WriteBehindProbeEntity(final Long id, final String name) { + this.id = id; + this.name = name; + } +} diff --git a/src/test/java/roomescape/repository/JdbcReservationRepositoryTest.java b/src/test/java/roomescape/repository/JdbcReservationRepositoryTest.java index 94b74ed0da..0e0dee6bd9 100644 --- a/src/test/java/roomescape/repository/JdbcReservationRepositoryTest.java +++ b/src/test/java/roomescape/repository/JdbcReservationRepositoryTest.java @@ -11,23 +11,31 @@ 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.dao.DataIntegrityViolationException; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import roomescape.domain.reservation.Reservation; -import roomescape.domain.reservation.ReservationSlot; -import roomescape.repository.reservation.JdbcReservationRepository; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.repository.reservation.ReservationRepository; import roomescape.domain.reservationtime.ReservationTime; -import roomescape.repository.reservationtime.JdbcReservationTimeRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; import roomescape.domain.theme.Theme; -import roomescape.repository.theme.JdbcThemeRepository; +import roomescape.repository.theme.ThemeRepository; -@JdbcTest +@SpringBootTest class JdbcReservationRepositoryTest { - private JdbcReservationRepository jdbcReservationRepository; - private JdbcReservationTimeRepository jdbcReservationTimeRepository; - private JdbcThemeRepository jdbcThemeRepository; + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private ReservationSlotRepository reservationSlotRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private ThemeRepository themeRepository; @Autowired private JdbcTemplate jdbcTemplate; @@ -35,9 +43,6 @@ class JdbcReservationRepositoryTest { @BeforeEach void setup() { clearTables(); - jdbcReservationRepository = new JdbcReservationRepository(jdbcTemplate); - jdbcReservationTimeRepository = new JdbcReservationTimeRepository(jdbcTemplate); - jdbcThemeRepository = new JdbcThemeRepository(jdbcTemplate); } @Test @@ -50,12 +55,13 @@ void reservation_save_test() { LocalDateTime createdAt = LocalDateTime.parse("2023-08-04T12:00:00"); Theme theme = createTheme("미술관의 밤"); - ReservationTime reservationTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); + ReservationTime reservationTime = reservationTimeRepository.save(ReservationTime.createNew(time)); - Reservation reservation = Reservation.createNew(name, date, theme, reservationTime, createdAt); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + Reservation reservation = Reservation.reserve(name, slot, createdAt); //when - Reservation result = jdbcReservationRepository.save(reservation); - Reservation saved = jdbcReservationRepository.findById(result.getId()) + Reservation result = reservationRepository.save(reservation); + Reservation saved = reservationRepository.findById(result.getId()) .orElseThrow(); // then @@ -71,16 +77,13 @@ void reservation_save_duplicate_test() { LocalTime time = LocalTime.parse("10:00"); Theme theme = createTheme("미술관의 밤"); - ReservationTime reservationTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); + ReservationTime reservationTime = reservationTimeRepository.save(ReservationTime.createNew(time)); // when & then - assertThrows(DataIntegrityViolationException.class, () -> { - jdbcReservationRepository.save( - Reservation.createNew("쿠다", date, theme, reservationTime) - ); - jdbcReservationRepository.save( - Reservation.createNew("아루", date, theme, reservationTime) - ); + assertThrows(PersistenceConflictException.class, () -> { + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); + reservationRepository.save(Reservation.reserve("아루", slot, LocalDateTime.now())); }); } @@ -92,11 +95,12 @@ void reservation_findAll_test() { LocalTime time = LocalTime.parse("10:00"); Theme theme = createTheme("미술관의 밤"); - ReservationTime reservationTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); - jdbcReservationRepository.save(Reservation.createNew("쿠다", date, theme, reservationTime)); + ReservationTime reservationTime = reservationTimeRepository.save(ReservationTime.createNew(time)); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); //when - List reservations = jdbcReservationRepository.findAll(); + List reservations = reservationRepository.findAll(); //then assertThat(reservations.size()).isEqualTo(1); @@ -110,22 +114,23 @@ void reservation_delete_test() { LocalTime time = LocalTime.parse("10:00"); Theme theme = createTheme("미술관의 밤"); - ReservationTime reservationTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); + ReservationTime reservationTime = reservationTimeRepository.save(ReservationTime.createNew(time)); - jdbcReservationRepository.save(Reservation.createNew("쿠다", date, theme, reservationTime)); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); - int beforeSize = jdbcReservationRepository.findAll().size(); + int beforeSize = reservationRepository.findAll().size(); - Reservation reservation = jdbcReservationRepository.findAll() + Reservation reservation = reservationRepository.findAll() .stream() .findFirst() .orElseThrow(); // when - jdbcReservationRepository.delete(reservation); + reservationRepository.delete(reservation); // then - int afterSize = jdbcReservationRepository.findAll().size(); + int afterSize = reservationRepository.findAll().size(); assertThat(afterSize).isEqualTo(beforeSize - 1); } @@ -136,32 +141,78 @@ void findBySlot() { // given LocalDate date = LocalDate.parse("2026-08-06"); Theme theme = createTheme("미술관의 밤"); - ReservationTime reservationTime = jdbcReservationTimeRepository.save( + ReservationTime reservationTime = reservationTimeRepository.save( ReservationTime.createNew(LocalTime.parse("10:00")) ); - Reservation saved = jdbcReservationRepository.save( - Reservation.createNew("쿠다", date, theme, reservationTime) - ); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + Reservation saved = reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); // when - Reservation found = jdbcReservationRepository.findBySlot(new ReservationSlot(date, theme, reservationTime)) + Reservation found = reservationRepository.findBySlot(new ReservationSlot(date, theme, reservationTime)) .orElseThrow(); // then assertThat(found).isEqualTo(saved); } + @Test + @DisplayName("날짜와 테마로 예약 목록을 조회한다") + void findByDateAndTheme() { + // given + LocalDate date = LocalDate.parse("2026-08-06"); + Theme theme = createTheme("미술관의 밤"); + Theme otherTheme = createTheme("잠실의 밤"); + ReservationTime ten = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); + ReservationTime eleven = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, ten)); + ReservationSlot otherTimeSlot = reservationSlotRepository.save(new ReservationSlot(date, theme, eleven)); + ReservationSlot otherThemeSlot = reservationSlotRepository.save(new ReservationSlot(date, otherTheme, ten)); + Reservation expected = reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); + Reservation sameThemeReservation = reservationRepository.save( + Reservation.reserve("아루", otherTimeSlot, LocalDateTime.now()) + ); + reservationRepository.save(Reservation.reserve("도기", otherThemeSlot, LocalDateTime.now())); + + // when + List reservations = reservationRepository.findByDateAndTheme(date, theme); + + // then + assertThat(reservations).containsExactlyInAnyOrder(expected, sameThemeReservation); + } + + @Test + @DisplayName("예약 시간으로 예약 존재 여부를 확인한다") + void existsByTime() { + // given + LocalDate date = LocalDate.parse("2026-08-06"); + Theme theme = createTheme("미술관의 밤"); + ReservationTime reservedTime = reservationTimeRepository.save( + ReservationTime.createNew(LocalTime.parse("10:00")) + ); + ReservationTime emptyTime = reservationTimeRepository.save( + ReservationTime.createNew(LocalTime.parse("11:00")) + ); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservedTime)); + reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); + + // when & then + assertThat(reservationRepository.existsByTime(reservedTime)).isTrue(); + assertThat(reservationRepository.existsByTime(emptyTime)).isFalse(); + } + private void clearTables() { jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } private Theme createTheme(final String name) { - return jdbcThemeRepository.save( + return themeRepository.save( Theme.createNew(name, "추리 테마", "https://example.com/theme.png") ); } diff --git a/src/test/java/roomescape/repository/JdbcReservationSlotRepositoryTest.java b/src/test/java/roomescape/repository/JdbcReservationSlotRepositoryTest.java new file mode 100644 index 0000000000..7adcf9ce58 --- /dev/null +++ b/src/test/java/roomescape/repository/JdbcReservationSlotRepositoryTest.java @@ -0,0 +1,78 @@ +package roomescape.repository; + +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.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; +import roomescape.repository.reservationslot.ReservationSlotRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; +import roomescape.repository.theme.ThemeRepository; + +@SpringBootTest +class JdbcReservationSlotRepositoryTest { + + @Autowired + private ReservationSlotRepository reservationSlotRepository; + + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + clearTables(); + } + + @Test + @DisplayName("날짜와 테마로 슬롯을 조회한다") + void findByDateAndTheme() { + Theme theme = themeRepository.save( + Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png") + ); + Theme otherTheme = themeRepository.save( + Theme.createNew("심해 연구소", "잠수 테마", "https://example.com/other.png") + ); + ReservationTime ten = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); + ReservationTime eleven = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); + LocalDate date = LocalDate.parse("2026-08-06"); + + ReservationSlot first = reservationSlotRepository.save(new ReservationSlot(date, theme, ten)); + ReservationSlot second = reservationSlotRepository.save(new ReservationSlot(date, theme, eleven)); + reservationSlotRepository.save(new ReservationSlot(date.plusDays(1), theme, ten)); + reservationSlotRepository.save(new ReservationSlot(date, otherTheme, ten)); + + List slots = reservationSlotRepository.findByDateAndTheme(date, theme); + + assertThat(slots) + .extracting(ReservationSlot::getId) + .containsExactlyInAnyOrder(first.getId(), second.getId()); + } + + private void clearTables() { + jdbcTemplate.update("DELETE FROM reservation_waiting"); + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); + } +} diff --git a/src/test/java/roomescape/repository/JdbcReservationTimeRepositoryTest.java b/src/test/java/roomescape/repository/JdbcReservationTimeRepositoryTest.java index b02f79f499..c2af6e3e56 100644 --- a/src/test/java/roomescape/repository/JdbcReservationTimeRepositoryTest.java +++ b/src/test/java/roomescape/repository/JdbcReservationTimeRepositoryTest.java @@ -10,16 +10,16 @@ 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.dao.DataIntegrityViolationException; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import roomescape.domain.reservationtime.ReservationTime; -import roomescape.repository.reservationtime.JdbcReservationTimeRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; -@JdbcTest +@SpringBootTest class JdbcReservationTimeRepositoryTest { - private JdbcReservationTimeRepository jdbcReservationTimeRepository; + @Autowired + private ReservationTimeRepository reservationTimeRepository; @Autowired private JdbcTemplate jdbcTemplate; @@ -27,7 +27,6 @@ class JdbcReservationTimeRepositoryTest { @BeforeEach void setup() { clearTables(); - jdbcReservationTimeRepository = new JdbcReservationTimeRepository(jdbcTemplate); } @Test @@ -37,9 +36,9 @@ void reservationTime_findAll_test() { LocalTime time = LocalTime.parse("11:00"); ReservationTime nonIdReservationTime = ReservationTime.createNew(time); - jdbcReservationTimeRepository.save(nonIdReservationTime); + reservationTimeRepository.save(nonIdReservationTime); - Optional reservationTime = jdbcReservationTimeRepository.findAll() + Optional reservationTime = reservationTimeRepository.findAll() .stream() .findFirst(); //then @@ -54,8 +53,8 @@ void reservationTime_save_test() { ReservationTime nonIdReservationTime = ReservationTime.createNew(time); //when - ReservationTime result = jdbcReservationTimeRepository.save(nonIdReservationTime); - ReservationTime saved = jdbcReservationTimeRepository.findById(result.getId()) + ReservationTime result = reservationTimeRepository.save(nonIdReservationTime); + ReservationTime saved = reservationTimeRepository.findById(result.getId()) .orElseThrow(); //then assertThat(result).isEqualTo(saved); @@ -68,11 +67,11 @@ void save_duplicate_test() { LocalTime time = LocalTime.parse("11:00"); //when - jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); + reservationTimeRepository.save(ReservationTime.createNew(time)); //then - assertThrows(DataIntegrityViolationException.class, () -> { - jdbcReservationTimeRepository.save(ReservationTime.createNew(time)); + assertThrows(PersistenceConflictException.class, () -> { + reservationTimeRepository.save(ReservationTime.createNew(time)); }); } @@ -82,14 +81,14 @@ void reservationTime_delete_test() { // given LocalTime time = LocalTime.parse("11:00"); ReservationTime nonIdReservationTime = ReservationTime.createNew(time); - ReservationTime reservationTime = jdbcReservationTimeRepository.save(nonIdReservationTime); - int beforeSize = jdbcReservationTimeRepository.findAll().size(); + ReservationTime reservationTime = reservationTimeRepository.save(nonIdReservationTime); + int beforeSize = reservationTimeRepository.findAll().size(); // when - int affectedRowCount = jdbcReservationTimeRepository.deleteById(reservationTime.getId()); + int affectedRowCount = reservationTimeRepository.deleteById(reservationTime.getId()); // then - int afterSize = jdbcReservationTimeRepository.findAll().size(); + int afterSize = reservationTimeRepository.findAll().size(); assertThat(affectedRowCount).isOne(); assertThat(afterSize).isEqualTo(beforeSize - 1); @@ -99,7 +98,7 @@ void reservationTime_delete_test() { @DisplayName("존재하지 않는 예약 시간 ID는 삭제 건수가 0이다") void reservationTime_delete_not_found_test() { // when - int affectedRowCount = jdbcReservationTimeRepository.deleteById(999L); + int affectedRowCount = reservationTimeRepository.deleteById(999L); // then assertThat(affectedRowCount).isZero(); @@ -107,10 +106,12 @@ void reservationTime_delete_not_found_test() { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); - jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } } diff --git a/src/test/java/roomescape/repository/JdbcReservationWaitingRepositoryTest.java b/src/test/java/roomescape/repository/JdbcReservationWaitingRepositoryTest.java index d69d150730..bb32fd62ab 100644 --- a/src/test/java/roomescape/repository/JdbcReservationWaitingRepositoryTest.java +++ b/src/test/java/roomescape/repository/JdbcReservationWaitingRepositoryTest.java @@ -10,26 +10,37 @@ 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.dao.DataIntegrityViolationException; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.reservationwaiting.ReservationWaitingLine; import roomescape.domain.theme.Theme; -import roomescape.repository.reservation.JdbcReservationRepository; -import roomescape.repository.reservationtime.JdbcReservationTimeRepository; -import roomescape.repository.reservationwaiting.JdbcReservationWaitingRepository; -import roomescape.repository.theme.JdbcThemeRepository; +import roomescape.repository.reservation.ReservationRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; +import roomescape.repository.reservationwaiting.ReservationWaitingRepository; +import roomescape.repository.theme.ThemeRepository; -@JdbcTest +@SpringBootTest class JdbcReservationWaitingRepositoryTest { - private JdbcReservationWaitingRepository jdbcReservationWaitingRepository; - private JdbcReservationRepository jdbcReservationRepository; - private JdbcReservationTimeRepository jdbcReservationTimeRepository; - private JdbcThemeRepository jdbcThemeRepository; + @Autowired + private ReservationWaitingRepository reservationWaitingRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private ReservationSlotRepository reservationSlotRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; + + @Autowired + private ThemeRepository themeRepository; @Autowired private JdbcTemplate jdbcTemplate; @@ -37,10 +48,6 @@ class JdbcReservationWaitingRepositoryTest { @BeforeEach void setup() { clearTables(); - jdbcReservationWaitingRepository = new JdbcReservationWaitingRepository(jdbcTemplate); - jdbcReservationRepository = new JdbcReservationRepository(jdbcTemplate); - jdbcReservationTimeRepository = new JdbcReservationTimeRepository(jdbcTemplate); - jdbcThemeRepository = new JdbcThemeRepository(jdbcTemplate); } @Test @@ -48,16 +55,16 @@ void setup() { void save() { Reservation reservation = createReservation(); ReservationWaiting waiting = ReservationWaiting.createNew( - reservation, + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") ); - ReservationWaiting saved = jdbcReservationWaitingRepository.save(waiting); + ReservationWaiting saved = reservationWaitingRepository.save(waiting); assertThat(saved.getId()).isEqualTo(1L); - Long reservationId = jdbcTemplate.queryForObject( - "SELECT reservation_id FROM reservation_waiting WHERE id = ?", + Long slotId = jdbcTemplate.queryForObject( + "SELECT slot_id FROM reservation_waiting WHERE id = ?", Long.class, saved.getId() ); @@ -67,23 +74,23 @@ void save() { saved.getId() ); - assertThat(reservationId).isEqualTo(reservation.getId()); + assertThat(slotId).isEqualTo(reservation.getSlot().getId()); assertThat(name).isEqualTo("아루"); } @Test - @DisplayName("같은 예약에 같은 이름으로 중복 대기를 저장할 수 없다") - void saveDuplicateNameForReservation() { + @DisplayName("같은 슬롯에 같은 이름으로 중복 대기를 저장할 수 없다") + void saveDuplicateNameForSlot() { Reservation reservation = createReservation(); - jdbcReservationWaitingRepository.save(ReservationWaiting.createNew( - reservation, + reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )); - assertThrows(DataIntegrityViolationException.class, () -> jdbcReservationWaitingRepository.save( - ReservationWaiting.createNew(reservation, "아루", LocalDateTime.parse("2026-08-05T12:01:00")) + assertThrows(PersistenceConflictException.class, () -> reservationWaitingRepository.save( + ReservationWaiting.createNew(reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:01:00")) )); } @@ -91,18 +98,18 @@ void saveDuplicateNameForReservation() { @DisplayName("대기 ID로 예약 대기를 조회한다") void findById() { Reservation reservation = createReservation(); - ReservationWaiting saved = jdbcReservationWaitingRepository.save(ReservationWaiting.createNew( - reservation, + ReservationWaiting saved = reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )); - ReservationWaiting found = jdbcReservationWaitingRepository.findById(saved.getId()) + ReservationWaiting found = reservationWaitingRepository.findById(saved.getId()) .orElseThrow(); assertThat(found).isEqualTo(saved); assertThat(found.getName()).isEqualTo("아루"); - assertThat(found.getReservation()).isEqualTo(reservation); + assertThat(found.getSlot().getId()).isEqualTo(reservation.getSlot().getId()); assertThat(found.getRequestedAt()).isEqualTo(LocalDateTime.parse("2026-08-05T12:00:00")); } @@ -110,13 +117,13 @@ void findById() { @DisplayName("대기 ID로 예약 대기를 삭제한다") void deleteById() { Reservation reservation = createReservation(); - ReservationWaiting saved = jdbcReservationWaitingRepository.save(ReservationWaiting.createNew( - reservation, + ReservationWaiting saved = reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )); - jdbcReservationWaitingRepository.delete(saved); + reservationWaitingRepository.delete(saved); Integer count = jdbcTemplate.queryForObject( "SELECT count(1) FROM reservation_waiting WHERE id = ?", @@ -128,41 +135,67 @@ void deleteById() { } @Test - @DisplayName("예약의 대기 줄을 조회한다") - void findLineByReservation() { + @DisplayName("슬롯의 대기 줄을 조회한다") + void findLineBySlot() { Reservation reservation = createReservation(); - ReservationWaiting saved = jdbcReservationWaitingRepository.save(ReservationWaiting.createNew( - reservation, + ReservationWaiting saved = reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )); - ReservationWaitingLine waitingLine = jdbcReservationWaitingRepository.findLineByReservation(reservation); + ReservationWaitingLine waitingLine = reservationWaitingRepository.findLineBySlot(reservation.getSlot()); assertThat(waitingLine.isEmpty()).isFalse(); - assertThat(waitingLine.sequenceOf(saved.getId())).isOne(); + assertThat(waitingLine.indexOf(saved.getId())).hasValue(0); + } + + @Test + @DisplayName("대기 줄에서 첫 번째 대기를 찾는다") + void findLineBySlotFirst() { + Reservation reservation = createReservation(); + ReservationWaiting second = reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), + "도기", + LocalDateTime.parse("2026-08-05T12:01:00") + )); + ReservationWaiting first = reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), + "아루", + LocalDateTime.parse("2026-08-05T12:00:00") + )); + + ReservationWaiting found = reservationWaitingRepository.findLineBySlot(reservation.getSlot()) + .first() + .orElseThrow(); + + assertThat(found).isEqualTo(first); + assertThat(found).isNotEqualTo(second); } private Reservation createReservation() { - Theme theme = jdbcThemeRepository.save( + Theme theme = themeRepository.save( Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png") ); - ReservationTime reservationTime = jdbcReservationTimeRepository.save( + ReservationTime reservationTime = reservationTimeRepository.save( ReservationTime.createNew(LocalTime.parse("10:00")) ); - return jdbcReservationRepository.save( - Reservation.createNew("쿠다", LocalDate.parse("2026-08-06"), theme, reservationTime) + ReservationSlot slot = reservationSlotRepository.save( + new ReservationSlot(LocalDate.parse("2026-08-06"), theme, reservationTime) ); + return reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDateTime.now())); } private void clearTables() { jdbcTemplate.update("DELETE FROM reservation_waiting"); jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } diff --git a/src/test/java/roomescape/repository/JdbcThemeRepositoryTest.java b/src/test/java/roomescape/repository/JdbcThemeRepositoryTest.java index 81f9769ca5..8fc4f4b8aa 100644 --- a/src/test/java/roomescape/repository/JdbcThemeRepositoryTest.java +++ b/src/test/java/roomescape/repository/JdbcThemeRepositoryTest.java @@ -10,22 +10,26 @@ 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.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; -import roomescape.domain.reservation.Reservation; -import roomescape.exception.ConflictException; -import roomescape.repository.reservation.JdbcReservationRepository; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; -import roomescape.repository.reservationtime.JdbcReservationTimeRepository; import roomescape.domain.theme.Theme; -import roomescape.repository.theme.JdbcThemeRepository; +import roomescape.repository.reservationtime.ReservationTimeRepository; +import roomescape.repository.reservationslot.ReservationSlotRepository; +import roomescape.repository.theme.ThemeRepository; -@JdbcTest +@SpringBootTest class JdbcThemeRepositoryTest { - private JdbcThemeRepository jdbcThemeRepository; - private JdbcReservationRepository jdbcReservationRepository; - private JdbcReservationTimeRepository jdbcReservationTimeRepository; + @Autowired + private ThemeRepository themeRepository; + + @Autowired + private ReservationSlotRepository reservationSlotRepository; + + @Autowired + private ReservationTimeRepository reservationTimeRepository; @Autowired private JdbcTemplate jdbcTemplate; @@ -33,9 +37,6 @@ class JdbcThemeRepositoryTest { @BeforeEach void setup() { clearTables(); - jdbcThemeRepository = new JdbcThemeRepository(jdbcTemplate); - jdbcReservationRepository = new JdbcReservationRepository(jdbcTemplate); - jdbcReservationTimeRepository = new JdbcReservationTimeRepository(jdbcTemplate); } @Test @@ -45,8 +46,8 @@ void theme_save_test() { Theme theme = Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png"); // when - Theme result = jdbcThemeRepository.save(theme); - Theme saved = jdbcThemeRepository.findById(result.getId()) + Theme result = themeRepository.save(theme); + Theme saved = themeRepository.findById(result.getId()) .orElseThrow(); // then @@ -57,10 +58,10 @@ void theme_save_test() { @DisplayName("테마 전체 조회") void theme_findAll_test() { // given - jdbcThemeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); + themeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); // when - List themes = jdbcThemeRepository.findAll(); + List themes = themeRepository.findAll(); // then assertThat(themes).hasSize(1); @@ -70,11 +71,11 @@ void theme_findAll_test() { @DisplayName("테마 이름 중복 저장 예외") void theme_save_duplicate_test() { // given - jdbcThemeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); + themeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); // when & then - assertThrows(ConflictException.class, () -> - jdbcThemeRepository.save(Theme.createNew("미술관의 밤", "새 설명", "https://example.com/new-theme.png")) + assertThrows(PersistenceConflictException.class, () -> + themeRepository.save(Theme.createNew("미술관의 밤", "새 설명", "https://example.com/new-theme.png")) ); } @@ -82,16 +83,16 @@ void theme_save_duplicate_test() { @DisplayName("테마 삭제") void theme_delete_test() { // given - Theme saved = jdbcThemeRepository.save( + Theme saved = themeRepository.save( Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png") ); - int beforeSize = jdbcThemeRepository.findAll().size(); + int beforeSize = themeRepository.findAll().size(); // when - int affectedRowCount = jdbcThemeRepository.deleteById(saved.getId()); + int affectedRowCount = themeRepository.deleteById(saved.getId()); // then - int afterSize = jdbcThemeRepository.findAll().size(); + int afterSize = themeRepository.findAll().size(); assertThat(affectedRowCount).isOne(); assertThat(afterSize).isEqualTo(beforeSize - 1); } @@ -100,7 +101,7 @@ void theme_delete_test() { @DisplayName("존재하지 않는 테마 ID는 삭제 건수가 0이다") void theme_delete_not_found_test() { // when - int affectedRowCount = jdbcThemeRepository.deleteById(999L); + int affectedRowCount = themeRepository.deleteById(999L); // then assertThat(affectedRowCount).isZero(); @@ -110,11 +111,11 @@ void theme_delete_not_found_test() { @DisplayName("테마 이름 존재 여부 확인") void theme_existsByName_test() { // given - jdbcThemeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); + themeRepository.save(Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png")); // when - boolean exists = jdbcThemeRepository.existsByName("미술관의 밤"); - boolean notExists = jdbcThemeRepository.existsByName("놀이공원 탈출"); + boolean exists = themeRepository.existsByName("미술관의 밤"); + boolean notExists = themeRepository.existsByName("놀이공원 탈출"); // then assertThat(exists).isTrue(); @@ -129,21 +130,21 @@ void findPopularThemes_test() { Theme secondTheme = createTheme("심해 연구소"); Theme thirdTheme = createTheme("폐병원 탈출"); - ReservationTime firstThemeTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); - ReservationTime secondThemeTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); - ReservationTime thirdThemeTime = jdbcReservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("12:00"))); + ReservationTime firstThemeTime = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); + ReservationTime secondThemeTime = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); + ReservationTime thirdThemeTime = reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("12:00"))); - jdbcReservationRepository.save(createHistoricalReservation("쿠다", today.minusDays(1), firstTheme, firstThemeTime)); - jdbcReservationRepository.save(createHistoricalReservation("아루", today.minusDays(2), firstTheme, firstThemeTime)); - jdbcReservationRepository.save(createHistoricalReservation("도기", today.minusDays(3), firstTheme, firstThemeTime)); + insertHistoricalReservation("쿠다", today.minusDays(1), firstTheme, firstThemeTime); + insertHistoricalReservation("아루", today.minusDays(2), firstTheme, firstThemeTime); + insertHistoricalReservation("도기", today.minusDays(3), firstTheme, firstThemeTime); - jdbcReservationRepository.save(createHistoricalReservation("포비", today.minusDays(1), secondTheme, secondThemeTime)); - jdbcReservationRepository.save(createHistoricalReservation("솔라", today.minusDays(2), secondTheme, secondThemeTime)); + insertHistoricalReservation("포비", today.minusDays(1), secondTheme, secondThemeTime); + insertHistoricalReservation("솔라", today.minusDays(2), secondTheme, secondThemeTime); - jdbcReservationRepository.save(createHistoricalReservation("레오", today.minusDays(1), thirdTheme, thirdThemeTime)); - jdbcReservationRepository.save(createHistoricalReservation("오래된예약", today.minusDays(10), thirdTheme, thirdThemeTime)); + insertHistoricalReservation("레오", today.minusDays(1), thirdTheme, thirdThemeTime); + insertHistoricalReservation("오래된예약", today.minusDays(10), thirdTheme, thirdThemeTime); - List popularThemes = jdbcThemeRepository.findPopularThemes(7, 2); + List popularThemes = themeRepository.findPopularThemes(7, 2); assertThat(popularThemes).hasSize(2); assertThat(popularThemes.get(0).getId()).isEqualTo(firstTheme.getId()); @@ -152,25 +153,33 @@ void findPopularThemes_test() { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } private Theme createTheme(final String name) { - return jdbcThemeRepository.save( + return themeRepository.save( Theme.createNew(name, "추리 테마", "https://example.com/theme.png") ); } - private Reservation createHistoricalReservation( + private void insertHistoricalReservation( final String name, final LocalDate date, final Theme theme, final ReservationTime reservationTime ) { - return Reservation.createNew(name, date, theme, reservationTime, date.minusDays(1).atStartOfDay()); + ReservationSlot slot = reservationSlotRepository.save(new ReservationSlot(date, theme, reservationTime)); + jdbcTemplate.update( + "INSERT INTO reservation (name, slot_id, created_at) VALUES (?, ?, ?)", + name, + slot.getId(), + date.minusDays(1).atStartOfDay() + ); } } diff --git a/src/test/java/roomescape/reservation/ReservationApiTest.java b/src/test/java/roomescape/reservation/ReservationApiTest.java index f78d508f94..f48608d54e 100644 --- a/src/test/java/roomescape/reservation/ReservationApiTest.java +++ b/src/test/java/roomescape/reservation/ReservationApiTest.java @@ -48,6 +48,7 @@ void setup() { createTheme(); createReservationTime("15:40"); LocalDate futureDate = LocalDate.now().plusDays(1); + insertReservationSlot(futureDate, 1L, 1L); Map reservation = new HashMap<>(); reservation.put("name", "브라운"); @@ -105,7 +106,7 @@ void setup() { jdbcTemplate.update("INSERT INTO theme (id, name, description, thumbnail_url) VALUES (?, ?, ?, ?)", 1L, "미술관의 밤", "추리 테마", "https://example.com/theme.png"); jdbcTemplate.update("INSERT INTO reservation_time (id, start_at) VALUES (?, ?)", 1L, "15:40:00"); - jdbcTemplate.update("INSERT INTO reservation (name, date, theme_id, time_id) VALUES (?, ?, ?, ?)", "브라운", "2023-08-05", 1L, 1L); + insertReservation("브라운", LocalDate.parse("2023-08-05"), 1L, 1L); List> reservations = RestAssured.given().log().all() .when().get("/reservations") @@ -124,6 +125,7 @@ void setup() { createTheme(); createReservationTime("10:00"); LocalDate futureDate = LocalDate.now().plusDays(1); + insertReservationSlot(futureDate, 1L, 1L); Map reservation = new HashMap<>(); reservation.put("name", "브라운"); @@ -170,11 +172,38 @@ void setup() { assertThat(count).isEqualTo(1); } + @Test + void 예약_삭제_시_첫_번째_대기가_예약으로_전환된다() { + createTheme(); + createReservationTime("10:00"); + LocalDate futureDate = LocalDate.now().plusDays(1); + createReservation("브라운", futureDate.toString(), 1L, 1L); + insertReservationWaiting(1L, "아루", "2026-06-07 12:00:00"); + insertReservationWaiting(1L, "도기", "2026-06-07 12:01:00"); + + RestAssured.given().log().all() + .queryParam("name", "브라운") + .when().delete("/reservations/1") + .then().log().all() + .statusCode(204); + + Integer reservationCount = jdbcTemplate.queryForObject("SELECT count(1) FROM reservation", Integer.class); + String reservationName = jdbcTemplate.queryForObject("SELECT name FROM reservation", String.class); + Integer waitingCount = jdbcTemplate.queryForObject("SELECT count(1) FROM reservation_waiting", Integer.class); + String waitingName = jdbcTemplate.queryForObject("SELECT name FROM reservation_waiting", String.class); + + assertThat(reservationCount).isEqualTo(1); + assertThat(reservationName).isEqualTo("아루"); + assertThat(waitingCount).isEqualTo(1); + assertThat(waitingName).isEqualTo("도기"); + } + @Test void 예약과_시간_연결() { createTheme(); createReservationTime("10:00"); LocalDate futureDate = LocalDate.now().plusDays(1); + insertReservationSlot(futureDate, 1L, 1L); Map reservation = new HashMap<>(); reservation.put("name", "브라운"); @@ -208,6 +237,7 @@ void setup() { LocalDate futureDate = LocalDate.now().plusDays(1); createReservation("브라운", futureDate.toString(), 1L, 1L); + insertReservationSlot(futureDate, 1L, 2L); RestAssured.given().log().all() .queryParam("date", futureDate.toString()) @@ -277,6 +307,8 @@ void setup() { @Test void 예약_가능_시간_조회_시_날짜_형식이_잘못되면_400을_반환한다() { + createTheme(); + RestAssured.given().log().all() .queryParam("date", "2026/05/20") .when().get("/themes/1/times/available") @@ -349,6 +381,8 @@ private void createReservationTime(final String startAt) { } private void createReservation(final String name, final String date, final Long themeId, final Long timeId) { + insertReservationSlot(LocalDate.parse(date), themeId, timeId); + Map reservation = new HashMap<>(); reservation.put("name", name); reservation.put("date", date); @@ -364,20 +398,50 @@ private void createReservation(final String name, final String date, final Long } private void insertReservation(final String name, final LocalDate date, final Long themeId, final Long timeId) { + Long slotId = insertReservationSlot(date, themeId, timeId); + jdbcTemplate.update( - "INSERT INTO reservation (name, date, theme_id, time_id) VALUES (?, ?, ?, ?)", + "INSERT INTO reservation (name, slot_id) VALUES (?, ?)", name, + slotId + ); + } + + private Long insertReservationSlot(final LocalDate date, final Long themeId, final Long timeId) { + jdbcTemplate.update( + "INSERT INTO reservation_slot (date, theme_id, time_id) VALUES (?, ?, ?)", + java.sql.Date.valueOf(date), + themeId, + timeId + ); + + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation_slot WHERE date = ? AND theme_id = ? AND time_id = ?", + Long.class, java.sql.Date.valueOf(date), themeId, timeId ); } + private void insertReservationWaiting(final Long slotId, final String name, final String requestedAt) { + jdbcTemplate.update( + "INSERT INTO reservation_waiting (slot_id, name, requested_at) VALUES (?, ?, ?)", + slotId, + name, + requestedAt + ); + } + private void clearTables() { + jdbcTemplate.update("DELETE FROM reservation_waiting"); jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); + jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } diff --git a/src/test/java/roomescape/reservation/ReservationPageApiTest.java b/src/test/java/roomescape/reservation/ReservationPageApiTest.java index f5d29bd36e..4bd35510e7 100644 --- a/src/test/java/roomescape/reservation/ReservationPageApiTest.java +++ b/src/test/java/roomescape/reservation/ReservationPageApiTest.java @@ -74,6 +74,7 @@ void setup() { createReservation("brown", LocalDate.now().plusDays(1), 1L, 1L); LocalDate changedDate = LocalDate.now().plusDays(2); + createReservationSlot(changedDate, 1L, 2L); RestAssured.given().log().all() .redirects().follow(false) @@ -87,11 +88,21 @@ void setup() { .header("Location", containsString("reservationName=brown")); String updatedDate = jdbcTemplate.queryForObject( - "SELECT date FROM reservation WHERE id = 1", + """ + SELECT rs.date + FROM reservation r + JOIN reservation_slot rs ON r.slot_id = rs.id + WHERE r.id = 1 + """, String.class ); Long updatedTimeId = jdbcTemplate.queryForObject( - "SELECT time_id FROM reservation WHERE id = 1", + """ + SELECT rs.time_id + FROM reservation r + JOIN reservation_slot rs ON r.slot_id = rs.id + WHERE r.id = 1 + """, Long.class ); @@ -154,7 +165,7 @@ void setup() { .header("Location", containsString("reservationName=aru")); Integer count = jdbcTemplate.queryForObject( - "SELECT count(1) FROM reservation_waiting WHERE reservation_id = 1 AND name = 'aru'", + "SELECT count(1) FROM reservation_waiting WHERE slot_id = 1 AND name = 'aru'", Integer.class ); org.assertj.core.api.Assertions.assertThat(count).isOne(); @@ -203,9 +214,30 @@ private void createReservation( final long themeId, final long timeId ) { + Long slotId = createReservationSlot(date, themeId, timeId); + jdbcTemplate.update( - "INSERT INTO reservation (name, date, theme_id, time_id) VALUES (?, ?, ?, ?)", + "INSERT INTO reservation (name, slot_id) VALUES (?, ?)", name, + slotId + ); + } + + private Long createReservationSlot( + final LocalDate date, + final long themeId, + final long timeId + ) { + jdbcTemplate.update( + "INSERT INTO reservation_slot (date, theme_id, time_id) VALUES (?, ?, ?)", + Date.valueOf(date), + themeId, + timeId + ); + + return jdbcTemplate.queryForObject( + "SELECT id FROM reservation_slot WHERE date = ? AND theme_id = ? AND time_id = ?", + Long.class, Date.valueOf(date), themeId, timeId @@ -215,10 +247,12 @@ private void createReservation( private void clearTables() { jdbcTemplate.update("DELETE FROM reservation_waiting"); jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } diff --git a/src/test/java/roomescape/reservation/ReservationServiceTest.java b/src/test/java/roomescape/reservation/ReservationServiceTest.java index 8797dc748a..1362ae010c 100644 --- a/src/test/java/roomescape/reservation/ReservationServiceTest.java +++ b/src/test/java/roomescape/reservation/ReservationServiceTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.theme.Theme; @@ -19,11 +20,13 @@ import roomescape.service.reservationtime.ReservationTimeService; import roomescape.service.theme.ThemeService; import roomescape.support.FakeReservationRepository; +import roomescape.support.FakeReservationSlotRepository; import roomescape.support.FakeReservationTimeRepository; import roomescape.support.FakeReservationWaitingRepository; import roomescape.support.FakeThemeRepository; class ReservationServiceTest { + private static final LocalDateTime REQUESTED_AT = LocalDateTime.parse("2026-08-05T12:00:00"); @Test @DisplayName("예약을 저장한다") @@ -32,8 +35,9 @@ void save() { Theme theme = fixture.saveTheme(); ReservationTime time = fixture.saveTime("10:00"); LocalDate date = LocalDate.parse("2026-08-06"); + fixture.saveSlot(date, theme, time); - Reservation saved = fixture.reservationService.save("쿠다", date, theme.getId(), time.getId()); + Reservation saved = fixture.reservationService.save("쿠다", date, theme.getId(), time.getId(), REQUESTED_AT); assertThat(saved.getId()).isEqualTo(1L); assertThat(fixture.reservationRepository.findById(saved.getId())).contains(saved); @@ -46,11 +50,12 @@ void saveDuplicateReservation() { Theme theme = fixture.saveTheme(); ReservationTime time = fixture.saveTime("10:00"); LocalDate date = LocalDate.parse("2026-08-06"); - fixture.reservationService.save("쿠다", date, theme.getId(), time.getId()); + fixture.saveSlot(date, theme, time); + fixture.reservationService.save("쿠다", date, theme.getId(), time.getId(), REQUESTED_AT); assertThrows( ConflictException.class, - () -> fixture.reservationService.save("아루", date, theme.getId(), time.getId()) + () -> fixture.reservationService.save("아루", date, theme.getId(), time.getId(), REQUESTED_AT) ); } @@ -61,10 +66,11 @@ void savePastDate() { Theme theme = fixture.saveTheme(); ReservationTime time = fixture.saveTime("10:00"); LocalDate pastDate = LocalDate.now().minusDays(1); + fixture.saveSlot(pastDate, theme, time); assertThrows( InvalidInputException.class, - () -> fixture.reservationService.save("쿠다", pastDate, theme.getId(), time.getId()) + () -> fixture.reservationService.save("쿠다", pastDate, theme.getId(), time.getId(), REQUESTED_AT) ); } @@ -76,10 +82,11 @@ void savePastTimeToday() { LocalTime now = LocalTime.now().withSecond(0).withNano(0); LocalTime pastTime = now.equals(LocalTime.MIDNIGHT) ? now : now.minusMinutes(1); ReservationTime time = fixture.reservationTimeRepository.save(ReservationTime.createNew(pastTime)); + fixture.saveSlot(LocalDate.now(), theme, time); assertThrows( InvalidInputException.class, - () -> fixture.reservationService.save("쿠다", LocalDate.now(), theme.getId(), time.getId()) + () -> fixture.reservationService.save("쿠다", LocalDate.now(), theme.getId(), time.getId(), LocalDateTime.now()) ); } @@ -89,7 +96,7 @@ void deleteById() { Fixture fixture = new Fixture(); Reservation reservation = fixture.saveReservation("쿠다", "2026-08-06", "10:00"); - fixture.reservationService.deleteById(reservation.getId()); + fixture.reservationService.deleteById(reservation.getId(), REQUESTED_AT); assertThat(fixture.reservationRepository.findById(reservation.getId())).isEmpty(); } @@ -99,21 +106,33 @@ void deleteById() { void deleteByIdNotFound() { Fixture fixture = new Fixture(); - assertThrows(ResourceNotFoundException.class, () -> fixture.reservationService.deleteById(1L)); + assertThrows(ResourceNotFoundException.class, () -> fixture.reservationService.deleteById(1L, REQUESTED_AT)); } @Test - @DisplayName("예약 대기가 존재하는 예약은 삭제할 수 없다") + @DisplayName("예약을 삭제하면 첫 번째 대기가 예약으로 전환된다") void deleteByIdWithWaiting() { Fixture fixture = new Fixture(); Reservation reservation = fixture.saveReservation("쿠다", "2026-08-06", "10:00"); - fixture.reservationWaitingRepository.save(ReservationWaiting.createNew( - reservation, + ReservationWaiting firstWaiting = fixture.reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), "아루", LocalDateTime.parse("2026-08-05T12:00:00") )); + ReservationWaiting secondWaiting = fixture.reservationWaitingRepository.save(ReservationWaiting.createNew( + reservation.getSlot(), + "도기", + LocalDateTime.parse("2026-08-05T12:01:00") + )); + + fixture.reservationService.deleteById(reservation.getId(), REQUESTED_AT); - assertThrows(ConflictException.class, () -> fixture.reservationService.deleteById(reservation.getId())); + assertThat(fixture.reservationRepository.findById(reservation.getId())).isEmpty(); + assertThat(fixture.reservationRepository.findAll()) + .extracting(Reservation::getName) + .containsExactly("아루"); + assertThat(fixture.reservationWaitingRepository.findById(firstWaiting.getId())).isEmpty(); + assertThat(fixture.reservationWaitingRepository.findById(secondWaiting.getId())).contains(secondWaiting); } @Test @@ -124,7 +143,7 @@ void deleteByIdAndNamePastReservation() { assertThrows( ConflictException.class, - () -> fixture.reservationService.deleteByIdAndName(reservation.getId(), "쿠다") + () -> fixture.reservationService.deleteByIdAndName(reservation.getId(), "쿠다", LocalDateTime.now()) ); } @@ -136,7 +155,7 @@ void deleteByIdAndNameMismatch() { assertThrows( ResourceNotFoundException.class, - () -> fixture.reservationService.deleteByIdAndName(reservation.getId(), "아루") + () -> fixture.reservationService.deleteByIdAndName(reservation.getId(), "아루", REQUESTED_AT) ); } @@ -146,12 +165,14 @@ void updateByIdAndName() { Fixture fixture = new Fixture(); Reservation reservation = fixture.saveReservation("쿠다", "2026-08-06", "10:00"); ReservationTime secondTime = fixture.saveTime("11:00"); + fixture.saveSlot(LocalDate.parse("2026-08-07"), reservation.getTheme(), secondTime); Reservation updated = fixture.reservationService.updateByIdAndName( reservation.getId(), "쿠다", LocalDate.parse("2026-08-07"), - secondTime.getId() + secondTime.getId(), + REQUESTED_AT ); assertThat(updated.getDate()).isEqualTo(LocalDate.parse("2026-08-07")); @@ -165,7 +186,14 @@ void updateByIdAndNameDuplicate() { Fixture fixture = new Fixture(); Reservation reservation = fixture.saveReservation("쿠다", "2026-08-06", "10:00"); ReservationTime secondTime = fixture.saveTime("11:00"); - fixture.reservationService.save("아루", LocalDate.parse("2026-08-06"), reservation.getTheme().getId(), secondTime.getId()); + fixture.saveSlot(LocalDate.parse("2026-08-06"), reservation.getTheme(), secondTime); + fixture.reservationService.save( + "아루", + LocalDate.parse("2026-08-06"), + reservation.getTheme().getId(), + secondTime.getId(), + REQUESTED_AT + ); assertThrows( ConflictException.class, @@ -173,7 +201,8 @@ void updateByIdAndNameDuplicate() { reservation.getId(), "쿠다", LocalDate.parse("2026-08-06"), - secondTime.getId() + secondTime.getId(), + REQUESTED_AT ) ); } @@ -191,7 +220,8 @@ void updateByIdAndNamePastReservation() { reservation.getId(), "쿠다", LocalDate.now().plusDays(1), - secondTime.getId() + secondTime.getId(), + LocalDateTime.now() ) ); } @@ -199,16 +229,19 @@ void updateByIdAndNamePastReservation() { private static class Fixture { private final FakeThemeRepository themeRepository = new FakeThemeRepository(); private final FakeReservationRepository reservationRepository = new FakeReservationRepository(); + private final FakeReservationSlotRepository reservationSlotRepository = new FakeReservationSlotRepository(); private final FakeReservationWaitingRepository reservationWaitingRepository = new FakeReservationWaitingRepository(); private final FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); private final ThemeService themeService = new ThemeService(themeRepository, reservationRepository); private final ReservationTimeService reservationTimeService = new ReservationTimeService( reservationTimeRepository, reservationRepository, + reservationSlotRepository, themeService ); private final ReservationService reservationService = new ReservationService( reservationRepository, + reservationSlotRepository, reservationWaitingRepository, reservationTimeService, themeService @@ -230,21 +263,26 @@ private Reservation saveReservation(final String name, final String date, final .findFirst() .orElseGet(this::saveTheme); ReservationTime time = saveTime(startAt); + saveSlot(LocalDate.parse(date), theme, time); + + return reservationService.save(name, LocalDate.parse(date), theme.getId(), time.getId(), REQUESTED_AT); + } - return reservationService.save(name, LocalDate.parse(date), theme.getId(), time.getId()); + private ReservationSlot saveSlot(final LocalDate date, final Theme theme, final ReservationTime time) { + return reservationSlotRepository.save(new ReservationSlot(date, theme, time)); } private Reservation savePastReservation() { Theme theme = saveTheme(); ReservationTime time = saveTime("10:00"); - return reservationRepository.save(Reservation.of( + ReservationSlot slot = saveSlot(LocalDate.now().minusDays(1), theme, time); + Reservation pastReservation = new Reservation( 1L, "쿠다", - LocalDate.now().minusDays(1), - theme, - time, + slot, LocalDateTime.now().minusDays(2) - )); + ); + return reservationRepository.save(pastReservation); } } } diff --git a/src/test/java/roomescape/reservationslot/ReservationSlotAdminApiTest.java b/src/test/java/roomescape/reservationslot/ReservationSlotAdminApiTest.java new file mode 100644 index 0000000000..f05ac9774b --- /dev/null +++ b/src/test/java/roomescape/reservationslot/ReservationSlotAdminApiTest.java @@ -0,0 +1,126 @@ +package roomescape.reservationslot; + +import static org.hamcrest.Matchers.is; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +class ReservationSlotAdminApiTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @LocalServerPort + private int port; + + @BeforeEach + void setup() { + RestAssured.port = port; + clearTables(); + } + + @Test + void 예약_슬롯_관리_API() { + long themeId = insertTheme(); + long timeId = insertReservationTime(); + Map slot = new HashMap<>(); + slot.put("date", "2026-08-06"); + slot.put("themeId", themeId); + slot.put("timeId", timeId); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(slot) + .when().post("/admin/reservation-slots") + .then().log().all() + .statusCode(201) + .body("id", is(1)) + .body("date", is("2026-08-06")) + .body("theme.id", is((int) themeId)) + .body("time.id", is((int) timeId)); + + RestAssured.given().log().all() + .when().get("/admin/reservation-slots") + .then().log().all() + .statusCode(200) + .body("size()", is(1)); + } + + @Test + void 같은_날짜_테마_시간의_예약_슬롯은_중복_생성할_수_없다() { + long themeId = insertTheme(); + long timeId = insertReservationTime(); + Map slot = new HashMap<>(); + slot.put("date", "2026-08-06"); + slot.put("themeId", themeId); + slot.put("timeId", timeId); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(slot) + .when().post("/admin/reservation-slots") + .then().log().all() + .statusCode(201); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(slot) + .when().post("/admin/reservation-slots") + .then().log().all() + .statusCode(409) + .body("code", is("RESERVATION_SLOT_DUPLICATED")) + .body("status", is(409)); + } + + @Test + void 예약_슬롯_추가_시_날짜가_없으면_400을_반환한다() { + Map slot = new HashMap<>(); + slot.put("themeId", insertTheme()); + slot.put("timeId", insertReservationTime()); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(slot) + .when().post("/admin/reservation-slots") + .then().log().all() + .statusCode(400) + .body("code", is("INVALID_INPUT")) + .body("status", is(400)); + } + + private long insertTheme() { + jdbcTemplate.update( + "INSERT INTO theme (id, name, description, thumbnail_url) VALUES (1, '미술관의 밤', '추리 테마', 'https://example.com/theme.png')" + ); + return 1L; + } + + private long insertReservationTime() { + jdbcTemplate.update("INSERT INTO reservation_time (id, start_at) VALUES (1, '10:00:00')"); + return 1L; + } + + private void clearTables() { + jdbcTemplate.update("DELETE FROM reservation_waiting"); + jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); + jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("DELETE FROM theme"); + jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); + } +} diff --git a/src/test/java/roomescape/reservationtime/ReservationTimeAdminApiTest.java b/src/test/java/roomescape/reservationtime/ReservationTimeAdminApiTest.java index dc2784261c..befa1d0725 100644 --- a/src/test/java/roomescape/reservationtime/ReservationTimeAdminApiTest.java +++ b/src/test/java/roomescape/reservationtime/ReservationTimeAdminApiTest.java @@ -87,7 +87,10 @@ void setup() { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); + jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); } } diff --git a/src/test/java/roomescape/reservationtime/ReservationTimeServiceTest.java b/src/test/java/roomescape/reservationtime/ReservationTimeServiceTest.java index 702244fd13..6d7b03a908 100644 --- a/src/test/java/roomescape/reservationtime/ReservationTimeServiceTest.java +++ b/src/test/java/roomescape/reservationtime/ReservationTimeServiceTest.java @@ -5,11 +5,13 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; import roomescape.exception.ConflictException; @@ -17,10 +19,12 @@ import roomescape.service.reservationtime.ReservationTimeService; import roomescape.service.theme.ThemeService; import roomescape.support.FakeReservationRepository; +import roomescape.support.FakeReservationSlotRepository; import roomescape.support.FakeReservationTimeRepository; import roomescape.support.FakeThemeRepository; class ReservationTimeServiceTest { + private static final LocalDateTime REQUESTED_AT = LocalDateTime.parse("2026-08-05T12:00:00"); @Test @DisplayName("예약 시간을 저장한다") @@ -50,9 +54,19 @@ void findAvailableTimes() { ReservationTime ten = fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); ReservationTime eleven = fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); LocalDate date = LocalDate.parse("2026-08-06"); - fixture.reservationRepository.save(Reservation.createNew("쿠다", date, theme, ten)); + ReservationSlot reservedSlot = fixture.saveSlot(date, theme, ten); + fixture.saveSlot(date, theme, eleven); + fixture.reservationRepository.save(Reservation.reserve( + "쿠다", + reservedSlot, + LocalDate.now().atStartOfDay() + )); - List availableTimes = fixture.reservationTimeService.findAvailableTimes(date, theme.getId()); + List availableTimes = fixture.reservationTimeService.findAvailableTimes( + date, + theme.getId(), + REQUESTED_AT + ); assertThat(availableTimes) .extracting(ReservationTime::getId) @@ -65,10 +79,16 @@ void findAvailableTimesInPastDate() { Fixture fixture = new Fixture(); Theme theme = fixture.saveTheme(); LocalDate pastDate = LocalDate.now().minusDays(1); - fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); - fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); + ReservationTime ten = fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); + ReservationTime eleven = fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("11:00"))); + fixture.saveSlot(pastDate, theme, ten); + fixture.saveSlot(pastDate, theme, eleven); - List availableTimes = fixture.reservationTimeService.findAvailableTimes(pastDate, theme.getId()); + List availableTimes = fixture.reservationTimeService.findAvailableTimes( + pastDate, + theme.getId(), + LocalDateTime.now() + ); assertThat(availableTimes).isEmpty(); } @@ -85,8 +105,14 @@ void findAvailableTimesTodayExcludesPastTimes() { LocalTime futureTime = now.plusMinutes(1); ReservationTime past = fixture.reservationTimeRepository.save(ReservationTime.createNew(pastTime)); ReservationTime future = fixture.reservationTimeRepository.save(ReservationTime.createNew(futureTime)); + fixture.saveSlot(LocalDate.now(), theme, past); + fixture.saveSlot(LocalDate.now(), theme, future); - List availableTimes = fixture.reservationTimeService.findAvailableTimes(LocalDate.now(), theme.getId()); + List availableTimes = fixture.reservationTimeService.findAvailableTimes( + LocalDate.now(), + theme.getId(), + LocalDateTime.now() + ); assertThat(availableTimes) .extracting(ReservationTime::getId) @@ -100,12 +126,8 @@ void deleteById() { Fixture fixture = new Fixture(); Theme theme = fixture.saveTheme(); ReservationTime time = fixture.reservationTimeRepository.save(ReservationTime.createNew(LocalTime.parse("10:00"))); - fixture.reservationRepository.save(Reservation.createNew( - "쿠다", - LocalDate.parse("2026-08-06"), - theme, - time - )); + ReservationSlot slot = new ReservationSlot(LocalDate.parse("2026-08-06"), theme, time); + fixture.reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDate.now().atStartOfDay())); assertThrows(ConflictException.class, () -> fixture.reservationTimeService.deleteById(time.getId())); } @@ -153,11 +175,13 @@ void getByIdNotFound() { private static class Fixture { private final FakeThemeRepository themeRepository = new FakeThemeRepository(); private final FakeReservationRepository reservationRepository = new FakeReservationRepository(); + private final FakeReservationSlotRepository reservationSlotRepository = new FakeReservationSlotRepository(); private final FakeReservationTimeRepository reservationTimeRepository = new FakeReservationTimeRepository(); private final ThemeService themeService = new ThemeService(themeRepository, reservationRepository); private final ReservationTimeService reservationTimeService = new ReservationTimeService( reservationTimeRepository, reservationRepository, + reservationSlotRepository, themeService ); @@ -166,5 +190,9 @@ private Theme saveTheme() { Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png") ); } + + private ReservationSlot saveSlot(final LocalDate date, final Theme theme, final ReservationTime time) { + return reservationSlotRepository.save(new ReservationSlot(date, theme, time)); + } } } diff --git a/src/test/java/roomescape/reservationwaiting/ReservationWaitingApiTest.java b/src/test/java/roomescape/reservationwaiting/ReservationWaitingApiTest.java index 97dc91e9a5..78585adecc 100644 --- a/src/test/java/roomescape/reservationwaiting/ReservationWaitingApiTest.java +++ b/src/test/java/roomescape/reservationwaiting/ReservationWaitingApiTest.java @@ -55,13 +55,12 @@ void createReservationWaiting() throws Exception { .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.name").value("아루")) .andExpect(jsonPath("$.requestedAt", notNullValue())) - .andExpect(jsonPath("$.reservationResponse.id").value(1)) - .andExpect(jsonPath("$.reservationResponse.name").value("쿠다")) - .andExpect(jsonPath("$.reservationResponse.theme.id").value(1)) - .andExpect(jsonPath("$.reservationResponse.time.id").value(1)); + .andExpect(jsonPath("$.slotResponse.id").value(1)) + .andExpect(jsonPath("$.slotResponse.theme.id").value(1)) + .andExpect(jsonPath("$.slotResponse.time.id").value(1)); Integer waitingCount = jdbcTemplate.queryForObject( - "SELECT count(1) FROM reservation_waiting WHERE reservation_id = 1 AND name = '아루'", + "SELECT count(1) FROM reservation_waiting WHERE slot_id = 1 AND name = '아루'", Integer.class ); assert waitingCount != null; @@ -191,10 +190,12 @@ void deleteReservationWaitingNotFound() throws Exception { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation_waiting"); jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE reservation_waiting ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); } @@ -225,20 +226,25 @@ private void createReservation( final long timeId ) { jdbcTemplate.update( - "INSERT INTO reservation (id, name, date, theme_id, time_id) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO reservation_slot (id, date, theme_id, time_id) VALUES (?, ?, ?, ?)", id, - name, date, themeId, timeId ); + jdbcTemplate.update( + "INSERT INTO reservation (id, name, slot_id) VALUES (?, ?, ?)", + id, + name, + id + ); } - private void createReservationWaiting(final long id, final long reservationId, final String name) { + private void createReservationWaiting(final long id, final long slotId, final String name) { jdbcTemplate.update( - "INSERT INTO reservation_waiting (id, reservation_id, name, requested_at) VALUES (?, ?, ?, ?)", + "INSERT INTO reservation_waiting (id, slot_id, name, requested_at) VALUES (?, ?, ?, ?)", id, - reservationId, + slotId, name, "2026-08-05 12:00:00" ); diff --git a/src/test/java/roomescape/support/FakeReservationRepository.java b/src/test/java/roomescape/support/FakeReservationRepository.java index ae0a4dfb7a..5716a2b88d 100644 --- a/src/test/java/roomescape/support/FakeReservationRepository.java +++ b/src/test/java/roomescape/support/FakeReservationRepository.java @@ -1,5 +1,6 @@ package roomescape.support; +import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -7,7 +8,9 @@ import java.util.Objects; import java.util.Optional; import roomescape.domain.reservation.Reservation; -import roomescape.domain.reservation.ReservationSlot; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.reservationtime.ReservationTime; +import roomescape.domain.theme.Theme; import roomescape.repository.reservation.ReservationRepository; public class FakeReservationRepository implements ReservationRepository { @@ -29,12 +32,29 @@ public Optional findById(final long id) { public Optional findBySlot(final ReservationSlot slot) { return reservations.values() .stream() - .filter(reservation -> Objects.equals(reservation.getDate(), slot.date())) - .filter(reservation -> Objects.equals(reservation.getTheme(), slot.theme())) - .filter(reservation -> Objects.equals(reservation.getTime(), slot.time())) + .filter(reservation -> Objects.equals(reservation.getDate(), slot.getDate())) + .filter(reservation -> Objects.equals(reservation.getTheme(), slot.getTheme())) + .filter(reservation -> Objects.equals(reservation.getTime(), slot.getTime())) .findFirst(); } + @Override + public List findByDateAndTheme(final LocalDate date, final Theme theme) { + return reservations.values() + .stream() + .filter(reservation -> Objects.equals(reservation.getDate(), date)) + .filter(reservation -> Objects.equals(reservation.getTheme(), theme)) + .toList(); + } + + @Override + public boolean existsByTime(final ReservationTime time) { + return reservations.values() + .stream() + .map(Reservation::getTime) + .anyMatch(reservationTime -> Objects.equals(reservationTime, time)); + } + @Override public Reservation save(final Reservation reservation) { Reservation saved = reservation; diff --git a/src/test/java/roomescape/support/FakeReservationSlotRepository.java b/src/test/java/roomescape/support/FakeReservationSlotRepository.java new file mode 100644 index 0000000000..bad99efbfc --- /dev/null +++ b/src/test/java/roomescape/support/FakeReservationSlotRepository.java @@ -0,0 +1,59 @@ +package roomescape.support; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import roomescape.domain.reservationslot.ReservationSlot; +import roomescape.domain.theme.Theme; +import roomescape.repository.reservationslot.ReservationSlotRepository; + +public class FakeReservationSlotRepository implements ReservationSlotRepository { + + private final Map slots = new LinkedHashMap<>(); + private long sequence = 1L; + + @Override + public List findAll() { + return new ArrayList<>(slots.values()); + } + + @Override + public Optional findById(final long slotId) { + return Optional.ofNullable(slots.get(slotId)); + } + + @Override + public Optional findBySlot(final ReservationSlot reservationSlot) { + return slots.values() + .stream() + .filter(slot -> Objects.equals(slot.getDate(), reservationSlot.getDate())) + .filter(slot -> Objects.equals(slot.getTheme(), reservationSlot.getTheme())) + .filter(slot -> Objects.equals(slot.getTime(), reservationSlot.getTime())) + .findFirst(); + } + + @Override + public List findByDateAndTheme(final LocalDate date, final Theme theme) { + return slots.values() + .stream() + .filter(slot -> Objects.equals(slot.getDate(), date)) + .filter(slot -> Objects.equals(slot.getTheme(), theme)) + .toList(); + } + + @Override + public ReservationSlot save(final ReservationSlot reservationSlot) { + ReservationSlot saved = new ReservationSlot( + sequence++, + reservationSlot.getDate(), + reservationSlot.getTheme(), + reservationSlot.getTime() + ); + slots.put(saved.getId(), saved); + return saved; + } +} diff --git a/src/test/java/roomescape/support/FakeReservationWaitingRepository.java b/src/test/java/roomescape/support/FakeReservationWaitingRepository.java index 532f850b82..a9ccea2b74 100644 --- a/src/test/java/roomescape/support/FakeReservationWaitingRepository.java +++ b/src/test/java/roomescape/support/FakeReservationWaitingRepository.java @@ -1,9 +1,11 @@ package roomescape.support; +import java.util.List; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; -import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationwaiting.ReservationWaiting; import roomescape.domain.reservationwaiting.ReservationWaitingLine; import roomescape.repository.reservationwaiting.ReservationWaitingRepository; @@ -13,6 +15,11 @@ public class FakeReservationWaitingRepository implements ReservationWaitingRepos private final Map waitings = new LinkedHashMap<>(); private long sequence = 1L; + @Override + public List findAll() { + return List.copyOf(waitings.values()); + } + @Override public ReservationWaiting save(final ReservationWaiting reservationWaiting) { ReservationWaiting saved = reservationWaiting; @@ -30,10 +37,10 @@ public Optional findById(final Long id) { } @Override - public ReservationWaitingLine findLineByReservation(final Reservation reservation) { + public ReservationWaitingLine findLineBySlot(final ReservationSlot slot) { return ReservationWaitingLine.fromWaitings(waitings.values() .stream() - .filter(waiting -> waiting.getReservation().equals(reservation)) + .filter(waiting -> Objects.equals(waiting.getSlot().getId(), slot.getId())) .toList()); } diff --git a/src/test/java/roomescape/theme/ThemeAdminApiTest.java b/src/test/java/roomescape/theme/ThemeAdminApiTest.java index dc10bd5a8e..4abb1c96c1 100644 --- a/src/test/java/roomescape/theme/ThemeAdminApiTest.java +++ b/src/test/java/roomescape/theme/ThemeAdminApiTest.java @@ -90,10 +90,12 @@ void setup() { private void clearTables() { jdbcTemplate.update("DELETE FROM reservation"); + jdbcTemplate.update("DELETE FROM reservation_slot"); jdbcTemplate.update("DELETE FROM reservation_time"); jdbcTemplate.update("DELETE FROM theme"); jdbcTemplate.update("ALTER TABLE theme ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation_time ALTER COLUMN id RESTART WITH 1"); + jdbcTemplate.update("ALTER TABLE reservation_slot ALTER COLUMN id RESTART WITH 1"); jdbcTemplate.update("ALTER TABLE reservation ALTER COLUMN id RESTART WITH 1"); } } diff --git a/src/test/java/roomescape/theme/ThemeServiceTest.java b/src/test/java/roomescape/theme/ThemeServiceTest.java index 86434a28c8..d86c2a756a 100644 --- a/src/test/java/roomescape/theme/ThemeServiceTest.java +++ b/src/test/java/roomescape/theme/ThemeServiceTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import roomescape.domain.reservation.Reservation; +import roomescape.domain.reservationslot.ReservationSlot; import roomescape.domain.reservationtime.ReservationTime; import roomescape.domain.theme.Theme; import roomescape.exception.ConflictException; @@ -70,12 +71,8 @@ void deleteById() { Theme.createNew("미술관의 밤", "추리 테마", "https://example.com/theme.png") ); ReservationTime time = ReservationTime.of(1L, LocalTime.parse("10:00")); - fixture.reservationRepository.save(Reservation.createNew( - "쿠다", - LocalDate.parse("2026-08-06"), - theme, - time - )); + ReservationSlot slot = new ReservationSlot(LocalDate.parse("2026-08-06"), theme, time); + fixture.reservationRepository.save(Reservation.reserve("쿠다", slot, LocalDate.now().atStartOfDay())); assertThrows(ConflictException.class, () -> fixture.themeService.deleteById(theme.getId())); }