Skip to content

Entity Design

Sechang Jang edited this page Feb 20, 2026 · 1 revision

엔티티 설계

이 문서에서 다루는 것

  • 전체 엔티티 구조와 관계
  • 정규화 원칙 적용 범위
  • Reservation의 고의적 반정규화 (스냅샷 패턴)와 그 근거
  • CASCADE 삭제 전략 결정

1. 전체 엔티티 구조

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 : "리뷰"
Loading

2. 정규화 원칙 적용

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_BASEDONDEMAND_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)은 보호됩니다.


3. Reservation: 고의적 반정규화 (스냅샷 패턴)

왜 반정규화가 필요했나

정규화 원칙대로라면 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
Loading

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()(현재) 모두 사용 가능합니다.


4. CASCADE 삭제 전략

엔티티 간 생명주기 의존성에 따라 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 성능 최적화 문서에서 이어집니다.


5. Review의 Soft Delete

Review는 @OnDelete CASCADE를 쓰지 않고 Soft Delete를 적용했습니다. deletedAt 컬럼이 있으면 삭제된 것, null이면 활성 상태입니다.

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

이유는 두 가지입니다. 첫째, 리뷰 삭제 시 해당 업체의 평점이 재계산되어야 합니다. 물리 삭제하면 평점 계산 기준이 사라집니다. Soft Delete는 "이 리뷰가 삭제됐다"는 사실 자체를 기록으로 남길 수 있습니다. 둘째, 분쟁 발생 시 삭제된 리뷰 내용을 복원할 수 있어야 합니다. 물리 삭제는 복원 불가입니다.

Clone this wiki locally