Spring

댓글 알림 기능을 비동기(Event)로 전환

으엉어엉 2026. 1. 15. 10:10
728x90

프로젝트를 진행하면서 댓글 작성 기능을 구현하던 중 고민이 생겼습니다.

댓글이 저장된 후 작성자에게 알림을 보내는 과정이 하나의 트랜잭션 안에서 순차적으로 묶여 있었기 때문입니다.

만약 알림을 보내는 과정에서 문제가 생기거나 시간이 지연된다면 사용자는 댓글 작성이 완료될 때까지 계속 기다려야 하거나 심지어 댓글 등록 자체가 실패할 수도 있다는 문제가 있었습니다.

오늘은 이 문제를 해결하기 위해 Spring Event와 @Async를 도입하여 시스템의 구조를 유연하게 리팩토링한 과정을 공유합니다.


1. 기존 방식의 문제점 (As-Is)

초기 코드는 CommentService가 NotificationService를 직접 의존하고 호출하는 구조였습니다.

 
// 기존 코드 (동기 방식)
@Transactional
public void createComment(RequestDto dto) {
    // 1. 댓글 저장 (Core Logic)
    commentRepository.save(comment);

    // 2. 알림 발송 (Side Effect)
    notificationService.send(receiver, ...); 
}

이 방식에는 세 가지 구조적인 단점이 있었습니다.

  1. 강한 결합도: 댓글 로직에 알림 로직이 섞여 있어 추후 이메일이나 슬랙 알림 등 새로운 기능이 추가될 때마다 CommentService 코드를 계속 수정해야 합니다.
  2. 트랜잭션 전파 위험: 알림 로직이 메인 트랜잭션에 묶여 있습니다. 만약 알림 서버에 장애가 생겨 에러가 발생하면 멀쩡한 댓글 작성까지 롤백(실패) 처리될 위험이 있습니다.
  3. 사용자 경험 저하: 사용자는 내 댓글이 저장되는 시간뿐만 아니라 알림이 전송되는 I/O 작업이 끝날 때까지 기다려야 합니다. 이로 인하여 동기 처리하기에 성능 저하가 

2. 해결 전략: 비동기 이벤트 기반 아키텍처 

이 문제를 해결하기 위해 두 가지 기술을 적용하여 로직을 분리했습니다.

  1. Spring Event (ApplicationEventPublisher): 서비스 간의 직접적인 의존성을 끊기 위해 이벤트를 발행합니다.
  2. 비동기 처리 (@Async): 알림 발송을 별도의 스레드에서 실행하여 메인 로직의 흐름을 방해하지 않습니다.

2-1. 이벤트 객체 정의

먼저 댓글이 생성되었다는 사실을 전달할 이벤트 객체를 만듭니다.

 
@Getter
@AllArgsConstructor
public class CommentCreatedEvent {
    private final User sender;
    private final User receiver;
    private final Post post;
    private final Comment comment;
    private final String message;
}

2-2. 서비스 코드 변경 

CommentService에서는 이제 알림 서비스를 직접 호출하지 않습니다. 대신 이벤트가 발생했다는 사실만 시스템에 알립니다. 덕분에 알림 서비스에 대한 의존성이 사라졌습니다.

@Service
@RequiredArgsConstructor
public class CommentService {
    
    private final CommentRepository commentRepository;
    private final ApplicationEventPublisher eventPublisher; // 변경됨

    @Transactional
    public Long createComment(CommentCreateRequestDto dto, String email) {
        // ... (댓글 저장 로직) ...
        commentRepository.save(comment);

        // 직접 호출 대신 이벤트 발행
        eventPublisher.publishEvent(new CommentCreatedEvent(
                author, postAuthor, post, comment, "새 댓글이 달렸습니다."
        ));

        return comment.getId();
    }
}

2-3. 이벤트 리스너 구현 (Consumer)

이벤트를 받아서 실제로 알림을 보내는 리스너입니다. 여기서 @Async와 @TransactionalEventListener의 조합이 핵심입니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationEventListener {

    private final NotificationService notificationService;

    @Async // 1. 별도 스레드에서 비동기로 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 2. 댓글 트랜잭션 커밋 후에만 실행
    public void handleCommentCreatedEvent(CommentCreatedEvent event) {
        try {
            notificationService.send(
                    event.getSender(), event.getReceiver(), 
                    event.getPost(), event.getComment(), event.getMessage()
            );
        } catch (Exception e) {
            // 비동기 로직의 실패가 메인 로직에 영향을 주지 않도록 로깅만 처리
            log.error("알림 전송 실패: {}", e.getMessage());
        }
    }
}

 

여기서 phase = TransactionPhase.AFTER_COMMIT 옵션을 사용한 이유는 데이터 정합성 때문입니다.

 

단순히 @Async만 사용하면 메인 트랜잭션이 커밋되기 전에 비동기 스레드가 먼저 실행될 수 있습니다. 이때 DB에 아직 댓글이 저장되지 않은 상태라면 EntityNotFoundException 같은 에러가 발생할 수 있습니다. 따라서 반드시 댓글 저장이 커밋된 직후에 실행되도록 보장했습니다.


3. 개선 결과

이렇게 리팩토링을 진행한 후 얻은 성과는 다음과 같습니다.

  1. 사용자 경험(UX) 개선 알림 발송 같은 부가적인 작업이 백그라운드에서 처리되므로 사용자는 댓글 작성 버튼을 누르자마자 즉각적인 완료 처리를 느낄 수 있도록 하였습니다
  2. 장애 격리 (Fault Tolerance) 알림 서버에 장애가 발생하거나 전송이 실패하더라도 이는 별도의 스레드에서 처리되므로 댓글 작성 기능에는 전혀 영향을 주지 않습니다. 핵심 기능의 안정성이 크게 향상되었습니다.
  3. 유지보수성 향상 (Loose Coupling) 댓글 서비스와 알림 서비스가 분리되었습니다. 나중에 알림 채널을 추가하거나 변경해야 할 때 댓글 서비스 코드는 전혀 건드리지 않고 리스너만 추가하거나 수정하면 되어 확장에 유연한 구조가 되었습니다.

4. 마무리

핵심 비즈니스 로직과 부가 기능을 분리하는 것은 시스템의 안정성과 유지보수성을 위해 매우 중요합니다. 이번 리팩토링을 통해 Spring Event와 비동기 처리가 이러한 분리에 얼마나 유용한지 체감할 수 있었습니다. 비슷한 고민을 하고 계신 분들께 도움이 되기를 바랍니다.

728x90