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

컨트롤러를 비대하게 만들지 않고 Rails 모델 검색·필터링 구현하기

Rails 컨트롤러에서 검색, 정렬, 필터링 기능을 구현하는 일은 생각보다 번거로운 작업입니다. ElasticSearch나 Solr 같은 도구는 강력한 솔루션이지만, 규모가 작은 애플리케이션에는 지나치게 무거운 의존성이 될 수 있습니다.

다행히 Rails에는 스코프(scope)가 내장되어 있어, 간단한 검색·필터링·정렬에 필요한 대부분의 기능을 이것만으로 해결할 수 있습니다. 스코프 체이닝(scope chaining)을 활용하면 거대한 외부 라이브러리를 추가하거나 반복적인 검색 코드를 일일이 작성하지 않고도 원하는 기능을 만들 수 있습니다.

스코프로 검색 구현하기

제품 목록을 테이블 형태로 보여주는 RESTful 컨트롤러의 #index 메서드를 상상해 보세요. 각 제품은 active(활성), pending(대기), inactive(비활성) 상태를 가지며, 하나의 위치(location)에서 판매되고 이름(name)을 가지고 있습니다.

이 제품들을 필터링하고 싶다면 다음과 같이 스코프를 작성할 수 있습니다.

app/models/product.rb
class Product < ActiveRecord::Base
  scope :filter_by_status, -> (status) { where status: status }
  scope :filter_by_location, -> (location_id) { where location_id: location_id }
  scope :filter_by_starts_with, -> (name) { where("name like ?", "#{name}%")}
end

각 스코프는 Product 모델에 클래스 메서드를 정의하며, 이를 통해 조회 결과를 원하는 조건으로 좁힐 수 있습니다.

@products = Product.filter_by_status("active").filter_by_starts_with("Ruby") # => 이름이 'Ruby'로 시작하는 모든 활성 제품

컨트롤러에서는 이 스코프들을 사용해 결과를 필터링할 수 있습니다.

app/controllers/product_controller.rb
def index
  @products = Product.where(nil) # 익명 스코프 생성
  @products = @products.filter_by_status(params[:status]) if params[:status].present?
  @products = @products.filter_by_location(params[:location]) if params[:location].present?
  @products = @products.filter_by_starts_with(params[:starts_with]) if params[:starts_with].present?
end

이제 아래 URL처럼 이름이 'Ruby'로 시작하는 활성 제품만 조회할 수 있습니다.

https://example.com/products?status=active&starts_with=Ruby

코드 정리가 필요한 순간

위 코드가 점점 장황해지고 반복적이라는 것을 눈치챘을 겁니다. 물론 우리는 Ruby를 사용하고 있으니, 반복문으로 깔끔하게 묶을 수 있습니다.

app/controllers/product_controller.rb
def index
  @products = Product.where(nil)
  filtering_params(params).each do |key, value|
    @products = @products.public_send("filter_by_#{key}", value) if value.present?
  end
end

private

# Product 목록을 필터링할 때 사용할 수 있는 파라미터 이름 목록
def filtering_params(params)
  params.slice(:status, :location, :starts_with)
end

더 재사용하기 좋은 해결책

이 코드를 모듈로 분리하면, 필터링이 필요한 어떤 모델에든 쉽게 포함시킬 수 있습니다.

app/models/concerns/filterable.rb
module Filterable
  extend ActiveSupport::Concern

  module ClassMethods
    def filter(filtering_params)
      results = self.where(nil)
      filtering_params.each do |key, value|
        results = results.public_send("filter_by_#{key}", value) if value.present?
      end
      results
    end
  end
end
app/models/product.rb
class Product
  include Filterable
  ...
end
app/controllers/product_controller.rb
def index
  @products = Product.filter(params.slice(:status, :location, :starts_with))
end

이제 모델의 검색과 필터링을 컨트롤러에서 한 줄, 모델에서 한 줄로 처리할 수 있게 되었습니다. 얼마나 간단합니까! 정렬 기능도 내장된 order 클래스 메서드로 구현할 수 있지만, 입력값을 사전에 검증할 수 있도록 정렬용 스코프를 직접 작성하는 편이 더 안전합니다.

여러분의 시간을 아껴 드리고자 Filterable 모듈을 gist로 공개해 두었습니다. 직접 프로젝트에 적용해 보세요. 저 역시 덕분에 많은 시간과 코드를 절약했습니다.

업데이트: Jan Sandbrink님의 지적으로 한 가지를 보완했습니다. filtering_params에서 파라미터 화이트리스트 처리를 잊기 쉬운데, 이를 놓치면 앱이 심각한 보안 문제에 노출될 수 있습니다.

이를 방지하기 위해 기존의 status, location, starts_with라는 스코프 이름 대신 filter_by_status, filter_by_location, filter_by_starts_with로 변경했습니다. 그렇게 하는 것이 더 명확하고 안전하기 때문입니다.

반드시 알아야 할 보안 경고

파라미터를 스코프에 전달하는 방식은 웹 앱에 기본적인 검색·필터링 기능을 손쉽게 추가할 수 있는 좋은 방법입니다. 하지만 사용자가 보내는 값을 무분별하게 그대로 받아들이면, 앱에 심각한 보안 취약점이 생길 수 있습니다.

특히 order는 SQL 인젝션(SQL injection)에 취약합니다. 따라서 파라미터로 정렬 기준을 지정한다면, 사용자가 전달한 컬럼 이름을 항상 검증하고 안전하다고 확인된 값만 허용해야 합니다.

Rails SQL Injection 사이트를 참고하면 어떤 ActiveRecord 메서드가 취약한지 학습할 수 있어, 앱을 안전하게 유지하는 데 큰 도움이 됩니다.

영상으로 배우는 것이 더 효과적이라면?

새 앱을 처음부터 만드는 단계부터 검색·필터링 기능을 추가하는 과정까지, 모든 단계를 동반 스크린캐스트에서 확인할 수 있습니다. 앱을 생성하고 샘플 데이터를 채운 뒤, 검색 폼을 추가하고 실제로 연동하는 과정을 함께 진행합니다. 영상과 함께 소스 코드도 제공되므로, 여러분의 Rails 앱에 간단한 검색·필터링 기능을 추가할 때 언제든 참고할 수 있습니다.

스크린캐스트 자세히 알아보기!