Rails 패턴
현대 Ruby on Rails 애플리케이션(Rails 7.1+ 및 8.x)을 위한 프레임워크 패턴입니다. Rails는 설계 상 의견이 강합니다. 이 패턴들은 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 패턴 규칙에 따라, 컨트롤러가 여러 책임을 지기 시작하면 서비스 객체로 추출하세요.)
서비스 객체
단일 모델 저장 이상을 다루는 비즈니스 연산의 기본입니다. 일관성을 유지하는 규칙:
- 접미사(
InvoiceCreator) 대신 도메인으로 네임스페이싱합니다(Invoices::Create). - 클래스 메서드
.call은 인스턴스#call에 위임합니다. - 불리언이나 단순 레코드 대신 Result 객체를 반환하여 호출자가 성공, 오류 및 영향을 받은 레코드에 따라 분기할 수 있게 합니다.
- 여러 레코드 쓰기는 트랜잭션으로 감쌉니다.
- 각 서비스는 단일 목적을 유지합니다(
Invoices::Create,Invoices::MarkPaid). 절대Invoices::Manager는 안 됩니다.
폼 객체
폼이 여러 모델에 걸쳐 있거나 컬럼에 매핑되지 않는 필드가 있을 때는 중첩 속성이나 잘못된 모델의 가상 속성 대신 폼 객체를 사용하세요. 폼 객체는 뷰에 모델처럼 작동하며(form_with model: @form), 레코드를 깔끔하게 구성합니다.
쿼리 객체
컨트롤러나 서비스에서 재사용되거나 스코프로 처리하기에는 너무 복잡한 ActiveRecord 쿼리의 경우, 스코프를 입력으로 받아 조합 가능한 쿼리 객체를 추출하세요. 경험상, 세 개 이상의 체인 조건을 넘거나 매개변수를 받기 시작하는 스코프는 쿼리 객체가 되어야 합니다.
백그라운드 작업
느린 작업은 모두 오프로드하세요. (Ruby 패턴 규칙에 따라, 적당한 처리량의 새로운 Rails 8은 Solid Queue; 성숙한 관찰 가능성, 높은 처리량 또는 기존 Redis가 필요할 때는 Sidekiq.) 어댑터와 관계없이: ID가 아닌 레코드 ID를 전달하고, perform을 멱등성 있게 만들며, retry_on/discard_on을 명시적으로 설정하세요.
부분 템플릿 대신 ViewComponent
조건부 렌더링, 두 개 이상의 인자, 또는 세 곳 이상에서 재사용되는 뷰 로직의 경우 ViewComponent를 선호하세요. 컴포넌트는 격리되어 테스트 가능하고 인터페이스를 명시적으로 드러냅니다. 깊은 조건부 로직이 있는 부분 템플릿은 부채가 됩니다.
Hotwire: Turbo와 Stimulus
기본 Rails 프론트엔드 스택입니다. (Ruby 패턴 규칙에 따라, 서버 렌더링 앱에는 Hotwire를 선호하세요; 상호작용 복잡성이 클라이언트 표면을 정당화할 때만 React/Vue를 사용하세요.) Turbo Frame은 부분 페이지 업데이트, Turbo Stream은 서버 주도 업데이트, Stimulus는 마크업 옆의 작은 클라이언트 측 동작에 사용합니다.
Rails 8 Solid 스택
Rails 8은 이전에 Redis가 필요했던 데이터베이스 기반 기본값을 제공합니다: Solid Queue(작업), Solid Cache(캐시), Solid Cable(ActionCable). 트레이드오프는 더 많은 데이터베이스 부하를 감수하는 대신 하나의 인프라 구성 요소를 줄이는 것입니다. 적당한 처리량에 적합하며, 높은 규모에서는 Redis가 여전히 우수합니다. Kamal은 기본 Docker 기반 배포 도구입니다.
코드 예제
서비스 객체를 사용하는 슬림 컨트롤러
# Bad: 비즈니스 로직이 컨트롤러에 있음
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: "인보이스가 생성되었습니다"
else
render :new
end
end
end
# Good: 컨트롤러는 오케스트레이션, 서비스가 작업 수행
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "인보이스가 생성되었습니다"
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("인보이스 #{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 방지
# Bad: 뷰에서 post.author.name을 호출할 때 N+1 발생
@posts = Post.published
# Good: 미리 로드
@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(*) 대신 컬럼 읽기가 됩니다. 이 예제는 새 테이블을 가정합니다. 이미 행이 있는 테이블에 카운터 캐시를 추가하려면 백필이 필요하며, 이 범위를 벗어납니다.
백그라운드 작업 형태
레코드가 아닌 레코드 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만 다룹니다. 가드는 원격 호출 전의 기간을 다루고, idempotency_key를 API에 전달하는 것은 그 이후의 기간을 다루므로, 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)
}
}
허용 가능한 콜백과 허용 불가능한 콜백
# 허용 가능: 순수 데이터 정규화
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과 나쁜 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만 이 모듈을 include합니다. 이는 교차 관심사가 아닙니다. "슬림" 모델의 모양을 위해
# 모듈에 숨겨진 Invoice 자체의 로직일 뿐입니다. Invoice로 다시 옮기세요.
하나의 클래스만 사용하는 concern은 단지 코드를 이동하는 것일 뿐입니다. 해당 클래스에 속합니다. concern은 여러 클래스에서 실제로 재사용되는 교차 관심사 로직을 위해 예약되어야 합니다.
Rails 7.1+ / 8.x 마이그레이션 노트
Rails 8.0
Rails.application.config.load_defaults 8.0은production.rb에서config.active_job.queue_adapter = :solid_queue및config.cache_store = :solid_cache_store를 활성화합니다. 새 앱은 기본적으로 Redis 없이 제공됩니다.config.active_record.raise_on_assign_to_nil은 기본적으로 true이며, nil이 할당될 때belongs_to연관 관계에서ActiveRecord::NotNullViolation을 발생시킵니다.bin/rails app:update를 실행하여 기존 앱을 업데이트하고 새 기본값을 검토하세요.
Rails 7.1
config.active_support.cache_format_version = 7.1을 사용하면 Rails 7.0 이전 버전과 호환되지 않는 더 나은 캐시 직렬화가 가능합니다.config.active_record.run_after_transaction_callbacks_in_order_defined = true는 Rails 7.0에서 도입된after_commit콜백의 중첩 트랜잭션 동작을 유지합니다.config.active_record.query_log_tags_enabled =true는ActiveRecord::Base.logger에 대한 쿼리를 SQL 주석과 함께 태그합니다.
관련 리소스
- Ruby 패턴 규칙 — 이 저장소의
rules/ruby/patterns.md에 설치됨. 여기에는 언제 각 패턴을 적용해야 하는지, 그리고rails-patterns를 보완하는 Ruby 특정 규칙이 포함되어 있습니다. - Rails 가이드 — Rails 공식 가이드.
- ViewComponent 문서 — 공식 ViewComponent 문서.
- Hotwire 핸드북 — Turbo와 Stimulus에 대한 공식 문서.