주니어에서 시니어로 가는
백엔드 시스템 기초 지도
원리에서 실험으로, 실험에서 설계와 장애 판단으로. 촬영 데이터 쓰기 부하라는 하나의 사례로 저장·동시성·분산 실패·운영을 이어서 배웁니다.
원문2026-09-20 장애 대응 해설 보강판 v310개 장참고 주제 80용어 275출처 37
이 교과서의 구성. 시작하기부터 종합 과제까지 모든 장을 쉬운 문장으로 다시 쓰고 계산기·시뮬레이터·확인 문제를 붙였습니다. PostgreSQL 16, SQLite, OpenJDK 21로 직접 실행한 실험 기록도 함께 실었습니다. 참고 주제 80개와 용어 275개는 원문 그대로 부록에 있습니다.
/ 키로 본문·참고 주제·용어를 한꺼번에 검색합니다. 점선 밑줄 용어를 누르면 뜻이 뜨고, 위쪽 버튼으로 용어집·참고 주제·보강 항목을 모아 볼 수 있습니다.
시작하기 — 이 책으로 무엇을 할 수 있게 되나
이 책은 “어떤 제품을 쓰면 되나”보다 “지금 무엇이 막혀 있고, 무엇을 확인해야 후보를 고를 수 있나”를 연습합니다. 요청 하나가 서버와 DB를 지나가는 원리에서 출발해, 작은 실험으로 실패를 재현하고, 조건이 바뀌었을 때 설계를 다시 고르는 데까지 갑니다.
이 장의 핵심
- 목표는 해법의 이름을 빨리 떠올리는 것이 아니라, 증상을 쪼개고 데이터의 성격을 확인해 후보를 만드는 과정입니다.
- 한 장은 원리 연결 → 실패 재현 → 판단 전이의 세 단계로 공부합니다.
- 본문 사례의 트래픽·지연·비용은 모두 학습용 가정입니다. 실제로 잰 값과 나눠서 읽으세요.
출발점이 된 질문
“쓰기 부하 때문에 DynamoDB를 도입했고, Write IOPS를 75% 줄였다.”
다른 개발자의 이력서에서 이 문장을 읽었습니다. 같은 상황이었다면 나는 왜 샤딩밖에 떠올리지 못했을까요?
해법의 이름을 몰랐을 수도 있습니다. 하지만 이 책이 더 중요하게 보는 차이는 다른 데 있습니다. 증상을 쪼개고, 데이터가 어떤 성격인지 확인하고, 그 결과로 후보를 만드는 과정입니다. 제품 이름을 먼저 떠올리는 속도는 학습 목표로 삼지 않습니다.
- 증상 쪼개기
무엇이 늘었나요? 요청 수, 데이터 크기, 쓰기 한 번의 비용, 특정 키로의 쏠림 중 무엇인가요?
- 데이터 성격 확인
잃어도 되나요? 늦게 반영돼도 되나요? 과거 값을 모두 남겨야 하나요?
- 후보 만들기
기존 DB 개선, 쓰기 합치기, 샤딩, 다른 저장소 분리를 같은 조건에서 나란히 놓습니다.
- 작게 검증하기
무엇을 재고, 어떤 결과가 나오면 그 후보를 버릴지 먼저 적습니다.
사례의 숫자는 가정입니다. 본문의 서비스 이름, 트래픽, 지연과 비용 조건은 모두 학습용 가정입니다. 이력서 주인의 실제 사실처럼 읽지 마세요. 실험도 “예상되는 경향”과 “이 환경에서 실제로 실행한 결과”를 나눠 적습니다.
이 책의 구성
원래 문서는 80개 주제를 짧게 설명했습니다. 이 개정판은 먼저 10개 장에서 그 주제들의 바탕이 되는 원리를 연결하고, 직접 해 볼 수 있는 작은 실험과 풀이를 붙였습니다. 80개 주제는 부록의 참고편에 모두 남겼습니다. 같은 주제가 본문에서는 배우는 재료로, 참고편에서는 다시 찾아보는 요약으로 쓰입니다.
처음 배운다면 1~4장을 순서대로 읽습니다. 그다음 이력서 사례가 궁금하면 5~6장, 중복 처리와 장애 대응이 궁금하면 7~9장으로 갑니다. 10장은 앞의 판단을 문서와 검증 절차로 마무리합니다. 참고편을 처음부터 외울 필요는 없습니다.
한 장을 세 단계로 공부합니다
같은 장을 세 번 읽는 대신, 세 단계로 확인합니다. 단계마다 남길 증거가 있어야 다음 단계로 넘어간 것입니다.
| 단계 | 할 일 | 남길 증거 |
|---|---|---|
| 원리 연결 | 요청·메모리·로그·데이터·응답의 흐름을 자기 말로 설명합니다. | 한 장짜리 경로도와 실패 시점 표시 |
| 실패 재현 | 가설을 먼저 적고, 조건 하나만 바꿔 실행합니다. | 실행 명령, 환경, 출력, 예상과 다른 점 |
| 판단 전이 | 다른 제약이 붙었을 때 대안을 다시 고릅니다. | 짧은 ADR과 반증 조건 |
해설은 문제를 푼 뒤에 엽니다. 설명을 이해한 느낌만으로 넘어가지 마세요. 각 장의 연습 문제에는 ‘내 답 메모’ 칸이 있습니다. 먼저 적고 해설을 여세요. 틀렸다면 개념을 더 외우기 전에 어떤 전제나 경계를 빠뜨렸는지 적습니다. 장 끝의 ‘스스로 점검’이 그 장의 완료 기준입니다.
처음 보는 말이 나오면
이 책은 약어의 뜻을 안다고 가정하지 않습니다. 본문에서 점선 밑줄이 있는 용어를 누르면(마우스를 올려도 됩니다) 전체 이름과 쉬운 뜻이 뜹니다. 지금 IOPS, WAL, p95를 눌러 보세요. 영어 이름을 외우기보다 그 말이 무엇을 세거나 어떤 역할을 하는지 이해하는 것이 목표입니다.
영어로 적힌 말이 모두 약어는 아닙니다. 세 종류를 구분하면 덜 헷갈립니다.
| 종류 | 예 | 읽는 법 |
|---|---|---|
| 약어 | IOPS = Input/Output Operations Per Second | 무엇을 세는 말인지 봅니다. IOPS는 초당 입출력 작업 횟수입니다. |
| 제품 이름 | PostgreSQL, DynamoDB | 억지로 풀네임을 만들지 않고, 어떤 역할의 제품인지 봅니다. |
| 명령 | COMMIT | DB에 보내는 명령입니다. |
처음 읽을 때는 아래 순서를 따릅니다. 예를 들어 IOPS의 뜻을 모르면, DB 개선안을 비교하기 전에 2장의 입출력 설명부터 읽습니다. 용어 찾아보기 전체를 먼저 외울 필요는 없습니다.
이름→쉬운 뜻→작은 예시→원리→판단
먼저 익힐 단위와 용어
책 전체에서 계속 쓰는 말입니다. 뜻과 함께 작은 예시를 붙였습니다.
- ms와 s
- 밀리초와 초. 1초는 1,000ms입니다. 250ms는 0.25초입니다.
- p95와 p99
- 관측값의 약 95%(99%)가 이 값 이하인 지점. 응답 100개를 빠른 순으로 세우면 95번째 값이 p95입니다. 평균이나 최댓값이 아닙니다.
- 지연과 처리량
- 일 하나가 끝나기까지 걸린 시간과 단위 시간에 끝낸 일의 양. 계산대에서 손님 한 명이 3분 걸리는 것과, 계산대 네 곳이 1시간에 80명을 처리하는 것은 서로 다른 값입니다.
- 원자성
- 묶인 작업이 일부만 반영되지 않는 성질. 이체에서 출금만 되고 입금이 빠진 상태가 남지 않습니다.
- 내구성
- 성공한 결과가 약속한 장애 조건에서 살아남는 성질. “어떤 장애까지?”라는 질문이 늘 따라붙습니다.
- 불변식
- 실행 순서와 실패가 어떻게 섞여도 지켜야 할 업무 규칙. 예: 참여 신청은 사용자·챌린지마다 한 번뿐입니다.
- 가설과 관측
- 검증할 설명과 실제로 잰 결과. “DB가 느려졌다”는 가설이고, “SQL 실행 p95가 20ms에서 180ms로 늘었다”는 관측입니다.
실습 준비
원래 교재는 labs 폴더의 스크립트로 실험합니다. 이 교과서는 Python 표준 라이브러리로 하던 실험을 브라우저 안의 시뮬레이터로 옮겼습니다. 페이지만 열면 바로 조건을 바꿔 볼 수 있습니다.보강
PostgreSQL 실습은 실습 전용 DB에서 선택적으로 합니다. Docker가 있다면 아래 두 줄로 준비됩니다. 운영 DB에는 절대 연결하지 마세요.보강
# PostgreSQL 17 실습 컨테이너를 띄우고 psql로 접속한다
docker run --name pg-lab -e POSTGRES_PASSWORD=lab -p 5432:5432 -d postgres:17
docker exec -it pg-lab psql -U postgres
무엇을 실제로 실행했나. 원래 교재는 제작 환경에 Docker와 PostgreSQL이 없어 SQL 실습을 실행하지 못했고, 예상 출력은 설명용이라고 밝혔습니다. 이 교과서는 2장 실험 B를 PostgreSQL 16.13에서 직접 실행해 실제 출력을 실었습니다. 다른 실습도 장마다 실행 여부를 표시합니다.보강
실험을 할 때는 아래 양식으로 기록합니다. 실험이 예상과 다르게 나오면 실패가 아니라, 가설을 고칠 자료가 생긴 것입니다.
질문:
가설:
환경·버전:
바꾼 변수:
고정한 변수:
명령:
관측:
해석:
아직 모르는 것:
다음 실험:
1장 요청 하나가 지나가는 길
촬영 상태를 저장하는 요청 하나를 처음부터 끝까지 따라갑니다. 어디서 계산하고 어디서 기다리는지, 어디서 끊기면 결과를 알 수 없게 되는지 표시할 수 있으면 이 장을 마친 것입니다.
이 장의 핵심
- 요청은 여러 경계를 지나고, 경계마다 기다리거나 실패할 수 있습니다.
- CPU가 바쁘면 계산을, CPU가 한가한데 느리면 대기를 의심합니다. 다만 CPU가 낮다는 사실만으로 원인을 정하지 않습니다.
- 스레드를 늘려도, 가상 스레드를 써도 DB 연결 같은 희소 자원의 한도는 늘지 않습니다.
- 클라이언트의 타임아웃은 “결과를 모른다”는 뜻이지 “저장에 실패했다”는 뜻이 아닙니다.
1.1 먼저 알아둘 말
- 클라이언트와 서버
- 일을 요청하는 쪽과, 요청을 받아 처리하는 쪽입니다. 둘이 주고받는 메시지가 요청과 응답입니다.
- 프록시
- 둘 사이에서 요청을 대신 전달하는 중간자입니다. 로드 밸런서나 API 게이트웨이가 여기에 해당합니다.
- 호스트 이름
- 사람이 쓰는 서버 이름입니다.
api.example.com처럼 씁니다. - 연결과 소켓
- 연결은 두 프로그램이 데이터를 주고받으려고 유지하는 통신 경로이고, 소켓은 프로그램이 그 경로를 다루는 운영체제의 창구입니다.
- POST
- HTTP 요청의 한 종류입니다. 여기서는 촬영 상태를 서버에 보낼 때 씁니다.
같은 프로세스의 스레드는 메모리를 공유합니다. 그래서 한 요청이 쓰던 데이터를 다른 요청이 실수로 바꿀 수 있습니다. 이 문제는 4장에서 다룹니다.
서버의 자원은 네 가지로 나눠 생각합니다. CPU는 명령을 실행하고, 메모리는 실행 중인 코드와 데이터를 담고, 디스크는 전원이 꺼져도 남겨야 할 데이터를 저장하고, 네트워크는 다른 프로세스·컴퓨터와 정보를 주고받습니다. 서버 성능은 이 자원들의 속도뿐 아니라 자원을 얻으려고 기다리는 시간에도 좌우됩니다. 이 장의 거의 모든 내용이 이 한 문장에서 나옵니다.
1.2 POST 요청 하나를 따라가기
촬영 앱이 “녹화 중” 상태를 서버에 저장한다고 합시다. 클라이언트는 호스트 이름을 주소로 바꾸고(DNS 조회), 연결을 새로 만들거나 이미 있는 연결을 다시 씁니다. HTTPS라면 TLS로 서버가 진짜인지 확인하고 통신을 암호화합니다. 요청이 프록시를 거쳐 서버에 도착하면, 서버는 인증 → 입력 검증 → 업무 처리 → 저장을 거쳐 응답합니다.
매번 하지 않는 일이 있다
DNS 조회와 연결 수립은 요청마다 반복되지 않습니다. 조회 결과는 캐시되고, 연결은 재사용되기 때문입니다. 새 연결이라면 TCP 연결에 왕복 한 번, TLS 1.3 핸드셰이크에 왕복 한 번이 들어서, 요청을 보내기까지 대략 두 번의 왕복(RTT)이 먼저 필요합니다.보강 탐색기에서 ‘다음 요청’을 고르면 이 단계들이 흐려지는 것을 볼 수 있습니다.
그래서 “첫 요청만 느리다”와 “모든 요청이 느리다”는 서로 다른 가설로 이어집니다. 앞의 것은 이름 조회·연결 수립 쪽을, 뒤의 것은 서버 안의 처리나 대기를 먼저 봅니다. 또 한 사용자의 요청이 빨라 보여도 다른 사용자는 풀이나 큐에서 오래 기다리고 있을 수 있습니다.
바이트와 다섯 가지 사건
네트워크로 받는 것은 ‘객체’가 아니라 바이트입니다. HTTP와 JSON 같은 형식이 그 바이트의 의미를 정합니다. 아래 다섯 사건은 모두 다른 시점에 일어납니다.
- 네트워크 수신 완료
- 요청 파싱 완료
- 업무 처리 완료
- DB 커밋 완료
- 클라이언트가 응답을 받음
③까지 끝났는데 ④ 전에 서버가 죽을 수도 있고, ④까지 끝났는데 ⑤가 일어나지 않을 수도 있습니다. 특히 ④와 ⑤ 사이에서 끊기면 서버에는 저장됐는데 클라이언트는 그 사실을 모릅니다. 이 장 끝의 연습 1과 7장의 멱등성이 바로 이 틈을 다룹니다.
1.3 계산과 대기를 구분하기
느려진 서버를 보면 먼저 “CPU가 일을 많이 하나, 아니면 무언가를 기다리나”를 나눕니다.
| 관찰 | 먼저 의심할 것 |
|---|---|
| CPU가 바쁘고 느리다 | 계산 암호화, 압축, JSON 파싱, 비싼 반복문 |
| CPU는 한가한데 느리다 | 대기 네트워크, DB 락, 커넥션 풀, 디스크, 실행 순서를 기다리는 스케줄링 대기 |
CPU가 낮다고 네트워크 탓은 아닙니다. CPU가 낮다는 사실은 “계산 병목은 아닐 수 있다”까지만 알려 줍니다. 락, 풀, 디스크 대기도 똑같이 CPU를 쓰지 않습니다. 반대로 전체 CPU 평균은 일부 코어의 포화를 숨길 수 있습니다.
스레드를 늘려도 DB 작업은 늘지 않는다
스레드를 늘리면 여러 작업이 기다리는 시간을 겹칠 수 있습니다. 하지만 DB 연결 20개가 모두 쓰이는 중이라면, 요청 스레드를 2,000개 만들어도 DB 작업은 동시에 20개까지만 진행됩니다. 나머지는 줄을 섭니다. 기다리는 요청도 메모리를 차지하고, 오래 기다리면 타임아웃이 납니다.
테이블이 20개인 식당을 떠올려 보세요. 대기표를 2,000장 나눠 줘도 동시에 식사하는 손님은 20팀입니다. 대기표를 더 찍는다고 테이블이 늘지는 않습니다.
가상 스레드가 해 주는 일과 해 주지 않는 일
가상 스레드는 기다리는 실행 흐름을 싸게, 많이 다룰 수 있게 해 주는 도구입니다. CPU, DB 연결, 외부 API의 호출 한도를 늘려 주지는 않습니다. JDK 24에서 synchronized 블록 때문에 가상 스레드가 캐리어 스레드에 묶이던 pinning 문제가 크게 개선됐습니다(JEP 491). 하지만 그 개선과 상관없이 희소 자원의 동시 사용량은 따로 제한해야 합니다. 개선된 범위를 “모든 pinning이 사라졌다”로 넓혀 읽지도 않습니다.S12
Spring Boot 4 · Kotlin에서는보강 가상 스레드를 켜는 설정과 DB 동시 작업의 상한은 서로 다른 줄입니다. 앞의 것을 켜도 뒤의 값은 그대로입니다.
spring:
threads:
virtual:
enabled: true # 요청 처리에 가상 스레드를 쓴다
datasource:
hikari:
maximum-pool-size: 20 # DB 작업의 동시 실행 상한은 여전히 이 값이다
connection-timeout: 3000 # 연결을 기다리는 최대 시간(ms). 기본값은 30000
HikariCP 지표를 Micrometer로 보면 대기가 숫자로 보입니다. hikaricp.connections.pending은 연결을 기다리는 요청 수, hikaricp.connections.acquire는 연결을 얻기까지 걸린 시간, hikaricp.connections.usage는 연결을 빌려 쓴 시간입니다. 아래 800ms 예제의 ‘풀 대기’가 acquire 시간으로 나타납니다.
1.4 운영체제까지 내려가 보기
애플리케이션이 파일이나 소켓을 쓰려면 운영체제 기능을 호출합니다. 이것이 시스템 콜입니다. 컨텍스트 스위치는 CPU가 실행 중인 작업의 상태를 보관하고 다른 작업으로 바꾸는 일입니다. 둘은 같은 사건이 아닙니다. 이미 도착한 데이터를 읽는 시스템 콜은 곧바로 돌아오고, 읽을 데이터가 없어 기다려야 할 때에야 스레드가 잠들고 다른 작업으로 전환될 수 있습니다.
프로그램이 보는 메모리 주소도 실제 메모리의 주소가 아닙니다. 운영체제가 가상 주소를 물리 메모리에 연결해 줍니다. 접근한 페이지가 아직 준비되지 않았으면 페이지 폴트가 납니다. 그런데 모든 페이지 폴트가 디스크 접근은 아닙니다. 이미 메모리에 있는 페이지를 연결만 하면 되는 minor fault와, 디스크에서 읽어 와야 하는 major fault가 있습니다.S25
| 관찰 | 뜻 | 바로 내리면 안 되는 결론 |
|---|---|---|
| 시스템 콜이 많다 | 운영체제 기능을 자주 부른다 | “컨텍스트 스위치가 많다” |
| 페이지 폴트가 많다 | 준비 안 된 페이지에 자주 접근한다 | “디스크가 느리다”(minor fault는 디스크를 읽지 않음) |
1.5 예제 — 800ms는 어디서 생겼나
설명용으로 만든 순차 경로가 있습니다. 인증 10ms, 풀 대기 550ms, SQL 실행 180ms, 응답 처리 60ms, 합계 800ms입니다. 어디부터 줄여야 할까요? 아래에서 한 구간씩 절반으로 줄여 보세요.
응답 처리(JSON 변환 포함)를 절반으로 줄여도 30ms, 약 4%만 줄어듭니다. 풀 대기를 절반으로 줄이면 275ms가 줄어듭니다. 그렇다고 바로 풀 크기를 늘리지는 않습니다. 먼저 풀 대기가 긴 이유를 나눕니다.
- 요청이 폭증했나? 들어오는 양이 처리 능력을 넘으면 대기가 쌓입니다(5장).
- 트랜잭션이 길어졌나? 연결을 쥔 채 외부 API를 기다리면 다른 요청이 연결을 못 얻습니다(4장, 9장).
- DB 자체가 느려졌나? SQL이 느려지면 연결을 돌려주는 시간이 늦어집니다(2장, 3장).
트레이스에서는 시간을 그냥 더하지 않는다
실제 분산 trace에서는 작업이 겹칠 수 있습니다. 아래처럼 DB 조회와 외부 API 호출을 동시에 보내면, span 시간의 합은 750ms지만 요청 전체는 600ms입니다. span 시간을 모두 더해 전체 지연으로 삼지 말고, 완료를 결정한 가장 긴 경로를 봅니다.
1.6 연습
연습 1서버가 저장을 마친 뒤 응답을 보내기 직전에 연결이 끊겼습니다. 클라이언트의 타임아웃은 저장 실패를 뜻할까요?
해설 보기
아닙니다. 클라이언트가 결과를 확인하지 못했을 뿐, 저장은 끝났을 수 있습니다. 그래서 무작정 다시 저장하면 같은 데이터가 두 번 들어갈 수 있습니다.
다시 시도하기 전에 “이것이 같은 요청인가”를 알아보고, 이미 처리됐다면 기존 결과를 돌려주는 정책이 필요합니다. 이 문제가 7장의 멱등성으로 이어집니다.
연습 2CPU 사용률은 20%인데 요청 지연이 3배가 됐습니다. “더 큰 CPU로 바꾸자”는 제안에 어떤 증거를 요구하겠습니까?
해설 보기
계산이 병목이라는 증거를 먼저 요구합니다. 코어별 CPU와 컨테이너의 CPU 스로틀링을 보고, 함께 DB·네트워크 시간, 풀 대기, 락 대기, 요청량을 봅니다.
전체 CPU 평균은 일부 코어의 포화를 숨길 수 있습니다. 8코어 중 한 코어만 100%로 바쁘면 평균은 약 12.5%입니다. 더 큰 CPU가 틀렸다고 단정하기보다, 그 변경으로 어떤 대기가 줄어들지 설명할 수 있는지 확인합니다.
스스로 점검
이 장의 완료 기준입니다. 설명할 수 있으면 체크하세요.
- 요청 경로를 그리고, 계산 2곳·대기 3곳·결과를 알 수 없게 되는 실패 경계 1곳을 표시할 수 있다. (탐색기의 ‘직접 표시해 보기’)
- 800ms 예제에서 무엇부터 조사할지, 왜 그 구간인지 말할 수 있다.
- 스레드 수(가상 스레드 포함)와 DB 연결 수가 왜 다른 한도인지 설명할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
2장 SQL 한 번과 디스크 쓰기 한 번은 다르다
“쓰기 부하가 크다”는 말을 요청 수, SQL 수, WAL 양, IOPS, 지연으로 쪼개 봅니다. COMMIT 한 번이 메모리와 디스크에서 무엇을 하는지 알면, 숫자 하나로 개선을 단정하지 않게 됩니다.
이 장의 핵심
- IOPS는 초당 입출력 작업 횟수입니다. 옮긴 데이터 양(처리량)이나 한 번에 걸린 시간(지연)과 다른 값입니다.
- COMMIT이 성공을 알리기 전에 필요한 것은 WAL의 영속화입니다. 테이블·인덱스 페이지는 나중에 따로 기록될 수 있습니다.
- 인덱스와 행 버전(MVCC)이 UPDATE 한 번의 일을 늘립니다. 실험 B에서는 같은 UPDATE의 WAL 레코드가 조건에 따라 3배가 됐습니다.
- 저장 호출을 줄였다는 사실이 곧 IOPS나 비용이 줄었다는 뜻은 아닙니다.
2.1 먼저 알아둘 말
- 저장과 영속화
- 저장은 데이터를 어딘가에 두는 일, 영속화는 실행 중인 메모리 밖에 남겨 나중에도 쓸 수 있게 하는 일입니다.
- 내구성
- 그 결과가 어떤 장애까지 살아남는지에 대한 보장입니다. 프로세스 종료, 서버 전원 장애, 디스크 고장은 서로 다른 장애입니다.
- 포화
- 처리 여유가 모자라 대기나 거절이 생기는 상태입니다.
- 병목
- 전체 진행 속도를 제한하는 단계입니다. 병목이 아닌 곳을 빠르게 해도 전체는 빨라지지 않습니다.
“부하가 늘었다”고 말할 때는 무엇이 바뀌었는지 붙이세요. 요청 수가 늘었나요, 데이터 하나의 크기가 커졌나요, 아니면 특정 키로 요청이 몰리기 시작했나요? 셋은 해법이 전혀 다릅니다.
2.2 IOPS를 이름부터 읽기
IOPS는 Input/Output Operations Per Second의 약어입니다. 조각으로 나눠 읽으면 뜻이 보입니다. Input/Output은 데이터를 읽고 쓰는 입출력, Operations는 작업 횟수, Per Second는 1초당입니다. 합치면 초당 입출력 작업 횟수입니다. DB 저장 성능을 말할 때는 저장 장치나 저장 계층에서 읽고 쓴 작업을 셉니다.S27
파일을 읽는 일은 읽기 I/O, 변경 내용을 저장 장치에 쓰는 일은 쓰기 I/O입니다. Read IOPS는 초당 읽기 횟수, Write IOPS는 초당 쓰기 횟수입니다. 같은 측정 경계와 기간에서 읽기가 초당 300회, 쓰기가 초당 700회라면 합계는 1,000 IOPS입니다. 화면에 보이는 값이 실제로 관측한 횟수인지, 제품에 설정한 처리 한도인지도 확인하세요.
IOPS는 데이터 크기도, 걸린 시간도 아닙니다. 작은 상자 1,000개와 큰 상자 1,000개를 옮기면 횟수는 같지만 옮긴 양은 다릅니다. 상자 하나가 도착하기까지 걸린 시간은 또 다른 값입니다. 그래서 세 가지 질문을 따로 묻습니다.
| 질문 | 지표와 예 | 읽는 법 |
|---|---|---|
| 몇 번 처리했나 | Write IOPS = 1,000 | 그 경계에서 초당 쓰기 작업 1,000회 |
| 얼마나 많이 옮겼나 | 쓰기 처리량 = 4,000 KiB/s | 초당 약 3.91 MiB |
| 하나가 얼마나 걸렸나 | 평균 쓰기 지연 = 2ms | 작업 하나가 평균 0.002초 |
모든 작업이 4 KiB이고 초당 1,000번 처리했다면 처리량은 1,000 × 4 = 4,000 KiB/s입니다. 1 MiB는 1,024 KiB이므로 4,000 ÷ 1,024 ≈ 3.91 MiB/s입니다. 4 KiB는 예로 든 크기일 뿐, I/O 작업 하나의 고정 크기가 아닙니다. 참고로 KiB는 정확히 1,024바이트이고 KB는 보통 1,000바이트입니다. 제품마다 섞어 쓰니 지표 정의를 확인하세요.
지연의 역수는 한도가 아니다
평균 쓰기 지연 2ms의 역수인 초당 500회를 시스템 전체의 처리 한도로 쓰면 안 됩니다. 여러 작업이 겹쳐 진행될 수 있기 때문입니다. 동시에 4개씩 겹쳐 처리한다면 4 ÷ 0.002초 = 초당 2,000회까지 가능합니다. 이 관계는 5장의 Little의 법칙으로 다시 봅니다.보강 반대로 설정한 IOPS가 커도, 처리량 한도나 다른 단계의 대기 때문에 실제 처리량은 작을 수 있습니다.
누가 세느냐에 따라 숫자가 다르다
관측 계층은 작은 작업을 합치거나 큰 작업을 나눠 셀 수 있습니다. 예를 들어 AWS EBS의 SSD 볼륨은 I/O 하나를 최대 256 KiB로 셉니다. 1,024 KiB짜리 I/O 하나는 256 KiB 네 번으로 나눠 세고, 물리적으로 연속된 32 KiB I/O 여덟 개는 256 KiB 한 번으로 합쳐 셀 수 있습니다.S27보강 계산기에서 ‘1 MiB I/O를 EBS SSD에서’를 눌러 보세요.
이력서의 “75% 감소” 읽기
기존 Write IOPS가 4,000이고 변경 뒤 1,000이라면 (4,000 − 1,000) ÷ 4,000 × 100 = 75%입니다. 같은 업무를 더 적은 저장 작업으로 처리했다면 개선의 근거가 됩니다. 하지만 요청이 줄었거나, 저장이 실패했거나, 일을 다른 저장소로 옮겨도 똑같은 숫자가 나옵니다. 이 숫자만으로 전체 비용이나 성능이 좋아졌다고 확정하지 않습니다. 6장에서 이 75%를 검증하는 방법을 다룹니다.
손으로 풀기10초 동안 쓰기 작업을 20,000번 완료했습니다. 작업 크기가 모두 4 KiB라면 평균 Write IOPS와 쓰기 처리량은 얼마일까요? SQL 실행 횟수도 알 수 있을까요?
해설 보기
20,000 ÷ 10 = 2,000 IOPS입니다. 처리량은 2,000 × 4 = 8,000 KiB/s, 약 7.81 MiB/s입니다.
SQL 실행 횟수는 알 수 없습니다. SQL 하나가 여러 저장 작업을 만들 수도 있고, 여러 SQL의 기록이 합쳐져 한 번에 쓰일 수도 있습니다. 그 사이에 있는 페이지·버퍼·WAL을 다음 절에서 봅니다.
2.3 행·페이지·버퍼
SQL은 데이터를 어떻게 다룰지 DB에 전달하는 언어이고, 행은 논리적인 데이터 한 건입니다. 그런데 DB가 디스크와 주고받는 단위는 행이 아니라 페이지입니다. PostgreSQL은 테이블과 인덱스를 페이지 단위로 관리하고, 기본 빌드의 페이지 크기는 8 KiB(8,192바이트)입니다. 문서에 “8kB”로 적힌 것이 이 크기입니다. 그렇다고 한 행을 바꿀 때마다 디스크에 8 KiB를 한 번 쓰는 것은 아닙니다.
DB는 자주 쓰는 페이지를 메모리의 버퍼에 올려 둡니다. 버퍼에서 내용이 바뀌었지만 아직 데이터 파일에 반영되지 않은 페이지를 dirty page라고 부릅니다. 메모리에서 바꾸는 것은 빠르지만, 프로세스나 서버가 죽으면 사라집니다. 그래서 복구에 쓸 기록이 따로 필요합니다. 그것이 WAL입니다.
2.4 WAL은 왜 먼저 기록하나
WAL(Write-Ahead Log)은 데이터 페이지보다 먼저 안전하게 남기는 변경 기록입니다. PostgreSQL은 변경마다 WAL 레코드를 만들고, 그 변경을 복구하는 데 필요한 WAL이 안전해지기 전에는 해당 데이터 페이지를 먼저 디스크에 쓰지 않습니다. 장애가 나면 마지막 checkpoint부터 WAL을 다시 적용해 상태를 되살립니다.S1 규칙은 한 줄입니다. 페이지보다 로그가 먼저.
정리하면, 일반적인 로컬 내구성 설정에서 COMMIT은 필요한 WAL이 영속화되기를 기다린 뒤 성공을 알립니다. 수정한 테이블·인덱스 페이지가 그 순간 모두 디스크에 있어야 하는 것은 아닙니다. 같은 순간에 커밋하는 여러 트랜잭션이 WAL 동기화(fsync) 한 번을 함께 쓸 수도 있습니다. 데이터 페이지는 체크포인트와 백그라운드 작업이 나중에 나눠 기록합니다. 그래서 커밋 수와 디스크 쓰기 횟수는 1:1이 아닙니다.
설정에 따라 약속이 달라진다
“커밋했다”가 무엇을 보장하는지는 설정과 저장 장치에 달려 있습니다.S1
| 설정 | 성공 응답을 보내는 시점 | 장애가 나면 |
|---|---|---|
synchronous_commit = on (기본값) | 이 서버의 WAL이 저장 장치에 내려간 뒤 | 성공 응답을 받은 트랜잭션은 복구됩니다. |
synchronous_commit = off | WAL 영속화를 기다리지 않고 바로 | 최근에 성공 응답을 받은 트랜잭션 일부가 사라질 수 있습니다. DB가 깨지지는 않고, 그 트랜잭션들이 롤백된 것처럼 됩니다. |
fsync = off보강 | 저장 장치까지 내려보내는 일을 운영체제에 맡김 | 전원·OS 장애 때 복구할 수 없는 데이터 손상이 생길 수 있습니다. 운영 환경에서 끄지 않습니다. |
| 동기 복제 | 지정한 대기 서버가 정해진 단계(remote_write, on, remote_apply)까지 마친 뒤 | 어느 노드가 어느 단계까지 마쳤는지가 조건입니다. |
“커밋 = 모든 복제본에 저장 완료”가 아닙니다. 비동기 커밋으로 응답을 앞당기면 성공을 받은 최근 트랜잭션이 장애 때 사라질 수 있습니다. 동기 복제를 써도 어떤 원격 노드가 어떤 단계까지 기다리는지 설정을 확인해야 합니다.
2.5 인덱스와 행 버전이 쓰기를 늘린다
인덱스는 특정 값을 가진 행을 빨리 찾는 별도 구조입니다. 읽을 후보를 줄여 주지만, 데이터를 추가·변경·삭제할 때 인덱스도 함께 고쳐야 합니다. 읽기를 빠르게 하는 대가로 쓰기와 저장 공간 비용을 냅니다.
PostgreSQL의 UPDATE는 원래 행의 바이트를 제자리에서 덮어쓰지 않습니다. MVCC 때문에 새 행 버전을 만들고, 옛 버전은 어떤 트랜잭션도 더 이상 볼 필요가 없어졌을 때 정리됩니다. 새 버전은 새 위치에 있으므로, 보통은 바꾸지 않은 열의 인덱스에도 새 위치를 가리키는 항목이 추가됩니다.보강
이 부담을 줄이는 최적화가 HOT(Heap-Only Tuple)입니다. 다음 두 조건이 모두 맞으면 새 버전을 같은 페이지에 두고, 인덱스에 새 항목을 넣지 않습니다.S2
- 인덱스가 걸린 열을 바꾸지 않는다. (BRIN 같은 요약 인덱스는 예외)
- 옛 행이 있는 페이지에 새 버전을 넣을 빈 공간이 있다.
“UPDATE마다 모든 인덱스가 반드시 같은 방식으로 바뀐다”고 단정하지 않는 이유가 여기 있습니다. 조건에 따라 인덱스를 건드리지 않을 수도, 모든 인덱스에 항목을 추가할 수도 있습니다. 긴 트랜잭션은 옛 버전이 필요한 기간을 늘려 정리를 늦춥니다. 그래서 쓰기 부하를 볼 때는 요청 수뿐 아니라 인덱스 수, 바뀌는 열, 행 크기, 트랜잭션 길이, VACUUM 상태를 함께 봅니다.
2.6 서로 다른 단위를 구분하기
지금까지 나온 지표를 한 표로 모았습니다. 오른쪽 열이 중요합니다. 지표 하나로 알 수 없는 것을 알면, 두 지표를 섞어 결론 내리는 실수를 피할 수 있습니다.
| 지표 | 뜻 | 이것만으로 알 수 없는 것 |
|---|---|---|
| 요청/초 | 애플리케이션이 받는 요청 빈도 | 요청 하나가 실행하는 SQL 수 |
| SQL/초, 트랜잭션/초 | DB의 논리 작업 빈도 | 실제 장치의 읽기·쓰기 횟수 |
| WAL 바이트/초 | 변경·복구 기록의 양 | 데이터 파일 쓰기를 포함한 전체 I/O |
| Write IOPS | 관측 계층이 센 초당 쓰기 작업 | 쓰기 크기, 지연, 논리 요청 수 |
| 쓰기 처리량 | 초당 옮긴 바이트 | 작업 하나의 지연과 대기 정도 |
| 쓰기 지연, 큐 길이 | 저장 작업이 늦거나 밀리는 정도 | 어느 SQL이 원인인지 |
IOPS에는 측정 위치를 반드시 적습니다. 운영체제(OS), 볼륨, 관리형 DB의 지표는 집계 방식과 단위가 다를 수 있습니다. 논리 쓰기 1만 건을 합쳤다고 물리 IOPS가 같은 비율로 줄지도 않습니다. 사이에 버퍼링, 배치, 체크포인트, 로그, 인덱스가 있기 때문입니다.
2.7 실험 A — 중간 상태를 꼭 저장해야 하나
사용자 100명이 10초 동안 매초 한 번씩 촬영 상태를 보낸다고 합시다. 매번 저장하면 1,000번입니다. 5초 동안 사용자별 마지막 값만 모았다가 저장하면 200번입니다. 모델 안의 저장 호출이 80% 줄었습니다. 이것은 디스크 IOPS나 비용을 잰 결과가 아닙니다.
이 최적화가 안전하려면 세 조건이 필요합니다. 중간 값이 사라져도 되고, 반영이 늦어져도 되고, 모아 둔 값이 장애로 사라졌을 때 어떻게 할지 정해져 있어야 합니다. 촬영의 최종 상태만 필요하다면 검토할 만합니다. 하지만 부정행위 감사를 위해 모든 상태 변화가 필요하다면 전혀 다른 문제입니다.
생각 실험. 5초마다 저장하는 프로세스가 4.9초에 죽었습니다. 마지막 몇 초가 사라져도 되나요? 별도의 내구성 있는 로그에서 다시 읽어 올 수 있나요? 시뮬레이터에서 ‘장애를 일으킨다’를 켜고 모아 두는 곳을 바꿔 보세요. 메모리에 모으는 것과 내구성 있는 큐에 모으는 것은 실패 조건이 다릅니다. 큐를 쓰면 유실은 막지만, 큐에 쓰는 기록이 따로 생깁니다.
Spring Boot 4 · Kotlin으로 옮기면보강 최신 상태만 메모리에 모았다가 주기적으로 한 번에 저장하는 가장 단순한 형태입니다. 프로세스가 강제로 죽으면 최대 5초 분량이 사라지므로, 이 손실을 허용할 수 있는 데이터에만 씁니다. @Scheduled를 쓰려면 @EnableScheduling이 필요합니다.
@Component
class LatestStatusBuffer(private val repository: CaptureStatusRepository) {
private val latest = ConcurrentHashMap<String, CaptureStatus>()
fun accept(status: CaptureStatus) {
// 늦게 도착한 옛 상태가 새 상태를 덮지 않도록 version으로 비교한다
latest.merge(status.sessionId, status) { old, new -> if (new.version > old.version) new else old }
}
@Scheduled(fixedDelay = 5_000)
fun flush() {
val batch = latest.keys.mapNotNull { latest.remove(it) }
if (batch.isEmpty()) return
try {
repository.upsertAll(batch) // 직접 만든 배치 upsert 한 번으로 저장
} catch (e: Exception) {
batch.forEach(::accept) // 실패하면 되돌려 넣고 다음 주기에 다시 시도
throw e
}
}
@PreDestroy
fun flushOnShutdown() = flush() // 정상 종료 때는 남은 값을 저장한다
}
2.8 실험 B — 인덱스가 UPDATE에 주는 영향
이번에는 실제 PostgreSQL에서 같은 UPDATE를 세 테이블에 실행해, 조건에 따라 WAL이 어떻게 달라지는지 봅니다. 세 테이블은 모양과 데이터(10만 행)가 같고 조건만 다릅니다. 각각 10행마다 한 행씩, 모두 1만 행의 status만 바꿉니다.보강
| 테이블 | 페이지 여유 공간 | status 열 인덱스 | 예상 |
|---|---|---|---|
| A | 있음 (fillfactor 70) | 없음 | HOT 가능 |
| B | 있음 (fillfactor 70) | 있음 | 조건 ① 불만족 |
| C | 없음 (기본값 100, 꽉 참) | 없음 | 조건 ② 불만족 |
실습 SQL 전체 보기 (02_indexes.sql)
-- 02_indexes.sql — 인덱스와 페이지 여유 공간이 UPDATE 한 번의 일을 얼마나 바꾸는지 본다.
-- 실습 전용 DB에서만 실행한다. CHECKPOINT는 superuser나 pg_checkpoint 권한이 필요하다.
DROP SCHEMA IF EXISTS lab CASCADE;
CREATE SCHEMA lab;
-- A: 페이지에 여유 공간(30%)을 남기고, status 열에는 인덱스가 없다.
CREATE TABLE lab.cap_a (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
updated_at timestamptz NOT NULL
) WITH (fillfactor = 70);
-- B: A와 같지만 status 열에 인덱스가 있다.
CREATE TABLE lab.cap_b (LIKE lab.cap_a INCLUDING ALL) WITH (fillfactor = 70);
CREATE INDEX cap_b_status ON lab.cap_b (status);
-- C: status 인덱스는 없지만 기본 fillfactor(100)라 페이지가 꽉 찬다.
CREATE TABLE lab.cap_c (LIKE lab.cap_a INCLUDING ALL);
INSERT INTO lab.cap_a
SELECT g, g % 1000, 'recording', timestamptz '2026-09-20' + g * interval '1 second'
FROM generate_series(1, 100000) AS g;
INSERT INTO lab.cap_b SELECT * FROM lab.cap_a;
INSERT INTO lab.cap_c SELECT * FROM lab.cap_a;
VACUUM ANALYZE lab.cap_a;
VACUUM ANALYZE lab.cap_b;
VACUUM ANALYZE lab.cap_c;
-- 세 테이블에 똑같은 UPDATE를 한다: 10행마다 한 행(총 10,000행)의 status만 바꾼다.
-- 매번 CHECKPOINT로 full-page image 조건을 맞춘다.
CHECKPOINT;
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, COSTS OFF)
UPDATE lab.cap_a SET status = 'stopped' WHERE id % 10 = 0;
SELECT relname, n_tup_upd, n_tup_hot_upd FROM pg_stat_xact_user_tables WHERE relname = 'cap_a';
COMMIT;
CHECKPOINT;
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, COSTS OFF)
UPDATE lab.cap_b SET status = 'stopped' WHERE id % 10 = 0;
SELECT relname, n_tup_upd, n_tup_hot_upd FROM pg_stat_xact_user_tables WHERE relname = 'cap_b';
COMMIT;
CHECKPOINT;
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, COSTS OFF)
UPDATE lab.cap_c SET status = 'stopped' WHERE id % 10 = 0;
SELECT relname, n_tup_upd, n_tup_hot_upd FROM pg_stat_xact_user_tables WHERE relname = 'cap_c';
COMMIT;
# 시작하기의 실습 컨테이너에 SQL 파일을 흘려 넣는다
docker exec -i pg-lab psql -U postgres -X < 02_indexes.sql
테이블 A에서 본 EXPLAIN 출력의 앞부분입니다. Buffers는 페이지 접근, WAL은 이 실행이 만든 WAL의 레코드 수·full-page image 수·바이트입니다.S3
Update on cap_a (actual time=19.372..19.374 rows=0 loops=1)
Buffers: shared hit=22410 dirtied=1206
WAL: records=10000 fpi=1205 bytes=7665555
-> Seq Scan on cap_a (actual time=0.007..5.771 rows=10000 loops=1)
Filter: ((id % '10'::bigint) = 0)
Rows Removed by Filter: 90000
| 관측 | A 여유 공간 · 인덱스 없는 열 | B 여유 공간 · 인덱스 있는 열 | C 꽉 찬 페이지 · 인덱스 없는 열 |
|---|---|---|---|
| HOT로 처리한 행 | 10,000 / 10,000 | 0 / 10,000 | 0 / 10,000 |
| WAL 레코드 | 10,000 | 30,090 | 30,000 |
| 레코드 구성 | HOT_UPDATE 10,000 | UPDATE 10,000 + 인덱스 삽입 19,992 + 중복 제거 82 | UPDATE 10,000 + LOCK 10,000 + 인덱스 삽입 10,000 |
| full-page image | 1,205 | 1,481 | 1,108 |
| WAL 바이트 | 7,665,555 (약 7.3 MiB, 90%가 FPI) | 10,983,415 (약 10.5 MiB, 81%가 FPI) | 11,083,094 (약 10.6 MiB, 80%가 FPI) |
| 버퍼 접근 (shared hit) | 22,410 | 72,418 | 61,824 |
| 실행 시간 | 약 19~22ms | 약 35~42ms | 약 42~46ms |
결과를 읽는 법
- 같은 UPDATE, 다른 일의 양. A는 1만 행 모두 HOT로 처리돼 행마다 WAL 레코드가 하나였습니다. B는 인덱스가 걸린 열을 바꿔서 행마다 UPDATE 하나와 인덱스 삽입 둘(기본 키, status 인덱스)이 생겼습니다. C는 인덱스 없는 열을 바꿨는데도, 페이지에 자리가 없어 새 버전이 다른 페이지로 가면서 옛 행을 잠그는 LOCK, UPDATE, 기본 키 인덱스 삽입이 생겼습니다. 위젯의 공식이 이 관찰에서 나왔습니다.
- WAL 바이트의 대부분은 full-page image였습니다. 체크포인트 뒤에 페이지를 처음 바꿀 때는 페이지 전체를 WAL에 남깁니다. A는 1,205개 페이지를 모두 바꿨고 FPI도 1,205개였습니다. 그래서 같은 SQL이라도 체크포인트 직후인지에 따라 WAL 양이 크게 달라집니다.
- 이 숫자들은 디스크 IOPS가 아닙니다.
Buffers: shared hit은 메모리에서 처리한 페이지 접근이고, WAL 수치는 이 실행이 만든 기록의 양입니다. 실제 디스크 쓰기는 커밋의 WAL flush와 나중의 체크포인트에서 일어납니다. - 실행 시간은 참고만 합니다. 작은 테이블을 메모리에 올려 두고 몇 번 잰 값이라, 속도 차이의 배수를 일반화하지 않습니다. 이 실험의 결론은 “B와 C는 A보다 약 2배 느리다”가 아니라 “같은 SQL도 인덱스와 페이지 공간에 따라 쓰기 일이 달라진다”입니다.
EXPLAIN ANALYZE는 SQL을 실제로 실행합니다. 실습 전용 DB에서만 쓰세요. 운영 DB의 UPDATE에 그대로 붙이지 않습니다. BEGIN … ROLLBACK으로 감싸도 실행 부하와 WAL은 이미 생깁니다.S3
2.9 연습
연습초당 트랜잭션 수는 그대로인데 Write IOPS와 쓰기 지연이 늘었습니다. 가능한 가설 세 가지와 각각을 확인할 방법을 적어 보세요.
해설 보기
| 가설 | 확인 방법 |
|---|---|
| 인덱스를 추가했거나 자주 바꾸는 열이 달라졌다 | 배포 diff와 WAL 양을 비교합니다. 실험 B처럼 같은 SQL도 WAL 레코드가 3배가 될 수 있습니다. |
| 체크포인트나 저장 경로가 바뀌었다 | 시간대별 DB·스토리지 지표를 봅니다. 체크포인트 직후에는 full-page image가 늘어납니다. |
| 행이 커졌거나 데이터 분포가 바뀌었다 | 요청 크기와 테이블·인덱스 크기를 봅니다. |
지표만으로는 어느 가설이 맞는지 확정할 수 없습니다. 가설마다 “이런 결과가 나오면 이 가설은 틀렸다”는 반증 조건을 함께 적으세요.
스스로 점검
이 장의 완료 기준입니다. 로그·버퍼·인덱스를 넣어 설명할 수 있으면 체크하세요.
- “COMMIT 뒤에도 데이터 페이지 쓰기가 남을 수 있는 이유”를 WAL과 버퍼로 설명할 수 있다.
- “트랜잭션 수와 IOPS가 다른 이유”를 버퍼·인덱스·WAL·측정 계층으로 설명할 수 있다.
- 실험 B에서 세 테이블의 WAL 레코드 수가 달라진 이유를 HOT의 두 조건으로 설명할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
3장 데이터를 어떻게 읽을지가 저장 모양을 결정한다
테이블부터 그리지 않고 질문부터 적습니다. 어떤 요청이 무엇을 얼마나 자주 읽는지가 키·제약·인덱스를 정합니다. 2장의 결론인 “인덱스는 읽기를 돕고 쓰기를 늘린다”를 여기서 설계로 옮깁니다.
이 장의 핵심
- 질문(접근 패턴)을 먼저 적고 그 질문에 맞는 키·인덱스를 고릅니다. 같은 인덱스가 모든 질문에 맞지는 않습니다.
- UNIQUE·CHECK 같은 제약은 “조회한 뒤 저장”하는 사이의 틈을 저장소에서 막는 약속입니다.
- 실행 계획은 예상 행 수와 실제 행 수의 차이부터 봅니다. 인덱스를 쓰지 않았다고 나쁜 계획은 아닙니다.
- ORM이 SQL을 대신 만들어도 왕복 횟수와 읽는 범위의 비용은 그대로 남습니다.
3.1 먼저 알아둘 말
- 테이블·행·열
- 테이블은 같은 종류의 데이터를 표처럼 모은 것, 행은 기록 한 건, 열은 그 기록의 속성입니다. 촬영 테이블의 한 행이 세션 하나라면 세션 ID·사용자 ID·상태가 열입니다.
- 키
- 행을 찾거나 구분하는 값입니다. 기본 키는 행 하나를 가리키고, 외래 키는 다른 테이블의 행을 가리킵니다.
- 스키마
- 구조와 제약의 정의입니다. PostgreSQL에서는 테이블을 묶는 이름 공간(
lab.capture의lab)이라는 뜻도 있습니다. - 인덱스
- 특정 조건의 데이터를 빨리 찾으려고 따로 유지하는 탐색 구조입니다. 2장에서 봤듯이 쓰기마다 함께 고쳐야 합니다.
3.2 질문을 먼저 적고 테이블을 그린다
촬영 서비스에 세 가지 질문이 있다고 합시다. 같은 사용자의 데이터인데도 필요한 것이 전부 다릅니다.
| 질문 | 무엇이 중요한가 | 설계에 주는 영향 |
|---|---|---|
| 이 사용자가 챌린지에 참여할 자격이 있나? | 정확성. 신청은 사용자·챌린지마다 한 번 | 관계와 제약(UNIQUE)으로 약속을 저장소에 둔다 |
| 이 촬영 세션의 최신 상태는? | 키 하나로 빠르게, 자주 바뀜 | 세션 키 조회, 갱신 비용(2장) |
| 지난달 모든 촬영 기록을 유형별로 분석하려면? | 많은 행을 훑음, 늦어도 됨 | 사전 집계나 분석용 경로 |
정합성·조회·보존 요구가 다르면 설계도 달라집니다. 신청 정보와 촬영 상태를 다른 테이블에 두는 것으로 충분할 수도 있고, 규모와 비용에 따라 다른 저장소로 나눌 수도 있습니다. 처음부터 서비스와 DB를 따로 만들 필요는 없습니다.
3.3 키와 제약은 데이터의 약속이다
- 기본 키
- 행을 하나씩 구별합니다.
- 외래 키
- 다른 데이터와의 참조 관계가 맞는지 검증합니다.
- UNIQUE
- 중복되면 안 되는 값의 조합을 강제합니다.
- CHECK
- 한 행의 값이 지켜야 할 조건을 표현합니다. 예:
active BETWEEN 0 AND 2
애플리케이션에서 “먼저 조회해 보고, 없으면 저장”만 하면 그 사이에 다른 요청이 똑같이 판단할 수 있습니다. 참여 신청이 사용자·챌린지마다 한 번이어야 한다면 UNIQUE(user_id, challenge_id)가 그 약속을 저장소에서 지킵니다. 실제로 두 연결로 순서를 고정해 실행해 봤습니다.보강
제약 위반을 사용자에게 어떤 응답으로 돌려줄지는 애플리케이션의 일입니다. DB 제약과 예외 처리 가운데 하나만 있으면 된다고 보지 않습니다. 제약은 틈을 막고, 예외 처리는 그 결과를 사용자가 이해할 수 있는 말로 바꿉니다.
Spring Boot 4 · Kotlin에서는보강 중복 키 오류(SQLSTATE 23505)는 Spring이 DataIntegrityViolationException 계열로 바꿔 던집니다. 이 예외는 NOT NULL이나 외래 키 위반에도 쓰이므로 제약 이름을 확인하는 편이 안전합니다. JPA는 INSERT를 커밋 직전까지 미룰 수 있으니, 서비스 안에서 오류를 받고 싶다면 saveAndFlush로 바로 내보냅니다.
@Transactional
fun apply(userId: Long, challengeId: Long): Entry =
try {
entryRepository.saveAndFlush(Entry(userId = userId, challengeId = challengeId))
} catch (e: DataIntegrityViolationException) {
val constraint = (e.cause as? org.hibernate.exception.ConstraintViolationException)?.constraintName
if (constraint == "entry_once") throw AlreadyAppliedException() else throw e // 409로 응답
}
정규화와 비정규화
정규화는 같은 사실을 한 곳에만 둬서 고칠 곳을 분명하게 만드는 방법입니다. 조회를 빠르게 하려고 요약이나 복제본을 추가하면 새 질문이 생깁니다. 원본의 주인은 누구인가, 언제 갱신하나, 어긋난 것을 어떻게 알아내고 다시 만드나. 비정규화는 “이 복사본은 버려도 되는가”까지 함께 정하는 설계입니다.
3.4 B-tree 인덱스와 복합 키
B-tree 계열 인덱스는 정렬된 키를 여러 갈래로 나눠 계층적으로 좁혀 찾습니다. 모든 행을 읽는 대신 몇 개의 페이지만 거쳐 후보 범위를 찾을 수 있습니다.
그렇다고 인덱스가 늘 이기지는 않습니다. 결과가 테이블의 대부분이라면 인덱스를 거쳐 많은 행을 하나씩 찾아가는 것보다 처음부터 훑는 순차 스캔이 나을 수 있습니다. 인덱스를 쓰지 않았다는 사실만으로 실행 계획이 나쁘다고 결론 내리지 않습니다.
“사용자별 최신 세션 20개”를 찾는 요청이 WHERE user_id = ? ORDER BY started_at DESC LIMIT 20이라면 (user_id, started_at DESC) 인덱스가 좋은 후보입니다. 반대로 “전체 사용자의 시간 범위 조회”에는 이 인덱스가 거의 도움이 되지 않습니다. 인덱스의 첫 열이 user_id라서, 시간만으로는 정렬된 순서를 이용할 수 없기 때문입니다. 복합 인덱스의 열 순서는 실제 조건·정렬·선택도(조건이 얼마나 적은 행을 고르는가)와 DB 기능을 함께 보고 정합니다.
3.5 실행 계획을 읽는 순서
EXPLAIN 결과는 아래에서 위로 실행되는 나무 모양입니다. 다음 순서로 읽습니다.
- 무엇을 실행했나. 어떤 조건과 입력값으로 실행했는지 확인합니다. 같은 SQL도 값에 따라 계획이 달라집니다.
- 예상과 실제의 차이. 예상 행 수(rows)와 실제 행 수(actual rows)가 얼마나 다른지 봅니다. 여기서 크게 틀리면 그 위의 선택도 틀리기 쉽습니다.
- 반복 횟수.
loops를 보고 작은 작업이 몇 번 반복됐는지 확인합니다. 시간과 행 수는 한 번 실행한 값의 평균입니다. - 나머지 비용. 버퍼(읽은 페이지), 임시 파일, 정렬, 조인, 실행 시간을 함께 봅니다. 위쪽 노드의 시간은 아래쪽 노드를 포함하므로 노드 시간을 단순히 더하지 않습니다.S3
아래는 이 장의 실험을 PostgreSQL 16.13에서 실제로 실행한 계획입니다.보강 줄을 누르면 그 줄이 무슨 뜻인지 설명이 나옵니다.
예상이 틀리면 인덱스부터 만들지 않는다
설명용 계획에서 예상 행 수가 10인데 실제로 100,000이었다면 추정이 1만 배 틀린 것입니다. “인덱스 추가”부터 하기 전에 통계가 최신인지, 특정 값에 데이터가 쏠렸는지, 열 사이에 상관관계가 있는지, 쿼리 조건이 어떤지를 먼저 조사합니다.S3 반대로 예상과 실제가 비슷한데 돌려주는 데이터 자체가 너무 크다면, 제품 요구나 페이지네이션을 바꿔야 할 수 있습니다.
통계가 낡으면 실제로 무슨 일이 생기는지 재현했습니다.보강 자동 ANALYZE를 끈 테이블에 인기 사용자 7의 기록 10만 건을 새로 넣고, 통계를 갱신하기 전과 후에 같은 조회를 실행했습니다.
| 시점 | 예상 행 수 | 실제 행 수 | 고른 방법 |
|---|---|---|---|
| 통계 갱신 전 | 199 | 100,100 | Bitmap Index Scan → Bitmap Heap Scan (적은 행에 맞는 방법) |
ANALYZE 뒤 | 약 10만 (작업자 3개가 약 4.2만씩) | 100,100 | Parallel Seq Scan (테이블 절반을 읽는 데 맞는 방법) |
추정이 약 500배 틀리자 PostgreSQL은 “몇 행 안 되니 인덱스로 찾자”고 판단했습니다. 통계를 갱신하자 같은 조회에 전혀 다른 방법을 골랐습니다. 이런 상황에서 인덱스를 더 만드는 것은 처방이 아닙니다. 먼저 통계가 데이터의 실제 모양을 따라가고 있는지 확인합니다.
3.6 실험 — 같은 데이터, 다른 조회
원래 교재의 labs/03_queries.sql을 다시 만들어 실행했습니다.보강 사용자별 최근 20건 조회를 인덱스 생성 전후로 비교하고, WHERE나 LIMIT을 없앴을 때 계획이 어떻게 바뀌는지 봅니다. 결과는 위 도우미의 탭과 같습니다.
| 조회 | 계획의 모양 | 읽은 페이지 | 실행 시간 |
|---|---|---|---|
| ① 사용자 42 최근 20건, 인덱스 없음 | Seq Scan → Sort(top-N) → Limit | 3,226 | 8.4ms |
| ② 같은 조회, 인덱스 있음 | Index Scan → Limit (Sort 없음) | 26 | 0.08ms |
③ WHERE 없이 전체 최근 20건 | Parallel Seq Scan → Sort → Gather Merge → Limit | 3,340 | 21.6ms |
④ LIMIT 없이 사용자 42 전부 | Bitmap Index Scan → Bitmap Heap Scan → Sort | 103 | 0.18ms |
- ①과 ②: 반환은 둘 다 20행이지만 ①은 10만 행을 읽고 99,900행을 버렸습니다. 반환 행 수가 적다고 읽은 범위가 좁은 것은 아닙니다.
- ②에서 Sort가 사라진 이유: 인덱스가 이미 사용자별·시간 역순으로 정렬돼 있어서, 앞에서부터 20개를 읽으면 그대로 답입니다.
- ③: 첫 열이
user_id인 인덱스는 전체 시간 순서를 알려 주지 못합니다. 같은 인덱스가 모든 질문에 맞지 않습니다. - ④: 100행을 모두 가져올 때는 인덱스 순서대로 한 행씩 찾아가는 대신, 필요한 페이지를 모아 읽고 100행을 정렬하는 편을 골랐습니다. 질문이 바뀌면 같은 인덱스라도 쓰는 방식이 바뀝니다.
실습 SQL 전체 보기 (03_queries.sql)
-- 03_queries.sql — 같은 데이터, 다른 조회. 인덱스가 읽는 범위를 어떻게 바꾸는지 본다.
-- 실습 전용 DB에서만 실행한다.
CREATE SCHEMA IF NOT EXISTS lab;
DROP TABLE IF EXISTS lab.capture, lab.capture_skew;
-- 촬영 기록 10만 건. 사용자 1,000명이 100건씩 가진다.
CREATE TABLE lab.capture (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
started_at timestamptz NOT NULL,
payload text NOT NULL
);
INSERT INTO lab.capture
SELECT g, g % 1000, timestamptz '2026-09-01' + g * interval '10 seconds', repeat('x', 200)
FROM generate_series(1, 100000) AS g;
ANALYZE lab.capture;
-- 1) 인덱스 없이: 사용자 42의 최근 20건
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, started_at FROM lab.capture
WHERE user_id = 42 ORDER BY started_at DESC LIMIT 20;
-- 2) 사용자·시간 역순 복합 인덱스를 만든 뒤 같은 조회
CREATE INDEX capture_user_time ON lab.capture (user_id, started_at DESC);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, started_at FROM lab.capture
WHERE user_id = 42 ORDER BY started_at DESC LIMIT 20;
-- 3) WHERE를 없애면: 전체 사용자의 최근 20건
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, started_at FROM lab.capture
ORDER BY started_at DESC LIMIT 20;
-- 4) LIMIT을 없애면: 사용자 42의 기록 전부
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, started_at FROM lab.capture
WHERE user_id = 42 ORDER BY started_at DESC;
-- 5) 통계가 낡으면 예상 행 수가 틀린다 (자동 ANALYZE를 끈 사본)
CREATE TABLE lab.capture_skew (LIKE lab.capture INCLUDING ALL) WITH (autovacuum_enabled = false);
INSERT INTO lab.capture_skew SELECT * FROM lab.capture;
ANALYZE lab.capture_skew;
-- 인기 사용자 7에게 새 기록 10만 건이 몰렸지만 통계는 아직 옛날 것이다
INSERT INTO lab.capture_skew
SELECT 100000 + g, 7, timestamptz '2026-09-20' + g * interval '1 second', repeat('x', 200)
FROM generate_series(1, 100000) AS g;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM lab.capture_skew WHERE user_id = 7;
ANALYZE lab.capture_skew;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM lab.capture_skew WHERE user_id = 7;
작은 테이블, 캐시, 하드웨어 조건에서는 시간 차이가 작게 나올 수 있습니다. 이 실험의 첫 목적은 특정 배수의 속도 향상이 아니라 접근 경로와 읽는 데이터 범위의 차이를 설명하는 것입니다. 결과를 빨리 돌려주려고 데이터의 보존·표현 방식 자체를 바꾸는 문제는 6장으로 이어집니다.
3.7 ORM이 만든 SQL도 비용은 같다
ORM이나 jOOQ가 SQL을 만들어 줘도 이 비용은 사라지지 않습니다. 목록 100건을 읽은 뒤 각 행마다 연관 데이터를 따로 조회하면 SQL 왕복이 101번이 됩니다. 이것이 N+1 문제입니다. 조인이나 배치 조회로 줄일 수 있지만, 큰 조인 결과의 중복과 메모리 비용도 함께 확인해야 합니다.
Spring Data JPA에서는보강 연관 컬렉션을 한 건씩 불러오는 대신 IN 배치로 묶으려면 spring.jpa.properties.hibernate.default_batch_fetch_size를 설정합니다. fetch join은 SQL 한 번으로 끝나지만 부모 열이 자식 수만큼 반복되고, 컬렉션 fetch join에 페이지네이션을 걸면 Hibernate가 결과를 메모리에서 잘라 내며 경고를 남깁니다. 목록 화면에는 배치 조회가, 한 건 상세에는 fetch join이 맞는 경우가 많습니다.
3.8 연습
연습 1latest_status를 따로 저장했더니 조회가 빨라졌습니다. 그런데 원본 이벤트와 값이 달라졌습니다. 무엇이 참일까요?
해설 보기
설계에서 권위(원본)를 먼저 정해야 답할 수 있습니다. 이벤트가 원본이라면 최신 상태는 이벤트로 다시 만들 수 있는 투영(projection)이고, 어긋나면 이벤트로 재생성합니다.
반대로 최신 상태만 원본이라면, 과거 이벤트를 모두 복구할 수 있다고 약속해서는 안 됩니다. 어느 쪽이든 불일치를 찾아내는 방법(대사)과 누가 고치는지를 함께 정합니다.
연습 2고객별 조회는 빠른데 전체 월별 집계가 느립니다. 저장소를 당장 바꿔야 할까요?
해설 보기
먼저 그 집계가 얼마나 자주 필요한지, 얼마나 늦어도 되는지, 데이터가 얼마나 많은지 확인합니다. 후보는 적절한 인덱스, 사전 집계, 비동기 분석 경로 등 여러 가지입니다.
하나의 저장 모델에 모든 조회의 최저 지연을 요구하면 쓰기와 비용이 커집니다. 실시간 조회와 월별 분석은 서로 다른 경로로 풀어도 됩니다.
스스로 점검
이 장의 완료 기준입니다.
- 내 서비스의 접근 패턴 3개를 적고, 각각에 맞는 키·인덱스의 이득과 쓰기 비용을 설명할 수 있다.
- 각 데이터의 원본(권위)이 어디이고 누가 고치는지 말할 수 있다.
- 실행 계획에서 예상 행 수와 실제 행 수, 읽은 범위와 반환 행 수의 차이를 짚을 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
4장 동시에 맞는 코드가 함께 실행되면 틀릴 수 있다
혼자 실행하면 맞는 코드도 두 요청이 겹치면 업무 규칙을 깹니다. 트랜잭션을 붙이는 것만으로는 왜 부족한지, 틈을 막는 방법(조건부 갱신, 락, 버전 검사)이 각각 무엇을 대가로 내는지 PostgreSQL에서 실제로 실행해 확인합니다.
이 장의 핵심
- 원자성·격리·내구성은 서로 다른 약속입니다. 업무 규칙(불변식)은 개발자가 모델과 제약으로 만들어야 합니다.
- “읽고 → 판단하고 → 쓰는” 사이의 틈이 초과 판매를 만듭니다. 조건을 변경과 같은 SQL에서 검사하면 틈이 사라집니다.
- Read Committed는 문장마다, Repeatable Read는 트랜잭션마다 스냅샷을 씁니다. Serializable은 충돌하면 트랜잭션을 중단하므로 전체를 다시 시도할 준비가 필요합니다.
- 한 프로세스 안의 락은 다른 서버 인스턴스를 막지 못하고, 분산 락이 DB 제약을 대신하지도 못합니다.
4.1 먼저 알아둘 말
- 동시성
- 여러 작업의 진행 시간이 겹치는 성질입니다. 꼭 같은 순간에 실행될 필요는 없습니다.
- 경합
- 같은 자원을 여러 작업이 함께 쓰려고 다투는 상황입니다.
- 불변식
- 실행 순서가 어떻게 섞여도 지켜야 할 업무 규칙입니다. 예: 재고보다 많이 팔지 않는다.
- 정합성
- 데이터가 그런 규칙과 관계에 맞는 상태라는 뜻입니다.
- 락
- 충돌하는 접근의 순서를 정하는 잠금입니다.
- 격리 수준
- 동시에 실행되는 트랜잭션이 서로의 결과를 어디까지 볼 수 있는지 정하는 규칙입니다.
4.2 원자성·격리·내구성을 나눈다
트랜잭션은 관련 작업을 하나의 성공·실패 단위로 묶습니다. 그런데 트랜잭션이 주는 약속은 세 가지로 나뉘고, 셋 모두 “업무 규칙이 맞다”는 약속은 아닙니다.
| 성질 | 약속하는 것 | 약속하지 않는 것 |
|---|---|---|
| 원자성 | 묶인 작업이 일부만 반영된 상태를 남기지 않는다 | 동시에 도는 다른 트랜잭션과의 관계 |
| 격리 | 동시에 실행되는 작업끼리 어떤 결과를 허용할지 정한다 | 수준에 따라 허용되는 이상 현상이 다르다 |
| 내구성 | 성공한 결과가 약속한 장애 조건에서 살아남는다 | 약속 밖의 장애(2장) |
그래서 “트랜잭션을 붙였다”는 말만으로 중복 신청, 초과 예약, 외부 결제 중복이 해결되지 않습니다. 특히 같은 DB 트랜잭션에 속하지 않은 외부 API 호출은 DB를 롤백해도 취소되지 않습니다. API 호출을 트랜잭션 안으로 옮겨도 이 경계는 그대로이고, DB 연결만 더 오래 붙잡게 됩니다(9장 사례 C).
4.3 읽고 나중에 쓰는 틈
재고가 1개 남았습니다. A와 B가 각각 재고 1을 읽습니다. 둘 다 “남아 있으니 판매 가능”이라고 판단합니다. 각자 읽은 값에서 1을 빼 0을 쓰면, 두 요청이 모두 성공했는데 재고는 0입니다. 재고가 음수가 아니라고 초과 판매가 없었던 것은 아닙니다.
아래는 같은 상황을 네 가지 방법으로 PostgreSQL 16.13에서 실제로 실행한 기록입니다. 두 연결의 실행 순서를 고정해서, “어쩌다 한 번” 일어나는 충돌을 매번 재현했습니다.보강
조건부 UPDATE가 틈을 막는 방법
같은 행의 조건을 변경과 함께 검사합니다.
UPDATE lab.inventory
SET stock = stock - 1
WHERE id = 1 AND stock > 0
RETURNING stock;
PostgreSQL의 Read Committed에서 다른 트랜잭션이 같은 행을 먼저 바꾸는 중이면, 뒤의 UPDATE는 기다렸다가 바뀐 행을 기준으로 조건을 다시 평가합니다. A가 1을 0으로 바꿨다면 B는 stock > 0을 만족하지 못해 0행을 바꿉니다. 애플리케이션은 이 변경 행 수를 보고 품절을 처리해야 합니다. SQL과 성공 판정이 하나의 계약입니다.S4
이 방법으로 모든 불변식이 풀리지는 않습니다. “여러 행의 합계가 한도를 넘지 않는다”처럼 행을 가로지르는 규칙에는 잠글 기준 행, 제약 표현, 격리 수준 같은 추가 설계가 필요합니다. 이 장의 연습 문제가 그런 경우입니다.
Spring Data JPA에서는보강 조건부 갱신은 @Modifying 쿼리로 쓰고, 반환값(바뀐 행 수)으로 성공을 판정합니다. 같은 영속성 컨텍스트에 그 엔티티가 이미 올라와 있다면 clearAutomatically = true로 옛 값을 비웁니다. 버전 검사는 엔티티에 @Version을 두면 충돌 때 ObjectOptimisticLockingFailureException으로, 비관적 락은 @Lock(LockModeType.PESSIMISTIC_WRITE)가 SELECT … FOR UPDATE로 나갑니다.
interface InventoryRepository : JpaRepository<Inventory, Long> {
@Modifying(clearAutomatically = true)
@Query("update Inventory i set i.stock = i.stock - 1 where i.id = :id and i.stock > 0")
fun decreaseIfAvailable(id: Long): Int // 0이면 품절
}
@Transactional
fun buy(itemId: Long, buyer: Long) {
if (inventoryRepository.decreaseIfAvailable(itemId) == 0) throw SoldOutException()
orderRepository.save(Order(itemId = itemId, buyer = buyer))
}
4.4 MVCC와 스냅샷
MVCC는 행의 여러 버전을 유지해 읽기와 쓰기가 서로 막지 않고 함께 진행되게 합니다. 스냅샷은 “어느 시점의 버전을 볼지” 정하는 기준입니다.
- Read Committed(PostgreSQL 기본값): 문장마다 새 스냅샷을 씁니다. 같은 트랜잭션 안의 SELECT 두 번이 다른 결과를 볼 수 있습니다.
- Repeatable Read: 트랜잭션에서 처음 실행한 문장 시점의 스냅샷을 끝까지 씁니다. PostgreSQL에서는 팬텀 읽기도 막지만, 모든 직렬화 이상을 막지는 않습니다.
- Serializable: 성공적으로 커밋된 결과가 “어떤 순서로 하나씩 실행한 것”과 같도록 보장합니다. 실제로 요청을 하나씩만 실행한다는 뜻이 아닙니다. 충돌을 감지하면 트랜잭션을 중단하므로 트랜잭션 전체를 다시 시도할 준비가 필요합니다.S4
| 격리 수준 | 더티 읽기 | 반복 불가능한 읽기 | 팬텀 읽기 | 직렬화 이상 |
|---|---|---|---|---|
| Read uncommitted | PostgreSQL에서는 없음 | 가능 | 가능 | 가능 |
| Read committed | 없음 | 가능 | 가능 | 가능 |
| Repeatable read | 없음 | 없음 | PostgreSQL에서는 없음 | 가능 |
| Serializable | 없음 | 없음 | 없음 | 없음 |
4.5 락과 버전 검사
| 방법 | 어떻게 막나 | 대가 |
|---|---|---|
| 비관적 락 | 충돌할 자원을 먼저 확보해 순서를 정한다(SELECT … FOR UPDATE) | 기다림, 교착 상태 가능성 |
| 낙관적 잠금 | 읽었을 때의 version을 조건으로 UPDATE하고, 바뀌었으면 충돌로 처리한다 | 충돌이 잦으면 재시도가 오히려 일을 늘린다 |
| 조건부 갱신 | 규칙을 UPDATE의 WHERE에 넣는다 | 한 행에 담긴 규칙에만 쓸 수 있다 |
교착 상태
두 트랜잭션이 서로 다른 순서로 A와 B를 잠그면, 각자 상대가 쥔 락을 기다리며 영원히 멈출 수 있습니다. 이것이 데드락입니다. PostgreSQL은 이를 감지하면 한쪽을 중단합니다. 실제로 재현해 봤습니다.보강
예방법은 두 가지입니다. 자원을 잠그는 순서를 통일하고(예: 항상 id가 작은 계좌부터), 트랜잭션을 짧게 유지합니다. 교착 상태로 중단된 트랜잭션을 다시 시도할 때도 재시도 횟수, 대기 시간, 사용자에게 보일 결과를 정해 둡니다.
로컬 락과 분산 락의 한계
한 프로세스 안의 synchronized나 로컬 락은 다른 서버 인스턴스로 들어온 요청을 막지 못합니다. 반대로 분산 락을 도입했다고 DB 제약이 필요 없어지지도 않습니다. 락의 임대 시간이 끝났는데도 멈춰 있던 오래된 작업자가 깨어나 쓰기를 하면 문제가 됩니다. 이를 막으려면 받는 쪽이 순번(fencing token)이나 버전으로 늦은 쓰기를 거부해야 합니다.
4.6 실험 — 두 연결로 실행 순서를 고정한다
원래 교재의 labs/04_concurrency.md는 두 psql 터미널을 열어 순서대로 명령을 입력합니다. 이 교과서는 같은 절차를 Python 스크립트로 자동화해 PostgreSQL 16.13에서 실행했고, 위 기록들이 그 결과입니다.보강 스레드를 많이 만들어 “어쩌다 한 번” 충돌하기를 기다리는 대신 A 읽기 → B 읽기 → A 쓰기·커밋 → B 쓰기·커밋처럼 순서를 손으로 고정하면, 작은 재현이 실패 원리를 더 분명하게 보여 줍니다.
직접 해 보려면 psql 창 두 개를 열고 기록의 A 줄은 첫 번째 창에, B 줄은 두 번째 창에 차례로 입력하세요. 기다리는 줄에서는 커서가 멈춰 있다가, 상대 창에서 COMMIT을 입력하는 순간 결과가 나옵니다.
자동화 스크립트 보기 (04_concurrency.py 중 일부)
# pip install psycopg2-binary
# 두 연결 A, B를 열고 정한 순서대로 SQL을 보낸다. 기다려야 하는 문장은 스레드로 보낸다.
def conditional():
setup(INV) # 재고 1개인 inventory 테이블을 새로 만든다
a, b = conn(), conn()
run(a, "A", "BEGIN")
run(b, "B", "BEGIN")
run(a, "A", "UPDATE lab.inventory SET stock = stock - 1 WHERE id = 1 AND stock > 0 RETURNING stock")
th, _ = run_async(b, "B", "UPDATE lab.inventory SET stock = stock - 1 WHERE id = 1 AND stock > 0 RETURNING stock")
run(a, "A", "INSERT INTO lab.orders (buyer) VALUES ('A')")
run(a, "A", "COMMIT") # 이 순간 B의 UPDATE가 풀려 0행을 바꾼다
th.join()
run(b, "B", "COMMIT")
4.7 연습
연습사용자마다 활성 세션은 최대 2개여야 합니다. 현재 활성 세션이 1개인 사용자에게서 두 요청이 동시에 와서, 각각 “1개니까 괜찮다”고 판단하고 새 세션을 만듭니다. 각 INSERT를 트랜잭션으로 감싸면 충분할까요?
해설 보기
아닙니다. 검사(개수 세기)와 새 행 삽입 사이에서 두 요청이 같은 판단을 할 수 있습니다. 결과는 3개입니다. 후보는 세 가지입니다.
- 사용자별 기준 행을 잠그고 센다.
SELECT … FROM user_quota WHERE user_id = ? FOR UPDATE로 사용자 단위 순서를 만든 뒤 개수를 확인합니다. - 조건부 카운터 갱신.
UPDATE user_quota SET active = active + 1 WHERE user_id = ? AND active < 2가 1행을 바꿨을 때만 세션을 만듭니다. 카운터와 세션을 같은 트랜잭션에서 바꿔야 둘이 어긋나지 않습니다. - Serializable과 전체 재시도. 충돌한 트랜잭션이 직렬화 실패(40001)로 중단되므로, 애플리케이션이 트랜잭션 전체를 다시 실행합니다.
아래는 뒤의 두 방법을 실제로 실행한 기록입니다.
스스로 점검
이 장의 완료 기준입니다.
- 지켜야 할 불변식을 한 문장으로 쓸 수 있다.
- 두 요청의 실행 순서를 적어 그 불변식이 깨지는 과정을 재현할 수 있다.
- 고른 해결책이 어디까지 보호하는지(보호 경계)와, 충돌했을 때 무엇을 하는지(대기·0행·재시도)를 설명할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
5장 느려지는 시스템에는 대기가 쌓인다
들어오는 일이 처리 능력을 넘으면 그 차이만큼 대기가 쌓입니다. 큐를 키우거나 타임아웃·재시도를 늘리는 처방이 왜 문제를 옮기기만 하는지, 숫자와 시뮬레이터로 확인합니다.
이 장의 핵심
- 유입이 처리보다 많으면 대기는 매초 (유입 − 처리)만큼 쌓입니다. 큐는 순간적인 차이를 흡수할 뿐 처리 능력을 늘리지 않습니다.
- Little의 법칙(L = λ × W)은 흐름이 안정된 상태의 평균에만 씁니다. 풀 크기를 정해 주는 공식이 아닙니다.
- 타임아웃은 기다림을 끊을 뿐 서버의 작업을 취소하지 않습니다. 계층마다 재시도하면 시도가 곱으로 불어납니다.
- 레이트 리밋은 시간당 진입량을, 동시성 제한은 진행 중인 작업 수를 제한합니다. 둘은 다른 도구입니다.
5.1 먼저 알아둘 말
- 유입률과 처리율
- 단위 시간에 새로 들어오는 일의 수와, 끝낸 일의 수입니다.
- backpressure
- 느린 단계가 앞 단계에 “천천히 보내라”는 신호를 보내 생산 속도를 제한하는 제어입니다.
5.2 처리량과 지연을 혼동하지 않는다
처리량은 단위 시간에 끝낸 일의 수이고, 지연은 일 하나가 끝나기까지 걸린 시간입니다. 요청이 들어오는 비율은 유입률입니다. 유입률이 늘어도 어느 지점부터는 완료 처리량이 더 늘지 않고 대기만 늘어납니다.
단순한 모델로 계산해 봅시다. 초당 100건이 들어오는데 초당 80건만 처리하면 대기는 초당 20건씩 늘어납니다. 30초 동안 이 상태가 이어지면 600건이 쌓입니다. 그 뒤 유입이 초당 40건으로 떨어져도, 처리 능력 80건에서 유입 40건을 빼면 순수하게 줄일 수 있는 양은 초당 40건이므로 600건을 비우는 데 15초가 더 걸립니다. 재시도, 버려지는 요청, 들쭉날쭉한 처리 시간은 뺀 계산입니다.S8
큐는 순간적인 차이를 흡수하고 생산자와 소비자의 속도를 분리해 줍니다. 하지만 지속적인 처리 능력 부족을 없애지는 않습니다. 무제한 큐는 “지금 거절”을 “나중의 메모리 부족”이나 “너무 오래 기다려 쓸모없어진 작업”으로 바꿀 뿐입니다. 큐에 넣는 순간 성공 응답을 보낸다면, 접수 성공과 업무 완료를 구분해서 말하고 측정하세요.
5.3 Little의 법칙을 조심해서 쓴다
흐름이 안정된 상태라면, 시스템 안에 머무는 평균 작업 수 L은 평균 처리율 λ와 평균 체류 시간 W의 곱입니다. 경계를 “DB 연결 점유”로 잡으면, 연결을 초당 200번 빌리고 한 번에 평균 0.1초씩 쥐고 있을 때 평균적으로 20개가 쓰이고 있습니다.
“그러니 풀을 20으로 설정하면 된다”가 아닙니다. 평균만으로는 변동, 긴 꼬리, 장애 여유, DB 전체 연결 한도를 알 수 없습니다. 평균 자리에 p99를 넣고 같은 법칙이라고 주장하지 않습니다. 대기가 계속 늘고 있는 불안정한 상태에서는 한 시점의 숫자를 넣어 용량을 정하지 않습니다.
5.4 타임아웃·재시도·입장 제어
타임아웃은 무한히 기다리는 것을 막아 줍니다. 하지만 원격 작업을 취소하거나 롤백해 주지는 않습니다. 클라이언트는 기다리기를 멈췄는데 서버의 작업은 계속될 수 있습니다. 거기에 재시도까지 더해지면 이미 느린 시스템에 일이 더 쌓입니다.
세 계층이 각각 최초 시도를 포함해 3번씩 호출하고 모든 호출이 같은 이유로 실패한다면, 가장 아래 작업은 최대 27번 실행됩니다. “재시도 3번”은 “시도 4번”이라서, 이를 혼동하면 64번이 됩니다.S9
재시도는 스택의 한 지점에서만 하고, 나머지 계층은 실패를 위로 올려보내는 편이 안전합니다.S9 S36 그리고 한 경로 안에서 다음을 함께 맞춥니다. 요청 전체의 마감 시간(deadline), 재시도할 오류의 종류, 최대 시도 횟수, backoff·jitter, 그리고 같은 요청을 다시 보내도 안전한가(7장 멱등성).
Spring Framework 7에서는보강 재시도와 동시성 제한이 코어 기능으로 들어왔습니다. @EnableResilientMethods로 켜고, @Retryable과 @ConcurrencyLimit을 메서드에 붙입니다. @Retryable은 기본값으로 모든 예외에 대해 재시도하고, maxRetries는 재시도 횟수이므로 전체 시도는 1 + maxRetries번입니다. 재시도할 예외를 좁히고, 부작용이 있는 호출은 멱등 키 없이 재시도하지 않습니다.
@Configuration
@EnableResilientMethods
class ResilienceConfig
@Service
class NotificationSender(private val client: PushClient) {
@Retryable(
includes = [ConnectException::class], // 연결 실패만 재시도한다
maxRetries = 2, // 최초 1번 + 재시도 2번 = 최대 3번 시도
delay = 100, multiplier = 2.0, maxDelay = 1000, jitter = 50
)
fun send(message: Push) = client.send(message)
@ConcurrencyLimit(20) // 동시에 20개까지만 진행한다
fun sendBulk(messages: List<Push>) = client.sendAll(messages)
}
입장 제어
입장 제어는 감당할 수 없는 일을 아예 시작하지 않는 것입니다.
| 도구 | 무엇을 제한하나 | 한계 |
|---|---|---|
| 레이트 리밋 | 시간당 들어오는 양 (예: 초당 200건) | 작업이 느려지면 같은 초당 200건이어도 진행 중인 작업이 늘어난다 |
| 동시성 제한 | 동시에 진행 중인 작업 수 (예: 20개) | 한도를 넘는 요청을 기다리게 할지 거절할지 정해야 한다 |
| bulkhead | 의존성별로 자원을 나눠, 하나가 전체를 점유하지 못하게 한다 | 나눈 칸이 너무 작으면 평소에도 거절이 생긴다 |
| circuit breaker | 연속으로 실패하는 의존성 호출을 잠시 빠르게 거절한다 | 실패한 의존성을 고쳐 주지는 않는다 |
Little의 법칙 계산기에서 처리율을 초당 200으로 두고 체류 시간을 100ms에서 500ms로 늘려 보세요. 레이트 리밋(초당 200)은 그대로인데 진행 중인 작업은 20개에서 100개가 됩니다. 두 도구가 다른 이유입니다.
5.5 네트워크 지연을 읽는 최소 원리
TCP는 바이트를 순서대로, 빠짐없이 전달합니다. 애플리케이션의 “요청 한 건”이라는 경계를 지켜 주는 것은 아닙니다. 잃어버린 바이트를 다시 보내는 손실 복구와 네트워크 혼잡을 조절하는 혼잡 제어 때문에, 같은 양의 바이트도 왕복 지연과 손실에 따라 걸리는 시간이 달라집니다. 받는 쪽이 감당할 양을 조절하는 흐름 제어와, 네트워크 경로의 혼잡을 조절하는 혼잡 제어는 다른 것입니다.
HTTP/2는 연결 하나로 여러 스트림을 처리합니다. 하지만 그 아래 TCP에서 바이트 하나를 잃으면, 그 뒤의 바이트는 다른 스트림 것이라도 순서를 지키느라 함께 기다립니다. QUIC은 스트림별로 순서를 지켜 다른 스트림으로 번지는 대기를 줄입니다. 그래도 같은 스트림 안의 순서 대기와 경로 전체의 혼잡은 없애지 못합니다.S13
5.6 실험 — 큐를 키우면 해결되는가
위 시뮬레이터에서 다음 순서로 눌러 보세요.
- 본문 예제: 큐 무제한. 거절은 0건이라 좋아 보입니다. 하지만 대기가 600건까지 쌓이고, 마지막 요청은 7초 넘게 기다립니다.
- 큐 용량 100, 넘치면 거절. 거절 수만 보면 나빠 보이지만, 받아들인 요청은 오래 기다리지 않고 몰림이 끝나자마자 대기가 사라집니다. 처리한 양은 같습니다. 거절된 요청에는 “지금은 못 받는다”는 분명한 답이 갑니다.
- 거절되면 즉시 재시도. 입장 제어만 있고 클라이언트의 재시도 약속이 없으면 보호 효과가 사라집니다. 초당 시도 수가 고유 요청 수의 몇 배로 불어납니다.
- backoff + jitter. 재시도를 흩뜨리고 횟수를 제한하면 시도 폭증이 줄어듭니다. 대신 일부 요청은 끝내 실패로 끝나므로, 그 요청을 어떻게 알릴지 정해야 합니다.
측정할 때는 시도 수와 고유 업무 요청 수를 함께 기록하세요. 시도 수만 보면 재시도 폭증이 “트래픽 증가”로 보입니다.
5.7 연습
연습DB 커넥션 풀 20개가 모두 쓰이는 중이고, 연결을 기다리는 요청(pending)이 계속 늘고 있습니다. 풀을 100개로 늘릴까요?
해설 보기
먼저 연결을 왜 오래 쥐고 있는지 봅니다. DB가 이미 포화됐거나 락 경합이 크다면, 연결을 늘리면 DB에 동시에 들어가는 일이 늘어 경합만 커질 수 있습니다(9장 연습 5).
DB에 여유가 있고 풀이 유일한 제한이라는 증거가 있다면 증설을 실험해 볼 근거가 됩니다. 그때도 인스턴스 수 × 풀 크기에 관리용·배치용 연결까지 더한 전체 연결 예산이 DB 한도 안에 드는지 확인합니다. 인스턴스 10대가 풀 100개씩이면 DB 연결 1,000개입니다.
스스로 점검
이 장의 완료 기준입니다.
- 유입·완료·대기·거절의 관계를 숫자로 설명할 수 있다. (예: 100/초 유입, 80/초 처리 → 30초 뒤 600건)
- 풀 증설, 처리 시간 줄이기, 입장 제어 가운데 하나를 고를 때 어떤 증거가 필요한지 적을 수 있다.
- 재시도 횟수와 시도 횟수의 차이, 계층별 재시도가 곱으로 불어나는 이유를 설명할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
6장 RDB·샤딩·DynamoDB·Redis를 같은 표에서 비교하기
제품 이름부터 떠올리지 않고, 데이터를 먼저 나눈 뒤 같은 질문으로 후보를 비교합니다. 시작하기의 그 이력서 문장, “DynamoDB로 Write IOPS 75% 감소”를 검증하는 방법도 여기서 정리합니다.
이 장의 핵심
- RDB는 분류, PostgreSQL·DynamoDB·Redis는 제품, 샤딩은 방법, 캐시는 역할입니다. 같은 층위의 말끼리 비교해야 기준이 선명해집니다.
- 한 서비스 안에서도 데이터마다 보존·조회·정합성 요구가 다릅니다. 저장소보다 데이터를 먼저 나눕니다.
- 샤딩도 DynamoDB도 키 설계와 쏠림(hot key) 문제를 없애 주지 않습니다.
- 읽기 캐시는 쓰기를 줄이지 않습니다. Redis로 쓰기를 줄이려면 “잃어도 되는 데이터”라는 전제가 필요합니다.
6.1 제품·저장 모델·분산 방법을 나누기
| 말 | 무엇인가 | 같이 비교하면 안 되는 이유 |
|---|---|---|
| RDB | 테이블과 관계 중심의 저장 모델(분류) | PostgreSQL은 그 분류의 제품 하나다 |
| PostgreSQL | 관계형 DB 제품 | 제품의 기능과 운영 방식이 따로 있다 |
| DynamoDB | AWS가 운영하는 키 중심 DB 서비스(제품) | “NoSQL” 전체를 대표하지 않는다 |
| Redis | 메모리 중심 데이터 저장소(제품) | 캐시로도, 저장소로도 쓸 수 있다 |
| 샤딩 | 데이터를 여러 독립 저장 단위로 나누는 방법 | 어느 제품에서든 쓸 수 있는 방법이다 |
| 캐시 | 결과를 재사용하는 역할 | 제품이 아니라 역할이다 |
파티션 키는 데이터를 어디에 배치하고 어떻게 찾을지 정하는 키, hot key는 요청이 한쪽으로 몰리는 키입니다. 이 말들을 층위 없이 섞어 외우면 “쓰기가 많으니 NoSQL” 같은 결론이 나옵니다.
6.2 데이터부터 나눈다
촬영 서비스에 네 가지 데이터가 있다고 가정합시다. 넷이 같은 보존 기간, 같은 조회 방식, 같은 정합성 요구를 가질 이유가 없습니다.
| 데이터 | 성격 | 먼저 떠오르는 요구 |
|---|---|---|
| 참여 자격 | 사용자·챌린지의 관계 | 중복 방지, 정확성(3장 UNIQUE) |
| 촬영 최신 상태 | 세션별 키, 자주 바뀜 | 빠른 갱신·조회, 중간 값은 버려도 될 수 있음 |
| 감사 이벤트 | 과거 변경의 기록 | 빠짐없는 보존, 추가만 함 |
| 영상 원본 | 큰 바이너리 | 객체 저장소 + 메타데이터 DB 조합이 후보 |
실제 이력서의 “영상 데이터”가 무엇을 뜻하는지는 모릅니다. 그러니 위 분류를 그 시스템의 사실로 단정할 수 없습니다. 먼저 데이터 샘플과 읽기·쓰기 경로를 확인해야 합니다. 큰 객체를 객체 저장소에 두기로 하더라도 접근 통제, 업로드 완료 확인, 연결이 끊긴 고아 객체 정리까지 함께 설계해야 합니다.
6.3 선택 전에 채울 여덟 칸
제품을 고르기 전에 이 여덟 질문에 답합니다. 가운데 열은 촬영 상태에 대한 학습용 예시입니다. 오른쪽 칸에 내 서비스의 답을 적어 보세요(이 브라우저에만 저장됩니다).
| 질문 | 촬영 상태 예시 | 선택에 주는 영향 | 내 서비스 |
|---|---|---|---|
| 무엇을 읽는가 | user/session 키로 최신 상태 조회 | 키 중심 저장이 후보 | |
| 무엇을 쓰는가 | 매초 마지막 상태 갱신 | 갱신 합치기가 가능한가 | |
| 무엇을 잃으면 안 되는가 | 확정 종료 이벤트는 손실 불가 | 상태와 감사 기록을 분리 | |
| 얼마나 늦어도 되는가 | 상태 화면은 5초 지연 허용 | 버퍼·비동기 처리를 검토 | |
| 어떤 원자성이 필요한가 | 참여 승인과 결제는 별도 규칙 | 경계별로 트랜잭션을 검토 | |
| 어디에 몰리는가 | 일부 인기 세션에 쏠릴 수 있음 | hot key 검증 | |
| 얼마나 보존하는가 | 최근 상태와 감사 기간이 다름 | TTL·아카이브 정책 | |
| 누가 운영하는가 | 작은 팀, 야간 대응 인력 제한 | 관리형 비용과 운영 부담 비교 |
“모든 데이터 유실 불가” 같은 문장도 어떤 장애, 어떤 관측 경계에서 지킬 것인지 구체적으로 적어야 선택에 쓸 수 있습니다.
6.4 기존 RDB에서 먼저 검토할 것
2~5장에서 배운 질문을 그대로 적용합니다.
- 필요 없는 갱신이나 인덱스가 있는가? (2장 실험 B)
- 긴 트랜잭션이나 락 경합인가? (4장)
- 저장 장치 자체의 한계인가? (2장 IOPS와 처리량)
- 중간 상태를 합쳐도 되는가? (2장 실험 A)
- 데이터 정리·보존 정책이 없는가?
- 쓰기와 읽기 부하가 섞여 서로 방해하는가? (3장, 5장)
테이블 파티셔닝은 한 테이블을 논리적으로 나눠 조회 범위를 줄이거나 오래된 데이터를 통째로 정리하기 쉽게 해 줍니다. 하지만 같은 서버·스토리지에 있다면 이것만으로 쓰기를 감당할 하드웨어가 늘지는 않습니다. 샤딩은 데이터와 부하를 여러 독립 저장 단위에 나누지만, 어느 샤드인지 찾는 일, 샤드를 가로지르는 조회와 트랜잭션, 재배치, 백업, 장애 대응이 새로 생깁니다.
그래서 샤딩을 떠올린 것은 쓸모 있는 가설입니다. 다음 질문은 “지금 병목을 정말 나눌 수 있는가, 나누면서 새로 생기는 일을 감당할 수 있는가”입니다.
6.5 DynamoDB를 후보로 만드는 이유와 탈락 조건
DynamoDB는 키 중심 접근을 제공하는 관리형 저장소입니다. 중요한 질의와 데이터의 크기·형태·변화 속도를 먼저 이해한 뒤 partition key와, 필요하면 sort key를 설계합니다. “나중에 아무 조건으로나 검색하고 조인하면 된다”보다 “중요한 접근 패턴을 미리 모델링한다”는 접근이 맞습니다.S5
예시 후보는 PK = USER#사용자ID, SK = SESSION#세션ID입니다(PK, SK). 이것이 정답이라는 뜻은 아닙니다. 사용자 한 명에게 쓰기가 몰리거나, 세션 항목 하나를 매우 자주 갱신하면 문제가 남습니다. 날짜만 partition key로 잡아 오늘의 쓰기를 모두 한 곳에 모으는 설계도 위험합니다. 키 종류가 많다는 사실보다 실제 요청 분포가 중요합니다.S6
숫자로 보면 분명해집니다. DynamoDB의 파티션 하나는 초당 최대 1,000 쓰기 단위를 받도록 설계됐고, 1 KB 이하 항목을 한 번 쓰면 1단위입니다.S6보강 항목 하나는 한 파티션에 있으므로, 인기 세션 항목 하나를 초당 2,000번 갱신하면 설계 한도를 넘습니다.
애플리케이션에서 키 뒤에 접미사를 붙여 쓰기를 나누는 write sharding을 검토할 수 있습니다. 대신 읽을 때 여러 조각을 모으는 비용이 생기고, 같은 항목의 순서·원자성 요구도 다시 따져야 합니다. DynamoDB를 도입하면 키 설계나 분산에 대한 고민이 필요 없어진다는 기대는 틀립니다.
후보를 평가할 때는 조건부 쓰기와 트랜잭션의 지원 범위, 읽기 일관성, 인덱스 갱신 비용, 항목 크기, 조회가 몇 곳으로 퍼지는지(fan-out), 데이터 이동과 복구를 함께 봅니다. “NoSQL은 트랜잭션이 없다” 같은 이분법은 쓰지 않습니다.S5 S17
6.6 접근 패턴을 키 설계로 바꾸는 작은 예
다음은 설계 초안이며 실제 이력서의 데이터 모델이 아닙니다. 사용자 42의 세션 s123을 항목 하나로 나타냅니다.
PK: USER#42
SK: SESSION#s123
status: recording
version: 7
updated_at: 2026-09-20T00:00:00Z
- 사용자와 세션을 모두 알면 전체 기본 키로 항목 하나를 바로 찾습니다.
- 사용자별 세션을 나열하려면 같은 PK 아래 SK 범위를 조회합니다. 하지만 이 SK는 시작 시각 순서가 아니므로 “최근 20개 세션” 정렬까지 해결하지는 않습니다. SK 모양을 바꾸거나 별도 접근 구조를 만들 때마다 갱신·저장·조회 비용을 다시 계산합니다.
- 세션 ID만 아는 요청이 잦다면 이 설계는 불편합니다. 세션을 기본 키로 삼는 대안, 사용자·시각 기준 보조 인덱스를 두는 대안을 비교합니다. 보조 인덱스는 새 접근 경로를 주는 대신 쓰기·저장 비용과 읽기 일관성 조건을 더합니다. 특히 GSI는 강한 일관성 읽기를 제공하지 않습니다.S17
늦게 도착한 상태가 최신 상태를 덮어쓰지 않게 하려면 version의 의미부터 정합니다. version이 세션별 단일 순번이라면 “저장된 버전보다 클 때만 갱신”하는 조건부 쓰기가 후보입니다. 여러 생산자가 각자 찍은 시각을 순번처럼 쓰면 시계 오차와 충돌 때문에 같은 보장이 되지 않습니다. 모든 이벤트를 보존해야 한다면 최신 항목 덮어쓰기만으로는 부족합니다.
Kotlin + AWS SDK v2로 쓰면보강 조건식이 거짓이면 쓰기가 거부되고 ConditionalCheckFailedException이 납니다. status는 DynamoDB 예약어라 이름 치환(#st)이 필요합니다.
fun saveIfNewer(userId: Long, sessionId: String, status: String, version: Long) {
val req = UpdateItemRequest.builder()
.tableName("capture_state")
.key(mapOf("PK" to AttributeValue.fromS("USER#$userId"), "SK" to AttributeValue.fromS("SESSION#$sessionId")))
.updateExpression("SET #st = :st, version = :v")
.conditionExpression("attribute_not_exists(version) OR version < :v")
.expressionAttributeNames(mapOf("#st" to "status"))
.expressionAttributeValues(mapOf(":st" to AttributeValue.fromS(status), ":v" to AttributeValue.fromN(version.toString())))
.build()
try {
dynamo.updateItem(req)
} catch (e: ConditionalCheckFailedException) {
// 이미 더 새로운 version이 저장돼 있다: 늦게 도착한 옛 상태이므로 버린다
}
}
이 예에서 배울 것은 키 문자열이 아닙니다. “어떤 요청이 어떤 키로 어느 항목을 읽고, 늦은 쓰기는 어떤 조건으로 거부되는가”를 말할 수 있어야 합니다. 3장의 복합 인덱스와 같은 접근 패턴 사고가 다른 저장소에서도 그대로 이어집니다.
6.7 Redis가 줄이는 것은 무엇인가
읽기 캐시는 원본에서 얻은 값을 재사용해 원본 읽기를 줄입니다. 쓰기가 많다는 문제에 읽기 캐시를 더해도 원본 쓰기 횟수는 줄지 않습니다. write-through로 매번 원본에도 반영한다면 그 쓰기는 그대로 남습니다.
갱신을 모아 나중에 원본에 쓰는 write-behind, 다시 만들 수 있는 임시 상태, 수명이 짧은 별도 데이터라면 Redis가 다른 역할을 할 수 있습니다. 대신 프로세스 종료, 노드 장애, 복제 지연, 만료, 메모리 부족 때 무엇이 사라지는지 정의해야 합니다. 영속화 옵션을 켰다는 사실만으로 기존 DB와 같은 장애 보장을 가진다고 판단하지 않습니다.S7
cache-aside는 캐시에 없으면 원본에서 읽어 캐시를 채우는 패턴입니다. DB를 갱신한 뒤 캐시를 지워도, 동시에 진행 중이던 오래된 읽기가 나중에 옛 값을 다시 넣을 수 있습니다. 아래는 그 순서를 따라가 본 것입니다.
TTL은 오래된 값이 머무는 시간을 제한하는 도구일 수 있지만, 특정 업무의 즉각적인 정합성을 자동으로 보장하지는 않습니다. 권한 회수나 결제 상태처럼 오래된 값의 피해가 큰 데이터에는 별도 요구가 필요합니다(8장).
6.8 B-tree와 LSM을 선택 사고에 연결한다
| B-tree | LSM 계열 | |
|---|---|---|
| 쓰기 | 정렬된 구조의 제자리를 찾아 고친다 | 메모리에 모았다가 정렬된 파일로 내린다 |
| 나중에 하는 일 | 페이지 분할, 정리(VACUUM 등) | 파일을 합치는 compaction |
| 대가 | 임의 위치 쓰기, 쓰기마다 인덱스 갱신 | 읽을 때 여러 파일을 봄(읽기 증폭), 공간·쓰기 증폭 |
“LSM은 쓰기에 빠르다”만으로 특정 서비스나 제품의 비용 우위를 단정할 수 없습니다. 이 내부 구조는 후보를 이해하는 원리입니다. 관리형 제품이 공개하지 않은 내부 구현을 추측해 결론을 내리는 데 쓰지 않고, 내 업무의 읽기·쓰기·지연·복구 조건을 실제 제공 기능과 연결하는 데 씁니다.
6.9 75%라는 성과를 검증하는 법
기존 RDB의 Write IOPS가 4,000에서 1,000이 됐다면 그 지표는 75% 줄었습니다. 하지만 다른 저장소로 옮긴 작업, 복제와 인덱스, 로그·모니터링, 네트워크, 운영 인건비를 포함한 전체 비용은 따로 봐야 합니다. 서비스마다 지표의 단위가 다르므로 RDB IOPS와 DynamoDB 쓰기 단위를 단순히 더하거나 비교해서도 안 됩니다.
비교표에는 같은 업무 완료 수, 데이터 크기와 분포, 성공 판정, 지연 분포, 오류율, 유실 허용 범위, 보존 기간을 먼저 고정합니다. RDB 지표 개선, 전체 비용 개선, 사용자 경험 개선은 각각 따로 근거를 남깁니다. 기간과 트래픽이 다른 그래프 두 장으로 인과관계를 확정하지 않습니다.
6.10 선택형 AWS 실험
이 책의 기본 실습은 유료 AWS 자원을 만들지 않습니다. AWS를 쓰기로 했다면 먼저 전용 실습 계정·리전과 태그, 예상 요청량·항목 크기·보존량, 실행 종료 시각과 정리 목록을 적습니다. 예산 알림은 지출을 즉시 막아 주는 장치가 아니라는 점도 기억합니다.
- 키 기반 조회와 조건부 쓰기를 확인합니다.
- 같은 고유 요청 수로 균등 분포와 쏠린 분포를 비교합니다.
- 재시도 시도 수, 성공 처리량, 지연, 스로틀링과 실제 청구 항목을 기록합니다. 차이가 관측되지 않았다면 서비스가 그 실험 부하를 감당한 것이지, hot key 문제가 원리상 사라진 것은 아닙니다.
DynamoDB Local의 한계. 키와 API 동작을 배우기에는 좋지만, 로컬에는 실제 서비스의 파티셔닝이 없고 provisioned throughput 설정도 무시됩니다. 로컬 성능으로 클라우드의 확장성이나 비용 우열을 주장하지 않습니다. TTL로 만료된 항목은 실제 서비스에서도 즉시 지워지지 않으므로, 만료 뒤 접근을 막아야 한다면 읽기 경로에서 직접 확인합니다.S10 S11
6.11 연습
연습촬영 최신 상태는 5초 지연을 허용합니다. 하지만 확정 종료 이벤트는 재전송되어도 한 번만 반영돼야 합니다. 두 데이터를 같은 갱신 합치기 정책에 넣을까요?
해설 보기
상태와 사건의 요구를 나눕니다. 최신 상태는 중간 값을 생략하는 합치기를 검토할 수 있습니다(2장 실험 A). 확정 종료 이벤트는 내구성 있는 저장, 멱등 반영(7장), 누락을 찾아내는 경로가 필요합니다.
RDB만으로 감당할 수 있는 규모라면 그 설계부터 검증하고, 나눴을 때의 이득이 측정되면 다른 저장소를 비교합니다.
스스로 점검
이 장의 완료 기준입니다.
- 어떤 제품 이름도 쓰지 않고 내 데이터의 요구를 여덟 칸으로 설명할 수 있다.
- 대안을 최소 세 개 들고, 각각의 보장·비용·운영 부담·탈락 조건을 적을 수 있다.
- “IOPS 75% 감소”라는 문장에서 무엇을 주장할 수 있고 무엇을 더 재야 하는지 구분할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
7장 부분 실패·중복·순서를 다루는 방법
네트워크 너머의 호출은 성공과 실패 말고 ‘결과를 모름’으로 끝날 수 있습니다. 메시지는 두 번 올 수 있고, 서로 다른 두 저장소를 한 번에 바꿀 수도 없습니다. 이것을 전제로, 실패 지점이 어디든 한 번만 반영되는 설계를 만듭니다.
이 장의 핵심
- 타임아웃은 실패가 아니라 ‘결과를 모름’입니다. 이 상태를 상태 모델과 화면에 넣어야 위험한 재시도를 막을 수 있습니다.
- 전달과 반영은 다른 경계입니다. 같은 메시지가 다시 올 수 있으니, 한 번만 반영되게 하는 일은 받는 쪽이 맡습니다.
- 같은 DB라면 중복 기록과 업무 변경을 한 트랜잭션으로 커밋하고, 그다음에 ACK합니다. 두 변경의 순서만 바꿔서는 해결되지 않습니다.
- Outbox는 이벤트 유실을 막는 저장 경계를 만들 뿐 중복을 없애지 않습니다. 이메일·결제 같은 외부 부작용은 DB 트랜잭션 밖에 있습니다.
7.1 먼저 알아둘 말
- 이벤트와 메시지
- 이벤트는 어떤 일이 일어났다는 기록입니다. 메시지는 다른 처리 주체에게 전달하는 데이터입니다.
- 브로커·producer·consumer
- 브로커는 메시지를 받아 저장하고 중계하는 시스템입니다. producer는 보내는 쪽, consumer(소비자)는 받는 쪽입니다.
- offset
- 기록 안에서 어디까지 진행했는지 나타내는 위치 번호입니다.
- ACK
- 정해진 단계를 확인했다는 응답입니다. 소비자의 ACK나 offset 진행은 “여기까지 처리했으니 다시 보내지 않아도 된다”는 신호입니다.
- 멱등성
- 같은 논리 작업을 여러 번 해도 결과가 한 번 한 것과 같게 만드는 성질입니다.
- 대사
- 서로 다른 기록을 비교해 누락과 불일치를 찾아 맞추는 절차입니다. 예를 들어 우리 DB의 결제 상태와 결제사의 거래 내역을 대조합니다.
7.2 결과를 모르는 상태를 모델에 넣는다
같은 프로세스 안의 함수 호출은 예외가 나도 무슨 일이 있었는지 대개 압니다. 다른 프로세스와 통신하면 사정이 달라집니다. 응답이 오지 않았을 때, 요청이 가는 길에 사라졌는지, 상대는 처리를 끝냈는데 응답만 사라졌는지, 아직 처리 중인지 보내는 쪽은 구분할 수 없습니다. 그래서 네트워크 타임아웃을 ‘확정 실패’로만 표현하면 위험한 재시도 정책이 만들어집니다.
상태 모델에는 업무에 맞게 접수 전·처리 중·성공·확정 실패·결과 확인 중 같은 상태를 둡니다. 결과 확인 중인 결제를 무조건 다시 만들지 않고, 결제사의 요청 키로 조회하거나 대사해서 확정하는 흐름이 필요할 수 있습니다. 사용자 화면과 운영 도구도 이 상태를 보여 줘야 합니다. 화면이 “결제 실패” 대신 “결제 확인 중”이라고 말해야 사용자가 서둘러 다시 결제하지 않습니다.
7.3 전달과 반영은 다른 경계다
at-least-once 전달은 “적어도 한 번”이므로 같은 메시지가 다시 올 가능성을 포함합니다. 소비자가 처리를 끝내고 ACK를 보내기 전에 죽으면, 브로커는 처리됐는지 모르므로 같은 메시지를 다시 보냅니다.
exactly-once라는 말을 들으면 두 가지를 확인합니다. 입력·상태·출력 중 어느 경계에서 중복이 보이지 않는다는 뜻인가? 어떤 실패를 가정했는가? 브로커가 주는 보장이 외부 DB 변경이나 이메일·결제 API의 부작용까지 자동으로 넓어지지는 않습니다. 예를 들어 Kafka의 트랜잭션을 쓰는 exactly-once 처리는 Kafka에서 읽고 처리해 Kafka에 쓰는 경로 안의 보장입니다.보강
멱등성은 같은 논리 작업을 반복해도 관찰할 결과가 한 번 한 것과 같도록 만드는 성질입니다. 키만 저장해 두면 충분하지 않습니다. 다음 네 가지를 정해야 합니다.
| 정할 것 | 예 |
|---|---|
| 누구의 어떤 작업의 키인가 | 사용자 42의 결제 요청 키. 다른 사용자가 같은 문자열을 보내면 다른 작업이다 |
| 같은 키에 다른 내용이 오면 | 첫 요청과 금액이 다르면 거절한다(충돌) |
| 처리 중에 같은 키가 또 오면 | “처리 중”이라고 답하고, 잠시 뒤 다시 확인하게 한다 |
| 기록을 언제까지 보관하나 | 24시간 뒤 만료. 만료 뒤 같은 키는 새 요청으로 본다는 것도 계약에 적는다 |
Kotlin + Spring의 JdbcClient로 쓰면보강 네 가지 결정이 코드의 어디에 들어가는지 보세요. IETF httpapi 작업 그룹의 Idempotency-Key 헤더 초안은 처리 중인 키에 409(Conflict, 요청이 서버의 현재 상태와 충돌), 다른 내용으로 재사용한 키에 422(Unprocessable Content, 형식은 맞지만 처리할 수 없는 내용)로 답하라고 권합니다. 외부 결제 호출은 DB 트랜잭션 밖에서 하고, 결제사에도 같은 키를 넘깁니다.
// CREATE TABLE idempotency_keys (
// user_id BIGINT NOT NULL, idem_key TEXT NOT NULL, -- 키의 범위: 사용자별
// request_hash TEXT NOT NULL, -- 같은 키에 다른 내용이 왔는지 비교
// status TEXT NOT NULL, -- IN_PROGRESS 또는 DONE
// response_body TEXT,
// expires_at TIMESTAMPTZ NOT NULL, -- 정리 작업이 이 시각 뒤에 지운다
// PRIMARY KEY (user_id, idem_key));
@Service
class PaymentService(private val jdbc: JdbcClient, private val gateway: PaymentGateway) {
fun pay(userId: Long, idemKey: String, req: PayRequest): PayResult {
val hash = req.fingerprint()
val claimed = jdbc.sql("""
INSERT INTO idempotency_keys (user_id, idem_key, request_hash, status, expires_at)
VALUES (:u, :k, :h, 'IN_PROGRESS', now() + interval '24 hours')
ON CONFLICT (user_id, idem_key) DO NOTHING
""")
.param("u", userId).param("k", idemKey).param("h", hash)
.update() // 트랜잭션 밖이라 바로 커밋된다
if (claimed == 0) { // 이미 본 키
val prev = jdbc.sql("SELECT request_hash, status, response_body FROM idempotency_keys WHERE user_id = :u AND idem_key = :k")
.param("u", userId).param("k", idemKey)
.query { rs, _ -> Triple(rs.getString(1), rs.getString(2), rs.getString(3)) }
.single()
return when {
prev.first != hash -> throw KeyReusedException() // 422: 같은 키, 다른 내용
prev.second == "IN_PROGRESS" -> throw InProgressException() // 409: 첫 요청이 아직 처리 중
else -> PayResult.fromJson(prev.third!!) // 첫 결과를 그대로 돌려준다
}
}
val result = gateway.charge(req, idempotencyKey = "$userId:$idemKey")
// 여기서 죽으면 IN_PROGRESS로 남는다. 결제사에 같은 키로 조회해 마무리하는 복구 작업이 필요하다
jdbc.sql("UPDATE idempotency_keys SET status = 'DONE', response_body = :b WHERE user_id = :u AND idem_key = :k")
.param("b", result.toJson()).param("u", userId).param("k", idemKey)
.update()
return result
}
}
7.4 같은 DB에서 소비자를 원자적으로 만들기
중복 기록 테이블과 업무 데이터가 트랜잭션을 지원하는 같은 DB에 있다고 합시다. 다음 절차가 기본입니다.
- 트랜잭션을 시작합니다.
- 소비자 식별자와 event_id를 UNIQUE로 등록합니다. 이미 있으면 예전에 끝낸 것으로 처리합니다.
- 업무 데이터를 바꿉니다.
- 중복 기록과 업무 변경을 함께 커밋합니다.
- 커밋이 끝난 뒤에 메시지를 ACK하거나 offset을 진행합니다.
BEGIN;
INSERT INTO processed_events (consumer, event_id)
VALUES ('balance-updater', 'evt-1')
ON CONFLICT DO NOTHING; -- 바뀐 행이 0이면 이미 반영한 이벤트: 업무 변경 없이 끝낸다
UPDATE account SET balance = balance + 100 WHERE id = 1; -- 위에서 1행이 들어갔을 때만 실행
COMMIT; -- 이 뒤에 ACK(또는 offset 진행)
어디서 죽든 재전달될 때 할 일이 정해집니다.
| 실패 위치 | DB 상태 | 재전달 때 해야 할 일 |
|---|---|---|
| 커밋 전 | 둘 다 롤백 | 처음처럼 다시 처리 |
| 커밋 후, ACK 전 | 중복 기록과 업무 변경이 둘 다 있음 | 업무 변경 없이 ACK만 진행 |
| ACK 후 | 완료 | 늦게 온 중복도 기록으로 막음 |
| 중복 기록만 따로 커밋한 뒤 | 잘못된 설계: 업무가 반영되지 않았을 수 있음 | 누락이 숨겨지므로 설계를 고쳐야 함 |
7.5 실험 — 장애 시점을 바꿔도 한 번만 반영되는가
원래 교재의 idempotency 실험(labs/principles.py)과 같은 방식으로 이 교과서에서 직접 실험했습니다. 임시 SQLite DB에서 event_id 기록과 잔액 증가를 한 트랜잭션으로 실행하고, 정한 지점에서 소비자 프로세스를 강제로 죽입니다(os._exit(1)). 그다음 소비자를 다시 실행해 잔액이 한 번만 늘었는지 확인합니다. 브로커는 별도 DB 파일로 흉내 냈습니다. 업무 DB와 한 트랜잭션으로 묶을 수 없다는 점이 실제 브로커와 같습니다.
app.execute('BEGIN IMMEDIATE')
try:
app.execute('INSERT INTO processed VALUES (?, ?)', (CONSUMER, eid))
except sqlite3.IntegrityError: # UNIQUE 위반: 이미 반영한 이벤트
app.execute('ROLLBACK')
ack(off) # 업무 변경 없이 ACK만 한다
continue
app.execute('UPDATE account SET balance = balance + ? WHERE id = 1', (amt,))
if crash == 'before_commit':
os._exit(1) # 정리 코드 없이 즉시 죽는다
app.execute('COMMIT') # 중복 기록과 잔액 변경을 함께 확정
if crash == 'after_commit':
os._exit(1)
ack(off) # 커밋이 끝난 뒤에만 ACK
이 실험이 보여 주는 범위. 같은 로컬 DB 트랜잭션의 실패 경계만 보여 줍니다. PostgreSQL의 동시성, Kafka의 offset 저장, 디스크 전원 장애, 외부 결제 부작용은 검증하지 않습니다. 프로그램을 죽이는 시험과 저장 장치 장애 시험도 구분합니다. 프로세스가 죽어도 운영체제가 살아 있으면, 커밋된 데이터는 이미 파일에 넘어가 있습니다.
7.6 병렬 처리와 offset
Kafka의 offset은 파티션 안의 진행 위치입니다. 소비자가 커밋하는 값은 “다음에 읽을 위치”, 즉 마지막으로 처리한 메시지의 offset + 1입니다.보강 그 값을 커밋하면 앞의 메시지는 모두 끝났다는 뜻이 됩니다. 그래서 메시지를 비동기로 병렬 처리할 때, 뒤 메시지만 끝났다고 앞의 미완료 메시지까지 건너뛰어 커밋하면 안 됩니다.
안전하게 커밋해도 죽으면 이미 끝낸 메시지가 다시 옵니다. 그래서 병렬 처리와 멱등성은 함께 다닙니다. event_id가 어느 범위에서 유일한지와 중복 기록을 얼마나 보관할지도 계약입니다. 중복 기록을 7일만 보관한다면 8일 뒤에 다시 온 같은 이벤트는 막지 못합니다. 같은 event_id로 다른 내용이 올 수 있는 시스템이라면 내용도 비교해 충돌을 찾아야 합니다.
7.7 Outbox가 해결하는 틈과 남기는 틈
업무 DB 커밋과 브로커 발행을 차례로 하면 그 사이에서 실패할 수 있습니다. outbox는 업무 변경과 “발행해야 할 이벤트”를 같은 DB 트랜잭션에 기록합니다. 별도의 relay가 아직 발행하지 않은 이벤트를 읽어 브로커에 보냅니다.
BEGIN;
UPDATE capture_session SET status = 'stopped' WHERE id = 's123';
INSERT INTO outbox (event_id, topic, payload)
VALUES ('evt-9', 'session.stopped', '{"session_id": "s123"}');
COMMIT;
-- relay: published_at IS NULL인 행을 순서대로 읽어 발행한 뒤 published_at을 채운다
relay가 발행한 뒤 완료 표시 전에 죽으면 같은 이벤트를 다시 발행할 수 있습니다. 따라서 Outbox는 중복 소비를 없애는 장치가 아닙니다. 이벤트 유실을 막는 저장 경계를 만들고, 나머지는 소비자 멱등성, 재시도, 막힌 이벤트 감지, 보존 정책으로 연결합니다.
외부 부작용이 있으면 같은 DB의 원자성이 그 부작용까지 덮지 못합니다. 외부 시스템의 멱등 요청 키, 상태 조회, 보상, 대사와 수동 복구를 누가 맡을지 설계합니다. saga의 보상도 모든 과거를 완벽하게 되돌리는 DB 롤백과 같지 않습니다. 이미 발송된 이메일처럼 취소할 수 없는 결과가 있습니다.
7.8 일관성을 질문으로 바꾼다
“eventual consistency면 된다” 대신 누가, 어떤 값을, 얼마나 오래된 상태로 봐도 되는지 적습니다. 같은 서비스 안에서도 데이터마다 답이 다릅니다.
| 데이터 | 누가 보나 | 허용할 수 있는 지연(예) | 어기면 |
|---|---|---|---|
| 로그인 직후 권한 | 권한이 방금 바뀐 사용자 | 회수는 거의 즉시 | 회수된 권한으로 계속 접근한다(8장) |
| 방금 수정한 내 데이터 | 수정한 본인 | 본인에게는 바로 보여야 함(read-your-writes) | 저장이 안 된 줄 알고 다시 수정한다 |
| 분석용 집계 | 운영자·기획자 | 몇 분에서 몇 시간 | 대개 큰 피해가 없다 |
CAP 정리(Consistency, Availability, Partition tolerance)는 네트워크 분할 모델에서 일관성과 가용성의 한계를 다룹니다. 여기서 일관성은 선형화 가능성을 뜻합니다. 연산이 호출과 응답 사이의 어느 한 시점에 일어난 것처럼 실시간 순서를 지키는 성질입니다. 가용성은 장애 나지 않은 노드에 도착한 요청이 규정된 연산 결과로 완료된다는 조건이며, 오류만 돌려줘도 된다는 뜻이 아닙니다. 네트워크가 분할되면 이 두 조건을 항상 함께 보장할 수 없다는 정리를 “셋 중 아무거나 둘 고르기”로 외우지 않습니다.S14
7.9 연습
연습DB 잔액을 먼저 올려 커밋하고, event_id는 별도 트랜잭션으로 저장합니다. 첫 단계 뒤 프로세스가 죽으면 어떻게 될까요? 두 단계의 순서만 바꾸면 해결될까요?
해설 보기
잔액을 먼저 커밋하면 재전달 때 잔액이 두 번 늘 수 있습니다. 7.5 실험의 ‘잔액 먼저 따로 커밋’에서 잔액이 200이 됐습니다. event_id를 먼저 커밋하면 업무가 반영되지 않았는데도 완료로 오인할 수 있습니다. 같은 실험의 ‘중복 기록만 먼저 따로 커밋’에서 잔액은 0인데 기록은 완료라고 말했습니다.
같은 DB라면 두 변경을 같은 트랜잭션으로 묶는 것이 핵심이며, 순서를 바꾸는 것만으로는 부족합니다.
스스로 점검
이 장의 완료 기준입니다.
- 실패 시점 네 곳을 표로 쓰고, 각각에서 중복·누락·재시도·대사를 설명할 수 있다.
- 보장 밖에 있는 외부 부작용(이메일, 결제 API 등)을 따로 적을 수 있다.
- 타임아웃 뒤의 ‘결과 확인 중’ 상태를 상태 모델과 화면에 넣을 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
8장 경계 밖의 입력과 안쪽의 권한
유효한 토큰은 ‘누구인지’만 알려 줍니다. 그 사람이 이 데이터에 이 작업을 해도 되는지는 따로 확인해야 합니다. 경계 밖에서 들어온 입력, 토큰·캐시·비밀정보의 수명, 배포 중에도 지켜야 할 API 계약을 차례로 다룹니다.
이 장의 핵심
- 인증(누구인가)과 인가(이 작업을 해도 되는가)는 다른 검사입니다. 유효한 JWT가 남의 촬영 세션을 수정할 권한을 주지는 않습니다.
- 본문·헤더·쿼리·외부 이벤트는 모두 검증 대상입니다. 값 바인딩은 값만 안전하게 만들고, 열 이름이나 정렬 방향은 허용 목록으로 다룹니다.
- 토큰·권한 캐시·비밀정보에는 수명이 있습니다. 발급할 때 유효했던 것이 지금도 유효하다는 보장은 없습니다.
- API 계약에는 필드뿐 아니라 의미·오류·멱등 키·재시도 가능 여부가 들어갑니다. 필드를 추가하는 변경도 클라이언트를 깨뜨릴 수 있습니다.
8.1 먼저 알아둘 말
- 토큰
- 주체나 권한 등에 관한 정보를 전달하는 값입니다. JWT(JSON Web Token)는 주장 정보를 전달하는 표준 토큰 형식입니다.
- 서명과 암호화
- 서명은 누가 만들었는지와 내용이 바뀌지 않았는지 확인하는 데 씁니다. 내용을 숨기는 암호화와는 다릅니다. 서명된 JWT의 내용은 누구나 읽을 수 있습니다.
8.2 인증과 인가를 나눈다
인증은 요청자가 누구인지 확인합니다. 인가는 그 요청자가 해당 데이터와 작업에 접근해도 되는지 정합니다. 유효한 JWT를 제시했다는 이유만으로 다른 사용자의 촬영 세션을 수정할 권한이 생기지는 않습니다.
촬영 세션 수정 API가 session_id만 받아 UPDATE한다면 소유권 검사가 빠진 것입니다. 업무에 맞게 user_id나 tenant_id와 대상 세션의 관계를 검증해야 합니다. 식별자가 추측하기 어렵다(예: UUID)는 사실을 권한 검사 대신 쓰지 않습니다. 식별자는 URL, 로그, 공유 링크로 얼마든지 새어 나갑니다.
-- 위험: 세션 ID만으로 바꾼다. 로그인만 했으면 누구의 세션이든 바뀐다
UPDATE capture_session SET status = :status WHERE id = :sessionId;
-- 요청자와 세션의 관계를 함께 검사한다
UPDATE capture_session SET status = :status
WHERE id = :sessionId AND user_id = :currentUserId;
-- 0행이면 없는 세션과 남의 세션을 구분하지 않고 똑같이 답한다
0행일 때 남의 세션에 403(Forbidden, 권한 없음)으로 답하면 그 ID의 세션이 존재한다는 사실이 드러납니다. 존재 자체를 숨겨야 한다면 없는 세션과 똑같이 404(Not Found, 대상 없음)로 답하는 방법도 있습니다. 어느 쪽이든 팀이 정해 계약에 적습니다.보강
8.3 무엇을 신뢰하고 있는지 표시한다
신뢰 경계는 믿을 수 있는 영역과 믿을 수 없는 영역이 만나는 곳입니다. 데이터가 경계를 넘을 때마다 “여기서 무엇을 확인하나”를 묻습니다.S15
요청 본문·헤더·쿼리·외부 이벤트는 모두 검증 대상입니다. 타입, 길이, 허용 범위, 중첩 깊이, 요청 크기와 처리량을 제한합니다. SQL은 값 바인딩을 쓰고, 동적으로 골라야 하는 열이나 정렬 방향은 허용 목록으로 다룹니다. 값 바인딩이 임의의 SQL 조각까지 안전하게 만들어 주지는 않기 때문입니다.
enum class SessionSort(val sql: String) {
NEWEST("started_at DESC, id DESC"),
OLDEST("started_at ASC, id ASC"),
STATUS("status ASC, started_at DESC");
companion object {
// 목록에 없는 값은 조용히 기본값으로 바꾸지 않고 400으로 거절한다
fun parse(key: String?): SessionSort =
entries.firstOrNull { it.name.equals(key ?: "newest", ignoreCase = true) }
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "sort must be one of newest, oldest, status")
}
}
외부 URL을 서버가 대신 요청하는 기능(미리보기, 웹훅 등)은 내부 주소나 메타데이터 접근 같은 새 경계를 만듭니다. 파일 업로드는 MIME 표기(파일이 스스로 주장하는 콘텐츠 종류)만 믿지 않고 크기, 접근 권한, 처리 경로를 검토합니다. 개인정보, 토큰, 영상 접근 URL이 로그와 트레이스에 남지 않도록 수집하는 자리에서 막습니다. 나중에 지우는 것보다 처음부터 남기지 않는 편이 확실합니다.S15
8.4 토큰·캐시·비밀정보의 수명
JWT 검증은 서명 확인으로 끝나지 않습니다. 허용한 알고리즘, 발급자, 대상 서비스, 만료 같은 정책을 모두 확인합니다. 그리고 인증 서버가 유효하다고 발급한 시점과 권한이 취소된 시점은 다를 수 있습니다.S16
Spring Boot 리소스 서버라면보강 속성 몇 줄로 발급자·대상·알고리즘 검사를 켭니다. 만료(exp)와 사용 시작(nbf) 시각은 기본으로 검사하며 시계 오차를 60초까지 허용합니다. issuer-uri를 쓰면 서버가 시작할 때가 아니라 첫 요청 때 발급자의 공개 키 위치를 찾아갑니다.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com # iss 검사 + 공개 키(JWKS) 위치 찾기
audiences: capture-api # aud 검사
jws-algorithms: RS256 # 허용할 서명 알고리즘
@PatchMapping("/sessions/{id}/status")
fun changeStatus(
@PathVariable id: String,
@RequestBody body: StatusChange,
@AuthenticationPrincipal jwt: Jwt, // 여기까지 오면 인증은 끝났다
): ResponseEntity<Void> {
val changed = sessions.changeStatus(id, ownerId = jwt.subject, status = body.status) // 인가: 소유 관계를 WHERE에
return if (changed == 1) ResponseEntity.noContent().build() else ResponseEntity.notFound().build()
}
토큰을 서버가 직접 검증할지(자체 검증), 매번 인증 서버에 물을지(introspection)는 세 가지를 함께 비교해 고릅니다.S16
| 자체 검증 | introspection | |
|---|---|---|
| 지연 | 로컬 계산만 한다 | 요청마다 인증 서버 왕복이 붙는다(캐시하면 줄지만 그만큼 늦게 반영) |
| 의존성 | 공개 키만 있으면 인증 서버가 멈춰도 검증할 수 있다 | 인증 서버가 멈추면 요청도 멈춘다 |
| 철회 반영 시간 | 토큰이 만료될 때까지 모른다 → 토큰 수명이 곧 최대 지연 | 물어보는 순간 반영된다 |
권한을 캐시해도 원본의 변경이 바로 반영되지 않습니다. 권한 캐시의 TTL을 정할 때는 적중률만 보지 않고, 권한 회수가 그만큼 늦어졌을 때의 피해를 봅니다. 5분짜리 권한 캐시는 “퇴사자가 최대 5분 더 접근할 수 있다”는 결정입니다.
비밀정보(DB 비밀번호, API 키, 서명 키)는 저장만의 문제가 아닙니다. 수명 전체를 다룹니다.S15
- 저장
코드·이미지·로그에 넣지 않고 비밀 저장소에 둔다
- 배포
필요한 서비스에만, 필요한 권한만큼 전달한다
- 회전
정기적으로 바꾸고, 바꾸는 동안 옛 값과 새 값이 함께 통하게 한다
- 폐기
쓰지 않는 비밀은 지우고, 지운 뒤 아무것도 깨지지 않는지 본다
- 유출 대응
새어 나갔을 때 누가 몇 분 안에 무엇을 바꾸는지 미리 정한다
8.5 API 계약은 배포 중에도 지켜야 한다
요청·응답의 필드, 의미, 오류, 멱등 키와 재시도 가능 여부가 모두 계약입니다. 배포 중에는 옛 클라이언트와 새 서버, 새 클라이언트와 옛 서버가 함께 돕니다(10장). 필드를 추가하는 변경도 모르는 필드를 오류로 처리하는 엄격한 파서나, 새 필드를 필수 생성자 인자로 받는 코드에서는 문제가 됩니다. 그래서 실제 소비자와의 호환성 테스트가 필요합니다.
| 변경 | 옛 클라이언트에 생길 수 있는 일 | 안전하게 하는 법 |
|---|---|---|
| 응답에 필드 추가 | 대개 무시되지만, 엄격한 파서는 실패 | 소비자 호환성 테스트로 확인 |
| 필드 이름 변경·삭제 | 값을 못 찾아 실패하거나 기본값으로 조용히 틀림 | 새 필드 추가 → 소비자 전환 → 옛 필드 제거(10장) |
| enum 값 추가 (예: PAUSED) | 닫힌 enum으로 받는 코드는 실패 | 모르는 값을 받는 기본값을 미리 준비, 추가 전에 공지 |
| 선택 요청 필드를 필수로 | 옛 클라이언트의 요청이 거절됨 | 기본값을 두고 선택 필드로 시작 |
| 같은 필드의 의미·단위 변경 (초 → 밀리초) | 오류 없이 틀린 값으로 동작 | 새 이름의 필드를 추가 |
| 오류 코드·재시도 가능 여부 변경 | 재시도·화면 처리 로직이 틀어짐 | 오류도 계약으로 문서화 |
JSON과 Protobuf의 비교는 전송 바이트 수만이 아니라 직렬화 CPU, 디버깅 편의, 스키마 배포, 호환성, 압축 여부까지 포함합니다. Protobuf의 바이너리 인코딩을 gzip 같은 일반 압축과 같은 것으로 보지 않습니다. 필드 번호, wire type, varint 같은 규칙으로 정보를 표현하는 방식입니다.S18
바이트에는 필드 이름이 없습니다. 받는 쪽은 같은 스키마가 있어야 해석할 수 있고, 한 번 쓴 필드 번호를 바꾸거나 다른 뜻으로 다시 쓰면 호환성이 깨집니다. 작아진 바이트 대신 스키마 배포와 디버깅 도구라는 비용이 생깁니다.S18
8.6 실습 — 권한 경계 표 만들기
촬영 조회·상태 변경·영상 다운로드의 세 작업을 행으로 적습니다. 열에는 본인, 다른 사용자, 탈퇴 사용자, 운영자, 만료 토큰을 둡니다. 각 칸에 허용·거부와 근거를 적습니다. 운영자도 모든 데이터에 무조건 접근해야 하는지는 따로 정할 문제입니다.
8.7 연습
연습사용자 A의 응답을 캐시 키 session:123으로 저장했고, 캐시된 응답을 돌려주기 전에 소유권을 검사하지 않습니다. 무엇이 위험할까요?
해설 보기
세션 ID가 전역에서 유일하더라도, 요청자의 권한을 확인하지 않으면 다른 사용자의 결과를 돌려줄 수 있습니다. 캐시 키와 인증·인가를 적용하는 위치를 함께 설계해야 합니다. 권한 검사 전의 결과와 후의 결과 중 무엇을 여러 사용자가 공유해도 되는지 구분합니다.
스스로 점검
이 장의 완료 기준입니다.
- 내 서비스의 신뢰 경계 3곳을 그리고, 각 경계에서 무엇을 확인하는지 말할 수 있다.
- 부정 테스트 5개를 쓸 수 있다(위 권한 경계 표를 채우면 후보가 만들어집니다).
- 유효한 인증과 허용된 접근이 왜 다른지 설명할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
9장 지표에서 가설로, 가설에서 안전한 대응으로
장애 대응은 원인을 맞히는 시험이 아닙니다. 사용자에게 생긴 일에서 출발해 CPU·힙·p95를 각각 가설로 나누고, 증거를 남기며 되돌릴 수 있는 완화를 고릅니다. 이 책에서 가장 긴 장이라 처음에는 순서대로 읽고, 실무에서는 자기 환경에서 검증한 절차서로 바꿔 씁니다.
이 장의 핵심
- 출발점은 “CPU가 높다”가 아니라 “어떤 업무가 언제부터 얼마나 실패하나”입니다. 기술 지표는 그 영향을 설명하고 대응 효과를 확인하는 데 씁니다.
- 모니터링·진단·완화·근본 수정은 끝나는 시점이 다릅니다. 재시작으로 응답이 돌아왔다면 완화에 성공한 것이지, 원인이 사라진 것은 아닙니다.
- 같은 그래프도 원인이 다를 수 있습니다. CPU는 “무슨 일을 더 하나”, 힙은 “어떤 메모리인가”, p95는 “어디서 기다리나”로 나눠 봅니다.
- 성공은 지표 하나로 선언하지 않습니다. CPU는 요청을 모두 거절해도 내려가고, p95는 느린 요청이 시간 초과로 빠져도 좋아 보입니다.
이 장을 읽는 순서. 공통 대응 순서 → CPU 급증 → 힙이 줄지 않을 때 → p95 악화 → 증거 수집 → 재시작 판단 → 사례 → 실험 → 판단 연습. 예시는 JVM으로 돌아가는 Java·Kotlin 서버지만, CPU·메모리·대기를 구분하는 방법은 다른 환경에도 통합니다. 명령의 지원 범위는 실행 중인 JDK와 운영체제에서 확인합니다. 사례의 수치는 판단 연습을 위한 가정이며 보편적인 임계값이 아닙니다.
9.1 먼저 알아둘 말
- label과 cardinality
- label은 메트릭을 구분하는 차원(예: route, status), cardinality는 서로 다른 값이나 조합의 개수입니다.
- 가설과 관측
- 가설은 현상을 설명하려고 검증할 주장, 관측은 실제로 측정한 자료입니다.
9.2 먼저 사용자에게 어떤 일이 생겼는지 말한다
“CPU가 높다”는 시스템의 상태입니다. “촬영 종료 요청의 8%가 실패하고 재시도 중이다”는 사용자 영향입니다. 장애 대응은 영향을 받는 기능·사용자·시간 범위를 좁히는 데서 시작합니다. 기술 지표는 그 영향을 설명하거나 대응 효과를 확인하는 데 씁니다.
SLI는 측정하는 서비스 수준, SLO는 목표입니다. 예를 들어 “30일 동안 유효한 촬영 종료 요청의 99.9%를 정해진 시간 안에 성공 처리한다”는 목표를 세울 수 있습니다. 무엇을 분모에 넣고 어떤 사건을 성공으로 볼지 적어야 합니다. 접수 단계의 HTTP 200(OK, 요청을 정상 처리함)만으로 영구 저장 성공을 대신하면 뒤에서 일어나는 비동기 실패를 숨길 수 있습니다.S19
유효 요청이 100만 건이고 목표 성공 비율이 99.9%라면 허용 실패는 1,000건입니다. 이것은 요청 기반 계산입니다. 모든 요청의 가치가 같다는 뜻이 아니고, “한 달에 43.2분”처럼 시간 기반 가용성의 허용 시간과 바로 바꿔 쓸 수도 없습니다.
9.3 메트릭·로그·트레이스를 조합한다
| 도구 | 잘 답하는 질문 | 약한 곳 |
|---|---|---|
| 메트릭 | 얼마나, 언제부터, 어느 경로에서? (크기와 분포) | 한 요청의 사연은 알 수 없다 |
| 로그 | 이 사건에 무슨 일이 있었나? (맥락) | 전체 추세를 세기에는 비싸고 느리다 |
| 트레이스 | 한 요청이 어디서 시간을 썼나? (경로와 구간) | 표본만 남기면 드문 사건을 놓칠 수 있다 |
하나의 도구로 모든 질문을 해결하려 하지 않습니다. 평균은 일부 긴 요청을 숨길 수 있습니다. p95·p99는 분포의 꼬리를 보는 데 도움이 되지만, 여러 서버의 p99를 평균 내면 전체 요청의 p99가 되지 않습니다(9.7에서 직접 확인합니다). 원시 표본이나 합칠 수 있는 분포 정보를 씁니다. 트래픽이 낮은 구간에서는 표본 수와 관측 기간도 함께 적습니다.
사용자 ID를 메트릭 label로 넣으면 사용자 수만큼 시계열이 늘어납니다. 상세 식별자는 접근 통제와 보존 정책을 갖춘 로그·트레이스에서 다루고, 메트릭에는 값의 종류가 제한된 차원만 씁니다. 관측에도 CPU·저장·보안 비용이 있습니다.S20
9.4 장애 대응은 원인을 맞히는 시험이 아니다
네 가지 일은 서로 이어지지만 끝나는 시점이 다릅니다.
- 모니터링
이상을 발견한다
- 진단
관측으로 가능한 원인을 줄인다
- 완화
원인을 완전히 몰라도 피해를 줄인다
- 근본 수정
같은 조건에서 다시 실패하지 않게 바꾼다
재시작으로 응답이 돌아왔다면 완화에 성공했을 수 있습니다. 같은 부하에서 몇 시간 뒤 다시 멈춘다면 원인은 아직 남아 있습니다. 장애 중에는 네 칸을 나눠 적습니다. 시간이 급하면 정확한 원인 규명보다 사용자 피해 억제가 먼저입니다. 다만 원인을 모르는 채 아무거나 바꾸는 것과, 근거가 있는 되돌릴 수 있는 완화를 고르는 것은 다릅니다.S29
확실한 사실
14:02부터 촬영 종료 요청의 8%가 실패 (게이트웨이 집계)
아직 가설
14:00에 배포된 변환 기능이 요청당 CPU를 더 쓴다
다음 확인
버전별 요청당 CPU 시간 비교, 실행 표본
지금 줄일 피해
한 인스턴스에서 변환 기능 플래그를 끄고 효과를 본다
처음 몇 분의 순서와 이유
아래 순서는 사고 실험입니다. 서비스 중단이나 데이터 손실이 진행 중이라면 마지막 단계까지 기다리지 않습니다.
몇 가지 말을 정리해 둡니다. 인스턴스는 같은 서버 프로그램을 실행하는 개별 복사본입니다. 기능 플래그는 특정 기능의 실행 여부를 바꾸는 스위치입니다. 격리는 문제 인스턴스에 새 요청이 가지 않게 하는 조치이고, 드레인(drain)은 새 요청을 막고 진행 중인 요청이 끝나기를 기다리는 과정입니다.
여유 서버가 없는데 하나를 빼면 남은 서버가 더 느려질 수 있습니다. 격리나 재시작 전에 남은 처리 용량, 진행 중인 쓰기, 재시도 동작을 확인하는 이유입니다. 서버가 한 대뿐이라면 중단을 감수할 범위를 밝히고, 저장되지 않은 작업과 재처리 방법을 먼저 파악합니다.
9.5 CPU가 치솟으면 “무슨 일을 더 하고 있나”를 찾는다
90%라는 숫자의 분모부터 확인한다
CPU 사용률은 일정한 관측 구간에서 CPU가 일한 비율입니다. 대시보드가 호스트 전체, 한 프로세스, 한 코어, 컨테이너에 배정한 용량 중 무엇을 분모로 쓰는지 확인합니다. 한 코어를 100%로 표현하는 도구는 여러 코어를 쓰는 프로세스를 100% 넘게 보여 줍니다. 서로 다른 기준의 90%를 그대로 비교하지 않습니다.
예를 들어 8코어 호스트에서 한 코어만 계속 바쁜 프로그램은 호스트 평균이 약 12.5%일 수 있습니다. 전체 평균은 낮아도, 요청을 처리하는 단일 스레드(프로그램 안에서 명령을 실행하는 흐름)는 이미 한계입니다. 반대로 CPU가 90%여도 작업이 지연 없이 끝나고 추가 유입을 감당한다면 그 숫자만으로 장애를 선언하지 않습니다.
컨테이너는 호스트의 자원을 제한·분리해 프로세스를 실행하는 환경입니다. CPU quota는 일정 기간에 허용한 CPU 실행 시간이고, throttling은 그 예산을 다 써서 실행이 제한되는 현상입니다. 호스트가 한가로워도 컨테이너는 자기 한도를 다 쓸 수 있습니다. Linux cgroup v2의 cpu.max와 cpu.stat, 또는 플랫폼의 대응 지표를 확인합니다. 누적 제한 횟수의 총합보다 장애 구간의 증가량을 봅니다.S30
CPU가 하는 일은 업무 계산만이 아니다
GC(Garbage Collection)는 더 이상 도달할 수 없는 객체의 메모리를 회수하는 작업입니다. 문자열을 대량으로 만들면 업무 계산과 객체 할당, 회수 작업이 함께 늘 수 있습니다. 그래서 CPU 급증을 “반복문 버그” 하나로 좁히지 않습니다.
| 함께 관측한 것 | 다음 증거와 가설 | 조치가 효과를 내는 이유·한계 |
|---|---|---|
| 요청 수와 CPU가 함께 증가 | 요청당 CPU 시간, 처리량, 새 요청 유형을 비교 | 불필요한 요청 제한이나 비필수 작업 중지가 유입 작업량을 줄인다. DB에 여유가 있을 때 확장도 후보 |
| 요청 수는 같은데 특정 경로의 CPU 증가 | 데이터 크기와 CPU 실행 표본을 비교 | 비싼 기능의 플래그 해제, 호환되는 롤백이 요청당 계산량을 줄인다 |
| GC 빈도·할당량도 증가 | GC에 쓴 CPU와 일시 정지, 객체 생성 경로 | 큰 응답·임시 객체·과도한 동시 요청을 줄이면 메모리 작업이 준다 |
| 컨테이너 제한 증가 | CPU 한도, 코어별 사용, 실행 대기 | 한도 조정은 실행 예산을 늘린다. 호스트 여유와 비용을 확인해야 한다 |
| 한 인스턴스만 계속 과열 | 같은 버전의 정상 인스턴스, 특정 요청·배치·스레드와 비교 | 해당 인스턴스 격리가 유효할 수 있다. 남은 서버의 과부하를 먼저 계산한다 |
CPU 프로파일은 실행 중인 코드 위치를 반복해서 표본으로 모은 자료입니다. “표본의 70%가 JSON 변환 경로에 있다”는 그 경로가 계산 비용의 큰 후보라는 뜻입니다. 전체 응답 시간의 70%라고 읽지는 않습니다. 기다리는 시간은 CPU를 쓰지 않기 때문입니다. 표본 수, 기록 구간, 수집 설정에 따라서도 결과가 달라집니다.
스레드 덤프는 한 시점에 스레드들이 어디서 무엇을 하는지 기록한 자료입니다. 몇 초 간격의 여러 기록에서 같은 경로가 반복되면 단서가 되지만, RUNNABLE 상태 한 장면만으로 무한 루프를 확정하지 않습니다. 이 상태에는 실행 가능하거나 일부 자원 처리를 기다리는 상황이 포함될 수 있습니다. CPU 실행 표본, 스레드별 CPU 사용, 시간 흐름과 연결해 봅니다. JDK Flight Recorder(JFR)는 실행·대기·GC 같은 사건을 기록하는 도구입니다.S31
계산으로 세운 가설을 관측으로 확인한다
가정: 2코어 인스턴스에 초당 200건이 들어오고, 요청당 평균 CPU 시간이 5ms라면 초당 CPU 수요는 200 × 0.005 = 1 CPU초입니다. 다른 작업이 없다면 2코어 용량의 약 절반입니다. 새 데이터 형식 때문에 요청당 15ms가 되면 수요가 3 CPU초로 늘어 2코어를 넘고, 대기가 쌓일 수 있습니다.
여기에 응답 시간을 CPU 시간 대신 넣으면 계산이 틀립니다. 응답 시간에는 DB·네트워크·풀 대기가 들어 있습니다. 이 모형은 평균 수요만 설명하며 순간 폭주, 단일 스레드, GC, 스케줄링은 빠져 있습니다. 실제로는 요청당 계산 증가와 대기 증가를 프로파일과 트레이스로 확인합니다.
조건부 완화. 새 변환 기능을 끈 뒤 요청량이 비슷한데 CPU와 대기가 내려가고 완료율이 회복되면 그 경로 가설이 강해집니다. 기능을 끄면 필요한 결과가 빠지지 않는지도 검증합니다. 한편 DB가 이미 포화인데 애플리케이션 서버만 늘리면 DB 연결과 요청을 더 늘려 상황을 악화시킬 수 있습니다.
9.6 힙이 줄어들지 않으면 “어떤 메모리인가”부터 구분한다
used, committed, max, RSS는 같은 숫자가 아니다
- 힙의 used·committed·max
- 힙(heap)은 주로 Java 객체가 놓이는 메모리 영역입니다. used는 지금 사용 중인 양, committed는 JVM이 쓸 수 있게 확보한 양, max는 그 영역이 쓸 수 있는 상한입니다. max만큼 물리 메모리가 이미 확보됐다는 뜻은 아닙니다.S32
- RSS
- 프로세스의 메모리 중 지금 물리 메모리에 올라와 있는 양을 나타내는 운영체제 지표입니다. JVM 힙 외에도 스레드 스택(호출 흐름과 지역 정보를 담는 영역), 클래스 정보, 네이티브 메모리(JVM 힙 바깥에서 쓰는 메모리)가 관계합니다. 운영체제마다 산정 방식이 다르고 컨테이너 전체 메모리 지표와도 다를 수 있습니다.
예를 들어 객체를 정리해 used가 1.5GiB에서 500MiB로 줄어도 committed는 2GiB로 남아 있을 수 있습니다. GC로 객체 공간을 다시 쓸 수 있게 된 것과, 확보한 메모리를 운영체제에 돌려준 것은 다른 사건입니다. RSS가 곧바로 같은 폭으로 줄어야 한다고 기대하지 않습니다. 반환 동작은 JVM, 수집기, 설정에 따라 다릅니다. (GiB는 230바이트, MiB는 220바이트입니다.)
여기서 기준선은 비교할 수 있는 회수 주기 뒤의 낮은 사용량을 말합니다. 모든 GC가 모든 영역의 불필요한 객체를 한 번에 회수하지는 않습니다. young GC는 주로 새 객체 영역을 다루므로, 직후의 전체 힙 사용량만 보고 오래 사는 객체의 추세를 단정하지 않습니다. 운영 중에 강제 GC를 반복해 기준선을 만들지 말고, 기존 GC 기록과 수집기의 주기를 먼저 이해합니다.
할당이 많은 것과 누수는 다르다
할당(allocation)은 새 객체를 만들 메모리를 받는 일입니다. 곧 회수할 수 있는 임시 객체가 초당 많이 생겨도 오래 사는 양은 일정할 수 있습니다. 이 경우 문제는 높은 할당·회수 비용입니다. 누수(leak)는 필요가 끝난 객체를 계속 참조해 회수를 막는 경우입니다. 사용 중인 캐시나 밀린 요청도 객체를 계속 잡고 있으므로, 기준선이 오른다는 사실만으로 코드 누수를 확정할 수 없습니다.S33
참조(reference)는 다른 객체로 이어지는 관계입니다. GC는 “이제 필요 없다”는 개발자의 의도를 모릅니다. 오래 보관하는 목록이 끝난 요청을 계속 참조하면 그 요청 객체는 살아 있는 것으로 취급됩니다. 힙 덤프는 이런 객체와 참조 관계를 분석하는 자료이고, 보유 경로(path to GC roots)를 보면 어떤 살아 있는 출발점이 객체를 붙잡고 있는지 찾을 수 있습니다.
해석 예: byte[]가 메모리를 많이 차지한다는 클래스별 집계만으로는 원인을 알 수 없습니다. 영상 버퍼, 캐시 값, 대기 중인 요청 본문이 모두 같은 배열일 수 있습니다. 누가 보유하는지 확인해 “완료된 요청을 지우지 않은 목록”과 “아직 끝나지 않은 요청이 쌓인 큐”를 구분해야 수정 방법이 정해집니다.
메모리 조치는 “무엇을 붙잡고 있는가”에 따라 고른다
| 가정 | 관측 | 조치와 주의 |
|---|---|---|
| A. 무제한 캐시 | 요청 수는 안정적인데 사용자별 캐시 항목 수와 회수 후 힙이 함께 계속 증가 | 크기 제한·만료 정책을 확인하고 실제로 제거가 일어나는지 본다. 제한은 보유량을 줄이지만, 캐시를 전부 비우면 DB 읽기가 폭증할 수 있으므로 점진적으로 줄이고 DB를 보호한다 |
| B. 느린 하위 호출 | 외부 API가 느려지면서 대기 중인 요청 수와 힙이 함께 증가 | 새 요청의 동시 실행 수와 큐 길이를 제한해 살아 있는 요청 수를 제한한다. 중요한 작업은 그냥 버리지 말고 거절·재시도·영속 큐의 업무 계약을 정한다. 대기가 줄어든 뒤 기준선도 내려가면 ‘지연에 의한 보유’ 가설이 강해진다 |
| C. 힙 밖 메모리 | 힙 사용량은 일정한데 RSS와 스레드 수가 계속 증가 | Xmx(최대 Java 힙 크기)를 늘려도 스레드 누수·직접 버퍼 문제는 풀리지 않고 전체 메모리 여유만 줄어든다. NMT(Native Memory Tracking)는 JVM 내부 메모리 사용을 추적하지만 시작할 때 켜야 하고 모든 외부 네이티브 할당을 보지는 못하므로 OS 관측과 함께 쓴다S34 |
OutOfMemoryError와 종료 코드 137
Java의 OutOfMemoryError는 필요한 메모리를 확보하지 못했다는 오류입니다. 상세 메시지가 Java heap space인지, Metaspace인지, 스레드 생성 실패인지에 따라 확인할 곳이 달라집니다. 컨테이너의 OOM 종료는 운영체제가 프로세스를 죽인 것일 수 있어 Java 예외나 힙 덤프가 남지 않을 수 있습니다. 종료 코드 137은 128 + 9, 즉 9번 신호(SIGKILL)로 강제 종료됐다는 뜻입니다.보강 메모리 한도 초과일 수도 있지만 다른 이유의 강제 종료일 수도 있으므로, 137 하나만으로 OOM을 확정하지 않고 플랫폼의 종료 사유와 메모리 이벤트를 대조합니다.S30 S33
언제 재시작하거나 메모리를 늘리는가
사용자 실패가 계속 늘고 종료가 임박했다면 증거를 완벽히 모으려고 복구를 늦추지 않습니다. 이미 저장된 지표·로그와 종료 사유, 가능하면 짧은 진단을 남긴 뒤 용량과 업무 안전성을 확인하고 한 대씩 교체합니다. 누수 후보가 있다면 재시작 뒤의 상승 속도와 재발 시간을 기록합니다. 재시작은 수명을 초기화할 뿐, 객체를 계속 붙잡는 코드는 그대로입니다.
실제로 필요한 정상 작업량이 힙 한도보다 크다면 메모리 증설이 합리적일 수 있습니다. 그러나 컨테이너 한도에는 힙 이외의 메모리도 들어갑니다. “컨테이너가 2GiB니까 Xmx도 2GiB”로 맞추면 나머지 영역에 여유가 없습니다. 정해진 비율을 외우기보다 관측한 전체 사용량, 피크, GC 특성, 비용으로 여유를 정합니다. 무제한 보유가 원인이라면 증설은 실패 시점을 미루는 완화에 그칠 수 있습니다.
9.7 p95가 증가하면 “어디서 기다리나”를 나눈다
p95(95th percentile)는 관측값의 약 95%가 그 값 이하인 지점입니다. 평균도 최댓값도 아닙니다. 연습에서는 100개의 응답 시간을 작은 순서로 정렬해 95번째 값을 p95로 삼습니다. 실제 도구는 표본, 버킷(관측값을 일정 구간에 묶어 센 통), 보간 방식에 따라 근삿값을 냅니다.S35
100건 중 94건이 20ms, 6건이 1,000ms이면 이 방식의 p95는 1,000ms입니다. 느린 요청이 4건이면 p95는 20ms이므로 p95 하나만 보면 심각한 일부 지연을 놓칠 수 있습니다. p99, 시간 초과 비율, 업무별 완료량을 함께 보는 이유입니다. 반대로 빠른 요청을 많이 거절하면 집계 방식에 따라 p95가 좋아 보일 수 있습니다. 성공 여부를 나눠 보되 실패한 요청을 사용자 영향에서 지우지 않습니다.
느려진 요청과 느린 요청의 비중 증가를 구분한다
검색 API는 항상 20ms, 보고서 API는 항상 1,000ms라고 합시다. 보고서 비중이 4%에서 6%로 변하면 어느 API도 느려지지 않았는데 전체 p95가 뜁니다. 측정 오류가 아니라 요청 구성의 변화입니다. API별·상태별·버전별로 나눠 보되 전체 분포도 유지합니다. 요청이 거의 없는 구간의 백분위수는 적은 표본에 민감하므로 건수와 관측 구간을 같이 적습니다.
CPU·힙·p95 그래프의 시간 범위와 집계 간격도 맞춥니다. 1분 단위 CPU 피크와 30분 단위 p95를 겹쳐 놓으면 같은 시점의 현상인지 판단하기 어렵습니다. 장애 직전의 정상 구간, 장애 구간, 완화 이후 구간을 같은 기준으로 비교합니다.
한 요청의 경로를 펼친다
단순한 직렬 요청이라면 응답 시간을 입구 대기 + 애플리케이션 작업 + 연결 획득 대기 + DB 실행 + 외부 호출 + 응답 전송으로 나눠 생각할 수 있습니다. 이 중 CPU를 쓰는 부분과 기다리는 부분을 구분합니다. 병렬 호출이 있다면 겹친 시간을 두 번 더하지 않고, 완료를 결정한 가장 긴 경로를 봅니다. 구성 요소들의 p95를 더해 전체 p95를 구할 수도 없습니다. 각 요청의 구간을 연결한 트레이스가 필요한 이유입니다.
| 늘어난 구간 | 확인할 자료 | 완화가 작동하는 이유·주의점 |
|---|---|---|
| 요청 진입·실행 대기 | 대기 작업 수, 가장 오래 기다린 시간, 실행 스레드·CPU | 유입·동시성 제한으로 대기를 제한한다. 제한만으로 업무가 완료된 것은 아니다 |
| DB 연결 획득 | active·max·pending, 연결 보유 시간 | 반환이 늦어지는 원인을 줄여 연결을 더 빨리 재사용한다. 풀 증설은 DB 한도를 확인해야 한다 |
| 실제 SQL 실행 | 느린 질의, 실행 계획, 락·I/O 대기 | 잘못된 질의와 차단하는 트랜잭션을 구분한다. 취소·롤백의 비용과 업무 영향을 확인한다 |
| 외부 API | 호출별 지연·오류·재시도, 연결 설정 | 필수가 아닌 호출의 우회나 대기 제한으로 자원 점유를 줄인다. 데이터 의미가 바뀌는지 확인한다 |
| JVM 일시 정지 | GC 일시 정지 시각과 요청 지연의 겹침 | 정지 원인이 확인되면 할당·보유량을 줄인다. 높은 힙 비율 하나로 GC 원인이라 단정하지 않는다 |
active는 지금 사용 중인 연결 수, max는 풀의 최대 수, pending은 연결을 기다리는 요청 수입니다. 풀이 다 찼다는 것은 자원이 부족하다는 관측이지 “최댓값을 올리면 해결된다”는 결론이 아닙니다. 연결을 오래 빌리고 있거나 반환하지 않는 원인이 있다면 그것이 먼저입니다.
타임아웃과 재시도는 자원 사용 정책이다
타임아웃은 작업 결과를 기다릴 시간의 한도입니다. 대기를 제한하면 호출자의 스레드·연결·메모리가 오래 묶이는 것을 줄일 수 있습니다. 그러나 호출자가 기다리기를 끝내도 하위 작업이 취소되지 않으면 서버의 일은 계속됩니다. 연결, 읽기, 전체 요청의 시간 한도가 각각 무엇인지 확인하고 취소 전달과 자원 반환을 검증합니다.
타임아웃을 늘리면 느린 작업이 끝날 기회를 얻는 대신 동시에 기다리는 작업이 많아질 수 있습니다. 무작정 줄이면 정상 작업도 실패하고 재시도를 부릅니다. 전체 응답 시간 예산, 하위 호출 시간, 재시도 횟수를 함께 정합니다(5장). 재시도는 이미 수행된 쓰기를 중복 실행할 수 있어 멱등성과 연결됩니다(7장). 회로 차단기는 연속으로 실패하는 하위 호출을 잠시 빠르게 거절하고 회복을 시험하는 장치일 뿐, 실패한 의존성을 고쳐 주지는 않습니다. 대체 응답의 업무 의미가 안전해야 합니다. 재시도 예산과 유입 제한은 실패한 작업이 다시 부하가 되어 실패를 늘리는 반복을 끊는 데 씁니다.S36
9.8 증거 수집도 부하를 주는 작업이다
가장 먼저 기존 대시보드·로그·트레이스를 시간 범위를 맞춰 보존합니다. 배포 버전, 인스턴스 식별자, 요청 수와 크기, 오류, CPU와 제한, 힙 지표의 정확한 이름, GC 정지, 풀·큐의 대기를 적습니다. 그래프를 캡처할 때는 단위와 집계 구간도 남깁니다. 재시작 뒤 “그때 무엇이 높았나”를 기억에 기대지 않기 위해서입니다.
그다음 질문에 맞는 진단을 고릅니다. CPU를 어느 경로에 쓰는지 궁금하면 짧은 실행 기록, 멈춘 호출 위치가 궁금하면 스레드 기록, 객체를 붙잡는 관계가 궁금하면 힙 분석이 후보입니다. 질문 없이 모든 덤프를 연달아 모으지 않습니다.
힙 덤프에는 토큰·사용자 데이터·요청 본문이 들어갈 수 있습니다. 스레드 기록과 JFR에도 경로나 질의 같은 민감한 맥락이 남을 수 있습니다. 진단 파일은 접근을 제한하고 필요한 기간만 보관합니다. 문서 형식의 문제가 아니라 실행 중인 데이터의 복사본을 만드는 일이기 때문입니다.
응답이 거의 멈춘 상태에서 덤프 수집이 응답하지 않거나 피해를 늘리면 중단하고 완화를 우선합니다. 짧은 진단조차 못 남겼다는 사실을 기록하면 됩니다. 완벽한 증거가 복구의 필수 조건은 아닙니다.
9.9 재시작을 조건과 검증이 있는 완화로 바꾼다
재시작은 쌓인 객체, 굳어 버린 실행 상태, 연결을 새로 만들 수 있습니다. 동시에 끝나지 않은 요청을 끊고, 메모리 안의 작업을 잃고, 캐시를 비우고, 새 연결·클래스 로딩·컴파일 비용을 치릅니다. 모든 인스턴스를 동시에 재시작하면 이 비용을 한꺼번에 치릅니다.
| 재시작을 고려할 근거 | 먼저 확인하는 이유 | 이후 확인 |
|---|---|---|
| 한 인스턴스만 멈추고 다른 인스턴스는 정상 | 그 인스턴스의 지역 상태 문제일 수 있어 격리 효과를 기대한다 | 남은 용량, 새 인스턴스 준비, 요청 완료율 |
| 메모리 고갈이 임박하고 실패가 증가 | 프로세스를 교체해 피해를 줄일 시간이 필요하다 | 최소 증거, 누락·중복, 회수 후 기준선과 재발 시간 |
| 알려진 문제 버전을 롤백하며 교체 | 같은 버전을 다시 띄우는 것보다 원인을 제거할 가능성이 크다 | 데이터·설정 호환성, 새 버전별 지표 |
| 공통 DB·외부 API가 느려 전체가 악화 | 앱을 재시작해도 공통 의존성은 빨라지지 않는다 | 연결 폭증을 막고, 하위 병목 완화가 먼저인지 판단 |
실행 순서의 예: 영향 기록 → 가능한 증거 확보 → 남은 처리 용량 확인 → 대상에 새 요청 차단 → 진행 중 요청의 완료나 취소 처리 → 한 대 교체 → 준비 상태 확인 → 소량 트래픽부터 복귀 → 전체 복구와 재발 관찰. 준비 상태(readiness)는 새 요청을 받아도 되는 상태라는 뜻으로, 프로세스가 실행됐다는 사실과 다릅니다.
서버가 한 대뿐이거나 반드시 순서를 지켜야 하는 소비자라면 이 순서를 그대로 복사하지 않습니다. 중단 시간, 재전달, 중복 반영, 리더 역할의 인계처럼 자기 서비스의 제약을 붙여야 합니다. 진단 자료를 기다리는 동안 피해가 커지면 수집을 생략하고 그 사유를 남깁니다.
변경 전에 성공·중단 기준을 말한다
“새 영상 변환 기능을 한 인스턴스에서 끈다. 같은 요청 유형·유입 수준에서 CPU와 큐 대기가 줄고 고유 업무 완료율이 회복되는지 본다. 잘못된 결과나 누락이 생기면 즉시 되돌린다.”
숫자 임계값과 관찰 길이는 평소 분포, 요청 수, 업무 마감에 맞춰 정합니다. “무조건 5분”이나 “CPU 70%” 같은 값을 모든 서비스의 규칙으로 만들지 않습니다.
관찰 지표 하나만 골라 성공을 선언하지 않습니다.
- CPU 감소는 요청을 모두 거절해서도 만들 수 있습니다.
- p95 감소는 느린 요청이 시간 초과로 측정에서 빠져서도 생깁니다.
- 힙 감소는 서버가 죽어서도 생깁니다.
반드시 사용자가 요청한 일이 올바르게 완료됐는지 함께 확인합니다.
9.10 사례 — 서로 다른 원인이 같은 그래프를 만든다
세 사례의 공통점. 지표 이름에서 바로 명령을 고르지 않고, 병목을 만드는 작업·보유·대기를 찾아 조치가 왜 작동하는지 설명합니다. CPU 급증과 p95 증가가 동시에 생겼다고 CPU가 항상 최초 원인인 것도 아닙니다.
9.11 직접 관찰하는 작은 실험
원래 교재의 실습(labs/IncidentLab.java)과 같은 구성의 프로그램을 이 교과서가 직접 작성해 실행했습니다. 외부 의존성 없이 JDK 17 이상에서 CPU 계산, 대기, 임시 할당, 객체 보유를 떼어서 관찰합니다. 서버 포트를 열거나 DB에 연결하지 않고, 보유 배열은 최대 32MiB, 실행 시간은 최대 30초로 제한했습니다. 그래도 CPU와 메모리를 실제로 쓰므로 운영 서버에서는 실행하지 않습니다.
javac IncidentLab.java
java -Xms32m -Xmx128m IncidentLab selftest # p95 예제, 입력 제한, 보유 상한 확인
java -Xms32m -Xmx128m IncidentLab cpu 3 # 한 스레드가 계산을 반복
java -Xms32m -Xmx128m IncidentLab wait 3 # 같은 시간 대부분 잠듦
java -Xms32m -Xmx128m -Xlog:gc IncidentLab transient 3 # 1MiB 배열을 만들고 바로 버림
java -Xms32m -Xmx128m -Xlog:gc IncidentLab retain 7 # 1MiB 배열을 목록에 보관(32MiB에서 멈춤)
elapsed_ms는 지난 벽시계 시간, main_cpu_ms는 주 실행 스레드가 쓴 CPU 시간입니다. GC나 다른 스레드의 CPU까지 포함한 프로세스 전체 CPU가 아닙니다. retained_payload_mib는 프로그램이 명시적으로 보유한 배열 내용의 크기이며 JVM 전체 사용량이 아닙니다.
| 모드·길이 | 이 교과서 (Linux, OpenJDK 21) | 원래 교재 (macOS, JBR OpenJDK 25) | 해석 |
|---|---|---|---|
| cpu · 약 3초 | 주 스레드 CPU 2,990ms | 약 2,995ms | 대부분의 시간을 계산에 썼다 |
| wait · 약 3초 | 주 스레드 CPU 5ms | 약 19ms | 기다린 시간은 CPU 시간에 더해지지 않는다 |
| transient · 약 3초 | 배열 할당 누계 397MiB, 명시적 보유 0MiB, GC 56번 | 416MiB, 보유 0MiB | 많은 할당이 곧 지속 보유는 아니다 |
| retain · 약 7초 | 배열 내용 보유 32MiB, heap used 65.5MiB | 32MiB, 약 66.8MiB | 보유한 내용 크기와 JVM 사용량은 다르다 |
마지막 줄을 보고 “보유 32MiB를 뺀 나머지가 누수”라고 계산하면 안 됩니다. 힙에는 다른 객체도 있고 객체 헤더, 정렬, 수집기의 공간 배치가 관계합니다. 이번 GC 로그에도 G1 Humongous Allocation이 나타났습니다. humongous는 G1이 큰 객체에 쓰는 분류이며, 큰 객체는 배치 방식 때문에 내용 크기보다 많은 영역(region, G1이 힙을 나누어 관리하는 구획)을 차지할 수 있습니다.S32
차이를 직접 분해해 봤습니다.보강 이 조건에서 G1의 영역 크기는 1MiB였고, 실습의 배열 하나는 1MiB 내용에 배열 헤더 16바이트가 붙어 영역 하나를 살짝 넘습니다. 큰 객체는 연속된 영역에 통째로 놓이므로 배열 하나가 영역 두 개를 차지했습니다. 배열을 16바이트만 줄여 영역 하나에 딱 맞추자 같은 32개를 보유해도 heap used가 65.1MiB에서 33.1MiB로 줄었습니다(labs/HumongousCheck.java). 영역 크기와 헤더 크기는 힙 크기와 JVM 설정에 따라 달라집니다.
같은 프로세스에서 증거 수집 명령도 확인했습니다. cpu 모드를 20초 실행하는 동안 Thread.print로 남긴 기록에서 main 스레드는 RUNNABLE 상태로 IncidentLab.cpu에 있었습니다. 5초 JFR 기록에는 실행 표본(jdk.ExecutionSample)이 241개 담겼고, jfr view hot-methods에서 표본의 100%가 IncidentLab.cpu였습니다. 계산만 하는 실험이니 당연한 결과지만, 실제 서버에서는 이렇게 한 곳에 몰리지 않습니다. 실제 운영 서버의 정지 시간·부하·권한 조건은 검증한 것이 아닙니다.
이 실험은 무제한 누수를 만들어 시스템을 고갈시키지 않습니다. 객체를 참조하는 코드와 메모리 현상의 관계를 보여 줄 뿐입니다. 실제 캐시의 만료, 요청 큐, 클래스 로더, 네이티브 누수는 따로 검증해야 합니다.
9.12 판단 연습과 해설
문제 1CPU가 95%이고 GC 정지 시간은 낮습니다. “GC가 원인이 아니다”라고 확정할 수 있을까요?
해설 보기
없습니다. 동시 GC는 애플리케이션과 함께 CPU를 쓸 수 있습니다. 정지 시간과 GC의 CPU 비용은 다릅니다. 실행 표본, GC 활동, 할당량, 요청당 계산을 함께 봅니다. 반대로 GC가 있다는 사실만으로 CPU 급증을 GC 탓으로 돌리지도 않습니다.
문제 2힙 committed 2GiB가 하루 종일 유지되지만, used는 회수 주기 뒤 400MiB 부근으로 돌아옵니다. 재시작해야 할까요?
해설 보기
이 정보만으로는 필요하지 않습니다. 확보한 공간이 유지되는 것과 살아 있는 객체가 계속 늘어나는 것을 구분합니다. 실제 사용자 지연, GC 비용, 전체 메모리 한도와 여유를 확인합니다. 부하가 일정하지 않거나 오래된 영역의 회수가 아직 끝나지 않았다면 기준선 해석도 보류합니다.
문제 3p95는 좋아졌지만 성공한 업무가 초당 200건에서 30건으로 줄고 거절이 늘었습니다. 복구일까요?
해설 보기
부하 제한으로 더 큰 붕괴를 막았을 수는 있지만 원래 서비스 수준을 회복한 것은 아닙니다. 수락·거절·완료·실패를 함께 보고, 중요한 작업의 완료 기한과 대기량을 관리합니다. 완화 성공과 전체 복구 완료를 구분해 보고합니다.
문제 4재시작할 때마다 3시간쯤 정상이다가 힙과 지연이 다시 늘어납니다. 다음에 무엇을 조사할까요?
해설 보기
재시작 전후의 버전·트래픽과 시간 경과를 맞추고, 회수 후 기준선, 객체 보유량, 캐시·큐·스레드 수의 증가를 봅니다. 진단 시간을 확보하도록 기록을 미리 준비합니다. 반복되는 수명 패턴은 누적 상태의 단서일 뿐 특정 누수의 증명은 아닙니다. 보유 경로로 원인을 좁힙니다.
문제 5DB 풀을 20에서 100으로 늘렸더니 애플리케이션의 pending은 줄었지만 DB 지연과 오류가 늘었습니다. 어떻게 해석할까요?
해설 보기
대기를 DB 쪽으로 옮겼을 수 있습니다. DB 전체의 동시 부하·락·I/O를 확인하고, 악화시킨 설정은 되돌리는 후보로 둡니다. 동시에 연결을 오래 쥐는 원인을 제거합니다. “우리 서버의 대기 그래프 개선”을 전체 사용자 개선으로 대신하지 않습니다.
문제 6오류율은 회복됐지만 큐에 10만 건이 남았습니다. 장애가 끝났을까요?
해설 보기
남은 작업의 나이, 완료 기한, 소진 시간, 재처리 안전성을 확인합니다. 서비스 정상화와 데이터 복구 완료를 나눕니다. 대응 중에는 의사결정·기술 조치·상황 전달의 책임을 정하고 기록을 남깁니다. 회고에서는 구체적인 재발 방지 작업에 담당자·검증 방법·기한을 붙입니다.S21
장애 기록 한 장
답을 쓰는 순서입니다. 칸을 채우면 이 브라우저에 저장되고, 복사 버튼으로 마크다운을 가져갈 수 있습니다.
스스로 점검
이 장의 완료 기준입니다.
- CPU·힙·p95 각각에 대해 원인 후보를 두 가지 이상 적고, 후보를 가르는 증거와 조치가 작동하는 이유를 설명할 수 있다.
- “재시작하면 해결”에서 끝내지 않고, 무엇이 초기화됐으며 어떤 조건에서 다시 실패할지 말할 수 있다.
- 완화의 성공을 지표 하나가 아니라 사용자 업무의 완료로 확인할 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
10장 바꾸고, 되돌리고, 복구할 수 있는 설계
배포는 한순간이 아니라 구버전과 신버전이 함께 도는 시간입니다. 그 시간을 설계하고, 테스트를 실패 비용에 맞춰 배정하고, 백업이 아니라 복구를 검증합니다. 결정은 대안·측정·재검토 조건을 담은 짧은 ADR로 남깁니다.
이 장의 핵심
- 배포·마이그레이션·백필·롤백·복구는 서로 다른 일입니다. 같은 버튼처럼 다루지 않습니다.
- 구버전과 신버전이 함께 도는 시간이 있습니다. 새 표현을 추가하고, 옮기고, 필요 없어지면 지우는 순서(expand–migrate–contract)로 나눕니다.
- 백업 파일이 생겼다는 사실은 복구 가능성을 증명하지 못합니다. 별도 대상에 복원해 제약·불변식·앱 접근까지 확인해야 복구를 검증한 것입니다.
- 비용에는 계속 유지할 일(백업, 모니터링, 업그레이드, 사람의 시간)이 들어갑니다.
10.1 먼저 알아둘 말
- 배포
- 실행 중인 시스템에 새 프로그램이나 설정을 적용하는 일입니다.
- 마이그레이션과 백필
- 마이그레이션은 데이터 구조나 저장 위치를 옮기는 변경, 백필은 빠진 과거 데이터를 새 구조에 채우는 일입니다.
- 롤백과 복구
- 롤백은 이전 상태로 되돌리는 조치, 복구는 장애 뒤 필요한 서비스·데이터 상태를 되찾는 일입니다.
- runbook
- 실행 순서를 적은 운영 절차서입니다.
10.2 작게 시작해도 경계는 명확히 한다
같이 바뀌는 업무 규칙과 데이터를 모으고, 데이터를 최종적으로 수정할 권한을 누가 가질지 정합니다. 하나의 배포 단위 안에서도 모듈 경계와 의존 방향은 지킬 수 있습니다. 서비스 분리는 독립 배포·확장·보안·팀 운영의 이득이 분산 비용보다 클 때 검토합니다.
코드가 복잡하다는 이유만으로 원격 호출로 나누면 네트워크·재시도·일관성·운영 책임이 더해집니다(7장). 반대로 분명한 독립 요구가 있는데 단일 배포만 고집할 필요도 없습니다. “언제 분리할 것인가”를 변경 빈도, 자원 요구, 장애 영향 같은 조건으로 적어 둡니다.
10.3 구버전과 신버전이 함께 있는 시간
컬럼 이름을 한 번에 바꾸고 서버를 차례로 배포하면, 아직 교체되지 않은 구버전이 옛 컬럼을 찾다가 실패합니다. expand–migrate–contract는 새 표현을 추가하고(expand), 호환되는 코드로 데이터를 옮기고(migrate), 더 이상 옛 표현이 필요 없을 때 지우는(contract) 단계화입니다.
두 곳에 쓰는 기간에는 부분 실패와 “어느 쪽이 원본인가”라는 문제가 생깁니다. 백필은 다시 실행해도 안전해야 하고, 처리 위치·오류·대사 결과를 기록해야 합니다. dual write라는 이름을 붙였다고 원자성이 생기지는 않습니다. 삭제나 되돌릴 수 없는 변환 뒤에는 코드 롤백만으로 돌아갈 수 없을 수 있습니다.
Spring Boot 4 + Flyway로 나누면보강 Boot 4에서는 Flyway 자동 구성이 별도 스타터로 나뉘어 spring-boot-starter-flyway를 추가해야 하고, PostgreSQL을 쓰면 Flyway의 PostgreSQL 모듈도 필요합니다. expand와 contract는 서로 다른 배포에 넣습니다. 큰 표의 백필은 서버가 시작할 때 도는 마이그레이션에 넣지 말고, 구간별로 다시 실행할 수 있는 별도 작업으로 돌립니다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
}
-- V7__add_duration_ms.sql (배포 1: expand)
ALTER TABLE capture_session ADD COLUMN duration_ms bigint;
-- V9__drop_duration_sec.sql (배포 4 이후: contract, 옛 접근이 모두 사라진 뒤)
ALTER TABLE capture_session DROP COLUMN duration_sec;
기능 플래그와 카나리 배포는 노출 범위를 줄이는 수단입니다. 카나리에 들어가는 트래픽이 실제로 중요한 사용 패턴을 포함하는지 확인하고, 오류·지연·업무 지표의 중단 기준을 미리 정합니다. 배포 성공과 업무 정상은 별개입니다.
10.4 테스트를 실패 비용에 배정한다
| 대상 | 알맞은 테스트 | 이유 |
|---|---|---|
| 순수 계산, 상태 전이 | 빠른 단위 테스트 | 경우의 수를 싸게 많이 확인할 수 있다 |
| SQL, 제약, 트랜잭션, 직렬화 | 실제 구현에 가까운 통합 테스트 | 경계의 의미가 중요하다. 흉내 낸 DB는 UNIQUE나 격리 수준을 재현하지 못한다 |
| 외부 계약 | 호환성 검증 | 필드 추가도 소비자를 깨뜨릴 수 있다(8장) |
| 핵심 사용자 흐름 | 소수의 전체 경로 테스트 | 느리고 깨지기 쉬우니 꼭 필요한 흐름만 |
| 동시성, 중복, 부분 성공, 배포 중 공존 | 실패 시점을 고정한 작은 테스트 | 정상 요청 하나로는 검증되지 않는다(4장·7장 실험) |
실패 시점을 고정한 작은 테스트가 “모두 정상 동작함”이라는 큰 테스트보다 원인을 더 잘 보여 줄 수 있습니다. 테스트가 구현을 그대로 따라 쓰면 같은 실수를 재현한 채 통과할 수 있다는 점도 기억합니다.
10.5 백업 성공과 복구 성공은 다르다
- RPO
- Recovery Point Objective. 복구할 때 허용하는 데이터 손실의 시간 범위 목표입니다. “최대 5분치까지 잃어도 된다.”
- RTO
- Recovery Time Objective. 서비스를 복구하기까지 걸리는 시간의 목표입니다. “1시간 안에 다시 쓸 수 있어야 한다.”
복제본은 삭제나 오염도 함께 반영하므로 백업과 같은 역할이 아닙니다. 백업 파일이 생성됐다는 사실만으로 복구 가능성이 증명되지도 않습니다.
복구 실험은 별도 대상에 백업을 복원하고 행 수뿐 아니라 핵심 제약, 업무 불변식, 애플리케이션 접근까지 확인합니다. 걸리는 시간에는 파일 복원 말고도 권한, DNS, 설정, 클라이언트 전환, 검증이 들어갈 수 있습니다. 다시 재생할 로그와 복구 지점을 얼마나 보존하는지도 점검합니다.S22
실험 — 백업을 새 서버에 복원해 본다
원래 교재의 labs/05_restore.md처럼, 이 교과서도 논리 백업(pg_dump)을 별도 대상으로 복원해 확인했습니다. 원본 DB에는 세션 200,000행, 참여 30,000행과 UNIQUE·CHECK 제약, 그리고 앱 계정 app_rw의 권한이 있습니다. 이것을 새로 만든 PostgreSQL 클러스터(다른 서버라고 가정)에 복원했습니다(labs/10_restore.sh).
데이터와 제약은 완벽히 돌아왔는데 앱은 접속조차 못 했습니다. pg_dump는 데이터베이스 하나를 백업하므로 계정 같은 클러스터 전역 객체는 들어 있지 않습니다. 행 수만 세고 “복구 성공”이라고 했다면 놓쳤을 문제입니다. 이 실험은 전체 서비스 장애 복구나 PITR, 관리형 DB의 RPO·RTO를 증명하지 않습니다. 작은 검증에서 출발해 범위를 넓히는 첫걸음입니다.
10.6 비용에는 계속 유지할 일이 포함된다
RDB·DynamoDB·Redis의 비용을 비교할 때 월 사용료만 넣지 않습니다. 데이터 보존, 백업·복구, 인덱스·복제, 모니터링, 데이터 이동, 업그레이드, 담당자가 이해하고 장애에 대응하는 시간을 넣습니다. 사람의 시간은 불확실하므로 범위와 가정을 함께 적습니다.
기술을 하나 더하면 장애 때 따져야 할 조합도 늘어납니다. 반복되는 실제 문제를 줄이는지, 운영 소유자가 있는지, 제거·교체 조건이 무엇인지 묻습니다. 이미 쓴 비용은 앞으로 계속 쓸 근거가 아니지만, 전환 비용 역시 무시할 수 없습니다.
10.7 짧은 ADR의 완성 예시
ADR(Architecture Decision Record)은 결정과 그 이유를 짧게 남기는 기록입니다. 아래는 이 책의 사례로 쓴 한 페이지 예시입니다.
ADR-012 · 촬영 상태 쓰기 부하에 대한 임시 결정
- 문제
- 학습용 서비스의 최신 촬영 상태가 RDB 쓰기 부하의 큰 비중을 차지한다. 실제 측정은 아직 없으며 대안 검증이 필요하다.
- 제약
- 확정 이벤트는 손실 불가. 최신 상태는 5초 지연 허용. 작은 운영팀. 실제 키 분포와 피크 부하는 아직 모름.
- 후보
- 기존 RDB의 쓰기·인덱스 개선, 상태 갱신 합치기, DynamoDB로 상태 분리, RDB 샤딩. Redis는 상태 보관·합치기의 실패 조건이 맞을 때 별도 후보.
- 임시 결정
- 먼저 RDB에서 상태와 이벤트의 논리 경계를 나누고 비용 원인을 측정한다. 중간 상태 생략이 허용되는 경로만 합치기 실험을 한다. 현재 근거만으로 저장소 이전은 확정하지 않는다.
- 검증
- 같은 고유 업무 요청과 내구성 조건에서 DB 지연·WAL·IOPS·오류·누락과 운영 비용을 비교한다. 이득이 작거나 허용 지연을 넘으면 합치기를 중단한다.
- 재검토
- 현재 저장 경로의 한계가 측정되고, 키 중심 접근과 분산 가능한 부하가 확인되며, 이전·복구 계획이 준비되면 DynamoDB PoC(아이디어의 가능성을 작게 검증하는 작업)를 진행한다.
이 문서는 특정 제품의 정답을 선언하지 않고, 다음 결정을 가능하게 합니다.
10.8 연습
연습새 저장소로 옮긴 뒤 기존 테이블을 즉시 삭제하자는 제안이 나왔습니다. 어떤 증거가 있어야 할까요?
해설 보기
읽기·쓰기 전환 완료, 변경분 대사, 백업과 복구 확인, 오래된 소비자·배치의 접근 종료, 법적·제품 보존 요구, 되돌릴 수 있는 마지막 시점과 책임자를 확인합니다. 10.5의 실험처럼 “복원해 보니 계정이 없었다” 같은 일은 실제로 해 봐야 드러납니다.
삭제 전에 기다리는 기간은 관성적인 숫자가 아니라 실제 사용과 복구 요구로 정합니다.
스스로 점검
이 장의 완료 기준입니다.
- 대안과 기각 이유, 측정할 결과, 롤백의 한계, 재검토 조건이 있는 한 페이지 ADR을 쓸 수 있다.
- 컬럼 하나를 바꿀 때 어느 단계부터 코드 롤백만으로 돌아갈 수 없는지 말할 수 있다.
- “백업 성공”과 “복구 성공”을 구분하고, 복구 실험에서 확인할 것을 세 가지 이상 댈 수 있다.
이 장과 연결된 참고 주제
부록 참고편의 요약입니다. 누르면 이 자리에서 열립니다.
종합 과제 — 촬영 데이터 쓰기 부하를 해결하라
지금까지 배운 것을 한 사례에 모읍니다. 한 제품이 정답인 문제가 아닙니다. 모르는 조건을 드러내고, 측정이 바꿀 수 있는 결정을 분명히 하는 답이 좋은 답입니다.
이것은 실제 이력서의 재현이 아닙니다. 시작하기의 이력서 문장에서 출발한 가상의 서비스입니다. 수치는 연습을 위한 가정입니다.
문제 자료
- 활성 사용자 10,000명이 평균 2초마다 600바이트의 촬영 상태를 보냅니다. 단순 계산으로 초당 5,000회의 상태 요청입니다. 피크는 평균의 3배라고 가정합니다.
- 각 요청은 상태를 갱신하고 감사 이벤트를 추가합니다.
- RDB IOPS가 피크에 크게 늘었지만, 어떤 SQL·인덱스·체크포인트가 기여하는지는 아직 측정하지 않았습니다.
처음 제공하는 요구. 최신 상태 화면은 5초 지연을 허용합니다. 확정 종료는 손실 없이, 재전달을 처리해야 합니다. 과거의 모든 중간 상태를 보존해야 하는지는 미확정입니다. 주된 조회는 사용자·세션별이며 일별 운영 집계도 필요합니다. 비용 목표의 정확한 금액과 운영팀이 추가로 맡을 수 있는 책임의 범위는 미확정입니다.
제출물 1~5
- 확정된 사실, 계산, 가설, 미확정 요구를 네 칸으로 나눕니다. 빠진 정보는 억지로 채우지 말고, 어느 결정이 그 답에 달려 있는지 적습니다.
- 논리 쓰기 횟수·WAL·IOPS·지연·키 분포를 어떻게 측정할지 적습니다. 저장 요청 수의 감소를 물리 IOPS 감소라고 표현하지 않습니다.
- 기존 RDB 개선, 합치기, 샤딩, DynamoDB 분리 중 적어도 세 가지를 비교합니다. 필요하면 Redis를 더하되 저장되는 데이터와 손실 조건을 밝힙니다.
- 실패 시점 세 곳을 정합니다. 접수 후 저장 전, 커밋 후 응답 전, 저장소 전환 중을 권합니다. 사용자에게 보일 상태와 재처리 방법을 적습니다.
- 하나의 대안을 작은 실험으로 검증하고, 10장의 ADR 양식으로 임시 결정을 내립니다.
- 제출물 1: 네 칸 분류
- 제출물 2: 측정 계획
- 제출물 3: 대안 비교표
- 제출물 4: 실패 시점 세 곳
- 제출물 5: 작은 실험과 ADR
제출물 1 연습 — 네 칸으로 나누기
제출물 2 도움말 — 무엇을 어떻게 잴까
| 재는 것 | 어떻게 | 다시 볼 곳 |
|---|---|---|
| 논리 쓰기 횟수 | SQL 종류별 호출 수, 테이블별 추가·갱신 행 수 | 2장 |
| WAL | 대표 문장의 EXPLAIN (ANALYZE, BUFFERS, WAL), 구간별 WAL 증가량 | 2장 |
| IOPS | 저장 장치 지표. 측정 경계(인스턴스·볼륨)와 I/O 크기를 함께 | 2장 |
| 지연 | API별 p95·p99, 시간 초과 비율, 업무 완료율 | 5장·9장 |
| 키 분포 | 사용자·세션별 초당 쓰기의 상위 N개와 분포 | 6장 |
제출물 3 틀 — 같은 조건으로 비교하기
| 기존 RDB 개선 | 상태 갱신 합치기 | RDB 샤딩 | DynamoDB 분리 | |
|---|---|---|---|---|
| 줄이는 것 | 불필요한 인덱스·갱신 비용 | 논리 쓰기 횟수 | 한 서버에 몰리는 부하 | RDB의 상태 쓰기 |
| 새로 생기는 일 | 작다 | 잃어도 되는 범위의 정의, 버퍼 장애 처리 | 샤드 찾기, 가로지르는 조회, 재배치 | 키 설계, 두 저장소 운영, 이전·복구 계획 |
| 탈락 조건 | 측정해 보니 저장 장치 한계 | 모든 중간 상태를 보존해야 함(카드 A) | 작은 팀이 운영을 감당 못 함 | 한 키로 쏠림(카드 B), 강한 일관성 필요 |
제출물 4 틀 — 실패 시점 세 곳
| 실패 시점 | 사용자에게 보일 상태 | 재처리 방법 |
|---|---|---|
| 접수 후 저장 전 | “저장 중”. 성공이라고 말하지 않는다 | 클라이언트가 같은 멱등 키로 재전송(7장) |
| 커밋 후 응답 전 | 클라이언트는 결과를 모름 → “확인 중” | 같은 키로 재요청하면 저장된 결과를 돌려준다 |
| 저장소 전환 중 | 평소와 같아야 한다 | 두 곳의 대사, 되돌릴 마지막 시점과 책임자(10장) |
조건 변경 카드
답을 쓴 뒤 카드를 하나씩 뒤집어 보세요. 조건이 바뀌면 결론도 바뀌어야 합니다.
카드 A이제 모든 2초 간격 상태를 1년 동안 감사할 수 있게 보존해야 합니다.
최신 값만 남기는 합치기는 이 요구를 채우지 못합니다. 모든 사건을 내구성 있게 남기는 경로와 최신 상태를 투영하는 경로를 나누는 후보를 검토합니다.
카드 B전체 사용자는 많지만 인기 세션 하나에 쓰기의 40%가 몰립니다.
사용자·세션 키를 쓴다는 사실만으로 분산 효과를 가정할 수 없습니다. 같은 항목의 갱신 의미와, 나눈 뒤의 읽기·순서를 다시 검토합니다(6장 키 설계 놀이터).
카드 C네트워크 장애로 ACK를 잃고 같은 이벤트가 10번 전달됩니다.
부하 실험의 성공 요청 수가 10배여도 고유 업무 성과가 10배는 아닙니다. 멱등 반영과 시도 수를 따로 측정합니다(7장).
카드 D인프라 비용은 내려갔지만 매주 수동 대사가 필요해졌습니다.
총비용과 운영 위험에 대사·복구 시간을 넣고, 자동화하거나 설계를 되돌릴지 검토합니다(6장 “무엇까지 주장할 수 있나”, 10장).
해설 — 좋은 답의 형태
좋은 답은 모르는 조건을 드러내고, 측정이 바꿀 수 있는 결정을 분명히 합니다. 예를 들어 감사 요구를 먼저 확정하고, 상태와 이벤트의 쓰기 비중을 측정한 뒤, 기존 RDB의 불필요한 인덱스를 지우는 실험과 상태 갱신 합치기를 따로 비교할 수 있습니다. 피크와 쏠림에서 한계가 확인되면 접근 패턴에 맞춘 DynamoDB PoC나 샤딩 후보로 좁힙니다.
반대로 중간 전제가 빠진 다음 같은 답은 다시 씁니다. 제품을 고르는 것보다 그 결론이 성립하는 조건을 쓰는 것이 중요합니다.
- “쓰기가 많다 → NoSQL”
- “빠르게 하려면 Redis”
- “IOPS 75% 감소 → 비용 75% 감소”
- “큐를 넣으면 과부하 해결”
자기 평가표
각 항목을 0~2점으로 매깁니다. 0점은 없거나 틀림, 1점은 설명만 있음, 2점은 사례의 증거·실험·반증 조건과 연결됨입니다.
다음에는 재고 예약이나 알림 발송 같은 다른 도메인에 같은 틀을 적용해 보세요. 같은 설명을 낯선 조건에서 고칠 수 있어야 배운 것이 옮겨 간 것입니다.
부록 A 참고 주제 80
원문의 80개 주제를 빠짐없이 옮겼습니다. 본문에서는 배우는 재료였던 주제를, 여기서는 다시 찾아보는 요약으로 씁니다.
참고편을 읽는 법
각 주제에는 연결된 본문 장과 학습 우선순위를 붙였습니다.
- 핵심 복습본문 원리를 다시 확인하는 주제입니다. 46개.
- 선택 심화실제 진단이나 설계에서 관련 질문이 생겼을 때 넓혀 읽을 주제입니다. 34개.
이 표시는 공부 순서일 뿐, 어느 직급이라면 모두 알아야 한다는 평가표가 아닙니다.
핵심 복습 주제라도 처음 만나는 용어는 본문 원리와 함께 읽으세요. 요약만 읽고 Raft·TLS·락프리 자료구조 같은 것을 직접 안전하게 구현할 수 있다고 보지 않습니다. 원문 쪽수는 2026-08 제목 정합 보강판(101쪽 PDF) 기준입니다. 이 교과서는 참고편과 용어를 원문 그대로 옮기고, PDF에서 줄이 바뀐 자리의 띄어쓰기만 바로잡았습니다.
원문의 개정 기록 보기
유지한 것. 80개 주제의 정의·비유·선택 기준·함정·운영 관점. 특히 보장 범위와 운영 책임을 묻는 접근.
추가한 것. 요청·저장 기초, 10개 연결 장, 해설 있는 연습, 표준 라이브러리 실험 3개, PostgreSQL 실습 4종, 하나의 종합 사례, 단계별 완료 기준, 버전 있는 주요 출처. 용어 보강판에서는 주요 약어의 영어 전체 이름과 한국어 뜻, 장별 선행 용어 설명, IOPS 계산·비교 예제, 용어별 예시를 더했습니다. 제품 이름과 SQL 명령에는 억지로 약어 풀이를 붙이지 않았고, 문맥에 따라 뜻이 다른 RDB·PK·ANALYZE 같은 표현도 구분했습니다.
교정한 것. Redis Cluster와 hash ring의 구분, ConcurrentHashMap의 연산별 진행 보장, 멱등 기록과 업무 변경의 원자성, CAP 정의, 바이너리 인코딩과 압축의 구분, 캐시 패턴과 일관성 요구의 구분, page fault와 디스크 I/O의 구분. Raft의 현재 term 엔트리 커밋 조건, 커넥션 점유 설명, 풀 크기 표현도 보완했습니다.
검증 범위. 새 본문의 주요 원리와 위 교정 항목은 공식 문서와 원문 자료로 대조했습니다. 참고편의 나머지 설명은 원문을 보존·재배치한 것이며, 모든 제품 버전의 동작을 새로 전수 실험한 것은 아닙니다.
주제 목록
장이나 우선순위로 거르거나 검색하세요. 제목을 누르면 비유·작동 원리·실무 적용·선택 기준·흔한 함정·운영 체크가 펼쳐집니다.
부록 B 용어 찾아보기
본문에서 점선 밑줄 용어를 누르면 여기 있는 뜻이 바로 뜹니다. 외울 목록이 아니라 읽다가 막혔을 때 찾는 자료입니다.
약어는 영어 전체 이름을, 제품·개념·명령은 원어나 역할을 적었습니다. 본문에서 쓰인 용어에는 ‘본문에서’ 링크가 붙어 처음 나온 자리로 돌아갈 수 있습니다.
부록 C 주요 출처와 검증 범위
본문의 S1 같은 표시를 누르면 해당 출처가 뜹니다.
2026-09-20 검토. PostgreSQL 실습과 원리는 17 문서를 기준으로 한다. 관리형 서비스의 제약·기능·가격은 변할 수 있으므로 실제 도입 때 배포 리전·모드·버전의 문서를 다시 확인한다. 아래는 새 본문과 집중 교정의 근거다. 원문 참고편의 모든 구현 세부를 전수 검증했다는 뜻은 아니다.
이 교과서의 2장 실험 B는 PostgreSQL 16.13에서 실행했습니다. HOT 조건과 WAL의 기본 동작은 17 문서의 설명과 같습니다.보강