[번역] Elixir에 Redis는 필요 없을수도 있어요

posted by donghyun

9 min read

태그

원문 링크

You may not need Redis with Elixir

by josé valim

엘릭서 토론에 참여한 적이 있다면, “엘릭서 쓸 때 Redis는 필요하지 않다”는 말을 한번 들어봤을수도 있습니다. Redis에는 많은 use case가 있으므로 이런 말은 개발자가 Elixir의 Redis와 다른 기능들을 Redis 쓰듯이 사용하려고 하는 혼란을 줄수가 있습니다. 이 글에서 위 내용이 사실인지 아닌지, 그리고 고려할만한 trade-off를 살펴보는것을 목표로 합니다. 우리는 다음 4가지 경우에 대해 논의할것입니다.

  1. Distributed PubSub
  2. Presence
  3. Caching
  4. Asynchronous processing

시작하기 전에, Redis가 정말 환상적인 기술이란 것을 강조하고 싶습니다. 이것은 Redis에 대한 비판이 아니라 Elixir 개발자가 취할수 있는 다양한 선택지에 대한 논의입니다.

Case #1: Distributed PubSub

Elixir에 Redis가 필요하지 않은 첫번째 시나리오는 Distributed PubSub입니다. 이 섹션에서는 PubSub시스템이 최대 한번의 전달을 제공하는 것을 고려합니다. 현재 가능한 subscriber들에게 이벤트를 전파합니다. subscriber가 한번 받지 못하면, 그 이후에라도 그 메시지는 받지 못합니다.

이러한 이유로 PubSub 시스템은 종종 지속성을 제공하기 위해 데이터베이스와 쌍을 이룹니다. 예를 들어, 누군가 채팅 애플리케이션에서 메시지를 보낼 때마다 시스템은 콘텐츠를 데이터베이스에 저장한 다음 모든 사용자에게 broadcast할수 있습니다. 즉 지정된 순간에 연결된 모든 사람이 즉시 업데이트를 볼수 있지만 연결이 끊긴 사용자는 나중에 확인할 수 있습니다.

여러 노드가 있고 해당 노드간에 메시지를 교환한다고 가정해보죠. 엘릭서에서는 distribution 지원을 해주는 Erlang VM덕에 다음과 같이 간단하게 할수 있습니다.

for node <- Node.list() do
	send({:known_name, node}, :hello_world)
end

200LOC 이하 에서는 서드파티 툴을 사용하지 않고도 동일한 노드 또는 한 클러스터의 다른 위치에 있는 모든 subscriber들에게 브로드캐스트하는 PubSub 시스템을 구현할 수 있습니다. 기껏해야 Elixir 라이브러리인 libcluster 정도가 어떤 strategy(k8s, aws, dns, etc)를 기반으로 노드간 연결 설정시에 필요할 것입니다.

즉 PubSub은 Elixir에 거의 기본적으로 제공됩니다. Distribution이 없는 기술은 Redis PubSub, PostgreSQL Notifications 또는 이와 유사한 것에 의존해야 동일한 결과를 얻을 수 있습니다.

물론 위의 내용은 인프라를 통해 노드 간 연결을 직접 설정할 수 있다고 가정합니다. 이는 Heroku와 같은 일부 PaaS에서는 불가능할 수 있습니다. 이 경우 위의 기술(Phoenix has a Redis adapter for its PubSub)을 사용하거나 Gigalixir같은 플랫폼을 사용하여 클러스터를 설정하면 간단합니다.

Case #2: Presence

Presence는 현재 클러스터에 연결된 사용자(user, 혹은 phones, IoT device 등의 단말)를 추적할 수 있는 기능입니다. 예를 들어 Alice가 노드 A에 연결되어있는 경우 Bob이 노드 B에 join했더라도 Bob을 볼 수 있는지 확인하고자 합니다.

Presence는 생각보다 구현하기가 더 복잡한 문제중 하나입니다. 예를 들어, connect된 엔티티들을 데이터베이스에 저장하여 Presence를 구현한다고 해보죠. 그런데 노드가 crash되거나 클러스터를 벗어나버리면 어떻게 될까요? 노드가 crash났기 때문에 연결된 모든 사용자를 제거해야하지만 정작 노드 자신은 사용자들을 제거할 수 없습니다. 따라서 다른 노드는 이러한 장애 시나리오를 감지하고 그에따라 조치를 취해야 합니다. 하지만 분산 시스템에서 실패를 옵저빙하는것도 복잡합니다. 일시적으로 응답않는 노드와 영구적으로 실패한 노드를 어떻게 구별할까요?

이 문제를 해결하는 또다른 일반적인 방법은 사용자가 연결되어 있는동안 데이터베이스에 자주 write하는것입니다. 일정 시간 내에 write가 보이지 않는경우 사용자의 연결이 끊어진 것으로 간주합니다. 그러나 이러한 솔루션은 쓰기 집약적(write-intensive) 이거나 부정확성 중에서 하나를 선택해야 합니다. 예를 들어 사용자가 1분후에 연결이 끊어진다고 가정합시다. 즉 모든 사용자에 대해 1분마다 데이터베이스에 기록해야합니다. 10,000명의 사용자가 있는 경우 초당 167회의 쓰기가 발생해야 사용자가 연결되어 있는지 추적할 수 있습니다. 한편 사용자가 떠나고 자신의 상태가 UI에 반영되는 사이의 간격은 최악의 시나리오에서 1분입니다. 쓰기 수를 줄이려는 시도는 이 간격이 증가함을 뜻합니다.

Elixir의 클러스터링 지원을 통해, 우리는 써드파티 의존 없이도 Presence를 구현할 수 있습니다! 사용자가 참여하고 나갈때 알림이 필요하므로 PubSub 시스템을 사용해 Presence를 구현합니다. 중앙 집중형 스토리지에 의존하는 대신 노드는 접속해 있는 사람에 대한 정보를 직접 통신하고 교환합니다. 이렇게하면 빈번한 쓰기가 필요하지 않습니다. 사용자가 떠날 때도 즉시 반영됩니다.

따라서 Redis 또는 다른 스토리지를 사용하여 Presence를 제공할 수 있지만, Elixir는 효율적이고 써드 파티가 필요없는 솔루션을 제공할 수 있습니다.

Case #3: Caching

이전 사례들의 솔루션은 Erlang의 고유한 분산 처리 기능을 기반으로 구축되었습니다. 다음 두 섹션에서 Redis가 필요한지 여부를 구별하는 요소는 멀티 코어 동시성이므로, 여기서의 논의가 좀더 일반적으로 적용됩니다. 따라서 이 섹션에서 제가 Elixir라고 하는건 JVM, Go 및 기타 환경에도 적용됩니다. 이들은 Ruby, Python, Node.js와 는 대조적으로 기본 런타임이 단일 운영 체제 프로세스 내에서 적절한 멀티 코어 동시성(multi-core concurrency)을 제공합니다(Ruby, Python, Node.js는 제공하지 않음).

비-동시적 시나리오부터 시작해보죠. Ruby, Python등으로 웹 애플리케이션을 빌드하고 있다고 가정합니다. 배포하기 위해 당신은 두개의 8코어 머신이 필요합니다. 만족스러운 멀티코어 동시성을 제공하지 않는 언어에서 배포를 위한 일반적인 옵션은 각 노드에서 웹 애플리케이션의 인스턴스를 코어당 한개씩, 즉 8개 인스턴스를 시작하는 것입니다. 최종적으로 CxN개(C: 코어 수, N: 노드 수) 인스턴스가 있습니다.

이제 이 애플리케이션에서 비용이 많이 드는 특정 작업이 있어 그 결과를 캐시하려고 합니다. 프로그래밍 환경에 관계없이 가장 쉬운 솔루션은 메모리에 캐시하는 것입니다. 그러나 이 애플리케이션의 인스턴스가 16개 이므로 메모리에 캐싱하는것은 차선책일수밖에 없습니다. 이 비싼 작업을 각 인스턴스에 대해 한번씩, 즉 16번 이상 수행해야 합니다. 이러한 이유로 Ruby, Python등과 같은 환경에서 캐싱을 위해 Redis, Memcached 또는 이와 유사한 것을 사용하는 것이 널리 퍼져있습니다. Redis를 사용하면 한번만 캐시하고 모든 인스턴스에서 공유됩니다. trade-off는 메모리 접근을 네트워크 round-trip으로 대체한 것이고 후자는 훨씬 비쌉니다.

이제 멀티코어 동시성이 있는 환경을 고려해 보겠습니다. Elixir와 같은 언어에서는 런타임이 메모리를 공유하고 모든 코어에 작업을 효율적으로 분산하므로 코어 수에 관계없이 노드 당 하나의 인스턴스를 시작합니다. 캐싱과 관련하여 캐시를 메모리에 유지하는 것이 훨씬 더 저렴한 시나리오입니다. 노드 당 한번만 계산하면 되기 때문입니다. 따라서 Redis 또는 Memcached를 모두 건너 뛰고 네트워크 round-trip을 피할 수 있는 선택지가 있습니다.

물론 이는 프로덕션에서 효과적으로 구동 중인 노드의 수에 달렸습니다. 운좋게도 많은 회사에서는 elixir로 넘어오면서 이전 기술보다 훨씬 적은 노드로 Elixir를 실행할 수 있다고 보고하고 있습니다.

복합적 접근 방식을 선택하여 메모리 및 Redis 모두에 캐시를 저장할수도 있습니다. 먼저 메모리를 조회하고 누락된경우 Redis로 fallback합니다. 둘 다 사용 불가능한 경우 작업을 실행하고 각각에 캐시합니다. 여기서 강조해야 할 중요한 부분은 멀티코어 환경이 리소스 사용률을 줄이면서 이러한 문제를 해결할 수 있는 유연성을 제공한다는 것입니다. Elixir/Erlang에서는 캐시를 메모리에 보관하고 PubSub을 사용하여 그것을 노드들에 분산할 수도 있습니다. 이 마지막 접근 방식은 훌륭한 라이브러리 FunWithFlags 에서 실사례를 볼수 있습니다.

고려해야할 또다른 trade-off는 새 노드를 배포하면 모든 in-memory 캐시가 사라진다는 점입니다. 따라서 배포간에 데이터를 유지해야할 경우 위에 설명된 대로 Redis를 캐시 계층으로 사용하거나 각 배포전에 데이터베이스, S3 또는 Redis와 같은 스토리지에 캐시를 덤프할 수 있습니다.

Case #4: Asynchronous processing

Elixir에서 Redis가 필요하지 않을수 있는 또다른 시나리오는 비동기 프로세스 수행입니다. 이전 사례에서 논의를 이어가보죠.

다중 코어 동시성이 없거나 제한된 환경에서, 각 인스턴스가 하나의 코어에 할당되면 요청을 동시에 처리할 수 있는 능력이 제한됩니다. 이로 인해 “메인 스레드를 block하지 말 것” 이라는 속담이 생겼을 정도입니다. 예를 들어 애플리케이션이 회원가입시 이메일을 전달하거나 계산 비용이 많이 드는 보고서를 생성해야 한다고 가정해보죠. 16개의 웹 인스턴스 중 하나가 이 작업을 수행하는 동안 효율적으로 다른 incoming request를 처리할 수 없습니다. 이러한 이유로 여기서 일반적인 선택은 보통 백그라운드-job processing queue와 같은 다른 곳으로 작업을 이동하는 것입니다. 먼저, 수행할 작업을 Redis 등에서 저장합니다. 그런 다음 16개의 웹 인스턴스(또는 더 일반적으로 완전히 다른 worker 집합)중 하나가 큐에서 이를 가져옵니다.

멀티 코어 동시 환경에서는 request가 CPU 또는 IO 작업을 수행하는지에 관계없이 동시에 처리할 수 있습니다. request 자체에서 이메일을 보내는 것은 다른 request들을 block 하지 않습니다. 보고서 생성도 문제가 아니죠, 다른 CPU에서 request를 처리할 수 있으니까요. 이러한 플랫폼은 일반적으로 처리할 수 있는 만큼 가능한 많은 request가 할당되고 시스템 리소스를 통해 작업을 분산합니다. 사용자에게 빠른 응답을 보내기 위해 reuquest 외부에서 이메일을 전송하는 것을 선호하더라도 전송작업을 외부 queue나 다른 머신으로 이동할 필요없이 비동기 worker를 하나 spawn해서 처리할 수 있습니다. 다시 한 번, 동시성은 이러한 시나리오를 처리 할 수 있는 보다 간단한 옵션을 제공합니다.

Erlang VM은 개발자가 비동기 같은 기능을 tag할 필요 없이 멀티플렉싱 CPU 또는 IO 작업을 처리합니다. Erlang과 Elixir의 Worker들도 역시 선제적(preemptive) 이므로 worker 그룹이 모든 머신 리소스를 고갈시키고 다른 worker가 작업을 진행하는 것을 block하는 것은 불가능합니다. 이것은 운영체제가 프로세스들을 관리하는 방법과 매우 유사하지만 훨씬 가볍습니다.

여기서 한 가지 주의사항이 있습니다. 백그라운드잡 프로세싱 큐에서는 종종 retries, job visibility 등과 같은 여러 기능이 함께 제공됩니다. 이러한 기능이 필요한 경우, storage에 의존하며 모든 bell과 whistle을 제공하는 도구를 사용하는 것이 좋습니다. 백그라운드잡 툴은 Elixir의 Exq와 같이 Redis를 사용할 수 도 있지만, 반드시 그럴 필요는 없습니다. Oban처럼 데이터베이스를 쓰거나 RabbitMQ 또는 Amazon SQS와 같은 편리한 메시징 시스템을 쓸수도 있습니다. 어쨋든 Elixir에서 이메일을 보내는 것과 같은 사소한 일에 대해서는 (특히 사용자가 계속 진행하기전에 이메일을 열어야하는 경우) 요청 내에서 이메일을 보낼 것입니다.

이 주의사항은, 일부 사람들이 “Elixir에서는 background job이 필요하지 않다” 라고 주장하기 때문에, 혼동을 일으켜 오해의 소지가 있습니다. Elixir에서는 당신의 요구사항이 필요로 할때 선택하는 것이지 처음부터 반드시 필요한 것은 아닙니다.

이 섹션은 루비 개발자로서의 마지막 컨설팅 작업 중 하나에 대한 이야기로 마무리하고 싶습니다. 이 이야기가 백그라운드 작업이 해답은 커녕 오히려 손해가 될수 있었던 경우에 대한 통찰력있는 사례였기 때문입니다.

이 이야기는 Ruby의 확장성 이슈가 있는 회사 이야기입니다. 그들의 문제는 특히 결제 처리였습니다. 그들은 특정 결제 프로세서와 통합해야 했는데 그러고 나니 한 요청을 처리하는데 거의 3초가 걸리곤 했습니다. 이와 같이 Ruby 서버가 결제 프로세서를 기다리는 동안 다른 작업을 할 수 없어 서비스 속도가 느려졌습니다. 첫번째 조치는 서버 수를 늘리는 것이었습니다. 그러나 애플리케이션이 더 많은 사용자들을 확보함에 따라 지연 시간은 여전히 예측할 수 없었고 운영은 더욱 복잡해졌으며 종종 아키텍처의 다르 부분에 부담을 주고 개발 시간을 많이 잡아먹었습니다.

그들은 스레드 웹 서버를 사용해보았지만 문제를 만족스럽게 해결하지 못했습니다. 또한 런타임 레벨에서 문제를 해결할 수 있는 JRuby로의 전환도 생각해봤지만 JVM 운영 경험이 거의 없어 그런 마이그레이션도 할수 없었습니다.

여기서 빠른 해결 방법(및 일반적인 관행)은 결제 처리를 백그라운드 잡으로 옮기는 것이었습니다. 그러나 처리가 실패하면 작업을 단순히 재시도할 수 없었습니다. 결제 처리 요구사항으 인해 모든 결제 시도에서 사용자 입력이 필요했습니다. 그래서 실패했을때 그들은 다시 시도할 수 있는 링크가 있는 이메일을 사용자에게 보내기로 선택했고, 이는 궁극적으로 사용자 전환율(conversion rate)에 영향을 미쳤습니다.

우리가 그 시스템에서 작업에 투입되었을때, 결제 프로세서와 통신하기 위해 별도의 애플리케이션을 개발했고 따라서 이를 개별적으로 확장하고 최소한의 영향으로 다양한 배포 옵션을 시도할 수 있었습니다. 그런 다음 처리되는 동안 결제 상태를 확인하기 위해 클라이언트 측 폴링을 추가했습니다. 결국 문제는 해결되었지만 수백 시간의 개발 시간이 소요되었고 솔루션에 도달할 때까지 수익이 줄었습니다. 이는 비동기 처리 및 동시성을 위한 풍부하고 강력한 도구가 있는 플랫폼에서는 존재하지 않는 어려움입니다.

요약

이 글에서, 우리는 Elixir에 포함된 기능을 사용함으로써 운영 복잡도를 줄일 수 있는 경우들에 대해 논의했습니다. 논의의 목표는 누군가 “Elixir에서 Redis가 필요하지 않을 수 있다” 라고 말할 때 개발자가 연결할 수 있는 심층적인 레퍼런스를 제공하는 것입니다.

위 모든 사례의 공통점을 요약해보고자 한다면, 저는 일시적 상태(emphemeral state)라고 말할것 같습니다. PubSub, Caching 등은 모두 일시적입니다. PubSub은 지금 이순간 가능한 단말들에 메시지를 전달합니다. Presence는 지금 이순간 연결되어 있는 단말들을 유지합니다. 캐시된 것들은 모두 손실되고 재 계산될수 있습니다. 따라서 Elixir에 임시 데이터가 있는 경우 Redis가 필요하지 않을 가능성이 있습니다. 그러나 이 상태를 유지하거나 백업해야하는 경우 Redis또는 다른 데이터베이스가 유용합니다.

어떤 이유로든 Redis만 사용하고 싶다면, Redis를 사용하세요! Redix와 같은 라이브러리를 사용하여 Elixir와 Redis를 함께 프로덕션 환경에서 실행하는 다른 회사들의 대열에 합류할 수 있습니다.