기능은 이미 만들 줄 알아요. 이번 장은 "많은 트래픽에서도 빠르고, 고장에 강하고, 믿을 수 있게 검증되는" 시스템의 뒷단을 봅니다. 캐시 일관성, 인덱스가 실제로 타는 조건, 무상태 확장, 테스트 피라미드, 그리고 지연을 평균이 아닌 p99로 보는 눈 — 시니어 면접과 장애 회고의 단골이에요.
🎯 이 장을 끝내면
cache-aside 흐름과 캐시 스탬피드(thundering herd)를 뮤텍스·조기 갱신으로 막는 법을 설명해요.
무상태(stateless) 서버가 수평 확장에 유리한 이유와 커넥션 풀 사이징의 함정을 알아요.
테스트 피라미드·슬라이스 테스트·Testcontainers로 신뢰할 수 있는 테스트를 짜요.
관찰성 3 pillars와 지연을 p95/p99로 보는 관점, 분산 추적(trace id)을 익혀요.
⚡
캐싱
가장 빠른 쿼리는 하지 않은 쿼리
로컬 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 부여 후 반환
🖼️ 그림으로 보기 — 캐시 히트 / 미스 흐름
⚠️
캐시 스탬피드(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)
🔑 복합 인덱스는 선두 컬럼(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 = 5는 a 범위까진 인덱스를 타지만, 범위 이후의 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 수평 확장
🧊 무상태(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
@DataJpaTest
JPA/리포지토리 계층만 슬라이스 로딩
쿼리 메서드·매핑 검증(기본 임베디드 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/시간으로 이어두면 장애 대응 속도가 확 달라져요.
🧠 이 장 핵심 요약
캐시: 로컬(Caffeine)+분산(Redis) 계층, cache-aside(미스 시 DB→적재), 쓰기 시 무효화. 스탬피드는 뮤텍스·조기 갱신으로.
인덱스: B-tree, 복합 인덱스는 선두 컬럼부터(leftmost). 커버링·카디널리티를 보고, EXPLAIN으로 검증. 인덱스는 쓰기 비용↑.
확장: 수평 확장엔 무상태가 핵심(세션 밖으로). 커넥션 풀은 클수록 좋은 게 아니라 최적점이 있다.