rubyonrails.org에서 더 보기:

Active Record와 PostgreSQL

이 가이드에서는 Active Record의 PostgreSQL 관련 사용법을 다룹니다.

이 가이드를 읽은 후에는 다음 내용을 알 수 있습니다:

  • PostgreSQL의 데이터타입 사용 방법
  • UUID primary key 사용 방법
  • index에 non-key 컬럼 포함하는 방법
  • deferrable foreign key 사용 방법
  • unique constraints 사용 방법
  • exclusion constraints 구현 방법
  • PostgreSQL로 전문 검색을 구현하는 방법
  • database view로 Active Record 모델을 지원하는 방법

PostgreSQL adapter를 사용하기 위해서는 최소 9.3 버전이 설치되어 있어야 합니다. 이전 버전은 지원되지 않습니다.

PostgreSQL을 시작하려면 Rails 설정 가이드를 참고하세요. PostgreSQL용 Active Record를 적절하게 설정하는 방법이 설명되어 있습니다.

1 데이터타입

PostgreSQL은 다양한 특정 데이터타입을 제공합니다. 다음은 PostgreSQL adapter에서 지원하는 타입 목록입니다.

1.1 Bytea

# db/migrate/20140207133952_create_documents.rb
create_table :documents do |t|
  t.binary "payload"
end
# app/models/document.rb
class Document < ApplicationRecord
end
# 사용법
data = File.read(Rails.root + "tmp/output.pdf")
Document.create payload: data

1.2 Array

# db/migrate/20140207133952_create_books.rb
create_table :books do |t|
  t.string "title"
  t.string "tags", array: true 
  t.integer "ratings", array: true
end
add_index :books, :tags, using: "gin"
add_index :books, :ratings, using: "gin"
# app/models/book.rb
class Book < ApplicationRecord
end
# 사용법
Book.create title: "Brave New World",
            tags: ["fantasy", "fiction"], 
            ratings: [4, 5]

## 단일 tag에 대한 Books
Book.where("'fantasy' = ANY (tags)")

## 다중 tags에 대한 Books  
Book.where("tags @> ARRAY[?]::varchar[]", ["fantasy", "fiction"])

## 3개 이상의 ratings를 가진 Books
Book.where("array_length(ratings, 1) >= 3")

1.3 Hstore

참고: hstore를 사용하려면 hstore extension을 활성화해야 합니다.

# db/migrate/20131009135255_create_profiles.rb
class CreateProfiles < ActiveRecord::Migration[8.1]
  enable_extension "hstore" extension_enabled?("hstore") 아닌 경우에만 실행
  create_table :profiles do |t|
    t.hstore "settings"
  end
end
# app/models/profile.rb
class Profile < ApplicationRecord
end
irb> Profile.create(settings: { "color" => "blue", "resolution" => "800x600" })

irb> profile = Profile.first 
irb> profile.settings
=> {"color"=>"blue", "resolution"=>"800x600"}

irb> profile.settings = {"color" => "yellow", "resolution" => "1280x1024"}
irb> profile.save!

irb> Profile.where("settings->'color' = ?", "yellow")
=> #<ActiveRecord::Relation [#<Profile id: 1, settings: {"color"=>"yellow", "resolution"=>"1280x1024"}>]>

1.4 JSON과 JSONB

# db/migrate/20131220144913_create_events.rb
# ... json datatype을 위해:
create_table :events do |t|
  t.json "payload"
end
# ... 또는 jsonb datatype을 위해:
create_table :events do |t|
  t.jsonb "payload"
end
# app/models/event.rb 
class Event < ApplicationRecord
end
irb> Event.create(payload: { kind: "user_renamed", change: ["jack", "john"]})

irb> event = Event.first 
irb> event.payload
=> {"kind"=>"user_renamed", "change"=>["jack", "john"]}

## JSON 문서 기반 쿼리 
# -> 연산자는 원래 JSON 타입(객체일 수 있음)을 반환하는 반면, ->> 텍스트를 반환합니다
irb> Event.where("payload->>'kind' = ?", "user_renamed")

1.5 Range Types

이 타입은 Ruby Range 객체에 매핑됩니다.

# db/migrate/20130923065404_create_events.rb
create_table :events do |t|
  t.daterange "duration" # 기간
end
# app/models/event.rb
class Event < ApplicationRecord
end
irb> Event.create(duration: Date.new(2014, 2, 11)..Date.new(2014, 2, 12))

irb> event = Event.first 
irb> event.duration
=> 2014 2 11 화요일...2014 2 13 목요일

## 특정 날짜의 모든 Event
irb> Event.where("duration @> ?::date", Date.new(2014, 2, 12))

## range 경계값 다루기
irb> event = Event.select("lower(duration) AS starts_at").select("upper(duration) AS ends_at").first

irb> event.starts_at
=> 2014 2 11 화요일
irb> event.ends_at
=> 2014 2 13 목요일

1.6 Composite Types

현재 composite type에 대한 특별한 지원은 없습니다. 이들은 일반 text 컬럼으로 매핑됩니다:

CREATE TYPE full_address AS
(
  city VARCHAR(90),
  street VARCHAR(90)
);
# db/migrate/20140207133952_create_contacts.rb
execute <<-SQL
  CREATE TYPE full_address AS
  (
    city VARCHAR(90),
    street VARCHAR(90)
  );
SQL
create_table :contacts do |t|
  t.column :address, :full_address
end
# app/models/contact.rb
class Contact < ApplicationRecord
end
irb> Contact.create address: "(Paris,Champs-Élysées)"
irb> contact = Contact.first
irb> contact.address
=> "(Paris,Champs-Élysées)" 
irb> contact.address = "(Paris,Rue Basse)"
irb> contact.save! 

1.7 Enumerated Types

이 type은 일반 text 컬럼으로 매핑하거나 ActiveRecord::Enum으로 매핑할 수 있습니다.

# db/migrate/20131220144913_create_articles.rb
def change
  create_enum :article_status, ["draft", "published", "archived"]

  create_table :articles do |t|
    t.enum :status, enum_type: :article_status, default: "draft", null: false
  end
end

또한 enum 타입을 생성하고 기존 테이블에 enum 컬럼을 추가할 수 있습니다:

# db/migrate/20230113024409_add_status_to_articles.rb
def change
  create_enum :article_status, ["draft", "published", "archived"]

  add_column :articles, :status, :enum, enum_type: :article_status, default: "draft", null: false
end

위의 migration들은 모두 되돌릴 수 있지만, 필요한 경우 별도의 #up#down 메서드를 정의할 수 있습니다. enum 타입을 삭제하기 전에 해당 enum 타입에 의존하는 모든 컬럼이나 테이블을 제거했는지 확인하세요:

def down
  drop_table :articles

  # 또는: remove_column :articles, :status
  drop_enum :article_status
end

모델에서 enum 속성을 선언하면 헬퍼 메서드가 추가되고 클래스의 인스턴스에 유효하지 않은 값이 할당되는 것을 방지합니다:

# app/models/article.rb
class Article < ApplicationRecord
  enum :status, {
    draft: "draft", published: "published", archived: "archived"
  }, prefix: true
end
irb> article = Article.create
irb> article.status  
=> "draft" # migration에 정의된 대로 PostgreSQL의 기본 status입니다

irb> article.status_published!
irb> article.status
=> "published"

irb> article.status_archived?
=> false

irb> article.status = "deleted"
ArgumentError: 'deleted'는 유효한 status가 아닙니다

enum의 이름을 변경하려면 모델 사용을 업데이트하면서 rename_enum을 사용할 수 있습니다:

# db/migrate/20150718144917_rename_article_status.rb
def change
  rename_enum :article_status, :article_state
end

새로운 값을 추가하려면 add_enum_value를 사용할 수 있습니다:

# db/migrate/20150720144913_add_new_state_to_articles.rb
def up
  add_enum_value :article_state, "archived" # published 뒤에 추가됨
  add_enum_value :article_state, "in review", before: "published"
  add_enum_value :article_state, "approved", after: "in review" 
  add_enum_value :article_state, "rejected", if_not_exists: true # 값이 이미 존재하는 경우 에러가 발생하지 않음
end

Enum value들은 삭제할 수 없으며, 이는 add_enum_value가 되돌릴 수 없다는 것을 의미합니다. 그 이유는 여기에서 확인할 수 있습니다.

값의 이름을 변경하려면 rename_enum_value를 사용할 수 있습니다:

# db/migrate/20150722144915_rename_article_state.rb
def change
  rename_enum_value :article_state, from: "archived", to: "deleted"
end

Hint: 사용 중인 모든 enum의 값들을 보기 위해서는, bin/rails dbpsql 콘솔에서 다음 쿼리를 실행하면 됩니다:

SELECT n.nspname AS enum_schema,
       t.typname AS enum_name,
       e.enumlabel AS enum_value
  FROM pg_type t
      JOIN pg_enum e ON t.oid = e.enumtypid
      JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace

1.8 UUID

PostgreSQL 13.0 이전 버전을 사용하는 경우 UUID를 사용하기 위해 특별한 extension을 활성화해야 할 수 있습니다. pgcrypto extension(PostgreSQL >= 9.4) 또는 uuid-ossp extension(더 이전 버전의 경우)을 활성화하세요.

# db/migrate/20131220144913_create_revisions.rb
create_table :revisions do |t|
  t.uuid :identifier
end
# app/models/revision.rb
class Revision < ApplicationRecord
end
irb> Revision.create identifier: "A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"

irb> revision = Revision.first
irb> revision.identifier 
=> "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"

migration에서 참조를 정의할 때 uuid 타입을 사용할 수 있습니다:

# db/migrate/20150418012400_create_blog.rb
extension_enabled?("pgcrypto") 없는 경우 enable_extension "pgcrypto" 실행
create_table :posts, id: :uuid

create_table :comments, id: :uuid do |t|
  # t.belongs_to :post, type: :uuid 
  t.references :post, type: :uuid
end
# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments
end
# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post
end

UUID를 primary key로 사용하는 것에 대한 자세한 내용은 이 섹션을 참조하세요.

1.9 Bit String Types

# db/migrate/20131220144913_create_users.rb
create_table :users, force: true do |t|
  t.column :settings, "bit(8)"
end
# app/models/user.rb
class User < ApplicationRecord
end
irb> User.create settings: "01010011"
irb> user = User.first
irb> user.settings  
=> "01010011"
irb> user.settings = "0xAF"
irb> user.settings
=> "10101111"  
irb> user.save!

1.10 Network Address Types

inetcidr 타입은 Ruby IPAddr 객체로 매핑됩니다. macaddr 타입은 일반 텍스트로 매핑됩니다.

# db/migrate/20140508144913_create_devices.rb
create_table(:devices, force: true) do |t|
  t.inet "ip"
  t.cidr "network"
  t.macaddr "address"
end
# app/models/device.rb
class Device < ApplicationRecord
end
irb> macbook = Device.create(ip: "192.168.1.12", network: "192.168.2.0/24", address: "32:01:16:6d:05:ef")

irb> macbook.ip 
=> #<IPAddr: IPv4:192.168.1.12/255.255.255.255>

irb> macbook.network
=> #<IPAddr: IPv4:192.168.2.0/255.255.255.0>  

irb> macbook.address
=> "32:01:16:6d:05:ef"

1.11 Geometric Types

points를 제외한 모든 geometric type은 일반 text로 매핑됩니다. point는 xy 좌표를 포함하는 배열로 변환됩니다.

1.12 Interval

이 타입은 ActiveSupport::Duration 객체로 매핑됩니다.

# db/migrate/20200120000000_create_events.rb
create_table :events do |t|
  t.interval "duration"
end
# app/models/event.rb
class Event < ApplicationRecord
end
irb> Event.create(duration: 2.days)

irb> event = Event.first 
irb> event.duration
=> 2

2 UUID Primary Keys

랜덤 UUID를 생성하기 위해서는 pgcrypto (PostgreSQL >= 9.4) 또는 uuid-ossp extension을 활성화해야 합니다.

# db/migrate/20131220144913_create_devices.rb
"pgcrypto" extension이 활성화되어 있지 않은 경우 활성화
enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
create_table :devices, id: :uuid do |t|
  t.string :kind
end
# app/models/device.rb
class Device < ApplicationRecord
end
irb> device = Device.create
irb> device.id
=> "814865cd-5a1d-4771-9306-4268f188fe9e"

create_table:default 옵션이 전달되지 않은 경우 gen_random_uuid() (pgcrypto에서 제공)가 사용됩니다.

UUID를 primary key로 사용하는 테이블을 위한 Rails model generator를 사용하려면, model generator에 --primary-key-type=uuid를 전달하세요.

예시:

$ rails generate model Device --primary-key-type=uuid kind:string

UUID를 참조하는 foreign key로 모델을 만들 때는 uuid를 네이티브 필드 타입으로 취급하세요. 예를 들어:

$ rails generate model Case device_id:uuid

3 Indexing

PostgreSQL은 다양한 index 옵션을 포함하고 있습니다. 다음 옵션들은 일반적인 index 옵션에 더해 PostgreSQL adapter에서 지원됩니다.

3.1 Include

새로운 index를 생성할 때, non-key 컬럼들을 :include 옵션으로 포함시킬 수 있습니다. 이러한 key들은 검색을 위한 index scan에서는 사용되지 않지만, 연관된 테이블을 방문하지 않고도 index only scan 중에 읽을 수 있습니다.

# db/migrate/20131220144913_add_index_users_on_email_include_id.rb

add_index :users, :email, include: :id

여러 개의 컬럼이 지원됩니다:

# db/migrate/20131220144913_add_index_users_on_email_include_id_and_created_at.rb

add_index :users, :email, include: [:id, :created_at]

4 Generated Columns

Generated column은 PostgreSQL 버전 12.0부터 지원됩니다.

# db/migrate/20131220144913_create_users.rb
create_table :users do |t|
  t.string :name
  t.virtual :name_upcased, type: :string, as: "upper(name)", stored: true
end

# app/models/user.rb 
class User < ApplicationRecord
end

# 사용 예시
user = User.create(name: "John")
User.last.name_upcased # => "JOHN"

5 Deferrable Foreign Keys

기본적으로 PostgreSQL의 테이블 제약조건은 각 문장 실행 직후에 바로 체크됩니다. 이는 참조된 레코드가 참조 테이블에 아직 없는 레코드를 생성하는 것을 의도적으로 허용하지 않습니다. 하지만 foreign key 정의에 DEFERRABLE을 추가하면 트랜잭션이 커밋될 때까지 이 무결성 검사를 나중으로 미룰 수 있습니다. 기본적으로 모든 검사를 연기하려면 DEFERRABLE INITIALLY DEFERRED로 설정할 수 있습니다. Rails는 add_referenceadd_foreign_key 메서드의 foreign_key 옵션에 :deferrable 키를 추가함으로써 이 PostgreSQL 기능을 제공합니다.

이의 한 예시로, foreign key를 생성했더라도 트랜잭션 내에서 순환 의존성을 생성할 수 있습니다:

add_reference :person, :alias, foreign_key: { deferrable: :deferred }
add_reference :alias, :person, foreign_key: { deferrable: :deferred }

두 테이블이 서로를 참조하면서 foreign key가 있는 경우, 위와 같이 deferrable: :deferred 옵션을 사용하여 순환 의존성 문제를 해결할 수 있습니다.

foreign_key: true 옵션으로 참조가 생성된 경우, 첫 번째 INSERT 문을 실행할 때 다음 트랜잭션이 실패하게 됩니다. 하지만 deferrable: :deferred 옵션이 설정되어 있으면 실패하지 않습니다.

ActiveRecord::Base.lease_connection.transaction do
  person = Person.create(id: SecureRandom.uuid, alias_id: SecureRandom.uuid, name: "John Doe")
  Alias.create(id: person.alias_id, person_id: person.id, name: "jaydee") 
end

:deferrable 옵션을 :immediate로 설정하면, foreign key가 제약조건을 즉시 체크하는 기본 동작을 유지하면서도 트랜잭션 내에서 set_constraints를 사용하여 수동으로 체크를 연기할 수 있습니다. 이는 트랜잭션이 커밋될 때 foreign key를 체크하게 됩니다:

ActiveRecord::Base.lease_connection.transaction do
  ActiveRecord::Base.lease_connection.set_constraints(:deferred) 
  person = Person.create(alias_id: SecureRandom.uuid, name: "John Doe")
  Alias.create(id: person.alias_id, person_id: person.id, name: "jaydee")
end

기본적으로 :deferrablefalse이며 제약 조건은 항상 즉시 확인됩니다.

6 Unique Constraint

# db/migrate/20230422225213_create_items.rb
create_table :items do |t|
  t.integer :position, null: false
  t.unique_constraint [:position], deferrable: :immediate
end

기존의 unique index를 deferrable로 변경하려면, :using_index를 사용하여 deferrable unique 제약 조건을 생성할 수 있습니다.

add_unique_constraint :items, deferrable: :deferred, using_index: "index_items_on_position"

position에 대한 items 테이블의 deferrable unique constraint를 기존 인덱스를 사용하여 추가합니다.

Foreign key처럼 unique constraint도 :deferrable:immediate 또는 :deferred로 설정하여 지연시킬 수 있습니다. 기본적으로 :deferrablefalse이며 constraint는 항상 즉시 확인됩니다.

7 Exclusion Constraints

# db/migrate/20131220144913_create_products.rb
create_table :products do |t|
  t.integer :price, null: false 
  t.daterange :availability_range, null: false

  t.exclusion_constraint "price WITH =, availability_range WITH &&", using: :gist, name: "price_check"
end

외래 키와 마찬가지로 exclusion constraints는 :deferrable:immediate 또는 :deferred로 설정하여 지연시킬 수 있습니다. 기본적으로 :deferrablefalse이며 제약 조건은 항상 즉시 확인됩니다.

# db/migrate/20131220144913_create_documents.rb
create_table :documents do |t|
  t.string :title
  t.string :body
end

add_index :documents, "to_tsvector('english', title || ' ' || body)", using: :gin, name: "documents_idx"
# app/models/document.rb
class Document < ApplicationRecord
end
# 사용방법 
Document.create(title: "Cats and Dogs", body: "are nice!")  

## 'cat & dog'와 일치하는 모든 document
Document.where("to_tsvector('english', title || ' ' || body) @@ to_tsquery(?)",
                 "cat & dog")

Optionally, PostgreSQL 12.0부터는 자동으로 생성된 컬럼으로 vector를 저장할 수 있습니다:

다음 코드는 새롭게 생성되는 documents 테이블에 full-text search를 설정하는 migration입니다. 이를 통해 제목과 본문에서 검색할 수 있습니다.

테이블 생성과 동시에 titlebody 컬럼을 포함하여 textsearchable_index_col이라는 이름의 virtual 컬럼을 생성합니다. 이 컬럼은 영어 형태의 tsvector 타입으로, 제목과 본문을 공백으로 연결한 텍스트를 저장합니다.

그런 다음 검색 성능을 높이기 위해 gin 인덱스를 추가합니다.

사용 예시로, 제목이 "Cats and Dogs"이고 내용이 "are nice!"인 문서를 생성하고, "cat & dog" 키워드로 검색하는 예시를 보여줍니다.

9 데이터베이스 Views

레거시 데이터베이스에 다음과 같은 테이블이 포함되어 있는 상황을 가정해봅시다:

rails_pg_guide=# \d "TBL_ART"
                                        Table "public.TBL_ART"
   컬럼      |             타입             |                         제약 조건
------------+-----------------------------+------------------------------------------------------------
 INT_ID     | integer                     | not null default nextval('"TBL_ART_INT_ID_seq"'::regclass)
 STR_TITLE  | character varying           |
 STR_STAT   | character varying           | default 'draft'::character varying
 DT_PUBL_AT | timestamp without time zone |
 BL_ARCH    | boolean                     | default false
인덱스:
    "TBL_ART_pkey" PRIMARY KEY, btree ("INT_ID")

이 테이블은 Rails 규칙을 전혀 따르지 않습니다. 단순 PostgreSQL view는 기본적으로 업데이트가 가능하기 때문에, 다음과 같이 래핑할 수 있습니다:

db/migrate/20131220144913_create_articles_view.rb

execute <<-SQL CREATE VIEW articles AS SELECT "INT_ID" AS id, "STR_TITLE" AS title, "STR_STAT" AS status, "DT_PUBL_AT" AS published_at, "BL_ARCH" AS archived FROM "TBL_ART" WHERE "BL_ARCH" = 'f' SQL

# app/models/article.rb
class Article < ApplicationRecord
  self.primary_key = "id"
  def archive!
    update_attribute :archived, true
  end
end
irb> first = Article.create! title: "Winter is coming", status: "published", published_at: 1.year.ago
irb> second = Article.create! title: "Brace yourself", status: "draft", published_at: 1.month.ago

irb> Article.count 
=> 2
irb> first.archive!
irb> Article.count
=> 1

이 애플리케이션은 보관되지 않은 Articles만 다룹니다. view는 보관된 Articles를 직접 제외할 수 있도록 조건을 허용합니다.

10 Structure Dumps

만약 config.active_record.schema_format:sql이라면, Rails는 structure dump를 생성하기 위해 pg_dump를 호출합니다.

ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags를 사용하여 pg_dump를 설정할 수 있습니다. 예를 들어, structure dump에서 주석을 제외하려면 initializer에 다음을 추가하세요:

ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = ["--no-comments"] 

structure dump를 실행할 때 flags를 지정합니다. PostgreSQL의 경우, pg_dump 명령어에 추가될 것입니다.

11 Explain

표준 explain 옵션과 함께, PostgreSQL 어댑터는 buffers를 지원합니다.

Company.where(id: owning_companies_ids).explain(:analyze, :buffers)
#=> EXPLAIN (ANALYZE, BUFFERS) SELECT "companies".* FROM "companies"
# ...
# 회사 테이블에 대한 순차적 스캔 (cost=0.00..2.21 rows=3 width=64) 
# ...

자세한 내용은 관련 문서를 참조하세요.



맨 위로