[ezcode] 알림 이벤트 객체 생성 리팩토링

NCOOKIE·2025년 6월 18일
1

ezcode

목록 보기
4/8

문제 배경

지난 글에서 알림 기능을 구현했는데, 코드 리뷰 때 튜터님께 엄청 깨졌다. 그래서 관련된 코드들을 리팩토링하기로 했다.

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

) {
}

튜터님께 지적받은 내용들을 요약하자면 다음과 같다.

  • 하나의 팩토리 클래스에서 NotificationCreateEvent 객체 생성하는 정적 메서드 구현
    • 하나의 클래스에서 너무 많은 책임을 가지고 있음
    • 지금은 괜찮지만 나중에 알림 종류가 늘어나면 덩치가 커짐
  • Map<String, Object> 타입의 payload
    • 알림 내용과 관련된 데이터들을 담고 있는 payload 타입을 Map<String, Object> 로 사용 중이었음
    • 덕분에 구현 자체는 간단했으나 추후 유지보수가 어려움
    • 어떤 키가 들어있는지, 값의 타입은 무엇인지 등 해당 객체를 생성하는 파일에서만 확인할 수 있기 때문

리팩토링

대안

Objects payload

payload 필드의 타입을 Object로 설정하는 방법에 대한 내용이다.

  • Object는 Java에서 모든 클래스의 최상위 부모이므로, 어떤 타입의 객체든 담을 수 있는 최고의 유연성을 제공
  • 대신 타입 안정성을 포기해야 함
  • 자바 컴파일러는 payload 필드에 무엇이 들어있는지 전혀 알지 못함
    ⇒ 어떠한 타입 관련 오류도 컴파일 시점에 잡아낼 수 없음

  • payload에 담긴 데이터를 실제로 사용하려면, 해당 데이터가 어떤 타입인지 instanceof로 일일이 확인하고, 올바른 타입으로 강제 캐스팅 필요
    • 새로운 payload 타입이 추가될 때마다 분기문을 수정해야 함
  • 다른 개발자가 private Object payload; 라는 코드를 봤을 때, 이 필드에 어떤 종류의 데이터가 들어올 수 있는지 전혀 예측할 수 없음
    • payload를 생성하는 모든 코드를 찾아다니며 데이터의 구조를 역추적해야 함
  • 직렬화 시 Object 필드에 담긴 객체는 JSON으로 변환되지만, 타입 정보가 사라짐

T payload

payload 필드의 타입을 제네릭으로 사용하는 방법에 대한 내용이다.

  • 처음에는 이런 방식으로 구현하려 했으나 여러 문제점이 있었음
    • 여러 종류의 알림을 리스트에 담으려면 List<Notification<?>>를 사용해야 하고, 이를 다루는 코드는 매우 복잡해짐
    • Notification 객체를 JSON으로 변환하려면 Jackson 같은 라이브러리에서는 제네릭 타입 T의 정확한 클래스를 알기 어려우므로 복잡한 커스텀 로직이 필요해짐
    • NotificationCreateEvent 객체는 추후 계층을 이동하며 NotificationRecord, NotificationResponse 등으로 변환해야 하는데, 제네릭을 사용하게 되면 그 때마다 변환 로직을 필요로 하게 됨

toNotification() 메서드 사용

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();
    }
}
  • 처음에는 각 이벤트 DTO가 스스로 알림 객체로 변환하는 책임을 갖도록 toNotification() 메서드를 만드는 방법도 고려
  • 그러나 이는 “데이터를 운반”하는 책임을 가지고 있는 dto 클래스에 “알림 객체를 생성하는 방법”까지 알게 하는 것임 → 단일 책임 원칙(SRP) 위반

해결

Map 타입의 payload 필드 개선

@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 {
}
  • Jackson의 @JsonTypeInfo, @JsonSubTypes 어노테이션 사용하여 해결
  • 이렇게 하면 payload 객체가 JSON으로 변환될 때 {"@type": "reply_created", ...} 와 같이 타입 정보가 함께 저장된다.
  • 덕분에 나중에 JSON을 다시 객체로 되돌릴 때 별도의 설정 없이 해당하는 타입으로 복원할 수 있다.
  • 추후 Redis 외에 다른 NoSQL을 사용하더라도 Jackson을 사용한다면 해당 코드를 그대로 사용할 수 있다.

Strategy 패턴을 활용한 Mapper 계층

  • '변환'이라는 책임을 전담하는 별도의 Mapper 계층을 두기로 했다. 이는 디자인 패턴 중 전략 패턴(Strategy Pattern)과 유사
  • 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에서 와일드카드 남용?

위에서 제네릭 방식을 채택하지 않은 이유로 와일드카드의 남용이 있었는데, converter에서는 와일드카드를 여러 번 사용 중이다. 이에 대해 궁금해서 찾아봤다.

결론은 "괜찮다"였다!

  • 와일드카드가 문제가 되는 경우
    • 불확실성이 외부로 전파될 때
    • 만약 메서드에서 Map<Class<?>, NotificationMapper<?>> mapperMap을 외부로 반환한다고 하면, 해당 map을 사용하는 측에서는 각 요소의 정확한 타입을 알 수 없다.
    • 때문에 instanceof와 타입 캐스팅 등을 사용해야 한다.
    • ⇒ 타입에 대한 불확실성을 해결해야 하는 책임이 외부(Client)에게 떠넘겨버림
  • NotificationConverterconvert 메서드를 사용하는 외부 Client는 와일드카드의 존재 자체를 알 필요가 없다.
    • 입력: Clientnew ReplyCreateEvent(...) 와 같이 명확한 타입의 객체를 전달
    • 출력: ClientNotificationCreateEvent 라는 명확한 타입의 객체를 돌려받음
  • Map<Class<?>, NotificationMapper<?>>다양한 종류의 매퍼(전략)들을 담기 위한 내부적인 도구일 뿐
    • convert 메서드는 전달받은 event 객체의 getClass()를 통해 어떤 매퍼를 사용해야 할지 내부적으로 100% 확신할 수 있음

converter 예외 발생 시 http status

  • 현재 프로젝트에서는 예외를 던질 때 도메인마다 커스텀 예외를 만들어서 던지도록 하고 있음
  • 이 때 http status는 어떤걸 사용하는 것이 좋을까?

  • 5xx 계열 서버 에러가 가장 적합하다!

if (mapper == null) 조건이 참이 되는 시나리오는 다음과 같다.

개발자가 새로운 알림 이벤트를 만들고 서비스 로직에서 발행한다. 하지만 깜빡하고 이 이벤트를 처리해줄 Mapper를 만들지 않았거나, 만들었어도 @Component 어노테이션을 붙이지 않았다.

이 사오항의 오류 원인은 클라이언트가 아니다. 클라이언트는 정상적인 요청을 보냈을 뿐이다. 문제는 서버 측의 프로그래밍 실수 또는 설정 누락이다.

따라서 이는 명백한 서버 오류이며, 5xx 계열 상태 코드로 응답해야 한다. 나는 그 중 일반적인 서버 에러 발생 시 사용하는 500을 적용했다.

참고

profile
일단 해보자

0개의 댓글