Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
73f6296
Refactor : 메서드명 수정
CJ-1998 Apr 4, 2025
11b160b
Refactor : 불필요한 annotation 제거
CJ-1998 Apr 4, 2025
5d3b11b
Feat : 락 획득 실패 예외 시 예외 응답 커스텀화
CJ-1998 Apr 6, 2025
f33c02e
Refactor : reservation valid 클래스 생성
CJ-1998 Apr 6, 2025
ec963df
Refactor : DB 관련 설정 수정
CJ-1998 Apr 6, 2025
3cea6bd
Fix : ApiControllerAdvice 오류 수정
CJ-1998 Apr 6, 2025
f5cb3c7
Chore : Guava dependency 추가
CJ-1998 Apr 6, 2025
7ba10b8
Feat : rate limiter에 필요한 annotation 생성
CJ-1998 Apr 6, 2025
9ad401b
Feat : Rate Limiter 로직 인터페이스 생성
CJ-1998 Apr 6, 2025
d991f5a
Feat : Guava 사용 RateLimiter 구현체 구현 중
CJ-1998 Apr 6, 2025
84f87d6
Fix : RateLimit 관련 annotation 수정
CJ-1998 Apr 6, 2025
b7223bc
Feat : 조회 요청 Rate Limit 로직 구현
CJ-1998 Apr 6, 2025
c3bfc96
Feat : 조회 Rate Limit 위한 Aspect 구현
CJ-1998 Apr 6, 2025
24bc008
Feat : Ip 주소 얻기 위한 config 생성
CJ-1998 Apr 6, 2025
63821bd
Feat : 조회 api에 IP 확인 위한 로직 추가
CJ-1998 Apr 6, 2025
a4635c7
Feat : 조회 api 로직에 Rate Limit annotation 추가
CJ-1998 Apr 6, 2025
1f3348d
Test : 조회 API 테스트 위한 클래스 생성
CJ-1998 Apr 6, 2025
97ac96c
Feat : 조회 API Rate Limiter 구현체에 예외 처리
CJ-1998 Apr 6, 2025
92c9d2d
Test : 조회 Rate Limit 테스트 코드 구현
CJ-1998 Apr 6, 2025
085e811
Chore : application 테스트 시 controller 오류 해결
CJ-1998 Apr 6, 2025
24de87e
Fix : 캐시 annotation 주석 처리
CJ-1998 Apr 6, 2025
2f7161e
Test : 조회 Rate Limit 정상 동작 테스트 추가
CJ-1998 Apr 6, 2025
eefc9eb
Rename : 조회용 Rate Limiter 패키지 이동
CJ-1998 Apr 6, 2025
99620c6
Feat : 예약 Rate Limit 위한 annotation 생성
CJ-1998 Apr 6, 2025
939718e
Feat : 예약 api의 Rate Limiter 구현
CJ-1998 Apr 6, 2025
5282d05
Feat : 예약 api의 Rate Limit 위한 Ascpect 구현
CJ-1998 Apr 6, 2025
5c593d2
Feat : 예약 api에 Rate Limiter 적용
CJ-1998 Apr 6, 2025
8619216
Test : 예약 api Rate Limit 테스트
CJ-1998 Apr 6, 2025
701e34b
Rename : Rate Limiter들 이름 수정
CJ-1998 Apr 6, 2025
7a33ced
Feat : Redis 이용한 예약 API Rate Limiter 구현
CJ-1998 Apr 6, 2025
b99e965
Test : Redis 이용 예약 Api Rate Limter 테스트
CJ-1998 Apr 6, 2025
f856cb8
Feat : Redis 사용해 조회 API Rate Limiter 구현
CJ-1998 Apr 6, 2025
01fe34a
Test : Redis 사용한 조회 API Rate Limiter 테스트
CJ-1998 Apr 6, 2025
a7e0eb3
Test : 테스트 코드 설정 수정
CJ-1998 Apr 7, 2025
57ea9d3
Chore : jacoco 설정 추가
CJ-1998 Apr 7, 2025
492f78c
Chore : application 모듈 테스트를 위한 데이터 추가
CJ-1998 Apr 7, 2025
092aeac
Test : 테스트 코드 수정
CJ-1998 Apr 7, 2025
6a81678
Chore : jacoco 전체 리포트 나오도록 수정
CJ-1998 Apr 7, 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
126 changes: 92 additions & 34 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,45 +1,103 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.3'
id 'io.spring.dependency-management' version '1.1.7'
id 'java'
id 'org.springframework.boot' version '3.4.3'
id 'io.spring.dependency-management' version '1.1.7'
id 'jacoco'
}

repositories {
mavenCentral()
mavenCentral()
}

bootJar{
enabled=false
bootJar {
enabled = false
}

subprojects {
group = 'project.redis'
version = '0.0.1-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

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

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
useJUnitPlatform()
}
group = 'project.redis'
version = '0.0.1-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

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

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}

test {
useJUnitPlatform()
finalizedBy 'jacocoTestReport'
}

jacoco {
toolVersion = "0.8.11"
}

jacocoTestReport {
dependsOn test

reports {
html.required = true
}
}
}

task jacocoRootReport(type: JacocoReport) {
group = "verification"
description = "Generates an aggregate JaCoCo coverage report."

dependsOn subprojects.test // 모든 모듈 테스트 먼저 수행

def execFiles = files()
def classDirs = files()
def sourceDirs = files()

subprojects.each { subproject ->
def buildDir = subproject.buildDir

// exec 파일
def execFile = file("${buildDir}/jacoco/test.exec")
if (execFile.exists()) {
execFiles += execFile
}

// 클래스 디렉토리
def classDir = file("${buildDir}/classes/java/main")
if (classDir.exists()) {
classDirs += classDir
}

// 소스 디렉토리
def srcDir = file("${subproject.projectDir}/src/main/java")
if (srcDir.exists()) {
sourceDirs += srcDir
}
}

executionData.setFrom(execFiles)
classDirectories.setFrom(classDirs)
sourceDirectories.setFrom(sourceDirs)

reports {
html.required.set(true)
html.outputLocation = layout.buildDirectory.dir("jacocoRootReport/html")
xml.required.set(true)
csv.required.set(false)
}
}
16 changes: 8 additions & 8 deletions init/ddl.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-- Cinema 테이블 생성
CREATE TABLE IF NOT EXISTS cinema (
cinema_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 영화관 ID
cinema_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 영화관 ID
cinema_name VARCHAR(255) NOT NULL -- 영화관 이름
created_by BIGINT NULL, -- BaseEntity 필드
created_at DATETIME NULL, -- BaseEntity 필드
Expand All @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS cinema (

-- Movie 테이블 생성
CREATE TABLE IF NOT EXISTS movie (
movie_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 영화 ID
movie_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 영화 ID
title VARCHAR(255) NOT NULL, -- 영화 제목
rating VARCHAR(50) NOT NULL, -- 영화 등급 (Enum 값으로 저장, 예: 'G', 'PG', 'R' 등)
released_at DATE NOT NULL, -- 개봉일
Expand All @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS movie (

-- Screening 테이블 생성
CREATE TABLE IF NOT EXISTS screening (
screening_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 상영 ID
screening_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 상영 ID
started_at DATETIME NOT NULL, -- 상영 시작 시간
ended_at DATETIME NOT NULL, -- 상영 종료 시간
movie_id BIGINT NOT NULL, -- 영화 ID (Foreign Key)
Expand All @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS screening (

-- Seat 테이블 생성
CREATE TABLE IF NOT EXISTS seat (
seat_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 좌석 ID
seat_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 좌석 ID
seat_row VARCHAR(10) NOT NULL, -- 좌석 행 (예: A, B, C 등)
seat_column INT NOT NULL, -- 좌석 열 (예: 1, 2, 3 등)
created_by BIGINT NULL, -- BaseEntity 필드
Expand All @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS seat (

-- Theater 테이블 생성
CREATE TABLE IF NOT EXISTS theater (
theater_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 극장 ID
theater_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 극장 ID
theater_name VARCHAR(255) NOT NULL, -- 극장 이름
cinema_id BIGINT NOT NULL, -- 영화관 ID (Foreign Key)
created_by BIGINT NULL, -- BaseEntity 필드
Expand All @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS theater (

-- User 테이블 생성
CREATE TABLE IF NOT EXISTS user (
user_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 사용자 ID
user_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 사용자 ID
username VARCHAR(255) NOT NULL, -- 사용자 이름
created_by BIGINT NULL, -- BaseEntity 필드
created_at DATETIME NULL, -- BaseEntity 필드
Expand All @@ -73,7 +73,7 @@ CREATE TABLE IF NOT EXISTS user (

-- Reservation 테이블 생성
CREATE TABLE IF NOT EXISTS reservation (
reservation_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 예약 ID
reservation_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -- 예약 ID
screening_id BIGINT NOT NULL, -- 상영 ID (Foreign Key)
seat_id BIGINT NOT NULL, -- 좌석 ID (Foreign Key)
user_id BIGINT NOT NULL, -- 사용자 ID (Foreign Key)
Expand All @@ -87,6 +87,6 @@ CREATE TABLE IF NOT EXISTS reservation (
);


CREATE INDEX idx_movie_released_title_genre ON movie(released_at, title, genre);
CREATE INDEX idx_movie_released_title_genre ON movie(title, genre, released_at);
CREATE INDEX idx_screening_started_at ON screening(started_at);

1 change: 1 addition & 0 deletions module_application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
implementation project(':module_domain')
implementation project(':module_infrastructure')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'

// Caffeine cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import project.redis.movie.MovieGenre;
import project.redis.movie.dto.NowPlayMovieDto;
import project.redis.ratelimiter.fetchratelimiter.LimitRequestPerTime;
import project.redis.screening.dto.ScreeningResponseDto;
import project.redis.screening.dto.ScreeningTimeDto;
import project.redis.screening.repository.ScreeningRepositoryCustom;
Expand All @@ -21,8 +21,9 @@ public class MovieQueryService {
private final ScreeningRepositoryCustom screeningRepository;

// @Cacheable(cacheNames = "movieCache")
@Cacheable(cacheNames = "redisCache")
public List<NowPlayMovieDto> getNowPlayingMovies(String movieTitle, String movieGenre) {
// @Cacheable(cacheNames = "redisCache")
@LimitRequestPerTime(key = "#clientIp")
public List<NowPlayMovieDto> getNowPlayingMovies(String movieTitle, String movieGenre, String clientIp) {
MovieGenre movieGenreEnum = getMovieGenre(movieGenre);

List<ScreeningResponseDto> nowPlayingMovies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ private List<Movie> findNowPlayingMovies(List<Movie> movies) {
private List<NowPlayMovieDto> makeNowPlayingMoviesInfo(List<Movie> movies) {
List<NowPlayMovieDto> nowPlayMovieDtos = new ArrayList<>();
for (Movie movie : movies) {
List<Screening> movieAllScreening = screeningAdapter.findScreeningsByMovie(movie);
List<Screening> movieAllScreening = screeningAdapter.findScreenings(movie);

Map<String, List<Screening>> cinemaNameScreening = mapByCinemaName(movieAllScreening);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import org.springframework.transaction.annotation.Transactional;
import project.redis.lock.DistributedLock;
import project.redis.message.MessageService;
import project.redis.ratelimiter.reserveratelimiter.LimitReservationPerTime;
import project.redis.reservation.Reservation;
import project.redis.reservation.adapter.ReservationAdapter;
import project.redis.reservation.dto.ReservationSeatsRequestDto;
import project.redis.reservation.dto.ReservationSeatsResponseDto;
import project.redis.reservation.validator.ReservationValidator;
import project.redis.screening.Screening;
import project.redis.screening.adapter.ScreeningAdapter;
import project.redis.seat.Seat;
Expand All @@ -32,11 +34,13 @@ public class ReservationService {
private final ReservationAdapter reservationAdapter;
private final SeatAdapter seatAdapter;
private final MessageService messageService;
private final ReservationValidator reservationValidator;

@Transactional
@DistributedLock(key = "seat-lock:#reservationSeatsRequestDto.userId")
public ReservationSeatsResponseDto reservationSeats(ReservationSeatsRequestDto reservationSeatsRequestDto) {
validReservationSeatsRequestDto(reservationSeatsRequestDto);
@LimitReservationPerTime(userId = "#reservationSeatsRequestDto.userId", screeningId = "#reservationSeatsRequestDto.screeningId")
public ReservationSeatsResponseDto reserveSeats(ReservationSeatsRequestDto reservationSeatsRequestDto) {
reservationValidator.valid(reservationSeatsRequestDto);

User user = findUserByUserId(reservationSeatsRequestDto);
Screening screening = findScreeningByScreeningId(reservationSeatsRequestDto);
Expand All @@ -51,45 +55,16 @@ public ReservationSeatsResponseDto reservationSeats(ReservationSeatsRequestDto r
return ReservationSeatsResponseDto.of(user.getUserId(), reservationsId);
}

private void validReservationSeatsRequestDto(ReservationSeatsRequestDto requestDto) {
List<String> seatRows = requestDto.getSeatRows();
List<Integer> seatColumns = requestDto.getSeatColumns();

if (seatRows.isEmpty() || seatColumns.isEmpty()) {
throw new IllegalArgumentException("seatRows 또는 seatColumns가 비어 있습니다.");
}

if (seatRows.size() != seatColumns.size()) {
throw new IllegalArgumentException("seatRows와 seatColumns의 개수가 같지 않습니다.");
}

long seatRowCount = seatRows.stream().distinct().count();
if (seatRowCount > 1) {
throw new IllegalArgumentException("예약하려는 좌석들의 행이 이어 붙어 있는 형태가 아닙니다.");
}

long seatColumnCount = seatColumns.stream().distinct().count();
if (seatColumnCount != seatColumns.size()) {
throw new IllegalArgumentException("예약하려는 좌석의 열이 중복됩니다.");
}

for (int index = 1; index < seatColumns.size(); index++) {
if (seatColumns.get(index) != seatColumns.get(index - 1) + 1) {
throw new IllegalArgumentException("예약하려는 좌석의 열이 연속되는 형태가 아닙니다.");
}
}
}

private User findUserByUserId(ReservationSeatsRequestDto reservationSeatsRequestDto) {
User user = userAdapter.findUserById(reservationSeatsRequestDto.getUserId());
User user = userAdapter.find(reservationSeatsRequestDto.getUserId());
if (user == null) {
throw new IllegalArgumentException("존재하지 않는 유저 id 입니다.");
}
return user;
}

private Screening findScreeningByScreeningId(ReservationSeatsRequestDto reservationSeatsRequestDto) {
Screening screening = screeningAdapter.findScreeningById(reservationSeatsRequestDto.getScreeningId());
Screening screening = screeningAdapter.findScreening(reservationSeatsRequestDto.getScreeningId());
if (screening == null) {
throw new IllegalArgumentException("존재하지 않는 상영 id 입니다.");
}
Expand All @@ -115,7 +90,7 @@ private List<Long> createReservations(ReservationSeatsRequestDto reservationSeat
String seatRow = reservationSeatsRequestDto.getSeatRows().get(index);
Integer seatColumn = reservationSeatsRequestDto.getSeatColumns().get(index);

checkReservedSeat(reservations, seatRow, seatColumn);
containsSameSeat(reservations, seatRow, seatColumn);

Long savedReservationId = createReservation(seatRow, seatColumn, screening, user);
reservationsId.add(savedReservationId);
Expand All @@ -127,18 +102,18 @@ private int getReservationsSize(ReservationSeatsRequestDto reservationSeatsReque
return reservationSeatsRequestDto.getSeatRows().size();
}

private void checkReservedSeat(List<Reservation> reservations, String seatRow, Integer seatColumn) {
boolean isAlreadyReserved = reservations.stream()
.anyMatch(reservation -> reservation.isSeatReserved(seatRow, seatColumn));
private void containsSameSeat(List<Reservation> reservations, String seatRow, Integer seatColumn) {
boolean hasSameSeat = reservations.stream()
.anyMatch(reservation -> reservation.checkSameSeat(seatRow, seatColumn));

if (isAlreadyReserved) {
if (hasSameSeat) {
throw new IllegalArgumentException("현재 예약된 좌석은 예약할 수 없습니다.");
}
}

private Long createReservation(String seatRow, Integer seatColumn, Screening screening, User user) {
Seat reservedSeat = Seat.of(seatRow, seatColumn);
SeatEntity seatEntity = seatAdapter.saveSeat(reservedSeat);
SeatEntity seatEntity = seatAdapter.save(reservedSeat);

Reservation reservation = Reservation.create(reservedSeat, screening, user);
Long savedReservationId = reservationAdapter.saveReservation(reservation, seatEntity);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package project.redis.reservation.validator;

import java.util.List;
import org.springframework.stereotype.Component;
import project.redis.reservation.dto.ReservationSeatsRequestDto;

@Component
public class ReservationValidator {

public void valid(ReservationSeatsRequestDto requestDto) {
List<String> seatRows = requestDto.getSeatRows();
List<Integer> seatColumns = requestDto.getSeatColumns();

if (seatRows.isEmpty() || seatColumns.isEmpty()) {
throw new IllegalArgumentException("seatRows 또는 seatColumns가 비어 있습니다.");
}

if (seatRows.size() != seatColumns.size()) {
throw new IllegalArgumentException("seatRows와 seatColumns의 개수가 같지 않습니다.");
}

long seatRowCount = seatRows.stream().distinct().count();
if (seatRowCount > 1) {
throw new IllegalArgumentException("예약하려는 좌석들의 행이 이어 붙어 있는 형태가 아닙니다.");
}

long seatColumnCount = seatColumns.stream().distinct().count();
if (seatColumnCount != seatColumns.size()) {
throw new IllegalArgumentException("예약하려는 좌석의 열이 중복됩니다.");
}

for (int index = 1; index < seatColumns.size(); index++) {
if (seatColumns.get(index) != seatColumns.get(index - 1) + 1) {
throw new IllegalArgumentException("예약하려는 좌석의 열이 연속되는 형태가 아닙니다.");
}
}
}
}
Loading