본문으로 건너뛰기
김신건의 로그

[Rails] Form Object, Service Object, Query Object

· 수정 · 📖 약 1분 · 425자/단어 #rails #design-pattern #refactoring #service
rails form object, service object, query object, rails fat model, POROs

정의

Fat Model / Skinny Controller가 한계에 달하면 **POROs (Plain Old Ruby Objects)**로 책임 분리. Form Object (다중 모델 입력), Service Object (비즈니스 로직), Query Object (복잡한 쿼리), Decorator (표현 로직) 등.

Form Object

여러 모델을 한 폼에서 처리 (signup with profile, order with items).

# app/forms/signup_form.rb
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :name, :string
  attribute :password, :string
  attribute :company_name, :string
  attribute :company_size, :integer

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name, presence: true
  validates :password, length: { minimum: 8 }
  validates :company_name, presence: true

  attr_reader :user, :company

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      @company = Company.create!(name: company_name, size: company_size)
      @user = User.create!(email: email, name: name, password: password, company: @company)
      true
    end
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end
end
# Controller
class SignupController < ApplicationController
  def new
    @form = SignupForm.new
  end

  def create
    @form = SignupForm.new(signup_params)
    if @form.save
      session[:user_id] = @form.user.id
      redirect_to root_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def signup_params
    params.require(:signup_form).permit(:email, :name, :password, :company_name, :company_size)
  end
end
<%# View %>
<%= form_with model: @form, url: signup_path do |f| %>
  <%= f.text_field :email %>
  <%= f.text_field :name %>
  <%= f.password_field :password %>
  <%= f.text_field :company_name %>
  <%= f.number_field :company_size %>
  <%= f.submit "가입" %>
<% end %>

ActiveModel::Model로 form_with 호환 + validation.

Service Object

복잡한 비즈니스 로직 캡슐화.

# app/services/orders/create_order.rb
module Orders
  class CreateOrder
    Result = Struct.new(:success?, :order, :errors, keyword_init: true)

    def initialize(user:, items:, address:)
      @user = user
      @items = items
      @address = address
    end

    def call
      validate!
      order = create_order
      send_confirmation(order)
      Result.new(success?: true, order: order, errors: [])
    rescue ValidationError => e
      Result.new(success?: false, errors: e.errors)
    end

    private

    attr_reader :user, :items, :address

    def validate!
      raise ValidationError, ["No items"] if items.empty?
      ...
    end

    def create_order
      Order.transaction do
        order = Order.create!(user: user, address: address)
        items.each do |item|
          order.line_items.create!(product: item.product, quantity: item.quantity)
        end
        order.calculate_total!
        order
      end
    end

    def send_confirmation(order)
      OrderConfirmationJob.perform_later(order.id)
    end

    class ValidationError < StandardError
      attr_reader :errors
      def initialize(errors)
        @errors = errors
        super(errors.join(", "))
      end
    end
  end
end
# Controller
def create
  result = Orders::CreateOrder.new(
    user: current_user,
    items: cart_items,
    address: shipping_address,
  ).call

  if result.success?
    redirect_to result.order
  else
    flash[:error] = result.errors.join(", ")
    render :new, status: :unprocessable_entity
  end
end

표준 인터페이스

class ApplicationService
  def self.call(...)
    new(...).call
  end
end

class CreateOrder < ApplicationService
  def initialize(user:, items:)
    @user = user
    @items = items
  end

  def call
    ...
  end
end

# 사용
Orders::CreateOrder.call(user: current_user, items: items)

Query Object

복잡한 ActiveRecord 쿼리 분리.

# app/queries/posts/featured_posts_query.rb
module Posts
  class FeaturedPostsQuery
    def initialize(relation = Post.all)
      @relation = relation
    end

    def call
      @relation
        .joins(:author, :tags)
        .where(is_published: true)
        .where("views > ?", 1000)
        .where(authors: { active: true })
        .where(tags: { name: ["featured", "trending"] })
        .group(:id)
        .having("COUNT(tags.id) >= ?", 2)
        .order(views: :desc)
        .limit(10)
    end
  end
end

# 사용
posts = Posts::FeaturedPostsQuery.new.call
posts = Posts::FeaturedPostsQuery.new(current_user.posts).call    # scope 가능

Model에서 분리해 단일 책임 + 테스트 용이.

Decorator (Presenter)

표현 로직 (formatting, display)을 모델에서 분리.

draper gem

gem "draper"
# app/decorators/post_decorator.rb
class PostDecorator < Draper::Decorator
  delegate_all

  def published_at
    object.published_at&.strftime("%Y년 %-m월 %-d일") || "발행 안 됨"
  end

  def excerpt
    h.truncate(h.strip_tags(object.body), length: 200)
  end

  def status_badge
    h.content_tag(:span, status_label, class: "badge bg-#{status_color}")
  end

  private

  def status_label
    { draft: "초안", published: "발행" }[object.status.to_sym]
  end

  def status_color
    object.published? ? "green" : "gray"
  end
end
# Controller
@post = Post.find(params[:id]).decorate
@posts = Post.all.decorate
<%= @post.published_at %>    <%# "2026년 6월 20일" %>
<%= @post.excerpt %>
<%= @post.status_badge %>

h = helpers. 데코레이터에서 view 헬퍼 사용.

POROs

draper 없이 단순 PORO:

class PostPresenter
  def initialize(post, view_context)
    @post = post
    @h = view_context
  end

  def title
    @h.content_tag(:h1, @post.title)
  end

  def published_at
    @post.published_at&.strftime("%Y-%m-%d")
  end
end

# Controller
@post = PostPresenter.new(Post.find(params[:id]), view_context)

Policy Object (Pundit)

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? || record.author == user
  end

  def destroy?
    user.admin?
  end

  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(author: user)
      end
    end
  end
end

자주 보는 패턴

모델은 단순화

class Post < ApplicationRecord
  belongs_to :author
  has_many :comments

  validates :title, presence: true

  scope :published, -> { where(is_published: true) }
  # 복잡한 쿼리는 Query Object로
  # 복잡한 비즈니스 로직은 Service로
  # 표현은 Decorator로
end

Controller도 단순화

def create
  result = Posts::CreatePost.call(user: current_user, params: post_params)
  if result.success?
    redirect_to result.post.decorate
  else
    @form = result.form
    render :new
  end
end

테스트

# Form Object
describe SignupForm do
  it "creates user and company" do
    form = SignupForm.new(email: "a@x.com", name: "Alice", password: "secret123", company_name: "Acme")
    expect(form.save).to be true
    expect(User.last.email).to eq("a@x.com")
    expect(Company.last.name).to eq("Acme")
  end
end

# Service
describe Orders::CreateOrder do
  it "creates order with items" do
    result = described_class.new(user: user, items: items, address: address).call
    expect(result).to be_success
    expect(result.order.line_items.count).to eq(items.size)
  end
end

# Query
describe Posts::FeaturedPostsQuery do
  it "returns top featured posts" do
    create_list(:post, 5, views: 2000, is_published: true)
    posts = described_class.new.call
    expect(posts.size).to eq(5)
  end
end

각 PORO를 독립 단위로 테스트. 빠르고 명확.

DHH의 반대 의견

DHH는 “Service Object 패턴”에 반대. Concern과 모델 자체로 충분하다는 입장. 트레이드오프:

  • 모델에 모두: 응집, 단순. 그러나 거대화.
  • Service Object: 분리, 테스트 쉬움. 그러나 클래스 폭발.

소규모 프로젝트는 모델로 충분. 복잡해지면 분리 검토.

함정

1. 너무 많은 service object

app/services/
  create_user.rb
  update_user.rb
  delete_user.rb
  send_user_email.rb
  ...

CRUD 단순 작업까지 service면 boilerplate. 진짜 복잡한 것만.

2. service에 너무 많은 의존성

class CreateOrder
  def initialize(user:, items:, payment:, shipping:, tax:, discount:, ...)
    ...
  end
end

너무 많으면 책임이 큼. 더 작은 단위로 분리.

3. Form object와 모델 검증 중복

같은 규칙을 둘 다 정의 → 동기화 비용. Form은 폼 특화, 모델은 데이터 무결성.

4. Query object 남용

class PostByIdQuery
  def call(id)
    Post.find(id)
  end
end

단순 lookup은 그냥 Post.find(id).

5. Decorator의 view 의존

Decorator가 view helper에 강결합 → 단위 테스트 어려움. helper 의존을 최소화.

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기