액티브레코드 AssociationProxy 튜토리얼 by thinkr

* 세미나 발표자료: AssociationProxyTutorial.pdf
이글은 지난 토요일 제4회 루비KR세미나에서 내가 발표한 내용을 정리한 것이다. 간단히 후기를 몇 마디 덧붙이자면, 갈수록 루비사용자의 저변이 확대되는 듯 하여 루비 사용자의 한 사람으로서 기분이 좋았다는 점, 처음으로 세미나 장소 다운 곳에서 세미나를할 수 있어서 안락(?)했던 점, 그리고 언제나 느끼는 거지만  ikspres님과 deepblue님의 변함없는 루비 커뮤니티에의 헌신이 고마웠던 점이다. 특히 deepblue님의 강의는 가을처럼 재미있고 따뜻했다.

투명한 '뿌락찌'

액티브레코드 연관 클래스는 ActiveRecord::Associations::AssociationProxy 클래스의 확장이다. 이프록시 클래스는 일종의 "투명 프록시(transparent proxy)"로서 개발자들 몰래 숨어서 맡은 역할을 수행한다.그렇지만, 이 프록시의 존재여부를 아는 것과 모르는 것은 차이가 있다. 예를 들어, 우리가 만약 User 모델에서has_many :articles를 선언하게 되면, 이제 우리는 user.articles 와 같이 액세스할 수 있는데, 이 때반환되는 객체가 실제로는 프록시 객체다. 아래 그림은 이 프록시 클래스의 계층구조다. (요즘도 "뿌락찌"라는 말을 아는 사람들이있을까?)

프록시는 어떻게 생성되는가?

액티브 레코드의 소스코드를 보면 has_many, has_one, belongs_to, has_and_belongs_to_many와 같은 연관 메서드들이 정의되어 있는 것을 볼 수 있다. 그런데 이들 메서드의 내용을 유심히 보면, 메서드들마다 조금씩다르기는 하지만, 대체로 다음과 같은 로직이 들어 있다. 예를 들어, has_many의 경우를 보자.
def has_many(association_id, options = {}, &extension)
    reflection = create_has_many_reflection(association_id, options, &extension)
    // ...
    collection_accessor_methods(reflection, HasManyAssociation)
    // ...
end
설명을 덧붙이자면, 우선 주어진 association_id와 옵션정보를 사용하여 리플렉션(Reflection)을 생성한다.리플렉션은 액티브레코드의 클래스나 객체에 대한 메타데이터 정보들을 담는 객체라고 보면된다. 연관 모델인 경우AssociationReflection 객체가 생성될 것이다. 이어서 생성된 리플렉션 객체를 가지고HasManyAssociation 프록시 객체를 생성하고, 리플렉션 정보를 이용하여 각각의 연관관계에서 필요한 헬퍼 메서드들을생성하게 된다. 예를 들어, User 모델에서 has_many :articles 라고 선언 하였다면, 이 메서드에 의해 User모델에 articles(), articles=() 등의 메서드가 만들어지고, 이들 메서드가 호출될 때에 실제로는 아래 그림처럼연관 프록시 객체를 대상으로 작업이 일어나게 되는 것이다.

연관 프록시를 사용하는 이점은 무엇인가?

그렇다면 이런 연관 프록시를 왜 사용할까? 연관 프록시를 사용하는 이점은 무엇일까?
연관 프록시를 사용하는 이점은 결국 연관(association)의 사용 사례를 살펴보면 알 수 있다.  연관을 사용하는 경우는크게 2가지인데, 하나는 액티브레코드 모델의 액세스 범위를 한정하는 것이고, 나머지 하나는 Custom Query를 생성하기위한 것이다.

예를 하나 들어보자.

어떤 사용자의 articles를 모두 찾는 기능을 만든다고 하자. Article 객체만을 사용하여 이 작업을 수행할 수도 있다.
Article.find_all_by_user_id(user)

그렇지만 이 방법보다는 연관을 사용하여 다음과 같이 표현하는 것이 훨씬 간단하고 표현 또한 직관적이다.
user.articles

더 편리한 것은 이 상태에서 뭔가 추가로 작업을 더 해 줘야 할 경우다. 후자의 경우 user.articles.find(...)와 같은 식으로 표현하여 특정 사용자로 범위가 한정된 모델객체에 대하여 일반적인 CRUD 조작을 할 수가 있다. 전자의 경우라면이들 경우마다 일일이 별개의 메서드를 만들어야 했을 것이다.

이것 외에 또 어떤 이점들이 있을까?

  1. 연관관계를 다루는 표현이 간편하고 직관적이다.
  2. 내부적으로 캐싱이 구현되어 있다. 즉, user.articles(true)를 하기 전에는 다시 로드하지 않는다.

연관을 사용하는 또 다른 경우는 커스텀 질의문을 확장하는 경우다.
예를 들어, 최근의 article들을 찾는다고 하자. Article 모델에 다음과 같이 클래스 메서드를 정의할 수도 있을 것이다.
class Article < ActiveRecord::Base
  def self.recent
    find(:all, :conditions => ['created_at > ?', 10.days.ago])
  end
end
그러나, 이 방법을 사용하면 매번 필요할 때 마다 이런 식으로 클래스 메서드들을 만들어 줘야 한다는 단점이 있다.
이제 액티브레코드 연관을 사용해 보자. 위와 똑같은 효과를 내기 위해서 아래와 같이 하면 된다.
class User < ActiveRecord::Base
  has_many :recent_articles,
           :class_name => 'Article',
           :conditions => ['created_at > ?', 10.days.ago]
end

결과는 동일하다. 즉 위와 같이 하여 user.articles.recent를 호출한 것이나, 아래와 같이 하여user.recent_articles를 호출할 것이나 SQL 쿼리문장은 동일하게 "SELECT * FROM articlesWHERE (articles.user_id = 1 AND created_at > ...) " 과 같은 쿼리를 호출할것이다.

그렇다면, 차이점은 무엇일까?

장단점이 있다. 우선, 위의 경우는 연관관계가 아닌 경우에서도 사용할 수 있다는 장점이 있다. 즉,Article.recent 도 가능하며, 사실 이게 더 정상적인 사용 패턴일 것이다 (오히려 user.articles.recent가가능한 것은 연관프록시가 가지고 있지 않은 메서드에 대한 호출을 받은 경우 method_missing에서 실제 모델의 클래스메서드로 delegate하기 때문에 생기는 side-effect일 뿐이다.) 반면 아래의 경우는 반드시 연관관계 속에서만 사용이가능하다. 왜냐면 이 객체는 실제로연관 프록시 객체이며, 연관 프록시 객체는 연관관계 속에서만 생성되기 때문이다. 그렇지만 후자의 방법을 쓸 경우의 장점도 만만치않다. 우선 Scoped Access의 잇점을 누릴 수 있다. 즉, user.recent_articles.find(...) 와같은 식의 접근이 가능한 것이다. 또한 이 경우에도 마찬가지로 캐싱(caching)이 된다.


연관 프록시의 확장

연관 프록시는 확장이 가능하다. anonymous module을 사용할 수도 있고, 명시적으로 모듈을 만들어 extend하는 방법도 가능하다.
다음과 같은 식으로 연관을 확장하여 새로운 메서드를 추가하더라도 동일한 효과를 거둘 수 있다.
class User < ActiveRecord::Base
  has_many :articles do
    def recent(reload=false)
      @recent = nil if reload
      @recent ||= find(:all, :conditions => ['created_at > ?', 10.days.ago])
    end
  end
end

실제로 위와 같이 한 다음 user.articles.recent를 호출하면 SELECT * FROM articles WHERE(articles.user_id = 1 AND (created_at > '2007-08-27 11:51:26')) 가호출된다.

결론적으로 보면, 아래 표와 같이 약 3가지의 사용 패턴이 나온다. 장단점을 간단히 비교해 보자.

구분
구현방법
범위(scope)
find() 메서드 사용
캐싱(caching)
[사용패턴#1]
Article.recent
(user.articles.recent 도 가능)
Article 모델의 클래스 메서드
전체 모델 범위
(user 단위로 scoping 가능)
불가
불가
[사용패턴#2]
user.recent_articles
User 모델에서 연관프록시 사용(has_many :recent_articles)
특정 user로 범위 한정
가능
가능
[사용패턴#3]
user.articles.recent
User 모델에서 연관프록시 확장
(has_many :articles do ... end)
특정 user로 범위 한정
불가
가능
(구현하기에 따라)


연관 모델과 관련된 3rd Party 라이브러리들

꼭 연관과 관련된 것은 아니지만, 연관과 유사한 작용을 해 주는 라이브러리들이 많이 있다. 그 중 몇 가지만 소개해 본

Scope_out 플러그인

    * http://code.google.com/p/scope-out-rails/

이 플러그인은 제목 그대로 액세스 범위를 한정해 주는 플러그인이다. 내부적으로는 with_scope 문을 사용한다. 이 플러그인은 기본적으로 다음 3가지 메서드를 지원한다.

  • with_xxx
  • find_xxx
  • calculate_xxx
class Article < ActiveRecord::Base

  scope_out :published, :conditions => { :published => 1 }
  scope_out :recent do
    { :conditions => ['created_at > ?', 10.days.ago] }
  end

따라서 위와 같이 scope_out이 정의되어 있을 경우 다음과 같이 사용할 수 있다.

    Article.find_recent(:all)
    user.articles.find_recent(:all)
    user.articles.calcuate_published(:count, :all)
    user.articles.with_published do
       user.articles
    end

Scope_Proxy 플러그인

    * http://www.jackchristensen.com/article/4/scopedproxy-plugin-for-ruby-on-rails

이 플러그인은 범위를 한정하는 것을 중복해서 가져갈 수 있게 해 주는 플러그인이다. 즉, Article.recent.published.find :all 과 같은 식의 액세스가 가능하다고 한다. 직접 사용해 보지는 않았다.

HasFinder 젬

    * http://www.pivotalblabs.com/articles/2007/09/02/hasfinder-its-now-easier-than-ever-to-create-complex-re-usable-sql-queries

이젬은 구현패턴#1과 연관된 젬이다. 다음과 같은 식으로 정의를 하면, Article.recent.popular와 같은 식으로개별 쿼리 메서드를 복합하여 사용할 수 있다는 점이 특색이다. user.articles.recent.popular와 같은 식으로연관관계 속에서도 사용할 수 있으며, user.articles.recent.popular.find(...)와 같은 식의 쿼리확장도 가능하다. 다만, 아직 안정화되지 않아서 생성되는 쿼리가 최적화되지 않았다.
class Article < ActiveRecord::Base

  has_finder :published, :conditions => { :published => 1 }
  has_finder :recent, :conditions => ['created_at > ?', 10.days.ago]


기타 연관과 관련된 트릭과 기법들

연관 메서드 중 loaded?라는 것이 있다. 이 메서드를 사용하여 연관이 eager loading되었는지 여부를 판단, 조건부로 처리할 수 있다.

    * http://www.dcmanges.com/blog/24
class Team
    has_many :players
    def has_leader?
        if players.loaded?
            # players have been eager loaded
            # loop through them looking for a leader
            players.detect(&:leader?)
        else
            # players have not been loaded
            # run a query to look for a leader
            !players.find_leader(:first).nil?
        end
    end
end

Conditional Association Loading Trick

연관 프록시의 loaded? 메서드를 오버로드하면 특정 조건에 부합하는 경우에만 재로드 하는 식의 조건부 로딩도 가능하다.

    * http://www.jroller.com/obie/entry/conditional_association_loading_trick
has_many :neighborhoods  do
  def loaded?
    return true if (proxy_owner.population.nil? or proxy_owner.population < 50000)
    super
  end
end

참고자료


핑백

덧글

  • JasonPA 2007/09/10 13:43 # 삭제

    깊이를 알게해주는 세미나 항상 고맙습니다. 다시 한번 또 읽어봐야겠습니다. ^^;
  • deepblue 2007/09/11 12:25 # 삭제

    좋은 발표 잘 들었습니다. 정리도 잘 해주셔서 많은 도움이 될 것 같습니다. 감사합니다.
  • jinto 2007/09/11 16:18 # 삭제

    옆자리에 앉았던 박제권입니다. 매일 껴안고 있는 책을 번역하신 분을 만나고, 세미나도 잘 듣고, 보람있는 토요일이었습니다. 반가웠습니다~
※ 이 포스트는 더 이상 덧글을 남길 수 없습니다.



Follow me on Twitter

Follow sjoonk on Twitter