티스토리 뷰

연습

Batch 메시징 설계기

onaeonae1 2025. 11. 16. 19:21

본 글은 과거 러프하게 작성한 글을 보강한 것이다.

 

새로 합류한 회사에서는 두 가지 성격이 다른 메시징 기능이 필요했다.

  • 경영성과 분석 서비스(이하 성과 서비스) → 매일 아침 성과 요약 리포트를 받아보고 싶은 수요
  • 재고/SCM 서비스 → 협업 과정에서 발생하는 이벤트를 실시간으로 공유하고 싶은 수요

두 기능은 모두 “알림”이지만, 처리 방식과 요구사항이 전혀 달랐다.
그래서 메시징을 하나로 뭉개기보다는 Batch 메시징과 Event 메시징을 분리해서 설계했다.

이 글은 그 중에서,
성과 서비스에서 사용한 Batch 메시징 설계에 대한 이야기(1편)이다.


1. Batch 메시징이 필요했던 배경

성과 서비스에서는 매일 새벽에 Batch Job이 돌아가 고객사의 매출·마케팅 등의 지표를 수집하고,
이를 기반으로 여러 종류의 차트와 테이블을 만든다.

문제는 여기서부터였다.

  • OWNER(고객사 책임자)는 “보고서를 만든 사람”이지, “전부 다 받는 사람”이 아니다.
  • 실무자들은 대시보드를 열어볼 시간은 없지만,
    아침에 한 번에 정리된 요약만 보고 하루를 시작하고 싶어 한다.
  • 사람마다 받고 싶은 내용(성과요약 / 맵핑 요약), 채널(이메일 / 카카오), 순서, 시간, 주말 수신 여부가 모두 다르다.

필요했던 것은 결국 다음과 같다

 

“OWNER가 정의한 보고서들을 바탕으로, 각 사용자에게 맞는 형태의 성과 요약 메시지를 매일 아침 자동으로 보내는 시스템”


2. 도메인 모델 설계

이 요구사항을 풀기 위해 다음 여섯 개의 모델을 사용했다.

  • ReportProfile – 사용자별 알림 프로필
  • ReportDashboardSetting – 대시보드(=고객사 단위) 알림 설정
  • ReportContentSetting – 사용자별 컨텐츠 타입 / 채널 설정
  • PerformanceSummary – OWNER가 정의한 성과 요약 보고서
  • PerformanceSummaryAssignee – 성과 요약 보고서의 수신 대상자
  • MappingSummaryNotification – 맵핑 테이블 요약 수신 설정

이 여섯 개가 합쳐져서, 다음을 결정한다.


"어떤 사용자가 / 어떤 대시보드에서 / 어떤 내용을 / 어떤 채널로 / 어느 시간에 받을지”

 

하나씩 역할만 짚고 넘어가 보자.

 

2.1 ReportProfile – “사용자 알림 프로필”

class ReportProfile(TimestampMixin):
    user_client = models.OneToOneField(UserClientRelation, on_delete=models.CASCADE, unique=True)
    phone_number = models.CharField(max_length=30, null=True, blank=True)
    notification_time = models.TimeField(default="09:00:00", choices=NOTIFICATION_TIME_CHOICES)
    weekend_receive = models.BooleanField(default=False)
  • UserClientRelation(=고객사 기준 사용자) 당 하나의 ReportProfile
  • 이 프로필에 사용자별 알림 시간, 전화번호, 주말 수신 여부가 묶인다.
  • 성과 메시지는 이 notification_time을 기준으로 Batch 발송이 스케줄링 된다.

“언제, 어디로 보낼 것인가”의 기반 정보.

2.2 ReportDashboardSetting – “대시보드 전체에 대한 기본 채널 ON/OFF”

 
class ReportDashboardSetting(TimestampMixin):
    dashboard = models.OneToOneField(Dashboard, on_delete=models.CASCADE)
    receive_email = models.BooleanField(default=True)
    receive_kakao = models.BooleanField(default=True)
  • 고객사/대시보드 단위로 채널의 기본 허용 여부를 관리한다.
  • 회사 정책상 “이 고객사는 카카오 알림톡은 쓰지 않는다” 같은 경우 여기서 차단.

“이 대시보드에서 이메일/카카오를 수신하는지”를 결정하는 전역 설정.

 

2.3 ReportContentSetting – “사용자별 컨텐츠 타입 + 채널 설정”

class ReportContentSetting(TimestampMixin):
    profile = models.ForeignKey(ReportProfile, on_delete=models.CASCADE)
    content_type = models.CharField(max_length=20, choices=ContentType.choices)
    receive_email = models.BooleanField(default=True)
    receive_kakao = models.BooleanField(default=False)
  • 각 ReportProfile 에 대해 content_type별로 수신/채널 설정을 갖는다.
  • content_type은 크게 보면 두 가지 카테고리:
    • PERFORMANCE_SUMMARY (성과 요약)
    • MAPPING_SUMMARY (맵핑 테이블 요약)
  • 예를 들어,
    • A 사용자는 성과 요약만 이메일로 받고
    • B 사용자는 성과 요약은 카카오로, 맵핑 요약은 이메일로 받게 설정할 수 있다.

“이 사용자는 어떤 종류의 리포트를 어떤 채널로 받을 것인가”를 정의.

2.4 PerformanceSummary – “OWNER가 만든 성과 요약 보고서 정의”

class PerformanceSummary(TimestampMixin):
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
    name = models.CharField(max_length=255)
    metric = models.CharField(max_length=30, choices=Metric.choices)
    group = models.CharField(max_length=255, null=True, blank=True)
    lookback_start = models.IntegerField(choices=LookbackPeriod.choices)
    lookback_size = models.IntegerField()
    prefilters = models.JSONField(null=True, blank=True)
    order = models.IntegerField()
 
  • OWNER가 “어떤 관점으로 성과를 보고 싶다”를 정의하는 엔티티.
  • e.g):
    • “지난 7일 동안의 매출을 브랜드별로 보고 싶다”
    • “지난 30일 동안의 매출 기여도를 채널별로 보고 싶다”
  • 이 정보는 Batch Job에서 집계를 수행할 때 직접 사용된다.
  • order는 나중에 여러 요약이 동시에 발송될 때의 정렬 우선순위 역할도 겸한다.

성과 데이터를 어떻게 집계할지를 정의하는 설계도.

2.5 PerformanceSummaryAssignee – “이 보고서를 누가 받을 것인가”

class PerformanceSummaryAssignee(TimestampMixin):
    performance_summary = models.ForeignKey(PerformanceSummary, on_delete=models.CASCADE)
    profile = models.ForeignKey(ReportProfile, on_delete=models.CASCADE)
    do_receive = models.BooleanField(default=False)
    order = models.IntegerField(null=True)
 
  • 특정 PerformanceSummary, 특정 ReportProfile 조합에 대해:
    • 이 사용자가 이 요약을 받을지(do_receive)
    • 이 사용자의 메시지 안에서 이 요약의 순서를 어디에 둘지(order)
  • OWNER가 만든 성과 요약들을 팀 구성원에게 할당하는 구조다.

“이 보고서가 누구에게 가야 하는가 + 그 사람 메시지 안에서 몇 번째에 와야 하는가”를 정의.

2.6 MappingSummaryNotification – “맵핑 요약 수신 여부”

 
class MappingSummaryNotification(TimestampMixin):
    profile = models.ForeignKey(ReportProfile, on_delete=models.CASCADE)
    mapping_type = models.CharField(max_length=50, choices=MappingType.choices)
    do_receive = models.BooleanField(default=True)
  • 맵핑 요약(예: 상품 매핑, 채널 매핑 등)에 대해
    • 어떤 프로필이 어떤 mapping_type 을 받을지/말지 설정.
  • 성과 요약과는 독립적인 라인으로 동작하지만,
    최종 메시지에서는 함께 병합되어 한 번에 발송될 수 있다.

“맵핑 관련 리포트도 사용자 단위로 개별 컨트롤”하기 위한 모델.


3. 핵심 아이디어: “Report 단위로 Redis에 올리고, 사용자별로 병합해서 꺼낸다”

이제 Batch 메시징의 핵심 구조를 한 줄로 요약하면 이렇다.

 

각 성과 요약(PerformanceSummary)과 맵핑 요약(MappingSummary)을

“Report 단위”로 Redis에 저장해 두고,
Batch 발송 시점에 각 사용자의 구독 정보에 맞게 병합해서 가져온다.

 

여기서 말하는 “Report 단위”는 대략 이런 구조를 가진다:

{
  content_type: "PERFORMANCE_SUMMARY" | "MAPPING_SUMMARY",
  title: "...",
  created_at: "...",
  priority: 1,
  columns: [...],
  rows: [[...], [...], ...],
  (성과 요약 한정) lookback_label: "...",
  (성과 요약 한정) metric_label: "..."
}
 
 

실제 코드에서는 이런 형태를 ReportsRedisData 같은 dataclass로 감싸고,
as_json_str() / from_json_str() 로 직렬화/역직렬화를 처리했다.

Redis 저장 키는 예를 들면 이런 식이다.

 
report:{client_id}:{content_type}:{summary_id}

이렇게 해두면:

  • 데이터 생성 단계(집계)는 사용자와 완전히 분리해서 설계할 수 있고,
  • 발송 단계에서는 각 사용자의 구독 정보를 기준으로 필요한 Report만 골라 병합하면 된다.

4. Batch 메시징 전체 흐름(설계 관점)

설계 관점에서 Batch 메시징의 흐름을 정리하면 다음과 같다.

4.1 1단계 – Summary / Mapping 결과 생성 → Redis 적재

  1. 성과 요약(PerformanceSummary) 정의를 기준으로 집계를 수행
  2. 맵핑 요약(MappingType) 결과도 같은 방식으로 집계
  3. 각각의 결과를 Report 포맷(=ReportsRedisData)으로 변환
  4. report:{client_id}:{content_type}:{summary_id} 키로 Redis에 저장

이 단계에서는 아직 “누가 받는지”에 대해 전혀 신경 쓰지 않는다.
오로지 “보고서별 결과를 빠르게 가져올 수 있게 만들어 두는 것”이 목적이다.

4.2 2단계 – 사용자 기준으로 받을 Report 목록 계산

사용자별 메시지를 만들기 위해, 각 ReportProfile에 대해 다음 정보를 조회한다.

  1. PerformanceSummaryAssignee
    • 이 프로필이 어떤 PerformanceSummary를 받을지
    • 각 Summary가 이 사람 메시지 안에서 몇 번째에 올지(order)
  2. MappingSummaryNotification
    • 이 프로필이 어떤 mapping_type 을 받을지
  3. ReportContentSetting
    • 이 프로필이 PERFORMANCE_SUMMARY / MAPPING_SUMMARY 를
      이메일 / 카카오 중 어디로 받을지
  4. ReportDashboardSetting
    • 이 대시보드에서 이메일/카카오 자체가 허용되는지
  5. ReportProfile 자체 설정
    • notification_time, weekend_receive 등

이 정보들을 조합해,

  • “이 사용자가 이메일로 받을 Report 목록”
  • “이 사용자가 카카오로 받을 Report 목록”

을 계산한다.
이때 Summary별 order, MappingSummary의 priority 등을 이용해서 정렬 순서를 함께 결정한다.

4.3 3단계 – Redis에서 Report들을 가져와 병합

앞에서 계산한 “이 사용자가 받을 Report 목록”을 기준으로:

  1. Redis에서 해당 키들을 조회 (report:{client_id}:{content_type}:{summary_id})
  2. JSON → ReportsRedisData 객체로 역직렬화
  3. priority / order 기준으로 정렬
  4. 필요하다면 merge_rows() 같은 메서드를 사용해 행(rows)을 병합

이때 중요한 점은:

  • 성과 요약 / 맵핑 요약이 모두 같은 포맷(ReportsRedisData)으로 다뤄진다는 것
  • 사용자별 메시지 안에서 두 content_type을 섞어서 보여줄 수도 있고, 분리해서 보여줄 수도 있다는 것

이다.

4.4 4단계 – 채널별 템플릿 렌더링 및 발송

마지막으로, 병합된 Report 목록을 채널별 템플릿에 얹어서 실제 메시지를 만든다.

  • 이메일 → HTML 테이블/섹션으로 구성
  • 카카오 → 텍스트 위주, 길이 제한 고려해서 분할

이 단계에서 구체적인 Task 구조, 재시도, 중복 방지, 발송 로그 등이 들어가는데,
이 부분은 2편에서 별도로 다룰 예정이다.


5. 정리 – 왜 이렇게 설계했는가

전체를 다시 한 줄로 정리하면:

 

OWNER는 “무엇을 보고 싶은지” 정의,

각 사용자는 “어떤 내용을, 어떤 채널로, 어느 시간에 받고 싶은지”를 정의한다.

시스템은 이 둘을 직접 섞지 않고,
Report 단위 결과를 Redis에 미리 올려두고 발송 시점에 사용자별로 병합하는 방식으로 문제를 풀었다.

 

이 설계의 장점은:

  • Summary/Mappingsummary가 늘어나도,
    집계 단계와 발송 단계가 느슨하게 결합되어 있어 확장성이 좋고
  • 사용자별 설정(컨텐츠 타입, 채널, 순서, 시간, 주말 수신 등)이 복잡해도
    모델 레벨에서 명확히 분리되어 있어 유지보수가 쉽고
  • 새로운 content_type 이 추가되더라도
    Report 포맷과 Redis 구조만 맞추면 비교적 쉽게 확장 가능하다는 점이다.

6. 다음 글

1편에서는 Batch 메시징의 모델 설계와 전체 구조만 다뤘다.

2편에서는:

  • Report 생성 Task
  • PerformanceSummary / MappingSummary 별 집계 방식
  • 사용자별/채널별 발송 Task 구조 및 adhoc 케이스들
  • 재시도 / 중복 방지 / 발송 로그 설계

같은 “구현 편”을 정리할 예정이고,
3편에서는 SCM 서비스에서 사용한 Event 기반 메시징(도메인 이벤트 → 핸들러 → 발송) 구조를 다뤄보겠다.

'연습' 카테고리의 다른 글

GIL with dict  (3) 2025.12.09
IDLE한 상태에서 배운 점  (1) 2025.11.26
inspect.signature 를 사용한 안전한 동적 import 확장하기  (0) 2025.10.26
Batch/Event + Messaging  (0) 2025.09.13
기술 스택 정리  (0) 2025.02.26
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함