Rails Service Objects 2026 완벽 가이드: 디자인 패턴, PORO와 기술 면접 대비
Rails Service Objects를 활용한 PORO 패턴, Result 모나드, 클린 아키텍처를 상세히 설명합니다. Rails 8 호환 실무 코드 예제와 기술 면접 빈출 질문을 포함합니다.

Rails Service Objects는 비즈니스 로직을 컨트롤러와 모델에서 Plain Old Ruby Objects(PORO)로 분리하는 설계 패턴입니다. 이 패턴을 적용하면 애플리케이션 규모가 확대되어도 유지보수성을 확보할 수 있습니다. 기술 면접에서도 클린 아키텍처에 대한 이해도를 평가하는 지표로 자주 출제됩니다.
Service Object는 단일 비즈니스 연산을 캡슐화하는 클래스입니다. 일반적으로 call이라는 이름의 공개 메서드 하나만 가지며, 의존성은 생성자를 통해 주입하고, 예측 가능한 결과를 반환합니다. 구현 방법뿐만 아니라 이 설계가 "왜" 중요한지 설명할 수 있는 지원자가 높은 평가를 받습니다.
Fat Model과 Fat Controller 문제를 해결하는 이유
Rails는 로직을 어딘가에 배치하도록 유도하는 프레임워크입니다. 명확한 지침이 없으면 팀은 "Fat Model, Skinny Controller" 원칙에 따라 모델에 로직을 집어넣어 ActiveRecord 클래스가 수천 줄로 비대해집니다. 반대로 컨트롤러에 남겨두면 액션을 단독으로 테스트하는 것이 불가능해집니다.
Service Objects는 세 번째 선택지를 제공합니다. 도메인 로직은 명시적으로 필요한 것 외에는 Rails에 의존하지 않는 전용 클래스에 배치됩니다. 이러한 느슨한 결합으로 전체 프레임워크를 로드하지 않고 테스트할 수 있고, 여러 진입점(컨트롤러, 백그라운드 작업, 콘솔) 간에 재사용할 수 있으며, 각 클래스가 단일 책임을 가지므로 가독성이 향상됩니다.
Rails Guides에서는 Service Objects를 명시적으로 규정하지 않지만, 커뮤니티에서는 이 패턴을 표준으로 채택하고 있습니다. Solid Queue와 Solid Cache로 외부 의존성을 대체하는 Rails 8의 단순함 강조는 PORO를 더욱 매력적으로 만듭니다. Gem이 줄어들고 순수 Ruby가 늘어납니다.
프로덕션 환경 Service Object의 구성 요소
Service Object에는 세 가지 요소가 필요합니다. 의존성을 받는 생성자, 단일 공개 call 메서드, 성공 또는 실패를 나타내는 반환 타입입니다.
# app/services/users/create_account.rb
module Users
class CreateAccount
def initialize(user_repo: User, mailer: UserMailer)
@user_repo = user_repo
@mailer = mailer
end
def call(params)
user = @user_repo.new(params)
return Result.failure(:validation_failed, user.errors) unless user.valid?
user.save!
@mailer.welcome_email(user).deliver_later
Result.success(user)
rescue ActiveRecord::RecordNotUnique
Result.failure(:email_taken, "Email already registered")
end
end
end생성자는 user_repo와 mailer를 기본값과 함께 받습니다. 프로덕션 코드는 기본값을 사용하고, 테스트에서는 목(mock)을 주입합니다. call 메서드는 유효성 검사, 영속화, 이메일 전송을 수행하고 모든 결과를 Result 객체로 래핑합니다.
Gem 없이 최소한의 Result 클래스 구축하기
일부 팀은 즉시 dry-monads를 도입합니다. 이 Gem은 훌륭하지만 학습 비용이 발생합니다. 30줄 정도의 Result 클래스로 대부분의 요구 사항을 충족할 수 있습니다.
# app/services/result.rb
class Result
attr_reader :value, :error, :code
def initialize(success:, value: nil, error: nil, code: nil)
@success = success
@value = value
@error = error
@code = code
end
def success? = @success
def failure? = !@success
def self.success(value) = new(success: true, value: value)
def self.failure(code, error = nil) = new(success: false, code: code, error: error)
def on_success
yield(value) if success?
self
end
def on_failure
yield(code, error) if failure?
self
end
end이 Result는 on_success와 on_failure를 통한 체이닝을 지원하고, 구조화된 에러 코드를 노출하며, 의존성이 전혀 없습니다. 컨트롤러에서 깔끔하게 사용할 수 있습니다.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
result = Users::CreateAccount.new.call(user_params)
result
.on_success { |user| redirect_to user, notice: "Account created" }
.on_failure { |code, error| render :new, status: :unprocessable_entity }
end
private
def user_params
params.require(:user).permit(:email, :password, :name)
end
enddry-rb 생태계를 이미 사용하는 팀이나 여러 연산을 체이닝하기 위한 Do 표기법이 필요한 팀은 dry-monads의 이점을 누릴 수 있습니다. 이 Gem은 Ruby 3.x의 패턴 매칭을 지원하는 Success, Failure, Maybe 타입을 제공합니다.
의도를 전달하는 명명 규칙
두 가지 주요 규칙이 존재합니다.
| 스타일 | 예시 | 사용 상황 |
|---|---|---|
| 동사구 | CreateAccount, SendInvoice, RefundPayment | 상태를 변경하는 명령 |
| 명사 + -or | AccountCreator, InvoiceSender, PaymentRefunder | 일부 팀이 선호하지만 덜 일반적 |
동사구 스타일은 자연스럽게 읽힙니다. Users::CreateAccount.new.call(params)처럼요. -or 접미사는 작동하지만 명확성 없이 음절만 늘어납니다.
디렉토리 구조도 중요합니다. 도메인별로 그룹화하면 관련 서비스가 함께 모입니다.
app/services/
users/
create_account.rb
reset_password.rb
update_profile.rb
orders/
place_order.rb
cancel_order.rb
calculate_totals.rb
result.rbRuby on Rails 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
테스트 가능한 서비스를 위한 의존성 주입
하드코딩된 의존성은 테스트를 어렵게 만듭니다. 이 서비스는 데이터베이스에 접근하고 실제 이메일을 보내지 않으면 테스트할 수 없습니다.
# 피해야 할 예: 하드코딩된 의존성
class CreateAccount
def call(params)
user = User.create!(params) # 직접 모델 호출
UserMailer.welcome(user).deliver # 직접 메일러 호출
end
end생성자를 통한 주입이 이 문제를 해결합니다.
# 개선된 버전: 주입 가능한 의존성
class CreateAccount
def initialize(user_repo: User, mailer: UserMailer)
@user_repo = user_repo
@mailer = mailer
end
def call(params)
user = @user_repo.create!(params)
@mailer.welcome(user).deliver_later
Result.success(user)
end
end테스트에서는 페이크를 대체할 수 있습니다.
# spec/services/users/create_account_spec.rb
RSpec.describe Users::CreateAccount do
let(:fake_repo) { class_double(User) }
let(:fake_mailer) { class_double(UserMailer) }
let(:service) { described_class.new(user_repo: fake_repo, mailer: fake_mailer) }
it "returns success with valid params" do
user = instance_double(User, valid?: true)
allow(fake_repo).to receive(:new).and_return(user)
allow(user).to receive(:save!)
allow(fake_mailer).to receive_message_chain(:welcome_email, :deliver_later)
result = service.call(email: "test@example.com", password: "secure123")
expect(result).to be_success
end
end데이터베이스 없이, 이메일 없이, 빠른 실행이 가능합니다. RSpec documentation에서 타입 검증 목을 위한 class_double과 instance_double에 대해 설명합니다.
Rails Service Objects 면접 질문
이 질문들은 중급 및 시니어 Rails 면접에서 출제됩니다. 코드 예제를 포함한 구체적인 답변을 준비해야 합니다.
Q: 비즈니스 로직은 모델과 Service Object 중 어디에 두어야 합니까?
모델은 유효성 검사, 연관관계, 스코프, 단일 레코드 동작을 담당합니다. Service Objects는 여러 모델에 걸친 연산, 외부 호출이 필요한 연산, 또는 명시적인 트랜잭션 경계가 필요한 연산을 처리합니다. User 모델은 이메일 형식을 검증하고, CreateAccount 서비스는 사용자를 생성하고, 환영 이메일을 보내고, 기본 설정을 프로비저닝합니다.
Q: 예외 없이 Service Objects의 에러를 어떻게 처리합니까?
성공/실패 상태를 가진 Result 객체를 반환합니다. 이렇게 하면 호출자 측에서 에러 처리가 명시적이 되고, 예외 기반 제어 흐름을 피하며, 다양한 실패 모드에 대해 구조화된 에러 코드를 제공합니다. 예외는 비즈니스 규칙 위반이 아닌 진정으로 예외적인 상황(데이터베이스 다운, 네트워크 장애)에 적합합니다.
Q: Service Object와 Interactor의 차이점은 무엇입니까?
Interactor(interactor 같은 Gem에서 제공)는 특정 인터페이스를 가진 Service Objects입니다. context 해시를 받아 변경하고 context.fail!로 실패를 알립니다. PORO로서의 Service Objects에는 규정된 인터페이스가 없어 팀에게 더 많은 유연성을 주지만 일관성은 떨어집니다.
Q: 외부 API를 호출하는 Service Object를 어떻게 테스트합니까?
HTTP 클라이언트를 의존성으로 주입합니다. 테스트에서는 미리 정의된 응답을 반환하는 스텁을 전달합니다. WebMock이나 VCR 같은 도구로 HTTP 상호작용을 기록하고 재생할 수 있지만, 생성자 주입으로 유닛 테스트에서 네트워크 호출을 완전히 피할 수 있습니다.
Rails 면접 대비에 대한 자세한 내용은 RSpec과 테스트 패턴을 다루는 Rails 면접 질문 가이드를 참조하세요.
복잡한 워크플로우를 위한 Railway 지향 설계
Railway 지향 프로그래밍은 워크플로우를 두 개의 병렬 트랙으로 다룹니다. 성공은 계속 전진하고, 실패는 즉시 종료합니다. 각 단계는 성공 트랙을 따라 진행하거나 실패 트랙으로 전환합니다.
# app/services/orders/place_order.rb
module Orders
class PlaceOrder
def initialize(
inventory: InventoryService.new,
payment: PaymentService.new,
fulfillment: FulfillmentService.new
)
@inventory = inventory
@payment = payment
@fulfillment = fulfillment
end
def call(cart:, payment_method:)
validate_cart(cart)
.then { |items| reserve_inventory(items) }
.then { |reservation| charge_payment(reservation, payment_method) }
.then { |charge| create_fulfillment(charge) }
end
private
def validate_cart(cart)
return Result.failure(:empty_cart, "Cart is empty") if cart.items.empty?
Result.success(cart.items)
end
def reserve_inventory(items)
@inventory.reserve(items)
end
def charge_payment(reservation, payment_method)
result = @payment.charge(reservation.total, payment_method)
return result if result.failure?
Result.success({ reservation: reservation, charge: result.value })
end
def create_fulfillment(data)
@fulfillment.create(data[:reservation], data[:charge])
end
end
endResult 클래스에는 성공 시에만 진행하는 then 메서드가 필요합니다.
# Add to app/services/result.rb
def then
return self if failure?
yield(value)
end재고 예약이 실패하면 결제가 실행되지 않습니다. 결제가 실패하면 풀필먼트가 실행되지 않습니다. 각 단계가 Result를 반환하고, 첫 번째 실패에서 체인이 단락됩니다.
Service Objects가 안티패턴이 되는 경우
Service Objects는 실제 문제를 해결하지만 불필요하게 증식할 수 있습니다. 다음 경고 신호에 주의해야 합니다.
-
한 줄짜리 서비스: 서비스가 하나의 모델 메서드만 호출한다면 가치 없이 간접 계층을 추가하는 것입니다. 로직이 단순할 때
AuthenticateUser.new.call(email, password)보다User.authenticate(email, password)가 낫습니다. -
테스트를 위해서만 존재하는 서비스: 의존성 주입은 테스트를 쉽게 만들지만, 테스트 가능성을 위해서만 모델 메서드를 래핑하는 서비스를 만드는 것은 실제 문제가 아키텍처가 아니라 테스트 설정에 있음을 시사합니다.
-
로직이 없는 빈약한 서비스: 동작을 추가하지 않고 모델에 위임만 하는 서비스는 형식적인 의례에 불과합니다. Netguru engineering blog에서 이 과잉 추출 문제를 논의합니다.
대안: PORO를 선택적으로 사용하면서, 공유 모델 동작에는 Concerns를, Money나 DateRange 같은 도메인 개념에는 Value Objects를, 단일 레코드 연산에는 모델 메서드를 사용합니다.
Rails Service Object 설계의 핵심 포인트
- 로직이 여러 모델에 걸치거나, 외부 호출이 필요하거나, 명시적인 에러 처리가 필요할 때 Service Object로 추출합니다
- 원시 값이나 예외가 아닌 Result 객체를 반환하는 단일 공개
call메서드를 사용합니다 - 프로덕션 사용을 위한 적절한 기본값과 함께 생성자를 통해 의존성을 주입합니다
CreateAccount나PlaceOrder처럼 액션을 설명하는 동사구로 서비스를 명명합니다- 평면적인 디렉토리 구조 대신 도메인별(
users/,orders/)로 서비스를 구성합니다 - dry-monads 같은 Gem을 추가하기 전에 최소한의 Result 클래스로 시작합니다
- 비즈니스 규칙 위반이 아닌 인프라 장애에 대해 예외를 예약합니다
- 모델이 자연스럽게 처리할 수 있는 한 줄 연산에 대해 서비스를 만드는 것을 피합니다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
Ruby on Rails 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 8월 20일 업데이트
태그
공유
관련 기사

Rails 8의 Solid Queue와 Solid Cache: 2026년 기술 면접 완벽 가이드
Rails 8에서 기본 탑재된 Solid Queue와 Solid Cache가 Redis를 대체하는 방식을 설명합니다. 아키텍처, 설정, 동시성 제어, 2026년 면접 핵심 질문까지 총정리합니다.

Rails 백그라운드 작업 2026: Sidekiq vs Good Job 비교 및 면접 질문
2026년 Rails에서 백그라운드 작업 처리를 상세히 분석합니다. Sidekiq, Good Job, Solid Queue의 특징을 비교하고 기술 면접에서 자주 출제되는 질문과 답변을 다룹니다.

2026년 Rails API 모드 완벽 가이드: RESTful API 설계, 직렬화 전략과 기술 면접 핵심 질문
Rails 8.1 API 모드로 프로덕션급 RESTful API를 구축하는 방법을 다룹니다. Alba와 jsonapi-serializer 비교, JWT 인증, 에러 처리, 테스트 패턴과 면접 질문까지 총정리합니다.