[Review] 시스템 디자인 스터디3 - 2주차 후기
안녕하세요!
K-DEVCON (이하 “데브콘”) 그로스 매니저 박종훈입니다.
어느새 곧 3월이 시작되려고 합니다.
이번 2회차 스터디에서는 “이메일 서비스” 의 디자인에 대해서 함께 이야기를 나눠보았습니다.
---
---
이메일 발송에 사용되는 SMTP 는 1981년에, 이메일 수신에 사용되는 POP은 1984년에 IMAP는 1986년에 처음 나온 프로토콜 입니다. 우리가 인터넷에서 가장 흔하게 사용하는 HTTP 이 1991년에 나온것 이란 것을 생각해보았을 때, 이메일 프로토콜들은 인터넷 역사 속에서 굉장히 오래 살아남은 프로토콜이라고 할 수 있습니다.
이메일 프로토콜들이 HTTP 보다 앞자리 포트를 할당받았다는 것에서도 오래 되었다는 것을 유추해볼 수 있습니다. (한 회원님이 이야기 해주신 재밌는 관점)
앞으로도 큰 변화가 있을 것 같지는 않지만, 이메일은 이미 우리 삶의 일부가 되었고 앞으로도 꾸준히 유지될 중요한 인터넷 인프라 중 하나라고 생각됩니다.
[0] 배경지식 - 이메일 프로토콜
서론에서 잠깐 언급 해두긴 했지만, 이메일에 사용되는 프로토콜은 크게 3가지 (SMTP, POP, IMAP)입니다. 이 중 SMTP 는 이메일 발신에 사용되며, POP 과 IMAP 은 이메일 수신에 사용됩니다. POP은 메일을 서버에서 내 기기로 다운로드 해오는 방식을 사용하고, IMAP은 외부 서버에 둔 채, 동기화하며 조회하는 방식을 사용합니다.
[1] 시스템 규모 추정과 개략적 디자인
살면서 이메일 서비스를 직접 구축해볼 만한 경험이 얼마나 있을까요? 흔치 않은 경험일 것이라고 생각됩니다.
지메일의 활성 사용자는 약 20억 명 이상, 아웃룩의 활성 사용자는 4억 명 이상을 보유하고 있다고 합니다. 책에서는 약 10억명의 사용자가 있는 서비스를 구상하였습니다.
사용자가 많긴 하지만, 그럼에도 구조는 단순합니다. 책에서는 아래와 같은 개략적 디자인을 제시하였습니다. 구조를 심플하게 두어 여러 서버로 분산할 수 있는 구조를 유지합니다.
![[Review] 시스템 디자인 스터디3 - 2주차 후기](https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/axxcchwrqnfq/b/k-devcon-dev/o/uploads%2F2026-03-08%2F206621d2-f4fc-45be-8fcf-eb5a707df0c0.webp)
![[Review] 시스템 디자인 스터디3 - 2주차 후기 - 이미지 2](https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/axxcchwrqnfq/b/k-devcon-dev/o/uploads%2F2026-03-08%2F3d886c90-fbfe-4aca-8b6a-d0671840db98.webp)
이 책에서 직접적으로 강조하지는 않지만 항상 포함되어 있는 컨셉중 하나는 변하는 데이터와 변하지 않는 데이터 나눠 보관 해야한다는 것입니다.
마찬가지로 이번 장에서도 메일 데이터와, 메타 데이터 나눠 보관 하는 것을 추천하고 있습니다.
책에서 제시한 기본적인 설계에 대해 다들 다른 의견은 없었기 때문에, 이번 시간에는 그 안에서 실제로 어떤 부분들을 더 고민해야 할지에 대해서 이야기를 나누어 보았습니다.
[2] 저장소 - 파일 시스템 구조
전통적 이메일 시스템은 파일 기반 저장 구조를 사용하였다고 합니다.
![[Review] 시스템 디자인 스터디3 - 2주차 후기 - 이미지 3](https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/axxcchwrqnfq/b/k-devcon-dev/o/uploads%2F2026-03-08%2Fba2716c8-5eb6-4d05-b28f-4c4f811ae11f.webp)
메일 서비스가 횟수 기반이 아닌 저장용량 공간 기반 과금 구조를 사용하는 이유가 여기에 있지 않을까 생각이 들었습니다. 또 오래된 오픈소스 시스템들(ex. 리눅스, OpenJDK 등)이 오랫동안 메일링 리스트로 협업해 온 것도 이러한 맥락이지 않을까 생각이 들었습니다. 메일을 파일형태로 관리하였기 때문에, 파일을 웹 서버에 올려 호스팅하면 그 자체로 아카이브 서비스가 되었을 것입니다.
[3] NoSQL 데이터 베이스 비정규화 (denomalization)
책에서 이메일 시스템을 위한 데이터 저장소로 NoSQL 을 사용하는 것을 추천합니다. NoSQL 은 분산 저장과 빠른 쓰기 기능를 제공해줄 수 있기 때문인데요
일반적으로 사용자가 메일함을 열면 가장 먼저 확인하는 것은 안 읽은 메일을 확인하는 것입니다.
이 조회가 빠르지 않으면 사용자 경험은 크게 저하될 수 있을 것입니다.
관계형 데이터베이스라면 `is_read` 컬럼에 인덱스를 걸어 해결할 수 있겠지만, 분산 NoSQL에서는 이런 보조 인덱스(Secondary Index) 사용이 제한적이거나 성능 비용이 크게 발생될 수 있습니다.
DynamoDB나 Cassandra 같은 분산 NoSQL 데이터베이스는 대규모 데이터의 분산 저장과 빠른 쓰기에 특화되어 있습니다. 하지만 이들은 파티션 키(Partition Key)와 클러스터링 키(Clustering Key) 기반으로만 효율적인 조회를 지원합니다. 즉, `WHERE is_read = false` 같이 파티션 키가 아닌 필드를 기준으로 필터링하는 쿼리는 전체 파티션을 스캔해야 하므로 매우 비효율적으로 될 수 있습니다.
이를 해결하기 위해 비정규화(Denormalization)를 사용하는 것을 권장합니다.
읽은 메일 테이블과 안 읽은 메일 테이블을 분리하여 관리합니다.
사용자가 메일을 읽으면 안 읽은 메일 테이블에서 해당 레코드를 삭제하고, 읽은 메일 테이블에 삽입합니다. 이로 인해 증가하는 복잡함에 비해 조회 성능에서 얻는 이득이 훨씬 크게 작용합니다.
[4] 스레드 구성
이메일을 사용하다 보면 이런 경험을 한 번쯤 해봤을 수 있습니다.
- 같은 대화인데 스레드가 분리되는 경우
- 전혀 다른 메일이 같은 스레드에 들어가는 경우
이는 이메일 스레딩이 생각보다 잘 표준화되어 있지 않기 때문인데요. 이메일 스레드를 구성하는 대표적인 두 가지 방식을 정리해보겠습니다.
JWZ 알고리즘 (Jamie Zawinski Threading)
Jamie Zawinski(Netscape 초기 개발자)가 제안한 스레딩 알고리즘으로, 이메일 헤더 정보를 기반으로 스레드를 구성합니다. 원리는 단순합니다.
핵심적으로 사용되는 헤더는 세 가지입니다.
- `Message-Id`: 각 메일의 고유 식별자
- `In-Reply-To`: 이 메일이 답장한 원본 메일의 Message-ID
- `References`: 대화 체인에 포함된 모든 메일의 Message-ID 목록
JWZ 알고리즘은 이 헤더들을 파싱하여 메일 체인을 트리 구조로 빌드합니다. 트리의 루트가 원본 메일이 되고, 각 답장이 자식 노드가 됩니다.
예시를 들면 다음과 같습니다.
📧 메일 A: 대화의 시작 (Root)
가장 먼저 작성된 메일이므로, 누군가를 참조하거나 답장하는 헤더가 없습니다.
Message-ID: <msg-101@company.com>
In-Reply-To: (없음)
References: (없음)
📧 메일 B: 메일 A에 대한 답장
메일 A의 ID를 부모로 지목합니다.
Message-ID: <msg-102@gmail.com>
In-Reply-To: <msg-101@company.com>
References: <msg-101@company.com>
📧 메일 C: 메일 B에 대한 답장
직전 메일인 B를 부모(In-Reply-To)로 삼으면서, 동시에 대화의 뿌리부터 지금까지 거쳐온 모든 ID를 References에 기록합니다.
Message-ID: <msg-103@mail.naver.com>
In-Reply-To: <msg-102@gmail.com>
References: <msg-101@company.com> <msg-102@gmail.com>
최종적으로 다음과 같은 구성이 됩니다.
[Root]
└── A: "점심 뭐 먹을까요?" (msg-101)
└── B: "Re: 점심 뭐 먹을까요?" (msg-102)
└── C: "Re: Re: 점심 뭐..." (msg-103)
이 구조는 입체적으로 이력을 나타낼 수 있는 장점이 있습니다. 하지만 모든 메일 클라이언트가 이 헤더들을 정확히 기록하는 것은 아닙니다. `References` 헤더를 포함하지 않거나 잘라서 일부만 보내거나, `In-Reply-To`를 잘못 설정하는 경우도 있습니다. 이런 상황에서 JWZ 알고리즘만으로는 스레드가 깨질 수 있습니다.
Gmail 방식
위에서 설명한 이유로 인해 Gmail은 JWZ 알고리즘에서 사용되는 헤더 정보를 완전 신뢰하지는 않았습니다. 그래서 제목을 사용한 그루핑 전략을 사용합니다.
- 제목 정규화: 제목에서 `Re:`, `Fwd:`, `RE:`, `FW:` 같은 접두사를 모두 제거한 뒤 비교합니다. 정규화된 제목이 동일하면 같은 대화로 간주합니다.
- 시간순 정렬 + 참조 헤더 조합: 헤더 정보에 더해 시간적 근접성도 고려합니다.
이 방식의 장점은 헤더가 불완전한 메일도 스레드에 포함시킬 수 있다는 것입니다. 하지만 관련 없는 메일이 제목이 비슷하다는 이유만으로 같은 스레드에 들어가는 경우가 발생할 수 있습니다. 예를 들어, 서로 다른 사람에게 "회의 일정 확인" 이라는 제목으로 메일을 보냈는데, Gmail이 이를 하나의 스레드로 합쳐질 수 있게 됩니다.
[5] 역인덱스
수백만 개의 이메일에서 원하는 메일을 빠르게 찾으려면 어떻게 해야할까요? 일반적인 index 조회로는 쉽지 않을 것 입니다.
그래서 제시된 것이 역인덱스(Inverted Index) 입니다. 일반적인 인덱스가 "문서 → 포함된 단어"의 매핑이라면, 역인덱스는 그 반대로 "단어 → 해당 단어를 포함하는 문서 목록"의 매핑을 가지고 있습니다.
"회의" → [mail_001, mail_045, mail_102, ...]
"보고서" → [mail_003, mail_045, mail_210, ...]
"김대리" → [mail_045, mail_078, ...]
검색시 역인덱스를 활용해 키워드를 포함하는 메일 ID 목록을 즉시 반환합니다.
elastic search 가 위 방식을 활용하여 빠른 검색 기능을 제공합니다.
번외 1 : 메일 읽음 확인
이메일 표준 스펙에는 읽음 확인 기능이 없습니다. 그럼에도 종종 이 기능을 제공하는 곳들을 볼 수 있습니다. 어떻게 제공해주는 걸까요?
사실은 편법에 가까운 방식을 사용합니다. 동작 원리는 간단합니다. 메일 본문 끝에 눈에 보이지 않는 1픽셀짜리 투명 이미지를 삽입합니다. 수신자가 메일을 열면 이미지가 발신자의 서버에서 로드되고, 이 요청을 감지하여 "읽음"으로 판단처리 합니다.
사실은 편법에 가까운 방식을 사용합니다. 동작 원리는 간단합니다. 메일 본문 끝에 눈에 보이지 않는 1픽셀짜리 투명 이미지를 삽입합니다. 수신자가 메일을 열면 이미지가 발신자의 서버에서 로드되고, 이 요청을 감지하여 "읽음"으로 판단처리 합니다.
하지만 이 방식에도 아쉬운 점이 있는데요
메일 클라이언트들에서 프라이버시 보호를 위해 외부 이미지 자동 로드를 차단하거나 프록시 서버를 통해 로드하는 경우가 있습니다. 이 경우 읽음 추적이 작동하지 않을 수 있습니다.
또 메일을 포워딩하면 HTML 내의 이미지 경로는 그대로 유지됩니다. 따라서 포워딩 받은 엉뚱한 사람이 메일을 열어도 원래 발신자 입장에서는 읽음으로 보일 수 있습니다.
마찬가지로 수신자가 여러 명일 경우 여러 명에게 보낸 메일에서 누가 읽었는지 정확히 구분하기 어렵습니다.
번외 2 : 메일 전송 취소
Gmail 이나 일부 메일 서비스에서는 전송을 취소할 수 있는 기능을 제공합니다. 이 기능은 어떻게 동작하는 걸까요? 여기에는 두 가지 시나리오가 있습니다.
같은 메일 서비스 내 전송 (예: Gmail → Gmail)
같은 서비스 내에서는 메일을 외부로 보내지 않고 내부 서버에서 처리할 수 있습니다. 이 경우 서버 내부에서 메일을 삭제하거나 상태를 변경하는 것이 기술적으로 가능할 것입니다.
다른 메일 서비스 간 전송 (예: Gmail → Naver)
SMTP 프로토콜을 통해 외부 서버로 이미 전달된 메일은 회수가 불가능합니다. 상대방 서버에 저장된 데이터를 원격으로 삭제할 수 있는 표준은 존재하지 않습니다. 이 경우에는 지연 발송을 사용합니다.
Gmail의 '실행 취소'는 사용자가 설정한 시간(5초, 10초, 20초, 30초) 동안 메일을 실제로 보내지 않고 서버에서 대기시키는 방식으로 구현되어 있습니다. 사용자가 "보내기" 버튼을 누르면 화면에는 발송된 것처럼 보이지만, 실제로는 아직 SMTP 전송이 시작되지 않은 상태입니다.
설정된 대기 시간이 지나거나 취소 토스트가 사라지거나 페이지를 이탈하면 발송이 진행됩니다.
번외 3 : 스팸 처리
전 세계 이메일의 약 50%가 스팸이라는 충격적인 통계가 있습니다. 이를 뚫고 정당한 메일을 보내기 위해선 '평판'이 필요합니다.
기술적 인증: 내 메일이 진짜임을 증명하기 위해 SPF, DKIM, DMARC 같은 인증 레코드를 DNS에 등록하는 것은 이제 필수입니다.
1. SPF (Sender Policy Framework)
"누가 보낼 수 있는지" 적어둔 화이트리스트입니다. 메일 서버의 DNS 설정에 **"우리 회사 메일은 A와 B 서버에서만 보낼 거야"**라고 미리 등록해두는 방식입니다. 수신측 서버는 메일을 받으면 "어? 명단에 없는 서버에서 왔네?" 하고 가짜임을 의심합니다.
2. DKIM (DomainKeys Identified Mail)
메일 내용에 찍는 전자 인감 도장입니다. 메일을 보낼 때 사용자에게는 보이지 않는 암호화된 디지털 서명을 포함합니다. 수신측은 이 서명을 검증해서 **"중간에 내용이 위변조되지 않았는지"**와 "진짜 그 도메인 주인이 보낸 게 맞는지" 확인합니다.
3. DMARC (Domain-based Message Authentication, Reporting, and Conformance)
"SPF나 DKIM이 실패하면 어떻게 처리할지" 결정하는 가이드라인입니다. "그냥 받아줄지(none)", "스팸함으로 보낼지(quarantine)", "아예 거절할지(reject)"를 서버 주인에게 알려주는 최종 규칙입니다.
발신자 웜업(Warm-up): 새 도메인으로 발송량을 서서히 늘리며 신뢰도를 쌓아야 합니다. (대량 메일을 보내려면 최소 2주에서 6주 정도가 필요합니다.)
—
스터디에서 더 많은 이야기들을 함께 나눠보았지만, 리뷰글은 여기서 마무리 해보겠습니다.
우리 스터디에서는 실제 사례들을 통해 문제를 어떻게 해결해 나갔을까 많은 고민을 해보고 이야기를 나눠보고 있습니다. 이번주도 다들 열심히 참여해주셔서 더 풍성한 스터디가 될 수 있었습니다.
![[Review] 시스템 디자인 스터디3 - 2주차 후기 - 이미지 4](https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/axxcchwrqnfq/b/k-devcon-dev/o/uploads%2F2026-03-08%2F9f0af068-3d86-481b-b82e-7963d376d034.webp)
✅ 직접 스터디를 개설해보고 싶은 분이 계시다면, K-DEVCON에서 운영을 도와드리겠습니다. 데브콘의 '랩짱'에 도전하여 커뮤니티 성장을 함께 이끌어주세요! 슬랙을 통해 운영진에게 DM 부탁드리겠습니다😉 ..