Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Rails 마이그레이션 완벽 분석: 효과적인 데이터베이스 마이그레이션 작성법

이번 글에서는 Rails 마이그레이션을 깊이 있게 파헤쳐 보겠습니다. 마이그레이션을 여러 요소로 나누어 하나씩 분석하면서, 그 과정에서 효과적인 마이그레이션을 작성하는 방법을 배워볼 것입니다. 또한 여러 데이터베이스를 위한 마이그레이션 작성법, 실패한 마이그레이션 처리 방법, 롤백 수행 기법까지 함께 알아보겠습니다.

이 글을 온전히 이해하려면 데이터베이스와 Rails에 대한 기본적인 이해가 필요합니다.

마이그레이션 기초(Migrations 101)

Rails의 마이그레이션은 애플리케이션의 생명 주기 동안 데이터베이스를 점진적으로 발전시킬 수 있게 해줍니다. 마이그레이션은 우아한 DSL을 제공하여 일반 Ruby 코드만으로 데이터베이스의 상태를 변경할 수 있도록 지원합니다. 데이터베이스별 SQL을 직접 작성할 필요가 없습니다. 마이그레이션이 데이터베이스를 조작하기 위한 추상화 계층을 제공하고, DSL을 데이터베이스 고유의 SQL 쿼리로 변환하는 세부적인 작업을 내부적으로 처리해 주기 때문입니다. 물론 필요하다면 raw SQL을 직접 실행할 수 있는 방법도 제공합니다.

Rails 데이터베이스 마이그레이션 심층 탐구

마이그레이션을 사용하면 테이블 생성, 컬럼 추가 및 삭제, 컬럼 인덱스 추가 등을 할 수 있습니다.

모든 Rails 애플리케이션에는 모든 마이그레이션이 저장되는 특수한 디렉터리인 db/migrate가 있습니다.

데이터베이스에 events 테이블을 생성하는 마이그레이션부터 시작해 보겠습니다.

$ rails g migration CreateEvents category:string

이 명령은 db/migrate 디렉터리에 타임스탬프가 포함된 파일 20200405103635_create_events.rb를 생성합니다. 파일의 내용은 다음과 같습니다.

class CreateEvents < ActiveRecord::Migration[6.0]
  def change
    create_table :events do |t|
      t.string :category
 
      t.timestamps
    end
  end
end

이 마이그레이션 파일을 하나씩 분석해 보겠습니다.

  • Rails가 생성하는 모든 마이그레이션 파일에는 파일명에 타임스탬프가 포함되어 있습니다. 이 타임스탬프는 매우 중요하며, 나중에 살펴보겠지만 Rails가 해당 마이그레이션이 실행되었는지 여부를 판단하는 데 사용됩니다.
  • 마이그레이션은 ActiveRecord::Migration[6.0]을 상속하는 클래스를 포함합니다. Rails 6를 사용 중이므로 슈퍼클래스에 [6.0]이 붙어 있습니다. Rails 5.2를 사용했다면 슈퍼클래스는 ActiveRecord::Migration[5.2]가 됩니다. 슈퍼클래스 이름에 Rails 버전이 포함된 이유는 뒤에서 자세히 다루겠습니다.
  • 마이그레이션에는 데이터베이스를 조작하는 DSL 코드가 담긴 change 메서드가 있습니다. 이 예제에서 change 메서드는 string 타입의 category 컬럼을 가진 events 테이블을 생성합니다.
  • 마이그레이션은 t.timestamps 코드를 사용하여 events 테이블에 created_atupdated_at 타임스탬프 컬럼을 추가합니다.

이 마이그레이션을 rails db:migrate 명령으로 실행하면, string 타입의 category 컬럼과 created_at, updated_at 타임스탬프 컬럼을 가진 events 테이블이 생성됩니다.

실제 데이터베이스 컬럼 타입은 사용하는 데이터베이스에 따라 varchar 또는 text가 됩니다.

마이그레이션 타임스탬프와 schema_migrations 테이블의 중요성

rails g migration 명령으로 마이그레이션을 생성할 때마다 Rails는 고유한 타임스탬프를 가진 마이그레이션 파일을 생성합니다. 타임스탬프 형식은 YYYYMMDDHHMMSS입니다. 마이그레이션이 실행될 때마다 Rails는 해당 마이그레이션의 타임스탬프를 내부 테이블인 schema_migrations에 삽입합니다. 이 테이블은 첫 번째 마이그레이션을 실행할 때 Rails가 자동으로 생성합니다. 테이블에는 version 컬럼 하나만 있으며, 이 컬럼이 곧 기본 키입니다. schema_migrations 테이블의 구조는 다음과 같습니다.

CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);

events 테이블 생성 마이그레이션을 실행했으니, Rails가 이 마이그레이션의 타임스탬프를 schema_migrations 테이블에 저장했는지 확인해 보겠습니다.

sqlite> select * from schema_migrations;
20200405103635

마이그레이션을 다시 실행하면, Rails는 먼저 schema_migrations 테이블에 해당 마이그레이션 파일의 타임스탬프와 일치하는 항목이 있는지 확인하고, 없는 경우에만 실행합니다. 이를 통해 시간이 지남에 따라 데이터베이스에 변경 사항을 점진적으로 추가할 수 있으며, 각 마이그레이션은 데이터베이스에서 단 한 번만 실행됩니다.

데이터베이스 스키마

마이그레이션을 계속 실행할수록 데이터베이스 스키마는 계속 발전합니다. Rails는 최신 데이터베이스 스키마를 db/schema.rb 파일에 저장합니다. 이 파일은 애플리케이션의 생명 주기 동안 데이터베이스에서 실행된 모든 마이그레이션의 Ruby 표현입니다. 이 파일 덕분에 오래된 마이그레이션 파일을 코드베이스에 계속 유지할 필요가 없습니다. Rails는 데이터베이스에서 최신 스키마를 schema.rb로 덤프(dump)하는 작업과, schema.rb에서 데이터베이스로 스키마를 로드(load)하는 작업을 위한 rake 태스크를 제공합니다. 따라서 오래된 마이그레이션은 코드베이스에서 안전하게 삭제할 수 있습니다. 애플리케이션을 설정할 때마다 모든 마이그레이션을 하나씩 실행하는 것보다 스키마를 로드하는 것이 훨씬 빠릅니다.

Rails는 데이터베이스 스키마를 SQL 형식으로 저장하는 방법도 제공합니다. 두 형식을 비교한 별도의 아티클이 이미 준비되어 있으니 참고하세요.

마이그레이션에 포함된 Rails 버전

생성되는 모든 마이그레이션은 슈퍼클래스 이름에 Rails 버전을 포함합니다. 따라서 Rails 6 앱에서 생성된 마이그레이션은 ActiveRecord::Migration[6.0]을, Rails 5.2 앱에서 생성된 마이그레이션은 ActiveRecord::Migration[5.2]를 슈퍼클래스로 가집니다. Rails 4.2 이하 버전의 오래된 앱이라면 슈퍼클래스에 버전이 없다는 것을 알 수 있습니다. 슈퍼클래스는 단순히 ActiveRecord::Migration입니다.

마이그레이션 슈퍼클래스에 Rails 버전이 추가된 것은 Rails 5부터입니다. 이는 기본적으로 마이그레이션 API가 이전 버전의 Rails에서 생성된 마이그레이션을 깨뜨리지 않고 시간이 지나도 발전할 수 있도록 보장합니다.

Rails 4.2 앱에서 events 테이블을 생성하는 동일한 마이그레이션을 살펴보며 더 깊이 이해해 보겠습니다.

class CreateEvents < ActiveRecord::Migration
  def change
    create_table :events do |t|
      t.string :category
 
      t.timestamps null: false
    end
  end
end

Rails 6 마이그레이션이 생성한 events 테이블의 스키마를 보면, 타임스탬프 컬럼에 NOT NULL 제약 조건이 존재하는 것을 확인할 수 있습니다.

sqlite> .schema events
CREATE TABLE IF NOT EXISTS "events" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);

이는 Rails 5부터 마이그레이션 API가 마이그레이션 파일에 명시적으로 작성하지 않아도 타임스탬프 컬럼에 자동으로 NOT NULL 제약 조건을 추가하기 때문입니다. 슈퍼클래스 이름에 포함된 Rails 버전은 해당 마이그레이션이 생성될 당시의 Rails 버전의 마이그레이션 API를 사용하도록 보장합니다. 이를 통해 Rails는 이전 버전의 마이그레이션과의 하위 호환성을 유지하면서도 마이그레이션 API를 발전시킬 수 있습니다.

데이터베이스 스키마 변경하기

change 메서드는 마이그레이션의 핵심 메서드입니다. 마이그레이션이 실행되면 change 메서드가 호출되고 그 안의 코드가 실행됩니다.

create_table 외에도 Rails는 강력한 change_table 메서드를 제공합니다. 이름에서 알 수 있듯이 기존 테이블의 스키마를 변경하는 데 사용됩니다.

def change
  change_table :events do |t|
    t.remove :category
    t.string :event_type
    t.boolean :active, default: false
  end
end

이 마이그레이션은 events 테이블에서 category 컬럼을 제거하고, 새로운 string 컬럼인 event_type과 기본값이 false인 boolean 컬럼인 active를 추가합니다.

Rails는 마이그레이션 내부에서 사용할 수 있는 다양한 헬퍼 메서드도 제공합니다.

  • change_column
  • add_index
  • remove_index
  • rename_table

그 외에도 많은 메서드가 있으며, change와 함께 사용할 수 있는 모든 메서드는 공식 문서에서 확인할 수 있습니다.

타임스탬프(Timestamps)

앞서 Rails가 마이그레이션에 t.timestamps를 추가하여 events 테이블에 created_atupdated_at 컬럼을 만들어 주는 것을 보았습니다. Rails는 레코드가 언제 생성되고 수정되었는지 추적하기 위해 이 특수한 컬럼들을 사용합니다. 레코드가 생성될 때 이 컬럼들에 값을 넣어주고, 레코드가 수정될 때마다 값을 갱신합니다. 이러한 컬럼들은 데이터베이스 레코드의 생명 주기를 추적하는 데 도움을 줍니다.

Rails에서 update_all 메서드를 실행할 때는 updated_at 컬럼이 갱신되지 않습니다.

실패 처리하기

마이그레이션은 만능이 아닙니다. 실패할 수 있습니다. 원인은 잘못된 문법일 수도 있고 유효하지 않은 데이터베이스 쿼리일 수도 있습니다. 이유가 무엇이든, 데이터베이스가 불일치(inconsistent) 상태에 빠지지 않도록 실패를 처리하고 복구해야 합니다. Rails는 각 마이그레이션을 트랜잭션 안에서 실행함으로써 이 문제를 해결합니다. 마이그레이션이 실패하면 트랜잭션이 롤백됩니다. 이를 통해 데이터베이스가 불일치 상태에 빠지지 않도록 보장합니다.

이는 데이터베이스 스키마 변경을 위한 트랜잭션(DDL 트랜잭션이라고 함)을 지원하는 데이터베이스에만 적용됩니다. MySQL과 PostgreSQL은 모두 DDL 트랜잭션을 지원합니다.

때로는 특정 마이그레이션을 트랜잭션 안에서 실행하고 싶지 않은 경우도 있습니다. 대표적인 예가 PostgreSQL에서 concurrent index를 추가하는 경우입니다. PostgreSQL은 테이블에 잠금(lock)을 걸지 않고 인덱스를 추가하려 하기 때문에, 이러한 마이그레이션은 DDL 트랜잭션 안에서 실행될 수 없습니다. 덕분에 운영 중인 프로덕션 데이터베이스를 중단시키지 않고도 인덱스를 추가할 수 있습니다. Rails는 disable_ddl_transactions!를 통해 마이그레이션에서 트랜잭션을 비활성화(opt-out)할 수 있는 방법을 제공합니다.

def change
  disable_ddl_transactions!
 
  add_index :events, :user_id, algorithm: :concurrently

이렇게 하면 마이그레이션이 트랜잭션 안에서 실행되지 않습니다. 이런 마이그레이션이 실패하면 직접 복구해야 합니다. 이 경우 REINDEX를 실행하거나, 인덱스를 제거한 후 다시 추가를 시도할 수 있습니다.

되돌릴 수 있는(Reversible) 마이그레이션

Rails는 다음 명령으로 데이터베이스의 변경 사항을 롤백할 수 있게 해줍니다.

rails db:rollback

이 명령은 데이터베이스에서 마지막으로 실행된 마이그레이션을 되돌립니다. 마이그레이션이 event_type 컬럼을 추가했다면 롤백 시 해당 컬럼이 제거됩니다. 인덱스를 추가했다면 롤백 시 해당 인덱스가 제거됩니다.

직전 마이그레이션을 롤백한 후 다시 실행하는 명령도 있습니다. 바로 rails db:redo입니다.

Rails는 대부분의 마이그레이션을 어떻게 되돌려야 하는지 스스로 판단할 만큼 똑똑합니다. 하지만 change 메서드 대신 updown 메서드를 제공하여 Rails에게 마이그레이션 되돌리기 방법에 대한 힌트를 줄 수도 있습니다. up 메서드는 마이그레이션이 실행될 때 사용되고, down 메서드는 마이그레이션이 롤백될 때 사용됩니다.

def up
  change_table :events do |t|
    t.change :price, :string
  end
end
 
def down
  change_table :events do |t|
    t.change :price, :integer
  end
end

이 예제에서는 events 테이블의 price 컬럼을 integer에서 string으로 변경하고 있습니다. 롤백 시 어떻게 되돌려야 하는지 down 메서드에 지정했습니다.

동일한 마이그레이션은 change 메서드로도 작성할 수 있습니다.

def change
  reversible do |direction|
    change_table :events do |t|
      direction.up { t.change :price, :string }
      direction.down { t.change :price, :integer }
    end
  end
end

Rails는 revert 메서드를 사용하여 이전 마이그레이션 전체를 되돌리는 방법도 제공합니다.

def change
  revert CreateEvents
 
  create_table :events do
   ...
  end
end

revert 메서드는 블록을 받아 마이그레이션의 일부만 되돌릴 수도 있습니다.

def change
  revert do
    reversible do |direction|
      change_table :events do |t|
        direction.up { t.remove :event_type }
        direction.down { t.string :event_type }
      end
    end
  end
end

Raw SQL 직접 실행하기

때로는 마이그레이션 안에서 복잡한 SQL을 실행해야 할 때가 있습니다. 이런 경우에는 일반적인 마이그레이션 DSL 대신 다음과 같이 raw SQL을 직접 실행할 수 있습니다.

def change
  execute <<-SQL
    ....
  SQL
end

다중 데이터베이스와 마이그레이션

Rails 6는 하나의 Rails 애플리케이션에서 여러 데이터베이스를 사용할 수 있는 기능을 추가했습니다. 여러 데이터베이스를 사용하려면 database.yml 파일에서 설정합니다.

development:
  primary:
    <<: *default
    database: db/development.sqlite3
  analytics:
    adapter: sqlite3
    database: db/analytics_dev.sqlite3

이 설정은 Rails에게 primaryanalytics라는 두 개의 데이터베이스를 사용하겠다고 알려줍니다. 앞서 살펴본 것처럼 마이그레이션은 기본적으로 db/migrate 디렉터리에 저장됩니다. 하지만 이 경우 두 데이터베이스의 마이그레이션을 하나의 디렉터리에 넣을 수 없습니다. analytics 데이터베이스의 마이그레이션이 primary 데이터베이스에서 실행되는 것을 원하지 않으며, 그 반대도 마찬가지입니다. 여러 데이터베이스를 사용한다면 두 번째 데이터베이스의 마이그레이션을 저장할 경로를 반드시 지정해야 합니다. 이는 database.ymlmigrations_paths를 지정하여 설정할 수 있습니다.

development:
  primary:
    <<: *default
    database: db/development.sqlite3
  analytics:
    adapter: sqlite3
    database: db/analytics_dev.sqlite3
    migrations_paths: db/analytics_migrate

이후 다음과 같이 analytics 데이터베이스용 마이그레이션을 생성할 수 있습니다.

rails generate migration AddExperiments rule:string active:boolean --db=analytics

이렇게 하면 db/analytics_migrate 안에 마이그레이션이 생성되며, 다음과 같이 실행할 수 있습니다.

rails db:migrate --db=analytics

단순히 rails db:migrate만 실행하면 모든 데이터베이스에 대한 마이그레이션이 실행됩니다.

analytics 데이터베이스는 어떤 마이그레이션이 실행되었고 어떤 것이 실행되지 않았는지 추적하기 위한 자체 schema_migrations 테이블을 가집니다.

배포 시 마이그레이션 실행하기

마이그레이션은 데이터베이스의 상태를 변경할 수 있고, 우리의 코드가 그 변경 사항에 의존할 수 있으므로, 새로운 코드가 적용되기 전에 반드시 마이그레이션이 먼저 실행되는 것이 매우 중요합니다.

Heroku 기반 배포에서는 Procfilerelease 단계에서 마이그레이션을 실행할 수 있습니다.

# Procfile
web: bin/puma -C config/puma.rb
release: bundle exec rake db:migrate

이렇게 하면 앱 dyno가 재시작되기 전에 마이그레이션이 실행됩니다.

Capistrano 기반 배포에서는 서버가 재시작되기 전에 마이그레이션을 실행해야 합니다.

Docker 기반 배포에서는 앱이 재시작되기 전에 마이그레이션을 먼저 실행하는 사이드카(sidecar) 컨테이너를 실행할 수 있습니다. 이는 매우 중요합니다. 그렇지 않으면 새 컨테이너가 해당 코드에 필요한 데이터베이스 변경 사항을 적용하기 전에 새 코드를 사용하게 되어 불일치 상태에 빠질 수 있기 때문입니다.

결론

이번 글에서는 Rails에서 데이터베이스 마이그레이션을 작성하는 다양한 측면을 살펴보았습니다. 마이그레이션을 구성하는 요소들이 무엇인지, 그리고 필요할 때 실패를 처리하고 마이그레이션을 롤백하는 방법도 알아보았습니다. Rails 6는 다중 데이터베이스 사용을 지원하며, 각 데이터베이스의 마이그레이션은 별도로 관리해야 합니다. 마지막으로, 새로운 코드가 데이터베이스 변경 사항을 사용하기 전에 변경 사항이 올바르게 적용되도록 배포 과정에서 마이그레이션을 실행하는 방법을 간략히 살펴보았습니다.

P.S. Ruby Magic의 글을 발행 즉시 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!