모아온 조회 API 성능 개선기 (3) - 쿼리 튜닝의 한계

2025. 12. 1. 01:38·개발/기타

들어가며

이전 글에서 OOM 문제를 해결했지만, 여전히 아티클 조회 API는 빠르면 2초, 느리면 30초 정도로 많은 시간이 소요됐다. 검색, 필터링, 정렬, 무한스크롤 4가지 기능을 하나의 쿼리로 처리하고 있었기 때문이다.

시리즈의 이전 글이 궁금하다면:

  • 1편: 모아온 조회 API 성능 개선기 (1) - 700만 건 테스트 데이터 생성
  • 2편: 모아온 조회 API 성능 개선기 (2) - OOM 문제 해결

이 문제를 처리하기 위해 여러가지 경우의 수 별로 테스트를 하고, 문제의 원인을 찾고, 개선하는 과정을 반복했다.

이 글에서는 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는 다양한 조건 조합을 처리해야 한다. 필터링만 고려해도 다음과 같은 조건이 있다.

  1. sector만 (예: BE 아티클)
  2. techStack만 (예: java 사용 아티클)
  3. topic만 (예: API_DESIGN 주제 아티클)
  4. sector + techStack (예: BE이면서 java 사용)
  5. sector + topic (예: BE이면서 API_DESIGN 주제)
  6. techStack + topic (예: java 사용하면서 API_DESIGN 주제)
  7. sector + techStack + topic (예: BE + java + API_DESIGN)
  8. 조건 없음 (전체 아티클)

여기에 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을 이용한 조회

처음에는 가장 직관적인 방식으로 쿼리를 작성했다.

  1. 연관 테이블을 모두 JOIN 한다.
  2. WHERE, GROUP BY, HAVING으로 교집합을 구한다.
  3. 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 테이블 조인에 소요되고 있었다.

실행 흐름

  1. tech_stack 필터링: tech_stack 테이블에서 ‘java’와 ‘docker’를 찾음
  2. article_tech_stack 조인: java 또는 docker를 가진 article_tech_stack 레코드 탐색
  3. article 조인: java 또는 docker를 가진 레코드 각각에 대해 article 조회
  4. GROUP BY + HAVING: tech_stack이 정확히 2개인 것만 필터링하여 교집합 처리
  5. 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을 차지하기도 한다.

1,063개 중 127개가 선택된 모습

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
'개발/기타' 카테고리의 다른 글
  • 모아온 조회 API 성능 개선기 (2) - OOM 문제 해결
  • 모아온 조회 API 성능 개선기 (1) - 700만 건 테스트 데이터 생성
yesjuhee
yesjuhee
Dopamine Driven Developer
  • yesjuhee
    나랑 노랑
    yesjuhee
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • 개발 (11)
        • DevOps (2)
        • Java & Spring (4)
        • AI (1)
        • DB (1)
        • 기타 (3)
      • 후기 or 회고 (15)
        • 우아한테크코스 (11)
        • 기타 (4)
      • 독서 (2)
      • 기타 (1)
      • 초록 스터디 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    초록 스터디
    후기
    모아온
    QueryDSL
    coderabbit
    독서
    Ai
    mysql
    우테코
    레벨2
    DispatcherServlet
    초록 밋업
    SCG
    소프티어 부트캠프
    레벨3
    claude code
    바킹독
    spring
    레벨4
    우아콘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yesjuhee
모아온 조회 API 성능 개선기 (3) - 쿼리 튜닝의 한계
상단으로

티스토리툴바