끊임없이 성장하는 올인원 APM(애플리케이션 성능 모니터링) 서비스로서, 우리는 AppSignal이 늘어나는 트래픽을 감당할 수 있도록 만드는 데 많은 시간을 쏟고 있습니다. 보통 우리는 그 방법에 대해 이야기하지 않습니다. 블로그에는 루비 내부 동작의 흥미로운 이야기나 엘릭서로 하는 기발한 실험 글은 가득해도, AppSignal 자체가 어떻게 돌아가는지는 다루지 않았으니까요.
하지만 이번에는 지난 몇 년간 스택에 적용한 큰 변화들을 공유하려 합니다. 덕분에 매달 두 자릿수 십억 건에 달하는 요청을 실시간으로 처리할 수 있게 되었으니까요. 오늘은 이 확장 경험을 바탕으로 우리 스택을 직접 이야기하고, 여러분께도 도움이 되기를 바랍니다.
표준 Rails 구성에서 커스텀 구성으로
AppSignal은 꽤 표준적인 Rails 구성으로 출발했습니다. Rails 앱이 API 엔드포인트를 통해 데이터를 수집하고, 해당 엔드포인트가 백그라운드 처리용 Sidekiq 잡을 생성하는 구조였죠.
시간이 지나며 속도를 조금이라도 끌어올리기 위해 Rails API를 Rack 미들웨어로 교체했고, 이후에는 Sidekiq 호환 잡을 Redis로 푸시하는 Go 웹 서버로 다시 대체했습니다.
앱 상태와 증분·업데이트 처리
이 구성은 한동안 잘 작동했지만, 데이터베이스가 감당해야 할 쿼리량을 따라가지 못하는 문제에 부딪히기 시작했습니다. 당시 이미 우리는 수백억 건의 요청을 처리하고 있었습니다. 핵심 원인은 각 Sidekiq 프로세스가 올바른 카운터를 증가시키고 정확한 문서를 업데이트하려면 앱 전체 상태를 데이터베이스에서 가져와야 했기 때문입니다.
데이터를 로컬에 캐싱하면 어느 정도 완화할 수 있었지만, 라운드 로빈 방식의 구성 특성상 페이로드가 어느 서버로 들어올지 알 수 없어 결국 모든 서버가 전체 데이터 캐시를 갖춰야 했습니다. 계속되는 데이터 증가 속도라면 앞으로 이 구성은 유지 불가능하다는 걸 깨달았습니다.
카프카의 등장
더 나은 데이터 처리 방법을 찾던 중 우리는 카프카(Kafka)를 데이터 처리 파이프라인으로 채택했습니다. 이제 메트릭을 데이터베이스에서 집계하는 대신 카프카 프로세서에서 집계합니다. 목표는 집계된 데이터를 플러시해야 하는 순간까지는 카프카 파이프라인이 데이터베이스를 조회하지 않는 것입니다. 이를 통해 페이로드당 쿼리 수를 최대 열 번의 읽기·쓰기에서 파이프라인 마지막 단계의 쓰기 한 번으로 줄였습니다.
각 카프카 메시지에 키를 지정하면 카프카는 같은 키의 메시지가 같은 파티션, 즉 같은 서버가 소비하도록 보장합니다. 우리는 메시지 키로 앱 ID를 사용합니다. 덕분에 서버가 모든 고객의 데이터를 캐싱하는 대신, 해당 서버가 카프카로부터 받는 앱의 데이터만 캐싱하면 됩니다.
카프카는 훌륭한 시스템이며, 지난 2년간 마이그레이션을 진행했습니다. 현재 거의 모든 처리는 카프카를 통해 Rust로 수행되지만, 알림 발송처럼 데이터베이스 작업이 많거나 Ruby로 처리하는 편이 더 쉬운 일들도 남아 있습니다. 그래서 카프카에서 Rails 스택으로 데이터를 넘겨줄 방법이 필요했습니다.
카프카와 Ruby/Rails 연결하기
전환을 시작할 당시 몇몇 카프카 Ruby 젬(gem)이 있었지만, 최신 버전(당시 0.10.x)의 카프카와 호환되는 것이 없었고 대부분 관리가 중단된 상태였습니다.
우리는 직접 젬을 작성하는 방안을 검토했고(결국 만들게 됩니다), 그 이야기는 별도 글에서 다룰 예정입니다. 하지만 좋은 드라이버는 필요 조건의 일부일 뿐입니다. 데이터를 소비해 Ruby로 작업을 실행하고, 워커가 크래시하면 새 워커를 띄우는 시스템도 함께 필요했습니다.
결국 우리는 색다른 해결책을 찾아냈습니다. Rust로 구축한 카프카 스택 위에서, sidekiq_out 토픽을 소비해 Redis에 Sidekiq 호환 잡을 생성하는 작은 바이너리를 작성한 것입니다. 이 바이너리를 워커 머신에 배포하면 Rails 내부에서 잡을 넣듯 Sidekiq에 새 잡을 공급할 수 있습니다.
이 바이너리에는 몇 가지 옵션이 있습니다. 예컨대 Redis의 데이터 양을 제한하고, 임계값이 해소될 때까지 카프카 토픽 소비를 멈추는 기능입니다. 덕분에 백로그가 쌓여도 카프카의 모든 데이터가 워커의 Redis 메모리로 밀려들지 않습니다.
Ruby 입장에서는 Rails에서 생성된 잡과 카프카에서 넘어온 잡 사이에 아무 차이가 없습니다. 덕분에 카프카에 대해 아무것도 몰라도, 카프카에서 데이터를 받아 Rails에서 처리하며 알림을 보내고 데이터베이스를 갱신하는 새 워커를 손쉽게 프로토타이핑할 수 있습니다.
새 Ruby 코드를 배포하지 않고도 카프카로 전환했다가 되돌릴 수 있어 마이그레이션이 한결 수월했고, 테스트도 아주 쉬워졌습니다. 로컬에 카프카 스택 전체를 구성할 필요 없이 테스트 스위트에서 잡을 생성해 Ruby가 소비하게 하면 되니까요.
모든 내부 메시지는 Protobuf로 정의합니다. 덕분에 테스트가 통과하면 워커가 카프카의 잡을 올바르게 처리한다고 상당히 확신할 수 있습니다.
결국 이 해결책은 많은 시간과 에너지를 아껴 주었고, Ruby 팀의 삶을 한층 단순하게 만들어 주었습니다.
장점과 단점
모든 것과 마찬가지로 이 구성에도 장단점이 있습니다.
장점
- Ruby 코드 변경 없이 API 호환
- 배포와 롤백이 쉬움
- 카프카와 Ruby 간 전환이 쉬움
- 리미터 사용 시 Redis가 메시지로 과부하되지 않아 서버 메모리를 절약하고, 메시지는 카프카에 그대로 보관됨
- 키 기반 메시지 덕분에 수평 확장 시 서버별 캐시가 작아짐
단점
- 각 Sidekiq 스레드가 해당 서버가 소비하는 파티션의 앱 데이터 전체 캐시에 접근해야 하는 문제는 여전함 (예: Memcache)
- 서버에서 별도 프로세스가 실행됨
- Rust 프로세서는 메시지를 Redis로 플러시할 때 오프셋을 커밋합니다. Redis에 저장된 것은 보장되지만 Ruby가 처리했다는 보장은 없습니다. 서버 크래시 시 Redis에 있지만 처리되지 않은 일부 메시지가 누락될 수 있습니다.
사이드키크와 카프카
처리 파이프라인을 카프카로 옮기는 동안 Sidekiq는 큰 힘이 되어 주었습니다. 지금은 거의 완전히 Sidekiq를 벗어나 모든 것을 카프카 드라이버로 직접 처리하고 있는데, 그 이야기는 다음 글로 미루겠습니다.
이 행복한 결말로 러브스토리를 마무리합니다. 성능과 확장에 대한 우리의 시선, 그리고 AppSignal을 확장하며 쌓은 경험이 즐거우셨길 바랍니다. 스택을 둘러싼 우리의 결정들이 여러분께도 도움이 되기를 바랍니다.
블로그의 다른 글도 살펴보시고, 카프카 구성에 대한 다음 에피소드가 게시될 때 팔로우로 놓치지 마세요. 그리고 개발자가 개발자를 위해 만든 진짜 올인원 APM을 찾고 계신다면, 언제든 저희를 찾아주세요.