오늘은 퍼사드(Facade)라는 소프트웨어 설계 패턴에 대해 알아보겠습니다. 처음 도입했을 때는 다소 어색하게 느껴졌지만, Rails 앱에서 반복해서 사용할수록 그 유용성을 깊이 실감하게 되었습니다. 더 중요한 것은 이 패턴 덕분에 코드를 더 철저하게 테스트할 수 있게 되고, 컨트롤러를 깔끔하게 정리하고, 뷰 안의 로직을 줄이며, 애플리케이션 코드의 전체 구조를 한층 명확하게 생각할 수 있게 되었다는 점입니다.
퍼사드는 프레임워크와 무관하게 적용할 수 있는 개발 패턴이지만, 이 글에서 제시하는 예제는 Ruby on Rails 기준으로 작성되었습니다. 어떤 프레임워크를 사용하든 이 글을 읽고 직접 적용해 보시길 권합니다. 이 패턴에 익숙해지면 코드베이스 곳곳에서 활용할 기회를 발견하게 될 것입니다.
그럼 바로 시작해 보겠습니다!
MVC 패턴의 문제점
MVC(Model-View-Controller) 패턴은 1970년대로 거슬러 올라가는 오랜 역사를 지닌 소프트웨어 설계 패턴입니다. 수많은 프로젝트에서 검증된 이 패턴은 관심사(concerns)를 세 가지 주요 영역으로 분리하고, 각 영역이 고유한 방식으로 서로 통신하도록 하여 소프트웨어 인터페이스를 설계합니다.
2000년대 초반에 등장한 많은 대형 웹 프레임워크들이 MVC 패턴을 기반으로 탄생했습니다. Java의 Spring, Python의 Django, Ruby의 Ruby on Rails 모두 이 상호 연결된 세 요소의 삼위일체를 핵심으로 만들어졌습니다. MVC를 사용하지 않은 소프트웨어에서 흔히 볼 수 있는 스파게티 코드와 비교하면, MVC 패턴은 소프트웨어 개발과 인터넷의 진화에 있어 엄청난 성취이자 전환점이었습니다.
본질적으로 Model-View-Controller 패턴은 다음과 같은 흐름으로 동작합니다. 사용자가 뷰(View)에서 어떤 동작을 수행하면, 뷰는 컨트롤러(Controller)에 요청을 보내고, 컨트롤러는 모델(Model)을 생성·조회·수정 또는 삭제할 수 있습니다. 모델 트랜잭션의 결과는 다시 컨트롤러로 반환되고, 컨트롤러는 사용자가 뷰에서 확인할 수 있는 변화를 렌더링합니다.
이 프로그래밍 패턴에는 여러 장점이 있습니다.
- 관심사를 분리함으로써 코드 유지보수성을 향상시킵니다
- 모델, 뷰, 컨트롤러를 각각 독립적으로 테스트할 수 있어 테스트 용이성이 높아집니다
- SOLID 원칙 중 단일 책임 원칙(SRP), 즉 "하나의 클래스에는 변경되어야 할 이유가 단 하나만 있어야 한다"는 원칙을 강제함으로써 좋은 코딩 습관을 장려합니다
당시에는 놀라운 성과였지만, 개발자들은 곧 MVC 패턴에도 한계가 있다는 사실을 깨달았습니다. 그래서 HMVC(계층형 MVC), MVA(model-view-adapter), MVP(model-view-presenter), MVVM(model-view-viewmodel) 등 MVC의 한계를 보완하려는 다양한 변형 패턴들이 등장했습니다.
MVC 패턴이 내포하는 문제 중 하나, 그리고 오늘 이 글의 주제인 바로 그 문제는 다음과 같습니다. 복잡한 뷰 로직은 누가 처리해야 하는가? 뷰는 단순히 데이터를 표현하는 일만 해야 하고, 컨트롤러는 모델에서 받은 메시지를 전달하는 역할만 하며, 모델은 뷰 로직과는 무관해야 하기 때문입니다.
이런 공통된 난제를 해결하기 위해 모든 Rails 애플리케이션은 초기화 시 helpers 디렉터리를 함께 생성합니다. helpers 디렉터리에는 복잡한 뷰 로직을 보조하는 메서드들을 담은 모듈을 넣을 수 있습니다.
Rails 애플리케이션의 헬퍼 예제는 다음과 같습니다:
app/helpers/application_helper.rb
module ApplicationHelper
def display_ad_type(advertisement)
type = advertisement.ad_type
case type
when 'foo'
content_tag(:span, class: "foo ad-#{type}") { type }
when 'bar'
content_tag(:p, 'bar advertisement')
else
content_tag(:span, class: "badge ads-badge badge-pill ad-#{type}") { type }
end
end
end예제는 단순하지만, 이런 종류의 조건 분기를 템플릿 자체에서 분리해내면 복잡도를 크게 줄일 수 있다는 점을 잘 보여줍니다.
헬퍼도 좋은 도구지만, 복잡한 뷰 로직을 처리하는 또 다른 방식으로 오랜 세월 검증되어 온 패턴이 하나 더 있습니다. 바로 퍼사드 패턴입니다.
퍼사드 패턴 소개
Ruby on Rails 애플리케이션에서 퍼사드는 일반적으로 app/facades 디렉터리에 위치시킵니다.
퍼사드는 헬퍼와 비슷해 보이지만, 모듈 안에 묶인 메서드들의 집합이 아닙니다. 퍼사드는 컨트롤러 내부에서 인스턴스화되는 PORO(Plain Old Ruby Object)로, 복잡한 뷰 관련 비즈니스 로직을 담당합니다. 이를 통해 얻을 수 있는 이점은 다음과 같습니다.
UsersHelper,ArticlesHelper,BooksHelper처럼 하나의 모듈에 모두 담는 대신, 각 컨트롤러 액션이 자신만의 퍼사드를 가질 수 있습니다. 예를 들어Users::IndexFacade,Articles::ShowFacade,Books::EditFacade처럼 말이죠.- 모듈보다 더 적극적으로 좋은 코딩 습관을 장려합니다. 퍼사드를 중첩(nesting)하여 단일 책임 원칙을 확실히 지킬 수 있기 때문입니다. 물론 수백 단계나 중첩하고 싶지는 않겠지만, 유지보수성과 테스트 커버리지를 높이기 위해 한두 단계의 중첩은 충분히 좋은 선택이 될 수 있습니다.
간단한 예제를 살펴보겠습니다:
module Books
class IndexFacade
attr_reader :books, :params, :user
def initialize(user:, params:)
@params = params
@user = user
@books = user.books
end
def filtered_books
@filtered_books ||= begin
scope = if query.present?
books.where('name ILIKE ?', "%#{query}%")
elsif isbn.present?
books.where(isbn: isbn)
else
books
end
scope.order(created_at: :desc).page(params[:page])
end
end
def recommended
# 여기에 중첩된 퍼사드가 있습니다.
# 뷰의 `Recommended Books` 부분은 단일 책임을 가지므로,
# 캡슐화와 테스트 용이성을 높이기 위해 별도로 추출하는 것이 좋습니다.
@recommended ||= Books::RecommendedFacade.new(
books: books,
user: user
)
end
private
def query
@query ||= params[:query]
end
def isbn
@isbn ||= params[:isbn]
end
end
end퍼사드 패턴을 사용하면 안 되는 경우
퍼사드가 무엇이 아닌지도 잠시 짚고 넘어갈 필요가 있습니다.
뷰에 표시해야 하는 코드를
lib디렉터리 같은 곳에 두는 클래스 안에 퍼사드를 넣으면 안 됩니다. 퍼사드의 생명주기는 컨트롤러 액션에서 시작되어, 해당 액션과 연결된 뷰에서 사용되는 것까지여야 합니다.퍼사드는 CRUD 작업을 수행하는 비즈니스 로직용으로 쓰라고 만들어진 것이 아닙니다(그런 용도에는 Services나 Interactor 같은 다른 패턴이 있습니다. 이는 다음 기회에 다루겠습니다). 다시 말해, 퍼사드는 생성·수정·삭제 작업과는 무관해야 합니다. 퍼사드의 목표는 뷰나 컨트롤러로부터 복잡한 표현(presentation) 로직을 추출해내고, 그 모든 정보에 접근할 수 있는 단일 인터페이스를 제공하는 것입니다.
마지막으로, 퍼사드는 만능 해결책(silver bullet)이 아닙니다. 퍼사드는 MVC 패턴을 우회하는 것이 아니라, MVC와 함께 작동합니다. 모델에 변경이 생겨도 뷰에 즉시 반영되지 않습니다. 늘 그렇듯 MVC에서는 컨트롤러 액션이 다시 렌더링되어야 퍼사드가 변경 사항을 뷰에 표시할 수 있습니다.
컨트롤러의 이점
퍼사드의 가장 직관적인 이점 중 하나는 컨트롤러 로직을 극적으로 줄일 수 있다는 점입니다.
컨트롤러 코드가 다음과 같았다면,
class BooksController < ApplicationController
def index
@books = if params[:query].present?
current_user.books.where('name ILIKE ?', "%#{params[:query]}%")
elsif params[:isbn].present?
current_user.books.where(isbn: params[:isbn])
else
current_user.books
end
@books.order(created_at: :desc).page(params[:page])
@recommended = @books.where(some_complex_query: true)
end
end이렇게 깔끔해집니다:
class BooksController < ApplicationController
def index
@index_facade = Books::IndexFacade.new(user: current_user, params: params)
end
end뷰의 이점
뷰 관점에서 퍼사드를 사용하면 크게 두 가지 이점이 있습니다.
- 조건문, 인라인 쿼리 등 각종 로직을 템플릿에서 깔끔하게 추출할 수 있어 코드 가독성이 크게 향상됩니다. 예를 들어 폼(form)에서 다음과 같이 사용할 수 있습니다:
<%= f.label :location %>
<%= f.select :location, options_for_select(User::LOCATION_TYPES.map { |type| [type.underscore.humanize, type] }.sort.prepend(['All', 'all'])), multiple: (current_user.active_ips.size > 1 && current_user.settings.use_multiple_locations?) %>이것이 단순히 다음처럼 바뀝니다:
<%= f.label :location %>
<%= f.select :location, options_for_select(@form_facade.user_locations), multiple: @form_facade.multiple_locations? %>- 여러 번 호출되는 변수를 캐싱할 수 있습니다. 이는 앱의 성능을 눈에 띄게 개선하고, 성가신 N+1 쿼리 문제를 없애는 데도 도움이 됩니다:
// 뷰의 한 곳에서 쿼리가 실행됩니다.
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
// 작업 수행
<% end %>
// 뷰의 다른 곳에서 동일한 쿼리가 다시 실행됩니다.
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
// 작업 수행
<% end %>퍼사드를 사용하면 다음과 같이 됩니다:
// 뷰의 한 곳에서 쿼리가 실행됩니다.
<% @index_facade.filtered_books.each do |book| %>
// 작업 수행
<% end %>
// 뷰의 다른 곳.
// 인스턴스 변수 캐싱 덕분에 두 번째 쿼리는 실행되지 않습니다.
<% @index_facade.filtered_books.each do |book| %>
// 작업 수행
<% end %>테스트의 이점
퍼사드의 가장 큰 장점 중 하나는, 데이터 표현이 기대대로 나오는지 확인하기 위해 컨트롤러 테스트 전체를 작성하거나, 더 나쁜 경우 특정 페이지에 도달하는 통합 테스트 플로우 전체를 작성할 필요 없이, 비즈니스 로직의 개별 조각들을 테스트할 수 있다는 점입니다.
단일 PORO를 테스트하는 것이므로, 테스트 스위트(test suite)를 빠르게 유지하는 데도 큰 도움이 됩니다.
설명을 위해 Minitest로 작성한 간단한 테스트 예제입니다:
require 'test_helper'
module Books
class IndexFacadeTest < ActiveSupport::TestCase
attr_reader :user, :params
setup do
@user = User.create(first_name: 'Bob', last_name: 'Dylan')
@params = {}
end
test "#filtered_books returns all user's books when params are empty"
index_facade = Books::IndexFacade.new(user: user, params: params)
expectation = user.books.order(created_at: :desc).page(params[:page])
# 컨트롤러 테스트나 통합 테스트 전체를 작성하지 않고도,
# 빈 파라미터로 퍼사드를 사용했을 때 올바른 결과가
# 반환되는지 확인할 수 있습니다.
assert_equal expectation, index_facade.filtered_books
end
test "#filtered_books returns books matching a query"
@params = { query: 'Lord of the Rings' }
index_facade = Books::IndexFacade.new(user: user, params: params)
expectation = user
.books
.where('name ILIKE ?', "%#{params[:query]}%")
.order(created_at: :desc)
.page(params[:page])
assert_equal expectation, index_facade.filtered_books
end
end
end퍼사드를 단위 테스트하면 테스트 스위트 성능이 상당히 개선됩니다. 이런 문제를 진지하게 다루지 않으면, 규모가 큰 회사일수록 결국 느린 테스트 스위트라는 벽에 부딪히게 됩니다.
하나의 퍼사드, 두 개의 퍼사드, 세 개의 퍼사드, 그 이상?
뷰가 어떤 데이터를 출력하는 partial을 렌더링하는 시나리오를 마주할 수 있습니다. 이럴 때는 부모 퍼사드를 사용하거나, 아니면 중첩된 퍼사드를 새로 만들 수 있습니다. 어느 쪽이 맞는지는 대체로 로직의 복잡도, 별도로 테스트하고 싶은지 여부, 그리고 해당 기능을 추출하는 것이 합리적인지에 따라 달라집니다.
몇 개의 퍼사드를 사용하고, 몇 단계까지 중첩해야 한다는 황금률은 없습니다. 그것은 개발자의 재량에 달려 있습니다. 저는 개인적으로 컨트롤러 액션당 하나의 퍼사드를 두고, 코드를 따라가기 쉽게 하기 위해 중첩은 한 단계까지만 허용하는 편입니다.
개발 과정에서 스스로에게 던져볼 만한 질문들은 다음과 같습니다:
- 이 퍼사드가 뷰에 표현하려는 로직을 잘 캡슐화하고 있는가?
- 퍼사드 안의 이 메서드가 지금 맥락에서 자연스러운가?
- 코드가 지금 더 따라가기 쉬워졌는가, 아니면 오히려 어려워졌는가?
확신이 서지 않을 때는 항상 코드를 최대한 쉽게 따라갈 수 있도록 만드는 것을 목표로 삼으세요.
결론
정리하자면, 퍼사드는 컨트롤러와 뷰를 가볍게 유지하면서 코드의 유지보수성, 성능, 테스트 용이성을 모두 끌어올릴 수 있는 훌륭한 패턴입니다.
다만 어떤 프로그래밍 패러다임과 마찬가지로 만능 해결책은 없습니다. 최근 몇 년 사이 등장한 수많은 패턴(HMVC, MVVM 등)조차도 소프트웨어 개발의 복잡성에 대한 완벽한 정답은 아닙니다.
열역학 제2법칙이 닫힌 계의 엔트로피는 항상 증가한다고 말하듯, 어떤 소프트웨어 프로젝트든 복잡도는 시간이 지나며 끊임없이 증가하고 진화합니다. 장기적으로 우리의 목표는 읽기 쉽고, 테스트하기 쉽고, 유지보수하기 쉽고, 따라가기 쉬운 코드를 작성하는 것입니다. 퍼사드는 바로 그 목표를 달성하는 데 탁월한 도구가 되어 줄 것입니다.
P.S. Ruby Magic의 새 글이 발행되자마자 받아보고 싶다면, Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!