diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 00000000..22153ca4
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,74 @@
+CREATE TABLE IF NOT EXISTS movie_entity (
+ release_date DATE,
+ run_time INTEGER NOT NULL,
+ create_at DATETIME(6),
+ modified_at DATETIME(6),
+ movie_id BIGINT NOT NULL AUTO_INCREMENT,
+ create_by VARCHAR(255),
+ genre VARCHAR(255),
+ modified_by VARCHAR(255),
+ rating VARCHAR(255),
+ thumbnail VARCHAR(255),
+ title VARCHAR(255),
+ PRIMARY KEY (movie_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE INDEX idx_movie_release_date ON movie_entity (release_date);
+CREATE INDEX idx_movie_genre ON movie_entity (genre);
+CREATE FULLTEXT INDEX idx_movie_title ON movie_entity(title);
+
+CREATE TABLE IF NOT EXISTS theater_schedule_entity (
+ end_time TIME(6),
+ screening_date DATE,
+ start_time TIME(6),
+ create_at DATETIME(6),
+ modified_at DATETIME(6),
+ movie_id BIGINT NOT NULL,
+ schedule_id BIGINT NOT NULL AUTO_INCREMENT,
+ theater_id BIGINT NOT NULL,
+ create_by VARCHAR(255),
+ modified_by VARCHAR(255),
+ PRIMARY KEY (schedule_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE INDEX idx_schedule_movie_id ON theater_schedule_entity (movie_id);
+
+CREATE TABLE IF NOT EXISTS theater_entity (
+ create_at DATETIME(6),
+ modified_at DATETIME(6),
+ theater_id BIGINT NOT NULL AUTO_INCREMENT,
+ create_by VARCHAR(255),
+ modified_by VARCHAR(255),
+ name VARCHAR(255),
+ PRIMARY KEY (theater_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS reservation_entity (
+ create_at DATETIME(6),
+ modified_at DATETIME(6),
+ reservation_id BIGINT NOT NULL AUTO_INCREMENT,
+ seat_id BIGINT NOT NULL,
+ user_id BIGINT NOT NULL,
+ create_by VARCHAR(255),
+ modified_by VARCHAR(255),
+ status ENUM('DONE'),
+ PRIMARY KEY (reservation_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS seat_entity (
+ create_at DATETIME(6),
+ modified_at DATETIME(6),
+ schedule_id BIGINT NOT NULL,
+ seat_id BIGINT NOT NULL AUTO_INCREMENT,
+ create_by VARCHAR(255),
+ modified_by VARCHAR(255),
+ seat_number VARCHAR(255),
+ status ENUM('AVAILABLE', 'RESERVED'),
+ PRIMARY KEY (seat_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS user_entity (
+ user_id BIGINT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255),
+ PRIMARY KEY (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index fe26b683..d6e063c2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,6 @@ services:
mysql:
image: mysql:8.0
container_name: local-movie-mysql
- restart: always
ports:
- "3306:3306"
environment:
@@ -14,6 +13,12 @@ services:
volumes:
- ./data/mysql/:/var/lib/mysql
+ redis:
+ image: redis:latest
+ container_name: local-movie-redis
+ ports:
+ - "6379:6379"
+
networks:
default:
driver: bridge
\ No newline at end of file
diff --git "a/docs/JPA_\354\227\260\352\264\200\352\264\200\352\263\204_\354\204\244\354\240\225\354\227\220_\353\214\200\355\225\234_\354\235\230\353\254\270.md" "b/docs/JPA_\354\227\260\352\264\200\352\264\200\352\263\204_\354\204\244\354\240\225\354\227\220_\353\214\200\355\225\234_\354\235\230\353\254\270.md"
new file mode 100644
index 00000000..4e7db7b8
--- /dev/null
+++ "b/docs/JPA_\354\227\260\352\264\200\352\264\200\352\263\204_\354\204\244\354\240\225\354\227\220_\353\214\200\355\225\234_\354\235\230\353\254\270.md"
@@ -0,0 +1,201 @@
+## 배경
+**상영 중인 영화 목록 조회 시, 불필요한 JOIN이 발생하는 문제**
+
+- JPA 연관관계를 설정한 상태에서 상영 중인 영화 목록을 조회할 때, 불필요한 JOIN이 수행되는 문제 발생.
+- JPA의 연관 관계 설정으로 인한 문제로 판단
+- **연관 관계를 설정한 경우와 설정하지 않은 경우의 차이점 분석.**
+
+이 문서에서는 두 방식의 차이를 비교하고, 적용한 해결 방법을 정리한다.
+
+## 특이사항
+현 프로젝트에서는 **필요한 데이터를 각각 조회하여 UseCase에서 조합.**
+
+**💠 테이블마다 조회하는 이유**
+- 캐시를 적용할 수있는 선택폭이 넓어짐.
+- 쿼리가 특정 기능에 종속되지 않아 재사용 가능.
+- join을 사용하여 하나의 쿼리로 조회하는 경우, 데이터가 많을 수록 join 비용 ↑
+
+## 문제 상황
+
+영화 Id에 대한 상영일정을 조회할 때, jpa 연관관계 설정으로 인해 join이 수행되고 있음.
+
+```kotlin
+@Entity
+class TheaterScheduleEntity (
+ // 필드 생략 ...
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "movie_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
+ val movie: MovieEntity,
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "theater_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
+ val theater: TheaterEntity
+)
+```
+
+```sql
+select
+ tse.*
+from
+ theater_schedule_entity tse
+ left join movie_entity m on m.movie_id = tse.movie_id
+where
+ m.movie_id in (?)
+order by
+ tse.start_time;
+```
+
+이미 영화 데이터를 조회한 상태에서 상영일정 데이터를 조회하기 때문에
+영화 테이블에 대한 join은 수행하지 않아도 된다고 판단.
+
+## 해결 방법
+
+### **JPQL을 사용하여 상영일정 테이블만 조회할 수 있도록 제어.**
+
+```sql
+select
+ *
+from
+ theater_schedule_entity tse
+where
+ tse.movie_id in (?)
+```
+
+불필요한 join을 제거했지만 한가지 문제가 있다.
+
+현재 프로젝트에서는 엔티티와 도메인 모델을 분리해서 관리하고 있는데,
+
+엔티티 조회 후, 도메인 모델로 변환해주기 위해 movie와 theater에 대한 값이 필요하다.
+
+**이때, N + 1 문제 발생한다.**
+
+```kotlin
+class TheaterScheduleMapper {
+ companion object {
+ fun toSchedule(entity: TheaterScheduleEntity): TheaterSchedule {
+ return TheaterSchedule(
+ scheduleId = entity.id,
+ movieId = entity.movie.id, // MovieEntity 사용
+ theaterId = entity.theater.id, // TheaterEntity 사용
+ screeningDate = entity.screeningDate,
+ startTime = entity.startTime,
+ endTime = entity.endTime
+ )
+ }
+ }
+}
+```
+
+```sql
+-- 영화 조회
+select * from movie_entity
+where release_date <= ? order by me1_0.release_date
+
+-- 상영일정 조회
+select * from theater_schedule_entity
+where movie_id in (?) ORDER BY start_time
+
+-- N + 1
+select * from theater_entity where theater_id=?
+select * from theater_entity where theater_id=?
+...
+
+-- 상영관 조회
+select * from theater_entity
+where te1_0.theater_id in (?)
+```
+
+의아한 것은 movie와 theater가 아닌 theater에 대해서만 발생한 것이다.
+
+**💠 movie 에 대한 추가 쿼리가 발생하지 않은 이유.**
+
+이미 `movie` 데이터를 조회한 상태에서 `theater_schedule`을 조회하면,
+JPA가 기존 영속성 컨텍스트에서 해당 `movie`엔티티를 관리하고 있기 때문에
+프록시 객체를 새로 생성하지 않고, 추가 쿼리도 발생하지 않음.
+
+프록시 객체를 통해 참조할 수 있는 필드는 id 값이므로, `entity.movie.id`를 조회할 때는 SELECT 쿼리가 실행되지 않는다.
+
+하지만 `entity.movie.title` 같은 필드를 조회하면 **Lazy Loading이 동작**하면서 추가 쿼리가 발생.
+
+> **💡 프록시 객체란?**
+>
+> JPA는 `@ManyToOne(fetch = FetchType.LAZY)`설정이 되어 있을 경우,
+실제 엔티티 대신 프록시 객체(대리 객체)를 반환하는데,
+이 객체는 **id 값만 가지고 있고 나머지는 데이터베이스에서 가져올 때까지 비워둔다.**
+`movie.title`같은 속성을 조회하려고 하면 실제 데이터를 가져오기 위해 SELECT 쿼리를 실행.
+
+N + 1 문제를 해결하기 위해 Fetch Join, @EntityGraph 등을 사용할 수 있지만, 이 방법들은 결국 join을 사용한다는 것이다.
+
+**불필요한 join을 제거하려 했지만 N + 1 문제로 다시 join을 사용해야 하는 상황**이다.
+
+### **JPA 연관관계 설정 제거.**
+
+단순하게 JPA 연관관계 설정을 제거하면 어떻게 될까.
+
+`TheatScheduleEntity`는 `MovieEntity`와 `TheaterEntity` 를 의존하는 게 아닌, id 값만 설정하는 것이다.
+
+```kotlin
+@Entity
+class TheaterScheduleEntity (
+ // 필드 생략 ...
+
+ val movieId: Long,
+
+ val theaterId: Long
+)
+```
+
+```kotlin
+class TheaterScheduleMapper {
+ companion object {
+ fun toSchedule(entity: TheaterScheduleEntity): TheaterSchedule {
+ return TheaterSchedule(
+ ...,
+ movieId = entity.movieId,
+ theaterId = entity.theaterId,
+ ...
+ )
+ }
+ }
+}
+```
+
+**실행된 쿼리**
+
+```sql
+-- 영화 조회
+select * from movie_entity
+where release_date <= ? order by me1_0.release_date
+
+-- 상영 일정 조회
+select * from theater_schedule_entity
+where movie_id in (?) order by start_time
+
+-- 상영관 조회
+select * from theater_entity
+where te1_0.theater_id in (?)
+```
+
+불필요한 join 없이 각 테이블만 조회하게 되었다.
+
+
+## 정리
+**JPA 연관관계를 제거하는 방법을 사용**했다.
+
+불필요한 join이 수행되는 것은 해결했지만 여전히 문제점은 있다.
+
+**💠 JPA 연관관계 설정을 제거했을 때의 문제점**
+
+- 객체지향적인 설계가 깨짐 → 데이터 중심적인 개발 방식이 됨
+- 한 번에 조회가 불가능 → 여러 개의 쿼리를 직접 실행해야 함
+- JPA가 제공하는 자동 기능(Cascade, 삭제, 업데이트 등)을 사용할 수 없음
+
+꽤나 많은 것들을 포기해야 한다.
+그럼에도 이 방법을 선택한 이유는 **의존성을 줄이기 위함**이다.
+
+상영일정은 영화, 상영관과 연결되어 있다.
+만약 도메인 별로 서비스가 나뉜다면 어떻게 될까. 상영일정은 영화와 상영관에 의존하고 있기 때문에 그 의존성을 모두 풀어내야 한다.
+
+무엇보다 DB 쿼리는 개발자가 제어할 수 있어야 한다고 생각한다.
+연관관계로 설정으로 인해 발생하는 사이드이펙트(불필요한 join, N+1)를 해결하는 비용도 무시할 수 없다고 생각한다.
\ No newline at end of file
diff --git a/docs/img/index-after-explain.png b/docs/img/index-after-explain.png
new file mode 100644
index 00000000..ceed63fa
Binary files /dev/null and b/docs/img/index-after-explain.png differ
diff --git a/docs/img/index-before-explain.png b/docs/img/index-before-explain.png
new file mode 100644
index 00000000..362e15ce
Binary files /dev/null and b/docs/img/index-before-explain.png differ
diff --git a/docs/performence-test.md b/docs/performence-test.md
new file mode 100644
index 00000000..2c14f963
--- /dev/null
+++ b/docs/performence-test.md
@@ -0,0 +1,275 @@
+# 목차
+* [전제 조건](#전제-조건)
+* [Index 성능 테스트](#Index-성능-테스트)
+* [Cache 성능 테스트](#caching-성능-테스트)
+ * [캐시 적용 전](#캐시-적용-전)
+ * [로컬 캐시](#로컬-캐시-적용)
+ * [글로벌 캐시](#글로벌-캐시-적용)
+* [추후 개선 방향](#추후-redisson-도입으로-인한-기대-효과)
+
+# 전제 조건
+
+- **DAU**: 500명
+- **1명당 1일 평균 접속 수**: 2번
+- **피크 시간대의 집중률**: 평소 트래픽의 10배
+- **Throughput 계산**:
+ - **1일 총 접속 수** = DAU × 1명당 1일 평균 접속 수 = N × 2 = **2N** (1일 총 접속 수)
+ - **1일 평균 RPS** = 1일 총 접속 수 ÷ 86,400 (초/일)= 2N ÷ 86,400 ≈ **X** **RPS**
+ - **1일 최대 RPS** = 1일 평균 RPS × (최대 트래픽 / 평소 트래픽)= X × 10 = **10X RPS**
+- VU: N명
+- optional
+ - thresholds
+ - e.g p(95) 의 응답 소요 시간 200ms 이하
+ - 실패율 1% 이하
+
+## Index 성능 테스트
+
+### 배경
+- 상영 중인 영화 목록을 조회하기 위해 현재 날짜를 기준으로 개봉일이 지난 데이터를 반환한다.
+- 요구사항으로 제목, 장르를 통한 검색 기능이 추가되었다.
+- 인덱스를 적용할 컬럼을 선택하고 적용 전후를 비교하는 성능 테스트 진행.
+
+### 적용 전
+
+**1. 실행 쿼리**
+```sql
+select
+ *
+from
+ movie_entity
+where
+ title like '%영화제목%'
+ and genre = '장르'
+ and releaseDate <= '현재날짜'
+order by
+ release_date
+```
+
+**2. 실행 계획 (explain & analyze)**
+
+
+
+```sql
+-> Sort: movie_entity.release_date (cost=51 rows=500) (actual time=0.336..0.336 rows=1 loops=1)
+ -> Filter: ((movie_entity.genre = 'Comedy') and (movie_entity.title like '%123%') and (movie_entity.release_date <= DATE'2025-03-01'))
+ (cost=51 rows=500) (actual time=0.089..0.327 rows=1 loops=1)
+ -> Table scan on movie_entity (cost=51 rows=500) (actual time=0.0278..0.284 rows=500 loops=1)
+```
+
+**3. 부하 테스트 결과**
+```
+ ✓ status is 200
+ ✗ response time < 200ms
+ ↳ 96% — ✓ 59 / ✗ 2
+
+ checks.........................: 98.36% 120 out of 122
+ data_received..................: 7.7 kB 126 B/s
+ data_sent......................: 6.9 kB 113 B/s
+ http_req_blocked...............: avg=4.7ms min=2.23ms med=3.56ms max=38.36ms p(90)=5.77ms p(95)=7.07ms
+ http_req_connecting............: avg=4.48ms min=2.11ms med=3.36ms max=38.08ms p(90)=5.56ms p(95)=6.65ms
+ ✓ http_req_duration..............: avg=76.84ms min=18.59ms med=27.98ms max=2.01s p(90)=35.91ms p(95)=39.52ms
+ { expected_response:true }...: avg=76.84ms min=18.59ms med=27.98ms max=2.01s p(90)=35.91ms p(95)=39.52ms
+ ✓ http_req_failed................: 0.00% 0 out of 61
+ http_req_receiving.............: avg=701.45µs min=104.87µs med=495.78µs max=2.91ms p(90)=1.44ms p(95)=1.59ms
+ http_req_sending...............: avg=159.6µs min=59.1µs med=132.35µs max=558.17µs p(90)=254.49µs p(95)=353.23µs
+ http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...............: avg=75.98ms min=18.39ms med=27.48ms max=2.01s p(90)=34.99ms p(95)=39.31ms
+ http_reqs......................: 61 0.999433/s
+ iteration_duration.............: avg=1.08s min=1.02s med=1.03s max=3.02s p(90)=1.04s p(95)=1.04s
+ iterations.....................: 61 0.999433/s
+ vus............................: 1 min=1 max=3
+ vus_max........................: 500 min=500 max=500
+```
+
+### 적용 후
+
+**인덱스 선택**
+- title(영화제목) :
+ - `LIKE '%영화 제목%'` 형태의 검색은 일반 인덱스를 타지 않아 풀 테이블 스캔 발생.
+ - 중간 검색 성능을 높이기 위해 **FullText Index 설정**
+- genre(장르) :
+ - 카디널리티가 상대적으로 높아 데이터 분포가 고르게 퍼져 있어 특정 값 검색 시 인덱스 활용도가 높을 것으로 예상.
+ - 특정 장르에 대한 검색이 자주 수행되는 경우, 인덱스를 활용했을 때 불필요한 데이터 스캔을 줄일 수 있음.
+- releaseDate(개봉일) :
+ - 현재 날짜를 기준으로 범위 검색이 자주 발생하는 컬럼
+ - 개봉일이 중복되는 데이터가 적어, 인덱스 성능이 유지될 가능성 있음.
+ - 개봉일 순서로 정렬되기 때문에 인덱스를 활용했을 때 빠르게 필터링 후 정렬 가능
+
+**1. 실행 쿼리**
+```sql
+select
+ *
+from
+ movie_entity
+where
+ match(title) against('영화제목')
+ and genre = '장르'
+ and releaseDate <= '현재날짜'
+order by
+ release_date
+```
+
+**2. 적용한 인덱스 DDL**
+```sql
+create index idx_release_date on movie_entity (release_date);
+create index idx_genre on movie_entity(genre);
+CREATE FULLTEXT INDEX idx_title ON movie_entity(title);
+```
+
+**3. 실행 계획 (explain & analyze)**
+
+
+```sql
+-> Sort row IDs: movie_entity.release_date (cost=0.256 rows=1) (actual time=0.0366..0.0367 rows=1 loops=1)
+ -> Filter: ((movie_entity.genre = 'Comedy') and (match movie_entity.title against ('123')) and (movie_entity.release_date <= DATE'2025-03-01'))
+ (cost=0.256 rows=1) (actual time=0.0221..0.0226 rows=1 loops=1)
+ -> Full-text index search on movie_entity using idx_title (title='123') (cost=0.256 rows=1) (actual time=0.0189..0.0193 rows=1 loops=1)
+```
+
+**4. 부하 테스트 결과**
+```
+✓ status is 200
+✓ response time < 200ms
+
+ checks.........................: 100.00% 122 out of 122
+ data_received..................: 244 kB 4.0 kB/s
+ data_sent......................: 6.9 kB 113 B/s
+ http_req_blocked...............: avg=5.8ms min=1.02ms med=3.8ms max=50.06ms p(90)=8.67ms p(95)=11.31ms
+ http_req_connecting............: avg=5ms min=899.73µs med=3.62ms max=49.78ms p(90)=7.59ms p(95)=10.75ms
+ ✓ http_req_duration..............: avg=28.74ms min=18.51ms med=27.45ms max=48.45ms p(90)=35.93ms p(95)=41.5ms
+ { expected_response:true }...: avg=28.74ms min=18.51ms med=27.45ms max=48.45ms p(90)=35.93ms p(95)=41.5ms
+ ✓ http_req_failed................: 0.00% 0 out of 61
+ http_req_receiving.............: avg=478.91µs min=91.19µs med=356.5µs max=1.62ms p(90)=1.05ms p(95)=1.26ms
+ http_req_sending...............: avg=201.44µs min=54µs med=162.49µs max=851.24µs p(90)=313.43µs p(95)=395.03µs
+ http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...............: avg=28.06ms min=17.94ms med=26.56ms max=47.46ms p(90)=34.84ms p(95)=41.06ms
+ http_reqs......................: 61 0.999475/s
+ iteration_duration.............: avg=1.03s min=1.02s med=1.03s max=1.08s p(90)=1.04s p(95)=1.04s
+ iterations.....................: 61 0.999475/s
+ vus............................: 1 min=1 max=1
+ vus_max........................: 500 min=500 max=500
+```
+
+## Caching 성능 테스트
+
+**배경**
+- 위에서 검색 기능에 대해 인덱스 테스트를 진행했다.
+- 캐싱할 데이터는 검색 기능을 통해 조회되는 데이터가 아닌, **상영 중인 영화 목록 전체 데이터를 선택했다.**
+
+**선정 이유**
+- 사용자 입력 값에 따라 조회되는 데이터가 다르며, 해당 데이터들이 캐시에 모두 저장되는 것은 효율적이지 못하다 판단.
+- 상영 중인 영화 목록 데이터의 경우, 가장 많이 조회되는 메인페이지에 반환되는 데이터로서, 데이터의 큰 변경없이 최대 하루까지는 동일한 데이터를 반환하기 때문에 캐시 데이터로 관리하기 용이할 것으로 예상되어 선택.
+
+**적용 캐시**
+- 로컬 캐시 :
+ - `EhCache` : 다양한 기능을 제공하기 위해 생성되는 객체가 많아 메모리를 많이 사용하게 됨. -> GC 영향을 크게 받음
+ - `Caffeine` : 메모리 내에서만 동작하며, EhCache 보다 상대적으로 GC의 부담이 적다.
+ - 로컬 캐시를 사용한다면 빠르고 단순해야 한다고 판단되어 **Caffeine 캐시 적용**
+- 글로벌 캐시 :
+ - **현재 RedisCacheManager 를 사용**하여 글로벌 캐시 구현.
+ - 코드 변경이 거의 없이 간단한 설정만으로 Redis 캐싱 적용 가능하기 때문에 선택.
+ - 간편하지만 기능 제한이 있고, 매번 Redis를 조회하기 때문에 로컬 캐시를 활용하지 못함.
+
+### 캐시 적용 전
+
+- api 단일 호출 시 `평균 380ms` 응답 속도.
+
+**부하 테스트 결과**
+```
+ ✓ status is 200
+ ✗ response time < 200ms
+ ↳ 0% — ✓ 0 / ✗ 61
+
+ checks.........................: 50.00% 61 out of 122
+ data_received..................: 119 MB 1.9 MB/s
+ data_sent......................: 6.2 kB 100 B/s
+ http_req_blocked...............: avg=4.8ms min=2.41ms med=4.14ms max=33.56ms p(90)=6.42ms p(95)=7.7ms
+ http_req_connecting............: avg=4.56ms min=2.25ms med=3.92ms max=33.42ms p(90)=6.04ms p(95)=7.12ms
+ ✗ http_req_duration..............: avg=566.52ms min=259.2ms med=585.57ms max=758.97ms p(90)=673.66ms p(95)=699.25ms
+ { expected_response:true }...: avg=566.52ms min=259.2ms med=585.57ms max=758.97ms p(90)=673.66ms p(95)=699.25ms
+ ✓ http_req_failed................: 0.00% 0 out of 61
+ http_req_receiving.............: avg=76.13ms min=55.72ms med=75.77ms max=111.01ms p(90)=89.9ms p(95)=94.07ms
+ http_req_sending...............: avg=145.93µs min=63.54µs med=124.67µs max=394.48µs p(90)=235.21µs p(95)=266.47µs
+ http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...............: avg=490.24ms min=179.18ms med=507.48ms max=668.74ms p(90)=574.4ms p(95)=618.82ms
+ http_reqs......................: 61 0.990756/s
+ iteration_duration.............: avg=1.57s min=1.26s med=1.59s max=1.76s p(90)=1.67s p(95)=1.7s
+ iterations.....................: 61 0.990756/s
+ vus............................: 1 min=1 max=1
+ vus_max........................: 500 min=500 max=500
+```
+
+### 로컬 캐시 적용
+
+- api 단일 호출 시 평`균 30 ms` 응답 속도
+ - **평균 380ms -> 30ms 성능 향상**
+
+**부하 테스트 결과**
+```
+ ✓ status is 200
+ ✗ response time < 200ms
+ ↳ 96% — ✓ 59 / ✗ 2
+
+ checks.........................: 98.36% 120 out of 122
+ data_received..................: 119 MB 1.9 MB/s
+ data_sent......................: 6.2 kB 101 B/s
+ http_req_blocked...............: avg=4.67ms min=2ms med=3.27ms max=38.85ms p(90)=4.78ms p(95)=11.9ms
+ http_req_connecting............: avg=4.46ms min=1.77ms med=3.11ms max=38.59ms p(90)=4.48ms p(95)=11.78ms
+ ✓ http_req_duration..............: avg=130.52ms min=71.94ms med=92.56ms max=1.72s p(90)=111.88ms p(95)=128.11ms
+ { expected_response:true }...: avg=130.52ms min=71.94ms med=92.56ms max=1.72s p(90)=111.88ms p(95)=128.11ms
+ ✓ http_req_failed................: 0.00% 0 out of 61
+ http_req_receiving.............: avg=86.94ms min=63.17ms med=84.6ms max=139.14ms p(90)=106.1ms p(95)=117.9ms
+ http_req_sending...............: avg=153.45µs min=64.11µs med=137.69µs max=868.38µs p(90)=228.69µs p(95)=261.08µs
+ http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...............: avg=43.42ms min=3.99ms med=7.53ms max=1.58s p(90)=12.18ms p(95)=14.78ms
+ http_reqs......................: 61 0.998356/s
+ iteration_duration.............: avg=1.13s min=1.07s med=1.09s max=2.73s p(90)=1.11s p(95)=1.14s
+ iterations.....................: 61 0.998356/s
+ vus............................: 1 min=1 max=2
+ vus_max........................: 500 min=500 max=500
+```
+
+### 글로벌 캐시 적용
+- api 단일 호출 시 `평균 380ms` 응답 속도.
+ - 캐시 적용 전과 거의 동일한 성능.
+
+**부하 테스트 결과**
+```
+ ✓ status is 200
+ ✗ response time < 200ms
+ ↳ 0% — ✓ 0 / ✗ 61
+
+ checks.........................: 50.00% 61 out of 122
+ data_received..................: 119 MB 1.9 MB/s
+ data_sent......................: 6.2 kB 101 B/s
+ http_req_blocked...............: avg=4.65ms min=2.18ms med=3.38ms max=52.59ms p(90)=5.26ms p(95)=8ms
+ http_req_connecting............: avg=4.38ms min=1.97ms med=3.21ms max=52.41ms p(90)=4.31ms p(95)=7.22ms
+ ✗ http_req_duration..............: avg=385.92ms min=258.91ms med=307.22ms max=2.78s p(90)=387.87ms p(95)=517.79ms
+ { expected_response:true }...: avg=385.92ms min=258.91ms med=307.22ms max=2.78s p(90)=387.87ms p(95)=517.79ms
+ ✓ http_req_failed................: 0.00% 0 out of 61
+ http_req_receiving.............: avg=85.06ms min=62.33ms med=79.09ms max=166.25ms p(90)=102.68ms p(95)=130.99ms
+ http_req_sending...............: avg=133.75µs min=63.96µs med=128.5µs max=298.16µs p(90)=182.68µs p(95)=213.79µs
+ http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...............: avg=300.72ms min=186.97ms med=217.62ms max=2.62s p(90)=308.7ms p(95)=444.09ms
+ http_reqs......................: 61 0.994743/s
+ iteration_duration.............: avg=1.39s min=1.26s med=1.31s max=3.8s p(90)=1.39s p(95)=1.52s
+ iterations.....................: 61 0.994743/s
+ vus............................: 1 min=1 max=3
+ vus_max........................: 500 min=500 max=500
+```
+
+**글로벌 캐시를 적용했음에도 적용 전과 동일한 성능이 나오는 이유**
+- **충분하지 않은 테스트 데이터**
+ - 캐시의 성능 차이를 확인하려면 반복적인 데이터 조회가 많아야 함
+ - 현재 테스트 데이터가 적어 캐시 적중률(Cache Hit Rate)이 낮을 가능성이 있음
+ - 결과적으로 캐시의 효과를 체감하기 어려움
+- **네트워크 통신 오버헤드**
+ - Redis는 외부 인프라로 동작하며, 네트워크를 통해 요청/응답해야 함
+ - 캐시를 적용했지만 네트워크 지연(latency)이 발생하면서 성능이 개선되지 않을 가능성이 있음
+ - 특히 단일 서버의 로컬 메모리 접근 속도(나노초 단위)와 비교하면, Redis를 통한 네트워크 통신 속도(마이크로초~밀리초 단위)가 상대적으로 느림
+
+## **추후 Redisson 도입으로 인한 기대 효과**
+- 로컬 캐시 + Redis 캐시를 함께 사용하여 조회 속도 향상
+- Pub/Sub 기반으로 여러 서버 간 캐시 데이터 자동 동기화
+- TTL, Max Size, 캐시 정책 등을 세밀하게 설정 가능
+- 향후 Rate Limiting, 분산 락 등의 추가 기능 확장 가능
\ No newline at end of file
diff --git a/k6/movie-script.js b/k6/movie-script.js
new file mode 100644
index 00000000..4d791f3b
--- /dev/null
+++ b/k6/movie-script.js
@@ -0,0 +1,37 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+const N = 500; // DAU
+const dailyRequests = N * 5;
+const avgRPS = dailyRequests / 86400; // 1일 평균 RPS
+const peakRPS = Math.max(1, Math.round(avgRPS * 10)) // 1일 최대 RPS (소수점 제거, 최소값 1이상)
+
+export let options = {
+ scenarios: {
+ constant_load: {
+ executor: 'constant-arrival-rate',
+ rate: peakRPS, // 초당 요청 수 (최대 RPS)
+ timeUnit: '1s',
+ duration: '1m',
+ preAllocatedVUs: N,
+ },
+ },
+ thresholds: {
+ http_req_duration: ['p(95)<200'], // 95% 요청이 200ms 이하
+ http_req_failed: ['rate<0.01'], // 실패율 1% 이하
+ },
+};
+
+const BASE_URL = 'http://host.docker.internal:8080';
+
+export default function () {
+ let res = http.get(`${BASE_URL}/api/movies`); // 상영 중인 영화 목록 전체 조회
+ //let res = http.get(`${BASE_URL}/api/movies?title=123&genre=Comedy); // 검색 파라미터 추가
+
+ check(res, {
+ 'status is 200': (r) => r.status === 200,
+ 'response time < 200ms': (r) => r.timings.duration < 200,
+ });
+
+ sleep(1);
+}
diff --git a/movie-application/build.gradle.kts b/movie-application/build.gradle.kts
index e369f9b9..714ef20c 100644
--- a/movie-application/build.gradle.kts
+++ b/movie-application/build.gradle.kts
@@ -1,3 +1,6 @@
dependencies {
implementation(project(":movie-business"))
+ implementation(project(":movie-common"))
+
+ implementation("org.springframework.boot:spring-boot-starter-cache")
}
\ No newline at end of file
diff --git a/movie-application/src/main/kotlin/com/example/application/dto/TheaterScheduleResult.kt b/movie-application/src/main/kotlin/com/example/application/dto/TheaterScheduleResult.kt
index eb77da2e..22d71b4e 100644
--- a/movie-application/src/main/kotlin/com/example/application/dto/TheaterScheduleResult.kt
+++ b/movie-application/src/main/kotlin/com/example/application/dto/TheaterScheduleResult.kt
@@ -1,7 +1,6 @@
package com.example.application.dto
import com.example.business.theater.domain.TheaterSchedule
-import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDate
import java.time.LocalTime
@@ -10,9 +9,7 @@ data class TheaterScheduleResult(
val movieId: Long,
val theaterId: Long,
val screeningDate: LocalDate,
- @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
val startTime: LocalTime,
- @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
val endTime: LocalTime
) {
diff --git a/movie-application/src/main/kotlin/com/example/application/usecase/MovieUseCase.kt b/movie-application/src/main/kotlin/com/example/application/usecase/MovieUseCase.kt
index 5c76d306..698d1746 100644
--- a/movie-application/src/main/kotlin/com/example/application/usecase/MovieUseCase.kt
+++ b/movie-application/src/main/kotlin/com/example/application/usecase/MovieUseCase.kt
@@ -7,6 +7,7 @@ import com.example.business.theater.domain.Theater
import com.example.business.theater.domain.TheaterSchedule
import com.example.business.theater.service.TheaterScheduleService
import com.example.business.theater.service.TheaterService
+import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Component
@Component
@@ -16,9 +17,10 @@ class MovieUseCase(
private val scheduleService: TheaterScheduleService
){
- fun getAvailableMovies(): List {
+ @Cacheable(value = ["available-movies"], key = "'movie-list'")
+ fun getAvailableMovies(title: String?, genre: String?): List {
// 상영 가능한 영화 목록 조회
- val availableMovies: List = movieService.getAvailableMovies()
+ val availableMovies: List = movieService.getAvailableMovies(title, genre)
// 영화 상영 일정 조회
val scheduleMap: Map> = scheduleService.getSchedules(availableMovies)
diff --git a/movie-application/src/test/kotlin/com/example/application/usecase/MovieUseCaseTest.kt b/movie-application/src/test/kotlin/com/example/application/usecase/MovieUseCaseTest.kt
index e31075bb..5e6f4643 100644
--- a/movie-application/src/test/kotlin/com/example/application/usecase/MovieUseCaseTest.kt
+++ b/movie-application/src/test/kotlin/com/example/application/usecase/MovieUseCaseTest.kt
@@ -1,6 +1,7 @@
package com.example.application.usecase
import com.example.application.fixture.MovieUseCaseFixture
+import com.example.business.movie.service.MovieService
import com.example.business.movie.domain.Movie
import com.example.business.movie.service.MovieService
import com.example.business.theater.domain.Theater
@@ -50,12 +51,17 @@ class MovieUseCaseTest {
val schedule1 = fixture.createSchedule(1L, 1L, 1L, LocalTime.of(12,0), LocalTime.of(14, 0))
val schedule2 = fixture.createSchedule(2L, 1L, 1L, LocalTime.of(14,0), LocalTime.of(16, 0))
- `when`(movieService.getAvailableMovies()).thenReturn(listOf(movie))
+ val title: String? = null
+ val genre: String? = null
+
+ `when`(movieService.getAvailableMovies(title, genre)).thenReturn(listOf(movie))
`when`(scheduleService.getSchedules(listOf(movie))).thenReturn(listOf(schedule1, schedule2))
`when`(theaterService.getTheaters(setOf(schedule1.theaterId, schedule2.theaterId))).thenReturn(listOf(theater))
// when
- val result = sut.getAvailableMovies()
+
+ val result = sut.getAvailableMovies(title, genre)
+
// then
assertThat(result[0].movie)
diff --git a/movie-business/src/main/kotlin/com/example/business/movie/repository/MovieRepository.kt b/movie-business/src/main/kotlin/com/example/business/movie/repository/MovieRepository.kt
index b8542220..45163ec3 100644
--- a/movie-business/src/main/kotlin/com/example/business/movie/repository/MovieRepository.kt
+++ b/movie-business/src/main/kotlin/com/example/business/movie/repository/MovieRepository.kt
@@ -1,10 +1,9 @@
package com.example.business.movie.repository
import com.example.business.movie.domain.Movie
-import java.time.LocalDate
interface MovieRepository {
- fun getMoviesReleasedUntil(now: LocalDate): List
+ fun getMoviesReleasedUntil(title: String?, genre: String?): List
}
\ No newline at end of file
diff --git a/movie-business/src/main/kotlin/com/example/business/movie/service/MovieService.kt b/movie-business/src/main/kotlin/com/example/business/movie/service/MovieService.kt
index 55449c7e..132c4b4c 100644
--- a/movie-business/src/main/kotlin/com/example/business/movie/service/MovieService.kt
+++ b/movie-business/src/main/kotlin/com/example/business/movie/service/MovieService.kt
@@ -9,7 +9,7 @@ import java.time.LocalDate
class MovieService(
private val movieRepository: MovieRepository
) {
- fun getAvailableMovies() : List {
- return movieRepository.getMoviesReleasedUntil(LocalDate.now())
+ fun getAvailableMovies(title: String?, genre: String?) : List {
+ return movieRepository.getMoviesReleasedUntil(title, genre)
}
}
\ No newline at end of file
diff --git a/movie-business/src/test/kotlin/com/example/business/service/MovieServiceTest.kt b/movie-business/src/test/kotlin/com/example/business/service/MovieServiceTest.kt
index 5e257ea9..d4f71422 100644
--- a/movie-business/src/test/kotlin/com/example/business/service/MovieServiceTest.kt
+++ b/movie-business/src/test/kotlin/com/example/business/service/MovieServiceTest.kt
@@ -29,6 +29,8 @@ class MovieServiceTest {
@Test
fun getAvailableMovies() {
// given
+ val title: String? = null
+ val genre: String? = null
val now = LocalDate.now()
val movie = Movie(
movieId = 1L,
@@ -40,11 +42,11 @@ class MovieServiceTest {
rating = "전체 이용가"
)
- `when`(movieRepository.getMoviesReleasedUntil(now))
+ `when`(movieRepository.getMoviesReleasedUntil(title, genre))
.thenReturn(listOf(movie))
// when
- val result = sut.getAvailableMovies()
+ val result = sut.getAvailableMovies(title, genre)
// then
assertThat(result).hasSize(1)
diff --git a/movie-common/build.gradle.kts b/movie-common/build.gradle.kts
index db004a77..d536bdae 100644
--- a/movie-common/build.gradle.kts
+++ b/movie-common/build.gradle.kts
@@ -1,3 +1,13 @@
dependencies {
+ implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
+ implementation("org.springframework.boot:spring-boot-starter-validation")
+ implementation("org.springframework.boot:spring-boot-starter-cache")
+
+ // Redis
+ implementation("org.springframework.boot:spring-boot-starter-data-redis")
+
+ // QueryDsl
+ implementation ("com.querydsl:querydsl-jpa:5.0.0:jakarta")
+ kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
}
\ No newline at end of file
diff --git a/movie-common/src/main/kotlin/com/example/common/config/MySqlFullTextDialect.kt b/movie-common/src/main/kotlin/com/example/common/config/MySqlFullTextDialect.kt
new file mode 100644
index 00000000..03d339ff
--- /dev/null
+++ b/movie-common/src/main/kotlin/com/example/common/config/MySqlFullTextDialect.kt
@@ -0,0 +1,42 @@
+package com.example.common.config
+
+import org.hibernate.boot.model.FunctionContributions
+import org.hibernate.dialect.MySQLDialect
+import org.hibernate.query.ReturnableType
+import org.hibernate.query.sqm.function.NamedSqmFunctionDescriptor
+import org.hibernate.query.sqm.function.SqmFunctionRegistry
+import org.hibernate.query.sqm.produce.function.StandardArgumentsValidators
+import org.hibernate.sql.ast.SqlAstNodeRenderingMode
+import org.hibernate.sql.ast.SqlAstTranslator
+import org.hibernate.sql.ast.spi.SqlAppender
+import org.hibernate.sql.ast.tree.SqlAstNode
+
+open class MySqlFullTextDialect: MySQLDialect() {
+
+ override fun initializeFunctionRegistry(functionContributions: FunctionContributions) {
+ super.initializeFunctionRegistry(functionContributions)
+
+ val functionRegistry: SqmFunctionRegistry = functionContributions.functionRegistry
+ functionRegistry.register("MATCH", MatchFunction)
+ }
+
+ object MatchFunction : NamedSqmFunctionDescriptor(
+ "MATCH",
+ false,
+ StandardArgumentsValidators.exactly(2),
+ null
+ ) {
+ override fun render(
+ sqlAppender: SqlAppender,
+ arguments: List,
+ returnType: ReturnableType<*>?,
+ translator: SqlAstTranslator<*>
+ ) {
+ sqlAppender.appendSql("MATCH(")
+ translator.render(arguments[0], SqlAstNodeRenderingMode.DEFAULT)
+ sqlAppender.appendSql(") AGAINST (")
+ translator.render(arguments[1], SqlAstNodeRenderingMode.DEFAULT)
+ sqlAppender.appendSql(" IN NATURAL LANGUAGE MODE)")
+ }
+ }
+}
\ No newline at end of file
diff --git a/movie-common/src/main/kotlin/com/example/common/config/QueryDslConfig.kt b/movie-common/src/main/kotlin/com/example/common/config/QueryDslConfig.kt
new file mode 100644
index 00000000..070b00ea
--- /dev/null
+++ b/movie-common/src/main/kotlin/com/example/common/config/QueryDslConfig.kt
@@ -0,0 +1,16 @@
+package com.example.common.config
+
+import com.querydsl.jpa.impl.JPAQueryFactory
+import jakarta.persistence.EntityManager
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+
+@Configuration
+class QueryDslConfig(
+ private val entityManager: EntityManager
+) {
+
+ @Bean
+ fun jpaQueryFactory(): JPAQueryFactory = JPAQueryFactory(entityManager)
+
+}
\ No newline at end of file
diff --git a/movie-common/src/main/kotlin/com/example/common/config/RedisCacheConfig.kt b/movie-common/src/main/kotlin/com/example/common/config/RedisCacheConfig.kt
new file mode 100644
index 00000000..8ae93c81
--- /dev/null
+++ b/movie-common/src/main/kotlin/com/example/common/config/RedisCacheConfig.kt
@@ -0,0 +1,66 @@
+package com.example.common.config
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.cache.CacheManager
+import org.springframework.cache.annotation.EnableCaching
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.data.redis.cache.RedisCacheConfiguration
+import org.springframework.data.redis.cache.RedisCacheManager
+import org.springframework.data.redis.connection.RedisConnectionFactory
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
+import org.springframework.data.redis.serializer.RedisSerializationContext
+import org.springframework.data.redis.serializer.RedisSerializer
+import org.springframework.data.redis.serializer.StringRedisSerializer
+import java.time.Duration
+
+@Configuration
+@EnableCaching
+class RedisCacheConfig(
+ @Value("\${spring.data.redis.host}")
+ val host: String,
+ @Value("\${spring.data.redis.port}")
+ val port: Int
+){
+
+ @Bean
+ fun redisConnectionFactory(): RedisConnectionFactory = LettuceConnectionFactory(host, port)
+
+ @Bean
+ fun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager {
+ val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
+ .entryTtl(Duration.ofDays(1)) // 캐시 1일 설정
+ .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
+ .serializeValuesWith(
+ RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer())
+ )
+
+ return RedisCacheManager.builder(redisConnectionFactory)
+ .cacheDefaults(cacheConfig)
+ .build()
+ }
+
+ @Bean
+ fun redisSerializer(): RedisSerializer {
+ val objectMapper = ObjectMapper()
+ .registerKotlinModule()
+ .registerModule(JavaTimeModule())
+ .apply {
+ activateDefaultTyping(
+ BasicPolymorphicTypeValidator.builder()
+ .allowIfBaseType(Any::class.java).build(),
+ ObjectMapper.DefaultTyping.EVERYTHING)
+ configure(
+ SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+
+ }
+ return GenericJackson2JsonRedisSerializer(objectMapper)
+ }
+
+}
\ No newline at end of file
diff --git a/movie-common/src/main/kotlin/com/example/common/exception/GlobalExceptionHandler.kt b/movie-common/src/main/kotlin/com/example/common/exception/GlobalExceptionHandler.kt
new file mode 100644
index 00000000..c1588773
--- /dev/null
+++ b/movie-common/src/main/kotlin/com/example/common/exception/GlobalExceptionHandler.kt
@@ -0,0 +1,27 @@
+package com.example.common.exception
+
+import com.example.common.model.BindErrorResponse
+import org.slf4j.LoggerFactory
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.springframework.validation.BindException
+
+@RestControllerAdvice
+class GlobalExceptionHandler {
+
+ private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
+
+ @ExceptionHandler(BindException::class)
+ fun validationExceptionHandler(e: BindException): ResponseEntity {
+ val errors = e.fieldErrors.associate { it.field to it.defaultMessage.orEmpty() }
+
+ errors.forEach { (field, message) ->
+ log.error("Invalid Input: {} - {}", field, message)
+ }
+
+ return BindErrorResponse.of(HttpStatus.BAD_REQUEST, errors)
+ }
+
+}
\ No newline at end of file
diff --git a/movie-common/src/main/kotlin/com/example/common/model/BindErrorResponse.kt b/movie-common/src/main/kotlin/com/example/common/model/BindErrorResponse.kt
new file mode 100644
index 00000000..5ae59bb1
--- /dev/null
+++ b/movie-common/src/main/kotlin/com/example/common/model/BindErrorResponse.kt
@@ -0,0 +1,17 @@
+package com.example.common.model
+
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+
+data class BindErrorResponse(
+ val status: HttpStatus,
+ val reason: Map = mapOf()
+) {
+ companion object {
+ fun of(
+ status: HttpStatus, reason: Map
+ ): ResponseEntity {
+ return ResponseEntity.status(status).body(BindErrorResponse(status, reason))
+ }
+ }
+}
diff --git a/movie-infrastructure/build.gradle.kts b/movie-infrastructure/build.gradle.kts
index a18800fd..b6661998 100644
--- a/movie-infrastructure/build.gradle.kts
+++ b/movie-infrastructure/build.gradle.kts
@@ -1,11 +1,17 @@
dependencies {
implementation(project(":movie-application"))
implementation(project(":movie-business"))
+ implementation(project(":movie-common"))
implementation("org.springframework.boot:spring-boot-starter-web")
+ implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly ("org.junit.platform:junit-platform-launcher")
+ // QueryDsl
+ implementation ("com.querydsl:querydsl-jpa:5.0.0:jakarta")
+ kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
+
runtimeOnly("com.mysql:mysql-connector-j")
}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/MovieApplication.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/MovieApplication.kt
index 66e5ac1b..748fb2af 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/MovieApplication.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/MovieApplication.kt
@@ -6,7 +6,8 @@ import org.springframework.boot.runApplication
@SpringBootApplication(scanBasePackages = [
"com.example.application",
"com.example.business",
- "com.example.infrastructure"
+ "com.example.infrastructure",
+ "com.example.common",
])
class MovieApplication
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/controller/MovieController.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/controller/MovieController.kt
new file mode 100644
index 00000000..396991f1
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/controller/MovieController.kt
@@ -0,0 +1,26 @@
+package com.example.infrastructure.`in`.controller
+
+import com.example.application.usecase.MovieUseCase
+import com.example.infrastructure.`in`.dto.AvailableMovieResponse
+import com.example.infrastructure.`in`.dto.MovieSearchRequest
+import org.springframework.http.ResponseEntity
+import jakarta.validation.Valid
+import org.springframework.web.bind.annotation.*
+
+
+@RestController
+@RequestMapping("/api/movies")
+class MovieController(
+ private val movieUseCase: MovieUseCase
+) {
+
+ @GetMapping("")
+ fun getMovies(
+ @Valid @ModelAttribute request: MovieSearchRequest
+ ): ResponseEntity> {
+ val movies = movieUseCase.getAvailableMovies(request.title, request.genre)
+
+ return ResponseEntity.ok(movies.map { AvailableMovieResponse.of(it) }.toList())
+ }
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/AvailableMovieResponse.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/AvailableMovieResponse.kt
new file mode 100644
index 00000000..d1f11bd2
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/AvailableMovieResponse.kt
@@ -0,0 +1,31 @@
+package com.example.infrastructure.`in`.dto
+
+import com.example.application.dto.AvailableMovieResult
+
+data class AvailableMovieResponse(
+ val movie: MovieResponse,
+ val theaters: List = listOf(),
+ val theaterSchedules: List = listOf()
+) {
+
+ companion object {
+ fun of(result: AvailableMovieResult): AvailableMovieResponse {
+ val movieResponse = MovieResponse.of(result.movie)
+
+ val theaterResponse = result.theaters.stream()
+ .map { TheaterResponse.of(it) }
+ .toList()
+
+ val scheduleResponse = result.theaterSchedules.stream()
+ .map { TheaterScheduleResponse.of(it) }
+ .toList()
+
+ return AvailableMovieResponse(
+ movie = movieResponse,
+ theaters = theaterResponse,
+ theaterSchedules = scheduleResponse
+ )
+ }
+ }
+
+}
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieResponse.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieResponse.kt
new file mode 100644
index 00000000..9a493ddb
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieResponse.kt
@@ -0,0 +1,31 @@
+package com.example.infrastructure.`in`.dto
+
+import com.example.application.dto.MovieResult
+import com.example.business.movie.domain.Movie
+import java.time.LocalDate
+
+data class MovieResponse (
+ val movieId: Long,
+ val title: String,
+ val releaseDate: LocalDate,
+ val thumbnail: String,
+ val runTime: Int,
+ val genre: String,
+ val rating: String
+) {
+
+ companion object {
+ fun of(movie: MovieResult): MovieResponse {
+ return MovieResponse(
+ movieId = movie.movieId,
+ title = movie.title,
+ releaseDate = movie.releaseDate,
+ thumbnail = movie.thumbnail,
+ runTime = movie.runTime,
+ genre = movie.genre,
+ rating = movie.rating
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieSearchRequest.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieSearchRequest.kt
new file mode 100644
index 00000000..98fd50bd
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/MovieSearchRequest.kt
@@ -0,0 +1,10 @@
+package com.example.infrastructure.`in`.dto
+
+import jakarta.validation.constraints.Size
+
+data class MovieSearchRequest(
+ @field:Size(min = 3, max = 100, message = "제목은 3자 이상 100자 이하로 입력해야 합니다.")
+ val title: String?,
+ @field:Size(min = 1, max = 100, message = "장르은 1자 이상 100자 이하로 입력해야 합니다.")
+ val genre:String?
+)
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterResponse.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterResponse.kt
new file mode 100644
index 00000000..3fbb9faf
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterResponse.kt
@@ -0,0 +1,19 @@
+package com.example.infrastructure.`in`.dto
+
+import com.example.application.dto.TheaterResult
+
+data class TheaterResponse(
+ val theaterId: Long,
+ val name: String
+) {
+
+ companion object {
+ fun of(theater: TheaterResult): TheaterResponse {
+ return TheaterResponse(
+ theaterId = theater.theaterId,
+ name = theater.name
+ )
+ }
+ }
+
+}
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterScheduleResponse.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterScheduleResponse.kt
new file mode 100644
index 00000000..d7840746
--- /dev/null
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/in/dto/TheaterScheduleResponse.kt
@@ -0,0 +1,33 @@
+package com.example.infrastructure.`in`.dto
+
+import com.example.application.dto.TheaterScheduleResult
+import com.example.business.theater.domain.TheaterSchedule
+import com.fasterxml.jackson.annotation.JsonFormat
+import java.time.LocalDate
+import java.time.LocalTime
+
+data class TheaterScheduleResponse(
+ val scheduleId: Long,
+ val movieId: Long,
+ val theaterId: Long,
+ val screeningDate: LocalDate,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ val startTime: LocalTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ val endTime: LocalTime
+) {
+
+ companion object {
+ fun of(schedule: TheaterScheduleResult): TheaterScheduleResponse {
+ return TheaterScheduleResponse(
+ scheduleId = schedule.scheduleId,
+ movieId = schedule.movieId,
+ theaterId = schedule.theaterId,
+ screeningDate = schedule.screeningDate,
+ startTime = schedule.startTime,
+ endTime = schedule.endTime
+ )
+ }
+ }
+
+}
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/BaseEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/BaseEntity.kt
index 74aff4bf..99655248 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/BaseEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/BaseEntity.kt
@@ -13,14 +13,14 @@ import java.time.LocalDateTime
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity (
@CreatedBy
- val createBy: String? = null,
+ var createBy: String? = null,
@CreatedDate
- val createAt: LocalDateTime = LocalDateTime.now(),
+ var createAt: LocalDateTime = LocalDateTime.now(),
@LastModifiedBy
- val modifiedBy: String? = null,
+ var modifiedBy: String? = null,
@LastModifiedDate
- val modifiedAt: LocalDateTime = LocalDateTime.now()
+ var modifiedAt: LocalDateTime = LocalDateTime.now()
)
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/MovieEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/MovieEntity.kt
index c5d0f85b..b56ccd9f 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/MovieEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/MovieEntity.kt
@@ -12,7 +12,7 @@ class MovieEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "movie_id")
- val id: Long = 0,
+ var id: Long = 0,
val title: String,
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/ReservationEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/ReservationEntity.kt
index e72ad81e..19f561e1 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/ReservationEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/ReservationEntity.kt
@@ -8,16 +8,15 @@ class ReservationEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
- val id: Long = 0,
+ var id: Long = 0,
@Enumerated(EnumType.STRING)
- val status: ReservationStatus,
+ var status: ReservationStatus,
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "user_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
- val user: UserEntity,
+ val userId: Long
+): BaseEntity() {
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "seat_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
- val seat: SeatEntity
-): BaseEntity()
\ No newline at end of file
+ constructor(status: ReservationStatus, userId: Long):
+ this(0, status, userId)
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/SeatEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/SeatEntity.kt
index e77f5af1..58067d02 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/SeatEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/SeatEntity.kt
@@ -8,14 +8,19 @@ class SeatEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "seat_id")
- val id: Long = 0,
+ var id: Long = 0,
val seatNumber: String,
@Enumerated(EnumType.STRING)
- val status: SeatStatus,
+ var status: SeatStatus,
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "schedule_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
- val schedule: TheaterScheduleEntity
-): BaseEntity()
\ No newline at end of file
+ var reservationId: Long?,
+
+ val scheduleId: Long
+): BaseEntity() {
+
+ constructor(seatNumber: String, status: SeatStatus, reservationId: Long?, scheduleId: Long) :
+ this(0, seatNumber, status, reservationId, scheduleId)
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterEntity.kt
index af4e7324..0faefd6d 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterEntity.kt
@@ -11,7 +11,7 @@ class TheaterEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "theater_id")
- val id: Long = 0,
+ var id: Long = 0,
val name: String
): BaseEntity()
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterScheduleEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterScheduleEntity.kt
index 0a366da8..4935f11e 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterScheduleEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/TheaterScheduleEntity.kt
@@ -9,7 +9,7 @@ class TheaterScheduleEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "schedule_id")
- val id: Long = 0,
+ var id: Long = 0,
val screeningDate: LocalDate,
@@ -17,11 +17,12 @@ class TheaterScheduleEntity (
val endTime: LocalTime,
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "movie_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
- val movie: MovieEntity,
+ val movieId: Long,
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "theater_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT))
- val theater: TheaterEntity
-): BaseEntity()
+ val theaterId: Long,
+): BaseEntity() {
+
+ constructor(screeningDate: LocalDate, startTime: LocalTime ,endTime: LocalTime, movieId: Long ,theaterId: Long):
+ this(0, screeningDate, startTime, endTime, movieId, theaterId)
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/UserEntity.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/UserEntity.kt
index 1d7bae83..01b046e0 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/UserEntity.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/entity/UserEntity.kt
@@ -11,7 +11,11 @@ class UserEntity (
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
- val id: Long = 0,
+ var id: Long = 0,
val name: String
-)
\ No newline at end of file
+): BaseEntity() {
+
+ constructor(name: String): this(0, name)
+
+}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/mapper/TheaterScheduleMapper.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/mapper/TheaterScheduleMapper.kt
index 22ac3838..6220af6b 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/mapper/TheaterScheduleMapper.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/mapper/TheaterScheduleMapper.kt
@@ -9,8 +9,8 @@ class TheaterScheduleMapper {
fun toSchedule(entity: TheaterScheduleEntity): TheaterSchedule {
return TheaterSchedule(
scheduleId = entity.id,
- movieId = entity.movie.id,
- theaterId = entity.theater.id,
+ movieId = entity.movieId,
+ theaterId = entity.theaterId,
screeningDate = entity.screeningDate,
startTime = entity.startTime,
endTime = entity.endTime
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/MovieCoreRepositoryImpl.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/MovieCoreRepositoryImpl.kt
index 86df6016..c0e20ced 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/MovieCoreRepositoryImpl.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/MovieCoreRepositoryImpl.kt
@@ -2,21 +2,66 @@ package com.example.infrastructure.out.persistence.repository
import com.example.business.movie.domain.Movie
import com.example.business.movie.repository.MovieRepository
+import com.example.infrastructure.out.persistence.entity.MovieEntity
+import com.example.infrastructure.out.persistence.entity.QMovieEntity.*
import com.example.infrastructure.out.persistence.mapper.MovieMapper
+import com.querydsl.core.types.Projections
+import com.querydsl.core.types.dsl.BooleanExpression
+import com.querydsl.core.types.dsl.Expressions
+import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.stereotype.Repository
+import java.math.BigDecimal
import java.time.LocalDate
@Repository
class MovieCoreRepositoryImpl(
- private val jpaRepository: MovieJpaRepository
+ private val queryFactory: JPAQueryFactory
): MovieRepository {
- override fun getMoviesReleasedUntil(now: LocalDate): List {
- val movies = jpaRepository.findByReleaseDateLessThanEqualOrderByReleaseDateAsc(now)
-
- return movies.stream()
+ override fun getMoviesReleasedUntil(title: String?, genre: String?): List {
+ return queryFactory
+ .select(
+ Projections.constructor(MovieEntity::class.java,
+ movieEntity.id,
+ movieEntity.title,
+ movieEntity.thumbnail,
+ movieEntity.releaseDate,
+ movieEntity.runTime,
+ movieEntity.genre,
+ movieEntity.rating
+ )
+ )
+ .from(movieEntity)
+ .where(
+ titleContain(title),
+ genreEq(genre),
+ movieEntity.releaseDate.loe(LocalDate.now())
+ )
+ .orderBy(movieEntity.releaseDate.asc())
+ .fetch()
.map { MovieMapper.toMovie(it) }
- .toList()
+ }
+
+ private fun titleContain(title: String?): BooleanExpression? {
+ return if (!title.isNullOrBlank()) {
+ Expressions.numberTemplate(
+ BigDecimal::class.java,
+ "function('MATCH', {0}, {1})",
+ movieEntity.title,
+ title
+ ).gt(0)
+ } else {
+ null
+ }
+ }
+
+ private fun genreEq(genre: String?): BooleanExpression? {
+ val movie = movieEntity
+ return if (!genre.isNullOrBlank()) {
+ movie.genre.eq(genre)
+ } else {
+ null
+ }
}
}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/TheaterScheduleJpaRepository.kt b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/TheaterScheduleJpaRepository.kt
index e675addc..6c441584 100644
--- a/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/TheaterScheduleJpaRepository.kt
+++ b/movie-infrastructure/src/main/kotlin/com/example/infrastructure/out/persistence/repository/TheaterScheduleJpaRepository.kt
@@ -1,12 +1,10 @@
package com.example.infrastructure.out.persistence.repository
import com.example.infrastructure.out.persistence.entity.TheaterScheduleEntity
-import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
interface TheaterScheduleJpaRepository: JpaRepository {
- @EntityGraph(attributePaths = ["theater"])
fun findByMovieIdInOrderByStartTimeAsc(movieIds: List): List
}
\ No newline at end of file
diff --git a/movie-infrastructure/src/main/resources/application-local.yml b/movie-infrastructure/src/main/resources/application-local.yml
index 7471ca85..63d67819 100644
--- a/movie-infrastructure/src/main/resources/application-local.yml
+++ b/movie-infrastructure/src/main/resources/application-local.yml
@@ -11,4 +11,16 @@ spring:
jpa:
hibernate:
ddl-auto: none
- show-sql: true
\ No newline at end of file
+ show-sql: true
+ properties:
+ hibernate:
+ dialect: com.example.common.config.MySqlFullTextDialect
+ format_sql: true,
+ default_batch_fetch_size: 1000
+
+ cache:
+ type: redis
+ data:
+ redis:
+ host: localhost
+ port: 6379