실시간 댓글 개발기(part.3) – Spring의 동시성 접근 제어에 발목 잡힌 이야기

전편(실시간 댓글 개발기(part.2) – 험난했지만 유익했던 웹소켓 스트레스 테스트 및 안정화 작업)에서 공유 드린 바와 같이 다양한 모니터링 및 디버깅 툴의 실제 활용 사례인 Spring SimpleBroker 튜닝기를 소개하고자 합니다.

 

장애분석: lock 걸리는 부분 찾아가기

 

불안정한 서버 문제를 해결하기 위해 전편에서 안내드린 사내 자바 모니터링 시스템인 Neo를 이용해서 Threaddump를 뜬 후에 https://fastthread.io 사이트에서 분석을 수행하였습니다.

아래의 그림과 같이 messageListenerContainer-15xxx 쓰래드에 문제가 있고, 이 lock은 DefaultSubscriptionRegistry$DestinationCache$1 객체에서 걸고 있다고 표시되었는데요, 이 lock때문에 대기중인 thread들을 부채꼴 모양으로 표시해주며, 이를 통해 InboundChannel이 먹통이 되고 있음을 확인할 수 있었습니다.

stacktrace를 떠보면 DefaultSubscriptionRegistry$DestinationCache.getSubscriptions() 메소드에서 문제가 생기고 있음을 알 수 있습니다. 해당 메소드는 MESSAGE 발송 때마다 호출되는 메소드인데요. 즉, 특정 글(topic)에 댓글이 달렸을 때 누가 그 댓글을 구독중인지 찾을 때 사용이 되며, 그 중에서도 cache miss가 발생해서 cache를 새로 채워 넣는 코드입니다.

SUBSCRIBE 할 때 Ant 스타일의 패턴매칭이 가능하도록 한 부분이 문제라는 것을 발견했습니다. 이 때문에 destination을 equal 조건으로 찾지 못하고 loop를 돌게 되었는데요, AntMatcher 자체도 equal에 비해서 느릴 수 밖에 없고 메모리 사용량도 많았습니다.

cache가 작으면 miss가 자주 나서 loop를 자주 돌게되고, cache가 크면 한번 miss 났을 때 뒤져야 할 loop 크기가 커지게 되어 즉, 캐시 크기로 해결 불가능한 문제임을 알 수 있었습니다.

 

Simple Broker 동작 방식 훑어보기

 

위에서 lock이 많이 걸린 것이 문제로 발견되었지만, 어느 부분에서 왜 lock이 많이 걸렸고 이를 어떻게 해결했는지 설명하기 위해서 Simple Broker의 동작을 훑어보고자 합니다.

 

SimpleBroker 기본 구조

https://docs.spring.io/spring-framework/docs/5.0.0.BUILD-SNAPSHOT/spring-framework-reference/html/websocket.html#websocket-stomp-enable

Simple Broker를 사용할 경우, 메시지 발송에 필요한 수신자 목록(sessionID) 관리를 모두 메모리 상에서 처리하게 됩니다. 이 수신자 목록 관리를 담당하는 것이 아래 설명할 SimpleBrokerMessageHandler 입니다.

특정 topic에 대한 SEND 요청이 들어오면 SimpleBrokerMessageHandler는 메모리를 뒤져서 적절한 sessionID를 찾은 뒤, 이 sessionID에 해당하는 response channel에 message를 보내줍니다.

 

SimpleBrokerMessageHandler 동작 방식

 

 

alex-live-server의 경우 일반적인 체팅 서버가 아니라 댓글을 실시간으로 갱신하기 위해서 사용하기 때문에 댓글 목록이 노출되는 시점에 CONNECT가 호출되고, 서버가 응답으로 보내준 CONNECTED를 받으면 곧바로 SUBSCRIBE를 보냅니다.

현재로서는 하나의 페이지에 하나의 댓글만 갱신하므로 SubscriptionID 또한 항상 하나만 사용하면 됩니다.

그리고 SEND 즉, 신규댓글생성은 alex-api-server를 통해서 수행하기 때문에 실제로 websocket client가 발송하지는 않지만 설명을 위해 구분 없이 표시하였습니다.

사용자가 웹페이지를 떠나게 되면 커넥션이 끊기면서 서버에서 DISCONNECT 처리를 수행한다.

요약하자면! 댓글 진입시에 Websocket을 열고 댓글의 구독자로 추가해 둔 다음, 특정 댓글이 갱신되면 그 댓글의 모든 구독자를 찾아서 message를 보내줍니다.

 

SubscriptionRegistry 자료구조

Java 기반 웹서비스가 멀티쓰래드 개발의 난이도를 상당히 낮춰주기는 했지만, Thread safety 문제는 여전히 개발자가 챙겨야하는 부분입니다.

Spring framework 등에서는 Session Scope 등을 직접 관리해 주면서 개발자의 부담을 줄여 준다거나 Redis 등에서는 Atomic Operation을 제공하여 개발자의 부담을 줄여주기도 합니다. 그러나, Spring에서 제공하는 Websocket을 사용하는 이상 Websocket에서 제공하는 세션관리 방법 안에서 어떻게든 해결책을 찾아내야 했고, 이 때문에 세션관리에 사용된 자료구조인 SubscriptionRegistry를 분석하게 되었습니다.

참고로, 자료구조 설명을 위해서 몇가지 표현을 사용하였습니다.

( attr1, attr2, att3 ) : 이 형태는 Tuple을 나타냅니다. 즉, Java 느낌에서는 attr1, attr2, attr3 세 개의 속성을 갖는 클래스를 의미합니다.
Map{ key -> value } : 말 그대로 Map을 표현하는 것인데, Java에서 사용하는 Map라는 표기는 타입만 나타낼 뿐 어떤 의미를 갖는 속성인지 나타낼 수 없기 때문에 약간 다르게 표시하였습니다. 즉, key라고 적힌 부분이 타입이 아닌 속성명이 들어갑니다.
Set{ key } : 역시 Set을 표현하며, Map과 내용은 같습니다.

 

SessionSubscriptionRegistry는 sessionID를 key로 하고 (sessionID, map{ destination -> set{subscriptionID} })를 value로 하는 자료구조입니다.

즉, SessionID가 주어지면 그 SessionID가 구독 중인 destination(즉, topic)과 subscriptionID를 알아낼 수 있습니다. 그런데, 메시지를 발송할 때는 destinationID로 sessionID를 찾아야 하므로 SessionSubscriptionRegistry를 매번 뒤지기에는 무리가 있습니다.

이를 위해 만들어진 것이 DestinationCache입니다.
DestinationCache에는 updateCache와 accessCache 두개의 MAP이 있으며, updateCache는 SessionSubscriptionRegistry에 변경이 생길 때마다 갱신해주고 cacheLimit이 차면 오래된 것을 지워주는데 사용되며, accessCache는 findSubscriptions() 메소드에서 사용합니다.

 

문제가 된 부분

 

updateAfterNewSubscription vs getSubscriptions()

구독자가 새로 추가되면 캐시에 구독자를 추가 시켜줍니다. 그런데 Ant Matcher를 쓰게 되면 이 사람이 어느 destination을 구독 하는지 찾기 위해서 캐시 전체를 뒤져야합니다. 캐시가 커질수록 이 부분에서 시간을 끌게 되는데요, 문제는 라이브댓글은 일반적인 대화방이 아니고 사용자가 댓글을 보기만해도 대화방에 진입한 것으로 처리합니다. 전체알림이 빵 터지면 수백만명이 몰려들어오고, 뉴스기사를 이것저것 보기 때문에 그때마다 채팅방 진입/진출이 발생합니다. 이 때문에 캐시를 마냥 늘릴 수가 없습니다.

Lock이 자주 걸리는 또다른 부분이 getSubscriptions()입니다. 이 메소드는 댓글이 작성될 때마다 호출되는데요, 이때 cache miss가 나면 모든 session을 뒤져서 각 session들의 Ant 패턴을 검사해보아야 합니다. 라이브댓글에서 대당 10K 사용자를 목표로 하기도 했고, 실제로 서버당 5k 정도는 수시로 채워지기 때문에 그 수준에서 문제없이 동작해야합니다.

 

설계변경: antMatcher 제거

 

변경된 부분은 몇 줄 되지 않습니다! AntPathMatcher를 사용하던 것들만 제거하였는데요,이 과정에서 살짝 부수적인 문제가 발생했는데, 원래 구현체인 DefaultSubscriptionRegistry가 상속을 전혀 하지 못하는 구조로 만들어져 있다는 점이었습니다. 이 때문에 새로 만든 Class에 전체 코드를 복붙한 다음 일부만 수정하는 형태로 구현하였습니다.

또한 SimpleBrokerMessageHandler 생성시점에 SubscriptionRegistry를 바꿀 방법을 제공하지 않기 때문에 bean 생성 후에 바꿔치기 하는 방식으로 구현했습니다. 이럴 경우 bean 생성하는 시점과 subscriptionRegistry를 교체하는 시점이 흩어져있기 때문에 설정을 덮어 쓴다거나 코드 수정할 때 누락되는 문제가 발생할 여지가 있어서 찜찜함이 남게 됩니다.

 

getSubscriptions() : pattern.match() -> destination.equals()

getSubscriptions() 메소드는 서버 수가 아무리 많아진다고 하더라도 초당 댓글 갯수만큼 모든 서버들에서 호출됩니다. 초당 수천번 호출될 수도 있는만큼 Ant Matcher를 걷어낸 것 만으로도 효과가 있었습니다.

 

updateAfterNewSubscription() : forEach() -> !=

Ant Matcher를 없앤다는 것은 Map으로 구현된 cache에서 O(1)로 검색할 수 있게 되었다는 것을 의미합니다. 이 덕분에 cacheLimit을 늘리는데 따른 부담도 줄어들었습니다.

 

결과

 

6월30일 트럼프가 군사분계선을 걸어서 넘어간 날 전체알림과 함께 사용자수와 뉴스기사가 폭증하였는데 다행히 Queue가 밀려서 발생하는 일시적인 지연은 있었으나, Queue가 해소되면서 정상으로 돌아왔습니다.

세부 지표로 보면 InboundQueue에 쌓여있던 대기 건들이 많이 해소되었는데, updateAfterNewSubscription() 문제가 해결되어서 그런 것으로 추정하였습니다.

 

마치며

 

그동안 개발했던 Spring Framework 기반 어플리케이션들은 동기화 이슈를 스스로 짠 코드에서 처리했었고, 그나마도 대부분 DBMS 중심으로 처리하였습니다. 그래서 우리가 짠 코드 및 설정 안에서 문제를 해결하려다 보니 시간이 조금 더 오래 걸렸던 것 같습니다. 쓰레드 덤프에서 “BLOCKED”라고 대문짝만하게 표시해주고 있었음에도 SimpleBroker 코드에 주목하는데 시간이 걸렸습니다. 이번 기회를 통해 다른 사람이 작성한 코드에서 발생하는 동시성이슈를 해결하는 능력을 업그레이드 할 수 있어서 개인적으로 기쁘게 생각합니다!

끝으로 성능이슈로 인해 서비스 정상화가 지연되는 와중에도 프로젝트 맴버들을 믿고 완주할 수 있도록 리소스를 확보해준 matthew에게 감사 말씀 드리며, 혼돈의 카오스를 함께하면서 수많은 좋은 기록들을 남겨 주어서 문제해결과 문서작성에 큰 기여를 했으나 조직이동으로 문서작업을 함께하지 못한 aiden에게도 사…감사하다는 말을 남기며 글을 마무리합니다. 감사합니다!

 


 

인터랙션플랫폼셀에서는 더 좋은 서비스를 위해 고민을 하고 함께 문제를 해결해 나갈 분을 찾고 있습니다. 아래 모집 공고를 참고하여 많은 관심과 지원 부탁드립니다. : )
“인터랙션플랫폼(댓글, 투표, 공감)웹 서비스 개발자 모집” 바로가기!

 

실시간 댓글 개발기

 

카카오톡 공유 보내기 버튼

Latest Posts