Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

대규모 사용자 성장을 위한 Ruby on Rails 확장 전략 완벽 가이드

오늘은 Ruby on Rails 애플리케이션을 대규모 사용자 기반까지 확장할 때 활용할 수 있는 다양한 전략을 자세히 살펴보겠습니다.

애플리케이션을 확장하는 가장 확실한 방법은 돈을 더 쓰는 것입니다. 그리고 이 방법은 놀라울 정도로 효과적입니다. 서버를 몇 대 추가하고 데이터베이스 서버를 업그레이드하면, 많은 성능 문제가 순식간에 사라지죠.

하지만 서버를 추가하지 않고도 애플리케이션을 확장하는 것이 가능합니다. 오늘 우리가 다룰 내용이 바로 그 부분입니다.

그럼 시작해 보겠습니다!

AppSignal로 Rails 애플리케이션 모니터링하기

확장과 성능 최적화에 대해 본격적으로 알아보기 전에, 먼저 정말로 최적화가 필요한지, 애플리케이션의 병목 지점이 어디인지, 어떤 리소스를 확장할 수 있는지 파악해야 합니다.

이를 확인하는 쉬운 방법 중 하나는 Ruby용 AppSignal의 성능 모니터링 및 메트릭 기능을 활용하는 것입니다.

성능 대시보드를 통해 평균적으로 느린 컨트롤러 액션과 백그라운드 작업을 정확히 찾아낼 수 있습니다.

예를 들어 ActiveRecord의 성능 대시보드는 다음과 같이 표시됩니다:

대규모 사용자 성장을 위한 Ruby on Rails 확장 전략 완벽 가이드

이는 확장 여정의 훌륭한 출발점이 됩니다. 서버를 더 추가하든, 코드를 통해 성능을 최적화하든 말이죠.

이제 Rails 앱을 확장하는 데 사용할 수 있는 가장 간단한 기법 중 하나인 캐싱으로 넘어가 보겠습니다.

Ruby on Rails에서의 캐싱

캐싱을 사용하면 같은 연산을 반복해서 수행하지 않아도 됩니다.

예를 들어 소셜 미디어 플랫폼을 운영하는데 매우 인기 있는 게시물이 있다고 가정해 봅시다. 캐싱을 활용하면 모든 사용자에게 해당 게시물을 렌더링하는 데 소요되던 CPU 자원을 즉시 회수할 수 있습니다. 물론 이것은 캐싱이 할 수 있는 일의 일부에 불과합니다.

캐싱 가능한 리소스들을 하나씩 살펴보겠습니다.

뷰(View) 캐싱

뷰를 렌더링하는 작업은 때때로 비용이 많이 드는 연산입니다. 특히 뷰가 렌더링해야 할 데이터가 많을 때 그렇습니다. 설령 연산이 무겁지 않더라도, 미리 렌더링된 뷰를 재사용하면 동일한 뷰를 백만 번 렌더링하는 것에 비해 상당한 성능 향상을 얻을 수 있습니다.

Rails는 cache 뷰 헬퍼를 통해 이 기능을 기본적으로 지원합니다. 예를 들어 목록을 렌더링할 때 각 게시물을 캐싱하는 방법은 다음과 같습니다:


이 경우 Rails는 템플릿의 HTML 내용, 게시물 ID, 업데이트 타임스탬프에 따라 특정 키를 기준으로 각 게시물을 자동으로 캐싱합니다.

이 기법에 대해 더 자세히 알고 싶다면 'Fragment caching in Rails'와 'Rails collection caching' 관련 글을 참고하세요.

단, 한 가지 유의할 점이 있습니다. 캐시 키에는 중첩된 템플릿 내용이 포함되지 않습니다. 따라서 캐시 호출을 한 단계 이상 깊게 중첩하면 오래된(stale) 결과가 반환될 수 있습니다. 자세한 내용은 'Russian doll caching in Rails'를 참고하세요.

응답(Response) 캐싱

뷰나 프래그먼트를 캐싱하는 것 외에도, GET 요청의 전체 응답을 캐싱할 수 있습니다. 이는 브라우저가 보내는 If-None-MatchIf-Modified-Since 헤더를 통해 지원됩니다.

요청에 If-None-Match 헤더가 포함되어 있으면, 응답에 변경 사항이 없을 경우 서버는 본문 없이 304 Not Modified 응답을 반환할 수 있습니다. 이때 서버가 계산한 Etag 값이 해당 헤더의 값과 비교됩니다.

마찬가지로 If-None-Match 없이 If-Modified-Since 헤더만 있는 경우, 해당 날짜 이후 응답이 변경되지 않았다면 서버는 본문 없이 304 Not Modified 응답을 반환할 수 있습니다.

Rails는 컨트롤러 액션 안에서 이를 손쉽게 구현할 수 있는 방법을 제공합니다. 다음과 같이 작성하면 됩니다:


Rails는 캐싱을 지원하는 데 필요한 모든 헤더를 전송하고, 들어오는 헤더를 처리하며, 데이터가 변경되지 않았으면 304로 응답합니다. 덕분에 상황이 변하지 않는 한 서버는 전체 뷰를 다시 렌더링하지 않아도 됩니다. 이 전략의 고급 설정에 대한 자세한 내용은 'Client-side caching in Rails: conditional GET requests'를 참고하세요.

값(Value) 캐싱

마지막으로 원시 값(캐시 스토어에 직렬화할 수 있는 모든 것)을 캐싱하는 것도 가능합니다. 이는 주로 리소스 집약적이거나 느린 연산의 결과를 캐싱하여 반복 수행을 피할 때 유용합니다.

어떤 값을 캐싱하면 좋을지 판단하는 것은 애플리케이션에 따라 크게 달라지지만, 일반적으로 가장 느린 이벤트들을 살펴보면 올바른 방향을 찾는 데 도움이 됩니다.

캐싱할 대상을 찾았다면, Rails가 제공하는 API는 매우 간단하게 사용할 수 있습니다:


위 코드는 perform_the_slow_computation을 한 번만 실행한 후, 그 결과를 cache_key_with_version 키 아래에 캐싱합니다. 다음에 동일한 코드가 호출되면 Rails는 먼저 캐시된 값이 있는지 확인하고, 있으면 perform_the_slow_computation을 다시 실행하는 대신 캐시된 값을 사용합니다.

이 캐싱 전략에서 가장 중요한 부분은 값 계산에 사용된 모든 입력값에 의존하는 좋은 캐시 키를 설계하는 것입니다. 이를 통해 오래된 값을 계속 사용하는 상황을 방지할 수 있습니다.

캐시 스토어(Cache Store)

무엇을 캐싱할지, 그리고 Rails가 제공하는 캐싱 기법을 알았으니, 다음 질문은 당연히 "이 데이터를 어디에 캐싱할까?"입니다. Rails는 여러 내장 캐시 스토어 어댑터를 제공합니다. 프로덕션 환경에서 가장 널리 사용되는 캐시 스토어는 Redis와 Memcached입니다. 그 외에 파일 스토어(file store)와 메모리 스토어(memory store) 옵션도 있습니다. 각 스토어에 대한 자세한 내용은 'Rails' built-in cache stores: an overview' 글에서 확인할 수 있습니다.

파일 스토어와 메모리 스토어는 빠르게 개발 환경을 구축하고 실행하는 데는 훌륭합니다. 하지만 일반적으로 프로덕션 환경, 특히 여러 서버로 구성된 분산 환경에서는 적합하지 않습니다. Redis와 Memcached는 모두 프로덕션 용도에 적합하며, 어느 쪽을 선택할지는 주로 애플리케이션의 특성에 따라 달라집니다.

Ruby on Rails에서의 백그라운드 워커

대부분의 애플리케이션은 메일 발송, 주기적인 정리 작업, 또는 사용자가 대기할 필요가 없는 시간이 오래 걸리는 작업을 위해 백그라운드 잡을 필요로 합니다. 아마 이미 백그라운드 워커를 설정해 두었을 가능성이 높습니다.

컨트롤러 액션 안에서 1초 이상 걸리는 작업을 발견하면, 이를 백그라운드 워커로 옮길 수 있는지 검토해 보세요. 대용량 테이블에서 데이터를 검색하는 것과 같은 사용자 대면 작업부터, 대량의 데이터를 처리하는 API 메서드까지 다양한 경우가 해당됩니다.

구현 예제

사용자 정의 잡을 실행하기 위해 Rails는 Active Job 프레임워크를 제공합니다. 복잡한 필터링 로직을 백그라운드 잡으로 옮기는 방법을 살펴보겠습니다. 먼저 백그라운드 잡을 생성합니다:


이 잡은 컨트롤러에서 다음과 같이 실행할 수 있습니다:


잡이 데이터를 계산하고 결과를 전달하는 동안, 템플릿에는 로딩 인디케이터를 표시해야 합니다.

그런데 잡의 결과를 어떻게 뷰로 전달할 수 있을까요? Turbo를 사용하면 이 과정이 정말 간단해집니다. 예를 들어 뷰 안에서 turbo_stream_from을 사용하여 특정 알림 채널의 turbo-stream 이벤트를 구독할 수 있습니다.

이를 활용해 템플릿을 작성해 보겠습니다:



초기 컨트롤러 액션에서 data가 정의되지 않았기 때문에, 처음에는 로딩 인디케이터만 렌더링됩니다. 이제 잡에서 결과를 전달해 보겠습니다:


여기서 핵심은 notify_completed 메서드입니다. 이 메서드는 Turbo::StreamsChannel을 사용하여 뷰에서 구독한 [user, :huge_datasets] 알림 스트림으로 replace 이벤트를 브로드캐스트합니다.

이것으로 컨트롤러의 복잡한 연산을 백그라운드 잡으로 옮기는 데 필요한 모든 준비가 끝났습니다. 작업을 백그라운드로 옮기는 가장 큰 장점은 백그라운드 워커를 웹 서버와 독립적으로 확장할 수 있다는 점입니다. 이를 통해 웹 서버 쪽 리소스를 상당히 절약할 수 있습니다. 또한 사용자 입장에서도 요청에 빠르게 응답하고 결과를 점진적으로 전달할 수 있어 인터페이스가 훨씬 반응성이 좋다고 느껴집니다.

참고: 백그라운드 잡 워커 선택에 도움이 필요하다면 'Delayed Job vs. Sidekiq: Which Is Better?' 글을 참고하세요.

Rails 애플리케이션의 데이터베이스 확장

이번 글에서 마지막으로 다룰 확장 리소스는 데이터베이스입니다. 데이터베이스는 대부분의 애플리케이션의 핵심입니다. 데이터와 그 데이터에 접근하는 서버의 수가 늘어나면, 데이터베이스는 점차 부하를 느끼기 시작합니다.

데이터베이스를 확장하는 가장 쉬운 방법은 데이터베이스 서버에 더 많은 처리 능력과 메모리를 추가하는 것입니다. 하지만 웹 서버 확장과 달리, 데이터베이스의 스케일 업은 일반적으로 매우 느린 작업입니다. 특히 저장 용량이 클 경우 더욱 그렇습니다.

두 번째 방법은 여러 데이터베이스를 사용하거나 데이터베이스를 샤딩(sharding)하여 수평적으로 확장하는 것입니다. 자세한 내용은 'Multiple Databases with Active Record'를 참고하세요.

여기서는 PostgreSQL을 중심으로 데이터베이스 성능을 최적화하는 방법에 집중해 보겠습니다.

PostgreSQL에서 시간이 많이 걸리는 쿼리 찾기

먼저 가장 시간이 많이 소요되는 쿼리를 식별해야 합니다. 이를 위해 서버에서 실행된 모든 SQL 문에 대한 통계를 담고 있는 pg_stat_statements 테이블을 조회할 수 있습니다. 실행 시간이 가장 긴 상위 100개 쿼리를 찾는 방법은 다음과 같습니다:


이 쿼리는 쿼리문, 호출 횟수, 평균 실행 시간을 반환합니다. 더 빠르게 만들 수 있을 것 같은 쿼리를 찾아 왜 느렸는지 분석해 보세요.

또한 쿼리에 EXPLAIN 또는 EXPLAIN ANALYZE를 실행하여 각각 쿼리 실행 계획과 실제 실행 세부 정보를 확인할 수 있습니다.

결과에서 가장 주의 깊게 봐야 할 것 중 하나는 Seq Scan(순차 스캔)입니다. 이는 Postgres가 쿼리를 실행하기 위해 모든 레코드를 순차적으로 훑어야 함을 의미합니다. 이런 경우가 발생하면, 필터링에 사용된 컬럼에 인덱스를 추가하여 순차 스캔을 우회해 보세요.

순차 스캔이 가장 많이 발생하는 테이블 찾기

유용하게 활용할 수 있는 또 다른 쿼리는 테이블별로 실행된 순차 스캔의 총 횟수를 찾는 것입니다:


이 결과에서 행 수가 매우 많은 테이블과 함께 높은 카운트 값이 나타난다면 문제가 있는 것입니다. 해당 테이블에 대한 모든 쿼리를 점검하고, 순차 스캔을 유발할 수 있는 쿼리를 찾아 인덱스를 추가하여 개선하세요.

인덱스 사용률 확인

다음 쿼리를 실행하면 인덱스 사용에 관한 통계도 확인할 수 있습니다:


이 쿼리는 각 테이블의 인덱스 사용률(%)을 반환합니다. 수치가 낮다는 것은 해당 테이블에 필요한 인덱스가 누락되어 있음을 의미합니다.

마무리

이번 글에서는 캐싱과 백그라운드 워커를 포함하여 Ruby on Rails 애플리케이션을 확장하는 여러 전략을 살펴보았습니다. 또한 PostgreSQL 데이터베이스의 성능을 최적화하는 방법도 알아보았습니다.

Rails는 애플리케이션에 여러 계층의 성능 최적화를 손쉽게 적용할 수 있도록 도와줍니다.

확장성과 관련해 가장 중요한 고려 사항은 조치를 취하기 전에 애플리케이션의 병목 지점을 먼저 식별하는 것입니다. 좋은 성능 모니터링 도구가 큰 도움이 됩니다. 필요하다면 Ruby용 AppSignal을 확인해 보세요.

즐거운 코딩 되세요!

P.S. Ruby Magic의 새 글을 발행 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!