-
Notifications
You must be signed in to change notification settings - Fork 37
[4주차] RateLimit 적용, 비관락/Redisson 분산락 적용 #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a743a66
f26fd2b
a4bbba4
7d1be8a
8f5db33
c03a112
97fe8ec
9f15018
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
조회 API에 RateLimit 적용 및 429 응답을 주는점 좋습니다 👍