tech · 2026-05-11

웹 서비스 아키텍처: 한 대의 서버에서 거대한 분산 시스템까지

#web#backend#infra#AWS#Docker#K8s#RabbitMQ#Kafka

웹 서비스 아키텍처: 한 대의 서버에서 거대한 분산 시스템까지

모든 거대한 인프라는 한 대의 서버에서 시작했고, 각 컴포넌트는 그 서버가 직면한 구체적인 고통의 답으로 등장했다.


1. 서론: 한 대의 서버라는 출발점

1.1 가장 단순한 웹 서비스

세상의 모든 웹 서비스는 처음에 이렇게 생겼다. 한 대의 컴퓨터가 있고, 그 위에 웹 서버 프로세스가 떠 있고, 같은 컴퓨터 위에 데이터베이스가 돌고 있다. 사용자가 브라우저로 도메인을 치면 DNS가 이 한 대의 IP를 알려주고, 패킷이 그 한 대로 들어가 응답이 나간다. 이게 끝이다. LAMP 스택(Linux + Apache + MySQL + PHP)이 그랬고, 지금도 토이 프로젝트는 이렇게 시작한다.

flowchart LR User[사용자 브라우저] -->|HTTP| Server[단일 서버
Nginx + App + DB] Server -->|디스크 I/O| Disk[(파일/DB)]

이 구조의 미덕은 단순함이다. 배포할 곳은 한 곳, 디버깅할 곳도 한 곳, 백업할 것도 한 곳. 트래픽이 일정 수준 아래라면 이 구조를 굳이 바꿀 이유가 없다. 모든 분산 시스템 책의 첫 페이지는 "할 수 있으면 분산시키지 말라"로 시작한다.

1.2 그런데 왜 이걸로 끝나지 않는가

문제는 사용자가 늘어나면서 시작된다. CPU가 100%에 닿고, 디스크 I/O가 한계에 도달하고, 메모리가 부족해진다. 한 대를 더 큰 한 대로 바꾸는 수직 확장(scale up) 이 첫 번째 답이지만, 이건 곧 한계에 부딪힌다. 가장 큰 서버에도 천장이 있고, 가격은 사양에 비선형적으로 비싸지며, 그 한 대가 죽는 순간 서비스 전체가 죽는다.

이 한계가 모든 분산 컴포넌트의 시작점이다. 캐시도, 큐도, 로드밸런서도, MSA도 결국은 "한 대의 서버 위에서 더는 풀 수 없는 문제" 의 답이다. 이 보고서의 모든 장은 그 한 대의 서버가 어디서 무너졌는지를 설명하고, 그 균열을 채운 컴포넌트가 무엇인지 따라간다.

단일 서버는 죄가 없다. 단지 트래픽과 데이터가 그 위에 쌓이면서, 한 대로는 풀리지 않는 문제들이 등장할 뿐이다.


2. 트래픽이라는 첫 시련: 한 대를 두 대로, 그게 그렇게 어렵다

2.1 어느 서버로 보낼 것인가

서버 한 대로 부족하니 두 대로 늘리자. 명쾌한 해답이지만 곧장 두 가지 질문이 따라온다.

  1. 사용자의 요청을 어느 서버로 보낼 것인가? (라우팅)
  2. 두 서버는 같은 상태(state)를 어떻게 공유할 것인가?

첫 번째 질문에 답하는 것이 로드밸런서(Load Balancer) 다. 요청을 어느 서버로 보낸다는 일은 보기보다 여러 가지를 동시에 한다. 서버 풀에 트래픽을 균등히 나누고, 죽은 서버를 헬스체크로 풀에서 빼고, 필요하면 같은 사용자를 같은 서버로 묶고(세션 친화), 경로·헤더에 따라 다른 서비스로 보낸다.

LB가 어디까지 할 수 있는가는 요청을 어디까지 들여다보는가로 갈린다.

계층 어디까지 보는가 할 수 있는 일 대표 도구
L4 IP와 포트 간단하고 빠른 분배 AWS NLB, HAProxy(L4)
L7 HTTP 헤더·경로·쿠키 경로별 분기, 쿠키 sticky session, SSL 종단, 헤더 재작성 Nginx, HAProxy, Envoy, ALB

L4는 패킷의 봉투 표지(IP·포트)만 보고 그대로 흘려보낸다. 빠르고 가볍지만 봉투 안의 HTTP는 못 보니, 경로별 라우팅이나 SSL 종단 같은 일은 할 수 없다. L7은 봉투를 뜯어 안을 읽고 결정한다. 그래서 한 도메인 뒤에 여러 서비스를 두고 URL 경로에 따라 다른 곳으로 보낼 수 있고, HTTPS를 LB가 한 곳에서 처리해 백엔드는 평문 HTTP만 다루면 되고 인증서·암복호화 부담도 LB가 떠안는다. 대신 요청을 풀고 다시 묶어야 해서 더 무겁다. 실서비스의 외부 진입은 거의 항상 L7로 끝난다.

도구가 정해지면 다음은 "여러 서버 중 어느 하나를 어떻게 고를 것인가"다. 분배 전략의 갈래는 셋이다.

  • 균등 분배: Round Robin, 무작위. 서버 사양과 부하가 비슷할 때 단순하고 충분.
  • 부하 인지: Least Connections, Least Response Time. 처리 시간이 들쭉날쭉할 때 한가한 곳을 고름.
  • 고정(sticky): IP Hash, 쿠키 기반. 같은 사용자를 같은 서버로 묶어 로컬 캐시·세션 친화성을 살림. 단, 무상태 앱 서버 가정과 긴장 관계가 있다.

분배 전략의 선택은 보통 도구 설정 한 줄로 끝난다. 정작 어려운 일은 두 번째 질문이다. 두 서버가 같은 상태를 어떻게 공유할 것인가, 즉 두 서버가 진짜로 한 대처럼 보일 수 있는가.

2.2 상태(state)가 모든 것을 어렵게 만든다

두 번째 질문은 첫 번째보다 어렵다. 라우팅은 도구 하나로 풀리지만, 상태는 시스템 전체의 구조를 바꾼다.

서버 두 대 중 어느 쪽으로 가도 동일한 응답이 나오려면, 사용자별 데이터(세션, 장바구니 등)가 서버 하나의 메모리 안에만 있으면 안 된다. LB가 다음 요청을 다른 서버로 보낼 수도 있고, 그 서버가 죽거나 재배포되면 메모리에 있던 데이터도 같이 사라지기 때문이다. 그래서 앱 서버는 무상태(stateless) 여야 하고, 상태는 모든 앱 인스턴스가 공유하는 외부 저장소(DB, Redis)로 빠져야 한다. 이 한 줄이 수평 확장의 전제 조건이다.

외부로 뺄 상태는 모양에 따라 두 갈래로 나뉜다. 세션이나 로그인 토큰처럼 작고 매 요청마다 빠르게 들고 나야 하는 데이터는 인메모리 저장소인 Redis에 둔다. 메모리에 있어 빠르고, 만료 시간이 자동으로 관리되기 때문이다. 반면 사용자가 올린 이미지나 동영상처럼 크고 한 번 올리면 자주 바뀌지 않는 파일은 외부 객체 스토리지인 S3에 둔다. 디스크에 무한히 쌓을 수 있고, 모든 인스턴스가 같은 URL로 같은 파일을 읽을 수 있기 때문이다. 상태가 외부로 빠지고 나면 인스턴스 자체는 언제든 죽이고 새로 띄워도 사용자 경험이 끊기지 않는다. "인스턴스를 가축처럼 다루고, 반려동물처럼 다루지 말라(cattle, not pets)" 라는 클라우드 시대의 격언이 여기서 나왔다.

수평 확장은 단순히 서버를 N배 하는 게 아니다. 여러 대를 한 대처럼 보이게 만드는 설계다.


3. 거리의 문제: CDN과 엣지 캐시

여러 대로 늘려 트래픽을 받을 수 있게 됐다. 그런데 서버 대수와 무관하게 풀리지 않는 문제가 하나 있다. 사용자와 서버 사이의 물리적 거리다.

3.1 빛의 속도조차 느릴 때

서버를 미국 동부에 두고 한국 사용자가 접속한다고 가정하자. 빛의 속도로도 한 번 왕복에 약 200ms가 걸린다. TCP 핸드셰이크 1번, TLS 핸드셰이크 2번, HTTP 요청-응답 1번까지 합쳐 왕복 4번. 200ms × 4면 첫 바이트가 도달하기 전에 1초 가까이 흐른다. 이미지 한 장, JS 번들 한 개를 가져오는 일조차 사용자에게는 느리게 느껴진다.

이 문제는 서버 성능을 아무리 올려도 풀리지 않는다. 물리법칙은 더 빠른 CPU로 우회되지 않는다. 답은 하나뿐이다. 콘텐츠를 사용자 가까이 미리 가져다 두는 것.

3.2 CDN이라는 분산 캐시 네트워크

CDN(Content Delivery Network) 은 전 세계 수백 개 PoP(Point of Presence)에 캐시 노드를 두고, 사용자가 가장 가까운 노드에서 정적 자원(이미지, JS, CSS처럼 한 번 만들면 잘 안 바뀌는 파일)을 받게 한다. 한국 사용자는 도쿄/서울 PoP에서, 유럽 사용자는 프랑크푸르트 PoP에서 받는다. 원본을 가진 본 서버(Origin)는 캐시가 만료된 자원에 대해서만 호출된다.

이 인프라를 직접 깔고 운영해 CDN 서비스로 판매하는 대표 사업자가 Cloudflare, Akamai, AWS CloudFront, Fastly다.

CDN은 단순한 정적 파일 전달을 넘어 빠르게 진화 중이다.

  • Edge Compute: PoP에서 직접 코드 실행 (Cloudflare Workers, Lambda@Edge). 인증, A/B 테스팅, 이미지 변환을 엣지에서 처리.
  • DDoS/WAF: 공격 트래픽을 Origin에 닿기 전에 차단.
  • Dynamic Content Acceleration: 캐시 못 하는 동적 응답조차 최적화된 백본 네트워크로 전달.

3.3 콘텐츠의 source of truth: 객체 스토리지

CDN이 흘려보내는 정적 자원도 어딘가에 원본이 있어야 한다. 그 자리에는 거의 항상 같은 인프라가 들어간다.

이미지, 동영상, 백업, 빌드 산출물 같은 파일은 DB에 넣을 수 없다. 한 행이 수백 MB가 되면 DB의 모든 가정(작은 행, 빠른 인덱스, 트랜잭션)이 무너진다. 그렇다고 서버의 로컬 디스크에 두면 인스턴스를 늘리는 순간 어느 인스턴스에 그 파일이 있는지가 새 문제가 된다. 답은 객체 스토리지(Object Storage) 다.

이름 그대로 객체(파일 한 덩어리)에 키 하나를 붙여 보관하는 저장소다. PUT으로 객체를 통째로 올리고, GET으로 객체를 통째로 받는다. 부분 수정은 없다. 접근은 HTTP API로 하고, 운영자가 디스크나 노드를 신경 쓸 일은 없다. 클라우드 사업자가 뒤에서 알아서 늘려 준다. AWS S3가 사실상 표준이고, GCS, Azure Blob, MinIO가 호환 인터페이스를 제공한다.

객체 스토리지가 지금처럼 기본 인프라가 된 데는 세 가지 이유가 있다. 첫째, 사실상 무한히 확장된다. 사용량이 늘어도 운영자가 직접 노드를 늘릴 일이 없다. 둘째, 사실상 잃어버리지 않는다. AWS S3가 약속하는 99.999999999%(11 9's) 내구성은 직관적으로는 1000억 개 파일을 1년 보관해 1개 잃을까 말까 한 수준이다. 셋째, CDN과 자연스럽게 결합된다. 파일을 S3에 올리면 CloudFront가 그대로 받아 전 세계 엣지로 흘려보낸다.

운영 패턴은 한 가지로 굳어진다. DB에는 파일의 메타데이터(파일 ID, 업로드 시각, 소유자)만 두고, 실제 바이트는 객체 스토리지에 두는 것. 작고 정확한 데이터는 DB에, 크고 안 변하는 파일은 객체 스토리지에. 둘은 다른 일을 한다.

3.4 CDN의 골칫거리: 캐시 무효화

CDN의 진짜 어려움은 캐시 무효화다. 무효화는 캐시된 옛 버전을 더 이상 유효하지 않다고 표시해 다음 요청부터 원본에서 새로 받게 만드는 일이다. 자원이 바뀔 때마다 이 작업을 전 세계 수백 개 PoP에 동시에 전파해야 하는데, 노드가 많아 느리고 비싸다. 그래서 무효화 대신 URL 자체를 바꾸는 식으로 우회한다. app.a3f9b2.js처럼 파일명에 빌드 도구가 내용으로 계산한 해시(지문)를 박으면, 한 글자만 바뀌어도 파일명이 자동으로 달라지고 새 URL은 캐시에 없으니 새 버전이 자연스럽게 흘러간다. 정적 자원은 그래서 영구 캐시로 둬도 안전하다. 다만 HTML은 매 빌드마다 가리키는 JS·CSS가 바뀌는 진입점이라 짧게만 캐시한다. 옛 HTML이 캐시되면 사용자가 옛 파일명을 요청해 새 빌드를 영영 못 본다.

거리가 멀어지면 마음이 멀어진다. 서버도 멀어지면 빨라봐야 의미가 없다. CDN의 본질은 "사용자에게 필요한 것을 미리 가까이 가져다 두는 일"이다.


4. 단단하지만 무거운 약속: RDB와 ACID

거리는 캐시로 풀었다. 이제 본체로 들어간다. 모든 캐시가 결국 가리키는 한 곳, 데이터가 처음 자리 잡는 곳이다.

4.1 왜 관계형부터 시작하는가

데이터 대부분은 미리 정해진 모양을 따른다. 사용자에는 이메일·이름·가입일이, 주문에는 누가 무엇을 얼마에 샀는지가 들어 있다. 같은 종류의 데이터는 같은 칸을 채운다. 이 "정해진 모양"이 스키마(schema) 다.

id email name created_at
1 alice@example.com Alice 2026-01-01
2 bob@example.com Bob 2026-01-02

말은 어렵지만 결국 표 한 장이다. 가로 한 줄이 사용자 한 명, 세로 칸 하나가 그 사용자에 대한 한 가지 사실. 이게 관계형 데이터베이스(RDB) 가 데이터를 보는 방식이다. 같은 스키마의 행을 같은 테이블에 차곡차곡 쌓고, 테이블 사이의 관계(주문은 사용자에게 속한다)를 명시한다. 모양을 강제했기 때문에 검색·집계·관계 추적 같은 일을 효율적으로 할 수 있다.

웹 서비스의 핵심 데이터는 대게 이 부류다. 사용자, 주문, 결제, 게시글이 그렇다. 모양이 정해져 있고 서로 관계가 있다. 그래서 거의 모든 웹 서비스의 시작은 보통 RDB다.

4.2 데이터에 거는 약속: ACID

그런데 모양만 잘 잡혀있다고 끝이 아니다. 사업은 이 데이터에 더 많은 것을 요구한다.

결제는 통과됐는데 잔고가 그대로면 손해가 난다. 재고가 1개인데 주문 두 건이 동시에 통과되면 한 사람은 물건을 못 받는다. 서버가 죽었다고 어제 들어온 결제 내역이 사라지면 회사가 망한다. 한 번이라도 어긋나면 비즈니스가 깨진다.

이런 사고를 막는 핵심 도구가 트랜잭션(transaction) 이다. 결제·잔고 차감·주문 기록 같은 여러 단계의 작업을 한 묶음으로 묶어, 모두 성공하거나 모두 실패하게 처리한다.

RDB는 이 트랜잭션이 안전하게 끝나도록 네 가지를 약속한다. 이게 ACID다.

  • Atomicity: 트랜잭션 내 모든 연산이 함께 성공하거나 함께 실패한다.
  • Consistency: 트랜잭션 종료 시점에 데이터 무결성 제약이 모두 만족된다.
  • Isolation: 동시 트랜잭션이 서로를 보지 않는 환상을 제공한다.
  • Durability: 커밋된 데이터는 장애가 와도 사라지지 않는다.

이 네 약속이 결제·주문 시나리오에서 어떻게 함께 작동하는지 풀어보자. 결제하고 잔고를 깎는 두 단계 중 한쪽만 성공하면 데이터가 어긋난다. 그래서 두 단계를 한 묶음으로 다뤄, 함께 성공하거나 함께 되돌린다(Atomicity). 트랜잭션이 끝나는 순간에는 "주문은 반드시 존재하는 사용자에 속해야 한다" 같은 무결성 규칙이 어떤 경우에도 깨져 있지 않다(Consistency). 재고가 1개인 상품에 두 주문이 동시에 들어와도, 서로의 진행을 못 보기 때문에 한 쪽만 성공하고 다른 쪽은 실패한다(Isolation). 결제 완료 응답이 나갔다면, 그 직후 서버가 죽어도 결제 기록은 남아 있다(Durability).

이 약속을 구현해 시장을 양분하는 두 도구가 PostgreSQLMySQL이다.

4.3 약속의 무게

그런데 트래픽이 늘면 가장 먼저 비명을 지르는 것도 여기다. 이상한 일이 아니다. ACID라는 약속을 지키느라 무거워졌기 때문이다.

  • 데이터를 안 잃겠다는 약속: 변경은 결국 디스크에 써야 한다. 디스크는 메모리보다 한참 느리다.
  • 동시 트랜잭션이 서로를 보지 않게: 같은 행에 대한 쓰기는 줄을 서야 한다. 이른바 잠금 경합(Lock Contention) 이다.
  • 분산에서도 정합성이 깨지지 않게: 쓰기는 한 노드로 모아야 한다. 이른바 단일 라이터(Single Writer) 다.
  • 모양을 강제한 대가: 큰 테이블의 모양 바꾸기(ALTER)가 위험한 작업이 된다.

앱 서버는 컨테이너 한 줄로 늘릴 수 있지만, RDB는 그렇게 못 늘린다. 그래서 모든 아키텍처 토론에서 RDB는 "가장 늦게 늘리고, 가장 늦게 줄이는 자원" 이라는 위치를 차지한다.

RDB는 모든 데이터의 정답이 아니라, 정합성이 필요한 데이터의 정답이다. 그리고 그 정합성을 지키기 때문에, 가장 먼저 부담을 받는다.


5. 읽기와 쓰기의 비대칭: Connection Pool, Replica, Sharding

RDB의 무게는 운영 현장에서 곧장 비명이 된다. 가장 먼저 깨지는 곳 부터 차례로 풀어 본다.

5.1 가장 먼저 깨지는 곳: Connection Pool

흔히 DB의 한계를 쿼리 속도로 떠올린다. 데이터가 많아지고 트래픽이 몰리면 한 쿼리가 실제로 오래 걸리기 쉽기 때문이다. 그러나 정작 먼저 막히는 건 쿼리에 닿기도 전인 커넥션이다. 커넥션은 앱과 DB 사이에 한 번 맺어둔 TCP 연결이다. 맺으려면 TCP 핸드셰이크와 인증을 거쳐야 하고, 일단 맺어지면 DB 쪽에서 메모리(수 MB)와 그 커넥션을 전담할 작업자 하나(프로세스 또는 스레드)를 잡아먹는다.

매 요청마다 이 비싼 일을 새로 하면 못 버틴다. 그래서 앱은 미리 몇 개를 맺어두고 돌려쓴다. 이 묶음이 커넥션 풀(Connection Pool) 이다. 앱이 쿼리를 던질 때마다 풀에서 한 개를 빌리고, 끝나면 반납한다.

문제는 이 풀이 앱 서버 안에 있다는 점이다. 앱 서버를 100대로 늘리고 각자 풀에 10개씩 잡으면 1,000개의 커넥션이 한 DB에 꽂힌다. PostgreSQL 기본 max_connections는 100, 잘 튜닝해도 수백 단위가 한계다. 한 커넥션마다 메모리·작업자를 잡으니 무한정 늘리면 DB 메모리가 폭발한다.

답은 풀을 앱 밖으로 빼는 것이다. 앱과 DB 사이에 풀러(Pooler) 라는 중계 프로세스를 두고, 앱은 이 풀러에만 연결한다. 풀러는 적은 수의 실제 DB 커넥션을 자기 안에 가지고, 앱들이 들어오면 그때그때 빌려준다.

flowchart LR A1[App 1] --> P[PgBouncer / RDS Proxy] A2[App 2] --> P A3[App N] --> P P -->|소수의 실제 커넥션 재활용| DB[(PostgreSQL)]

앱이 풀러에 꽂는 커넥션은 1만 개에 이르더라도, 풀러가 DB에 실제로 맺는 커넥션은 수십 개다. 풀러는 한 형태가 아니라 가벼운 표준부터 풀링+α 통합형, 클라우드 관리형까지 갈래가 있다.

형태 도구 특징
표준 풀러 PgBouncer (Postgres) 가볍고 빠름. 풀링만 함
통합형 Pgpool-II (Postgres), ProxySQL (MySQL) 풀링 + 읽기 분산 / 쿼리 라우팅
클라우드 관리형 RDS Proxy (AWS) 풀링 + IAM·failover까지 위임

풀러는 한 가지를 정해야 한다. 빌려준 커넥션을 언제 회수해 다음 앱에 다시 빌려줄지다. 회수가 빠를수록 한 DB 커넥션을 더 많은 앱이 돌려 쓸 수 있어 효율이 올라간다. 회수 시점에 따라 세 모드가 갈린다.

  • 세션 풀링: 앱이 연결을 끊을 때까지 잡아둔다. 가장 안전하지만 효율이 거의 안 오른다.
  • 트랜잭션 풀링: 트랜잭션 하나가 끝날 때마다 회수한다. 효율이 크게 올라간다. 운영의 표준.
  • 구문 풀링: SQL 한 문장마다 회수한다. 효율은 가장 높지만 트랜잭션 자체를 쓸 수 없어 매우 제한적.

트랜잭션 풀링에는 함정이 있다. 한 커넥션이 살아있는 동안의 대화 단위를 세션이라 부르는데, 이 세션 동안 DB 쪽에 쌓이는 상태가 있다. 대표적인 예가 prepared statement로, 자주 쓰는 쿼리를 한 번 컴파일해두고 같은 세션 동안 재활용하는 캐시다.

트랜잭션 풀링은 한 트랜잭션이 끝나면 그 DB 커넥션을 회수해 다른 앱에 넘긴다. 다음 트랜잭션은 다른 DB 커넥션 위에서 시작되니, 직전에 만들어둔 prepared statement가 그 커넥션엔 없다. 앱이 그 캐시를 재사용하려고 하면 "그런 statement는 없다"는 에러로 쿼리가 실패한다. 그래서 트랜잭션 풀링 환경에서는 prepared statement 캐싱을 꺼두는 게 표준 처치다. 매 쿼리를 새로 컴파일해 보내면 어느 커넥션을 받아도 문제가 없다.

5.2 읽기는 많고 쓰기는 적다: Read Replica

대부분의 서비스에서 트래픽 패턴은 비대칭이다. 게시글을 보는 횟수는 작성하는 횟수의 수십~수백 배다. 그래서 그다음 카드가 Read Replica(읽기 복제본) 다.

쓰기는 한 대(Primary, 또는 마스터)에서만 받는다. Primary는 자기가 받은 모든 변경(insert·update·delete)을 변경 로그(PostgreSQL의 WAL, MySQL의 binlog)에 기록하는데, 여러 대의 Replica가 이 로그를 받아 자기 데이터를 같은 모양으로 따라간다. 결과적으로 쓰기는 한 곳, 읽기는 여러 곳이 된다.

flowchart LR App[애플리케이션] -->|쓰기| Primary[(Primary DB)] App -->|읽기| R1[(Replica 1)] App -->|읽기| R2[(Replica 2)] Primary -. 변경 로그 .-> R1 Primary -. 변경 로그 .-> R2

함정은 복제 지연(Replication Lag) 이다. 사용자가 글을 쓰고 즉시 새로고침했는데, 그 새로고침이 아직 복제가 안 된 Replica로 가면 자기 글이 보이지 않는다. 그래서 보통 "쓴 직후 일정 시간은 Primary에서 읽는다" 같은 read-your-writes 보장 로직이 들어간다.

5.3 쓰기조차 한 대로 부족할 때: Sharding

Replica는 읽기를 늘릴 뿐이다. 쓰기 트래픽 자체가 Primary 한 대 용량을 넘거나, 데이터 양이 한 디스크에 안 들어갈 정도가 되면 다음 카드를 꺼내야 한다. 샤딩(Sharding) 이다.

샤딩은 데이터를 어떤 기준 컬럼(샤드 키)으로 나눠 여러 DB에 분산 저장한다. 보통 user_idtenant_id처럼 트래픽이 자연스럽게 갈라지는 축을 쓴다. user_id 1100만은 샤드 A, 100만200만은 샤드 B에 두는 식이다. 같은 사용자에 대한 요청은 항상 같은 샤드로 가니, 한 샤드의 부하가 쪼개진다.

어떻게 나누느냐에 세 가지 방식이 있다.

분할 방식 어떻게 나누나 함정
Range Sharding 키의 범위로 나눔 (1100만, 100만200만…) 신규 사용자가 한 범위로 몰리면 그 샤드만 핫해진다
Hash Sharding 키의 해시값으로 나눔 (균등 분포) "최근 가입자 100명" 같은 범위 쿼리가 어려움
Directory "키 → 샤드" 매핑을 별도 테이블에 적어두고 매번 조회. 자유 매핑이라 위 두 함정을 모두 해결 가능 매핑 테이블이 모든 요청의 병목·단일 장애점

샤딩의 진짜 비용은 운영 복잡도다. 샤드가 여러 대라는 건 곧 다음을 의미한다.

  • 샤드 간 트랜잭션이 어렵다. 샤드 A의 사용자가 샤드 B의 사용자에게 송금하려면 두 DB가 동시에 성공·실패를 합의해야 한다. 합의 도중 한쪽이 응답을 안 주거나 죽으면 어디까지 커밋됐는지 정리할 수 없으니, 이걸 푸는 분산 트랜잭션 프로토콜은 비싸고 깨지기 쉽다.
  • 샤드 키 아닌 컬럼으로 검색하면 모든 샤드에 물어봐야 한다. user_id로 샤딩한 환경에서 "이메일이 X인 사용자"를 찾으려면 모든 샤드에 부채꼴로 쿼리를 던지고 결과를 모아야 한다(scatter-gather). 샤드가 100개면 한 번의 쿼리가 100번이 되고, 응답 속도는 가장 느린 샤드에 맞춰진다.
  • 샤드를 늘릴 때 데이터 재배치(resharding)가 큰 일이다. 새 샤드로 일부 데이터를 옮기는 동안 서비스는 계속 돌아야 한다. 옮기는 중 들어오는 쓰기·읽기를 옛 샤드로 보낼지 새 샤드로 보낼지 매번 정해야 하고, 데이터가 클수록 작업이 시간 단위로 늘어난다. 게다가 샤드 키를 잘못 골라 한 샤드만 비대해지면 처음부터 다시 나눠야 한다.
flowchart LR subgraph TX["① 샤드 간 트랜잭션"] direction TB AppT[App] -->|송금| ShA[(샤드 A)] AppT -->|송금| ShB[(샤드 B)] ShA <-.->|"커밋?"| ShB end subgraph SG["② scatter-gather"] direction TB AppQ[App] -->|"이메일=X"| S1[(샤드 1)] AppQ -->|"이메일=X"| S2[(샤드 2)] AppQ -->|"이메일=X"| S3[(샤드 3)] AppQ -->|"이메일=X"| SN[(샤드 N)] end subgraph RS["③ resharding"] direction TB Old1[(기존 샤드)] -.->|일부 이전| New[(신규 샤드)] Old2[(기존 샤드)] -.->|일부 이전| New Live[Live 트래픽] --> Old1 Live --> Old2 Live --> New end TX ~~~ SG ~~~ RS

이런 부담 때문에 샤딩은 보통 마지막 카드다. 그 전에 인덱스 튜닝, 쿼리 최적화, 캐시 도입, 핫 데이터 분리 같은 카드를 먼저 쓴다.

5.4 Partitioning vs Sharding

두 단어가 비슷해서 자주 혼동된다. 파티셔닝(Partitioning) 은 한 DB 인스턴스 안에서 큰 테이블을 작은 조각으로 나누는 일이다. 예를 들어 주문 테이블을 월별로 쪼개면 11월 주문은 한 파티션, 12월은 다른 파티션에 들어간다. 같은 인스턴스 안이라 트랜잭션은 그대로 가능하고, 운영 부담은 낮다.

샤딩(Sharding) 은 여러 DB 인스턴스에 걸쳐 나누는 일이다. 5.3에서 본 그것이다. 같은 "분할"이지만 인스턴스 경계를 넘는 순간 트랜잭션·운영 난이도가 한 단계 뛴다.

한마디로, 파티셔닝은 한 집을 방으로 나누는 일이고, 샤딩은 여러 집으로 이사하는 일이다.

5.5 온라인 스키마 변경: 큰 테이블의 ALTER

Replica·샤딩과 함께 운영을 괴롭히는 또 하나의 함정이 큰 테이블의 스키마 변경이다. 컬럼 하나를 추가하는 ALTER TABLE이라도, 테이블이 1억 행이라면 DB는 새 모양으로 다시 쓰는 데 수십 분에서 수 시간을 쓴다. 그동안 이 테이블에 대한 다른 쿼리들은 메타데이터 락에 막혀 줄을 선다. 사용자 입장에서는 사이트가 멈춘 것처럼 보인다.

답은 온라인 스키마 변경 도구다. 핵심 아이디어는 "원본을 바꾸지 말고, 새 모양의 복제본을 만들어 데이터를 채운 뒤 이름을 바꿔치기한다"이다.

  • MySQL의 gh-ost(GitHub), pt-online-schema-change(Percona): 새 모양의 빈 테이블을 만들고, 원본의 binlog를 읽어 변경을 따라 흘리면서 기존 데이터를 천천히 복사한다. 다 따라잡으면 짧은 락 한 번으로 이름을 바꿔치기한다.
  • PostgreSQL의 pgroll: 옛 스키마와 새 스키마를 한동안 동시에 유지하면서 점진적으로 전환한다(expand-contract 패턴). 마이그레이션 중에도 양쪽 버전이 모두 정상 동작한다.

무중단 마이그레이션의 표준 패턴이다.

5.6 분산이 강제하는 트레이드오프: CAP

Replica와 Sharding의 특징은 데이터를 한 대가 아닌 여러 대에 흩어 둔다는 것이다. 이렇게 하나의 시스템이 여러 노드에 분산되는 순간, 피할 수 없는 제약이 생긴다.

이 제약을 가장 깔끔하게 정리한 게 CAP 정리다. 분산 시스템에는 세 가지 속성이 있다. Consistency(일관성) 는 어느 노드에 물어도 같은 답이 나온다는 것, Availability(가용성) 는 어떤 요청이든 응답이 돌아온다는 것, Partition Tolerance(분할 내성) 는 노드 사이가 끊겨도 시스템이 계속 돈다는 것이다. CAP의 결론은 단순하다. 이 셋을 동시에 다 가질 수는 없다.

문제는 P를 포기할 수 없다는 점이다. 노드 사이의 네트워크는 실제로 끊긴다. 같은 데이터센터 안에서도 끊기고, 리전 사이라면 더 자주 끊긴다. P는 선택의 문제가 아니라 현실의 제약이다. 그래서 진짜 선택은 네트워크가 끊겼을 때 무엇을 포기할 것인가로 좁혀진다. 일관성을 지키려면 끊긴 쪽 노드를 일시적으로 응답 불가 처리해야 하고(CP), 가용성을 지키려면 끊긴 쪽도 일단 응답하되 나중에 값이 어긋나는 걸 감수해야 한다(AP).

Read Replica가 그대로 AP의 모습이다. Primary와의 복제 링크가 끊겨도 Replica는 응답을 계속 돌려주지만, 그 답은 잠깐 옛 값이다. 가용성을 지키느라 일관성이 잠시 깨진 것이다. 반대로 결제·재고처럼 한 번이라도 틀리면 안 되는 데이터는 일관성을 우선하는 CP 성향으로 간다. RDB의 기본 동작, MongoDB의 강한 일관성 모드, 동기 복제 같은 선택지가 그 자리에 들어간다. 이 선택은 기술이 아니라 비즈니스의 결정이다. "좋아요는 가끔 한 개 틀려도 되지만 늦으면 안되고, 결제는 1초 늦더라도 절대로 틀리면 안된다."

모든 길은 로마로 통하듯, 모든 서버는 결국 DB로 통한다. DB를 옮기거나 새로 만드는 것은 도시를 짓는 것과 같아서, 도시로 향하는 길을 여러 갈래로 만드는게 우선이다.


6. 시간을 사는 법: 캐시 레이어와 Redis

DB로 닿는 길을 아무리 늘려도, 결국 길 끝의 DB를 만나면 한계가 찾아온다. 그래서 다음 답은 길을 더 늘리는 게 아니라, 자주 묻는 답을 외워두는 것이다.

6.1 디스크는 메모리보다 1000배 느리다

DB가 느린 가장 근본적인 이유는 디스크 I/O다. 데이터를 디스크에 안전하게 적고 다시 읽어 오는 그 동작 자체가 메모리 접근보다 수백~수천 배 느리다. SSD로 바꿔도 본질은 같다. DB에 묻는다는 건 매번 두꺼운 책을 펴서 답을 찾는 일과 같다.

그런데 잘 보면 같은 쿼리가 반복되는 비율이 압도적으로 높다. "베스트 게시글 목록", "내 프로필", "오늘의 환율" 같은 응답은 1분에도 수천 번 호출되지만, 그 사이에 데이터는 거의 바뀌지 않는다. 같은 페이지를 수천 번 펼치는 셈이다.

답은 단순하다. 책은 그대로 두고, 자주 펴는 페이지의 답만 외워둔다. 같은 질문이 들어오면 책을 다시 펴지 않고 외운 답을 낸다. 이게 캐시(Cache) 다. 다만 사람의 기억처럼 캐시에도 용량이 있어, 시간이 지나거나 새 답이 들어오면 옛 답부터 자연스럽게 잊는다.

6.2 Redis가 사실상 표준이 된 이유

캐시를 어디에 둘까. 가장 쉬운 답은 애플리케이션 서버 자체의 메모리(로컬 캐시)다. 같은 프로세스 안이니 네트워크조차 안 탄다. 그러나 서버가 여러 대로 늘어나는 순간 문제가 생긴다. 각 서버가 각자의 캐시를 들고 있어 어떤 서버는 갱신된 답을, 어떤 서버는 옛날 답을 돌려준다. 게다가 서버를 재시작하면 캐시가 통째로 날아간다.

그래서 캐시도 결국 공유된 한 곳에 두는 게 깔끔하다. 그 한 곳을 빠르게 만든 도구가 Redis다. 메모리에 데이터를 두는 키-값 저장소이고, 디스크 대신 RAM에서 응답하니 마이크로초 단위로 답한다. 캐시의 본질은 빠르게 답하고 때 되면 잊는 것이다. 그러나 실서비스 운영에서는 본질만으로 끝나지 않는 일들이 따라붙고, 운영에 필요한 기능들이 캐시와 결합되며 한 겹씩 얹혀 왔다.

  • 자동 만료(TTL, Time To Live): 키에 만료 시간을 박아두면 시간이 되는 순간 알아서 사라진다. "5분짜리 캐시"를 코드 한 줄로 만든다. 캐시의 가장 본질적인 기능이다.
  • 다양한 자료구조: 값에 단순 문자열뿐 아니라 리스트(List), 해시(Hash), 정렬된 집합(Sorted Set) 같은 컬렉션이 들어간다. 리스트는 작업 큐, 정렬된 집합은 실시간 순위표, 해시는 객체 부분 갱신 도구가 된다. DB로도 가능하지만 폴링·정렬·통째 갱신이 매번 따라붙는 일들이, 메모리 위에서는 한 명령으로 끝난다.
  • 원자적 명령: 두 사람이 동시에 좋아요를 누를 때 "값 읽고 +1 후 다시 쓰기" 평범한 코드는 한 명의 클릭을 잃기 쉽다. 둘이 같은 값을 본 뒤 같은 +1을 덮어쓰기 때문이다. DB에서는 트랜잭션을 열고 행을 잠궈 막지만, Redis는 "+1"을 한 명령으로 던질 수 있어 끼어들 틈 자체가 없다. 덕분에 카운터·분산 락 같은 동시성 도구를 단순하고 빠르게 구현할 수 있다.
  • 영속화·복제: 메모리에만 두면 전원이 꺼지는 순간 캐시가 통째로 날아가고, 평소 캐시가 막아주던 트래픽이 곧장 DB로 쏟아진다. 그래서 Redis는 주기적으로 메모리 상태를 디스크에 스냅샷으로 떠두거나, 모든 변경 명령을 디스크 로그(AOF, Append-Only File)에 적어 흔적을 남긴다. 마스터-레플리카 복제도 지원해 장애 시 다른 노드로 빠르게 넘어간다.

같은 영역의 경쟁자 Memcached는 더 단순하고 약간 더 빠르지만, 자료구조와 영속화가 빈약하다. "캐시 한 가지만 잘하면 된다"는 시대를 지나 "캐시 외 잡일도 같이 시키고 싶다"는 실서비스의 요구가 Redis 쪽으로 쏠리면서 사실상 표준이 되었다.

6.3 Redis 하나가 여러 자리를 메운다

위에서 본 자료구조와 원자적 명령 덕분에, Redis는 '캐시'라는 이름이 무색할 만큼 다양한 역할을 동시에 떠맡는다.

역할 용도 자료구조 / 명령
캐시 DB 조회 결과 저장 String / Hash + TTL
세션 스토어 로그인 세션, 로그아웃된 토큰 차단 목록(JWT 블랙리스트) String + TTL
분산 락 여러 서버가 같은 작업을 동시에 못 하게 막기 (예: 결제 중복 실행 방지) SETNX + TTL (Redlock)
가벼운 작업 큐 List(LPUSH/BRPOP), Stream
순위표 실시간 랭킹 Sorted Set
카운터 조회수, 좋아요 INCR (원자적)
레이트 리밋 API 호출 횟수 제한 INCR + TTL
Pub/Sub 실시간 알림 브로드캐스트 Pub/Sub, Stream

작은 시스템일수록 한 Redis가 이 표의 거의 모든 칸을 동시에 떠맡는다. 별도 시스템(전용 큐, 전용 락 서비스)을 더 두지 않아도 "빠르고 공유된 작은 도구"가 하나 있으면 어지간한 운영 요구가 풀리기 때문이다. 그래서 Redis는 캐시 레이어이자 작은 미들웨어이고, 종종 운영의 스위스 군용칼이 된다.

6.4 캐시 패턴: 어떻게 채울까

캐시를 두기로 했다면 두 가지 결정이 따라온다. 언제 채울 것인가, 언제 지울 것인가. 채우는 방식이 곧 캐시 패턴이고, 세가지 표준이 있다.

  • Cache Aside (Lazy Loading): 가장 흔한 패턴. 읽을 때 캐시를 먼저 보고, 없으면 DB에서 가져와 캐시에 채운 뒤 응답한다. 단순하고, 캐시가 죽어도 DB로 자동 폴백된다. 단 첫 요청은 항상 캐시 미스라 느리고, 인기 키가 만료되는 순간 트래픽이 한꺼번에 DB로 몰릴 수 있다.
  • Write Through: 쓰기 요청이 오면 DB와 캐시를 한 번에 갱신한다. 캐시가 항상 최신 값을 들고 있어 정합성은 좋지만, 매 쓰기마다 캐시까지 건드려 쓰기 응답 시간이 늘어난다.
  • Write Behind (Write Back): 캐시에만 즉시 쓰고, DB는 비동기로 따라오게 한다. 쓰기 응답이 가장 빠르지만 캐시가 죽으면 아직 DB로 못 옮긴 데이터가 사라질 위험이 있다.

대부분의 서비스는 Cache Aside로 시작해서, 정합성이 중요한 일부 핫패스(예: 잔액)에만 Write Through를 보강하는 식으로 운영한다. Write Behind는 빠르지만 데이터 유실 위험 때문에 적용 범위가 좁다.

6.5 캐시의 함정: 어떻게 깨질까

캐시는 도입하는 순간 새로운 종류의 사고를 데려온다. 거의 모든 캐시 운영을 괴롭히는 셋이다.

1. Cache Stampede

인기 키(예: 첫 화면에 박힌 베스트 게시글 ID)의 TTL이 만료되는 그 순간, 같은 키를 찾던 수많은 요청이 동시에 캐시 미스를 보고 한꺼번에 DB로 몰린다. 평소엔 캐시가 막아주던 트래픽이 한순간에 DB로 쏟아져 DB가 뻗는다. 해결은 두 갈래다. 만료가 다가오면 일부 요청이 미리 캐시를 갱신해 두거나, 동시에 들어온 요청 중 단 하나만 DB로 보내고 나머지는 그 결과를 기다리게 한다. 어느 쪽이든 DB는 한꺼번에 맞지 않게 한다.

2. Hot Key 문제

어떤 키 하나에 트래픽이 비정상적으로 쏠려, 그 키를 들고 있는 Redis 노드 하나가 무너진다. 캐시 클러스터 전체는 멀쩡한데 키 하나 때문에 서비스가 흔들린다. 해결은 캐시를 다단으로 구성하는 것이다. 로컬 캐시에 짧은 시간 동안 두고, 그 뒤에 Redis를 둔다. 같은 노드까지 도달하는 요청 자체를 줄여 핫키의 부담을 분산한다.

3. 캐시 일관성

DB는 갱신됐는데 캐시는 옛날 값을 들고 있는 경우다. 이것으로 인해서 사용자가 자기 변경을 바로 보지 못하는 상황이 생긴다. 가장 흔한 사고이자 가장 깔끔히 풀기 어려운 문제다. 답은 둘 중 하나다. 하나는 짧은 TTL을 두고 약간의 지연을 인정하면서 시간이 지나면 자연스럽게 최신화되도록 두는 방식이다. 또 하나는 DB가 갱신되는 순간 관련 캐시 키를 직접 지우는 방식인데, 깔끔해 보이지만 어떤 키를 지워야 하는지 정확히 추적하는 일이 생각보다 까다롭다.

캐시는 시간을 돈으로 사는 행위다. 다만 그 과정에서 일관성과 운영 복잡도라는 수수료가 붙는다.


7. 동기 호출의 저주: 메시지 큐와 비동기 처리

캐시는 같은 요청에 같은 답을 즉시 돌려주는 도구였다. 그런데, 다른 요청이 쏟아져 들어온다면 어떻게 대응할까. 사람들이 생각한 것은 정말 즉시 처리해야 되는가였다.

7.1 모든 호출이 즉시 끝나야 하는가

회원가입 직후 환영 이메일을 보내고, 마케팅 팀에도 새 가입자 정보를 넘긴다고 해보자. 가장 단순한 구현은 이렇다.

def signup(email, password):
    user = create_user(email, password)        # DB에 사용자 저장
    send_welcome_email(user.email)             # 외부 메일 서버에 환영 메일 발송
    notify_marketing_team(user)                # 사내 마케팅 시스템에 신규 가입 알림
    return user

세 줄을 위에서부터 차례대로 부른다. 한 줄이 끝나야 다음 줄이 시작되고, 마지막 줄까지 끝나야 사용자에게 응답이 돌아간다. 이런 호출 방식을 동기(Synchronous) 호출이라 부른다. 한 줄씩 짚어보자.

  • create_user: DB에 사용자 정보를 저장하는 회원가입의 본질이다. 빠르고, 우리가 통제할 수 있다.
  • send_welcome_email: 외부 메일 서버에 환영 메일을 보내는 일이다. 메일 서버와 주고받는 표준 프로토콜이 SMTP(Simple Mail Transfer Protocol) 다. 평소에도 수 초씩 걸리고, 메일 서버 컨디션에 따라 회원가입 페이지가 같이 느려진다.
  • notify_marketing_team: 사내 마케팅 시스템에 알림을 보내는 일이다. 그 시스템이 잠깐이라도 죽어 있으면 호출이 실패하고, 그 실패가 회원가입 함수까지 올라와 가입 자체가 실패한다.

본질은 첫 줄뿐인데, 사용자는 나머지 두 줄이 끝날 때까지 기다린다. 본질이 아닌 부수 작업이 응답 시간을 잡아먹고, 외부 시스템 한두 개의 컨디션이 회원가입 성공률을 좌우한다. 이게 동기 호출의 저주다.

7.2 큐가 두 시스템 사이에 들어오면 무엇이 바뀌는가

답은 단순하다. 일을 시키는 쪽과 받는 쪽 사이에 메시지 큐(Message Queue) 라는 중간 저장소를 둔다. 메시지를 줄 세워 보관하는 곳이다. 일을 시키는 쪽(발행자, Producer)은 큐에 메시지를 던지고 바로 자기 일을 끝낸다. 받는 쪽(컨슈머, Consumer 혹은 워커, Worker)은 큐에서 메시지를 꺼내 자기 페이스로 처리한다. 회원가입 코드라면 회원가입 함수가 "이메일 보내라", "마케팅 알려라" 메시지를 큐에 던지고 바로 응답을 돌려주고, 실제 처리는 큐 뒤편의 워커가 맡는다.

flowchart LR Web[Web 서버] -->|이벤트 발행| Q[Message Queue] Q --> W1[Worker: 이메일] Q --> W2[Worker: 마케팅 알림] Q --> W3[Worker: 추천엔진 학습 데이터 적재]

큐가 사이에 들어오면 네 가지가 바뀐다.

  1. 비동기 처리: 발행자는 큐에 던지고 곧바로 응답을 돌려준다. 처리에 얼마나 걸리든 사용자 응답 시간에는 묶이지 않는다.
  2. 결합 분리(Decoupling): 컨슈머가 잠깐 죽어도 발행자는 영향을 받지 않는다. 메시지는 큐에 쌓이며 기다리다 컨슈머가 살아나면 처리된다. 마케팅 시스템이 죽었다고 회원가입까지 같이 깨지는 사고가 사라진다.
  3. 부하 평탄화(Load Smoothing): 트래픽 스파이크가 와도 컨슈머는 자기 페이스로 큐에서 꺼내 처리한다. 큐가 완충 역할을 해, 한순간에 몰린 일을 시간으로 펼쳐 준다.
  4. 확장성: 처리가 밀리면 컨슈머를 늘리면 된다. 컨슈머 수에 비례해 처리량이 늘어난다.

7.3 큐의 두 모델: 브로커와 스트림

큐 도구는 메시지를 어떻게 다루는지에 따라 크게 두 부류로 나뉜다. 한 번 전달하고 사라지게 두는 브로커(Broker) 모델과, 로그처럼 쌓아두는 스트림(Stream) 모델이다. 차이는 단순하지만, 그 한 가지 차이가 어떤 시스템을 만들 수 있느냐를 가른다.

브로커 모델의 대표는 RabbitMQ다. 우편함을 떠올리면 된다. 메시지가 큐에 들어왔다가 컨슈머가 받아 가면 큐에서 사라진다. 배달이 끝나면 우편함에서 편지를 꺼내는 셈이다. 큐 본래의 역할에 가장 충실하고, "어떤 메시지를 어떤 컨슈머에게 보낼지" 라우팅을 풍부하게 정할 수 있어 작업 큐, 알림, 실시간 처리에 잘 어울린다.

스트림 모델의 대표는 Kafka다. 일기장에 적어두는 방식이다. 메시지가 들어오면 로그에 차곡차곡 적히고, 컨슈머가 읽어도 사라지지 않는다. 이 로그 한 권 한 권에 이름을 붙인 단위를 Topic(토픽) 이라 부른다. 가입 이벤트는 signups, 결제 이벤트는 payments 식으로 주제별로 나뉘며, producer는 특정 토픽에 메시지를 적고 consumer는 보고 싶은 토픽을 골라 읽는다. 여러 시스템이 같은 메시지를 각자 다른 속도로 다시 읽어 갈 수 있다는 뜻이다. 결제 시스템은 실시간으로, 추천 시스템은 분 단위로, 데이터 웨어하우스는 하루 단위로 같은 회원가입 이벤트를 자기 속도대로 가져간다. 이 한 가지 차이가 이벤트 드리븐 아키텍처를 가능하게 만들었다.

같은 회원가입 이벤트라도 RabbitMQ로 흘리면 한 컨슈머가 받아 처리하고 끝이지만, Kafka로 흘리면 결제, 추천, 분석이 같은 이벤트를 각자 자기 속도로 소비한다. 메시지가 한 번만 처리되면 되는 일에는 브로커가, 여러 시스템이 같은 흐름을 다시 읽어야 하는 일에는 스트림이 맞는다.

7.4 큐를 코드에서 굴리는 도구: 워커 프레임워크

이제 큐를 코드로 구현하려는 단계로 내려오면, 가장 먼저 떠오르는 발상은 다음과 같다: "보내는 함수에서 일을 큐에 넣고, 다른 쪽은 while loop으로 큐에서 꺼내 처리하면 되는 거 아닌가." 간단히 굴러갈 것 같지만, 막상 운영에 올려두면 다음 다섯 가지가 매 프로젝트마다 다시 따라온다.

  • 메시지 형식: 일을 어떤 모양으로 큐에 담을지, 양쪽이 같은 모양을 어떻게 계속 맞출지
  • 받는 쪽 프로세스 운용: 워커 프로세스를 어떻게 띄우고 우아하게 종료시키고 죽었을 때 다시 띄울지, 동시에 몇 개를 굴릴지
  • 실패 대응: 처리하다 실패하면 몇 번 다시 시도할지, 간격은 어떻게 벌릴지, 영영 실패한 일은 어디로 보낼지
  • 결과 회수: 보낸 쪽이 결과를 알아야 할 때, 끝났음을 어떻게 돌려줄지
  • 주기 작업: "매일 자정 리포트 집계" 같은 정기 작업을 어디에 두고 누가 트리거할지

이 다섯을 프로젝트마다 새로 짜는 건 낭비다. 그래서 큐 위에 한 겹 더 얹은 워커 프레임워크가 자리한다. 큐가 컨베이어 벨트라면, 워커 프레임워크는 그 벨트 위에 기사 배치·재시도 규칙·정기 배송 일정까지 묶은 운영 시스템이다.

Python 진영의 사실상 표준이 Celery다. RabbitMQ나 Redis를 브로커로 두고, 함수에 표시 한 줄만 붙이면 그 함수가 큐로 보낼 수 있는 작업이 된다.

# myapp.py
from celery import Celery

# 어느 큐(broker)를 쓸지 알려주는 Celery 앱
app = Celery("myapp", broker="redis://localhost:6379/0")


@app.task # 이 표시가 함수를 "큐로 보낼 수 있는 작업"으로 등록한다
def send_welcome_email(email: str) -> None:
    ...  # 외부 메일 서버에 환영 메일 발송

# .delay()가 직접 호출 대신 큐에 메시지를 발행
send_welcome_email.delay(user.email)

핵심은 마지막 줄이다. 동기 호출 send_welcome_email(user.email)이 비동기 호출 send_welcome_email.delay(user.email)로 바뀌는 한 줄짜리 변경이 발행자 쪽 전부다. 메시지 형식 문제는 여기서 자연히 풀린다. 발행자와 컨슈머가 같은 함수 정의를 공유하므로, 양쪽이 같은 모양을 따로 맞출 일이 없다.

받는 쪽은 워커 프로세스를 명령 한 줄로 띄운다.

celery -A myapp worker --concurrency=4

-A myappmyapp.py의 Celery 앱을 가리키고, --concurrency=4가 한 워커가 동시에 처리할 작업 수다.

받는 쪽 프로세스 운용은 일의 성질에 따라 풀 모드가 갈린다. 이미지 변환·인코딩 같은 CPU 작업은 한 작업이 코어 하나를 통째로 점유한다. 동시에 여러 개를 굴리려면 프로세스를 그만큼 늘려 여러 코어에 분산해야 한다. 반대로 외부 API 호출·DB 쿼리처럼 대부분의 시간을 응답 대기로 보내는 작업은 그 동안 CPU가 비어 있어, 한 프로세스 안에서 여러 호출을 동시에 안고 있을 수 있다. Celery는 두 방식 모두 옵션으로 지원한다. 트래픽이 밀리면 벨트 위 기사 수를 늘리듯 프로세스 수를 키우고, 워커가 죽어도 다시 띄우면 큐에 쌓인 메시지를 이어서 처리한다.

정상 흐름은 여기까지다. 그다음 자리는 실패 대응이다. 외부 API가 잠깐 죽거나 네트워크가 끊긴 일시적 실패는 시간 간격을 점점 벌려가며 자동 재시도하고, 일정 횟수를 넘기면 DLQ(Dead Letter Queue) 로 빼낸다.

비동기로 던지면 보낸 쪽은 곧바로 끝난다. 하지만 결과 회수가 필요한 일도 있다. PDF 변환이 끝나면 사용자에게 다운로드 링크를 보여줘야 하고, 결제 처리는 결과 페이지를 띄워야 한다. 이때 결과 백엔드(Result Backend) 가 처리 결과를 별도 저장소(Redis나 DB)에 적어 두고, 보낸 쪽이 나중에 그 저장소에서 조회하거나 콜백으로 받게 한다. 가장 쉬운 길은 큐(브로커)와 결과 저장소를 같은 Redis 인스턴스에 묶는 것이라 처음엔 그렇게 시작하기 쉽지만, 둘이 같은 메모리·커넥션을 두고 다투면 큐 트래픽이 결과 조회를 느리게 만들고 결과 저장이 큐를 막는다. 보통 처음부터 분리한다.

마지막 자리는 정기 작업이다. Celery Beat라는 별도 스케줄러 프로세스가 "매일 자정 리포트"·"5분마다 헬스체크" 같은 일정을 들고 있다가, 정해진 시각마다 큐에 메시지를 발사한다. 컨베이어 벨트의 정기 배송 일정을 코드에 등록하는 셈이다. 한 가지 함정이 있다. 워커는 가용성을 위해 여러 인스턴스를 띄우는 게 정석이라 Beat도 똑같이 두 개 이상 띄우기 쉬운데, Beat는 다르다. 둘이 같은 일정을 들고 있으면 자정마다 둘이 동시에 메시지를 발사해 같은 작업이 두 번 처리된다. 그래서 Beat는 단일 인스턴스로 두거나, 여러 인스턴스가 떠도 한 번만 발사되도록 분산 락을 끼운 redbeat 같은 대체 구현을 쓴다.

7.5 큐가 가져오는 새 문제들

큐는 만능이 아니다. 사이에 큐를 둔 순간, 동기 호출에는 없던 종류의 문제들이 따라붙는다. 다음은 전형적인 큐 운영에서 겪는 4가지 문제다.

1. 순서가 보장되지 않는다

회원이 주문을 만들고 곧바로 결제를 진행한다. "주문 생성" 메시지와 "주문 결제" 메시지가 같은 큐에 차례로 들어간다. 그런데 컨슈머가 여러 개 떠 있으면 결제 메시지를 처리하는 워커가 생성 메시지를 처리하는 워커보다 먼저 끝낼 수 있다. 그러면 컨슈머는 "존재하지 않는 주문에 결제를 시도" 하다 실패한다. 큐가 메시지를 한 줄로 받았다고 컨슈머가 한 줄로 처리하는 건 아니다. 컨슈머가 둘 이상이 되는 순간 누가 먼저 끝낼지는 정해지지 않는다. Kafka는 이를 풀기 위해 토픽 하나를 파티션(partition) 이라는 여러 조각으로 쪼개 두고, 한 조각 안에서는 들어간 순서대로 처리됨을 보장한다. 그래서 같은 주문에 관한 모든 메시지가 같은 파티션에 가도록 파티션 키(예: 주문 ID)를 잡으면, 그 주문에 한해서는 순서가 살아남는다.

2. 같은 메시지가 두 번 올 수 있다

결제 완료 메시지가 두 번 도착해 같은 결제가 두 번 처리되면 사용자에게 두 번 청구가 나간다. 환영 메일이 두 번 가는 정도는 짜증으로 끝나지만, 결제·차감·잔고 변동에서 이 사고는 회사가 책임진다. 대부분의 큐는 "메시지가 적어도 한 번은 도달함" 만 보장한다(At-Least-Once). 뒤집어 말하면, "메시지가 한번만 도달한다"를 보장하지 않는다. 네트워크 끊김이나 컨슈머 재시작 같은 이유로 같은 메시지가 두 번 이상 들어올 수 있다는 뜻이다. 그래서 컨슈머는 멱등(idempotent) 해야 한다. 같은 메시지를 두 번 처리해도 결과가 한 번 처리한 것과 같아야 한다는 뜻이다. 보통은 메시지에 고유 ID를 박아 두고 컨슈머가 처리한 ID를 어딘가에 기록해, 같은 ID가 두 번째로 들어오면 건너뛰게 만든다.

3. 처리에 계속 실패하는 메시지

형식이 깨진 메시지 하나가 큐에 들어왔다. 컨슈머가 처리하다 실패해 다시 큐로 돌려보낸다. 큐가 다시 꺼내 주고, 또 실패한다. 이게 반복되면 그 메시지 하나에 컨슈머 자원이 묶여 뒤에 쌓인 정상 메시지까지 처리되지 못한다. 큐가 사실상 마비된다. 해결은 단순하다. 일정 횟수 이상 실패한 메시지는 데드 레터 큐(DLQ, Dead Letter Queue) 라는 별도의 큐로 빼낸다. 메인 큐는 정상 메시지 처리에 집중하고, DLQ에 쌓인 메시지는 사람이 따로 들여다보고 손본다.

4. 즉시가 아니라 곧

7.1의 회원가입 코드를 다시 떠올려 보자. 동기 호출이었을 때는 응답이 돌아오는 순간 환영 메일도, 마케팅 알림도 이미 끝나 있었다. 큐를 사이에 둔 뒤로는 그렇지 않다. 회원가입 응답은 즉시 돌아오지만, 환영 메일은 아직 발송 전일 수 있다. 이렇게 "지금은 아니지만 곧 끝난다" 는 상태를 최종 일관성(Eventual Consistency) 이라 부른다. 같은 트랜잭션 안에서 모든 일이 즉시 끝나던 세계에서, 일들이 시간차를 두고 정합되는 세계로 넘어왔다는 뜻이다. UI도 이를 받아들여야 한다. "가입 완료"와 함께 "환영 메일이 곧 도착합니다" 같은 안내가 필요해지고, "방금 올린 글이 검색에 아직 안 잡혀요" 같은 사용자 문의가 정상이 된다.

모든 일이 즉시 끝날 필요는 없다. 급하지 않은 일은 비동기 큐로 보내라. 그러나 동시에 시스템은 "곧"이라는 시간 개념을 받아들여야 한다. PM들이 싫어하는 말인데, 그만한 이유가 있다.


8. 검색이라는 별개의 차원: 검색엔진

여기까지는 RDB의 부담을 옆에서 덜어주는 도구들이었다. 같은 호출에 더 빨리, 다른 호출을 비동기로 바꾸는 식이었다. 이제부터는 RDB로는 아예 풀 수 없는 일을 본다. 그 첫 번째가 검색이다.

8.1 SQL의 LIKE는 왜 부족한가

게시글 본문에 "도커"라는 단어가 들어간 글을 검색해야 한다. 가장 단순한 풀이는 SQL의 LIKE 연산자를 쓰는 것이다. LIKE는 문자열 패턴 매칭 연산자이고, %는 "임의의 문자가 0자 이상" 을 뜻하는 와일드카드다. 그래서 WHERE content LIKE '%도커%' 는 "본문 어딘가에 '도커' 가 들어 있는 글" 이라는 뜻이 된다.

이 쿼리는 게시글이 1만 개일 때까지는 잘 동작한다. 100만 개가 되면 5초가 걸리고, 1000만 개가 되면 사용자가 떠난다.

이유는 단순하다. 풀 텍스트 LIKE는 인덱스를 거의 못 탄다. RDB의 인덱스는 사전과 같다. 단어의 첫 글자로 위치를 찾고 거기서부터 좁혀 나가는 식이다. 그런데 %도커%처럼 앞에 %가 붙으면 첫 글자를 알 수 없다. 사전을 처음부터 끝까지 훑는 수밖에 없다(풀 스캔). 게시글이 늘수록 사전이 두꺼워지고, 그만큼 시간도 비례해 늘어난다.

설령 인덱스를 탔다 쳐도, 검색이 본질적으로 요구하는 능력은 LIKE의 영역 바깥이다. 같은 의미를 가진 다른 형태의 단어를 묶고, 동의어·오타를 보정하고, 어떤 글이 더 적합한지 점수를 매기는 일은 RDB가 본래 풀어 주려고 만든 도구가 아니다.

검색은 본질적으로 RDB와 다른 자료구조를 요구한다. 그것이 역색인(Inverted Index) 이다.

8.2 역색인이라는 단순한 천재성

역색인은 단어에서 그 단어가 등장한 문서 목록으로 가는, 거꾸로 된 사전이다.

"도커"  → [doc#3, doc#42, doc#193, ...]
"쿠버"  → [doc#42, doc#88, ...]

"도커"와 "쿠버"가 모두 들어간 글을 찾으려면 두 리스트의 교집합을 구하면 된다. 수백만 개 문서에서도 밀리초 단위로 동작한다.

여기에 두 가지를 더 얹으면 비로소 "검색"이 된다. 하나는 형태소 분석이다. "검색하다/검색했다/검색하는"을 모두 "검색"으로 통일해 같은 단어로 묶는다.

다른 하나는 가중치다. 같은 단어를 담은 글이라도 어느 글이 사용자에게 더 적합한지 점수를 매기는 일이다. 직관은 두 가지다. 첫째, 이 글에 그 단어가 얼마나 자주 등장하는가(TF, Term Frequency). "도커"가 한 번 스쳐 지나간 글보다 본문 곳곳에 "도커"가 등장하는 글이 도커 주제에 더 가까울 가능성이 크다. 둘째, 그 단어가 전체 문서에서 얼마나 흔한가. "그리고", "있다"처럼 거의 모든 글에 나오는 단어는 변별력이 없고, "도커"처럼 일부 글에만 나오는 단어는 글을 가르는 강한 단서가 된다. 흔할수록 가치가 떨어지므로 점수 산정에서 페널티를 준다. 이게 IDF(Inverse Document Frequency) 다.

두 직관을 곱한 TF-IDF가 점수의 기본 공식이고, 단어 빈도의 한계 효과(한 글에 100번 나온다고 100배 적합한 건 아니다)와 문서 길이 보정까지 더한 개량판 BM25가 거의 모든 검색엔진의 표준 점수 체계다.

이 모든 걸 한데 묶어 운영 가능한 수준으로 패키징한 도구가 Elasticsearch(ES) 다. OpenSearch, Solr 같은 형제도 있지만 시장의 사실상 표준은 ES다.

그런데 한국어 운영은 한가지 문제가 더 있다. 분석기다. 검색엔진은 색인할 때 문서를 통째로 박아 넣지 않는다. 문서를 단어 단위로 잘게 자르고, 형태소로 정규화한 뒤 역색인에 넣는다. 이 잘게 자르는 도구를 토크나이저(Tokenizer) 라 하고, 자르는 일과 정규화·동의어 처리까지 묶은 파이프라인을 분석기(Analyzer) 라 한다.

영어는 공백으로 단어를 자르면 거의 끝이지만, 한국어는 형태소 분석이 필수다. ES에서는 nori(공식 한국어 플러그인) 또는 mecab-ko 기반 사용자 사전이 사실상 표준. "갤럭시폴드5"를 한 단어로 묶을지 "갤럭시 / 폴드 / 5"로 자를지, "노트북" 검색에 "노트 PC"가 잡히게 할지가 모두 분석기·동의어 사전 설정의 결과다.

그리고 분석기 설정은 한 번 잘못 잡으면 되돌리기 어렵다. 역색인의 키는 결국 "분석기가 잘라 정규화한 단어"다. 분석기를 바꾸면 키 자체가 달라지므로, 인덱스 전체를 처음부터 다시 쪼개 만들어야 한다(reindex). 데이터가 쌓인 뒤 분석기를 갈아 끼우는 비용은 작지 않으니, 사용자 사전·동의어 설정은 운영 들어가기 전에 충분히 검증해야 한다.

8.3 검색엔진은 RDB의 대체가 아니다

검색이 막강해 보이니 자주 나오는 오해가 있다. 검색엔진을 만능 DB로 쓰려는 시도다. 결제 데이터, 사용자 잔고, 주문 상태까지 ES에 올려 두면 한 번에 검색·조회되는 구조가 매력적으로 보인다. 그러나 ES는 검색용으로 만들어진 도구이고, RDB가 잘하는 일에서는 약점이 명확하다.

  • 트랜잭션이 약하다: 결제·차감처럼 "전부 성공하거나 전부 롤백" 이 필요한 일은 ES에 맡기면 안 된다. ACID 보장이 RDB만큼 단단하지 않기 때문이다.
  • 복잡한 JOIN이 어렵다: 사용자, 주문, 결제 테이블을 엮어 보고서를 만드는 일은 RDB의 전공이다. ES에서 같은 일을 하려면 데이터를 미리 합쳐 한 문서로 박아 넣어야 한다.
  • 단일 행 조회는 RDB가 더 빠르다: "이 사용자 ID 한 명을 찾아라" 같은 단순 키 조회는 RDB가 더 가볍고 빠르다. ES는 색인을 통해 답하는 도구라 단건 조회에 오버헤드가 있다.

그래서 정석적인 흐름은 다음과 같다: RDB가 진실의 단일 출처(Source of Truth), 검색엔진은 그 데이터의 검색용 사본. 회원가입·결제·주문 같은 본체 데이터는 RDB에 들어가고, 검색엔진은 그 데이터를 받아 검색용으로 다시 구성한다.

둘을 잇는 다리는 메시지 큐다. RDB가 갱신될 때마다 변경 이벤트를 큐에 던지고, 검색엔진 쪽 워커가 큐에서 꺼내 색인을 업데이트한다. RDB → ES 직접 호출 대신 큐를 거치면 RDB의 쓰기가 ES 색인 시간에 묶이지 않고, ES가 잠깐 죽어도 RDB는 멀쩡히 돌아가며, 같은 변경 이벤트를 ES 외에 캐시·분석 시스템도 함께 소비할 수 있다.

8.4 단어 검색에서 한 발 더: 벡터 검색

키워드 검색은 "도커"라는 단어가 들어간 글을 잘 찾지만, "컨테이너 기술이 궁금해" 같은 질문에 "도커 입문" 글을 추천하지는 못한다. 단어가 일치하지 않기 때문이다. 의미가 비슷한지를 보려면 다른 도구가 필요하다.

LLM 시대 들어 등장한 답이 벡터 검색(Vector Search) 이다. 핵심은 단순하다. 텍스트를 의미가 좌표로 표현되는 지도 위에 옮긴다. 의미가 비슷한 글은 가까운 곳에, 다른 의미는 먼 곳에 찍힌다. 그러면 두 글이 의미적으로 얼마나 가까운지가 두 점 사이의 거리로 환원된다. "의미"라는 추상적인 개념이 "거리"라는 단순한 숫자가 되는 셈이다.

의미 좌표 공간 (개념도, 실제로는 수백 차원)

      ● 휴가 신청서
   ● 회의록 정리
      ● 분기 보고서



                          ★ "컨테이너 기술이 궁금해"
                         ● 도커 입문
                       ● 컨테이너 기초
                          ● 쿠버네티스 시작

같은 주제의 글들끼리 한쪽에 모여 있고, 주제가 다른 글들은 반대편에 모여 있다. 사용자의 질문(★)은 같은 주제의 글들 가까이에 떨어진다. 검색은 이 가까움을 거리로 측정해 답을 고르는 일이다.

이 지도 위에 글을 옮기는 도구가 임베딩 모델(Embedding Model) 이다. 글을 입력으로 받아 숫자 배열을 내놓는 신경망이고, 그 숫자 배열(벡터)이 글의 좌표가 된다. 비슷한 의미의 글이 가까이 모이도록 학습된 좌표계라고 보면 된다.

동작은 두 단계다. 색인 단계에서 모든 문서를 임베딩 모델에 통과시켜 좌표(벡터)로 만들어 저장한다. 검색 단계에서 사용자의 질문도 같은 모델로 좌표를 만들고, 저장된 좌표들 중 가까운 문서 몇 개를 골라 돌려준다. 도입의 예로 돌아가면, "컨테이너 기술이 궁금해"의 좌표와 "도커 입문"의 좌표가 가까이 모여 있어, 단어가 한 글자도 겹치지 않아도 검색이 통한다.

벡터 검색을 지원하는 도구는 두 갈래다. 전용 벡터 DB(Pinecone, Qdrant, Weaviate, Milvus)와, 기존 DB·캐시에 벡터 기능을 얹은 확장(Postgres pgvector, Elasticsearch dense vector, Redis Vector). 새 인프라를 들이기 부담스럽다면 후자로 시작해도 충분하다.

키워드 검색(BM25)과 벡터 검색을 함께 쓰는 하이브리드 검색이 표준이 되어 가고 있다. 키워드는 정확히 일치하는 단어를 잡고, 벡터는 의미적으로 가까운 글을 잡는다. 둘이 잡는 영역이 달라 서로의 약점을 보완한다. "도커"를 검색하면 키워드 쪽은 "도커"가 명시된 글을, 벡터 쪽은 "컨테이너 기초"처럼 의미가 가까운 글을 함께 끌고 온다.

이 하이브리드 검색이 오늘날 가장 자주 등장하는 자리가 RAG(Retrieval-Augmented Generation) 다. LLM이 사용자 질문에 답하기 직전, 외부 문서 저장소에서 질문과 관련 있는 문서 몇 개를 검색해 답변 생성의 컨텍스트로 함께 넣어 주는 구조다. 회사 내부 문서나 LLM이 학습하지 못한 최신 자료를 답변에 끌어 쓰게 만드는 장치다. 이 "LLM 답변 직전의 검색" 자리에 키워드와 벡터를 함께 쓰는 하이브리드 구성이 사실상 기본 형태로 자리잡았다. 즉 RAG는 LLM 시대의 검색 응용이다.

막강해 보이는 검색엔진도 결국 하나의 자료구조다. 더 좋은 자료구조라는 건 없다. 상황과 목적에 맞는 자료구조가 있을 뿐이다. 검색엔진도 마찬가지다.


9. 테이블 바깥의 데이터: NoSQL의 다섯 갈래

검색이 RDB로 풀리지 않는 한 부류였다. 다른 부류들도 있다. 데이터의 모양 자체가 RDB와 어긋나는 경우다.

9.1 모든 데이터가 관계형은 아니다

웹 서비스가 다루는 데이터의 모양은 한 가지가 아니다. 사용자, 주문, 결제는 행과 열, 외래키, 트랜잭션으로 자연스럽게 풀린다. RDB가 잘 푸는 영역이다. 그런데 그 옆에 다른 모양의 데이터가 같이 산다.

사용자에게 "친구의 친구"를 추천한다고 하자. 데이터는 (A는 B의 친구, B는 C의 친구, C는 D의 친구...) 같은 관계의 그물이다. 이걸 RDB로 풀려면 friends 테이블을 자기 자신과 JOIN하고, 또 JOIN하고, 또 JOIN해야 한다. 3단계만 들어가도 쿼리 플랜이 폭발한다. 데이터는 그물 모양인데 도구는 표 모양이라서 생기는 비용이다.

채팅 메시지는 또 다른 모양이다. 시간 순서로 끝없이 쌓이고, "어제 자정 이후 메시지를 빨리 가져와라" 가 가장 흔한 요구다. 정합성보다 쓰기 처리량과 시간 축 정렬이 훨씬 중요하다. 사용자 활동 이벤트(클릭 로그)는 더 극단이다. 초당 수만 건이 들어오지만 한 번 들어가면 거의 읽지 않는다. 정합성도, 인덱스도 다 비용이다. 쓰기 폭주를 받아 줄 다른 도구가 필요하다.

이런 데이터를 모두 RDB 한 곳에 욱여넣으면 어딘가에서 반드시 무너진다. 그래서 데이터의 모양에 따라 다른 저장소를 골라 쓰는 방식이 자리 잡았다. 이름이 거창하지만 의미는 단순하다. Polyglot Persistence(다언어 영속성), 한 시스템 안에 여러 저장소를 동시에 둔다.

9.2 NoSQL의 다섯 갈래

앞 절의 사례들은 무작위가 아니다. 데이터의 모양과 접근 패턴이 비슷한 것끼리 묶이면서, 각 묶음마다 그것을 잘 푸는 저장소가 자라났다. 흔히 NoSQL이라 불리는 도구들은 대체로 다음 다섯 갈래로 정리된다.

부류 모델 강점 대표 도구 전형적 용도
Key-Value 키 → 값 단순, 빠름, 수평 확장 쉬움 DynamoDB, Redis 세션, 카운터, 캐시
Document JSON 문서 스키마 유연, 중첩 구조 자연스러움 MongoDB, Couchbase 카탈로그, 사용자 프로필, CMS
Wide-Column 행 키 + 동적 컬럼 매우 큰 쓰기 부하, 시간 시리즈 친화 Cassandra, ScyllaDB, HBase 메시지 로그, IoT, 활동 피드
Graph 노드/엣지 다단계 관계 탐색 Neo4j, Neptune 소셜 그래프, 추천, 사기 탐지
Time Series (시간, 측정값) 시간 축 압축/롤업, 다운샘플링 InfluxDB, TimescaleDB, Prometheus 메트릭, 모니터링

다섯 가지는 용도에 따라 사용되는 곳이 다르다.

Key-Value는 사물함이다. 번호표(키)를 주면 짐(값)이 나오고, 그 이상은 묻지도 따지지도 않는다. 묻지 않으니 빠르고, 사물함 자체를 더 늘리는 일도 단순하다. 로그인 세션이나 조회수 카운터처럼 "키 하나로 찾아 바로 쓰는" 데이터가 여기에 산다.

Document는 책 한 권이다. 한 사용자의 정보(프로필, 주소, 결제 수단, 즐겨찾기)가 한 권의 JSON 문서로 묶여 있다. 그 책을 통째로 펼치면 사용자 페이지 한 화면이 그대로 채워진다. RDB라면 대여섯 테이블을 JOIN해야 풀던 일이 한 번의 조회로 끝난다. 카탈로그, 사용자 프로필, CMS처럼 "한 덩어리로 읽고 쓰는 데이터" 의 모양이다.

Wide-Column은 한 사용자에 한 줄을 두고, 그 줄에 시간순으로 활동을 끝없이 덧붙이는 구조다. 클릭, 조회, 결제가 모두 그 줄의 새 컬럼으로 적힌다. 쓰기가 폭주해도 받아내고, 그 사용자의 활동을 시간 순으로 훑는 일은 한 행 읽기로 끝난다. 메시지 로그, IoT 센서, 활동 피드가 이 모양이다.

Graph는 친구의 친구 같은 관계 그물을 정직하게 푼다. 사람을 노드로, 친구 관계를 엣지로 그대로 저장해 둔다. "친구의 친구의 친구"는 그래프 위를 세 칸 걸어가는 일이 되고, RDB가 자가 JOIN을 세 번 해야 풀던 문제를 한 번의 탐색이 끝낸다. 소셜 그래프, 추천, 사기 탐지가 이 모양이다.

Time Series는 시간 축 자체를 최우선으로 다룬다. 같은 센서에서 1초마다 들어오는 측정값을 받아 두되, 어제치는 분 단위로, 작년치는 일 단위로 자동 압축한다. "최근은 촘촘히, 오래된 건 거칠게". 보는 것으로 충분한 데이터, 즉 모니터링 메트릭이나 IoT 시계열이 여기서 가장 효율적이다.

잘못된 종류의 DB를 선택하는 것은 망치로 나사를 때리는 행위다. 잠깐은 박혀서, 더 늦게 알아챈다.


10. 데이터의 두 얼굴: OLTP와 OLAP, 그리고 CDC라는 다리

10.1 같은 데이터, 다른 일

지금까지 다룬 모든 저장소(RDB, Redis, MQ, 검색엔진, NoSQL)는 한 가지 공통점이 있다. 서비스 운영을 위해서 만들어졌다는 점이다. 사용자가 주문하면 빠르게 한 행을 쓰고 읽는다. 이 작업의 정식 이름이 OLTP(Online Transaction Processing) 다.

그런데 같은 회사가 묻는 다른 질문이 있다. "지난달 주문 수, 카테고리별 매출, 코호트별 리텐션은?" 이런 질문은 한 행이 아니라 수백만 행을 집계한다. 행 단위 RDB로 돌리면 서비스 자체가 멈춘다. 이게 OLAP(Online Analytical Processing) 의 영역이다.

측면 OLTP OLAP
단위 행(row) 열(column) 집계
한 번에 작은 결과 한 번에 거대한 스캔
빈도 초당 수천~수만 분/시간당 수십~수백
물리 모델 Row-oriented Columnar (열 단위 압축)
강한 도구 PostgreSQL, MySQL BigQuery, Snowflake, Redshift, ClickHouse

왜 물리 모델까지 갈리는가. RDB는 한 행을 통째로 빠르게 꺼내도록 데이터를 행 단위로 디스크에 늘어놓는다. 반면 분석은 거의 항상 한 열 전체를 읽는다. "지난달 매출"은 매출 컬럼 하나만 수백만 행 더해서 끝난다. 행 단위 저장은 필요 없는 컬럼까지 같이 끌어와 디스크 I/O를 낭비하지만, 열 단위(columnar) 저장은 같은 컬럼끼리 길게 모아 두고 한꺼번에 압축한다. 같은 1억 행을 합산하는 일이 OLTP 엔진에서 분 단위라면 OLAP 엔진에서는 초 단위로 끝나는 이유다.

다른 데이터에 같은 엔진을 쓰려는 시도는 거의 항상 실패한다. 물리 모델이 다르다. 분석이 본격적으로 필요해지는 순간 분리는 선택이 아니라 필수다.

10.2 Data Warehouse, Data Lake, Lakehouse

분석용 저장소는 한 자리에서 정해진 게 아니라, 매번 직전 단계의 답답함을 풀려고 다음 단계가 등장한 형태다.

처음 등장한 것이 Data Warehouse다. 운영 DB에서 매일 데이터를 끌어와 미리 정해 둔 스키마에 맞게 정제·적재한다. SQL로 바로 분석할 수 있고, 컬럼 단위 압축 덕에 대규모 집계가 빠르다. BigQuery, Snowflake, Redshift, ClickHouse가 이 자리에 있다. 답답한 점은 스키마 강제다. JSON 로그, 이미지 메타데이터, 머신러닝 피처처럼 형태가 자주 바뀌거나 정형화되지 않는 데이터는 Warehouse에 들어가기 전에 매번 변환을 거쳐야 한다.

그래서 Data Lake가 등장했다. 원본을 가공하지 않고 그대로 객체 스토리지(S3)에 던져 둔다. 파일 포맷만 Parquet/ORC 같은 분석 친화 포맷으로 통일한다. 스키마를 강제하지 않으니 무엇이든 적재할 수 있다. 대신 트랜잭션, 일관된 스키마, 시점 쿼리 같은 DB의 기본기는 사라진다. "데이터를 다 넣어 놨는데 정작 신뢰하고 쿼리할 수가 없다"는 운영 부담이 새 골칫거리가 된다.

다음 답이 Lakehouse다. Lake의 자유도를 유지하면서 그 위에 Warehouse가 갖던 트랜잭션, 스키마 진화, 시점 쿼리(time travel)를 다시 얹는다. 핵심은 객체 스토리지 위에 얹는 테이블 포맷이다. Apache Iceberg, Delta Lake, Apache Hudi가 이 자리. Databricks의 Unity Catalog, Snowflake의 Iceberg 통합이 이 방향이다.

대부분의 모던 회사는 다음 한 줄짜리 척추를 갖는다.

flowchart LR OLTP[(OLTP DB)] -- CDC --> Stream{{이벤트 스트림}} Stream --> Lake[(Lakehouse / DW)] Lake --> BI[BI 대시보드] Lake --> ML[ML 학습/추론]

근데, 여기서 갑자기 CDC 라는게 나타난다. 이것은 무엇일까?

10.3 CDC라는 다리: OLTP에서 모든 것으로

서비스 DB(OLTP)의 데이터를 검색엔진, 데이터 웨어하우스, 캐시, 다른 마이크로서비스로 어떻게 흘릴까. 가장 단순한 답은 야간 ETL(Extract-Transform-Load) 이다. 매일 새벽에 운영 DB에서 데이터를 뽑아(Extract), 분석용 형태로 변환하고(Transform), 분석 저장소에 적재(Load)하는 일괄 작업이다. 그러나 데이터가 커지면 이 방법은 두 가지 이유로 깨진다. 한 번의 작업이 새벽 안에 끝나지 않고, 분석 결과가 항상 하루 늦은 데이터가 된다.

답은 CDC(Change Data Capture) 다. DB의 변경 자체를 이벤트 스트림으로 변환한다. 정확히 말하면 DB가 이미 모든 변경을 트랜잭션 로그(PostgreSQL WAL, MySQL binlog)에 적고 있고, CDC는 그 로그를 읽어 외부 시스템에 흘리는 것이다.

여기서 한 번 짚고 가자. CDC의 출구 쪽은 메시지 큐 그 자체다. DB 변경 로그를 큐에 publish하는 것이 producer이고, 검색엔진·DW·캐시·다른 마이크로서비스가 consumer다. 이미 갖고 있던 메시징 인프라(Kafka)가 그대로 데이터 동기화의 척추가 된다. CDC에서 정말로 새로운 부분은 producer 쪽뿐이다. DB 트랜잭션 로그를 큐의 입력으로 끌어 쓴다는 발상이 그것이다.

flowchart LR OLTP[(OLTP RDB
PostgreSQL/MySQL)] -- WAL/binlog --> Debezium[Debezium / CDC] Debezium --> Kafka{{Kafka Topic}} Kafka --> ES[(Elasticsearch)] Kafka --> DW[(Data Warehouse
BigQuery/Snowflake)] Kafka --> Cache[(Redis Cache)] Kafka --> MS[Microservice
Event Consumer]

CDC 도구는 결국 같은 일을 한다. DB 트랜잭션 로그를 읽어 큐로 흘리는 것. 갈리는 축은 단 하나, 누가 운영을 책임지는가 다. 우리 팀이 직접 돌리면 자유도가 가장 높은 오픈소스 조합(Debezium + Kafka Connect), AWS에 맡기면 AWS DMS, 외부 회사에 통째로 맡기면 Fivetran·Airbyte다. 뒤로 갈수록 손은 덜 가지만 자유도와 비용은 그만큼 양보하게 된다.

도구 운영 책임 한 줄 설명
Kafka Connect 우리 팀 Kafka와 외부 시스템(DB, 검색엔진 등) 사이의 다리들을 모아 돌리는 틀
Debezium 우리 팀 Kafka Connect 위에서 도는 RDB 전용 다리. CDC의 사실상 표준
AWS DMS AWS DB 변경을 다른 곳으로 흘리는 일을 AWS가 대신 운영
Fivetran / Airbyte 외부 SaaS DB뿐 아니라 Salesforce·Stripe 같은 SaaS 데이터까지 한꺼번에 끌어옴

CDC가 가져다주는 가장 큰 변화는 한 줄로 정리된다. "DB를 단일 출처로 두고, 다른 모든 것이 그 변경 이벤트를 구독한다." 기존엔 주문 한 건이 들어오면 애플리케이션 코드가 DB에 한 번 쓰고, 검색 인덱스에 한 번 더 보내고, 캐시 키 무효화도 또 한 번 호출했다. 같은 한 번의 변경을 위해 세 곳에 따로따로 명령을 날리는 셈이고, 셋이 한 트랜잭션에 묶이지도 않아 어느 한쪽이 실패하면 데이터가 조용히 어긋난다. CDC는 이 모두를 하나의 흐름으로 모은다. DB에 한 번 쓰면, 그 변경이 큐를 타고 검색엔진·캐시·다른 마이크로서비스로 자동으로 흘러간다.

10.4 Stream Processing: 흐르는 데이터의 변환

CDC가 만들어 낸 이벤트 스트림 위에서는 분석의 시점이 달라진다. 기존 분석은 "데이터를 다 모아 두고 한꺼번에 돌리는" 배치였다. 그러나 이벤트가 끊임없이 흐른다면, 굳이 다 쌓일 때까지 기다리지 않고 들어오는 즉시 윈도우 단위(최근 1분, 최근 1시간)로 집계할 수 있다. 이 영역이 Stream Processing 이다.

대표 사용처: 실시간 부정 거래 탐지, 실시간 대시보드, 알림 트리거, 클릭 스트림 집계.

10.5 Batch: 그래도 사라지지 않는 영역

Stream Processing과 Batch는 대체 관계가 아니다. 같은 데이터에 두 가지 시간 모드(흐르는 동안 / 마감 단위로 한 번)를 적용하는 한 쌍이고, 보통 한 시스템 안에 같이 들어간다. 일별 정산, 주별 리포트, 월별 마이그레이션처럼 본질적으로 배치인 일이 있기 때문이다. 마감 시점이 명확해야 하고("4월 매출"), 결과가 언제 다시 돌려도 같아야 하며(감사·정정), 처리 도중 터졌을 때 어디부터 다시 돌릴지가 분명해야 한다. 흐르는 데이터에는 이 세 가지 모두 자연스럽지 않다.

10.6 ETL vs ELT: 변환을 어디서 하는가

같은 세 글자(E, T, L)인데 순서만 다르다. 그러나 변환을 어디서 하느냐가 만드는 차이는 크다.

ETL(Extract → Transform → Load)은 적재 전에 변환한다. 외부 ETL 도구가 운영 DB에서 데이터를 뽑아 미리 정해 둔 분석용 모양으로 가공한 뒤, warehouse에는 가공된 결과만 적재한다. warehouse가 작아도 되고 적재 후엔 쿼리가 빠르다. 대신 적재 전 모양이 강제되니, 새 분석 질문이 생기면 파이프라인 자체를 바꿔야 한다.

ELT(Extract → Load → Transform)는 적재 후에 변환한다. 운영 DB에서 데이터를 그대로 Lake/Warehouse로 부어 두고, 분석가가 SQL로 그때그때 변환·집계한다. 원본을 다 보존하니 새 질문이 생겨도 SQL 한 번이면 답이 나오지만, warehouse가 충분히 커야 한다.

예전 warehouse는 작고 비쌌다. 페타바이트 데이터를 그대로 부어 SQL로 돌리는 건 꿈도 못 꿨다. 그래서 외부 ETL 도구가 미리 데이터를 줄이고 가공해, warehouse에는 깔끔한 결과만 넣는 방식이 정답이었다.

그런데 세 가지 변화가 이 전제를 차례로 무너뜨렸다. 먼저 BigQuery·Snowflake·Redshift 같은 클라우드 warehouse가 폭발적으로 커지며 페타바이트 위에서도 SQL을 분 단위로 끝내게 됐다. 미리 줄여 둘 필요가 사라졌다. 그 위에 Lake/Lakehouse가 등장해 가공 전 원본을 그대로 적재하는 방식이 표준이 됐고, '적재 전 변환'이라는 ETL의 전제 자체가 깨졌다. 마지막으로 SQL 변환을 코드처럼 정의·테스트·버전 관리하는 도구가 표준화되며, 외부 ETL 도구를 따로 둘 이유까지 사라졌다. 변환의 자리는 warehouse 바깥에서 안으로 옮겨갔다. 그게 ELT다.

같은 데이터라도 서비스용과 분석용은 분리해야 한다. CDC는 그 둘을 잇는 표준 다리이며, 모든 모던 데이터 스택의 척추다. OLTP DB는 진실의 출처, 다른 모든 것은 그 변경의 사본이라는 관점이 핵심이다.

OLTP 엔진은 한 줄을 빠르게 꺼내려고 평생을 만들어졌다. 거기에 백만 줄을 합쳐 달라는 부탁은, 목사님한테 불경을 외워달라고 하는 것과 같다.


11. 모놀리스의 한계: MSA와 그 부속품

여기까지는 한 시스템 안에서 데이터를 어떻게 다루느냐의 이야기였다. 이제는 시스템 자체를 어떻게 쪼개느냐의 문제다.

11.1 모놀리스가 처음에 강했던 이유

모든 기능이 코드베이스 한 덩어리에 들어 있는 구조를 모놀리식 아키텍처(Monolith) 라 부른다. 처음 몇 명이 한 저장소를 열어 놓고 같이 만지는 단계에선 이 구조가 모든 면에서 강하다. 함수 호출은 빠르고, 트랜잭션은 한 DB에서 끝나며, 배포는 한 번에, 디버깅은 한 프로세스 안에서 끝난다. 작은 팀이 빠르게 움직일 때 가장 효율적인 구조다.

11.2 그런데 왜 깨야 하는가

문제는 조직과 코드가 같이 자랄 때다. 100명의 엔지니어가 한 코드베이스를 동시에 만지면 다음이 일어난다.

  • 한 줄 고치는데 전체 테스트 1시간.
  • 한 모듈 배포하려면 전체 재배포.
  • 한 모듈의 메모리 누수가 모든 기능을 죽임.
  • 결제 트래픽 스파이크에 정적 페이지까지 같이 느려짐.
  • 결제팀이 추천팀의 코드를 모르고 건드리다 사고.

MSA(Microservices Architecture) 는 이걸 풀자는 답이다. 책임을 나누고, 서비스마다 별도 프로세스·배포·DB로 분리한다. 콘웨이의 법칙("시스템 구조는 조직 구조를 닮는다")이 관찰이라면, MSA는 그 관찰을 처방으로 바꾼 것이다. 어차피 닮게 될 거라면, 조직이 움직이고 싶은 모양대로 먼저 시스템을 쪼개자는 발상이다.

11.3 MSA가 끌고 오는 부속품

한 덩어리를 쪼개면 한 프로세스 안에선 자동으로 풀리던 일들이 다시 풀어야 할 문제가 된다. 이 문제를 메우려고 따라붙는 추가 컴포넌트들이 MSA의 '부속품'이다. 문제는 크게 두 영역에서 생긴다. 서비스끼리 어떻게 부르고 통신 공통 기능을 어떻게 처리할지를 다루는 통신 영역, 그리고 서비스마다 갈린 DB와 메시지 형식의 일관성을 어떻게 묶을지를 다루는 데이터 영역이다.

통신 영역의 문제는 셋이다.

  • API Gateway: 외부 입구가 흩어져 클라이언트에게 매 서비스 주소를 알릴 수 없으니, 인증·라우팅·호출 제한을 한 입구에 모아준다.
  • Service Discovery: 인스턴스가 오토스케일·배포로 끊임없이 떴다 사라지고 IP가 매 순간 바뀌므로, 매 호출 시점에 인스턴스 위치를 찾아준다.
  • Service Mesh: 인증·재시도·관측·암호화 같은 통신 공통 기능을 서비스마다 따로 구현하면 같은 코드를 N번 쓰는 셈이라, 이 공통 기능을 서비스 옆 별도 프록시(사이드카)로 빼서 모든 서비스에 동일하게 적용한다.

데이터 영역의 문제도 셋이다.

  • Saga: 한 작업이 여러 DB를 건드리는데(예: 주문 → 결제 → 재고 차감) 결제만 성공한 채 재고가 깨지면 트랜잭션 롤백으로 못 되돌리므로, 단계마다 깨졌을 때의 되돌림(예: 결제 환불 호출)을 미리 짜 둔다.
  • Outbox: DB 쓰기와 메시지 큐 발행이 동시에 일어나야 할 때 둘 중 하나만 성공하는 사고를 막기 위해, 메시지를 같은 DB 트랜잭션 안에 별도 테이블로 적어 두고 워커가 그걸 큐로 흘린다.
  • Schema Registry: 서비스끼리 주고받는 메시지 형식이 따로 배포되며 어긋나는 일(예: 결제 서비스가 다국 통화 지원으로 currency 필드를 필수로 추가했는데 주문 서비스는 옛 버전이라 그 필드 없이 보내 결제가 깨짐)을 막기 위해, 스키마를 한 곳에 등록하고 새 버전이 옛 버전과 호환되는지 검사한다.

11.4 서비스 간 인터페이스: REST, GraphQL, gRPC

서비스가 쪼개지면 한 메모리 안에서 끝나던 함수 호출이 네트워크 호출이 된다. 같은 한 줄의 함수 호출인데 세가지 문제가 동시에 따라온다.

  • 호출 시간: 마이크로초에서 밀리초·수십 밀리초로 늘어남.
  • 호출 실패: 실패 안 하던 게 타임아웃·끊김으로 깨질 수 있음.
  • 스키마 어긋남: 보내는 쪽과 받는 쪽이 따로 배포되며 형식이 어긋날 수 있음.

그래서 호출의 모양을 의도적으로 골라야 한다. 누가 부르는가, 스키마를 얼마나 강하게 묶을 것인가, 캐시·도구 생태계를 얼마나 활용할 것인가가 그 축이다. 이 축 위에서 자리 잡은 세 가지가 REST, GraphQL, gRPC다.

스타일 호출 주체 스키마 강도 캐시·생태계 주로 쓰는 곳
REST 외부 클라이언트 약함 (런타임 검사) 풍부 (HTTP 캐시·도구) 외부 API의 첫 답
GraphQL 외부 클라이언트 (다양) 강함 (스키마 + 강타입) 보통 (HTTP 캐시 약함) BFF, 클라이언트 다양
gRPC 내부 서비스 가장 강함 (컴파일 타임) 빈약 (브라우저 미지원) MSA 내부 서비스 간

REST는 외부 클라이언트가 직접 부르는 API의 첫 답이다. URL이 자원을 가리키고(예: /orders/123 = 123번 주문) HTTP 메서드가 동작을 뜻한다(GET 조회, POST 생성, PUT 수정, DELETE 삭제). 표준 HTTP 위의 단순한 매핑이라, 캐시·로깅·디버깅·문서화 같은 도구를 새로 만들 필요 없이 공짜로 가져다 쓸 수 있다.

GraphQL은 클라이언트 종류가 다양해질 때 등장한다. 같은 백엔드를 웹·iOS·Android가 같이 쓰면 화면마다 필요한 필드가 달라, REST로는 안 쓰는 필드까지 통째로 받거나(over-fetch) 한 화면 그리려고 여러 번 호출해야 하는(under-fetch) 비효율이 발생한다. GraphQL은 클라이언트가 한 호출에 필요한 필드만 골라 받게 해서 비효율을 해결한다. 같은 비효율을 백엔드 자체를 클라이언트별로 쪼개서 푸는 답이 BFF (Backend for Frontend) 패턴이고, GraphQL과 자주 결합한다.

gRPC는 내부 서비스끼리만 부르는 자리에서 빛난다. 바이너리 프로토콜이라 브라우저가 직접 부르지 못해 외부 API로는 안 맞지만, 내부에선 이 제약이 오히려 강점으로 바뀐다. 객체를 작은 바이트로 바꿔 네트워크에 흘리는 직렬화와 빌드 시점에 형식 어긋남을 잡아 주는 강한 타입 안전을 가져가서, MSA 내부의 호출 한 건이 안고 있는 지연·실패를 가장 잘 누르는 답이다.

한 서비스 안에 셋이 공존하는 게 흔한 모습이다.

11.5 양방향 실시간: WebSocket과 SSE

쪼갠 시스템 위에 또 하나 풀어야 할 문제는 클라이언트와의 통신 모양이다.

웹의 기본 통신 규약은 HTTP다. 클라이언트(브라우저·모바일 앱)가 서버에 "이거 줘"라고 요청을 보내면 서버가 응답을 돌려주고 연결이 끊어진다. 한 번의 요청-응답이 한 사이클이고, 다음 요청이 오기 전까진 둘 사이에 열린 길이 없다.

이 모델에선 클라이언트가 늘 먼저 말 걸고 서버는 답만 한다. 능동자와 수동자가 고정된 셈이고, 그래서 서버는 먼저 말 못 한다. 평범한 페이지 로드에는 충분하지만, 채팅에서 상대방이 메시지를 보냈을 때, 협업 편집에서 다른 사용자가 커서를 움직였을 때, 주식 시세가 갱신됐을 때처럼 서버가 먼저 말해야 하는 자리에선 길이 막힌다.

기술 방향 특성
Long Polling 단방향(C→S 반복) HTTP만, 단순. 비효율
SSE (Server-Sent Events) 단방향(S→C) HTTP/1.1, 자동 재연결. 알림·스트리밍
WebSocket 양방향 TCP 풀듀플렉스(한 연결에 양쪽이 동시 송수신). 채팅·게임·협업의 표준
WebRTC P2P (클라이언트끼리 직접) 화상/음성 미디어 전용

그럼 이 문제를 어떻게 푸는가. 크게 네 가지다.

Long Polling은 가장 단순한 우회다. 클라이언트가 "새 거 있어요?"를 묻고 서버가 답할 게 생길 때까지 응답을 보류했다가 보내면, 클라이언트는 곧장 다시 묻는다. HTTP만으로 돌아가는 대신 매번 새 연결이 오가서 트래픽·지연 모두 비효율이다.

SSE (Server-Sent Events) 는 서버→클라이언트 단방향 푸시 채널이다. HTTP 위에서 한 연결을 길게 유지하고 서버가 새 데이터를 적어 보낸다. 알림, 실시간 스트리밍, 주식 시세처럼 클라이언트는 받기만 하면 되는 자리에서 가장 가볍고, 자동 재연결도 표준에 박혀 있다. 단, 클라이언트가 능동적으로 보낼 일이 있으면 그 채널은 따로 만들어야 한다.

WebSocket은 양방향 통신의 표준이다. 한 연결로 클라이언트와 서버가 동시에 메시지를 주고받을 수 있어, 채팅·협업 편집·멀티플레이어 게임처럼 양쪽이 동시에 능동자가 되어야 하는 자리에서 가장 잘 맞는다. 그러나 HTTP 위가 아닌 별도 프로토콜이라 HTTP 캐시·디버깅 도구를 그대로 쓸 수 없다.

WebRTC는 결이 다른 답이다. 서버를 거치지 않고 클라이언트끼리 직접 연결해(P2P) 화상·음성 같은 미디어를 흘린다. 영상 통화나 화면 공유 같은 자리가 그 영역이다. 다만 P2P 연결을 맺기 위한 별도 인프라(시그널링·NAT 우회 서버)가 따라붙는다.

위에서 언급한 방법들이 HTTP 요청-응답을 대체하지 않고 양방향이 꼭 필요한 자리에만 쓰이는 이유는 공통의 운영 비용 때문이다. 연결이 살아 있는 동안 그 사용자는 특정 인스턴스에 묶인다. 평범한 HTTP 요청은 어느 인스턴스로 보내도 같지만, 살아 있는 연결은 그 가정이 깨진다. 채팅 서버 인스턴스 A에 사용자 X의 연결이, 인스턴스 B에 사용자 Y의 연결이 살아 있다고 해 보자. X가 보낸 메시지를 같은 채팅방의 Y에게도 전해야 하는데, A와 B는 메모리를 공유하지 않으니 직접 닿을 길이 없다. 그래서 인스턴스 사이를 잇는 별도 채널(보통 Redis나 Kafka)을 둬서 어느 쪽에서 받은 메시지든 다른 쪽으로 흘러가게 하거나, 같은 사용자의 후속 연결을 같은 인스턴스로 고정해서(Sticky Session) 인스턴스 변동 자체를 줄인다. 즉, HTTP 시대엔 없던 인스턴스 간 동기화 인프라(Redis·Sticky Session)를 새로 운영해야 하고, 그만큼 운영 복잡도가 늘어난다.

또 한 가지, 인스턴스당 동시 연결 수가 곧 메모리 점유량이다. 1만 명이 동시에 채팅 화면을 켜고 있으면 1만 개 연결의 상태를 한 인스턴스가 들고 있어야 하니, 무상태 HTTP 서버처럼 마음껏 스케일아웃하기 어렵다. 모바일 환경에선 와이파이↔셀룰러 전환으로 끊김이 일상이라, 재연결과 함께 같은 메시지를 두 번 받아도 결과가 같게 하는 멱등성 처리도 필수다. 이 부담이 크면 Pusher·Ably 같은 외부 SaaS에 떠넘기는 패턴이 흔하다. 즉, 인스턴스 메모리가 사용자 수에 비례해 늘어나 스케일아웃이 빠듯해지고, 모바일 끊김·중복까지 따로 코드를 짜야 한다.

MSA는 한 키보드 앞에 줄 선 백 명에게 각자의 키보드를 주는 것이다. 대신 옆자리에 말 한마디로 전하던 것도 키보드로 보고서를 써 올려야 한다.


12. 분산의 그림자: 실패에 대한 방어 패턴

한 호출이 여러 서비스를 거치는 순간, 호출은 도중에 사라지거나, 늦게 도착하거나, 두 번 도달한다. 한 부분의 실패가 전체로 번지지 않게 막아야한다.

12.1 함수 호출과 네트워크 호출은 다르다

같은 프로세스 안의 함수 호출은 거의 실패하지 않는다. 반면에, 네트워크 호출은 매번 동전을 던지는 일과 비슷하다. 둘을 가르는 결정적 차이는 두 가지, 신뢰와 지연이다.

신뢰가 깨진다. 함수 호출은 호출자와 피호출자가 같은 메모리 안에 있어 한쪽이 죽으면 다른 쪽도 같이 죽는다. 부분 실패가 없다. 네트워크 호출은 다르다. 호출자는 살아있고 피호출자도 살아있는데, 그 사이의 패킷만 사라지는 일이 일상이다. 응답이 영원히 돌아오지 않는 호출이 생기고, 호출한 쪽은 그 사실을 모른 채 자원을 붙들고 매달린다.

지연이 들쭉날쭉이다. 함수 호출은 마이크로초 단위로 끝난다. 네트워크 호출은 평소 5ms로 돌아오던 게 어느 날 5초가 된다. 의존 서비스의 GC, 옆 호출의 큐잉, 네트워크 혼잡 어느 한 가지로도 충분하다. 그 5초가 위쪽 서비스의 타임아웃·커넥션·메모리를 줄줄이 끌어내린다.

이 둘을 인정하지 않는 코드는 평소엔 잘 동작하다 장애 때 폭발한다.

신뢰·지연이 가장 자주 잡는 둘이지만, 분산 시스템에서 깨지는 가정은 더 넓다. Peter Deutsch가 정리한 8가지(fallacies)를 reference로 남긴다.

# 거짓말 현실
1 네트워크는 신뢰할 수 있다 응답 누락, 끊김
2 지연은 0이다 평소 5ms가 어느 날 5초
3 대역폭은 무한하다 큰 페이로드가 채널을 막음
4 네트워크는 안전하다 평문 트래픽 노출
5 토폴로지는 변하지 않는다 인스턴스가 매시간 죽고 IP가 바뀜
6 한 명의 관리자가 있다 외부 의존이 다른 회사 운영팀
7 전송 비용은 0이다 region 간 트래픽에 비용이 붙음
8 네트워크는 동질적이다 프로토콜·인코딩·OS 차이로 호출 형식 어긋남

12.2 부분 실패에 대한 방어 패턴

네트워크 호출이 한번 일어난다. 그 호출이 길에서 만날 수 있는 문제는 크게 다섯 가지다.

응답이 영영 돌아오지 않는다. 호출자는 의존이 죽었는지, 사이가 끊겼는지, 그저 늦는지 알 수 없다. 답이 없는 호출에 시간 제한을 거는 게 Timeout이다. 그 끊김이 일시적이었을 수 있으니 한 번 더 던져 본다. Retry다. 다만 모두가 같은 순간에 다시 던지면 의존이 한 번 더 무너지므로(thundering herd), 간격을 점점 벌리고 무작위 지연을 섞는다.

재시도해도 계속 실패하면 의존이 진짜로 죽은 것이다. 호출을 계속 던져 봤자 의존도, 호출 쪽 자원도 더 빨리 마른다. 호출 자체를 잠시 차단해 자원을 살리는 게 Circuit Breaker다. 차단 뒤 일정 시간이 지나면 호출 한두 개만 흘려 의존이 회복됐는지 떠보고(half-open), 정상이면 다시 연다.

마지막 두 가지는 한 의존의 실패가 다른 곳으로 번지지 않게 막는 패턴이다. 의존성별로 스레드·커넥션 풀을 따로 두는 Bulkhead가 한 의존의 장애가 다른 영역까지 자원을 빨아먹는 길을 막고, 두 번 와도 결과가 한 번 온 것과 같게 만드는 Idempotency가 Retry가 만든 중복 도달의 부작용을 지운다.

패턴 문제 해결
Timeout 죽은 의존 서비스가 영원히 응답 안 함 호출에 명시적 시간 제한
Retry + Backoff 일시적 실패 지수 백오프, 지터로 thundering herd 방지
Circuit Breaker 망가진 서비스를 계속 호출하면 전체가 같이 망가짐 일정 실패율 넘으면 호출 차단, 일정 시간 후 half-open
Bulkhead 한 의존성의 장애가 다른 영역까지 자원을 잡아먹음 의존성별 스레드/커넥션 풀 격리
Idempotency 재시도가 중복 처리를 만듦 멱등 키, 멱등한 핸들러

위 다섯 가지로 한 호출의 실패를 막았다고 하더라도, 다른 차원의 문제가 등장한다. 여러 서비스에 걸친 한 트랜잭션을 어떻게 안전하게 끝낼 것인가. RDB 한 대 안에서는 ACID가 공짜로 보장하지만, 결제·재고·배송이 각자 다른 DB에 있을 때 그 보장은 사라진다.

주문 한 건이 들어온다. 결제 서비스가 카드를 승인하고, 재고 서비스가 한 개를 빼고, 배송 서비스가 송장을 만든다. 셋이 다른 DB라 하나의 트랜잭션으로 묶을 수 없다. 결제까지 끝났는데 재고에서 실패하면, 결제는 이미 끝났고 재고는 그대로다. 어긋남이 그대로 남는다. 이 어긋남을 푸는 도구가 Saga다. 단계마다 되돌림(보상 트랜잭션)을 미리 짜둔다. 재고에서 실패하면 결제 환불 호출, 그다음 단계에서 실패하면 재고 복구 호출. 거꾸로 한 단계씩 되돌려 정합성을 메운다.

Outbox는 다른 결의 어긋남을 막는다. 결제 서비스가 결제 DB에 커밋한 뒤 "결제 완료" 메시지를 큐에 발행해야 다음 단계가 진행되는데, 커밋은 됐고 메시지 발행이 네트워크 끊김으로 실패하면 결제는 끝났는데 다음 단계는 영영 그 사실을 모른다. 그래서 메시지를 같은 DB 트랜잭션 안의 outbox 테이블에 함께 적어 두고, 별도 워커가 그 테이블을 읽어 큐로 흘린다. DB 커밋과 메시지 기록이 한 트랜잭션이라 둘 중 하나만 성공하는 사고가 사라진다.

패턴 문제 해결
Saga 여러 서비스에 걸친 트랜잭션을 한 번에 롤백할 수 없음 단계마다 보상 트랜잭션을 짝지어, 실패 시 거꾸로 되돌림
Outbox DB 커밋과 메시지 발행이 따로라 한쪽만 성공할 수 있음 메시지를 같은 트랜잭션의 outbox 테이블에 기록, 워커가 비동기 발행

12.3 가장 중요한 한 가지: 멱등성

12.2의 다섯 중 마지막에 박아둔 멱등성(idempotency) 은 사실 다른 넷의 안전판이다. 같은 요청이 두 번 와도 결과가 한 번 온 것과 같아야 한다는 성질. 정의는 단순하지만, 이게 깨지면 분산 시스템의 다른 모든 안전 장치가 같이 무너진다.

Retry를 떠올려 보자. 호출이 타임아웃돼서 한 번 더 던졌는데, 사실 첫 호출은 의존 쪽에서 정상적으로 처리된 뒤 응답만 못 돌아온 거였다. 받는 쪽이 멱등이 아니면 이 한 번의 retry가 결제를 두 번 처리하고, 잔고를 두 번 깎고, 메시지를 두 번 보낸다. 결제 두 번 처리는 회사 잔고에서 직접 빠지는 사고다.

Saga의 보상 트랜잭션도, 큐의 At-Least-Once 보장도 마찬가지다. 받는 쪽이 멱등이 아니면 환불 호출이 두 번 가서 자산이 새어 나가고, 컨슈머가 메시지를 두 번 처리해 같은 일이 두 번 일어난다. 그래서 멱등성은 한 자리의 패턴이 아니라 모든 분산 패턴의 전제다. Retry가 안전한 것도, Circuit Breaker의 half-open이 안전한 것도, 큐를 마음 놓고 쓰는 것도 받는 쪽이 멱등이라는 가정 위에 서 있다.

구현은 단순하다. 결제 같은 도메인은 클라이언트가 매 요청마다 고유한 멱등 키(Idempotency-Key)를 헤더로 보내고, 서버는 그 키로 처리 결과를 캐시한다. 같은 키가 두 번 들어오면 두 번 처리하지 않고 첫 결과를 그대로 돌려준다. 코드 한 줄짜리 계약이지만, 이 계약이 분산 시스템을 안전하게 만든다.

분산에서 장애는 태풍이 아니라 비다. 우산 없이 다니는 건 매일 흠뻑 젖겠다는 것이다.


13. 무너진다는 가정 위의 시스템: 백업, Disaster Recovery

13.1 방어로 안 되는 사고가 있다

앞 장은 에러가 안 나게 하는 방어였다. 그러나 방어를 아무리 잘 세워도 무너지는 영역이 있다. 운영자가 실수로 DROP TABLE을 친다. 디스크가 통째로 갈린다. 라이브러리 버그로 모든 레코드에 잘못된 값이 누적된다. 그리고 가장 큰 차원으로 region 자체가 죽을 수도 있다. AWS us-east-1의 정기적 장애, 데이터 센터 화재, BGP 라우팅 사고, 자연재해.

그래서 이 장에서 다루는 것은 사고를 막는 게 아니라, 이미 벌어진 사고를 어떻게 복구할 것인가 다.

복구의 깊이를 정하기 위해 가장 먼저 정해야 할 두 숫자가 있다.

  • RPO (Recovery Point Objective): 사고 시점 기준, 최근 얼마간의 데이터까지 잃어도 되는가? 마지막 백업과 사고 사이의 시간이 곧 잃는 데이터의 양이라 시간으로 잰다. 예: 1초 / 5분 / 1시간.
  • RTO (Recovery Time Objective): 얼마만큼의 다운타임을 허용할 것인가? 예: 1분 / 1시간 / 24시간.

이 두 숫자는 비즈니스가 정한다. 결제 시스템의 RPO는 0초에 가까워야 하고, 사내 분석 도구의 RPO는 24시간이어도 무방하다. RPO/RTO를 짧게 잡을수록 비용은 비선형적으로 증가한다.

13.2 백업의 종류와 위치

데이터를 잃지 않는 것은 모든 신뢰성의 출발점이다. 가장 단순한 발상은 "그냥 사본을 떠서 같은 서버에 넣어두면 되지 않나" 다. 이 발상은 두 군데에서 깨진다.

하나, 사본을 어떻게 뜨느냐. 어제 자정에 한 번 통째로 떠놓으면 자정 이후의 변경분이 통째로 사라진다. 자주 뜰수록 잃는 양은 줄지만, 통째로 자주 뜨는 건 비싸다. 그래서 통째로 뜨느냐 변경분만 흘리느냐, 그 분기에서 RPO가 갈린다.

둘, 사본을 어디에 두느냐. 같은 서버 디스크에 두면 디스크가 죽을 때 사본도 같이 죽는다. 같은 region에 두면 region이 죽을 때 같이 죽는다. 사본이 원본과 얼마나 분리되어 있느냐가 어디까지의 사고에서 살아남느냐를 결정한다.

그래서 백업은 방식(통째로 vs 변경분)과 위치(원본과 얼마나 분리), 두 축으로 본다.

방식의 핵심 분기는 DB를 통째로 떠놓느냐, 변경분만 흘려보내느냐다. 통째로 뜨는 쪽은 한 번 뜰 때 비용이 크니 자주 못 뜨고 RPO가 길어지고, 변경분 쪽은 매 트랜잭션을 흘리므로 RPO를 초 단위까지 좁히지만 저장·운영 비용이 더 든다.

  • 스냅샷(Snapshot): 통째로 뜨는 방식의 표준. AWS RDS(관리형 DB)·EBS(가상 디스크)가 제공하는 디스크 블록 단위 시점 사본. 빠른 복구.
  • 논리 백업(Logical Backup): 같은 통째로 방식이지만 SQL 텍스트로 뜬다. pg_dump·mysqldump로 DB 전체를 떠놓아, 다른 엔진·버전으로도 옮길 수 있어 이식성 좋고 작은 DB에 적합.
  • 시점 복구(PITR, Point-in-Time Recovery): 변경분을 흘려보내는 방식. WAL/binlog 변경 로그를 함께 보관해 임의 시점으로 복구한다. RPO가 초 단위로 좁아짐.

위치는 어디까지의 사고에서 살아남느냐를 결정한다. 같은 서버 디스크에 두면 디스크가 죽을 때 사본도 함께 사라진다. 같은 region의 다른 스토리지(S3 같은 객체 스토리지)로 분리하면 서버 사망까지 살아남고, 다른 region에 복사하면 region 사망까지 살아남는다.

  • 같은 region: 스냅샷·PITR이 보통 여기. 빠르지만 region이 죽으면 같이 죽는다.
  • Off-site / Cross-region: 다른 region·외부 매체에 복사. 진짜 재해에 대비.

세계적 표준은 3-2-1 규칙: 3개의 사본, 2종의 매체, 1개는 off-site. 흔한 구성은 운영 DB(원본) + 같은 region의 스냅샷 + 다른 region의 객체 스토리지 복사로, 사본 3·매체 2(블록·객체)·off-site 1을 한 번에 만족한다. 위치 축을 한 줄로 압축한 운영 정석이다.

13.3 Disaster Recovery 전략의 4단계

13.2의 백업이 데이터를 살리는 영역이라면, DR(Disaster Recovery, 재해 복구) 은 시스템 전체를 다른 region에서 다시 일으키는 영역이다.

핵심 축은 단순하다. 사고가 나면 다른 region에서 무엇을 새로 시작해야 하는가. 새로 시작할 게 많을수록 RTO는 길어지고, 미리 켜놓을수록 짧아진다. 인프라부터 띄워야 하면 시간 단위, 앱만 띄우면 분 단위, 이미 떠 있으면 0초에 가깝다. 미리 켜놓는 만큼 비용이 드는 트레이드오프 위에 네 단계가 자리한다. 어디까지 갈지는 비즈니스가 다운타임·데이터 손실에 얼마나 민감하냐로 정해진다.

전략 의미 일반 RTO 비용
Backup & Restore 백업만, 복구 시 인프라부터 새로 띄움 수 시간~일 최저
Pilot Light 핵심(DB)만 다른 region에 복제, 나머지는 꺼둠 수십 분
Warm Standby 축소판이 항상 다른 region에 동작 수 분
Multi-site Active-Active 양쪽 region이 모두 트래픽 처리 0초에 가까움 최고
  1. Backup & Restore (수 시간 정지를 견딜 수 있을 때). 다른 region엔 백업 파일만 둔다. 사고가 나면 인프라부터 새로 띄우고, 그 위에 앱을 올리고, 백업을 복원한다. 새로 시작할 일이 가장 많아 RTO는 수 시간~일, RPO도 백업 주기만큼(보통 시간 단위). 내부 도구·관리 시스템·작은 회사의 현실적 출발점이다.

  2. Pilot Light (수 시간 정지가 곤란해질 때). DB와 핵심 인프라(VPC·네트워크·ELB 설정 등)를 미리 켜놓고 데이터를 상시 복제한다. 사고 시엔 그 위에 앱만 새로 띄우면 된다. DB 복제로 RPO는 초~분 단위로 좁아지고, 앱 기동 시간만큼 RTO는 수십 분이 걸린다. 데이터는 거의 잃을 수 없지만 분 단위 다운타임은 견딜 수 있는 자리.

  3. Warm Standby (분 단위 다운타임도 부담스러울 때). 앱까지 축소판으로 미리 켜놓는다. 평소엔 트래픽을 받지 않다가, 사고 시 트래픽을 그쪽으로 돌리고 스케일 업한다. 앱 기동 시간이 빠지므로 RTO가 수 분으로 짧아진다. RPO는 Pilot Light와 같다.

  4. Multi-site Active-Active (다운타임이 곧 즉각적 매출 손실일 때). 두 region이 평소에 트래픽까지 함께 받는다. 사고가 나도 새로 시작할 게 없다. 다른 쪽이 이미 풀가동 중이라 그쪽이 그대로 받는다. RTO·RPO 모두 0에 가깝다. 대신 양쪽 데이터를 항상 일치시켜야 해서 일관성·비용·운영 복잡도가 모두 가장 비싸다.

flowchart LR A[Backup & Restore
RTO: 시간~일
비용: 최저] --> B[Pilot Light
RTO: 분~시간
비용: 저] B --> C[Warm Standby
RTO: 분
비용: 중] C --> D[Multi-site Active-Active
RTO: 0
비용: 최고]

대부분의 회사는 Pilot Light 또는 Warm Standby 가 현실적 균형이다. Active-Active는 그 비용을 감당할 수 있는 진짜 글로벌 서비스에만 정당화된다.

복구는 사고 전에만 살 수 있는 보험이다. 사고 난 다음 날엔, 사고 싶어도 팔지 않는다.


14. "내 컴퓨터에선 됐는데"의 종말: 도커와 컨테이너

"내 컴퓨터에선 됐는데 서버에선 안 된다." 한 시대의 비용이 이 한 마디에 들어 있었다. 라이브러리 버전, OS 패치, 환경 변수가 한 군데만 달라도 전부 다른 결과가 나왔다.

70년 전 항만 노동자들도 똑같은 고민을 했다. 1956년 말콤 매클린이 강철 박스의 규격을 못박기 전, 같은 자루도 배가 바뀌면 다시 묶고, 트럭이 바뀌면 또 풀어 옮겼다. 짐이 어떻게 생겼느냐에 따라 배·크레인·트럭·창고가 따로 굴러갔다. 박스 사이즈를 통일한 순간, 안에 무엇이 들었든 같은 배·같은 크레인·같은 트럭이 받아들였다.

도커(Docker)는 이 비유를 이름까지 가져왔다. dock은 항만, docker는 그 항만에서 짐을 표준 박스에 봉해 어느 배에든 실을 수 있게 만드는 노동자다. 의존성·환경·코드를 같은 모양의 이미지로 묶는 도커가, 같은 일을 소프트웨어 위에서 한 번 더 한다. 컨테이너는 "내 컴퓨터에선 됐는데"의 비용을 거의 0으로 떨어뜨렸다.

14.1 VM의 무게, 컨테이너의 가벼움

파이썬 코드를 서버에 올렸더니 버전이 달라 안 돌아간다. 버전을 맞춘다. 그랬더니 외부 라이브러리가 안 맞는다. 라이브러리까지 묶어 패키징한다. 그랬더니 libssl 같은 시스템 라이브러리가 또 다르다. 이것도 묶자. 그 다음엔 환경 변수. 이렇게 가다 보면 결국 물리 하드웨어만 빼고 전부 묶게 된다. 가상머신(VM) 의 탄생이다.

가상 컴퓨터에 OS부터 코드까지 깔아 옮기면 환경 차이는 사라진다. 한동안 정말로 그렇게 했다.

다만 이 방식은 두 군데에서 무겁다. 하나, 가상 컴퓨터마다 OS를 통째로 끌고 다닌다. OS는 결코 가볍지 않다. 부팅마다 init·로그·네트워크 데몬이 줄줄이 올라오고, 디스크엔 시스템 라이브러리·셸·유틸리티가 통째로 깔린다. VM 한 대를 띄운다는 건 앱을 올리기도 전에 이 전체를 메모리·디스크에 깔아 두는 일이고, 정작 앱이 쓰는 건 그중 작은 한 조각이다. 둘, 모든 VM이 같은 물리 서버의 하드웨어를 공유하면서 OS만 N벌이다. 정말로 N개가 따로 필요한가?

Docker의 답은 다르다. OS는 한 벌만 두고, 그 위 프로세스만 서로 모르게 격리해 띄우자. 격리됐다는 건, 한 프로세스가 자기만의 파일시스템·네트워크·프로세스 목록을 가진다는 뜻이다. 다른 컨테이너의 파일이 안 보이고, 자기만의 IP를 갖고, ps를 쳐도 다른 컨테이너의 프로세스는 나오지 않는다. 컨테이너 안에서 보면 자기가 OS를 혼자 쓰는 것 같지만, 실제로는 호스트 커널을 다른 컨테이너들과 함께 빌려 쓰는 중이다.

결국 핵심은 격리라는 이야긴데, 격리는 리눅스가 이미 갖고 있던 기능이다. namespace가 "보이는 범위"를 한정하고, cgroups가 쓸 수 있는 CPU·메모리 한도를 정한다. Docker는 이 둘을 그대로 가져다 썼다. VM이 OS를 한 벌 더 깔아 격리했다면, 컨테이너는 OS 한 벌 위에서 프로세스끼리만 격리한다.

flowchart LR subgraph VM_S["① VM: OS가 N벌"] direction TB VA1[App A] --- VOS1["Guest OS
(커널·시스템 서비스·라이브러리)"] VA2[App B] --- VOS2["Guest OS
(커널·시스템 서비스·라이브러리)"] VA3[App C] --- VOS3["Guest OS
(커널·시스템 서비스·라이브러리)"] VOS1 --- VHV[Hypervisor] VOS2 --- VHV VOS3 --- VHV VHV --- VHW[물리 하드웨어] end subgraph CT_S["② 컨테이너: OS가 1벌"] direction TB CA1[App A] --- CENG["Docker / 컨테이너 런타임
(namespace · cgroups)"] CA2[App B] --- CENG CA3[App C] --- CENG CENG --- COS["호스트 OS (한 벌만)
커널·시스템 서비스·라이브러리"] COS --- CHW[물리 하드웨어] end VM_S ~~~ CT_S

가벼움의 대가도 있다. 커널을 공유하므로 호스트와 컨테이너가 같은 OS 계열이어야 한다. VM은 하드웨어 자체를 가상화하므로 Windows 위에서 리눅스 VM을 띄우거나 반대도 가능하지만, 컨테이너는 오로지 리눅스 위에서만 리눅스 컨테이너가 돈다. 맥북에서 리눅스 컨테이너를 띄우고 싶다면 리눅스 VM을 먼저 띄우고 그 안에 컨테이너를 올려야 하는 우스꽝스러운 일이 벌어진다. Docker Desktop이 macOS에선 LinuxKit으로, Windows에선 WSL2로 정확히 이걸 한다.

14.2 레시피, 사진, 실행: Dockerfile·이미지·컨테이너

핵심은 두 가지다. 이미지(image) 는 애플리케이션과 모든 의존성을 묶은 사진이고, 컨테이너(container) 는 그 사진을 띄워 굴리는 한 번의 실행이다. 사진 한 장으로 똑같은 컨테이너를 1개 띄우든 1000개 띄우든, 노트북에서 띄우든 운영 서버에서 띄우든, 안에서 보이는 환경이 같다.

이미지를 만드는 방법은 단순하다. Dockerfile이라는 한 장짜리 레시피에 "어느 베이스 이미지 위에 / 어떤 의존성을 깔고 / 어떤 코드를 복사하고 / 어떻게 실행할지" 한 줄씩 적으면, Docker가 그 줄을 위에서 아래로 따라가며 이미지를 빌드한다.

FROM python:3.11-slim                  # 베이스: 파이썬이 깔린 가벼운 리눅스
WORKDIR /app                           # 이후 명령이 실행될 작업 디렉터리
COPY requirements.txt .                # 의존성 명세서를 먼저 복사
RUN pip install -r requirements.txt    # 의존성 설치
COPY . .                               # 그 다음에 코드 전체 복사
CMD ["python", "main.py"]              # 컨테이너가 시작되면 실행될 명령

Dockerfile의 한 줄은 곧 하나의 레이어(layer) 다. 레이어가 위에서 아래로 차곡차곡 쌓여 이미지가 되고, 다음 빌드 때 변하지 않은 레이어는 캐시 그대로 재사용된다. 위 예시에서 의존성을 먼저 깔고(COPY requirements.txt + RUN pip install) 코드를 나중에 복사하는(COPY . .) 순서가 표준인 이유가 이거다. 코드 한 글자만 바뀌어도 무거운 의존성 설치 레이어는 캐시로 통과해 빌드가 초 단위로 끝난다. 의존성과 코드를 한 줄에 같이 두면 코드 한 글자 바뀔 때마다 의존성을 통째로 다시 깐다.

만들어진 이미지를 컨테이너로 띄우는 건 한 줄이다.

docker run -d --name myapp -p 8080:80 myimage:latest

-d가 백그라운드 실행, -p 8080:80이 호스트의 8080 포트를 컨테이너의 80 포트로 연결, myimage:latest가 어떤 이미지를 띄울지 가리킨다. 같은 이미지를 같은 명령으로 노트북에서 띄우든 운영 서버에서 띄우든 결과가 같다.

실전에선 이미지가 무거워질 때 Multi-stage build로 정리한다. Java·Go처럼 빌드해서 실행하는 언어는 위 방식 그대로면 JDK·컴파일러·소스코드까지 따라와 이미지가 1GB를 넘기기도 한다. 한 Dockerfile 안에 빌드용 단계와 실행용 단계를 나란히 두고 빌드 단계의 산출물(jar 등)만 실행 단계로 옮기면, 운영에 안 따라와도 될 도구가 떨어져 나간다.

14.3 도커 이미지가 코드처럼 흐른다: 레지스트리·태그·OCI 표준

이미지를 만들었다면, 다음 문제는 어떻게 다른 컴퓨터로 옮기느냐다. 노트북에서 빌드한 이미지를 운영 서버가 띄우려면 그 사이를 잇는 저장소가 필요하다. 그 자리가 컨테이너 레지스트리(registry) 다. 이미지를 위한 GitHub이라고 보면 된다. CI가 빌드한 이미지를 레지스트리에 push(올리기)하면, 운영 서버가 거기서 pull(받기)해 컨테이너로 띄운다. 코드가 git을 거쳐 어디에서나 같은 코드인 것처럼, 이미지가 레지스트리를 거쳐 어디에서나 같은 실행이 된다.

어떤 레지스트리를 쓰느냐는 환경에 따라 갈린다. 위에서 본 python:3.11-slim 같은 공개 베이스 이미지는 누구나 받을 수 있는 Docker Hub(공개 레지스트리)에서 가져온다. 회사 코드를 담은 이미지는 보통 사내(private) 레지스트리에 둔다. AWS 인프라에 올릴 거면 AWS ECR, 코드 저장소가 GitHub이라 권한·인증을 같이 관리하고 싶다면 GitHub GHCR, 외부로 나가면 안 되는 이미지를 자체 데이터센터에서 운영하면 Harbor가 흔한 선택이다.

코드가 바뀔 때마다 새 이미지가 빌드되어 레지스트리에 쌓인다. 같은 이미지의 여러 버전 중 어느 걸 가리켜 띄울지 골라야 하는데, 가장 흔한 방법이 태그(tag) 다. myimage:1.2.3처럼 사람이 읽기 쉬운 이름을 단다. 다만 같은 태그로 다른 이미지를 다시 푸시할 수 있다. 운영에서 myimage:latest를 쓰면 어제까지 정상이던 latest가 오늘은 다른 코드일 수 있다.

이걸 막는 게 다이제스트(digest) 다. 이미지 내용의 SHA-256 해시로 myimage@sha256:abc... 형태로 가리킨다. 같은 다이제스트는 항상 같은 바이트, 누가 덮어쓰려 해도 해시가 달라져 새 다이제스트가 된다. 운영 이미지는 태그가 아니라 다이제스트로 핀 박는 게 정석이다.

이런 도구 간 호환성을 떠받치는 게 OCI(Open Container Initiative) 표준이다. 이미지 파일 구조·실행 방식·레지스트리 API를 산업 표준으로 박아둔 덕에, Docker로 빌드한 이미지를 ECR·GHCR·Harbor 어디든 똑같이 받고 띄운다. "Docker 이미지"라고 부르지만 사실 "OCI 이미지"이고, Docker는 그 표준의 한 구현일 뿐이다.

14.4 죽어도 남는 것, 분리해도 닿게 하는 것: 볼륨과 네트워킹

컨테이너는 일회용(disposable) 이다. 죽이면 그 안의 상태도 같이 사라진다. 두 가지가 문제다. DB 컨테이너가 죽으면 어제까지의 데이터가 같이 사라지고, 개발 중에 코드 한 줄 고쳐도 다시 빌드하기 전엔 반영되지 않는다.

답이 볼륨(Volume) 이다. 컨테이너 안 특정 디렉터리를 바깥 저장소에 연결해, 그 디렉터리에 쓴 내용이 컨테이너가 죽어도 살아남게 한다. docker run-v 한 줄로 끝난다.

# 이름붙은 볼륨: Docker가 관리하는 영속 저장소에 연결 (운영용)
docker run -v pgdata:/var/lib/postgresql/data postgres:16

# 바인드 마운트: 호스트 디렉터리를 그대로 연결 (개발용)
docker run -v $(pwd):/app myimage:latest

자리가 다르다. 이름붙은 볼륨은 운영용이다. Docker가 호스트 어딘가에 영속 저장소를 만들어 컨테이너에 붙여 준다. DB 데이터처럼 컨테이너와 별개로 살아남아야 하는 자리다. 바인드 마운트는 개발용으로, 호스트 디렉터리를 컨테이너 안에 그대로 비춰 코드를 고치면 다시 빌드할 필요 없이 곧장 반영된다.

데이터의 짝이 네트워킹이다. 컨테이너끼리, 그리고 컨테이너와 외부를 잇는 일이다. 컨테이너는 자기만의 네트워크 namespace(IP·포트 공간)를 가져 기본은 격리 상태고, 그대로 두면 옆 컨테이너에도 외부에도 닿지 못한다. Docker가 두 방향으로 다리를 놓는다. 컨테이너끼리 통신하는 다리는 bridge 네트워크다. 같은 호스트의 컨테이너들이 Docker가 만든 가상 스위치 위에서 이름으로 서로를 부른다. 외부에서 컨테이너로 들어오는 다리는 포트 매핑이다.

docker run -p 8080:80 myimage    # 호스트 8080으로 오는 트래픽을 컨테이너 80으로 NAT 연결

14.2의 -p 한 줄 뒤에서 일어나는 일이다.

flowchart LR User["외부 트래픽
(User · Internet)"] subgraph Host["호스트"] direction TB Vol[("볼륨 pgdata
(컨테이너 밖, 영속)")] subgraph Bridge["bridge 네트워크"] direction TB CApp["App 컨테이너 :80"] CDB["DB 컨테이너 :5432"] end end User -->|"포트 매핑
호스트 :8080 → 컨테이너 :80"| CApp CApp -->|"db:5432로 호출"| CDB CDB -.->|"읽기/쓰기"| Vol

볼륨은 컨테이너가 죽어도 데이터를 남기고, 네트워킹은 컨테이너가 격리돼도 통신을 닿게 한다. 이 둘이 컨테이너의 일회성이라는 한계를 메워 운영에서 쓸 수 있게 만든다.

14.5 여러 컨테이너, 한 파일: Docker Compose

개발자가 노트북에서 "DB + 앱 + Redis"를 한꺼번에 띄우려면 docker run postgres ..., docker run redis ..., docker run myapp ...을 매번 치고 그 사이를 네트워크·볼륨으로 손수 묶어야 한다. 금방 지친다. Docker Compose가 이 이것을 대체한다. docker-compose.yml 한 파일에 여러 서비스의 정의·의존성·네트워크·볼륨을 적어두면, docker compose up 한 번으로 모두 뜨고 down 한 번으로 모두 정리된다.

services:                                   # 서비스 정의: 함께 뜰 컨테이너들
  app:
    build: .                                # 현재 디렉터리 Dockerfile로 빌드
    ports: ["8080:80"]                      # 호스트 8080 → 컨테이너 80
    depends_on: [db, redis]                 # 의존성: db·redis가 먼저 뜬 뒤에 시작
    networks: [appnet]
  db:
    image: postgres:16                      # Docker Hub 공개 이미지
    environment:
      POSTGRES_PASSWORD: dev
    volumes:
      - pgdata:/var/lib/postgresql/data     # 볼륨: 데이터를 컨테이너 밖 저장소에 영속 저장
    networks: [appnet]
  redis:
    image: redis:7
    networks: [appnet]

networks:
  appnet:                                   # 네트워크: 위 컨테이너들이 묶일 bridge

volumes:
  pgdata:                                   # 볼륨 선언: 컨테이너 죽어도 남는 이름붙은 저장소

같은 bridge 네트워크에 묶인 서비스끼리는 서비스 이름(db, redis)이 DNS처럼 동작한다. 앱에서 postgres://db:5432로 적으면 그대로 DB 컨테이너에 닿는다. 볼륨(pgdata)은 컨테이너가 죽었다 살아도 그대로 남는다.

flowchart LR User["개발자 브라우저"] subgraph Host["호스트 (개발자 노트북)"] direction TB Vol[("pgdata 볼륨
(컨테이너 밖, 영속)")] subgraph Net["appnet (bridge 네트워크)"] direction TB CApp["app 컨테이너 :80"] CDB["db (postgres) :5432"] CRedis["redis :6379"] end end User -->|"포트 매핑
호스트 :8080 → :80"| CApp CApp -->|"db:5432"| CDB CApp -->|"redis:6379"| CRedis CDB -.->|"영속 저장"| Vol

운영용은 K8s, 개발용은 Compose. 같은 OCI 이미지로 양쪽 다 굴러간다.

친구가 여행 짐 싸는 것을 본 적이 있다. "이거 넣을까?" 하면서 여행 가방이 이삿짐 센터가 되더라. 도커는 개발자보다 그 친구에게 필요할지도 모르겠다.


15. 수백 개의 컨테이너를 싣고 있는 배의 조타수: 쿠버네티스

도커가 박스 하나를 표준화하는 일이었다면, 박스가 한두 개로 끝나지 않을 때 새 문제가 시작된다. 박스 하나는 항만 노동자가 들어다 놓는다. 수만 개가 수십 척의 배에 흩어지면 다른 사람이 필요하다. 어느 박스를 어느 배에 실을지 정하고, 떨어진 박스는 다시 올리고, 짐이 몰리면 배를 더 띄우고, 박스를 교체하는 와중에도 항해를 유지하고, 어느 배가 어디 있는지 항구마다 알려주는 사람. 그리스어로 그를 κυβερνήτης, 쿠베르네테스라 불렀다. 한국어로는 조타수다. 당시의 조타수는 키만 잡는 자리가 아니라 항해 전반을 총괄하는 역할이었다. 영어 governor가 같은 어원이다.

Kubernetes는 그 이름을 그대로 받았다. 도커가 짐을 싸는 일이라면, 쿠버네티스는 그 짐을 실은 배들을 모는 일이다.

15.1 docker run을 수백 번씩 칠 수 없다

컨테이너 한두 개는 docker run으로 충분하다. 수백, 수천 개가 되는 순간 새 문제가 다섯 가지 등장한다.

  • 스케줄링: 어느 노드에 어느 컨테이너를 띄울 것인가? 메모리·CPU가 남는 자리에 알아서 배치돼야 한다.
  • 자가 치유: 컨테이너가 죽으면 누가 다시 띄우는가? 노드 자체가 죽으면 그 위의 컨테이너들은 어디로 가는가?
  • 오토스케일링: 트래픽이 두 배가 됐을 때 누가 인스턴스를 두 배로 늘리는가? 한가해지면 누가 줄이는가?
  • 무중단 배포: 1000개 인스턴스의 이미지 태그를 어떻게 한 번에 갈아끼우는가? 중간에 트래픽은 어디로 가는가?
  • 서비스 디스커버리: A 서비스의 인스턴스가 매분 늘었다 줄었다 하는데, B 서비스는 그 IP를 어떻게 알아내는가?

조타수가 하던 일들과 정확히 일치한다. 이것들을 푸는 도구가 Kubernetes(K8s) 다.

15.2 K8s는 명령이 아니라 선언이다

K8s의 발상은 단 하나다. 사용자는 "이 서비스 인스턴스 3개"라고 원하는 상태(desired state) 만 적는다. 컨트롤러는 주기적으로 API server에서 현재 상태(current state) 를 조회해 둘을 비교하다가, 어긋나면 차이를 메운다. 이 "원하는 상태와 현재 상태의 차이를 끊임없이 메우는 동작"이 reconcile이다.

컨트롤러는 "Pod가 죽었다"는 알림에 반응하는 게 아니라, "지금 몇 개 살아 있나"를 매 사이클마다 새로 확인한다. 그래서 한 알림을 놓쳐도 다음 사이클에 따라잡는다.

온도조절기와 같다. 사람이 28도라고 적어두면 조절기가 실내 온도를 끊임없이 측정해 28도가 아닐 때마다 에어컨을 돌린다. "에어컨 켜라/꺼라"를 명령형으로 일일이 시키지 않는다.

위에서 본 다섯 문제는 한 장의 YAML로 풀린다. YAML은 들여쓰기로 계층을 표현하는 텍스트 양식이고, K8s가 desired state를 받는 표준 입력이다.

# desired state

# Deployment: "원하는 Pod 모양과 개수"
apiVersion: apps/v1
kind: Deployment
metadata: { name: myapp }
spec:
  replicas: 3      # 스케줄링 + 자가 치유:  "3개"만 적으면 빈 노드에 띄우고, 죽으면 다시 띄워 3개 유지
  strategy:
    rollingUpdate: { maxUnavailable: 0 }  # 무중단 배포: 한 번에 하나씩 새 버전으로 교체
  template:
    spec:
      containers:
        - { name: app, image: myapp:v2 }  # 이미지 태그를 v3으로 바꾸기만 하면 무중단 배포를  자동으로 진행
---
# HPA: "트래픽에 따라 replicas를 알아서 조정"
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: myapp }
spec:
  scaleTargetRef: { kind: Deployment, name: myapp }
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - resource: { name: cpu, target: { averageUtilization: 70 } }
    # 오토스케일링: CPU 70% 넘으면 K8s가 알아서 replicas 늘림
---
# Service: "이 Pod 묶음의 고정 이름"
apiVersion: v1
kind: Service
metadata: { name: myapp }    # 서비스 디스커버리: 다른 서비스는 Pod IP가 아니라 "myapp" 이름만 호출
spec:
  selector: { app: myapp }
  ports: [{ port: 80 }]

이 YAML을 K8s에 보내는 도구가 kubectl이다. Kubernetes control(K8s 제어)을 줄인 이름으로, K8s의 CLI다. 사용자가 kubectl apply로 desired state 한 줄만 바꿔 던지면 K8s가 알아서 차이를 메운다.

15.3 객체는 많지만, 쓰는 것만 쓴다

위의 선언이 실제로 굴러가려면 "원하는 상태"를 한 객체로는 다 적을 수 없다. 컨테이너를 어떻게 묶을지(실행 단위), 그 묶음을 몇 개로 어떻게 갱신할지(워크로드), 외부에서 어디로 들어올지(네트워킹), 죽으면 안 되는 데이터를 어디에 둘지(저장), 환경별 값과 비밀을 어떻게 주입할지(설정·비밀), 같은 클러스터에서 환경끼리 어디까지 갈라놓을지(경계), 트래픽에 따라 어떻게 늘리고 줄일지(자동 스케일). K8s 객체는 이 축마다 하나씩 대응해 desired state를 분담한다. 운영의 90%는 다음 객체들로 끝난다.

카테고리 객체 식별 한 줄 자주 같이 쓰는 것
실행 단위 Pod 같이 살고 죽는 컨테이너 묶음 sidecar, init container
워크로드 Deployment stateless Pod N개 + 롤링 ReplicaSet, Service
StatefulSet stateful Pod + 안정 식별자 PV / PVC
DaemonSet 노드당 1개 (에이전트류)
Job / CronJob 일회성 / 주기 작업
네트워킹 Service 클러스터 내부 가상 IP (L4) label / selector
Ingress 외부 HTTP 진입점 (L7 + TLS) Service, cert-manager
저장 PV / PVC 영속 디스크 + 요청서 StatefulSet
설정·비밀 ConfigMap / Secret 환경별 값 / 비밀 주입 Pod env·volume
경계 Namespace 환경·팀·RBAC 가상 경계 (모든 객체가 이 안에)
자동 스케일 HPA / VPA 가로(개수) / 세로(크기) Deployment, Pod

표만 봐서는 객체들이 왜 이렇게 분리되어 있는지 안 보인다. 하나씩 풀어 본다.

15.3.1 Pod

K8s가 띄우는 최소 단위는 컨테이너가 아니라 Pod다. Pod는 컨테이너 한 개 이상을 묶은 단위다. 굳이 컨테이너 위에 한 층을 더 둔 이유는 단순하다. 실전에선 컨테이너 하나만 띄우는 일이 드물기 때문이다. 대표적으로 두 가지 예시가 있다. 하나는 사이드카(sidecar) 컨테이너로, 메인 앱 컨테이너 옆에 로그 수집기·메트릭 에이전트·프록시 같은 보조 컨테이너를 같이 띄우는 패턴이다. 다른 하나는 init container로, 메인 앱 컨테이너가 뜨기 전에 한 번만 돌고 끝나는 보조 컨테이너다. 대표적인 쓰임이 새 코드가 요구하는 DB 스키마(테이블 구조) 변경을 DB에 미리 반영해 두는 DB 마이그레이션이다. 이런 보조 컨테이너들이 메인 앱과 같은 IP·같은 디스크를 공유하며 같이 뜨고 같이 죽어야 한다. 그 묶음 단위가 Pod다.

Pod를 정의할 때 빠뜨리면 운영 사고로 직결되는 자리가 두 곳 있다.

하나는 probe다. K8s가 Pod에 주기적으로 거는 건강검진으로, 두 종류가 있다. liveness probe는 "죽었나"를 물어 실패 시 Pod를 재시작하고, readiness probe는 "트래픽 받을 준비됐나"를 물어 실패 시 서비스에서 뺀다(Pod는 살려둠).

다른 하나는 resources.requests / limits다. Pod가 노드 자원을 얼마나 쓰겠다고 미리 적어 두는 자리다. requests는 스케줄러에 "최소 이만큼이 보장돼야 띄울 수 있다"고 거는 하한이고, limits는 런타임에 "이 이상은 못 쓴다"고 거는 상한이다. requests가 너무 작으면 스케줄러가 자리 많은 줄 알고 Pod를 한 노드에 몰아넣어 메모리 부족으로 OOM kill이 터지고, 너무 크면 노드가 한가한데도 "자리 없다"며 Pending에 멈춘다. limits가 너무 작으면 정상 트래픽에도 컨테이너가 자기 한도에 걸려 OOM kill되거나 CPU throttle로 느려지고, 너무 크면 한 Pod가 폭주할 때 노드 자원을 다 빨아 옆 Pod까지 흔든다. 운영 클러스터의 효율은 사실상 이 두 숫자의 함수다.

15.3.2 Deployment

운영 환경에선 Pod 하나로는 부족하다. 트래픽을 한 Pod가 다 못 받기도 하고, 그 Pod가 죽거나 노드가 통째로 사라지면 서비스가 곧장 끊기기 때문이다. 그래서 같은 Pod 여러 개를 항상 살려 두고 싶다. 또, 새 버전을 배포할 때 옛 Pod를 한꺼번에 다 내리고 새 Pod로 띄우면 그 사이가 다운타임이 된다. 배포가 매주·매일 일어나는 환경에선 이게 매번 장애로 잡히니, 옛 버전을 살려 둔 채 새 버전을 점진적으로 끼워 넣어야 한다.

이 두 가지를 한 객체로 풀어 주는 게 Deployment다. Deployment는 desired state로 세 가지를 받는다. Pod 스펙(Pod 하나를 어떻게 띄울지 적은 설계도. 이미지·환경변수·리소스·probe 같은 게 다 들어감), replicas(같은 Pod 스펙으로 몇 개를 항상 유지할지), 롤링 정책(새 버전으로 갈아끼울 때 한 번에 몇 개씩, 얼마나 빠르게 옮길지에 대한 규칙).

Deployment는 이 세 가지를 받아 직접 Pod를 만들지 않고 중간에 ReplicaSet을 둔다. ReplicaSet은 "이 Pod 스펙으로 N개를 항상 띄워 둔다"만 책임지는 컨트롤러로, ReplicaSet 한 개가 N개의 replica(같은 스펙으로 뜬 Pod)를 유지한다. Pod 하나가 죽으면 새로 띄우고, replicas 숫자가 바뀌면 그 수에 맞게 늘리거나 줄인다. 평상시 클러스터엔 Deployment 하나당 ReplicaSet 하나가 떠 있다.

새 버전을 배포하면 그림이 잠깐 바뀐다. Deployment가 새 Pod 스펙으로 새 ReplicaSet을 하나 더 띄우고, 옛 ReplicaSet의 replicas는 점점 줄이고 새 ReplicaSet의 replicas는 점점 늘려 두 ReplicaSet 사이에서 Pod 수를 옮긴다. 한 번에 몇 개씩 옮길지를 정하는 게 앞서 말한 롤링 정책이다. 옛 ReplicaSet의 Pod 수가 0이 되는 순간 배포가 끝난다.

15.3.3 label / selector

ReplicaSet은 평상시에 "이 Pod 스펙으로 N개를 항상 띄워 둔다"는 일을 한다. 이걸 해내려면 매 사이클마다 "지금 내가 띄운 Pod가 몇 개나 있나"를 셀 줄 알아야 한다. N보다 적으면 더 띄우고, 많으면 죽이고, 죽은 게 있으면 새로 띄워 채운다. 그래서 ReplicaSet에는 "어떤 Pod들이 내 묶음에 속하느냐"를 판단하는 기준이 필요하다.

가장 익숙한 후보는 이름이다. "Pod-A, Pod-B, Pod-C가 내 묶음"이라고 적어 두면 될 것 같지만, K8s에서는 이게 통하지 않는다. 사용자가 ReplicaSet에 적는 desired state는 "Pod 스펙 한 벌과 N개"일 뿐, Pod 이름 N개를 일일이 적는 게 아니다. 실제 Pod 이름은 K8s가 Pod를 띄우는 시점에 자동으로 붙이고, 죽었다 다시 뜨면 또 다른 이름이 붙는다. 무엇보다 ReplicaSet은 Pod 하나하나를 개별로 구분할 필요가 없다. 다 같은 스펙으로 뜬 복제본이라, 알아야 하는 건 "이 Pod가 내 묶음의 일원이냐 아니냐"뿐이다.

K8s가 라벨을 쓰는 이유가 그래서다. 개별 식별자가 아니라 그룹 식별자로 묶는다. Pod 스펙에 app: myapp 같은 키-값 태그(label)를 박아 두면, 그 스펙으로 뜨는 Pod에는 매번 같은 라벨이 함께 붙는다. ReplicaSet은 자기 정의에 selector: { app: myapp } 같은 조건(selector)을 적어 "이 라벨을 가진 Pod가 내 묶음"이라고 선언해 둔다. 매 사이클마다 K8s는 selector와 라벨이 일치하는 Pod들을 ReplicaSet에 보여 주고, ReplicaSet은 그 수를 세서 N과 맞춘다.

이름이 아니라 라벨로 묶는 게 K8s 객체끼리 서로를 가리키는 표준 패턴이다.

15.3.4 Service

한 서비스가 다른 서비스의 Pod를 호출해야 할 때가 잦다. 그런데 호출자가 Pod IP를 직접 적어 부르는 건 작동하지 않는다. Pod IP는 Pod가 죽었다 새로 뜰 때마다 바뀌고, replicas가 늘었다 줄었다 하면 어느 IP가 살아 있는지도 매번 달라진다. 호출자가 매번 살아 있는 Pod IP 목록을 조회해 라우팅할 수는 없는 일이다.

호출자와 Pod 사이에 한 단계 추상을 끼워 두는 객체가 Service다. Service는 자기 정의에 selector: { app: myapp } 같은 조건을 적어 두면, 그 라벨을 가진 Pod들이 자동으로 자기 묶음에 들어온다. 그 Pod 묶음 앞에 변하지 않는 가상 IP(클러스터 내부 라우팅 테이블에만 존재하는 IP)와 DNS 이름(myapp.namespace.svc.cluster.local 같은 형태)을 붙여 둔다. 호출자는 Pod 개수·IP·생사를 신경 쓰지 않고 그 이름만 부르면, K8s가 그 순간 살아 있는 Pod 중 하나로 자동 라우팅한다.

어디까지 노출할지에 따라 Service 타입이 셋이다. ClusterIP는 기본형으로, 클러스터 내부 서비스끼리만 부를 때 쓴다. 가상 IP를 클러스터 안에서만 보이게 둬서 외부에선 접근할 수 없다. NodePort는 별도 LB 없이 외부 트래픽을 받아야 할 때(로컬 개발이나 작은 클러스터) 쓴다. 클러스터 모든 노드의 같은 포트를 열어, 어느 노드의 IP로든 그 포트를 치면 Service로 라우팅된다. LoadBalancer는 인터넷에 열어 두는 프로덕션 외부 진입점에 쓴다. K8s가 클라우드 API를 호출해 L4 LB를 자동으로 띄우고, 그 LB의 외부 IP가 트래픽을 Service로 넘긴다.

15.3.5 Ingress

Ingress는 외부에서 들어오는 HTTP 트래픽을 받아서 여러 Service로 나눠준다. Service의 LoadBalancer 타입으로도 외부 노출은 충분히 되는데, 왜 Ingress가 필요할까. 외부에 내놓을 서비스가 여럿일 때 답이 보인다. 가령 외부에 api.myshop.com, admin.myshop.com, www.myshop.com 세 도메인을 내놓아야 한다고 해 보자. LoadBalancer Service만으로 풀려면 셋마다 클라우드 LB를 따로 띄워야 한다. LB가 셋이면 매달 청구 비용도 셋, 외부 IP·DNS 레코드·TLS 인증서까지 셋으로 갈라져 따로 관리해야 한다. 그렇다고 하나의 LoadBalancer로 셋을 같이 받자니, LoadBalancer는 L4라 IP·포트만 보고 트래픽을 나눈다. URL의 호스트네임이 apiwww냐를 보고 다른 Service로 분기하는 일은 못 한다.

Ingress는 이 둘을 한 객체로 풀어 둔다. 두 가지 일을 동시에 처리한다. 하나는 host/path 기반 L7 라우팅으로, URL을 보고 api.myshop.com은 backend Service로, www.myshop.com은 frontend Service로 보내는 식이다. 다른 하나는 TLS 종단이다. Ingress가 외부에서 들어오는 HTTPS를 인증서로 복호화해 받고, 백엔드 Service들에는 평문 HTTP로 넘긴다. 인증서가 Ingress 한곳에 모이니, 도메인이 늘어도 발급·갱신을 한 자리에서 처리할 수 있다. cert-manager 같은 컨트롤러를 깔면 Let's Encrypt(무료 인증서 발급처)에서 자동 발급·갱신까지 받아 사람이 손댈 일이 거의 없다. 결과적으로 클라우드 LB 한 개, 외부 IP 한 개, 인증서 한 자리에서 여러 Service를 외부에 노출할 수 있다.

Ingress 객체 자체는 라우팅 규칙·TLS 설정을 적어 둔 명세일 뿐이다. 그 명세를 읽어 실제로 트래픽을 받아 라우팅을 수행하는 구현체가 클러스터 어딘가에 떠 있어야 한다. 두 가지가 보편적이고, 어디에 클러스터를 띄웠느냐로 갈린다. 자체 데이터센터·베어메탈에선 NGINX Ingress·Traefik 같은 Ingress 컨트롤러를 Pod로 띄워, 그 Pod가 외부 트래픽을 받아 직접 라우팅한다. AWS·GCP 같은 클라우드 위에선 클라우드 자체의 L7 LB(AWS ALB·GCP HTTPS LB)를 쓴다. K8s가 Ingress 정의를 읽어 클라우드 API를 호출하면 LB가 자동으로 떠서 그 앞에 붙고, 라우팅도 그 LB가 처리한다. 두 갈래의 차이는 트래픽을 받는 주체뿐, 사용자가 쓰는 Ingress 명세는 어느 쪽에서든 똑같이 작동한다.

Service가 Pod 묶음 앞의 L4 LB라면, Ingress는 여러 Service 앞의 L7 LB다.

15.3.6 StatefulSet

Deployment는 Pod를 stateless로 가정한다. 디스크에 영구적으로 데이터를 쌓아 두지 않는다는 뜻이다. 그래서 Pod 이름이 무작위 해시여도, 죽었다 다른 노드에서 다시 떠도 Service 뒤의 어느 Pod가 받든 같은 응답을 돌려주니 사용자 경험엔 차이가 없다.

그러나 DB나 Kafka 브로커처럼 디스크에 데이터·로그를 쌓아 두는 stateful 워크로드는 다르다. 추가로 보장이 세 개 더 필요해진다. 첫째, 데이터 보존을 위한 보장. Pod가 죽어도 그 안의 데이터를 잃으면 안 되니, 디스크가 함께 사라지지 않고 다시 뜬 Pod에 같은 디스크가 다시 붙어야 한다. 둘째, 멤버 식별을 위한 보장. stateful 워크로드는 보통 여러 Pod로 데이터를 복제하는 클러스터로 굴린다. 한 Pod가 쓰기를 도맡고, 나머지는 그 Pod를 따라가며 복제본을 든다. 따라가는 쪽이 "내가 따라갈 곳은 db-0"이라고 알고 있으려면 그 이름이 재시작 후에도 같아야 한다. 무작위 해시 이름으론 안 된다. 변하지 않는 안정된 이름이 필요하다. 셋째, 부팅 순서 보장을 위한 보장. 쓰기를 받는 Pod가 먼저 떠 있어야 따라가는 Pod들이 그쪽에 붙어 데이터를 따라잡는다. 한 번에 다 띄우면 따라갈 곳을 못 찾아 클러스터가 못 모인다. 정해진 순서로 떠야 한다.

StatefulSet은 이 세 요구를 채우는 워크로드 객체다. (1) 전용 디스크: 각 Pod에 자기 PVC를 묶어 두어, Pod가 죽어도 디스크는 안 사라지고 같은 이름으로 다시 뜬 Pod에 그 디스크가 다시 붙는다. (2) 안정 식별자: Pod에 무작위 해시가 아니라 0부터 순서대로 번호(db-0, db-1, db-2)를 붙여, 죽었다 다시 떠도 같은 이름을 다시 받게 한다. (3) 순차 부팅: Pod를 한 번에 다 띄우지 않고 0번부터 한 개씩 순서대로 띄운다(삭제는 역순). 분산 DB에선 흔히 쓰기를 받는 쪽을 leader, 그 Pod를 따라가는 쪽을 follower라고 부르는데, StatefulSet은 이런 leader/follower 구조의 클러스터를 K8s 위에서 굴리는 자리다.

15.3.7 PV / PVC

앞서 StatefulSet이 "각 Pod에 전용 디스크를 묶어 둔다"고 했다. 그 디스크가 어떻게 K8s 객체로 모델링되는지가 PV/PVC의 자리다. PV(PersistentVolume) 는 클러스터에 등록된 진짜 디스크(AWS EBS·GCE PD 같은)의 추상으로, 인프라 운영자가 "여기 100GB 디스크가 있다"고 깔아 두는 자리다. PVC(PersistentVolumeClaim) 는 Pod 옆에 두는 디스크 명세서로, 앱 개발자가 "5GB, 한 Pod만 읽고 쓸 거"라고만 적는 자리다. 객체가 둘로 갈라진 건 인프라(어떤 디스크가 어디 있나)와 앱(얼마만큼 필요한가)의 책임을 분리하기 위해서다.

K8s는 PVC에 작성된 조건(용량·접근 모드 등)에 맞는 PV를 골라 묶는다(=binding). Pod 명세에 PVC 이름을 적어 두면 그 PVC가 묶고 있던 PV가 Pod 안에 디스크로 마운트된다. Pod가 죽어도 PVC와 PV는 살아남으니, 같은 이름으로 다시 뜬 Pod(db-0 같은)가 같은 PVC를 다시 마운트하면 같은 디스크에 다시 붙는다. Pod가 죽어도 디스크는 계속해서 살아남게 되는 이유다.

15.3.8 DaemonSet / Job / CronJob

Deployment·StatefulSet 외에도 워크로드 변형이 더 있다. DaemonSet은 "모든 노드에 한 개씩"이라는 제약으로 뜬다. 노드별 로그 수집기·메트릭 에이전트가 대표적이다. 그 노드에서 일어나는 일을 그 자리에서 봐야 하니 노드마다 Pod가 하나씩 떠 있어야 한다. Deployment처럼 어디든 replica를 흩어 두면 어떤 노드엔 수집기가 없고 어떤 노드엔 둘이 도는 식으로 어긋난다.

Job은 한 번 실행되고 끝나는 작업을 위한 객체다. 데이터 마이그레이션이나 일회성 백업이 그렇다. Deployment는 Pod가 종료되면 죽은 걸로 보고 다시 띄우니, Job이 "끝남이 정상"이라고 알려주는 자리다. CronJob은 그 Job을 주기적으로 스케줄링하는 자리다(매일 새벽 백업 같은).

15.3.9 ConfigMap / Secret

컨테이너 이미지는 한 번 빌드하면 못 바꾼다. 그러니 환경마다 다른 값(DB 호스트·API 키·feature flag 같은)을 이미지 안에 박을 수 없다. dev·staging·prod에 같은 이미지를 그대로 띄우려면 그 값들은 이미지 밖에서 주입돼야 한다. ConfigMapSecret이 그래서 존재한다. 둘 다 키-값 맵을 클러스터에 따로 두고, Pod 명세에서 환경 변수나 파일로 주입한다.

둘로 갈라진 건 보안 등급이 다르기 때문이다. ConfigMap은 일반 설정값(DB 호스트, feature flag)을 담고 평문이라 누구나 본다. Secret은 비밀값(API 키, DB 비밀번호)을 담는다. base64 인코딩, etcd 저장 시 암호화, RBAC 접근 제어가 추가로 걸린다. 같은 이미지를 dev·staging·prod에 그대로 띄우면서 환경별 값만 갈아끼우는 자리다.

15.3.10 Namespace

같은 이미지에 환경별 값만 갈아끼워 dev·staging·prod를 한 클러스터에 같이 띄우려면, dev의 app Service와 prod의 app Service가 같은 이름으로 따로 살 수 있어야 한다. 그 분리를 만들어 주는 객체가 Namespace다. 모든 객체는 어느 Namespace에 속하고, 이름 충돌은 같은 Namespace 안에서만 일어난다. dev Namespace의 app과 prod Namespace의 app은 이름은 같아도 K8s가 다른 객체로 본다. 이 경계 위에 환경 분리(dev/staging/prod), 팀 분리, 권한(RBAC)까지 다 얹힌다. 단, Namespace는 같은 클러스터 안의 논리적 분리일 뿐 물리적 격리는 아니다. 클러스터가 작을 땐 Namespace로 환경을 가르고, 커지면 클러스터 자체를 분리한다.

15.3.11 HPA / VPA

트래픽은 시간대마다 들쭉날쭉하니 replica 수도 자동으로 늘었다 줄었다 해야 한다. 사용자가 매번 모니터링하다 손으로 바꿀 순 없기 때문이다. HPA(Horizontal Pod Autoscaler) 가 이것을 해준다. 클러스터엔 Pod별 CPU·메모리 사용량을 주기적으로 모으는 메트릭 서버가 떠 있다. HPA는 그 값을 보다가 사용량이 임계치를 넘으면 replica를 늘리고, 떨어지면 줄인다.

VPA(Vertical Pod Autoscaler) 는 다른 축으로 조정한다. replica 수가 아니라 Pod 한 개에 주어진 자원 자체(CPU·메모리 request/limit)를 늘리거나 줄인다. Pod 수를 늘리기 어려운 워크로드(stateful DB·캐시 같은)에 잘 맞는다. HPA는 가로로(Pod 개수), VPA는 세로로(Pod 크기) 늘린다고 보면 된다.

여기까지가 desired state를 채우는 표준 객체들이다. 그 외에 K8s를 확장하고 객체들을 패키징하는 도구 두 갈래가 있다.

15.3.12 Operator + CRD

K8s는 일반적인 컨테이너 운영은 표준 객체로 다 다룬다. Pod 띄우고, replica 유지하고, 트래픽 라우팅하는 류다. 하지만 K8s가 처음부터 알 수 없는 운영도 있다. PostgreSQL 데이터를 주기적으로 따로 복사해 두는 백업, 한 DB 인스턴스가 죽으면 다른 인스턴스로 자동 전환되는 페일오버, Kafka 브로커가 추가·제거될 때 파티션을 다시 나누는 리밸런싱 같은 일이다. PostgreSQL이 어떻게 백업하는지, Kafka가 어떻게 파티션을 옮기는지는 그 소프트웨어 내부 동작을 알아야 자동화할 수 있다. K8s는 컨테이너 라이프사이클만 알 뿐, 그 컨테이너 안의 일은 모른다.

그렇다고 새 DB·메시지 브로커가 등장할 때마다 K8s 본체에 그 운영 로직을 박을 수도 없다. 그래서 사용자가 두 가지를 직접 끼울 수 있어야 한다. 하나는 "이건 PostgreSQL이다"라고 K8s에 알려 줄 새 객체 종류, 다른 하나는 "이 PostgreSQL이 이런 상태로 가도록 이런 일들을 자동으로 해라"는 운영 로직이다.

각각이 CRD(CustomResourceDefinition)Operator다. CRD는 새 객체 종류의 스키마를 K8s에 등록한다. Operator는 그 객체를 보고 현재 상태를 desired state에 맞춰 가는 컨트롤러다(=reconcile 루프). PostgreSQL을 K8s에서 굴리고 싶다고 하자. Postgres라는 CRD와 그걸 보고 Pod·StatefulSet·PVC·백업 작업을 알아서 만들어 주는 Operator를 깔면 된다. 보통 벤더(Crunchy Data, Zalando 같은)가 만든 걸 갖다 쓴다. K8s가 도메인별 운영 자동화로 끝없이 확장되는 통로다.

15.3.13 Helm / Kustomize

같은 앱을 dev·staging·prod에 띄우면 객체 정의 YAML들이 환경마다 조금씩 달라진다. dev엔 replicas 2개, prod엔 10개. ConfigMap 값도 환경별로 다르다. 이 차이를 손으로 복사·수정해 관리하면 환경이 늘 때마다 사고가 난다. 공통 부분과 환경별 차이를 갈라 관리하는 두 도구가 표준이다. Helm은 빈 칸에 환경별 값을 채워 넣는 템플릿 방식이고, Kustomize는 완성된 베이스 위에 환경별 차이만 덧붙이는 오버레이 방식이다.

Helm은 객체 YAML들을 차트라는 템플릿 묶음으로 만든다. YAML 안에 replicas: {{ .replicas }} 같은 빈 칸을 두고, 환경별 값 파일을 따로 둔다. 깔 때 환경별 값 파일을 골라 넘기면 같은 차트가 dev·prod에 다른 결과로 풀려 깔린다. 차트 저장소에서 남이 만든 PostgreSQL·Redis 차트를 받아 깔 수도 있어, 사실상 K8s의 패키지 매니저다. 언어로 치면 npm, OS로 치면 apt에 해당한다.

Kustomize는 반대 방향이다. 공통 베이스 YAML을 그대로 두고, 그 위에 환경별 패치 파일만 따로 둔다. 패치는 "이 부분만 이렇게 바꿔라"라는 차이 명세다. dev 폴더엔 "replicas: 2"로 바꾸는 한 줄, prod 폴더엔 "replicas: 10"으로 바꾸는 한 줄. 베이스 YAML 자체는 평범한 K8s YAML 그대로 읽혀 디버깅이 쉽고, kubectl에 내장돼 따로 도구를 깔 필요도 없다.

둘은 조직 취향에 따라 갈린다. 외부 OSS를 차트 형태로 자주 받아 쓰는 곳이면 Helm이 사실상 강제, 자체 앱만 굴리는 곳이면 Kustomize도 충분하다.

여기까지 곳곳에 풀어 놓은 짝 비교를 한 표로 모으면 운영 중 가장 자주 헷갈리는 부분이 한눈에 정리된다.

자주 헷갈리는 짝 차이
Pod vs Deployment 일회용 실행 단위 / 묶음을 desired state로 굴리는 컨트롤러
Service vs Ingress 클러스터 내부 가상 IP (L4) / 외부 HTTP 진입점 (L7 + TLS)
ConfigMap vs Secret 평문 키-값 / etcd 암호화 + 접근 제어
Deployment vs StatefulSet stateless·서로 동등한 Pod / stateful·안정 식별자 + 영속 볼륨
DaemonSet vs Deployment 노드당 1개 (에이전트류) / replicas 만큼 (서비스 워크로드)
HPA vs VPA Pod 개수(가로) 조정 / Pod 한 개 자원(세로) 조정
Helm vs Kustomize 차트 템플릿 + 변수 주입 / 베이스 YAML + 환경별 오버레이

여기까지 본 객체와 설정이 한 장의 YAML로 어떻게 모이는지 보면 추상이 한 번에 닫힌다.

# deployment.yaml — 핵심 객체가 한 장에 모인 예시
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: prod                            # 환경 분리 (dev/staging/prod)
spec:
  replicas: 3                                # desired state: Pod 3개
  strategy:                                  # 무중단 배포 정책
    rollingUpdate:
      maxUnavailable: 0                      # 한 개도 안 깨고
      maxSurge: 1                            # 한 개씩 새로 띄움 (readinessProbe 통과해야 옛 Pod 종료)
  selector:                                  # 이 Deployment가 관리할 Pod 식별
    matchLabels: { app: myapp }
  template:                                  # Pod 모양 정의 (이 형태로 replicas만큼 띄움)
    metadata:
      labels: { app: myapp }                 # selector와 같아야 매칭됨
    spec:
      containers:
        - name: app
          image: myapp@sha256:abc...         # 14.3의 다이제스트 핀
          ports: [{ containerPort: 8080 }]
          envFrom:                           # 이미지 밖에서 환경별 값 주입
            - configMapRef: { name: myapp-config }   # 일반 설정 (DB 호스트, feature flag)
            - secretRef:    { name: myapp-secret }   # 비밀값 (API 키, DB 비밀번호)
          resources:                         # 빠지면 OOM/Pending 사고
            requests: { cpu: "100m", memory: "128Mi" }   # 최소 보장량 (이만큼 있는 노드에 배치)
            limits:   { cpu: "500m", memory: "256Mi" }   # 상한 (넘으면 throttle/OOM kill)
          livenessProbe:                     # "이 Pod 죽었나?"
            httpGet: { path: /health, port: 8080 }
            periodSeconds: 10
          readinessProbe:                    # "트래픽 받을 준비됐나?"
            httpGet: { path: /ready, port: 8080 }
            periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: prod
spec:
  type: ClusterIP                            # 클러스터 내부 가상 IP (L4)
  selector: { app: myapp }                   # label로 Pod 묶음 식별
  ports: [{ port: 80, targetPort: 8080 }]    # Service 80 → Pod 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: prod
spec:
  ingressClassName: nginx                    # 구현체 선택 (NGINX Ingress 컨트롤러)
  tls:                                       # HTTPS 종단 (인증서는 Secret에)
    - hosts: [myapp.myshop.com]
      secretName: myapp-tls
  rules:
    - host: myapp.myshop.com                 # L7: 호스트네임으로 분기
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp                  # 같은 namespace의 Service로
                port: { number: 80 }

명세는 길어도, 클러스터에 던지는 명령은 한 줄이다.

kubectl apply -f deployment.yaml             # 위 명세 전부를 desired state로 선언 (K8s가 알아서 맞춤)
flowchart TB Internet[외부 트래픽] subgraph NS[Namespace: prod] Ing[Ingress
L7 + TLS] Svc[Service
ClusterIP, L4] subgraph Dep[Deployment, replicas 3] direction LR Pod1[Pod] Pod2[Pod] Pod3[Pod] end CS[ConfigMap / Secret] HPA[HPA] DB[(StatefulSet
db-0, db-1
+ PVC)] end Internet --> Ing Ing --> Svc Svc -.label로 Pod 묶음 식별.-> Dep CS -.env 주입.-> Dep HPA -.트래픽 따라 replicas 조정.-> Dep Dep --> DB

여기까지가 사용자가 YAML로 던지는 desired state의 모양이다. 이제 남은 것은 그 선언을 받아 어느 노드에 띄울지 결정하고, 실제로 컨테이너를 돌리는 것이다.

15.4 결정하는 곳과 일하는 곳: control plane과 data plane

결정과 실행을 한 곳에 묶어두면 둘 중 한 곳이 죽는 순간 전체가 멈춘다. K8s는 결정만 하는 영역과 실제로 컨테이너를 돌리는 영역을 분리했다. 한쪽이 control plane, 사용자의 desired state를 받아 보관하고 어느 노드에 띄울지 정하는 두뇌. 다른 한쪽이 data plane, 결정된 컨테이너가 실제로 도는 워커 노드들이다. 이 분리 덕분에 한쪽 장애가 다른 쪽으로 번지지 않는다. control plane이 죽어도 이미 떠 있는 Pod와 그 사이 트래픽은 그대로 흐르고, 새 변경(배포·자가 치유 같은)만 미뤄진다. 반대로 data plane 노드 한 대가 죽으면 그 위 Pod들이 사라지지만, control plane이 감지해 다른 노드에 새 Pod를 띄워 desired 개수를 다시 맞춘다.

두 영역이 실제로 어떻게 맞물려 돌아가는지는 한 요청이 통과하는 길을 따라가 보면 가장 잘 보인다.

사용자가 kubectl apply로 YAML을 K8s 클러스터에 던지면 다음 6가지 순서로 처리된다. 각 컴포넌트는 API server를 통해 자기가 관심 있는 객체의 변경을 항상 watch하고 있어서, 한 단계의 결과가 다음 단계를 자동으로 발동시킨다.

  1. API server가 가장 먼저 YAML을 받는다. API 서버는 control plane의 단일 진입점이라, 사용자의 kubectl 명령이든 클러스터 안 다른 컴포넌트의 요청이든 클러스터 상태를 바꾸거나 조회하는 모든 호출이 이 한 곳을 통과한다. 받은 요청은 인증(누가 보냈는가)·권한(그 작업을 할 자격이 있는가)·필드 검증(YAML 형식이 올바른가) 검사를 통과해야 다음으로 넘어간다.

  2. 검증을 통과한 desired state(YAML)를 API server는 etcd에 저장한다. etcd는 클러스터의 모든 상태(Pod·Service 정의, desired·current)를 키-값 형태로 저장하는 분산 데이터베이스다. 노드를 한 대만 두면 그 한 대가 죽는 순간 클러스터 전체 상태가 사라지므로, etcd는 여러 노드에 같은 데이터를 복제한다. 그러면 어느 노드의 값을 진실로 볼지 충돌이 생기는데, 이걸 푸는 합의 알고리즘이 Raft다. 이름은 통나무를 묶어 한 척으로 띄우는 raft(뗏목)에서 따왔다. 리더 노드 한 대가 변경을 제안하면 다수의 follower 노드가 동의해야 그 변경이 확정되는 방식이라, 한 대가 죽어도 상태가 살아남는다.

  3. 새 desired state가 etcd에 들어오면, 그걸 watch하던 controller manager가 차이를 감지한다. controller manager는 ReplicaSet 컨트롤러(Pod 개수 유지)·Deployment 컨트롤러(롤링 업데이트 진행)·Node 컨트롤러(노드 상태 감시)·Endpoint 컨트롤러(Service에 연결된 Pod 목록 갱신) 등 수십 개를 한 프로세스로 묶은 것이고, 각 컨트롤러는 같은 이름의 자기 담당 객체와 desired·current를 비교해 차이를 메운다. 예를 들어 replicas: 3이던 Deployment를 replicas: 4로 바꿔 다시 던지면, ReplicaSet 컨트롤러가 current 3개와 desired 4개의 차이를 감지해 새 Pod 하나를 더 띄운다. 클라우드 자원(LB·디스크·DNS)은 AWS·GCP·Azure마다 API가 달라 K8s 코어가 특정 클라우드에 종속되지 않도록, 그 부분만 cloud controller manager로 따로 떼어 분리됐다. 사용자가 자기 CRD 객체용으로 띄우는 Operator도 같은 패턴이다. controller manager 바깥에 따로 떠서, 자기 객체의 desired와 current 차이를 메운다.

  4. 앞서 새 Pod를 띄우기로 하는 것은 controller가 정했다. 이제 다음 문제는 어디에 띄울지다. controller는 Pod 객체를 etcd에 만들 때 nodeName(어느 노드에서 돌릴지) 필드를 비워두고, 이걸 watch하던 scheduler가 그 빈 자리를 채운다. 가장 단순하게는 자원 잔량(CPU·메모리)이 충분한 노드를 고르면 된다. 그러나 실제 운영에서는 그 이상의 배치 조건이 자주 나온다. "A Pod는 B Pod 옆에 두자", "같은 노드에 두 개 두지 말자", "GPU 노드에는 GPU 쓰는 Pod만 받자" 같은 요구다. 이런 제약을 적어두는 도구가 affinity(이 Pod는 저 Pod 옆에 두기)·anti-affinity(같은 노드에 두지 말기)·taint/toleration(이 노드는 특정 Pod만 받기)이다. scheduler는 자원 잔량과 이 제약들을 모두 만족하는 노드를 하나 골라, 그 노드 이름을 비어 있던 nodeName 필드에 적어 넣는다.

  5. scheduler가 결정한 노드가 etcd에 기록되면, 그 노드의 kubelet이 자기에게 할당된 Pod임을 알아챈다. kubelet은 각 워커 노드에 떠 있는 K8s 에이전트로, control plane의 명령을 노드에서 실제로 실행하는 역할이다. kubelet은 그 Pod를 컨테이너 런타임에게 "이 Pod 띄워줘"라고 넘긴다. 컨테이너 런타임은 노드에서 실제로 컨테이너 프로세스를 띄우고 죽이는 저수준 소프트웨어다. K8s는 특정 런타임에 묶이지 않도록 CRI(Container Runtime Interface) 라는 표준 인터페이스만 정해두고, 이걸 만족하는 어떤 런타임이든 쓸 수 있게 했다. 도커에서 떨어져 나온 containerd와 K8s 전용으로 만들어진 CRI-O가 사실상 표준이다. kubelet은 Pod를 띄우고 끝이 아니라, 떠 있는 동안 livenessProbe·readinessProbe를 주기적으로 걸고 결과를 API server에 보고한다. 죽은 컨테이너는 kubelet이 즉시 재시작하고, ready 상태는 Endpoint 컨트롤러를 거쳐 Service의 트래픽 대상 목록에 반영된다.

  6. 마지막 단계는 Service의 변하지 않는 가상 IP를 실제로 어떻게 구현하느냐다. 가상 IP는 실제 노드(NIC)에 붙은 주소가 아니라 "이 가상 IP로 들어오는 패킷은 실제 Pod 중 하나로 바꿔 보낸다"는 매핑 규칙으로 존재한다. 가상 IP 자체는 그대로 유지되지만, 그 뒤에 맵핑된 실제 Pod IP 목록은 Pod가 뜨고 죽을 때마다 변한다. 이 맵핑을 각 노드의 커널에 적어두고 Pod 변동마다 갱신하는 컴포넌트가 kube-proxy다.

    Pod가 뜨면 그 사실이 API server에 기록되고, 이를 watch하던 kube-proxy가 그 Pod IP를 Service의 가상 IP 매핑에 추가한다. 예를 들어 myapp Service의 가상 IP가 10.0.0.5라면, 호출자는 항상 10.0.0.5로만 보내고 kube-proxy가 그 시점에 떠 있는 실제 Pod IP(192.168.1.10 같은) 중 하나로 패킷을 바꿔 전달한다. 패킷 변환 자체는 리눅스 커널의 iptablesIPVS가 한다. 둘 다 커널 안에서 정의된 규칙대로 패킷의 목적지를 바꾸는 메커니즘이다.

    여기까지는 Service IP에서 실제 Pod로 가는 흐름이다. 그런데 클러스터 안에서는 다른 노드의 Pod끼리 직접 통신할 일도 많다. 마이크로서비스 사이의 호출이나 sidecar 패턴이 그렇다. 같은 노드 위 Pod끼리는 노드 안 네트워크로 쉽게 닿지만, 서로 다른 노드에 떠 있으면 노드 사이로 Pod IP를 라우팅할 경로가 따로 있어야 한다.

    문제는 그 노드 간 네트워크 구성이 환경마다 다르다는 점이다. 온프렘 데이터센터, AWS VPC, GCP, 베어메탈마다 라우팅 방식이 달라서, K8s가 한 가지 방식만 가정하고 만들어두면 다른 환경에서는 못 쓴다. 그래서 K8s는 노드 간 네트워크 구현을 직접 떠안지 않고, CNI(Container Network Interface) 라는 표준 인터페이스만 정해뒀다. "Pod에게 IP를 할당하고 노드 간 라우팅을 만들어줘"라는 계약만 적어두고, 실제 구현은 환경에 맞는 플러그인이 하게 둔 것이다. 그 자리를 채우는 대표적 구현체가 CalicoCilium이다. 노드 간 Pod IP 라우팅을 깔고, 추가로 NetworkPolicy(어떤 Pod가 어떤 Pod에 접근할 수 있는지 정의하는 K8s 객체) 적용도 담당한다.

flowchart TB User["사용자
kubectl apply"] Caller["호출자
(외부 / 다른 서비스)"] subgraph CP["Control Plane (두뇌)"] direction TB API["API server
(단일 진입점)"] ETCD[("etcd
(Raft 합의)")] CM["controller manager
(차이 메움)"] Sched["scheduler
(노드 선택)"] end OP["Operator
(CRD용 외부 컨트롤러)"] subgraph DP["Data Plane (워커 노드들)"] direction LR subgraph N1["Node 1"] direction TB K1["kubelet"] CRI1["containerd
(CRI)"] P1["Pod"] KP1["kube-proxy"] end subgraph N2["Node 2"] direction TB K2["kubelet"] P2["Pod"] KP2["kube-proxy"] end end %% reconcile 한 바퀴 (6단계) User -->|"① YAML 던짐"| API API <-->|"② 저장 / 조회"| ETCD CM -.->|"③ watch · 차이 메움"| API Sched -.->|"④ watch · 노드 선택"| API K1 -.->|"⑤ watch · Pod 띄움"| API KP1 -.->|"⑥ watch · Service 매핑"| API K2 -.-> API KP2 -.-> API %% Operator는 control plane 바깥에서 같은 패턴 OP -.->|"watch · CRD reconcile"| API %% kubelet → CRI → Pod (실제 컨테이너 띄움) K1 -->|"CRI"| CRI1 --> P1 K2 ==> P2 %% kubelet의 probe K1 -.livenessProbe / readinessProbe.-> P1 %% 노드 간 Pod 통신 (CNI) P1 <-.CNI 라우팅.-> P2 %% Service IP 호출 흐름 Caller -->|"Service IP"| KP1 KP1 -->|"실제 Pod IP"| P1

앞에서 control plane이 죽어도 기존 운영은 그대로 흐른다고 했지만, 사실 이야기 하지 않은 것이 있다. 그 동안 새 배포나 자가 치유 같은 변경이 멈춰 있는 상태가 길어지면 그것도 그대로 문제인 것이다. 이걸 막으려고 control plane도 여러 인스턴스로 띄운다. 대신 같은 일을 두 머리가 동시에 하면 충돌이 나는 자리(scheduler가 같은 Pod를 두 노드에 꽂는 식)는 leader election으로 한 명만 일하고 나머지는 대기한다. 리더가 죽으면 다음이 받는다. StatefulSet 클러스터의 leader/follower, etcd Raft의 리더 선출과 같은 패턴이다.

15.5 매니지드와 멀티 클러스터: 현실의 운영

control plane과 data plane을 직접 깔아 굴릴 수도 있다. 다만 실제로 control plane을 직접 운영하는 회사는 드물다. etcd 백업·갱신·HA, API server 인증서 회전, 컴포넌트 버전 호환만 해도 풀타임 SRE 한 팀이 매달릴 일이라서다. 게다가 control plane은 어느 회사든 굴리는 모양이 같지만, 워커 노드 위는 어떤 앱을 얼마나 어떻게 띄울지가 회사마다 다르다. 그래서 클라우드가 control plane을 책임지고 사용자에겐 워커 노드 위만 맡기는 매니지드 K8s가 표준이 됐다.

셋의 차이는 K8s 자체가 아니라 각 사업자 생태계와의 결합도뿐이다. 그래서 보통 이미 쓰는 클라우드가 곧 K8s 사업자 선택이 된다.

서비스 사업자
EKS (Elastic Kubernetes Service) AWS
GKE (Google Kubernetes Engine) GCP
AKS (Azure Kubernetes Service) Azure

셋 다 control plane은 클라우드가 책임지고, 사용자는 워커 노드와 그 위 객체만 운영한다. 여기서 더 줄이고 싶으면 노드 관리까지 클라우드가 떠맡는 모드(GKE Autopilot, EKS Fargate)도 있다. 매니지드는 이분법이 아니라 어디까지 외부화하느냐의 스펙트럼이다. 다만 어느 모드를 쓰든 K8s API 자체는 그대로다. 같은 YAML이 EKS·GKE·AKS 어디서든 그대로 굴러간다는 점, 이게 K8s가 vendor lock-in 걱정 없이 클라우드 위 표준이 된 결정적 이유다.

매니지드라도 워커 노드와 그 위 객체 운영은 여전히 사용자 몫이다. 게다가 그 운영이 한 클러스터에 다 담기는 일은 드물다. region이 늘고 환경(dev/staging/prod)이 갈라지고 워크로드가 커지면 클러스터도 따라 늘어나기 때문이다. 이렇게 클러스터를 여러 개 두는 멀티 클러스터(multi-cluster) 가 표준이다. 이유는 셋이다.

  • Region 분리: 한 region 전체가 죽어도 다른 region 클러스터가 트래픽을 받도록, region마다 클러스터를 한 개씩 둔다(DR).
  • 환경 분리: dev/staging/prod를 한 클러스터의 Namespace로 가르는 건 작은 회사에 적합하지만, prod가 커지면 클러스터를 따로 두는 게 안전하다. dev에서 사고가 나도 prod control plane이 영향을 안 받는다.
  • Blast radius 한정: 한 클러스터의 control plane 사고가 모든 워크로드를 끌어내리지 않게, 큰 워크로드를 별도 클러스터로 분리해 장애 범위를 좁힌다.

멀티 클러스터를 굴리려면 두 가지 일이 동시에 생긴다. 하나는 같은 앱을 여러 클러스터에 똑같이 띄우는 일이다. 사람이 클러스터마다 kubectl apply를 치면 어딘가 빠뜨리거나 버전이 어긋나기 쉽다. 그래서 YAML을 Git 저장소에 두고, 그 Git을 watch하며 모든 클러스터에 자동으로 동기화하는 도구를 띄운다. 새 commit이 들어오면 즉시 클러스터에 반영된다. 이 방식이 GitOps고, 사실상의 표준 도구가 Argo CD·Flux 둘이다. 둘 다 K8s 위에 떠서 Git의 desired state ↔ 클러스터의 실제 상태를 끊임없이 reconcile하는 컨트롤러다.

다른 하나는 클러스터 자체를 만들고 업그레이드하고 폐기하는 일이다. 매번 console에서 손으로 클릭하면 사고 나기 쉽다. 그래서 클러스터 자체도 YAML로 선언한다. K8s가 정한 표준이 Cluster API다. AWS·GCP·vSphere 같은 인프라 provider별 plugin이 그 선언을 받아 어느 인프라에서든 클러스터를 자동으로 생성·업그레이드·폐기한다. 한편 클러스터를 만드는 게 아니라 여러 클러스터를 한 묶음으로 다루며 워크로드를 분배·페일오버하는 도구도 따로 있다. Karmada가 대표적이다.

단, 멀티 클러스터는 운영 복잡도가 한 단계 더 뛴다. 클러스터 간 트래픽 라우팅, 인증서·secret 동기화, 노드 풀 정책 관리 등 한 클러스터에선 없던 일이 줄줄이 따라온다. 한 클러스터로 충분한 규모에서 일찍 도입하면 학습·운영 비용만 늘어난다.

15.6 K8s를 비켜 가거나 보완하기: 서버리스와 Service Mesh

K8s가 모든 답은 아니다. 두 방향에서 K8s를 비켜 가거나 보완하는 흐름이 있다.

한쪽은 서버리스(Serverless) 다. K8s를 직접 굴리지 않고 더 위로 올라가는 방향. 안쪽엔 두 갈래가 있다. 컨테이너 서버리스(AWS Fargate, Google Cloud Run)는 컨테이너 이미지는 그대로 쓰면서 K8s의 노드·스케줄러·오토스케일링을 클라우드가 대신 굴려 준다. 이미지만 던지면 트래픽에 맞춰 알아서 0 ↔ N으로 늘었다 줄었다 한다. 함수형 서버리스(AWS Lambda, Cloudflare Workers, Vercel Functions)는 한 단계 더 올라가서 사용자가 함수만 적고 인프라가 통째로 사라진다. 콜드 스타트와 실행 시간 제한이라는 비용을 받아들이는 대신, 0 트래픽일 때 0원으로 운영된다. 백엔드의 일부(이메일 발송, 이미지 변환, 웹훅 처리)는 거의 항상 서버리스가 더 합리적이고, 트래픽이 일정한 코어 서비스는 K8s가 더 맞는다. 양자택일이 아니라 워크로드별 선택이다.

다른 한쪽은 Service Mesh다. 이쪽은 K8s를 비켜 가는 게 아니라 K8s 위에 한 층을 더 얹는 방향이다. K8s 위에 마이크로서비스가 수십·수백 개 굴러가게 되면 서비스마다 retry·타임아웃·Circuit Breaker·mTLS·트래픽 분할·관측성 같은 통신·보안 로직을 코드에 박아야 한다. 같은 로직이 서비스 수만큼 중복되고, 정책이 바뀌면 모든 서비스를 고쳐 다시 배포해야 한다. Service Mesh는 그 공통 로직을 애플리케이션 코드 바깥으로 빼는 방식이다. 각 Pod 옆에 사이드카 프록시(Envoy)를 자동으로 끼워 모든 인·아웃 트래픽을 그 프록시가 가로채고, Service Mesh의 control plane이 모든 사이드카에 정책을 일괄로 푸시한다. 클러스터 입구에서 외부 트래픽 한 점을 받는 Ingress와 달리, Service Mesh는 클러스터 내부의 모든 서비스 ↔ 서비스 호출에 끼어드는 도구다. 대표 구현이 Istio·Linkerd. 코드를 한 줄도 안 고치고 Canary 배포·재시도 정책·서비스 간 mTLS를 클러스터 차원에서 켤 수 있다. 운영 복잡도와 자원 비용이 적지 않아 마이크로서비스 수가 일정 규모를 넘기 전에는 과한 도구이지만, 그 규모를 넘기는 순간 다른 길이 잘 안 보인다.

박스 하나하나를 어떻게 옮길지 고민하는 건 항만 노동자의 시선이고, 박스들이 어디에 어떤 모양으로 놓여 있으면 좋을지 생각하는건 조타수의 시선이다. 적어도 K8s 위에서, 우리는 모두 조타수가 될 수 있다.


16. 매일 배포, 골라서 릴리즈, 같은 환경: CI/CD, IaC, Feature Flag

16.1 세 가지 거리: 코드, 환경, 사용자

컨테이너 이미지가 빌드되어 있다고 하자. 이 이미지는 어떻게 운영 클러스터까지 도달하며, 도달한 뒤에는 어떻게 사용자 눈앞에 켜지는가? 누군가 손으로 빌드하고 손으로 kubectl apply를 누르고 손으로 기능을 노출시킨다면, 어느 순간 사람이 빠뜨린 한 줄 때문에 prod가 죽는다.

코드에서 사용자까지의 길은 사실 서로 직각인 세 갈래가 합쳐진 것이다.

flowchart LR Code[코드 변경] -->|CI/CD| Live[운영에 배포된 코드] IaC[IaC] -->|환경 자체를 코드로| Cloud[운영 환경] Cloud --> Live Live -->|Feature Flag| User[사용자에게 노출]
  • 코드의 거리 (CI/CD): 변경된 코드를 자동으로 검증하고 운영까지 흘려보낸다.
  • 환경의 거리 (IaC): 코드가 도달할 환경 자체(클러스터·DB·네트워크)를 콘솔 클릭이 아닌 git에 둔다.
  • 사용자까지의 거리 (Feature Flag): 운영에 도달한 코드가 실제로 사용자에게 켜지는 시점을 코드 배포와 분리한다.

이 셋이 분리되어 있어야 "배포는 매일, 릴리즈는 골라서, 환경은 재현 가능" 이라는 모던 운영 원칙이 성립한다.

16.2 메인은 항상 그린이어야 한다: CI

매일 수십 개 PR이 머지되는 저장소에서 "이 PR이 빌드를 깨뜨렸나" 를 사람이 매번 검사할 수는 없다. 깨진 사실이 늦게 드러날수록 누구의 변경 때문인지 추적하기 어려워지고, 메인이 깨진 채 다음 PR이 그 위에 쌓이면 prod 배포는 점점 더 위험해진다.

CI(Continuous Integration) 는 모든 변경(PR)에 대해 자동으로 빌드·테스트·정적 분석·보안 스캔·이미지 빌드를 돌려 그 PR이 무엇을 깨뜨렸는지 를 즉시 잡아낸다. 잡힌 PR은 메인에 머지되지 못한다. CI가 지키는 단 하나의 원칙은 메인 브랜치가 항상 그린(green) 이어야 한다는 것이다. 깨졌으면 5분 안에 알아채고 30분 안에 고친다. 그렇게 유지될 때 언제든 메인의 최신 커밋을 그대로 prod로 띄울 수 있는 상태 가 매일 보장된다.

자동으로 도는 단계는 PR을 어떻게 시스템이 검사하는가 의 순서대로 흐른다.

  • 단위·통합 테스트: 변경된 코드가 의도대로 동작하는지, 그리고 주변 모듈과 맞물려서도 어긋나지 않는지 실제로 실행해 본다.
  • 정적 분석·린트·타입 체크: 코드를 실행하지 않고 읽기만 해서 잡을 수 있는 오류·스타일 위반·타입 불일치를 미리 걸러낸다.
  • 보안 스캔: 외부 라이브러리에 이미 알려진 취약점이 끼어 있는지(SCA), 우리가 짠 코드 자체에 위험한 패턴이 있는지(SAST) 자동으로 훑는다.
  • 컨테이너 이미지 빌드 + 레지스트리 푸시: 여기까지 다 통과한 변경을 바로 prod에 띄울 수 있는 형태(컨테이너 이미지)로 굳혀 ECR·Harbor·GHCR 같은 저장소에 올린다.
  • 이미지 서명 (Sigstore Cosign): 그 이미지에 "이건 우리가 CI에서 빌드한 게 맞다" 는 디지털 도장을 찍어 둔다. 운영 단계에서 중간에 바꿔치기된 이미지가 아닌지 를 이 도장으로 확인할 수 있다.

흔히 쓰이는 도구는 다음과 같다.

도구 특징
GitHub Actions GitHub 저장소와 한 몸. 워크플로 파일 하나로 시작할 수 있어서 코드와 같은 자리에서 같이 자란다. OSS·중소 규모 팀의 사실상 default
GitLab CI 저장소·이슈·CI/CD·레지스트리·보안 스캔이 한 제품에 묶여 있는 올인원 DevOps 플랫폼. 자체 호스팅 환경에서 강세
Jenkins 가장 오래되고 자유도가 높은 도구. 플러그인으로 무엇이든 시킬 수 있지만 누군가 늘 돌봐야 하는 정원에 가깝다
CircleCI SaaS 전용. 빌드 속도와 캐시 전략에 특화되어 있어 빠른 피드백을 위한 도구라는 평가

어느 걸 쓰든 핵심은 똑같다. "메인은 깨지지 않았다" 가 사람의 기억이 아니라 시스템의 상태로 박혀 있어야 한다.

16.3 운영 상태는 git 저장소의 함수다: CD와 GitOps

CI를 통과한 이미지를 prod까지 옮기는 일도 자동화 대상이다. 누가 손으로 kubectl apply를 누르는 한 어느 시점의 prod에 어느 버전이 떠 있는가는 사람의 기억과 슬랙 로그에 의존한다. 이 의존이 사고로 이어지는 풍경은 익숙하다. 롤백 명령을 잘못 입력해 버리거나, 어제 푼 hotfix가 다른 사람 머신에서 푸시되지 않은 채 남아 있거나, 같은 매니페스트가 두 클러스터에 미묘하게 다르게 적용되어 있는 식이다. CD(Continuous Delivery/Deployment) 는 이 의존을 끊는 자동화다.

CD에는 두 모델이 있다.

  • Push-based: CI 파이프라인이 직접 운영 환경에 배포 명령을 보낸다 (kubectl apply, terraform apply). 도입이 단순하고 변경이 즉시 반영된다는 게 장점이다. 다만 CI 자체가 운영 클러스터의 자격증명을 쥐고 있어야 한다는 보안 부담이 따르고, 배포가 실제로 적용됐는지를 CI가 다시 확인해 가야 하며, 콘솔에서 누가 손댄 drift 는 잡지 못한다.
  • Pull-based (GitOps): 운영 클러스터 안에 GitOps 에이전트가 떠 있고, git 저장소가 갖고 있는 desired state를 주기적으로 당겨 와서 현재 클러스터가 그것과 같아지도록 동기화한다. 클러스터가 자기 상태를 git에 맞추러 가는 모양새라 외부에서 권한을 들이밀 필요가 없고, drift 도 자동으로 감지·동기화된다는 게 큰 장점이다. 대신 클러스터마다 에이전트를 돌리는 운영 부담이 늘고, polling 주기 탓에 변경이 수십 초~수 분 늦게 반영된다. 긴급 hotfix도 콘솔에서 직접 박는 경로가 닫혀 항상 git PR 절차를 거쳐야 한다.

Pull-based의 대표 도구는 다음과 같다.

도구 특징
Argo CD K8s 친화적. UI에서 git 상태와 클러스터 상태의 차이 를 한눈에 시각화해 보여줘서 사실상 GitOps의 default로 자리잡음
Flux UI 없이 K8s 컨트롤러 철학에 충실. 자동화 흐름·다중 클러스터 확장에 강세이고, Helm·Kustomize와의 결합이 깔끔하다는 평가

GitOps의 강점은 한 줄로 요약된다. 운영 상태는 git 저장소의 함수다. 누가 언제 무엇을 바꿨는지는 git log 가 곧 감사 로그가 되고, 롤백은 git revert 면 끝. 운영의 모든 변경이 PR을 거치며, 지금 클러스터의 모습git의 모습 이 다르면 자동으로 동기화되거나 알람으로 떠오른다.

16.4 장애를 가두는 법: Big Bang에서 Canary로

새 버전을 prod에 푸는 가장 단순한 방법은 전부 한 번에 교체하는 것 이다. 그런데 사고는 항상 일어나고, 한 번에 다 푼 뒤 사고가 나면 그 영향이 곧바로 전체 사용자에게 닿는다. 배포 전략의 진화는 결국 사고가 났을 때 영향이 닿는 범위(폭발 반경)를 어떻게 줄이는가 의 역사다.

전략 동작 폭발 반경 비용
Big Bang 모든 인스턴스를 한 번에 새 버전으로 교체 사고 시 즉시 전체 사용자에게 영향 (가장 큼) 자원·구현 부담 거의 없음 (가장 낮음)
Rolling Update 인스턴스를 한 그룹씩 점진 교체 교체 진행률만큼의 사용자. 사고 인지가 늦으면 결국 전체에 가까워질 수 있음 교체 중에 두 버전이 동시에 떠 있게 되므로, 양쪽 모두에서 동작하도록 호환성 유지 부담
Blue-Green 새 환경(green)을 따로 띄워 둔 뒤 LB를 한 번에 스위치 사고 시 LB만 다시 스위치하면 즉시 회복 자원이 잠시 두 배. 두 환경을 평행하게 운영하는 부담
Canary 1% → 10% → 100%로 트래픽 비율을 점진 확대 1%부터 시작해 단계 비율만큼만 영향. 사고 발견 즉시 차단 단계마다 지금 진전해도 되는가 를 메트릭으로 판정해야 하므로 관측성 인프라 전제
Shadow 운영 트래픽을 새 버전에 복제로 흘려보내고 응답은 버림 사용자에게 영향 0 트래픽 복제·응답 비교 인프라 구축 부담

쿠버네티스의 Deployment 가 Rolling을 기본 제공한다. Argo Rollouts·Flagger 같은 도구는 Canary·Blue-Green을 자동화하면서, 메트릭이 기준치에서 벗어나면 자동 롤백까지 한 묶음으로 묶어 준다.

16.5 Feature Flag: 배포와 릴리즈의 분리

CI·CD가 갖춰지면 코드는 매일 prod에 도착한다. 그런데 코드가 도착했다는 사실사용자가 그 기능을 본다는 사실 은 같은 일이 아니다. 새 결제 화면을 prod에 배포해 둔 채 내부 직원에게만 노출하다가, 일주일 뒤 1%, 그다음 10%, 마지막에 100%로 풀어 가는 식. 이런 단계가 가능한가? 그렇다. Feature Flag가 이것을 가능하게 해준다.

if feature_flag.enabled("new_checkout", user=user):
    return render_new_checkout()
else:
    return render_old_checkout()

이 한 줄이 가져다주는 변화.

  • 점진 공개 (Progressive Rollout): 코드는 이미 prod에 떠 있어도, 사용자에게 보이는 비율은 내부 직원 → 1% → 10% → 100% 식으로 따로 늘려 갈 수 있다. 코드 배포와 노출이 분리된다.
  • A/B 테스트: 절반에게는 A, 다른 절반에게는 B를 보여주고 전환율·지연 같은 메트릭으로 어느 쪽이 나은지 비교한다.
  • Kill Switch: 사고가 나면 코드 롤백 없이 플래그만 꺼서 즉시 차단할 수 있다. 배포 파이프라인을 다시 돌릴 필요가 없다.
  • Trunk-based Development: 미완성 기능을 main에 머지해도 플래그 off 상태라 사용자에게는 안 보이므로, 오래 살아 있는 feature 브랜치 없이 main에 빠르게 머지하는 개발 흐름이 가능해진다.

다만 플래그는 빚이다. 쓰지 않는 플래그가 누적되면 분기마다 N개 플래그의 2^N 조합이 테스트 매트릭스로 폭발하고, 왜 이 분기가 여기 있는가 를 아무도 기억하지 못하는 코드가 된다. 모든 플래그에 만료일 을 적고 정기적으로 청소하는 규칙이 권장되는 이유다.

16.6 IaC: 인프라도 코드처럼

앞서 본 CI·CD·배포 전략·Feature Flag는 코드 자체의 흐름을 자동화하고, 분리하고, 통제하는 답이었다. 그런데 그 코드가 도달할 환경, 즉 클러스터·DB·네트워크·보안 그룹은 어떻게 다룰까. 사실 인프라 쪽이 코드보다 더 사고가 잘 나는 구간이다. 콘솔 클릭은 git log에 남지 않고, dev·staging·prod 환경이 비슷하지만 미묘하게 다르고, 어제까지 잘 되던 게 오늘 안 되면 콘솔에서 누가 손댔나 부터 더듬어야 한다. 한마디로 콘솔로 만든 인프라는 재현이 안 되고, 감사도 안 되고, 롤백도 안 된다.

답은 IaC(Infrastructure as Code). 인프라를 코드로 선언하고 git에 둔다.

핵심 원칙은 셋이다.

  • 선언적: "어떤 상태이길 원함" 만 적고, 도구가 현재 상태와의 차이를 계산해 적용한다.
  • 멱등: 같은 코드를 여러 번 적용해도 같은 결과.
  • 버전 관리 + drift 감지: 모든 변경이 git PR을 거치고, 콘솔에서 누가 손댄 부분(drift) 은 도구가 감지해 알린다.

대표 도구는 클라우드 범위·언어 선호·운영 환경 중 어디에 가장 큰 제약이 있는가에 따라 갈린다.

도구 어떤 팀이 고르는가
Terraform 멀티 클라우드의 사실상 표준. HCL이라는 자체 언어를 받아들이는 게 조건이지만, 모르고 고르면 가장 안전한 선택지
OpenTofu Terraform과 호환되면서 라이선스 부담을 피하고 싶은 팀. 마이그레이션 비용이 거의 0이라 사실상 부담 없는 대안
Pulumi HCL의 표현력 한계(반복·조건·추상화)가 답답한 팀. TypeScript·Python·Go 같은 일반 프로그래밍 언어 로 인프라를 짠다
AWS CDK / CloudFormation AWS 단일 클라우드 환경의 default. 멀티 클라우드 추상화의 대가를 지불할 이유가 없는 팀
Crossplane K8s가 모든 것의 중심 인 환경. 클라우드 자원까지 K8s manifest로 다루고 싶은 팀이 채택

가끔 production에 배포하려는데 믿음과 용기가 필요한 순간이 있다. 믿음과 용기보다는 깨지는 곳을 정확히 잡고, 잘못되면 곧바로 되돌릴 수 있는 시스템이 바람직하다.


17. 보이지 않으면 운영할 수 없다: 관측성

이미지가 운영 환경에 떨어지면 끝이 아니라 시작이다. 운영의 첫번째 자질은 보이는가.

17.1 관측의 시작: "왜?"가 아니라 "어디?"

서비스가 한 대일 때 장애 분석은 단순하다. 그 한 대의 로그를 켜고 보면 된다.

서비스가 여러 개일 때는 다르다. "결제가 안 됐다"는 신고가 들어왔다고 해 보자. 사용자 요청 하나는 게이트웨이, 결제 서비스, 외부 PG, DB까지 5~10개 서비스를 거친다. 어느 단계에서 실패했는지는 처음에는 알 수 없다. 이 순간 가장 먼저 떠오르는 질문은 "왜?" 가 아니라 "어디?" 다.

이 "어디?"에 답하기 위한 인프라가 관측성(Observability) 이다. 보통 세 기둥으로 정리되는데, 셋은 답하는 질문이 다르다.

기둥 답하는 질문 형태 대표 도구
Metrics 무엇이 잘못되고 있는가 시간에 따라 누적된 수치 (RPS, latency, CPU) Prometheus + Grafana, Datadog
Logs 그 시점에 무슨 일이 있었는가 이벤트의 상세 텍스트 기록 ELK, Loki, OpenSearch
Traces 한 요청이 어디서 어떻게 깨졌는가 요청이 거친 모든 서비스의 호출 트리 Jaeger, Tempo, Zipkin, Datadog APM

지표만 보면 "뭔가 이상하다"는 알아채지만 어디부터 봐야 할지는 모른다. 로그만 보면 "이런 일이 있었다"는 보이지만 어떤 요청에 묶이는지 모른다. 트레이스만 보면 "이 요청이 여기서 깨졌다"는 짚을 수 있지만 그게 한 번뿐인지 빈번한지 모른다. 셋이 함께 있어야 비로소 분산 시스템의 "어디"에 답할 수 있다.

17.2 OpenTelemetry라는 통일

세 기둥은 이론이고, 현실에서는 어떻게 SDK를 박을까가 첫 관문이다.

과거에는 골치 아팠다. Datadog을 쓰면 Datadog SDK, Jaeger를 쓰면 Jaeger SDK, Prometheus를 쓰면 또 다른 라이브러리. 도구를 바꿀 때마다 애플리케이션 코드의 계측 부분을 다시 짜야 했다. 도구 선택이 한 번 정하면 다시 못 갈아엎을 만큼 무거운 결정이 됐다.

flowchart LR subgraph past["Before: 도구마다 SDK 따로"] direction TB A1[Application] A1 -->|"Datadog SDK"| DD1[Datadog] A1 -->|"Jaeger SDK"| J1[Jaeger] A1 -->|"Prometheus client"| P1[Prometheus] end subgraph now["After: OTel SDK 하나"] direction TB A2[Application] -->|"OTel SDK"| Col[OTel Collector] Col --> DD2[Datadog] Col --> J2[Jaeger] Col --> P2[Prometheus] end past ~~~ now

지금은 OpenTelemetry(OTel) 가 사실상 표준이다. 핵심 발상은 단순하다. 계측은 한 번, 백엔드는 자유롭게. 애플리케이션은 OTel SDK 하나로만 계측하고, 그 데이터를 Datadog으로 보낼지 Jaeger로 보낼지 Prometheus로 보낼지는 OTel Collector 라는 중간 다리에서 결정한다. 백엔드 교체 비용이 코드 재작성에서 Collector 설정 한 줄로 떨어졌다.

관측성은 한번 박아두면 몇 년을 쓰는 인프라다. 그 사이에 회사는 도구를 바꾼다. 비용이 올라서 바꾸고, 더 좋은 도구가 나와서 바꾸고, 인수합병으로 합쳐서 바꾼다. OTel은 그 미래의 변경 비용을 미리 0에 가깝게 깔아두는 장치다.

17.3 양치기 소년이 되지 않게 - SLO와 Error Budget

지표를 모으는 궁극적 이유는 결국 단 하나다. 장애가 났을 때 빠르게 알아채고, 빠르게 대응하기 위해서. 사람이 24시간 그래프를 노려보고 있을 수는 없으니, 그 역할을 대신하는 게 알람(Alerting) 이다. Prometheus Alertmanager, PagerDuty, Opsgenie 같은 도구가 이 역할을 한다.

그러면 자연스럽게 다음 질문이 따라온다. 무엇을 보고, 어떤 기준으로 알람으로 띄울 것인가.

먼저 무엇을 볼지 결정하는 두 가지 표준 분류가 있다.

  • USE Method (자원 관점): Utilization(얼마나 쓰고 있나), Saturation(얼마나 막혀 있나), 디스크 I/O 실패율·패킷 드롭률 등. CPU·메모리·디스크·네트워크 같은 인프라 메트릭에 쓴다.
  • RED Method (요청 관점): Rate(초당 요청 수), HTTP 5xx 비율·RPC 에러율, Duration(지연). HTTP·RPC 서비스 메트릭에 쓴다.

둘 다 봐야 한다. USE 만 보면 자원은 멀쩡한데 사용자 응답이 깨지는 경우를 놓치고, RED 만 보면 사용자가 정상 응답을 받고있지만 디스크가 폭발 직전인 시한폭탄을 놓친다. 인프라 알람은 USE로, 서비스 알람은 RED로 나눠서 보는 이유다.

문제는 그 다음이다. USE는 자원마다(= 노드별로), RED는 엔드포인트마다(login, search, payment...) 따로 수집된다. 여기에 서비스 수십 개가 곱해지면 메트릭은 금세 수백·수천 개로 늘어난다. 모든 지표에 임계치 알람을 걸면 새벽마다 알람이 울린다. 막상 출동해 보면 사용자에게는 영향이 없는 경우가 태반이다. 이게 알람 피로다. 두세 번만 반복되면 사람은 알람을 무시하게 되고, 진짜 장애를 놓치게 된다.

그래서 모던 운영 조직은 SLO(Service Level Objective) 기반 알람으로 옮긴다. SLO는 사용자 입장에서 본 약속이다. 예를 들면 "API의 p99 지연이 500ms 이하." 알람은 그 약속이 깨지기 시작할 때만 울린다. 가령 p99가 500ms를 5분 이상 초과하면 깨우는 식이다. 임계치 자체가 SLO에서 도출되니, 사람을 깨우는 기준이 단순히 "임계치를 넘었나"에서 "사용자 약속을 깨뜨리고 있는가"로 옮겨간다.

같은 약속을 뒤집어 본 게 Error Budget(에러 예산) 이다. 한 달 동안 약속을 100% 완벽히 지킬 수는 없다. 가령 위 SLO를 "30일 중 99.9% 이상 만족"으로 잡으면, 나머지 0.1%(30일 기준 약 43분)가 "실패해도 되는 예산"이 된다. 이 예산을 평소보다 빠르게 갉아먹고 있을 때(burn rate alert)도 알람을 울린다. 특히, Error Budget은 단순히 관측 지표를 넘어 조직의 의사결정 방향에도 참고할 수 있다.

  • 예산이 남으면: 위험을 감수하고 새 기능 배포·실험 가능.
  • 예산이 떨어지면: 신기능 개발 멈추고 신뢰성 작업으로 전환.

이 규칙은 "개발조직은 빠르게 풀고 싶어하고 SRE는 안정을 원함" 이라는 갈등을 숫자 한 개로 정렬한다. 회의에서 누구 직관이 옳냐를 다투지 않고, 예산 잔량으로 결정한다. SLO/Error Budget은 알람 도구가 아니라 조직 운영 도구다.

알람 설계의 모던 표준은 multi-window multi-burn-rate다. 이름은 어렵지만 두 가지 단순한 개념을 연결한 것이다.

첫째, 알람의 시급도는 "얼마나 빨리 예산을 쓰고 있나"로 봐야 한다. Error Budget을 정상 속도의 몇 배로 쓰고 있는가가 burn rate다. 1배면 한 달 예산을 한 달에 걸쳐 쓰는 정상 페이스, 15배면 2일 만에 한 달치를 다 쓰는 페이스다. burn rate가 작으면 티켓, 크면 즉시 페이지처럼 알람 단계를 burn rate별로 나누는 게 multi-burn-rate다. (예: 15배는 즉시 페이지, 6배는 빠른 대응.)

둘째, 같은 burn rate라도 "얼마 동안 봤을 때의 burn rate인가"에 따라 의미가 다르다. 짧은 간격(예: 5분)만 보면 잠깐 튀어오른 트래픽도 burn rate가 높게 찍혀 false positive가 난다. 긴 간격(예: 1시간)만 보면 5분짜리 폭주가 평균에 묻혀 알람이 늦는다. 그래서 짧은 창과 긴 창 양쪽에서 동시에 burn rate가 높을 때만 깨우는 게 multi-window다. 짧은 창은 "지금 폭주가 시작됐다"를, 긴 창은 "일시적이지 않다"를 짝지어 확인해 준다.

두 축을 곱한 결과는 이런 형태다. 15배 burn rate가 5분과 1시간 창 모두에서 만족하면 즉시 페이지, 6배 burn rate가 30분과 6시간 창에서 만족하면 빠른 대응. Google SRE Workbook의 표준 패턴.

17.4 모든 것을 보려고 하면 아무것도 보지 못한다

관측성은 켜기만 하면 되는 게 아니다. 잘못 켜면 운영을 마비시키는 두 함정이 있다.

Cardinality(카디널리티) 폭발. Prometheus 같은 시계열 DB는 라벨 조합 하나당 하나의 시계열을 만든다. endpoint=/login, status=200, region=us-east-1 이라는 조합이 시계열 한 개. /login, 500, us-east-1 은 또 다른 시계열. 라벨 조합 수가 곧 시계열 수다.

여기에 user_idrequest_id 같은 고유값을 라벨로 박는 순간 끝장이다. 사용자 100만 명이면 시계열 100만 개. Prometheus 메모리·디스크가 터지고, 쿼리는 분 단위로 늘어진다. 운영 사고 1순위가 이 한 줄짜리 실수다. 라벨은 끝값이 정해진 것만 (endpoint, status_code, region 정도) 쓴다. 사용자 단위 추적이 필요하면 라벨이 아니라 Trace로 한다. 메트릭은 라벨 차원의 곱으로 저장되니 사용자 1명이 시계열 1개를 만들지만, Trace는 요청 한 건이 한 건의 기록이라 카디널리티 폭발 모델이 아예 다르기 때문이다.

로그 비용. 로그를 다 적재하면 저장·검색 비용이 천문학적이 된다. Datadog/Splunk 청구서 사고는 거의 항상 무차별 적재에서 시작한다. 답은 두 가지를 같이 적용하는 것이다.

  • 샘플링: 정상 요청은 1%만 적재하고, 에러 요청은 100% 적재한다. 정상 1만 건은 안 봐도 되지만, 에러 한 건은 절대 놓치지 말아야 한다.
  • 계층화: debug 는 dev 에만, info 는 staging 까지, warn/error 는 prod 까지. 환경마다 "무엇이 신호고 무엇이 잡음인가" 가 다르기 때문이다. dev 에서는 디버깅 정보가 신호지만, prod 에서는 같은 정보가 잡음이 되어 진짜 에러를 묻어 버린다.

두 기법은 결국 같은 발상이다. 무엇을 안 볼지를 먼저 정한다. 두 함정의 공통 원인도 그 발상의 반대다. "일단 다 적재하자" 는 직관. 관측성은 모든 것을 보는 인프라가 아니라, 볼 수 있는 것을 골라서 잘 보는 인프라다.

17.5 트레이스가 멈춘 곳에서 시작한다: Continuous Profiling

결제 API의 p99 지연이 1.2초로 튀었다고 해보자. 알람이 울린다. 로그에 명시적 에러는 없다. 트레이스를 펴 보면 "결제 API 안의 외부 호출 처리 구간이 800ms를 쓴다"까지는 짚어 준다. 그런데 거기서 막힌다. 그 800ms가 외부 API 응답 자체인지, 응답을 받아 파싱하는 우리 코드인지, 그 안의 어느 함수·어느 라인이 시간을 다 쓰는지가 안 보인다.

Metrics·Logs·Traces 셋이 다 깔려 있어도 못 답하는 영역이다. 함수 단위의 CPU·메모리 사용. 한 발 더 들어가 답하는 게 Continuous Profiling이다. 프로파일은 "그 800ms 안의 JSON 직렬화 함수가 시간을 다 쓴다"까지 짚어 준다. 로컬에서 재현되지 않는 성능 문제의 마지막 답이 여기서 나온다.

도입 비용은 낮다. 프로덕션에서 1~3% 정도의 CPU 오버헤드로 함수 콜스택까지 상시 수집한다. 모든 함수에 측정 코드를 박는 게 아니라, 짧은 주기로 콜스택 스냅샷만 떠서 통계로 합산하는 sampling 방식이기 때문이다. 과거의 무거운 프로파일러와 달리 상시 켜 둬도 된다. 이 영역에서는 Grafana Pyroscope, Parca, Datadog Continuous Profiler, Polar Signals가 대표적이고, 모두 OTel과 함께 박을 수 있다.

AI 워크로드에선 한 층이 더 있다. GPU 프로파일링. CPU 프로파일러는 GPU 안에서 무슨 일이 일어나는지 못 본다. "어느 함수가 CUDA 커널을 호출했나"까지는 짚지만, 그 커널 안에서 시간이 어디로 갔는지는 답하지 못한다. 행렬곱인지, attention인지, GPU 사이 통신(NCCL)인지, HBM 대역이 막혔는지. LLM 추론처럼 GPU가 병목인 시스템에서는 이 층까지 내려가야 "왜 토큰당 지연이 50ms가 아니라 200ms인가" 같은 질문에 답이 나온다. NVIDIA Nsight Systems(시스템 타임라인)·Nsight Compute(커널 단위), PyTorch Profiler, NVIDIA DCGM(클러스터 GPU 메트릭)이 표준 도구다.

GPU 프로파일링은 별도 도구지만, 따로 떠 있는 섬은 아니다. 다른 관측성 도구 위에 그대로 얹힌다. PyTorch Profiler는 OTel/Chrome trace 포맷으로 분산 트레이스에 합류하고, DCGM exporter는 Prometheus로 GPU 메트릭(SM 활용률·HBM 대역·NCCL 시간)을 흘려 보낼 수 있다.

매일 울리는 알람은 경보가 아니라 배경음악이 된다. 관측의 첫 자질이 "보이는가"라면, 두 번째 자질은 그것을 일일이 보지 않아도 되는가이다.


18. 출입문과 자물쇠: 인증, secret, TLS, 네트워크 격리

지금까지의 모든 컴포넌트는 "기능이 동작하는 것"을 풀어왔다. 보안은 그 모든 컴포넌트 위에 가로지르는 속성이며, 한 가지 도구로 풀리지 않는다. 자물쇠 하나로 집을 지키지 않는다. 현관·금고·CCTV가 따로 있는 이유와 같다.

flowchart LR Net[네트워크 격리
VPC·SG·Zero Trust] --> Trans[전송 보안
TLS·mTLS·PKI] --> Auth[인증/인가
AuthN·AuthZ·OAuth·RBAC] Sec[Secret 관리
Vault·KMS·회전] -.자격.-> Auth
  • 인증/인가: "누구이며 무엇을 할 수 있는가". 사용자/서비스의 신원과 권한.
  • Secret 관리: "그 신원을 증명하는 자격이 어디에 안전하게 보관되는가". API 키, DB 비밀번호, 인증서 키.
  • 전송 보안: "신원과 secret이 네트워크를 건널 때 도청·위조되지 않는가". TLS, mTLS, 인증서 운영.
  • 네트워크 격리: "그 호출이 도달할 수 있는 범위 자체를 어떻게 제한하는가". VPC, Subnet, Zero Trust.

이 네 축에는 통과 순서가 있다. 요청 한 건이 자원에 닿으려면 바깥부터 네트워크 격리 → 전송 보안 → 인증/인가 를 차례로 통과한다. 그리고 그 인증·인가가 신원·권한을 증명하는 자격을 보관하는 곳이 Secret 관리다. 단, 통과 순서가 있다고 한 축의 통과가 다음을 면제해 주는 건 아니다. 한 축이 뚫려도 다음이 막도록 모두 켜 두는 원칙을 defense in depth(심층 방어) 라고 부른다.

살펴보는 방향은 통과 순서의 반대로, 개발자가 가장 가까이서 만나는 인증/인가부터 거꾸로 풀어 간다.

18.1 인증은 한 번, 인가는 매번

  • Authentication(인증, AuthN): 너는 누구인가?
  • Authorization(인가, AuthZ): 너는 무엇을 할 수 있는가?

인증은 "이 사람이 영업팀 김대리" 라는 걸 확인하는 일, 인가는 "영업팀 대리에게는 어디까지 허용되는가" 를 결정하는 일이다. 신원 발급은 인사팀이 한 곳에서 하고, 권한 정책은 각 부서가 자기 일에 대해 따로 정한다.

인증은 한 번, 인가는 매번이다. 로그인 한 번으로 누구인지가 정해지지만, 매 호출마다 "그래서 이걸 해도 되는가" 가 다시 물어진다.

이 둘을 한 곳에서 같이 처리하면 권한 정책이 인증 코드와 엉켜 변경이 어려워진다. 권한 하나 바꾸려고 로그인 로직을 건드리는 일이 생긴다. 그래서 모던 시스템도 같은 분리를 따른다. 인증은 한 곳(아이덴티티 프로바이더)에 모이고, 인가는 각 서비스가 자체 정책으로 결정한다.

그런데 인가가 매번이라면 비용이 든다. 호출마다 IdP에 다시 다녀올 수 없고, 각 서비스가 사용자 비밀번호를 직접 들고 있어서도 안 된다. 한 번 인증된 사실을 호출마다 들고 다닐 수 있는 증서가 필요하다. 그게 토큰 이다. IdP가 서명해 발급한 토큰을 사용자가 매 호출에 첨부하면, 각 서비스는 그 서명을 검증하고 자체 정책으로 인가를 결정한다. 영업팀 김대리가 인사팀에서 받은 사원증을 매 부서마다 보여 주는 그림이다. 사원증에는 인사팀 서명과 "영업팀 대리" 라는 직급이 박혀 있다. 각 부서는 김대리도 모르고 인사팀에 묻지도 않은 채, 그 서명을 믿고 영업팀 대리에게 허용된 일까지만 처리한다.

인가가 매번 결정된다고 했을 때, 그 결정의 형태 가 한 단계 더 남는다. 정책 모델 의 선택이다. 가장 단순한 RBAC(Role-Based, 역할 단위)는 영업팀 대리 같은 역할에 권한을 묶어 두는 방식으로, 대부분의 서비스가 기본으로 쓴다. 부서·지역·시간 같은 속성 조합이 필요해지면 ABAC(Attribute-Based)가 들어오고, 이 문서를 owner의 owner가 공유한 그룹 멤버에게 허용 같은 협업 자원은 관계 기반 ReBAC(Google Zanzibar 계열의 OpenFGA, SpiceDB)가 정답에 가깝다. 기본은 RBAC, 세밀한 권한은 ABAC, 협업 자원은 ReBAC 로 섞어 쓰는 패턴이 흔하다.

18.2 기억하는 서버, 잊는 서버

토큰에는 두 단계가 있다. 어디서 발급받을 것인가, 어떻게 검증할 것인가.

검증부터 살펴보자. 검증 방식은 두 갈래다. 세션은 서버가 누가 누구인지를 직접 기억한다. JWT(JSON Web Token)는 기억하지 않는 대신 서명된 증서를 들려 보낸다.

방식 동작 장점 단점
세션 쿠키 서버가 세션 ID 발급, 서버 측에 상태 보관 (Redis) 즉시 무효화 가능, 단순 상태 저장소 필요, 쿠키/CSRF 신경
JWT 서버가 서명된 토큰 발급, 서버는 무상태 무상태, 스케일 친화적 즉시 무효화 어려움(블랙리스트 필요), 페이로드 비대

세션은 문지기가 누가 누군지를 직접 기억한다. 누군가의 권한을 빼앗으면 곧바로 문지기가 막는다. 다만 문지기의 기억은 사람 수만큼 늘어나, 결국 별도의 기억 창고(Redis 같은 상태 저장소)가 필요해진다.

JWT는 반대다. 문지기가 사람을 기억하지 않고 들고 온 토큰의 서명만 본다. 사람이 아무리 늘어도 일일이 기억할 필요가 없다. 대신 누군가의 권한을 빼앗아도 그 사람이 들고 있는 토큰이 만료되기 전까지는 그대로 통과한다. 무효화하려면 모든 문지기가 블랙리스트를 따로 들고 있거나, 토큰 만료까지 기다려야 한다.

기억하는 쪽은 운영이 무거워지고, 잊는 쪽은 사고에 늦게 반응한다. 어느 쪽도 공짜가 아니다.

검증 다음은 발급이다. 지금까지는 우리 앱이 직접 사용자 비밀번호를 받고 토큰을 발급하는 그림이었다. 그런데 사용자는 매 앱마다 새 비밀번호를 만들고 싶지 않고, 우리 앱도 비밀번호를 보관할 책임을 지고 싶지 않다. 사용자가 이미 구글에 등록해 둔 계정을 그대로 쓰면 양쪽 부담이 한 번에 사라진다. 이 모델을 표준화한 것이 OAuth2OIDC 다. 둘 다 사용자가 이미 구글 같은 외부 서비스에 로그인된 상태에서, 그 인증 결과를 우리 앱에 어떻게 넘기는가의 절차다.

OAuth2 는 인가(AuthZ)의 표준이고, 우리 앱이 받는 것은 권한뿐이다. "이 앱이 사용자의 구글 캘린더를 대신 읽어도 된다" 같은 토큰만 발급되고, 그 사용자가 누구인지는 OAuth2 표준 안에 없다. 그래서 OIDC 가 OAuth2 위에 신원 정보를 얹는 확장으로 등장했다 (AuthN). 같은 흐름 한 번에 권한 토큰 옆 ID 토큰까지 받아 "이 사람이 그 구글 계정 주인이다" 가 같이 전달된다.

사용자가 구글에서 인증·동의를 마치면 그 결과가 서명된 토큰으로 우리 앱에 전달되고, 우리 앱은 그 토큰을 다시 위의 두 갈래로 저장·검증한다. "구글로 로그인" 버튼 안쪽이 바로 이 흐름이다.

18.3 Secret: 회수되지 않는다, 회전될 뿐

토큰, DB 비밀번호, API 키도, TLS 인증서의 개인 키도 모두 공통점이 있다. 외부에 노출되는 순간 그 신원으로 위장이 가능해지는 문자열, secret이라는 점이다.

Secret이 코드나 설정 파일에 들어가는 순간 git 히스토리에 영원히 박힌다. force-push 로 지워도 누군가의 로컬 클론, 빌드 로그, 백업 어딘가에 남는다. 한 번 들어가면 회수가 안 된다. 그래서 secret은 애초에 코드 안이 아니라 코드 바깥의 전용 저장소에서 다룬다.

그래도 한 번 새 나가면 어떻게 할까. 인감과 비슷하다. 새 나간 도장은 다시 거둘 수 없고, 할 수 있는 일은 새 도장을 파고 옛 도장을 무효화하는 것뿐이다. Secret도 회수는 안 되고 회전만 된다.

여기까지 결정되면 남는 질문은 셋이다. 어디 두고, 어떻게 바꾸고, 어떻게 빌드 파이프라인까지 전달하는가. 표준 도구가 각 자리에 있다.

측면 표준 도구 핵심
어디 두는가 Secrets Manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) Secret의 단일 출처. 코드는 부팅 시 여기서 읽어 간다.
어떻게 바꾸는가 회전(rotation) 정기 회전이 기본. 재시작 없이 회전을 견디게 짜는 것이 운영 비용을 결정한다.
빌드 파이프라인까지 CI/CD 안의 secret (GitHub Secrets, OIDC 단기 토큰) long-lived key는 한 번 새면 끝. 단기 토큰은 사고 반경을 분 단위로 줄인다.

한 층 더 들어가면 KMS(Key Management Service)가 있다. Secret 자체가 아니라 "secret을 봉인하는 키"를 HSM(Hardware Security Module) 기반으로 위탁 관리한다. Secrets Manager가 내부적으로 KMS를 불러 secret을 암호화/복호화하는 구조라, 애플리케이션 코드가 직접 만질 일은 드물다.

18.4 인증서의 본업은 발급이 아니라 회전이다

TLS(Transport Layer Security) 는 네트워크 통신을 암호화하면서 상대가 진짜인지 검증하는 표준이다. HTTP 위에 TLS를 얹은 것이 HTTPS다(SSL은 TLS의 옛 이름이라 둘은 같다). 검증의 도구는 인증서, 즉 신뢰할 수 있는 기관(CA, Certificate Authority)이 이 도메인은 이 회사 소유 라고 서명한 디지털 신분증이다.

그 인증서의 개인 키도 secret이다. 한 번 새 나가면 그 키로 서명된 모든 통신이 영원히 위장 가능해진다. 그래서 인증서는 만료일을 가진다. 한 키가 영원히 살지 못하게 막는 장치다. 만료가 다가오면 새 키로 발급받아 갈아 끼우고, 옛 키는 폐기한다. 이게 회전이다.

회전을 놓치면 인증서는 만료되고, 서비스가 멈춘다. 인증서 만료는 매년 가장 흔한 prod 사고 원인 중 하나다. 사람이 일일이 캘린더를 보고 관리하면 번거롭다. 자동 갱신과 만료 알람이 코드와 같은 격으로 운영되어야 한다.

운영은 라이프사이클을 따라간다.

먼저 발급. 자기가 서명한 인증서는 브라우저가 신뢰하지 않는다. 누가 보증하는가 가 인증서의 본질이라, 모든 브라우저가 이미 신뢰하는 공개 CA(Let's Encrypt, AWS ACM, GCP Managed Cert)의 서명이 필요하다.

그다음은 갱신. 공개 인증서는 보통 90일 주기다. 분기에 한 번이라는 길이는 사람이 놓치기 딱 좋은 간격이다. 그래서 자동화 도구(certbot, cert-manager, Caddy)가 사람 캘린더 대신 회전을 챙긴다. 자동화는 추가 기능이 아니라, 회전이라는 본업을 사람의 망각으로부터 떼어 내는 장치다.

그 위에 한 겹이 더 얹힌다. 자동화도 권한 누락이나 네트워크 문제로 실패할 수 있고, 그 실패를 사람이 알아챌 시간 이 필요하다. 그래서 만료 30일 전 알람이 사실상 표준이다. 자동 갱신 + 만료 알람 두 겹이 회전을 받친다.

또 중요한 것은 암호화의 위치다. TLS 종단(termination), 즉 암호화를 어디서 풀 것인가 의 위치 결정이다. 사용자가 보낸 HTTPS 요청은 어딘가에서 풀려야 서버가 내용을 읽고 처리할 수 있다. 그 풀리는 지점을 입구의 LB/CDN 에 둘지, 끝의 서비스 에 둘지의 선택이다.

LB/CDN에서 풀면 내부 서비스는 암호화 비용을 안 진다. 빠르고 가볍다. 하지만 LB-내부 구간이 평문이라 누군가 내부망에 들어오면 그 트래픽을 그대로 본다. 끝까지 TLS를 유지하면 안전하지만 모든 서비스가 인증서와 복호화 비용을 진다. 그래서 보통 LB에서 한 번 풀고 내부는 mTLS로 다시 감싸는 절충을 쓴다.

mTLS(mutual TLS) 는 양쪽이 서로의 인증서를 검증하는 TLS다. 일반 TLS는 클라이언트가 서버의 인증서만 본다. 사용자가 진짜 사이트인지 확인하면 충분해서다. 내부 서비스 간 호출은 다르다. 서비스 A가 서비스 B를 부를 때 B도 A를 검증해야 누가 누구인지 가 정해진다. 그래서 내부에서는 mTLS가 절충 도구 가 아니라 기본형 이 된다. Zero Trust의 핵심이다.

문제는 규모다. 10개 서비스가 서로 호출하면 100쌍, 50개면 2,500쌍의 인증서가 필요하고 모두 회전 대상이다. TLS 알고리즘 한 줄 바꾸려면 N개 서비스가 같이 배포되고, 라이브러리 버전이 서비스마다 어긋난다.

표준 해법은 두 갈래다. 한쪽은 사내 PKI(Public Key Infrastructure) 다. 사내 CA를 운영해 인증서 라이프사이클(발급, 회전)을 한 곳에서 자동화한다(SPIFFE/SPIRE). 그러면 각 서비스는 부팅 시 자동으로 자기 신원 인증서를 받고 회전도 자동으로 처리된다. 사람이 각 서비스의 인증서를 일일이 챙기지 않아도 된다.

다른 한쪽은 Service Mesh(Istio/Linkerd) 다. 사이드카가 TLS 통신 자체 를 떼어 내 인프라 층으로 내려 보낸다. 애플리케이션 코드는 mTLS를 모른 채로 서로를 부르고, 사이드카가 그 사이에서 인증서를 들고 다닌다. 코드 한 줄 안 바꾸고도 모든 내부 통신이 mTLS로 감싸지고, 회전과 정책 변경도 인프라 작업이 된다.

두 갈래는 다른 차원이라 함께 쓰기도 한다. 어느 쪽이든 보안의 단가가 뚝 떨어진다.

18.5 한 서비스가 뚫려도 옆방은 막는다

어떤 보안도 완벽하지 않다. 한 서비스가 뚫리는 일은 일어난다. 그 사고가 옆방으로 번지지 않게 하는 게 네트워크 격리 다. 누가 무엇에 닿을 수 있는지를 네트워크 레이어에서 통제한다.

도구는 두 그룹으로 나뉜다. 격리의 구조 (어떻게 칸막이를 치는가) 와 격리의 통로 (그 안에서 어떻게 통하는가).

flowchart LR Internet([인터넷 사용자]) Admin([운영자]) subgraph VPC["VPC (가상 사설망)"] subgraph Pub["Public Subnet · NACL (로비)"] direction LR LB[Load Balancer] Bastion["Bastion Host
or SSM"] end subgraph Priv["Private Subnet · NACL"] direction LR App[App Service] DB[(DB)] end NAT[NAT Gateway] end Internet -->|HTTPS| LB Admin -.운영 접근.-> Bastion LB --> App Bastion -.-> App App --> DB App -->|outbound 전용| NAT NAT --> Internet classDef sg stroke-dasharray:5 5,stroke:#888,stroke-width:2px; class LB,Bastion,App,DB sg;

인스턴스 점선 테두리 = Security Group 적용, 서브넷 라벨 = NACL.

격리의 구조

  • VPC(Virtual Private Cloud): 가상 사설망. 공유 사무실 안에 우리 회사만 쓰는 칸막이 공간을 친 것에 가깝다.
  • Subnet: 그 칸막이 공간을 다시 방으로 나눈 것.
    • Public Subnet: 인터넷에서 직접 도달 가능 (LB, Bastion). 로비.
    • Private Subnet: 외부 도달 불가 (DB, 내부 서비스). 금고.
  • Security Group: 인스턴스 문지기. 한 번 들어온 연결의 응답은 자동으로 통과시킨다(stateful).
  • NACL(Network ACL): 서브넷 검문소. 모든 패킷을 규칙으로 일일이 검사하므로 양방향을 따로 명시해야 한다(stateless).

격리의 통로

  • NAT Gateway: private subnet의 outbound 전용. 금고에서 밖으로 나가는 한 방향 문.
  • Bastion Host / SSM Session Manager: 운영 접근의 단일 통제점. 한 문에 모아야 누가 언제 들어갔는지 감사·차단이 가능하다.
  • VPC Peering / Transit Gateway: 팀/계정별로 VPC가 나뉘어 있을 때 두 VPC를 직접 연결한다.
  • PrivateLink: SaaS(AWS 매니지드 서비스, Snowflake 등)를 공개 인터넷 거치지 않고 사설 망으로 호출한다.

이 구조는 결국 사고의 반경(blast radius)을 작게 유지하기 위한 것이다. 네트워크 토폴로지로 닿을 수 있는 범위 를 좁히고, 그 안의 통로마저 통제된 한 문에 모은다. 그 결과 한 서비스가 뚫려도 옆으로 번지는 길이 모두 닫혀 있다. 사고를 0으로 만드는 일이 아니라, 사고가 터졌을 때 한 방에서 끝나게 만드는 일이다.

18.6 Zero Trust: 안에 있다고 통과는 없다

지금까지의 네트워크 격리는 경계 안과 밖 을 나누는 일이었다. 안은 신뢰, 밖은 차단. 이 모델의 전제가 흔들린다. 직원은 카페에서 개인 노트북으로 사내 시스템에 접속하고, 회사 데이터는 사내 서버가 아니라 SaaS(Slack, Notion, Salesforce) 에 살고, 협력사·외주 인력이 내부 도구를 같이 쓴다. 어디서부터가 내부인가 라는 질문에 답할 수 없게 된 순간, 경계 모델은 깨진다.

Zero Trust는 그래서 내부에 있다는 사실을 신뢰의 근거로 쓰지 않는다. 모든 호출은 매번 인증·인가를 거친다. 안에 있어서 통과하는 게 아니라, 매 호출이 지금 이 신원으로 이걸 할 수 있는가 를 다시 증명한다.

새 도구를 따로 도입하는 것은 아니다. 앞에서 다룬 도구들이 하나의 원칙 아래 묶이는 것이다.

  • 사람의 호출: SSO + MFA + 디바이스 확인
  • 서비스의 호출: mTLS + 서비스 ID
  • 이 검증을 한 곳으로 모으는 게이트웨이: Cloudflare Access, Tailscale, BeyondCorp, Google IAP

목적은 네트워크 격리의 연장선이다. 격리가 네트워크 토폴로지 로 사고의 반경을 줄였다면, Zero Trust 는 모든 호출에 검문 을 걸어 그 반경을 한 단계 더 좁힌다. 경계가 무너져도 호출마다 다시 물으니, 한 호출이 통과해도 다음 호출의 권한이 자동으로 따라오지 않는다. 사고의 반경이 한 호출 단위 까지 떨어진다.

다만 이게 공짜는 아니다. 호출마다 검문하려면 SSO/MFA 인프라, mTLS 인증서 회전, 정책 관리가 모두 운영 부담으로 따라온다. 경계가 명확하고 작은 시스템(사내 LAN 안의 소규모 내부 도구, 서비스 한두 개)이라면 perimeter 모델로도 충분하고, 풀 Zero Trust 는 과한 투자가 된다.

또한, 현실적인 질문은 Zero Trust 를 켤지 말지 가 아니라 어디까지 적용할지 다. 사용자 진입(SSO + MFA)은 거의 모든 시스템이 기본으로 켜고, 서비스 간 mTLS 는 민감한 경로부터 시작하며, 전사 게이트웨이는 분산 인력·SaaS·외주 접속이 일상화된 시점에 도입한다. 경계가 흐려진 만큼 검문을 안쪽으로 옮기는 일, 그게 실제 적용 그림이다.

18.7 OWASP Top 10: 매년 같은 자리에서 사고가 난다

보안 사고는 새로운 길로 들어오지 않는다. 인가 검사를 빼먹은 엔드포인트, 사용자 입력을 그대로 SQL 에 붙여 넣은 한 줄, 평문으로 저장된 비밀번호. 매년 다른 회사에서 같은 종류의 사고가 반복된다. 사람의 실수 패턴이 같고, 코드 베이스가 자라면서 검증 누락이 같은 자리에 다시 생기기 때문이다.

OWASP Top 10 은 그 반복을 받아 적은 목록이다. OWASP(Open Worldwide Application Security Project) 라는 비영리 보안 커뮤니티가 매년 실제 사고 통계에서 가장 흔한 10 가지 카테고리를 추려 내놓는다. 매년 갱신되지만 큰 줄기는 거의 변하지 않는다. 새로운 위협을 좇기 전에, 매년 같은 자리에서 무너지지 않는 것부터 한다.

ID 카테고리 요지 기본 방어
A01 Broken Access Control 다른 사용자 자원 접근 등 인가 실패 서버 측 권한 검사 일관화, RBAC/ABAC
A02 Cryptographic Failures 평문 저장, 약한 알고리즘 TLS 강제, KMS 사용, 비밀번호는 bcrypt/argon2
A03 Injection (SQLi/XSS/SSRF) 신뢰 못 할 입력이 코드/쿼리로 흐름 매개변수 쿼리, 출력 인코딩, allowlist
A04 Insecure Design 위협 모델 부재 Threat modeling, secure design review
A05 Security Misconfiguration 기본 비밀번호/관리 콘솔 노출 IaC 표준, CIS Benchmark
A06 Vulnerable Components 오래된 의존성 SCA(SBOM), Dependabot/Renovate
A07 Identification & Auth Failures 약한 토큰/세션 처리 OIDC, MFA, 세션 회전
A08 Software & Data Integrity 검증 안 된 빌드/업데이트 이미지 서명(Cosign), SLSA
A09 Logging & Monitoring Failures 사고를 보지 못함 관측성 + 보안 이벤트 별도 파이프라인
A10 SSRF 서버가 외부 입력으로 내부망 접근 메타데이터 IP 차단, outbound allowlist

결함은 한 곳에만 살지 않는다. 외부에서 들여온 라이브러리에 박힌 알려진 취약점, 우리가 짠 코드 안의 위험한 한 줄, 그 코드를 실제로 돌릴 때만 드러나는 설정 누락, 그리고 자동화가 절대 못 보는 사람이 의도적으로 만들어 내는 우회. 결함이 사는 층이 다르면 검증 방식도 달라진다. 한 층의 도구는 다른 층을 못 본다. 그래서 검증은 층마다 다른 도구를 쌓는 방식이 된다.

flowchart LR L1["외부 라이브러리"] L2["우리 소스 코드"] L3["실행 중인 시스템"] L4["사람의 적대적 검증"] L1 --> L2 --> L3 --> L4

왼쪽일수록 자동화·저비용, 오른쪽일수록 사람 인력·고비용. 각 층마다 표준 도구가 있다.

  • SCA(Dependabot, Snyk, Trivy): 외부 라이브러리 의 알려진 CVE 를 데이터베이스와 대조해 잡는다.
  • SAST(Semgrep, CodeQL, SonarQube): 우리 소스 코드 를 실행하지 않고 패턴 분석으로 위험 부분을 잡는다.
  • DAST(OWASP ZAP, Burp): 실행 중인 시스템 에 실제 요청을 던져 보고 응답에서 결함을 잡는다.
  • Pentest / Bug Bounty: 사람의 적대적 검증. 자동화가 못 잡는 논리 우회를 사람이 직접 시도해 잡는다.

쌓을수록 비용이 커진다. SCA 는 CI 에 한 줄 추가로 끝나지만, Pentest 는 외부 인력이 수일에서 수주가 들어간다. 그래서 작은 시스템은 SCA 부터 시작하고, 자산 가치가 커지는 만큼 SAST → DAST → Pentest 를 차례로 더한다. 모든 시스템이 Pentest 까지 가는 게 아니라, 시스템이 잃을 수 있는 것의 크기만큼 시야를 늘려 가는 것이다.

보안은 단단한 벽을 쌓는 것만큼이나 매일 새로운 자물쇠를 거는 게 중요하다.


19. 청구서는 코드가 결정한다: 비용 가시성, 절감 레버, 단가, FinOps

관측성과 보안은 한 컴포넌트의 기능이 아니라 컴포넌트들 위에 가로지르는 속성이었다. 비용도 같은 자리에 있다. 한 줄의 코드가 어떤 인스턴스 타입을 잡았는지, 한 클러스터가 몇 시간을 깨어 있었는지, 한 메트릭이 몇 개의 시계열을 만들었는지가 매월 한 번 합산되어 청구서로 도착한다. 비용은 재무팀의 일이 아니라, 컴포넌트마다 따라오는 결이다.

운영 모델은 한 사이클로 정리된다. FinOps 가 그 사이클의 표준 이름이다.

flowchart LR Inform["Inform
가시성·태깅·할당"] Optimize["Optimize
아키텍처·운영 레버"] Operate["Operate
단가·가드레일·KPI"] Inform --> Optimize --> Operate Operate -.피드백.-> Inform
  • Inform: 누가 얼마를 어디에 쓰고 있는가가 보인다.
  • Optimize: 아키텍처와 운영 레버 양쪽에서 줄인다.
  • Operate: 단가로 측정하고, 가드레일과 KPI 로 매일 굴린다.

세 단계는 폭포수가 아니라 반복 사이클이다.

클라우드는 한 번 사고 끝나는 비용(CapEx)이 아니라 매월 청구되는 비용(OpEx)이다. 매일 매시간 청구되고, 결정 권한이 엔지니어 손에 있고, 잘못 짠 코드 한 줄이 회계 분기를 망가뜨릴 수 있다.

19.1 분해되지 않은 청구서는 줄일 수 없다

월말에 청구서가 한 장 도착한다. 거기엔 총액만 있다. 어느 팀의, 어느 서비스의, 어느 기능의 비용인지가 안 적혀 있다. 이 한 장을 분해하지 못하면 줄일 곳을 짚을 수가 없다. 가장 먼저 푸는 문제는 절감이 아니라 분해다.

분해의 단위는 태그(tag) 다. 모든 클라우드 자원에 team, service, env, cost-center 같은 라벨을 붙이고 청구서를 그 라벨로 그룹핑한다. 태깅이 일관되지 않으면 Untagged 가 청구서의 30~50% 를 차지하는 일이 흔하다. 그래서 태깅은 사람의 선의에 맡기지 않고 IaC 모듈 단계에서 강제한다.

태그가 갖춰지면 다음은 청구서 자체의 형식이다. AWS·GCP·Azure 의 청구서 컬럼이 모두 다르고, 멀티클라우드를 쓰면 ETL 코드를 회사마다 따로 짜야 했다. 같은 "그 달의 청구액" 한 컬럼이 AWS 는 lineItem/UnblendedCost, GCP 는 cost, Azure 는 CostInBillingCurrency 식이었다. 이걸 풀려고 나온 표준이 FOCUS(FinOps Open Cost & Usage Specification) 다. 어느 클라우드의 데이터든 한 스키마로 떨어진다.

ChargePeriodStart  ProviderName  ServiceName      BilledCost  ChargeCategory  Tags
2026-04-01         AWS           AmazonEC2         1234.56    Usage           team=payments
2026-04-01         GCP           BigQuery            87.42    Usage           team=analytics
2026-04-01         Azure         VirtualMachines    512.30    Usage           team=ml

주요 클라우드가 모두 export 를 지원한다. 비용 데이터판 OpenTelemetry.

분해를 위한 도구는 한 클라우드면 자체 도구(AWS Cost Explorer, GCP Billing) 로 충분하고, 멀티클라우드나 K8s 단위 할당이 필요하면 벤더 중립 도구(Vantage, CloudZero, Cast AI) 를 쓴다. 분해는 절감 자체가 아니라 전제 조건이다. 분해 없이 시작하는 절감은 감으로 자르는 일이라 거의 늘 잘못 자른다.

19.2 우리가 결정한 적 없는 비용

청구서를 분해하면 비용은 두 종류다. 의도해서 쓰는 비용과, 누구도 켜라고 결정한 적 없는 비용. 후자는 새어나가는 비용으로, 대표적으로 네 가지다.

(1) 가격 비대칭. 들어오는 건 무료, 나가는 건 비싸다. 트래픽이 어디를 넘느냐에 따라 GB 당 가격이 다르다. AWS 기준:

경로 GB 당 가격
인터넷 → AWS (들어오는) 무료
AWS → 인터넷 (나가는) $0.09
같은 region 안, AZ(데이터 센터 단위) 사이 $0.01 × 2 (송·수신 각각)
region 사이 $0.02

일반 ISP 가 받는 도매 대역폭이 GB 당 $0.001 안팎이라 AWS 외부 송출은 그 수십~수백 배다. NAT Gateway, 트랜짓 게이트웨이도 같은 부류. 코드에서는 호출 한 번이지만 인프라에서는 시간당 요금과 GB 당 요금 두 줄짜리 청구로 따라붙는다.

(2) 숨은 호출. 코드 한 줄 뒤의 GB 청구. K8s 에서 pod 끼리 부르는 호출이 모르는 사이에 매번 AZ 를 넘는다. K8s Service 가 같은 AZ 안에 가까운 pod 가 있어도 AZ 를 따지지 않고 무작위로 라우팅하기 때문. 코드에는 http://payment-service 한 줄이지만, 그 뒤에서 위 표의 cross-AZ 양쪽 합 $0.02/GB 가 계속 쌓인다. Topology Aware Hints(같은 zone 안 pod 우선 라우팅) 로 잡으면 사라진다.

(3) 카디널리티 폭발. 한 줄이 메트릭 N 배로. 카디널리티 폭발이 그대로 비용으로 옮겨 온다. Datadog 같은 도구는 메트릭 한 종류당 과금하는데, custom metric 에 user_id 라벨 한 줄을 박으면 사용자 수만큼 메트릭이 늘어 청구서 자릿수가 바뀐다. Coinbase 의 Datadog 연 $65M 청구 가 이 메커니즘의 대표 피해 사례. Snowflake·BigQuery 같은 쿼리 단위 과금 DW 도 같은 종류다. 잘못된 select * 한 번이 GB 단위 스캔으로 청구된다.

(4) 자원 방치. 만들고 안 끈 것들. EC2 인스턴스가 종료될 때 함께 삭제되도록 표시(Delete on Termination) 안 한 EBS 볼륨, 잊은 snapshot, 트래픽 0 인 ALB(로드밸런서, 빈 채로도 시간당 과금). Flexera 2026 보고서 기준 클라우드 지출의 29% 가 이런 waste 로 추정된다.

이것들의 공통점은 누구도 그것을 켜라고 결정한 적이 없다는 것이다. 이것들이 바로 비용 절감의 첫 출발선이 된다.

19.3 우리가 결정해야 하는 비용

위에서 본 비용이 우선 잡고 봐야 하는 비용이었다면, 이번에 볼 것은 잡아야 하는지 고민해야 하는 비용이다. 예를 들어 앞선 섹션들에서 봤던 개념들은 비용측면에서 다음과 같은 트레이드오프를 고려해야 한다.

Read Replica. 읽기 분산 위해 DB 인스턴스 선형 추가. Read Replica 는 읽기 부하 분산과 장애 대비를 위한 표준이지만, replica 한 대당 DB 인스턴스 비용이 그대로 더해진다. replica 3 → 5 는 그만큼 더 안전이 아니라 그만큼 더 비싸다. 가장 흔한 over-provision (필요 이상으로 잡아두기).

Multi-region. 신뢰성 위해 컴퓨트 2 배. multi-region DR 은 신뢰성을 위한 것이지만, 컴퓨트가 두 곳에 떠 있고 region 간 복제 egress 가 추가돼 2~3 배가 든다. region 한 곳이 잠깐 죽었을 때의 손실이 매월 두 region 운영비보다 작으면 single-region 이 답.

Serverless. 호출 적으면 싸지만 많아지면 EC2 가 더 싸짐. Lambda 같은 서버리스는 호출 단위로 과금이라 호출이 없으면 비용 0 이지만, 호출량이 일정 수준을 넘으면 시간당 과금인 EC2 가 더 싸진다. 휴리스틱으로 일 5만 호출이 자주 인용되는 분기점. 꾸준한 트래픽이면 EC2/Fargate, 가끔 튀는 트래픽이면 Lambda.

MSA·K8s. 서비스마다 인프라가 한 세트씩. 모놀리스 한 대를 MSA 10 개 서비스로 쪼개면 정확히 10 배는 아니지만 서비스마다 인프라 한 세트가 따라붙는다. HA 위한 인스턴스 2 대, service mesh 사이드카, 분산 trace 와 로그 수집이 서비스 수에 비례한다. K8s 도 마찬가지로 EKS 같은 매니지드는 클러스터를 띄우는 것만으로 월 $73(관리용 control plane 비용) 이고 거기에 워커 노드 비용이 더 든다. 분산은 복잡도만 늘리는 게 아니라 기본 비용도 함께 키운다.

여태까지의 기술 검토에 비용이라는 축이 하나 더해진다. 신뢰성·성능 옆자리, 같은 무게다.

19.4 결정한 비용을 줄이는 법

비용이 무서워서 필요한 결정을 못 내리는 건 더 큰 문제다. 비용을 감안하고 깔기로 결정했다면, 남은 일은 하나다. 같은 결정에서 청구서를 어디까지 깎을 수 있느냐. 이 단계는 코드를 바꾸지 않는다. 같은 코드에 클라우드의 가격 옵션을 다르게 거는 일이다. 그래서 표준 절차가 되고, 그래서 순서가 있다. 표준 레버 다섯 가지, 표의 위에서 아래가 곧 적용 순서다.

순서 레버 동작 절감폭 트레이드오프
1 Rightsize 실제 사용량에 맞춰 인스턴스·요청량 줄이기 평균 30~70% 측정·관찰 기간 필요
2 Autoscaling 트래픽에 맞춰 자동으로 늘리고 줄이기 idle 비용 0 화 콜드스타트, 룰 튜닝
3 Spot / Preemptible 클라우드가 회수 가능한 잉여 자원 최대 90% 중단 가능, stateless 만
4 약정 (RI / Savings Plan) 1~3년 사용 약속하고 할인 최대 72% 기간 락인
5 Storage tier (병렬 트랙) 자주 안 보는 데이터는 싼 티어로 S3 IA·Glacier 30~80% 접근 지연·복원 비용

순서가 어긋나면 사고가 난다. 가장 흔한 게 rightsize 안 한 인스턴스에 3년 약정을 거는 일. 큰 사이즈로 묶여 빠져나갈 수 없다.

1. Rightsize 먼저. Cast AI 2026 보고서 기준 K8s 클러스터 평균 CPU 사용률이 요청량의 8%. 안전 마진으로 크게 잡은 사이즈 안에서 90% 가 안 쓰는 자리라는 뜻. VPA·Goldilocks 같은 도구가 실측 기반 권장값을 뽑아 준다.

2. Autoscaling 으로 idle 제거. 트래픽이 낮을 때도 인스턴스가 그대로 떠 있으면 그게 idle 비용이다. 자동 조정은 세 축. HPA (pod 수), VPA (pod 자원량), Cluster Autoscaler·Karpenter (노드 수). K8s 노드 표준은 Karpenter. 인스턴스 그룹에 묶이지 않고 그 시점에 가장 싼 타입을 골라 띄운다.

3. Spot 으로 큰 폭 절감. 70~90% 가 빠지지만 클라우드가 분 단위로 회수할 수 있다. CI 빌드, 배치 잡 같은 중단·재시작 무해한 워크로드만.

4. Baseline 위에 약정. 항상 떠 있는 만큼 (baseline) 을 13 년 약속하면 1년 2740%, 3년 최대 72%. Compute Savings Plan 이 가장 유연하다. 약정은 baseline 의 70~80% 만 커버하는 게 정석. 100% 약정은 트래픽이 줄었을 때 빠져나갈 길이 없다.

5. Storage tier (병렬 트랙). 위 네 단계와 독립이다. 자주 안 보는 데이터를 싼 티어로 옮긴다. S3 Standard($0.023/GB) 와 Glacier Deep Archive($0.00099/GB) 사이는 23 배. 다만 cold tier 는 꺼낼 때 시간과 비용이 든다. Deep Archive 는 복원에 12~48 시간 + GB 당 별도 요금. 자주 꺼내는 데이터에 걸면 오히려 비싸진다.

여기에 한 가지 더, Graviton. AWS 의 ARM 기반 칩으로 옮기면 같은 워크로드가 시간당 20% 싸지고 성능도 2540% 좋아진다. 호환성만 맞으면 코드 거의 안 바꾸고 30~40% 가 빠지는 단일 레버.

19.5 총량이 아니라 단가가 진짜 신호다

레버를 적용해 청구서가 줄었다. 진짜로 줄어든 건지는 다른 질문이다. 회사가 세 배 자라는 중이라면 청구서가 두 배 늘어도 좋다. 청구서가 그대로여도 사용자당 비용은 늘었을 수 있다. 총량은 가짜 신호다. 진짜 신호는 단가다.

단가의 계산은 단순하다. 단가 = 청구서 ÷ 사용량. 분자는 태그 기반 비용 분해 (가시성), 분모는 RPS · MAU 같은 관측 메트릭 (관측성). 둘 다 갖춰져야 계산된다.

흔한 단가 네 가지:

  • 요청 한 건당 비용 (cost per request)
  • 사용자 한 명당 비용 (cost per user, MAU 당)
  • 테넌트 한 곳당 비용 (cost per tenant). B2B SaaS에서 중요
  • 트랜잭션 한 건당 비용 (cost per transaction)

단가가 주는 신호는 결국 성장 vs 비효율 분리 다. 청구서가 늘었는데 단가는 그대로면 성장. 청구서는 그대로인데 단가가 늘었으면 어디선가 비효율이 자라고 있다는 뜻. 같은 청구서 숫자라고 해도 완전히 다른 해석.

19.6 FinOps: 매일 운영하는 일

여기까지가 도구다. 도구를 한 번 깐다고 비용이 자동으로 관리되는 건 아니다. 언제 누가 어떻게 챙기는가의 운영 모델이 따로 필요하다. FinOps 의 Inform → Optimize → Operate 사이클을 실제 시간 축으로 풀면 이런 모양이다.

시점 할 일
첫 주 Cost Explorer 켜고 Untagged 비율 측정. 가장 큰 5 개 항목 식별
첫 달 IaC 에 태그 강제. 이상치 알람. 팀별 청구서 매주 Slack 자동 도착
첫 분기 Savings Plan coverage 70~80% 목표, rightsize 라운드, storage lifecycle, spot 식별
첫 해 단가 대시보드, PR 단계 비용 코멘트 자동화, 디자인 리뷰에 비용 항목

"비용 절감 프로젝트" 한 번 하고 끝내는 게 가장 흔한 실패 패턴이다. 이번 분기에 줄인 비용이 다음 분기에 다시 자란다. 비용은 보안과 같다. 한 번 막아서 끝나는 게 아니라, 매일 다시 쌓이는 결.

여기까지가 운영 모델이다. 이 모든 도구는 클라우드 안의 도구라는 한계가 있다. 어떤 워크로드는 클라우드 자체가 답이 아니다. 대역폭 집약 서비스 (영상 스트리밍), 트래픽이 일정한 고 IO DB, 24/7 풀가동 GPU 트레이닝 같은 워크로드는 자체 데이터센터가 더 싸다. Basecamp, Coinbase 가 자체 인프라로 부분 회귀한 사례. 클라우드는 default 가 아니라 feature 다.

먹방 유튜버들이 종종 수백만 원짜리 재료로 호화 식단을 올린다. 전문 쉐프들은 하지 않는 일이다.


20. 결론: 컴포넌트는 답이 아니라 트레이드오프다

20.1 한 대의 서버에서 시작된 답들의 누적

flowchart TB User[사용자] --> DNS[DNS
GeoDNS / Failover] DNS --> CDN[CDN / WAF] CDN --> Gate[API Gateway / LB] Gate --> Auth[인증/인가/TLS] Auth --> Svc[Microservice A
K8s 위에서 실행
VPC 격리] Svc <-->|REST/GraphQL/gRPC| SvcB[Microservice B] Svc -. WebSocket/SSE .-> User Svc --> Cache[(Redis
캐시·세션·락·큐)] Svc -->|쓰기| RDB[(RDB Primary
+ PgBouncer
Source of Truth)] Svc -->|읽기| Replica[(Read Replica)] RDB -. WAL/binlog .-> Replica Svc --> Search[(검색엔진
역색인·벡터)] Svc --> Object[(Object Storage
S3)] Svc -->|이벤트 발행| MQ{{Message Queue
Kafka / SQS}} MQ --> Worker[Async Workers] Worker --> RDB Worker --> Search Worker --> Ext[외부 시스템
이메일·SMS·결제] RDB -. CDC/Debezium .-> MQ MQ -. 인덱싱 .-> Search MQ -. 적재 .-> DW[(Data Warehouse
Lakehouse)] Svc --> Otel[OpenTelemetry] Otel --> Metrics[Prometheus] Otel --> Logs[ELK / Loki] Otel --> Traces[Jaeger / Tempo] Code[Git Repo] --> CI[CI: GitHub Actions] CI --> Reg[Image Registry] Reg --> CD[CD: Argo CD / GitOps] CD --> Svc IaC[Terraform / IaC] --> Cloud[Cloud 자원] FF[Feature Flag] -. 토글 .-> Svc Cloud -. 청구·태깅 .-> Cost[FinOps
가시성·단가·절감 레버] Region2[(Multi-region
DR / Backup)] -. 복제 .-> RDB

위 그림 안의 어떤 컴포넌트도 우연히 들어와 있지 않다. 각각은 한 대 서버의 어떤 한계가 무너지면서 그 자리를 메우러 들어온 답이다. 그리고 그 답들은 따로 떠 있는 게 아니라, 비슷한 결을 따라 서로 엮여 있다.

부담을 나눠 가지는 곳

요청은 사용자에서 데이터까지 한 번에 도달하지 않는다. 사용자 가까운 CDN 에서 한 번 멈추고, 로드밸런서가 어느 인스턴스로 보낼지 한 번 가르고, 캐시가 같은 답을 들고 있으면 한 번 더 가로챈 다음에야 RDB 까지 닿는다. 길목마다 하는 일이 다른 것 같지만 자세히 보면 모두 한 가지 일을 한다. RDB 한 대에 몰릴 부담을 그 위층에서 미리 나눠 가지는 일이다. 그래도 흡수되지 못한 트래픽은 결국 RDB 로 모이고, 그래서 RDB 가 모든 자원 중에 가장 늦게 자라는 자리를 차지한다. 그 안에서도 같은 결의 분담이 이어진다. Connection Pool 이 커넥션의 몰림을 정리하고, Read Replica 가 읽기 부하를 갈래 내고, 마지막에 Sharding 이 쓰기까지 여러 대로 쪼갠다. 결국 CDN, 로드밸런서, 캐시, Connection Pool, Read Replica, Sharding 이 RDB 한 대로 가는 길 곳곳에 서서, 부담이 한 곳에 모이지 않게 갈라 보내는 골조다.

시간차를 받아들이는 곳

한 호출 안에 즉시 끝나야 할 일과 굳이 즉시 끝낼 필요가 없는 일이 같이 묶여 있을 때, 응답 시간은 가장 느린 일에 맞춰진다. 외부 시스템 호출 하나가 전체 응답 시간을 잡아먹고, 그 외부 시스템이 잠깐 죽으면 우리 응답까지 같이 깨진다. 답은 메시지 큐를 그 사이에 끼우는 것이고, 그 한 번의 결정이 시스템 전체의 시간 감각을 바꾼다. 모든 일이 같은 순간에 끝날 필요는 없고, 시간차를 두고 맞춰지는 세계를 받아들이는 것이다. 한 번 발행된 이벤트는 워커, 외부 시스템, 검색엔진, 데이터 웨어하우스 같은 여러 곳으로 동시에 흘러간다.

진실의 출처와 사본들

같은 발상이 데이터 동기화에 적용된 게 CDC 다. RDB 의 변경 자체를 이벤트로 흘리면 검색엔진과 캐시와 데이터 웨어하우스와 다른 마이크로서비스가 모두 그 변경의 사본이 된다. 그래서 RDB 한 곳을 진실의 출처로 두고 캐시·검색엔진·NoSQL·객체 스토리지가 그 변경의 사본으로 나란히 자리 잡는 구성이 어색하지 않다. 데이터의 모양이 다르면 도구도 달라지지만, 진실의 출처는 한 곳이라는 원칙이다. OLTP 와 OLAP 가 분리된 것도 같은 결이다. 한 줄을 빠르게 꺼내는 일과 백만 줄을 합치는 일은 같은 데이터에 두 가지 다른 일이고, CDC 가 그 둘을 잇는 다리가 된다.

분산이 만든 위험을 막는 곳

한 코드베이스를 백 명이 함께 만지는 게 어려워지면 시스템 자체가 쪼개진다. 한 덩어리였던 코드가 마이크로서비스로 갈라지는 순간 함수 호출이 네트워크 호출로 바뀌고, 그때부터 호출은 사라지고 늦고 두 번 도달하는 것이 일상이 된다. 마이크로서비스 사이의 호출 한 번 뒤에는 Timeout, Retry, Circuit Breaker, 멱등성이 모두 깔려 있다. 그 위로 한 층이 더 얹힌다. region 자체가 죽는 사고를 대비해 시스템 전체의 사본이 다른 region 에 둥지를 트는 일이다. 한 호출이 사라지지 않게 막는 일과 한 region 이 통째로 사라져도 서비스가 살아남게 하는 일은, 단위만 다를 뿐 같은 보험이다.

운영을 사람 손에서 떼어내는 곳

시스템이 마이크로서비스 수십 개로 늘어나면 운영은 더 이상 사람이 손으로 할 수 있는 일이 아니다. 컨테이너가 환경 차이를 박스 안에 가두고, K8s 가 그 박스 수백 개를 한 줄짜리 선언으로 받아 알아서 띄우고 죽이고 다시 띄운다. Code → CI → Registry → CD 로 이어지는 흐름이 코드 한 글자가 운영까지 도달하는 길을 자동화한다. 여기에 더해 IaC 가 인프라 자체를 git 으로 옮기고, Feature Flag 가 코드의 도착과 사용자에게 보이는 시점을 분리한다. 매일 production 에 배포하는 일이 더 이상 사람의 용기에 의존하지 않게 만드는 골조다.

모든 컴포넌트 위를 가로지르는 축

OpenTelemetry, 인증과 TLS, FinOps 는 어떤 한 컴포넌트의 기능이 아니라 모든 컴포넌트 위에 가로지르는 축이다. 시스템이 한 대에서 여러 컴포넌트로 자라는 동안, 그 위에서 보는 일과 막는 일과 청구서를 받는 일도 같이 자랐다. 컴포넌트 하나가 더 추가될 때마다, 이 세 축이 다뤄야 할 면적도 그만큼 같이 늘어난다.

한 대의 서버에서 출발해 한계가 무너질 때마다 같은 결을 따라 한 줄씩 쌓아 올린 답들. 그 누적이 결국 한 시스템의 모양이 된다.

20.2 풀어낸 자리에 새 문제가 들어선다

이 컴포넌트들의 공통점은 단 하나다. 어떤 문제를 풀기 위해 등장했고, 그 대가로 새 문제를 데려왔다.

풀어낸 것 데려온 것
캐시: 같은 답을 빠르게 일관성, stampede·hot key·갱신 운영
큐: 응답을 즉시 끝냄 최종 일관성, 멱등성, 순서, DLQ
Replica: 읽기 분산 복제 지연, read-your-writes
MSA: 조직 독립성 네트워크 호출, 부속품 N개, 분산 추적
K8s: 컨테이너 관리 학습 곡선, control plane 운영, drift
CI/CD: 자동 배포 파이프라인 운영, secrets 관리, rollback 절차
CDC: 실시간 데이터 흐름 스키마 진화, 순서·중복 처리
검색엔진: 전문 검색·복잡 질의 인덱싱 지연, 이중 쓰기, 재인덱싱
관측성 카디널리티 폭발, 로그 비용
보안 정책 관리, 인증서 회전

표의 두 열을 나란히 놓고 보면 한 가지 패턴이 분명해진다. 어떤 답도 문제를 완전히 없애지 않는다. 다만 문제의 종류와 자리를 바꿀 뿐이고, 한쪽 어려움이 사라진 자리에 다른 종류의 어려움이 그만큼 들어선다.

그래서 컴포넌트를 도입할 때 던져야 하는 질문은 "이걸 쓰면 무엇이 좋아지나" 가 아니다. 첫 질문은 "정말 우리가 이걸 써야만 하는 상황인가" 다. 안 쓰면 어떤 문제가 생기는지를 구체적으로 답할 수 있어야 한다. 그 답이 나와야 두 번째 질문이 따라온다. "쓴다면, 따라올 문제들을 어떻게 받아낼 것인가."

공짜로 풀리는 문제는 없다. 도입이란 짐을 없애는 일이 아니라, 감내할 짐을 고르고 받아낼 준비를 마치는 일이다.

20.3 "맞는 아키텍처"는 없다, "맞는 시점"이 있다

모두가 궁금해하는 질문이 있다. "어떤 게 맞는 아키텍처인가?"

답은 정직하게 하나뿐이다. "지금 이 트래픽, 이 데이터, 이 팀, 이 비즈니스에 필요한 만큼." 매년 답이 바뀐다.

너무 일찍 들인 컴포넌트는 운영 비용으로 청구된다. 일찍 깐 캐시가 디버깅을 어렵게 하고, 일찍 쪼갠 MSA가 작은 팀을 마비시키고, 일찍 들인 Kafka가 운영자 한 명을 잡아먹고, 일찍 깐 Multi-region이 비용을 두 배로 만든다.

너무 늦게 들인 컴포넌트는 장애와 야근으로 청구된다. 캐시 없이 트래픽 두 배를 받다 DB가 뻗고, MSA 없이 백 명이 한 코드베이스를 만지다 메인이 매일 깨지고, 백업 없이 사고가 나 어제 데이터가 통째로 사라진다.

좋은 아키텍처는 두 위험 사이에서 "지금 이 순간 가장 큰 위험" 을 고르는 일이다. 화려한 모양이 좋은 아키텍처가 아니라, 지금 이 트래픽과 이 조직에 정직한 모양이 좋은 아키텍처다. 그 정직함에는 들이지 않는 결정도 포함된다.

모든 거대한 인프라는 한 대의 서버에서 시작했다. 단일 서버는 끝까지 죄가 없다. 그 위에 무엇을 더 얹을지, 그리고 끝내 얹지 않을지를 매번 정직하게 고르는 일. 아키텍처라는 단어가 가리키는 것은 그 결정 하나하나의 누적이다.