[TIL] 메일 발송 리팩토링

코딩냥이·2026년 3월 2일

📌 리팩토링을 하게 된 계기

입영 신청 및 연기 처리 서비스에서 이메일 알림 기능을 구현하면서 다음과 같은 문제를 발견했다.

기존 구조에서는 이벤트 리스너에서 사용자 정보를 다시 조회하는 방식이었다.

User user = userRepository.findById(event.userId())

이 방식에는 몇 가지 문제가 존재했다.

  1. 트랜잭션 이후 추가 DB 조회 발생

  2. 비동기 처리 시 데이터 불일치 가능성

  3. 이벤트가 도메인 계층에 의존

  4. 시스템 결합도 증가

즉, 이벤트 구조가 self-contained 하지 않은 상태였다.

이를 개선하기 위해 알림 시스템을 전반적으로 리팩토링했다.

✅ 리팩토링 목표

이번 리팩토링의 핵심 목표는 다음과 같았다.

트랜잭션 커밋 이후에만 메일 발송

이벤트 처리 시 DB 조회 제거

이벤트 Payload 구조 도입

메일 생성 로직을 템플릿 기반으로 개선

✅ 1. Transaction 이후 비동기 이벤트 처리

Spring의 @TransactionalEventListener를 활용하여 DB 커밋 이후에만 이벤트가 실행되도록 변경했다.

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void send(NotificationEvent event) {

이 구조를 통해 다음 문제를 예방할 수 있다.

  1. DB 롤백 후 메일 발송되는 문제

  2. 트랜잭션 성능 저하

  3. 메인 로직 지연

즉,

트랜잭션 경계와 외부 시스템 호출을 분리했다.

✅ 2. Event Payload 구조 도입 (DB 조회 제거)

기존 이벤트는 userId만 전달했다.

NotificationEvent(userId, ...)

리스너에서 다시 DB 조회가 필요했다.

이를 개선하기 위해 Payload 객체를 이벤트에 포함하도록 변경했다.

public class NotificationPayload {

    private Long userId;
    private String email;
    private Long applicationId;
    private Long defermentId;
    private LocalDate enlistmentDate;
    private LocalDate changeDate;

}

서비스에서는 필요한 데이터를 모두 담아서 이벤트를 발행한다.

publisher.publishEvent(
    NotificationEvent.enlistmentRequested(
        NotificationPayload.from(
            user.getId(),
            user.getEmail(),
            applicationId,
            null,
            enlistmentDate,
            null
        )
    )
);

리스너에서는 더 이상 DB 조회가 필요 없다.

var payload = event.notificationPayload();
String to = payload.getEmail();

이 구조는 이벤트를 self-contained message로 만드는 핵심 개선이었다.

✅ 3. Bulk 처리 시 N+1 문제 해결

일괄 승인 로직에서는 사용자 이메일을 가져오기 위해 반복 조회가 발생할 수 있었다.

이를 Map 캐싱 방식으로 개선했다.

List<User> users = userRepository.findAllById(userIds);

Map<Long, String> emailMap =
    users.stream()
         .collect(Collectors.toMap(User::getId, User::getEmail));

이 방법으로 DB 호출 횟수를 크게 줄일 수 있었다.

✅ 4. 메일 생성 로직 개선

기존에는 switch 문으로 메일 내용을 생성했다.

switch (type) {
case ENLISTMENT_REQUESTED -> ...
}

이를 템플릿 Map 기반 구조로 리팩토링했다.

private final Map<NotificationType, String> bodyTemplates = Map.of(
NotificationType.ENLISTMENT_REQUESTED, """
입영 신청이 정상 접수되었습니다.
신청번호 : {applicationId}
입영 날짜 : {enlistmentDate}
"""
);

placeholder 치환 방식으로 메일을 생성한다.

String body = template
.replace("{applicationId}", safe(payload.getApplicationId()))
.replace("{enlistmentDate}", safe(payload.getEnlistmentDate()));

이 방식은 다음 장점이 있다.

  1. switch 제거

  2. 유지보수성 향상

  3. 확장성 증가

역할 분리 명확

✅ 리팩토링 이후 아키텍처

리팩토링 이후 구조는 다음과 같이 개선되었다.

Service

Event Publisher

Async Boundary

Email Sender

Template Provider

도메인 로직과 인프라 로직이 명확히 분리되었다.

✅ 리팩토링을 통해 얻은 것

이번 리팩토링을 통해 다음을 경험할 수 있었다.

Spring 이벤트 기반 비동기 처리 구조 이해

트랜잭션 경계 설계 경험

이벤트 Payload 설계

N+1 문제 해결 전략

단순 기능 구현을 넘어 아키텍처 관점에서 시스템을 개선하는 경험이었다.

profile
코딩하는 냥이입니다.

0개의 댓글