
우아한테크코스에서 아맞다 서비스를 기획 및 개발중에 있습니다.
아맞다는 지난 데모데이때, 메인화면 api에 대해서 11만개의 리퀘스트를 받았습니다.

모니터링 당시 가장 오래 걸리는 api가 0.5초, 그 외 메인 화면 api들을 합쳐서 약 1~2초 가량의 응답속도를 보이는 것을 확인할 수 있었습니다.
각 api는 1초 미만들이지만, 메인 화면에 대해서는 1초가 넘어가는 상황입니다.


- 대부분의 기능은 로그인 이후에만 이루어지며, 각 유저는 식별이 가능했습니다.
- 또한 11만 리퀘스트는 현실적으로 현재 서비스를 예상되는 사용자수가 아니었습니다.
- 하지만 악의적인 유저로 인해 실제 유저가 불편함을 겪는 문제는 발생할 수 있습니다.
위 사항들을 고려하여 ddos,dos 공격을 방어하기 위한 방어선을 구축하게 되었습니다.
dos 공격에 대해서는, 이전 포스팅에서 nginx의 rate limit 기능을 활용하여 DOS 공격에 대한 방어선을 구축하였습니다.
참고
ddos 공격에도 어느정도 서비스 안정성을 고려할 방법을 찾아야 했습니다.
여러가지 방법들로 어느정도 빠르게 응답을 내는 방법들 입니다.
하지만 순간적으로 몰리는 요청에 대한 근본적인 해결책은 되지않는다 판단하였습니다.

- 대부분의 기능은 로그인 이후에만 이루어지며, 각 유저는 식별이 가능했습니다.

필터단에서 인메모리 방식으로 구현된 코드입니다.
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 2) // 필터에서 3번째로 실행됨
@RequiredArgsConstructor
public class SlidingWindowRateLimitFilter extends OncePerRequestFilter {
private static final long WINDOW_MILLIS = 60_000;
private static final int MAX_REQUESTS = 100;
private final HeaderProvider headerProvider;
private final JwtProvider jwtProvider;
private final ObjectMapper objectMapper;
private final Map<Long, Deque<Long>> requestLogs = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(
final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain
) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
Long memberId = extractMemberIdSafely(authorizationHeader);
if (memberId == null) {
filterChain.doFilter(request, response);
return;
}
long now = Instant.now().toEpochMilli();
Deque<Long> timestamps = requestLogs.computeIfAbsent(memberId, id -> new ConcurrentLinkedDeque<>());
if (isRateLimited(timestamps, now)) {
respondTooManyRequests(request, response, timestamps, now);
return;
}
filterChain.doFilter(request, response);
}
private boolean isRateLimited(final Deque<Long> timestamps, final long now) {
synchronized (timestamps) {
while (!timestamps.isEmpty() && timestamps.peekFirst() < now - WINDOW_MILLIS) {
timestamps.pollFirst();
}
if (timestamps.size() >= MAX_REQUESTS) {
return true;
}
timestamps.addLast(now);
return false;
}
}
private void respondTooManyRequests(
final HttpServletRequest request,
final HttpServletResponse response,
final Deque<Long> timestamps,
final long now
) throws IOException {
long retryAfterSeconds = calculateRetryAfterSeconds(timestamps, now);
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
problemDetail.setTitle("Too Many Requests");
problemDetail.setDetail("요청이 너무 많습니다. 약 " + retryAfterSeconds + "초 후 다시 시도해 주세요.");
problemDetail.setInstance(URI.create(request.getRequestURI()));
response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfterSeconds));
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.getWriter()
.write(objectMapper.writeValueAsString(problemDetail));
}
private long calculateRetryAfterSeconds(final Deque<Long> timestamps, final long now) {
synchronized (timestamps) {
if (timestamps.isEmpty()) {
return 1;
}
long oldest = timestamps.peekFirst();
long retryMillis = (oldest + WINDOW_MILLIS) - now;
return Math.max(1, (retryMillis + 999) / 1000);
}
}
private Long extractMemberIdSafely(final String authorizationHeader) {
try {
String accessToken = headerProvider.extractAccessToken(authorizationHeader);
return jwtProvider.parseAccessPayload(accessToken)
.getMemberId();
} catch (Exception e) {
return null;
}
}
}
private Long extractMemberIdSafely(final String authorizationHeader) {
try {
String accessToken = headerProvider.extractAccessToken(authorizationHeader);
return jwtProvider.parseAccessPayload(accessToken)
.getMemberId();
} catch (Exception e) {
return null;
}
}
private boolean isRateLimited(final Deque<Long> timestamps, final long now) {
synchronized (timestamps) {
while (!timestamps.isEmpty() && timestamps.peekFirst() < now - WINDOW_MILLIS) {
timestamps.pollFirst();
}
if (timestamps.size() >= MAX_REQUESTS) {
return true;
}
timestamps.addLast(now);
return false;
}
}
private void respondTooManyRequests(
final HttpServletRequest request,
final HttpServletResponse response,
final Deque<Long> timestamps,
final long now
) throws IOException {
long retryAfterSeconds = calculateRetryAfterSeconds(timestamps, now);
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
problemDetail.setTitle("Too Many Requests");
problemDetail.setDetail("요청이 너무 많습니다. 약 " + retryAfterSeconds + "초 후 다시 시도해 주세요.");
problemDetail.setInstance(URI.create(request.getRequestURI()));
response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfterSeconds));
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.getWriter()
.write(objectMapper.writeValueAsString(problemDetail));
}
이제 처리율 제한 로직이 잘 동작하는지, 부하테스트를 통해 확인해보겠습니다.
부하 테스트는 JMeter를 사용하였습니다.

| Label | # Samples | Average | Min | Max | Std. Dev | Error % | Throughput | Received KB/sec | Sent KB/sec |
|---|---|---|---|---|---|---|---|---|---|
| get all events | 150000 | 322 | 14 | 2557 | 273.82 | 0.00% | 481.9/sec | 118.09 | 180.19 |
| get organization | 150000 | 286 | 11 | 2510 | 250.22 | 0.00% | 481.9/sec | 205.55 | 161.42 |
| TOTAL | 300000 | 304 | 11 | 2510 | 257.72 | 0.00% | 963.5/sec | 323.57 | 341.54 |
active user 300명을 가정, 각 유저가 500개의 요청을 보낸다고 가정
active user는 300명이지만 애플리케이션내에서 식별되는 유저는 한명
처리율 제한으로 인해 많은 요청이 429로 쳐내질 것이므로 응답속도가 향상될것으로 예상


| Label | # Samples | Average | Min | Max | Std. Dev | Error % | Throughput | Received KB/sec | Sent KB/sec | Avg. Bytes |
|---|---|---|---|---|---|---|---|---|---|---|
| get all events | 150000 | 121 | 8 | 14347 | 519.51 | 99.91% | 1201.4/sec | 426.71 | 470.49 | 363.79 |
| get organization | 150000 | 121 | 7 | 14315 | 525.38 | 99.88% | 1202.0/sec | 426.74 | 469.61 | 363.50 |
| TOTAL | 300000 | 121 | 7 | 14347 | 522.76 | 99.89% | 2402.7/sec | 853.17 | 974.01 | 363.56 |
15만개의 요청에 대해 동일한 유저의 요청이므로 모두 걸러지면서 에러율이 99.91%, 메인하면 기준 평균 응답속도는 0.2초로 약 50% 개선된 것을 확인할 수 있습니다.
그러나 높아진 MAX 지표가 눈여겨 볼만합니다.



| Label | # Samples | Average | Min | Max | Std. Dev | Error % | Throughput | Received KB/sec | Sent KB/sec | Avg. Bytes |
|---|---|---|---|---|---|---|---|---|---|---|
| get all events | 150000 | 376 | 0 | 24637 | 645.72 | 98.58% | 511.2/sec | 346.72 | 175.64 | 694.95 |
| get organization | 150000 | 163 | 0 | 19478 | 438.59 | 98.52% | 511.4/sec | 312.88 | 146.96 | 626.55 |
| TOTAL | 300000 | 269 | 0 | 24637 | 562.11 | 98.53% | 1022.3/sec | 659.43 | 322.52 | 660.59 |
이제 ddos 공격을 하려면 그만큼의 구글 로그인 계정과, 그만큼의 ip가 필요합니다.

| Label | # Samples | Average | Min | Max | Std. Dev | Error % | Throughput | Received KB/sec | Sent KB/sec | Avg. Bytes |
|---|---|---|---|---|---|---|---|---|---|---|
| get all events | 16325 | 2583 | 165 | 21138 | 1830.48 | 0.00% | 60.7/sec | 26.77 | 29.81 | 440.02 |
| get organization | 16183 | 2338 | 42 | 16131 | 1824.26 | 0.00% | 60.2/sec | 26.80 | 19.68 | 456.92 |
| TOTAL | 32508 | 2461 | 42 | 21138 | 1882.35 | 0.00% | 120.6/sec | 49.47 | 2437.15 |
호기심에 정말 많은 부하를 걸기 위해 500명의 유저를 가정, 유저당 request를 300번 보내도록 테스트하였습니다.
Average가 5초까지 도달합니다.
active user 500명이 rts 300개의 요청을 처리하기에는 부하테스트시에 기기 사양이 부족한지 아니면 서버가 버티지 못하는것일지 너무 많은 시간이 걸려서 중간에 중지하였습니다.
위 요청이 쓰기 요청이 없는 조회 요청만 존재하는 메인화면에 대한 부하테스트라는 것을 고려했을때, 많은 요청에 대해서 대비되지 않는 것을 확인할 수 있습니다.
nginx를 이미 사용하고 있으니, 여기에 로드밸런스 역할을 추가하였습니다.
upstream backend_servers {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
요청 ip에 대해 인스턴스를 맵핑하는 방법도 있지만, 우테코 내에서는 같은 ip를 사용하고 있기에 데모데이에 인스턴스 분산 효과를 누리지 못합니다.
이제, 인스턴스를 3배로 늘려봅시다.

위와 같이 동일한 역할을 하는 인스턴스를 3개 추가하였습니다.
또한 이전과 동일하게 약 300명의 멤버가 500개의 request를 보낸 경우에 15만개에 대한 요청 부하 테스트를 해보았습니다.

| Label | # Samples | Average | Min | Max | Std. Dev | Error % | Throughput | Received KB/sec | Sent KB/sec | Avg. Bytes |
|---|---|---|---|---|---|---|---|---|---|---|
| get all events | 150000 | 162 | 0 | 20384 | 782.10 | 45.59% | 745.2/sec | 906.51 | 159.57 | 1245.7 |
| get organization | 150000 | 127 | 0 | 20392 | 532.44 | 44.56% | 745.4/sec | 958.17 | 136.11 | 1316.3 |
| TOTAL | 300000 | 144 | 0 | 20392 | 689.29 | 44.97% | 1490.2/sec | 1864.27 | 295.62 | 1281.0 |
이제 대비는 끝났습니다.
런칭데이때는 어떨까요?

많은 회원가입과..

많은 이벤트들이 열렸고..

저희도 이벤트를 열었습니다!



gpt에게 모자이크해달라고 하니 뭔가뭔가임..
ㅋㅋㅋㅋㅋㅋㅋ 고생했다잉