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
2 changes: 1 addition & 1 deletion api/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
dependencies {
implementation project(':application')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.google.guava:guava:33.4.0-jre'
}
11 changes: 11 additions & 0 deletions api/src/main/java/com/example/aop/MovieSearchRateLimited.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MovieSearchRateLimited {
}
50 changes: 50 additions & 0 deletions api/src/main/java/com/example/aop/RateLimitAspect.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.example.aop;

import com.example.config.ratelimit.RateLimit;
import com.example.exception.BusinessError;
import com.example.reservation.request.ReservationRequest;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

private final RateLimit rateLimit;

@Around("@annotation(movieSearchRateLimited)")
public Object around(ProceedingJoinPoint joinPoint, MovieSearchRateLimited movieSearchRateLimited) throws Throwable {

HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String ip = request.getRemoteAddr();

if (!rateLimit.isMovieSearchAllowed(ip)) {
throw BusinessError.MOVIE_SEARCH_MAX_FIND_ERROR.exception();
}

return joinPoint.proceed();
}

@Around("@annotation(reservationRateLimited)")
public Object around(ProceedingJoinPoint joinPoint, ReservationRateLimited reservationRateLimited) throws Throwable {
Copy link
Contributor

Choose a reason for hiding this comment

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

RateLimit 어노테이션과 AOP 를 잘 활용해주셨네요 👍🏿

Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof com.example.reservation.request.ReservationRequest) {
ReservationRequest request = (ReservationRequest) arg;
if (!rateLimit.isReservationAllowed(request.getMemberId(), request.getScreeningId())) {
throw BusinessError.RESERVATION_RATE_LIMIT_ERROR.exception();
}

}
}

return joinPoint.proceed();
}
}
11 changes: 11 additions & 0 deletions api/src/main/java/com/example/aop/ReservationRateLimited.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ReservationRateLimited {
}
Copy link
Contributor

Choose a reason for hiding this comment

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

val ttl: Int = 1, // 호출 제한 시간
val count: Int, // 분당 호출 제한 카운트
val timeUnit: TimeUnit = TimeUnit.HOURS

이런 옵션들도 설정 가능하게끔 하는 것을 고려해보는 것도 좋을 것 같아요. Controller 에서 API 의 제약조건에 대해서 더 명확하게 알 수 있을 것 같습니다.

16 changes: 16 additions & 0 deletions api/src/main/java/com/example/config/advice/ExceptionAdvice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.example.config.advice;

import com.example.exception.BusinessException;
import com.example.response.ApiResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ExceptionAdvice {

@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> BusinessExceptionHandler(BusinessException e) {
return ApiResponse.businessException(e.getHttpStatus(), e.getCode(), e.getMessage());
}

}
93 changes: 93 additions & 0 deletions api/src/main/java/com/example/config/ratelimit/GuavaRateLimit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.example.config.ratelimit;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class GuavaRateLimit implements RateLimit {

private static final double PERMITS_PER_SECOND = 2.0;
private static final int MAX_REQUEST_PER_MINUTE = 50;

private final Cache<String, Boolean> blockedIpCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build();

private final Cache<String, Integer> requestCountCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();

private final Cache<String, RateLimiter> ipRateLimiterCache = CacheBuilder.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();

private final Cache<String, Long> reservationCache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES) // 5분 동안 캐시 유지
.build();

@Override
public boolean isMovieSearchAllowed(String ip) {
if (isBlocked(ip)) {
return false;
}

RateLimiter rateLimiter = getRateLimiter(ip);
if (!rateLimiter.tryAcquire()) {
return false;
}

int count = incrementRequestCount(ip);
if (count > MAX_REQUEST_PER_MINUTE) {
blockIp(ip);
return false;
}

return true;
}

@Override
public boolean isReservationAllowed(Long memberId, Long screeningId) {

String key = memberId + "_" + screeningId;

// null이면 예약 내역이 없다는 거니까
// 데이터를 만들어줘야지
if (reservationCache.getIfPresent(key) == null) {
reservationCache.put(key, System.currentTimeMillis());
return true;
}

return false;
}

private boolean isBlocked(String ip) {
return blockedIpCache.getIfPresent(ip) != null;
}

private void blockIp(String ip) {
blockedIpCache.put(ip, Boolean.TRUE);
}

private RateLimiter getRateLimiter(String ip) {
RateLimiter rateLimiter = ipRateLimiterCache.getIfPresent(ip);
if (rateLimiter == null) {
rateLimiter = RateLimiter.create(PERMITS_PER_SECOND);
ipRateLimiterCache.put(ip, rateLimiter);
}
return rateLimiter;
}

private int incrementRequestCount(String ip) {
Integer count = requestCountCache.getIfPresent(ip);
if (count == null) {
count = 0;
}
count++;
requestCountCache.put(ip, count);
return count;
}
}
9 changes: 9 additions & 0 deletions api/src/main/java/com/example/config/ratelimit/RateLimit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.config.ratelimit;

public interface RateLimit {

Copy link
Contributor

Choose a reason for hiding this comment

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

RateLimit 기능을 개발할때 인터페이스를 만들어서 객체간의 결합도를 낮췄습니다. 적절하게 인터페이스를 사용했는지 궁금합니다!!

인터페이스를 사용하여 다양한 구현체를 만드는 방식은 좋은 접근 입니다. 클라이언트 측은 인터페이스에 대한 명세만 알고있으면되고, 세부 구현체가 어떤 것인지는 몰라도 되기 때문입니다.

다만, 어떤 구현체를 선택해서 사용할지에 대한 로직도 추가적으로 필요할 것 같습니다.
Guava, Redis 의 RateLimit 활용에 사용을 해주셨는데, 시나리오 특성상 2개의 구현체가 존재해야하므로 잘 써주신 것 같아요.

다만 실무적으로 본다면 분산 애플리케이션 환경에서는 Redis 만 사용할 가능성이 높기 때문에 인터페이스 없이 바로 구현체를 활용할 수 있을 것 같습니다.

아이디어랑 접근 방식은 좋습니다 💯

boolean isMovieSearchAllowed(String ip);

boolean isReservationAllowed(Long memberId, Long screeningId);

}
7 changes: 5 additions & 2 deletions api/src/main/java/com/example/movie/MovieController.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.example.movie;

import com.example.aop.MovieSearchRateLimited;
import com.example.movie.request.MovieSearchRequest;
import com.example.movie.response.MovieResponse;
import com.example.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -17,7 +19,8 @@ public class MovieController {
private final MovieService movieService;

@GetMapping("/v1/movies")
public List<MovieResponse> getMovies(MovieSearchRequest request) {
return movieService.getMovies(request.toServiceRequest());
@MovieSearchRateLimited
public ApiResponse<List<MovieResponse>> getMovies(MovieSearchRequest request) {
return ApiResponse.ok("영화 목록 조회",movieService.getMovies(request.toServiceRequest()));
Copy link
Contributor

Choose a reason for hiding this comment

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

하드코딩은 제거하면 좋을 것 같습니다 !

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.example.movie.dto.GenreDto;
import lombok.Getter;

import static com.example.exception.BusinessError.*;

@Getter
public class MovieSearchRequest {
private String title;
Expand All @@ -15,10 +17,10 @@ public MovieSearchRequest(String title, String genre) {

private void validate() {
if (this.title != null && this.title.length() > 225) {
throw new IllegalArgumentException("영화 제목은 225자 이하로 입력해주세요");
throw MOVIE_SEARCH_TITLE_ERROR.exception();
}
if (this.genre != null && !GenreDto.isValidGenre(this.genre)) {
throw new IllegalArgumentException("유효하지않은 장르입니다");
throw MOVIE_SEARCH_GENRE_ERROR.exception();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.example.reservation;

import com.example.aop.ReservationRateLimited;
import com.example.reservation.request.ReservationRequest;
import com.example.reservation.response.ReservationServiceResponse;
import com.example.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -14,7 +16,8 @@ public class ReservationController {
private final ReservationService reservationService;

@PostMapping("/v1/reservation")
public ReservationServiceResponse reserve(@RequestBody ReservationRequest request) {
return reservationService.reserve(request.toServiceRequest());
@ReservationRateLimited
public ApiResponse<ReservationServiceResponse> reserve(@RequestBody ReservationRequest request) {
return ApiResponse.created("영화예매 성공", reservationService.reserve(request.toServiceRequest()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

import java.util.List;

import static com.example.exception.BusinessError.*;


@Getter
public class ReservationRequest {
private Long memberId;
Expand All @@ -16,7 +19,20 @@ public ReservationRequest(Long memberId, Long screeningId, List<Long> seatIds) {
this.seatIds = seatIds;
}

private void validate() {
if (memberId == null) {
throw USER_LOGIN_ERROR.exception();
}
if (screeningId == null) {
throw RESERVATION_SCREENING_SELECT_ERROR.exception();
}
if (seatIds.isEmpty()) {
throw RESERVATION_SEAT_SELECT_ERROR.exception();
}
}

public ReservationServiceRequest toServiceRequest() {
this.validate();
return ReservationServiceRequest.builder()
.memberId(memberId)
.screeningId(screeningId)
Expand Down
35 changes: 35 additions & 0 deletions api/src/main/java/com/example/response/ApiResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.response;

import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public class ApiResponse<T> {

Copy link
Contributor

Choose a reason for hiding this comment

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

ApiResponse 도 잘 만들어주셨습니다 👍🏿

private final static int DEFAULT_OK_CODE = 2000;
private final static int DEFAULT_CREATE_CODE = 2001;

private HttpStatus status;
private int code;
private String message;
private T data;

public ApiResponse(HttpStatus status, int code, String message, T data) {
this.status = status;
this.code = code;
this.message = message;
this.data = data;
}

public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(HttpStatus.OK, DEFAULT_OK_CODE, message, data);
}

public static <T> ApiResponse<T> created(String message, T data) {
return new ApiResponse<>(HttpStatus.CREATED, DEFAULT_CREATE_CODE, message, data);
}

public static ApiResponse<Void> businessException(HttpStatus httpstatus, int code, String message) {
return new ApiResponse<>(httpstatus != null ? httpstatus : HttpStatus.BAD_REQUEST, code, message, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.example.config.ratelimit;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class GuavaRateLimitTest {

private final RateLimit rateLimit = new GuavaRateLimit();

@Test
@DisplayName("초당 요청 제한 테스트: 연속 요청 시 per-second RateLimiter가 제한하는지 확인")
void test1() {
Copy link
Contributor

Choose a reason for hiding this comment

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

@DisplayName 을 사용했다고 해서 test 이름을 대충 지어도 되는건 아닙니다 !
오히려 test 이름을 함수로 사용하고 @DisplayName 를 없애는 것을 고려해보는 것도 좋을 것 같습니다.

String ip = "192.168.1.101"; // 테스트용 IP

int allowedCount = 0;
int totalRequests = 10;

for (int i = 0; i < totalRequests; i++) {
if (rateLimit.isAllowed(ip)) {
allowedCount++;
}
}

assertThat(allowedCount < totalRequests).isTrue();
assertThat(allowedCount <= 3).isTrue();
}

@Test
@DisplayName("1분 내 50회 초과 요청 시 IP 차단 테스트: 51번째 요청부터 차단된다")
void test2() throws InterruptedException {
String ip = "192.168.1.101";

for (int i = 0; i < 50; i++) {
assertThat(rateLimit.isAllowed(ip)).isTrue();
Thread.sleep(1000);
}

boolean allowed = rateLimit.isAllowed(ip);
assertThat(allowed).isFalse();

assertThat(rateLimit.isAllowed(ip)).isFalse();
}
}
Loading