캐싱 인덱스 · 쿼리 확장성 테스트 전략 관찰성 📝 문제풀이
◀ 심화 CH 05 📋 목차 🏁 로드맵
🏛️ 심화 CHAPTER 06 · 5년차 트랙

느린 시스템은 왜 느릴까 — 캐시·인덱스·확장·테스트로 답하기

기능은 이미 만들 줄 알아요. 이번 장은 "많은 트래픽에서도 빠르고, 고장에 강하고, 믿을 수 있게 검증되는" 시스템의 뒷단을 봅니다. 캐시 일관성, 인덱스가 실제로 타는 조건, 무상태 확장, 테스트 피라미드, 그리고 지연을 평균이 아닌 p99로 보는 눈 — 시니어 면접과 장애 회고의 단골이에요.

🎯 이 장을 끝내면
캐싱
가장 빠른 쿼리는 하지 않은 쿼리
로컬 vs 분산 · cache-aside · 스탬피드 완화 · 일관성

캐시는 "비싼 계산·조회 결과를 가까운 곳에 잠시 보관"하는 장치예요. 위치에 따라 성격이 완전히 달라집니다.

구분로컬 캐시 (Caffeine)분산 캐시 (Redis)
위치애플리케이션 힙 안(JVM)별도 네트워크 서버
속도가장 빠름(메모리 접근)네트워크 왕복 비용 있음
공유인스턴스마다 따로(일관성 깨지기 쉬움)모든 인스턴스가 공유
용량힙 크기에 제약(GC 압박 주의)수평 확장 가능(대용량)
💡
둘은 경쟁이 아니라 계층. 실무에선 L1(로컬 Caffeine) + L2(분산 Redis) 다층 캐시를 자주 씁니다. 아주 뜨거운 데이터는 로컬에서 네트워크 없이 받고, 없으면 Redis에서 채워요. 대신 로컬은 인스턴스별로 값이 달라질 수 있어 짧은 TTL이나 무효화 이벤트(pub/sub)로 어긋남을 관리합니다.
🔁 Cache-Aside(Look-Aside) 패턴 — 가장 흔한 읽기 흐름 ① 읽기: 먼저 캐시 조회 → 있으면(hit) 그대로 반환.
② 미스(miss): DB에서 조회 → 결과를 캐시에 적재(TTL 부여) → 반환.
③ 쓰기: DB를 갱신하고 해당 키를 캐시에서 무효화(evict) — 다음 읽기에서 새로 채움. 핵심은 "캐시 채우기 책임이 애플리케이션에 있다"는 점이에요. 그래서 캐시가 죽어도 DB로 폴백되어 동작은 유지되지만, 미스가 몰리면 DB가 위험해집니다(다음 박스).
🔎
캐시 조회
hit이면 즉시 반환
🗄️
miss → DB 조회
원본에서 읽기
📥
캐시 적재
TTL 부여 후 반환
🖼️ 그림으로 보기 — 캐시 히트 / 미스 흐름
요청 🔎 캐시 조회 있나? 없나? hit ✅ 캐시 값 즉시 반환 DB 접근 없음 — 가장 빠른 경로 miss 🗄️ DB 조회 원본에서 읽기 📥 캐시 적재 TTL 부여 반환
⚠️
캐시 스탬피드(Cache Stampede / Thundering Herd). 인기 키의 TTL이 딱 만료되는 순간, 그 키를 기다리던 수많은 요청이 동시에 미스가 되어 전부 DB로 몰려가 같은 값을 중복 재생성해요. 순간적으로 DB가 폭주해 장애로 번집니다. 완화책:
뮤텍스/분산 락: 한 요청만 재생성하고 나머지는 대기 후 채워진 값 사용.
조기 갱신(early recomputation): TTL이 임박하면(만료 전에) 확률적으로 한 요청이 미리 값을 새로 계산(probabilistic early expiration).
논리적 만료: 물리적으로는 지우지 않고 "만료 표시"만 두어 옛 값을 잠깐 서빙하며 백그라운드 갱신.
🧭
캐시 일관성은 "최신"이 아니라 "감내 가능한 지연"으로 설계. 캐시는 본질적으로 원본과 일시적으로 어긋날 수 있어요. TTL을 짧게 하면 신선하지만 히트율이 떨어지고, 길게 하면 그 반대예요. 쓰기 시 무효화(evict)를 확실히 하고, 어긋남을 얼마나 허용할지(예: 몇 초)를 요구사항으로 못 박은 뒤 TTL을 정하는 게 실무 감각입니다.
🗂️
인덱스 · 쿼리 심화
인덱스는 "책 뒤 색인"이다
B-tree · 선두 컬럼 · 커버링 · 카디널리티 · EXPLAIN

대부분의 RDB 인덱스는 B-tree(정확히는 B+tree) 구조예요. 정렬된 트리라서 동등 검색·범위 검색·정렬을 로그 시간에 처리합니다. 색인이 없으면 테이블을 처음부터 끝까지 훑는 풀 스캔이 돼요.

🖼️ 그림으로 보기 — 풀 스캔 O(n) vs 인덱스 O(log n)
풀 스캔 (인덱스 없음) 한 건씩 전부 비교 → 데이터 늘면 선형 증가 B-tree 인덱스 (책 뒤 색인) 루트 중간 중간 🎯 몇 단계만에 목표 도달 → 로그 시간
🔑 복합 인덱스는 선두 컬럼(leftmost prefix)부터 인덱스 (a, b, c)는 "a로 먼저 정렬, 같은 a 안에서 b로 정렬, 그 안에서 c로 정렬"된 하나의 순서예요. 그래서 탐색에 유효하려면 왼쪽(선두) 컬럼부터 연속으로 조건을 줘야 합니다.
WHERE a=?, WHERE a=? AND b=?, a=? AND b=? AND c=? — 인덱스 탐색에 유효.
⚠️ WHERE b=? 만 주거나 a를 건너뛰고 b, c만 주면 이 인덱스로 효율적 탐색 불가(정렬 기준의 선두를 잃음). 전화번호부가 "성 → 이름" 순인데 이름만 알고 찾으려는 것과 같아요. 성(선두)을 모르면 색인의 정렬을 못 씁니다.
⚠️
선두 컬럼에 범위 조건이 오면 뒤 컬럼 활용이 끊긴다. (a, b) 인덱스에서 WHERE a > 10 AND b = 5a 범위까진 인덱스를 타지만, 범위 이후의 b는 인덱스로 좁히지 못하고 필터로만 걸러져요. 그래서 "등가 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에" 두는 게 복합 인덱스 설계의 기본 감각입니다.
개념실무 포인트
커버링 인덱스쿼리가 필요로 하는 모든 컬럼이 인덱스에 포함됨테이블 본체 접근(랜덤 I/O) 없이 인덱스만으로 응답 → 매우 빠름
카디널리티컬럼 값의 고유(distinct) 정도높을수록(예: 이메일) 선택도↑ 좋은 인덱스. 낮으면(예: 성별) 인덱스 효과 미미
EXPLAIN옵티마이저가 짠 실행 계획 확인인덱스 사용 여부·스캔 방식·예상 행수 검증. 추측 말고 EXPLAIN
-- 실행 계획으로 인덱스를 실제로 타는지 확인
EXPLAIN
SELECT id, name FROM member
WHERE team_id = 7 AND status = 'ACTIVE';
-- type=ref, key=idx_team_status → 인덱스 사용 OK
-- type=ALL → 풀 스캔(인덱스 미사용) 경고 신호
💡
인덱스는 공짜가 아니에요. 조회는 빨라지지만 INSERT/UPDATE/DELETE 때마다 인덱스도 함께 갱신해야 해서 쓰기 비용이 늘고 저장 공간도 더 씁니다. "일단 다 걸자"가 아니라 실제 느린 쿼리(slow query)를 찾아 필요한 만큼만, 카디널리티와 조회 패턴을 보고 설계하세요.
📈
확장성
더 큰 서버 하나 vs 여러 서버
수직/수평 · 무상태 · 로드밸런싱 · 커넥션 풀
구분수직 확장(Scale-up)수평 확장(Scale-out)
방법서버 한 대의 CPU·RAM을 키움서버 대수를 늘림
한계물리적 상한·비용 급증·단일 장애점이론상 계속 확장(분산 복잡도↑)
가용성한 대 죽으면 전체 중단한 대 죽어도 나머지가 처리
🖼️ 그림으로 보기 — 수직 확장 vs 수평 확장
수직 확장 (Scale-up) 🖥️ 1x 🖥️ 더 큰 한 대 (4x) 한 서버의 CPU·RAM을 키움 수평 확장 (Scale-out) 로드밸런서 🖥️ 🖥️ 🖥️ 여러 대로 분산 · 한 대 죽어도 계속
🧊 무상태(Stateless) 서버가 수평 확장에 유리한 이유 서버가 요청 간 상태(세션 등)를 자기 메모리에 저장하면, 그 사용자의 다음 요청은 같은 서버로만 가야 해요(sticky session). 이러면 아무 서버로 보내는 자유로운 로드밸런싱이 깨지고, 그 서버가 죽으면 상태도 사라집니다.
반대로 무상태 서버는 상태를 외부(DB·Redis·토큰)에 두어 어느 인스턴스가 처리해도 동일해요. 그래서 인스턴스를 자유롭게 늘리고 줄이며(오토스케일), 로드밸런서가 아무 데나 보내도 됩니다. "세션을 서버 메모리에 두지 마라"가 수평 확장의 제1 계명이에요. 상태를 밖으로 밀어내면 서버는 대체 가능한 부품이 됩니다.
⚠️
커넥션 풀(HikariCP)은 클수록 좋다? — 아니에요. DB 커넥션은 결국 DB의 CPU 코어·디스크가 동시에 처리할 수 있는 만큼만 진짜 병렬로 일해요. 풀을 무작정 키우면 컨텍스트 스위칭·락 경합·메모리만 늘어 오히려 느려지고, DB의 max_connections를 압박합니다. 실제로 작은 풀(수~수십)이 큰 풀보다 처리량·지연이 더 나은 경우가 많아요(HikariCP 위키의 유명한 사례). 부하 테스트로 최적점을 찾는 게 정답이지, "일단 크게"가 아닙니다.
🧭
로드밸런싱. 여러 인스턴스 앞에 로드밸런서(L4/L7)를 두어 요청을 분산하고, 헬스 체크로 죽은 인스턴스를 자동으로 빼요. 라운드로빈·최소 연결 등 알고리즘이 있지만, 앞의 무상태 전제가 없으면 어떤 알고리즘도 sticky에 발목 잡힙니다.
🧪
테스트 전략
빠르고 많이, 느리고 적게
피라미드 · 슬라이스 테스트 · Testcontainers · 플래키

테스트 피라미드는 "빠르고 격리된 단위 테스트를 가장 많이, 느리고 값비싼 E2E는 가장 적게" 쌓으라는 지침이에요. 위로 갈수록 현실적이지만 느리고 깨지기 쉽습니다.

🔺 E2E (소수)
전체 흐름·느림·비쌈
🔗 통합 (중간)
여러 컴포넌트·실제 DB
⚙️ 단위 (다수)
빠름·격리·많이
⚠️
피라미드를 뒤집으면 "아이스크림 콘" 안티패턴. 느린 E2E·수동 테스트가 위에 잔뜩 쌓이고 단위 테스트가 빈약하면, 빌드는 느려지고 실패 원인 추적이 어려워지며 플래키(flaky) 테스트가 신뢰를 무너뜨려요. 넓은 바닥(단위)이 핵심입니다.
도구/기법무엇언제
@WebMvcTest웹 계층(컨트롤러)만 슬라이스로 로딩컨트롤러·요청 매핑·검증 테스트. 서비스는 Mock
@DataJpaTestJPA/리포지토리 계층만 슬라이스 로딩쿼리 메서드·매핑 검증(기본 임베디드 DB)
Mockito의존성을 가짜(mock)로 대체단위 테스트에서 외부 의존성 격리·행위 검증
Testcontainers테스트 중 진짜 DB(MySQL 등)를 도커로 띄움임베디드 DB와 실제 DB의 방언 차이를 없앤 신뢰도 높은 통합 테스트
🎭 모킹(Mockito) vs 실제 의존성(Testcontainers) 모킹 — 빠르고 외부와 격리돼요. 하지만 "내가 가정한 동작"만 검증하니 실제 DB/외부의 진짜 행동(방언·제약·트랜잭션)은 놓칠 수 있어요.
실제 의존성(Testcontainers) — 진짜 MySQL/Redis를 띄워 운영과 같은 환경으로 검증. 느리지만 통합 지점의 버그를 잡아요. 규칙: 단위는 모킹으로 로직을, 통합은 실제 의존성으로 연결을 검증. 임베디드 H2로만 검증하고 운영 MySQL에서 터지는 사고가 흔해서 Testcontainers가 표준이 됐어요.
💡
플래키 테스트는 "가끔 실패"가 아니라 신뢰의 적. 실행할 때마다 결과가 달라지는 테스트(시간·순서·랜덤·네트워크 의존)는 팀이 실패를 무시하게 만들어요. Thread.sleep 대신 조건 대기(awaitility), 테스트 간 상태 격리, 고정된 시계·시드로 결정론적으로 만드세요.
🔭
관찰성(Observability)
"느려요"를 숫자로 바꾸기
3 pillars · p95/p99 · 분산 추적(trace id)

관찰성은 "시스템 바깥에서 나오는 신호만 보고 내부 상태를 추론"하는 능력이에요. 세 축(3 pillars)이 서로를 보완합니다.

📜
로그(Logs)
개별 사건의 상세 기록
·
📊
메트릭(Metrics)
수치의 시계열(집계·경보)
·
🧵
트레이싱(Traces)
요청의 서비스 간 경로
📉 지연은 "평균"이 아니라 p95/p99로 봐라 평균 응답시간은 느린 소수의 사용자를 숨겨요. 대부분 10ms인데 일부가 3초여도 평균은 낮게 나올 수 있죠. 하지만 그 "일부"가 실제 이탈·불만의 주인공입니다.
p95 = 상위 5%를 뺀 나머지가 겪는 최악값, p99 = 100명 중 가장 느린 1명 수준이에요. SLO/알람은 평균이 아니라 p95·p99(꼬리 지연, tail latency)로 잡아야 실제 사용자 경험을 지킵니다. "평균은 괜찮은데 컴플레인이 온다"의 정체가 바로 꼬리 지연이에요. 특히 여러 서비스를 거치면 꼬리가 곱해져 더 커집니다.
🧭
분산 추적(Distributed Tracing)과 trace id. MSA에서 한 요청은 여러 서비스를 거쳐요. 진입점에서 발급한 trace id를 모든 하위 호출에 전파(스팬으로 연결)하면, 흩어진 로그를 하나의 요청 흐름으로 꿰어 "어느 구간에서 느렸는지"를 짚을 수 있어요. Spring에선 Micrometer Tracing(구 Sleuth) + OpenTelemetry로 자동 전파합니다.
💡
연결해서 쓸 때 강해진다. 메트릭(p99)이 튀는 걸로 이상을 감지 → 해당 시간대 trace로 느린 요청을 찾고 → 그 요청의 로그로 근본 원인을 확인. 세 축을 trace id/시간으로 이어두면 장애 대응 속도가 확 달라져요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·설계 리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
🏁 마지막 챕터 심화 트랙의 마지막 장이에요. 로드맵으로 돌아가 전체 흐름을 복습하고, 약한 곳을 다시 짚어보세요.