Cherrydan
검색 API 3.6초를 P95 22ms로 줄이고, 검증에서 기존 조회를 유지하며 검색 테이블을 교체하기까지
약 80만 건을 훑던 검색 병목을 관측하고, 전날 데이터 전용 검색 구조와 원자적 테이블 교체로 개선했습니다.
- Spring Boot
- MySQL
- Prometheus
- Grafana
- Loki
- Promtail
- Locust
Cherrydan은 체험형 마케팅 캠페인을 탐색하고 알림을 받는 서비스입니다. 기획 1명, 디자인 1명, 프론트엔드 2명, 백엔드 2명이 함께했으며, 저는 백엔드에서 캠페인 API와 푸시 알림, Spring Security 인증, GitHub Actions 기반 CI/CD를 구현했습니다.
검색 응답이 느리다는 현상을 쿼리 하나의 문제로 보지 않았습니다. 관측 체계로 병목을 재현하고, 사용자가 실제로 조회하는 데이터 범위를 찾은 뒤, 매일 갱신되는 검색 테이블을 기존 조회가 이어지는 상태에서 교체하는 복구 흐름까지 설계했습니다.
문제의 시작
사용자는 관심 지역이나 키워드를 등록하고, 매일 새로 올라온 캠페인 수를 푸시 알림으로 받았습니다. 알림에 ‘새 캠페인 17개’가 표시되면 이를 누른 검색 화면에서도 같은 조건의 실제 캠페인 17개를 바로 확인할 수 있어야 했습니다. 단순한 검색 화면이 아니라 사용자가 푸시를 받고 가장 먼저 경험하는 핵심 진입 경로였습니다.
아침 푸시 이후 이 검색 경로에 요청이 몰리자 평균 응답 시간이 3.6초까지 늘고 데이터베이스 CPU가 약 96~97%에 도달했습니다. Locust 100 VU 재현에서 커넥션 타임아웃 발생률이 약 80%였습니다. 검색 한 번의 지연이 다른 요청이 사용할 연결까지 붙잡아 서비스 전체 응답성에 영향을 주는 상태였습니다.
당시 쿼리는 전문 검색 인덱스를 먼저 사용한 뒤 날짜와 활성 조건을 적용했습니다. 약 80만 건에서 키워드 후보를 만든 다음 실제로 필요한 전날 활성 데이터 약 5,600건만 남기면서 후보의 약 95%를 뒤늦게 버렸습니다. 인덱스를 하나 더 추가하거나 연결 풀만 늘리는 방식은 같은 비효율을 더 많은 자원으로 버티게 할 뿐이었고, 사용자가 실제로 읽는 데이터 범위를 바꾸지 못했습니다.
그래서 먼저 지연, CPU, 연결 풀, 오류율과 로그를 같은 시간축에서 볼 수 있게 만들고, 각 변경을 Locust의 VU·P95·P99·CPU·타임아웃으로 비교했습니다. 최종 구조는 전날 검색과 과거 검색을 모두 유지하면서 주요 요청의 작은 데이터 범위를 활용해야 했고, 매일 검색 데이터를 갱신하는 순간에도 기존 조회가 빈 결과를 반환하지 않아야 했습니다.
먼저 같은 문제를 볼 수 있게 만들기
Prometheus로 요청 지연, 처리량, 오류율, 데이터베이스 CPU, DB 연결 풀, JVM 메모리와 GC까지 7개 신호를 수집했습니다. Grafana에서 같은 시간축으로 비교하고, Loki와 Promtail로 요청 로그를 모아 느린 구간을 쿼리 단위로 좁혔습니다.
검색 응답이 500ms를 넘는 경우를 예시 임계값으로 두고 Slack 알림을 연결했습니다. 알림 자체보다 지연이 생긴 시각의 CPU·연결 풀·로그를 한 화면에서 따라갈 수 있게 만든 것이 핵심이었습니다.
지연·자원·로그를 같은 시간축에 놓아 다음 실험의 근거로 삼았습니다.
검색 요청의 7개 신호는 Prometheus로, 로그는 Promtail과 Loki로 모아 Grafana에서 비교하고 지연 임계값을 넘으면 Slack으로 알립니다.
초기 재현과 실행 계획
Locust 100 VU 조건에서 평균 응답은 3.6초, 데이터베이스 CPU는 약 96~97%였고 커넥션 타임아웃 은 약 80%였습니다. 이 평균값은 이후 P95와 직접 개선율로 비교하지 않고 초기 상태를 재현한 별도 지표로 남겼습니다.
실행 계획을 확인하니 약 80만 건에 전문 검색을 먼저 수행한 뒤 전날 활성 데이터 약 5,600건만 남겼습니다. 전체의 약 95%를 뒤늦게 버리는 순서가 CPU와 연결 풀을 소진시키는 근본 원인이었습니다.
선택도가 높은 날짜 조건이 전문 검색 뒤에 적용돼 불필요한 후보를 크게 만들었습니다.
전체 약 80만 건에 전문 검색한 뒤 날짜와 활성 조건으로 전날 약 5,600건만 남기며 약 95%를 폐기합니다.
시도 1 — 날짜 조건과 조인 순서 고정
첫 번째 시도는 날짜 인덱스와 조인 순서를 고정해 전날 활성 집합을 먼저 만들고 검색을 적용했습니다. 10 VU에서 P95 220ms, P99 690ms를 기록했고 100 VU에서 CPU는 68%로 낮아졌습니다.
하지만 축소된 후보 전체에 검색 함수를 매번 적용해 동시 요청이 늘면 CPU 여유가 충분하지 않았습니다. 실행 계획은 좋아졌지만 검색 비용이 요청마다 반복된다는 한계가 남았습니다.
필터 순서는 바로잡았지만 검색 계산이 매 요청에 남았습니다.
날짜 인덱스로 전날 활성 후보를 먼저 줄인 뒤 검색 함수를 적용하지만 요청마다 검색 계산이 반복됩니다.
시도 2 — 작은 집합에는 단순 검색
두 번째 시도는 전날의 작은 후보 집합에서 단순 부분 일치 검색을 사용했습니다. 500 VU에서 P95 47ms, P99 220ms, CPU 42%를 기록했습니다.
응답은 더 빨라졌지만 매 요청이 큰 원본 테이블에서 전날 집합을 추출하는 구조는 그대로였습니다. 트래픽의 약 90%가 전날 캠페인을 찾고, 실제 검색 대상은 전체 데이터의 약 0.7%라는 사용 패턴을 데이터 구조에 반영할 필요가 있었습니다.
대부분의 요청이 전체 중 아주 작은 같은 범위를 반복해 읽었습니다.
검색 트래픽 약 90%가 전체 데이터 약 0.7%인 전날 캠페인을 반복 조회해 전용 검색 구조가 필요했습니다.
최종 선택 — 전날 전용 FULLTEXT와 과거 LIKE
전날 캠페인은 전용 검색 테이블의 FULLTEXT 인덱스로 조회하고, 과거 날짜 요청은 원본 테이블에서 LIKE 검색으로 처리했습니다. 빈번한 경로는 작고 예측 가능한 집합에 맞추고 드문 경로는 원본을 유지해 동기화 범위를 제한했습니다.
전날 데이터가 비어 있거나 요청이 대상 날짜를 벗어나면 원본 검색으로 돌아가도록 했습니다. 북마크 같은 연관 데이터는 묶어서 조회해 검색 병목을 없앤 뒤 N+1 조회가 새 병목이 되지 않게 했습니다.
빈번한 전날 검색은 전용 FULLTEXT로, 과거 검색은 원본 LIKE로 분리했습니다.
전날 요청은 전용 검색 테이블의 FULLTEXT를 사용하고 과거 요청이나 전용 데이터가 비면 원본 테이블의 LIKE 검색을 사용합니다.
매일 갱신되는 테이블을 안전하게 교체하기
주기 작업은 임시 테이블에 전날 활성 데이터를 모두 적재합니다. 임시 테이블 적재가 완료되면 메타데이터 수준의 원자적 rename으로 기존 검 색 테이블과 교체해, 갱신 도중에도 기존 테이블이 조회를 계속 받게 했습니다.
rename 대기 시간은 60초로 제한했습니다. 제한을 넘기면 기존 테이블을 그대로 두고 실패를 기록한 뒤 3분 간격으로 다시 시도하되, 최초 시도를 포함한 전체 실행 횟수를 최대 3회로 제한했습니다. 이 제한은 교착을 없앤다는 의미가 아니라 잠금 대기가 서비스 요청을 오래 붙잡지 않게 하는 경계입니다.
임시 테이블 적재가 끝난 뒤 이름을 바꾸고 실패하면 기존 검색 경로를 유지합니다.
주기 작업이 임시 테이블을 적재하는 동안 검색 요청은 기존 테이블을 사용합니다. 원자적 rename이 60초 안에 되지 않으면 기존 테이블을 유지하고 3분 간격으로 다시 시도하되 최초 시도를 포함한 전체 실행 횟수를 최대 3회로 제한합니다.
같은 조건으로 비교한 결과
최종 구조는 500 VU에서 P95 22ms, P99 150ms, 데이터베이스 CPU 15.2%, 커넥션 타임아웃 0%를 기록했습니다. 서로 다른 부하 조건의 수치는 같은 행에서 섞지 않고, 각 실험의 조건과 함께 기록했습니다.