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)** + +![img.png](img/index-before-explain.png) + +```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)** + +![img_1.png](img/index-after-explain.png) +```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