대규모 시스템 설계 1편 - 사용자 규모에 따른 확장성
『가상 면접 사례로 배우는 대규모 시스템 설계 기초』를 읽기 시작했다. 1장은 사용자가 한 명뿐인 서버 한 대에서 출발해, 사용자가 늘어날 때마다 구성 요소를 하나씩 더해 가며 백만 명 규모까지 끌고 간다. 로드밸런서, 캐시, CDN, 복제, 샤딩 같은 말들이 순서대로 등장하는데, 이 순서가 이 장의 전부라고 해도 된다.
읽으면서 계속 걸린 게 하나 있었다. 각 단계가 "이런 것도 있다"는 소개처럼 읽힌다는 점이다. 로드밸런서를 놓으면 트래픽이 분산된다, 캐시를 두면 빨라진다, 샤딩을 하면 데이터를 나눌 수 있다. 다 맞는 말이지만 이렇게만 기억하면 순서가 남지 않는다. 실제로 중요한 건 그 반대 방향이다. 어떤 병목이 먼저 터졌기에 이 장치가 필요해졌는가, 그리고 그 장치를 넣은 대가로 어떤 새 문제가 생겼는가.
이 글은 1장의 단계들을 그 관점으로 다시 배열한 정리다. 각 단계마다 구조가 어떻게 바뀌는지를 직접 그린 그림으로 확인하고, 그 그림에서 실제로 읽어야 하는 것이 무엇인지를 이어서 적었다. 특정 언어나 프레임워크 코드는 거의 넣지 않았다. 이 장에서 다루는 건 어떤 API를 부르느냐가 아니라 상자와 화살표를 어떻게 배치하느냐의 문제이기 때문이다.
한 대로 시작한다
처음에는 서버 한 대면 충분하다. 웹 서버도, 애플리케이션도, 데이터베이스도 전부 그 한 대 안에 있다. 사용자가 하루에 수십 명이라면 이 구성이 가장 합리적이다. 배포도 쉽고, 장애 조사도 서버 한 대만 들여다보면 된다.
이때 요청이 흐르는 경로를 한 번 짚고 가는 게 좋다. 사용자가 브라우저에 도메인을 입력하면 먼저 DNS에 질의가 나간다. DNS는 도메인 이름을 IP 주소로 바꿔 주는 조회 시스템이고, 대부분 직접 운영하지 않고 관리형 서비스에 맡긴다. 조회 결과로 IP 주소를 받은 브라우저가 그 주소로 HTTP 요청을 보내고, 서버는 HTML이나 JSON을 돌려준다.

이 그림에서 눈여겨볼 지점은 두 가지다. 하나는 DNS 조회가 점선으로 그려져 있다는 것이다. 이 왕복은 우리가 만든 서버로 가지 않는다. 즉 우리 시스템의 첫 단계는 우리가 통제하지 않는 외부 조회에서 시작한다는 뜻이고, 이 사실이 뒤에서 데이터 센터를 여러 개 두고 사용자를 가까운 곳으로 유도할 때 지렛대가 된다. DNS가 어떤 IP를 돌려주느냐로 트래픽의 방향을 바꿀 수 있기 때문이다.
다른 하나는 오른쪽 큰 상자 안에 웹 서버, 애플리케이션, DB가 나란히 들어 있다는 점이다. 지금은 아무 문제가 없어 보이지만, 이 글에서 앞으로 하는 일은 결국 이 상자 안의 것들을 하나씩 밖으로 꺼내는 작업이다. 그리고 클라이언트가 브라우저와 모바일 앱 두 종류라서 같은 서버가 HTML과 JSON을 모두 만들고 있다는 점도 남겨 두자. 나중에 정적 콘텐츠를 CDN으로 밀고 동적 응답만 서버가 만들게 나누는 근거가 여기서 나온다.
가장 먼저 갈라놓는 것 — 웹 계층과 데이터 계층
사용자가 늘면 가장 먼저 하는 일은 서버를 늘리는 게 아니라 데이터베이스를 별도 서버로 떼어내는 것이다. 왜 하필 DB가 먼저일까. 가장 큰 이유는 자원을 쓰는 성격이 다르다는 것이다. 웹 계층은 요청 하나마다 CPU와 메모리를 짧게 쓰고 끝난다. 반면 데이터베이스는 버퍼 풀에 메모리를 길게 붙잡고 있고, 디스크 I/O를 지속적으로 낸다. 두 프로세스를 한 장비에 두면 서로의 캐시를 밀어내며 간섭한다. 트래픽이 늘어 웹 프로세스가 메모리를 더 쓰기 시작하면 DB의 버퍼 풀이 줄어들고, 그 순간 디스크 읽기가 늘면서 응답이 급격히 나빠진다.

이 그림의 왼쪽과 오른쪽을 견주면 표면적으로는 상자 하나가 둘이 된 것뿐이다. 그런데 오른쪽 두 상자 밑에 붙은 문구가 이 그림의 진짜 내용이다. 웹 서버는 "대수를 늘려 확장"하고 DB는 "복제와 샤딩으로 확장"한다. 확장하는 방법 자체가 다르다.
이게 왜 중요하냐면, 한 장비에 묶여 있는 동안에는 둘 중 어느 쪽도 자기 방식으로 확장할 수 없기 때문이다. 웹 처리량이 모자라서 장비를 늘리면 DB도 같이 늘어나는데 DB는 그냥 늘린다고 나뉘지 않는다. 반대로 DB 때문에 메모리 큰 장비로 옮기면 웹 계층에는 필요 없는 비용을 같이 낸다. 서로 다른 축으로 커져야 하는 두 덩어리를 하나로 묶어 두면 결국 더 까다로운 쪽에 맞춰 전체를 끌고 가게 된다.
여기에 두 가지가 더 붙는다. 웹 애플리케이션에서 메모리 부족으로 프로세스가 죽을 때 같은 장비의 DB까지 흔들리는 상황을 막는다는 것, 그리고 하루에도 몇 번씩 재시작하는 애플리케이션과 그렇지 않은 데이터베이스의 배포 주기를 분리한다는 것이다.
그림에서 상자를 나누는 선 하나가 실제로는 자원, 확장 축, 장애 범위, 배포 주기를 한꺼번에 나눈다.
이 시점에서 어떤 데이터베이스를 쓸지도 갈린다. 조인이 필요하고 데이터 간 관계가 분명하며 트랜잭션 보장이 중요하다면 관계형 데이터베이스가 자연스럽다. 반대로 데이터 구조가 유연해야 하거나, 아주 큰 데이터를 넣어야 하거나, 저장과 조회가 키 하나로 끝나는 형태라면 비관계형 저장소가 어울린다. 다만 "비관계형이 더 빠르다" 같은 일반화는 성립하지 않는다. 접근 패턴이 맞으면 빠른 것이지, 저장소 종류 자체가 성능을 결정하지 않는다.
서버를 키울 것인가, 늘릴 것인가
트래픽이 더 늘면 두 가지 선택이 있다. 서버 사양을 올리는 수직 확장(scale up)과, 서버 대수를 늘리는 수평 확장(scale out)이다.
수직 확장은 매력적이다. 애플리케이션을 한 줄도 고치지 않아도 되고, 구조가 그대로 유지되니 새로 배울 것도 없다. 트래픽이 크지 않고 서비스 구조가 단순하다면 수직 확장이 가장 합리적인 선택이다. 문제는 이 방법이 어느 지점에서 반드시 멈춘다는 것이다.

이 그림을 성능 비교로 읽으면 절반만 읽은 것이다. 왼쪽에서 상자가 커졌다는 사실보다 중요한 건, 커진 뒤에도 상자가 여전히 하나라는 점이다. 사양을 아무리 올려도 그 한 대가 죽으면 서비스 전체가 멈춘다는 성질은 바뀌지 않는다. 한 장비에 붙일 수 있는 CPU와 메모리에 물리적 상한이 있다는 것도 그렇지만, 실제로 수직 확장을 포기하게 만드는 건 대개 성능이 아니라 가용성이다.
오른쪽 그림에서 서버 3이 빨간색으로 그려져 있고 그쪽으로 가는 화살표가 점선인 것이 이 대비의 핵심이다. 서버 한 대가 빠져도 로드밸런서가 나머지 두 대로 트래픽을 보내면 서비스는 계속된다. 사양을 올려서는 절대 얻을 수 없는 성질이다.
| 축 | 수직 확장 (scale up) | 수평 확장 (scale out) |
|---|---|---|
| 방법 | 장비 사양을 올린다 | 장비 대수를 늘린다 |
| 구조 변경 | 거의 없다 | 상태를 밖으로 밀어내야 한다 |
| 상한 | 장비의 물리적 한계 | 실질적 상한이 훨씬 멀다 |
| 장애 조치 | 없다 (단일 장애점) | 남은 서버로 넘긴다 |
| 필요한 부품 | 없다 | 로드밸런서, 공유 저장소 |
표의 마지막 두 줄이 짝을 이룬다. 수평 확장이 주는 장애 조치 능력은 공짜가 아니라 로드밸런서와 공유 저장소라는 새 부품을 들이는 대가로 얻는 것이다. 그리고 그 부품들이 각자 새 문제를 데려온다.
로드밸런서를 놓는 순간 드러나는 것
로드밸런서는 클라이언트와 웹 서버 사이에 서서 요청을 여러 서버로 나눠 준다. 이때 중요한 구조 변화가 하나 있다. 공개 IP를 갖는 건 로드밸런서뿐이고, 뒤의 웹 서버들은 사설 IP만 갖는다. 클라이언트는 특정 서버를 직접 지목할 수 없고, 서버끼리는 내부망으로만 통신한다. 트래픽 분산이 목적이었는데 보안 경계가 하나 생기는 부수 효과가 따라온다.
분배 방식은 여러 가지가 있다. nginx를 기준으로 보면 기본값은 요청을 순서대로 돌리는 라운드 로빈이고, 연결 수가 가장 적은 서버로 보내는 방식과 클라이언트 IP를 해시해 목적지를 고정하는 방식이 있다.
upstream backend {
# least_conn; # 연결 수가 가장 적은 서버로
# ip_hash; # 같은 클라이언트를 같은 서버로 고정
server 10.0.1.11:8080;
server 10.0.1.12:8080;
server 10.0.1.13:8080;
}
nginx 문서에 따르면 ip_hash는 IPv4의 경우 주소의 앞 세 옥텟을, IPv6는 주소 전체를 해시 키로 쓴다. 그 서버가 사용할 수 없는 상태가 되면 다른 서버로 넘어간다.
이 한 줄짜리 설정이 왜 존재하는지가 흥미롭다. 이건 수평 확장이 만들어 낸 첫 번째 문제의 흔적이다. 서버가 한 대일 때는 로그인 세션을 서버 메모리에 담아 두어도 아무 문제가 없었다. 그런데 서버가 두 대가 되면, 1번 서버에서 로그인한 사용자의 다음 요청이 2번 서버로 갈 수 있다. 2번 서버에는 그 세션이 없으니 사용자는 갑자기 로그아웃된다. 이걸 급하게 막는 방법이 같은 클라이언트를 항상 같은 서버로 고정하는 것, 즉 세션 고정(sticky session)이다.
이 방법은 동작하지만 대가가 크다. 서버를 추가하거나 제거하면 해시가 대상으로 삼는 서버 집합이 바뀌면서 상당수 클라이언트의 목적지가 재배치되고, 그 순간 재배치된 사용자들의 세션이 사라진다. 배포 때마다 인스턴스가 교체되는 롤링 배포나, 트래픽에 따라 대수가 오르내리는 오토스케일링과는 특히 상성이 나쁘다. 분산이 고르지 않을 수도 있다. 큰 조직이나 통신사 NAT 뒤에 있는 수많은 사용자가 같은 IP 대역으로 묶여 한 서버에만 몰릴 수 있기 때문이다. 세션 고정은 근본 원인을 그대로 둔 채 증상만 가린다. 근본 원인은 웹 서버가 상태를 들고 있다는 것이다.
무상태 웹 계층 — 상태를 밖으로 밀어내기
무상태(stateless) 웹 계층이란 어떤 요청이 어느 서버로 가든 똑같이 처리될 수 있는 상태를 말한다. 다르게 표현하면, 요청과 요청 사이에 유지되어야 하는 데이터를 서버의 로컬 메모리나 로컬 디스크에 두지 않는다는 뜻이다. 12-factor app도 같은 원칙을 프로세스 항목에서 이야기한다. 프로세스는 아무것도 공유하지 않는 무상태여야 하고, 지속되어야 하는 데이터는 상태를 가진 백엔드 서비스에 저장해야 한다는 것이다.

두 그림의 차이는 상자 하나가 늘어난 것뿐인데, 화살표의 성격이 완전히 달라진다. 왼쪽에서는 로드밸런서에서 나가는 두 화살표 중 하나가 빨간색이다. 어느 서버로 가느냐에 따라 결과가 달라진다는 뜻이고, 그래서 그 화살표의 방향을 우리가 통제해야만 한다. 세션 고정이 하는 일이 정확히 이것이다. 오른쪽에서는 두 화살표가 모두 초록색이다. 어느 쪽으로 가든 상관없어졌기 때문에 로드밸런서는 이제 아무 서버나 고르면 된다.
여기서 얻는 게 단순히 "세션이 안 날아간다"가 아니다. 웹 서버가 서로 구별되지 않는 존재가 되면서 언제든 지우고 새로 띄울 수 있는 대상이 된다. 오토스케일링도, 무중단 배포도, 장애 인스턴스 교체도 전부 이 성질 위에서 성립한다. 무상태화는 세션 문제를 푸는 기법이 아니라 그 뒤의 모든 자동화를 가능하게 하는 전제 조건이다.
한 가지 더 읽을 것은 오른쪽 아래에 새로 생긴 파란 상자다. 문제를 없앤 게 아니라 옮겼다. 상태는 사라지지 않았고 공유 저장소로 자리를 옮겼을 뿐이다. 그 저장소는 이제 모든 웹 서버가 의존하는 새로운 단일 지점이 되고, 이 저장소가 죽으면 웹 서버가 몇 대든 로그인이 되지 않는다. 확장의 각 단계가 문제를 없애기보다 더 다루기 쉬운 자리로 옮긴다는 이 글의 주제가 여기서 처음 분명해진다.
상태는 세션만이 아니라는 점도 짚어야 한다. 서버가 한 대일 때 아무렇지 않다가 여러 대가 되면 깨지는 것들이 몇 가지 더 있다.
업로드된 파일을 로컬 디스크에 저장하면 다른 인스턴스에서 그 파일을 찾을 수 없으므로 오브젝트 스토리지 같은 공유 저장소로 보내야 한다. 주기적으로 도는 배치 작업은 모든 인스턴스에서 동시에 실행되므로, 한 번만 돌아야 하는 작업이라면 분산 락으로 단일 실행을 보장하거나 배치 전용 인스턴스를 따로 두어야 한다. 인메모리 카운터나 락도 마찬가지로 인스턴스마다 따로 세어지고 따로 잠긴다.
세션을 외부 저장소로 빼는 대신 서명된 토큰으로 클라이언트에 위임하는 선택지도 있다. 서버는 저장소 없이도 무상태가 되지만, 한 번 발급한 토큰을 만료 전에 즉시 무효화하기가 어렵다는 대가가 붙는다. 그래서 만료를 짧게 가져가고 갱신 토큰을 함께 쓰거나, 무효화된 토큰 목록을 별도 저장소에 두는 절충을 하게 되는데, 그러면 결국 저장소가 다시 등장한다. 어느 쪽이 낫다기보다는 즉시 무효화가 얼마나 중요한 서비스인지에 따라 갈린다.
읽기와 쓰기를 나눈다 — 데이터베이스 다중화
웹 계층이 무상태가 되면 서버를 늘리는 데 제약이 사라진다. 그러면 부하는 자연스럽게 그다음 지점으로 이동한다. 데이터베이스다. 가장 먼저 쓰는 방법은 복제다. MySQL 문서 기준으로 설명하면, 원본 서버(source)의 변경을 하나 이상의 사본 서버(replica)로 복사하고, 쓰기는 원본으로 읽기는 사본으로 보낸다.

그림에서 화살표 색이 갈린 것을 먼저 보면 좋다. 웹 서버에서 나가는 화살표 중 주황색 하나만 위쪽 원본으로 가고, 초록색 두 개는 아래쪽 사본들로 간다. 여기서 읽어야 할 것은 비대칭이다. 읽기는 사본 대수만큼 나뉘지만 쓰기는 여전히 한 곳으로 모인다. 사본을 열 대로 늘려도 쓰기 처리량은 그대로다.
이 비대칭이 이 글 후반부의 순서를 결정한다. 대부분의 서비스는 읽기가 쓰기보다 훨씬 많기 때문에 복제만으로도 꽤 오래 버틸 수 있다. 하지만 언젠가 쓰기가 한계에 닿는 날이 오고, 그때는 복제로 풀 수 없다. 샤딩이 이 글의 마지막에 나오는 이유가 이 그림에 이미 들어 있다.
원본에서 사본으로 가는 점선 화살표도 그냥 넘기면 안 된다. 이 화살표는 사용자 요청과 무관하게, 요청이 끝난 뒤에 따로 흐른다. 사본이 잠시 뒤처져도 원본은 기다리지 않는다는 뜻이고, 이 성질이 다음 절의 문제를 만든다. 그리고 이 화살표는 장애 대응의 통로이기도 하다. 원본이 죽으면 사본 하나를 새 원본으로 승격시켜 서비스를 이어 갈 수 있다.
복제가 데려온 문제 — 방금 쓴 글이 보이지 않는다
MySQL 복제는 기본적으로 비동기다. 원본이 커밋을 마치고 클라이언트에 성공을 응답하는 시점과, 그 변경이 사본에 반영되는 시점 사이에 시간 차가 있다. 이 시간 차를 복제 지연(replication lag)이라고 부른다.
지연 자체는 대개 짧다. 문제는 그 짧은 순간에 하필 같은 사용자가 자기가 쓴 것을 다시 읽으려 할 때다.
t0 사용자가 글쓰기 요청 → 원본에 저장, "성공" 응답
t0+ε 화면이 목록으로 이동 → 사본에서 조회
t1 복제가 사본에 도착 (t0 < t0+ε < t1)
결과: t0+ε 시점의 목록에는 방금 쓴 글이 없다

그림의 번호 순서를 따라가 보면 이상한 점이 눈에 띈다. 2번(복제)과 3번(조회)이 서로 경쟁하고 있다는 것이다. 우리는 1번과 3번만 코드로 통제하고, 2번이 언제 끝나는지는 통제하지 않는다. 앞 그림에서 점선으로 그려졌던 복제 화살표가 여기서는 문제의 원인이 된다. 같은 구조를 다른 각도에서 본 것뿐인데 장점이 결함으로 바뀐다.
읽어야 할 인사이트는 이 문제가 성능 문제가 아니라 정합성 문제라는 점이다. 사본을 더 좋은 장비로 바꾸거나 대수를 늘려도 사라지지 않는다. 자기가 쓴 것을 자기가 못 읽는 이 현상을 read-after-write 문제라고 부르는데, 사용자 입장에서는 글쓰기가 실패한 것처럼 보이므로 다시 쓰게 되고, 그렇게 중복 게시물이 생긴다.
대응은 세 방향이다. 가장 단순한 건 재조회 자체를 없애는 것이다. 쓰기를 처리하면서 이미 알고 있는 값을 그대로 응답에 실으면 사본을 거칠 일이 없다. 목록 전체가 필요한 화면이라면 이 방법만으로는 부족하니, 쓰기 직후 짧은 시간 동안 그 사용자의 읽기만 원본으로 보내는 방식을 쓴다. 다만 이 방식은 원본으로 가는 읽기를 늘리므로 범위를 좁게 잡아야 애초에 복제를 도입한 이유가 무너지지 않는다. 마지막으로 복제 방식을 더 강하게 가져가 사본이 받았음을 확인한 뒤에 커밋을 마치는 선택지도 있는데, 이쪽은 쓰기 응답이 느려지는 대가를 낸다.
장애 시나리오도 이 구조와 함께 온다. 사본 한 대가 죽으면 읽기를 남은 사본이나 원본으로 돌리면 된다. 사본이 전부 죽으면 읽기가 전부 원본으로 몰리는데, 이때 원본이 버티지 못하고 함께 무너지는 연쇄 장애가 자주 일어난다. 원본이 죽는 경우는 더 까다롭다. 사본 하나를 새 원본으로 승격시켜야 하는데, 승격 시점까지 사본에 전달되지 않은 변경이 있으면 그만큼은 유실된다. 비동기 복제를 쓰는 이상 이 가능성은 구조적으로 남는다. 어느 쪽을 고르든, 무엇을 포기하기로 했는지는 알고 있어야 한다.
캐시와 CDN — 요청을 아예 뒤로 보내지 않기
복제로도 부족해지면 방향을 바꾼다. 데이터베이스를 더 늘리는 대신, 데이터베이스까지 가는 요청 자체를 줄이는 것이다. 캐시와 CDN이 하는 일이 이것이다.

위아래 두 줄을 따로 보지 말고 같은 발상의 두 사례로 보는 게 이 그림의 요점이다. 위쪽에서는 클라이언트와 원본 서버 사이에 CDN이 끼어들어 요청을 중간에서 끊고, 아래쪽에서는 웹 서버와 DB 사이에 캐시가 끼어들어 요청을 중간에서 끊는다. 위치만 다를 뿐 둘 다 "뒤로 가는 요청을 앞에서 잘라 낸다"는 같은 일을 한다. 앞에서 잘라 낼수록 아낀 자원이 크고, 대신 원본과 어긋날 위험도 커진다.
두 그림 모두에서 뒤쪽으로 가는 화살표가 점선이라는 점도 의미가 있다. 이 경로는 평소에 거의 쓰이지 않는다. 적중률이 높다는 건 점선 경로가 드물게만 열린다는 뜻이고, 바로 그래서 위험하다. 캐시가 죽으면 평소에 거의 안 쓰이던 점선이 갑자기 실선이 되면서, DB는 한 번도 받아 본 적 없는 양의 요청을 한꺼번에 받는다. 캐시는 성능을 위해 넣었는데 결과적으로 가용성의 전제 조건이 되어 버리는 이 역전이 캐시를 다룰 때 가장 조심할 지점이다. 캐시 서버가 한 대라면 그것도 단일 장애점이라는 뜻이기도 하다.
만료 시간, 정합성, 캐시가 비었을 때의 부하 같은 문제는 이 블로그의 Redis 캐시 편에서 따로 다뤘으므로 여기서는 넘어간다. 대신 CDN에서만 걸리는 것을 하나 짚으면, 무엇을 올리느냐다. 사용자마다 다른 응답을 여러 사용자가 공유하는 캐시에 넣으면 다른 사람의 데이터가 노출된다. HTTP 캐싱을 정의한 RFC 9111은 캐시를 여러 사용자를 위해 응답을 저장하는 공유 캐시(shared cache)와 한 사용자 전용인 개인 캐시(private cache)로 구분한다. CDN은 전자에 해당하므로, 개인화된 응답에는 Cache-Control: private를 붙여 공유 캐시에 저장되지 않게 해야 한다.
캐시 무효화도 미리 정해 둘 문제다. 배포할 때마다 퍼지를 호출하는 방식과, 파일명이나 쿼리에 버전을 붙여 아예 다른 URL로 만드는 방식이 있는데 후자가 운영하기 편한 편이다.
공간을 나눈다 — 데이터 센터 다중화
여기까지 오면 한 데이터 센터 안에서 할 수 있는 일은 대체로 다 한 셈이다. 다음 단계는 공간을 나누는 것이다. 그리고 여기서 첫 그림에서 눈여겨봤던 DNS가 다시 등장한다.

이 그림에서 가장 중요한 상자는 데이터 센터가 아니라 위쪽의 geoDNS다. 우리는 사용자의 브라우저를 통제할 수 없다. 그런데 도메인을 IP로 바꾸는 단계는 통제할 수 있다. 첫 그림에서 점선으로 그려졌던 그 조회 한 번이, 여기서는 트래픽 전체의 방향을 결정하는 스위치가 된다. 사용자에게 아무것도 시키지 않고 경로를 바꿀 수 있는 지점이 시스템에 하나쯤 있다는 것, 그게 이 구조의 핵심이다.
두 번째로 읽을 것은 데이터 센터 1과 2 사이의 양방향 점선이다. 이 화살표 하나가 실제 작업량의 대부분을 차지한다. 지역 B 사용자를 데이터 센터 1로 우회시키는 것 자체는 DNS 레코드만 바꾸면 되지만, 그 사용자의 데이터가 데이터 센터 1에도 있어야 우회가 의미를 갖는다. 앞에서 본 복제 지연이 여기서는 대륙 간 거리만큼 늘어난 형태로 다시 나타난다.
그래서 데이터 센터 다중화에서 어려운 것은 우회 자체가 아니다. 센터 간 데이터 동기화, 배포와 설정의 일관성, 그리고 이 모든 것이 실제 장애 상황에서 동작하는지의 검증이다. 특히 마지막이 어렵다. 평소에 한 번도 넘겨 본 적 없는 페일오버 경로는 정작 필요한 순간에 동작하지 않는 경우가 많다. 그림에서 초록색으로 그려 둔 우회 화살표는 평소에 존재하지 않는 선이고, 존재하지 않는 선은 검증되지 않는다.
시간을 나눈다 — 메시지 큐와 최종 구조
공간 다음은 시간이다. 요청 처리 경로에서 지금 당장 끝내지 않아도 되는 일을 떼어 큐에 넣고, 별도의 소비자가 나중에 처리한다. 이미지 변환이나 알림 발송처럼 무겁고 급하지 않은 작업이 대표적이다.
이렇게 하면 사용자 응답 시간이 무거운 작업의 소요 시간과 분리되고, 생산자와 소비자를 각각 필요한 만큼 독립적으로 확장할 수 있게 된다. 소비자가 잠시 죽어 있어도 생산자는 계속 큐에 넣을 수 있으니 장애가 앞으로 전파되지도 않는다.

이 그림은 앞의 여덟 장을 겹쳐 놓은 것이다. 그런데 겹쳐 놓고 나서야 보이는 게 있다. 클라이언트에서 출발한 요청이 오른쪽으로 갈수록 수가 줄어든다는 것이다. 정적 요청은 CDN에서 끊기고, 동적 요청 중 상당수는 캐시에서 끊기고, 살아남은 것만 DB까지 간다. 그리고 무겁고 급하지 않은 일은 아래쪽 큐로 빠져 응답 경로에서 사라진다.
이 구조를 한 문장으로 요약하면 "뒤로 갈수록 비싼 자원이므로 앞에서 최대한 걷어 낸다"이다. CDN은 가장 싸고 가장 앞에 있으며, 원본 데이터베이스는 가장 비싸고 가장 뒤에 있다. 확장 설계란 결국 이 깔때기의 각 단계에서 얼마나 걷어 낼 수 있는지를 정하는 일에 가깝다.
동시에 이 그림은 운영 부담의 목록이기도 하다. 상자가 하나 늘 때마다 감시해야 할 대상, 배포해야 할 대상, 죽었을 때 대응해야 할 대상이 하나씩 늘었다. 처음의 서버 한 대짜리 그림과 이 그림 사이의 거리가 곧 이 시스템이 감당하기로 한 복잡도다.
마지막에 꺼내는 수단 — 샤딩
읽기는 사본과 캐시로 나눴고, 무거운 작업은 큐로 뺐다. 그래도 남는 게 있다. 그림 5에서 이미 예고된 쓰기다. 쓰기는 여전히 원본 한 대로 모이고, 데이터의 총량도 계속 늘어난다. 한 장비의 디스크와 메모리에 담기지 않는 크기가 되면 더 이상 미룰 수 없다.
샤딩은 큰 데이터베이스를 샤드라는 작은 조각으로 나누는 것이다. 각 샤드는 같은 스키마를 공유하지만 서로 다른 행을 담는다. 사용자 100만 명의 데이터를 네 샤드에 25만 명씩 나눠 담는 식이다. 전부는 샤딩 키를 무엇으로 잡느냐에 달려 있고, 가장 흔한 출발점은 나머지 연산이다.
shard 번호 = userId % 샤드 수
userId 101 → 101 % 4 = 1 → shard-1
userId 102 → 102 % 4 = 2 → shard-2
userId 103 → 103 % 4 = 3 → shard-3
userId 104 → 104 % 4 = 0 → shard-0
분산은 고르게 되고 계산도 빠르다. 샤드 수가 앞으로도 바뀌지 않는다면 이걸로 충분하다.

위아래 두 줄의 상자 안에 적힌 번호를 하나씩 대조해 보면 이 그림이 하려는 말이 나온다. 샤드를 넷에서 다섯으로 늘렸을 뿐인데 101, 102, 103을 빼면 거의 모든 번호가 다른 상자로 옮겨 갔다. 새로 추가한 다섯 번째 샤드로 갈 데이터만 옮기면 되는 게 아니라, 데이터 대부분이 자리를 옮겨야 한다는 뜻이다.
여기서 얻는 인사이트는 나머지 연산이 나쁘다는 게 아니라, 샤딩의 진짜 비용이 나누는 순간이 아니라 다시 나누는 순간에 청구된다는 것이다. 처음 네 샤드로 나누는 작업은 서비스를 잠깐 멈추고 할 수도 있다. 하지만 트래픽이 계속 들어오는 상태에서 데이터 대부분을 옮기는 일은 차원이 다르고, 이동이 끝나기 전까지는 어떤 요청이 어느 샤드를 봐야 하는지도 애매해진다.
이 재배치 문제를 줄이기 위해 나온 것이 오른쪽 상자에 적힌 안정 해시(consistent hashing)다. 1997년 Karger 등이 발표한 논문에서 제시된 개념으로, 해시 함수의 치역, 즉 노드 집합이 바뀔 때 재배치되는 키가 최소가 되도록 설계된 해시 방식이다. 키와 노드를 모두 하나의 원형 공간에 배치하고 키를 시계 방향으로 가장 가까운 노드에 할당하는 구조라서, 노드를 하나 추가하면 그 노드가 새로 맡게 되는 구간의 키만 이동하고 나머지는 그대로 남는다. 이 개념은 샤딩만이 아니라 분산 캐시나 키-값 저장소의 노드 배치에도 널리 쓰인다.
샤딩을 하고 나면 그동안 당연했던 것들이 하나씩 사라진다. 서로 다른 샤드에 있는 테이블은 조인할 수 없다. 그래서 조인으로 풀던 조회를 애플리케이션에서 여러 번 조회해 합치거나, 아예 비정규화해서 한 샤드 안에서 끝나게 데이터 구조를 바꾸게 된다. 샤드를 넘는 트랜잭션도 그대로는 되지 않는다.
그리고 분포가 치우치는 문제가 있다. 특정 키에 접근이 극단적으로 몰리면 그 키가 속한 샤드만 과부하가 되는데, 샤드를 아무리 늘려도 그 키는 여전히 한 샤드에 있으므로 해결되지 않는다. 이런 비용 때문에 샤딩은 앞의 방법들이 모두 한계에 닿았을 때 마지막에 꺼내는 수단이다. 순서를 지키는 데는 이유가 있다. 캐시나 복제는 나중에 되돌리기가 비교적 쉽지만, 한번 나눈 데이터를 다시 합치는 일은 그렇지 않다.
보이지 않으면 고칠 수 없다

앞선 그림을 다시 떠올려 보자. 로드밸런서, 웹 서버 여러 대, 캐시, CDN, 원본과 사본 데이터베이스, 메시지 큐, 소비자 프로세스, 그리고 여러 샤드다. 서버 한 대였을 때는 그 한 대에 접속해 로그를 보면 끝났다. 이제는 어디를 봐야 할지부터 문제가 된다.
그래서 이 지점에서 로그, 메트릭, 자동화가 선택이 아니라 전제가 된다. 로그는 서버마다 파일로 흩어져 있으면 사실상 쓸 수 없으므로 한곳에 모아 검색할 수 있어야 한다.
메트릭은 층위별로 필요하다. 장비 수준의 CPU·메모리·디스크 I/O, 구성 요소 수준의 캐시 적중률이나 복제 지연이나 큐 적체량, 그리고 비즈니스 수준의 지표까지다. 특히 복제 지연과 큐 적체는 이 글에서 본 문제들이 실제로 발생하고 있는지를 알려 주는 신호이므로 반드시 보고 있어야 한다. 자동화는 사람이 배포와 복구를 손으로 하지 않게 만드는 일이다. 구성 요소가 늘어날수록 수동 작업의 실수 확률은 그만큼 곱해진다.
마무리
1장을 다 읽고 남는 건 구성 요소 목록이 아니라 순서다. 각 단계를 "무엇을 추가했는가"가 아니라 "무엇이 터졌기에 추가했고, 추가한 결과 무엇이 새로 터졌는가"로 정리하면 이렇게 된다.
| 추가한 것 | 해결한 병목 | 새로 생긴 문제 |
|---|---|---|
| DB 분리 | 웹과 DB의 자원 간섭 | 네트워크 홉이 하나 늘어남 |
| 수평 확장 + 로드밸런서 | 처리량과 단일 장애점 | 세션이 서버에 묶임 |
| 무상태 웹 계층 | 세션 소실, 세션 고정의 부작용 | 공유 저장소 의존 |
| DB 복제 | 읽기 부하 | 복제 지연, 승격 시 유실 가능성 |
| 캐시 / CDN | 반복 조회와 정적 트래픽 | 정합성, 캐시 장애 시 연쇄 장애 |
| 데이터 센터 다중화 | 지역 지연과 센터 단위 장애 | 센터 간 동기화, 검증 어려움 |
| 메시지 큐 | 무거운 작업이 응답을 붙잡음 | 처리 순서와 실패 처리 |
| 샤딩 | 쓰기와 저장 용량 한계 | 조인·트랜잭션 제약, 재샤딩, 쏠림 |
오른쪽 열이 이 글의 핵심이다. 확장은 문제를 없애는 과정이 아니라 더 다루기 쉬운 문제로 바꾸는 과정에 가깝다. 세션 소실은 저장소 의존보다 다루기 쉽고, 복제 지연은 원본 한 대가 감당 못 하는 상황보다 다루기 쉽다. 그래서 각 단계에서 던져야 하는 질문은 "이걸 넣으면 빨라지는가"가 아니라 "이걸 넣으면 무엇을 새로 감당해야 하는가"다.
그림을 열 장 그리면서 알게 된 것이 하나 더 있다. 이 장에 나오는 그림들은 대부분 앞 그림에 상자 하나를 더한 것에 지나지 않는데, 그 상자 하나가 매번 화살표의 의미를 바꿔 놓는다. 같은 복제 화살표가 그림 5에서는 확장의 수단이고 그림 6에서는 결함의 원인이다. 구조도를 볼 때 상자보다 화살표를, 화살표보다 그 화살표가 실패했을 때의 그림을 먼저 떠올리는 편이 낫다는 게 이 장에서 얻은 습관이다.
동시에 이 순서는 앞에서부터 밟아야 의미가 있다. 사용자가 수백 명인 서비스에 샤딩을 넣는 것은 얻는 것 없이 비용만 치르는 일이다. 지금 무엇이 병목인지 측정하지 않은 채 다음 단계로 가는 것도 마찬가지다. 1장이 사용자 한 명에서 시작하는 이유가 여기에 있다고 생각한다.
다음 편에서는 2장의 개략적 규모 추정을 정리한다. 지금 이 구조 중에서 어디까지가 필요한지를 정하려면 결국 숫자가 필요하기 때문이다.
참고자료
- 알렉스 쉬, 『가상 면접 사례로 배우는 대규모 시스템 설계 기초』 1장 (이병준 옮김, 인사이트): https://ebook.insightbook.co.kr/book/100
- MySQL 8.4 Reference Manual — Replication: https://dev.mysql.com/doc/refman/8.4/en/replication.html
- MySQL 8.4 Reference Manual — Replication Solutions: https://dev.mysql.com/doc/refman/8.4/en/replication-solutions.html
- nginx — Module ngx_http_upstream_module: https://nginx.org/en/docs/http/ngx_http_upstream_module.html
- nginx — Using nginx as HTTP load balancer: https://nginx.org/en/docs/http/load_balancing.html
- The Twelve-Factor App — VI. Processes: https://12factor.net/processes
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
- RFC 1035 — Domain Names: Implementation and Specification: https://www.rfc-editor.org/rfc/rfc1035
- Karger et al., "Consistent Hashing and Random Trees" (STOC 1997): https://dl.acm.org/doi/10.1145/258533.258660
'시스템 설계 > 가상면접 사례로 배우는 대규모 시스템 설계' 카테고리의 다른 글
| 노드 한 대를 늘렸을 뿐인데 94%가 움직였다 — 안정 해시 설계 (가상 면접 사례로 배우는 대규모 시스템 설계 기초 4편) (0) | 2026.08.28 |
|---|---|
| 요청을 어떻게 거절할 것인가 — 처리율 제한 장치의 설계 (가상 면접 사례로 배우는 대규모 시스템 설계 기초 3편) (0) | 2026.08.24 |
| 숫자 없이는 설계도 없다 — 개략적 규모 추정 (가상 면접 사례로 배우는 대규모 시스템 설계 2장) (0) | 2026.08.21 |
