TL;DR: 정렬 기준이 되는 값을 저장하는 위치와 방법이 성능을 결정한다.
쇼핑몰 서비스에서 상품 목록 페이지는 가장 많은 트래픽이 몰리는 지점 중 하나다. 브랜드 필터링과 좋아요 순 정렬이라는 요구사항은 기능 명세서 위에서는 단순해 보이지만, 데이터 규모가 10만 건을 넘어서는 순간 그 단순함은 빠르게 무너진다. 초기엔 수십 ms 수준이던 응답 시간이 수백 ms로 늘어나고, 동시 요청이 쌓이기 시작하면 초 단위로 치솟는다. 기능은 동작하지만, 서비스는 버티지 못하는 상태다.
이 글은 해당 병목을 단계적으로 해소한 과정을 담는다. 쿼리 실행 계획 분석을 시작으로, 인덱스 설계, like_count 비정규화를 통한 구조 개선, 그리고 Redis 캐시 도입까지. 각 선택이 어떤 문제를 풀기 위한 것이었는지, 그리고 어떤 트레이드오프를 수반하는지를 판단한다.
1. 문제 인식
2. 인덱스 설계
3. 비정규화
4. Redis 캐시
1. 문제 인식
SELECT p.*, COUNT(l.id) AS like_count
FROM product p
LEFT JOIN likes l ON p.id = l.product_id
WHERE p.brand_id = 42
GROUP BY p.id
ORDER BY like_count DESC
LIMIT 20;
```
명세 기준으로는 흠잡을 데 없다. 브랜드 필터링, 좋아요 수 집계, 내림차순 정렬이 단일 쿼리 안에서 처리된다. 문제는 이 쿼리가 **데이터베이스 내부에서 어떤 순서로 실행되는가**를 들여다보는 순간 드러난다.
### EXPLAIN 분석
```
-> Sort: like_count DESC (cost=12430.55 rows=98421)
-> Table scan on <temporary>
-> Aggregate using temporary table
-> Nested loop left join
-> Filter: (p.brand_id = 42)
-> Full table scan on p (rows=102847)
실행 계획을 안쪽부터 읽으면 다음 순서로 처리된다.
- product 테이블 풀 스캔 (102,847건 전체 읽기)
- brand_id = 42 필터 적용 (이 시점에야 대상을 좁힘)
- likes 테이블과 Nested Loop Join
- 임시 테이블 생성 후 GROUP BY 집계
- 집계 결과를 대상으로 filesort 정렬
주목할 지점은 두 가지다. 첫째, 필터가 풀 스캔 이후에 적용된다. brand_id 인덱스가 없기 때문에 10만 건을 전부 읽은 뒤에야 조건을 걸러낸다. 둘째, LIMIT 20이 선언되어 있음에도 정렬 대상은 필터를 통과한 전체 집합이다. 최종적으로 20건만 반환하더라도, 그 전 단계에서는 수만 건을 집계하고 정렬한다.
동시 요청이 수십, 수백 개가 들어오는 상황을 가정하면 — 각 요청마다 이 네 단계가 독립적으로 실행된다. DB 커넥션과 임시 테이블이 누적되고, 결국 전체 서비스 응답성이 함께 저하된다.
근본 원인
likes 테이블이 좋아요 관계의 원본 데이터를 보유하는 것은 정규화 원칙에 충실한 올바른 설계다. 그러나 이 구조가 정렬 기준으로 사용되는 순간 근본적인 문제가 생긴다.
인덱스는 저장된 값을 빠르게 탐색하는 구조다. 집계 결과(COUNT)는 저장된 값이 아니라 실행 시점에 계산되는 값이다. 따라서 GROUP BY + ORDER BY 조합에서는 인덱스가 정렬 단계를 커버할 수 없고, filesort는 피할 수 없다. 인덱스를 어떻게 구성하더라도, 집계 구조가 유지되는 한 이 병목은 구조적으로 존재한다.
결론적으로, 이것은 쿼리 최적화 문제가 아니라 데이터 구조의 문제다.
2. 인덱스 설계
비정규화로 넘어가기 전에, 먼저 인덱스만으로 얼마나 개선할 수 있는지 확인했다. 구조를 바꾸는 것은 비용이 따르는 결정이다. 그 전에 현재 구조 안에서 할 수 있는 것을 소진하는 것이 순서다.
단순 인덱스의 효과와 한계
CREATE INDEX idx_product_brand ON product(brand_id);
brand_id 단독 인덱스를 적용하면 필터링 단계의 풀 스캔은 해소된다. 실행 계획에서 Full table scan이 Index lookup으로 바뀌고, 필터 대상을 좁히는 비용은 줄어든다. 그러나 병목의 핵심은 필터링이 아니었다. likes 테이블을 집계한 결과로 정렬해야 하는 구조 자체가 문제였다. 집계 이후의 filesort는 인덱스 추가와 무관하게 여전히 발생한다. 필터 비용을 줄였을 뿐, 전체 실행 계획의 병목 구간은 이동하지 않았다.
복합 인덱스가 의미를 갖는 조건
CREATE INDEX idx_product_brand_like ON product(brand_id, like_count DESC, id DESC);
이 복합 인덱스는 brand_id 필터링과 like_count 정렬을 인덱스 단계에서 함께 처리할 수 있도록 설계된 구조다. 이상적으로는 filesort 없이 인덱스 순서대로 결과를 반환할 수 있다.
그러나 이 인덱스가 실질적인 효과를 갖기 위한 전제 조건이 있다. like_count가 product 테이블의 컬럼으로 존재해야 한다는 것이다. 인덱스는 테이블에 저장된 값을 기반으로 구성된다. 현재 구조에서 like_count는 저장된 값이 아니라 조회 시점에 집계되는 파생 값이다. 파생 값은 인덱스로 관리할 수 없다.
결국 인덱스 설계의 문제가 아니었다. 정렬 기준이 되는 값이 테이블에 존재하지 않는다는 구조의 문제였다. 인덱스는 구조가 갖춰진 위에서만 효과를 낼 수 있다. 이 시점에서 비정규화를 선택했다.
3.비정규화
비정규화는 종종 설계 원칙을 위반하는 것으로 오해된다. 그러나 실무에서 비정규화는 원칙의 위반이 아니라 읽기 성능과 쓰기 복잡도 사이의 의식적인 트레이드오프다. 중요한 것은 비정규화 여부가 아니라, 그 판단이 명확한 근거 위에 서 있는가다.
이 경우의 판단 근거는 다음 세 가지였다.
- 읽기/쓰기 비대칭. 상품 목록 조회는 좋아요 등록·취소보다 수십 배 이상 빈번하게 발생한다. 쓰기 비용을 다소 높이더라도 읽기 경로를 단순화하는 것이 전체 시스템 관점에서 이득이다.
- 정렬 기준 값의 특성. like_count는 단순 조회용 값이 아니라 인덱스 정렬의 기준값으로 사용된다. 정렬 기준이 되는 값은 반드시 테이블에 물리적으로 존재해야 인덱스가 효과를 낼 수 있다.
- 실시간 정합성의 비즈니스 가치. 좋아요 수의 실시간 정확도는 결제 금액이나 재고 수량과 달리 비즈니스 크리티컬한 값이 아니다. 수십 ms의 오차는 UX에 영향을 주지 않는다.
ALTER TABLE product ADD COLUMN like_count INT DEFAULT 0;
좋아요 등록 시
UPDATE product SET like_count = like_count + 1 WHERE id = ?;
좋아요 취소 시
UPDATE product SET like_count = like_count - 1 WHERE id = ? AND like_count > 0;
like_count > 0 조건은 음수 방지를 위한 가드다. 취소 요청이 중복으로 들어오거나 데이터 불일치 상황에서 컬럼 값이 음수로 내려가는 것을 차단한다.
-- Before: JOIN + GROUP BY + filesort
SELECT p.*, COUNT(l.id) AS like_count
FROM product p
LEFT JOIN likes l ON p.id = l.product_id
WHERE p.brand_id = 42
GROUP BY p.id
ORDER BY like_count DESC
LIMIT 20;
-- After: 인덱스 스캔만으로 처리
SELECT * FROM product
WHERE brand_id = 42
ORDER BY like_count DESC, id DESC
LIMIT 20;
```
EXPLAIN 결과:
```
-> Limit: 20 row(s)
-> Index scan on product using idx_product_brand_like
(brand_id=42, reverse) (cost=4.28 rows=20)
실행 계획에서 풀 테이블 스캔, Nested Loop Join, 임시 테이블 집계, filesort가 모두 제거됐다. 비용 추정치는 12,430에서 4.28로 낮아졌고, 읽는 행 수는 98,421에서 20으로 줄었다. 데이터베이스가 결과를 만들기 위해 실제로 접근하는 행의 수가 근본적으로 달라진 것이다.
감수해야 하는 것
비정규화는 공짜가 아니다. 두 가지 리스크를 명확히 인지한 상태에서 선택해야 한다.
동시성. UPDATE ... SET like_count = like_count + 1은 단일 구문 기준으로 atomic하게 동작한다. 그러나 이 연산이 트랜잭션 안에서 다른 작업과 함께 묶이는 경우, 격리 수준과 잠금 범위에 따라 결과가 달라질 수 있다. 특히 동일 상품에 대한 좋아요가 고빈도로 집중되는 상황에서는 잠금 경합이 발생할 수 있다는 점을 인식해야 한다.
정합성. likes 테이블 삽입과 product.like_count 갱신은 반드시 같은 트랜잭션 경계 안에서 처리되어야 한다. 어느 한쪽만 성공하고 나머지가 실패하면 두 테이블 간 데이터 불일치가 발생한다. 트랜잭션 범위를 명확히 정의하지 않으면 비정규화 구조는 언제든 오염될 수 있다.
이 두 리스크를 인지하고도 비정규화를 선택한 근거는 앞서 언급한 비즈니스 판단에 있다. like_count의 단기적 오차는 서비스에 영향을 주지 않는다. 반면 조회 응답 시간의 저하는 모든 사용자 경험에 직접적인 영향을 미친다. 정합성과 성능 사이에서 어느 쪽을 우선할지를 명확히 결정했고, 그 결정이 이 설계를 정당화한다.
4. Redis 캐시
인덱스와 비정규화로 단일 쿼리의 실행 비용을 낮췄다. 그러나 이것은 하나의 요청에 대한 최적화다. 트래픽이 증가하면 최적화된 쿼리도 결국 DB를 반복 호출한다. 동일한 브랜드의 상품 목록을 수백 명이 동시에 요청하는 상황에서, 쿼리가 아무리 빠르더라도 DB 커넥션 풀은 점진적으로 포화 상태에 가까워진다.
캐시는 이 반복 자체를 구조적으로 제거한다. 동일한 요청에 대해 DB까지 도달하는 경로를 차단하는 것이 목적이다.
캐시 적용 대상 선정
모든 API에 캐시를 적용하는 것은 오히려 시스템 복잡도를 높인다. 캐시 계층이 추가될수록 데이터 정합성 보장 범위가 넓어지고, 무효화 전략이 그만큼 정교해져야 하기 때문이다. 적용 전에 반드시 먼저 물어야 한다.
이 데이터는 얼마나 자주 바뀌는가? 바뀌었을 때 즉시 반영이 비즈니스적으로 중요한가?
| 상품 상세 조회 | 낮음 | 중간 | ✅ 적합 |
| 상품 목록 조회 | 중간 | 낮음 | ✅ 적합 |
| 주문 조회 | 높음 | 높음 | ❌ 부적합 |
| 재고 수량 | 높음 | 매우 높음 | ❌ 부적합 |
재고 수량이나 주문 상태처럼 정합성이 비즈니스 크리티컬한 데이터에 캐시를 적용하면, 캐시 불일치가 서비스 장애로 직결될 수 있다. 캐시는 정확도보다 속도가 우선인 데이터에만 적용하는 것이 원칙이다.
캐시 키 설계
캐시 키는 두 가지 조건을 동시에 만족해야 한다. 동일한 요청은 반드시 동일한 키를 생성해야 하고, 다른 요청은 반드시 다른 키를 생성해야 한다. 어느 한쪽이 깨지면 캐시 히트율이 떨어지거나, 잘못된 데이터가 반환된다.
상품 상세: product:detail:{productId}
상품 목록: product:list:brand:{brandId}:size:{pageSize}
(brandId 없으면 "all")
예시:
- product:detail:1023
- product:list:brand:42:size:20
- product:list:brand:all:size:20
키 구조를 계층적으로 설계하면 prefix 기반의 일괄 삭제나 모니터링이 용이해진다. product:list:brand:42:* 패턴으로 특정 브랜드의 목록 캐시 전체를 식별하거나 제거할 수 있다.
Look-Aside 패턴
요청 → Redis 조회
├── Hit → 캐시 반환 (DB 호출 없음)
└── Miss → DB 조회 → Redis 저장 → 반환
캐시 패턴 중 가장 범용적으로 사용되는 구조다. 애플리케이션이 캐시를 먼저 확인하고, 미스 시에만 DB를 조회한 뒤 결과를 캐시에 적재한다. DB와 캐시 간 동기화 책임이 애플리케이션 레이어에 있기 때문에, 캐시 적재 시점과 무효화 조건을 코드 수준에서 명시적으로 제어할 수 있다.
구현에는 @Cacheable AOP 방식 대신 RedisTemplate을 직접 사용했다. AOP 방식은 선언 하나로 캐시 적용이 가능하지만, 캐시 저장 시점, TTL 적용 여부, 직렬화 방식 등의 세부 동작이 추상화 뒤에 감춰진다. RedisTemplate을 직접 다루면 코드가 다소 장황해지지만, 캐시의 전체 생명주기가 코드 흐름 안에서 명시적으로 드러난다. 캐시를 처음 도입하는 시점에서는 이 명시성이 디버깅과 동작 이해 모두에서 더 중요하다.
TTL 전략
TTL은 캐시의 유효 기간을 결정하는 동시에, 무효화 로직 없이도 데이터 불일치를 자동으로 해소하는 안전망 역할을 한다.
- 상품 상세: 10분 — 상품명, 가격 등 기본 정보는 변경 빈도가 낮다. 10분의 오차는 UX에 영향을 주지 않는다.
- 상품 목록: 1분 — 좋아요 순위의 실시간성이 요구되지는 않지만, 지나치게 오래된 순위는 의미가 없다. 짧은 TTL로 데이터 신선도를 유지한다.
TTL 값은 데이터의 변경 빈도와 정합성 허용 범위를 기반으로 결정해야 한다. 임의의 값이 아니라, 비즈니스 요구사항으로부터 역산되는 값이어야 한다.
무효화 전략
TTL에만 의존하면 데이터 변경 후 만료 전까지 stale 데이터가 제공된다. 상품 상세의 경우, 좋아요 등록·취소 시점에 해당 캐시를 즉시 삭제하는 이벤트 기반 무효화를 적용했다.
좋아요 등록/취소 → product:detail:{productId} 즉시 삭제
상품 목록 캐시는 TTL 만료에 의존한다. 목록 내 좋아요 순위가 1분 이내로 정확하지 않아도 서비스 품질에 영향이 없다고 판단했기 때문이다. 모든 변경 이벤트마다 목록 캐시를 즉시 무효화하려면, 어떤 키를 삭제해야 하는지를 추적하는 별도 구조가 필요하다. 그 복잡도가 얻는 이점보다 크다고 판단했다.
무효화 전략은 정합성 요구 수준에 비례해 설계해야 한다. 모든 캐시를 즉시 무효화하는 것이 항상 좋은 것이 아니듯, 모든 캐시를 TTL에만 맡기는 것도 항상 안전하지 않다.
캐시 미스 처리
캐시는 언제든 미스가 발생할 수 있다. TTL 만료, 콜드 스타트, Redis 장애, 메모리 부족으로 인한 eviction까지. 어떤 원인이든 캐시 미스가 발생했을 때 서비스가 정상적으로 응답할 수 있어야 한다.
캐시 미스 → DB 조회 → 결과 반환 + 캐시 적재
Redis 연결 자체가 단절되는 상황도 동일하게 처리되어야 한다. 캐시 계층은 응답 속도와 DB 부하를 개선하기 위한 보조 수단이다. 캐시가 없어도 서비스는 동작해야 한다. 캐시 가용성이 서비스 가용성의 전제 조건이 되는 순간, 캐시 장애는 곧 서비스 장애가 된다.
전체 구조
[클라이언트 요청]
│
▼
[Redis 캐시 조회]
Hit ↙ ↘ Miss
[즉시 반환] [DB 쿼리 실행]
│
▼
[product 테이블]
brand_id 필터
like_count DESC 정렬 ← 비정규화된 컬럼
복합 인덱스로 커버 ← (brand_id, like_count DESC, id DESC)
│
▼
[결과 Redis 저장 + 반환]
결론: 각 선택의 기준
인덱스는 구조가 뒷받침될 때만 효과적이다. 조회 조건과 정렬 기준이 동일한 테이블 안에 물리적으로 존재할 때, 인덱스는 그 효용을 완전히 발휘한다. 집계가 개입되는 순간 인덱스는 필터링 단계에만 기여할 수 있고, 정렬 단계에서는 무력해진다. 인덱스 설계 이전에 데이터 구조가 인덱스를 수용할 수 있는 형태인지를 먼저 검토해야 한다.
비정규화는 읽기/쓰기 비대칭이 명확할 때 선택한다. 조회 빈도가 쓰기 빈도를 압도하고, 정렬 기준이 되는 값이 집계 없이는 얻을 수 없는 구조라면 비정규화는 설계 원칙의 위반이 아니라 합리적인 트레이드오프다. 단, 동시성과 정합성 리스크를 명확히 인지한 상태에서 선택해야 한다. 리스크를 모른 채 선택한 비정규화는 기술 부채가 되고, 인지한 채 선택한 비정규화는 설계 결정이 된다.
캐시는 반복을 제거하는 수단이지, 느린 쿼리를 덮는 수단이 아니다. 쿼리 자체의 비용이 여전히 높은 상태에서 캐시를 올리면, 캐시 미스가 발생하는 순간 병목이 그대로 노출된다. 쿼리와 구조를 먼저 최적화한 뒤, 동일 요청의 반복을 차단하는 용도로 캐시를 도입하는 것이 올바른 순서다. 그리고 캐시가 없는 상황에서도 서비스가 정상 동작하는 구조를 전제로 설계해야 한다.
세 가지 모두 적용 범위와 유효 조건이 다르다. 어떤 것도 만능이 아니고, 어떤 것도 항상 필요하지 않다. 공통적으로 중요한 것은 하나다. 이 선택이 어떤 문제를 풀기 위한 것인지, 그리고 그 선택이 어떤 새로운 복잡도를 만들어내는지를 설명할 수 있어야 한다. 설계 판단의 품질은 선택한 기술의 종류가 아니라, 그 선택에 대한 근거의 명확함으로 결정된다.
'Programming > Technical Writing' 카테고리의 다른 글
| kafka를 통한 이벤트 기반 아키텍처 (0) | 2026.03.27 |
|---|---|
| 외부 결제 시스템(PG) 연동에서 Circuit Breaker가 필요한 이유 (0) | 2026.03.20 |
| 동시성 제어: 트랜잭션 락은 언제 쓰는가 (0) | 2026.03.06 |
| 협업/분리/확장 관점에서 DIP 쓰는 법 (0) | 2026.02.27 |
| 주니어는 모르는 ERD 설계의 함정들 (1) | 2026.02.13 |