본문에서는 Rails 애플리케이션의 성능을 저하시키는 주요 병목 현상과 그 해결책을 다음과 같이 상세히 다룹니다.
1. N+1 쿼리
- 문제점:
@posts.each { |post| puts post.user.name }
와 같이 반복문 내에서 연관 객체에 접근할 경우, 게시물 수만큼 추가 쿼리가 발생하여 비효율적입니다. - 해결책:
Post.includes(:user)
를 사용하여 모든 게시물과 해당 사용자 정보를 단 2개의 쿼리로 미리 로드하여 N+1 문제를 방지하고 쿼리 수를 대폭 줄입니다.
2. 누락되거나 잘못된 인덱스
- 문제점:
Order.where(user_id: 123).order(:created_at)
와 같은 쿼리에서 적절한 인덱스가 없으면, 대용량 테이블에서 전체 스캔이 발생하여 매우 느려집니다. - 해결책:
add_index :orders, [:user_id, :created_at]
와 같이 복합 인덱스를 추가하여 쿼리가 필요한 행으로 직접 이동하게 함으로써 검색 속도를 향상시킵니다.
3. 과도한 데이터 로딩
- 문제점:
User.all
처럼 모든 컬럼을 로드하면 불필요한 데이터가 메모리에 적재되어 GC(Garbage Collection) 시간을 증가시킵니다. - 해결책:
User.select(:id, :email)
로 필요한 컬럼만 선택하거나,User.active.pluck(:id)
로 특정 컬럼 값만 추출하고,User.find_each(batch_size: 1000)
를 사용하여 데이터를 배치 처리하여 메모리 사용량을 최적화합니다.
4. 반복문 내 부분 렌더링
- 문제점:
<% @posts.each { |post| render "post", post: post } %>
는 각 항목마다render
를 개별적으로 호출하여 수많은 템플릿 호출을 유발합니다. - 해결책:
<%= render partial: "post", collection: @posts %>
와 같이 컬렉션 렌더링을 사용하거나,cache post do ... end
블록으로 캐싱을 적용하여 렌더링 오버헤드를 줄입니다.
5. 부실하거나 누락된 캐싱
- 문제점:
Stats.calculate
와 같이 비용이 많이 드는 작업을 모든 요청마다 재수행합니다. - 해결책:
Rails.cache.fetch("stats:v1", expires_in: 10.minutes) { Stats.calculate }
를 통해 한 번 계산된 결과를 지정된 시간 동안 재사용하여 부하를 줄입니다.
6. 요청 중 무거운 작업 처리
- 문제점:
InvoiceMailer.send_invoice(@invoice).deliver_now
와 같이 이메일 발송과 같은 무거운 작업을 요청 처리 중에 실행하면 사용자 응답이 지연됩니다. - 해결책:
SendInvoiceJob.perform_later(@invoice.id)
와 같이ActiveJob
을 사용하여 작업을 백그라운드 큐(예: Sidekiq)로 옮겨 즉각적인 응답을 제공합니다.
7. 과도한 JSON 응답
- 문제점:
render json: @user
는 모든 필드와 연관 관계를 포함하여 클라이언트에 불필요하게 큰 페이로드를 전송합니다. - 해결책:
render json: @user.as_json(only: [:id, :email], methods: [:full_name])
를 사용하여 필요한 필드만 반환하고,User.page(params[:page]).per(50)
와 같이 페이지네이션을 적용하여 응답 크기를 줄입니다.
보너스: Puma 및 DB 풀
- 문제점:
database.yml
의pool: 5
와 같이 데이터베이스 연결 풀이 너무 작으면 요청이 대기하거나 타임아웃될 수 있습니다. - 해결책:
puma.rb
의 스레드 및 워커 설정에 맞춰database.yml
의pool
크기를 조정하여 Puma의 동시성을 지원하고 안정성을 확보합니다.