JD의 블로그

서비스 확장성에 관하여 본문

프로그래밍

서비스 확장성에 관하여

GDong 2020. 5. 24. 16:02

이 글은 Scalability for Dummies를 요약한 글입니다.

 

Part 1 - 복제

확장 가능한 웹 서비스의 퍼블릭 서버는 로드 밸런서 뒤에 숨겨져 있다. 이 로드 밸런서는 사용자의 요청을 애플리케이션 서버의 그룹 또는 클러스터에 균등하게 분배한다. 사용자는 어느 서버가 요청을 받던 관계없이 항상 동일한 요청 결과를 다시 받아야한다. 이것이 확장성을 위한 가장 기본적인 조건이다. 모든 서버에는 정확히 동일한 코드베이스가 포함되어 있으며 세션이나 프로필 사진과 같이 사용자 관련 데이터를 로컬 디스크나 메모리에 저장하지 않는다.

 

세션은 모든 애플리케이션 서버에서 액세스 할 수 있는 중앙 집중식 데이터 저장소에 저장해야한다. 외부 데이터베이스 또는 Redis와 같은 외부 영구 캐시일 수도 있다. 외부 영구 캐시 같은 경우 외부 데이터베이스보다 성능이 좋다. 즉, 데이터 저장소를 애플리케이션 서버에 두지 말라는 의미이며, 이런 데이터 저장소는 서버와 같은 데이터 센터 내부에 존재하거나 가까운 위치에 존재하게 만들어야 한다는 것이다.

 

배포의 측면에서 바라보면 어떻게 해야할까? 하나의 서버가 오래된 코드를 제공하지 않고 코드 변경이 모든 서버로 전송되려면 어떻게 하는게 좋을까? 이 문제는 Capistrano라는 도구에 의해 해결되었다. 모든 서버에 동일한 코드 베이스를 서비스한 다음에, 이러한 서버 중 하나를 기반으로 이미지 파일을 만들 수 있는데 이 이미지는 모든 새 인스턴스의 기반이 된다. 새로운 인스턴스를 시작할 때, 가장 최신의 코드를 기반으로 초기 배포를 수행하면 된다.

 

 

Part 2 - 데이터베이스

이제 서버는 수평적으로 확장할 수 있으며, 수 많은 동시 다발적 요청을 처리할 수 있게 되었다. 그러나 어플리케이션이 점점 느려지더니 결국 터져버리게 되는데, 그 이유는 바로 데이터베이스 때문이다. 

 

그러나 데이터베이스를 해결하기 위해서는 좀 더 대담한 방법을 시도해야하는데 이를 해결하기 위해서는 크게 2가지 방법이 존재한다.

 

1. MySQL을 유지하며 데이터베이스에 마스터-슬레이브 복제(Read Replication)를 수행하게 하고 더 많은 RAM을 추가하는 방식으로 마스터 서버를 업그레이드합니다. 그리고 "샤딩", "비정규 화", "SQL 쿼리 튜닝"과 같은 방식을 통해 성능을 개선할 수도 있을 것입니다. 이 시점에서 하는 해결책들은 이전 조치보다 비싸고 시간도 많이 걸리게 된다. 데이터 양이 아직 적고 마이그레이션하기 쉬운 상태였다면 2번을 선택해보는 게 더 좋았을 수도 있다.

 

2. 처음부터 비정규화를 하고 데이터베이스 쿼리에 더 이상 조인을 포함하지 않는 방식을 채택하는 것이다. MySQL을 유지하면서 NoSQL 데이터베이스처럼 사용하거나 MongoDB 또는 DynamoDB와 같은 NoSQL 데이터베이스를 사용하여 더 쉽게 확장하는 방식으로 전환할 수도 있다. 이런 방식을 채택하게 되면 어플리케이션 코드에서 조인을 수행해야한다. 이 단계를 빨리 수행할수록 향후 변경해야 할 코드가 줄어든다. 그러나 NoSQL 데이터베이스로 전환하고 어플리케이션에서 데이터 조인을 수행하도록 하더라도 곧 데이터베이스 요청이 다시 느려질 수 있다. 이럴 때에는 캐시를 도입해야한다. 

 

Part 3 - 캐시

"캐시"란 항상 Memcached 또는 Redis 같은 메모리 내 캐시를 의미한다. 여기서 캐시라는 것은 간단한 Key-Value 스토어이며 어플리케이션과 데이터 스토리지 사이에 위치하여 버퍼 역할을 하게 된다. 어플리케이션에서 데이터를 읽어야 할 필요가 있다면 먼저 캐시에서 데이터를 읽게 된다. 캐시에 없는 경우에만 데이터베이스에 접근하게 된다. 이러한 방식을 사용하는 이유는 캐시가 엄청나게 빠르기 때문이다. 모든 데이터는 RAM에 저장되며 모든 요청은 기술적으로 가능한 가장 빠르게 처리된다. 

 

그리고 데이터를 캐싱하는 방법에는 2가지 패턴이 있다.

 

1. 캐시된 데이터베이스 쿼리

여전히 가장 보편적으로 사용되는 캐싱 패턴이다. 데이터베이스에 쿼리를 하게 되면, 캐시에 그 결과 데이터 셋을 저장한다. 해싱된 쿼리가 그 캐시의 Key가 된다. 다음 번에 그 쿼리를 실행하게 되면, 그 쿼리가 캐시에 존재하는지 체크한다. 그러나 이 방법은 몇가지 문제를 가지고 있는데, 가장 주된 문제는 만료 시간이다. 복잡한 쿼리를 캐쉬했을 때 캐쉬된 결과를 삭제하는 것은 힘들다. 그런데, 그 복잡한 쿼리를 통해 나온 데이터 중 하나만 바뀌더라도 캐쉬된 쿼리 모두를 삭제해야한다. 

 

2. 캐시된 객체

클래스나 인스턴스를 기반인 객체 단위로 캐시하는 것이다. 이는 비동기로 처리할 수 있게 해준다. 

 

Part 4 - 비동기화

비동기화가 될 수 있는 두 가지 방법이 있다.

 

1. 시간을 많이 소비하는 작업을 사전에 하고 완료된 작업을 낮은 요청 시간을 가지고 처리하는 방식이다. 이러한 방법은 동적 콘텐츠를 정적 콘텐츠로 바꾸는 방식을 예로 들 수 있다. 대규모 프레임워크로 구축된 웹 사이트 페이지는 사전에 렌더링되어 모든 변경 사항에 정적 HTML 파일이 로컬에 저장된다. 종종 이런 작업은 정기적으로 수행되며, 아마 cronjob에 의해 매시간 호출되는 스크립트에 의해 수행된다. 스크립트가 미리 렌더링된 HTML 페이지를 AWS S3 또는 CLoudFront에 업로드할 경우 웹 사이트의 확장성은 더욱 뛰어나진다.

 

2. 분명 예측할 수 있는 작업은 미리할 수 있지만, 예측하지 못하는 상황도 존재하기 마련이다. 이런 경우에 다른 작업을 한 후에 이 일을 처리하라고 요청할 수도 있다. 웹 사이트의 프런트 엔드는 작업을 작업 대기열로 보내고 이를 사용자에게 알려줍니다. 작업 대기열은 많은 worker가 지속적으로 체크를 한다. 새 작업이 있으면 작업자가 작업을 수행하고 몇 분 후에 작업이 완료되었다는 신호를 보낸다. 새로운 "작업 완료" 신호를 지속적으로 확인하고 작업이 완료되면 사용자에게 알려주는 방식을 통해 비동기적으로 작업을 처리하는 것이다.

 

이런 것은 RabbitMQ를 통해서 구현할 수 있다. 기본 아이디어는 작업자가 처리할 수 있는 작업 또는 작업 대기열을 갖는 것이다.  시간이 많이 걸리는 작업을 수행하는 경우 비동기적으로 수행하는 것을 추천한다.