Skip to content

Latest commit

 

History

History
221 lines (156 loc) · 15.3 KB

File metadata and controls

221 lines (156 loc) · 15.3 KB

방탈출 예약 시스템

방탈출 카페의 예약을 관리하고, 사용자가 직접 예약할 수 있는 웹 애플리케이션.

미션 진화

  • [미션 1] 방탈출 예약 관리 — 관리자가 전화·현장 예약을 등록·관리하는 백엔드 API
  • [미션 2 - 사이클 1] 방탈출 사용자 예약 — 사용자가 브라우저에서 직접 예약하는 서비스로 확장
  • [미션 2 - 사이클 2] 예약 변경/취소와 에러 처리 — 서비스 정책 적용, 의도된 에러 응답, 본인 예약 조회/변경/취소
  • [미션 3 - 사이클 1] 예약 대기 — 신청/취소/조회 기능 구현, 예약/대기 상태 구분

도메인 모델

예약(Reservation)과 대기(Waiting)를 별도 도메인으로 분리.
두 객체는 슬롯(date + time_id + theme_id)을 공유하되 서로의 id는 모른다.
대기는 "특정 예약"이 아니라 "슬롯이라는 자리"를 기다린다고 보았기 때문.

  • Reservation — 확정된 예약. 슬롯의 권리자.
  • Waiting — 그 슬롯을 기다리는 줄. 순번을 가진다.
  • Waitings — 대기들의 일급 컬렉션. 순번 부여·중복 검증·재정렬 같은 비즈니스 규칙을 소유한다.

예약이 취소되면 슬롯이 비고, 첫 대기가 예약으로 승격된다.
reservation 테이블에는 UNIQUE(date, time_id, theme_id), waiting 테이블에는 UNIQUE(date, time_id, theme_id, name)(같은 사람 중복 대기 방어)과 UNIQUE(date, time_id, theme_id, order_index)(순번 충돌 방어)를 둔다. 전자는 모델이 바뀌어도 남는 도메인 불변식, 후자는 저장된 순번 모델에 종속된 제약이다.

통합 모델(status 컬럼)·절충 모델(FK 참조) 대신 분리 모델을 택한 자세한 근거는 PR 본문에 포함.

테스트 구조

테스트를 "범위의 크기"가 아니라 '검증 대상'과 '협력 범위' 두 축으로 나눴다.
각 계층은 서로 다른 종류의 신뢰를 책임지며, 같은 것을 두 층에서 중복 검증하지 않는 것을 원칙으로 한다.

패키지 검증 대상 협력 범위 도구
domain/ 도메인 규칙·정합성 객체 단위 순수 JUnit
domain/policy/ 입력으로 결정되는 규칙 정책 + 주입 Clock 순수 JUnit
application/ 상태 의존 비즈니스 규칙 스프링 + H2 @SpringBootTest
adapter/persistence/ 직접 작성한 SQL JdbcTemplate + H2 @JdbcTest
adapter/web/ 입력 검증·예외 변환 웹 계층 @WebMvcTest
acceptance/ 사용자 시나리오 HTTP 전체 RestAssured
  • 순번 규칙 같은 입력 기반 규칙은 domain/의 단위 테스트에서 완결한다.
  • 중복 예약 거부, 예약 취소 → 대기 승격처럼 상태에 의존하는 규칙은 application/의 통합 테스트에서 본다.
  • 직접 작성한 SQL의 정렬·필터·제약은 adapter/persistence/의 슬라이스 테스트에서 검증한다.
  • 격리는 @Transactional 롤백이 아니라 명시적 cleanup(DatabaseCleaner)으로 처리한다.

각 테스트의 계층 선택 근거와 "무엇을 일부러 검증하지 않았는가"는 PR의 코드 셀프 코멘트를 참고.

API 명세

관리자 API (/admin/...)

예약 관리

기능 Method / URL 요청 본문 응답
예약 조회 GET /admin/reservations - [{id, name, date, time, theme}, ...]
예약 추가 POST /admin/reservations {name, date, timeId, themeId} {id, name, date, time, theme}
예약 삭제 DELETE /admin/reservations/{id} - 204 No Content

시간 관리

기능 Method / URL 요청 본문 응답
시간 조회 GET /admin/times - [{id, startAt}, ...]
시간 추가 POST /admin/times {startAt} {id, startAt}
시간 삭제 DELETE /admin/times/{id} - 204 No Content

테마 관리

기능 Method / URL 요청 본문 응답
테마 조회 GET /admin/themes - [{id, name, description, thumbnail}, ...]
테마 추가 POST /admin/themes {name, description, thumbnail} {id, name, description, thumbnail}
테마 삭제 DELETE /admin/themes/{id} - 204 No Content

사용자 API (/user/...)

사용자 흐름: 테마를 본다 → 날짜를 고른다 → 그 조건에서 예약 가능한 시간을 본다 → 시간을 골라 예약한다.

기능 Method / URL 요청 본문 응답
테마 목록 조회 GET /user/themes - [{id, name, description, thumbnail}, ...]
예약 가능 시간 조회 GET /user/themes/{themeId}/available-times?date=YYYY-MM-DD - [{id, startAt}, ...]
사용자 예약 추가 POST /user/reservations {name, date, timeId, themeId} {id, name, date, time, theme}
인기 테마 조회 GET /user/themes/popular - [{id, name, description, thumbnail, reservationCount}, ...]
내 예약 조회 GET /user/reservations?name={name} - [{id, name, date, time, theme, status, waitingOrder}, ...]
예약 대기 신청 POST /user/waitings {name, date, timeId, themeId} {id, name, date, time, theme, order}
내 예약 변경 PATCH /user/reservations/{id} {name, date, timeId} {id, name, date, time, theme}
내 예약 취소 DELETE /user/reservations/{id} {name} 204 No Content
내 대기 취소 DELETE /user/waitings/{id} {name} 204 No Content

에러 응답 명세

형식

모든 에러 응답은 동일한 본문 형식을 가진다.

{
  "message": "..."
}
  • 단일 필드: 클라이언트는 단 하나의 파싱 규칙으로 모든 에러에 대응할 수 있다.
  • 메시지에 다음 행동 포함: 사용자가 응답만 보고 다음 행동을 결정할 수 있도록 작성한다.
  • 노출 금지: 스택 트레이스, DB 메시지, 서버 내부 경로, 타임스탬프는 응답에 포함하지 않는다 (서버 로그로만 분리).

상태 코드 정책

상황 상태 코드 비고
비즈니스 규칙 위반 400 중복 예약, 과거 시점, 이미 지난 예약 취소/변경 등
유효하지 않은 입력값 400 빈 이름, 잘못된 날짜 형식, 30자 초과 이름 등
리소스 부재 404 존재하지 않는 예약/시간/테마, 남의 예약 접근 시도
예측하지 못한 서버 에러 500 사용자에게는 노출되지 않도록 fallback 메시지로 대체

409, 422를 사용하지 않는다. 토론에서 합의된 원칙. 충돌이나 비즈니스 규칙 불일치를 어떤 상황으로 볼지 사람마다 해석이 갈려, 400으로 통일하고 메시지로 의도를 전달한다.

케이스별 응답

예약 생성 (POST /user/reservations)

케이스 상태 코드 메시지
과거 날짜·시간 400 "지나간 날짜, 시간으로는 예약할 수 없습니다."
중복 예약 (같은 날짜+시간+테마) 400 "해당 시간은 이미 예약되었습니다. 다른 시간을 선택해 주세요."
존재하지 않는 시간 ID 404 "존재하지 않는 시간입니다."
존재하지 않는 테마 ID 404 "존재하지 않는 테마입니다."
빈 이름 400 "예약자 이름은 비어 있을 수 없습니다."
이름 30자 초과 400 "예약자 이름은 30자를 초과할 수 없습니다."
날짜 형식 오류 400 "날짜 형식이 올바르지 않습니다. (예: 2026-05-15)"

예약 변경 (PATCH /user/reservations/{id})

케이스 상태 코드 메시지
존재하지 않거나 본인 예약이 아님 404 "존재하지 않는 예약입니다."
이미 지난 예약 변경 시도 400 "이미 지난 예약은 변경할 수 없습니다."
변경하려는 시간이 다른 사람에 의해 예약됨 400 "해당 시간은 이미 예약되었습니다. 다른 시간을 선택해 주세요."
변경하려는 시간이 과거 400 "지나간 날짜·시간으로는 변경할 수 없습니다."

같은 시간으로의 변경은 허용된다 (자기 자신과는 충돌하지 않음).

예약 취소 (DELETE /user/reservations/{id})

케이스 상태 코드 메시지
존재하지 않거나 본인 예약이 아님 404 "존재하지 않는 예약입니다."
이미 지난 예약 취소 시도 400 "이미 지난 예약은 취소할 수 없습니다."

대기 신청 (POST /user/waitings)

케이스 상태 코드 메시지
예약 가능한 슬롯에 대기 신청 400 "예약 가능한 시간입니다. 대기가 아닌 예약을 신청해 주세요."
본인이 이미 예약한 슬롯에 대기 신청 400 "이미 본인이 예약한 시간에는 대기를 신청할 수 없습니다."
같은 슬롯에 중복 대기 신청 400 "이미 해당 시간에 대기 신청한 내역이 있습니다."
존재하지 않는 시간/테마 404 "존재하지 않는 시간입니다." / "존재하지 않는 테마입니다."

시간·테마 삭제

케이스 상태 코드 메시지
예약이 존재하는 시간 삭제 400 "예약이 존재하는 시간은 삭제할 수 없습니다."
예약이 존재하는 테마 삭제 400 "예약이 존재하는 테마는 삭제할 수 없습니다."

미션 3 사이클 1 — 완료 기능

Phase 1 — 예약 대기 신청/취소

  • 이미 다른 사용자에 의해 예약된 슬롯(날짜+시간+테마)에 대기를 신청할 수 있다.
    • 같은 슬롯에 대한 대기는 신청 순서대로 순번이 부여된다.
    • 같은 사용자가 같은 슬롯에 중복 대기할 수 없다.
  • 사용자는 본인의 대기를 취소할 수 있다.

Phase 2 — 내 예약 목록 조회 (상태 구분)

  • 이전 미션의 내 예약 목록 조회를 확장한다.
  • 사용자의 예약과 대기가 상태로 구분되어 함께 표시된다.
  • 대기에는 본인의 대기 순번도 함께 보여준다.

Phase 3 — 화면 & 테스트

  • 예약 페이지(reservations.html)에 예약/대기 상태 렌더링
  • 내 예약 페이지(my-reservations.html) 예약/대기 상태 렌더링
  • 각 케이스 요구사항 테스트 추가

트랜잭션 경계와 그 근거

두 흐름을 각각 하나의 트랜잭션으로 묶는다.
묶는 기준은 "부분 실패 시 어떤 깨진 상태가 남는가"로 도출. 어느 단계가 실패해도 데이터 모순 + 사용자에게 보이는 깨진 약속이 남으면, 그 작업들은 함께여야 한다.

예약 취소 → 자동 승격 (deleteByOwner , 두 테이블 4종 변경)

[1] reservation DELETE(취소자) → [2] reservation INSERT(승격자) → [3] waiting DELETE(승격된 1번) → [4] order_index UPDATE×N(나머지 당기기)

  • [2] 실패([1]만 성공) — 취소자 예약은 사라졌는데 승격이 안 됨 → 슬롯이 텅 빔. 1번 대기자는 목록엔 "대기 1번"인데 슬롯은 예약 가능 상태 → 누구나 새로 예약해 기존 1번을 추월. 가장 망가지는 시나리오.
  • [3] 실패([1,2] 성공) — 승격자가 예약이 됐는데 그의 대기 ROW가 안 지워짐 → 예약자이면서 동시에 대기 1번. 내 목록에 같은 슬롯이 두 줄로 중복 노출.
  • [4] 실패([1,2,3] 성공) — 승격은 됐지만 뒷순번이 안 당겨짐 → 순번에 구멍(1 없이 2,3만). 이후 그 슬롯 대기 신청이 충돌(500).

→ 셋 다 데이터 모순 + 깨진 약속이므로 4종을 한 경계로 묶는다.

대기 취소 → 순번 재정렬 (cancelByOwner · waiting 한 테이블 2종 변경)

[1] waiting DELETE(취소 대상) → [2] order_index UPDATE×N(뒤 순번 당기기)

  • [2] 실패([1]만 성공) — 대기는 빠졌는데 재정렬이 안 됨 → 순번에 구멍([1,2,3]에서 2 취소 시 [1,3]). 3번이던 사람이 계속 "대기 3번"인데 앞엔 1명뿐 → 표시 순번과 실제 줄 길이 불일치. 이후 그 슬롯 신청이 충돌(500).

→ 2종을 한 경계로 묶는다.

검증

이 깨진 상태들은 각 단계가 실패할 때 생기지만 모두 하나의 @Transactional 경계가 막는다. 따라서 검증은 쌓인 변경이 가장 많은 4 실패 하나로 대표하고(같은 회귀를 중복 검증하지 않음), 나머지 끝 상태는 이 문서로 남긴다. → ReservationPromotionTransactionTest, WaitingCancelTransactionTest.