알림톡이 전송되지 않았던 이유?

hynnch2·2025년 7월 6일

어느날 회사에서 예약 완료 시 발송하는 카카오톡 알림톡이 전송되지 않는다고 알게 되었습니다.
이상한 점은 해당 부분만 문제가 발생하고 있었으며, 서비스 전체 동작은 문제가 없었다는 점입니다.

왜 이런 이슈가 발생했는지 파헤치는 과정에서 찾은 내용을 공유하고자 글을 작성하며,
이번 이슈에서 깨달은 점은 적절한 모니터링과 Blocking 서비스의 Timeout 설정은 필수라는 점 이었습니다.


Spring EventListener?

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/event/package-summary.html

Spring 공식 문서에는 관련하여 정리된 정보가 많이 없었습니다.
이에, library를 보면서 어떤 기능을 하는지 먼저 파악이 필요했습니다.

우선 spring.context.event package에 나와있는 설명으로는
Support classes for application events, like standard context events. To be supported by all major application context implementations.
즉, Application에서 발생하는 기본 event를 처리하는데 사용한다고 나와있었습니다.

문제 부분

@EventListener
suspend fun onPaymentCompletedEvent(event: PaymentCompletedEvent) = coroutineScope {
	...
	launch {
    	alimtalkService.sendCompletedAlimtalk()
    }
}

-----

fun sendCompletedAlimtalk(~~) {
	...
    applicationEventPublisher.publishEvent(
    	AlimtalkSendEvent()
    )
}

-----

@Async
@EventListener
fun onAlimtalkSendEvent(event: AlimtalkSendEvent) {
	sendAlimtalk() // Blocking API
	...
}

위의 비즈니스 로직 구현을 보면
1. 내부에서 발행한 PaymentEvent를 받아서 비동기로 sendCompletedAlimtalk를 호출한다.
2. sendCompletedAlimtalk 내부에서 publishEvent를 통해 AlimtalkSendEvent를 발행한다.
3. 내부에서 발행한 AlimtalkSendEvent 를 받아서 Alimtalk을 전송한다.

위에서 보면 비동기 동작을 지원하는 Couroutine과 Async 어노테이션을 사용했기 때문에 동작에 문제가 없을 수 있어 보이는데요.

여기에는 2가지 문제점이 있었습니다.

  1. EventListener에서 suspend를 사용했지만, Coroutine Thread 를 사용하지 않음.
  2. Async에서 Blocking 호출을 하고 있었고, Timeout 설정이 되어 있지 않았음

Spring Event 처리 방식 확인 및 특징

먼저 위와 같은 문제가 발생한 것을 확인하기 위해
위에 작성한 코드가 어떻게 동작하는지 정확한 원인 파악이 필요했습니다.

우선, @EventListener 를 사용했을 때, Spring 내부에서 Event를 받아서 처리하는 부분을 확인했습니다.
코드를 보았을 때, 의도한 바는 Coroutine에서 사용하는 Thread를 사용할 것이라 생각했지만, 디버그 결과 main thread를 사용하고 있었기 때문이었습니다.

우선 publish하는 곳의 코드를 따라가보면 다음과 같았습니다.

우선 코드를 하나씩 살펴보면 publish 한 이벤트가 applicationEvnet Type인지 확인합니다.
이에 따라, 최적화 eventType에 대한 hint를 얻고, 처리하므로 성능 향상에 처리가 있을 수 있겠네요.
우리가 발행한 event는 Data Class로 정의한 새로운 class이므로 첫 번째 if문은 false로 처리됩니다.

여기서도 typeHint는 null이기 때문에 payloadType 또한 null로 처리되며
applicationEvent는 PayloadApplicationEvent(this, event, null)로 새로 생성됩니다.

Event가 처리되는 중요한 부분입니다.
위에서는 어떤 데이터가 발행되는지 확인하고 데이터를 처리하는 과정이었다면,
이렇게 만들어진 데이터를 어떻게 발행하고 처리되는지 확인하는 핵심 부분입니다.

여기서 applicationEventMulticaster가 있는지 확인하고 data를 보내게 되는데요.
이 함수는 어디서 주입받을까요.
(spring context에 parent가 있다면 같이 전송하기 때문에, Context가 다른 spring context를 띄웠을 때 부모로 event를 발행하기는 편할 것 같네요)

initApplicationEventMulticaster()를 통해 초기 생성 당시에 주입받게 되는데,
applicationEventMulticaster Bean을 주입하지 않으면 기본으로 SimpleApplicationEventMulticaster을 주입받습니다.

SimpleApplicationEventMulticaster class를 보면 내부에 taskExecutor라는 변수로 Executor를 가지고 있습니다.

즉, 별다른 옵션을 주지 않는다면 taskExecutor에서 관리하는 Thread를 가지고 applicationEvent를 처리하게 됩니다.
그렇다면 우리가 보낸 Event는 SimpleApplicationEventMulticaster를 통해 처리되었고, taskExecutor는 null값으로 처리가 되었을 것으로 보입니다.

executor가 별도로 설정되어 있지 않았으므로 executor가 처리하도록 위임하지 못하고, 해당 쓰레드에서 바로 처리가 되는 것을 확인할 수 있었습니다.

즉, EventListener를 사용할 때 동기적으로 처리되는 이유는 위의 이유 때문이었습니다.

다만, suspend를 포함하여, Async하게 처리한 다른 EventListener는 어떻게 처리가 되는 것인지 확인해봤습니다.
이 부분은 ApplicationListenerMethodAdapter 쪽 코드를 보면 확인할 수 있었습니다.

Adapter에서 event를 받아서 this.doInvoke()를 호출하면

먼저 coroutine을 지원하기 위해 suspend 함수 여부를 확인하고, 처리합니다.
이 과정에서 1, 2번째 케이스가 나뉘기 시작합니다.

1. suspend 함수가 처리하는 EventListener

Suspend 함수로 처리되지만 Dispatchers.,getUnconfined()에서 확인하듯이 현재 쓰레드를 통해 처리합니다.
즉, coroutine 동작으로 처리되지만, 현재 쓰레드로 처리된다는 점이 특징입니다.
이로 인해, 우리가 알던 delay() 같은 함수를 호출하거나 외부 call 호출 시 이전 쓰레드로 처리되지 않을 수 있습니다.

2. @Async 함수가 같이 선언된 EventListener

실제 invoke()를 호출하기 전 getTargetBean()을 통해 메소드를 가져오게 됩니다.
여기서 spring bean에 등록된 것을 가져오기 때문에 @Async가 붙은 어노테이션이 동작하게 되고, proxy로 처리되어 비동기로 처리된다는 것을 알 수 있습니다.

즉, 의도한대로 별도 Async에서 사용할 Thread pool(정의 안했다면 기본 옵션)으로 Event를 처리하게 된다는 것을 알게 되었습니다.

그럼 왜 전송이 안되었던거지?

결국 @Async로 호출되고 있었던 Service 쪽에서 문제가 발생하고 있었습니다.
특정 서버의 API 호출이 잠시 튀면서 응답을 주고 있지 못했고, 이로 인해 서버에서 비동기 쓰레드로 Blocking 호출하고 있던 쓰레드가 점점 고갈되고 있었습니다.
이를 인지하지 못하고 있었고, 결국 Thread pool에 있는 모든 Thread가 무한정 대기하면서 복구하지 못하는 문제가 발생했었습니다.

외부 호출하는 쪽에서는 2~3분 뒤에 응답을 했지만, 서버쪽에서는 왜 이를 인지하지 못하고 계속 Thread를 점유하고 있었는지는 의문이었는데요. 이 부분은 다음에 조금 더 자세한 사유를 확인해보고 정리하려고 합니다.

다만, 우리가 찾은 해결책은

  • @Async에서 사용할 Thread pool을 정의하고, error handling policy 정책을 정한다. => spring에 등록해서 관리
  • API Call의 Timeout 을 전역적으로 셋팅한다.
  • (최종, 추후 적용) eventListener를 걷어내고 외부 queue 리소스를 활용한다.

위와 같이 결론을 짓고 문제 해결하였습니다.

결론

결국 내용을 확인하면 어느정도 의도한 대로 동작은 잘 처리된 것 같습니다.
비동기로 처리되는 것을 기대하였으며, main Service에 영향이 가지 않도록 백그라운드로 잘 처리되었습니다.

다만, 이번에 문제가 되었던 부분은 Blocking API를 호출할 때 응답을 느려질 수 있는 점을 파악하지 못하고, Timeout 처리가 되어 있지 않은 API call을 호출한 것이 문제였습니다.

저희는 gRPC 통신을 하고 있기 때문에, gRPC Interceptor 문서를 참고하여 Global Timeout 을 설정했습니다.
또한,@Async + Custom Thread Pool 을 지정하여 의도한대로 동작할 수 있도록 처리했습니다.

추가로 궁금한 사항이 있다면 질문 부탁드립니다. 감사합니다!

profile
more than yesterday

0개의 댓글