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

Ruby on Rails 애플리케이션에서 structure.sql을 사용할 때의 장단점

이번 글에서는 Ruby on Rails 애플리케이션에서 기본 스키마 형식인 schema.rb 대신 structure.sql을 사용할 때의 중요한 차이점과 장점을 살펴봅니다. 데이터가 비즈니스의 핵심이 되는 시대에 데이터베이스가 제공하는 다양한 기능을 얼마나 잘 활용하느냐가 프로젝트의 성패를 가르는 요소가 되기도 합니다.

두 형식의 주요 차이점을 짚어본 뒤, structure.sql로 전환하는 방법을 알아보고, 이것이 어떻게 데이터 무결성을 보장하고 그렇지 않으면 유지할 수 없었던 데이터베이스 기능까지 보존할 수 있는지 살펴보겠습니다.

이 글의 예제는 PostgreSQL 데이터베이스와 함께 structure.sql을 사용하는 Rails 앱을 기준으로 작성되었지만, 핵심 개념은 다른 데이터베이스에도 동일하게 적용할 수 있습니다. 신뢰할 수 있는 데이터베이스 없이 완성도 높은 웹 애플리케이션을 만들 수는 없으니까요.

그럼 바로 시작해 보겠습니다!

schema.rb와 structure.sql의 차이점

Ruby on Rails 프로젝트를 시작할 때 가장 먼저 하는 일 중 하나는 데이터베이스 마이그레이션을 실행하는 것입니다. 예를 들어 User 모델을 생성하면 Rails는 마이그레이션 실행을 요청하고, 그 결과로 schema.rb 파일이 생성됩니다:

rails g model User first_name:string last_name:string

Rails는 다음과 같은 마이그레이션을 생성합니다:

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :first_name
      t.string :last_name
 
      t.timestamps
    end
  end
end

마이그레이션을 실행하면 Rails가 자동으로 생성한 schema.rb 파일을 확인할 수 있습니다:

ActiveRecord::Schema.define(version: 2019_12_14_074018) do
 
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
 
  create_table "users", force: :cascade do |t|
    t.string "first_name"
    t.string "last_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end
 
end

schema.rb 파일은 비교적 단순한 애플리케이션과 사용 사례에는 훌륭한 선택입니다.

여기서 주목해야 할 점은 크게 두 가지입니다:

  1. 데이터베이스 구조를 Ruby로 표현한 것입니다. 즉, schema.rb는 실제 데이터베이스를 검사하여 그 구조를 Ruby 문법으로 표현한 결과물입니다.
  2. 데이터베이스에 종속되지 않습니다. SQLite, PostgreSQL, MySQL 등 Rails가 지원하는 어떤 데이터베이스를 사용하더라도 문법과 구조가 거의 동일하게 유지됩니다.

하지만 애플리케이션이 성장하면서 이 방식이 한계에 부딪히는 순간이 올 수 있습니다.

예를 들어 마이그레이션 파일이 수백, 수천 개에 달한다고 상상해 보세요.

새로운 운영 환경을 빠르게 구축해야 할 때, 모든 마이그레이션을 순서대로 실행하는 데 너무 오랜 시간이 걸리는 상황이 발생할 수 있습니다. 또는 일부 마이그레이션이 예전 데이터베이스 버전 기준으로 작성된 코드라 현재 버전에서는 실행할 수 없는 경우도 있습니다. 과거의 데이터 상태를 가정하고 작성된 마이그레이션이 더 이상 유효하지 않아 실패하는 상황도 생길 수 있죠.

이런 모든 시나리오는 간단한 rails db:create db:migrate 명령 하나로 새로운 애플리케이션 인스턴스(운영 환경이든 새 팀원의 개발 환경이든)를 효율적으로 세팅하는 것을 막습니다. 만약 그렇다면 올바른 데이터베이스 스키마로 빠르게 시작하려면 어떻게 해야 할까요?

물론 한 가지 방법은 돌아가서 깨진 마이그레이션을 모두 수정하는 것입니다. 결코 나쁜 생각이 아닙니다!

하지만 많은 마이그레이션을 일일이 고치는 비용이 너무 크다면, rails db:setup 태스크를 실행하는 방법도 있습니다. 이 태스크는 schema.rb 파일로부터 데이터베이스 스키마를 생성합니다. 그런데 만약 데이터베이스에 schema.rb로 표현되지 않는 복잡한 로직이 담겨 있다면 어떻게 될까요?

다행히 Rails는 대안을 제공합니다. 바로 structure.sql입니다.

structure.sql은 다음과 같은 점에서 schema.rb와 다릅니다:

  • 데이터베이스 구조의 정확한 복사본을 담을 수 있습니다. 팀 단위로 작업할 때, 그리고 rails db:setup 태스크로 운영 환경에 새 데이터베이스를 빠르게 생성해야 할 때 매우 중요한 특징입니다.
  • 데이터베이스의 고급 기능 정보를 보존할 수 있습니다. 예를 들어 PostgreSQL을 사용한다면 뷰(view), 머터라이즈드 뷰(materialized view), 함수, 제약 조건(constraint) 등을 활용할 수 있게 해줍니다.

애플리케이션이 일정 수준의 성숙도에 도달하면, 효율을 높이고 데이터 정확성을 지키며 최고의 성능을 보장하기 위해 available한 모든 방법을 동원해야 합니다. structure.sql로 Rails 데이터베이스의 동작을 관리하면 그것이 가능해집니다.

schema.rb에서 structure.sql로 전환하기

schema.rb에서 structure.sql로 변경하는 것은 비교적 간단한 과정입니다. config/application.rb에 다음 줄만 추가하면 됩니다:

module YourApp
  class Application < Rails::Application
    config.load_defaults 6.0
 
    # Add this line:
    config.active_record.schema_format = :sql
  end
end

그다음 rails db:migrate를 실행하면 db/structure.sql 경로에서 파일을 확인할 수 있습니다. 참 쉽죠! Rails는 사용 중인 데이터베이스 전용 도구로 데이터베이스 구조를 덤프합니다(PostgreSQL이라면 pg_dump, MySQL이나 MariaDB라면 각 테이블별 SHOW CREATE TABLE 출력 결과 등이 담깁니다). 이 파일을 버전 관리에 포함시켜 팀원 모두가 동일한 데이터베이스 구조를 공유하도록 하는 것이 좋습니다.

파일을 처음 보면 다소 압도적일 수 있습니다. schema.rb는 겨우 25줄이었는데, structure.sql은 무려 109줄이나 됩니다! 이렇게 큰 파일이 과연 개발에 어떤 이점을 줄 수 있을까요?

데이터베이스 수준의 제약 조건 추가하기

ActiveRecord는 제가 Rails에서 가장 좋아하는 부분 중 하나입니다. 마치 자연어처럼 느껴지는 방식으로 데이터베이스를 조회할 수 있게 해주니까요. 예를 들어 특정 회사에서 이름이 'Dan'인 모든 사용자를 찾고 싶다면 ActiveRecord로 다음처럼 간단히 쿼리할 수 있습니다:

company = Company.find(name: 'Some Company')
 
# Reads just like in a natural language!
company.users.where(first_name: 'Dan')

하지만 ActiveRecord가 부족한 경우도 있습니다. 예를 들어 User 모델에 다음과 같은 유효성 검사가 있다고 해보죠:

class User < ApplicationRecord
  validate :name_cannot_start_with_d
 
  private
 
  def name_cannot_start_with_d
    if first_name.present? && first_name[0].downcase == 'd'
      errors.add(:first_name, "cannot start with the letter 'D'")
    end
  end
end

'Dan'이라는 이름으로 사용자를 생성하려고 하면 유효성 검사가 실행되면서 에러가 발생합니다:

User.create!(first_name: 'Dan')
Traceback (most recent call last):
ActiveRecord::RecordInvalid (Validation failed: First name cannot start with the letter 'D')

좋습니다. 하지만 만약 여러분이나 팀원이 ActiveRecord의 유효성 검사를 우회해서 데이터를 변경하면 어떻게 될까요?

u = User.create(first_name: 'Pan')
 
# The update_attribute method bypasses ActiveRecord validations
u.update_attribute :first_name, 'Dan'
u.first_name
=> "Dan"

보시다시피 유효성 검사를 우회하는 것은 매우 쉽습니다.

이는 애플리케이션에 치명적인 결과를 초래할 수 있습니다. ActiveRecord는 축복이자 저주와도 같습니다. 깔끔하고 직관적인 DSL 덕분에 다루는 재미가 있지만, 모델 수준의 유효성 검사 적용 면에서는 지나치게 관대할 때가 많습니다. 해결책은 이미 아시겠지만, 데이터베이스 수준의 제약 조건을 추가하는 것입니다.

rails g migration AddFirstNameConstraintToUser

생성된 파일에 'D'로 시작하는 이름을 허용하지 않는 로직을 작성합니다:

class AddFirstNameConstraintToUser < ActiveRecord::Migration[6.0]
  def up
    execute "ALTER TABLE users ADD CONSTRAINT name_cannot_start_with_d CHECK (first_name !~* '^d')"
  end
 
  def down
    execute "ALTER TABLE users DROP CONSTRAINT IF EXISTS name_cannot_start_with_d"
  end
end

마이그레이션을 되돌릴 수 있는 코드를 반드시 함께 작성하는 것이 매우 중요합니다. 위 예제에서는 updown 메서드를 사용했습니다. up은 마이그레이션이 실행될 때, down은 롤백할 때 호출됩니다. 데이터베이스 구조를 제대로 되돌리지 않으면 나중에 수동으로 뒷수습을 해야 할 수도 있습니다. 번거로움을 피하려면 항상 updown 모두 실행 가능한 마이그레이션 파일을 작성하는 것을 권장합니다.

이제 마이그레이션을 실행하고 해당 제약 조건을 우회할 수 있는지 확인해 보겠습니다:

rails db:migrate
user = User.create first_name: 'Pan'
user.update_attribute :first_name, 'Dan'
 
ActiveRecord::StatementInvalid (PG::CheckViolation: ERROR:  new row for relation "users" violates check constraint "name_cannot_start_with_d")
DETAIL:  Failing row contains (2, Dan, null, 2019-12-14 09:40:11.809358, 2019-12-14 09:40:41.658974).

완벽합니다! 제약 조건이 의도한 대로 동작하고 있습니다. 어떤 이유로든 ActiveRecord의 유효성 검사를 우회하더라도, 최후의 골키퍼인 데이터베이스가 데이터 무결성을 지켜줍니다.

그런데 이것이 structure.sql과 무슨 관계가 있을까요?

structure.sql 파일을 열어보면 다음 내용이 추가된 것을 확인할 수 있습니다:

CREATE TABLE public.users (
    id bigint NOT NULL,
    first_name character varying,
    last_name character varying,
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL,
    CONSTRAINT name_cannot_start_with_d CHECK (((first_name)::text !~* '^d'::text)));

제약 조건이 스키마 그 자체에 포함된 것입니다!

schema.rb도 데이터베이스 수준의 제약 조건을 어느 정도 지원하지만, 트리거(trigger), 시퀀스(sequence), 저장 프로시저(stored procedure), 체크 제약 조건(check constraint) 같은 데이터베이스가 제공하는 모든 기능을 표현하지는 못한다는 점을 기억해야 합니다. 실제로 동일한 마이그레이션(AddFirstNameConstraintToUser)을 실행했을 때 schema.rb만 사용한다면 스키마 파일은 다음과 같이 변합니다:

ActiveRecord::Schema.define(version: 2019_12_14_074018) do
 
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
 
  create_table "users", force: :cascade do |t|
    t.string "first_name"
    t.string "last_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end
 
end

파일이 전혀 바뀌지 않았습니다! 제약 조건이 추가되지 않은 것이죠.

이 상태로 새 개발자를 프로젝트에 온보딩하면, 팀원들 사이에 서로 다른 데이터베이스 규칙 하에서 작업하게 될 위험이 있습니다.

structure.sql을 버전 관리에 커밋하면 팀 전체가 동일한 기준을 공유하도록 보장할 수 있습니다. structure.sql 파일이 있는 상태에서 rails db:setup을 실행하면 데이터베이스 구조에 위의 제약 조건이 포함됩니다. schema.rb로는 그런 보장이 불가능합니다.

운영 시스템에서도 마찬가지입니다. 모든 마이그레이션을 순차적으로 실행하기에는 시간이 너무 오래 걸려서 새 데이터베이스와 함께 애플리케이션 인스턴스를 빠르게 구축해야 한다면, structure.sql 파일로 데이터베이스를 세팅하는 것이 훨씬 빠릅니다. 그리고 structure.sql은 다른 인스턴스와 정확히 동일한 구조로 데이터베이스를 생성해 준다는 확신을 줍니다.

성장통

팀 단위로 관리할 때는 간결한 schema.rb 파일이 장황한 structure.sql 파일보다 훨씬 다루기 쉽습니다.

structure.sql로 전환할 때 가장 큰 성장통 중 하나는, 해당 파일에 필요한 변경 사항만 커밋되도록 관리하는 일입니다. 생각보다 쉽지 않을 때가 있죠.

예를 들어 다른 사람의 브랜치를 pull 받아 그 브랜치의 마이그레이션을 실행했다고 해보죠. 이제 여러분의 structure.sql에는 해당 브랜치의 변경 사항이 담기게 됩니다. 그 후 자신의 브랜치로 돌아와 새 마이그레이션을 생성하면, structure.sql 파일에는 두 브랜치의 변경 사항이 뒤섞여 버립니다. 이런 충돌을 관리하는 데는 분명 학습 곡선이 존재하고, 손이 많이 가는 작업입니다.

이 접근 방식은 일종의 트레이드오프입니다. 데이터베이스의 고급 기능을 보존할 수 있도록 약간의 복잡성을 감수하는 대신, 더 단순한 스키마 표현을 선택하고 데이터베이스의 모든 기능을 자유롭게 쓸 수 없게 됩니다(예: db:setup 태스크로 백업을 설정하는 경우). 하지만 저는 버전 관리의 번거로움을 조금 견디는 편이, 운영 시스템의 오염되거나 잘못된 데이터를 고치는 고통이나 데이터베이스가 제공하는 고급 기능을 활용하지 못하는 아쉬움을 견디는 것보다 낫다고 생각합니다.

일반적으로 structure.sql 파일에 특정 브랜치에 필요한 변경 사만 담기도록 관리하기 위해 사용해 본 전략은 두 가지입니다:

  • 마이그레이션이 포함된 브랜치에서 작업을 마쳤다면 반드시 rails db:rollback STEP=n(n은 해당 브랜치의 마이그레이션 개수)을 실행하세요. 데이터베이스 구조가 원래 상태로 되돌아가는 것을 보장할 수 있습니다.
  • 브랜치 작업 후 롤백을 잊어버렸을 수도 있습니다. 이 경우 새 브랜치에서 작업할 때는 새 마이그레이션을 생성하기 전에 master로부터 깨끗한 structure.sql 파일을 먼저 받아오세요.

경험 법칙으로, structure.sql 파일에는 master에 병합되기 전까지 자신의 브랜치와 관련된 변경 사항만 담겨 있어야 합니다.

맺음말

일반적으로 Rails 애플리케이션이 규모가 작거나 데이터베이스의 고급 기능이 필요 없다면, 가독성이 좋고 간결하며 관리하기 쉬운 schema.rb를 사용하는 것이 안전합니다.

하지만 애플리케이션의 규모와 복잡도가 커질수록 데이터베이스 구조를 정확하게 반영하는 것이 필수적입니다. 그래야 팀이 올바른 제약 조건, 데이터베이스 모듈, 함수, 연산자를 유지할 수 있습니다. 잘 관리된 structure.sql 파일과 함께 Rails를 사용하는 법을 익히면, 단순한 schema.rb로는 결코 얻을 수 없는 강력한 이점을 손에 넣게 될 것입니다.

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