본문 바로가기
AI_RSS_트래픽 프로젝트

AI_RSS_분석_대용량트래픽 프로젝트 cpu2,8Gib 단일 인스턴스 성능 개선기

by chol_rang 2026. 3. 31.

오늘로써 대충 프로젝트가 마무리가 됐다.

사실 시간과 내 머리가 더 좋았다면 프론트랑 성능또한 더 확연하게 좋아졌겠지만 일단 이정도로 마무리 하기로 했다. 

https://aitrend.홈페이지.한국

 

트렌드 분석 대시보드

 

aitrend.xn--hu5b25b77nvwc.xn--3e0b707e

 

해당 주소가 단일 인스턴스로 RSS+스크래핑+분석+AI까지 진행한 프로젝트 결과물이다. 

 

이 글에서는
AWS의 프리티어 인스턴스 모델인 m7i-flex.large(vCPU2+8GB메모리)만 사용한 단일 인스턴스

Locust+K6를 활용한 동시 1,000명 기준 부하 테스트를 13차례 반복진행하면서 평균 응답 시간을

54초에서 28ms까지 성능 향상을 도모한 과정을 기록한다.

매 단계별로 최대한 성능 기록을 측정하려고 했기 때문에, 어떤 작업이 실제로 효과가 좋고 얼마나 좋아졌는지 추적할 수 있게끔 진행했다. 

 

참고로.... 글이 많이 깁니다... 

개선 여정만 보셔도 무방하도록 작성해보겠습니다.

 

최종 성적표

지표 1차 (2026-03-09) 13차 (2026-03-30) 개선율
평균 응답 54,800ms 28.4ms 99.9% 감소 (약 1,930배)
95th percentile 109,000ms 69ms 99.9% 감소 (약 1,579배)
99th percentile 측정 불가 190ms -
RPS 3.3 478.0 약 144배 증가
실패율 3.0% 0% 완전 해소
처리 건수 396건 / 2분 143,521건 / 5분 동일 조건 대비 363배 이상

 


개선 여정

차수 날짜 주요 변경 평균 응답 95% RPS 실패율
1차 03-09 초기 상태 54,800ms 104,000ms 3.3 3.0%
3차 03-11 Rate Limit 제거 7,600ms 11,000ms 9.6 0%
5차 03-11 페이지네이션 + 경량 Serializer 2,800ms 6,000ms 19.6 0%
7차 03-16 Redis 목록 캐시 도입 (10,000명) 860ms 3,200ms 335.6 0%
8차 03-17 gevent worker 전환 1,820ms 4,400ms 261.4 2.86%
9-1차 03-26 Caddy + N+1 해결 1,048ms 2,600ms 310.3 1.96%
10-1차 03-27 Gunicorn 안정화 옵션 추가 612ms 2,000ms 362.3 0%
11차 03-27 max-requests 제거 431ms 1,300ms 390.1 0%
11-3차 (k6) 03-27 RATE=400 안정 검증 76.4ms 308ms 391.9 0%
12-1차 03-29 defer + 경량 Serializer 전면 적용 63.3ms 260ms 460.7 0%
13차 03-30 COUNT/GROUP BY Redis 캐싱 28.4ms 69ms 478.0 0%

 


 

각 단계 상세 기록 

최초 1차 테스트 기록

 

Locust_2026-03-09-15h28 (2분, 동시 사용자 최대 1,000명, 총 396 요청)

지표 기준(목표) 테스트결과 판정
Failure % 0% 12건 / 396건 ≈ 3.0% (전부 429 Too Many Requests) 나쁨 — Rate limit 완화·조정 필요
RPS 부하 대비 유지, 50명 기준 10 RPS 이상 약 3.3 RPS (동시 1,000명 구간) 나쁨 — 목표 대비 낮고, 부하 증가 시 RPS 저하
일반 API 평균/95% < 200ms / < 500ms 전체 평균 54.8초, 95% 104초 (health·news·social 등 모두 50초대) 나쁨 — 목표 대비 크게 초과
무거운 API (QA) 평균 < 5초, 99% < 15초 평균 83.4초, 95% 99초, 99% 99초 나쁨 — 목표 대비 크게 초과
Response time 추이 부하 올려도 수평 유지 부하 증가에 따라 지속 상승 (초반 0.2초 → 말기 55초대) 나쁨 — 병목·한계 구간

 

1차 테스트는 진짜 너무 처참했다.

도저히 시중에 풀 수 없는 속도로 1,000명이 아니라 200명만 되도 평균값이 3초가 걸렸다.

물론 AI+RAG+vectorDB+추론+결과까지 진행한 내용이라 더욱 그럴수도있겠지만 그걸 감안해도 너무 속도가 처참했다. 

위의 결과가 마지막에 얼마나 낮아졌는지 체감 할 수 있는 가장 큰 사진이다. 


1단계 — Rate Limit 제거 (3차, 2026-03-11)

세 번째 테스트를 진행하였으나 2차부터는 일단은 1000명은 커녕 100명도 제대로 소화하지 못할거같아 당분간은 100명으로 테스트를 진행했다.

 

1차 테스트에서 실패율 3%의 원인은 Rate Limit(429 Too Many Requests)이었다. Throttle 클래스가 조회 API 전체에 걸려 있었고, 1,000명이 동시에 요청하자 즉시 429를 반환했다.

이 Throttle은 최종 테스트 진행 이후 다시 적용시킬 예정으로 ec2의 .env에 설정을 해두었다. 

# 변경 전 — 조회 API에 무조건 Throttle 적용
class ReadAPIThrottle(AnonRateThrottle):
    rate = "300/min"

# 변경 후 — DISABLE_READ_API_THROTTLE=true 환경변수로 비활성화 가능
class ReadAPIThrottle(AnonRateThrottle):
    def allow_request(self, request, view):
        if settings.DISABLE_READ_API_THROTTLE:
            return True
        return super().allow_request(request, view)

 

세 번째 테스트 (2026-03-11 15h13, Rate limit 비활성화)

 

Locust_2026-03-11-15h13 (2분, 동시 사용자 100명, 총 1,152 요청, 실패 0건)

지표 기준(이때 당시 목표) 테스트 결과 판정
Failure % 0% 0건 / 1,152건 = 0% (429 없음) 좋음 — 목표 달성
RPS 부하 대비 유지, 50명 기준 10 RPS 이상 약 9.6 RPS (100명 구간) 보통 — 목표 10 RPS에 근접, 2차(25 RPS)보다 낮음
일반 API 평균/95% < 200ms / < 500ms 전체 평균 7.6초, 95% 11초 나쁨 — 목표 200ms/500ms 대비 크게 초과
무거운 API (QA) 평균 < 5초, 99% < 15초 평균 12.1초, 95% 22초, 99% 22초 나쁨 — 목표 대비 2배 이상
Response time 추이 부하 올려도 수평 유지 100명 유지 구간에서 0.5초대 → 7.6초대로 지속 상승 나쁨 — 병목·한계 구간

 

1~2차 vs 3차 요약: 3차는 Rate limit을 끄고 동일 100명으로 테스트. 실패율 0%로 개선되었으나,
429가 사라지면서 모든 요청이 서버를 통과해 응답 시간이 2차보다 악화 (평균 1.87초 → 7.6초, 95% 6.3초 → 11초)
RPS는 25 → 9.6으로 감소.
즉, 제한을 풀었을 때 처리량·지연 모두 한계에 도달한 상태로 보는 것이 타당함.

Rate Limit이 요청을 막아서 숨겨져 있던 진짜 병목이 드러난 것이었다.

1차: 평균 54,800ms / RPS  3.3 / 실패 3.0% (429)
3차: 평균  7,600ms / RPS  9.6 / 실패 0%

Rate Limit을 풀기 전에 응답 크기와 DB 부하를 먼저 잡았어야 했다.


2단계 — 페이지네이션 + 경량 Serializer (5차, 2026-03-11)

응답 속도가 7초 넘게 걸리는 직접적인 이유를 찾아보니 analysis-results API 하나의 응답이 5.5MB였다. 두 가지가 원인이었다.

첫째로는 pagination_class = None으로 설정돼 있어서 최대 1,000건을 한 번에 내려보내고 있었다.

둘째는 result_data 필드 하나가 건당 수백 KB짜리 거대한 JSON이었는데, 이걸 목록 조회 시에도 전부 직렬화하고 있었다.

# 변경 전 — 1,000건 한 번에, result_data까지 전부 직렬화
class TrendAnalysisResultViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = TrendAnalysisResult.objects.all()[:1000]
    serializer_class = TrendAnalysisResultSerializer  # 전체 필드
    pagination_class = None

# 변경 후 — 20건씩 페이지네이션, 목록/상세 Serializer 분리
class AnalysisResultPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

class TrendAnalysisResultViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = TrendAnalysisResult.objects.all()
    pagination_class = AnalysisResultPagination

    def get_serializer_class(self):
        if self.action == "list":
            return TrendAnalysisResultListSerializer  # id, analysis_type, status, created_at 등 경량 필드만
        return TrendAnalysisResultSerializer  # 상세 조회에서만 전체 필드

TrendAnalysisResultListSerializer에서는 result_data, summary, parameters 필드를 완전히 제외했다.

상세 조회(/id/)에서는 기존 Serializer를 그대로 사용해 기능 변경 없이 응답 크기만 줄였다. BaseAnalysisViewSet에도 동일하게 적용해서 keywords 등 다른 분석 API도 같이 개선됐다.

 

다섯 번째 테스트 (2026-03-11 16h24, 워커 4 + analysis-results 페이지네이션·경량 Serializer)

 

Locust_2026-03-11-16h24 (2분, 동시 사용자 100명, 총 2,355 요청, 실패 0건)

지표 기준(당시 목표) 테스트 결과 판정
Failure % 0% 0건 / 2,355건 = 0% (429 없음) 좋음 — 목표 달성
RPS 부하 대비 유지, 50명 기준 10 RPS 이상 약 19.6 RPS (100명 구간) 좋음 — 4차(9.3)의 2배 이상, 목표 10 RPS 초과
일반 API 평균/95% < 200ms / < 500ms 전체 평균 2.8초, 95% 6초 나쁨 — 목표에는 미달이나 4차(8.1초·27초) 대비 대폭 개선
무거운 API (QA) 평균 < 5초, 99% < 15초 평균 8.9초, 95% 33초, 99% 34초 나쁨 — 목표에는 미달, 4차(16.1초)보다 개선
Response time 추이 부하 올려도 수평 유지 초반 0.1~0.7초대 → 중간 7초대 스파이크 → 후반 2.8초대로 안정 보통 — 4차 대비 구간 내 수평에 가깝게 유지

요약: 워커 4 + analysis-results 페이지네이션 + 목록 경량 Serializer 적용 후 동일 100명·Rate limit 비활성화로 재테스트.

RPS 9.3 → 19.6으로 약 2배 증가. 전체 평균 응답 시간 8.1초 → 2.8초, 95% 27초 → 6초로 크게 개선.

analysis-results 목록 응답 크기가 5.5MB급 → ~1.5KB 수준으로 줄어 직렬화·네트워크 부하가 감소한 효과.


3단계 — Redis 목록 캐시 도입 (7차, 2026-03-16)

페이지네이션 이후에도 목록 API가 매 요청마다 DB를 조회하는 구조는 그대로였다.

1,000명이 동시에 같은 목록을 요청해도 DB 쿼리가 1,000번 실행됐다.

common/list_cache.py를 신규 모듈로 만들어서 캐시 키 생성, get/set, TTL 관 리를 중앙화했다.

# common/list_cache.py — 캐시 키는 prefix + 쿼리파라미터 조합
def make_list_cache_key(request, prefix: str) -> str:
    params = sorted(request.query_params.items())
    query_string = "&".join(f"{k}={v}" for k, v in params)
    return f"{prefix}:{query_string}"

def get_cached_list_response(request, prefix: str):
    key = make_list_cache_key(request, prefix)
    return cache.get(key)

def set_cached_list_response(request, prefix: str, data: dict) -> None:
    key = make_list_cache_key(request, prefix)
    ttl = get_list_cache_ttl(prefix)
    cache.set(key, data, ttl)

각 ViewSet의 list() 메서드에서 Redis 캐시 히트 시 DB를 아예 건너뛰도록 적용했다.

# dashboard/views.py — news, social 목록에 캐시 적용
def list(self, request, *args, **kwargs):
    cached = get_cached_list_response(request, "dashboard:news")
    if cached is not None:
        return Response(cached)
    response = super().list(request, *args, **kwargs)
    set_cached_list_response(request, "dashboard:news", response.data)
    return response

같은 방식을 analyzer/views.py(analysis-results, keywords)와 user_qa/views.py(history)에도 동일하게 적용했다.

 

일곱 번째 테스트 (2026-03-16 19h49, 동시 1,000명, Redis 적용 후)

 

Locust_2026-03-16-19h49 (3분 5초, 동시 사용자 10,000명, user_qa_query는 @task(0)으로 제외). 목록 API에 Redis 캐시 적용 이후 상태.

지표 기준(당시 목표) 테스트 결과 판정
Failure % 0% 0건 / 62,137건 = 0% 좋음 — 목표 달성
RPS 부하 대비 유지, 50명 기준 10 RPS 이상 약 335.6 RPS (10,000명 구간) 좋음 — 매우 높은 처리량
일반 API 평균/95% < 200ms / < 500ms 전체 평균 0.86초(857ms), 95% 3.2초(3200ms) 나쁨 — 목표에는 미달, 5차(2.8초/6초)보다는 개선
무거운 API (QA) 평균 < 5초, 99% < 15초 QA POST는 이번 테스트에서 제외 -
Response time 추이 부하 올려도 수평 유지 10,000명 유지 구간에서 평균 ~0.8초, 95% ~3초대 수준 유지 보통 — 3·4·5차보다 안정적이지만 목표보다는 느림

요약:

  • Redis 적용 후 동시 10,00명에서 실패율 0%, 총 RPS 약 335.6으로 매우 높은 처리량을 유지했다.
  • Redis 캐시·analysis-results 최적화로 6차(Redis 적용 전) 대비 평균 21.8초 → 0.86초, 95% 47초 → 3.2초로 응답 시간이 크게 개선되었다.
  • 다만 프로젝트 목표(일반 API 평균 < 200ms, 95% < 500ms) 관점에서는 여전히 목표 미달이며, 99% 지연이 ~7초 수준으로 꼬리 지연도 남아 있다.

CloudWatch MCP 실측(해당 시간창):

  • Logs Insights(trend/web-access) 처리 로그: 50,257건
  • 5xx 로그: 18건 (약 0.04%)
  • 비고: trend/web-access는 Caddy 경유 요청 기준이라 Locust 총 요청과 1:1로 일치하지 않을 수 있음.
  • EC2 CPUUtilization: 평균 56.97%, 최대 88.20%
  • EC2 mem_used_percent(CWAgent): 평균 35.08%, 최대 38.05%
  • RDS CPUUtilization: 평균 4.49%, 최대 4.78%
  • RDS ReadIOPS / WriteIOPS: 평균 6.37 / 3.63, 최대 11.92 / 9.71
  • 한 줄 판정: 앱(EC2) 중심 부하가 크고 DB(RDS)는 비교적 여유인 구간.

이때부터 드디어 최저 응답속도가 500ms 이하로 내려오기 시작했다.


4단계 — gevent worker 전환 (8차, 2026-03-17)

Gunicorn의 기본 sync worker는 요청 하나를 처리하는 동안 I/O 대기(DB, Redis, 외부 API 호출 등)가 발생하면 그 시간 동안 다른 요청을 처리하지 못한다. 동시 연결이 많아질수록 병목이 된다.

# docker-compose.yml 변경 전
gunicorn --workers 4 --timeout 120

# 변경 후
gunicorn --workers 4 --worker-class gevent --worker-connections 1000 --timeout 120

gevent는 I/O 대기 구간에서 다른 greenlet으로 컨텍스트를 넘겨 한 워커가 여러 요청을 동시에 처리할 수 있게 한다. 이후 nofile 제한도 1024 → 4096으로 올리고 worker-connections를 1000 → 500으로 줄여 안정성을 잡았다.

처리량은 높아졌지만 이 시점에 연결 리셋 계열 실패가 2.86% 남아 있었다. 원인은 이후 단계에서 해결됐다.

 

여덟 번째 테스트 (2026-03-17 12h58, Redis + gevent, 동시 1,000명)

 

Locust_Redis+gevent 설정 후 천명 테스트 (5분 1초, 동시 사용자 1,000명, QA POST 제외)

지표기준 (목표)여덟 번째 테스트 결과판정

지표 기준(해당 목표) 테스트 결과 판정
Failure % 0% 2,175건 / 76,020건 ≈ 2.86% 나쁨 — 연결 끊김 계열 실패 존재
RPS 부하 대비 유지,
50명 기준 10 RPS 이상
현재 약 261.4 RPS (총 평균 약 253.0 RPS) 좋음 — 처리량은 높음
일반 API 평균/95% < 200ms / < 500ms 전체 평균 1.82초(1824ms),
95% 4.4초(4400ms)
나쁨 — 목표 미달
무거운 API (QA) 평균 < 5초,
99% < 15초
QA POST 제외, history 평균 1.93초, 99% 5.0초 참고
Response time 추이 부하 올려도 수평 유지 평균 1~2초대, 95% 4초대 구간 유지 보통 — 고부하에서 지연 존재

 

CloudWatch 실측:

  • Logs Insights(trend/web-access) 처리 로그: 12,364건
  • 5xx 로그: 28건 (약 0.23%)
  • EC2 CPUUtilization: 평균 45.98%, 최대 48.02%
  • EC2 mem_used_percent(CWAgent): 평균 59.71%, 최대 62.14%
  • RDS CPUUtilization: 평균 4.56%, 최대 5.15%
  • RDS ReadIOPS / WriteIOPS: 평균 4.99 / 2.59, 최대 12.21 / 9.64

왜 7차보다 8차가 나빠졌는가(해석):

  • 8차는 gevent 도입으로 동시 처리 잠재력은 확보했지만, 당시 설정(worker-connections 과다 등)과 연결 안정성 문제가 함께 나타나 연결 리셋/종료 실패(2.86%)가 발생했다.
  • 도입 초기 튜닝 미완성 + 애플리케이션 병목 잔존이 겹친 과도기 구간으로 보였다.

5단계 — Caddy 전환 + N+1 해결 (9차, 2026-03-26)

리버스 프록시를 Nginx에서 Caddy로 교체했다. 설정 관리가 단순해지고 HTTP/2가 기본으로 지원됐다.

동시에 N+1 쿼리 문제를 잡았다. 뉴스 기사 목록 API에서 각 기사의 source_detail을 직렬화할 때, source_id별 article_count를 구하기 위해 게시물 한 건마다 별도의 COUNT 쿼리가 나가고 있었다. 20건 목록이면 최소 20번의 추가 쿼리가 실행된 셈이다.

# 변경 전 — 매 source마다 article_count를 개별 쿼리로 조회 (N+1)
def to_representation(self, instance):
    representation = super().to_representation(instance)
    representation["article_count"] = instance.newsarticle_set.count()  # 건마다 SELECT COUNT(*)
    return representation
# 변경 후 — 목록 직렬화 시 source_id 전체를 한 번에 GROUP BY 집계 후 context로 공유
def to_representation(self, instance):
    if "source_article_count_map" not in self.context:
        parent_instance = getattr(getattr(self, "parent", None), "instance", None)
        if parent_instance is not None:
            source_ids = {
                obj.source_id for obj in parent_instance
                if getattr(obj, "source_id", None) is not None
            }
            rows = (
                NewsArticle.objects.filter(source_id__in=source_ids)
                .values("source_id")
                .annotate(total=Count("id"))
            )
            self.context["source_article_count_map"] = {
                row["source_id"]: row["total"] for row in rows
            }

    # 이후 호출부터는 context의 맵에서 O(1)으로 조회
    count_map = self.context.get("source_article_count_map", {})
    representation["article_count"] = int(count_map.get(instance.id, 0))
    return representation

목록 직렬화 시 source_id 전체를 한 번에 GROUP BY로 집계하고 결과를 context에 저장한다. 이후 같은 요청 내에서 각 기사를 직렬화할 때는 이미 계산된 맵에서 값을 꺼내오기만 한다. N번의 쿼리가 1번으로 줄었다. BaseSocialMediaPostSerializer에도 동일한 패턴을 적용했다.

 

아래는 N+1 문제 해결 전 실행 SQL 과 해결 이후 실행 SQL 및 요약 본이다. 

문제 해결 전

[db_profiling] 직렬화 뉴스 목록 (NewsArticleSerializer many, source_detail→article_count): queries=51, sql_time=532.00ms, wall=736.06ms

--- 실행 SQL (51건, connection.queries) ---
1. [15.00ms] SELECT "data_collector_newsarticle"."id", "data_collector_newsarticle"."source_id", "data_collector_newsarticle"."title", "data_collector_newsarticle"."url", "data_collector_newsarticle"."description", "data_collector_newsarticle"."author", "data_collector_newsarticle"."category", "data_collector_newsarticle"."thumbnail_url", "data_collector_newsarticle"."published_at", "data_collector_newsarticle"."collected_at", "data_collector_newsarticle"."is_processed", "data_collector_newssource"."id", "data_collector_newssource"."publisher", "data_collector_newssource"."category", "data_collector_newssource"."url", "data_collector_newssource"."source_type", "data_collector_newssource"."is_active", "data_collector_newssource"."collection_interval", "data_collector_newssource"."last_collected_at", "data_collector_newssource"."created_at", "data_collector_newssource"."updated_at" FROM "data_collector_newsarticle" INNER JOIN "data_collector_newssource" ON ("data_collector_newsarticle"."source_id" = "data_collector_newssource"."id") ORDER BY "data_collector_newsarticle"."collected_at" DESC LIMIT 50
2. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
3. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
4. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
5. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
6. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
7. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
8. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
9. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
10. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
11. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
12. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
13. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
14. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
15. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 593
16. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 604
17. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 604
18. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 604
19. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 604
20. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 145
21. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 145
22. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 145
23. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 150
24. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 148
25. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 148
26. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 148
27. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 148
28. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 149
29. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 557
30. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
31. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
32. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
33. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
34. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
35. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
36. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
37. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
38. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
39. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
40. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
41. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
42. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
43. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
44. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
45. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
46. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
47. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
48. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
49. [15.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
50. [16.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138
51. [0.00ms] SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" = 138

 

 

시나리오별 요약

시나리오 쿼리 수 sql_time 패턴 요약
직렬화 뉴스 목록 (50건+JOIN), source_detail → article_count 51 532 ms 목록 1회 + source_id별 COUNT 반복
(동일 소스에서 수십 번 중복 호출 포함)
직렬화 소셜 목록 (50건+JOIN), source_detail → post_count 51 656 ms 목록 1회 + source_id별 COUNT 반복
GET /api/dashboard/news/ (캐시 미스) 22 235 ms 전테이블 COUNT 1 + 목록 1 + 행마다 COUNT × 20
GET /api/dashboard/social/ 22 218 ms 동일 구조

대표 SQL (뉴스 직렬화)

COUNT가 source_id마다 반복됩니다.

[1]    SELECT … FROM newsarticle JOIN newssource … ORDER BY collected_at DESC LIMIT 50
[2~51] SELECT COUNT(*) … WHERE source_id = 593   ← 동일/유사 패턴 약 50회 (전체는 원본 파일 참고)

 

문제 해결 후

[db_profiling] 직렬화 뉴스 목록 (NewsArticleSerializer many, source_detail→article_count): queries=2, sql_time=47.00ms, wall=211.35ms

--- 실행 SQL (2건, connection.queries) ---
1. [31.00ms] SELECT "data_collector_newsarticle"."id", "data_collector_newsarticle"."source_id", "data_collector_newsarticle"."title", "data_collector_newsarticle"."url", "data_collector_newsarticle"."description", "data_collector_newsarticle"."author", "data_collector_newsarticle"."category", "data_collector_newsarticle"."thumbnail_url", "data_collector_newsarticle"."published_at", "data_collector_newsarticle"."collected_at", "data_collector_newsarticle"."is_processed", "data_collector_newssource"."id", "data_collector_newssource"."publisher", "data_collector_newssource"."category", "data_collector_newssource"."url", "data_collector_newssource"."source_type", "data_collector_newssource"."is_active", "data_collector_newssource"."collection_interval", "data_collector_newssource"."last_collected_at", "data_collector_newssource"."created_at", "data_collector_newssource"."updated_at" FROM "data_collector_newsarticle" INNER JOIN "data_collector_newssource" ON ("data_collector_newsarticle"."source_id" = "data_collector_newssource"."id") ORDER BY "data_collector_newsarticle"."collected_at" DESC LIMIT 50
2. [16.00ms] SELECT "data_collector_newsarticle"."source_id", COUNT("data_collector_newsarticle"."id") AS "total" FROM "data_collector_newsarticle" WHERE "data_collector_newsarticle"."source_id" IN (138, 557, 593, 145, 148, 149, 150, 604) GROUP BY "data_collector_newsarticle"."source_id"

 

시나리오별 요약

시나리오 쿼리 수 sql_time 패턴 요약
직렬화 뉴스 목록 (50건+JOIN), source_detail → article_count 2 47 ms 목록 1회 + source_id IN (…) 후 GROUP BY 1회
직렬화 소셜 목록 (50건+JOIN), source_detail → post_count 2 48 ms 목록 1회 + GROUP BY 집계 1회
GET /api/dashboard/news/ (캐시 미스) 3 32 ms 전체 COUNT 1 + 목록 1 + GROUP BY 1 (소스 묶음)
GET /api/dashboard/social/ 3 47 ms 동일 (COUNT 1 + 목록 1 + GROUP BY 1)

대표 SQL (뉴스 직렬화)

집계를 한 번에 가져옵니다.

-- [1] 목록
SELECT … FROM newsarticle JOIN newssource … LIMIT 50;

-- [2] 소스별 건수 한 번에
SELECT source_id, COUNT(id) AS total
FROM newsarticle
WHERE source_id IN (138, 557, 593, …)
GROUP BY source_id;

 

아홉 번째 테스트 (2026-03-26 04h37, Redis + gevent + Caddy + N+1 해결, 동시 1,000명)

 

Locust_Redis+gevent+caddy+n+1 해결 후 천명 테스트 (3분, 동시 사용자 1,000명, QA POST 제외)

지표 기준(당시 목표) 테스트 결과 판정
Failure % 0% 1,370건 / 48,493건 ≈ 2.82% 나쁨 — 8차 대비 소폭 개선, 목표 미달
RPS 부하 대비 유지, 50명 기준 10 RPS 이상 현재 약 309.4 RPS (총 평균 약 268.6 RPS) 좋음 — 8차 대비 처리량 증가
일반 API 평균/95% < 200ms / < 500ms 전체 평균 1.52초(1523ms), 95% 3.9초(3900ms) 나쁨 — 목표 미달, 8차 대비 개선
무거운 API (QA) 평균 < 5초, 99% < 15초 QA POST 제외, history 평균 1.65초, 99% 4.3초 참고
Response time 추이 부하 올려도 수평 유지 평균 1초대 중반, 95% 3~4초대 보통 — 8차 대비 완화

 

8차 vs 9차 요약: Caddy 경유 + N+1 개선 후, RPS 261.4 → 309.4, 평균 응답 1.82초 → 1.52초, 95% 4.4초 → 3.9초로 개선.

CloudWatch MCP 실측(해당 시간창):

  • Logs Insights(trend/web-access) 처리 로그: 45,069건
  • 5xx 로그: 13건 (약 0.03%)
  • 비고: 9차는 리포트 시각 04:37을 종료 시각으로 보고, 04:34~04:37 UTC(3분)로 재매핑.
  • EC2 CPUUtilization: 평균 66.54%, 최대 99.99%
  • EC2 mem_used_percent(CWAgent): 평균 23.48%, 최대 23.85%
  • RDS CPUUtilization: 평균 4.26%, 최대 5.87%
  • RDS ReadIOPS / WriteIOPS: 평균 3.16 / 3.51, 최대 8.11 / 9.65
  • 한 줄 판정: 9차도 앱(EC2) CPU 중심 부하가 크고 DB는 여유인 구간.

6단계 — EC2 CPU 99% 포화 진단 및 Gunicorn 설정 정리 ( 9~11차, 2026-03-26~27 )

문제 인식 — CPU 99%가 반복됐다

9차부터 11차까지 테스트할 때마다 CloudWatch에서 EC2 CPU가 분 단위로 99~100%에 도달하는 게 반복적으로 관측됐다.

9차    EC2 CPU 최대: 99.99%  / 실패율: 2.82%
9-1차  EC2 CPU 최대: 99.99%  / 실패율: 1.96%
10차   EC2 CPU 최대: 99.91%  / 중간 구간 p95 10~13초 급등, RPS 420 → 130 급락
10-1차 EC2 CPU 최대: 99.81%  / 간헐적 꼬리 지연 잔존
11차   EC2 CPU 최대: 100.00% / 스파이크 완화됐지만 피크는 그대로

겉으로는 비슷해 보이지만 테스트마다 원인이 조금씩 달랐다. CloudWatch와 Locust 타임라인을 교차 분석해서 구간별로 원인을 분리했다.

원인 1 — 단일 EC2 자체 한계 (9차 ~ 9-1차)

m7i-flex.large는 vCPU 2코어다. 동시 1,000명이 요청하면 Gunicorn 워커 4개 + Caddy가 같은 호스트에서 경쟁한다. 캐시 콜드 미스가 몰리는 순간에는 DB 읽기 + 직렬화 + 응답이 동시에 처리되면서 CPU가 포화됐다.

RDS CPU: 3~5% (여유)
EC2 CPU: 99.99% (포화)
→ 병목은 DB가 아니라 앱 서버 처리량

RDS가 여유로운데 EC2만 터진다는 건 쿼리 자체보다 직렬화·응답 생성 단계에서 CPU를 많이 쓴다는 의미였다. 실제로 defer()와 경량 Serializer를 적용한 12-1차부터 CPU 사용률이 의미 있게 내려갔다.

원인 2 — max-requests에 의한 워커 동시 재시작 (10차)

10차에서 특이한 패턴이 관측됐다. Locust 타임라인에서 특정 구간에 p95가 갑자기 10~13초로 튀고 RPS가 420 → 130으로 급락했다가, 약 25초 뒤에 정상으로 돌아왔다.

10차 Locust 타임라인
05:29:17 UTC — p95 급등 시작 (정상 구간: ~300ms)
05:29:42 UTC — p95 최대 ~13초, RPS 최저 ~130
05:30:10 UTC — 서서히 회복, 600ms대로 수렴

CloudWatch EC2 CPU (분 단위)
05:28 UTC:  3.5%
05:29 UTC: 73.2%
05:30 UTC: 99.8%   ← 스파이크 직후 지속 고부하
05:31 UTC: 99.9%

CPU 포화가 스파이크 이후에 이어진다는 점이 이상했다. 요청이 급증한 게 아니라 처리 가능한 워커 수가 순간적으로 줄었을 때의 패턴이었다.

원인은 --max-requests 10000이었다. 요청 10,000건을 처리하면 워커가 재시작되는데, 고부하 구간에서 여러 워커가 비슷한 시점에 10,000건을 채우면 재시작 중인 워커들이 새 요청을 받지 못해 남은 워커에게 부하가 집중되면서 CPU가 포화됐다.

--max-requests는 메모리 누수가 있는 워커를 주기적으로 교체하려는 의도로 쓰는 옵션이다. 메모리 누수가 확인되지 않은 상태에서 기계적으로 추가했다가 오히려 고부하 구간의 안정성을 해쳤다.

# 변경 전 — 워커가 10,000건마다 재시작
gunicorn --max-requests 10000 --max-requests-jitter 1000

# 변경 후 — max-requests 완전 제거
gunicorn --workers 4 --worker-class gevent --worker-connections 500 \
         --timeout 180 --graceful-timeout 30 --keep-alive 5
10-1차 (max-requests 있음): 평균 612ms / 95% 2,000ms / CPU 최대 99.8%
 11차  (max-requests 제거): 평균 431ms / 95% 1,300ms / CPU 최대 100% (피크는 있지만 스파이크 없음)

CPU 피크 자체는 여전히 100%였지만, 급격한 처리량 급락이 사라졌다. 피크가 있어도 응답 지표가 안정적이라면 허용 가능한 범위다.

Gunicorn 최종 설정

gunicorn \
  --bind 0.0.0.0:8000 \
  --workers 4 \
  --worker-class gevent \
  --worker-connections 500 \
  --timeout 180 \
  --graceful-timeout 30 \
  --keep-alive 5

옵션설명효과

--worker-class gevent I/O 대기 중 다른 greenlet으로 전환 동시 처리량 향상
--worker-connections 500 워커당 동시 연결 수 제한 (기본 1000에서 하향) 메모리·FD 안정화
--timeout 180 워커 응답 타임아웃 느린 요청 강제 종료
--graceful-timeout 30 워커 종료 시 진행 중인 요청 대기 시간 연결 리셋 에러 제거
--keep-alive 5 HTTP keep-alive 유지 시간 (초) 연결 재수립 오버헤드 감소

CPU 99% 개선 흐름 요약

9차    (CPU 99%, 실패 2.82%):    Gunicorn 설정 미흡 + 직렬화 부하
10차   (CPU 99%, p95 스파이크):  max-requests 워커 재시작 충돌
11차   (CPU 100%, 스파이크 없음): max-requests 제거
12-1차 (CPU 84%,  실패 0%):     defer + 경량 Serializer로 CPU 부하 자체 감소
13차   (CPU 71%,  실패 0%):     COUNT/GROUP BY 캐싱으로 추가 감소

CPU 포화 원인이 두 가지 층위에서 겹쳐 있었다. Gunicorn 설정 문제(max-requests)는 빠르게 해결됐고, 직렬화·DB 집계 부하는 코드 레벨 최적화가 필요했다. 둘을 분리해서 순서대로 해결했기 때문에 각각의 효과를 수치로 확인할 수 있었다.


7단계 — Redis TTL 그룹 분리 + defer + 경량 Serializer 전면 적용 (12-1차, 2026-03-29)

이 단계가 응답 시간 감소폭 기준으로 두 번째로 컸다.

Redis TTL 그룹 분리

기존에는 LIST_API_CACHE_TTL 하나로 전체를 통제했다. 뉴스·소셜처럼 자주 갱신되는 데이터와, 분석 결과처럼 잘 안 바뀌는 데이터에 같은 TTL을 쓰는 건 비효율이었다.

# settings.py 변경 전
LIST_API_CACHE_TTL = 900  # 단일 TTL

# 변경 후 — 그룹별 분리
LIST_API_CACHE_TTL_DASHBOARD  = 120   # 뉴스·소셜 (빠른 갱신)
LIST_API_CACHE_TTL_ANALYZER   = 600   # 분석 결과 (느린 갱신)
LIST_API_CACHE_TTL_QA_HISTORY = 60    # QA 히스토리 (가장 짧게)

defer — DB에서 불필요한 컬럼 아예 읽지 않기

목록 Serializer에서 이미 안 쓰는 필드들이 있는데, ORM이 기본적으로 모든 컬럼을 SELECT해오고 있었다. defer()로 불필요한 컬럼을 DB 레벨에서 제외했다.

# analyzer/views.py — 목록 조회 시 무거운 JSON 컬럼 제외
_TREND_RESULT_LIST_DEFER = (
    "result_data",   # 건당 수백 KB짜리 분석 결과 JSON
    "parameters",    # 분석 파라미터 JSON
    "summary",       # 요약 텍스트
    "error_message", # 에러 메시지
)

def get_queryset(self):
    queryset = super().get_queryset()
    if self.action == "list":
        queryset = queryset.defer(*_TREND_RESULT_LIST_DEFER)
    return queryset
# dashboard/views.py — 뉴스 목록 조회 시 본문·URL 등 제외
def get_queryset(self):
    queryset = queryset.select_related("source")
    if self.action == "list":
        queryset = queryset.defer(
            "description",  # 기사 본문
            "url",          # 원문 URL
            "author",       # 작성자
            "is_processed", # 처리 여부 플래그
        )
    return queryset

경량 Serializer — 뉴스·소셜 목록 전용 분리

analysis-results에만 적용했던 목록 전용 Serializer를 뉴스·소셜 API에도 확장했다.

# data_collector/serializers.py — 대시보드 뉴스 목록 전용 Serializer
class NewsArticleListSerializer(serializers.ModelSerializer):
    source_name = serializers.SerializerMethodField(read_only=True)
    title_short = serializers.SerializerMethodField(read_only=True)  # 50자 잘라서 반환
    published_at_display = serializers.DateTimeField(
        source="published_at", format="%Y-%m-%d %H:%M:%S", read_only=True
    )

    class Meta:
        model = NewsArticle
        fields = [
            "id", "source_name", "title_short",
            "published_at_display", "collected_at_display",
            "category", "thumbnail_url",
        ]
        # description, url, author, content 등 미포함

SocialMediaPost에도 동일하게 BaseSocialMediaPostListSerializer를 만들어 목록/상세 분리를 일관되게 적용했다.

11-1차 (적용 전): 평균 462ms / 95% 1,500ms / 99% 1,900ms / RPS 385.6
12-1차 (적용 후): 평균  63ms / 95%   260ms / 99%   650ms / RPS 460.7

평균 기준 7.3배 개선. 직렬화 비용과 DB에서 읽어오는 데이터 양이 예상보다 훨씬 큰 병목이었다.

 

 

열두 번째-1 테스트 (2026-03-29 22h29 KST, Locust, 동시 1,000명, 3분, QA POST 제외)

 

지표 기준(목표) 테스트 결과 판정
Failure % 0% 0건 / 83,039건 = 0% 좋음 — 목표 달성
RPS 부하 대비 유지 약 460.7 RPS (Aggregated) 좋음
일반 API 평균/95%/99% < 200ms / < 500ms 전체 평균 63.3ms, 50% 27ms, 95% 260ms, 99% 650ms 좋음
무거운 API (QA) 평균 < 5초, 99% < 15초 QA POST 제외(user_qa_query @task(0)) -
Response time 추이 부하 구간 안정 3분·실패 0% 좋음

 

구분 11-1번째 12-1번째
기간 3분 3분
평균 응답 ~462ms 63.3ms
95% ~1500ms 260ms
RPS (Aggregated) ~385 ~461
실패 (문서 기준 0% 목표) 0%

 

CloudWatch MCP 실측(UTC 2026-03-29T13:29:46Z ~ 2026-03-29T13:32:46Z):

  • Logs Insights(trend/web-access) 처리 로그: 82,943건 
  • @message에 500 / 502 / 503 단순 패턴 스캔: 0건 (로그 포맷에 따라 누락 가능)
  • EC2 CPUUtilization (i-0a66c20698b2f3878, 분 단위 Average): 약 3.4% → 30.2% → 84.2% (KST 22:29 ~ 22:31 각 분; 구간 단순 평균 약 39% 내외, 마지막 분 약 84%)
    (MCP 실측 차원: InstanceId=i-0a66c20698b2f3878, InstanceType=m7i-flex.large)
  • EC2 mem_used_percent(CWAgent, InstanceId=i-0a66c20698b2f3878, ImageId=ami-0130d8d35bcd2d433, InstanceType=m7i-flex.large): 약 30.91% → 31.58% → 31.86% (구간 평균 약 31.45%)
  • RDS trend-db CPUUtilization (분): 약 4.3% → 7.9% → 7.4% (구간 평균 약 6.5% 내외)
  • RDS ReadIOPS / WriteIOPS (분): Read 대략 2.3 → 14.2 → 5.5, Write 대략 0.95 → 10.9 → 1.05

8단계 — COUNT/GROUP BY 쿼리 Redis 캐싱 (13차, 2026-03-30)

12-1차 이후 RDS Performance Insights에서 상위 SQL을 뽑아봤다.

-- 상위 4개 모두 집계 쿼리였다
SELECT COUNT(*) AS "__count" FROM "data_collector_newsarticle"          -- AAS ~0.0076
SELECT source_id, COUNT(id) AS "total"
  FROM "data_collector_socialmediapost"
  WHERE source_id IN (1, 2, 5) GROUP BY source_id                       -- AAS ~0.0076
SELECT COUNT(*) AS "__count" FROM "data_collector_socialmediapost"      -- AAS ~0.0015
SELECT COUNT(*) AS "__count" FROM "analyzer_trendanalysisresult"        -- AAS ~0.0015

COUNT(*)는 DRF 페이지네이션이 totalCount를 계산하기 위해 매 목록 요청마다 전체 테이블을 스캔하는 쿼리였다. 목록 응답은 Redis에서 꺼내줘도 페이지네이션 카운트는 여전히 DB를 매번 때리고 있었다. source_id IN (...) GROUP BY 쿼리는 5단계에서 N번 → 1번으로 줄였지만, 그 1번도 모든 요청마다 DB를 치고 있었다.

페이지네이션 COUNT 캐싱

# common/list_cache.py 추가
def get_or_set_cached_list_count(request, prefix: str, queryset) -> int:
    key = make_list_cache_key(request, prefix) + ":count"
    cached = cache.get(key)
    if cached is not None:
        return int(cached)
    n = queryset.count()
    cache.set(key, n, get_list_cache_ttl(prefix))
    return n
# common/pagination.py — 신규 파일
class CachedCountPaginator(Paginator):
    def __init__(self, *args, cached_count=None, **kwargs):
        self._cached_total = cached_count
        super().__init__(*args, **kwargs)

    @property
    def count(self):
        if self._cached_total is not None:
            return self._cached_total  # Redis 캐시 값 사용, DB 쿼리 없음
        return super().count

각 ViewSet에 list_cache_prefix를 붙여주는 것만으로 연동됐다.

class NewsArticleViewSet(viewsets.ReadOnlyModelViewSet):
    list_cache_prefix = "dashboard:news"

class TrendAnalysisResultViewSet(viewsets.ReadOnlyModelViewSet):
    list_cache_prefix = "analyzer:results"

GROUP BY 집계 캐싱

# common/list_cache.py 추가
def get_cached_source_id_count_map(model, namespace: str, source_ids: set) -> dict:
    if not source_ids:
        return {}
    sorted_ids = sorted(source_ids)
    key = f"source_id_counts:{namespace}:{','.join(map(str, sorted_ids))}"
    cached = cache.get(key)
    if cached is not None:
        return cached
    rows = (
        model.objects.filter(source_id__in=source_ids)
        .values("source_id")
        .annotate(total=Count("id"))
    )
    result = {row["source_id"]: row["total"] for row in rows}
    cache.set(key, result, int(getattr(settings, "LIST_API_CACHE_TTL_DASHBOARD", 120)))
    return result

Serializer에서 기존에 직접 실행하던 GROUP BY 쿼리를 이 함수로 교체해서 Redis 캐시를 거치도록 했다. 13차 테스트에서 RDS Performance Insights의 db.sql 상위 항목이 비어 있었다. 해당 집계 쿼리들이 DB 부하 순위에서 사라졌다는 뜻이다.

 

열세 번째 테스트 (2026-03-30 16h59 KST, Locust, 동시 1,000명, 5분, QA POST 제외)

 

Locust_Redis+gevent+caddy+n+1+Gunicorn설정추가+maxrequests삭제+defer,경량serializer+쿼리 최적화 후 천명 테스트.html


구간: UTC 2026-03-30T07:59:53Z ~ 2026-03-30T08:04:54Z (KST 16:59:53 ~ 17:04:54, 5분 1초).

 

지표 기준 테스트 결과 판정
Failure % 0% 0건 / 143,521건 = 0% 좋음 — 목표 달성
RPS 부하 대비 유지 약 478.0 RPS (Aggregated) 좋음
일반 API 평균/95%/99% < 200ms / < 500ms 전체 평균 28.4ms, 50% 22ms, 95% 69ms, 99% 190ms 매우 좋음
무거운 API (QA) 평균 < 5초, 99% < 15초 QA POST 제외(user_qa_query @task(0)) -
Response time 추이 부하 구간 안정 5분 유지·실패 0% 좋음

12-1 대비 13차 2~3줄 요약(얼마나 더 개선됐는지):

  • 평균 응답 63.3ms -> 28.4ms로 약 55.1% 감소(약 2.2배 개선), 95% 260ms -> 69ms로 약 73.5% 감소(약 3.8배 개선)했습니다.
  • 99% 650ms -> 190ms로 약 70.8% 감소(약 3.4배 개선)했고, RPS는 ~460.7 -> ~478.0로 소폭 증가했으며 Failure는 0%로 동일합니다.

13차 최종 결과

2026-03-30 / Locust / 동시 1,000명 / 5분 / 총 143,521건

엔드포인트 평균 50% 95% 99% RPS
analysis-results 24ms 16ms 65ms 180ms 104.0
analysis/keywords 25ms 16ms 66ms 180ms 82.7
dashboard/news 25ms 16ms 66ms 180ms 105.1
dashboard/social 35ms 26ms 75ms 190ms 103.6
user_qa/history 34ms 26ms 75ms 200ms 82.6
전체 합산 28.4ms 22ms 69ms 190ms 478.0

 

CloudWatch 실측 (UTC 07:59~08:04, ap-northeast-2):

지표
EC2 CPU (분 평균) 3% → 19% → 71% → 71% → 71% (구간 평균 약 47%)
EC2 메모리 평균 22.2% (안정)
RDS CPU 평균 4.7% (저부하)
RDS ReadIOPS 4.4 → 16.9 → 5.3 → 3.9 → 3.4
5xx 에러 0건
CloudWatch 로그 수 143,443건 (Locust 143,521건 대비 78건 차이)

EC2 CPU가 71%까지 올라갔지만 응답 품질은 유지됐다.

 


심화

사실 vcpu2, 8GB 단일 인스턴스로 이정도의 성능이 최대치인지 물론 잘 모른다. 

각각의 엔드포인트마다 요구하는 크기도 다를뿐더러 추가로 어떻게 해야해야 성능이 더 좋아질지 공부를 해도 잘 모르는것이 현실이였다.

 

사실 Scale-UP이나 Scale-Out를 진행하면 몇배로 성능이 뛰어서 1,000명 테스트가 아닌 1만명~수십만명까지 진행할 수 도 있겠지만 최소 금액으로 트래픽을 감당하고 성능향상을 도모하고 싶어서 진행하지않았다. 
AWS의 프리티어 단일인스턴스로 부하를 어디까지 걸어도 괜찮을지를 보고싶었기에 해당 프로젝트를 진행했다. 

 


 

부하 테스트에서 배운 것

  • 측정 없이는 최적화가 없다. 처음엔 워커 수를 늘리면 빠를 거라 생각했다. 워커 3 → 4로 올렸더니 응답이 오히려 8.1초로 악화됐다. 문제는 응답 크기에 있었는데 워커 수로 해결하려 했으니 당연한 결과였다
  • CloudWatch와 RDS Performance Insights를 제대로 활용하기 시작한 이후부터 진단이 달라졌다. EC2 CPU가 99%라는 걸 알아도 어떤 요청이 CPU를 많이 쓰는지, 어떤 SQL이 RDS를 많이 때리는지를 보기 시작하면서 원인을 순서대로 제거할 수 있었다.
  • CloudWatch Logs Insights로 실제 처리된 요청 수가 Locust 집계와 일치하는지까지 교차 검증했다.
  • 한 번에 여러 개를 동시에 바꾸면 어느 변경이 효과를 낸 건지 알 수 없다. max-requests 하나를 제거하는 것이 새 기능을 추가하는 것보다 큰 효과를 낸 경우도 있었다. 단계별로 하나씩 바꾸고 재측정하는 루프가 결국 더 빠른 경로였다.