들어가며
이전 글에서 OOM 문제를 해결했지만, 여전히 아티클 조회 API는 빠르면 2초, 느리면 30초 정도로 많은 시간이 소요됐다. 검색, 필터링, 정렬, 무한스크롤 4가지 기능을 하나의 쿼리로 처리하고 있었기 때문이다.
시리즈의 이전 글이 궁금하다면:
이 문제를 처리하기 위해 여러가지 경우의 수 별로 테스트를 하고, 문제의 원인을 찾고, 개선하는 과정을 반복했다.
이 글에서는 techStack 필터링을 중심으로 쿼리 최적화를 통해 성능 개선 시도와, 그 과정에서 마주친 한계점을 소개하려고 한다.
실험환경 및 제약조건
이 글에서 다루는 최적화의 대상은 “아티클 탐색 기능”이다. 하나의 API로 검색, 필터링, 정렬, 무한스크롤을 모두 제공한다.
아티클 탐색 API 구조
API 스펙
- 엔드포인트: GET /articles
- 쿼리 파라미터:
- 검색: search
- 필터링: sector, topics, techStacks
- 정렬: sort
- 페이징: limit, cursor
실제 요청 예시
GET /articles?sector=BE&techStacks=java,docker&topics=API_DESIGN,DATABASE&sort=clicks&limit=20&cursor=1111_12&search=moaon
이 API는 다양한 조건 조합을 처리해야 한다. 필터링만 고려해도 다음과 같은 조건이 있다.
- sector만 (예: BE 아티클)
- techStack만 (예: java 사용 아티클)
- topic만 (예: API_DESIGN 주제 아티클)
- sector + techStack (예: BE이면서 java 사용)
- sector + topic (예: BE이면서 API_DESIGN 주제)
- techStack + topic (예: java 사용하면서 API_DESIGN 주제)
- sector + techStack + topic (예: BE + java + API_DESIGN)
- 조건 없음 (전체 아티클)
여기에 techStack와 topics는 List<String> 타입으로, 여러 개를 선택할 수 있다. techStack 으로 java와 docker를 선택하면, java와 docker를 둘 다 사용한 아티클이 필터링된다.
테이블 구조
DB 테이블 구조를 간단히 살펴보자. 필터링 조건 중 sector는 article과 같은 테이블에 있다. techStack과 topic 필터링을 위해서는 JOIN이 필요하다.
-- article: 기본 정보
CREATE TABLE article (
id BIGINT PRIMARY KEY,
sector ENUM('BE', 'FE', 'ANDROID', 'IOS', 'INFRA', 'NON_TECH'),
clicks INT,
created_at DATETIME,
-- ... 기타 칼럼들
-- 정렬/무한스크롤을 위한 복합 인덱스
INDEX idx_clicks_id (clicks DESC, id DESC),
INDEX idx_created_at_id (created_at DESC, id DESC)
-- 검색을 위한 전문 검색 인덱스
FULLTEXT INDEX `idx_article_fulltext` (`title`, `summary`, `content`) WITH PARSER NGRAM
);
-- article_topics: 1:N 관계
CREATE TABLE article_topics (
article_id BIGINT,
topics ENUM('API_DESIGN', 'DATABASE', ...)
);
-- tech_stack: 기술스택 목록
CREATE TABLE tech_stack (
id BIGINT PRIMARY KEY,
name VARCHAR(255) UNIQUE
);
-- article_tech_stack: N:M 중간 테이블
CREATE TABLE article_tech_stack (
article_id BIGINT,
tech_stack_id BIGINT
);
실험 범위
이 글에서는 techStack 필터링을 중심으로 다룬다.
모든 조건 조합을 다루기에는 경우의 수가 너무 많다. sector, topic, techStack 필터링에 검색, 정렬, 페이징까지 조합하면 수백 가지 케이스를 테스트해야 한다. 실제로는 다양한 조건 조합으로 실험했지만 이 글에서는 간소화된 케이스를 통해 문제의 본질에 집중해보려고 한다.
사용자가 “Java와 Docker를 모두 사용하는 아티클”을 요청하는 상황을 생각해보자. sort와 limit는 필수 파라미터이므로 다음과 같은 요청을 받게 된다.
GET /articles?techStacks=java,docker&sort=clicks&limit=20
기존 방식 - JOIN을 이용한 조회
처음에는 가장 직관적인 방식으로 쿼리를 작성했다.
- 연관 테이블을 모두 JOIN 한다.
- WHERE, GROUP BY, HAVING으로 교집합을 구한다.
- ORDER BY, LIMIT으로 상위 21개 페이지를 반환한다.
SELECT
a.id,
a.title,
-- ... 기타 컬럼들
FROM
article a
LEFT JOIN article_tech_stack ats ON a.id = ats.article_id
JOIN tech_stack ts ON ts.id = ats.tech_stack_id
WHERE
ts.name IN ('java', 'docker')
GROUP BY
a.id
HAVING
COUNT(DISTINCT ats.tech_stack_id) = 2
ORDER BY
a.clicks DESC,
a.id DESC
LIMIT 21;
하지만 이 쿼리는 평균 4.93초가 소요되는 심각한 성능 문제가 있었다.
병목 지점 분석
EXPLAIN ANALYZE로 실행 계획을 분석한 결과, 전체 시간 중 대부분(4.3초)이 article 테이블 조인에 소요되고 있었다.
실행 흐름
- tech_stack 필터링: tech_stack 테이블에서 ‘java’와 ‘docker’를 찾음
- article_tech_stack 조인: java 또는 docker를 가진 article_tech_stack 레코드 탐색
- article 조인: java 또는 docker를 가진 레코드 각각에 대해 article 조회
- GROUP BY + HAVING: tech_stack이 정확히 2개인 것만 필터링하여 교집합 처리
- ORDER BY + LIMIT: (clicks, id)로 내림차순 정렬 후 상위 21개 선택
소요 시간 비교
| 단계 | 작업 | 처리량 | 시간 | 비고 |
| 1 | tech_stack 필터링 | 2개 | 0.5초 | |
| 2 | article_tech_stack 조인 | 32,138개 | 0.2초 | |
| 3 | article 조인 | 32,138번 | 4.3초 | ⚠️ 병목 |
| 4 | GROUP BY + HAVING | 148개 | 0.2초 | 99.5%가 걸러짐 |
| 5 | ORDER BY + LIMIT | 21개 | 0.1초 |
기술 스택 필터링으로 최종 148개의 아티클만 필요함에도 불구하고, MySQL 옵티마이저는 article 조인을 먼저 수행하는 실행 계획을 선택했다. 그 결과 32,138번의 불필요한 조인 연산이 발생했다.
상세 EXPLAIN ANALYZE 결과
-> Limit: 21 row(s) (actual time=4958..4958 rows=21 loops=1)
-> Sort: a1_0.clicks DESC, a1_0.id DESC (actual time=4958..4958 rows=21 loops=1)
-> Filter: (`count(distinct article_tech_stack.tech_stack_id)` = 2) (actual time=4853..4958 rows=148 loops=1)
-> Stream results (actual time=4853..4957 rows=31990 loops=1)
-> Group aggregate: count(distinct article_tech_stack.tech_stack_id) (actual time=4853..4931 rows=31990 loops=1)
-> Sort: a1_0.id (actual time=4853..4886 rows=32138 loops=1)
-> Stream results (cost=62966 rows=31940) (actual time=20.5..4586 rows=32138 loops=1)
-> Nested loop inner join (cost=62966 rows=31940) (actual time=20.5..4534 rows=32138 loops=1)
-> Nested loop inner join (cost=28823 rows=31940) (actual time=20.2..184 rows=32138 loops=1)
-> Filter: (ts2_0.`name` in ('java','docker')) (cost=1.53 rows=2) (actual time=0.398..0.532 rows=2 loops=1)
-> Covering index range scan on ts2_0 using UK_tech_stack_name over (name = 'docker') OR (name = 'java') (cost=1.53 rows=2) (actual time=0.396..0.527 rows=2 loops=1)
-> Filter: (ts1_0.article_id is not null) (cost=13612 rows=15970) (actual time=16.5..90.7 rows=16069 loops=2)
-> Index lookup on ts1_0 using FK_article_tech_stack_tech_stack (tech_stack_id=ts2_0.id) (cost=13612 rows=15970) (actual time=16.5..89.3 rows=16069 loops=2)
-> Single-row index lookup on a1_0 using PRIMARY (id=ts1_0.article_id) (cost=0.969 rows=1) (actual time=0.135..0.135 rows=1 loops=32138)
병목 지점은 아래 부분에서 확인 가능하다.
-> Nested loop inner join (cost=62966 rows=31940) (actual time=20.5..4534 rows=32138 loops=1)
...
-> Single-row index lookup on a1_0 using PRIMARY (id=ts1_0.article_id) (cost=0.969 rows=1) (actual time=0.135..0.135 rows=1 loops=32138)
- rows=32138 loops=1: 32,138개 조인
- actual time=20.5..4534: 4.3초 소요
개선 시도 - 서브쿼리를 이용한 JOIN
병목 지점을 개선하기 위해 서브쿼리로 필터링을 먼저 수행하는 방식을 시도했다. 서브쿼리에서 techStack 조건을 만족하는 article_id 목록을 먼저 구한 뒤, 이 결과와 article 테이블을 조인했다.
SELECT
a1_0.id,
a1_0.title,
-- ... 기타 컬럼들
FROM
article a1_0
JOIN (
SELECT ts1_0.article_id
FROM article_tech_stack ts1_0
JOIN tech_stack ts2_0 ON ts2_0.id = ts1_0.tech_stack_id
WHERE ts2_0.name IN ('java', 'docker')
GROUP BY ts1_0.article_id
HAVING COUNT(DISTINCT ts1_0.tech_stack_id) = 2
) AS filtered_articles ON a1_0.id = filtered_articles.article_id
ORDER BY
a1_0.clicks DESC,
a1_0.id DESC
LIMIT 21;
같은 조건(java, docker 필터링)에서 4.93초 → 0.69초로 약 7배 개선되었다.
핵심 개선: 조인 순서 변경
실행 흐름 비교
| 단계 | 기존 JOIN 방식 | 서브쿼리 + JOIN 방식 |
| 1 | tech_stack 필터링 (2개) | tech_stack 필터링 (2개) |
| 2 | article_tech_stack 조인 (32,138개) | article_tech_stack 조인 (32,138개) |
| 3 | article 조인 (32,138번) ⚠️ | GROUP BY + HAVING (148개) |
| 4 | GROUP BY + HAVING (148개) | article 조인 (148번) ✅ |
| 5 | ORDER BY + LIMIT (21개) | ORDER BY + LIMIT (21개) |
성능 비교
| 항목 | 기존 | 개선 | 효과 |
| article 조인 횟수 | 32,138번 | 148번 | 217배 감소 |
| article 조인 시간 | 4.3초 | 0.04초 | 107배 개선 |
| 총 실행 시간 | 4.93초 | 0.69초 | 7배 개선 |
기존 JOIN 방식과 비교했을 때 article 조인 시점의 차이가 있었고, 이는 조인 횟수의 차이로 이어졌다. 기존 방식은 조인을 먼저 하고, GROUP BY와 HAVING을 통해 교집합 필터링을 한다. 서브쿼리를 사용한 개선안은 서브쿼리에서 교집합 필터링까지 끝낸 후, 148개의 아티클을 조인한다.
한계: 필터링 조건과 데이터 분포
서브쿼리로 필터링 한 결과 무려 7배(4.93초 → 0.69초)나 개선됐다. 완벽한 솔루션인 것 같지만 여기에는 함정이 있다.
이 쿼리는 필터링 결과의 개수에 큰 영향을 받는다.
java와 docker로 필터링 하는 예시에서는 두 기술스택을 가지는 아티클이 100만건 중 148개 있었기 때문에 148회의 JOIN만 하면 됐다. 그런데 모아온에 저장된 아티클의 기술스택 분포도를 살펴보면, 특정 기술스택에 편향되는 경향을 보인다. 예를들면, 1,063개의 아티클 중에 react 기술 스택을 선택하면 127개의 아티클이 검색된다. 즉 특정 기술스택은 전체 아티클의 1/10을 차지하기도 한다.

100만 건 기준으로 계산하면, react 필터링 시 약 12만 개가 선택되고, 12만 번의 조인이 발생하여 다시 수 초가 소요된다. 결론적으로, 서브쿼리 방식은 필터링 결과가 적을 때만 효과적이다. 데이터 분포에 따라 성능이 크게 달라지는 불안정한 해결책이었다.
상세 EXPLAIN ANALYZE 결과
-> Limit: 21 row(s) (actual time=91.1..91.1 rows=21 loops=1)
-> Sort: a1_0.clicks DESC, a1_0.id DESC, limit input to 21 row(s) per chunk (actual time=91..91 rows=21 loops=1)
-> Stream results (cost=28995 rows=0) (actual time=31.9..90.9 rows=148 loops=1)
-> Nested loop inner join (cost=28995 rows=0) (actual time=31.7..90.4 rows=148 loops=1)
-> Table scan on filtered_articles (cost=2.5..2.5 rows=0) (actual time=31.1..31.1 rows=148 loops=1)
-> Materialize (cost=0..0 rows=0) (actual time=31.1..31.1 rows=148 loops=1)
-> Filter: (count(distinct article_tech_stack.tech_stack_id) = 2) (actual time=25.8..31 rows=148 loops=1)
-> Group aggregate: count(distinct article_tech_stack.tech_stack_id) (actual time=25.8..29.6 rows=31990 loops=1)
-> Sort: ts1_0.article_id (actual time=25.8..26.7 rows=32138 loops=1)
-> Stream results (cost=3158 rows=30644) (actual time=3.55..15.8 rows=32138 loops=1)
-> Nested loop inner join (cost=3158 rows=30644) (actual time=3.55..13.4 rows=32138 loops=1)
-> Filter: (ts2_0.`name` in ('java','docker')) (cost=1.53 rows=2) (actual time=3.09..3.11 rows=2 loops=1)
-> Covering index range scan on ts2_0 using UK_tech_stack_name over (name = 'docker') OR (name = 'java') (cost=1.53 rows=2) (actual time=3.09..3.1 rows=2 loops=1)
-> Covering index lookup on ts1_0 using idx_tech_article (tech_stack_id=ts2_0.id) (cost=812 rows=15322) (actual time=0.305..4.41 rows=16069 loops=2)
-> Single-row index lookup on a1_0 using PRIMARY (id=filtered_articles.article_id) (cost=0.946 rows=1) (actual time=0.4..0.4 rows=1 loops=148)
핵심 개선 사항은 다음과 같다
-> Nested loop inner join (cost=28995 rows=0) (actual time=31.7..90.4 rows=148 loops=1)
-> Table scan on filtered_articles (cost=2.5..2.5 rows=0) (actual time=31.1..31.1 rows=148 loops=1)
-> Materialize (cost=0..0 rows=0) (actual time=31.1..31.1 rows=148 loops=1)
...
-> Single-row index lookup on a1_0 using PRIMARY (id=filtered_articles.article_id) (cost=0.946 rows=1) (actual time=0.4..0.4 rows=1 loops=148)
- Materialize: 서브쿼리가 148개 필터링 (31ms)
- article 조인: 148번 (59ms)
결론 - 절대적인 답은 없다
기술 스택 필터링 실험 결과
서브쿼리 방식 외에도 다양한 방식으로 최적의 쿼리를 찾으려고 실험을 계속 진행했다. 하지만 다른 방식들도 필터링 조건의 조합에 따라서 소요 시간의 차이가 많이 났다.
USE INDEX 키워드를 이용해 정렬 관련 인덱스 article(clicks, id)를 사용하는 방식도 시도해봤다. 아래 표에 보이는 것처럼 기술 스택 1개일 때는 인덱스 힌트 방식이 223ms로 가장 빨랐지만, 2개부터는 서브쿼리 방식보다 20배 이상 느려졌다. 같은 쿼리라도 조건과 데이터 분포에 따라 전혀 다른 성능을 보인다.
| 쿼리 | techStack 1개 | techStack 2개 | techStack 3개 |
| 단순 조인 | 1,393 ms | 4,930 ms | 5,878 ms |
| 서브쿼리 + 조인 | 964 ms | 249 ms | 200 ms |
| 인덱스 힌트 사용 | 223 ms | 5,539 ms | 40,608 ms |
추가로 고려해야 할 부분
위의 표만 보면 서브쿼리 방식이 괜찮아 보일 수 있다. 하지만 기술스택 필터링은 “아티클 조회 기능”이 제공하는 기능 중 아주 일부분이다. 필터링, 정렬, 검색, 페이징 중 하나라도 조건이 추가되면 서브쿼리 방식도 금방 수초대의 시간으로 늘어난다.
이 기능들이 조합되면 경우의 수는 셀 수 없이 많아진다. 각 조합마다 최적의 쿼리를 찾는 것은 현실적으로 불가능하다. 더 큰 문제는 데이터 분포의 변화다. 아티클이 특정 직군, 특정 기술스택에 몰려 있을 수 있다. 이러한 데이터 분포가 변화할 때 마다 같은 조건의 쿼리라도 성능이 크게 달라진다.
최종 결론
결국 쿼리 튜닝을 통한 아티클 조회 성능 시도는 실패했다.
서브 쿼리 활용과 같은 쿼리 튜닝을 통해 기존의 방식보다는 평균적인 응답 시간이 개선됐지만, 조건에 따라 쓸 수 없는 성능이 나오기도 했다. 결국 “최적”이 아닌 “최선”으로 판단되는 쿼리를 사용하도록 코드를 수정했다. 해당 과정은 아래 PR에서 볼 수 있다.
https://github.com/woowacourse-teams/2025-moaon/pull/406
1차 조회 성능 개선은 실패로 돌아갔지만, 여기서 끝나지는 않았다. 다음 편에서는 아예 다른 접근 방식을 통해 조회 개선에 성공한 사례를 소개한다.
'개발 > 기타' 카테고리의 다른 글
| 모아온 조회 API 성능 개선기 (2) - OOM 문제 해결 (0) | 2025.11.24 |
|---|---|
| 모아온 조회 API 성능 개선기 (1) - 700만 건 테스트 데이터 생성 (0) | 2025.10.06 |