LINE Engineering
Blog

LINE LIVE 채팅 기능의 기반이 되는 아키텍처

Hagiwara Go (Oklahomer) 2016.10.26

LINE 주식회사의 Oklahomer입니다. 

LINE 주식회사의 Oklahomer입니다. 이번 블로그에서는 LINE LIVE라는 동영상 송출 서비스의 채팅 기능이 어떻게 구성되어 있는지 소개하겠습니다.

채팅 소개

LINE LIVE의 iOS/Android 앱에서는 라이브 방송 중인 동영상을 시청하면서 실시간으로 코멘트를 보낼 수 있는 채팅 기능을 제공하고 있습니다. 이 기능의 역할은 시청자들이 서로 대화를 즐기는 것에만 국한되지 않고 동영상 송출자가 시청자가 보낸 코멘트에 답변하면서 송출자와 시청자 사이의 접점이 되기도 합니다. 또한 송출자가 코멘트 내용에 따라 방송을 기획하는 등 송출자와 시청자가 함께 방송을 만들어가는 데 있어서도 중요한 역할을 합니다.

유명인이 라이브 방송을 할 경우에는 당연히 시청자 수도 많아지게 되며, 방송 중에 시청자 코멘트가 쇄도하면 많은 양의 코멘트가 순간적으로 유입될 것이라는 점은 쉽게 예상할 수 있습니다. 물론 코멘트 유입이 증가한다는 것은 모든 시청자에게 중계해야 할 코멘트의 양도 늘어난다는 뜻이기 때문에, 이를 어떻게 해서 신속하게 분산시킬 것인지가 늘 과제입니다. 실제로 한 방송에서만 1분당 1만 건을 넘는 속도로 코멘트가 전송되는 경우도 있습니다.

그렇기 때문에 채팅에서는 폭포수처럼 쏟아지는 코멘트를 감당할 수 있도록 개발을 진행했으며, 현재 100대 이상의 서버 인스턴스에서 가동되고 있습니다.

아래에서 서버 구성에 대해 설명하겠습니다.

전체적인 서버 구성

전체적인 구조는 아래 그림을 참고해 주시기 바랍니다.

자세한 설명은 뒤에서 하기로 하고, 우선 채팅 기능을 구현할 때 중요한 '채팅방' 개념을 살펴보겠습니다. 이를 이해하려면, Chat Server1에 접속된 Client 1이 코멘트를 보내고 그 코멘트가 Chat Server2에 접속된 Client 2로 전송된다는 점에 주목해야 합니다.

앞서 설명했듯이 인기 송출자의 방송은 유입되는 코멘트 양이 많습니다. 다 읽을 수 없을 정도로 쏟아지는 많은 코멘트는 인기를 체감할 수 있는 아주 중요한 요소입니다. 하지만 양이 너무 많으면 코멘트를 분산시키는 서버와 이를 표시하는 클라이언트 모두에 부담이 됩니다. 그래서 우리는 '채팅방'이라는 개념을 도입하여 시청자 수 증가에 따라 채팅방을 분할하고, 같은 채팅방에 속한 유저들만이 서로 대화할 수 있도록 했습니다. 채팅방은 여러 서버에 분산되기 때문에 같은 채팅방에 속한 유저라도 접속은 각기 다른 서버에 밸런싱됩니다.

이를 실현하기 위해 채팅 서버는 아래 3가지 특징을 고려하여 구성하였습니다.

  • WebSocket: 클라이언트와 서버 간의 통신
  • Akka toolkit을 통한 고속 병행 처리
  • Redis를 이용한 서버 간의 코멘트 동기화

그럼 이 3가지 항목에 대해 각각 자세하게 설명하겠습니다.

WebSocket

WebSocket을 도입하면 연결되어 있는 하나의 커넥션에서 낮은 레이턴시로 쌍방향 커뮤니케이션을 할 수 있습니다. 이로써 빠른 속도로 유입되는 코멘트를 실시간으로 유저에게 전달할 수 있을 뿐만 아니라, 유저가 코멘트를 보낼 때마다 HTTP 리퀘스트를 보낼 필요가 없기 때문에 서버 리소스를 효과적으로 사용할 수 있다는 이점을 얻을 수 있습니다.

단, 하나의 커넥션에서 메시징을 할 때는 Web API처럼 엔드포인트에 따라 응답 형식을 나눌 수 없기 때문에 서버와 클라이언트 모두 수신한 페이로드를 적절하게 식별하여 핸들링해야 합니다. 채팅을 구현할 때는 JSON 형식의 페이로드를 주고받는데, 모든 페이로드에 공통된 필드를 하나 추가한 후 그 값을 통해 페이로드가 무엇을 나타내는지를 식별하여, 이에 대응되는 클래스에 맵핑했습니다. 이 방법을 사용한 덕분에 유료 기프트의 구현 등 새로운 페이로드 정의가 필요한 경우에도 유연하게 대응할 수 있습니다.

여기서 주목해야 할 점은 모바일 기기에서 긴 스트리밍을 시청하는 경우 커넥션이 불안정해지는 경우가 자주 있다는 점입니다. 이를 막기 위해, 페이로드의 송신 상태를 감시하여 커넥션이 불안정하다고 판단될 경우에는 일단 커넥션을 끊고 재접속을 유도하는 등의 방법으로 대응하고 있습니다.

Akka toolkit

Akka 액터 시스템을 구성하는 중요한 요소로서 가장 먼저 actor와 supervisor의 구조를 들 수 있습니다. 채팅 서버의 액터 시스템을 구성하기에 앞서, 그 전제가 되는 특징에 대해 소개하겠습니다. 전체적인 액터 모델의 개요에 대한 설명은 여기에서는 생략하겠습니다.

Actor

각각의 actor는 내부에 상태와 행동(behavior)을 가지고 있으며, mailbox라고 불리는 queue가 할당됩니다. actor가 가진 상태는 숨겨져 있어서 외부에서는 파악할 수 없기 때문에, actor 간에 서로 메시지를 주고받으면서 수신한 메시지에 대해 각각 정의된 행동을 한 후 다시 다음 actor로 메시지를 보냅니다.

이러한 메시지 패싱은 비동기적으로 이루어지기 때문에, 메시지를 보낸 actor는 곧바로 자신의 mailbox에서 다음 메시지를 수신하여 다음 처리 단계로 넘어갈 수 있습니다. 따라서, 각각의 actor에는 큰 태스크를 부여하지 않고 세분화된 태스크를 조금씩 처리하면서 서로 메시지 패싱을 하는 것이 액터 시스템 전체의 효율적인 병행 처리를 위해 중요합니다. 이를 잘못 설계하면 블로킹 처리가 actor의 행동에 포함되어, 메시지가 쌓여 mailbox가 넘쳐날 수도 있습니다. 서드 파티의 라이브러리를 사용할 때 실수로 블로킹 API를 호출하게 되는 상황을 특히 주의해야 합니다. 최악의 경우에는 처리가 완전히 차단되며, 이 경우 해당 actor의 실행을 담당하는 스레드가 계속 점유되는 상태가 지속되어 결국에는 스레드 고갈로 이어집니다.

그럼에도 akka 액터 시스템을 도입하면 다음과 같은 이점을 얻을 수 있습니다. '각 actor가 경량 스레드를 할당받은 상태이며, 그 스레드에서만 실행된다'는 콘셉트로 구현 가능하기 때문에, 임의의 actor가 여러 스레드에서 동시에 호출되는 경우가 없습니다. 따라서, actor 내의 상태를 관리할 때와 같은 경우에는 스레드 세이프(thread safe) 상태인지 여부를 고려하지 않아도 됩니다. 또한, 장애 대응력을 높이는 데에 중요한 supervisor의 구조도 이점이라고 할 수 있겠습니다.

Supervisor

Actor의 라이프 사이클을 파악하기 위한 중요한 포인트는 actor가 다른 actor에 의해서만 생성된다는 점입니다. 이로 인해 actor 간에는 반드시 부모-자식 관계가 생겨납니다. 이렇게 생성한 actor(부모)가 생성된 actor(자식)의 supervisor(감시자)로서의 역할을 수행하기 때문에, 모든 actor에는 반드시 supervisor가 존재하게 됩니다. Akka 액터 모델에는 let-it-crash 개념이 있어서, 자식 actor 내에서 예외 처리를 하면 이는 즉시 supervisor인 부모 actor로 전파되어 에러 핸들링은 부모 actor의 책임이 됩니다. 부모 actor는 받은 예외를 식별하여 필요에 따라 아래의 4가지 directive 중에서 가장 적합한 대응 방법을 선택합니다.

  • Restart: Actor 재실행. actor 인스턴스를 새로 만들고 mailbox에 쌓여 있는 다음 메시지부터 이어서 처리합니다.
  • Resume: Mailbox에 쌓여 있는 다음 메시지부터 이어서 처리합니다. Restart가 actor 인스턴스를 다시 생성하는 데에 반해, resume에서는 기존의 actor가 그대로 이용됩니다. Actor 내의 상태를 정상적으로 유지하지 못하게 된 경우 등에는 restart를 사용하고, 처리를 계속할 수 있는 경우에는 resume를 사용하는 식으로 구분해서 쓸 수 있습니다.
  • Stop: Actor 정지. 이 시점에 mailbox에 쌓여 있는 나머지 메시지는 처리되지 않습니다.
  • Escalate: 자식 actor의 예외를 부모도 직접 핸들링할 수 없는 경우에는 escalate하여 더 상위의 supervisor에게 예외를 전파하여 대응하게 합니다.

Actor가 재실행되거나 정지하게 되면, 그 actor를 참조하고 있는 애플리케이션을 구현할 때도 actor의 라이프 사이클을 고려해야 한다는 생각이 듭니다. 하지만, 실제로는 actor를 생성했을 때는 실제 actor가 아니라 ActorRef라고 불리는 actor에 대한 참조만 반환됩니다. 애플리케이션에서는 이 ActorRef에 메시지를 보내게 되기 때문에, 그 아래에 있는 실제 actor가 재실행되는 중인지 혹은 정지되는 중인지 등의 상태를 고려할 필요가 없어서 구현이 심플하게 유지됩니다. 또한, 이렇게 추상화하면 애플리케이션 코드가 actor의 위치를 알 필요가 없어지기 때문에, 여러 대의 서버에 걸쳐 액터 시스템을 구축할 때에도 유연하게 대응할 수 있습니다.

채팅 서버의 액터 시스템 구성

앞서 살펴본 전체적인 구조보다 actor에 더 초점을 맞춰 간략하게 표현한 그림입니다.

위 그림과 같이, 주로 ChatSupervisor, ChatRoomActor, UserActor의 3종류의 actor가 서로 연계하여 유저의 코멘트를 전달합니다. 각각의 역할은 다음과 같습니다.

  • ChatSupervisor: JVM에 단 하나만 존재하는 actor이며, actor를 생성하고 감시하거나 외부에서 유입되는 메시지를 그에 대응되는 actor로 루팅하는 역할을 담당합니다. 우리가 정의하는 actor 중 가장 상위에 위치하는 것으로, 로직을 가지지 않으며 각 메시지를 실제로 처리하지는 않습니다.
  • ChatRoomActor: 각 채팅방마다 생성되는 것이며, 채팅방에서의 코멘트 송신과 송출 종료 등의 이벤트를 나타내는 각종 메시지는 일단 여기로 전달됩니다. 자세한 내용은 뒤에서 설명하겠지만, 서버 간의 코멘트 동기화를 위해 Redis에 publish하거나 Redis에 코멘트를 저장하는 것도 여기에서 실시하며, 클라이언트에 전달해야 할 메시지는 UserActor로 패싱됩니다.
  • UserActor: 유저별로 생성되는 actor이며, ChatRoomActor에서 메시지를 수신하여 자신이 담당하는 클라이언트의 WebSocket 커넥션에 페이로드 송신을 명령합니다.

이상으로 채팅방 내에서 Redis에 연계하는 방법과 UserActor로 WebSocket 커넥션을 통해 페이로드를 송신하는 방법을 설명했습니다. 앞서 설명한 대로 actor 내에서는 블로킹 처리를 하지 않는 것이 중요합니다. 그래서 이러한 처리를 할 때도 되도록 비동기 메서드를 사용하여 Akka 액터 시스템에 할당된 실행 스레드가 점유되는 것을 방지하고 있습니다.

Redis Cluster와 Pub/Sub의 사용

채팅에서는 서버 간 코멘트 동기화와 코멘트 및 각종 수치의 일시적인 저장을 위해 Redis Cluster를 사용하고 있습니다.

코멘트의 동기화

유저 수에 따라 채팅방이 분할된다는 점, 같은 채팅방이라도 여러 서버에 분산된다는 점은 이미 설명했습니다. 하지만 채팅방이 여러 서버에 걸쳐 있는 경우, 서버 간에 어떻게 코멘트를 동기화하는가가 중요합니다. Akka toolkit은 풍부한 기능을 제공하고 있고, Akka cluster나 event bus 등을 사용한다는 선택사항도 있습니다. 하지만 Akka cluster를 채택하면 node의 분산 문제, event bus를 이용하면 배포 시에 번잡스럽다는 문제가 있기 때문에, 이를 고려하여 운영과 구현이 모두 간편한 Redis Pub/Sub 기능을 사용하고 있습니다. 아래 그림을 보시면 더 쉽게 이해할 수 있습니다. 하나의 채팅방이 여러 서버에 걸쳐 존재한다는 점, 같은 채팅방 내의 코멘트가 Redis의 Pub/Sub에 의해 동기화되고 있다는 점을 알 수 있습니다.

고속 KVS로 사용하기

또한, 송출 중인 코멘트를 일시적으로 Redis에 저장하기 위한 용도와 카운터로 사용하기 위한 목적으로 높은 가용성과 확장성을 지닌 Redis Cluster를 채택했습니다. 코멘트의 유입량이 많아질 경우 등을 고려하여 송출 중의 코멘트나 기프트 송신 정보 등의 이벤트는 일단 고속 read/write가 가능한 인메모리 KVS 역할을 하는 Redis Cluster에 저장하고, 송출 종료 후에 영구적인 스토리지 역할을 하는 MySQL에 마이그레이션합니다. Redis에 저장될 때는 이런 각종 이벤트는 송출 경과 시간을 기준으로 한 하나의 정렬된 세트(Sorted Sets)에 저장되기 때문에, 채팅방에 입장했을 때 가장 최근의 이벤트 수십 건을 시계열로 정렬하는 용도 등으로도 유용하게 활용됩니다. 이와 같은 이벤트는 MySQL에 마이그레이션하는 단계에서 정규화되어 해당되는 테이블에 저장됩니다.

Redis 클라이언트

Java의 Redis 클라이언트 라이브러리는 다양한 종류가 있으며, Redis 문서에서 권장하는 라이브러리로는 Jedis, lettuce, Redisson 등이 있습니다. 채팅에서는 아래와 같은 이유로 lettuce를 채택했습니다.

  • Redis Cluster를 지원한다.
  • Master/Slave failover와 MOVED, ASK redirect를 지원하며, node나 hash slot 정보의 캐시를 최신 상태로 유지해 준다.
  • 비동기 API가 제공된다.
  • Pub/Sub를 사용하는 subscribe용 커넥션에서도 failover를 지원한다.
  • 개발이 활발하게 이루어지고 있다.

앞서 설명한 바와 같이, Akka actor 내에서는 블로킹 처리를 최대한 막아야하기 때문에 비동기 API 제공은 매우 중요합니다. 또한 ChatRoomActor에서 Pub/Sub를 사용할 때는 최대 송출 시작부터 종료까지 subscribe를 계속해야 하기 때문에, subscribe용 커넥션의 연결 여부를 모니터링할 수 있어야 하는 것도 중요합니다. Lettuce에는 ClusterClientOptions를 적절하게 설정하면 node down이 감지될 경우 subscribe용 커넥션도 적절히 재연결해 주는 기능이 있습니다. 또한, subscribe할 때 접속 클라이언트가 가장 적은 node에 subscribe용 커넥션을 생성해 주는 것도 큰 이점입니다.

마치며

이상으로 LINE LIVE 채팅 기능의 기반이 되는 구성을 소개했으며 요약하면 아래 3가지와 같습니다.

  • WebSocket을 사용하여 서버와 클라이언트 간의 실시간 양방향 메시징을 지원한다.
  • 서버 내에서는 Akka toolkit을 이용하여 고속으로 병렬 처리한다.
  • Redis Cluster를 일시적인 데이터 저장과 Pub/Sub에 의한 서버 간 코멘트 정보 동기화를 위해 사용한다.

끝으로, 서두에서 언급한 바와 같이 이 기능은 100대 이상의 서버 인스턴스에서 가동되고 있습니다. 그래서 대량의 코멘트를 분산시키다 보면 서드 파티 라이브러리의 경계 조건(edge case)이라고도 할 수 있는 이슈에 봉착하게 되는 경우가 있습니다. 그럴 경우에는 해당 커뮤니티나 GitHub issue 등을 통해 개발자와 커뮤니케이션하여 수정하거나, workaround 사용이 필요하기도 합니다. 최근의 예로는, Redis Cluster에 node를 추가할 때 여러 서버에서 lettuce의 동작이 불안정해지는 현상이 발견되어 GitHub issue를 등록하여 조치된 사례가 있습니다.

LINE에서는 해외 개발자와 함께 즐겁게 일해 보고 싶은 엔지니어를 모집하고 있습니다. 많은 관심과 지원 바랍니다.

LINE LIVE Architecture

Hagiwara Go (Oklahomer) 2016.10.26

LINE 주식회사의 Oklahomer입니다. 

Add this entry to Hatena bookmark

리스트로 돌아가기