안녕하세요!
지난 1편에 이어 이번 2편에서는 본격적인 코드 작성에 앞서 시스템의 뼈대가 되는 데이터베이스 설계 과정을 공유해 드리려고 합니다.
영화 예매 시스템은 회원, 영화, 상영관, 좌석, 예매, 결제 등 수많은 도메인이 유기적으로 엮여 있습니다. 솔직히 말씀드리면 인프런에서 김영한 강사님의 DB강의를 수강한 저는 처음 ERD를 그릴 때만 해도 '와, 나 데이터베이스 설계 좀 치는데?' 하며 완벽하다고 자부했습니다. 물론, 앞으로 마주칠 끔찍한 수정 지옥을 알기 전까지는 말이죠... (솔직히 이런 대환장 파티는 알고 싶지 않았습니다 흑흑) 😂
처음에는 완벽해 보였던 설계도 막상 코딩을 시작하고 비즈니스 로직을 붙여나가다 보니, "여긴 왜 이렇게 묶어놨지?", "이 데이터는 왜 빠졌지?" 하며 수정을 거듭하게 되더라고요. 이번 포스팅에서는 그런 시행착오와 고민을 거쳐 최종적으로 다듬어진 개념적·논리적 설계, 물리적 ERD, 그리고 주요 테이블 정의서를 가감 없이 풀어보겠습니다.
1. 📊 데이터베이스 설계 프로세스 개요
데이터를 안전하게 보관하고 향후 복잡한 조회 쿼리(예매 순 정렬, 좌석 충돌 검사 등)의 성능을 높이기 위해 아래와 같은 단계로 설계했습니다.
- 개념적 및 논리적 설계: 엔티티 간의 관계(1:1, 1:N, N:M)를 정의하고 정규화를 통해 데이터의 무결성을 확보했습니다.
- 물리적 설계 (ERD 구축): 실제 MySQL 환경을 고려하여 대리 키(Surrogate Key, BIGINT, PK, AI)를 적용하고, 외래 키(FK) 관계를 명확히 매핑했습니다.
- 성능 최적화 고려:
- 빈번한 조회가 발생하는 항목은 인덱스(Index)를 적극적으로 추가했습니다.
- 집계 쿼리로 인한 성능 저하를 방어하기 위해 필요한 컬럼은 역정규화(Denormalization)를 적용했습니다.
2. 📐 물리적 ERD (Entity Relationship Diagram) 구조
실제 데이터베이스에 반영될 테이블 간의 관계와 연관성을 시각화한 전체 ERD 구조입니다.
erDiagram
MEMBER ||--o{ RESERVATION : makes
MEMBER ||--o{ PAYMENT : processes
MEMBER ||--o{ MOVIE_REVIEWS : writes
MEMBER ||--o{ MOVIE_LIKE : clicks
MEMBER ||--o{ MOVIE_REVIEWS_LIKE : recommends
MOVIE ||--o{ SHOWTIME : has
MOVIE ||--o{ MOVIE_REVIEWS : has
MOVIE ||--o{ MOVIE_LIKE : has
MOVIE ||--o{ MOVIE_PERSON : involves
MOVIE ||--o{ MOVIE_IMAGE : has
PERSON ||--o{ MOVIE_PERSON : acts_or_directs
PERSON ||--o{ PERSON_IMAGE : has
THEATER ||--o{ SCREEN : contains
SCREEN_TYPE ||--o{ SCREEN : defines
SCREEN_TYPE ||--o{ PRICE_POLICY : applies
SEAT_GRADE ||--o{ SEAT : grades
SEAT_GRADE ||--o{ PRICE_POLICY : prices
SCREEN ||--o{ SEAT : has
SCREEN ||--o{ SHOWTIME : hosts
SHOWTIME ||--o{ RESERVATION : schedules
SHOWTIME ||--o{ RESERVATION_SEAT : includes
RESERVATION ||--o{ RESERVATION_SEAT : contains
RESERVATION ||--o{ PAYMENT : links
3. 📋 주요 테이블 정의서 (Table Specification)
설계된 전체 테이블 중 백엔드 로직의 핵심이 되는 주요 테이블들의 상세 구조입니다.
1) 영화 (MOVIE)
- 역할: 영화의 상세 정보 및 메타데이터를 관리합니다.
- 설계 포인트: 상세 페이지나 목록에서 매번 집계 쿼리를 날리는 부하를 줄이기 위해 audience_count, rating_avg, like_count 등의 컬럼을 두어 역정규화를 적용했습니다.
| no | 컬럼 한글명 | 컬럼 영문명 | 데이터타입 | 제약조건 | 비고 |
| 1 | 영화 ID | movie_id | BIGINT | PK, AI | 고유 식별자 |
| 2 | 영화 제목 | title | VARCHAR(255) | - | 제목 |
| 3 | 영화 설명 | descriptions | TEXT | NULL | 시놉시스 |
| 4 | 상영 시간 | running_time | INT | - | 분 단위 상영 길이 |
| 5 | 관람 등급 | content_rating | VARCHAR(10) | - | ALL, 12, 15, 18 등 |
| 6 | 개봉일 | open_date | DATE | - | 개봉일 |
| 7 | 상영 상태 | movie_status | CHAR(10) | - | Y(상영중), N(종료), P(예정) |
| 8 | 누적 관람객 | audience_count | BIGINT | DEFAULT 0 | (역정규화) 총 관람객 수 |
| 9 | 평균 평점 | rating_avg | DECIMAL(3,1) | DEFAULT 0 | (역정규화) 평균 평점 |
| 10 | 총 평점 수 | rating_count | INT | DEFAULT 0 | (역정규화) 총 평점 수 |
| 11 | 총 좋아요 수 | like_count | INT | DEFAULT 0 | (역정규화) 좋아요 수 |
| 12 | 생성일 | created_at | DATETIME | - | 시스템 등록 시점 |
| 13 | 수정일 | updated_at | DATETIME | - | 정보 수정 시 자동 갱신 |
- 인덱스:
- idx_movie_title (title)
- idx_movie_status_audience_count (movie_status, audience_count DESC)
- idx_movie_status_open_date (movie_status, open_date ASC)
2) 상영 시간 (SHOWTIME)
- 역할: 특정 영화가 어떤 상영관에서 언제 상영되는지 관리합니다.
- 설계 포인트: 상영 시간 등록 시 시간 충돌 검사가 빈번하게 일어나므로 복합 인덱스를 구성했습니다.
| no | 컬럼 한글명 | 컬럼 영문명 | 데이터타입 | 제약조건 | 비고 |
| 1 | 상영 시간 ID | showtime_id | BIGINT | PK, AI | 고유 식별자 |
| 2 | 영화 ID | movie_id | BIGINT | FK | MOVIE 참조 |
| 3 | 스크린 ID | screen_id | BIGINT | FK | SCREEN 참조 |
| 4 | 시작 시간 | start_time | DATETIME | - | 상영 시작 시간 |
| 5 | 종료 시간 | end_time | DATETIME | CHECK | 상영 종료 시간 (end_time > start_time) |
| 6 | 상영 상태 | showtime_status | VARCHAR(10) | - | SCHEDULED, CANCELED, FINISHED |
| 7 | 예매 좌석 수 | audience_count | INT | DEFAULT 0 | (역정규화) 남은 좌석 계산 편의를 위한 역정규화 |
| 8 | 생성일 | created_at | DATETIME | - | 시스템 등록 시점 |
| 9 | 수정일 | updated_at | DATETIME | - | 정보 수정 시 자동 갱신 |
- 인덱스: idx_showtime_screen_id_start_time (screen_id, start_time, end_time)
3) 예매 (RESERVATION) 및 예매 좌석 (RESERVATION_SEAT)
- 역할: 사용자의 예매 내역과 선택된 개별 좌석 상세를 관리합니다.
- 설계 포인트:
- 예매 상태(reservation_status)를 두어 PENDING(결제 대기), CANCELED(취소), COMPLETED(예매 완료) 상태를 체계적으로 관리합니다.
- RESERVATION_SEAT 테이블에 uq_showtime_id_seat_id (showtime_id, seat_id) 유니크 제약조건을 두어 동일한 상영 시간에 같은 좌석이 중복 예매되는 것을 DB 단에서 원천 방어했습니다.
| 테이블명 | 주요 컬럼 | 제약조건 및 특징 |
| RESERVATION | reservation_id, member_id, showtime_id, seat_count, total_price, reservation_status, reservation_at |
• 예매 총괄 정보 및 PENDING 상태 관리 • reservation_at으로 실제 예매 시점 기록 |
| RESERVATION_SEAT | reservation_seat_id, reservation_id, showtime_id, seat_id, price |
• uq_showtime_id_seat_id (중복 좌석 예매 방지 유니크 제약) |
4) 결제 (PAYMENT)
- 역할: 예매에 따른 결제 정보를 관리합니다.
- 설계 포인트: reservation_id에 UQ 제약조건을 주어 하나의 예매에 대해 중복 결제가 발생하는 것을 방지했습니다.
| no | 컬럼 한글명 | 컬럼 영문명 | 데이터타입 | 제약조건 | 비고 |
| 1 | 결제 ID | payment_id | BIGINT | PK, AI | 고유 식별자 |
| 2 | 회원 ID | member_id | BIGINT | FK | MEMBER 참조 |
| 3 | 예매 ID | reservation_id | BIGINT | FK, UQ | RESERVATION 참조 (중복 결제 방지) |
| 4 | 결제 상태 | payment_status | VARCHAR(20) | - | PENDING, FINISHED, CANCELED |
| 5 | 결제 금액 | amount | INT | - | 총 결제 금액 |
| 6 | 결제 완료일시 | paid_at | DATETIME | - | 결제가 최종 완료된 시점 |
| 7 | 생성일 | created_at | DATETIME | - | 시스템 등록 시점 |
| 8 | 수정일 | updated_at | DATETIME | - | 정보 수정 시 자동 갱신 |
4. 💡 이번 DB 설계에서 고민했던 점
- 역정규화를 통한 조회 성능 확보와 동기화 지옥 (feat. 알았지만 이렇게까지 딥할 줄은... 🔥):
- 총 예매 수 같은 핵심 데이터는 '이건 무조건 많이 쓰이겠지!' 하고 미리 역정규화를 해두었지만, 평점이나 좋아요 수 같은 부가 통계들은 "설마 조회할 때마다 조인해서 COUNT 때리면 되겠지~" 하고 아주 안일하게 생각했습니다. 물론, 개발 초기라 데이터가 텅 비어있을 때의 뇌피셜이었죠 😅
- 개발을 진행하면서 페치 조인(Fetch Join)을 깎고, 복잡한 조회 쿼리 성능을 잡기 위해 인덱스를 추가하는 등 쿼리 최적화에 힘을 쏟았습니다.
- 그러다 통계 컬럼 쪽을 매만질 때쯤, "데이터 정합성을 위해 동기화 처리를 해줘야 한다"는 개념은 머리로는 당연히 알았지만 "뭐 동기화 해주면 되지~" 라고 안일한 생각을 했던 나 자신... 동기화 시점까지 중요할 줄은 미처 몰랐습니다..
예매 로직을 개발하면서 "이 상태 변경 로직을 도대체 언제 어디서, 어떤 연관관계 메서드로 해야 깔끔하게 묶어야 하지?" 하며 정합성 때문에 많은 고민을 했더랬죠..ㅎㅎ - 비록 극한의 상용 서비스 대참사를 직접 맞은 건 아니지만, "조회 성능을 챙기기 위해 역정규화를 도입하는 순간, '데이터 동기화'라는 거대한 숙제가 덤으로 따라온다"는 실무적인 트레이드오프를 온몸으로 체득할 수 있었습니다.
- 동시성 정합성을 위한 방어선 구축:
- 애플리케이션 레벨의 동시성 제어(Redis 분산 락) 외에도, 데이터베이스 구조 자체에서 중복 예매가 일어날 수 없도록 복합 유니크 제약조건을 철저히 설계하여 데이터 무결성의 신뢰도를 높였습니다.
5. 📚 다음 포스팅 예고
지금까지 서비스의 단단한 뼈대가 되는 데이터베이스 설계 과정을 살펴보았습니다. 다음 3편에서는 본격적인 코딩에 들어가기 앞서, 전체 시스템의 아키텍처 구조도와 레이어별 역할, 그리고 주요 비즈니스 흐름도(Flow)를 상세히 다뤄보겠습니다.
다음 포스팅에서 만나요! 🚀