스프링 부트/Java

스프링 이벤트와 비동기 처리를 통한 알림톡 발송 성능 개선하기

주인 완 2024. 11. 13. 01:09

최근에 학교 축제의 주점 입장에 활용되는 원격 웨이팅 서비스를 개발하던 중, 사용자 입장에서 무척 아쉬운 문제를 발견했다. 바로 웨이팅을 신청하는 데 걸리는 시간이 평균적으로 2~3초 정도 걸린다는 것이 그 원인이었다.

2초 가까이 걸리는 웨이팅..

이를 스프링 이벤트와 비동기 처리를 통해 관심사를 분리시킴으로써 평균적으로 10~30ms 정도 소요되도록 개선하였다. 대략적으로 소요 시간을 약 100배 정도 개선한 것이다!

 

어떤 부분에서 해당 문제가 대두되었고, 이를 어떻게 해결하였는지 적어보려고 한다.

 

기존 로직의 문제점

웨이팅 신청 로직 예시 코드

기존 로직은 웨이팅 신청, 그리고 알림톡 전송 로직이 강하게(분산 락을 통해) 결합되어 있는 상황이었다. 강하게 결합된 로직들은 단 하나의 로직이 실패하면 트랜잭션 자체가 롤백되어 정합성을 보장한다는 특징을 지닌다. 하지만 그러한 특징이 오히려 알림톡 전송이 실패했을 때 웨이팅 신청의 로직 자체를 롤백하여 되돌린다는 단점을 야기하였다.

 

웨이팅 하나를 신청하는 데 평균 3초라는 오랜 시간이 걸린 이유는 결국 강결합 때문이었던 것이다. 알림톡 발송에 대한 응답이 올 때까지 웨이팅 신청 로직이 이를 대기하여 소요 시간이 무척이나 오래 걸리는 것이었다.

 

주요 관심사 <-> 부가 관심사

이를 주요 관심사 부가 관심사의 개념을 적용하여 관심사를 분리함으로서 해결할 수 있었다.

 

주요 관심사는 핵심 목적이나 기능을 수행하는 것을 뜻한다. 이 경우, 웨이팅 신청(정확히 말하면 웨이팅 객체를 DB에 커밋하는 작업)이 해당되며, 웨이팅 신청 로직의 최우선 과제이다. 

부가 관심사는 핵심 목적을 보조하거나, 실패해도 비즈니스 로직에 치명적인 영향을 미치지 않는 부가적인 작업이다. 이 경우, 알림톡 발송이 부가 관심사에 해당한다고 우리는 판단하였고 근거는 다음과 같다. 

 

1. 알림톡이 전송되지 않아도, 마이페이지를 통해 실시간 대기를 확인할 수 있다.

2. 알림톡은 사용자 경험을 개선하는 기능이지, 웨이팅 신청의 정상적인 처리와는 직접적인 연관이 없다.

 

따라서 주요 관심사(DB 커밋)과 부가 관심사(알림톡 발송)의 결합도를 낮춤으로써 주요 관심사의 불필요한 기다림을 방지하였고, 이를 ApplicationEventPublisher을 통한 이벤트 처리를 통해 구현해주었다.

 

ApplicationEventPublisher

ApplicationEventPublisher는 어노테이션을 통해 이벤트 처리를 용이하게 도와준다.

흐름도

1. 이벤트를 발행 시, ApplicationEventPublisher가 Event를 전달받는다.
2. Event를 EventMultiCaster에게 전달한다.
3. EventMultiCaster는 EventHandler에게 Event를 브로드캐스팅(전파)한다.
4. EventHandler 내의 각 Listener는 자신의 파라미터 이벤트 타입을 검증하여 일치하면 해당 이벤트를 실행한다.

 

따라서 우리는 발행할 로직에서 사용될 Event과, 이벤트를 수신할 Listener을 구현해야 한다.

 

이벤트 클래스

우리는 알림톡 발송을 이벤트로 발행해야 하니, 알림톡 발송 시에 필요한 환경 변수들을 담아주는 클래스를 구현해주어야 한다. 이때, 이벤트는 현재를 기준으로 과거에 일어난 일이니 클래스명을 과거형으로 작성해주어야 한다.

알림톡 발송을 위한 이벤트 클래스

 

그리고 기존의 웨이팅 신청 로직에서 알림톡 발송 로직을 이벤트를 발행하도록 변경한다.

이벤트 발행이 적용된 웨이팅 신청 로직

 

이벤트 핸들러

이제 이벤트를 발행하였으니, 발행한 이벤트를 받아 처리할 핸들러(Listener)를 구현해주어야 한다.

정말 감사하게도 스프링은 @EventLister 어노테이션을 통해 Listener 구현을 위한 불필요한 과정을 추상화시켜 준다.

매개변수로 이벤트 클래스를 전달받아 이벤트를 처리하는 코드

매개변수로 이벤트 클래스를 전달받아, Listener는 자신의 이벤트 타입과 검증한다. 일치하면 해당 이벤트를 실행하고, 그렇지 않으면 아무 동작도 하지 않는다.

 

여전히 하나의 트랜잭션

우리는 신청한 웨이팅이 정상적으로 커밋된 이후 이벤트(알림톡 발송)를 처리하려고 한다. 하지만 현재의 코드로는 하나의 트랜잭션 내에서 작동해, 이벤트를 발행한 트랜잭션에서 이를 분리하는 것이 불가능하다. 이를 우리는 @TransactionalEventListener 어노테이션을 통해 구현해 줄 수 있다.

 

@TransactionalEventListener은 이벤트를 실행하는 시점을 옵션을 통해 지정해 줄 수 있는데, 우리는 기본값인  AFTER_COMMIT(트랜잭션이 커밋된 이후 이벤트 실행)을 사용한다.

위의 코드에서 변경된 부분은 어노테이션 하나이다.

 

이벤트 처리를 통해 얻는 이점들

이벤트 처리를 통해 우리는 하나의 큰 트랜잭션을 분리할 수 있게 되었다!

  • 별도의 트랜잭션으로 서비스 로직을 분리하여 웨이팅 신청 <-> 알림톡 발송 간 영향도를 완전히 제거하였다.
  • 알림톡 발송이 실패해도, 웨이팅 신청은 정상적으로 진행된다.
  • 알림톡 발송이 성공해도, 웨이팅 커밋이 실패하면 웨이팅 신청 로직은 실패한 것으로 간주된다.

 

성능이 개선된 것은 아니다. 

스프링에서 하나의 요청은 하나의 스레드가 처리한다. 따라서 중간에 별도의 트랜잭션이 생성되어도 기존의 쓰레드가 해당 트랜잭션을 이어서 처리하기에, 소요 시간은 엇비슷하다. 즉 동기 처리인 것이다. 이렇게 되면 사용자는 웨이팅 신청 이후 알림톡이 발송이 완료될 때까지 응답을 기다려야 한다. 그 누구도 웨이팅 하나 신청하는데 3초나 기다릴 여유는 없을 것이다. 현대 사회는 바쁘니까..

 

그러면 대체 어떻게 해야 1초도 안 되는 시간에 웨이팅 신청을 완료할 수 있을까? 간단하다. 비동기로 처리하면 된다.

 

비동기 처리

항상 그래왔듯이, 스프링은 비동기 처리를 @Async, @EnableAsync 어노테이션을 통해 간단히 지원해 준다.

  • @Async : 비동기 처리를 원하는 메서드에 적용해 준다.
  • @EnableAsync : 메인 클래스 또는 스레드 풀 설정 클래스에 적용해 준다.

이때 주의해야 할 점이 있는데, 스레드 풀을 별도로 설정하지 않는다면 새로운 요청에 비례해 새로운 쓰레드가 생성된다는 것이다..

따라서 우리는 스레드 풀을 설정해주어야 한다. 글의 범위가 조금 벗어난 것 같지만, 설정 클래스를 하나 언급하는건 크게 문제가 되지 않는다고 생각하여 그냥 작성하였다.

쓰레드 풀, 큐, 이름을 설정하는 클래스

스레드 풀과 큐의 크기는 사용하는 인스턴스의 성능에 따라 적절히 조절해주면 된다.

최종 이벤트 핸들러 클래스 코드

 

쓰레드 검증

이제 웨이팅 신청 시, 우리가 별도로 지정해 준 스레드에서 이벤트가 제대로 실행되는지 확인해 볼 시간이다.

웨이팅 신청했을 때의 로그

CreateWaiting 스레드를 통해 우리가 의도한 대로 이벤트 핸들러가 제대로 작동하는 모습을 확인할 수 있다. 또한, 웨이팅을 커밋하는 스레드와 알림톡을 발송할 때의 스레드가 다르다는 것도 확인할 수 있다.

 

성능 검증

포스트맨을 통한 웨이팅 신청 API 테스트

35ms가 소요되는 것을 확인할 수 있다.

 

마무리

스프링 이벤트 처리와 비동기 처리를 통해 웨이팅 신청 로직의 성능을 약 100배 정도 개선해 보았다. 해당 로직만 개선한 것이 아니라 서비스 내에서 알림톡을 사용하는 전체적인 로직의 관심사를 파악하고, 필요한 로직은 해당 작업을 통해 성능을 개선해 주었다.

 

긴 글을 읽어주신 모든 분들께 감사하다는 말씀을 드린다.