Rails의 스코프(scope)를 사용하면 원하는 레코드를 아주 손쉽게 조회할 수 있습니다.
class Review < ActiveRecord::Base
belongs_to :restaurant
scope :positive, -> { where("rating > 3.0") }
end
irb(main):001:0> Restaurant.first.reviews.positive.count
Restaurant Load (0.4ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 1
(0.6ms) SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
=> 5
하지만 스코프를 잘못 사용하면 애플리케이션 성능에 심각한 악영향을 줄 수 있습니다.
그 이유는 무엇일까요? 스코프는 사실상 미리 로드(preload)할 수 없기 때문입니다. 예를 들어 여러 레스토랑 목록과 각 레스토랑의 긍정적인 리뷰 수를 함께 보여주려고 하면 어떻게 될까요?
irb(main):001:0> restauraunts = Restaurant.first(5)
irb(main):002:0> restauraunts.map do |restaurant|
irb(main):003:1* "#{restaurant.name}: #{restaurant.reviews.positive.length} positive reviews."
irb(main):004:1> end
Review Load (0.6ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
Review Load (0.5ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 2 AND (rating > 3.0)
Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 3 AND (rating > 3.0)
Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 4 AND (rating > 3.0)
Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 5 AND (rating > 3.0)
=> ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]
그렇습니다. 바로 N+1 쿼리 문제입니다. Rails 앱이 느려지는 가장 큰 원인이죠.
다행히 연관 관계(association)를 조금 다른 관점에서 바라보면 이 문제를 아주 쉽게 해결할 수 있습니다.
스코프를 연관 관계로 전환하기
belongs_to나 has_many 같은 Rails 연관 메서드를 사용할 때 모델은 보통 다음과 같은 형태입니다.
class Restaurant < ActiveRecord::Base
has_many :reviews
end
하지만 공식 문서를 살펴보면 이런 메서드들이 훨씬 더 많은 기능을 제공한다는 것을 알 수 있습니다. 추가 파라미터를 넘겨서 동작 방식을 자유롭게 바꿀 수 있죠.
그중 가장 유용한 옵션이 바로 scope입니다. 앞서 Review 모델에 정의했던 스코프와 완전히 똑같은 방식으로 작동합니다.
class Restaurant < ActiveRecord::Base
has_many :reviews
has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review"
end
irb(main):001:0> Restaurant.first.positive_reviews.count
Restaurant Load (0.2ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 1
(0.4ms) SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
=> 5
이제 새로 만든 연관 관계를 includes로 미리 로드할 수 있습니다.
irb(main):001:0> restauraunts = Restaurant.includes(:positive_reviews).first(5)
Restaurant Load (0.3ms) SELECT `restaurants`.* FROM `restaurants` ORDER BY `restaurants`.`id` ASC LIMIT 5
Review Load (1.2ms) SELECT `reviews`.* FROM `reviews` WHERE (rating > 3.0) AND `reviews`.`restaurant_id` IN (1, 2, 3, 4, 5)
irb(main):002:0> restauraunts.map do |restaurant|
irb(main):003:1* "#{restaurant.name}: #{restaurant.positive_reviews.length} positive reviews."
irb(main):004:1> end
=> ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]
SQL 호출이 무려 6번에서 단 2번으로 줄어들었습니다.
(참고로 class_name 옵션을 활용하면 같은 모델에 대해 여러 개의 연관 관계를 정의할 수 있습니다. 실무에서 생각보다 자주 유용하게 쓰이니 기억해 두면 좋습니다.)
코드 중복은 어떻게 처리할까?
여기에는 아직 잠재적인 문제가 하나 남아 있습니다. where("rating > 3.0") 조건이 이제 Restaurant 클래스에도 존재하게 된다는 점입니다. 나중에 긍정 리뷰의 기준을 rating > 3.5로 변경해야 한다면 두 곳을 모두 수정해야겠죠!
상황은 더 복잡해질 수 있습니다. 예를 들어 특정 사용자가 지금까지 남긴 모든 긍정 리뷰를 조회하는 기능이 필요하다면, User 클래스에도 똑같은 스코프를 중복해서 정의해야 합니다.
class User < ActiveRecord::Base
has_many :reviews
has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review"
end
DRY(Don't Repeat Yourself) 원칙에 전혀 맞지 않습니다.
다행히 이 문제에 대한 간단한 해결책이 있습니다. 연관 관계의 where 안에서 Review 클래스에 이미 정의해 둔 positive 스코프를 그대로 재사용하면 됩니다.
class Restaurant < ActiveRecord::Base
has_many :reviews
has_many :positive_reviews, -> { positive }, class_name: "Review"
end
이렇게 하면 '어떤 리뷰가 긍정적인 리뷰인가'라는 핵심 개념은 여전히 한 곳에만 정의되어 있으므로, 기준이 바뀌어도 한 곳만 수정하면 됩니다.
마치며
스코프는 훌륭한 도구입니다. 적절한 곳에서 사용하면 데이터 조회를 쉽고 즐겁게 만들어 주죠. 하지만 N+1 쿼리를 피하고 싶다면 반드시 주의해서 사용해야 합니다.
따라서 스코프가 성능 문제를 일으키기 시작한다면, 해당 스코프를 연관 관계로 감싸고 includes로 미리 로드하세요. 추가로 드는 노력은 많지 않지만, 불필요한 SQL 호출을 대폭 줄여 앱의 응답 속도를 눈에 띄게 개선해 줄 것입니다.