이번 장에서는 지메일 또는 야후 메일과 같은 대규모 이메일 서비스를 설계해본다.
결론적으로, 상당한 양의 데이터를 처리해야 하기 때문에 분산 데이터 베이스 솔루션 이 필요하다 !
이메일을 주고 받는 프로토콜은 여러 가지가 있는데, 대부분의 메일 서버는 POP, IMAP, SMTP 같은 프로토콜을 사용해왔다.
DNS 서버는 수신자 도메인의 메일 교환기 레코드 (MX) 검색에 이용된다. 가령 커맨드-라인에서 gmail.com의 DNS 레코드를 검색해 보면 다음과 같은 MX 레코드가 표시된다.
우선순위 값은 선호도를 나타내는 것으로, 그 값이 낮을수록 우선순위가 높아서 선호하는 것으로 이해하면 된다. 따라서 위의 사진을 기준으로는, gmail-smtp-in.l.google.com이 우선순위가 가장 높기 때문에 송신자 측 메일 서버는 이 메일 서버에 접속해서 메세지를 보내려고 시도한다. 연결 실패 시, 그 다음으로 우선순위가 높은 메일 서버와 연결을 시도한다.
이메일 첨부 파일은 이메일 메세지와 함께 전송되며, 일반적으로 Base64 인코딩을 사용한다.
분산 메일 서버에 대해 알아보기 전에 기존 메일 서버(보통 서버 한대로 운용)의 역사와 동작 방식을 간단히 살펴보자.
전통적 메일 서버 아키텍쳐
해당 프로세스는 아래 단계로 구성된다.
저장소
전통적 메일 서버는 이메일을 파일 시스템의 디렉터리에 저장했다. 이때 각각의 이메일은 고유한 이름을 가진 별도 파일로 보관한다. 각 사용자의 설정 데이터와 메일함은 사용자 디렉터리에 보관한다.
하지만 이런 구조는 사용자가 많아짐에 따라 수십억 개의 이메일을 검색하고 백업하는 목적으로 활용하기에는 곤란했다. 이메일 양이 많아지고 파일 구조가 복잡해지면 디스크 I/O가 병목이 되곤 했다.
또한 이메일을 서버의 파일 시스템에 저장했으므로 가용성과 안정성 요구 사항도 만족할 수 없었다.
디스크 손상이나 서버 장애가 언제든 발생할 수 있었기 때문에, 더 안정적인 분산 데이터 저장소 계층이 필요했다.
이렇게 등장한 분산 메일 서버는 현대적 사용 패턴을 지원하고 확장성과 안정성 문제를 해결한다. 이번 절에서는 이메일 API, 분산 이메일 서버 아키텍처, 이메일 발송 및 수신 흐름을 살펴 본다.
이메일 API의 의미는 메일 클라이언트마다, 그리고 이메일 생명주기 단계마다 달라질 수 있다.
POST /v1/messages 엔드포인트
To, Tc, Bcc 헤더에 명시된 수신자에게 메세지를 전송한다.
GET /v1/folder 엔드포인트
주어진 이메일 계정에 존재하는 모든 폴더를 반환한다.
응답 형식은 아래와 같다.
[{
id: string 고유한 폴더 식별자
name: string 폴더 이름
기본 폴더는 다음 폴더 가운데 하나다.
All, Archive, Drafts, Flagged, Junk, Sent, Trash
user_id: string 계정 소유자 ID
}]
GET /v1/folders/{:folder_id}/messages 엔드포인트
주어진 폴더 아래에 있는 메세지들을 전부 반환한다.
GET /v1/messages/{:message_id}
주어진 특정 메세지에 대한 모든 정보를 반환한다. 메세지는 이메일 애플리케이션의 핵심 구성 요소로, 발신자, 수신자, 메세지 제목, 본문, 첨부 파일 등의 정보로 구성된다.
{
user_id: string 계정주의 ID
from: [name: string, email: string] 발신자의 <이름, 이메일> 쌍
to : [name: string, email: string] 수신자 <이름, 이메일> 쌍의 목록
subject: string 이메일 제목
body: string 이메일 본문
is_read: Boolean 수신자가 메세지를 읽었는지 여부
}
분산 메일 서버에서 가장 중요한 작업 흐름은 아래와 같다.
분산 메세지 큐는 비동기적 메일 처리를 가능하게 하는 핵심 컴포넌트이고 웹서버에서 외부 전송 담당 SMTP 프로세스를 분리함으로써 전송용 SMTP 프로세스의 규모를 독립적으로 조정할 수 있게 한다.
외부 전송 큐의 크기를 모니터링할 때는 각별히 주의해야 하는데, 만일 메일이 처리되지 않고 큐에 오랫동안 머물러있다면 다음과 같은 문제가 있을 수 있다.
1. 이메일이 SMTP 로드밸런서에 도착한다.
2. 로드밸런서는 트래픽을 여러 SMTP 서버로 분산한다. SMTP 연결에는 이메일 수락 정책을 구성하여 적용할 수 있다. 예를 들어 유효하지 않은 이메일은 반송하도록 하면 불필요한 이메일 처리를 피할 수 있다.
3. 이메일의 첨부 파일이 큐에 들어가기 너무 큰 경우에는 첨부 파일 저장소(S3)에 보관한다.
4. 이메일을 수신 이메일 큐에 넣는다. 이 큐는 메일 처리 작업 프로세스와 SMTP 서버 간의 결합도를 낮추어 각자 독립적으로 규모 확장이 가능하도록 한다. 갑자기 수신되는 이메일의 양이 폭증하는 경우 버퍼 역할도 한다.
5. 메일 처리 작업 프로세스(worker)는 스팸 메일을 걸러내고 바이러스를 차단하는 등의 다양한 역할을 한다. 아래의 절차들은 검증 작업이 끝난 이메일을 대상으로 한다.
6. 이메일을 메일 저장소, 캐시, 객체 저장소 등에 보관한다.
7. 수신자가 온라인 상태인 경우 이메일을 실시간 서버로 전달한다.
8. 실시간 서버는 수신자 클라이언트가 새 이메일을 실시간으로 받을 수 있도록 하는 웹소켓 서버다.
9. 오프라인 상태 사용자의 이메일은 저장소 계층에 보관한다. 해당 사용자가 온라인 상태가 되면 웹메일 클라이언트는 웹 서버에 RESTful AP를 통해 연결한다.
10. 웹 서버는 새로운 이메일을 저장소 계층에서 가져와 클라이언트에 반환한다.
몇 가지 핵심 요소를 더 자세히 알아보자.
이메일 메타데이터의 특성을 알아보고 올바른 데이터베이스와 데이터 모델을 고르는 문제, 그리고 이메일 타래 지원 방안에 대해 알아보자.
가능한 모든 선택지는 아래와 같다.
그냥 결론적으로는 본 설계안이 필요로 하는 기능을 완벽히 지원하는 데이터베이스는 없다. 대형 이메일 서비스 업체는 보통 자체적으로 데이터베이스 시스템을 만들어서 사용한다.
데이터를 저장하는 한가지 방법은 user_id를 파티션 키로 사용하여 특정한 사용자의 데이터는 항상 같은 샤드에 보관하는 것이다. 이 데이터 모델의 한 가지 문제는 메세지를 여러 사용자와 공유할 수 없다는 것이다. 하지만 이는 요구사항과 관계 없으므로 신경 쓰지 않아도 된다.
그럼 이제 테이블을 정의해보자. 기본 키는 파티션 키와 클러스터 키의 두 가지 부분으로 구성된다.
이메일 서비스의 데이터 계층은 다음과 같은 질의를 지원해야 한다.
질의 1: 특정 사용자의 모든 폴더 질의
아래에서 볼 수 있듯, 파티션 키는 user_id다. 따라서 어떤 사용자의 모든 폴더는 같은 파티션 안에 있다.
질의 2: 특정 폴더에 속한 모든 이메일 표시
사용자가 자기 메일 폴더를 열면 이메일은 가장 최근 이메일부터 오래된 것 순서로 정렬되어 표시된다. 같은 폴더에 속한 모든 이메일이 같은 파티션에 속하도록 하려면 <user_id, folder_id> 형태의 복합 파티션 키를 사용해야 한다.
email_id의 xkdlqdms TIMEUUID로 이메일을 시간순으로 정렬하는데 사용하는 클러스터 키이다.
질의 3: 이메일 생성/삭제/수신
이 질의를 지원하기 위해서는 두 테이블이 필요하다. 다음과 같은 간단한 질의를 통해 특정 이메일의 상세 정보를 가져올 수 있다.
SELECT * FROM emails_by_user WHERE email_id = 123;
한 이메일에는 여러 첨부 파일이 있을 수 있다. email_id와 filename 필드를 같이 사용하면 모든 첨부 파일을 질의할 수 있다.
질의 4: 읽은, 또는 읽지 않은 모든 메일
관계형 데이터베이스로 도메인 모델을 구현하는 경우, 읽은 메일 전부는 다음과 같이 질의 가능하다.
SELECT * FROM emails_by_folder
WHERE user_id = <user_id> and folder_id = <folder_id> and is_read = true
ORDER BY email_id
읽지 않은 메일을 전부 가져오는 것도 비슷하다.
하지만 본 설계안의 데이터 모델은 NoSQL이다. NoSQL 데이터베이스는 보통 파티션 키와 클러스터 키에 대한 질의만 허용한다. emails_by_folder 테이블의 is_read 필드는 이에 해당하지 않으므로, 대부분의 NoSQL 데이터베이스는 위의 질의문을 실행하지 못한다.
이 문제를 해결하는 방법은 주어진 폴더에 속한 모든 메세지를 가져온 다음 애플리케이션 단에서 필터링을 수행하는 것이다. 하지만 대규모 서비스에서는 안티 패턴이다.
따라서 이 문제는 NoSQL 데이터베이스 테이블을 비정규화하여 해결하는 것이 보통이다. 즉, emails_by_folder 테이블을 아래처럼 두 테이블로 분할하는 것이다.
읽지 않은 메일을 읽은 메일로 변경하려면 해당 이메일을 unread_emails 테이블에서 삭제한 다음, read_emails 테이블로 옮기면 된다.
또한 특정 폴더 안에 읽지 않은 모든 메일을 가져오는 질의는 아래와 같이 작성하면 된다.
SELECT * FROM unread_emails
WHERE user_id = <user_id> and folder_id = <folder_id>
ORDER BY email_id
높은 가용성을 달성하기 위해 다중화에 의존하는 분산 데이터베이스는 데이터 일관성과 가용성 사이에서 타협적인 결정을 내릴 수 밖에 없다.
이메일 시스템의 경우에는 데이터의 정확성이 아주 중요하므로, 모든 메일함은 반드시 하나의 주 사본을 통해 서비스된다고 가정해야 한다.
따라서 장애가 발생하면 클라이언트는 다른 사본을 통해 주 사본이 복원될 때까지 동기화/갱신 작업을 완료할 수 없다. 데이터 일관성을 위해 가용성을 희생하는 것이다.
메일 서버를 구상하고 이메일을 보내는 것은 쉽지만 특정 사용자의 메일함에 실제로 메일이 전달되도록 하는 것은 어려운 문제다.
이메일 전송 가능성을 높이기 위해서는 다음과 같은 요소들을 고려해야 한다.
기본적인 이메일 검색은 보통 이메일 제목이나 본문에 특정 키워드가 포함되었는지 찾는 것을 뜻한다. 검색 기능을 제공하려면 이메일이 전송, 수신, 삭제될 때마다 색인(인덱싱) 작업을 수행해야 한다.
그에 반해 검색은 사용자가 '검색' 버튼을 누를 때만 실행된다. 따라서 이메일 시스템의 검색 기능에서는 쓰기 연산이 읽기 연산보다 훨씬 많이 발생한다.
검색 기능을 지원하기 위해서는 엘라스틱서치를 이용하는 방법과 데이터 저장소에 내장된 기본 검색 기능을 활용하는 방안의 두 가지 선택지를 지금부터 비교해보자.
방안 1: 엘라스틱 서치
엘라스틱 서치 기술을 활용해 검색 기능을 구현할 경우 아래와 같은 설계안이 나온다.
질의가 대부분 사용자의 이메일 서버에서 실행되므로 user_id를 파티션 키로 사용하여 같은 사용자의 이메일은 같은 노드에 묶어 놓는다.
본 설계안은 카프카를 활용하여 색인 작업을 시작하는 서비스와 실제로 색인을 수행할 서비스 사이의 결합도를 낮추는 방안을 채택했다.
엘라스텍 서치를 사용할 경우 한 가지 까다로운 문제는 주 이메일 저장소와 동기화를 맞추는 부분이다.
방안 2: 맞춤형 검색 솔루션
대규모 이메일 서비스 사업자는 보통 자기 제품에 고유한 요구사항을 만족시키기 위해 검색 엔진을 자체적으로 개발해 사용한다. 하지만 이메일 검색 엔진의 설계는 아주 복잡한 과제이기 때문에, 여기서는 자세히 다루지는 않는다.
두 가지 방안을 간략하게 비교해본 표는 위와 같다.
소규모의 이메일 시스템을 구축하는 경우에는 엘라스틱 서치가 좋은 선택지다.
통합하기 쉽고 엔지니어링에 많은 노력이 필요하지도 않다. 대규모 시스템을 구축하는 경우에도 일래스틱서치를 사용할 수는 있겠지만 이메일 검색 인프라를 개발하고 관리하는 전담 팀이 필요할 수 있다.
지메일이나 아웃룩 규모의 이메일 시스템을 지원하려면 독립적인 검색 전용 시스템을 두기보다는 데이터 베이스에 내장된 전용 검색 솔루션을 사용하는 것이 바람직할 수도 있다.
각 사용자의 데이터 접근 패턴은 다른 사용자와 무관하므로, 시스템의 대부분 컴포넌트는 수평적으로 규모 확장이 가능할 것으로 기대할 수 있다.
가용성을 향상시키기 위해서는 데이터를 여러 데이터센터에 다중화하는 것이 필요하다. 사용자는 네트워크 토폴로지 측면에서 보았을 때 자신과 물리적으로 가까운 메일 서버와 통신한다. 장애 때문에 네트워크 파티션(network partition), 즉 통신이 불가능한 네트워크 영역이 생기게 되면 사용자는 다른 데이터센터에 보관된 메시지를 이용한다.
면접장에서 시간이 된다면 아래 추가로 논의해볼 만한 주제도 언급하면 좋을 것 같다.