이 글에서는 액티브레코드에서 쿼리의 퍼포먼스를 높히기 위해 제공되는 두 가지 장치인 joins()includes()의 사용 방법을 알아본다. 두 모델간의 어쏘시에이션(Association)이 다음과 같이 설정된 상태를 가정한다.

class Category < ActiveRecord::Base
  has_many :articles
end

class Article < ActiveRecord::Base
  belongs_to :category
  has_many :comments
  has_many :tags
end

class Comment < ActiveRecord::Base
  belongs_to :article
end

class Tag < ActiveRecord::Base
  belongs_to :article
end
joins
joins()은 두 개 또는 그 이상의 테이블을 결합해서 레코드를 수집하는 기능을 제공한다.
Article.joins(:comments)
가령 위 문장을 실행하면 articles 테이블의 id 칼럼이 comments 테이블의 article_id 칼럼과 일치하는 모든 articles의 레코드가 리턴된다. article 레코드가 여러개의 comments 레코드와 일치하게 되면 해당 article 레코드는 comments 수 만큼 중복해서 결과에 포함된다. 중복한 레코드를 제외한 결과를 보기 위해서는 uniq()를 사용한다.
Article.joins(:comments).uniq
그런데 만약 어떤 article이 comments를 하나도 갖고 있지 않은 경우를 생각해보면 앞의 join()문에 의하면 이 article 레코드는 리턴되는 결과에 포함되지 않는다. 이러한 상황은 사용 목적에 따라 문제가 될 수도 있다. 따라서 사용자는 joins()의 동작 방식에 대해 충분히 숙지를 하고 있어야한다. 만약 위의 예에서 comments가 없는 article이라도 결과에 포함하기를 원한다면 Outer Join을 사용하여야 할것이다.
Article.joins("LEFT OUTER JOIN comments ON comments.article_id = articles.id").uniq
아래에는 belongs_to 관계인 categories 테이블과의 두 가지 조인 방식을 비교해 놓았다.

# Inner Join
Article.joins(:category).uniq

# Outer Join
Article.joins("LEFT OUTER JOIN categories ON categories.id = articles.category_id").uniq
다음은 categories와 comments 테이블을 한번에 조인하는 예이다.

# Inner Join
Article.joins([:category, :comments]).uniq

# Outer Join
Article.joins("LEFT OUTER JOIN categories ON categories.id = articles.category_id LEFT OUTER JOIN comments ON comments.article_id = articles.id").uniq
Inner Join의 경우 두 개의 조인 조건을 모두 만족하는 articles 레코드만이 리턴됨을 명심한다.
joins 사용 이유
joins()를 통해 묶인 테이블에 대해서는 where()에서 쿼리 조건 지정 시 어쏘시에이션 테이블의 칼럼 지정 매우 편해진다.

articles = Article.joins([:category, :comments]).uniq
articles.where(categories: {name: 'Home'}, comments: {text: "good"})
articles.where("categories.name ilike ? AND comments.text ilike ?", "%home%", "%good%")
includes
includes()는 어쏘시에이션 관계에 있는 모델에 대한 쿼리 시, 선행 로딩(Eager Loding)을 통해 실제 발생하는 쿼리의 수를 대폭 줄여준다.

articles = Article.limit(10)

articles.each do |article|
 puts article.title
 puts article.category.name
end
이 예는 흔히 N+1 이라고 알려진 문제점을 보여주는 예시이다. 이 경우 발생하는 퀴리의 총 갯수는 11개가 된다. 1개는 articles 테이블을 조회할 때 한번 발생하고, 나머지 10(N)개는 each 루프마다 한번씩 개별적인 article에 대해 category를 가져오면서 발생한다. 따라서 N값에 비례해서 데이터 수집 시간이 증가하는 문제점이 발생한다.
이 문제를 해결하는 방법중의 하나가 선행 로딩이라는 것인데 Article 모델을 가져오면서 어쏘시에이션된 모델의 인스턴스까지 한번에 가져와서 로드한다.

articles = Article.includes(:category).limit(10)

articles.each do |article|
 puts article.title
 puts article.category.name
end
이 경우 Article.include(:category)가 호출되는 시점에서 2개의 쿼리만이 필요하게 된다. 하나는 종전대로 articles 레코드를 가져오는 쿼리이고, 다른 하나는 categories 테이블에서 articles와 관련된 레코드를 한번에 가져오는 쿼리이다.

articles = Article.includes(:category).limit(10)
# SELECT  "articles".* FROM "articles" LIMIT 10
# SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (69, 66, 68, 67, 70)
joins()에서와 마찬가지로 belongs_to와 has_many 관계에 있는 다른 모델과도 함께 사용할 수 있다. 참고로 아래의 경우 발생하는 쿼리의 수는 3개가 된다.

articles = Article.includes(:category, :comments)
includes().references()
includes()와 references()를 같이 사용하면 레일즈는 자동으로 Outer Join절을 만들어준다. 따라서 이 패턴을 사용하면 단 1개의 쿼리를 통해서 N+1 문제와 조인을 동시에 해결할 수 있다.

articles = Article.includes(:category).where("categories.name ilike ?", "home").references("categories")
아래 코드는 네임드 스코프(named scope)에 includes().where().references() 패턴을 적용해서 Article, Category, Comment 모델에 대한 search() 메쏘드를 구현하는 예이다.

# article.rb
scope :search, -> (term=nil) do
  articles = includes(:category, :comments)
  if term.present?
    articles = articles.where("articles.title ilike :term OR comments.text ilike :term OR categories.name ilike :term", term: "%#{term}%")
    .references("comments", "categories")
  end
  articles
end

Article.search('foo')
Article.search()