diff --git a/src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java b/src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java index 7e4a308..d260d5c 100644 --- a/src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java +++ b/src/main/java/kr/co/knuserver/application/booth/BoothLikeService.java @@ -10,9 +10,9 @@ import kr.co.knuserver.domain.booth.repository.BoothRepository; import kr.co.knuserver.global.exception.BusinessErrorCode; import kr.co.knuserver.global.exception.BusinessException; +import kr.co.knuserver.global.config.LikeProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; @@ -26,20 +26,9 @@ public class BoothLikeService { private static final String RATE_LIMIT_KEY = "like:rate:%s:%s:%d"; private static final String RANKING_KEY = "like:ranking"; - @Value("${like.rate-limit.ttl-seconds}") - private long rateLimitTtlSeconds; - - @Value("${like.double-event.enabled:false}") - private boolean doubleEventEnabled; - - @Value("${like.double-event.start-time:13:00}") - private String doubleEventStart; - - @Value("${like.double-event.end-time:15:00}") - private String doubleEventEnd; - private final StringRedisTemplate redisTemplate; private final BoothRepository boothRepository; + private final LikeProperties likeProperties; public long like(Long boothId, String deviceId, String clientIp) { Booth booth = boothRepository.findById(boothId) @@ -94,12 +83,13 @@ public Set> getTopRanking(int limit) { } private int getLikeMultiplier() { - if (!doubleEventEnabled) { + LikeProperties.DoubleEvent event = likeProperties.doubleEvent(); + if (!event.enabled()) { return 1; } LocalTime now = LocalTime.now(ZoneId.of("Asia/Seoul")); - LocalTime start = LocalTime.parse(doubleEventStart); - LocalTime end = LocalTime.parse(doubleEventEnd); + LocalTime start = LocalTime.parse(event.startTime()); + LocalTime end = LocalTime.parse(event.endTime()); boolean isEventTime = !now.isBefore(start) && now.isBefore(end); if (isEventTime) { log.debug("[DoubleEvent] 2배 이벤트 적용 중 now={}", now); @@ -109,11 +99,9 @@ private int getLikeMultiplier() { private void checkRateLimit(String clientIp, String deviceId, Long boothId) { String rateLimitKey = RATE_LIMIT_KEY.formatted(clientIp, deviceId, boothId); - log.debug("[RateLimit] key={}", rateLimitKey); boolean allowed = Boolean.TRUE.equals( - redisTemplate.opsForValue().setIfAbsent(rateLimitKey, "1", Duration.ofSeconds(rateLimitTtlSeconds)) + redisTemplate.opsForValue().setIfAbsent(rateLimitKey, "1", Duration.ofSeconds(likeProperties.rateLimit().ttlSeconds())) ); - log.debug("[RateLimit] allowed={}", allowed); if (!allowed) { throw new BusinessException(BusinessErrorCode.TOO_MANY_REQUESTS); } diff --git a/src/main/java/kr/co/knuserver/global/config/FilterConfig.java b/src/main/java/kr/co/knuserver/global/config/FilterConfig.java index 37b5029..b89a318 100644 --- a/src/main/java/kr/co/knuserver/global/config/FilterConfig.java +++ b/src/main/java/kr/co/knuserver/global/config/FilterConfig.java @@ -1,7 +1,10 @@ package kr.co.knuserver.global.config; +import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.knuserver.application.booth.DeviceIdGenerator; import kr.co.knuserver.global.filter.DeviceIdCookieFilter; +import kr.co.knuserver.global.filter.LikeRateLimitFilter; +import kr.co.knuserver.global.ratelimit.LikeRateLimiter; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -16,12 +19,24 @@ public class FilterConfig { private int cookieMaxAge; private final DeviceIdGenerator deviceIdGenerator; + private final LikeRateLimiter likeRateLimiter; + private final ObjectMapper objectMapper; @Bean public FilterRegistrationBean deviceIdCookieFilter() { DeviceIdCookieFilter filter = new DeviceIdCookieFilter(cookieMaxAge, deviceIdGenerator); FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); bean.addUrlPatterns("/api/v1/booths/*"); + bean.setOrder(2); + return bean; + } + + @Bean + public FilterRegistrationBean likeRateLimitFilter() { + LikeRateLimitFilter filter = new LikeRateLimitFilter(likeRateLimiter, objectMapper); + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.addUrlPatterns("/api/v1/booths/*"); + bean.setOrder(3); return bean; } } diff --git a/src/main/java/kr/co/knuserver/global/config/LikeProperties.java b/src/main/java/kr/co/knuserver/global/config/LikeProperties.java new file mode 100644 index 0000000..fe7da2b --- /dev/null +++ b/src/main/java/kr/co/knuserver/global/config/LikeProperties.java @@ -0,0 +1,21 @@ +package kr.co.knuserver.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "like") +public record LikeProperties( + RateLimit rateLimit, + DoubleEvent doubleEvent +) { + + public record RateLimit( + long ttlSeconds, + long maxLikes + ) {} + + public record DoubleEvent( + boolean enabled, + String startTime, + String endTime + ) {} +} diff --git a/src/main/java/kr/co/knuserver/global/filter/LikeRateLimitFilter.java b/src/main/java/kr/co/knuserver/global/filter/LikeRateLimitFilter.java new file mode 100644 index 0000000..28a27ee --- /dev/null +++ b/src/main/java/kr/co/knuserver/global/filter/LikeRateLimitFilter.java @@ -0,0 +1,62 @@ +package kr.co.knuserver.global.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import kr.co.knuserver.global.ratelimit.LikeRateLimiter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +@Slf4j +@RequiredArgsConstructor +public class LikeRateLimitFilter implements Filter { + + private final LikeRateLimiter likeRateLimiter; + private final ObjectMapper objectMapper; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + if (!isLikesPath(httpRequest)) { + chain.doFilter(request, response); + return; + } + + String clientIp = (String) httpRequest.getAttribute(ClientIpFilter.CLIENT_IP_ATTRIBUTE); + String deviceId = (String) httpRequest.getAttribute(DeviceIdCookieFilter.DEVICE_ID_ATTRIBUTE); + + if (!likeRateLimiter.isAllowed(clientIp, deviceId)) { + log.warn("[LikeRateLimit] 한도 초과 ip={} deviceId={}", clientIp, deviceId); + sendTooManyRequestsResponse(httpResponse); + return; + } + + chain.doFilter(request, response); + } + + private boolean isLikesPath(HttpServletRequest request) { + return "POST".equalsIgnoreCase(request.getMethod()) + && request.getRequestURI().matches(".*/booths/[^/]+/likes$"); + } + + private void sendTooManyRequestsResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + objectMapper.writeValueAsString(Map.of("result", "TOO_MANY_REQUESTS")) + ); + } +} diff --git a/src/main/java/kr/co/knuserver/global/property/PropertiesConfig.java b/src/main/java/kr/co/knuserver/global/property/PropertiesConfig.java new file mode 100644 index 0000000..f3a8d3f --- /dev/null +++ b/src/main/java/kr/co/knuserver/global/property/PropertiesConfig.java @@ -0,0 +1,10 @@ +package kr.co.knuserver.global.property; + +import kr.co.knuserver.global.config.LikeProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(LikeProperties.class) +public class PropertiesConfig { +} diff --git a/src/main/java/kr/co/knuserver/global/ratelimit/LikeRateLimiter.java b/src/main/java/kr/co/knuserver/global/ratelimit/LikeRateLimiter.java new file mode 100644 index 0000000..3f0230c --- /dev/null +++ b/src/main/java/kr/co/knuserver/global/ratelimit/LikeRateLimiter.java @@ -0,0 +1,37 @@ +package kr.co.knuserver.global.ratelimit; + +import java.time.Duration; +import kr.co.knuserver.global.config.LikeProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LikeRateLimiter { + + private static final String KEY = "like:rate:%s:%s"; + private static final Duration WINDOW = Duration.ofMinutes(10); + + private final StringRedisTemplate redisTemplate; + private final LikeProperties likeProperties; + + public boolean isAllowed(String clientIp, String deviceId) { + String key = KEY.formatted(clientIp, deviceId); + long maxLikes = likeProperties.rateLimit().maxLikes(); + + String countStr = redisTemplate.opsForValue().get(key); + long currentCount = countStr == null ? 0 : Long.parseLong(countStr); + + if (currentCount >= maxLikes) { + return false; + } + + Long newCount = redisTemplate.opsForValue().increment(key); + if (newCount == 1) { + redisTemplate.expire(key, WINDOW); + } + + return true; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f85bac0..ff73789 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,11 @@ device-id: like: rate-limit: ttl-seconds: ${LIKE_RATE_LIMIT_TTL_SECONDS:1} + max-likes: ${LIKE_RATE_LIMIT_MAX_LIKES:600} + double-event: + enabled: ${LIKE_DOUBLE_EVENT_ENABLED:false} + start-time: ${LIKE_DOUBLE_EVENT_START:12:00} + end-time: ${LIKE_DOUBLE_EVENT_END:14:00} cors: allowed-origins: ${ALLOWED_ORIGINS}