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

ActiveRecord vs Ecto 비교 (1부): 마이그레이션과 데이터 매핑

데이터는 대부분의 소프트웨어 애플리케이션에서 핵심적인 역할을 합니다. 개발자에게 데이터베이스에서 데이터를 매핑하고 조회하는 작업은 반복적으로 마주하게 되는 일인데요. 그렇기 때문에 이 과정을 제대로 이해하고, 작업을 단순화해 주는 추상화 도구를 활용할 줄 아는 것이 무엇보다 중요합니다.

이 글은 두 편으로 구성된 시리즈의 첫 번째 글로, ActiveRecord(Ruby)와 Ecto(Elixir)를 비교해 봅니다. 두 도구가 각각 어떻게 데이터베이스 스키마 마이그레이션과 매핑을 지원하는지 함께 살펴보겠습니다.

그러니까 우리는 사과와 오렌지를 비교하는 셈입니다. 말 한마디 없이도 존재감을 드러내던 원조 배트걸과, "나는 배트맨이다"라고 직접 선언하는 배트맨의 대결과 같습니다. 암묵적 방식, 즉 '컨벤션 우선(convention over configuration)'과 명시적 의도 표현 방식의 대결입니다. 라운드 1. 시작!

ActiveRecord란?

출시된 지 10년이 넘었기 때문에 ActiveRecord라는 이름은 이미 들어보셨을 가능성이 높습니다. Ruby on Rails 프로젝트에 기본으로 포함되는 유명한 ORM(Object Relational Mapper)이기 때문입니다.

ActiveRecord는 MVC에서의 M, 즉 모델(Model)에 해당합니다. 모델은 시스템에서 비즈니스 데이터와 로직을 표현하는 역할을 담당하는 계층으로, ActiveRecord는 데이터베이스에 영속적으로 저장해야 하는 비즈니스 객체의 생성과 사용을 손쉽게 만들어 줍니다. ActiveRecord는 ActiveRecord 패턴을 구현한 것이며, 이 패턴 자체가 객체 관계 매핑(ORM) 시스템에 대한 설명이라 할 수 있습니다.

Rails와 함께 사용되는 사례로 가장 잘 알려져 있지만, ActiveRecord는 독립형 도구로도 활용할 수 있어 다른 프로젝트에 임베드하는 것도 가능합니다.

Ecto란?

ActiveRecord와 비교하면 Ecto는 상당히 새로운(그리고 아직 그만큼 유명하지 않은) 도구입니다. Elixir로 작성되었으며 Phoenix 프로젝트에 기본적으로 포함되어 있습니다.

ActiveRecord와 달리 Ecto는 ORM이 아니라, Elixir 언어로 쿼리를 작성하고 데이터베이스와 상호작용할 수 있게 해주는 라이브러리입니다.

Ecto는 Elixir에서 쿼리를 작성하고 데이터베이스와 상호작용하기 위한 도메인 특화 언어(DSL)입니다.

Ecto는 설계 단계부터 독립형 도구로 만들어졌기 때문에, 어떤 프레임워크에도 종속되지 않고 다양한 Elixir 프로젝트에서 자유롭게 사용됩니다.

정말 사과와 오렌지를 비교하는 건 아닐까?

네, 맞습니다! ActiveRecord와 Ecto는 의미론적으로 서로 다른 도구지만, 데이터베이스 마이그레이션, 데이터 매핑, 쿼리, 유효성 검증과 같은 공통 기능은 두 도구 모두 지원합니다. 그리고 두 도구 모두로 동일한 결과를 얻을 수 있습니다. Ruby 배경을 가진 상태에서 Elixir로 넘어오려는 개발자들에게 이 비교가 흥미로운 참고 자료가 될 거라고 생각했습니다.

인보이스(Invoice) 시스템 예제

이 글의 나머지 부분에서는 가상의 인보이스(청구서) 시스템을 예제로 사용합니다. 슈퍼히어로들에게 정장을 파는 상점이 있다고 상상해 봅시다. 설명을 단순하게 유지하기 위해 인보이스 시스템에는 usersinvoices 두 개의 테이블만 존재한다고 가정합니다.

아래는 두 테이블의 필드와 타입 구조입니다:

users

필드타입
full_namestring
emailstring
created_at (ActiveRecord) / inserted_at (Ecto)datetime
updated_atdatetime

invoices

필드타입
user_idinteger
payment_methodstring
paid_atdatetime
created_at (ActiveRecord) / inserted_at (Ecto)datetime
updated_atdatetime

users 테이블은 full_name, email, updated_at, 그리고 사용하는 도구에 따라 달라지는 네 번째 필드, 총 네 개의 필드를 가집니다. ActiveRecord는 created_at 필드를 생성하는 반면, Ecto는 inserted_at 필드를 생성하여 레코드가 데이터베이스에 처음 삽입된 시점의 타임스탬프를 나타냅니다.

두 번째 테이블은 invoices입니다. user_id, payment_method, paid_at, updated_at, 그리고 users 테이블과 마찬가지로 사용하는 도구에 따라 created_at 또는 inserted_at, 총 다섯 개의 필드를 가집니다.

users와 invoices 테이블의 연관관계는 다음과 같습니다:

  • 한 명의 유저(user)는 여러 개의 인보이스(invoice)를 가질 수 있다 (has many)
  • 하나의 인보이스는 한 명의 유저에 속한다 (belongs to)

마이그레이션(Migrations)

마이그레이션은 개발자가 반복적인 과정을 통해 데이터베이스 스키마를 시간에 따라 손쉽게 발전시킬 수 있게 해줍니다. ActiveRecord와 Ecto 모두 SQL을 직접 다루는 대신 고수준 언어(각각 Ruby와 Elixir)를 사용해 데이터베이스 스키마를 마이그레이션할 수 있도록 지원합니다.

users와 invoices 테이블을 실제로 생성해 보면서 ActiveRecord와 Ecto에서 마이그레이션이 어떻게 동작하는지 살펴보겠습니다.

ActiveRecord: users 테이블 생성하기

마이그레이션

class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :full_name, null: false
      t.string :email, index: {unique: true}, null: false
      t.timestamps
    end
  end
end

ActiveRecord 마이그레이션은 create_table 메서드를 사용해 테이블을 생성합니다. created_atupdated_at 필드는 마이그레이션 파일에 명시적으로 정의하지 않았지만, t.timestamps를 호출하면 ActiveRecord가 두 필드를 알아서 생성해 줍니다.

생성된 테이블 구조

CreateUsers 마이그레이션을 실행하면 다음과 같은 구조의 테이블이 생성됩니다:

   Column   |            Type             | Nullable |              Default
------------+-----------------------------+----------+-----------------------------------
 id         | bigint                      | not null | nextval('users_id_seq'::regclass)
 full_name  | character varying           | not null |
 email      | character varying           | not null |
 created_at | timestamp without time zone | not null |
 updated_at | timestamp without time zone | not null |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "index_users_on_email" UNIQUE, btree (email)

이 마이그레이션은 email 필드에 대한 유니크 인덱스 생성까지 담당합니다. email 필드 정의에 index: {unique: true} 옵션이 전달되었기 때문에, 테이블 구조에 "index_users_on_email" UNIQUE, btree (email) 인덱스가 포함되어 있는 것입니다.

Ecto: users 테이블 생성하기

마이그레이션

defmodule Financex.Repo.Migrations.CreateUsers do
  use Ecto.Migration
 
  def change do
    create table(:users) do
      add :full_name, :string, null: false
      add :email, :string, null: false
      timestamps()
    end
 
    create index(:users, [:email], unique: true)
  end
end

Ecto 마이그레이션은 create()table() 함수를 조합해 users 테이블을 생성합니다. Ecto 마이그레이션 파일은 ActiveRecord 버전과 상당히 유사합니다. ActiveRecord에서는 t.timestamps가 타임스탬프 필드(created_atupdated_at)를 생성하는 반면, Ecto에서는 timestamps() 함수가 타임스탬프 필드(inserted_atupdated_at)를 생성합니다.

인덱스 생성 방식에서 두 도구 사이에는 작은 차이가 있습니다. ActiveRecord는 필드를 생성할 때 옵션으로 인덱스를 정의하는 반면, Ecto는 테이블을 생성할 때처럼 create()index() 함수의 조합을 사용해 일관된 방식으로 인덱스를 생성합니다.

생성된 테이블 구조

   Column    |            Type             | Nullable |              Default
-------------+-----------------------------+----------+-----------------------------------
 id          | bigint                      | not null | nextval('users_id_seq'::regclass)
 full_name   | character varying(255)      | not null |
 email       | character varying(255)      | not null |
 inserted_at | timestamp without time zone | not null |
 updated_at  | timestamp without time zone | not null |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_email_index" UNIQUE, btree (email)

Financex.Repo.Migrations.CreateUsers 마이그레이션을 실행해 생성된 테이블은 ActiveRecord로 생성한 테이블과 구조가 완전히 동일합니다.

ActiveRecord: invoices 테이블 생성하기

마이그레이션

class CreateInvoices < ActiveRecord::Migration[5.2]
  def change
    create_table :invoices do |t|
      t.references :user
      t.string :payment_method
      t.datetime :paid_at
      t.timestamps
    end
  end
end

이번 마이그레이션에는 앞선 예제에 없던 t.references 메서드가 포함되어 있습니다. 이 메서드는 users 테이블에 대한 참조(reference)를 생성하는 데 사용됩니다. 앞서 설명했듯이 한 유저는 여러 인보이스를 가질 수 있고, 하나의 인보이스는 한 유저에 속합니다. t.references 메서드는 invoices 테이블에 이 참조를 저장할 user_id 컬럼을 생성해 줍니다.

생성된 테이블 구조

     Column     |            Type             | Nullable |               Default
----------------+-----------------------------+----------+--------------------------------------
 id             | bigint                      | not null | nextval('invoices_id_seq'::regclass)
 user_id        | bigint                      |          |
 payment_method | character varying           |          |
 paid_at        | timestamp without time zone |          |
 created_at     | timestamp without time zone | not null |
 updated_at     | timestamp without time zone | not null |
Indexes:
    "invoices_pkey" PRIMARY KEY, btree (id)
    "index_invoices_on_user_id" btree (user_id)

생성된 테이블은 앞서 만든 테이블과 동일한 패턴을 따릅니다. 유일한 차이점은 추가 인덱스(index_invoices_on_user_id)가 존재한다는 점인데, 이는 t.references 메서드를 사용하면 ActiveRecord가 자동으로 인덱스를 추가해 주기 때문입니다.

Ecto: invoices 테이블 생성하기

마이그레이션

defmodule Financex.Repo.Migrations.CreateInvoices do
  use Ecto.Migration
 
  def change do
    create table(:invoices) do
      add :user_id, references(:users)
      add :payment_method, :string
      add :paid_at, :utc_datetime
      timestamps()
    end
 
    create index(:invoices, [:user_id])
  end
end

Ecto 역시 references() 함수를 사용해 데이터베이스 참조 생성을 지원합니다. 하지만 컬럼 이름을 자동으로 추론하는 ActiveRecord와 달리, Ecto는 개발자가 user_id 컬럼 이름을 명시적으로 정의하도록 요구합니다. 또한 references() 함수는 참조가 가리키는 테이블도 개발자가 직접 명시해야 하는데, 이 예제에서는 users 테이블입니다.

생성된 테이블 구조

     Column     |            Type             | Nullable |               Default
----------------+-----------------------------+----------+--------------------------------------
 id             | bigint                      | not null | nextval('invoices_id_seq'::regclass)
 user_id        | bigint                      |          |
 payment_method | character varying(255)      |          |
 paid_at        | timestamp without time zone |          |
 inserted_at    | timestamp without time zone | not null |
 updated_at     | timestamp without time zone | not null |

Indexes:
    "invoices_pkey" PRIMARY KEY, btree (id)
    "invoices_user_id_index" btree (user_id)
Foreign-key constraints:
    "invoices_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

두 마이그레이션 모두 상당히 유사하지만, references 기능을 처리하는 방식에서는 몇 가지 차이가 있습니다:

  1. Ecto는 user_id 필드에 외래 키(Foreign Key) 제약 조건을 생성합니다("invoices_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)). 이를 통해 users와 invoices 테이블 간의 참조 무결성(referential integrity)이 유지됩니다.

  2. ActiveRecord는 user_id 컬럼에 대한 인덱스를 자동으로 생성합니다. 반면 Ecto는 개발자가 이를 명시적으로 선언해야 하기 때문에, 마이그레이션에 create index(:invoices, [:user_id]) 문장이 존재하는 것입니다.

ActiveRecord: 데이터 매핑과 연관관계

ActiveRecord는 "컨벤션 우선(conventions over configurations)" 철학으로 유명합니다. 기본적으로 모델 클래스 이름을 기반으로 데이터베이스 테이블 이름을 추론합니다. 예를 들어 User라는 클래스는 기본적으로 users 테이블을 데이터 소스로 사용합니다. 또한 테이블의 모든 컬럼을 인스턴스 속성으로 자동 매핑해 주며, 개발자가 정의해야 하는 것은 테이블 간의 연관관계뿐입니다. 이 연관관계 정보 역시 ActiveRecord가 관련 클래스와 테이블을 추론하는 데 활용됩니다.

ActiveRecord로 users와 invoices 테이블을 매핑하는 코드를 살펴보겠습니다:

users

class User < ApplicationRecord
  has_many :invoices
end

invoices

class Invoice < ApplicationRecord
  belongs_to :user
end

Ecto: 데이터 매핑과 연관관계

반면 Ecto는 데이터 소스와 그 필드들을 개발자가 명시적으로 선언하도록 요구합니다. Ecto에도 ActiveRecord와 유사한 has_manybelongs_to 기능이 있지만, 여기에 더해 연관된 테이블과 해당 테이블 스키마를 처리할 스키마 모듈까지 개발자가 명확히 지정해야 합니다.

Ecto가 users와 invoices 테이블을 매핑하는 방식은 다음과 같습니다:

users

defmodule Financex.Accounts.User do
  use Ecto.Schema
 
  schema "users" do
    field :full_name, :string
    field :email, :string
    has_many :invoices, Financex.Accounts.Invoice
    timestamps()
  end
end

invoices

defmodule Financex.Accounts.Invoice do
  use Ecto.Schema
 
  schema "invoices" do
    field :payment_method, :string
    field :paid_at, :utc_datetime
    belongs_to :user, Financex.Accounts.User
    timestamps()
  end
end

마무리

이번 글에서는 망설임 없이 사과와 오렌지를 비교해 보았습니다. ActiveRecord와 Ecto가 데이터베이스 마이그레이션과 매핑을 어떻게 처리하는지 비교했습니다. 암묵적으로 존재감을 드러내던 원조 배트걸과, "나는 배트맨이다"라고 명시적으로 선언하는 배트맨의 대결이었습니다.

"컨벤션 우선" 덕분에 ActiveRecord를 사용하면 일반적으로 작성해야 하는 코드가 적습니다. Ecto는 정반대 방향으로 나아가 개발자에게 자신의 의도를 더 명시적으로 표현할 것을 요구합니다. 물론 "더 적은 코드"가 좋은 것이긴 하지만, ActiveRecord에는 개발자가 모든 결정을 내리고 내부 설정을 전부 이해해야 하는 부담을 덜어주는 최적의 기본값들이 마련되어 있습니다. 초보자 입장에서는 ActiveRecord가 더 적합한 선택입니다. 표준 규칙만 충실히 따르면 "충분히 괜찮은" 결정을 기본값으로 자동 수행해 주기 때문입니다.

Ecto의 명시성은 코드의 동작을 읽고 이해하기 쉽게 만들어 주지만, 그만큼 개발자가 데이터베이스 속성과 사용 가능한 기능에 대해 더 많이 알아야 한다는 요구도 함께 합니다. 언뜻 보기에 Ecto를 번거롭게 느끼게 만드는 이 특성이야말로 Ecto의 미덕 중 하나입니다. ActiveRecord와 Ecto 세계 모두에서의 저자의 개인적인 경험에 비추어 보면, Ecto의 명시성은 ActiveRecord 프로젝트에서 흔히 발견되는 "무대 뒤"에서 일어나는 숨겨진 동작과 불확실성을 제거해 줍니다. 개발자가 코드에서 읽은 것이 곧 애플리케이션에서 일어나는 일이며, 암묵적인 동작은 존재하지 않습니다.

몇 주 후에 발행될 "ActiveRecord vs Ecto" 2부 시리즈에서는 ActiveRecord와 Ecto에서 쿼리와 유효성 검증이 어떻게 동작하는지 다룰 예정입니다.

이 글에 대한 여러분의 생각을 알려주세요. 저희는 항상 다룰 새로운 주제를 찾고 있으니, 더 알아보고 싶은 주제가 있다면 @AppSignal로 언제든지 알려 주세요!