Rails 패턴
최신 Ruby on Rails 애플리케이션(Rails 7.1+ 및 8.x)을 위한 프레임워크 패턴입니다. Rails는 설계상 독선적(opinionated)입니다. 여기 제시된 패턴은 50개 모델을 넘어서도 유지보수 가능한 앱을 위해 커뮤니티가 합의한 것입니다. 이 스킬은 "어떻게"(how)를 다룹니다. "무엇"과 "언제"(어떤 패턴을 선택할지에 대한 결정)는 Ruby 패턴 규칙 — 이 저장소의 rules/ruby/patterns.md로, rules/ecc/ruby/patterns.md로 설치됨 — 을 참조하세요.
활성화 시점
- Rails 애플리케이션(풀스택, API 전용, 또는 하이브리드)을 구축할 때
app/또는config/를 건드리는 PR을 리뷰할 때- 모델, 컨트롤러, 서비스, 또는 잡을 생성할 때
- 컨트롤러 액션이 약 10줄을 넘어갈 때
- 모델 파일이 약 200줄을 넘어갈 때
- ActiveRecord 쿼리가 컨트롤러나 뷰에 나타나기 시작할 때
핵심 개념
디렉터리 규약
Rails 앱은 예측 가능한 구조를 따릅니다. 디렉터리는 신중하게 추가하고, 아무렇게나 추가하지 마세요.
app/
models/ ActiveRecord 모델. 데이터와 가까운 영속성 및 도메인 로직.
controllers/ HTTP 요청 처리. 얇은 오케스트레이션만.
views/ ERB 템플릿. 비즈니스 로직 없음.
components/ ViewComponent 클래스. 테스트가 필요한 뷰 로직.
services/ 서비스 객체. 다단계 비즈니스 작업.
forms/ 폼 객체. 여러 모델에 걸친 복잡한 폼 처리.
queries/ 쿼리 객체. 재사용 가능하고 조합 가능한 ActiveRecord 쿼리.
jobs/ 백그라운드 잡. Solid Queue, Sidekiq, 또는 GoodJob을 통한 비동기 작업.
mailers/ ActionMailer 클래스.
helpers/ 뷰 헬퍼. 아주 작은 표현 관련 로직만.
policies/ 인가 정책(Pundit 사용 시). 선택 사항.
channels/ WebSocket 작업을 위한 ActionCable 채널.
app/lib/, app/utils/, app/managers/는 피하세요. 어떤 것이 위 디렉터리에 맞지 않는다면, 보통 디렉터리를 새로 만들기보다 설계를 다시 생각해야 합니다. 진정으로 제네릭한 코드는 lib/에 넣습니다.
얇은 컨트롤러
컨트롤러는 요청을 받고, 적절한 객체에 위임하고, 응답을 렌더링합니다. 비즈니스 로직은 다른 곳에 있습니다. (Ruby 패턴 규칙에 따라, 컨트롤러가 여러 책임을 가지기 시작하면 서비스 객체로 추출하세요.)
서비스 객체
단일 모델 저장 이상을 다루는 비즈니스 작업의 기본입니다. 일관성을 유지하는 규칙:
- 도메인별로 네임스페이스(
Invoices::Create), 접미사(InvoiceCreator)를 사용하지 마세요. - 클래스 메서드
.call은 인스턴스#call에 위임합니다. - 불리언이나 단순 레코드가 아닌 Result 객체를 반환하여, 호출자가 성공, 오류, 영향받은 레코드에 따라 분기할 수 있게 합니다.
- 여러 레코드 쓰기는 트랜잭션으로 감쌉니다.
- 각 서비스는 단일 목적으로 유지(
Invoices::Create,Invoices::MarkPaid), 절대Invoices::Manager처럼 하지 마세요.
폼 객체
폼이 여러 모델에 걸쳐 있거나 컬럼에 매핑되지 않는 필드가 있을 때, 중첩 속성이나 잘못된 모델의 가상 속성 대신 폼 객체를 사용하세요. 뷰에 대해 모델처럼 행동하며(form_with model: @form), 레코드를 깔끔하게 구성합니다.
쿼리 객체
컨트롤러나 서비스에서 재사용되거나 스코프로는 너무 복잡한 ActiveRecord 쿼리를 위해, 입력으로 스코프를 받아 조합 가능한 쿼리 객체를 추출하세요. 경험상, 세 개 이상의 연결된 조건을 넘거나 매개변수를 받기 시작하는 스코프는 쿼리 객체가 되어야 합니다.
백그라운드 잡
느린 작업은 모두 오프로드하세요. (Ruby 패턴 규칙에 따라, 신규 Rails 8 및 적당한 처리량에는 Solid Queue, 성숙한 관찰 가능성, 높은 처리량, 또는 기존 Redis가 필요하면 Sidekiq을 사용하세요.) 어댑터와 관계없이: 레코드가 아닌 ID를 전달하고, perform을 멱등성(idempotent) 있게 만들고, retry_on/discard_on을 명시적으로 설정하세요.
부분 템플릿보다 ViewComponent
조건부 렌더링, 두 개 이상의 인자, 또는 세 곳 이상에서 재사용되는 뷰 로직은 ViewComponent를 선호하세요. 컴포넌트는 독립적으로 테스트 가능하며 인터페이스를 명시적으로 드러냅니다. 깊은 조건부 로직을 가진 부분 템플릿은 부채가 됩니다.
Hotwire: Turbo와 Stimulus
기본 Rails 프론트엔드 스택입니다. (Ruby 패턴 규칙에 따라, 서버 렌더링 앱에는 Hotwire를 선호하고, 상호작용 복잡성이 클라이언트 표면을 정당화할 때만 React/Vue를 사용하세요.) Turbo Frames는 부분 페이지 업데이트, Turbo Streams는 서버 주도 업데이트, Stimulus는 마크업 옆의 작은 클라이언트 측 동작을 담당합니다.
Rails 8 Solid 스택
Rails 8은 이전에 Redis가 필요했던 데이터베이스 기반 기본값을 제공합니다: Solid Queue(잡), Solid Cache(캐시), Solid Cable(ActionCable). 트레이드오프는 더 많은 데이터베이스 부하와 하나 적은 인프라 구성 요소입니다. 적당한 처리량에 적합하며, 높은 규모에서는 Redis가 여전히 우세합니다. Kamal은 기본 Docker 기반 배포 도구입니다.
코드 예제
서비스 객체를 사용한 얇은 컨트롤러
# 나쁨: 컨트롤러에 비즈니스 로직이 있음
class InvoicesController < ApplicationController
def create
@invoice = Invoice.new(invoice_params)
@invoice.user = current_user
@invoice.line_items.build(invoice_params[:line_items])
@invoice.tax_total = TaxCalculator.new(@invoice).calculate
@invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_total
if @invoice.save
InvoiceMailer.created(@invoice).deliver_later
AccountingExportJob.perform_later(@invoice.id)
redirect_to @invoice, notice: "Invoice created"
else
render :new
end
end
end
# 좋음: 컨트롤러는 오케스트레이션, 서비스가 작업 수행
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "Invoice created"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
서비스 객체
# app/services/invoices/create.rb
module Invoices
class Create
# Struct는 Rails 7.1이 지원하는 모든 Ruby에서 실행 가능하게 유지합니다.
# Ruby 3.2+에서는 `Data.define(:success?, :invoice, :errors)`가 더 간결한
# 불변 대안입니다.
Result = Struct.new(:success, :invoice, :errors, keyword_init: true) do
def success?
success
end
end
def self.call(params:, user:)
new(params: params, user: user).call
end
def initialize(params:, user:)
@params = params
@user = user
end
def call
invoice = build_invoice
ApplicationRecord.transaction do
invoice.save!
end
begin
send_notifications(invoice)
rescue StandardError => e
Rails.logger.error("Notification dispatch failed for invoice #{invoice.id}: #{e.message}")
end
Result.new(success: true, invoice: invoice, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, invoice: e.record, errors: e.record.errors)
end
private
attr_reader :params, :user
def build_invoice
invoice = user.invoices.new(params.except(:line_items))
invoice.line_items.build(params[:line_items])
invoice.tax_total = TaxCalculator.call(invoice)
invoice.total = invoice.line_items.sum(&:amount) + invoice.tax_total
invoice
end
def send_notifications(invoice)
InvoiceMailer.created(invoice).deliver_later
AccountingExportJob.perform_later(invoice.id)
end
end
end
폼 객체
# app/forms/signup_form.rb
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :company_name, :string
attribute :terms_accepted, :boolean
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
validates :password, presence: true, length: { minimum: 12 }
validates :company_name, presence: true
validates :terms_accepted, acceptance: true
attr_reader :user, :company
def save
return false unless valid?
ApplicationRecord.transaction do
@company = Company.create!(name: company_name)
@user = @company.users.create!(email: email, password: password, role: :owner)
end
true
rescue ActiveRecord::RecordInvalid => e
errors.merge!(e.record.errors)
false
end
end
쿼리 객체
# app/queries/invoices/overdue.rb
module Invoices
class Overdue
def self.call(scope: Invoice.all, as_of: Time.current)
new(scope: scope, as_of: as_of).call
end
def initialize(scope:, as_of:)
@scope = scope
@as_of = as_of
end
def call
scope
.where(status: :sent)
.where(due_date: ..as_of)
.where.not(id: paid_invoice_ids)
.includes(:customer, :line_items)
end
private
attr_reader :scope, :as_of
def paid_invoice_ids
Payment.where(created_at: ..as_of).pluck(:invoice_id)
end
end
end
쿼리 객체는 스코프를 받아서 조합 가능합니다: Invoices::Overdue.call(scope: current_user.invoices).
N+1 방지
# 나쁨: 뷰에서 post.author.name을 호출할 때 N+1 발생
@posts = Post.published
# 좋음: 미리 로딩 (eager load)
@posts = Post.published.includes(:author)
includes는 Rails가 preload와 eager_load 중 선택하게 합니다. 연관 관계로 필터링할 때는 별도 쿼리를 위해 preload를 강제하고, JOIN을 위해 eager_load를 사용하세요. Rails 6.1부터 strict_loading은 우발적인 지연 로딩에서 예외를 발생시킵니다.
카운터 캐시
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count는 COUNT(*) 대신 컬럼 읽기가 됩니다. 이 예제는 새 테이블을 가정합니다. 이미 행이 있는 테이블에 카운터 캐시를 추가하려면 백필(backfill)이 필요하며, 이 범위를 벗어납니다.
백그라운드 잡 형태
레코드 ID를 전달하고, 레코드를 전달하지 마세요. 재시도는 전달을 최소 한 번(at-least-once)으로 만들므로, 외부 서비스를 호출하는 모든 잡은 멱등성을 가져야 합니다. 그렇지 않으면 원격 호출 성공 후 일시적인 실패가 다음 시도에서 효과를 중복시킵니다.
class AccountingExportJob < ApplicationJob
queue_as :exports
retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
discard_on AccountingApi::PermanentError
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
export = AccountingExport.create_or_find_by!(
invoice: invoice,
idempotency_key: "invoice-export-#{invoice.id}-#{invoice.updated_at.to_i}"
)
return if export.completed_at?
receipt = AccountingApi.export(invoice, idempotency_key: export.idempotency_key)
export.update!(completed_at: Time.current, external_id: receipt.id)
end
end
add_index :accounting_exports, :idempotency_key, unique: true
고유 인덱스가 이것을 안전하게 만듭니다: 두 시도가 경쟁할 때, 데이터베이스는 두 번째 삽입을 거부하고 Active Record는 호출 내에서 충돌을 해결하여 기존 행을 반환합니다. 이는 잡 수준의 재시도 없이 발생합니다. 위의 retry_on은 AccountingApi::TransientError만 다룹니다. 가드는 원격 호출 전의 윈도우를 다루고, API에 idempotency_key를 전달하는 것은 이후의 윈도우를 다루므로, API 호출과 update! 사이의 충돌도 단일 내보내기로 해결됩니다.
ViewComponent
# app/components/invoice_status_badge_component.rb
class InvoiceStatusBadgeComponent < ViewComponent::Base
STATUS_CLASSES = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800"
}.freeze
def initialize(invoice:)
@invoice = invoice
end
def call
tag.span(@invoice.status.humanize, class: "rounded-full px-2 py-1 text-sm #{status_class}")
end
private
def status_class
STATUS_CLASSES.fetch(@invoice.status.to_sym, "bg-gray-100")
end
end
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>
Hotwire
<%# Turbo Frame: Edit 클릭 시 이 프레임만 교체됨 %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "Edit", edit_invoice_path(@invoice) %>
</div>
<% end %>
<%# Turbo Stream: app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
}
}
허용 가능한 콜백 vs 허용 불가능한 콜백
# 허용 가능: 순수 데이터 정규화
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# 서비스로 이동해야 함: 콜백에 숨겨진 부작용
# class User < ApplicationRecord
# after_create :send_welcome_email # 선택 해제하기 어렵고, 테스트하기 어려움
# end
좋은 Concern vs 나쁜 Concern
# 좋음: 진정한 횡단 관심사, 관련 없는 모델에서 재사용 가능
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
def soft_delete! = update!(deleted_at: Time.current)
def restore! = update!(deleted_at: nil)
end
# 나쁨: 정확히 하나의 모델에서 사용되는 "concern", 그 모델에 속해야 할 로직을 보유
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# Invoice만이 이를 포함합니다. 횡단 관심사가 아닙니다. Invoice 자체의 로직이
# "얇은" 모델인 것처럼 보이기 위해 모듈에 숨겨져 있습니다. Invoice로 다시 옮기세요.
하나의 클래스에서만 사용되는 concern은 단지 코드를 이동하는 것에 불과합니다. 그 클래스에 속해야 합니다. Concern은 관련 없는 클래스들에서 재사용되거나 논란의 여지가 있는 다중 상속을 해결하는 횡단 관심사일 때만 사용하세요.