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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다.

// validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Guava Rate Limiter
implementation("com.google.guava:guava:31.1-jre")
}

test {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,55 @@
package org.example.controller;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.baseresponse.BaseResponse;
import org.example.dto.request.MoviesFilterRequestDto;
import org.example.dto.response.PlayingMoviesResponseDto;
import org.example.exception.RateLimitExceededException;
import org.example.service.RedisRateLimiterService;
import org.example.service.movie.MovieService;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

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

@Slf4j
@RestController
@RequiredArgsConstructor
public class MovieController {
private final MovieService movieService;
private final RedisRateLimiterService redisRateLimiterService;

@GetMapping("/movies/playing")
public ResponseEntity<List<PlayingMoviesResponseDto>> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto) {
public BaseResponse<List<PlayingMoviesResponseDto>> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto, HttpServletRequest request) {
String clientIp = getClientIP(request);

// IP 차단 여부 확인
Long allowed = redisRateLimiterService.isAllowed(clientIp);
if (allowed == -1) {
throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조회 API에 RateLimit 적용 및 429 응답을 주는점 좋습니다 👍

}

List<PlayingMoviesResponseDto> playingMovies = movieService.getPlayingMovies(moviesFilterRequestDto);
return ResponseEntity.ok(playingMovies);
return new BaseResponse<>(playingMovies);
}

private String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.example.controller;

import lombok.RequiredArgsConstructor;
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;
Expand All @@ -10,15 +12,16 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequiredArgsConstructor
public class ReservationController {
private final ReservationService reservationService;

@PostMapping("/reservation")
public ResponseEntity<String> getPlayingMovies(@RequestBody @Validated ReservationRequestDto reservationRequestDto) {
public BaseResponse<BaseResponseStatus> getPlayingMovies(@RequestBody @Validated ReservationRequestDto reservationRequestDto) {
reservationService.reserveMovie(reservationRequestDto);
return ResponseEntity.ok("예약이 완료되었습니다.");
return new BaseResponse<>(SUCCESS);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
public record ReservationRequestDto(
@NotNull Long usersId,
@NotNull Long screenScheduleId,
@NotNull @Valid List<ReservationSeat> reservationSeats
@NotNull @Valid List<ReservationSeatDto> reservationSeats
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import org.example.domain.seat.Col;
import org.example.domain.seat.Row;

public record ReservationSeat(
public record ReservationSeatDto(
@ValidEnum(enumClass = Row.class, message = "올바른 행 이름을 입력해주세요.")
String row,
@ValidEnum(enumClass = Col.class, message = "올바른 열 이름을 입력해주세요.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.example.service;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;

@Slf4j
@Service
@AllArgsConstructor
public class RedisRateLimiterService {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis를 활용해서 rateLimit 요구사항 구현이 잘된점이 좋네요 👍

private final StringRedisTemplate redisTemplate;

private static final String LUA_SCRIPT =
"local ip = KEYS[1]\n" +
"local blockKey = 'block:' .. ip\n" +
"local requestKey = 'request:' .. ip\n" +

"if redis.call('EXISTS', blockKey) == 1 then\n" +
" return -1\n" +
"end\n" +

"local currentCount = tonumber(redis.call('GET', requestKey) or '0')\n" +
"redis.call('SET', requestKey, currentCount) \n" +

"if currentCount >= tonumber('50') then\n" +
" redis.call('SETEX', blockKey, 3600, 'BLOCKED')\n" +
" redis.call('DEL', requestKey)\n" +
" return -1\n" +
"end\n" +

"currentCount = redis.call('INCR', requestKey)\n" +

"return currentCount";

public Long isAllowed(String ip) {
Long result = redisTemplate.execute(RedisScript.of(LUA_SCRIPT, Long.class), Collections.singletonList(ip));
return result;
}
}
160 changes: 67 additions & 93 deletions module-api/src/main/java/org/example/service/ReservationService.java
Original file line number Diff line number Diff line change
@@ -1,147 +1,121 @@
package org.example.service;

import jakarta.persistence.OptimisticLockException;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.common.exception.SeatException;
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.ReservedSeats;
import org.example.dto.SeatsDto;
import org.example.dto.request.ReservationRequestDto;
import org.example.dto.request.ReservationSeat;
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;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

import static org.example.common.response.BaseResponseStatus.*;
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 static final int MAX_SEAT_COUNT = 5;

private final ReservationJpaRepository reservationJpaRepository;
private final SeatJpaRepository seatJpaRepository;
private final ScreenScheduleJpaRepository screenScheduleJpaRepository;
private final ReservationSeatRepository reservationSeatRepository;
private final RedissonLockUtil redissonLockUtil;

@Transactional
public void reserveMovie(ReservationRequestDto reservationRequestDto) {
validateSeats(reservationRequestDto.reservationSeats());
List<SeatsDto> reservationSeats = new ArrayList<>(
reservationRequestDto.reservationSeats().stream()
.map(seat -> new SeatsDto(Row.valueOf(seat.row()), Col.valueOf(seat.col())))
.toList()
);

Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId());
validateUserReserveSeats(reservationRequestDto.reservationSeats(), reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId());

try {
List<Seat> seats = new ArrayList<>();
for (ReservationSeat reservationSeat : reservationRequestDto.reservationSeats()) {
Row row = Row.valueOf(reservationSeat.row());
Col col = Col.valueOf(reservationSeat.col());
// 예약하려는 좌석 검증
validateSeats(reservationSeats);

Seat seat = seatJpaRepository.findSeats(screenRoomId, row, col)
.orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR));

seats.add(seat);
}
saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats);
} catch (OptimisticLockException e) {
throw new SeatException(CONCURRENT_RESERVATION_ERROR);
}
}
// 사용자가 동일한 상영에 대해 예약한 좌석 검증
validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId());

private void validateSeats(List<ReservationSeat> seats) {
validateSeatCount(seats);
validateContinuousSeats(seats);
}
// 개별 좌석별 락 키 생성
List<String> lockKeys = reservationRequestDto.reservationSeats().stream()
.map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col())
.toList();

private void validateSeatCount(List<ReservationSeat> seats) {
if (seats.size() > MAX_SEAT_COUNT) {
throw new SeatException(MAX_SEATS_EXCEEDED_ERROR);
}
// Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호)
redissonLockUtil.executeWithMultiLock(lockKeys, 5, 10, () -> {
saveReservationWithTransaction(reservationRequestDto, reservationSeats, screenRoomId);
Copy link

@soonhankwon soonhankwon Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

락을 획득한 요청만 Transactional이 적용되도록 설계하신점 좋습니다 👍
다만, "스프링 특성"상 메서드에서 같은 클래스의 메서드를 호출할때 "애노테이션"이 적용되지 않습니다.

  • 해당 문제일때 해결하는 방법을 찾아보시면 좋은 경험이 될 듯 합니다.

return null;
});
}

private void validateContinuousSeats(List<ReservationSeat> seats) {
seats.sort(Comparator.comparing(ReservationSeat::col));

for (int i = 1; i < seats.size(); i++) {
ReservationSeat prev = seats.get(i - 1);
ReservationSeat current = seats.get(i);

if (!prev.row().equals(current.row())) {
throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR);
}
@Transactional
public void saveReservationWithTransaction(ReservationRequestDto reservationRequestDto, List<SeatsDto> reservationSeats, Long screenRoomId) {
// 좌석에 락을 걸고 저장할 좌석들 반환
List<Seat> seats = validateReservedSeats(screenRoomId, reservationSeats);

int prevCol = Col.valueOf(prev.col()).getColumn();
int currentCol = Col.valueOf(current.col()).getColumn();
if (currentCol != prevCol + 1) {
throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR);
// 예약된 좌석인지 검증
for (Seat seat : seats) {
boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(reservationRequestDto.screenScheduleId(), seat.getId()).isPresent();
if (isReserved) {
throw new SeatException(ALREADY_RESERVED_SEAT_ERROR);
}
}
}

private void validateUserReserveSeats(List<ReservationSeat> reservationSeats, Long userId, Long screenScheduleId) {
List<ReservedSeats> reservedSeatsByUserId = reservationJpaRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId);
if (reservedSeatsByUserId.isEmpty()) {
return;
}

isInMaxCount(reservationSeats, reservedSeatsByUserId); // 예약하려는 좌석이 5개 이상인지
containsReservedSeat(reservationSeats, reservedSeatsByUserId); // 이미 예약된 좌석과 겹치는지
isSameRow(reservationSeats, reservedSeatsByUserId); // 좌석이 같은 행에 있는지
isContinuousCol(reservationSeats, reservedSeatsByUserId); // 좌석이 연속된 열인지
Long reservationId = saveReservation(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId());
saveReservationSeats(seats, reservationId);
}

private void isInMaxCount(List<ReservationSeat> reservationSeats, List<ReservedSeats> reservedSeatsByUserId) {
if (reservationSeats.size() + reservedSeatsByUserId.size() > MAX_SEAT_COUNT) {
throw new SeatException(MAX_SEATS_EXCEEDED_ERROR);
}
private void validateSeats(List<SeatsDto> seats) {
Seat.validateCountExceeded(seats.size());
Seat.validateContinuousSeats(seats);
}

private void containsReservedSeat(List<ReservationSeat> reservationSeats, List<ReservedSeats> reservedSeatsByUserId) {
for (ReservedSeats reservedSeats : reservedSeatsByUserId) {
for (ReservationSeat reservationSeat : reservationSeats) {
if (reservedSeats.getRow().equals(Row.valueOf(reservationSeat.row()))
&& reservedSeats.getCol().equals(Col.valueOf(reservationSeat.col()))) {
throw new SeatException(ALREADY_RESERVED_SEAT_ERROR);
}
}
private void validateUserReserveSeats(List<SeatsDto> reservationSeats, Long userId, Long screenScheduleId) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API 구현시 꼼꼼하게 Validation 진행하신 점이 인상깊습니다 👍

List<SeatsDto> reservedSeats = reservationSeatRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId);
if (reservedSeats.isEmpty()) {
return;
}

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

private void isSameRow(List<ReservationSeat> reservationSeats, List<ReservedSeats> reservedSeatsByUserId) {
Row row = reservedSeatsByUserId.get(0).getRow();
for (ReservationSeat reservationSeat : reservationSeats) {
if (!row.equals(Row.valueOf(reservationSeat.row()))) {
throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR);
}
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 void isContinuousCol(List<ReservationSeat> reservationSeats, List<ReservedSeats> reservedSeatsByUserId) {
Col reservedCol = reservedSeatsByUserId.get(reservedSeatsByUserId.size() - 1).getCol();
Col reservationCol = Col.valueOf(reservationSeats.get(0).col());
if (reservationCol.getColumn() != reservedCol.getColumn()+1) {
throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR);
}
private Long saveReservation(Long userId, Long screenScheduleId) {
Reservation reservation = Reservation.of(userId, screenScheduleId);
Reservation savedReservation = reservationJpaRepository.save(reservation);
return savedReservation.getId();
}

private void saveReservation(Long userId, Long screenScheduleId, List<Seat> seats) {
private void saveReservationSeats(List<Seat> seats, Long reservationId) {
for (Seat seat : seats) {
boolean isReserved = reservationJpaRepository.existsByUsersIdAndScreenScheduleIdAndSeatId(userId, screenScheduleId, seat.getId());
if (isReserved) {
throw new SeatException(ALREADY_RESERVED_SEAT_ERROR);
}

Reservation reservation = Reservation.create(userId, screenScheduleId, seat.getId());
reservationJpaRepository.save(reservation);
ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId());
reservationSeatRepository.save(reservationSeat);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ public class FindMovieService {
private final MovieJpaRepository movieJpaRepository;

@Cacheable(value = "playingMovies",
key = "#moviesFilterRequestDto.genre + #moviesFilterRequestDto.playing")
key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') " +
"+ (#moviesFilterRequestDto.playing != null ? #moviesFilterRequestDto.playing : false)")
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.valueOf(moviesFilterRequestDto.getGenre()), moviesFilterRequestDto.isPlaying());
= movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre, moviesFilterRequestDto.isPlaying());
return new FoundMovieScreeningInfoList(movieScreeningInfos);
}
}
Loading