[ezcode] 알림 기능 구현

NCOOKIE·2025년 6월 15일
1

ezcode

목록 보기
3/8

아키텍처에 대한 고민

현재 프로젝트에서는 4 계층 아키텍처를 기반으로 구현하고 있기 때문에 이와 관련된 구조적인 고민을 하고 있었다.

문제 상황

여러 도메인들과 알림 도메인 간의 의존성을 분리하기 위해 스프링 이벤트 사용하려고 함 (loose coupling)

  • 스프링 이벤트는 현재 infra 쪽에서 구현 중
    • 추후 MQ 등의 기술로 대체할 수 있기 때문에 infra에 위치시킴
  • 알림 엔티티 저장하려면 도메인 서비스를 거쳐야 함

이러한 상황에서 알림 기능을 구현하려고 하니 구조적으로 문제가 발생했다.

  1. 애플리케이션 서비스(유즈케이스)에서 알림 도메인 서비스 참조할 경우
    • 도메인 서비스라는 구현체를 여러 도메인에서 직접 의존하게 됨
    • 이렇게 되면 스프링 이벤트를 사용하는 의미가 퇴색됨
  2. 이벤트 리스너에서 DB 저장
    • infra 계층에서 domain 서비스를 호출, 그러니까 참조를 하는 것이 맞나?

이벤트 publisher와 listener 모두 인프라 계층에 있는데, 알림을 저장하는 기능은 도메인 서비스에 위치해 있다. 때문에 인프라 계층에서 도메인 계층을 직접 참조하게 된다. 즉, 아키텍처 구조에 위배된다.

대안

  1. 스프링 이벤트 사용 대신 저장할 알림 데이터를 큐에 저장 / 스케쥴러 등에서 이를 하나씩 꺼내 DB에 저장
    • 구체적인 구현 방법은 조사해 봐야 함
  2. 이벤트 리스너를 도메인 계층에 위치 - 튜터님 의견
    • 이벤트 리스너가 인프라에 있는게 맞나? 부터 생각해봐야...
    • 알림 전송이라는 기능은 도메인에 있는게 맞다고 생각
  3. 알림을 도메인에서 제외 / 알림 관련 기능은 모두 infra에서 구현
    • notification이 도메인에 있는게 맞나부터 생각
    • 알림에 대한 비즈니스 로직이 있나? 그럼 그냥 외부 기술로 보고 인프라에 넣는게 어떰?
    • 알림을 도메인에서 분리한다고 할 때 추후 확장성에는 문제가 없는지 생각해봐야 함
  4. 각각의 도메인과 알림 도메인 사이의 중계자 역할을 하는 이벤트 버스 형태의 도메인 생성 - 김태선 튜터님
    • 비슷한 경험이 있음
    • pub/sub 형태의 이벤트 버스 구조
    • 이벤트 버스에서 토크나이저? 데이터를 재구축하는 로직 구성
    • 이벤트 버스 안에 있는 변환 모듈, 스트림 모듈 ⇒ 카프카 사용이 필요함, 디스크 IO 사용
      • 현재 헥사고날 아키텍처 구조에서는 오버스펙임
    • 이벤트 버스 ← 다대다 테이블의 중간 테이블 생각하면 됨
    • 검색 키워드) 이벤트 버스, 카프카 스트림즈, 토폴로지

의사 결정

"알림"이라는 기능을 도메인에서 제거하고 외부 기술로 취급하는 3번 방식을 채택하기로 했다. 현재 우리 서비스에서는 "알림"이라는 기능이 자체적인 비즈니스 로직을 가지지 않을 것이라고 판단했기 때문이다.

여기서 알림의 비즈니스 로직이란, 발생한 이벤트에 대해서 주기적으로 알림을 발송한다던지, 특정 그룹에게만 알림을 전송한다던지 등의 작업이 있을 것이다.

우리 서비스에서 알림은 단순히 알림 발송, 읽음 처리, 데이터 저장 등을 수행하는 "외부 기술"이라고 봤기 때문에 이와 같은 판단을 내리게 됐다.

현업에서도 많이 사용한다는 이벤트 버스는 스프링의 webflux 모듈로 구현하는데, 이 구조는 현재 프로젝트에 적용시키기 어렵기도 하고, 여러모로 오버스펙인 것 같아 패스하기로 했다. 이건 기회가 된다면 한 번 맛 봐보고 싶다.

알림 기능 구현

구현

구현 자체는 굉장히 간단하다. 그래서 이 글에서는 기술적인 내용보다는 아키텍처 구조와 코드의 짜임새 관점 위주로 이야기 할 것이다.

기술 스택은 웹소켓을 사용한다. 기존에 다른 팀원 분께서 채팅 기능을 구현하기 위해 서비스에 연결 시작부터 끝까지 웹소켓 연결을 유지하도록 구현하신 상태였으므로 알림도 웹소켓을 통해 전송한다.

양방향 통신인 웹소켓과 다르게 단방향 통신인 SSE를 왜 썼는가 하면... 이미 잘 동작하고 있는 인프라(웹소켓)를 재사용하면 불필요한 커넥션을 만들지 않아도 되서 리소스도 아끼고 기술의 파편화도 막을 수 있기 때문이다.

여기서는 작성한 댓글에 추천을 받았을 때의 시나리오를 예시로 들어보겠다.

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	private final JwtUtil jwtUtil;

	@Value("${spring.message.activemq.address}")
	private String mqAddress;

	@Value("${spring.message.activemq.username}")
	private String mqUsername;

	@Value("${spring.message.activemq.password}")
	private String mqPassword;

	@Value("${spring.message.activemq.port}")
	private Integer mqPort;

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {

		registry
			.addEndpoint("/ws")
			.setAllowedOriginPatterns("*")
			.setHandshakeHandler(new CustomHandShakeHandler(jwtUtil))
			.withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {

		registry
			.enableStompBrokerRelay("/topic", "/queue")
			.setRelayHost(mqAddress)
			.setRelayPort(mqPort)
			.setClientLogin(mqUsername)
			.setClientPasscode(mqPassword)
			.setSystemLogin(mqUsername)
			.setSystemPasscode(mqPassword);

		registry.setApplicationDestinationPrefixes("/chat");
		registry.setUserDestinationPrefix("/user");
	}
}
@RequiredArgsConstructor
public class CustomHandShakeHandler extends DefaultHandshakeHandler {

	private final JwtUtil jwtUtil;

	@Override
	protected Principal determineUser(
		@NonNull ServerHttpRequest request,
		@NonNull WebSocketHandler wsHandler,
		@NonNull Map<String, Object> attributes
	) {
		URI uri = request.getURI();
		String query = uri.getQuery();
		String tokenParam = null;

		if (query != null && query.startsWith("token=")) {
			tokenParam = query.substring(6);
		}
        
		Claims claims = jwtUtil.extractClaims(tokenParam);
		String email = claims.get("email", String.class);

		return () -> email;
	}
}

이 설정 코드는 스프링에서 STOMP와 WebSocket을 구성하면서, 외부 메세지 브로커(ActiveMQ)를 통해 멀티 인스턴스 간 메세지 분산을 가능하게 한다.

엔드포인트 등록

  • /ws : 클라이언트가 WebSocket 연결을 맺을 URL 경로
  • setAllowedOriginPatterns("*") : 모든 오리진으로부터의 접속을 허용한다. 실제 서비스에서는 프론트 도메인으로 수정할 예정이다.
  • CustomHandShakeHandler
    • SockJS가 HTTP → WebSocket 업그레이드 시점에 호출되는 커스텀 핸드쉐이크 핸들러
    • 웹소켓 핸드쉐이크 시점에 클라이언트의 JWT를 검증해서 Principal(인증된 사용자)을 결정해 주는 역할
    • determineUser(...) : 업그레이드가 완료된 직후 호출되는 콜백 메서드로, 여기에서 “이 연결은 누구인가?” 를 결정해 줘야 클라이언트별 메시징이 가능하다.
  • withSockJS() : WebSocket을 지원하지 않는 클라이언트를 위해 SockJS 폴백(fallback) 옵션을 켠다.

외부 브로커 릴레이

  • enableStompBrokerRelay("/topic", "/queue")
    • Spring 내장 브로커(SimpleBroker)가 아니라, /topic, /queue 경로의 메시지를 ActiveMQ로 전달하도록 설정
  • 브로커 릴레이를 쓰면 모든 애플리케이션 인스턴스가 중간에 ActiveMQ를 통해 메시지를 주고받기 때문에, A 인스턴스에서 발행한 메시지를 B 인스턴스가 구독하고 있는 클라이언트에도 전달할 수 있다.
    • WebSocket 세션이 여러 서버에 분산되어 있어도 메시지 일관성을 유지한다.

=> scale-out, 그러니까 멀티 인스턴스 환경에서도 채팅, 알림 기능이 정상적으로 동작한다.

Destination Prefixes

  • setApplicationDestinationPrefixes("/chat") : 클라이언트가 /chat/xxx 로 보낸 메시지는 @MessageMapping("xxx") 핸들러로 라우팅
  • setUserDestinationPrefix("/user") : 스프링이 내부적으로 사용자 1대1 메시지를 처리할 때 /user/{username}/queue/… 같은 경로를 자동으로 만듦

Port

public interface NotificationEventService {

	void saveAndNotify(NotificationCreateEvent dto);

	void notifyList(NotificationListRequestEvent dto);

	void setRead(NotificationReadEvent dto);

}

알림이라는 기능을 도메인 계층에서 제거하기로 결정했으므로 애플리케이션 계층에 notification port를 만들어줬다. 이는 프로젝트의 아키텍처를 다룬 글에서도 언급했던 내용이다.

public record NotificationCreateEvent(

	String principalName,

	NotificationType notificationType,

	NotificationPayload payload,

	boolean isRead,

	LocalDateTime createdAt

) {

	public static NotificationCreateEvent of(String principalName, NotificationType notificationType, NotificationPayload payload) {
		return new NotificationCreateEvent(
			principalName,
			notificationType,
			payload,
			false,
			LocalDateTime.now()
		);
	}
}

알림 발송 메서드를 호출하는 애플리케이션 계층에서 실제 알림 기능이 구현되어 있는 인프라 계층으로 알림 데이터를 넘길 때 사용되는 DTO다.

Application Service

@Service
public class ReplyVoteService extends BaseVoteService<ReplyVote, ReplyVoteDomainService> {

	private final UserDomainService userDomainService;
	private final ReplyDomainService replyDomainService;
	private final DiscussionDomainService discussionDomainService;

	private final NotificationEventService notificationEventService;

	public ReplyVoteService(
		ReplyVoteDomainService domainService,
		UserDomainService userDomainService,
		ReplyDomainService replyDomainService,
		DiscussionDomainService discussionDomainService,
		NotificationEventService notificationEventService
	) {
		super(domainService);
		this.userDomainService = userDomainService;
		this.replyDomainService = replyDomainService;
		this.discussionDomainService = discussionDomainService;
		this.notificationEventService = notificationEventService;
	}

	...
    
	@Override
	protected void afterVote(User voter, Long targetId) {

		Reply reply = replyDomainService.getReplyById(targetId);
		if (!voter.isSameUser(reply.getUser())) {
			notificationEventService.saveAndNotify(
				NotificationEventDtoFactory.forReplyVoteCreated(
					reply.getUser().getEmail(),
					reply.getId(),
					voter.getNickname()
				)
			);
		}
	}
}

추천 데이터가 생성되면 afterVote 메서드가 호출되고, 여기서 알림 발송을 수행한다. 이 때 NotificationCreateEvent 객체를 넘기기 위해 NotificationEventDtoFactory라는 클래스의 static 메서드를 사용한다.

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();
	}

	public static NotificationCreateEvent forDiscussionVoteCreated(
		String principalName,
		Long discussionId,
		String voter
	) {

		return NotificationCreateEvent
			.builder()

			.principalName(principalName)
			.notificationType(NotificationType.COMMUNITY_DISCUSSION_VOTED_UP)
			.message("자유글에 추천을 받았습니다.")
			.payload(Map.of(
				"discussionId", discussionId,
				"voter", voter
			))
			.redirectUrl("/redirect")
			.isRead(false)
			.createdAt(LocalDateTime.now())

			.build();
	}
    
    ...
    
}

Event Publihser

@Component
@RequiredArgsConstructor
public class NotificationEventPublisher implements NotificationEventService {

	private final ApplicationEventPublisher publisher;

	@Override
	public void saveAndNotify(NotificationCreateEvent dto) {

		publisher.publishEvent(dto);
	}

	@Override
	public void notifyList(NotificationListRequestEvent dto) {

		publisher.publishEvent(dto);
	}

	@Override
	public void setRead(NotificationReadEvent dto) {

		publisher.publishEvent(dto);
	}
}

인프라 계층에 위치해 있으며, Port 인터페이스인 NotificationEventService의 구현체다. 전달받은 NotificationCreateEvent 객체를 listener에게 전달한다.

Event Listener

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationEventListener {

	private final NotificationRepository repository;
	private final StompMessageService messageService;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void handleNotificationCreateEvent(NotificationCreateEvent dto) {

		NotificationRecord record = NotificationRecord.from(dto);
		repository.save(record);
		messageService.handleNotification(NotificationResponse.from(record), dto.principalName());
	}
    
    ...
    
}

발행된 알림 이벤트를 수신해서 알림 데이터를 저장하고 실제로 유저에게 알림을 발송한다. 알림과 관련된 코드에서 에러가 발생하더라도 이전 작업, 여기서는 댓글 작성 관련 로직은 롤백되면 안 된다. 때문에 기존 트랜잭션이 commit 된 이후 실행되도록 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용한다.

StompMessageService

@Component
@RequiredArgsConstructor
public class StompMessageService {

	private final SimpMessagingTemplate messagingTemplate;

	...

	public void handleNotification(NotificationResponse data, String principalName) {

		messagingTemplate.convertAndSendToUser(
			principalName,
			"/queue/notification",
			data
		);
	}

	...
    
}

웹소켓을 통해 알림 데이터를 발송한다.

그리고 튜터님 피드백...

사실 대부분의 코드는 이미 작성되어 있었기 때문에 (기존의 인프라를 재사용한다는 판단이 여기서 빛을 발한다!) 알림 기능 구현은 금방 끝났다.

CRUD가 아닌 기능을 구현한 경험은 거의 처음인지라 튜터님께 피드백을 받으러 갔더니... 왕창 깨졌다. 정확히 말하자면 알림 객체를 생성하는 NotificationEventDtoFactory 클래스와 NotificationCreateEvent DTO 클래스와 관련되서 지적을 많이 받았다. 그 내용과 리팩토링 과정에 대해서는 다음 글에서 다루도록 하겠다.

profile
일단 해보자

0개의 댓글