Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,21 @@ export default function() {
- 실패율은 0% 이다.

![img_20.png](img_20.png)
- 최대 TPS는 4.13K 이다.
- 최대 TPS는 4.13K 이다.

# 6. leaseTime, waitTime
```
waitTime: 1초, leaseTime: 5초
```
**waitTime**
- 분산 캐싱 적용 후, 평균 처리 시간이 약 450ms가 소요되었으므로 넉넉하게 1초로 설정했습니다.

**leaseTime**
- leaseTime은 가장 오래 걸린 처리 시간에 영향을 받으므로 waitTime보다 길게 5초로 설정했습니다.
너무 길지 않게 설정해서 다른 요청들이 불필요하게 대기하지 않도록 하였습니다.


# 7. 테스트 커버리지 결과
![img_7.png](img_7.png)

![img_10.png](img_10.png)
17 changes: 17 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
id 'jacoco'
}

bootJar.enabled = false // 빌드시 현재 모듈(multi-module)의 .jar를 생성하지 않습니다.
Expand All @@ -19,6 +20,7 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다.
apply plugin: 'java-library'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'jacoco'

configurations {
compileOnly {
Expand Down Expand Up @@ -72,7 +74,22 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다.
implementation("com.google.guava:guava:31.1-jre")
}

jacoco {
toolVersion = "0.8.10"
}

jacocoTestReport {
dependsOn test

reports {
xml.required = false
csv.required = false
html.required = true // HTML 리포트 생성
}
}

test {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
}
Binary file added img_10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img_7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions module-api/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'java'
id 'jacoco'
}

bootJar.enabled = true
Expand All @@ -13,4 +14,23 @@ dependencies {

testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
}

jacoco {
toolVersion = "0.8.10"
}

jacocoTestReport {
dependsOn test

reports {
xml.required = false
csv.required = false
html.required = true
}
}

test {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import org.example.baseresponse.BaseResponse;
import org.example.baseresponse.BaseResponseStatus;
import org.example.dto.request.ReservationRequestDto;
import org.example.service.ReservationService;
import org.springframework.http.ResponseEntity;
import org.example.service.reservation.ReservationService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
package org.example.dto.request;

import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.example.annotaion.ValidEnum;
import org.example.domain.movie.Genre;

@Setter
@Getter
@AllArgsConstructor
public class MoviesFilterRequestDto {
@Size(max = 255)
private String movieTitle;

@ValidEnum(enumClass = Genre.class, message = "장르는 다음 중 하나여야 합니다: ACTION, ROMANCE, HORROR, SF")
private String genre;

private boolean playing;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package org.example.dto.request;

import jakarta.validation.constraints.NotNull;
import org.example.annotaion.ValidEnum;
import org.example.domain.seat.Col;
import org.example.domain.seat.Row;

public record ReservationSeatDto(
@NotNull
@ValidEnum(enumClass = Row.class, message = "올바른 행 이름을 입력해주세요.")
String row,
@NotNull
@ValidEnum(enumClass = Col.class, message = "올바른 열 이름을 입력해주세요.")
String col
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,15 @@ public class FindMovieService {
private final MovieJpaRepository movieJpaRepository;

@Cacheable(value = "playingMovies",
key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') " +
"+ (#moviesFilterRequestDto.playing != null ? #moviesFilterRequestDto.playing : false)")
key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') + true")
public FoundMovieScreeningInfoList getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) {
Genre genre = null;
if (moviesFilterRequestDto.getGenre() != null) {
genre = Genre.valueOf(moviesFilterRequestDto.getGenre());
}

List<MovieScreeningInfo> movieScreeningInfos
= movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre, moviesFilterRequestDto.isPlaying());
= movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre);
return new FoundMovieScreeningInfoList(movieScreeningInfos);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@
import org.example.dto.response.PlayingMoviesResponseDto;
import org.example.dto.response.ScreeningInfo;
import org.example.dto.response.ScreeningTimeInfo;
import org.example.exception.RateLimitExceededException;
import org.springframework.stereotype.Service;

import java.util.*;

import static org.example.baseresponse.BaseResponseStatus.TOO_MANY_REQUEST_ERROR;

@Slf4j
@Service
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
package org.example.service;
package org.example.service.reservation;

import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.config.RedissonLockUtil;
import org.example.domain.reservation.Reservation;
import org.example.domain.reservationseat.ReservationSeat;
import org.example.domain.seat.Col;
import org.example.domain.seat.Row;
import org.example.domain.seat.Seat;
import org.example.dto.SeatsDto;
import org.example.dto.request.ReservationRequestDto;
import org.example.exception.SeatException;
import org.example.repository.ReservationJpaRepository;
import org.example.repository.ReservationSeatRepository;
import org.example.repository.ScreenScheduleJpaRepository;
import org.example.repository.SeatJpaRepository;
Expand All @@ -21,18 +18,17 @@
import java.util.ArrayList;
import java.util.List;

import static org.example.baseresponse.BaseResponseStatus.ALREADY_RESERVED_SEAT_ERROR;
import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR;

@Slf4j
@Service
@AllArgsConstructor
public class ReservationService {
private final ReservationJpaRepository reservationJpaRepository;
private final SeatJpaRepository seatJpaRepository;
private final ScreenScheduleJpaRepository screenScheduleJpaRepository;
private final ReservationSeatRepository reservationSeatRepository;
private final ScreenScheduleJpaRepository screenScheduleJpaRepository;
private final RedissonLockUtil redissonLockUtil;
private final SaveReservationService saveReservationService;
private final SeatJpaRepository seatJpaRepository;

public void reserveMovie(ReservationRequestDto reservationRequestDto) {
List<SeatsDto> reservationSeats = new ArrayList<>(
Expand All @@ -49,37 +45,34 @@ public void reserveMovie(ReservationRequestDto reservationRequestDto) {
// 사용자가 동일한 상영에 대해 예약한 좌석 검증
validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId());

// 좌석들 반환
List<Seat> seats = validateReservedSeats(screenRoomId, reservationSeats);

// 개별 좌석별 락 키 생성
List<String> lockKeys = reservationRequestDto.reservationSeats().stream()
.map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col())
.toList();

// Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호)
redissonLockUtil.executeWithMultiLock(lockKeys, 5, 10, () -> {
saveReservationWithTransaction(reservationRequestDto, reservationSeats, screenRoomId);
redissonLockUtil.executeWithMultiLock(lockKeys, 1, 5, () -> {
saveReservationService.saveReservationWithTransaction(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats);
return null;
});
}

@Transactional
public void saveReservationWithTransaction(ReservationRequestDto reservationRequestDto, List<SeatsDto> reservationSeats, Long screenRoomId) {
// 좌석에 락을 걸고 저장할 좌석들 반환
List<Seat> seats = validateReservedSeats(screenRoomId, reservationSeats);
public List<Seat> validateReservedSeats(Long screenRoomId, List<SeatsDto> reservationSeats) {
List<Seat> seats = new ArrayList<>();
for (SeatsDto reservationSeat : reservationSeats) {
Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol())
.orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR));

// 예약된 좌석인지 검증
for (Seat seat : seats) {
boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(reservationRequestDto.screenScheduleId(), seat.getId()).isPresent();
if (isReserved) {
throw new SeatException(ALREADY_RESERVED_SEAT_ERROR);
}
seats.add(seat);
}

Long reservationId = saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId());
saveReservationSeats(seats, reservationId);
return seats;
}

private void validateSeats(List<SeatsDto> seats) {
Seat.validateCountExceeded(seats.size());
Seat.validateSeatCount(seats.size());
Seat.validateContinuousSeats(seats);
}

Expand All @@ -89,33 +82,9 @@ private void validateUserReserveSeats(List<SeatsDto> reservationSeats, Long user
return;
}

Seat.validateCountExceeded(reservationSeats.size() + reservedSeats.size()); // 예약하려는 좌석이 5개 이상인지
Seat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지
Seat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지
Seat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지
}

private List<Seat> validateReservedSeats(Long screenRoomId, List<SeatsDto> reservationSeats) {
List<Seat> seats = new ArrayList<>();
for (SeatsDto reservationSeat : reservationSeats) {
Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol())
.orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR));

seats.add(seat);
}
return seats;
}

private Long saveReservation(Long userId, Long screenScheduleId) {
Reservation reservation = Reservation.of(userId, screenScheduleId);
Reservation savedReservation = reservationJpaRepository.save(reservation);
return savedReservation.getId();
}

private void saveReservationSeats(List<Seat> seats, Long reservationId) {
for (Seat seat : seats) {
ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId());
reservationSeatRepository.save(reservationSeat);
}
ReservationSeat.validateCountExceeded(reservationSeats, reservedSeats); // 예약하려는 좌석이 5개 이상인지
ReservationSeat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지
ReservationSeat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지
ReservationSeat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.example.service.reservation;

import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import org.example.domain.reservation.Reservation;
import org.example.domain.reservationseat.ReservationSeat;
import org.example.domain.seat.Seat;
import org.example.dto.SeatsDto;
import org.example.dto.request.ReservationRequestDto;
import org.example.exception.SeatException;
import org.example.repository.ReservationJpaRepository;
import org.example.repository.ReservationSeatRepository;
import org.example.repository.SeatJpaRepository;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

import static org.example.baseresponse.BaseResponseStatus.*;

@Service
@AllArgsConstructor
public class SaveReservationService {
private final ReservationJpaRepository reservationJpaRepository;
private final ReservationSeatRepository reservationSeatRepository;

@Transactional
public void saveReservationWithTransaction(Long userId, Long screenScheduleId, List<Seat> seats) {
// 예약된 좌석인지 검증
for (Seat seat : seats) {
boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(screenScheduleId, seat.getId()).isPresent();
if (isReserved) {
throw new SeatException(CONCURRENT_RESERVATION_ERROR);
}
}

Long reservationId = saveReservation(userId, screenScheduleId);
saveReservationSeats(seats, reservationId);
}

private Long saveReservation(Long userId, Long screenScheduleId) {
Reservation reservation = Reservation.of(userId, screenScheduleId);
Reservation savedReservation = reservationJpaRepository.save(reservation);
return savedReservation.getId();
}

private void saveReservationSeats(List<Seat> seats, Long reservationId) {
for (Seat seat : seats) {
ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId());
reservationSeatRepository.save(reservationSeat);
}
}
}
Loading