[Review] 시스템 디자인 스터디 후기 - 분산 이메일 서비스

[대규모 시스템 설계 기초 2 - 분산 이메일 서비스]


이번 스터디 주제는 분산 이메일 서비스 설계다. 처음 챕터를 읽으면서 "우리가 이메일 서비스를 직접 설계할 일이 얼마나 있을까?"라는 현실적인 의문이 들었는데, 스티비 같은 이메일 마케팅 SaaS를 만든다거나 이메일 클라이언트를 구성하는 경우처럼 생각보다 응용 범위가 넓다는 이야기를 나눴다. 다소 낯선 주제였지만 이야기를 나눠보니 실무와 맞닿아 있는 부분이 꽤 많았다. 오늘은 책에 나온 분산 이메일 서비스에 대한 내용을 공부하고 나눈 이야기에 대해 정리했다.


1. 규모 추정

책에 나온 지메일 사용자 수는 2020년 기준 약 18억 명이고, 현재는 약 20억 명 수준으로 알려져 있다. 국내에는 약 1,500만 명이 사용 중이라고 한다. 설계 문제에서는 보통 10억 명 정도를 기준으로 시작한다. 이메일 서비스의 요금 모델이 횟수 기반이 아니라 용량(스토리지) 기반이라는 점도 이야기했는데, 이메일이 파일 시스템처럼 저장소 중심으로 설계되었기 때문에 자연스럽게 용량 기준의 과금 구조를 갖게 되었다는 맥락으로 이해할 수 있다.

2. 프로토콜 이야기

HTTP vs SMTP
책에서는 클라이언트와 웹 서버 사이의 통신에는 HTTP를 사용하고, 서버 간 또는 외부 메일 서버와의 통신에는 SMTP를 사용하는 구조를 가정한다. 단, 내부 메일 서버끼리는 HTTP로 통신하더라도, 외부로 메일을 전송할 때는 반드시 SMTP를 사용하기 때문에 "SMTP를 써야 진짜 이메일"이라는 의견도 나왔다.

SMTP가 HTTP보다 먼저 나온 프로토콜이다. 웹이 없던 시절에도 이메일은 존재했다.
SMTP는 초기에는 25번 포트를 사용하다가 현대에 와서는 암호화된 이메일 전송용 포트로  465, 587 번을 사용한다고 한다

POP / POP3 / IMAP

POP(Post Office Protocol)
는 메일 수신 프로토콜의 초기 버전으로, 이메일을 서버에서 클라이언트로 내려받는 방식이다. 현재 사실상 표준으로 사용되는 버전은 POP3로, POP의 3번째 개정판이다. IMAP(Internet Message Access Protocol) 은 POP의 한계를 보완하기 위해 만들어진 프로토콜로, 이메일을 서버에 남겨둔 채 여러 기기에서 동기화하며 접근할 수 있다.

POP는 서버 용량이 비쌌던 시절, 이메일을 내려받은 뒤 서버에서 삭제하는 방식으로 서버 스토리지를 아끼기 위해 설계되었다. POP3부터는 서버에 사본을 남기는 옵션이 추가되었다. IMAP이 등장하면서 멀티 디바이스 환경에서의 동기화, 폴더 관리, 미리보기 등 더 풍부한 기능을 제공하게 되었고, 현재는 대부분의 메일 클라이언트가 IMAP을 권장한다.

이메일 서비스가 IMAP을 제한하는 이유
IMAP은 POP3보다 서버 부하가 훨씬 크다. POP3는 메일을 내려받고 연결을 끊지만, IMAP은 클라이언트가 서버와 상태를 상시 동기화해야 하기 때문에 커넥션을 유지하고, 폴더 구조·읽음 상태·플래그 등을 실시간으로 반영해야 한다. 이 때문에 IMAP은 서버 입장에서 스토리지뿐 아니라 CPU, 메모리, 네트워크 비용까지 더 많이 소모한다.
이런 이유로 일부 서비스는 IMAP을 유료 플랜으로 제한하거나, 보안 강화를 명목으로 접근 조건을 까다롭게 운영한다.

결국 IMAP 제한은 기술적 부하와 비즈니스 모델이 결합된 결과다. IMAP을 무제한으로 허용하면 외부 클라이언트를 통한 접근이 늘어나고, 서비스 사업자 입장에서는 자사 웹/앱 트래픽이 줄어드는 동시에 서버 부하만 늘어나는 구조가 된다.

ActiveSync
ActiveSync는 1996년에 처음 출시된 마이크로소프트의 모바일 동기화 프로토콜로, 메일·캘린더·연락처 등을 모바일 기기와 동기화하는 용도로 설계되었다. 오래된 기술인 만큼 일부 환경에서 연동이 끊기는 문제가 발생하는 경험담이 공유되었다. 또한 아웃룩은 기본 보안 정책으로 외부 이미지 자동 로드와 스크립트를 차단한다. 이 때문에 이메일 디자인을 이미지 기반이 아닌 HTML/CSS 기반으로 재작업한 사례도 공유되었다.

3. 저장소 구조와 비정규화


이 책에서는 이메일을 저장하는 데이터베이스를 NoSQL로 가정했다. 이유는 읽는 것 보다 쓰는 작업이 많은 이메일 서비스의 워크로드 특성때문이다.

읽음/안읽음 테이블 분리
NoSQL 환경에서는 조회 조건으로 쓸 수 없는 필드를 기준으로 필터링하는 것이 불가능하다. 예를 들어, 카산드라나 DynamoDB에서는 클러스터링 키나 파티션 키가 아닌 컬럼에 WHERE 조건을 걸 수 없다. 이 때문에 책에서는 읽은 메일(read_emails) 테이블과 안 읽은 메일(unread_emails) 테이블을 분리하는 방식을 제안한다. 컬럼 구조가 동일한데도 테이블을 나누는 것이 처음엔 이상해 보이지만, 분산 DB에서 쿼리 효율을 높이기 위한 의도적인 비정규화다. 실제로 실무에서 사용되는 패턴이며, 조회가 많은 환경에서 성능을 위해 의도적으로 데이터를 중복 저장하는 패턴이다.

파티션 키 vs 샤드 키
두 개념이 혼용되는 경우가 많아 이번에 정리했는데, Cassandra의 파티션 키는 이름은 파티션이지만 실제로는 샤드 키처럼 동작한다고 한다.

MongoDB Time Series Collection과 metaField
분산 DB에서의 데이터 배치 전략과 관련해 실제로 실무에서 사용했던 MongoDB의 Time Series Collection을 공유했다.

Time Series Collection은 시계열 데이터(센서, 로그, 주가 등 시간 순서로 쌓이는 데이터)를 효율적으로 저장하기 위한 MongoDB의 특수 컬렉션으로, 세 가지 핵심 필드로 구성된다.
// 예시: IoT 센서 데이터
{
  timestamp: ISODate("2024-08-01T18:00:00Z"),  // timeField (수정 불가)
  sensor: { id: "sensor_A", location: "Seoul" }, // metaField (수정 가능)
  temperature: 25.3,  // measurement
  humidity: 60.1      // measurement
}


왜 timeField는 수정이 안 될까?
시계열 데이터의 본질이 "특정 시점에 기록된 사실"이기 때문이다. 과거의 측정값을 바꾸는 것은 데이터 무결성 위반에 해당하므로, MongoDB가 아예 언어 레벨에서 막아버린 것이다.

metaField가 같은 데이터끼리 물리적으로 모인다 — 버킷(Bucket) 구조
MongoDB는 Time Series Collection의 데이터를 내부적으로 버킷(Bucket) 단위로 묶어서 저장한다.
버킷은 다음 두 조건을 동시에 만족하는 문서들의 묶음이다.
sensor_A의 데이터 → 같은 버킷에 모여서 저장
sensor_B의 데이터 → 별도 버킷에 저장

[ 버킷 1: sensor_A, 18:00~18:59 데이터 1000건 ]
[ 버킷 2: sensor_B, 18:00~18:59 데이터 1000건 ]
[ 버킷 3: sensor_A, 19:00~19:59 데이터 1000건 ]


이것이 Cassandra의 파티션 키와 같은 철학이다.
 "자주 같이 조회되는 데이터는 물리적으로 가깝게 저장한다"

metaField 기준으로 데이터를 모아두면 "sensor_A의 최근 1시간 데이터"를 조회할 때 전체를 스캔하지 않고 해당 버킷만 읽으면 된다.

샤딩 환경에서의 metaField
분산 환경으로 확장하면, MongoDB 8.0부터는 timeField를 샤드 키로 쓰는 것이 더 이상 권장되지 않는다. 이유는 시간은 단조 증가(monotonically increasing)하기 때문에, timeField를 샤드 키로 쓰면 새로운 데이터가 항상 특정 노드 하나에만 몰려 핫스팟(Hotspot) 이 생기기 때문이다. 대신 metaField를 샤드 키로 사용해야 데이터가 여러 노드에 고르게 분산된다.
결국 Time Series Collection의 metaField는 단일 인스턴스 내 버킷 배치부터 샤딩 환경의 노드 분산까지, 데이터 배치 전략의 핵심 기준이 된다.

4. Elasticsearch와 LSM 트리


이메일에서의 검색 시스템은 보통 제목이나 본문에 특정 키워드가 포함되어있는지 찾는 기능을 제공한다. 검색 기능을 제공하려면 이메일이 전송, 수신, 삭제될 때마다 인덱싱 작업을 수행해야하는데, 실제 검색은 사용자가 검색이라는 버튼 등을 누를 때만 실행된다. 이 때문에 이메일 시스템의 검색 기능은 쓰기 연산이 읽기 연산보다 훨씬 많이 발생한다. 

이 책에서는 검색기능을 제공하기 위해 엘라스틱 서치와 DB 저장소에 이미 내장된 기본 검색 기능을 활용하는 방법 두 가지를 제시했다.

Elasticsearch 동기화 이슈
책에서는 이메일 검색에 Elasticsearch(또는 OpenSearch)를 활용하는 방식을 다룬다. 실무에서도 비슷한 구조를 운영하는 경험담이 나왔는데, 주요 이슈는 메인 DB와 ES 인덱스의 동기화다.

ES가 다운되면 재기동 시 모든 인덱스를 메모리에 다시 올려야 해서 복구에 몇 시간이 걸리는 경험도 공유되었다.

ES 미싱 데이터 검색 시 온디맨드 인덱싱

사용자 검색 요청
    ↓
ES에서 검색 → 결과 없음 (아직 싱크 안 됨)
    ↓
원본 DB에서 fallback 조회
    ↓
그 결과를 ES에 즉시 인덱싱 (온디맨드)
    ↓
다음 검색부터는 ES에서 바로 조회 가능

배치 동기화 주기 때문에 원본 DB에는 있지만 아직 ES에 싱크되지 않은 데이터가 존재할 수 있다. 실제 싱크되지 않은 데이터에 대한 검색을 진행하면 원본 디비에서 fallback 조회를 한 후 ES에서 인덱싱이 일어나서 검색 응답이 느려진다는 사례가 공유되었다.
 
Lucene의 핵심 자료구조: 역색인(Inverted Index)
Elasticsearch는 내부적으로 Apache Lucene을 사용한다. Lucene의 핵심 자료구조는 역색인(Inverted Index)이다. 일반 인덱스가 "문서 → 단어" 방향이라면, 역색인은 "단어 → 문서 목록" 방향으로 저장한다. 이는 특정 키워드가 제목이나 본문에 포함되어있는 이메일을 찾는 이메일 검색 시스템에 유용하게 활용될 수 있다.

일반 인덱스:  문서1 → ["hello", "world"]
역색인:       "hello" → [문서1, 문서3, 문서7]
              "world" → [문서1, 문서5]

"hello"를 검색하면 역색인에서 바로 [문서1, 문서3, 문서7]을 꺼낼 수 있어서 전체 스캔 없이 빠르게 검색된다. 이것이 RDB의 LIKE '%keyword%' 보다 ES 검색이 훨씬 빠른 이유다.


LSM 트리 (Log-Structured Merge-Tree)
검색 시스템 구현을 위해 데이터 베이스에 내장된 기본 검색 기능을 사용하는 방안으로 소개되었다. LSM 트리의 핵심 아이디어는 쓰기 성능 최적화다.

  1. 데이터를 메모리(MemTable)에 먼저 쓴다
  2. 일정 크기가 되면 디스크(SSTable)로 플러시한다
  3. 주기적으로 Compaction을 통해 여러 SSTable을 병합한다

이는 기존 B-Tree 계열 인덱스가 랜덤 쓰기에 최적화된 것과 달리, 검색 엔진처럼 쓰기가 매우 빈번한 워크로드에 적합하다. 카프카의 로그 저장 방식과 유사한 append-only 철학이기도 하다. 읽기 성능을 보완하기 위해 블룸 필터(Bloom Filter) 를 활용해 특정 데이터가 SSTable에 존재하는지 빠르게 판단할 수 있다.

관련 DB로는 카산드라, 구글의 LevelDB, 페이스북이 포크한 RocksDB 등이 있다. RocksDB는 카프카가 내부적으로 사용하는 것으로도 알려져 있다.


Lucene의 Segment 구조와 LSM과의 유사성

Lucene은 데이터를 Segment 단위로 저장한다. Segment의 동작 방식이 LSM 트리와 유사하다.
  1. 새로 들어온 문서는 메모리 버퍼에 먼저 쌓인다.
  2. 일정 크기가 되면 디스크에 새 Segment로 flush된다.
  3. 주기적으로 여러 Segment를 하나로 Merge한다. (LSM의 Compaction과 유사)
  4. 한번 쓴 Segment는 불변(immutable)이다 — 수정 대신 삭제 마킹 후 재삽입

LSM의 SSTable, Compaction과 개념이 거의 같다. 다만 Lucene의 핵심 목적은 LSM처럼 "쓰기 최적화"가 아니라 역색인 기반의 전문 검색(Full-text Search) 이기 때문에, "Lucene = LSM 트리"라고 단정짓기보다는 "LSM과 유사한 Segment 구조를 채택한 역색인 엔진"으로 이해하는 게 정확하다.