Monew 프로젝트 개선: 뉴스기사 목록 조회 성능 개선 - 복합 인덱스 적용기

2026. 5. 7. 23:52·코드잇 스프린트/실습

[Monew 프로젝트 메인 페이지]

현재 모뉴 프로젝트의 메인페이지는 뉴스기사 목록 페이지이며, 기본적으로 목록조회로 뉴스기사들이 나타난다.

 

WHERE is_deleted = false
  AND source = 'NAVER'
ORDER BY published_at DESC, created_at DESC
LIMIT 11

기본적으로 뉴스기사 목록 조회 API는 위의 조건을 기본으로 사용하고있다.

 

또한, 목록조회시 아래와 같은 기능이 함께 포함되어 있다.

 

  • 댓글 수 조회
  • 사용자별 조회 여부(viewedByMe)
  • 관심사 기반 필터링
  • 커서 페이지네이션

[실제 QueryDSL 기반 조회 쿼리]

select
    na1_0.id,
    na1_0.source,
    na1_0.original_link,
    na1_0.title,
    na1_0.published_at,
    na1_0.summary,
    count(distinct c1_0.id),
    na1_0.view_count,
    av1_0.id is not null,
    na1_0.created_at
from news_articles na1_0
left join article_interests ai1_0
    on ai1_0.news_article_id = na1_0.id
left join comments c1_0
    on c1_0.article_id = na1_0.id
left join article_views av1_0
    on av1_0.news_article_id = na1_0.id
where
    na1_0.is_deleted = false
    and na1_0.source = 'NAVER'
group by ...
order by
    na1_0.published_at desc,
    na1_0.created_at desc
fetch first 11 rows only;

 

 

조회 조건과 정렬 기준을 보면

source, is_deleted, published_at, created_at의 컬럼들이 반복적으로 사용되고 있다.

 

별도의 복합 인덱스가 존재하지 않아 DB는 조회시 조건 필터링, 정렬 수행, LIMIT 적용 과정을 매번 수행해야했다.

특히 ORDER BY published_at DESC, created_at DESC 구간에서 정렬 비용이 증가할 가능성이 있었다.


왜 인덱스를 선택했는가?

성능 개선 방법은 여러 선택지가 있었다.

Redis 캐싱, QueryDSL 동적 조인 제거, 댓글 수 비정규화, 복합 인덱스 추가등 ..

 

Redis를 우선 적용하지 않은 이유

  • 현재 뉴스기사 데이터 수는 약 200건 수준으로 많지 않았고, 캐싱보다 DB 조회 구조 자체를 먼저 최적화하는게 우선.
  • Redis는 캐시 키 설계, TTL 관리, 캐시 무효화 전략등 추가 고려사항이 존재했다.
  • 현재 병목은 반복되는 WHERE + ORDER BY 패턴에 가깝기때문에 DB 레벨에서 조회 경로를 최적화 하는 인덱스 추가가 가장 직접적인 해결 방법이라고 판단했다.

인덱스의 이해

기본키(PK) 만들면 DB가 자동으로 인덱스를 생성한다.

CREATE TABLE users (
    id UUID PRIMARY KEY,
    nickname VARCHAR(50)
);

 

id
 ├── a123
 ├── b234
 ├── c345

PK는 유일성을 띄니까 이런식으로 자료구조를 만든다. 그래서 Where id='a123'을 하면 테이블 전체를 안뒤지고 바로 a123으로 간다. 인덱스 설정이 안돼있으면 테이블 1행부터 하나하나 찾아서 조회한다.

 

[인덱스가 없는경우 보통 DB의 조회]

1행 읽기
→ source=CHOSUN
→ 조건 불만족
→ 다음 행

2행 읽기
→ source=NAVER 만족
→ is_deleted=true
→ 조건 불만족
→ 다음 행

3행 읽기
→ source=NAVER 만족
→ is_deleted=false 만족
→ 결과 포함

4행 읽기
→ source=YONHAP
→ 불만족

5행 읽기
→ source=NAVER 만족
→ is_deleted=false 만족
→ 결과 포함

 

 


[인덱스 설계]

CREATE INDEX idx_news_articles_source_deleted_published_created
ON news_articles (
    source,
    is_deleted,
    published_at DESC,
    created_at DESC
);
  • 여러 컬럼을 하나의 인덱스로 묶은것: 복합 인덱스
source
 └── NAVER
      └── is_deleted=false
           └── published_at DESC
                └── created_at DESC

복합 인덱스를 만들어놓으면 위같이 이미 정렬이 돼있다.

복합 인덱스는 source → is_deleted → published_at DESC → created_at DESC
순서 기준으로 정렬된 B-Tree 구조를 생성한다. 따라서 PostgreSQL은 테이블 전체를 순차 탐색하지 않고,
조건에 해당하는 범위로 빠르게 접근할 수 있다.

 

PostgreSQL의 B-Tree 인덱스는 트리 구조 기반으로 데이터를 관리하기 때문에, 탐색 비용이 O(log N) 수준으로 감소한다.
예를 들어 데이터가 많아질수록 Sequential Scan은 전체 row를 순차 탐색해야 하지만,
B-Tree 인덱스는 소수의 트리 탐색만으로 원하는 범위에 접근할 수 있다.

 

[인덱스 추가 결과]

현재 286개 뉴스기사 데이터가 있다.

위와같이 복합 인덱스를 추가하고 돌렸을때 순차 스캔 방식이 비용이 더 싸다고 PostgreSQL의 옵티마이저가 판단해서 INDEX를 적용한 조회를 하지 않았다.

 

이는 뉴스기사가 앞으로 더 쌓여서 순차 스캔보다 인덱스 조회하는 비용이 더 싸다고 생각될때 옵티마이저가 자동으로 실행계획을 바꿀것이다.


[마무리]

실제로 EXPLAIN ANALYZE 결과를 확인했을 때, 현재 데이터 수가 적은 환경에서는 Seq Scan이 선택되었지만,
정렬 비용(Sort)과 그룹 집계(GroupAggregate)가 주요 비용 요소로 나타나는 것을 확인할 수 있었다.

-> 그룹 집계:  ex)뉴스기사별 댓글 수 구하는 작업

 

특히 뉴스기사 데이터는 지속적으로 증가하는 특성이 있기 때문에, 향후 대량 데이터 환경에서 Index Scan을 활용할 수 있도록
기본 조회 패턴에 맞춘 복합 인덱스를 선제적으로 적용했다.


 

실제 Seq Scan은 0.1ms 수준이였지만 GroupAggregate은 0.9ms/ Sort도 1.096ms 을 사용했다.

-> 테이블 읽기 자체보다 Group By + 정렬 작업이 더 무겁다.

 

GroupAggregate: 같은 기사끼리 묶기, 댓글 개수 계산

Sort: published_at DESC 정렬

 

 

[그래도 인덱스 만들었는데 강제 적용해봤다.]

EXPLAIN ANALYZE
SELECT
    na.id,
    na.source,
    na.original_link,
    na.title,
    na.published_at,
    na.summary,
    COUNT(DISTINCT c.id),
    na.view_count,
    av.id IS NOT NULL,
    na.created_at
FROM news_articles na
         LEFT JOIN article_interests ai
                   ON ai.news_article_id = na.id
         LEFT JOIN comments c
                   ON c.article_id = na.id
                       AND c.is_deleted = false
         LEFT JOIN article_views av
                   ON av.news_article_id = na.id
                       AND av.user_id = '71a099cb-4f16-471e-844a-a1d51248dc32'
WHERE na.is_deleted = false
  AND na.source = 'NAVER'
GROUP BY
    na.id,
    na.source,
    na.original_link,
    na.title,
    na.published_at,
    na.summary,
    na.view_count,
    av.id,
    na.created_at
ORDER BY
    na.published_at DESC,
    na.created_at DESC
    FETCH FIRST 11 ROWS ONLY;

 

1346건 기준,,

인덱스 적용전: Execution Time: 14.334ms

인덱스 적용후: Execution Time: 13.728 ms

 

Index Cond: (((source)::text = 'NAVER'::text) AND (is_deleted = false))

source와 is_deleted는 인덱스를 탔는데 published_at DESC와 created_at DESC는 정렬까지는 쓰지 못했다.

 

LEFT JOIN comments
COUNT(DISTINCT c.id)
GROUP BY ...
LEFT JOIN article_interests
LEFT JOIN article_views

조인하고 집계하면서 row 순서가 깨져서 PostgreSQL이 인덱스 순서를 그대로 최종 정렬에 쓰기 어려워진것.

'코드잇 스프린트 > 실습' 카테고리의 다른 글

Docker 컨테이너 포트를 80으로 쓰면 안되는 이유  (0) 2026.04.29
뉴스기사 조회수 증가 동시성 문제 트러블 슈팅  (0) 2026.04.23
MoNew 프로젝트: ERD 설계 데이터 타입에 대한 고민(VARCHAR vs TEXT)  (0) 2026.04.15
FINDEX 프로젝트: Railway로 프로젝트 배포하기  (0) 2026.03.17
Findex 프로젝트: 지수정보 update가 이루어지지않는다 400에러  (0) 2026.03.17
'코드잇 스프린트/실습' 카테고리의 다른 글
  • Docker 컨테이너 포트를 80으로 쓰면 안되는 이유
  • 뉴스기사 조회수 증가 동시성 문제 트러블 슈팅
  • MoNew 프로젝트: ERD 설계 데이터 타입에 대한 고민(VARCHAR vs TEXT)
  • FINDEX 프로젝트: Railway로 프로젝트 배포하기
과컴
과컴
벡엔드 개발자 최소기준 맞추겠습니다.
  • 과컴
    곽의 프로그램
    과컴
  • 전체
    오늘
    어제
    • 분류 전체보기 (76)
      • 위클리페이퍼 (6)
      • 파이썬 (4)
      • 코드잇 스프린트 (48)
        • Spring 이론 (7)
        • Java이론 (11)
        • 실습 (23)
      • 백엔드 개발자 최소기준 (1)
      • 코딩테스트 (5)
        • 알고리즘 (0)
        • SQL (1)
      • Git (5)
      • 스프링부트 핵심가이드 (1)
      • 트러블 슈팅 (2)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백준브론즈
    백준2576
    파이썬입문
    백준1075번
    문자열
    혼공파
    파이썬
    파이썬기초
    백준1152
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
과컴
Monew 프로젝트 개선: 뉴스기사 목록 조회 성능 개선 - 복합 인덱스 적용기
상단으로

티스토리툴바