Flying Mate

'RoR'에 해당되는 글 1건

  1. 2008/04/23 SimpleDB with RightAws (2)

SimpleDB with RightAws

소소하지 않은 일상 2008/04/23 09:32 by FlyingMate

notice: 같은 글을 스프링로그에도 적었는데, 그 쪽이 더 보기 편하다.
update: 마침 RightScale의 투자유치 소식이 들린다.

모델 하나를 ActiveRecord에서 SimpleDB로 전환하였다.
RightScale사의 RightAws Gem을 이용하였다. 유효성 검증(Validation)과 다른 모델과의 연관관계(Association)에 대한 고민은 일단 미뤄두고, 모델과 SimpleDB 사이의 가장 기본적인 연결을 목표로 하였다.

간단한 모델이라서 간단한 작업일 줄 알았는데 생각보다 손이 많이 갔다. ComplexDB라고 이름 붙이고 싶을 정도다. SimpleDB 자체가 복잡해서가 아니라 그 동안 ActiveRecord가 ORM으로서 너무나 많은 것들을 해주고 있었기 때문에, 기존의 기능들을 포기하지 않으면서 SimpleDB를 사용하려다보면 신경써야 할 부분이 많아진 것이다.

우선 Gem을 설치하고, config/environment.rb에서 RightAws Gem을 require한다. Amazon SimpleDB 계정에 연결하는 코드도 담는다.


sudo gem install right_aws

# config/environment.rb
require 'sdb/active_sdb'
require 'sdb/right_sdb_interface'
RightAws::ActiveSdb.establish_connection(access_key, secret_key)


도메인(데이터베이스에서의 테이블)을 만들어야 하는데 애플리케이션 배포시 한 번만 만들어주면 되므로 애플리케이션 코드에 넣지 않고 irb쉘에서 생성해주었다.


irb> require 'sdb/active_sdb'
irb> require 'sdb/right_sdb_interface'
irb> RightAws::ActiveSdb.establish_connection(access_key, secret_key)
irb> class Product < RightAws::ActiveSdb::Base
irb>   set_domain_name :products
irb> end
irb> Product.create_domain


set_domain_name 은 해당 모델의 도메인 이름을 명시해주는 부분인데, irb나 루비파일에서 도메인을 생성할 때, set_domain_name을 해주지 않고 Product.create_domain을 바로 하면 디폴트로 product라는 이름의 도메인이 생성된다. 반대로 레일스 환경(ruby script/console 포함)에서 Product.create_domain을 해주면 products라는 복수형의 도메인이 생성된다.

RightAWS는 ActiveSupport의 로드 여부를 파악해서 로드되어있으면 복수형의 도메인명을 디폴트로, 로드되어 있지 않으면 단수형을 디폴트로 잡는다. 즉 레일스 환경이 아니더라도 require 'activesupport'를 통해 복수형 도메인명을 디폴트로 사용할 수 있다. 복잡하다 싶으면 set_domain_name을 사용하면 된다. 아무래도 ActiveRecord의 컨벤션에 익숙해지다보니 복수형이 마음이 편하다.

그 다음은 모델 클래스를 재정의하는 것이다. 위의 irb에서도 domain을 생성하기 위해 모델 클래스를 잠깐 정의했는데, 위와 동일한 형태로 레일스 애플리케이션의 모델 코드에서 사용할 수 있다. class Product < ActiveRecord::Base로 시작되던 코드를 class Product < RightAws::ActiveSdb::Base로 바꾼다.

나는 좀 다르게 사용했는데 RightAws를 모델이 바로 상속하지 않고, 중간에 다른 클래스가 RightAws를 상속하고, 그 클래스를 모델이 상속하는 형태로 구성해보았다. 틀림없이 RightAws의 코드를 수정하거나 메서드를 다시 정의하거나 새롭게 추가해야 할 일이 생길 것이기 때문이다. RightAws가 플러그인이 아니라 젬이기 때문에 라이브러리 코드를 직접 수정하지 않는게 좋고, 모델들에 공통적으로 적용할 내용을 개별 모델 코드에 중복해서 작성하는 것도 피하기 위해서이다.

중간에 마음대로 만지작거릴 수 있는 클래스를 하나 만든 것인데, active_item.rb파일을 lib에 담아두었고, 그 파일 안에 ActiveItem 모듈과 Base 클래스를 정의했다. 아래에 전체 코드가 있다. 좀 정돈이 안 되어있고 미완성이지만 동작하기는 한다. SimpleDB를 본격적으로 사용하게 된다면 좀더 가꾸어볼 생각이다. 스카폴드로 만들어진 컨트롤러와 뷰에서는 코드 수정 없이 SimpleDB를 적용할 수 있도록 find메서드와 destroy, update_attributes, to_s 정도를 다시 정의했다.

module ActiveItem
  class Base < RightAws::ActiveSdb::Base    
    @@attributes = []
    class << self
      def attributes    
        if @@attributes.blank?
          first = find(:first)
          @@attributes = first.attributes.keys.select {|attr| attr != 'id'}
        end
        @@attributes
      end    

      def find(*args)
        result = super(*args)
        result.is_a?(Enumerable) ? result.each(&:reload) : result.reload
        result
      end

      def find_all_by_(format_str, args, limit=nil)
        results = super(format_str, args, limit)
        results.each(&:reload)
        results
      end

      def find_by_(format_str, args)
        result = super(format_str, args)
        result.reload
        result
      end    
    end  

    def method_generated
      @@method_generated ||= false
    end

    def method_generated=(value)
      @@method_generated = value
    end

    def method_missing(method_id, *args)
      method_name = method_id.to_s.match(/\w+/)[0]
      if !self.method_generated
        self.class.attributes.each do |attr|
          method_body = <<-EOV
            def #{attr}
              self[:#{attr}]
            end

            def #{attr}=(value)
              self[:#{attr}] = value
            end
          EOV
          self.class.class_eval(method_body, __FILE__, __LINE__)
        end
        self.method_generated = true
      end

      if self.class.attributes.include?(method_name)
        send(method_id, *args)
      else
        super
      end
    end

    def destroy
      self.delete
    end

    def update_attributes(attrs)    
      save_attributes(attrs)
    end

    def to_s
      id.to_s
    end  
  end
end

모델에서는 ActiveItem을 상속한다. ActiveItem 정의부에서 모델에 필요한 것들 몇 가지를 대신 해주었기 때문에 기존의 ActiveRecord를 상속했을 때와 마찬가지로 모델 코드가 간결해졌다.

class Product < ActiveItem::Base
end

Database와 SimpleDB의 차이점을 이해할 필요가 있다. 일단 스키마 정의가 따로 없기 때문에, 어트리뷰트 목록을 어떻게 받아올지 결정해야 한다. 모델 코드에 어트리뷰트를 명시해줄 수도 있고, 별로 좋은 방법은 아닌 것 같지만 위에서처럼 데이터 하나를 가져와 어트리뷰트 목록을 파악한 후 클래스 변수에 담아둘 수도 있다.

스키마 정의가 없고, 레코드마다 어트리뷰트 목록을 다르게 저장할 수 있긴 하지만 AWS 도큐먼트를 보면 인덱싱과 데이터 검색을 위해서 어트리뷰트 목록을 레코드 전체에 걸쳐 일관되게 사용하기를 권하고 있다. 가령 자동차 정보를 입력할 때, 차가 있을 경우 {:car => "Bugatti"}로 저장하고, 차가 없을 경우 :car 어트리뷰트를 아예 두지 않을 수도 있지만 웬만하면 {:car => "None"}으로 저장해서 탐색 시간을 줄이라고 말한다.

또 다른 큰 차이점은 Database에는 String, Text, Integer, Boolean, Datetime 등의 데이터 타입이 있는데 반해 Document-Oriented Datastore인 SimpleDB는 모든 데이터가 String이라는 점이다. 데이터베이스에서 당연했던 개념이 SimpleDB에서는 번거로운 문제가 되는데 바로 Integer와 Datetime이다.

Integer 형이 String이 되면 자리수가 다른 두 수 사이의 대소관계가 달라지게 된다. Integer 세상에서는 3 < 12 이지만 String 세상에서는 '3' > '12' 가 되기 때문에 새로운 문제가 발생한다. SimpleDB는 이를 위해 Zero-padding을 사용한다.

꽤 큰 자리수까지 0을 채워서 String 대소 관계를 Integer 대소관계와 동일하게 만들어주는 것이다. '000003' < '000012' 이렇게. 음수 데이터를 저장해야 하는 경우는 충분히 큰 수를 모든 데이터에 더해서 양수로 저장한다. 음수를 그대로 저장하면 '-32' < '-54'이 되기 때문이다. 꺼내서 사용할 때는 더했던 수를 다시 뺀다.


Datetime의 경우 ruby에서 Time.now를 곧바로 데이터로 저장하면 Tue Apr 22 23:06:26 +0900 2008 형태가 된다. 데이터베이스에서는 문제 없이 전후를 구분해낼 수 있지만, SimpleDB는 시간 순서를 전혀 파악하지 못한다. 때문에 ISO8601로 변환해서 저장해야 해야 하는데(Time.now.iso8601), 그렇게 되면 2008-04-22T23:06:26+09:00 와 같이 년월일시분초 순서로 저장되어 문자열 비교만으로 전후 관계를 찾아낼 수 있다.

다 해결이 된 듯 하지만 또 다른 문제를 야기하는데, 레일스의 Datetime Select 헬퍼에서는 ISO8601 형태의 시간 데이터를 자동으로 읽어내거나 자동으로 저장하지 못하기 때문에 헬퍼를 새로 정의하거나 편집 단계에서 일시적으로 원래의 Datetime형태(Time.parse를 이용)로 바꾸어 사용해야 한다.

크게 어려운 과제는 아니지만, 기존에는 뷰 헬퍼가 어떻게 동작하는지 알 필요 없이 Datetime과 select 테그를 손쉽게 연결할 수 있었는데, SimpleDB를 사용하려면 헬퍼의 지저분한 내부구현을 파악해야 하고 고려해야 하니 머리가 아파진다. 문제가 되지 않았던 것들이 문제가 되니, 정녕 이것을 사용하는 것이 옳은 것인가 여러 번 회의하게 되기도 한다.

기술적으로 가장 큰 차이라고 말할 수 있는 부분은, 데이터베이스에서 일반적으로 한 칼럼에 하나의 데이터를 넣었던 반면(Denormalization을 위해 여러 개 넣기도 하지만) SimpleDB의 어트리뷰트에는 기본적으로 배열이 저장된다는 점이다. 위의 모델은 다른 모델과 연관관계가 없었기 때문에 특별한 점이 없었지만, 다대다 관계를 정의할 때 일상적으로 만들어줘야 했던 연관 테이블이 SimpleDB에서는 필요 없게 된다.

이 때문에 RightAws에서는 어트리뷰트를 저장하는 메서드가 두 가지다. save와 put. save는 덮어씌우기이고 put은 추가하기이다.

@product = Product.find(:first)
@product[:link] = "naver.net"
@product.save #=> {:link => ["naver.net"]}
@product[:link] = "daum.com"
@product.put #=> {:link => ["naver.net", "daum.com"]}
@product[:link] = "cyworld.org"
@product.save #=> {:link => ["cyworld.org"]}

친구 목록을 저장해야 된다면 friendships 테이블을 만드는 대신, 아래와 같이 어트리뷰트에 친구들의 user.id를 몽땅 집어넣어 사용할 수 있다.

@user[:friend_ids] = "3"
@user.put
@user[:friend_ids] = "5"
@user.put
@user.reload
puts @user[:friend_ids] #=> ["3", "5"]

save_attributes와 put_attributes도 save/put과 같은 관계이다. 대신 ActiveRecord에 있던 update_attributes가 없어졌기 때문에 ActiveItem에서 update_attributes를 save_attributes로 연결해 두었다. 위에 언급한대로, 다대다 등 필요한 경우에는 put_attributes를 사용하면 된다.

뷰나 컨트롤러, 헬퍼 메서드 라이브러리를 수정하지 않고 form_for 메서드를 그대로 사용하려면 몇 가지 작업할 부분이 있다. 기본적인 스카폴드와 ActiveRecord에서는


<% form_for(@product) do |f| %>

이렇게만 해 주어도 new 탬플릿에서는 '/products'에 'post'로 보내고, edit에서는 /products/#{@product.id}에 'put'으로 알아서 데이터를 보내게 되어 있다. 그런데, RightAws::ActiveSdb::Base를 상속할 때는 edit와 delete url의 #{@product.id} 부분이 채워지지 않아서 잘못된 경로로 커밋이 되었는데, RightAws에서 to_s 메서드를 잘못 정의했기 때문(값이 할당되지 않은 변수 @id를 반환하도록 되어 있다)이었다. to_s를 다시 정의하니 제대로 된 경로를 가리키게 되었다.

또 다른 문제는 form_for가 내부적으로 해당 객체의 접근자 메서드를 이용한다는 점인데 RIghtAws는 접근자 메서드 대신 어트리뷰트 형태의 메서드만을 제공한다. 즉 <%= f.text_field :title %> 필드가 있으면 form_for는 자동으로 @product.title = params[:product][:title]를 시도하는데, title이라는 인스턴스 메서드가 정의되어 있지 않으니 오류가 생긴다. 뷰 코드 수정 없이 이를 빨리 해결하려면 각각의 어트리뷰트에 대해 접근자 메서드를 만들어주면 된다.

def title
  self[:title]
end

def title=(value)
   self[:title] = value
end

RightAws를 상속하는 모든 모델에서 모든 어트리뷰트에 대해 이렇게 선언해주는 것은 DRY하지 않으니 ActiveItem에서는 method_missing과 class_eval을 이용해 메서드를 자동으로 생성해주도록 하였다.

def method_missing(method_id, *args)
  method_name = method_id.to_s.match(/\w+/)[0]
  if !self.method_generated
    self.class.attributes.each do |attr|
      method_body = <<-EOV
        def #{attr}
          self[:#{attr}]
        end

        def #{attr}=(value)
          self[:#{attr}] = value
        end
      EOV
      self.class.class_eval(method_body, __FILE__, __LINE__)
    end
    self.method_generated = true
  end

  if self.class.attributes.include?(method_name)
    send(method_id, *args)
  else
    super
  end
end


잘 동작하긴 하는데, method_generated = true/false 부분이 제대로 기능하고 있는지에 대해 확신이 없다. 메서드가 한 번 생성되었으면 다시 생성하지 않도록 하기 위함인데 내가 모르는 사이 동일한 메서드가 클래스에 수십개씩 더해지고 있는게 아닌가 걱정되기도 한다.

find 류의 메서드를 Override한 이유는 reload 때문이다. RightAws에서 find문으로 객체를 받아오면 바로 사용할 수 없고 reload를 해야 한다. find문은 id값과 new_record 여부만 담아오고, reload를 해야 어트리뷰트 목록과 그 값들을 메모리로 가져온다.


@product = Product.find(:first)
@product.title #=> nil
@product.reload
@product.title #=> "스프링노트"

RightAws가 find에서 reload 처리를 해주지 않는 이유는 아마도 필요한 경우만 reload를 할 수 있도록 선택권을 준 것이겠지만(전송량이 곧 비용이기 때문에), reload하지 않은 객체를 사용해야 할 일이 뭐가 있을까 언듯 떠오르지 않아서 ActiveItem에서는 find된 모든 객체에 reload 처리를 해주기로 했다. 이제 컨트롤러와 뷰 코드를 거의 수정하지 않아도 ActiveRecord::Base를 ActiveItem::Base로 바꿔줄 수 있게 되었다!

라고 끝내면 좋겠지만 ActiveRecord는 수천라인으로 된 대형 라이브러리이다. Vaildation과 Association은 시작도 안 했다. 변화를 시도하면, 그 동안 아무런 고민 없이 사용했던 메서드들 객체들이 하나 둘 문제를 일으킬 것이다. 파업이라도 하는 것처럼.

SimpleDB를 사용하기 위해 ActiveRecord를 포기해야 하는가. 포기하지 않아도 되도록 ActiveRecord를 그대로 사용하되 SimpleDB와의 연결을 대신해줄 어답터가 빨리 나와주었으면 좋겠다. SimpleDB 덕분에 ActiveRecord 공부를 더 많이 하게 된 하루였다.

TRACKBACK :: http://flyingmate.net/trackback/46

댓글을 달아 주세요

  1. 트위니  댓글주소  수정/삭제  댓글쓰기

    레일즈 공부 열심이시군요. Core까지 열어보시다니 내공이...

    저도 요새 레드마인 이라는 넘 때문에 심심치않게 레일즈를 보고 있습니다.

    레드마인을 사내 PMS로 도입했는데 오픈소스다보니 고쳐달라는 것도 있고 고치고 싶은것도 있고...

    안부 겸 발자취 남깁니다 ^^;

    2008/04/23 11:01
    • FlyingMate  댓글주소  수정/삭제

      트위니님 안녕하세요. 애자일 OST에서 뵈었던 게 벌써 1년이 다 되어 가네요. 그리고 벌써 3차 CBT를 끝내셨네요^^ 포지션도 바뀌셨나봐요. 전부 축하드립니다!

      레드마인은 거의 엔터프라이즈급 레일스 오픈소스죠. 저도 꼭 뜯어봐야 할 소스코드 목록에 레드마인을 넣어두고 있습니다. 나중에 노하우 좀 전수해 주세요^^

      2008/04/23 15:42

1 
Flying Mate

공지사항

카테고리

분류 전체보기 (39)
소소하지 않은 일상 (39)

달력

«   2008/07   »
    1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31    

믹시