- 실제 서비스 기능(로그인, 회원가입, 게시판)을 구현
- 로컬 환경에서 성능 테스트 도구를 사용해 분석하고 개선하는 데 목적이 있음
- K6를 중심으로 “테스트 → 시각화 → 성능 개선”까지 일련의 흐름을 실습
- 테스트 환경은 로컬 기반이지만, 실제 운영 환경에 가까운 시나리오와 부하를 설계함으로써 의미 있는 인사이트 도출을 목표로 함
-
성능 테스트는 어플리케이션의 성능, 확장성 및 안정성을 평가하는 과정
-
어플리케이션의 부하처리 능력, 응답 시간, 처리량 및 자원 사용량과 같은 성능 관련 지표를 측정
-
어플리케이션이 예상된 작업 부하 아래에서 어떻게 수행되는지 이해하는데 도움을 줌
-
Stress Test: 최대 사용자 수까지 증가 시 성능 한계 확인
- 어플리케이션의 성능 한계를 확인
- 어플리케이션의 부하 처리 능력을 평가
- 대량의 동시 사용자 또는 트랜잭션을 생성하여 어플리케이션의 응답 시간과 자원 사용량을 모니터링 (스트레스 테스트에서 어플리케이션이 어떻게 실패하는지, 예를 들어 응답 시간이 급격하게 증가하는 등의 현상을 파악)
-
Spike Test: 갑작스러운 트래픽 급증에 대한 반응 측정
- EX) 특정 이벤트나 광고 캠페인으로 인해 갑자기 많은 사용자가 어플리케이션에 액세스하는 시나리오를 시뮬레이션
- “어플리케이션이 갑작스러운 부하 증가에 어떻게 반응하고, 부하가 정상 수준으로 돌아올 때까지 얼마나 시간이 걸리는지를 확인”
-
Endurance Test: 장시간 안정성과 리소스 누수 여부 검증
- 지속적으로 부하를 가하거나 특정 부하를 유지하는 시나리오에서 어플리케이션의 안정성을 확인
- 어플리케이션의 장기적인 안정성, 메모리 누수, 자원 누수 등을 검증하는데 사용됨
- 장시간 실행 중에 어플리케이션이 성능 저하나 시스템 장애를 경험하지 않는지 확인
- K6를 선택한 이유:
- RPS 성능이 높고 메모리 사용량이 적음
- JVM 기반 도구(JMeter, nGrinder)에 비해 리소스 효율적
- Python 기반 Locust보다 정밀한 측정 가능
| 구분 | 비율 | 상세 기능 |
|---|---|---|
| 읽기 | 70% | 게시글 목록 조회 게시글 상세 조회 댓글 조회 |
| 쓰기 | 30% | 게시글 작성 게시글 수정 |
📈 Stress 시나리오 (점진적 증가)
- 1분 간 50명의 사용자로 시작
- 매 1분마다 50명씩 증가
- 최대 500명까지 도달
⚡ Spike 시나리오 (급격한 폭증)
- 1분간 정상 트래픽(100명) 유지
- 30초 내에 1,000명으로 급증
- 2분간 최대 부하 유지
- 1분간 다시 정상 트래픽으로 복귀
🕒 Endurance 시나리오 (지속적 부하)
- 100명의 동시 사용자로 시작
- 10분 간 지속적인 요청 처리
- 테스트 도중 CPU 사용량이 급증하는 현상을 포착
- 시스템 자원 모니터링을 통해 확인해본 결과, CPU 점유율이 거의 99%에 달하고 있었고,
- 그중에서도 mysqld.exe, 즉 MySQL 프로세스가 단일 프로세스임에도 46% 최고로 사용할 때에는 58% 이상의 CPU를 사용
- 이로 인해 데이터베이스 부하가 주요 성능 저하 요인으로 의심
✅ 개선안:
- 느린 쿼리 로그를 활성화하고 실제 실행된 쿼리를 분석
- 단순한 COUNT 쿼리임에도 1.5초 이상 소요되는 쿼리문이 존재했고,
- 이 쿼리는 JOIN과 조건절이 복잡하게 얽혀 있어 성능이 저하되는 원인이 될 수 있음을 확인
- EXPLAIN ANALAZE 후, 인덱스를 직접 생성해 최적화를 시도했지만,성능 개선은 전혀 이루어지지 않음.
CREATE INDEX idx_post_deleted_member ON post(deleted_at, member_id);왜 성능 개선이 안 됐을까? 대체 왜? : 인덱스가 무력화되는 주요 이유
- LIKE '%검색어%'는 index range scan이 불가능함
- 이 구문은 전체 문자열을 탐색하기 때문에 Full Table Scan으로 처리됨
- 🔸 인덱스가 존재하더라도, 문자열 처음부터 일치하지 않으면 무시됨
- 🔸 예: LIKE '검색어%'는 인덱스 사용 가능하지만, %검색어%는 불가능
- title, content, m.name 세 컬럼 중 하나라도 매칭되면 포함되는 구조
- OR 조건이 있으면 MySQL 옵티마이저는 모든 조건을 풀스캔으로 처리할 가능성이 높음
- 🔸 OR 연산은 MySQL이 각 조건의 인덱스를 병합하지 못하면 Full Table Scan으로 전락
- 조인 대상 테이블인 member 역시 LIKE를 사용함
- 이 또한 인덱스 사용이 어렵고, JOIN 이후 필터링되기 때문에 성능 저하의 원인
- jstat -gc 분석 결과, Old Gen 누적 사용량 지속 증가
- 단일 DTO 구조가 객체 생명 주기를 길게 함
✅ 개선안:
- DTO를 역할별로 분리하여 불필요한 필드 제거
- GC 대상 객체 수 감소 → 메모리 사용량 54% 감소
| 항목 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| Old Gen 사용량(OU) | 63MB | 29MB | 약 54% 감소 |
| Full GC 횟수(FGC) | 0 | 0 | 유지 |
| GC 회수 여부 | 회수 안 됨 | 회수 안 됨 | 동일하나, 진입 자체 감소 |
| 테스트 유형 | 평균 응답시간 감소율 (http_req_duration avg) |
95% 응답시간 감소율 (http_req_duration p(95)) |
반복 처리 시간 단축 (iteration_duration avg) |
RPS 향상 | 요청 수 증가 |
|---|---|---|---|---|---|
| 스트레스 | ⬇️ 8.22% | ⬇️ 9.71% | ⬇️ 8.02% | ⬆️ 8.53% | ⬆️ 8.46% |
| 스파이크 | ⬇️ 12.71% | ⬇️ 18.41% | ⬇️ 11.13% | ⬆️ 11.70% | ⬆️ 11.68% |
| 인듀런스 | ⬇️ 6.32% | ⬇️ 3.20% | ⬇️ 5.18% | ⬆️ 5.86% | ⬆️ 5.84% |
- 자주 사용되며 자주 변하지 않는 데이터를 캐시에 저장
- 게시글 목록/상세와 같은 읽기 작업에 적합
- 캐시와 DB에 동시 쓰기 → 정합성 보장
- 매 요청마다 DB 접근 필요 → 오버헤드 큼
- 계좌 잔액, 주식 등 실시간 정합성 필요한 서비스에 적합
- 캐시에만 쓰고 일정 주기로 DB에 반영 → 성능 최상
- 장애 발생 시 데이터 손실 위험
- 정합성보단 속도 우선인 로그/조회수 등 비정형 데이터에 적합
- Ehcache는 JVM위에서 동작하기에 빠르며 단일 서버, 테스트 환경에 적합하다고 하다
- 하지만 우리 테스트의 경우 cpu 사용률이 90% 넘어가는 프로젝트이기에 JVM 부하가 많이 걸리는 문제가 발생함
- 여기에 Ehcache를 도입하면 GC도 많이 일어나게 되고 메모리를 많이 사용하기게 OOM 발생 확률도 높아질 것으로 예상됨
| 항목 | Ehcache | Redis |
|---|---|---|
| 저장 위치 | JVM 내부 (Heap/Off-Heap) | 외부 프로세스 (TCP 통해 접근) |
| 성능 | 매우 빠름 (JVM 메모리 사용) | 빠름 (네트워크 오버헤드 존재) |
| 확장성 | 단일 서버 기반, 수동 분산 | 클러스터링 지원, 수평 확장 가능 |
| 복잡도 | 설정 간단, 의존성 적음 | 설치 및 운영 필요 (Docker 등 활용 가능) |
| 영속성 | 옵션으로 디스크 저장 가능 | 옵션으로 AOF/RDB 통해 가능 |
| 공유 캐시 | 불가 (JVM 간 공유 안 됨) | 가능 (다수 서버 간 캐시 공유) |
| 주요 활용 | 단일 인스턴스, 개발/테스트 | 분산 환경, 실서비스 환경 |
- JVM Heap 공간 안에 캐시 데이터를 저장 → 애플리케이션 데이터와 공간 경쟁
- 대량 캐시 적재 시 GC가 잦아지고, Full GC 발생 → 서버 일시 중단 또는 응답 지연
- Ehcache에 객체가 많으면 GC가 오래 걸림 → 특히 Old 영역에 쌓이면 문제 심각
- GC 튜닝을 안 하면 단 1개의 요청이 전체 JVM의 응답을 멈추게 함
- 잘못된 TTL 설정이나 과도한 캐시로 인해 Heap이 꽉 차면 java.lang.OutOfMemoryError 발생
- 서버 자체가 죽는 현상 발생 → 장애로 직결
- 조회 시
post:list:{page},post:detail:{id}형태의 키로 Redis 캐싱 - 수정/삭제 시 자동 캐시 무효화
- TTL 설정으로 주기적 만료 관리
- Redis에서만 조회수 증가 → DB는 일정 주기로 업데이트
- 조회수는 정합성보다 성능이 중요하므로 Spring Scheduled 기반 배치 처리
- DB 접근 횟수 감소 → 성능 부하 완화
| 테스트 유형 | 평균 응답시간 감소율 (http_req_duration avg) |
95% 응답시간 감소율 (http_req_duration p(95)) |
반복 처리 시간 단축 (iteration_duration avg) |
RPS 향상 | 요청 수 증가 |
|---|---|---|---|---|---|
| 스트레스 | ⬇️ 13.1% | ⬇️ 10.5% | ⬇️ 10.8% | ⬆️ 10.3% | ⬆️ 9.4% |
| 스파이크 | ⬇️ 12.6% | ⬇️ 12.9% | ⬇️ 11.4% | ⬆️ 12.4% | ⬆️ 12.9% |
| 인듀런스 | ⬇️ 23.6% | ⬇️ 20.0% | ⬇️ 11.6% | ⬆️ 13.2% | ⬆️ 13.2% |
- SSAFY 로컬 머신에서 모든 트래픽(Spring, Vue, MySQL, Redis)을 처리
- CPU 사용률 90% 이상으로 물리적 한계 도달
- 로드밸런서 미적용으로 트래픽 분산 불가 → SPOF 발생
✅ 개선안:
- 로드밸런서 (e.g., AWS ALB, Nginx) 적용
- Auto Scaling으로 Spring 인스턴스를 동적으로 확장
- 비동기 큐(Message Queue)와 애플리케이션 배치 처리는 모두 Write Back(Write-Behind) 전략에서 캐시 데이터를 데이터베이스(DB)에 반영하는 대표적인 방법
- 두 방식은 시스템 구조, 성능, 확장성, 장애 대응 등에서 서로 다른 장단점과 트레이드오프를 가짐
장점
- 시스템의 결합도를 낮추고, Producer와 Consumer가 독립적으로 개발·운영 가능
- 대량의 트래픽에도 탄력적으로 대응 가능, 장애 시 메시지 재처리 용이
- 실시간 또는 준실시간 데이터 반영이 가능해 데이터 신선도 유지에 유리
단점
- 메시지 큐 인프라(RabbitMQ, Kafka, Redis 등) 구축과 운영에 추가 비용 및 복잡성 발생
- 큐 장애나 적체 발생 시 지연 가능성, 운영 모니터링 필요
장점
- 시스템 구조가 단순, 별도의 큐 인프라 없이 스케줄러만 추가하면 됨
- 대량 데이터를 한 번에 처리하므로 DB 부하를 예측·관리하기 쉬움
- 애플리케이션 내에서 다양한 비즈니스 로직, 외부 연동, 복잡한 처리 구현이 용이
단점
- 배치 주기 동안 데이터베이스 반영이 지연, 실시간성이 떨어짐
- 대량 처리 중 오류 발생 시 전체 작업 영향, 오류 추적 및 복구 복잡
- 확장성 한계(서버 증설 필요), 장애 복구 시 전체 재처리 필요
- 실시간성, 확장성, 장애 내구성이 중요하다면 → 비동기 큐가 유리하다.
- 구현 단순성, 비용 절감, 주기적 대량 처리가 목적이라면 → 배치 처리가 적합하다.
- 두 방식을 혼합해, 실시간 처리가 필요한 부분은 큐로, 대량 집계나 정산 등은 배치로 처리하는 하이브리드 구조도 가능하다.
- Redis 단일 노드를 Master-Slave 구성 또는 Sentinel 기반 클러스터로 전환하여 가용성 및 확장성 강화
- 모든 트래픽(읽기/쓰기)이 하나의 DB 인스턴스에 집중되어 병목 발생
- 동시 접속자 증가 시 CPU 및 I/O 자원 고갈
- 스케일링 어려움: 수직 확장(서버 사양 업그레이드) 외에 대안 부족
- 백업, 장애 복구 시 서비스 전체가 영향받을 수 있음
- 읽기/쓰기 분리: Master-Slave 구조로 읽기 부하 분산
- DB 샤딩 또는 수평 분할 고려: 사용자 또는 기능 단위로 데이터 분산
이번 프로젝트는 단순히 기능을 구현하는 데서 끝나지 않고, 서비스가 실제 환경에서 어떻게 견디는지에 대한 질문에서 출발했습니다. Stress, Spike, Endurance 테스트를 반복하면서, 실제 병목을 확인했습니다. 단일 서버 구조에서 오는 CPU 한계, 슬로우 쿼리에서 드러난 인덱스 무력화, GC 로그에 남은 메모리 누수의 흔적들은 단순한 코드 최적화로 해결되지 않는 문제들이었습니다. 그 과정에서 알게 된 것은, 성능 개선이란 단순히 빨라지는 것이 아니라, 시스템 전체를 이해하고 설계하는 과정이라는 점이었습니다. DTO 구조 하나를 바꾸는 일이 GC 효율을 개선할 수 있고, 쿼리 하나를 어떻게 작성하느냐에 따라 전체 응답 속도가 달라지며, 어디에 캐시를 두고 어떤 전략을 쓰느냐에 따라 사용자의 체감 성능이 달라질 수 있다는 것을 직접 체험했습니다. 또한 단일 서버 구조의 한계를 넘기 위해 로드 밸런서, 오토 스케일링, 비동기 큐, 캐시 전략 등 실제 서비스에서 사용하는 분산 시스템 설계 개념을 고민하고, 적용 방향까지 탐색했습니다. 이번 프로젝트는 제게 “작동하는 서비스”를 넘어서 “확장 가능한 구조”와 “안정적인 처리 흐름”이 얼마나 중요한지를 알려준 경험이었습니다. 후에 이 프로젝트를 발전시켜서 생각했던 설계 방향을 직접 구현해볼 예정입니다.
이전까지의 프로젝트에서는 기능 구현 위주로 진행했다면 이번에는 성능 최적화라는 비기능에 초점을 맞춰 프로젝트를 진행했습니다. 아주 간단한 게시판으로 시작을 했지만 데이터가 10만개 이상이고 사용자 수를 100이상으로 설정하니 게시판 조회, 읽기 등 기본적인 작업에서도 병목이 발생했습니다. 이를 위해 DB 계층에서 인덱싱을 적용해보려다 실패하기도 하고, Dto 수정 및 Redis 도입하여 성능을 개선하기도 했습니다. 이 과정에서 특정 기술 및 전략의 장단점을 파악하고 적절하게 도입하는 것이 중요하다는 것을 배울 수 있었습니다. 또한, 단순히 기능을 구현하는 수준을 넘어서 실제 서비스 환경에서 성능을 유지하기 위한 구조적 고민과 설계의 중요성을 체감할 수 있었습니다. 다음 2학기에서는 이러한 경험을 기반으로, 앞서 설계만 해보았던 내용들을 실제 적용해보고 그 때의 문제점을 개선해보고자 합니다.



