Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby on Rails 모델 패턴과 안티패턴: 유지보수하기 쉬운 모델 만들기

Ruby on Rails 패턴 및 안티패턴 시리즈의 두 번째 글에 오신 것을 환영합니다. 지난 포스트에서는 패턴과 안티패턴이 무엇인지 개념을 살펴보고, Rails 생태계에서 가장 잘 알려진 몇 가지 사례들을 소개했습니다. 이번 글에서는 Rails 모델과 관련된 대표적인 패턴과 안티패턴을 자세히 다뤄보겠습니다.

모델을 다루면서 어려움을 겪고 있다면 이 글이 딱 맞을 것입니다. 비대해진 모델을 슬림하게 만드는 과정을 빠르게 훑어본 뒤, 마이그레이션을 작성할 때 피해야 할 함정들로 마무리하겠습니다. 그럼 바로 시작하겠습니다.

비대해진(Overweight) 모델 다이어트 시키기

Rails 애플리케이션을 개발하다 보면, 완성도 높은 웹사이트든 API든 사람들은 대부분의 로직을 모델에 몰아넣는 경향이 있습니다. 지난 포스트에서 여러 가지 일을 떠맡은 Song 클래스 예제를 살펴봤는데요. 하나의 모델에 너무 많은 책임을 두면 단일 책임 원칙(SRP, Single Responsibility Principle)이 깨지게 됩니다.

실제 코드로 확인해 보겠습니다.

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

이런 모델의 문제점은 노래와 관련된 각종 로직의 '쓰레기 처리장'처럼 변한다는 점입니다. 메서드들이 시간이 지나며 하나씩 조금씩 추가되다 보면 어느새 걷잡을 수 없이 불어나 있죠.

이에 대한 한 가지 해결책은 모델 내부의 코드를 더 작은 모듈로 분리하는 것입니다. 물론 이 방식은 코드를 한곳에서 다른 곳으로 옮길 뿐 근본적인 해결책은 아닙니다. 하지만 코드를 재배치하는 것만으로도 구조를 체계적으로 정리하고, 가독성이 떨어지는 비대한 모델을 피할 수 있습니다.

일부 개발자는 Rails의 concerns를 활용해 로직을 여러 모델에서 재사용하기도 합니다. 저도 이 주제로 글을 쓴 적이 있는데, 호오가 정확히 갈렸던 기억이 납니다. 어쨌든 concerns도 모듈과 사정은 비슷합니다. 결국 코드를 어디서든 include 할 수 있는 모듈로 옮기는 것일 뿐이라는 점을 인지하고 사용해야 합니다.

또 다른 대안은 작은 클래스를 만들어 필요할 때 호출하는 방식입니다. 예를 들어 노래 변환 코드를 별도의 클래스로 추출할 수 있습니다.

class SongConverter
  attr_reader :song
 
  def initialize(song)
    @song = song
  end
 
  def to_wav
    ...
  end
 
  def to_mp3
    ...
  end
 
  def to_flac
    ...
  end
end
 
class Song
  ...
 
  def converter
    SongConverter.new(self)
  end
 
  ...
end

이제 SongConverter는 노래를 다른 형식으로 변환하는 단일 목적을 가진 클래스가 되었습니다. 자체적인 테스트를 작성할 수 있고, 향후 변환 관련 로직도 이곳에 확장해 나갈 수 있습니다. 노래를 MP3로 변환하고 싶다면 다음과 같이 하면 됩니다.

@song.converter.to_mp3

개인적으로는 모듈이나 concern보다 이 방식이 더 명확해 보입니다. 아마 상속보다 합성(composition)을 선호하기 때문일 텐데요. 직관적이고 읽기 쉽다고 생각합니다. 어떤 방식이 좋을지는 두 가지 경우를 모두 검토한 후 프로젝트 상황에 맞게 결정하시면 됩니다. 물론 둘 다 함께 사용해도 아무 문제 없습니다.

SQL 스파게티 코드 피하기

현실에서 맛있는 파스타를 싫어하는 사람은 드물지만, 코드 세계의 파스타, 즉 스파게티 코드를 좋아하는 사람은 거의 없습니다. 당연한 이유에서죠. Rails 모델에서는 Active Record 사용이 금세 코드베이스 곳곳으로 휘감기는 스파게티가 되기 쉽습니다. 어떻게 피할 수 있을까요?

길고 반복되는 쿼리가 스파게티화되는 것을 막는 몇 가지 아이디어가 있습니다. 먼저 데이터베이스 관련 코드가 얼마나 널리 퍼질 수 있는지 살펴보겠습니다. 다시 Song 모델로 돌아가, 특정 데이터를 조회하려는 시점을 보겠습니다.

class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.where(status: :published)
                .where(artist_id: artist_id)
                .order(:title)
 
    ...
  end
end
 
class SongController < ApplicationController
  def index
    @songs = Song.where(status: :published)
                 .order(:release_date)
 
    ...
  end
end
 
class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.where(status: :published)
 
    ...
  end
end

위 예제에는 Song 모델을 조회하는 세 가지 사용 사례가 있습니다. 노래 관련 데이터를 리포팅하는 SongReporterService에서는 특정 아티스트의 발매된 곡들을 가져오고, SongController에서는 발매된 곡들을 릴리즈 날짜순으로 정렬해 가져오며, 마지막으로 SongRefreshJob에서는 발매된 곡들만 가져와 어떤 작업을 수행합니다.

지금은 문제없어 보이지만, 만약 갑자기 상태명을 released로 변경하거나 조회 로직에 다른 수정이 필요하다면 어떻게 될까요? 애플리케이션 전체를 돌며 해당 코드가 등장하는 모든 곳을 일일이 찾아 수정해야 합니다. 게다가 위 코드는 DRY하지 않습니다. 같은 내용이 애플리케이션 곳곳에서 반복되고 있죠. 다행히도 이 문제에는 해결책이 있습니다.

Rails 스코프(scope)를 사용하면 이 코드를 DRY하게 정리할 수 있습니다. 스코핑을 활용하면 자주 사용하는 쿼리를 정의해두고 연관관계(association)나 객체에서 호출할 수 있습니다. 덕분에 코드의 가독성이 높아지고 변경도 쉬워집니다. 그리고 무엇보다 중요한 것은, 스코프는 joins, where 같은 다른 Active Record 메서드와 체이닝이 가능하다는 점입니다. 스코프를 적용한 코드를 살펴보겠습니다.

class Song < ApplicationRecord
  ...
 
  scope :published, ->            { where(published: true) }
  scope :by_artist, ->(artist_id) { where(artist_id: artist_id) }
  scope :sorted_by_title,         { order(:title) }
  scope :sorted_by_release_date,  { order(:release_date) }
 
  ...
end
 
class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.published.by_artist(artist_id).sorted_by_title
 
    ...
  end
end
 
class SongController < ApplicationController
  def index
    @songs = Song.published.sorted_by_release_date
 
    ...
  end
end
 
class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.published
 
    ...
  end
end

어떠신가요? 반복되던 코드를 잘라내 모델 안으로 옮기는 데 성공했습니다. 하지만 이것이 항상 최선이 되는 것은 아닙니다. 특히 이미 비대한 모델(fat model)이나 God Object 증세를 겪고 있다면 더욱 그렇습니다. 모델에 메서드와 책임을 계속 추가하는 것은 그리 좋은 생각이 아닐 수 있습니다.

제 조언은 스코프 사용을 최소한으로 유지하고, 정말 공통적으로 쓰이는 쿼리만 추출하는 것입니다. 위 예제라면 여기저기서 모두 사용되는 where(published: true) 정도가 스코프로 만들기에 완벽한 후보입니다. 그 외 SQL 관련 코드에는 Repository 패턴이라 불리는 기법을 활용할 수 있습니다. 지금부터 알아보겠습니다.

Repository 패턴

여기서 소개하는 내용은 Domain-Driven Design 책에 정의된 Repository 패턴과 1:1로 일치하지는 않습니다. 우리가 말하는 Rails 버전의 Repository 패턴의 핵심 아이디어는 데이터베이스 로직과 비즈니스 로직을 분리하는 것입니다. Active Record 대신 Raw SQL 호출을 전담하는 리포지토리 클래스까지 만들 수도 있지만, 정말 필요한 경우가 아니라면 그렇까지는 권하지 않습니다.

우리가 할 수 있는 일은 SongRepository를 만들어 데이터베이스 로직을 그곳에 모으는 것입니다.

class SongRepository
  class << self
    def find(id)
      Song.find(id)
    rescue ActiveRecord::RecordNotFound => e
      raise RecordNotFoundError, e
    end
 
    def destroy(id)
      find(id).destroy
    end
 
    def recently_published_by_artist(artist_id)
      Song.where(published: true)
          .where(artist_id: artist_id)
          .order(:release_date)
    end
  end
end
 
class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = SongRepository.recently_published_by_artist(artist_id)
 
    ...
  end
end
 
class SongController < ApplicationController
  def destroy
    ...
 
    SongRepository.destroy(params[:id])
 
    ...
  end
end

여기서 한 일은 조회 로직을 테스트 가능한 하나의 클래스로 고립시킨 것입니다. 또한 모델은 더 이상 스코프와 조회 로직에 신경 쓸 필요가 없어졌고, 컨트롤러와 모델은 얇아졌으며, 모두가 행복합니다. 그렇죠? 음, 사실 여전히 Active Record가 무거운 짐을 지고 있습니다. 위 시나리오에서 사용한 find는 다음과 같은 SQL을 생성합니다.

SELECT "songs".* FROM "songs" WHERE "songs"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]

이론적으로 '올바른' 방식은 이 모든 것이 SongRepository 안에 정의되어야 합니다. 하지만 앞서 말씀드린 대로 저는 그렇게 하는 것을 권하지 않습니다. 굳이 필요 없고, 오히려 통제권을 잃게 되니까요. Active Record에서 벗어나는 것이 합리적인 경우는, Active Record로는 쉽게 지원되지 않는 복잡한 SQL 트릭이 꼭 필요할 때 정도입니다.

Raw SQL과 Active Record 이야기를 하다 보니 한 가지 주제를 빼놓을 수 없겠네요. 바로 마이그레이션, 그리고 올바른 마이그레이션 작성법입니다. 자세히 들어가 보겠습니다.

마이그레이션 — 누가 신경 쓰나요?

마이그레이션을 작성할 때 종종 듣는 논거가 있습니다. "마이그레이션 코드는 애플리케이션의 다른 부분만큼 잘 작성될 필요가 없다"는 주장인데요. 솔직히 저는 이 논리가 영 마음에 들지 않습니다. 사람들은 '한 번 실행되고 잊혀질 코드'라는 핑계로 마이그레이션 안에 냄새 나는 코드를 넣곤 합니다. 소수의 인원이 서로 끊임없이 동기화하며 일하는 팀이라면 어쩌면 맞는 말일지도 모르죠.

하지만 현실은 다른 경우가 많습니다. 애플리케이션은 각 파트의 동작 방식을 모르는 더 많은 사람들이 함께 사용할 수 있습니다. 의심스러운 일회성 코드를 마이그레이션에 넣으면, 깨진 데이터베이스 상태나 이상한 마이그레이션 때문에 누군가의 개발 환경을 몇 시간 동안 망가뜨릴 수 있습니다. 이것이 엄밀한 의미의 안티패턴인지는 확실치 않지만, 반드시 인지하고 있어야 할 부분입니다.

그렇다면 마이그레이션을 다른 팀원들에게 더 편리하게 만들려면 어떻게 해야 할까요? 프로젝트의 모든 사람에게 도움이 되는 실전 팁 목록을 하나씩 살펴보겠습니다.

항상 down 메서드를 제공하세요

언제 롤백이 필요해질지 아무도 모릅니다. 만약 마이그레이션이 되돌릴 수 없는 구조라면, 다음과 같이 ActiveRecord::IrreversibleMigration 예외를 명시적으로 발생시키세요.

def down
  raise ActiveRecord::IrreversibleMigration
end

마이그레이션에서 Active Record 사용을 피하세요

핵심 아이디어는 마이그레이션 실행 시점의 데이터베이스 상태 외부 의존성을 최소화하는 것입니다. 그러면 여러분의 하루를 망치거나(혹은 구원해줄지도 모르지만) Active Record 유효성 검증의 개입 없이, 순수한 SQL만으로 작업할 수 있습니다. 예를 들어 특정 아티스트의 모든 곡을 발행(published) 처리하는 마이그레이션을 작성해 보겠습니다.

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end
 
  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

부득이하게 Song 모델이 꼭 필요하다면, 마이그레이션 파일 내부에 모델을 직접 정의하는 방법을 권합니다. 이렇게 하면 app/models의 실제 Active Record 모델이 변경되더라도 마이그레이션은 영향을 받지 않습니다. 그럼 이 방식이 모든 면에서 완벽할까요? 다음 항목으로 넘어가 보겠습니다.

스키마 마이그레이션과 데이터 마이그레이션을 분리하세요

Rails Guides의 마이그레이션 문서를 읽어보면 다음과 같은 설명이 나옵니다.

마이그레이션은 시간이 지남에 따라 데이터베이스 스키마를 진화시킬 수 있게 해주는 Active Record의 기능입니다. 순수 SQL로 스키마 수정을 작성하는 대신, 마이그레이션은 Ruby DSL을 사용해 테이블의 변경 사항을 기술할 수 있게 해줍니다.

가이드 요약 어디에도 데이터베이스 테이블의 실제 데이터를 수정하는 내용은 언급되어 있지 않습니다. 오직 구조(schema)에 대한 이야기만 있죠. 따라서 앞선 예제에서 일반 마이그레이션으로 곡 데이터를 업데이트했던 것은 완전히 올바른 방법이 아니었던 셈입니다.

프로젝트에서 이런 작업을 정기적으로 수행해야 한다면 data_migrate 사용을 고려해 보세요. 데이터 마이그레이션을 스키마 마이그레이션과 깔끔하게 분리해주는 훌륭한 도구입니다. 앞선 예제도 손쉽게 다시 작성할 수 있습니다. 데이터 마이그레이션을 생성하려면 다음 명령어를 실행합니다.

bin/rails generate data_migration update_artists_songs_to_published

그리고 생성된 파일에 마이그레이션 로직을 추가하면 됩니다.

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end
 
  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

이렇게 하면 모든 스키마 마이그레이션은 db/migrate 디렉터리에, 데이터를 다루는 마이그레이션은 db/data 디렉터리에 깔끔하게 정리됩니다.

마치며

Rails에서 모델을 다루고 가독성을 유지하는 일은 끊임없는 싸움입니다. 이번 글을 통해 흔히 마주치는 문제들의 함정과 해결책을 엿볼 수 있었기를 바랍니다. 물론 이 글에서 다룬 모델 패턴과 안티패턴 목록이 전부는 아니지만, 최근 제가 주목한 가장 중요한 사례들입니다.

Rails 패턴과 안티패턴에 더 관심이 있다면 시리즈의 다음 편을 기대해 주세요. 다음 포스트에서는 Rails MVC의 뷰(view)와 컨트롤러(controller) 쪽에서 자주 발생하는 문제들과 해결책을 다룰 예정입니다.

다음에 또 만나요!

P.S. Ruby Magic 포스트가 발행되는 즉시 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!