내 프로젝트에 100만명의 사용자가 몰려든다면.!
개발중인 당신의 프로젝트에 100명, 1천명, 1만명, 100만명의 사용자가 몰리게 되면 서버의 아키텍처를 어떻게 구성해야할지 간단하게 알아봅니다.
이 글을 읽은 후엔 대규모 트래픽을 처리하는 서비스의 아키텍처 설계 방식을 이해할 수 있습니다.
단일 서버
사실 내가 만든 프로젝트는 사용자가 매우매우 적기 때문에 다음 사진처럼 단일서버로 조촐하게 구성해도 충분할 수 있다..ㅜㅜ
이렇게 구성된 서버에서는
- 사용자가
api.mysite.com으로 접속하면 DNS(Domain Name Service)를 통해 도메인 이름이 IP주소로 변환되고 - 이 IP주소로 HTTP 요청이 오가며 사용자는 원하는 정보를 얻을 수 있다.
데이터베이스
단일 서버로만 구성해도 충분하지만 우리는 저장한 데이터를 유지하여 서버가 꺼졌다 켜지더라도 사용자의 데이터가 남아있길 원한다.
그러면 데이터베이스 서버를 하나 두면 된다.
사용하고자 하는 DB는 관계형 데이터베이스이든 비-관계형 데이터베이스든 상관없다.
여기까지가 우리가 흔히 사용하는 서버의 구조다. 이제 조금 더 많은 트래픽을 감당하기 위해 어떻게 해야할지 알아보자.
수직적 규모 확장 vs 수평적 규모 확장
스케일 업(Scale up) 이라고도 하는 수직적 규모 확장은 서버에 더 좋은 CPU, 더 많은 RAM처럼 더 좋은 자원을 추가하는 행위이고, 스케일 아웃(Scale out) 이라고도 하는 수평적 규모 확장은 서버의 개수를 늘이는 행위이다.
스케일 업의 가장 큰 장점은 단순함이다. 서버의 성능을 올려서 갑작스러운 트래픽 증가를 빠르게 대처할 수 있다.
하지만 몇 가지 단점이 존재한다.
- CPU의 성능이나 메모리를 무한대로 늘일 수 없기 때문에 성능을 개선하는데 한계가 있다.
- 장애에 대한 자동복구(failover)나 다중화(redundancy)를 제공하지 않는다.
이런 단점 때문에 대규모 어플리케이션을 지원하기 위해선 스케일 아웃이 더 적절하다.
로드밸런서
서버가 여러 대가 생기면 이런 고민을 하게 된다.
서버는 하나의 IP 주소를 가질텐데 api.mysite.com에 접속하면 DNS에서는 어떤 IP주소를 할당해줘야 하지?
이는 로드밸런서를 통해 해결한다. 로드밸런서는 내가 증설한 서버 인스턴스들에게 트래픽을 고르게 분산하는 역할을 한다.
위 사진처럼 사용자는 로드밸런서의 공개 IP 주소로 접속하고 두 개의 웹 서버와 통신을 위해선 사설 IP 주소(private IP address)를 사용한다.
이렇게 구성되어 서버가 2대가 된다면 장애를 자동으로 복구하지 못하는 문제(no failover)가 해소되고, 웹 계층의 가용성(availability)는 향상된다.
서버 1이 다운되면 모든 트래픽을 서버 2로 전송시켜 사용자 입장에선 서버에 문제가 생겼는지 알아차리지 못한다.
이제 트래픽이 가파르게 증가되어도 웹 서버 계층에 더 많은 서버를 추가하여 우아하게 대처할 수 있다. ㅎㅎ
웹 계층은 이제 트래픽이 몰려도 안정적으로 처리할 수 있게 되었다. 그렇다면 데이터 계층은 어떨까? 현제 설계엔 여전히 하나의 데이터베이스 서버뿐이고, 역시 장애의 자동복구나 다중화를 지원하는 구성은 아니다. 그래서 데이터베이스 다중화를 통해 이런 문제를 해결해야한다.
데이터베이스 다중화
DB에 보내는 연산은 읽기와 쓰기로 나뉜다. 잘 생각해보면 우리는 쓰기 연산보다 읽기 연산의 비중이 훨씬 크다. 예를 들어 인스타그램에서는 피드를 작성하는 것보다 다른 사람의 피드를 조회하는 경우가 훨씬 많다.
이를 착안하여 똑똑한 개발자들은 읽기 전용 DB와 쓰기 전용 DB를 나누는 엄청난 발상을 했다. 쓰기 전용 DB는 Primary, 읽기 전용 DB는 Replica로 불린다. 이름에서도 알 수 있듯 Primary는 데이터의 원본을 보관하는 주 데이터베이스이며, Replica는 그 사본을 보관하는 보조 데이터베이스이다.
Primary에서 발생한 쓰기 연산(INSERT, UPDATE, DELETE)은 네트워크를 통해 Replica로 복제(Replication) 된다. 이때 복제 과정에는 약간의 지연(replication lag)이 발생할 수 있어, Replica는 순간적으로 Primary보다 최신 상태가 아닐 수 있음을 유의해야한다.
DB를 다중화하여 우리는
- 더 나은 성능: 쓰기-Primary에서 읽기-Replica에서 수행되어, 병렬로 처리될 수 있는 query의 수가 늘어나 더 나은 성능을 보임
- 안정성(reliability): DB를 지역적으로 멀리 떨어뜨려 놓으면 자연 재해로 DB서버가 파괴되어도 데이터가 안전하게 보존됨
- 가용성(availability): DB가 파괴되어도 남은 DB서버를 통해 서비스를 계속 운영할 수 있음
을 얻을 수 있다.
이제 하나의 DB서버가 다운되면 어떻게 될까?
- 부 서버가 다운되면 읽기 연산을 한시적으로 주 서버로 옮기고, 새로운 부 서버를 추가한다.
- 주 서버가 다운되면 부 서버는 주 서버로 승격되고, 모든 연산은 승격된 주 서버에서 수행된다.
근데 주 서버가 다운된 경우, 부 서버에 보관된 데이터가 최신 상태가 아닐 수 있다. 그러면 없는 데이터는 복구 스크립트를 돌려서 추가하고 다중 마스터나 원형 다중화 방식을 통해 대처할 수 있다.
캐시
이제 웹 계층과 데이터 계층에 대해 충분히 이해하게 되었으니, 응답시간(latency)를 개선해볼 순서이다. 응답 시간은 캐시(Cache)를 붙이고 정적 컨텐츠를 CDN(Content Delivery Network)로 옮기면 개선할 수 있다.
캐시란 값 비싼 연산 결과 or 자주 참조되는 데이터를 담는 임시 보관소를 의미하고, Redis, 서버의 인메모리 등이 해당된다. 애플리케이션의 성능은 데이터베이스 호출이 얼마나 잦은지에 크게 좌우되는데, 캐시는 이런 문제를 완화할 수 있다.
캐시 계층을 도입하여 성능을 개선하고 데이터베이스의 부하를 줄일 수 있다. 사진처럼 캐시에 원하는 정보가 있다면 DB에 직접 접근하지 않아도 되므로 쿼리 요청이 줄어든다.
또한 캐시를 별도의 계층으로 분리하면 캐시 서버만 독립적으로 확장할 수 있고, 장애가 발생해도 계층 단위로 격리할 수 있어 전체 서비스 안정성이 높아진다.
캐시 사용시 유의할 점
- 캐시엔 갱신은 자주 일어나지 않지만 참조는 빈전히 일어나는 데이터를 저장해야한다.
- 캐시 메모리는 휘발성이므로 영속적으로 보관해야할 데이터는 저장하지 않는게 좋다.
- 캐시는 데이터에 저장 기간을 두기 때문에 만료 기한(TTL)을 적절히 잡아야 한다.
- 데이터 저장소의 원본과 캐시 내의 사본이 일치하기 위해 다양한 전략을 사용해야한다.
자세한 내용은 Jo의 개발 블로그 를 참고하자. - 캐시 메모리를 과할당(overprovision)하여 캐시에서 데이터가 자주 밀려나지(eviction) 않게 한다.
- LRU(Least Recently Used), LFU(Least Frequently Used), FIFO(First In First Out) 등 데이터 방출(eviction) 정책을 경우에 맞게 적용하자.
CDN
CDN(Content Delivery Network)은 정적 콘텐츠를 전송하는 데 쓰이는 지리적으로 분산된 서버의 네트워크이며 이미지, 비디오, CSS, JS 등을 캐시할 수 있다. 사용자가 데이터를 받으려는 서버로부터 물리적인 거리가 멀면 당연히 천천히 다운될 것이다. 때문에 CDN을 이용하면 LA에 있는 사용자에게도 빠르게 콘텐츠를 제공할 수 있게 된다.
출처: Toss 개발자센터
사용자가 웹사이트를 방문하면 그 사용자에게 가장 가까운 CDN 서버가 정적 콘텐츠를 전달하게 된다. 캐시와 비슷하게 CDN에 사용자가 원하는 컨텐츠가 있으면 꺼내주고 없다면 서버로부터 컨텐츠를 받아온다.
CDN 사용 시 고려 사항
캐시처럼 운영되기에 캐시의 유의사항과 비슷하다.
- 비용: CDN은 보통 제3 사업자(thrid-party providers)에 의해 운영되므로 CDN을 거쳐가는 데이터 양에 따라 요금을 내게 된다.
- 적절한 만료 기간 설정
- CDN 장애에 대한 대처 방안
- 컨텐츠 무효화 방법: 아직 만료되지 않은 콘텐츠라도 CDN 서비스 API를 통해서 무효화할 수 있다.
메시지 큐
메시지 큐는 서비스 간에 데이터를 안정적으로 주고받기 위해 사용하는 비동기 통신 방식이다. 메시지 큐를 이용하면 서버 간 결합이 느슨해져서 규모 확장성이 좋아진다.
메시지 큐의 기본 아키텍처는 간단하다. 생산자가 메시지를 만들어 메시지 큐에 발생(publish)하면 소비자가 큐에서 메시지를 가져와 그에 맞는 동작을 수행한다. 생산자는 소비자 프로세스가 다운되어도 메시지를 발행할 수 있고, 소비자는 생산자 서비스가 가용한 상태가 아니어도 메시지를 수신할 수 있다.
예를 들어 온라인 쇼핑몰을 개발한다고 했을 때 주문이 발생하면 "주문 생성됨"을 메시지 큐에 넣고, 소비자인 결제/알림/배송 서비스가 메시지를 꺼내서 처리하게 된다.
데이터베이스 규모 확장
저장할 데이터의 양이 1000TB보다 더 많아지게 되면 데이터베이스에 대한 부하도 증가한다. 이때는 데이터베이스의 규모를 확장해야하며 방법은 두 가지이다.
수직적 확장
이전에 학습했던 것처럼 서버의 CPU, RAM 등의 성능을 향상시키는 방법이다. AWS에는 RDS에 24TB RAM을 갖춘 서버도 상품으로 제공하고 있다고 한다..
하지만 단일 데이터베이스는 SPOF(Single Point of Failure)로 인한 위험성이 크고, 수직적 확장에는 한계가 분명하기에 수평적 확장을 고려하지 않을 수 없다.
수평적 확장
데이터베이스의 수평적 확장은 더 많은 서버를 추가하여 성능을 향상시키는 방법이고, 샤딩(sharding) 이라고도 부른다.
샤딩은 대규모 데이터베이스를 샤드(shard)라고 부르는 작은 단위로 분할하는 기술을 일컫는다. 이를 통해 많은 양의 데이터를 여러 DB에 분할하여 저장한다. 모든 샤드는 같은 스키마를 쓰지만 샤드에 보관되는 데이터 사이에는 중복이 없다.
샤딩을 구현할 때는 샤딩 키를 어떻게 정할지 결정하는 것이 중요하다. 샤딩 키는 데이터가 어떻게 분산될지 정하는 하나 이상의 컬럼으로 구성된다. 샤딩키를 어떻게 정하고 분리할지 간단한 예시로 알아보자.
사용자 테이블에 대해 샤딩을 적용한다면 user_id % N 같이 해시 함수를 적용해 샤드를 분배할 수 있다. 1만개의 데이터가 4개의 샤드로 나뉜다면 각 샤드마다 2,500개의 데이터가 고르게 분산된다.
각 샤드에는 user_id가 다음과 같이 구성될 것이다.
샤딩은 데이터베이스 규모 확장을 실현하는 훌륭한 기술이지만 완벽하진 않다. 샤딩을 도입하면 시스템이 복잡해지고 풀어야할 새로운 문제도 생긴다.
이는 데이터베이스 규모 확장까지 적용된 서비스의 설계이다.
백만 사용자, 그리고 그 이상
앞서 소개된 방법 말고도 다양한 방법을 적용하여 대규모 트래픽을 견뎌낼 수 있다. 예를 들어 시스템을 최적화하고 더 작은 단위의 서비스로 분할할 수도 있다.
여기에 제시된 방법들이 대규모 트래픽을 해결하기 위해서 반드시 도입해야하는 건 아니다. 사용하는 목적에 맞게 기술을 도입하기 바란다.