[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 |