입영 신청 및 연기 처리 서비스에서 이메일 알림 기능을 구현하면서 다음과 같은 문제를 발견했다.
기존 구조에서는 이벤트 리스너에서 사용자 정보를 다시 조회하는 방식이었다.

User user = userRepository.findById(event.userId())
이 방식에는 몇 가지 문제가 존재했다.
트랜잭션 이후 추가 DB 조회 발생
비동기 처리 시 데이터 불일치 가능성
이벤트가 도메인 계층에 의존
시스템 결합도 증가
즉, 이벤트 구조가 self-contained 하지 않은 상태였다.
이를 개선하기 위해 알림 시스템을 전반적으로 리팩토링했다.
이번 리팩토링의 핵심 목표는 다음과 같았다.
트랜잭션 커밋 이후에만 메일 발송
이벤트 처리 시 DB 조회 제거
이벤트 Payload 구조 도입
메일 생성 로직을 템플릿 기반으로 개선
✅ 1. Transaction 이후 비동기 이벤트 처리
Spring의 @TransactionalEventListener를 활용하여 DB 커밋 이후에만 이벤트가 실행되도록 변경했다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void send(NotificationEvent event) {
이 구조를 통해 다음 문제를 예방할 수 있다.
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로 만드는 핵심 개선이었다.
일괄 승인 로직에서는 사용자 이메일을 가져오기 위해 반복 조회가 발생할 수 있었다.
이를 Map 캐싱 방식으로 개선했다.

List<User> users = userRepository.findAllById(userIds);
Map<Long, String> emailMap =
users.stream()
.collect(Collectors.toMap(User::getId, User::getEmail));
이 방법으로 DB 호출 횟수를 크게 줄일 수 있었다.
기존에는 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()));
이 방식은 다음 장점이 있다.
switch 제거
유지보수성 향상
확장성 증가
역할 분리 명확
리팩토링 이후 구조는 다음과 같이 개선되었다.
Service
↓
Event Publisher
↓
Async Boundary
↓
Email Sender
↓
Template Provider
도메인 로직과 인프라 로직이 명확히 분리되었다.
이번 리팩토링을 통해 다음을 경험할 수 있었다.
Spring 이벤트 기반 비동기 처리 구조 이해
트랜잭션 경계 설계 경험
이벤트 Payload 설계
N+1 문제 해결 전략
단순 기능 구현을 넘어 아키텍처 관점에서 시스템을 개선하는 경험이었다.