
현재 프로젝트에서는 4 계층 아키텍처를 기반으로 구현하고 있기 때문에 이와 관련된 구조적인 고민을 하고 있었다.
여러 도메인들과 알림 도메인 간의 의존성을 분리하기 위해 스프링 이벤트 사용하려고 함 (loose coupling)
이러한 상황에서 알림 기능을 구현하려고 하니 구조적으로 문제가 발생했다.
이벤트 publisher와 listener 모두 인프라 계층에 있는데, 알림을 저장하는 기능은 도메인 서비스에 위치해 있다. 때문에 인프라 계층에서 도메인 계층을 직접 참조하게 된다. 즉, 아키텍처 구조에 위배된다.
"알림"이라는 기능을 도메인에서 제거하고 외부 기술로 취급하는 3번 방식을 채택하기로 했다. 현재 우리 서비스에서는 "알림"이라는 기능이 자체적인 비즈니스 로직을 가지지 않을 것이라고 판단했기 때문이다.
여기서 알림의 비즈니스 로직이란, 발생한 이벤트에 대해서 주기적으로 알림을 발송한다던지, 특정 그룹에게만 알림을 전송한다던지 등의 작업이 있을 것이다.
우리 서비스에서 알림은 단순히 알림 발송, 읽음 처리, 데이터 저장 등을 수행하는 "외부 기술"이라고 봤기 때문에 이와 같은 판단을 내리게 됐다.
현업에서도 많이 사용한다는 이벤트 버스는 스프링의 webflux 모듈로 구현하는데, 이 구조는 현재 프로젝트에 적용시키기 어렵기도 하고, 여러모로 오버스펙인 것 같아 패스하기로 했다. 이건 기회가 된다면 한 번 맛 봐보고 싶다.
구현 자체는 굉장히 간단하다. 그래서 이 글에서는 기술적인 내용보다는 아키텍처 구조와 코드의 짜임새 관점 위주로 이야기 할 것이다.
기술 스택은 웹소켓을 사용한다. 기존에 다른 팀원 분께서 채팅 기능을 구현하기 위해 서비스에 연결 시작부터 끝까지 웹소켓 연결을 유지하도록 구현하신 상태였으므로 알림도 웹소켓을 통해 전송한다.
양방향 통신인 웹소켓과 다르게 단방향 통신인 SSE를 왜 썼는가 하면... 이미 잘 동작하고 있는 인프라(웹소켓)를 재사용하면 불필요한 커넥션을 만들지 않아도 되서 리소스도 아끼고 기술의 파편화도 막을 수 있기 때문이다.
여기서는 작성한 댓글에 추천을 받았을 때의 시나리오를 예시로 들어보겠다.
@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("*") : 모든 오리진으로부터의 접속을 허용한다. 실제 서비스에서는 프론트 도메인으로 수정할 예정이다.CustomHandShakeHandlerdetermineUser(...) : 업그레이드가 완료된 직후 호출되는 콜백 메서드로, 여기에서 “이 연결은 누구인가?” 를 결정해 줘야 클라이언트별 메시징이 가능하다.withSockJS() : WebSocket을 지원하지 않는 클라이언트를 위해 SockJS 폴백(fallback) 옵션을 켠다.enableStompBrokerRelay("/topic", "/queue")/topic, /queue 경로의 메시지를 ActiveMQ로 전달하도록 설정=> scale-out, 그러니까 멀티 인스턴스 환경에서도 채팅, 알림 기능이 정상적으로 동작한다.
setApplicationDestinationPrefixes("/chat") : 클라이언트가 /chat/xxx 로 보낸 메시지는 @MessageMapping("xxx") 핸들러로 라우팅setUserDestinationPrefix("/user") : 스프링이 내부적으로 사용자 1대1 메시지를 처리할 때 /user/{username}/queue/… 같은 경로를 자동으로 만듦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다.
@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();
}
...
}
@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에게 전달한다.
@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)를 사용한다.
@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 클래스와 관련되서 지적을 많이 받았다. 그 내용과 리팩토링 과정에 대해서는 다음 글에서 다루도록 하겠다.