목차
- Step 1. 서버 한 대에 전부 올렸더니
- Step 2. App 서버만 CPU 100%
- Step 3. 두 번째 서버가 놀고 있다
- Step 4. 페이지를 넘길 때마다 로그아웃
- Step 5. 죽은 서버로 계속 요청이 간다
- Step 6. App을 재시작했더니 DB까지 죽었다
- Step 7. AWS로 옮기기
- 정리하며
Cloud & CERT 과제의 첫 번째 주제는 3-Tier Architecture다. 사용자 로그인, 상품 조회, 주문, 회원·주문 정보 저장 기능을 가진 작은 쇼핑몰을 서버 한 대에서 시작한다. 문제가 생길 때마다 구조를 고쳐 나가는 방식으로 진행했고, 각 단계에서 무엇을 배웠는지, 어디서 잘못 생각했는지 순서대로 정리했다.
Step 1. 서버 한 대에 전부 올렸더니

문제 사용자가 늘자 서버가 느려졌다. 웹 처리가 느린 건지, 주문 로직이 느린 건지, DB 조회가 느린 건지 구분이 안 된다.
처음에는 웹 서버, 애플리케이션, DB를 한 서버에 모두 설치했다. 사용자가 늘자 서버가 느려졌는데, 웹 처리 때문인지 주문 로직 때문인지 DB 조회 때문인지 구분할 수 없었다. 셋이 CPU, 메모리, 디스크를 같이 쓰고 있으니 당연한 결과다. 하나만 고치려고 재시작해도 나머지가 같이 멈췄다.
그래서 서버를 나누기로 했는데, 여기서 첫 번째로 잘못 생각했다. 처음에는 "로그인 서버, 주문 서버, 상품 서버"처럼 기능별로 나누려 했다. 그런데 이건 마이크로서비스 쪽 이야기이고, 문제에서 물은 것은 "웹이냐 로직이냐 DB냐"를 가르는 것이었다. 그래서 기준을 처리 계층으로 바꿨다.
- Presentation (Web) — HTTP 요청을 받고 정적 파일을 돌려주는 계층이다. 네트워크 I/O가 주된 부하다.
- Application — 로그인 검증, 주문 처리 같은 비즈니스 로직을 담당한다. CPU를 주로 쓴다.
- Data (DB) — 회원과 주문 정보를 영구 저장한다. 디스크 I/O가 크고, 유일하게 상태를 가진 계층이다.
세 계층은 부하 특성도, 상태 유무도 다르다. 그래서 떼어 놓아야 원인을 분리할 수 있고 각각 따로 확장할 수 있다.

Step 2. App 서버만 CPU 100%
문제 사용자가 100명에서 10,000명이 됐다. 더 큰 서버로 바꿔도 잠깐 버티다 또 한계가 왔고, 교체할 때마다 서비스를 내려야 했다.
사용자가 100명에서 10,000명으로 늘자 App 서버만 한계에 도달했다. 더 큰 서버로 교체했지만 잠깐 버티다 다시 한계가 왔고, 교체할 때마다 서비스를 내려야 했다.
이때 공부한 것이 Vertical Scaling(Scale Up)과 Horizontal Scaling(Scale Out)이다.

Scale Up은 서버 사양을 키우는 방식이다. 코드를 고칠 필요가 없고 단순하다. 하지만 하드웨어에는 상한이 있고, 사양이 올라갈수록 비용은 더 가파르게 오르며, 교체할 때마다 다운타임이 생긴다. 무엇보다 여전히 한 대라서 그 서버가 죽으면 끝이다.
Scale Out은 같은 서버를 여러 대로 늘리는 방식이다. 이론상 상한이 없고 서비스를 내리지 않고 증설할 수 있다. 대신 트래픽을 나눠 줄 장치가 필요하고, 서버끼리 상태를 어떻게 공유할지가 문제가 된다. 상태를 가진 DB는 이 방식으로 늘리기 어렵다.
App 계층은 상태가 없어야 하는 계층이니까 Scale Out을 선택했다. 다만 이 선택 때문에 이후 Step의 문제들이 줄줄이 따라왔다.
Step 3. 두 번째 서버가 놀고 있다
문제 사용자는 여전히 서버 한 대의 주소로 접속한다. 두 번째 서버는 트래픽을 전혀 받지 못한다.
App 서버를 두 대로 늘렸는데, 사용자가 여전히 첫 번째 서버 주소로만 접속하니 두 번째 서버는 트래픽을 전혀 받지 못했다. 요청을 나눠 주는 역할, 즉 Load Balancer가 앞단에 있어야 했다.

로드밸런서가 요청을 나누는 기준도 여러 가지다. 순서대로 돌아가며 보내는 Round Robin, 현재 연결이 가장 적은 서버로 보내는 Least Connections, 서버 성능 비율대로 보내는 Weighted, 같은 클라이언트를 같은 서버로 보내는 IP Hash가 있다. 또 IP와 포트만 보고 나누는 L4 방식과, URL 경로나 헤더까지 보고 나누는 L7 방식의 차이도 정리했다.
Step 4. 페이지를 넘길 때마다 로그아웃
문제 요청이 App 1로 갔다가 App 2로 가면, App 2는 이 사용자를 모른다.
로드밸런서를 붙이자 이번에는 로그인한 사용자가 페이지를 넘길 때마다 로그아웃됐다. 세션이 App 서버 메모리에 저장되어 있어서, App 1에서 로그인한 사용자가 App 2로 가면 App 2는 그 사용자를 모르기 때문이다.
여기서 두 번째로 잘못 생각했다. 처음에는 Sticky Session, 즉 로드밸런서가 같은 사용자를 같은 서버로 계속 보내게 하면 해결된다고 생각했다. 그런데 그 서버가 죽으면 거기에 묶인 사용자는 전부 로그아웃되고, 특정 서버로 부하가 쏠릴 수도 있다. 문제를 해결한 게 아니라 숨긴 것이었다.
그래서 세션을 서버 밖으로 빼는 방향으로 바꿨다. Redis 같은 공용 세션 저장소에 세션을 두면 어느 App 서버로 요청이 가든 같은 세션을 읽을 수 있다. JWT처럼 상태를 클라이언트가 들고 다니게 하는 방법도 있지만, 강제 로그아웃이 어렵다는 한계가 있다.
핵심은 App 서버를 Stateless로 만들고, 상태는 상태 전용 계층에 두는 것이다. 그래야 서버를 몇 대로 늘리든 몇 대가 죽든 상관이 없어진다.

Step 5. 죽은 서버로 계속 요청이 간다
문제 App 1이 죽었는데 분산 장치는 여전히 App 1로도 요청을 보낸다. 사용자 절반이 에러를 본다.
App 1이 죽었는데도 로드밸런서는 계속 App 1로 요청을 보냈고, 사용자 절반이 에러를 봤다. 서버가 살아 있는지 판단하는 장치가 필요했고, 그게 Health Check다.

로드밸런서가 주기적으로 각 서버에
/health 같은 요청을 보낸다. 정해진 횟수만큼 연속으로 실패하면 그 서버를 라우팅 대상에서 빼고, 다시 연속으로 성공하면 복귀시킨다.공부하면서 Health Check의 깊이도 중요하다는 걸 알게 됐다. 프로세스가 응답만 하면 정상으로 보는 얕은 체크는 DB 연결이 끊겨도 "정상"이라고 판단한다. 반대로 DB 연결까지 확인하는 깊은 체크는 DB 하나가 죽으면 모든 App 서버가 동시에 빠져 버릴 수 있다.
최소 몇 대가 필요하냐는 질문에는 처음에 "2대"라고 답하려 했다. 그런데 정확히는 N+1이다. 평소 부하를 몇 대로 감당하는지가 N이고, 한 대가 빠진 상태에서도 남은 서버가 전체 부하를 버틸 수 있어야 진짜 이중화다.
Step 6. App을 재시작했더니 DB까지 죽었다
문제 App 1을 배포하느라 재시작했더니 같은 서버의 DB도 내려갔고, App 2까지 전부 멈췄다.
App 서버를 다중화했지만, DB는 아직 한 대였고 처음에 편하다는 이유로 App 1과 같은 서버에 두었다. App 1을 배포하느라 재시작하자 DB도 같이 내려갔고, DB를 쓰던 App 2까지 멈췄다. App을 이중화한 의미가 완전히 사라진 것이다.
이 Step에서 Failure Domain, 즉 하나가 고장 났을 때 같이 죽는 범위라는 개념을 배웠다. 범위는 프로세스, 서버, 랙과 전원, 데이터센터(AZ), 리전 순으로 넓어진다.
DB를 App과 다른 장애 범위에 두어야 하는 이유는 세 가지로 정리했다.
- App은 자주 배포하고 재시작하지만 DB는 거의 건드리지 않아야 한다. 생명주기가 다르다.
- 모든 App이 DB에 의존한다. 그래서 DB의 장애 범위가 곧 서비스 전체의 장애 범위가 된다.
- App은 죽어도 새로 띄우면 되지만, DB는 데이터를 잃으면 되돌릴 수 없다.
그래서 DB를 별도 서버로 분리하고, Primary와 Standby로 복제해 서로 다른 AZ에 두는 구조로 바꿨다.

Step 7. AWS로 옮기기
마지막으로 지금까지 만든 구조를 AWS 서비스에 대응시켰다. 가상 네트워크는 VPC, 요청 분산은 ALB, 컴퓨팅은 EC2(Auto Scaling Group과 함께), 관계형 DB는 RDS(Multi-AZ), DNS는 Route 53이 담당한다. Step 4에서 추가한 세션 저장소는 ElastiCache로 구현할 수 있다.

최종 구조의 각 구성 요소를 정리하면 다음과 같다.
구성 요소 | 무엇인가 | 왜 필요한가 | 없으면 | 장애 나면 |
Route 53 | 도메인을 주소로 바꿔 주는 DNS | 사용자가 고정된 이름으로 접속 | IP가 바뀌면 접속 불가 | 이름 해석 실패로 전체 접속 불가 |
ALB | 요청을 여러 서버로 나누는 분배기 | Scale Out의 전제, 죽은 서버 제외 | 서버를 늘려도 한 대만 일함 | 서비스 진입 불가 (관리형이라 다중 AZ 이중화) |
Web EC2 | 요청 수신, 정적 파일 처리 | 표현 계층을 로직과 분리 | App이 네트워크 처리까지 떠안음 | ALB가 제외하고 나머지가 처리 |
App EC2 | 로그인·주문 로직 | 핵심 기능, 독립 확장 | 서비스 기능 없음 | Health Check로 제외, ASG가 새로 생성 |
ElastiCache | 세션 공용 저장소 | App을 Stateless로 유지 | 서버가 바뀌면 로그아웃 | 전체 사용자 로그아웃 |
RDS | 회원·주문 데이터 저장 | 데이터 일관성과 영속성 | 데이터 저장 불가 | Multi-AZ면 Standby로 전환, 단일이면 전체 중단 |
VPC | 격리된 가상 네트워크 | 모든 자원의 경계 | 자원이 격리 없이 노출 | 장애보다 설정 오류가 위험 |
조사하다 알게 된 점도 하나 있다. 실제 소규모 서비스에서는 ALB가 Web 계층의 역할을 상당 부분 흡수하고 정적 파일은 S3와 CloudFront로 빼서, Web EC2 없이 ALB에서 바로 App으로 가는 구조를 많이 쓴다고 한다.
정리하며
과제 1에서 가장 크게 느낀 건 해결책 하나가 다음 문제를 만든다는 점이다. Scale Out을 고르자 요청 분배 문제가 생겼고, 분배를 하자 세션 문제가, 세션을 해결하자 장애 감지 문제가, 이중화를 하자 장애 범위 문제가 나왔다. 결국 좋은 구조란 문제가 없는 구조가 아니라, 각 선택이 어떤 문제를 새로 만드는지 알고 대비한 구조라는 생각이 들었다.
![[Cloud & CERT] 조사 1. 3-Tier Architecture 설계 (박진혁)](https://www.notion.so/image/https%3A%2F%2Fwww.notion.so%2Fimages%2Fpage-cover%2Ftexturelabs_fabric_169S.jpg?table=block&id=3e4d23c8-25c0-80ac-ac76-de49d477f477&cache=v2&width=1600)
