오늘로써 대충 프로젝트가 마무리가 됐다.
사실 시간과 내 머리가 더 좋았다면 프론트랑 성능또한 더 확연하게 좋아졌겠지만 일단 이정도로 마무리 하기로 했다.
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 하나를 제거하는 것이 새 기능을 추가하는 것보다 큰 효과를 낸 경우도 있었다. 단계별로 하나씩 바꾸고 재측정하는 루프가 결국 더 빠른 경로였다.
'AI_RSS_트래픽 프로젝트' 카테고리의 다른 글
| AI_RSS_분석_대용량트래픽 프로젝트 배포 (0) | 2026.03.20 |
|---|---|
| 모르는 상태로 하는 RSS&분석&RAG 프로젝트(19) 트렌드 분석 (0) | 2026.01.14 |
| 모르는 상태로 하는 RSS&분석&RAG 프로젝트(18) 트러블슈팅2 analyze_time_lag (0) | 2026.01.09 |
| 모르는 상태로 하는 RSS&분석&RAG 프로젝트(17) 시간차 분석 (뉴스 ↔ SNS 전파 패턴) (0) | 2026.01.09 |
| 모르는 상태로 하는 RSS&분석&RAG 프로젝트(16) 크로스 플랫폼 키워드 추출 및 빈도 분석 (0) | 2026.01.02 |