문제

게임을 운영하다 보면 전체 유저에게 우편을 보내야 할 일이 많다. 점검 보상, 이벤트 보상, 긴급 공지 등. 가장 직관적인 방법은 모든 유저의 우편함에 하나씩 복사하는 것이다. 하지만 본인이 운영하는 서비스의 경우, 누적 유저 90만 명, MAU 10만 명 규모에서 전체 우편 하나 보낼 때마다 90만 번의 DB Write를 발생시키는 건 미친 짓이다. 비용도 비용이지만, 그 중 대부분은 접속조차 하지 않는 유저다.

아이디어: 공통 우편함을 분리하자

핵심 아이디어는 간단하다. 우편함을 두 종류로 나누는 것이다.

  • 개인 우편함(MailBox): 유저별로 존재하는 기존 우편함. 1:1 우편이 저장된다.
  • 공통 우편함(CommonMailBox): 전체 우편만 모아둔 단일 저장소. 유저별로 존재하지 않는다.

전체 우편을 보낼 때, 서버는 공통 우편함에 딱 한 번만 Write한다. 유저 수와 무관하게 Write는 1회로 끝난다. 클라이언트가 공통 우편함에 요청을 보내면, 아직 수신하지 않은 전체 우편을 자신의 개인 우편함으로 가져오는 구조다.

설계

Mail 클래스

개인 우편함에 저장되는 기본 우편 모델이다.

mail.dart
class Mail {
  final String id;
  final int? commonMailIndex; // 전체 우편 유래 시 해당 인덱스
  final String title;
  final String content;
  final List<MailAction> actions;
}

commonMailIndex는 이 우편이 전체 우편에서 유래했는지를 추적하는 필드다. 일반 1:1 우편이면 null이고, 전체 우편에서 변환된 것이면 해당 전체 우편의 인덱스 값이 들어간다.

CommonMail 클래스

공통 우편함에 저장되는 전체 우편 모델이다.

common_mail.dart
class CommonMail {
  final int index;            // 전체 우편 고유 인덱스 (순차 증가)
  final DateTime? validUntil; // 이 우편의 유효 기한
  final String title;
  final String content;
  final List<MailAction> actions;
 
  String get id => 'common$index';
 
  Mail toMail() => Mail(
    id: id,
    commonMailIndex: index,
    title: title,
    content: content,
    actions: actions,
  );
}

핵심은 index 필드다. 전체 우편은 발행될 때마다 순차적으로 증가하는 인덱스를 부여받는다. 클라이언트는 “내가 마지막으로 수신한 인덱스”만 기억하면, 그 이후에 발행된 전체 우편만 골라서 가져올 수 있다.

인덱스 카운터의 원자성

index는 전체 우편의 순서를 보장하는 유일한 기준이다. 동시에 여러 전체 우편이 발행될 때 같은 인덱스가 부여되면, 클라이언트가 우편을 누락하거나 중복 수신할 수 있다. 따라서 이 카운터의 원자적 증가(atomic increment)가 반드시 보장되어야 한다. Firebase라면 Transaction, SQL DB라면 AUTO_INCREMENT나 Sequence를 사용하는 식으로 인덱스 충돌을 원천 차단해야 한다.

데이터 구조

commonMailBox/              ← 전체 유저 공용 (단일)
  0: { CommonMail }
  1: { CommonMail }
  2: { CommonMail }

mailBox/                    ← 유저별 개인 우편함
  {uid}/
    abc123: { Mail }        ← 1:1 우편
    common0: { Mail }       ← 전체 우편에서 변환된 우편
    common1: { Mail }

공통 우편함은 앱 전체에서 하나만 존재하고, 개인 우편함은 유저마다 하나씩 존재한다. 전체 우편이 개인 우편함으로 들어오면 common{index} 형태의 ID를 가지게 되어, 일반 우편과 구분된다.

동작 흐름

  1. 관리자가 전체 우편을 발행하면, 공통 우편함에 새 CommonMail이 추가된다. (Write 1회)
  2. 클라이언트가 공통 우편함에 수신 요청을 보낸다. 이때 “마지막으로 수신한 인덱스”를 함께 전달한다.
  3. 공통 우편함에서 해당 인덱스 이후의 유효한 전체 우편 목록이 반환된다.
  4. 클라이언트는 반환된 전체 우편을 CommonMail.toMail()로 변환하여 자신의 개인 우편함에 저장한다.
  5. 마지막으로 수신한 인덱스를 갱신한다.

이후 유저는 개인 우편함에서 일반 우편과 동일하게 전체 우편을 열람하고, 보상을 수령할 수 있다.

왜 효율적인가?

Write 비용 절감

전체 우편 발행 시 서버 Write는 유저 수와 무관하게 1회다. 유저 10만 명에게 보내도 DB Write는 1번이면 된다. Naive 방식 대비 Write 비용이 에서 로 줄어든다.

단일 카운터 추적

클라이언트는 “마지막으로 수신한 인덱스”라는 정수 하나만 관리하면 된다. 복잡한 중복 제거 로직이 필요 없고, 인덱스 비교 한 번으로 미수신 우편을 판별할 수 있다.

Lazy 전달

유저가 실제로 요청할 때만 전체 우편이 개인 우편함으로 복사된다. 접속하지 않는 유저에게는 아무 비용도 발생하지 않는다. 이탈 유저 비율이 높을수록 절감 효과가 커진다.

자동 만료

validUntil을 지나면 해당 전체 우편은 더 이상 배포되지 않는다. 오래된 전체 우편이 무한히 쌓이는 것을 방지하고, 클라이언트가 불필요한 데이터를 가져오지 않도록 한다.

마무리

“모두에게 보내는 우편”의 핵심은, 실제로 모두에게 보내지 않는 것이다. 공통 저장소에 한 번 쓰고, 각 클라이언트가 필요할 때 가져가게 하면 된다. 서버의 부담은 유저 수에 비례하지 않고, 전체 우편의 개수에만 비례한다.