목차
트래픽이 늘어 서버 스펙을 한 단계 올렸다. 한동안 괜찮았다. 그리고 다시 한계가 왔다.
스펙을 올려도 또 한계가 온다
스펙을 올리는 건 가장 먼저 하게 되는 선택이다. 코드를 안 고쳐도 되고, 구조도 그대로다. 실제로 상당히 멀리 간다.
그런데 이 방식에는 끝이 있다. 한 대가 될 수 있는 최대치라는 게 존재하기 때문이다. 그 최대치에 닿으면 더 살 게 없다.
그래서 확장에는 방향이 둘 있다.
수직 확장은 한 대를 키운다. CPU 코어를 늘리고 메모리를 붙인다. 수평 확장은 같은 서버를 여러 대 둔다. 각각은 그대로고 수만 늘어난다.
두 방향은 난이도도 성질도 다르다.
수직 확장이 쉬운 이유, 그리고 막히는 곳
수직 확장의 장점은 하나로 요약된다. 아무것도 안 바뀐다. 서버가 한 대라는 전제가 유지되니, 메모리에 들고 있던 것도 그대로고, 로그도 한 곳에 모이고, 디버깅도 하던 대로다.
막히는 자리도 분명하다.
상한이 있다. 어느 지점을 넘으면 더 큰 사양이 존재하지 않는다.
가격이 선형이 아니다. 사양이 올라갈수록 같은 성능을 사는 값이 비싸진다. 고사양 구간에서는 같은 돈으로 작은 서버 여러 대를 사는 게 유리해지는 지점이 온다.
바꾸려면 멈춘다. 스펙 변경은 대개 재시작을 동반한다. 서버가 한 대면 그 시간은 곧 서비스 정지다.
한 대가 죽으면 전부 죽는다. 이게 가장 큰 약점이다. 아무리 크게 키워도 서버는 하나고, 그 하나가 장애를 만나면 서비스가 통째로 내려간다. 수직 확장은 가용성을 전혀 사주지 않는다.
| 수직 확장 | 수평 확장 | |
|---|---|---|
| 코드 변경 | 없음 | 전제가 필요하다 |
| 상한 | 있다 | 사실상 계속 늘릴 수 있다 |
| 장애 | 한 대가 죽으면 끝 | 한 대가 죽어도 나머지가 받는다 |
| 배포 | 멈춰야 한다 | 한 대씩 교체 가능 |
| 운영 난이도 | 낮다 | 올라간다 |
수평 확장이 좋아 보이지만 마지막 줄이 값이다. 늘리는 순간 새로 생기는 문제들이 있고, 그 앞에 전제가 하나 있다.
수평 확장의 전제: 서버가 기억하지 않아야 한다
서버가 세 대다. 사용자의 첫 요청이 A에 갔고, A가 로그인 정보를 자기 메모리에 저장했다. 다음 요청이 B에 갔다.
B는 이 사용자를 모른다. 로그아웃된 것처럼 보인다.
이게 수평 확장의 진짜 관문이다. 서버를 늘리는 건 쉽다. 어느 서버에 가도 같은 결과가 나오게 만드는 것이 어렵다.
그 조건을 **무상태(stateless)**라고 부른다. 무상태 글이 HTTP 층에서 다루는 그 성질이고, 여기서는 그게 왜 확장의 전제가 되는지가 요점이다. 서버가 요청 사이의 정보를 자기 안에 들고 있으면, 그 서버로 다시 가야만 하는 요청이 생긴다. 그 순간 대수를 늘려도 자유롭게 나눠줄 수가 없다.
무상태는 “아무것도 저장하지 않는다”가 아니다. 서버의 로컬 메모리에 저장하지 않는다는 뜻이다. 상태 자체는 어딘가에 있어야 한다. 어디로 옮기느냐가 다음 이야기다.
상태를 어디로 치우나
수평 확장을 준비한다는 건 결국 서버 안에 있던 것들을 밖으로 꺼내는 작업이다. 자주 걸리는 자리가 넷이다.
| 서버 안에 있던 것 | 옮길 곳 | 안 옮기면 |
|---|---|---|
| 로그인 세션 | 공용 저장소 | 요청마다 로그인 상태가 바뀐다 |
| 업로드된 파일 | 오브젝트 스토리지 | 올린 서버에서만 보인다 |
| 로컬 캐시 | 공용 캐시 | 서버마다 다른 값을 준다 |
| 스케줄러·배치 | 한 대만 실행하도록 조정 | 대수만큼 중복 실행된다 |
앞의 두 편이 이 표의 셋째 줄이었다. 캐시를 프로세스 밖으로 꺼낸 이유와 세션을 밖으로 꺼내는 이유가 같다. 서버가 여러 대인데 각자 다른 걸 들고 있으면 안 되기 때문이다.
넷째 줄은 놓치기 쉽다. @Scheduled가 붙은 메서드는 서버마다 돈다. 한 대일 때는 정산이 하루 한 번 돌던 것이, 세 대가 되면 하루 세 번 돈다. 결과가 두 배로 찍히거나 메일이 세 번 나간다. 이건 캐시처럼 “조금 느려지는” 문제가 아니라 데이터가 틀어지는 문제다.
파일 업로드는 특히 조용히 깨진다. 서버 A에 올린 이미지가 A에서는 보이고 B로 배정된 요청에서는 404가 된다. 사용자에게는 “가끔 이미지가 안 뜬다”로 보여서 원인을 찾기가 어렵다. 서버를 늘리기 전에 로컬 디스크에 쓰는 코드가 있는지 먼저 훑어야 한다.
늘려도 안 늘어나는 것
무상태를 만들어 서버를 세 대로 늘렸다고 하자. 처리량이 세 배가 되나. 아니다.
앱 서버는 늘었지만 DB는 그대로 한 벌이다. 모든 서버가 같은 DB를 본다. 앱에서 하던 계산은 나눠졌지만 DB로 가는 부하는 오히려 늘었다.
첫 글에서 본 병목의 이동이 그대로 일어난다. 그리고 그 글의 첫 장면 - 서버를 늘렸는데 더 느려진 사고 - 가 정확히 이 그림이다.
여기서 커넥션 풀의 계산이 다시 필요해진다. 앱 한 대가 커넥션 20개를 쓰면 세 대는 60개다. DB가 받을 수 있는 상한은 그대로다. 앱을 늘릴 때는 풀 크기를 같이 조정해야 하고, 안 그러면 DB가 연결 자체를 거절하기 시작한다.
일반화하면 이렇다. 나눌 수 없는 부분이 남아 있으면 전체가 그만큼만 빨라진다. 앱 처리가 아무리 나눠져도 모두가 거쳐야 하는 한 곳이 있으면 거기가 천장이다. 그 한 곳을 어떻게 나눌지(DB 복제·샤딩)는 DB 복제와 파티셔닝·샤딩이 다루는 별개의 주제다.
서버가 여럿이면 새로 생기는 일
상태를 다 치워도 대수가 늘면 운영 쪽에서 없던 일이 생긴다.
배포 중에 버전이 섞인다. 한 대씩 교체하는 동안 새 버전과 옛 버전이 동시에 떠 있다. 같은 사용자의 연속된 두 요청이 서로 다른 버전을 만날 수 있다. 그래서 배포는 한 단계 이전 버전과 호환되게 해야 한다. API 필드를 지우면서 동시에 그 필드를 쓰는 코드를 배포하면 그 사이에 깨진다.
로그가 흩어진다. 사용자가 겪은 오류를 찾으려면 세 대의 로그를 다 봐야 한다. 그래서 로그를 한 곳으로 모으고, 한 요청에 식별자를 붙여 서버를 넘나들어도 이어 볼 수 있게 한다.
새로 뜬 서버는 차갑다. 방금 뜬 서버는 캐시가 비어 있고 JIT 최적화도 안 됐다. 뜨자마자 다른 서버와 같은 양의 요청을 주면 그 서버만 느리다. 그래서 준비될 때까지 요청을 안 보내거나 천천히 늘린다.
내려갈 때도 예의가 필요하다. 처리 중인 요청이 있는 서버를 그냥 죽이면 그 요청들이 실패한다. 새 요청을 안 받으면서 하던 것을 마치고 내려가야 한다.
이 두 가지 - 준비될 때까지 안 보내기, 내려갈 때 마치고 나가기 - 는 결국 앞에서 나눠주는 쪽과 손발을 맞춰야 하는 일이다.
오토스케일링은 자동 수평 확장이다
수평 확장이 되면 자연스럽게 나오는 질문이 있다. “몇 대를 띄울지를 사람이 정해야 하나.”
오토스케일링은 지표를 보고 대수를 자동으로 조절한다. CPU 사용률이나 요청 수가 기준을 넘으면 늘리고, 한가하면 줄인다.
개념은 단순한데 앞에서 본 것들이 전부 조건으로 붙는다.
- 무상태가 전제다. 언제 죽고 언제 태어나도 되는 서버여야 자동으로 늘리고 줄일 수 있다.
- 뜨는 데 시간이 걸린다. 지표가 올라간 걸 보고 띄우기 시작하면 이미 늦다. 여유를 두고 미리 반응하도록 잡는다.
- 줄일 때가 더 위험하다. 요청을 처리 중인 서버를 내리면 실패가 난다. 우아한 종료가 없으면 축소가 사고가 된다.
- 뒤가 못 버티면 소용없다. 앱을 자동으로 열 대까지 늘려도 DB 커넥션 상한이 그대로면, 늘어난 서버가 DB를 밀어붙일 뿐이다.
마지막 줄이 이 글 전체의 요약이다. 수평 확장은 앱만의 결정이 아니다.
그럼 그 대수를 실제로 누가 고치나. 사람이 손으로 고치는 대신 “몇 대여야 한다”를 선언해두고 시스템이 그 상태를 유지하게 만드는 방식이 있다. 쿠버네티스 글이 그 구조를 다룬다.
그래서 실무에선 어떻게 쓰이나
실제 판단은 “둘 중 무엇”이 아니라 순서로 간다. 대개 이렇게 흐른다.
먼저 수직 확장으로 시간을 번다. 코드를 안 고쳐도 되니 가장 싸다. 트래픽이 아직 예측하기 어려운 단계라면 이게 합리적이다.
동시에 무상태를 만들어 둔다. 실제로 늘리지 않더라도 세션·파일·캐시를 밖으로 꺼내두면, 필요한 순간에 대수만 바꾸면 된다. 이 준비를 급할 때 하려고 하면 장애 중에 구조를 바꾸는 상황이 된다.
그리고 가용성 때문에라도 두 대 이상으로 간다. 처리량이 부족하지 않아도, 한 대짜리 서비스는 배포할 때마다 멈추고 그 한 대가 죽으면 끝이다. 수평 확장은 성능만 사는 게 아니라 죽어도 되는 서버를 산다.
코드에서 확인할 것은 대체로 짧은 목록이다.
// 이런 것들이 남아 있으면 서버를 늘릴 수 없다
private static Map<String, User> loginUsers = new HashMap<>(); // 메모리 세션
Files.write(Path.of("/var/app/uploads/" + name), bytes); // 로컬 디스크
@Scheduled(cron = "0 0 3 * * *") void settle() { ... } // 대수만큼 실행세 줄 다 한 대일 때는 완벽하게 동작하는 코드다. 한 대라는 전제가 사라지는 순간에만 틀린다. 그래서 늘리기 전에는 아무 증상도 없다.
정리
| 수직 확장 | 한 대를 키운다. 코드 변경이 없지만 상한·가격·단일 장애점이 걸린다 |
| 수평 확장 | 같은 서버를 여러 대. 상한이 사실상 없고 가용성을 같이 얻는다 |
| 전제 | 무상태. 어느 서버에 가도 같은 결과가 나와야 한다 |
| 치울 것 | 메모리 세션 · 로컬 파일 · 로컬 캐시 · 중복 실행되는 스케줄러 |
| 안 늘어나는 것 | 모두가 거쳐야 하는 한 곳(DB). 거기가 새 병목이 된다 |
| 커넥션 풀 | 대수가 늘면 풀 크기의 합도 는다. DB 상한과 같이 본다 |
| 운영 | 배포 중 버전 혼재 · 흩어진 로그 · 차가운 서버 · 우아한 종료 |
| 오토스케일링 | 자동 수평 확장. 무상태와 우아한 종료가 있어야 성립한다 |
서버를 여러 대 두기로 했다면 바로 다음 질문이 온다. 들어온 요청을 누가 어느 서버로 보내나. 다음 글은 앞에 서서 나눠주는 장치를 본다. 이 글에서 남겨둔 “차가운 서버”와 “우아한 종료”도 거기서 마무리된다.