레일스 애플리케이션에서는 직접 SQL을 작성하기보다는 액티브레코드 ORM을 사용해 데이터베이스에 하는 것을 선호한다. 이런 접근 코드 가독성을 좋게하고, 개발 생산성에 도움이 된다. 하지만 액티브레코드가 마법 도구가 아니라는 점을 명심하고 간혹 눈을 찡그리며 액티브 레코드가 만들어낼 SQL을 그려보는 작업도 필요하다. 이런 사고가 없다면 서비스 중에 문제를 유발하기도 한다.
실제 액티브 레코드를 잘못 사용해 일어날 수 있는 가장 대표적인 악재가 바로 그 유명한 1+N 케이스다. 예를 들어 아래와 같이 모델이 정의되어 있다고 해보자.
- class Post < ActiveRecord::Base
has_many :comments - has_many :authors
end
애플리케이션에 흔히 볼 수 있는 1:N 관계다. 이 모델을 이용해 아래와 같은 코드를 작성한다고 해보자.
- posts = Post.find(:all)
posts.each do |post|
puts "#{post.title} (#{post.comments.length})"
end
테이블에 저장된 모든 포스트를 덧글 수와 함께 출력하는 간단한 프로그램이다. 얼핏보기에는 큰 문제가 없어보인다. 하지만 개발 로그를 한번 살펴보기 바란다. 깜짝 놀랄 일이 벌어진다. 이 코드가 만들어낼 코드는 이런 모양일 것이다.
- SELECT * from posts
- SELECT * from comments where post_id = 1
- SELECT * from comments where post_id = 2
- .....
- SELECT * from comments where post_id = 100
1 + N(100)번, 즉 101번의 쿼리가 일어나는 것이다. 글이 만개정도 있다면 어떻게 될지 끔찍하다.
Eager Loading
1+N 케이스를 제거하기 위해 레일스에서 제공하는 방법이 바로 Eager Loading이다. 사용법은 간단하다.
- posts = Post.find(:all, :include => :comments)
posts.each do |post|
puts "#{post.title} (#{post.comments.length})"
end
find 메서드에 :include 조건을 추가하기만 하면 된다. 그렇게 하면 조인 쿼리를 추가해 모든 덧글을 한꺼번에 로딩한다.
- LEFT OUTER JOIN comments ON comments.post_id = posts.id
보다 자세한 설명은 매뉴얼에도 잘 설명되어 있으니 참고하기 바란다.
여기서 행복하게 이번 글을 마무리한다면 너무 싱거울 것같다. Eager Lading에도 문제점이 있다. 좀 더 생각해보자. :include가 만들어내는 쿼리는 이런 식이다.
- SELECT
posts.`id` AS t0_r0, posts.`owner_id` AS t0_r1, posts.`name` AS t0_r2,
comments.`id` AS t1_r0, comments.`post_id` AS t1_r1, comments.`name` AS t1_r3,
authors.`id` AS t2_r0, authors.`post_id` AS t2_r1, authors.`name` AS t2_r2,
FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id
LEFT OUTER JOIN authors ON authors.post_id = posts.id
WHERE (`posts`.`id` = '5')
Post 레코드의 사이즈가 꽤 크면 어떨까? 컬럼이 많은 테이블이 있다면 어떨까? 이보다 많은 테이블을 한꺼번에 :include하면 어떨까? 결론부터 말하면 사이즈가 큰 테이블을 대책없이 5~6차례 조인하는 것 보다는 차라리 5~6번의 작은 쿼리를 하는 것이 더 나은 경우가 많다. 그렇다면 어떻게?
Association Injection
- posts = Post.find(:all)
- posts.comments.preload!
- posts.authors.preload!
필자가 하고 만들고 싶은 코드는 위와 같다. Post만 먼저 가져온 다음, Post들의 덧글과 저자 정보를 별도의 쿼리를 이용해 가져와서 주입하는 것이다. 하지만 아쉽게도 현 시점의 레일스에서는 이런식의 쿼리를 지원하지 않는다. 그래서 간단한 형태로 직접 구현해보았다.
처음 생각은 Association Proxy 객체 직접 만들어 바꿔치기하는 방법을 구현하려고 했다. 하지만 주어진 시간에 비해 액티브 레코드 내부 구조를 너무 많이 건드리거나 살펴봐야했다. 그래서 포기하고 좀 더 실용적인 노선을 취하기로 했다.
모델이 아래처럼 생겼다고 해보자.
- class Post < ActiveRecord::Base
has_one :post_content
def content
post_content.body
end
end
Post 객체에 post_content를 담은 변수를 두고 content 메서드를 재정의해서 이 값을 사용하도록 하면 같은 효과가 날 것 같다. 그래서 아래처럼 InjectAssociations 모듈을 만들었다.
- module InjectAssociations
def inject_association(assoc, method)
attr_accessor :"_#{method}"
module_eval <<-END
def #{method}_with_inject
association = instance_variable_get("@#{assoc}")
return _#{method} if (association.nil? || !association.loaded?) && _#{method}
#{method}_without_inject
end
END
alias_method_chain method, :inject
end
end
그리고 모델을 아래처럼 확장한다.
- class Post < ActiveRecord::Base
- extend InjectAssociations
- inject_association :post_content, :content
end
메타프로그래밍을 한 코드가 쉽게 읽혀지지 않으니 코드를 풀어서 생각해보자. inject_association을 수행하면 아래와 같은 코드가 만들어진다.
- attr_accessor :_content
def content_with_inject
association = instance_variable_get("@page_content")
return _content if (association.nil? || !association.loaded?) && _content
content_without_inject
end
alias_method_chain method, :inject
위 코드는 객체가 _content를 가지고 있다면 이 값을 사용하도록 한다. 물론 Assocication을 로딩한 상황이라면 이 값을 우선한다.
이제 content를 로딩해서 객체들에 주입해주기만 하면 된다. _content을 세팅해주기 위해서 아래와 같은 작업을 수행한다.
- def preload_content!(posts)
contents = PostContent.find( :all, :conditions => ["post_id IN (?)", posts.map(&:id)]).index_by(&:post_id)
posts.each do |post|
post._content = contents[post.id] ? contents[post.id].body.to_s : ""
end
self
end
그래서 아래와 같이 하면 비슷한 효과를 누릴 수 있게 되었다.
- posts = Post.find(:all, :include => :comments)
- Post.preload_cotent!(posts)
- posts.each{|post| puts post.content}
여기까지 구현해서, 테스트까지 마쳤다. 그리고 필자는 원하는 효과를 얻었다. 아직은 좀 우아함이 떨어지지만, 개선해서 플러그인으로 발전시켜볼 궁리까지 하게 되었는데 얼마전 엣지 레일스에서 뭔가를 발견했다.
Pre Loading
필자가 발견한 것은 바로 Changeset 8672로 [PATCH] Alternative to eager loading이 반영된 것이다. 두둥. 아래처럼 사용할 수 있다.
- posts = Post.find(:all, :preload => [:comments, :authors])
위 코드로 끝이다. Eager Loading이 오히려 독이 되는 상황이라면 :include 대신 :preload만 써주면 된다. 역시나 구현은 좀 복잡하고, 내부 구현과 밀접했다. 필자는 귀차니즘에 문제를 우회할 궁리를 했는데, 어떤 사람은 정공법으로 풀어냈다는 사실(그것도 4달이나 먼저)에 약간 반성을 해본다.
암튼, 좋은 소식이다. rev8672 이상의 엣지 레일스를 사용한다면 :preload 옵션을 고려해보기 바란다. 이 기능은 아마도 레일스 2.1에 추가될 것 같다.
더 똑똑한 ORM, DataMapper
006 영리한 DataMapper, 게으른 프로그래머에서 소개한 바 있는 DataMapper는 좀 더 똑똑한 방법으로 위 문제를 해결한다.
- zoos = Zoo.all
first = zoos.first
first.exhibits
3번째 줄의 first.exhibits를 호출하는 순간 다른 객체의 exhibits가 호출될 것을 예상할 수 있다. 똑똑하게 이를 예견한 DataMapper는 이 시점에 모든 zoo 객체의 exhibits를 로딩해버린다. 위에서 preload를 하는 것과 같은 결과가 일어난다. 게다가 정말 필요할 때 불러오는 게으른(lazy) 방법이다. 뛰는 액티브 레코드 위에 나는 데이터 매퍼?
잘 갖춰진 기능을 제공하는 Hibernate
자바로 만들어진 ORM 프레임워크인 Hibernate에서는 더 많은 기능을 제공하고 있다. 역시 많이 알 필요가 있겠다. 토비님이 알려주신 내용을 옮겨본다.
Java의 ORM프레임워크인 Hibernate의 subselect fetching과 비슷한 개념이군요. Subselect fetching은 lazy collection/proxy에 적용되는 개념이니까 DataMapper쪽의 방법과 더 비슷한 것 같습니다.
이런 접근방법에서 가장 최적화된 방법은 batch fetching이 있습니다. Collection쪽을 한꺼번에 다 읽어서 메모리에 로딩하는 것이 비효율적인 경우가 많이 있습니다. 그래서 fetch size를 적당히 주어서 그 단위로 쪼개서 읽는 것이지요. 위의 예대로라면 posts가 20개고 fetch size가 5라면 comments를 5개단위로 쪼개서 4번 query로 나눠서 읽는 것이지요. 물론 각 query가 실행되는 것은 fetch size로 쪼개진 그룹의 첫번째 collection이 액세스 되는 시점에서 겠죠. 적절한 fetch size의 조정을 통해서 최적화된 액세스가 가능하다는 장점이 있어서 실전에서 많이 사용됩니다.
레일스에도 조만간 lazy subselect/batch fetching을 사용할 수 있기를 기대해봅니다.
조만간 하이버네이트 책을 한권 꼭 읽어봐야겠다.
결론은 잠 못드는 프로그래머
지금까지 간단한 쿼리 튜닌 문제인 1+N을 가지고 꽤나 긴 이야기를 해봤다. 글을 써가면서 점점 나아지는 코드를 보고 있으니 프로그래밍의 길은 끝도 없다는 생각이 든다. 문제를 좀 더 똑똑하게 해결하려면, 약간의 아이디어와 이 아이디어를 실행할 수 있는 추진력이 2:8 정도의 비율로 필요한 것 같다. 오늘도 밤은 깊어가지만, 잠들수 없는 이유다.
(2)
(
