Loopers

선택의 순간은 언제인가?

QA

길부터 닦고, 짐을 줄이고, 지름길을 뚫어라

10만건 상품 데이터 기반 조회 성능 개선기 — 인덱스, 비정규화, 캐시를 순서대로 적용하며 배운 것들


왜 이 글을 쓰게 되었는가

살다 보면 뭘 하든 뭔가를 포기해야 한다. 점심 메뉴를 고를 때도, 이직을 결정할 때도. 코드도 별반 다를 게 없었다.

4주차까지 좋아요 수 기반 정렬, 브랜드 필터링, 인기 상품 조회를 만들고 나서 궁금해졌다. “데이터가 10만건이면 어떻게 되지?” EXPLAIN ANALYZE를 돌려보니 모든 쿼리가 Seq Scan이었다. 10ms 넘는 쿼리들이 줄줄이 나왔다.

문제를 찾고 나서 바로 답이 나오진 않았다. 인덱스를 걸까? 캐시를 넣을까? 비정규화를 할까? 셋 다 “성능 개선”이라는 같은 목표인데, 각각 잃는 게 달랐다.


문제 발견 — 고민의 시작

내가 마주한 병목은 세 가지였다.

문제 증상 체감
인덱스 부재 모든 쿼리 Seq Scan, 300ms ~ 500ms+ “왜 이렇게 느리지?”
Lost Update 좋아요 + 어드민 수정 동시 발생 시 데이터 유실 “좋아요 눌렀는데 왜 안 올라가지?”
캐시 없음 동일 요청이 반복되어도 매번 DB 조회 “같은 페이지를 왜 계속 DB에서?”

문제를 나열하고 나니 다음 고민이 찾아왔다. 뭐부터 해야 하지?

이전 회사 솔루션으로 MAU 900만 은행 서비스를 운영할 때는, 로그인과 송금/결제 응답을 1초 이내로 줘야 해서 바로 Redis 캐싱을 도입했었다. 또한 하드웨어적으로 너무나도 빵빵해서 일반적으로 서비스회사들의 대용량 트래픽 처리하는 방식과는 거리감이 있었다.
하지만 이커머스에서 점진적으로 성장하는 단계에, 상품 10만건이 쌓였다고 바로 Redis를 쓰는 게 맞을까?

극초기 스타트업이 홍보가 잘되어서 갑자기 상품이 쏟아진 상황을 상상해봤다. 시간이 급하면 캐싱부터 때리는 게 빠른 선택일 수 있다.
하지만 시간이 있다면? 쿼리 성능부터 잡는 게 나중에 발목 안 잡히는 방법이라고 생각했다.

실무에서는 보통 slow query log를 켜놓고 문제가 되는 쿼리부터 잡아간다. PostgreSQL은 log_min_duration_statement를 설정하면 지정 시간(예: 500ms) 이상 걸린 쿼리를 전부 기록해준다. MySQL도 slow_query_log = ON으로 같은 걸 할 수 있다. 운영 중인 서비스라면 이 로그를 보고 인덱스를 보완해나가는 형태로 접근하는 게 일반적이다. 처음부터 완벽하게 인덱스를 설계하는 게 아니라, 느린 쿼리가 잡히면 그때 대응하는 거다.

결국 내가 내린 판단 기준은 이거였다.

비용이 적은 것부터 하자. 인덱스 → 비정규화 → 캐시.

길부터 닦고(인덱스), 짐을 줄이고(비정규화), 그래도 부족하면 지름길을 뚫자(캐시).


Step 1: 인덱스 — 길부터 닦자

인덱스가 뭔지 잠깐 짚고 가자

인덱스는 데이터의 위치 정보를 따로 저장해두는 거다. 책의 목차처럼 원하는 데이터가 어디 있는지 바로 찾아갈 수 있게 해준다.

인덱스가 없으면? 테이블 처음부터 끝까지 순서대로 훑어야 한다(Seq Scan). 데이터가 100건이면 상관없는데, 10만건이 되면 매번 전부 읽는 비용이 느껴지기 시작한다. 참고로 스펙이 좋아도 3만건 이상의 데이터에서 필요 없는 행까지 풀스캔하면 느릴 수밖에 없다.

복합 인덱스는 여러 컬럼을 하나의 인덱스로 묶는 건데, 여기서 컬럼 순서가 중요하다. 인덱스는 지정한 순서대로 정렬되어 있어서, 순서에 따라 타는 범위가 달라진다.

일반적으로 인덱스 컬럼 순서는 이렇게 잡는다:

1. = (등호) 조건 컬럼 → 먼저
2. range (>, <, BETWEEN) 조건 컬럼 → 다음
3. ORDER BY 컬럼 → 마지막

range 조건이 앞에 오면 그 뒤 컬럼은 인덱스를 제대로 못 탄다. 그래서 범위 비교는 뒤로 빼는 게 낫다.

그리고 인덱스에는 읽기와 쓰기의 트레이드오프가 있다. 인덱스를 걸수록 조회는 빨라지지만, INSERT/UPDATE/DELETE 때마다 인덱스도 같이 갱신해야 해서 쓰기가 느려진다. 둘 다 빠르게 만드는 건 불가능하다. 그래서 조회가 빈번한 곳에만 건다.

실무에서 인덱스 개수는 테이블 성격에 따라 다르다:

테이블 유형 인덱스 수
단순한 테이블 3~5개
서비스 핵심 테이블 5~10개
조회/분석/검색 목적 테이블 10개 이상

참고로 토스 같은 곳은 한 테이블에 컬럼이 40개 가까이 되는 경우도 있다고 한다. 그만큼 인덱스 설계가 복잡해진다.

인덱스 설계가 어려운 이유

인덱스 설계가 단순히 “이 컬럼에 걸까 말까” 수준이면 좋겠는데, 실제로는 그렇지 않다. 복합 인덱스에서는 컬럼의 조합뿐 아니라 순서가 성능을 결정한다.

에어비앤비가 VLDB 2025에 발표한 논문(Sam Lightstone, Ping Wang)에서 재밌는 수치를 봤다. 테이블이 12개이고 각 테이블에 인덱싱 가능한 컬럼이 10개만 있어도, 가능한 인덱스 조합의 수는 약 10^84개라고 한다. 관측 가능한 우주의 원자 수가 약 10^80개니까, 그걸 넘는 숫자다.
에어비앤비는 이 문제를 풀기 위해 SQL:Trek이라는 자동 인덱스 설계 도구를 만들었다. 프로덕션 DB를 건드리지 않고 5% 샘플링한 시뮬레이션 DB에서 후보 인덱스를 실제로 생성하고 EXPLAIN으로 평가하는 방식인데, 특정 워크로드에서 50,000배 성능 개선을 끌어냈다고 한다.

물론 내 프로젝트에서 10^84개의 조합을 고민할 일은 없다. 하지만 “인덱스는 그냥 걸면 되는 거 아니야?”라고 생각했던 내게,
이게 왜 엔지니어링인지 느끼게 해준 글이었다.
테이블 4개에 인덱스 4개 거는 것만으로도 선택도, 컬럼 순서, 쓰기 비용을 전부 따져야 했으니까.

첫 번째 시도: 일반 복합 인덱스

먼저 떠오른 건 일반 복합 인덱스였다. (deleted_at, brand_id, like_count) 같은 조합으로 걸면 되지 않을까?

EXPLAIN ANALYZE를 돌려봤다. 여전히 Seq Scan.

원인은 deleted_at IS NULL의 선택도였다. 전체 데이터의 95%가 활성 상태(deleted_at이 NULL)라서 플래너가 “어차피 거의 다 읽어야 하니 인덱스 안 타는 게 낫겠다”고 판단한 것이다.

여기서 Covering Index도 고려해봤다. 조회에 필요한 컬럼을 전부 인덱스 노드에 포함시키면, 테이블까지 갔다올 필요 없이 인덱스만으로 결과를 돌려줄 수 있다. 조회 비용을 극단적으로 낮춰야 할 때 쓰는 방법인데, 이번 케이스에서는 Partial Index로 충분했기 때문에 적용하지 않았다.
참고로 에어비앤비의 SQL:Trek도 Covering Index를 안티패턴으로 분류한다. 특정 쿼리에는 최적이지만 테이블 전체 스토리지가 두 배 가까이 늘고 쓰기 비용이 커져서, 워크로드 전체로 보면 손해인 경우가 많다는 이유다.

두 번째 시도: Partial Index

WHERE deleted_at IS NULL 조건을 인덱스 자체에 넣는 Partial Index를 적용했다.

CREATE INDEX idx_products_active_brand_likes
    ON products (brand_id, like_count DESC)
    WHERE deleted_at IS NULL;

결과:

쿼리 AS-IS TO-BE 개선율
브랜드+좋아요순 10.757ms (Seq Scan) 0.082ms (Index Scan) 131x
가격순 11.092ms (Parallel Seq) 0.549ms (Index Scan) 20x
최신순 11.774ms (Parallel Seq) 0.143ms (Index Scan) 82x
좋아요순 11.549ms (Parallel Seq) 0.157ms (Index Scan) 74x

다만 JPA의 @Index는 Partial Index의 WHERE 절을 지원하지 않는다. SQL 마이그레이션 스크립트로 따로 관리해야 했다. JPA 편의성을 포기하고 20~131배 성능을 가져갔다.

배운 점: 인덱스를 걸었는데 안 탈 수 있다. 플래너가 왜 그런 판단을 하는지(선택도)를 모르면 인덱스를 걸어놓고도 Seq Scan에 머문다. 실무에서도 인덱스를 처음부터 완벽하게 잡기보다, slow query log로 느린 쿼리를 잡아가며 조정해나가는 게 현실적인 접근이다.


Step 2: 비정규화 — 짐을 줄이자

인덱스로 조회는 해결했는데, 좋아요 쪽에 다른 문제가 있었다.

기존 방식의 문제

기존 좋아요 처리는 이랬다:

1. SELECT FOR UPDATE (Product 전체 조회 + 비관적 락)
2. 메모리에서 likeCount + 1
3. UPDATE (Product 모든 컬럼 저장)

이게 왜 문제냐면, 어드민이 동시에 상품 정보(이름, 가격)를 수정하면 한쪽 변경이 날아간다. 좋아요가 Product 전체를 읽고 전체를 덮어쓰니까, 어드민이 바꾼 가격이 사라지는 거다. Lost Update 문제.

선택지

A. 비관적 락 유지 + 좋아요 전용 테이블 분리

  • 장점: 정규화 유지
  • 단점: JOIN 비용, 테이블 늘어남, 락은 여전히 필요

B. 원자적 SQL UPDATE

  • 장점: 락 불필요, 다른 필드에 영향 없음, 쿼리 수 줄어듦
  • 단점: like_count가 products 테이블에 비정규화
@Modifying
@Query("UPDATE ProductJpaEntity p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId")
void incrementLikeCount(@Param("productId") Long productId);

B를 골랐다. DB에서 like_count = like_count + 1을 실행하면, 어드민이 상품명을 바꾸든 가격을 바꾸든 likeCount 컬럼에만 영향이 간다. 락도 필요 없어졌다.

항목 AS-IS TO-BE
쿼리 수/요청 SELECT FOR UPDATE + UPDATE (전체) SELECT + UPDATE (1컬럼)
비관적 락 2회/요청 0회
Lost Update 위험 있음 없음

like_count를 products 테이블에 두는 비정규화를 받아들이고, 동시성 안전과 성능을 둘 다 가져갔다. 정규화가 깔끔하긴 하지만 실제로 터지는 문제 앞에서는 실용적인 쪽을 택했다.

더 큰 트래픽이라면?

지금은 원자적 UPDATE로 충분하지만, 트래픽이 훨씬 커지면 다른 방법도 있다.

Sharded Counter 방식이 그중 하나다. 하나의 카운터 row에 모든 요청이 몰리면 row-level lock 경합이 생기는데, 카운터를 여러 조각(shard)으로 나누면 동시 처리 성능을 높일 수 있다.

product_like_counter { product_id, shard_id, count }

shard 0: count = 42
shard 1: count = 38
shard 2: count = 45
→ 총 좋아요 수 = SUM(count) = 125

쓰기 시에는 랜덤 shard에 UPDATE하고, 읽기 시에는 SUM으로 합산한다. Redis를 쓸 수 있는 환경이면 Redis에서 카운트를 모아두는 것도 방법이다. 서버가 날아가도 Redis에 남아있으니까.

또 하나는 Materialized View 같은 접근인데, 이건 스냅샷이라기보다 read model에 가깝다. 이벤트로 비동기적으로 조회용 테이블을 만들어두는 방식이다. CQRS에서 쓰는 패턴과 비슷하다.

지금 규모에서는 원자적 UPDATE가 맞지만, 이런 선택지가 있다는 걸 알아두면 나중에 대응할 때 덜 당황한다.


Step 3: 캐시 — 지름길을 뚫자

인덱스와 비정규화로 쿼리 하나하나는 빨라졌다. 그런데 같은 상품 목록을 수백 명이 동시에 요청하면? DB가 아무리 빨라도 반복 조회는 부하다.

캐시를 넣기로 했다. 근데 캐시도 종류가 있다.

로컬 캐시(Caffeine, Ehcache 등)는 애플리케이션 메모리에 데이터를 올려두는 방식이다. 네트워크를 안 타니까 빠르다. 카카오페이 기술 블로그에서 본 사례가 인상적이었는데, 카카오페이는 상품·통신사·혜택 같은 메타 정보 조회에 로컬 캐시를 쓴다. Redis까지 갔다올 필요 없이 서버 메모리에서 바로 꺼내는 거다. 대신 서버가 여러 대면 서버마다 캐시가 따로 있으니까, A 서버에선 바뀐 데이터가 B 서버에선 안 바뀌어 있는 문제가 생긴다.

글로벌 캐시(Redis)는 서버들이 하나의 캐시를 공유한다. 어느 서버에서 조회해도 같은 데이터가 나온다. 대신 매번 네트워크를 타야 한다.

나는 Redis를 골랐다. 이유는 단순하다. 이커머스에서 상품 상세 페이지는 어느 서버에서 열어도 같은 가격, 같은 좋아요 수가 보여야 한다. 서버별로 다른 값을 보여주는 건 사용자 경험 측면에서 받아들이기 어려웠다. 네트워크 비용은 있지만, 데이터 정합성을 포기하는 것보다는 낫다고 판단했다.

물론 카카오페이처럼 “변경이 거의 없는 메타 정보”라면 로컬 캐시가 더 나은 선택일 수 있다. 자주 안 바뀌니까 서버 간 불일치가 발생할 확률 자체가 낮고, 네트워크 비용을 아끼는 게 이득이다. 결국 캐시에 뭘 담느냐에 따라 답이 달라진다. 이것도 트레이드오프다.

한 가지 먼저 짚고 갈 점이 있다. 캐싱은 버전 관리가 중요하다. 상품 정보 같은 걸 캐시에만 의존하면, 실제 DB 데이터와 점점 멀어질 수 있다. 캐시는 어디까지나 “빠르게 돌려주는 복사본”이지, 원본이 아니다. 이걸 잊으면 정합성 문제가 생긴다.

고민 1: 캐시를 언제 지울 것인가

처음에는 @CacheEvict를 붙였다. 근데 이게 문제가 있었다.

@CacheEvict은 AOP 프록시에서 동작해서 트랜잭션 커밋보다 먼저 실행된다. 이런 일이 생길 수 있다:

Thread 1: 좋아요 → likeCount UPDATE (아직 미커밋)
Thread 1: 캐시 삭제 (@CacheEvict, 커밋 전 실행)
Thread 2: 캐시 조회 → MISS → DB에서 이전 값 읽음 (미커밋이니까)
Thread 2: 이전 값으로 캐시 다시 채움
Thread 1: 커밋
→ 캐시에는 이전 값이 TTL 동안 박혀있음

@TransactionalEventListener(AFTER_COMMIT) 패턴으로 바꿨다. 커밋이 끝난 다음에 캐시를 지우니까 이 문제가 없어진다.

다만 @TransactionalEventListener는 트랜잭션 없는 환경(단위 테스트)에서 안 돌아간다. isSynchronizationActive() 체크를 넣어서 트랜잭션이 없으면 바로 evict하도록 분기를 추가해야 했다.

고민 2: 좋아요 한 번에 목록 캐시를 통째로 날릴 것인가

처음에는 좋아요가 들어오면 @CacheEvict(allEntries=true)로 목록 캐시를 통째로 비웠다. 좋아요 수가 바뀌면 인기순 정렬 결과가 달라지니까 논리적으로는 맞다.

근데 트래픽이 늘면 캐시가 없을 때보다 더 느려진다.

좋아요 1회 발생
→ 목록 캐시 전체 삭제 (page 0, 1, 2, ... 모든 sort 조합)
→ 직후 수백 요청이 동시에 Cache MISS
→ 전부 DB 조회 (Cache Stampede)
→ Redis 왕복 + DB 비용 = 캐시 없을 때보다 느림

이 Cache Stampede를 막는 방법은 여러 가지가 있다. 캐시 저장 앞에 락을 걸어서 한 번에 하나의 요청만 DB를 치게 하고, 나머지는 캐시가 채워질 때까지 기다리게 하는 방법도 있다. 하지만 이번에는 더 단순하게 접근했다.

목록 캐시는 좋아요/주문 시 안 지우고 TTL 1분으로 알아서 갱신되게 했다. 상품 상세는 바로 evict해서 정확한 값을 주고, 목록의 좋아요 수는 최대 1분 늦을 수 있다.

이걸 분산 시스템에서는 Eventual Consistency라고 부른다. 각 시점에서는 데이터가 다를 수 있지만, 시간이 지나면 결국 같아진다는 거다. 카카오페이도 로컬 캐시에서 같은 판단을 했다. 서버 간 데이터 불일치를 허용하되 TTL 1시간으로 최종적으로 맞춰지게 한 거다. 그쪽은 메타 정보라 1시간이 괜찮았고, 나는 상품 목록이라 1분으로 잡았다. 숫자는 다르지만 판단의 뼈대는 같다 — “실시간 정확성 vs 시스템 안정성” 사이에서 후자를 골랐다.

“목록에서 좋아요 수가 1분 늦을 수 있다”를 받아들이고, Stampede를 막았다. 커머스에서 목록의 좋아요 수가 1분 차이 나는 건 사용자가 알기 어렵다. 근데 Stampede로 페이지가 느려지면 바로 느낀다.

고민 3: Redis가 죽으면?

캐시는 성능을 위해 얹은 거지, 핵심이 아니다. Redis가 죽었다고 서비스가 멈추면 안 된다.

SafeCacheErrorHandler를 만들어서 Redis 예외를 잡고 DB로 넘어가게 했다. 예외를 잡으면 장애를 모를 수 있어서 Micrometer 카운터(cache.errors)로 모니터링을 붙였다.

처음에는 로그에 exception.getMessage()만 남겼는데, 이러면 연결 오류인지 직렬화 오류인지 모른다. exception 자체를 넘겨서 stack trace를 남기도록 고쳤다. 대신 캐시 key는 로그에서 뺐다. key에 userId 같은 게 들어갈 수 있어서.

// AS-IS: 메시지만, key 노출
log.warn("캐시 조회 실패 - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage());

// TO-BE: stack trace 보존, key 제거
log.warn("캐시 조회 실패 - cache: {}", cache.getName(), exception);

로그를 자세히 남기고 싶은데 보안 때문에 못 남기는 것도 결국 트레이드오프다.

고민 4: afterCommit에서 캐시 삭제가 실패하면?

코드 리뷰에서 나온 지적이다. afterCommit() 안에서 Cache.evict()가 터지면 예외가 올라가서 클라이언트에 500이 간다. 근데 주문이랑 재고는 이미 DB에 들어간 상태다. 고객은 주문이 됐는데 에러 화면을 보는 셈이다.

CacheErrorHandler@CacheEvict 같은 어노테이션에만 동작하고, 직접 호출한 Cache.evict()는 안 잡아준다. 그래서 afterCommit 블록마다 try-catch를 넣었다. 캐시 실패해도 로그만 남기고 응답은 정상으로 내보낸다.

고민 5: 브랜드 삭제할 때 전체 캐시를 밀어야 하나

브랜드를 삭제하면 그 브랜드 상품들이 soft-delete된다. 처음에는 detailCache.clear()로 상품 상세 캐시를 전부 비웠다.

브랜드 A 하나 지웠을 뿐인데 브랜드 B, C 상품 캐시까지 다 날아간다. 목록 캐시에서 Stampede 막아놓고, 상세 캐시에서 같은 걸 반복하고 있었다.

BrandProductsDeletedEvent를 만들어서 삭제된 상품의 productId 목록을 이벤트에 담았다. 캐시 핸들러는 그 ID만 evict한다.

// AS-IS: 브랜드 A 삭제 → 모든 상품 캐시 소멸
detailCache.clear();

// TO-BE: 브랜드 A 삭제 → A 상품만 evict, B/C 캐시는 그대로
event.deletedProductIds().forEach(this::evictProductDetail);

고민 6: 다음 단계는 뭘까

지금은 단일 서버에 Redis 한 대다. 캐시 무효화가 단순하다. 이벤트 발행하고, 같은 서버 안에서 evict하면 끝이다.

근데 카카오페이 기술 블로그를 보면서 “서버가 여러 대가 되면 어떻게 되지?”를 생각해봤다. 카카오페이는 상품·통신사·혜택 같은 메타 정보를 로컬 캐시에 담는다. 네트워크를 안 타니까 빠른데, 서버가 여러 대면 서버마다 캐시가 따로라서 불일치가 생긴다. 이걸 Redis Pub/Sub으로 풀었다.

서버 A: 상품 수정 → Redis Pub/Sub에 이벤트 발행
서버 B: 이벤트 수신 → 로컬 캐시 evict
서버 C: 이벤트 수신 → 로컬 캐시 evict

여기서도 트레이드오프가 있다. Redis Pub/Sub은 메시지를 저장하지 않는다. 구독 시점에 서버가 잠깐 죽어있으면 그 이벤트는 날아간다. 카카오페이는 이걸 받아들였다. 어차피 TTL이 있으니 최악의 경우에도 TTL 지나면 갱신된다. 메시지 유실이 걱정되면 Kafka나 RabbitMQ를 쓰면 되지만, 그만큼 인프라 복잡도가 올라간다. 카카오페이는 “메타 정보는 자주 안 바뀌니까 유실돼도 괜찮다”고 판단한 거다.

내 프로젝트는 단일 서버에 Redis 글로벌 캐시라서 이 문제를 직접 겪진 않는다. 하지만 나중에 트래픽이 늘어서 서버를 늘리고, 네트워크 비용을 줄이려고 로컬 캐시를 도입하게 되면 이 구조가 필요해진다. Sharded Counter를 언급한 것과 같은 이유다 — 지금 당장은 아니지만, 다음 단계가 뭔지 알아두면 그때 가서 덜 당황한다.


테스트 환경

항목 사양
Machine MacBook Pro 18,3 (2021)
Chip Apple M1 Pro (10코어 — 8P + 2E)
Memory 32 GB
Java OpenJDK 21.0.6 LTS
Spring Boot 3.4.4
DB PostgreSQL 16.13 (Docker)
Cache Redis (Docker)
데이터 규모 상품 10만건

최종 결과

쿼리 성능 (EXPLAIN ANALYZE)

쿼리 AS-IS TO-BE 개선율
브랜드+좋아요순 10.757ms 0.082ms 131x
가격순+OFFSET 11.092ms 0.549ms 20x
최신순 11.774ms 0.143ms 82x
좋아요순 11.549ms 0.157ms 74x

좋아요 동시성

항목 AS-IS TO-BE
비관적 락 2회/요청 0회
Lost Update 위험 있음 없음
쿼리 수 3개 2개

캐시 정합성

항목 AS-IS TO-BE
evict 시점 커밋 전 커밋 후
Stampede 위험 있음 없음 (TTL 갱신)
Redis 장애 시 500 에러 DB fallback

273개 테스트 전체 통과.


회고

이번에 제일 많이 한 생각은 “정답이 없다”는 거였다.

  • Partial Index는 JPA 편의성을 포기하고 131배 성능을 가져갔다.
  • 비정규화는 정규화의 깔끔함을 포기하고 동시성 안전을 가져갔다.
  • 캐시 TTL은 실시간 정확도를 포기하고 Stampede를 막았다.
  • Redis 글로벌 캐시는 네트워크 비용을 받아들이고 데이터 정합성을 가져갔다.
  • 예외를 잡는 건 디버깅 편의를 포기하고 서비스 안정성을 가져갔다.

뭘 선택하든 뭔가를 잃었고, 중요한 건 뭘 포기하고 뭘 가져가는지 알고 고르는 것이었다.

카카오페이 기술 블로그를 보면서 비슷한 걸 느꼈다. 카카오페이는 로컬 캐시를 쓰면서 서버 간 불일치를 Eventual Consistency로 받아들였고, 나는 Redis 글로벌 캐시를 쓰면서 네트워크 비용을 받아들였다. 둘 다 “완벽한 정합성”을 포기한 건 같은데, 포기한 지점이 다르다. 캐시에 뭘 담느냐, 서비스 특성이 뭐냐에 따라 같은 문제에도 답이 달라지는 거다.

실무에서도 마찬가지일 거다. 시간이 급하면 캐싱부터 때릴 수도 있다. 시간이 있으면 인덱스부터 닦는 게 나중에 안 물린다. 어떤 순서가 맞느냐가 아니라, 지금 내 상황에서 뭘 포기하는 게 제일 덜 아픈지 판단할 수 있으면 된다고 생각한다.

코드도 인생처럼, 완벽한 선택은 없다. 다만 알고 고른 선택이 있을 뿐이다.