가상 면접 사례로 배우는 대규모 시스템 설계 기초 1편 - 트래픽에 따른 서버 확장
1. 들어가며
복습 겸 정리하는 대규모 시스템 설계 기초 1편입니다!
다른 분께 추천받아서 읽기 시작했는데 면접 대비를 떠나서 정말 재밌던 책이었습니다.
백엔드하면 역시 대규모 트래픽이라는 큰 숙제이자 로망(?)이 있다고 생각하는데요.
학생 때 그런 대규모 트래픽을 처리하는 경험을 하기는 쉽지 않습니다. 그나마 웹서버를 직접 구현해보는 과제에서 throughput을 측정하여 점수를 매기므로 간접적으로 경험하긴 하지만 실무에서 대규모 트래픽을 처리할 때 하는 일과는 차이가 좀 있다고 생각합니다.
이 책을 읽으면 정말 설계하는 기분을 느끼고 어떤 것을 위주로 고민하게 되는지 접해볼 수 있습니다. 또한 면접 대비로 많이 추천받는 책이기도 하니 한번 정독해보시길 강력 추천 드립니다 :)
책 내용이 방대해서 2번의 글로 나눴습니다.
이번 글에서는 서버를 하나씩 확장해나가면서 각각의 확장이 어떤 일인지 살펴보고자 합니다.
2. 책 내용 정리
1) 사용자 수에 따른 규모 확장
첫번째 장은 사용자 수에 따라 시스템 규모를 확장해나가는 과정을 천천히 따라가볼 수 있습니다.
서버에서 트래픽에 따른 규모 확장이 어떻게 이루어지는지 간략하게 배워볼 수 있습니다.
1) 단일 서버
그럼 단일 면접 질문인 URL에 도메인 주소를 입력하면 어떤 일이 벌어지나요?를 짚어나가면서 단일 서버 구조를 배워보도록 하겠습니다. 요즘은 토이 프로젝트여도 야심차게 만드는 경우가 많지만 간단한 해커톤일 때 가장 많이 구축하는 구조인 것 같습니다.
- 사용자는 도메인 이름으로 웹사이트에 접속. 도메인 이름을 IP 주소로 변환하기 위해선 DNS에 질의를 해야한다.
- DNS 조회 결과 IP 주소가 반환된다. (접속할 웹서버의 IP 주소)
- 해당 IP 주소로 HTTP 요청이 전달된다.
- 요청을 받은 웹서버는 HTML 페이지나 JSON 등의 형태로 HTTP 응답을 반환한다.
DNS 질의 과정에 대해서 낯설다면 다음 글을 참조해주세요!
https://velog.io/@cieroyou/DNS-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0
2) 데이터베이스
이제 서버가 확장되고 유저의 데이터를 저장할 곳이 필요해졌습니다. 해당 역할을 하는 것이 데이터베이스입니다. 데이터베이스는 웹 서버가 있는 인스턴스에 같이 올려도 기능은 하지만 보통 다른 인스턴스(혹은 서버)에 구축합니다.
데이터베이스를 독립적으로 올리는 이유는, 웹서버와 DB를 한 인스턴스에 넣을 경우 강력한 단일 장애 지점이 되기 때문입니다. 그 인스턴스에 장애가 생겼을 때 모든 기능이 망가지게 될 것입니다. 따로 구축했다면, 예를 들어 웹서버 인스턴스에 장애가 생기고 무상태성을 가지고 있다면 다른 인스턴스를 빠르게 띄우고 해당 인스턴스에 웹서버를 구축 후 빠르게 데이터베이스에 연결하면 장애를 복구할 수 있습니다.
데이터베이스에는 크게 RDB(관계형 데이터베이스)와 NoSQL로 나뉩니다.
그 안에서 NoSQL에도 여러 종류로 나뉩니다. 각 NoSQL마다 장단점이 있기 때문에 한번에 정리하는 것은 개인적으로 별로 좋지 않다고 생각합니다. 예를 들어 MongoDB와 REDIS는 각각 맡는 역할에 큰 차이가 있습니다. 따라서 각자 필요에 맞게 적절한 DB를 사용하는 것이 중요합니다 :)
3) 규모 확장
3.1) 서버 확장
단일한 웹서버와 데이터베이스로는 처리할 수 있는 처리량에 한계가 있습니다. 이때 규모 확장을 해야하는데 규모 확장에는 크게 두 가지가 있습니다.
Scale Up: 수직적 확장. 서버에 고사양 자원(CPU, RAM 등 업그레이드)을 추가.
Scale Out: 수평적 확장. 더 많은 서버를 추가하는 성능 개선.
Scale out으로 서버 규모를 확장하면 여러 서버 인스턴스가 생기게 되고 필연적으로 각 서버에 트래픽 분산해야 합니다. 이 역할을 맡는 것이 로드밸런서 (load balancer)입니다. 이름부터가 부하 균형기..! 입니다. :)
서버가 여러개로 분리가 되어 있으므로 사용자는 서버에 직접 접속하는 것이 아닌 로드밸런서의 IP로 접속해야 합니다. 보안을 위해 각 서버는 사설 IP를 사용하기도 합니다. 사용자가 로드밸런서에 접속하면 로드밸런서가 해당 부하를 분산해줍니다.
여기서 세션을 사용하거나 캐시를 사용하는 등의 이유로 서버에 각 유저의 상태성이 남아있고 그것이 독립적으로 구현이 되어 있지 않다면(REDIS 등) sticky session을 사용하여 고정된 서버로 접속하도록 유도할 수도 있습니다.
다만 stick session이 과연 좋은 일일지도 생각해봐야합니다. 상태 정보를 웹 계층에서 제거하고 독립적인 NoSQL, RDBMS 등에 저장한다면 sticky session을 피할 수 있습니다. 대표적으로 Spring Security는 Session 정보를 Redis에 저장할 수 있도록(정확히는 저장소 자체를) 추상화되어 지원합니다.
또한 특정 서버에 장애가 발생 시 로드밸런서에서 해당 서버로 로드 밸런싱을 안하는 방식으로 장애 복구를 할 수 있습니다.
3.2) 데이터베이스 확장
트래픽이 늘어나면 그 부하는 데이터베이스에도 당연히 갑니다. 데이터베이스 커넥션 개수에도 영향이 있고 내부적으로 쿼리를 처리하는 것도 모두 부하입니다!
따라서 데이터베이스를 다중화하여 트래픽 부하를 분산할 수 있습니다.
DB 부하 분산 방법에는 다양한 방법이 있을 수 있습니다. 대표적으로 2개만 소개하겠습니다.
- Master - Slave 관계 (RW - RO라고도 합니다.)
데이터베이스 관계를 설정하고 원본은 주 서버에서, 사본은 부 서버에서 저장합니다. 주 서버에서는 쓰기 연산을 처리하고 부 서버에서는 읽기 연산만 지원합니다. 보통 Master 서버는 하나, Slave 서버는 여러 개 두는 구조입니다. (쓰기 연산동안 다른 읽기 DB에 lock이 걸어야 정합성이 맞으므로 Master DB를 늘리는 것은 쉽지 않습니다. 혹시 다른 방법으로 극복이 가능하다면 알려주세요!) 읽기 연산이 많은 경우 효과적입니다. 각 읽기 연산이 여러 Slave 노드로 분산될 수 있기 때문입니다.
하지만 주 서버가 다운될 경우 복잡한 FailOver 과정을 거쳐야 합니다. 다음 글을 참조해주세요! https://velog.io/@sweet_sumin/DB-Master-Slave-%EA%B0%9C%EB%85%9 - 샤딩(sharding)
대규모 데이터베이스를 샤드 단위로 분할하는 기술.
샤드는 같은 스키마를 사용하지만, 보관되는 데이터는 중복되지 않습니다.
샤딩에서 중요한 것은 샤딩 키를 어떻게 정하는가? 입니다. 샤드 간 데이터 분포를 어떻게 고르게 할 것인지, JOIN이 어렵지 않게 어떻게 할 것인지 고민해야 합니다.
3.3) 캐싱, CDN
데이터베이스 확장은 쉽지 않고 (샤딩, Master-Slave 구조 모두) 호출 자체도 성능에 영향을 미치는 편입니다. 따라서 자주 참조되는 데이터를 캐싱해두는 것도 방법입니다.
읽기 주도형 캐시 전략: 웹 서버는 캐시에 응답이 있는지 확인 후, 데이터가 있으면 반환합니다. 없으면 데이터베이스에 query로 데이터를 찾아 캐시에 저장한 후 클라이언트에 반환합니다.
혹은 원본 서버가 멀리 있거나 데이터가 큰 경우 유저와 지리적으로 가까운 CDN을 활용하여 빠르게 응답을 내려주고 부하를 줄일 수 있습니다.
4) 메시지 큐
백엔드를 공부하면서 얼핏이라도 MSA(Micro Server Architecture)를 접해볼 것이라 생각합니다. 트래픽이 늘어나고 다양한 요구사항이 생기면서 기존 모놀리틱 아키텍처(하나의 프로젝트로 웹서버를 구성)에는 한계를 느껴 하나의 어플리케이션을 분리하여 여러 어플리케이션으로 조합하는 일을 말합니다.
모놀리틱 아키텍쳐의 한계점
- 배포 시간의 증가 - 하나의 거대한 프로젝트이므로 배포 시간이 늘어날 수 밖에 없습니다.
- 부분적 스케일 아웃의 어려움 - 예를 들어 서비스 내 특정 도메인만 트래픽이 몰린다면 해당 도메인만 확장하고 싶을 수 있습니다. 하지만 모놀리틱으로는 불가능합니다.
- 안전성 - 서버가 나뉘어있다면 장애 지점을 해당 서버로만 한정할 수 있습니다. (단순히 MSA라서 되는 것은 아니고 서킷 브레이커와 느슨한 결합이 있어야 합니다.)
- 프레임워크 다양화 불가 - 각 도메인마다 적합한 프레임워크가 다를 수 있습니다. 예를 들면 정합성이 중요하다면 Spring MVC를 쓰고 속도가 중요하다면 Spring Webflux를, 빠른 개발 속도가 필요하다면 fast api를 쓰는 등 다른 프레임워크를 적용하고 싶을 수 있습니다. 모놀리틱에서는 단일 프레임워크로 진행할 수 밖에 없습니다.
주의: 그렇다고 모놀리틱 아키텍처가 잘못되었다고 인식해서는 안 된다고 생각합니다. MSA는 그만큼 설계, 코드 구현, 관리에 비용이 더 있으며 특히 서비스 간 통신이 필요해지기 때문에 응답 속도 증가가 될 수 있습니다.
하나의 어플리케이션을 여러 어플리케이션으로 분리한다면 그전엔 내부 procedure call로 해결되었던 일이 서버 간 통신으로 바뀌게 됩니다. 따라서 해당 일을 추상화하는 RPC 혹은 Message Queue, 아니면 그냥 API call을 통해 서버 간 통신을 할 필요성이 생깁니다.
그 중 Message Queue는 메시지의 무손실을 보장하는 비동기 통신을 지원하는 컴포넌트입니다. 각 서버가 발행하는 메시지의 버퍼 역할을 하며 비동기적으로 전송합니다. producer가 메시지를 만들어 메시지 큐에 발행하면 consumer가 해당 메시지를 받아 그에 맞는 동작을 수행합니다.
그냥 서버 간 API로 call을 날리면 되지 않나? 라고 생각하실 수 있습니다.
Message Queue를 사용했을 때의 장점
메시지 큐를 이용하면 서비스 또는 서버 간 결합이 느슨해져서, 규모 확장성이 보장되어야 하는 안정적 어플리케이션을 구성하기 좋다. 생산자는 소비자 프로세스가 다운되어 있어도 메시지를 발행할 수 있고, 소비자는 생산자 서비스가 가용한 상태가 아니더라도 메시지를 수신할 수 있다.
또한 비동기인 만큼 API call과 다르게 응답을 기다리지 않아도 되어서 빠를 수 있고 특히 Kafka, RabbitMQ와 같은 것들은 수 밀리세컨즈의 latency를 자랑합니다. (Kafka는 p99%까지, RabbitMQ는 큐 당 높지 않은 throughput에서)
5) 로그, 메트릭
시스템이 확장되어가면서 오류를 쉽게 찾아낼 방법이 필요해집니다. 이럴 때 좋은 것이 로그, 메트릭입니다.
위에서 설명한 Kafka와 같은 throughput이 매우 높은 MessageQueue를 결합하여 로그 시스템을 구축할 수 있습니다.
메트릭의 경우 OutOfMemory와 같이 OS가 직접 kill해버리는 경우 정확한 원인 파악을 위해 필수라고 생각합니다.
따라서 JVM 옵션에서 Heap dump를 추적하는 등의 일이 필요합니다.