Ruby on Rails 패턴과 안티패턴 시리즈의 첫 번째 글에 오신 것을 환영합니다. 이 시리즈에서는 Rails 애플리케이션을 개발하면서 마주치게 되는 다양한 패턴들을 깊이 있게 다룰 예정입니다.
오늘은 (디자인) 패턴이 무엇인지 살펴보고, 안티패턴이 무엇인지도 함께 설명해 드리겠습니다. 설명을 더 쉽게 이해할 수 있도록 오랜 역사를 지닌 Ruby on Rails 프레임워크를 활용하겠습니다. 혹시 Rails가 취향에 맞지 않더라도 실망하지 마세요. 여기서 소개하는 아이디어와 패턴은 어떤 기술을 사용하든 충분히 공감하고 적용할 수 있는 내용입니다.
그런데 패턴과 안티패턴을 본격적으로 설명하기 전에, 한 가지 궁금한 점이 있습니다. 우리는 어떻게 이런 개념들이 필요하게 된 걸까요? 왜 소프트웨어에 이런 것들이 필요할까요? 그리고 왜 우리는 솔루션을 설계해야 할까요?
당신은 이미 디자이너입니다
컴퓨터 프로그래밍 초창기부터 사람들은 자신이 작성하는 프로그램의 설계와 씨름해 왔습니다. 프로그램(소프트웨어)을 작성한다는 것은 곧 문제에 대한 솔루션을 설계하는 일입니다. 소프트웨어를 작성하는 순간 당신은 이미 디자이너입니다. 자신 있게 직함에 추가하세요. 좋은 솔루션을 설계하는 것이 중요한 이유는, 우리가 작성한 코드를 다른 사람들이 읽고 수정하게 되기 때문입니다. 또한 우리가 만든 솔루션 위에서 미래의 다른 개발자들이 계속해서 무언가를 만들어 갈 것이기 때문입니다.
이러한 맥락 속에서 세대를 거쳐온 엔지니어들은 커리어 전반에 걸쳐 코드와 아키텍처에서 비슷한 설계를 반복적으로 발견했습니다. 사람들은 문제에 대한 표준적인 해결책을 추출하고 문서화하기 시작했죠. 어떤 사람들은 이것이 인간이 사물을 인식하는 자연스러운 방식이라고 말합니다. 우리는 모든 것을 분류하고 패턴을 찾는 것을 좋아하며, 소프트웨어도 예외가 아닙니다.
소프트웨어 엔지니어링이 점점 복잡해짐에 따라 패턴은 더욱 많이 등장했습니다. 소프트웨어 디자인 패턴은 발전하여 전 세계 엔지니어들 사이에 굳건히 자리 잡았습니다. 책, 에세이, 강연을 통해 검증된 솔루션에 대한 아이디어가 널리 퍼졌습니다. 이러한 솔루션들은 수많은 사람들의 시간과 비용을 절약해 주었죠. 이제 디자인 패턴이라는 용어가 정확히 무엇을 의미하는지 살펴보겠습니다.
디자인 패턴이란 무엇인가?
소프트웨어 엔지니어링에서 패턴은 흔히 발생하는 문제를 해결하기 위해 재사용할 수 있는 솔루션으로 정의됩니다. 패턴은 소프트웨어 엔지니어들 사이에서 좋은 관행으로 여겨지는 것입니다. 다만 이러한 기준을 사람이 정하다 보니 패턴이 금세 그 반대인 안티패턴으로 변하기도 하는데, 이 부분은 뒤에서 다루겠습니다.
디자인 패턴은 해결책으로 가는 길을 보여주지만, 소프트웨어에 바로 끼워 넣을 수 있는 코드 조각을 주지는 않습니다. 패턴을 잘 설계된 코드를 작성하기 위한 가이드라고 생각하고, 실제 구현은 스스로 해내야 합니다. 일상적인 코딩에서 패턴을 활용하는 흐름은 1980년대 후반 Kent Beck과 Ward Cunningham이 '패턴 언어(pattern language)'라는 개념을 제안하면서 시작되었습니다.
흥미롭게도 패턴 언어라는 아이디어의 원조는 1970년대 후반 Christopher Alexander가 집필한 『A Pattern Language』라는 책입니다. 놀라실 수도 있지만, 이 책은 소프트웨어 엔지니어링이 아니라 건축학에 관한 책입니다. 패턴 언어란 각각의 문제와 다양하게 활용 가능한 솔루션의 핵심을 담은, 체계적이고 일관된 패턴의 집합을 의미합니다. 어딘가 익숙하지 않나요? (힌트: 프레임워크, 힌트 하나 더: Rails)
이후 1994년, 전설적인 『Design Patterns』(일명 Gang of Four, GoF) 책이 출간되면서 소프트웨어 엔지니어링의 디자인 패턴은 대중적으로 널리 알려지게 되었습니다. 이 책에는 오늘날에도 사용되는 패턴들의 정의와 설명이 담겨 있습니다. Factory, Singleton, Decorator 등이 대표적입니다.
좋습니다. 이제 디자인과 패턴에 대한 개념을 새롭게 정리했으니, 안티패턴이 무엇인지 알아볼 차례입니다.
디자인 안티패턴이란 무엇인가?
패턴을 선역이라고 생각한다면, 안티패턴은 악역입니다. 좀 더 정확히 말하면, 소프트웨어 안티패턴이란 널리 사용되지만 비효율적이거나 생산성을 오히려 떨어뜨리는 것으로 간주되는 패턴을 의미합니다. 대표적인 안티패턴의 예로는 수많은 기능과 의존성을 하나에 몰아 넣은 God 객체가 있습니다. 이런 객체는 여러 객체로 분리하고 추출하는 것이 바람직합니다.
코드에서 안티패턴이 발생하는 원인은 매우 다양합니다. 그중 흥미로운 경우는 선역(패턴)이 악역(안티패턴)으로 변하는 상황입니다. 예를 들어 이전 회사에서 특정 기술을 깊이 다루며 높은 숙련도를 쌓았다고 가정해 봅시다. 설명을 위해 Docker를 예로 들겠습니다. 당신은 애플리케이션을 Docker 컨테이너로 효율적으로 패키징하고, 클라우드에서 오케스트레이션하며, 로그를 내려받는 방법을 잘 압니다. 그러던 어느 날 프론트엔드 애플리케이션을 배포하는 새 직장에 입사했습니다. Docker에 능숙한 당신의 첫 번째 결정은 모든 것을 컨테이너로 패키징해 클라우드에 배포하는 것일 겁니다.
그런데 막상 현직에서는 프론트엔드 앱이 그렇게 복잡하지 않아서, 컨테이너에 담는 것이 최선의 방법이 아닐 수 있습니다. 처음에는 좋은 아이디어처럼 들리지만 시간이 지나면 오히려 생산성을 해치는 결과로 이어집니다. 이 안티패턴을 바로 "황금 망치(Golden Hammer)"라고 부릅니다.
"손에 망치를 쥐면 모든 것이 못으로 보인다"는 격언이 이를 잘 요약합니다. Docker와 서비스 오케스트레이션에 능숙하다면, 모든 것이 클라우드에서 오케스트레이션될 Docker 서비스로 보이는 것이죠.
이런 일은 언제든 일어날 수 있고 실제로 일어납니다. 선역이 악역으로, 악역이 선역으로 바뀌기도 하죠. 그렇다면 Ruby와 Rails는 이 그림에서 어디에 위치할까요?
먼저 Ruby를, 그다음 Rails를
많은 사람들이 Ruby on Rails라는 빠른 웹사이트 구축용 프레임워크를 통해 Ruby를 처음 접했습니다. 저 역시 같은 경로였고, 그 자체로 나쁜 것은 아닙니다. Rails는 Model-View-Controller, 줄여서 MVC라는 검증된 소프트웨어 패턴을 기반으로 합니다. Rails의 MVC 패턴을 자세히 들어가기 전에, 자주 발생하는 큰 함정 하나를 짚고 넘어가야 합니다. 바로 Ruby를 제대로 학습하지 않은 채 Rails를 사용하는 것입니다.
Rails는 아이디어가 떠오르면 빠르게 구현하고 싶을 때 손꼽히던 프레임워크였습니다. 요즘은 상황이 많이 달라졌지만 여전히 사용되고 있으며, 전성기만큼은 아닙니다. 사용과 실행이 워낙 쉬운 탓에 수많은 입문자가 rails new 명령어로 웹 앱 만들기를 시작합니다. 그리고 시간이 지나면서 문제가 하나씩 드러나기 시작합니다. 입문자는 Rails가 주는 개발 속도와 단순함에 매료되고, 처음에는 모든 것이 마법처럼 매끄럽게 동작합니다. 그러다 어느 순간 깨닫게 됩니다. 그 '마법'을 너무 당연하게 받아들였고, 무대 뒤에서 무슨 일이 벌어지는지 전혀 이해하지 못했다는 사실을 말입니다.
저도 이 문제를 겪었고, 수많은 입문자와 초급 개발자들이 같은 어려움을 겪고 있다고 확신합니다. 프레임워크를 손에 쥐고 개발을 진행하다가, 고도로 커스텀한 기능을 추가하려는 순간 막히곤 합니다. 프레임워크가 제공하는 마법 포인트를 모두 소진했기 때문입니다. 이 시점에서 결국 기본으로 돌아가 기초를 학습해야 합니다. 돌아가는 것 자체는 큰 문제가 아닙니다. 누구에게나 일어나는 일이니까요. 하지만 Ruby 같은 필수 기초를 배우지 않은 채 앞으로 나아가면 문제는 더 커집니다. 이런 맥락에서 도움이 되는 책으로 『The Well-Grounded Rubyist』를 추천합니다.
입문자라고 해서 처음부터 끝까지 읽을 필요는 없습니다. 옆에 두고 필요할 때 빠르게 참고하면 됩니다. 하던 일을 멈추고 통째로 읽으라는 뜻이 아닙니다. 다만 가끔씩 멈춰서 Ruby 기초를 복습해 보세요. 새로운 시야가 열릴 수 있습니다.
MVC: Rails의 핵심 패턴
자, 그럼 MVC는 어떨까요? Model-View-Controller 패턴은 오랜 역사를 지녔으며, Ruby(Rails), Python(Django), Java(Play, Spring MVC) 등 수많은 언어의 프레임워크에 채택되었습니다. 핵심 아이디어는 각자의 역할을 수행하는 독립된 컴포넌트로 구성하는 것입니다:
- Model: 데이터와 비즈니스 로직을 처리합니다.
- View: 데이터의 표현과 사용자 인터페이스를 담당합니다.
- Controller: Model에서 데이터를 가져와 View를 사용자에게 보여주는 다리 역할을 합니다.
이론상으로는 훌륭하고, 로직이 단순하고 웹사이트가 복잡한 로직을 담고 있지 않다면 아주 잘 동작합니다. 하지만 여기서부터 상황이 까다로워지는데, 곧 다루겠습니다.
MVC는 웹 개발 커뮤니티 전체로 폭발적으로 퍼져 나갔습니다. 요즘 엄청난 인기를 누리는 React조차 웹 앱의 뷰 레이어로 설명됩니다. 이토록 널리 대중화되어 흔들릴 수 없는 패턴은 없습니다. Rails는 ActionCable을 통해 Publish-Subscribe 패턴을 더했는데, 여기서 채널(channel) 개념은 MVC 패턴의 컨트롤러로 설명됩니다.
그렇다면 이토록 널리 쓰이는 패턴 안에는 어떤 안티패턴이 존재할까요? MVC 패턴의 각 구성 요소별로 가장 흔한 안티패턴을 살펴보겠습니다.
모델의 문제점
애플리케이션이 성장하고 비즈니스 로직이 확장되면, 사람들은 모델에 코드를 과도하게 몰아 넣는 경향이 있습니다. 끝없는 성장은 Fat Model이라 불리는 안티패턴으로 이어질 수 있습니다.
유명한 'Fat Model, Skinny Controller' 패턴은 사람에 따라 악역이 되기도, 선역이 되기도 합니다. 하지만 우리는 어느 쪽이든 '살진(fat)' 상태는 안티패턴이라고 판단합니다. 이해를 돕기 위해 예시를 들어 보겠습니다. Spotify나 Deezer 같은 스트리밍 서비스가 있다고 상상해 봅시다. 그 안에는 다음과 같은 노래(Song) 모델이 있습니다:
class Song < ApplicationRecord
belongs_to :album
belongs_to :artist
belongs_to :publisher
has_one :text
has_many :downloads
validates :artist_id, presence: true
validates :publisher_id, presence: true
after_update :alert_artist_followers
after_update :alert_publisher
def alert_artist_followers
return if unreleased?
artist.followers.each { |follower| follower.notify(self) }
end
def alert_publisher
PublisherMailer.song_email(publisher, self).deliver_now
end
def includes_profanities?
text.scan_for_profanities.any?
end
def user_downloaded?(user)
user.library.has_song?(self)
end
def find_published_from_artist_with_albums
...
end
def find_published_with_albums
...
end
def to_wav
...
end
def to_mp3
...
end
def to_flac
...
end
end이런 모델의 문제는 노래와 관련된 온갖 로직의 쓰레기 처리장이 되어버린다는 점입니다. 메서드가 시간이 지나면서 하나씩 슬금슬금 추가되다 보면 어느새 이런 꼴이 됩니다. 모델 전체가 거대하고 복잡해지며, 로직을 몇 군데로 분리하는 것이 미래에 큰 도움이 될 수 있습니다.
코드만 봐도 이 모델이 권장되는 원칙들을 위반하고 있다는 것을 바로 알 수 있습니다. 우선 단일 책임 원칙(Single Responsibility Principle, SRP)을 위반합니다. 팔로워와 퍼블리셔에게 알림을 보내고, 텍스트의 비속어를 검사하고, 노래를 다양한 오디오 포맷으로 내보내는 메서드까지 갖추고 있습니다. 이 모든 것이 모델의 복잡도를 높이며, 이 모델의 테스트 파일이 어떤 모습일지는 감조차 잡기 어렵습니다.
이 모델을 어떻게 리팩토링할지는 메서드들이 다른 곳에서 어떻게 호출되고 사용되는지에 따라 크게 달라집니다. 몇 가지 일반적인 접근 방식을 소개할 테니, 상황에 가장 잘 맞는 방법을 선택하시면 됩니다.
팔로워와 퍼블리셔에게 알림을 보내는 콜백은 잭(Job)으로 추출할 수 있습니다. 잡이 큐에 등록되고 로직이 모델 밖으로 분리됩니다:
class NotifyFollowers < ApplicationJob
def perform(followers)
followers.each { |follower| follower.notify }
end
end
class NotifyPublisher < ApplicationJob
def perform(publisher, song)
PublisherMailer.song_email(publisher, self).deliver_now
end
end잡은 별도 프로세스에서 모델과 독립적으로 실행됩니다. 이제 잡 로직을 따로 테스트하고, 모델에서는 올바른 잡이 큐에 등록되었는지만 확인하면 됩니다.
비속어 검사와 사용자의 다운로드 여부 확인이 앱의 뷰 영역에서 이뤄진다고 가정해 봅시다. 이 경우 Decorator 패턴을 활용할 수 있습니다. 빠르게 시작할 수 있는 인기 있는 솔루션으로 Draper 젬이 있습니다. Draper를 사용하면 다음과 같은 데코레이터를 작성할 수 있습니다:
class SongDecorator < Draper::Decorator
delegate_all
def includes_profanities?
object.text.scan_for_profanities.any?
end
def user_downloaded?(user)
object.user.library.has_song?(self)
end
end그다음 컨트롤러에서 decorate를 호출합니다:
def show
@song = Song.find(params[:id]).decorate
end그리고 뷰에서는 다음과 같이 사용합니다:
<%= @song.includes_profanities? %>
<%= @song.user_downloaded?(user) %>외부 의존성을 추가하고 싶지 않다면 직접 데코레이터를 만들 수도 있습니다. 이 내용은 다른 블로그 글에서 다루겠습니다. 이제 모델의 주요 관심사가 대부분 분리되었으니, 노래를 찾는 메서드와 변환하는 메서드를 처리해 보겠습니다. 모듈을 사용해 분리할 수 있습니다:
module SongFinders
def find_published_from_artist_with_albums
...
end
def find_published_with_albums
...
end
end
module SongConverter
def to_wav
...
end
def to_mp3
...
end
def to_flac
...
end
endSong 모델은 SongFinders 모듈을 extend하여 해당 메서드들을 클래스 메서드로 사용할 수 있고, SongConverter 모듈을 include하여 해당 메서드들을 모델 인스턴스에서 사용할 수 있습니다.
이 모든 작업을 마치면 Song 모델은 꽤 슬림하고 목적에 충실한 모습이 됩니다:
class Song < ApplicationRecord
extend SongFinders
include SongConverter
belongs_to :album
belongs_to :artist
belongs_to :publisher
has_one :text
has_many :downloads
validates :artist_id, presence: true
validates :publisher_id, presence: true
after_update :alert_artist_followers, if: :published?
after_update :alert_publisher
def alert_artist_followers
NotifyFollowers.perform_later(self)
end
def alert_publisher
NotifyPublisher.perform_later(publisher, self)
end
end모델 안티패턴은 이 외에도 많으며, 이것은 모델에서 잘못될 수 있는 여러 문제 중 하나의 예시일 뿐입니다. 이 시리즈의 다음 블로그 글에서 더 많은 모델 안티패턴을 심층적으로 다룰 예정이니 기대해 주세요. 지금은 뷰에서 어떤 문제가 생길 수 있는지 살펴보겠습니다.
뷰의 문제점
모델의 문제 외에도 Rails 개발자는 때때로 뷰의 복잡성으로 고민하게 됩니다. 한때 HTML과 CSS가 웹 애플리케이션 뷰 영역의 왕이었다면, 시간이 지나며 JavaScript가 왕좌를 차지해 프론트엔드의 거의 모든 측면이 JavaScript로 작성되었습니다. Rails는 이와 조금 다른 패러다임을 따릅니다. 뷰의 모든 것을 JavaScript로 작성하는 대신, JS를 '뿌려두는(sprinkle)' 방식을 권장합니다.
어느 쪽이든 HTML, CSS, JS, Ruby를 한곳에서 다루다 보면 지저분해지기 쉽습니다. Rails 뷰를 만들 때 까다로운 점은 도메인 로직이 가끔 뷰 안에 들어가 있다는 것입니다. 이것은 우선 MVC 패턴을 깨뜨리는 행위이므로 절대 금물입니다.
또 다른 경우는 뷰와 파셜(partial)에 임베디드 Ruby(ERB)를 과도하게 사용하는 것입니다. 일부 로직은 헬퍼(helper)나 데코레이터(뷰 모델 또는 프레젠터라고도 불림)로 옮기는 것이 좋습니다. 관련 예시는 시리즈의 다음 글에서 다룰 예정이니 기대해 주세요.
컨트롤러의 문제점
Rails 컨트롤러 역시 다양한 문제에 시달릴 수 있습니다. 그중 하나가 바로 Fat Controller 안티패턴입니다.
앞서 모델이 살찌었다가 체중을 감량했더니, 이번에는 컨트롤러가 그만큼 살이 붙은 것을 발견하게 됩니다. 보통 이런 일은 비즈니스 로직이 컨트롤러 안에 들어갈 때 발생합니다. 사실 그 로직의 올바른 위치는 모델이나 다른 곳이어야 하죠. 앞서 Fat Model 섹션에서 공유한 아이디어 중 일부는 컨트롤러에도 적용할 수 있습니다. 코드를 프레젠터로 추출하거나, ActiveRecord 콜백을 활용하거나, Service 객체를 도입하는 방법 등이 있습니다.
Trailblazer나 dry-transaction 같은 젬을 활용하는 개발자들도 있습니다. 핵심 아이디어는 특정 트랜잭션을 전담하는 클래스를 만드는 것입니다. 모든 것을 컨트롤러 밖으로 옮기고 모델을 슬림하게 유지하면, 로직을 이런 별도의 클래스 안에 저장하고 테스트할 수 있습니다. 사람에 따라 이를 서비스(service), 트랜잭션(transaction), 액션(action) 등으로 부르기도 합니다.
마무리
안티패턴은 이 외에도 더 많고, 해결책은 그보다 더 많습니다. 이 글 하나에 모든 것을 담으려면 너무 많은 공간과 시간이 필요하고, 글 자체가 살찌게 될 것입니다(방금 이야기한 모델과 컨트롤러처럼 말이죠). 시리즈를 계속 팔로우해 주세요. 앞으로 Rails의 MVC 패턴의 모든 측면을 깊이 파헤치고, 가장 유명한 안티패턴들을 다루는 방법을 알려드리겠습니다. 그때까지 패턴과 안티패턴이 무엇인지, 그리고 Ruby on Rails 프레임워크에서 가장 흔한 사례들을 소개한 이번 개요 글이 즐거운 독서가 되었기를 바랍니다.
다음 글에서 다시 만나요!
P.S. Ruby Magic 글이 발행되자마자 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!