개발자는 기록이 답이다

안읽은 채팅 메세지가 있을 경우 이메일 전송(feat. 이메일 수신자 2300개) 본문

카테고리 없음

안읽은 채팅 메세지가 있을 경우 이메일 전송(feat. 이메일 수신자 2300개)

slow-walker 2025. 3. 2. 22:57

 

 

최근 회사에서 "안 읽은 채팅 메시지가 있을 경우, 해당 사용자에게 이메일 보내기" 기능을 구현하고 있습니다.

이 기능은 B2B 사용자를 대상으로 하며, 이메일 전송 시 고려해야 할 여러 사항이 있어 정리해보고자 합니다.

Functional

  • 사용자는 특정 시간에 안읽은 메시지가 있을 경우, 메일로 알림을 받을 수 있다.
    • 매일 9:00, 13:00, 17:00에 정각에 메일 전송
    • 메일 플랫폼 : 샌드그리드
    • 안읽은 메세지가 없으면 메일 안보내도 됨
  • 사용자는 메일에서 안읽은 채팅 메세지가 있는 데이터에 대한 총 개수를 알 수 있다.
  • 사용자는 메일에서 관리자 페이지에 접속할 수 있다.

Non-functional

  • Job Schedule 활용해야함
    • 기존의 Job Schedule 방식
      • 왼쪽 도메인서비스에서 트리거가 있을 경우, job을 등록해놓고 해당 시간이 되면 publish함

  • parallel
    • 사용자가 1000명 이상이어도 정해진 시간(정각)에 정확히 이메일을 받아야 함.
      • 운영 데이터베이스에서 검색된 대상 사용자 수: 2,272명
    • 동기적으로 처리할 경우 시간이 delay될 가능성 있음
    • 제약 사항 : 코투린, virtual thread 사용 안함
  • 사내 다른 도메인 서비스는 down이 심해서 최대한 호출을 적게 해야함
    • 현재 시스템은 MSA 구조이므로, 다른 서비스(BC)에도 부하가 가지 않도록 설계해야 함

 

위의 요구사항대로 설계를 하기 위해 어떤 고찰들을 했는지 적어보고자 합니다.

 

스케줄링 방법

1. @Scheduled을 여러 번 사용

  • 스케일링된 환경에서는 각 노드의 시간이 다를 수 있어 side effect가 발생할 가능성이 있음.
  • 따라서, cron 전용 서비스에서만 스케줄링을 수행해야 함.

2. 기존 scheduled_job 테이블과 별도로 recurring 전용 테이블 추가

  • recurring 전용 테이블을 추가하기 전에, Job을 구분하는 기준이 무엇인지 먼저 검토하는 것이 중요함.
  • 예를 들어, B2B 대상이 3,000개라면 9시, 13시, 17시마다 총 3번의 스케줄링이 필요함.
    • Job은 1개의 row를 insert한다는 관점에서,,
    • 1개의 Job = B2B 대상 1명
      • 매일 총 9,000개의 Job이 scheduled_job 테이블에 저장됨.
      • 하지만, 매일 9,000개의 row가 스케줄링을 위해 DB에 쌓이는 경우 데이터 관리 및 성능 부담이 발생할 수 있음.
    • 1개의 Job = B2B 대상 3000명
      • 실패한 항목과 성공한 항목을 개별 row로 관리하기 어려움.
      • 설사 찾는다고 하더라도 3000개 중에 320번은 실패했다 라는것을 알기 위한 관리 포인트가 생김
      • 정렬, 멱등성도 고려해야함
  • 과연 이것이 진정한 Recurring Job인지 고민이 필요함.
    • 안 읽은 채팅이 있는 경우에만 메일을 보내고, 읽은 경우에는 보내지 않는다면 Ad hoc 방식과 크게 다르지 않을까?
    •  

3. 기존 scheduled_job 테이블 확장

  • 칼럼을 추가하여, 각 칼럼 별로 업데이트
  • 기존 scheduled_job을 확장하여 다음과 같은 모델링을 고려
    • 원래는 scheduled_at 칼럼 하나만 존재했으나, 다음과 같이 확장
      • prev_scheduled_at (이전 실행 시간)
      • next_scheduled_at (다음 실행 예정 시간)
      • cron_schedule (CRON 표현식 저장)
create table scheduled_job
(
    job_id       binary(27)  not null,
    job_name     varchar(50) not null, 
    payload      text        not null, 
    status       char(10)    not null, // 상태 REGISTERED | PUBLISHED | CANCELLED
    prev_scheduled_at datetime    not null, // 이전 스케줄링 시간
	next_scheduled_at datetime null	   // 다음 스케줄링 시간  
	cron_schedule  json not null	   // ["0 9 * * *", "0 13 * * *", "0 15 * * *"]
    published_at datetime,             // 발행된 시간
    cancelled_at datetime,             // 취소된 시간
    created_at   datetime,             
    updated_at   datetime,
    primary key (job_id)
);

create index ix_01_scheduled_job on scheduled_job (status);

 

 

하지만 이 모델을 사용했을때 발생할 수 있는 문제점이 있다.

 

1. Job 선(先)등록이 필수적

  • 해당 모델을 사용하려면 모든 Job이 사전에 등록되어 있어야 함.
  • 하지만, 구현하려는 이메일 전송 서비스는 Domain Service에서 특정 트리거에 의해 동적으로 등록되지 않음.
    • 즉, 사전에 Job을 생성할 수 없는 구조이므로, 기존 모델과 맞지 않을 가능성이 있음.

2. cron_schedule 기반의 스케줄 업데이트 부담

  • cron_schedule을 읽고 이전(prev_scheduled_at) 및 다음(next_scheduled_at) 스케줄을 업데이트하는 과정에서 추가적인 처리 비용이 발생함.
  • 특히, 해당 과정에서
    • 데이터베이스 Disk I/O 부하 증가 (스케줄 정보를 지속적으로 읽고, 업데이트해야 함)
    • CRON 표현식을 datetime으로 변환하는 작업 필요
  • 이러한 추가 연산은 성능 저하를 유발할 가능성이 있음

 

결론내린 설계

Job 스케줄에서 정해진 시간에만 도메인 서비스에 Publish하도록, Static한 Job Name을 사용하기로 결정했다.

이 과정에서 Job 스케줄은 NotifyUnreadChatMessageCommand라는 도메인의 커맨드를 인식할 수 있지만,
이는 직접 도메인 로직을 포함하는 것이 아니라, 단순히 "시간"을 전달하는 역할만 수행하는 것이다.

 

 

이외에도 고려해야 할 사항은 2가지가 존재한다.

다른 Bounded Context(BC)에 미치는 부하대량 이메일 전송의 비효율성도 함께 고민해야 한다.

 

1. 다른 BC의 부하를 최소화하는 설계

 

이메일을 전송하기 위해서는 B2B 대상의 ID 목록을 조회하여 이메일 정보를 가져오는 API를 호출해야 한다. 하지만 이 과정에서 다른 BC의 서버 부하를 증가시키는 문제가 발생할 수 있다.

 

다른 팀에 API 부하에 대해 문의한 결과, 아래와 같은 응답을 받았다.

말씀하신 API 는 시간당 평균 645 request 를 p95 100ms 전후로 응답하고 있고
hospital-api 에서는 시간당 평균 3,387 request 를 p95 50ms 전후로 응답하고 있습니다.
따라서 짧은 시간 안에 저만큼의 request 를 받는 것이 부담일 수는 있어서 throttling만 잘 해 주시면 가능 할 것 같아요.

 

사실 API 호출 자체는 하루 3번, 각 시간마다 1번만 진행할 예정이었다.
한 번에 모든 B2B ID(3000개)를 요청하면 Slow Query가 발생할 가능성이 있는지 확인하고 싶었고,
이에 대해 논의하는 과정에서 Miscommunication가 있었음을 인지하여 구두로 소통했다. (추후 miscom 재발을 막기 위해 회고 필요)

 

따라서 이 문제를 해결하기 위해, 한 번에 모든 B2B ID를 요청하는 것이 아니라 일정 크기(Chunk)로 나누어 요청하는 방식을 선택했다.
예를 들어, 3000개의 ID를 한 번에 요청하는 대신 300개씩 10번 요청하면,

  • 한 번에 부하가 몰리는 것을 방지할 수 있고
  • 다른 BC의 서버가 순간적으로 과부하되는 리스크를 줄일 수 있다.
private fun <T> List<T>.chunkedWithDelay(
    chunkSize: Int,
    delayMillis: Long,
    action: (List<T>) -> Unit,
) {
    this.chunked(chunkSize).forEach { chunk ->
        action(chunk)
        Thread.sleep(delayMillis)
    }
}

2. 1분 안에 3000개의 B2B 대상에게 이메일 전송

이메일 전송은 단순히 API를 호출하는 것이 아니라, 대량의 요청을 빠르게 처리해야 하는 과제도 포함된다.

 

 

📌 고려해야 할 요소

  1. 이메일 전송 서비스의 Rate Limit, Response Time 확인
  2. 비동기 처리를 통한 성능 최적화
    • 모든 이메일을 동기적으로 처리하면, 각 요청이 완료될 때까지 대기하는 시간이 길어져 성능이 저하됨.
    • 따라서, CompletableFuture를 활용한 병렬 처리로 여러 이메일을 동시에 전송하는 방식이 필요함.
val emailRequests = b2bTargets.map { target ->
    CompletableFuture.supplyAsync {
        sendEmail(target)
    }
}

 

이 방식으로 각 이메일 전송을 개별 스레드에서 처리하고, 전체 요청이 끝날 때까지 join()을 사용해 비동기적으로 실행할 수 있다.

 

고도화

 

  • 현재는 B2B대상 이메일을 알기 위해 매번 호출하고 있는데, 팀 도메인 서비스에 맞게 ACL과 캐싱 작업을 해두고,
  • 해당 병원 이메일이 변경되었다는 이벤트를 받을때에만 업데이트 해두는게 좋을것 같다.