Ruby on Rails 패턴과 안티패턴 시리즈의 네 번째 글로 돌아왔습니다.
지난 글에서는 일반적인 패턴과 안티패턴을 소개하고, Rails의 모델(Model)과 뷰(View)에 적용되는 사례를 살펴봤습니다. 이번 글에서는 MVC(모델-뷰-컨트롤러) 디자인 패턴의 마지막 조각인 컨트롤러(Controller)를 집중적으로 분석해 보겠습니다. Rails 컨트롤러와 관련된 패턴과 안티패턴을 함께 짚어보시죠.
요청 처리의 최전선, 컨트롤러
Ruby on Rails는 웹 프레임워크이므로 HTTP 요청은 프레임워크의 심장과도 같습니다. 다양한 클라이언트가 요청을 통해 Rails 백엔드에 접근하며, 바로 이 지점에서 컨트롤러가 진가를 드러냅니다. 컨트롤러는 요청을 받아 처리하는 최전선에 서 있기 때문에 Rails 프레임워크의 근간을 이루는 요소입니다. 물론 컨트롤러보다 먼저 실행되는 코드도 있지만, 대부분의 개발자가 직접 통제할 수 있는 영역은 바로 컨트롤러 코드입니다.
config/routes.rb에서 라우트를 정의하고 나면, 설정된 경로로 서버에 요청을 보낼 수 있고 해당 컨트롤러가 나머지 작업을 알아서 처리합니다. 이 문장만 읽으면 모든 게 아주 단순해 보일 수 있습니다. 하지만 현실에서는 상당한 무게가 컨트롤러의 어깨에 얹히곤 합니다. 인증(Authentication)과 권한 부여(Authorization) 문제부터, 필요한 데이터를 어떻게 가져올지, 비즈니스 로직을 어디서 어떻게 수행할지까지 고민거리가 한두 가지가 아닙니다.
컨트롤러 안에서 벌어질 수 있는 이 모든 책임들은 여러 안티패턴으로 이어질 수 있습니다. 그중 가장 '악명 높은' 사례 중 하나가 바로 '비대한(Fat)' 컨트롤러입니다.
비대한(Fat) 컨트롤러란?
컨트롤러에 지나치게 많은 로직을 담으면 단일 책임 원칙(SRP, Single Responsibility Principle)을 위반하게 됩니다. 즉, 컨트롤러가 감당해야 할 일보다 훨씬 많은 작업을 떠안게 되는 것이죠. 결국 수많은 코드와 책임이 컨트롤러에 계속 쌓이게 됩니다. 여기서 '비대하다'는 표현은 컨트롤러 파일에 담긴 방대한 코드량과 그 안에서 처리되는 복잡한 로직을 모두 포괄합니다.
컨트롤러의 역할에 대한 의견은 다양하지만, 일반적으로 컨트롤러가 가져야 할 책임은 다음 세 가지로 정리됩니다.
- 인증 및 권한 부여 — 요청을 보낸 주체(주로 사용자)가 자신이 주장하는 신원이 맞는지, 해당 리소스에 접근하거나 작업을 수행할 권한이 있는지 확인합니다. 인증 정보는 보통 세션이나 쿠키에 저장되지만, 컨트롤러는 여전히 그 유효성을 검사해야 합니다.
- 데이터 조회 — 요청 파라미터를 기반으로 필요한 데이터를 찾는 로직을 호출합니다. 이상적으로는 단 하나의 메서드 호출로 모든 작업이 끝나야 하며, 컨트롤러는 무거운 작업을 직접 수행하지 않고 위임해야 합니다.
- 템플릿 렌더링 — 마지막으로, 결과를 적절한 형식(HTML, JSON 등)으로 렌더링해 올바른 응답을 반환하거나, 다른 경로 또는 URL로 리다이렉트합니다.
이 원칙들을 지키면 컨트롤러 액션과 컨트롤러 전체에 과도한 작업이 몰리는 상황을 예방할 수 있습니다. 컨트롤러 수준에서 단순함을 유지하면 작업을 애플리케이션의 다른 영역으로 위임하기 쉬워지고, 분리된 각 책임을 개별적으로 테스트하면서 견고한 앱을 만들 수 있습니다.
원칙은 알겠는데 구체적인 예시가 궁금하시죠? 컨트롤러의 부담을 덜어 줄 패턴들을 하나씩 살펴보겠습니다.
쿼리 객체(Query Object)
컨트롤러 액션에서 흔히 발생하는 문제 중 하나는 데이터 조회 쿼리가 과도하게 많다는 점입니다. 앞서 Rails 모델 편에서도 비슷한 문제, 즉 모델에 조회 로직이 몰리는 사례를 다룬 적이 있습니다. 이번에는 Query Object라는 패턴을 활용해 보겠습니다. Query Object는 복잡한 쿼리를 하나의 객체로 격리하는 기법입니다.
대개 Query Object는 ActiveRecord 관계(relation)로 초기화되는 PORO(Plain Old Ruby Object)입니다. 전형적인 Query Object는 다음과 같은 모습입니다.
# app/queries/all_songs_query.rb
class AllSongsQuery
def initialize(songs = Song.all)
@songs = songs
end
def call(params, songs = Song.all)
songs.where(published: true)
.where(artist_id: params[:artist_id])
.order(:title)
end
end컨트롤러에서는 다음처럼 사용합니다.
class SongsController < ApplicationController
def index
@songs = AllSongsQuery.new.call(all_songs_params)
end
private
def all_songs_params
params.slice(:artist_id)
end
end조금 더 유연한 방식도 시도해 볼 수 있습니다.
# app/queries/all_songs_query.rb
class AllSongsQuery
attr_reader :songs
def initialize(songs = Song.all)
@songs = songs
end
def call(params = {})
scope = published(songs)
scope = by_artist_id(scope, params[:artist_id])
scope = order_by_title(scope)
end
private
def published(scope)
scope.where(published: true)
end
def by_artist_id(scope, artist_id)
artist_id ? scope.where(artist_id: artist_id) : scope
end
def order_by_title(scope)
scope.order(:title)
end
end후자의 방식은 params를 선택적으로 받을 수 있게 만들어 쿼리 객체를 더 견고하게 만듭니다. 덕분에 AllSongsQuery.new.call처럼 호출할 수도 있습니다. 이 스타일이 마음에 들지 않는다면 클래스 메서드를 사용하는 방법도 있습니다. 클래스 메서드로 작성하면 엄밀히 말해 '객체'는 아니지만, 이건 취향의 문제입니다. 참고로 실무에서 더 간편하게 호출하는 방법도 소개하겠습니다.
# app/queries/all_songs_query.rb
class AllSongsQuery
class << self
def call(params = {}, songs = Song.all)
scope = published(songs)
scope = by_artist_id(scope, params[:artist_id])
scope = order_by_title(scope)
end
private
def published(scope)
scope.where(published: true)
end
def by_artist_id(scope, artist_id)
artist_id ? scope.where(artist_id: artist_id) : scope
end
def order_by_title(scope)
scope.order(:title)
end
end
end이제 AllSongsQuery.call 한 줄로 끝납니다. artist_id가 담긴 params를 전달할 수도 있고, 필요하다면 초기 스코프를 교체해서 넘길 수도 있습니다. 쿼리 클래스에 매번 new를 호출하고 싶지 않다면 다음 '트릭'을 활용해 보세요.
# app/queries/application_query.rb
class ApplicationQuery
def self.call(*params)
new(*params).call
end
endApplicationQuery를 만들어 두고, 다른 쿼리 클래스에서 상속받으면 됩니다.
# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
...
endAllSongsQuery.call이라는 호출 방식은 그대로 유지하면서 훨씬 우아한 구조가 되었습니다.
Query Object의 가장 큰 장점은 독립적으로 테스트할 수 있다는 점입니다. 쿼리 클래스를 확장하고 컨트롤러 로직을 걱정하지 않고 테스트할 수 있습니다. 다만 한 가지 주의할 점은, 요청 파라미터의 처리는 다른 곳에서 수행해야 하며 쿼리 객체에 맡겨서는 안 된다는 것입니다. 어떠신가요, Query Object를 도입해 볼 생각이 드시나요?
서비스 객체(Service): 일을 나눠 맡기기
데이터 수집과 조회를 Query Object로 위임하는 방법을 살펴봤습니다. 그렇다면 데이터를 가져온 시점과 렌더링하는 시점 사이에 쌓이는 로직은 어떻게 처리해야 할까요? 좋은 질문입니다. 그 해결책 중 하나가 바로 서비스(Service)입니다. 서비스는 보통 하나의 비즈니스 액션만을 수행하는 PORO(Plain Old Ruby Object)로 정의됩니다. 아래에서 이 개념을 조금 더 깊이 탐구해 보겠습니다.
예를 들어 두 개의 서비스가 있다고 가정해 봅시다. 하나는 영수증을 생성하고, 다른 하나는 영수증을 사용자에게 전송합니다.
# app/services/create_receipt_service.rb
class CreateReceiptService
def self.call(total, user_id)
Receipt.create!(total: total, user_id: user_id)
end
end
# app/services/send_receipt_service.rb
class SendReceiptService
def self.call(receipt)
UserMailer.send_receipt(receipt).deliver_later
end
end그리고 컨트롤러에서는 SendReceiptService를 다음과 같이 호출합니다.
# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
def create
receipt = CreateReceiptService.call(total: receipt_params[:total],
user_id: receipt_params[:user_id])
SendReceiptService.call(receipt)
end
end이제 두 서비스가 실질적인 작업을 모두 수행하고, 컨트롤러는 단순히 호출만 합니다. 각 서비스를 개별적으로 테스트할 수 있다는 점도 좋습니다. 하지만 문제가 있습니다. 서비스 간에 명확한 연결고리가 없다는 것이죠. 이론상 각 서비스는 하나의 비즈니스 액션을 수행합니다. 하지만 이해관계자(stakeholder)의 관점에서 추상화 수준을 생각해 보면, '영수증 생성'이라는 행위에는 이메일 발송까지 포함되어 있다고 여겨질 수 있습니다. 그렇다면 누구의 추상화 수준이 '옳은' 걸까요?
이 사고 실험을 조금 더 복잡하게 만들어 봅시다. 영수증 생성 시점에 총합(total)을 어딘가에서 계산하거나 가져와야 한다는 새로운 요구사항이 추가되었다면 어떻게 해야 할까요? 총합 계산을 위한 또 다른 서비스를 작성해야 할까요? 해답은 단일 책임 원칙(SRP)을 따르며 각 요소를 서로 분리된 채로 추상화하는 것입니다.
# app/services/create_receipt_service.rb
class CreateReceiptService
...
end
# app/services/send_receipt_service.rb
class SendReceiptService
...
end
# app/services/calculate_receipt_total_service.rb
class CalculateReceiptTotalService
...
end
# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
def create
total = CalculateReceiptTotalService.call(user_id: receipts_controller[:user_id])
receipt = CreateReceiptService.call(total: total,
user_id: receipt_params[:user_id])
SendReceiptService.call(receipt)
end
endSRP를 따르면 서비스들이 ReceiptCreation 프로세스 같은 더 큰 추상화로 조합(compose)될 수 있습니다. 이런 '프로세스' 클래스를 만들면 프로세스 완료에 필요한 모든 액션을 한데 묶을 수 있습니다. 처음에는 과도한 추상화처럼 들릴 수 있지만, 동일한 액션들을 여기저기서 반복 호출한다면 충분히 가치 있는 접근입니다. 이 방식이 마음에 든다면 Trailblazer의 Operation도 확인해 보시길 추천합니다.
정리하자면, 새로 추가된 CalculateReceiptTotalService는 숫자 계산을 전담하고, CreateReceiptService는 영수증을 데이터베이스에 기록하며, SendReceiptService는 사용자에게 영수증 관련 이메일을 발송합니다. 이처럼 작고 목적이 분명한 클래스들을 두면 다른 사용 사례에서도 손쉽게 조합할 수 있어, 유지보수와 테스트가 쉬운 코드베이스를 만들 수 있습니다.
서비스 패턴의 배경 이야기
Ruby 생태계에서는 서비스 클래스 방식을 actions, operations 등 다양한 이름으로 부릅니다. 이 모든 것의 본질은 커맨드(Command) 패턴입니다. 커맨드 패턴의 핵심은 객체(여기서는 클래스)가 비즈니스 액션을 수행하거나 이벤트를 트리거하는 데 필요한 모든 정보를 캡슐화한다는 것입니다. 커맨드 호출자가 알아야 할 정보는 다음과 같습니다.
- 커맨드의 이름
- 커맨드 객체/클래스에서 호출할 메서드 이름
- 메서드 파라미터에 전달할 값들
우리의 경우 커맨드 호출자는 컨트롤러입니다. 접근 방식은 사실상 동일하고, Ruby에서는 이름을 'Service'라고 부를 뿐입니다.
무거운 작업은 분리하자
컨트롤러가 외부 서드파티 API를 호출하면서 렌더링까지 블로킹되고 있다면, 이 호출을 분리해 별도의 컨트롤러 액션으로 따로 렌더링할 때가 되었을지도 모릅니다. 예를 들어 도서 정보를 렌더링하면서 평점을 Goodreads처럼 우리가 통제할 수 없는 외부 서비스에서 가져오는 상황을 생각해 볼 수 있습니다.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def show
@book = Book.find(params[:id])
@rating = GoodreadsRatingService.new(book).call
end
endGoodreads가 다운되었다면 사용자는 해당 서버로의 요청이 타임아웃될 때까지 기다려야 합니다. 상대방 서버가 느려지면 페이지 역시 느리게 로딩되겠죠. 이럴 때 서드파티 호출을 별도의 액션으로 추출할 수 있습니다.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
...
def show
@book = Book.find(params[:id])
end
def rating
@rating = GoodreadsRatingService.new(@book).call
render partial: 'book_rating'
end
...
end이제 뷰에서 rating 경로를 호출하면 되고, show 액션에는 더 이상 블로커가 없습니다. 물론 book_rating 파셜(partial)이 필요합니다. 이 작업을 더 쉽게 하려면 render_async 젬(gem)을 활용하세요. 페이지에서 평점을 렌더링할 위치에 다음 구문만 넣으면 됩니다.
<%= render_async book_rating_path %>평점 렌더링용 HTML을 book_rating 파셜로 추출하고, 레이아웃 파일에는 다음을 추가합니다.
<%= content_for :render_async %>이렇게 하면 젬이 페이지 로드 시점에 AJAX 요청으로 book_rating_path를 호출하고, 평점을 받아오면 화면에 표시해 줍니다. 이 방식의 가장 큰 이점은 평점을 별도로 로딩하기 때문에 사용자가 도서 페이지를 훨씬 빠르게 볼 수 있다는 점입니다.
혹은 Basecamp의 Turbo Frames를 사용할 수도 있습니다. 개념은 동일하며, 마크업에 <turbo-frame> 요소를 넣기만 하면 됩니다.
<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>어떤 옵션을 선택하든 핵심 아이디어는 같습니다. 무겁거나 불안정한 작업을 메인 컨트롤러 액션에서 분리하고, 사용자에게 페이지를 최대한 빨리 보여주는 것입니다.
마치며
컨트롤러를 얇게(thin) 유지하고 단순히 다른 메서드들의 '호출자'로 바라보는 아이디어가 마음에 드신다면, 이 글이 그 목표를 달성하는 데 도움이 되었기를 바랍니다. 물론 여기서 소개한 패턴과 안티패턴이 전부는 아닙니다. 더 좋은 방법이나 선호하는 접근이 있다면 언제든 의견을 공유하고 함께 논의해 보세요.
이 시리즈는 계속됩니다. 다음 글에서는 시리즈 전반을 관통했던 흔한 Rails 문제들과 핵심 교훈을 총정리할 예정이니 기대해 주세요.
다음 글에서 만나요!
P.S. Ruby Magic의 글을 발행 즉시 읽어보고 싶으시다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!