들어가며
현재 일본과 대만, 태국에서는 LINE 앱에서 LINE VOOM이라는 서비스를 제공하고 있습니다. LINE VOOM은 LINE 앱 사용자를 위한 SNS로 사진과 텍스트, 비디오 등의 포스트를 업로드할 수 있는 공간입니다.
VOOM RecSys Platform 팀에서는 수많은 포스트 중 사용자들이 관심을 가질 만한 포스트를 추천해 주기 위해 모델 학습 파이프라인부터 사용자 화면에 결과를 전달하는 API에 이르기까지 전체 시스템을 설계하고 구현해 운영하고 있는데요. 이전에는 저희 팀에서 포스트를 추천하기 위해 하루에 한 번씩 모델을 학습하며 그날 추천에 나갈 포스트 후보군들을 추리는 방식으로 진행했습니다. 그러나 이 방식은 전날 데이터를 기반으로 학습하고 추천하기 때문에 월드컵이나 올림픽 같은 실시간 이벤트나 그날 새로 올라온 뉴스를 반영하는 데에는 한계가 있었습니다. 이에 이런 한계를 극복하고 사용자에게 보다 더 최신 포스트를 추천할 수 있도록 2023년의 시작과 함께 실시간 추천 시스템을 구축하기 시작해 2023년 11월 말에 실제 서비스에 적용했습니다.
이를 위해 실시간으로 이벤트를 수신해 검수, 임베딩 생성 등의 작업을 처리하는 ‘Retriever’ 시스템을 구축했습니다. Retriever의 사전적 의미는 '리트리버(사냥 때 총으로 쏜 새를 찾아 오는 데 이용하는 큰 개)'인데요. 요청 받은 이벤트를 검색해서 가져오는 시스템의 기능을 고려해 이름을 지었습니다.
Retriever 소개
사용자가 게시물 생성/수정/삭제와 같은 액션을 실행할 때마다 Kafka의 특정 토픽에 액션에 대한 이벤트가 쌓입니다. Retriever는 이렇게 토픽에 쌓인 포스트 이벤트를 소비(consume)해서 추천해도 되는 포스트인지 확인하기 위한 다양한 검수를 진행합니다. 또한, 검수를 통과한 포스트를 추천에 활용할 수 있도록 비디오 길이나 해시 같은 메타 데이터를 추출한 뒤 모델 학습 및 예측을 위한 임베딩을 생성하고 저장하는 등의 처리 작업을 수행합니다.
Retriever의 구조를 설계할 때 다음과 같이 5가지 주요 원칙을 세웠습니다.
- 확장성: 특정 시기(예: 1월 1일)에 게시물 생성이 급증하는 경우에도 쉽게 대응할 수 있어야 합니다.
- 유연성: 게시물이 추천 시스템에 포함되기까지 다양한 단계를 거쳐야 하는데 이 과정에 필요한 사항이 추가되거나 삭제될 때 빠르게 대응할 수 있어야 합니다.
- 안정성: 시스템의 한 부분에 문제가 발생해도 이 문제가 다른 부분으로 확산되지 않아야 합니다.
- 쉬운 운영: 게시물 처리 과정을 쉽게 추적할 수 있어야 하고, 필요할 경우 쉽게 재처리할 수 있어야 합니다.
- 성능: 작업을 병렬로 처리할 수 있어야 합니다.
위 원칙을 지키기 위해 큐잉(queueing) 시스템을 채택했습니다. 큐잉 시스템이 위 원칙을 지키기에 적합한 이유는 다음과 같습니다.
- 확장성: 큐잉 시스템은 작업을 큐에 보관하며 부하에 따라 소비자(consumer)의 수를 동적으로 조절해 확장성을 보장합니다.
- 유연성: 처리 과정을 변경해야 할 때, 큐잉 시스템은 파이프라인의 단계를 유연하게 수정하거나 추가, 제거할 수 있는 구조를 제공합니다.
- 안정성: 시스템의 한 부분에 장애가 발생하더라도 큐에 저장된 작업은 안전하게 보호되며, 장애가 해결된 후에도 작업을 계속 처리할 수 있습니다.
- 쉬운 운영: 큐에서 작업 상태를 쉽게 추적할 수 있으며, 처리 실패 시 재처리가 간편합니다. 작업 처리 완료 후 커밋을 발행하는 방식으로 실패한 작업을 재처리할 수 있습니다.
- 성능: 큐 시스템은 병렬 처리를 용이하게 해 시스템 전체의 처리량을 높일 수 있습니다.
이런 이유로 Retriever의 전체 구조는 큐잉 시스템을 기반으로 구성했습니다.
초기 버전에서는 Go 언어의 채널을 메시지 큐의 역할과 유사하게 활용했습니다. 신속하게 개발해서 편리하게 사용할 수 있다는 장점 때문이었는데요. 사용하다 보니 다음과 같은 이유로 채널을 대신 할 메시지 큐 적용을 고려하게 됐습니다.
- 불안정성: Go 채널은 메모리를 기반으로 통신하기 때문에 프로그램 종료나 장애 발생 시 채널 내 메시지가 손실될 수 있습니다. 반면 Kafka나 일반적인 메시지 큐는 메시지를 디스크에 영구적으로 저장하기 때문에 시스템 장애가 발생해도 메시지를 보호할 수 있습니다.
- 확장성 제한: Go 채널은 프로그램 내에서만 작동하므로 시스템을 확장하거나 여러 인스턴스로 분산하는 데 제한이 있습니다. 반면 메시지 큐는 부하에 따라 유연하게 확장할 수 있는 기능을 제공합니다.
- 메시지 버퍼링 제한: Go 채널은 버퍼 크기가 고정돼 있어 버퍼가 가득 차면 메시지 전송이 블로킹될 수 있습니다. 대규모 시스템에서는 동적 메시지 버퍼링이 필요한데요. 이를 메시지 큐에서 제공하고 있습니다.
- 메시지 처리 보장 부재: Go 채널은 메시지 처리를 보장하는 내장 메커니즘이 없지만, 메시지 큐는 메시지가 안전하게 처리될 때까지 보관하는 등의 처리 보장 기능을 제공합니다.
- 모니터링 및 관리 도구 부재: Go 채널은 별다른 관리 도구를 제공하지 않아 대규모 시스템에서 운영 및 관리하기가 복잡합니다. 반면 Kafka나 메시지 큐의 경우 모니터링과 경고, 로깅을 위한 풍부한 도구와 인터페이스를 제공합니다.
위와 같은 사항을 고려해 안정성과 확장성, 관리 용이성을 갖춘 메시지 큐로 전환하기로 결정했고, 여러 큐잉 시스템을 면밀히 검토한 후 Redis Streams를 새로운 큐잉 솔루션으로 선택했습니다. Redis Streams가 다른 메시지 큐와 비교해 어떤 장점이 있었는지 자세히 살펴보겠습니다.
메시지 큐로 Redis Streams를 선택한 이유
메시지 큐로 사용할 수 있는 기술 스택은 굉장히 다양합니다. 그 중 저희가 리트리버에 적합한 MQ를 선정할 때 고려한 조건은 다음과 같습니다.
- 쿠버네티스 환경의 여러 파드(pod)에서 브로드캐스트 방식이 아니라 중복 없이 처리 가능한가?
- 큐에 메시지가 발행됐을 때 해당 큐를 구독/소비하고 있는 소비자(파드)에게 모두 동일한 메시지가 전송(브로드캐스트)되느냐? 아니면 각 파드별로 서로 다른 메시지를 중복 없이 처리할 수 있는가?
- 처리 과정 중 실패한 메시지를 안전하게 재처리할 수 있는가?
- 사내 인프라를 활용할 수 있는가?
다음은 여러 기술을 대상으로 위 세 가지 조건을 만족하는지 조사한 표입니다.
중복 없이 동시 처리 가능 여부 | 재처리 가능 여부 | 사내 인프라 활용 및 인프라에 대한 유지 보수 위탁 가능 여부 | |
---|---|---|---|
RabbitMQ | O | O | X |
ActiveMQ | O | O | X |
Redis Lists | X | X | O |
Redis Pub-Sub | X | X | O |
Redis Streams | O | O | O |
이에 '관리 포인트를 최소화할 수 있는 방법은 없을까?'를 고민한 끝에 Redis를 선택했습니다. 저희 회사에서는 개발자들이 AWS와 같은 다양한 서비스를 보다 쉽게 사용할 수 있도록 Verda라는 사내 프라이빗 클라우드 플랫폼을 제공합니다. Verda에서 제공하는 서비스를 활용하면 서버 구축 및 관리에 대한 부담을 줄일 수 있는데요. Redis의 경우 간편하게 서버를 발급 받을 수 있고, Grafana를 이용한 모니터링 설정도 완비돼 있으며, Verda Redis 팀에게 기본적인 운영 관련 도움도 받을 수 있습니다. 여러모로 저희 팀에게는 최적의 선택이었습니다.
단, Redis는 RabbitMQ나 ActiveMQ에 비해 메시지 큐로 사용된 역사가 상대적으로 짧고 사용 사례도 적었습니다. 따라서 Retriever에서 필요한 기능을 Redis에서 잘 제공하고 있는지 확인할 필요가 있었습니다.
Redis를 활용해 메시지 큐를 구현하는 방법은 크게 세 가지로 Lists, Pub/Sub, Streams입니다.
먼저 Lists 자료형은 링크드 리스트(linked list)로 구현돼 있으며, LPUSH 명령어로 새로운 데이터를 추가하고 RPOP 명령어로 가장 오래된 데이터를 꺼내오는 FIFO 방식을 통해 메시지 큐로 활용할 수 있습니다. 다만, 서버 재시작 같은 상황에서 데이터 처리에 실패하면 큐에서 데이터를 다시 가져와 재처리하는 것이 불가능해 메시지 유실의 위험이 있습니다.
다음으로 Pub/Sub은 데이터를 저장하는 토픽(topic)이라는 개념과 함께, 메시지를 발행하는 발행자(publisher)와 해당 토픽을 구독해 메시지를 수신하는 구독자( (subscriber)로 구성됩니다. 이 방식 역시 메시지 수신을 보장하지 않아 유실의 위험이 있습니다. 또한 구독자들에게 메시지를 일괄적으로 발행하기 때문에 채팅이나 푸시 알림과 같은 용도로는 적합하지만 Retriever와 같이 메시지를 중복 없이 동시에 처리해야 하는 경우에는 부적합합니다.
마지막으로 Streams 자료형은 5.0 버전에서 새로 도입된 자료형으로, 추가 전용(append-only) 로그 방식으로 작동하며 메시지를 발행하는 생산자(producer)와 메시지를 소비하는 소비자(consumer)로 구성됩니다. 일견 Pub/Sub과 유사한데요. 중요한 차이점은 소비자들을 소비자 그룹으로 묶을 수 있다는 것입니다. 이렇게 같은 그룹으로 묶인 소비자들에게는 동일한 메시지를 일괄적으로 발행하는 것이 아니라 각각 다른 메시지를 순차적으로 전달함으로써 중복 없이 동시 처리가 가능하게 만듭니다. 또한 XACK 명령어를 사용해 메시지 처리 여부를 확인할 수 있고, 일정 기간 동안 ACK를 받지 못한 메시지는 다른 소비자가 재처리할 수 있습니다.
이와 같은 장점을 고려해 비록 상대적으로 사용 사례가 적지만 Retriever의 요구 사항을 모두 충족하는 Redis Streams를 메시지 큐로 선택했습니다.