1. 다양한 알림
저희는 서비스 간 강한 결합 문제를 해결하기 위해 Spring의 내장 이벤트를 도입했습니다. 이벤트 기반으로 전환하면서 서비스 간 의존성은 낮아졌지만, 알림이라는 또 다른 복잡한 요구사항에 직면하게 되었습니다.
저희 스터디 플랫폼에서는 정말 다양한 종류의 알림이 필요했습니다.
- 새로운 채팅 메시지가 도착했을 때
- 내가 만든 스터디에 누군가 참여 신청했을 때
- 내 신청이 수락/거절되었을 때
- 스터디 규칙이나 공지사항이 변경되었을 때
- 등등
이 모든 알림은 각기 다른 내용, 다른 수신 대상, 그리고 알림 클릭 시 이동해야 하는 링크나 앱에서 처리해야 할 추가 데이터를 가집니다.
만약 이 모든 알림 발송 로직을 하나의 NotificationService 같은 곳에서 if/else나 switch로 분기 처리한다면, 알림 종류가 늘어날수록 코드는 금방 복잡해지고 유지보수가 어려워질 것이 뻔했습니다. 또한, 알림 내용 생성, 로그 기록, 실제 FCM 발송 등 여러 책임이 한 곳에 섞일 위험도 있었습니다.
2. 어떻게 분리하고 확장할 것인가?
저희는 다음과 같은 목표를 세웠습니다.
- 알림 유형별 로직 분리: 각 알림(채팅, 스터디 등)의 내용과 데이터를 생성하는 로직을 독립적으로 관리한다.
- 책임 분리: 알림 내용 생성, 로그 기록, FCM 발송 등의 책임을 명확히 나눈다.
- 확장성: 새로운 종류의 알림이 추가되더라도 기존 코드 수정은 최소화한다.
- 이벤트 기반 유지: 기존에 도입한 이벤트 기반 아키텍처 위에서 동작하도록 한다.
이를 위해 저희는 핸들러/디스패처 패턴을 알림 시스템에 적용하기로 결정했습니다.
3. 알림 처리 시스템 설계
3.1. 알림 이벤트 식별 (NotificationPayload)
먼저, 어떤 이벤트가 알림 발송을 필요로 하는지 구분하기 위해 Payload를 상속하는 마커 인터페이스 NotificationPayload를 사용했습니다. 알림이 필요한 이벤트의 페이로드는 이 인터페이스를 구현하도록 했습니다.
// Payload.java
public interface Payload {}
// NotificationPayload.java
public interface NotificationPayload extends Payload {}
// FileUploadedEventPayloadWithNotification.java (예시)
public class FileUploadedEventPayloadWithNotification implements NotificationPayload { }
이를 통해 알림 전용 리스너는 Event<? extends NotificationPayload> 타입의 이벤트만 구독하여 처리 범위를 명확히 할 수 있었습니다.
3.2. 알림 내용 준비: 핸들러 패턴 (NotificationHandler)
각 알림 유형별로 필요한 정보(수신자, 메시지 키/인자, 데이터 페이로드)를 생성하는 책임을 NotificationHandler 인터페이스와 그 구현체에게 위임했습니다.
// NotificationEventHandler.java
public interface NotificationEventHandler<T extends NotificationPayload> {
List<NotificationDetails> prepareDetails(T payload);
EventType support();
}
// NotificationDetails.java
@Getter
public class NotificationDetails {
private String recipientId;
private String title;
private String content;
private DataPayloadDto dataPayloadDto;
public static NotificationDetails forFileUploaded(Long recipientId, String title, String content, DataPayloadDto dataPayloadDto) {
NotificationDetails notificationDetails = new NotificationDetails();
notificationDetails.recipientId = recipientId.toString();
notificationDetails.title = title;
notificationDetails.content = content;
notificationDetails.dataPayloadDto = dataPayloadDto;
return notificationDetails;
}
}
- prepareDetails(T payload): 이벤트 페이로드를 받아 알림 발송에 필요한 모든 정보를 NotificationDetails 객체(또는 리스트)에 담아 반환합니다. 중요한 점은 여기서 최종 메시지 문자열이 아닌, 메시지 키와 인자를 반환하여 다국어 처리는 호출하는 쪽(리스너)에서 하도록 분리했습니다.
- support(): 이 핸들러가 어떤 EventType을 처리하는지 알려줍니다.
3.3. 데이터 페이로드 구조화 (FcmDataPayloadDto)
FCM의 data 페이로드는 Key-Value가 모두 String이어야 하지만, 백엔드에서는 타입 안전성과 명확성을 위해 DTO를 사용했습니다. 모든 알림 유형에서 사용될 수 있는 필드를 포함하고, @JsonInclude(Include.NON_NULL)을 사용하여 실제 값이 있는 필드만 전송되도록 했습니다.
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class DataPayloadDto {
private String type;
// STUDY 관련
private String studyId;
private String studyName;
// FILE 관련
private String fileName;
public static DataPayloadDto forFileUpload(EventType type, Long studyId, String studyName, String fileName) {
DataPayloadDto dataPayloadDto = new DataPayloadDto();
dataPayloadDto.type = type.toString();
dataPayloadDto.studyId = studyId.toString();
dataPayloadDto.studyName = studyName;
dataPayloadDto.fileName = fileName;
return dataPayloadDto;
}
}
3.4. 핸들러 구현 예시 (StudyFileUploadedNotificationEventHandler)
파일 업로드 완료 알림을 처리하는 핸들러 예시입니다. EntityFacade를 사용해 필요한 추가 정보를 조회하고 NotificationDetails 객체를 생성하여 반환합니다.
@Component
@RequiredArgsConstructor
public class StudyFileUploadedEventHandler implements NotificationEventHandler<FileUploadedEventPayloadWithNotification> {
private static final String MESSAGE_TITLE_CODE = "notification.file-uploaded.title";
private static final String MESSAGE_CONTENT_CODE = "notification.file-uploaded.content";
private final EntityFacade entityFacade;
private final MessageSource ms;
@Override
public List<NotificationDetails> prepareDetails(EventType type, FileUploadedEventPayloadWithNotification payload) {
List<Long> recipientIdList = getRecipientIdList(payload);
Study study = entityFacade.getStudy(payload.getStudyId());
DataPayloadDto dataPayloadDto = DataPayloadDto.forFileUpload(type, payload.getStudyId(), study.getTitle(), payload.getOriginalFilename());
return recipientIdList.stream().map(id -> {
String title = ms.getMessage(MESSAGE_TITLE_CODE, null, Locale.getDefault());
String content = ms.getMessage(MESSAGE_CONTENT_CODE, new String[]{payload.getOriginalFilename()}, Locale.getDefault());
return NotificationDetails.forFileUploaded(id, title, content, dataPayloadDto);
}).toList();
}
@Override
public EventType support() {
return EventType.FILE_UPLOADED;
}
private List<Long> getRecipientIdList(FileUploadedEventPayloadWithNotification payload) {
return List.of(payload.getUploaderId());
}
}
3.5. 핸들러 라우팅: 디스패처 (NotificationHandlerDispatcher)
이벤트 리스너가 이벤트 타입(EventType)만으로 적절한 핸들러를 찾을 수 있도록 디스패처를 구현했습니다. 애플리케이션 시작 시점에 모든 NotificationEventHandler 빈을 주입받아 EventType을 키로 하는 Map을 미리 구성해 둡니다.
@Component
@Slf4j
public class NotificationEventHandlerDispatcher {
private Map<EventType, NotificationEventHandler> notificationHandlerMap = new EnumMap<>(EventType.class);
@Autowired
public NotificationEventHandlerDispatcher(List<NotificationEventHandler> notificationHandlerList) {
notificationHandlerList.forEach(notificationEventHandler ->
notificationHandlerMap.put(notificationEventHandler.support(), notificationEventHandler));
}
public NotificationEventHandler findNotificationHandler(EventType eventType) {
if (!support(eventType)) {
log.error("적절한 핸들러 반환 실패");
throw new IllegalArgumentException("cannot find proper handler");
} return notificationHandlerMap.get(eventType);
}
private boolean support(EventType eventType) {
return notificationHandlerMap.containsKey(eventType);
}
}
4. 이벤트 처리 및 오케스트레이션: 이벤트 리스너 (NotificationEventListener)
이제 이벤트 리스너는 매우 명확한 역할만 수행합니다. 알림이 필요한 이벤트(Event<? extends NotificationPayload>)를 받아서, 디스패처로 핸들러를 찾고, 핸들러에게 상세 정보 준비를 위임한 뒤, 그 결과를 바탕으로 NotificationLogService, FcmSendingService를 순서대로 호출하여 전체 알림 처리 흐름을 조율합니다.
@Component
@RequiredArgsConstructor
public class NotificationConsumer {
private final NotificationEventHandlerDispatcher notificationEventHandlerDispatcher;
private final NotificationLogService notificationLogService;
private final SendingService sendingService;
@EventListener
public <T extends NotificationPayload> void handleNotificationEvent(Event<T> event) {
NotificationEventHandler handler = notificationEventHandlerDispatcher.findNotificationHandler(event.getEventType());
List<NotificationDetails> notificationDetailsList = handler.prepareDetails(event.getEventType(), event.getPayload());
notificationDetailsList.forEach(n -> {
notificationLogService.create(Long.parseLong(n.getRecipientId()), EventType.valueOf(n.getDataPayloadDto().getType()), n.getTitle(), n.getContent());
sendingService.sendMessage(Long.parseLong(n.getRecipientId()), n.getTitle(), n.getContent(), n.getDataPayloadDto());
});
}
}
5. 결론: 유연하고 확장 가능한 알림 시스템
Spring 이벤트와 핸들러/디스패처 패턴을 도입함으로써 저희는 다음과 같은 이점을 얻을 수 있었습니다.
- 낮은 결합도: 핵심 도메인 서비스들은 알림 시스템에 대해 전혀 알 필요 없이 자신의 책임에만 집중할 수 있게 되었습니다.
- 명확한 책임 분리: 알림 내용 생성(Handler), 로깅(LogService), 발송(SendingService), 흐름 제어(Listener)의 역할이 명확하게 분리되었습니다.
- 높은 확장성: 새로운 알림 유형이 추가될 때, 해당 EventType 정의, Payload 클래스, NotificationHandler 구현체만 추가하면 되므로 기존 코드에 미치는 영향이 최소화됩니다.
- 향상된 테스트 용이성: 각 컴포넌트(서비스, 핸들러, 리스너)를 독립적으로 단위 테스트하기 훨씬 수월해졌습니다.
'백엔드' 카테고리의 다른 글
| Spring Boot 비동기 S3 파일 업로드 구현기 (Transfer Manager + 이벤트 + 임시저장) (0) | 2025.04.14 |
|---|---|
| Spring Boot 서비스 강한 의존성 탈출 : Spring 내부 이벤트 기반 아키텍처 도입 (0) | 2025.04.14 |
| Redis SortedSet을 사용한 인기글 구현 (0) | 2025.03.16 |
| Redis + Ip 기반 조회 수 어뷰징 방지 (0) | 2025.03.16 |
| 목록 조회 시 페이징 쿼리 최적화를 통한 최소 10배 성능 개선 (0) | 2025.03.12 |