🐧:) THRON은 중복이나 데이터 손실 없이 모든 콘텐츠와 제품 정보를 단일 지점에서 제어, 관리 및 배포할 수 있는 디지털 자산 관리 SaaS 서비스를 제공하고 있는 회사입니다.
이 회사의 아키텍쳐는 대부분이 비동기 방식으로 이루어져 있는데요, 비동기 방식으로 아키텍쳐를 유지할 때에 신경써주어야 하는 테넌트에 대한 ‘공정성’ 에 대해서 어떻게 이를 구현할 수 있을 지 THRON 엔지니어링 팀의 글 이 있어 소개 합니다.
특히, 문제를 정의하고 이를 위한 솔루션을 모색하고 구현하는 방식을 주의깊게 보시면 다른 문제를 해결하실 때에도 인사이트를 얻어가실 수 있으실 겁니다. 이 글에서는 샤딩(Sharding)이라는 개념과 큐(Queue) 개념이 많이 나오는데, 만약 개념을 모르신다면, 이에 대해서 먼저 이해하고 오시면 더욱 좋습니다. 원본 글은 여기서 확인 하실 수 있습니다.
THRON은 멀티테넌트 SaaS이며, 이러한 종류의 시스템에서 해결해야 할 과제 중 하나는 테넌트에 대해 공정성을 유지하는 것입니다. 대부분의 구성 요소가 비동기 방식으로 작업을 수행하는 마이크로서비스 아키텍처를 기반으로 하고 있으며, 이 글에서는 샤딩 큐의 개념, 공유 리소스에서 여러 테넌트 간의 우선순위를 관리하는 데 샤딩 큐를 사용하는 방법, 그리고 내부 큐 라이브러리를 구축한 방법과 이유에 대해 설명합니다.
해결해야 할 문제
공정성을 보장하기 위해 THRON은 아키텍처에 샤딩된 큐을 도입했었습니다.
이것이 어떻게 작동하는지 이해하기 위해 콘텐츠 변환 서비스 큐을 예로 들어보겠습니다. 파일이 업로드될 때마다 여러 형식을 만들고, 의미 정보를 추출하고, 기타 전처리 작업을 수행해야 합니다. 아래처럼 변환 완료 큐에서 변환 완료를 지속적으로 선택하여 처리하는 변환 서비스가 있다고 가정해 보겠습니다.
비용 관리 관점에서 변환 서비스 풀은 일반적으로 제한되어 있으므로 수행해야 하는 작업이 수행 가능한 인스턴스보다 많은 경우가 발생할 수 있다는 점을 고려하는 것이 중요합니다. ( 즉, 한정된 자원으로 더 많은 일을 해야 한다고 이해하시면 됩니다. ) 한 테넌트 A가 많은 수의 이미지를 업로드하는 반면 다른 테넌트인 B와 C는 몇 개의 이미지만 업로드하는 경우 어떻게 될까요?
단순 큐(FIFO)을 사용하는 경우, 운이 나쁘게도 B와 C는 엄청난 양의 A 작업 뒤에 자신의 작업이 큐에 있게 됩니다. 이 시나리오는 공정성 요건을 위반하는 것이며, 이런 상황을 피하고 싶었습니다.
변환 서비스를 무한대로 확장할 수 있다고 가정하면 모든 것을 한 번에 처리할 수 있지만, 안타깝게도 현실에서는 불가능합니다. 비용의 상한선이 존재하며, 대역폭이나 쓰기 처리량 제한과 같은 제한이 있습니다. 또한 향후에는 비즈니스 요구사항(비즈니스 모델, 다양한 유형의 구독 등)에 따라 테넌트당 변환 서비스 처리량을 조절해야 할 수도 있습니다.
이는 콘텐츠 가져오기가 시간이 중요하지 않은 경우에도 적용됩니다. 기존의 멀티 테라바이트 크기의 아카이브를 THRON 제품으로 마이그레이션하는 경우, 몇 주 안에 이 작업을 완료하는 것이 합리적일 때가 있는데 이런 경우에는 왜 인프라와 비용 때문에 'Full Throttle'로 진행해야 하는지에 대한 의문이 생길 수도 있습니다.
문제 해결 목표
저희는 변환 서비스에서 임시 로직에 의존하지 않고 공정성을 달성하는 것을 목표로 잡았고 (테넌트 공정성이 필요한 모든 생산자/소비자에게 이 솔루션을 적용할 수 있도록), 이를 위해 샤딩 큐로 구현하였습니다.
변환 서비스 관점에서는 변환 처리 대기열이라는 하나의 대기열만 존재합니다. 이 큐는 테넌트당 하나씩 서로 다른 큐로 볼 수 있는 여러 개의 샤드로 구성됩니다. 변환 서비스가 변환 완료 대기열에서 작업을 큐로 부터 꺼낼때, 실제로는 라운드 로빈 방식으로 샤드 중 하나에서 작업을 큐에서 꺼내는 것입니다.
즉, 큐에 n개의 샤드에 관련된 메시지가 있고 n개의 작업을 큐 로부터 꺼내면, 서로 다른 테넌트가 작업을 큐에 넣은 순서와 테넌트당 작업 수에 관계없이 서로 다른 테넌트의 작업을 얻게 됩니다.
요구 사항
- 공정성 (fairness) : 한 테넌트의 행동이 다른 테넌트에 어떤 식으로든 영향을 미치지 않아야 합니다;
- 확장성 (scalability) : 높은 부하를 처리하고 메시지 수에 따라 확장할 수 있어야 합니다;
- 배달 보장 (delivery guarantees) : 최소 한 번 이상. 중복 배달은 용인할 수 있지만 손실은 없어야 합니다.
솔루션을 구매할 것인지 구현 할 것인지
바로 사용할 수 있는 제품을 찾으려고 노력했지만 요구 사항을 충족하는 제품을 찾지 못했습니다. 저희는 기존의 검증된 빌딩 블록 위에 추상화 계층을 추가하여 모든 내부 엔지니어링 팀이 라이브러리로 사용할 수 있도록 하고 싶었습니다. 이를 어떻게 구현하고, 어떤 빌딩 블록을 사용하기로 결정 했는지 살펴보겠습니다.
구현 방법
아이디어는 간단합니다. 샤드 ID가 포함된 순환 대기열과 샤드별 대기열이 있는 매우 간단한 개념입니다. 메시지를 큐로 부터 꺼내려면 순환 대기열에서 다음 샤드 ID를 선택하고 해당 샤드 ID에 해당하는 대기열에서 메시지를 꺼내기만 하면 됩니다. 이것이 바로 앞서, 저희가 새로운 것을 발명하는 것이 아니라 기존 빌딩 블록 위에 추상화 계층을 추가하는 것, 즉 기존 큐 위에 라운드 로빈 메커니즘을 구현하는 것이라고 말씀드린 이유 입니다.
작동하는 POC로 시작할 계획이었기 때문에, 기본적으로 우리가 잘 알고 있는 도구이고 적은 노력으로 목표를 달성할 수 있는 MongoDB를 사용하여 첫 번째 구현을 달성했습니다. 하지만 기존 클러스터에 부하를 추가하거나 새 클러스터를 추가하여 현재 라이선스에 영향을 미치고 싶지 않았기 때문에 MongoDB 기반 솔루션이 모든 경우에 적합한 것은 아니었습니다.
그래서 대안을 모색하기 시작했는데, 그 대안은 SQS, DynamoDB, Redis였습니다.
DynamoDB와 SQS는 서비스로서의 특성과 확장성 때문에 매력적이었지만, 제한이 있었습니다. 예를 들어, SQS ListQueues는 최대 1000개의 큐를 반환하기 때문에, 1000개 이상의 테넌트를 관리하는데에 어려움이 있었습니다. 또한 SQS 메시지는 최대 14일 동안 큐에 남아있을 수 있지만, 작업이 큐에 더 오래 있어야 하는 경우가 있어서 좀 더 길게 큐에 머물 수 있어야 했습니다. SQS 메시지는 ID로 가져올 수 없고 큐에 대기/대기 해제만 할 수 있습니다. 그리고 메시지가 큐에 있는지 여부, 상태 등을 확인할 수 있는 "관리자 콘솔"도 필요했습니다.
그 당시에는 DynamoDB에는 트랜잭션이 없었기 때문에 ( 🐧 : 현재는 존재합니다. 여기를 참고하시면 됩니다. 원문은 2019 년도의 글입니다. ) 예를 들어 "큐에 있는 메시지"와 "큐에 있는 메시지 카운터"를 원자적으로 업데이트할 수 없었습니다. 일부 AWS 서비스 위에 샤딩된 큐를 구현할 수도 있었지만, Redis 구현에 비해 더 제한적이고 시간이 많이 소요될 것으로 예상되어 결국 Redis 위에 구현하기로 결정했습니다.
주요 사용 사례 중 하나는 큐이며, 큐가 제공하는 데이터 구조로 큐를 구현하는 것은 매우 쉽습니다. 또한 큐 외에도 카운터와 같이 필요한 지원 데이터 구조를 저장하는 데 사용할 수 있습니다.
단점은 자동으로 확장되지 않기 때문에 클러스터에 노드를 추가/제거하는 등 확장에 필요한 사항을 수동으로 관리해야 한다는 것입니다. 우리 워크로드에서 Redis의 성능을 고려할 때, 몇 달 동안 증가하는 요구 사항을 지원할 수 있는 클러스터의 크기를 쉽게 조정할 수 있다는 것을 깨달았기 때문에 이런 한계점은 감안하였습니다.
최소 한 번 배달 처리(at-least-once deliver) 방식과 관련해서는 Amazon SQS와 정확히 동일한 접근 방식을 사용하여 이를 달성하고 있습니다. 소비자가 대기열에서 메시지를 대기열에서 꺼내도 대기열에서 제거되지 않는데, 이는 소비자가 실제로 메시지를 수신하고 올바르게 처리할 수 있는지 확인할 수 없기 때문입니다. 메시지가 처리되었다고 간주한 후 명시적으로 삭제하는 것은 소비자의 몫입니다. 대기열에서 삭제되었지만 여전히 대기열에 있는 동안 다른 소비자에게 동일한 메시지가 전달되는 것을 방지하기 위해 해당 메시지는 제한된 기간 동안 보이지 않는 것으로 표시됩니다(표시 시간 제한 개념).
이러한 접근 방식을 사용하면 누군가가 실제로 메시지를 받고 처리했다고 명시적으로 선언할 때만 대기열에서 제거되므로 메시지가 손실되지 않도록 할 수 있습니다. 반면에 소비자가 메시지를 처리했지만 대기열에서 제거하기 전에 실패할 수도 있습니다. 이 경우 메시지는 다시 표시된 상태로 반환된 후 다시 처리됩니다. 따라서 적어도 한 번의 배달이 이루어집니다.
결과물로 얻은 긍정적인 면
- 샤드 큐의 개념을 통해 비즈니스 로직과 공정성이라는 비기능적 요구 사항을 분리했습니다.
- 모든 내부 팀이 라이브러리로 사용할 수 있게 함으로써 동일한 문제에 대해 서로 다른 팀이 서로 다른 솔루션을 제공하는 것을 방지할 수 있습니다.
- 일종의 '스위스 군용 칼'과 같은 기능으로 다른 컨텍스트에서 이미 재사용하고 있는 Redis에 대한 경험을 쌓을 수 있었습니다.
발전해야 하는 내용
- 현재로서는 자바/스칼라 라이브러리이기 때문에 JVM에서 실행되는 언어에서만 사용할 수 있습니다;
- 현재 상태에서는 장기 실행 작업에 적용할 수 없습니다. 예를 들어 단일 비디오 변환 작업은 최대 몇 시간이 걸릴 수 있으므로 모든 기본 리소스가 단일 테넌트의 장기 실행 변환에 할당되어 있고 다른 테넌트가 변환을 필요로 하는 경우 몇 시간 동안 기다려야 할 수도 있습니다.
향후 발전할 내용
- 오픈 소스 프로젝트로 공개하는 것이 좋을 지를 고민해야 합니다.
- 선택한 언어/플랫폼에 관계없이 사용할 수 있도록 API를 RESTful 서비스로 노출하는 것을 고려하고 있습니다.
- DynamoDB 위에 구현을 추가할 수 있는지 이해하고 있으며, 최근에는 트랜잭션에 대한 지원이 추가되었으므로 기술 분석을 검토해야 합니다.
🐧 :) 해당 글은 2019 년도에 쓰여졌기 때문에, 지금 이대로 구현하는 것보다는 다른 서비스의 새로운 기능을 사용하는 것이 더 나은 선택일 수도 있습니다. 하지만 글에서 제공하는 멀티 테넌시 환경에서의 큐 설계 개념은 현재에도 유효하기 때문에 가져와 보았습니다. 여러분은 어떻게 생각하시나요? 현재 어떻게 구현하고 계시나요? 의견이 있으시면 언제든 남겨주시면 감사하겠습니다.
댓글
의견을 남겨주세요