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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -94,12 +83,13 @@ public Set<ZSetOperations.TypedTuple<String>> 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);
Expand All @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/kr/co/knuserver/global/config/FilterConfig.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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() {
DeviceIdCookieFilter filter = new DeviceIdCookieFilter(cookieMaxAge, deviceIdGenerator);
FilterRegistrationBean<DeviceIdCookieFilter> bean = new FilterRegistrationBean<>(filter);
bean.addUrlPatterns("/api/v1/booths/*");
bean.setOrder(2);
return bean;
}

@Bean
public FilterRegistrationBean<LikeRateLimitFilter> likeRateLimitFilter() {
LikeRateLimitFilter filter = new LikeRateLimitFilter(likeRateLimiter, objectMapper);
FilterRegistrationBean<LikeRateLimitFilter> bean = new FilterRegistrationBean<>(filter);
bean.addUrlPatterns("/api/v1/booths/*");
bean.setOrder(3);
return bean;
}
}
21 changes: 21 additions & 0 deletions src/main/java/kr/co/knuserver/global/config/LikeProperties.java
Original file line number Diff line number Diff line change
@@ -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
) {}
}
Original file line number Diff line number Diff line change
@@ -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"))
);
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading