본문 바로가기

프로젝트/트러블슈팅

[트러블 슈팅] 이벤트로 알림 서비스와 느슨한 결합 만들기

원데이히어로 프로젝트에서 알림 기능을 구현했다. 프로젝트 요구사항에 따르면 미션 플로우가 진행될 때마다 실시간으로 알림을 보내줘야 했다. 여기서 미션은 프로젝트 내의 도메인 용어로 단기 알바, 심부름 요청을 뜻한다.

 

이번 글에서 실시간 알림을 구현하면서 겪었던 문제와 이러한 문제를 어떻게 해결했는지 그리고 남아있는 문제는 무엇인지 공유해보겠다.

 

먼저 미션 서비스 로직 수행부터 알림을 보내는 로직까지 코드는 다음과 같은 흐름으로 동작한다.

  1. 미션 서비스 로직 수행
  2. RDB에서 알림을 위한 데이터를 조회
  3. NoSQL에 알림을 저장
  4. SSE(Server-Sent-Event)를 통해 알림을 전송

여기서 왜 알림 서비스의 메인 데이터베이스를 NoSQL로 선택했는지, SSE로 실시간 알림을 구현했는지는 이 글의 주제와 동떨어진 이야기이므로 넘어가겠다. 그리고 앞서 이야기했던 흐름을 예시로 문제 상황을 설명하기에는 예시가 너무 복잡하다. 따라서 다음과 같이 흐름을 단순화하겠다.

  1. 미션 서비스 로직 수행
  2. 알림을 전송

먼저 이러한 로직을 단순하게 구현해 보자. 미션 서비스 로직을 수행한 뒤 알림 서비스를 호출하여 구현했다.

@RequiredArgsConstructor
@Service
public class MissionService {

    private final NotificationService notificationService;

    @Transactional
    public MissionResponse doMission(
        MissionServiceRequest missionServiceRequest 
    ) {
        // 미션 서비스 로직 수행

        notificationService.notifyClient(Mission mission); // 알림 서비스 호출

        return missionResponse;
    }
}

@RequiredArgsConstructor
@Service
public class NotificationService {

    public void notifyClient(Mission mission) {
        // 알림 전송 로직 수행
    }
}

 

이 코드의 문제점이 무엇일까?

 

바로 트랜잭션 문제이다. 만약 알림 서비스에서 예외가 발생하면 트랜잭션은 롤백된다. 알림 전송에 실패해서 미션과 관련된 작업이 모두 실패한다니 조금 이상하다. 

 

그리고 같이 따라오는 문제가 성능 문제이다. 아래 로그를 확인해 보면 트랜잭션이 알림 서비스까지 이어지고 있다. 만약 알림 전송이 네트워크 작업이라면, 스레드는 불필요하게 오랜 시간 커넥션을 유지하게 된다. 따라서 갑자기 사용자가 몰린다면 DB 커넥션이 부족해 커넥션을 획득하기 위해서 오래 대기해야 한다.

트랜잭션과 성능 문제

 

왜 이런 문제가 발생하는 것일까? 미션과 알림이 강하게 결합되어 있기 때문이다. 미션과 알림의 강결합 때문에 미션 서비스가 알림 서비스에 영향을 받고 있다. 이때 이벤트(event)를 활용하면 이러한 결합을 느슨하게 만들 수 있다.

 

그리고 미션 로직이 성공했을 때만 알림을 전송해야 한다. 따라서 @TransactionalEventListener 어노테이션을 활용하여 트랜잭션이 커밋된 후(AFTER_COMMIT) 이벤트 핸들러가 실행되도록 한다.

@RequiredArgsConstructor
@Service
public class MissionService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public MissionResponse doMission(
        MissionServiceRequest missionServiceRequest 
    ) {
        // 미션 서비스 로직 수행

        eventPublisher.publishEvent(eventPayload); // 알림 이벤트 발행

        return missionResponse;
    }
}

@RequiredArgsConstructor
@Component
public class NotificationEventListener {

    private final NotificationService notificationService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void notifyEvent(EventPayload eventPayload) {
        notificationService.notifyClient(eventPayload);
    }
}

 

 

다시 로그를 확인해 보면, 트랜잭션이 완료된 이후 알림 서비스가 실행된 것을 확인할 수 있다. 

이벤트로 트랜잭션 문제 해결

 

이벤트로 서비스 간의 강결합 문제를 해결했지만, 이벤트 발행과 처리를 모두 동기로 수행하다 보니 문제가 발생했다. 동기로 처리하면 모든 과정을 수행한 뒤 응답한다. 만약 알림 전송에 3초가 걸린다고 하면, 응답 또한 3초 이상 걸리게 된다. 알림은 즉시 전송하지 않아도 되고 일정 시간 안에 전송하면 된다. 

 

 

이러한 문제는 이벤트를 비동기로 처리해 해결할 수 있다. @Async 어노테이션만 메서드 레벨에 붙여준다면 메서드가 비동기로 실행된다.

 

@RequiredArgsConstructor
@Component
public class NotificationEventListener {

    private final NotificationService notificationService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void notifyEvent(EventPayload eventPayload) {
        notificationService.notifyClient(eventPayload);
    }
}

 

다시 로그를 살펴보면, 미션 로직 수행 후 알림 전송이 완료될 때까지 기다리지 않고 바로 응답했다. 그리고 알림 서비스는 async-exec-1 이라는 새로운 스레드에서 동작했다. 이렇게 비동기 이벤트로 처리함으로써 남아있는 문제까지 해결했다.

 

 

지금껏 보여줬던 예시는 실제 프로젝트보다 흐름이 단순하다. 프로젝트에서는 다음과 같이 구현했다.

 

하나씩 살펴보자면,

 

MissionEventService는 알림을 위한 데이터를 조회한다. 따라서 알림 도메인에 속하므로 비동기로 수행한다.

 

NotificationService는 조회한 데이터를 통해 알림을 생성한 뒤 저장한다. NoSQL에서 읽기 쓰기 작업이 일어나므로 트랜잭션이 필요 없다. 따라서 트랜잭션이 성공하여 종료 후 이벤트를 수행한다.

 

마지막으로 SseEmitters는 SseEmitter 객체를 통해 클라이언트에게 알림을 전송한다. 이제 트랜잭션 상태를 고려하지 않아도 된다. 따라서 @EventListener 어노테이션을 사용했다. 

 

 

 

여기까지 왔으면 모든 문제를 해결한 것처럼 보인다. 하지만 여전히 남아있는 문제가 있다. 이 부분은 차후 해결해보고자 한다.

 

먼저 첫 번째 문제는 이벤트 손실에 관한 문제이다.

 

로컬 핸들러를 이용해 이벤트를 비동기로 처리하고 있다. 만약 알림 전송 이벤트가 실패한다면 이벤트를 유실하게 된다. 이러한 문제는 이벤트 저장소를 구축하여 해결할 수 있다. 배치를 사용하여 일정 시간마다 이벤트 저장소에서 실패한 이벤트를 조회해 재처리한다.

  

두 번째 문제는 Scale Out이 어렵다는 문제이다.

 

원데이히어로 프로젝트는 모놀리틱한 구조(Monolithic Architecture)이다. 따라서 충분히 스프링 이벤트를 통해 비동기 이벤트를 처리할 수 있었다. 만약 Scale-Out을 위해 알림 시스템만 따로 서버를 분리한다면 메시징 시스템을 이용해 비동기 이벤트를 처리할 수 있다.

 

당장 메시징 시스템을 도입하지 않은 이유는 첫번 째, 새로운 기술을 공부할 시간이 부족했다. 프로젝트 기한은 한정되어 있고 비동기 이벤트, NoSQL와 더불어 메시징 시스템까지 공부할 영역이 너무 넓었다. 어떤 메시징 시스템을 선택할지부터 적용하기까지 많은 자원이 들었다. 두 번째, 새로운 기술을 도입했으므로 관리 포인트가 늘어난다. 결국, 우리 프로젝트에서 메시징 시스템으로 비동기 이벤트를 처리하는 것은 오버 엔지니어링이라고 판단했다.

 

참고

  • https://techblog.woowahan.com/7835/ (우아한 기술 블로그, 회원시스템 이벤트기반 아키텍처 구축하기)
  • 도메인 주도 개발 시작하기, 최범균