Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b6ea57e
feat: 멀티 모듈 구현 및 상영 중인 영화 조회 api 구현
yeonjookang Jan 10, 2025
218b3ea
merge: remote pull
yeonjookang Jan 10, 2025
8a036cd
refactor: 1주차 피드백을 바탕으로 리팩토링
yeonjookang Jan 14, 2025
2a942b4
doc: README 수정
yeonjookang Jan 14, 2025
0beee8e
feat: 검색 기능을 포함하도록 controller 수정 및 테스트코드 작성
yeonjookang Jan 15, 2025
c8baddf
fix: Controller 테스트 오류 해결(EnabledJpaAuditing 애노테이션)
yeonjookang Jan 16, 2025
a03c8d9
feat: 서비스 layer 단위 테스트 추가 및 정렬 순서 수정
yeonjookang Jan 16, 2025
802cfdf
test: dtocovertor 테스트코드 추가
yeonjookang Jan 16, 2025
eaa326d
test: repository 테스트코드 추가
yeonjookang Jan 16, 2025
b3117e3
feat: 캐싱 추가
yeonjookang Jan 19, 2025
8a4a07c
docs: 2주차 성능 보고서 작성
yeonjookang Jan 19, 2025
b326a56
feat: 예약 API Controller 구현
yeonjookang Jan 23, 2025
1cb3e59
feat: validation 의존성 추가
yeonjookang Jan 23, 2025
b990868
Revert "feat: 예약 API Controller 구현"
yeonjookang Jan 23, 2025
4a0c233
fix: layered architecture 수정
yeonjookang Jan 24, 2025
72ddda7
fix: query dsl로 수정
yeonjookang Jan 24, 2025
2990a0a
fix: genre에 대해서만 캐싱하도록 redis 수정
yeonjookang Jan 25, 2025
a1c5468
feat: 예약 API 구현
yeonjookang Jan 25, 2025
88a67ca
fix: redis key에 null이 들어가는 오류 수정
yeonjookang Jan 26, 2025
97e8125
feat: 분산락 구현(동시성 해결 실패)
yeonjookang Jan 26, 2025
1419cf2
feat: 비관적락 구현
yeonjookang Feb 1, 2025
19bed88
feat: 낙관적 락 구현
yeonjookang Feb 2, 2025
3f9ea4d
feat: aop를 활용한 분산락 구현
yeonjookang Feb 3, 2025
e13e725
feat: 함수형 분산락 구현
yeonjookang Feb 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
46 changes: 46 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

# QueryDSL generated files
build/
generated/
**/build/
**/generated/

# Ignore specific Q classes (if needed)
**/Q*.java

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
86 changes: 83 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,83 @@
## [본 과정] 이커머스 핵심 프로세스 구현
[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다.
> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등)
## Multi Module Design
Module을 나누는 기준은 여러 개가 있지만, 이번 프로젝트에서는 Layered Architecture 에서 설명되는 Layer 별로 구분하였습니다.
- api: 사용자의 요청을 받고, 응답한다.
- 본래, presentation과 application 두 개의 모듈로 분리되어있던 구조를 수정하였습니다.
- 사용자의 요청을 받고 처리한다는 점에서, Error와 Response를 한 곳에서 관리하기 위함입니다.
- domain: 시스템이 제공할 도메인 규칙을 구현한다.
- infra: 외부 시스템과의 연동을 담당한다.
- 본래 존재하였던 core 모듈을 삭제하였습니다.
- 실제로 공통 역할이 아니지만 core(or common) 모듈에 패키지를 생성해서 정의하는 경우를 방지하기 위함입니다.
- 현재까지의 요구사항에서는 core 모듈에 들어갈 기능이 없다고 판단되었습니다.

각 module은 하위 module에만 의존합니다. <br>
JPA 를 다른 ORM 으로 변경될 가능성은 낮다고 판단하여 PA 는 생산성을 위해서 Entity 와 Repository 를 Domain 으로 끌어 올려 사용하였습니다.
JPA 를 제외한 나머지는 저수준의 변경사항으로 부터 고수준을 지키는 방식을 사용합니다.

## Table Design
![img_2.png](img_2.png)
- Movie 테이블과 Theater 테이블은 N:N 관계로 중간에 Screening 테이블을 두고 있습니다.
- Theater 별로 시간표가 구분되는 것을 고려하여 Screening 테이블은 상영 시간표 정보를 포함하고 있습니다.
- 좌석별 등급 등 좌석 개별의 특성이 추가될 수 있다고 생각하여 Seat 테이블을 생성하였습니다.
- Theater 테이블과 Seat 테이블은 1:N 관계입니다.
- Seat 테이블과 Reservation 테이블은 1:1 관계입니다.
- 공유 자원인 Seat과 행위인 Reservation을 분리하기 위함입니다.
- Reservation 테이블과 User 테이블은 1:1 관계입니다.

## N+1 문제 해결
저는 N+1 문제가 ID 참조을 사용하기 때문이라고 생각합니다. 따라서 해당 프로젝트에 간접참조를 사용하여, N+1 문제를 해결하고자 합니다.
뿐만 아니라, 간접 참조를 사용하면 도메인 간 물리적인 연결을 제거하기 때문에 도메인 간 의존을 강제적으로 제거되고, FK의 데이터 정합성을 따지기 위한 성능 오버헤드를 줄이며, 데이터 수정 시의 작업 순서가 강제됨에 따라 발생하는 더 큰 수정 개발을 방지할 수 있습니다.

대신, 애플리케이션 단에서 무결성 관리를 관리하도록 합니다. (삽입/수정/삭제)

## 성능 테스트 보고서
- DAU: 500명
- duration : '5m' 으로 진행
- 95% 요청이 200ms 이하, 실패율 1% 이하를 thresholds로 추가
### 1. INDEX 적용 전
- 영화 300개, 상영관 4개, 시간표 1,000개
- 실행 계획
```sql
EXPLAIN ANALYZE SELECT m.id, m.name, m.grade, m.release_date, m.thumbnail, m.running_time, m.genre, t.id, t.name, s.start_at, s.end_at FROM movie m JOIN screening s ON m.id = s.movie_id JOIN theater t ON s.theater_id = t.id WHERE s.start_at >= NOW();
```
![img_3.png](img_3.png)

-> 풀테이블 스캔 전략
- 부하테스트 <br>
![img_4.png](img_4.png)

-> 95% 응답 시간이 1.1s 이므로 실패

### 2. INDEX 적용 후
- Screening 테이블에 start_at에 대한 인덱스 생성
- 영화 300개, 상영관 4개, 시간표 1,000개
- 실행 계획 <br>
![img_5.png](img_5.png)

-> 인덱스 레인지 전략
- 부하테스트 <br>
![img_6.png](img_6.png)

-> 95% 응답 시간이 12.67ms로, 성능이 약 86배 향상

### 3. Local Caching 적용 후
* 현재 상영 중인 영화 조회 쿼리는, LocalDateTime을 기준으로 필터링하기 때문에, 캐싱에 의미가 없습니다. 따라서, start_at 기준을 뺀, 상영중인 영화가 아닌 모든 영화를 조회하는 API를 가지고 캐싱을 진행하였습니다.
=> nowShowing에 대한 쿼리 파라미터를 추가하여 TRUE이면 상영중인 영화, FALSE 이면 모든 영화가 조회되도록 코드를 수정하였습니다.
- 데이터를 영화 500개, 상영관 3개, 시간표 3,000개로 늘렸습니다.
- 실행 계획
```sql
EXPLAIN ANALYZE SELECT m.id, m.name, m.grade, m.release_date, m.thumbnail, m.running_time, m.genre, t.id, t.name, s.start_at, s.end_at FROM movie m JOIN screening s ON m.id = s.movie_id JOIN theater t ON s.theater_id = t.id WHERE m.genre = 'HORROR' AND m.name LIKE '%3%';
```

- 성능 개선 전 부하 테스트(쿼리가 수정되고 데이터가 늘어나서 다시 수행) <br>
![img_7.png](img_7.png)

-> 95%의 응답 시간이 747.93ms 이므로 실패
- 로컬 캐싱 적용 후 부하 테스트 <br>
![img_8.png](img_8.png)

-> 95%의 응답 시간이 6.38ms 로, 성능이 약 117배 향상되었습니다.

### 4. Global Caching 적용 후
![img_9.png](img_9.png)

-> 95%의 응답 시간이 7.33ms로, 로컬 캐싱보다는 느리지만 캐싱 적용 전보다 훨씬 성능이 향상되었습니다.
52 changes: 52 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}

repositories {
mavenCentral()
}

subprojects {
// 모든 하위 모듈들에 이 설정을 적용
group 'com.example'
version '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
targetCompatibility = '17'

apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

dependencies { // 모든 하위 모듈에 추가 될 의존성 목록입니다.
implementation 'org.springframework.boot:spring-boot-starter'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
testImplementation "org.testcontainers:testcontainers:1.19.0"
testImplementation "org.testcontainers:junit-jupiter:1.19.0"

implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'

}

test {
useJUnitPlatform()
}
}
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: '3.8'
services:
mysql:
image: mysql:latest # 이미 로컬에 있는 MySQL 이미지 사용
environment:
MYSQL_ROOT_PASSWORD: 1234 # 루트 계정 비밀번호
MYSQL_DATABASE: hanghae99 # 생성할 기본 데이터베이스
MYSQL_USER: user # 사용자 계정 (선택)
MYSQL_PASSWORD: password # 사용자 비밀번호 (선택)
ports:
- "3305:3306" # 호스트의 3306 포트를 컨테이너의 3306 포트와 연결

redis:
image: redis:latest # 이미 로컬에 있는 Redis 이미지 사용
ports:
- "6378:6379" # 호스트의 6378 포트를 컨테이너의 6379 포트와 연결
9 changes: 9 additions & 0 deletions domain/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
bootJar.enabled = false

jar.enabled = true

dependencies {
implementation project(':infra')

api ('org.springframework.boot:spring-boot-starter-validation')
}
15 changes: 15 additions & 0 deletions domain/src/main/java/com/example/message/MessageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.message;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MessageService {

public void send() throws InterruptedException {
Thread.sleep(500);
log.info("예매가 성공적으로 되었습니다.");
}

}
57 changes: 57 additions & 0 deletions domain/src/main/java/com/example/movie/converter/DtoConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.example.movie.converter;

import com.example.jpa.repository.movie.dto.MoviesDetailDto;
import com.example.movie.dto.MoviesDetailResponse;
import com.example.movie.dto.ScreeningTimeDetail;
import com.example.movie.dto.ScreeningsDetail;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

@Component
@NoArgsConstructor
public class DtoConverter {
public List<MoviesDetailResponse> moviesNowScreening(List<MoviesDetailDto> dbResults) {
return dbResults.stream()
.collect(Collectors.groupingBy(MoviesDetailDto::getMovieId))
.entrySet().stream()
.map(entry -> {
Long movieId = entry.getKey();
List<MoviesDetailDto> groupedByMovie = entry.getValue();


MoviesDetailDto firstEntry = groupedByMovie.get(0);


List<ScreeningsDetail> screeningsDetails = groupedByMovie.stream()
.collect(Collectors.groupingBy(MoviesDetailDto::getTheaterId))
.entrySet().stream()
.map(theaterEntry -> {
Long theaterId = theaterEntry.getKey();
String theaterName = theaterEntry.getValue().get(0).getTheaterName();
List<ScreeningTimeDetail> screeningTimes = theaterEntry.getValue().stream()
.sorted(Comparator.comparing(MoviesDetailDto::getStartAt))
.map(dto -> new ScreeningTimeDetail(dto.getStartAt(), dto.getEndAt()))
.toList();
return new ScreeningsDetail(theaterId, theaterName, screeningTimes);
})
.toList();

// Create the final DTO
return new MoviesDetailResponse(
movieId, // Add movieId here
firstEntry.getMovieName(),
firstEntry.getGrade(),
firstEntry.getReleaseDate(),
firstEntry.getThumbnail(),
firstEntry.getRunningTime(),
firstEntry.getGenre(),
screeningsDetails
);
})
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.movie.dto;

import com.example.jpa.entity.movie.Genre;
import com.example.jpa.entity.movie.Grade;

import java.time.LocalDate;
import java.util.List;

public record MoviesDetailResponse(
Long movieId,
String movieName,
Grade grade,
LocalDate releaseDate,
String thumbnail,
Long runningTime,
Genre genre,
List<ScreeningsDetail> screeningsDetails
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.movie.dto;

import java.time.LocalDateTime;

public record ScreeningTimeDetail(
LocalDateTime startAt,
LocalDateTime endAt
) {
}
10 changes: 10 additions & 0 deletions domain/src/main/java/com/example/movie/dto/ScreeningsDetail.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.movie.dto;

import java.util.List;

public record ScreeningsDetail(
Long theaterId,
String theater,
List<ScreeningTimeDetail> screeningTimes
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.movie.service;

import com.example.jpa.entity.movie.Genre;
import com.example.jpa.repository.movie.MovieRepository;
import com.example.jpa.repository.movie.dto.MoviesDetailDto;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class MovieCacheService {

private final MovieRepository movieRepository;

@Cacheable(
cacheNames = "getMoviesByGenre",
key = "'movies:genre:' + #p0",
cacheManager = "cacheManager"
)
public List<MoviesDetailDto> getMoviesByGenre(String genre) {
return movieRepository.searchWithFiltering(null, Genre.valueOf(genre), null);
}

}
Loading