트래픽이 오른 날
터지는 것들
DB · 백엔드 장애 85
트래픽이 오른 날
터지는 것들
개발 환경에서는 멀쩡했는데, 트래픽이 오른 날 무너져요. 코드는 한 줄도 안 바뀌었는데요. 그런 순간만 85개 모았어요.
교과서 목차는 없어요. "인덱스란 무엇인가" 같은 건 검색하면 나오니까요.
이 책에 담은 건 코드에 버그가 없는데 사고가 나는 것, 상식적인 설정이 모여 장애가 되는 것들이에요. 한 편은 실제로 겪는 장면에서 시작해서, 지금 우리 시스템에 바로 돌려 볼 수 있는 점검 쿼리로 끝나요.
이런 날들이에요
차례에서 뽑은 아홉 편이에요. 제목 아래는 그 편의 첫 문장이고요. 겪어 본 장면이 하나라도 있다면, 나머지 76편도 비슷한 결이에요.
한 편은 이렇게 생겼어요
85편이 전부 같은 뼈대예요. 읽다가 덮어도 어디까지 봤는지 알 수 있고, 필요한 편만 골라 펴도 흐름이 끊기지 않아요.
- 현상 — 실제로 겪는 장면
- 원리 — 왜 그렇게 되는가
- 코드 — 문제 코드(✕)와 고친 코드(✓)
- 실전 — 현장 수치, 더 나쁜 경우, 놓치기 쉬운 것
- 처방 — 무엇을 어떻게 바꾸나
- 함정 — 처방이 부르는 새 문제
- 점검 — 지금 우리 시스템을 확인하는 쿼리·명령
- 근거 — 출처와 참고
- 핵심 한 줄 — 덮고 나서 남는 문장
본문 3편, 통째로
요약이 아니라 책에 실린 그대로예요. 셋 다 블로그에 없는 책 전용 편이고요. 이 정도가 85편의 평균이라고 보시면 돼요.
커넥션 풀을 키우면 오히려 느려진다
응답이 느려서 커넥션 풀을 20에서 100으로 올렸어요. 그런데 처리량이 오히려 떨어지고 p99 는 더 튀었어요. DB CPU 는 100% 에 붙어 있는데 실제로 끝나는 쿼리 수는 줄었고요. 리소스를 더 줬는데 결과가 반대예요.
모니터링만 보면 "DB 가 바쁘다"로 읽히니까 풀을 더 키우고 싶어져요. 그게 정확히 반대 방향이에요.
이 현상은 개발 환경에서 절대 안 나와요. 동시 요청이 몇 개 없으니 풀이 20이든 200이든 실제로 쓰이는 건 두어 개거든요. 풀 크기는 트래픽이 오르기 전까지는 그냥 숫자예요. 그래서 부하 테스트도 동시성을 충분히 올리지 않으면 이 구간을 못 만나요.
증상은 대개 이렇게 와요. 트래픽이 평소의 두 배가 되는데 응답 시간은 다섯 배, 열 배로 벌어져요. 서버를 늘려도 나아지지 않고, 오히려 파드가 늘어난 만큼 더 나빠져요.
DB 가 동시에 진짜로 일할 수 있는 양은 정해져 있어요. CPU 바운드 워크로드면 코어 수, IO 바운드면 스토리지가 감당하는 동시 요청 수요. 그 이상을 밀어 넣으면 리틀의 법칙이 그대로 나타나요.
리틀의 법칙은 L = λW 예요. 시스템 안에 머무는 요청 수 L, 도착률 λ, 한 요청의 체류 시간 W. 여기서 실제 처리 능력(λ)은 코어 수에 막혀 있는데 L(동시에 열린 커넥션)만 늘리면, 등식이 성립하려면 W 가 비례해서 늘 수밖에 없어요. 처리량은 그대로인데 지연만 커지는 구간이 만들어지는 거예요.
그런데 현실은 이보다 나빠요. 곡선이 평평해지는 데서 끝나지 않고 꺾여서 내려가요. 동시 실행이 코어를 넘으면 컨텍스트 스위칭이 늘고, 버퍼 풀·락 매니저처럼 모두가 함께 쓰는 자료구조에서 경합이 생기고, 각 쿼리가 CPU 를 잡는 시간이 잘게 쪼개지면서 캐시 지역성이 깨져요. 일하는 시간보다 자리 바꾸는 시간이 커지는 거죠.
그래서 풀을 키운다는 건 "더 많이 일하게 한다"가 아니에요. "이 꺾인 구간으로 더 깊이 들어간다"예요. 커넥션은 자원이 아니라 대기열이에요.
· HikariCP 위키의 공식 (원출처는 PostgreSQL 위키) ✓ pool_size = (core_count * 2) + effective_spindle_count · core_count 는 앱 서버가 아니라 · 쿼리를 실제로 실행하는 DB 서버의 코어 수다 · effective_spindle_count 는 SSD 면 1 · → 4코어 SSD 인스턴스면 (4*2)+1 = 9 · 그리고 이 값은 인스턴스 하나가 아니라 · 앱 전체를 합친 상한이다 ✕ 4코어 DB × 파드 10 × 풀 50 = 500 커넥션 요청 ✕ max_connections 를 넘으면 느려지는 게 아니라 끊긴다 ✕ FATAL: sorry, too many clients already
숫자가 어디서 오는지 짚어 볼게요.
PostgreSQL 은 커넥션마다 백엔드 프로세스를 포크해요. 그래서 유휴 커넥션도 공짜가 아니에요. 트랜잭션이 스냅샷을 뜰 때 전체 백엔드를 훑는 구조라, 놀고 있는 커넥션이 많으면 일하고 있는 쿼리까지 느려져요. PostgreSQL 14 에서 이 스냅샷 확장성이 크게 개선됐지만 0 이 되진 않았어요.
max_connections 도 넉넉히 잡아 두면 되는 값이 아니에요. 서버는 이 값에 비례해서 락 테이블 같은 공유 메모리를 기동할 때 미리 잡아요. 크게 잡아 두는 것 자체가 상시 비용이에요.
공식도 맥락이 있어요. (core_count * 2) + effective_spindle_count 는 회전 디스크를 전제로 만들어진 식이에요. 디스크가 헤드를 움직이는 동안 CPU 가 놀 수 있으니 코어 수보다 조금 더 열어 둔다는 발상이거든요. SSD 면 effective_spindle_count 를 1 로 봐서 4코어에 9 정도가 나와요. 출발점으로는 쓸 만하지만 정답은 아니에요. 쿼리가 CPU 를 태우는 종류냐, 락이나 IO 를 기다리는 시간이 긴 종류냐에 따라 최적점이 크게 달라져요.
그리고 이 숫자는 앱 전체를 합친 상한이에요. 파드 10개가 각자 풀 50 을 들고 있으면 DB 입장에서는 500 커넥션 요청이에요. 오토스케일이 도는 환경에서 트래픽이 오르면 파드가 늘면서 이 선을 한 번에 넘어요. 그때는 느려지는 게 아니라 연결이 거부돼요.
MySQL 은 프로세스가 아니라 스레드라서 커넥션 하나의 비용이 훨씬 싸요. 메모리로는 덜 아파요. 그런데 동시 실행이 코어를 넘으면 같은 꺾임이 그대로 나와요. 싼 건 커넥션이지 동시성이 아니에요.
순서가 있어요. 이 순서를 건너뛰면 대개 실패해요.
1. 풀 크기부터 만지지 마세요. 풀이 마르는 건 대개 증상이에요. 느린 쿼리 하나가 커넥션을 오래 붙잡고 있는 거고요. pg_stat_statements 에서 호출 횟수가 아니라 총 소요 시간(total_exec_time) 상위부터 보세요. 100ms 짜리가 하루 10만 번 도는 게 3초짜리가 100번 도는 것보다 훨씬 큰 문제예요.
2. 트랜잭션을 짧게. 트랜잭션 안에서 외부 API 를 부르거나 파일을 쓰거나 사용자 입력을 기다리면, 그 시간 내내 커넥션이 묶여 있어요. 풀이 100개여도 각자 3초씩 붙잡으면 초당 33개밖에 못 처리해요. 커넥션 수가 아니라 점유 시간이 처리량을 정합니다.
3. 그다음에 크기를 정해요. 계산으로 맞히는 게 아니라 재서 찾아요. 부하를 계단식으로 올리면서 처리량과 p99 를 같이 그리세요. 처리량이 평평해지기 시작하는 지점이 그 시스템의 무릎이고, 그 너머는 지연만 늘어요.
4. 파드가 늘어나는 환경이면 앱 풀을 키우는 대신 앞에 pgbouncer 를 두세요. 앱 쪽 커넥션은 많게, 실제 DB 커넥션은 적게 가져가는 게 목적이에요. 방향이 반대예요.
5. 획득 타임아웃을 짧게 잡으세요. 풀이 말랐을 때 무한정 기다리게 두면 요청이 전부 큐에 쌓이고 앱 스레드까지 말라요. 다 같이 기다리다 같이 죽는 것보다, 일부가 빨리 실패하고 나머지가 사는 게 나아요.
처방대로 pgbouncer 를 넣었는데 그때부터 이상한 버그가 나기 시작한다면, pool_mode 를 보세요.
transaction 모드는 트랜잭션 단위로 커넥션을 섞어요. 그래서 세션에 붙는 것들이 조용히 깨져요. SET 으로 잡은 세션 변수, 세션 수준 어드바이저리 락, LISTEN/NOTIFY, WITH HOLD 커서, 세션 임시 테이블이 전부 여기 해당해요.
조용하다는 게 핵심이에요. 예외가 나면서 멈추는 게 아니라, 다음 트랜잭션이 다른 커넥션에 앉으면서 방금 설정한 게 그냥 없어져요. 개발 환경에서는 커넥션이 사실상 하나라 절대 안 드러나요. 트래픽이 올라 커넥션이 섞이기 시작하는 날 터집니다.
프리페어드 스테이트먼트도 오래 이 목록에 있었어요. pgbouncer 1.21(2023년 10월)부터 transaction 모드에서 지원돼요. 다만 max_prepared_statements 를 0 이 아닌 값으로 켜 줘야 동작하고, 기본값이 0 이라 켜지 않으면 그대로 깨져요. 그 이전 버전이라면 드라이버 쪽에서 서버 사이드 프리페어를 꺼야 하고요.
· 커넥션이 무슨 상태로 있나 SELECT state, count(*) FROM pg_stat_activity GROUP BY state; ✕ idle in transaction 이 많으면 ✕ 커넥션 부족이 아니라 코드 문제다 ✕ 트랜잭션이 안 닫히고 있다 · active 가 무엇을 기다리는지까지 SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE state = 'active' GROUP BY 1,2 ORDER BY 3 DESC; · 앱 쪽 — 풀이 마르면 '대기 중'이 쌓인다 ✓ hikaricp_connections_pending > 0 이 지속되면 ✓ 풀이 부족하거나 오래 붙잡는 쿼리가 있다 ✓ hikaricp_connections_acquire_seconds 의 p99 를 같이 본다
- 리틀의 법칙 L = λW — 대기행렬 이론의 기본 항등식
- 풀 크기 공식 — HikariCP 위키 About Pool Sizing (Brett Wooldridge), 원출처는 PostgreSQL 위키의 회전 디스크 전제 공식
- 유휴 커넥션의 스냅샷 비용과 PostgreSQL 14 개선 — 스냅샷 확장성 작업 (Andres Freund)
- pgbouncer transaction 모드의 프리페어드 스테이트먼트 지원 — pgbouncer 1.21 (2023-10), max_prepared_statements 설정 필요
긴 트랜잭션 하나가 DB 전체를 부풀린다
디스크 사용량이 계속 늘어요. 데이터는 안 늘었는데 테이블 크기만 커지고, autovacuum 은 분명히 돌고 있는데 아무것도 회수하지 못해요.
VACUUM VERBOSE 를 돌려 보면 "아직 지울 수 없는 죽은 행"이 잔뜩 잡혀요. 청소부가 왔다 갔는데 쓰레기는 그대로인 상태예요.
디스크를 늘리면 며칠 조용해지다가 같은 자리로 돌아와요. 지운 데이터가 사라지지 않는 것처럼 보이는 게 이 문제의 특징이에요.
이상한 건 특정 테이블만 그런 게 아니라는 점이에요. 손댄 적 없는 테이블까지 같이 부풀어 있고, 쿼리도 전반적으로 조금씩 느려져 있어요. 원인을 테이블 쪽에서 찾으면 아무것도 안 나옵니다.
PostgreSQL 은 갱신을 제자리에서 하지 않아요. UPDATE 는 새 버전을 쓰고 옛 버전을 죽은 행으로 남겨요. DELETE 도 실제로 지우는 게 아니라 표시만 하고요. 이 죽은 행을 나중에 회수하는 게 VACUUM 이에요.
그런데 VACUUM 은 아무 죽은 행이나 지우지 못해요. 아직 살아 있는 트랜잭션이 볼 수도 있는 버전은 못 지워요. 각 트랜잭션은 시작 시점의 스냅샷을 들고 있고, 그 스냅샷 기준으로는 방금 죽은 행이 아직 살아 있는 데이터거든요. 지워 버리면 그 트랜잭션이 읽을 게 없어져요.
그래서 회수 가능한 경계를 정하는 건 가장 오래된 살아 있는 트랜잭션 하나예요. 한 세션이 BEGIN 만 해 놓고 놀고 있으면(idle in transaction), 그 시점 이후에 죽은 모든 행이 회수 불가 상태로 쌓여요.
autovacuum 은 그동안에도 계속 돌아요. 다만 매번 "지울 게 없다"고 판단하고 나가요. 일을 안 하는 게 아니라 일할 수 없는 상태인 거예요. 그래서 autovacuum 로그만 보면 정상으로 보입니다.
· 트랜잭션을 열어 놓고 노는 세션을 끊는다 ✓ idle_in_transaction_session_timeout = '5min' · 너무 오래 도는 쿼리 자체도 끊는다 ✓ statement_timeout = '30s' ✕ 둘 다 기본값이 0 이고, 0 은 '무제한'이다 · 배치·리포트 계정은 따로 넉넉히 준다 ✓ ALTER ROLE batch SET statement_timeout = '30min';
이 문제의 무서운 점은 범위예요. 스냅샷은 특정 테이블이 아니라 DB 전역이에요. 주문 테이블을 보려고 열어 둔 트랜잭션 하나가, 전혀 상관없는 로그 테이블의 청소까지 막아요. 그래서 "제일 많이 쓰는 테이블이 부풀었다"가 아니라 쓰기가 많은 테이블 전부가 같이 부풀어요.
실제로 6시간짜리 유휴 트랜잭션 하나가 100만 행 테이블을 50GB 로 부풀린 사례가 있어요. 49GB 가 지울 수 없는 죽은 행이었죠.
부풀기만 하는 것도 아니에요. 죽은 행이 쌓이면 같은 데이터를 읽는 데 더 많은 페이지를 읽어야 하니 쿼리가 같이 느려져요. 인덱스도 함께 부풀고, 플래너 통계도 실제 분포에서 멀어지기 시작해요. 그래서 "디스크가 는다"로 시작한 증상이 "전반적으로 느리다"로 번져요.
더 나쁜 경우가 있어요. 회수가 오래 막히면 트랜잭션 ID 순환 위험 구간으로 들어가고, PostgreSQL 은 자신을 지키려고 쓰기를 아예 거부해요. 그 시점에는 선택지가 별로 없습니다.
주범은 보통 사람이 아니라 도구예요. BI 대시보드가 연결을 유지한 채 세션을 열어 두는 경우, 마이그레이션 툴이 큰 트랜잭션을 잡고 있는 경우, ORM 이 세션을 반납하지 않는 경우, 그리고 예외를 삼키고 롤백을 안 한 코드. 사람이 psql 을 열어 놓고 점심 먹으러 간 경우도 의외로 흔해요.
1. idle_in_transaction_session_timeout 을 반드시 켜세요. 기본값 0 은 '영원히'예요. 5분 정도로 시작하면 대부분의 사고를 막아요. 이건 트랜잭션을 열어 놓고 아무것도 안 하는 세션만 끊어요. 도는 쿼리는 안 건드립니다.
2. statement_timeout 도 같이 거세요. 다만 배치나 리포트 계정은 역할 단위로 넉넉히 따로 주는 게 좋아요. 전역으로 짧게 걸면 야간 배치가 죽어요.
3. 애플리케이션에서 트랜잭션 경계를 좁히세요. 읽기만 하는 조회에 트랜잭션을 걸지 말고, 트랜잭션 안에서 외부 호출을 하지 마세요. 사람의 응답을 기다리는 트랜잭션은 특히 위험해요.
4. BI·분석 도구는 읽기 복제본으로 보내세요. 다만 복제본에서 오래 도는 쿼리가 주 서버의 회수를 다시 막을 수 있으니(아래 함정), 이 설정은 의식적으로 정해야 해요.
5. 모니터링에 가장 오래된 트랜잭션의 나이를 지표로 올리세요. 테이블 크기가 늘어나는 걸 보고 알아차리는 것보다 훨씬 빨라요. 임계값은 idle_in_transaction_session_timeout 보다 낮게 잡으면 타임아웃이 걸리기 전에 알 수 있어요.
타임아웃을 켰는데도 회수가 안 되면, 막고 있는 게 유휴 트랜잭션이 아닐 수 있어요. 후보가 셋 더 있고, 셋 다 pg_stat_activity 에는 안 보여요.
복제 슬롯. 물리든 논리든 슬롯의 소비자가 죽었거나 크게 뒤처져 있으면, 슬롯이 WAL 과 함께 회수를 붙잡아요. pg_replication_slots 를 따로 봐야 해요. 쓰다 버린 논리 복제 슬롯이 남아 있는 경우가 특히 흔합니다.
프리페어드 트랜잭션. 2단계 커밋을 쓰다가 커밋도 롤백도 안 된 채 남은 게 있으면 영원히 스냅샷을 붙잡아요. pg_prepared_xacts 에서 확인해요.
복제본의 오래 도는 쿼리. hot_standby_feedback = on 이면 복제본에서 도는 쿼리가 주 서버의 회수를 막아요. 이건 버그가 아니라 의도된 동작이에요. 끄면 복제본의 긴 쿼리가 취소되고, 켜면 주 서버가 부풀어요. 둘 중 하나를 고르는 문제예요.
그리고 부푼 걸 되돌리는 건 별개 작업이에요. VACUUM 은 공간을 재사용 가능하게 만들 뿐 OS 에 돌려주지 않아요. 파일 크기를 실제로 줄이려면 VACUUM FULL 이 필요한데 테이블 전체에 배타 락을 걸어요. 서비스 중이라면 pg_repack 처럼 락을 짧게 잡는 도구를 쓰세요.
· 1) 가장 오래된 트랜잭션 SELECT pid, state, now()-xact_start AS age, query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY age DESC LIMIT 5; · 2) 복제 슬롯 — 여기가 자주 범인이다 SELECT slot_name, active, pg_size_pretty( pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots; · 3) 남아 있는 2단계 커밋 SELECT * FROM pg_prepared_xacts; · 4) 테이블이 실제로 얼마나 부풀었나 SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
- MVCC 와 죽은 행 회수 — PostgreSQL 은 UPDATE 를 제자리 갱신이 아니라 새 버전 쓰기로 처리한다
- 회수 경계는 가장 오래된 살아 있는 스냅샷이 정한다 — 그래서 영향이 DB 전역이다
- hot_standby_feedback 은 복제본 쿼리 취소와 주 서버 팽창 사이의 맞바꿈이다
- VACUUM 은 공간을 재사용 가능하게 만들 뿐 파일을 줄이지 않는다 — 축소는 VACUUM FULL 또는 pg_repack
퍼센타일은 평균낼 수 없다
인스턴스마다 p99 를 계산해서 대시보드에서 평균을 내요. 그래프는 550ms 로 안정적인데 사용자 불만은 계속 들어와요. 로그를 뒤져 보면 2초 넘게 걸린 요청이 수두룩하고요.
지표가 고장 난 게 아니에요. 애초에 계산할 수 없는 값을 계산한 거예요.
이 실수가 오래 사는 이유는 대시보드가 멀쩡해 보이기 때문이에요. 숫자가 튀지도 않고 그래프도 안정적이라 아무도 의심하지 않아요. 오히려 값이 안정적인 게 문제의 증거인데, 그걸 "우리 서비스가 안정적이다"로 읽어요.
장애 회고에서 이 지표가 다시 나와요. "그때 p99 가 550ms 였는데요"라는 문장이 나오고, 사용자 로그와 안 맞는 이유를 아무도 설명하지 못한 채 회고가 끝나요.
퍼센타일은 평균이 되는 값이 아니에요.
서버 두 대가 있고 한 대의 p99 가 100ms, 다른 한 대가 1000ms 라고 해 볼게요. 둘을 평균하면 550ms 인데, 전체 요청을 한 줄로 세워서 구한 진짜 p99 는 1000ms 에 가까워요. 평균은 두 값 사이 어딘가를 가리키지만 분위수는 그렇게 움직이지 않아요.
이유는 분위수가 순서 통계량이기 때문이에요. p99 는 "전체를 정렬했을 때 99% 지점의 값"이에요. 이건 부분집합의 p99 들로부터 복원할 수 없어요. 각 부분집합에 요청이 몇 개 있었는지, 그 안의 분포가 어땠는지를 이미 버렸거든요.
평균은 다릅니다. 각 부분의 평균과 개수만 알면 가중평균으로 전체 평균이 정확히 복원돼요. 평균은 결합 가능하고 분위수는 아니에요. 이 차이가 전부예요.
시간 축도 똑같아요. 1분 단위 p99 를 한 시간 평균 내면 그 값도 의미가 없어요. 그래프 도구가 여러 점을 한 픽셀에 그릴 때 자동으로 평균 내는 것도 같은 문제고요. 줌 아웃하면 뾰족한 게 사라지는 현상이 여기서 나와요.
✕ avg(p99_per_instance) ✕ 통계적으로 아무 의미 없는 숫자다 ✓ histogram_quantile(0.99, ✓ sum by (le) ( ✓ rate(http_duration_bucket[5m]))) · 순서가 핵심이다 · sum by (le) 로 버킷을 먼저 합치고 · 그 위에서 분위수를 계산한다 ✕ sum by (le, instance) 로 두면 ✕ 인스턴스별 분위수가 나오고, ✕ 그걸 평균 내면 처음의 그 실수로 돌아간다
그래서 진짜 문제는 이거예요. 꼬리를 보려고 만든 지표를 평균이 다시 뭉개요. p99 를 보는 이유는 평균이 감추는 최악을 보려는 건데, 그 p99 를 또 평균하면 원점이에요.
올바르게 합칠 수 있는 건 두 가지뿐이에요. 원본 데이터, 아니면 병합 가능한 요약 구조. 히스토그램, t-digest, HdrHistogram 같은 것들이 여기 속해요. 히스토그램은 "10ms 이하에 N개, 50ms 이하에 M개" 식으로 버킷별 개수를 들고 있어서, 여러 인스턴스의 버킷을 더한 다음 분위수를 계산하면 전체 기준의 올바른 값이 나와요.
Prometheus 의 histogram_quantile 이 정확히 그 순서예요. 버킷을 먼저 합치고, 그다음에 분위수를 낸다. 순서가 뒤바뀌면 안 돼요.
여기서 Prometheus 의 두 지표 타입 차이가 중요해져요. summary 타입은 클라이언트에서 분위수를 미리 계산해 내보내요. 인스턴스 하나를 볼 때는 정확하지만 여러 인스턴스에 걸쳐서는 합칠 수 없어요. 서비스 전체의 p99 가 필요하면 histogram 을 써야 해요. 이건 나중에 바꿀 수 있는 선택이 아니에요. summary 로 내보낸 과거 데이터는 영원히 합칠 수 없거든요.
히스토그램에도 대가가 있어요. 버킷 경계가 성기면 분위수가 부정확해요. le="1" 다음이 le="10" 이면 1초에서 10초 사이는 보간으로 추정하는데, 실제 분포가 한쪽에 몰려 있으면 크게 어긋나요. 반대로 버킷을 촘촘히 두면 시계열 개수가 늘어 저장 비용과 조회 비용이 같이 올라가고요.
1. 지표는 히스토그램으로 내보내고, 분위수는 항상 조회 시점에 계산하세요. 미리 계산된 퍼센타일을 저장하면 나중에 어떤 방식으로도 올바르게 합칠 수 없어요. 되돌릴 수 없는 정보 손실이에요.
2. 버킷 경계를 SLO 기준으로 잡으세요. "200ms 안에 응답"이 목표면 200ms 근처를 촘촘하게 두세요. 라이브러리 기본 버킷을 그대로 쓰면 정작 관심 있는 구간이 성긴 경우가 많아요.
3. 대시보드에 평균과 분위수를 같이 올리세요. 둘이 벌어지는 폭 자체가 신호예요. 평균은 그대로인데 p99 만 오르면 소수의 요청이 나빠지고 있다는 뜻이거든요.
4. 알림은 분위수보다 SLO 위반 비율로 거는 편이 나아요. "p99 > 1s" 대신 "1초를 넘긴 요청의 비율이 1% 초과". 이 값은 카운터의 비율이라 여러 인스턴스에 걸쳐 합쳐도 정확하고, 사용자가 느끼는 것과도 더 가까워요.
5. 그래프를 줌 아웃할 때 어떤 함수로 다운샘플링되는지 확인하세요. 평균으로 뭉개지고 있으면 최댓값으로 바꾸세요. 장애 회고에서 "그때 그래프에는 안 보였는데요"의 절반은 여기서 나와요.
히스토그램으로 바꿨는데도 값이 이상하다면, 라벨을 보세요.
sum by (le) 에서 le 만 남기고 나머지 라벨을 다 지워야 전체 기준 분위수가 나와요. 실수로 instance 나 pod 를 남기면 인스턴스별 분위수가 나오고, 그걸 다시 평균 내면 고치려던 그 실수로 돌아가요. 쿼리는 문법적으로 멀쩡하고 그래프도 그려지니까 알아채기 어려워요.
반대 방향의 함정도 있어요. 라벨을 너무 많이 붙이면 히스토그램이 폭발해요. 히스토그램 하나는 버킷 수만큼의 시계열을 만들어요. 여기에 카디널리티가 높은 라벨(사용자 ID, 정규화하지 않은 URL 경로 같은 것)을 붙이면 시계열 수가 곱셈으로 늘어나 모니터링 자체가 무너져요. 지표를 고치려다 모니터링을 죽이는 셈이에요.
그리고 rate() 의 시간 구간이 스크레이프 간격에 비해 너무 짧으면 표본이 부족해서 분위수가 튀어요. 보통 스크레이프 간격의 4배 이상을 잡아요. 15초 간격이면 1분 이상이요.
· 1) 우리 지표는 histogram 인가 summary 인가 · summary 면 서비스 전체 p99 는 애초에 못 낸다 curl -s localhost:8080/metrics | grep '# TYPE .*duration' · 2) 버킷 경계가 관심 구간을 덮는가 curl -s localhost:8080/metrics | grep 'duration_bucket' ✕ le="1" 다음이 le="10" 이면 ✕ 1~10초는 전부 추정값이다 · 3) SLO 위반 비율 — 합쳐도 정확한 값 ✓ sum(rate(http_duration_bucket{le="1"}[5m])) ✓ / sum(rate(http_duration_count[5m])) · 1초 안에 끝난 비율. 1 에서 빼면 위반율
- 분위수는 순서 통계량이라 부분집합의 분위수로부터 복원되지 않는다 — 평균과 달리 결합 불가능하다
- Prometheus histogram vs summary — summary 는 클라이언트에서 분위수를 미리 계산하므로 인스턴스 간 병합이 불가능하다
- histogram_quantile 은 버킷 합산 뒤에 적용해야 한다 — sum by (le) 를 먼저 둔다
- t-digest · HdrHistogram — 병합 가능한 분위수 요약 구조
차례 85편 전부
숨기지 않을게요. 여섯 개 장을 펼치면 85편의 제목이 전부 나와요. 무료로 읽기 배지가 붙은 82편은 블로그에 공개돼 있고, 나머지 3편은 책에만 있어요.
▶ 1부 데이터베이스1장 기초 체력
- 01FOR UPDATE 에 인덱스가 없으면 테이블 전체가 잠긴다무료로 읽기
- 02MySQL 의 utf8 은 UTF-8 이 아니다무료로 읽기
- 03OFFSET 페이지네이션은 뒤로 갈수록 죽는다무료로 읽기
- 04ALTER TABLE 이 기다리면 뒤에 줄이 선다무료로 읽기
- 05커넥션 풀을 키우면 오히려 느려진다
- 06pgbouncer transaction 모드에서 안 되는 것들무료로 읽기
- 07인덱스를 만드는 동안 쓰기가 막힌다무료로 읽기
- 08안 쓰는 인덱스는 순수한 세금이다무료로 읽기
- 09청소부가 멈추면 DB 가 쓰기를 거부한다무료로 읽기
- 10N+1 — 반복문 하나가 쿼리 101번무료로 읽기
- 11격리 수준은 이름값을 못 한다무료로 읽기
- 12서머타임에 배치가 두 번 돌거나 건너뛴다무료로 읽기
- 13소프트 삭제가 인덱스를 갉아먹는다무료로 읽기
- 14LIKE '%검색어%' 는 인덱스를 못 쓴다무료로 읽기
- 15대량 DELETE 가 복제를 무너뜨린다무료로 읽기
- 16인덱스가 있는데 안 타는 이유무료로 읽기
- 17통계가 낡으면 플래너가 헛짚는다무료로 읽기
- 18복합 인덱스는 순서가 전부다무료로 읽기
- 19JSON 컬럼은 스키마를 미루는 빚이다무료로 읽기
▶ 1부 데이터베이스2장 트랜잭션
- 20트랜잭션 안에서 외부 API 를 부르지 마라무료로 읽기
- 21긴 트랜잭션 하나가 DB 전체를 부풀린다
- 22잃어버린 갱신 — 두 번의 쓰기가 한 번이 된다무료로 읽기
- 23쓰기 스큐 — 각자는 옳은데 합치면 틀리다무료로 읽기
- 24@Transactional 이 조용히 무시되는 순간무료로 읽기
- 25REQUIRES_NEW 가 자기 커넥션 풀을 잠근다무료로 읽기
- 26SKIP LOCKED 로 만드는 작업 큐무료로 읽기
- 27커밋되기 전에 이벤트를 보내지 마라무료로 읽기
- 28트랜잭션은 짧게 — 경계가 곧 비용이다무료로 읽기
- 29낙관적이냐 비관적이냐 — 충돌 확률이 정한다무료로 읽기
- 30배치를 트랜잭션 하나로 돌리지 마라무료로 읽기
- 31읽기 전용인데 쓰기 트랜잭션으로 도는 것들무료로 읽기
▶ 1부 데이터베이스3장 인덱스와 쿼리 플랜
- 32EXPLAIN 은 추정과 실제의 차이를 보는 것이다무료로 읽기
- 33LIMIT 이 붙으면 계획이 통째로 바뀐다무료로 읽기
- 34인덱스 온리 스캔이 안 되는 이유무료로 읽기
- 35정렬이 메모리를 넘으면 디스크로 간다무료로 읽기
- 36여섯 번째 실행부터 느려지는 쿼리무료로 읽기
- 37OR 은 인덱스를 반으로 가른다무료로 읽기
- 38NULL 은 인덱스에서 다르게 논다무료로 읽기
- 39COUNT(*) 는 결국 다 세는 일이다무료로 읽기
- 40인덱스도 부푼다무료로 읽기
- 41인덱스를 탔는데도 느린 경우무료로 읽기
- 42조인 순서가 뒤집히면 100배가 된다무료로 읽기
- 43가장 느린 쿼리가 가장 아픈 쿼리는 아니다무료로 읽기
▶ 2부 백엔드4장 기본기
- 44재시도가 장애를 키운다무료로 읽기
- 45헬스체크가 서버를 죽인다무료로 읽기
- 46배포할 때마다 502 가 조금씩 난다무료로 읽기
- 47컨슈머가 계속 리밸런싱만 한다무료로 읽기
- 48CDN 이 로그인한 사람의 화면을 캐시한다무료로 읽기
- 49타임아웃을 안 걸면 기본값은 '영원히'다무료로 읽기
- 50배포 직후 DB 가 맞는다무료로 읽기
- 51캐시 스탬피드무료로 읽기
- 52평균은 멀쩡한데 사용자는 느리다무료로 읽기
- 53서킷 브레이커무료로 읽기
- 5443초가 24시간이 되다무료로 읽기
- 55오타 하나와 4시간무료로 읽기
- 56정규식 한 줄로 CPU 가 100% 가 된다무료로 읽기
- 57멱등성 — 재시도는 반드시 일어난다무료로 읽기
- 58이중 쓰기 — 가장 조용한 데이터 손실무료로 읽기
- 59에러를 삼키면 장애가 조용해진다무료로 읽기
- 60넘치면 흘려보내라무료로 읽기
- 61페일오버했는데 트래픽이 안 넘어간다무료로 읽기
▶ 2부 백엔드5장 트래픽
- 62오토스케일은 급증을 못 막는다무료로 읽기
- 63새로 뜬 인스턴스가 먼저 죽는다무료로 읽기
- 64핫 키 하나가 샤드 전체를 태운다무료로 읽기
- 65백프레셔가 없으면 메모리가 먼저 죽는다무료로 읽기
- 66분산 환경의 레이트 리밋은 경합한다무료로 읽기
- 67스케일 아웃해도 DB 는 한 대다무료로 읽기
- 68keep-alive 를 안 쓰면 악수만 하다 끝난다무료로 읽기
- 69부하 테스트가 놓치는 것들무료로 읽기
- 70큐로 첫 파도를 평탄화하라무료로 읽기
- 71동시성 제한이 없으면 다 같이 느려진다무료로 읽기
- 72트래픽이 아니라 비용이 먼저 터진다무료로 읽기
- 73장애는 대개 트래픽이 아니라 의존성에서 온다무료로 읽기
▶ 2부 백엔드6장 관측과 배포
다루는 것, 안 다루는 것
✓다뤄요
✕안 다뤄요
- SQL 문법 입문 — JOIN 이 뭔지부터는 다른 책이 훨씬 잘해요
- 특정 프레임워크 설정법 — 원리를 쓰고, 설정은 예시로만 나와요
- 클라우드 콘솔 클릭 가이드 — 화면은 반년이면 바뀌니까요
- 아키텍처 유행 — MSA 를 할지 말지는 이 책의 주제가 아니에요
이런 분께
✓맞아요
- 운영 트래픽을 처음 받아 보는 주니어~3년차 백엔드 개발자
- 장애 회고에서 "왜 몰랐지"를 반복하고 싶지 않은 분
- 혼자 서버와 DB 를 다 보는 1인 개발자
- 면접에서 교과서 답 말고 실전 사례가 필요한 분
✕안 맞아요
- DB 를 처음 배우는 분 — 기초 개념 없이 바로 사고 현장으로 들어가요
- 이론의 계보와 증명을 원하는 분 — 이 책은 현장 쪽이에요
- 복사해서 붙이면 끝나는 정답 코드를 찾는 분 — 진단이 먼저인 책이에요
자주 묻는 것
입문자도 볼 수 있나요?
SELECT 가 뭔지, 서버가 뭔지부터 필요한 분께는 안 맞아요. 반대로 서버나 DB 를 조금이라도 만져 봤다면 읽을 수 있게 썼어요. 위 맛보기 3편이 딱 이 책의 평균 난이도예요 — 읽어 보고 정하시면 돼요.블로그에 공개된 글과 겹치지 않나요?
어떤 DB · 언어 기준인가요?
어떤 파일로 받나요?
환불되나요?
업데이트 되나요?
누가 썼나요?
사는 곳
85편 · 435쪽 · PDF 즉시 내려받기
결제 창이 아직 안 열렸어요. 크몽 등록이 끝나면 이 버튼이 살아나요. 그동안은 블로그에 공개한 82편으로 먼저 판단해 주세요.
환불 — 디지털 콘텐츠라 파일을 내려받은 뒤에는 청약철회가 어려워요. 대신 전체 차례 85편과 본문 3편을 이 페이지에 열어 뒀어요 — 사기 전에 충분히 보고 정하실 수 있게요.
저작권 — 개인이 읽는 용도예요. 사내 공유·재배포·재판매는 안 돼요.