- 설명: 친구들과 즉흥적으로 술, 운동, 여행 등의 활동을 함께할 수 있는 모임 플랫폼입니다.
- 목표: 사용자가 하루에 약 10,000명 이상 접속하는 대규모 트래픽을 처리할 수 있도록 설계되었습니다.
GMG 프로젝트는 친구들과 아무런 계획 없이 즉흥적으로 활동을 즐기는 경험에서 출발했습니다.
“술 ㄱ? ㄱㄱ”, “런닝 ㄱ? ㄱㄱ”처럼 갑작스러운 제안에 “콱씨! 남자가 그냥 하는 거지” 하고 바로 뛰어드는 그 순간의 즐거움!
이런 즉흥적이고 가벼운 경험을 서비스로 구현하고자 프로젝트를 만들었고, 소셜보다는 스쳐가는 인연, 부담 없이 즐기는 즉흥적 만남을 상징합니다.
그 이름을 GMG, 즉 요즘말로 “가면 가” 라고 지었습니다. 🎉
- 모임 생성/참여/취소
- 사용자 친화적 인터페이스로 몇 초만에 모임 생성 가능
- 참여 상태 관리, 방장 권한 제한 등 안전 장치 포함
- 리뷰 시스템
- 모임 종료 후 리뷰 작성 가능, 중복 작성 방지
- 즉흥 매칭
- 실시간 참여 가능 사용자 기준으로 추천
- 카테고리 분류
- 런닝, 술, 운동, 여행 등 즉흥 활동별 모임 검색 및 필터링
목표: 일일 사용자 10,000명 이상의 트래픽을 안정적으로 처리하며 쾌적한 서비스 경험을 제공합니다.
GMG 서비스는 실시간 조회가 핵심인 즉흥 모임의 특성을 고려하여, 대규모 트래픽 상황에서도 빠르고 안정적인 성능을 제공하는 것을 목표로 설계되었습니다.
이를 위해 모니터링 시스템(Prometheus, Grafana) 과 부하 테스트 도구(Artillery) 를 활용하여 시스템의 병목 지점을 지속적으로 추적하고 개선했습니다.
초기 부하 테스트 과정에서 MyPage 조회 시 Heap 메모리가 급증했다가 급격히 감소하는 스파이크 현상을 발견했습니다. 이는 여러 API(회원 정보, 내가 쓴 리뷰, 참여 모임 목록 등)를 동기적으로 처리하면서 순간적인 과부하가 발생하는 것이 원인이었습니다.
해결 과정
- 문제 식별: Grafana 대시보드를 통해 특정 API 요청 시 G1 Eden Space의 메모리 사용량이 비정상적으로 치솟는 것을 확인했습니다.
- 원인 분석: 동기 방식(Synchronous)으로 여러 데이터를 한 번에 조회하면서, 각 요청이 끝날 때까지 스레드가 대기하고 메모리 반환이 지연되는 병목 현상이 문제였습니다.
- 개선 적용: Spring의
@Async를 활용하여 각 데이터 조회 로직을 비동기 방식(Asynchronous) 으로 전환했습니다.- 이를 통해 각 작업을 별도의 스레드에서 동시에 처리하여 전체 응답 시간을 단축하고 시스템 부하를 분산시켰습니다.
개선 결과 아래는 개선 전후의 Heap 메모리 사용량 그래프입니다.
![]()
- ① 동기 방식: 요청이 몰릴 때마다 메모리 사용량이 160 MiB 이상으로 급증하며 불안정한 패턴을 보입니다.
- ② 비동기 방식: 여러 요청이 동시에 처리됨에도 불구하고, 메모리 사용량이 128 MiB 수준에서 안정적으로 유지되며 효율적으로 관리되는 것을 확인할 수 있습니다.
이러한 최적화를 통해 사용자는 여러 정보가 포함된 MyPage에 접속하더라도 지연 없는 빠른 응답을 경험할 수 있게 되었습니다.
메모리 안정성 확보뿐만 아니라, 실제 사용자 경험의 핵심 지표인 응답 시간의 개선을 검증하기 위해 Artillery로 부하 테스트를 진행했습니다.
테스트 시나리오
- 조건: 1분 동안 초당 10명에서 100명까지 사용자가 점진적으로 증가하는 상황
- 총 요청 수: 3,300개
개선 결과 (응답 시간)
비동기 방식 적용 후, 전체적인 응답 속도가 크게 향상되었으며 특히
p99값이 획기적으로 개선되었습니다.
p99응답 시간: 93ms- 가장 느린 요청(상위 1%)은 응답에 93ms가 소요되어, 일부 사용자는 현저한 지연을 경험할 수 있습니다.
p99응답 시간: 29ms (68% 개선)p99응답 시간(29ms)이 평균 응답 시간(13ms)과 큰 차이를 보이지 않습니다. 이는 일부 사용자가 지연을 겪는 '롱테일(Long Tail)' 현상 없이, 거의 모든 사용자가 예외 없이 일관되고 빠른 응답을 받을 수 있다는 것을 의미합니다.테스트 결과, 3,300개의 모든 요청이 성공적으로 처리되었으며, 비동기 아키텍처가 대규모 트래픽 상황에서도 안정적이고 뛰어난 사용자 경험을 제공함을 입증했습니다.
서비스 초기, 모임 조회수를 DB에 직접 읽고 쓰는(Read/Write) 방식으로 구현했을 때, 다수의 사용자가 동시에 조회하는 상황에서 레이스 컨디션(Race Condition) 이 발생하는 것을 발견했습니다. 이로 인해 조회수 카운트가 누락되는 등 데이터의 정합성이 깨지는 문제가 있었습니다.
해결 과정: Write-Back 전략 도입
이 문제를 해결하기 위해, 빈번한 쓰기(
Write)가 발생하는 조회수 집계 로직에 Redis 기반의 쓰기 지연(Write-Back) 전략을 도입했습니다.
조회수 증가 요청: 사용자가 모임을 조회하면, DB가 아닌 Redis의 Hash 자료구조에서 해당 모임의 조회수를 1 증가시킵니다. > - 만약 Redis에 캐시된 데이터가 없다면, DB에서 값을 읽어와 초기화한 후 카운트를 올립니다.
변경 내역 추적: 조회수가 변경된 모임의 ID는 Redis의 Set 자료구조에 별도로 저장합니다. Set은 중복을 허용하지 않으므로, 어떤 모임의 조회수가 변경되었는지 효율적으로 추적할 수 있습니다.
스케줄러를 이용한 DB 동기화:
@Scheduled어노테이션을 사용한 스케줄러가 주기적으로 실행됩니다. > - 스케줄러는 Redis의 Set에 기록된 모든 모임 ID를 조회합니다.
- ID 목록을 바탕으로 Redis에 저장된 최종 조회수들을 가져와 DB에 한 번의 트랜잭션으로 일괄 업데이트(
Batch Update) 합니다.- 업데이트가 완료되면 추적용 Set을 비워 다음 집계를 준비합니다.
개선 결과 이 방식을 통해 DB에 대한 직접적인 쓰기 요청을 최소화하여 레이스 컨디션을 원천적으로 차단하고 데이터의 정합성을 확보했습니다. 또한, DB 부하를 획기적으로 줄여 전체 시스템의 응답성과 안정성을 크게 향상시켰습니다.
![]()
문제 상황: 모임 상세 페이지나 회원 정보와 같이 조회가 빈번하지만 데이터 변경은 적은 요청에 대해서도 매번 DB에 직접 접근하는 방식으로 구현되어 있었습니다. 이로 인해 불필요한 DB I/O가 반복되었고, 이는 서비스의 응답 지연(Latency)을 유발 하고 DB에 과도한 부하를 주는 원인이 되었습니다.
해결 과정: Cache-Aside 전략 적용
DB까지 도달하는 비용을 줄이고 사용자에게 더 빠른 응답을 제공하기 위해, Redis를 활용한 캐싱(Caching) 전략을 도입했습니다.
- 캐시 우선 조회: 클라이언트의 요청이 들어오면, DB에 접근하기 전에 먼저 Redis 캐시에 데이터가 있는지 확인합니다.
- Cache Hit: 만약 Redis에 데이터가 존재한다면, DB를 거치지 않고 즉시 캐시 데이터를 사용자에게 반환합니다.
- Cache Miss: 데이터가 캐시에 없다면, DB에서 해당 데이터를 조회한 후, 조회한 데이터를 Redis에 저장하고 사용자에게 반환합니다. 이후 동일한 요청은 캐시에서 처리됩니다.
개선 결과 이 캐싱 전략을 통해 DB 조회 횟수를 획기적으로 줄여 시스템의 전체적인 응답 속도를 크게 향상시켰습니다. 또한, DB의 읽기(Read) 부하를 효과적으로 분산시켜 대규모 트래픽에도 더 안정적으로 대응할 수 있는 확장성 있는 구조를 갖추게 되었습니다.
![]()
이 API는 메인 페이지의 모임 리스트를 무한 스크롤로 조회하는 기능입니다.
한 번에 10개의 모임 데이터를 불러오며, 사용자가 스크롤을 내릴 때마다
마지막 데이터의 date, time, meeting_id를 기준으로 다음 데이터를 요청합니다.
Grafana 모니터링에서 CPU, Heap, Thread 사용량이 서로 다르게 반응하는 현상을 발견했습니다.
이를 기반으로 병목 구간을 분석하고, DB 옵티마이저의 동작을 고려한 인덱스 튜닝을 진행하였습니다.
문제 식별
- 기존에는 2번의 쿼리(IN 절 방식) 으로 데이터를 조회했습니다. > - 번째 쿼리: 인덱스(
idx_meeting_date_time_id)를 활용해 meeting 테이블에서 빠르게 10개의 meetingId 목록을 조회
- 2번째 쿼리: 첫 번째 쿼리 결과의 ID들을 IN 절로 전달하여 상세 정보를 조회 (
LEFT JOIN,GROUP BY,ORDER BY포함)- 이렇게 2번의 DB 접근이 이루어지다 보니 > - 네트워크 왕복 비용이 2배로 증가하고,
- HikariCP 커넥션 점유 시간이 길어져 병목 현상이 발생했습니다.
- 이를 해결하기 위해 > - 단일 쿼리(서브쿼리) 로 통합하여 DB 접근 횟수를 줄이고 네트워크 비용을 절감하도록 개선을 시도했습니다.
원인 분석
- 서브쿼리로 통합 후 테스트를 진행한 결과, 응답 시간이 오히려 느려지는 현상이 발생했습니다.
- 원인은 "DB 옵티마이저의 잘못된 실행 계획 선택" 이었습니다.
- 옵티마이저 동작 방식 > - 옵티마이저는
idx_meeting_date_time_id(date DESC, time DESC, meeting_id DESC) 인덱스를 활용해 “정렬 비용이 낮은 경로” 를 우선적으로 선택했습니다.
- 하지만 이후
GROUP BY절이 수행되면서 임시 테이블(temporary table+filesort) 이 생성되었습니다.- 임시 테이블이 만들어지는 순간, 인덱스의 원래 정렬 순서는 무시되고 "결과 집합이 뒤섞인 상태로 재정렬(
filesort)" 되었습니다.- 그 결과, CPU 부하 증가 및 GC 빈도가 높아지며 전체 성능이 저하되었습니다.
개선 적용
- EXPLAIN 명령어로 실제 실행 계획을 확인하여, 옵티마이저가 인덱스 기반 접근 대신 정렬 기반 접근을 선택하고 있음을 확인했습니다.
- 비효율적인 인덱스 제거로 옵티마이저의 선택지를 단순화했습니다.
DROP INDEX idx_meeting_date_time_id ON meeting;- 인덱스 제거 후, 옵티마이저가 불필요한 정렬/임시 테이블을 생성하지 않고 단일 테이블 풀 스캔 기반으로 최적화된 실행 계획을 선택하게 되었습니다.
- 결과 > - DB 접근 횟수는 1회로 감소
- 네트워크 왕복 비용 절감
- 쿼리 응답 속도 개선 효과를 얻었습니다.
아래는 Grafana 기반 모니터링 결과입니다.
![]()
- 스레드 수가 약 50개 내외에서 일정하게 유지됩니다.
- 각 요청이 하나의 스레드와 하나의 DB 커넥션만 사용하여 빠르게 종료되므로, 스레드가 불필요하게 늘어나지 않습니다.
- 즉, 스레드 풀 내에서 자원 순환이 원활하게 이루어져, 서버는 안정적인 부하 분산 상태를 유지했습니다.
- 각 요청이 데이터를 완전히 조회하기 위해 DB 커넥션을 두 번 획득해야 합니다.
- 요청이 몰리자, 준비된 HikariCP 커넥션 풀(10개) 이 빠르게 고갈되었고 대기(
BLOCKED) 상태의 스레드가 증가했습니다.- 이때 웹 서버는 “응답 없는 스레드가 많네? 새 스레드를 더 만들어야겠다!”라고 판단하여 계속해서 스레드를 생성합니다.
- 결과적으로 작업을 수행하지 못하고 대기만 하는 스레드가 누적되며, 스레드 수는 최대 90개까지 치솟았습니다.
![]()
- 전체 Heap 메모리 사용량이 40~60 MiB 범위에서 안정적으로 유지됩니다.
- 쿼리 실행이 완료되면 GC(Garbage Collector) 가 즉시 동작하여 Eden 영역이 빠르게 정리되는 형태를 보였습니다.
- 이는 임시 테이블 생성이 최소화되고 네트워크 왕복이 1회로 줄어든 덕분입니다.
- 첫 번째 쿼리에서 ID 목록 조회, 두 번째 쿼리에서 상세 데이터 조회가 이루어집니다.
- 두 번째 쿼리 시점에서 Heap 사용량이 96 MiB 이상까지 급등하는 피크 현상이 발생했습니다.
- 원인은 두 번의 네트워크 왕복 과정에서 임시 객체가 Heap에 중첩적으로 쌓이고, 쿼리 캐시 적재와 결과 병합 처리로 인해 GC 해제 타이밍이 지연된 것입니다.
- 결과적으로 메모리 반환이 늦어지고 사용량이 불안정하게 변동하는 패턴을 보였습니다.
p99응답 시간: 28ms- 평균 응답 시간은 15ms 수준으로, 상위 1% 요청(p99)과의 편차가 거의 없습니다. 이는 일부 사용자가 지연을 겪는 '롱테일(Long Tail)' 현상 없이, 거의 모든 사용자가 예외 없이 일관되고 빠른 응답을 받을 수 있다는 것을 의미합니다.
- 옵티마이저의 실행 계획이 단순화되어 CPU 부하 및 커넥션 점유율이 감소했습니다.
p99응답 시간: 87ms- 평균 응답 시간은 20ms 수준으로 비교적 안정적이지만, 상위 1% 요청(p99)은 단일 쿼리 방식에 비해 2배 이상 느린 응답 시간을 보였습니다.
- 두 번의 DB 네트워크 왕복으로 인해 Connection Pool 점유 시간 증가, Thread Blocking이 발생했습니다.
- 옵티마이저 개입은 줄었지만, 전체 요청 처리량(TPS) 은 단일 쿼리보다 낮았습니다.
테스트 결과, 7,150개의 모든 요청이 성공적으로 처리되었습니다.








