
지난 글에서 알림 기능을 구현했는데, 코드 리뷰 때 튜터님께 엄청 깨졌다. 그래서 관련된 코드들을 리팩토링하기로 했다.
public class NotificationEventDtoFactory {
public static NotificationCreateEvent forReplyCreated(
String principalName,
Long replyId,
Long discussionId,
String content
) {
return NotificationCreateEvent
.builder()
.principalName(principalName)
.notificationType(NotificationType.COMMUNITY_REPLY)
.message("새로운 댓글이 달렸습니다.")
.payload(Map.of(
"replyId", replyId,
"discussionId", discussionId,
"content", content
))
.redirectUrl("/redirect")
.isRead(false)
.createdAt(LocalDateTime.now())
.build();
}
...
}
@Builder
public record NotificationCreateEvent(
String principalName,
NotificationType notificationType,
String message,
Map<String, Object> payload,
String redirectUrl,
boolean isRead,
LocalDateTime createdAt
) {
}
튜터님께 지적받은 내용들을 요약하자면 다음과 같다.
Map<String, Object> 타입의 payloadMap<String, Object> 로 사용 중이었음payload 필드의 타입을 Object로 설정하는 방법에 대한 내용이다.
Object는 Java에서 모든 클래스의 최상위 부모이므로, 어떤 타입의 객체든 담을 수 있는 최고의 유연성을 제공payload 필드에 무엇이 들어있는지 전혀 알지 못함payload에 담긴 데이터를 실제로 사용하려면, 해당 데이터가 어떤 타입인지 instanceof로 일일이 확인하고, 올바른 타입으로 강제 캐스팅 필요private Object payload; 라는 코드를 봤을 때, 이 필드에 어떤 종류의 데이터가 들어올 수 있는지 전혀 예측할 수 없음payload를 생성하는 모든 코드를 찾아다니며 데이터의 구조를 역추적해야 함Object 필드에 담긴 객체는 JSON으로 변환되지만, 타입 정보가 사라짐payload 필드의 타입을 제네릭으로 사용하는 방법에 대한 내용이다.
List<Notification<?>>를 사용해야 하고, 이를 다루는 코드는 매우 복잡해짐Notification 객체를 JSON으로 변환하려면 Jackson 같은 라이브러리에서는 제네릭 타입 T의 정확한 클래스를 알기 어려우므로 복잡한 커스텀 로직이 필요해짐public record ReplyCreatedEventDto(
String principalName,
Long replyId,
Long discussionId,
String content
) implements NotificationEventDto {
@Override
public NotificationCreateEvent toNotification() {
return NotificationCreateEvent.builder()
.principalName(principalName)
.notificationType(NotificationType.COMMUNITY_REPLY)
.message("새로운 댓글이 달렸습니다.")
.payload(new ReplyCreatedPayload(replyId, discussionId, content))
.redirectUrl("/redirect")
.isRead(false)
.createdAt(LocalDateTime.now())
.build();
}
}
toNotification() 메서드를 만드는 방법도 고려@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME, // 타입 식별자를 이름으로 사용
include = JsonTypeInfo.As.PROPERTY, // 별도의 프로퍼티로 타입 식별자 추가
property = "@type" // 타입 식별자 프로퍼티의 이름 (예: "@type": "reply_created")
)
@JsonSubTypes({
@JsonSubTypes.Type(value = ReplyCreatePayload.class, name = "reply_created"),
@JsonSubTypes.Type(value = DiscussionVotePayload.class, name = "discussion_vote"),
@JsonSubTypes.Type(value = ReplyVotePayload.class, name = "reply_vote")
})
public interface NotificationPayload {
}
public record ReplyCreatePayload(
Long replyId,
Long discussionId,
String content
) implements NotificationPayload {
}
public record DiscussionVotePayload(
Long discussionId,
String voterNickname
) implements NotificationPayload {
}
{"@type": "reply_created", ...} 와 같이 타입 정보가 함께 저장된다.NotificationMapper<E> 라는 공통 인터페이스를 정의...Mapper 클래스에 위임NotificationConverter는 어떤 이벤트가 들어왔을 때, 그에 맞는 Mapper를 찾아 연결해주는 역할만 한다.public interface NotificationMapper<E> {
Class<E> getSupportedType();
NotificationCreateEvent map(E event);
}
@Component
public class ReplyCreateNotificationMapper implements NotificationMapper<ReplyCreateEvent> {
@Override
public Class<ReplyCreateEvent> getSupportedType() {
return ReplyCreateEvent.class;
}
@Override
public NotificationCreateEvent map(ReplyCreateEvent event) {
return new NotificationCreateEvent(
event.principalName(),
NotificationType.COMMUNITY_REPLY,
"새로운 댓글이 달렸습니다.",
new ReplyCreatePayload(event.replyId(), event.discussionId(), event.content()),
"/redirect",
false,
LocalDateTime.now()
);
}
}
@Component
public class NotificationConverter {
private final Map<Class<?>, NotificationMapper<?>> mapperMap;
public NotificationConverter(List<NotificationMapper<?>> mappers) {
this.mapperMap = mappers.stream()
.collect(Collectors.toUnmodifiableMap(
NotificationMapper::getSupportedType,
Function.identity()
));
}
@SuppressWarnings("unchecked")
public NotificationCreateEvent convert(Object event) {
NotificationMapper mapper = mapperMap.get(event.getClass());
if (mapper == null) {
throw new IllegalArgumentException("No mapper found for event type: " + event.getClass());
}
return mapper.map(event);
}
}
NotificationCreateEvent event = notificationConverter.convert(
new ReplyCreateEvent(
recipient.getEmail(),
reply.getId(),
reply.getDiscussion().getId(),
reply.getContent()
)
);
notificationEventService.saveAndNotify(event);
이 구조는 도메인 이벤트와 알림 시스템의 결합을 완벽하게 끊어준다. 또한 새로운 알림 타입을 추가할 때, 기존 코드를 수정할 필요 없이 새로운 Mapper 클래스 하나만 추가하면 되므로 개방-폐쇄 원칙(OCP)도 만족한다.
위에서 제네릭 방식을 채택하지 않은 이유로 와일드카드의 남용이 있었는데, converter에서는 와일드카드를 여러 번 사용 중이다. 이에 대해 궁금해서 찾아봤다.
결론은 "괜찮다"였다!
Map<Class<?>, NotificationMapper<?>> mapperMap을 외부로 반환한다고 하면, 해당 map을 사용하는 측에서는 각 요소의 정확한 타입을 알 수 없다.instanceof와 타입 캐스팅 등을 사용해야 한다.NotificationConverter의 convert 메서드를 사용하는 외부 Client는 와일드카드의 존재 자체를 알 필요가 없다.Client는 new ReplyCreateEvent(...) 와 같이 명확한 타입의 객체를 전달Client는 NotificationCreateEvent 라는 명확한 타입의 객체를 돌려받음Map<Class<?>, NotificationMapper<?>>는 다양한 종류의 매퍼(전략)들을 담기 위한 내부적인 도구일 뿐convert 메서드는 전달받은 event 객체의 getClass()를 통해 어떤 매퍼를 사용해야 할지 내부적으로 100% 확신할 수 있음if (mapper == null) 조건이 참이 되는 시나리오는 다음과 같다.
개발자가 새로운 알림 이벤트를 만들고 서비스 로직에서 발행한다. 하지만 깜빡하고 이 이벤트를 처리해줄 Mapper를 만들지 않았거나, 만들었어도
@Component어노테이션을 붙이지 않았다.
이 사오항의 오류 원인은 클라이언트가 아니다. 클라이언트는 정상적인 요청을 보냈을 뿐이다. 문제는 서버 측의 프로그래밍 실수 또는 설정 누락이다.
따라서 이는 명백한 서버 오류이며, 5xx 계열 상태 코드로 응답해야 한다. 나는 그 중 일반적인 서버 에러 발생 시 사용하는 500을 적용했다.