#345 Hstore pro
- Download:
- source codeProject Files in Zip (85.5 KB)
- mp4Full Size H.264 Video (21.3 MB)
- m4vSmaller H.264 Video (11.5 MB)
- webmFull Size VP8 Video (13.6 MB)
- ogvFull Size Theora Video (25.4 MB)
때로는 어플리케이션을 만들 때 schema-less 데이터베이스의 잇점을 필요로 하는 상황이 발생할 수 있습니다. 아래에서 보게 될 어플리케이션에서는 서적과 비디오를 포함해서 다양한 종류의 제품 정보를 관리하게 됩니다.
제품의 종류별로 다른 정보를 저장할 필요가 있습니다. 서적에 대해서는 저자를, 비디오에 대해서는 등급과 총상영시간을 저장하고자 합니다. 이 정보들은 현재 데이터베이스의 description
이라는 텍스트 필드에 저장되어 있지만 매우 비효율적이어서 해당 속성을 추출해서 각각에 대해서 작업을 수행할 수 있는 방법을 제공해 주지 못합니다. 이들 속성에 대해서 데이터베이스에 별도의 컬럼을 추가할 수 있지만 제품의 종류가 많아질수록 이 또한 감당할 수 없게 됩니다. 그렇다면 이런 상황에 대한 최선의 대처방법은 무엇일가요?
Hstore
이 때는, MongoDB와 같은 NoSQL 데이터베이스로 바꿔보는 것도 고려해 볼만 합니다만 여기서는 Postgres 데이터베이스를 사용하므로, 대안으로 hstore 데이터형을 시도해 볼 수 있습니다. 이것은 하나의 데이터베이스 컬럼에 해쉬형태의 속성들을 저장할 수 있게 해 줍니다. Hstore는 그저 하나의 텍스트필드 상의 시리얼화된 해시에 불과할 뿐 아니라, 쿼리를 하거나 데이터 작업을 할 때 사용할 수 있는 수많은 연산자와 함수들을 제공해 줍니다. 어플리케이션에 이것을 추가해서 각 제품 종류별로 데이터를 저장해 보겠습니다.
우선, 현재의 어플리케이션에 대해서 미리 알아두어야 할 사항이 있습니다. 342번 연제에서 보여드린 바와 같이 Postgres를 데이터베이스로 이미 설정해 둔 상태입니다. 해당 스키마가 루비코드로 표현할 수 있을 정도로 간단하지 않기 때문에 어플리케이션의 구성 파일에서 코멘트표시(#)를 제거하여 schema_format을 sql를 사용하도록 했습니다.
# 데이터베이스를 생성할 때 액티브레코드의 스키마 덤프 대신에 SQL을 사용함. # 이것은, contrainst 또는 특정 데이터베이스 전용 컬럼형을 가지는 경우와 같이, # DB 스키마를 해당 스키마 덤프 기능으로 완전하게 덤프할 수 없을 때 필요함. config.active_record.schema_format = :sql
어플리케이션에 hstore형을 추가하기 위해서 activerecord-postgres-hstore 젬을 사용할 것입니다. 향후 레일스 4가 릴리스될 때는 hstore에 대한 지원이 내장될 것이어서 이 젬이 필요없게 될 것입니다. gemfile에 이 젬을 추가한 후 bundle 명령을 실행하여 일반적으로 젬을 설치하는 방법대로 따라 합니다.
gem 'activerecord-postgres-hstore'
다음으로는 전용 제너레이터를 실행하여 hstore를 셋업할 것입니다.
$ rails g hstore:setup create db/migrate/20120504000000_setup_hstore.rb
이것은 마이그레이션 파일을 만들어, 현재의 데이터베이스에 hstore
extension을 추가하게 됩니다.
class SetupHstore < ActiveRecord::Migration def self.up execute "CREATE EXTENSION hstore" end def self.down execute "DROP EXTENSION hstore" end end
데이터베이스를 마이그레이션하기 전에 products
테이블에 properties
컬럼을 추가하는 또 다른 마이그레이션을 생성할 것입니다. 이것은 제품 종류별 상이한 동적 속성들을 저장하기 위해 것으로 hstore
형이 될 것입니다. 이것은 바로 전의 마이그레이션을 실행한 후에 유효한 데이터형으로 인식될 것입니다.
$ rails g migration add_properties_to_products properties:hstore
마이그레이션 작업을 하나 더 생성해서 새 컬럼에 대한 인덱스를 추가할 것입니다. 이와 같이 컬럼에 대한 인덱스를 추가하는 것은 일반적으로 좋은 발상입니다.
class IndexProductsProperties < ActiveRecord::Migration def up execute "CREATE INDEX products_properties ON products USING GIN(properties)" end def down execute "DROP INDEX products_properties" end end
이 마이그레이션에서는 GIN
함수를 이용해서 properties 컬럼에 대한 마이그레이션을 추가합니다. 대신에 GiST
함수를 사용할 수도 있는데 관련 문서를 보면 이 둘간의 차이에 대해서 더 자세히 알 수 있을 것입니다.
이제 모든 마이크레이션 작업이 준비된 상태에서, rake db:migrate
명령을 실행하여 데이터베이스를 마이그레이션하면 됩니다.
Hstore 사용하기
콘솔창에서 hstore에 대한 설명함께 시작할 것입니다. 먼저, 제품 정보(product) 하나를 가져올 것입니다. 이것은 이제 properties
속성을 가지게 되며 기본값으로 nil
값을 가질 것입니다.
1.9.3-p125 :001 > p = Product.first 1.9.3-p125 :002 > p.properties => nil
이 속성을 해시값으로 설정할 수 있습니다. 이미 가져온 제품정보는 비디오에 대한 것이어서 이 속성에 대해서 rating
과 runtime
값을 할당하고 저장할 것입니다. 이것은 데이터베이스에서 properties
컬럼에 이 정보를 저장할 것입니다.
1.9.3-p125 :003 > p.properties = { rating: "PG-13", runtime: 107 } => {:rating=>"PG-13", :runtime=>107} 1.9.3-p125 :004 > p.save
이제, 해당 제품정보를 다시 로드하여 properties 값을 보면 해쉬값을 가질 것입니다.
1.9.3-p125 :005 > p.reload 1.9.3-p125 :006 > p.properties => {"rating"=>"PG-13", "runtime"=>"107"}
데이터베이스로부터 가져온 해시값에 대한 한가지 중요한 차이점은 처음에 심볼과 정수로 값을 할당하더라도 이 해시값의 키와 값은 모두가 문자열이라는 것입니다. Hstore는 문자열값만을 저장하므로 논리값, 날짜, 정수값을 저장하고자 할 때는 이후에 수작업으로 형변환을 해 줄 필요가 있을 것입니다. 알아두어야 할 또 다른 것은 properties
객체는 가져올 때마다 다른 객체가 될 것이라는 것입니다. 이 해쉬를 사용해서 특정 속성값을 할당하려고 할 경우 매번 이전 해시값이 사용되기 때문에 제대로 동작하지 않을 것입니다. 따라서 매번 반드시 전체 해시값을 할당해야만 합니다.
# This won’t work 1.9.3-p125 :007 > p.properties["runtime"] = 123 => 123 1.9.3-p125 :008 > p.properties => {"rating"=>"PG-13", "runtime"=>"107"} # We have to set the full hash like this 1.9.3-p125 :009 > p.properties = { :rating => "PG-13", :runtime => 123 } => {:rating=>"PG-13", :runtime=>123} 1.9.3-p125 :010 > p.properties => {:rating=>"PG-13", :runtime=>123}
Hstore 컬럼 쿼리하기
이러한 문제들을 해결하기 위해, 새로 추가한 hstore 컬럼 조회하기와 같은 흥미로운 것을 살펴 볼 수 있겠습니다. 관련 문서를 보면, 이러한 상황에서 사용할 수 있는 다른 종류의 연산자들이 있다는 것을 알 수 있고 그 중에 하나가 -> 입니다. 이 연산자는 키 값이 주어졌을 때, hstore 컬럼으로부터 특정 값을 추출하기 위해 사용합니다. “PG-13” 등급을 가지는 모든 제품을 찾는다고 가정하면, 다음과 같이 할 수 있습니다:
1.9.3-p125 :015 > Product.where("properties -> 'rating' = 'PG-13'") Product Load (0.8ms) SELECT "products".* FROM "products" WHERE (properties -> 'rating' = 'PG-13') => [#<Product id: 1, name: "The Sixth Sense", category: "Videos", price: #<BigDecimal:7fdb01f85c30,'0.999E1',18(18)>, description: "Rated: PG-13\n\nRuntime: 107 minutes\n\nA boy who commu...", created_at: "2012-05-04 17:46:25", updated_at: "2012-05-04 20:24:50", properties: {"rating"=>"PG-13", "runtime"=>"107"}>]
이 때 LIKE
를 사용해서, 다음과 같이, G 문자를 포함하는 등급을 가지는 모든 제품을 찾을 수 있습니다:
1.9.3-p125 :016 > Product.where("properties -> 'rating' LIKE '%G%'")
runtime
이 100보다 큰 모든 제품을 찾기 위해, 숫자를 비교하고자 한다면, 해당 값을 다음과 같이 정수로 형변환해야 할 것입니다:
1.9.3-p125 :017 > Product.where("(properties -> 'runtime')::int > 100")
Postgres에서의 형변환은 약간 까다로워서 특정 데이터가 목표 데이터형으로 변환되지 않을 경우 예를 발생합니다. 또다른 WHERE
절을 추가하면 형변환이 가능한 값을 가지는 레코드만 불러 올 수 있게 됩니다. 간단한 문자열만 비교하는 경우라면, @>
연산자를 사용하는 것이 더 효율적입니다. 이것은 왼쪽에 있는 hstore 컬럼이 오른쪽에 있는 것을 포함
하는지를 알아보기 위해 검사할 것입니다.
1.9.3-p125 :021 > Product.where("properties @> ('rating' => 'PG-13')")
이것의 장점은 해당 속성값이 있을 경우 인덱스값을 사용한다는 것입니다.
어플리케이션에서 Hstore 사용하기
이제 hstore가 동작하는 방법을 알게 되었으므로, 어플리케이션의 product 폼에 이것을 적용해 봅시다. 각 서적의 저자명을 description
필드에 같이 두는 대신에, 저자에 대한 별도의 필드를 갖도록 할 것입니다. 폼 partial에 새로운 필드를 추가하면서 시작하겠습니다.
<div class="field"> <%= f.label :author %><br /> <%= f.text_field :author %> </div>
새로 추가한 필드에 대해 Product
모델의 접근자 메소드가 필요할 것입니다. 레일스 3.2가 제공하는 새롭게 추가된 store 기능을 이용할 수 있다면 제격일 것입니다. 우리가 원하는 것을 정확하게 해 줄 store_accessor
메소드가 있습니다. 이 메소드에 특정 컬럼의 이름을 넘겨주면 되는데, 여기서는 properties
와 접근자를 만들기를 원하는 속성 리스트가 될 것입니다. 이것은 getter와 setter 메소드를 만들어 줄 것입니다. 그러나 불행히도 이것은 hstore 젬과 호환되지 않습니다. 아마도 레일스 4에서는 제대로 동작할 것이지만 현재로서는 일일이 getter와 setter 메소드를 작성해 주어야 할 것입니다.
class Product < ActiveRecord::Base attr_accessible :name, :category, :price, :description, :author # store_accessor :properties, :author def author properties && properties["author"] end def author=(value) self.properties = (properties || {}).merge("author" => value) end end
getter 메소드는 properties
해시가 존재하는지를 점검하게 되고 존재할 경우에는 author
키에 대한 값을 반환해 줍니다. setter 메소드는 전체 해시에 기존의 해시 또는 없을 경우에는 빈 해시를 할당하고 author
키에 대한 새 값을 합쳐 줍니다. 여기시 마지막으로 하게 될 작업은 새로 추가한 속성을 mass assignment시 접근할 수 있도록 attr_accessible
리스트에 추가하는 것입니다.
이제 페이지를 다시 로드하면 새로 추가한 필드가 보이고 이 필드에 값을 입력하고 폼을 서브밋한 후 다시 수정하게 되면 author 필드에 입력했던 바로 그 값을 보게 될 것입니다.
다른 접근자 메소드 생성하기
다른 hstore 속성에 대해서도 이와 같이 getter와 setter 메소드를 작성할 수 있지만 대신에 메타 프로그래밍을 이용해서 생성해 보도록 하겠습니다.
class Product < ActiveRecord::Base attr_accessible :name, :category, :price, :description # store_accessor :properties, :author %w[author rating runtime].each do |key| attr_accessible key define_method(key) do properties && properties[key] end define_method("#{key}=") do |value| self.properties = (properties || {}).merge(key => value) end end end
이제 properties
해시에 저장할 수 있는 속성값에 대한 배열을 정의한 후 루프를 돌면서 각 속성값에 대해서 define_method
메소드를 호출하면 getter와 setter 메소드가 만들어지게 됩니다. 또한 attr_accessor
메소드를 호출하여 접근할 수 있는 속성 리스트에 해당 속성을 추가해 줍니다. author
를 추가한 것과 동일한 방법으로 edit 와 show 폼에 rating
와 runtime
필드를 추가했다면, 비디오 중의 하나를 수정하고 다시 페이지를 방문하면 새로 추가한 필드가 보일 것입니다.
우리가 여기서 해야할 한가지는 보다 스마트한 edit 폼을 만들 것인데, 서적에 대한 정보를 수정하고 있다면 author만을, 비디오에 대한 정보를 수정한다면 rating
와 runtime
필드를 보여 주도록 합니다. 그러나 이렇게 구현하는 것은 이 연제의 범주를 벗어나는 일입니다. 하나의 폼에서 hstore 속성을 처리하는 것에 대한 대안이 이 예제 어플리케이션에 기술되어 있습니다. 이렇게 하면 동적으로 속성을 추가/삭제할 수 있습니다. 이 방법은 덜 빡빡한 접근법으로 현재의 어플리케이션에서 볼 수 있지만 다른 어플리케이션에서도 동작할 수 있습니다.
hstore 속성에 대한 접근자 메소드를 가질 경우의 장점이라면 유효성 검증을 쉽게 할 수 있다는 것입니다. 런타임 중에 입력한 값이 유효하다는 것을 확인하고자 한다면 여느 때와 같은 방법으로 그렇게 할 수 있습니다.
validates_numericality_of :runtime, allow_blank: true
hstore를 이용하여 만들 수 있는 폼의 종류는 매우 많습니다. 어플리케이션에서 사용할 수 있는 데이터베이스 컬럼같이 속성들을 다룰 수 있거나 데이터 해시를 좀 더 유연하게 하여 어떤 값도 입력할 수 있게 할 수 있습니다. 이제 짧은 보너스 팁을 알려드리고 이번 연제를 마무리 하겠습니다. 속성에 대해서 쿼리를 수행하고자 한다면 다음과 같이 scope 호출을 추가하면 됩니다:
class Product < ActiveRecord::Base attr_accessible :name, :category, :price, :description validates_numericality_of :runtime, allow_blank: true # store_accessor :properties, :author %w[author rating runtime].each do |key| attr_accessible key scope "has_#{key}", lambda { |value| where("properties @> (? => ?)", key, value) } define_method(key) do properties && properties[key] end define_method("#{key}=") do |value| self.properties = (properties || {}).merge(key => value) end end end
이제 이 scope를 이용하면 “R” 등급을 가지는 모든 제품을 찾을 수 있습니다.
1.9.3-p125 :001 > Product.has_rating("R") Product Load (3.2ms) SELECT "products".* FROM "products" WHERE (properties @> ('rating' => 'R')) => []
Product
모델에 대해서 멋진 리팩토링을 하는 방법은 코드를 깔끔하게 하기 위해서 각 속성에 대한 접근자를 생성하는 코드를 메소드 호출로 이동시키는 것입니다.