-
Notifications
You must be signed in to change notification settings - Fork 1
Entity Design
- 전체 엔티티 구조와 관계
- 정규화 원칙 적용 범위
- Reservation의 고의적 반정규화 (스냅샷 패턴)와 그 근거
- CASCADE 삭제 전략 결정
erDiagram
User {
uuid id PK
string name
string email
string phoneNumber
}
Business {
uuid id PK
}
BusinessCategory {
uuid id PK
uuid business_id FK
string categoryName
string businessType
}
BusinessHours {
uuid id PK
uuid business_id FK
enum dayOfWeek
time openTime
time closeTime
boolean isClosed
}
OperatingHours {
uuid id PK
uuid business_id FK
enum dayOfWeek
time openTime
time closeTime
boolean isClosed
integer sequence
}
Menu {
uuid id PK
uuid business_id FK
uuid business_category_id FK
string serviceName
integer price
integer durationMinutes
enum orderType
boolean isActive
}
BookingSlot {
uuid id PK
uuid business_id FK
uuid menu_id FK
date slotDate
time startTime
time endTime
boolean isAvailable
}
Reservation {
uuid id PK
uuid customer_id FK
uuid business_id FK
uuid menu_id FK
uuid booking_slot_id FK
date reservationDate
time reservationTime
integer reservationPrice
integer reservationDuration
string customerName
string customerPhone
enum status
}
Review {
uuid id PK
uuid business_id FK
uuid user_id FK
uuid reservation_id FK
string menuName
integer rating
datetime deletedAt
}
Wishlist {
uuid id PK
uuid user_id FK
uuid business_id FK
}
User ||--o{ Reservation : "예약"
User ||--o{ Review : "작성"
User ||--o{ Wishlist : "찜"
Business ||--o{ BusinessCategory : "카테고리"
Business ||--o{ BusinessHours : "요일별 영업시간"
Business ||--o{ OperatingHours : "예약가능시간대"
Business ||--o{ Menu : "메뉴"
Business ||--o{ BookingSlot : "슬롯"
Business ||--o{ Reservation : "예약받음"
Business ||--o{ Review : "리뷰받음"
Business ||--o{ Wishlist : "찜받음"
BusinessCategory ||--o{ Menu : "분류"
Menu ||--o{ BookingSlot : "슬롯생성"
BookingSlot ||--o| Reservation : "예약됨"
Reservation ||--o| Review : "리뷰"
Timefit의 기본 설계 원칙은 3NF(제3정규형) 준수입니다. 각 테이블은 하나의 개념만 담으며, 반복 그룹 제거(1NF), 부분 함수 종속 제거(2NF), 이행 함수 종속 제거(3NF)를 목표로 합니다.
BusinessHours와 OperatingHours의 역할 분리
예약 시스템에서 "영업시간"을 단일 테이블로 관리하면 두 가지 다른 개념이 뒤섞입니다.
문제 상황:
하나의 operating_hours 테이블에 모두 저장 시
→ "업체가 월요일 09:00~18:00 운영"과
"월요일 09:00~12:00 예약 받음, 13:00~18:00 예약 받음"을
같은 컬럼으로 표현 불가
→ 점심 브레이크타임 표현을 위해 null 처리나 별도 플래그 남발
두 엔티티의 역할을 명확히 분리했습니다.
| 엔티티 | 역할 | 제약 |
|---|---|---|
BusinessHours |
업체의 전체 영업 가능 범위 (고객에게 표시되는 영업시간) | 요일당 1개, unique(business_id, day_of_week)
|
OperatingHours |
실제 예약을 받을 세부 시간대 (BookingSlot 생성 기준) | 요일당 N개 허용, sequence로 순서 관리 |
// BusinessHours — 요일당 1개 (UniqueConstraint 보장)
@Table(
name = "business_hours",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_business_hours_business_day",
columnNames = {"business_id", "day_of_week"}
)
}
)
public class BusinessHours extends BaseEntity {
private DayOfWeek dayOfWeek;
private LocalTime openTime; // 전체 영업 시작
private LocalTime closeTime; // 전체 영업 종료
private Boolean isClosed; // 휴무일 여부
}
// OperatingHours — 요일당 N개 (sequence로 순서 보장)
public class OperatingHours extends BaseEntity {
private DayOfWeek dayOfWeek;
private LocalTime openTime; // 예약 받을 시작 시간
private LocalTime closeTime; // 예약 받을 종료 시간
private Boolean isClosed;
private Integer sequence; // 같은 요일 내 순서 (브레이크타임 표현)
}실제 점심 브레이크타임 표현 예시:
BusinessHours (월요일): 09:00 ~ 18:00 (영업 범위)
└── OperatingHours sequence=0: 09:00 ~ 12:00 (오전 예약 가능)
└── OperatingHours sequence=1: 13:00 ~ 18:00 (오후 예약 가능)
→ 12:00~13:00은 등록하지 않으면 자동으로 예약 불가 구간
계층적 제약: OperatingHours ⊆ BusinessHours
OperatingHours 등록·수정 시 반드시 해당 요일의 BusinessHours 범위 안에 있어야 합니다. 이 검증은 OperatingHoursValidator에서 담당합니다.
검증 조건:
OperatingHours.openTime ≥ BusinessHours.openTime
OperatingHours.closeTime ≤ BusinessHours.closeTime
위반 시: "예약 시간대는 영업 시간(09:00~18:00) 내에 있어야 합니다."
이 두 계층이 분리되어 있기 때문에 BusinessHours를 축소할 때 기존 OperatingHours가 범위를 벗어나는지도 검증할 수 있습니다. 단일 테이블로 관리했다면 이 계층 간 제약 자체를 표현할 수단이 없었습니다.
Business - BusinessCategory - Menu 계층 분리
업체 정보, 카테고리(분류), 메뉴(서비스 상품)를 별도 테이블로 분리했습니다. 하나의 Menu 테이블에 카테고리 정보를 컬럼으로 넣으면 카테고리 이름 변경 시 모든 관련 메뉴 레코드를 갱신해야 합니다. BusinessCategory를 독립 엔티티로 두어 카테고리 변경이 단일 레코드 수정으로 완결됩니다.
Menu의 OrderType 이분화
Menu를 RESERVATION_BASED(사전 예약형)와 ONDEMAND_BASED(즉시 주문형)로 구분합니다. 두 타입은 근본적으로 다른 처리 흐름을 가집니다.
RESERVATION_BASED → Menu 생성 → BookingSlot 생성 → 슬롯 기반 예약
ONDEMAND_BASED → Menu 생성 → 즉시 예약 (BookingSlot 없음)
public enum OrderType {
RESERVATION_BASED, // 시간대 기반, capacity 제한, BookingSlot 필수
ONDEMAND_BASED // 즉시 주문, capacity 무제한, BookingSlot 불필요
}OrderType 생성 후 변경을 막은 이유: RESERVATION_BASED를 ONDEMAND_BASED로 바꾸면 이미 생성된 BookingSlot, 연결된 Reservation 처리가 복잡해집니다. 타입 변경이 필요한 경우 기존 메뉴를 비활성화하고 새 메뉴를 생성하도록 강제합니다.
BookingSlot의 독립성
예약 가능한 시간 슬롯을 Menu에서 분리해 별도 테이블로 관리합니다. Menu에 시간 정보를 넣으면 같은 서비스의 다른 시간대를 표현하기 위해 중복 레코드가 필요합니다. BookingSlot은 날짜/시간 정보만 담고 Menu를 참조해 1:N 관계를 명확히 분리합니다.
BookingSlot 자동 생성 체인
BookingSlot은 세 엔티티의 교차점에서 생성됩니다.
BusinessHours (요일별 전체 영업 범위)
└── OperatingHours (BusinessHours 내 예약 가능 세부 시간대)
↓ Menu.durationMinutes 간격으로 슬롯 분할
└── BookingSlot (실제 예약 가능 단위)
└── Reservation (고객 예약)
// BookingSlot 생성 로직 핵심
// OperatingHours: 09:00~12:00, Menu durationMinutes: 60분, slotInterval: 60분
// → 09:00, 10:00, 11:00 세 개의 슬롯 생성
LocalTime current = operatingHours.getOpenTime(); // 09:00
while (!current.plusMinutes(durationMinutes)
.isAfter(operatingHours.getCloseTime())) {
slots.add(BookingSlot.create(current, current.plusMinutes(durationMinutes)));
current = current.plusMinutes(slotIntervalMinutes);
}이 구조 덕분에 OperatingHours가 변경되면 그 이후 생성될 BookingSlot에만 영향을 미치고, 이미 예약된 슬롯(Reservation이 연결된 BookingSlot)은 보호됩니다.
정규화 원칙대로라면 Reservation은 Menu와 User를 FK로 참조하고, 가격/소요시간/고객명은 조회 시 JOIN으로 가져오면 됩니다. 그러나 이 구조는 예약 서비스에서 두 가지 심각한 문제를 만듭니다.
문제 1 — 참조 대상이 사라지거나 변경되는 경우
시나리오:
고객이 3만원짜리 헤어컷을 예약함
이후 업체가 헤어컷 가격을 4만원으로 변경
고객이 예약 내역을 조회 → 4만원으로 표시됨 (결제 금액과 불일치)
더 심각한 시나리오:
고객이 예약한 메뉴를 업체가 삭제
Reservation → Menu FK가 끊김
예약 내역을 조회 → 서비스명 없음, 가격 없음
문제 2 — Cascade 삭제 연쇄
Menu가 삭제되면 연결된 BookingSlot도 삭제됩니다. BookingSlot이 삭제되면 그 슬롯으로 만들어진 Reservation은 어떻게 되어야 할까요? DB FK 제약조건으로 보호할 수도 있지만, 그러면 예약이 있는 한 메뉴를 삭제할 수 없습니다. 예약이 완료/취소되어 "끝난" 메뉴도 삭제가 불가해집니다.
예약 생성 시점의 데이터를 Reservation 레코드 안에 복사해서 저장합니다.
// Reservation 엔티티 — 스냅샷 필드들
@Column(name = "reservation_price", nullable = false)
private Integer reservationPrice; // Menu.price의 예약 시점 복사본
@Column(name = "reservation_duration", nullable = false)
private Integer reservationDuration; // Menu.durationMinutes의 예약 시점 복사본
@Column(name = "customer_name", nullable = false, length = 50)
private String customerName; // User.name의 예약 시점 복사본
@Column(name = "customer_phone", nullable = false, length = 20)
private String customerPhone; // User.phoneNumber의 예약 시점 복사본// 예약 생성 시 스냅샷 고정 — RESERVATION_BASED
public static Reservation createReservationBased(
User customer, Business business, Menu menu, BookingSlot bookingSlot,
String customerName, String customerPhone, String notes) {
Reservation reservation = new Reservation();
reservation.reservationPrice = menu.getPrice(); // 생성 시점 가격 고정
reservation.reservationDuration = menu.getDurationMinutes(); // 생성 시점 소요시간 고정
reservation.customerName = customerName; // 생성 시점 고객명 고정
reservation.customerPhone = customerPhone; // 생성 시점 연락처 고정
// ...
}FK 참조도 유지합니다. customer_id, menu_id, booking_slot_id는 여전히 FK로 존재합니다. 스냅샷은 "예약 당시 어떤 조건이었는가"를 보존하는 것이고, FK는 "이 예약이 어떤 메뉴/고객/슬롯과 연결되었는가"라는 관계를 표현합니다. 역할이 다릅니다.
graph LR
subgraph "예약 생성 시점"
M["Menu\nprice = 30,000\nduration = 60분"]
U["User\nname = 김철수\nphone = 010-xxx"]
end
subgraph "Reservation 레코드"
R["reservation_price = 30,000 ← 복사본\nreservation_duration = 60 ← 복사본\ncustomer_name = 김철수 ← 복사본\ncustomer_phone = 010-xxx ← 복사본\nmenu_id = FK ← 관계\ncustomer_id = FK ← 관계"]
end
subgraph "이후 변경 발생"
M2["Menu\nprice = 40,000 (변경됨)"]
note["예약 내역에 영향 없음\n스냅샷은 그대로 30,000"]
end
M -->|"생성 시 복사"| R
U -->|"생성 시 복사"| R
M2 -.->|"변경해도"| note
Review의 menuName도 같은 이유
Review는 예약 완료 후 작성합니다. 리뷰가 작성된 후 해당 메뉴가 삭제되어도 "어떤 서비스에 대한 리뷰인지"는 표시되어야 합니다.
@Column(name = "menu_name", nullable = false, length = 100)
private String menuName; // 리뷰 작성 시점의 메뉴명 스냅샷스냅샷 패턴이 가져오는 단점도 있습니다.
데이터 중복이 발생합니다. 같은 고객의 예약 100건이 있다면 customer_name이 100번 저장됩니다. 고객이 이름을 바꾸면 기존 예약에는 변경이 반영되지 않는데, 이것은 버그가 아니라 의도된 동작입니다. "예약 당시 이름"이 보존되는 것이 맞습니다.
조회 시 최신 고객 정보가 필요한 경우에는 FK로 User를 JOIN해서 현재 정보를 가져옵니다. Reservation이 응답하는 DTO를 보면 이 두 정보를 함께 반환합니다. customerName(스냅샷)과 customer.getName()(현재) 모두 사용 가능합니다.
엔티티 간 생명주기 의존성에 따라 CASCADE 삭제 여부를 다르게 설정했습니다.
// BookingSlot — Menu가 삭제되면 슬롯도 삭제
@OnDelete(action = OnDeleteAction.CASCADE)
private Menu menu;
// Reservation — Business, Menu, BookingSlot이 삭제되면 예약도 삭제
@OnDelete(action = OnDeleteAction.CASCADE)
private Business business;
@OnDelete(action = OnDeleteAction.CASCADE)
private Menu menu;
@OnDelete(action = OnDeleteAction.CASCADE)
private BookingSlot bookingSlot;
// Wishlist — Business가 삭제되면 찜 목록도 삭제
@OnDelete(action = OnDeleteAction.CASCADE)
private Business business;
// Review — Business가 삭제되면 리뷰도 삭제
@OnDelete(action = OnDeleteAction.CASCADE)
private Business business;@OnDelete(action = OnDeleteAction.CASCADE)는 JPA의 cascade = CascadeType.REMOVE와 다릅니다. JPA cascade는 Java 애플리케이션 레벨에서 처리하므로 부모 엔티티를 영속성 컨텍스트에 로드해야 하고, N개의 자식이 있으면 N번 DELETE가 발생합니다. @OnDelete는 DB 레벨 FK 제약 조건으로 처리하므로 애플리케이션을 거치지 않고 DB가 직접 처리합니다. 대량 데이터가 있는 상황에서 Business 하나를 삭제할 때 딸린 Menu, BookingSlot, Reservation 전부를 JPA로 처리하면 수만 번의 DELETE가 발생할 수 있습니다.
CASCADE를 적용하지 않은 곳 — User
Reservation의 customer_id는 CASCADE하지 않았습니다. 고객이 탈퇴해도 예약 기록은 업체 측의 영업 데이터이므로 보존이 필요합니다. 대신 고객 탈퇴 시 개인정보(이름, 연락처)를 익명화 처리하는 별도 로직이 필요합니다.
Menu 삭제의 복잡성
Menu를 삭제하면 BookingSlot이 CASCADE로 삭제됩니다. 그런데 BookingSlot에 Reservation이 연결되어 있으면 Reservation도 연쇄 삭제됩니다. 이것은 서비스 운영에서 허용하면 안 됩니다. 예약이 완료/취소 등 종료 상태가 아닌 활성 예약(PENDING, CONFIRMED)이 있는 Menu는 삭제를 막아야 합니다.
// MenuValidator — 활성 예약이 있으면 Menu 삭제 불가
public void validateNoActiveReservations(UUID menuId) {
boolean hasActiveReservations = reservationRepository
.existsByMenuIdAndStatusIn(
menuId,
List.of(ReservationStatus.PENDING, ReservationStatus.CONFIRMED)
);
if (hasActiveReservations) {
throw new MenuException(MenuErrorCode.MENU_HAS_ACTIVE_RESERVATIONS);
}
}활성 예약이 없는 Menu를 삭제할 때는 N+1 없이 3번의 쿼리로 BookingSlot을 정리하고 삭제합니다. 이 내용은 DB 성능 최적화 문서에서 이어집니다.
Review는 @OnDelete CASCADE를 쓰지 않고 Soft Delete를 적용했습니다. deletedAt 컬럼이 있으면 삭제된 것, null이면 활성 상태입니다.
@Column(name = "deleted_at")
private LocalDateTime deletedAt;이유는 두 가지입니다. 첫째, 리뷰 삭제 시 해당 업체의 평점이 재계산되어야 합니다. 물리 삭제하면 평점 계산 기준이 사라집니다. Soft Delete는 "이 리뷰가 삭제됐다"는 사실 자체를 기록으로 남길 수 있습니다. 둘째, 분쟁 발생 시 삭제된 리뷰 내용을 복원할 수 있어야 합니다. 물리 삭제는 복원 불가입니다.
시스템
백엔드 포트폴리오