Backend/RubyOnRails

[๋ฒˆ์—ญ] Ruby on Rails Controller Patterns and Anti-patterns(Ruby On Rails์˜ ์ปจํŠธ๋กค๋Ÿฌ์˜ ํŒจํ„ด๊ณผ ์•ˆํ‹ฐํŒจํ„ด)

Seyun(Marco) 2024. 1. 12. 00:20
728x90

๐Ÿ’ก ์›๋ณธ๊ธ€ : https://blog.appsignal.com/2021/04/14/ruby-on-rails-controller-patterns-and-anti-patterns.html

 

Ruby on Rails Controller Patterns and Anti-patterns | AppSignal Blog

In this part of the series on Rails patterns and anti-patterns, we are going to analyze the final part of the MVC (Model-View-Controller) design pattern — the Controller.

blog.appsignal.com

 

Ruby On Rails ํŒจํ„ด ๋ฐ ์•ˆํ‹ฐ ํŒจํ„ด ์‹œ๋ฆฌ์ฆˆ์˜ ๋„ค ๋ฒˆ์งธ ํŽธ์— ์˜ค์‹ ๊ฑธ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค.

์ด์ „์— ํŒจํ„ด๊ณผ ์•ˆํ‹ฐํŒจํ„ด์— ๋Œ€ํ•ด ์ „๋ฐ˜์ ์œผ๋กœ ์‚ดํŽด๋ณด๊ณ  Models๊ณผ Views์™€ ๊ด€๋ จ๋œ ๋‚ด์šฉ์„ ๋‹ค๋ค˜์Šต๋‹ˆ๋‹ค. ์ด๋ฒˆ ๊ธ€์—์„œ๋Š” MVCํŒจํ„ด์˜ ๋งˆ์ง€๋ง‰ ๋ถ€๋ถ„์ธ Controller์— ๋Œ€ํ•ด ๋ถ„์„ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. Rails Controller์™€ ๊ด€๋ จ๋œ ํŒจํ„ด๊ณผ ์•ˆํ‹ฐํŒจํ„ด์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

์ตœ์ „์„ ์—์„œ

Ruby On Rails๋Š” ์›น ํ”„๋ ˆ์ž„์›Œํฌ์ด๋ฏ€๋กœ HTTP ์š”์ฒญ์€ ๋งค์šฐ ์ค‘์š”ํ•œ ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ์ข…๋ฅ˜์˜ ํด๋ผ์ด์–ธํŠธ๋Š” ์š”์ฒญ์„ ํ†ตํ•ด Rails ๋ฐฑ์—”๋“œ์— ๋„๋‹ฌํ•˜๋ฉฐ, ๋ฐ”๋กœ ์ด ์ง€์ ์—์„œ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋น›์„ ๋ฐœํ•ฉ๋‹ˆ๋‹ค. ์ปจํŠธ๋กค๋Ÿฌ๋Š” ์š”์ฒญ์„ ์ˆ˜์‹ ํ•˜๊ณ  ์ฒ˜๋ฆฌํ•˜๋Š” ์ตœ์ „์„ ์— ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์ปจํŠธ๋กค๋Ÿฌ๋Š” Ruby On Rails ํ”„๋ ˆ์ž„์›Œํฌ์˜ ๊ธฐ๋ณธ์ ์ธ ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค. ๋ฌผ๋ก  ์ปจํŠธ๋กค๋Ÿฌ๋ณด๋‹ค ๋จผ์ € ๋‚˜์˜ค๋Š” ์ฝ”๋“œ๋„ ์žˆ์ง€๋งŒ ์ปจํŠธ๋กค๋Ÿฌ ์ฝ”๋“œ๋Š” ์šฐ๋ฆฌ๊ฐ€ ๋Œ€๋ถ€๋ถ„ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋Š” ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.

์ผ๋‹จ “config/routes.rb”์—์„œ ๊ฒฝ๋กœ๋ฅผ ์ง€์ •ํ•˜๊ณ  ์„ค์ •๋œ ๊ฒฝ๋กœ๋กœ ์„œ๋ฒ„๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋‚˜๋จธ์ง€๋Š” ํ•ด๋‹น ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์•Œ์•„์„œ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์•ž์˜ ๋ฌธ์žฅ์„ ์ฝ์œผ๋ฉด ๋ชจ๋“  ๊ฒƒ์ด ๊ฐ„๋‹จํ•˜๋‹ค๋Š” ์ธ์ƒ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์‹ค์ œ๋กœ๋Š” ๋งŽ์€ ๋ถ€๋ถ„์ด ์ปจํŠธ๋กค๋Ÿฌ์˜ ์–ด๊นจ์— ๋‹ฌ๋ ค ์žˆ์Šต๋‹ˆ๋‹ค. ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๋ถ€์—ฌ์— ๋Œ€ํ•œ ๋ฌธ์ œ๊ฐ€ ์žˆ๊ณ , ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋ฐฉ๋ฒ•๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•˜๋Š” ์œ„์น˜ ๋ฐ ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

์ปจํŠธ๋กค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์šฐ๋ ค์™€ ์ฑ…์ž„์€ ๋ช‡๊ฐ€์ง€ ์•ˆํ‹ฐํŒจํ„ด์œผ๋กœ ์ด์–ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐ€์žฅ ์œ ๋ช…ํ•œ ์•ˆํ‹ฐํŒจํ„ด์€ ๋šฑ๋šฑํ•œ(fat) ์ปจํŠธ๋กค๋Ÿฌ ์•ˆํ‹ฐํŒจํ„ด ์ž…๋‹ˆ๋‹ค.

๋šฑ๋šฑํ•œ(๋น„๋งŒ) ์ปจํŠธ๋กค๋Ÿฌ

์ปจํŠธ๋กค๋Ÿฌ์— ๋„ˆ๋ฌด ๋งŽ์€ ๋กœ์ง์„ ๋„ฃ์„๋•Œ ์ƒ๊ธฐ๋Š” ๋ฌธ์ œ์ ์€ ๋‹จ์ผ ์ฑ…์ž„ ์›์น™(SRP)์„ ์œ„๋ฐ˜ํ•˜๊ธฐ ์‹œ์ž‘ํ•œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์ปจํŠธ๋กค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ๋„ˆ๋ฌด ๋งŽ์€ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด ์ข…์ข… ๋งŽ์€ ์ฝ”๋“œ์™€ ์ฑ…์ž„์ด ์Œ“์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ “fat(๋šฑ๋šฑํ•œ)”์ด๋ผ๋Š” ์˜๋ฏธ๋Š” ์ปจํŠธ๋กค๋Ÿฌ ํŒŒ์ผ์— ํฌํ•จ๋œ ๊ด‘๋ฒ”์œ„ํ•œ ์ฝ”๋“œ์™€ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ง€์›ํ•˜๋Š” ๋กœ์ง์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” ์ข…์ข… ์•ˆํ‹ฐํŒจํ„ด์œผ๋กœ ๊ฐ„์ฃผ๋ฉ๋‹ˆ๋‹ค.

์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋ฌด์—‡์„ ํ•ด์•ผ ํ•˜๋Š”์ง€์— ๋Œ€ํ•ด์„œ๋Š” ๋งŽ์€ ์˜๊ฒฌ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๊ฐ€์ ธ์•ผ ํ•  ์ฑ…์ž„์˜ ๊ณตํ†ต์ ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  • ์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๋ถ€์—ฌ - ์š”์ฒญ์ž์™€ ๋งคํ•‘๋œ ์—”ํ‹ฐํ‹ฐ(๋ณดํ†ต์€ ์‚ฌ์šฉ์ž)๊ฐ€ ๋ณธ์ธ์ธ์ง€, ๋ฆฌ์†Œ์Šค์— ์•ก์„ธ์Šคํ•˜๊ฑฐ๋‚˜ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๊ถŒํ•œ์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ์€ ์„ธ์…˜์ด๋‚˜ ์ฟ ํ‚ค์— ์ €์žฅ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์ง€๋งŒ, ์ปจํŠธ๋กค๋Ÿฌ๋Š” ์ธ์ฆ ๋ฐ์ดํ„ฐ๊ฐ€ ์—ฌ์ „ํžˆ ์œ ํšจํ•œ์ง€ ํ™•์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ - ์š”์ฒญ๊ณผ ํ•จ๊ป˜ ์ œ๊ณต๋œ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์˜ฌ๋ฐ”๋ฅธ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ๊ธฐ ์œ„ํ•œ ๋กœ์ง์„ ํ˜ธ์ถœํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์™„๋ฒฝํ•œ ์„ธ๊ณ„์—์„œ๋Š” ๋ชจ๋“  ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” ํ•˜๋‚˜์˜ ๋ฉ”์„œ๋“œ์— ๋Œ€ํ•œ ํ˜ธ์ถœ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ปจํŠธ๋กค๋Ÿฌ๋Š” ๋ฌด๊ฑฐ์šด ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ด์„œ๋Š” ์•ˆ๋˜๋ฉฐ, ๋” ๋งŽ์€ ์ž‘์—…์„ ์œ„์ž„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  • ํ…œํ”Œ๋ฆฟ ๋žœ๋”๋ง - ๋งˆ์ง€๋ง‰์œผ๋กœ, ์ ์ ˆํ•œ ํ˜•์‹(HTML, JSON ๋“ฑ)์œผ๋กœ ๊ฒฐ๊ณผ๋ฅผ ๋žœ๋”๋งํ•˜์—ฌ ์˜ฌ๋ฐ”๋ฅธ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋˜๋Š” ๋‹ค๋ฅธ ๊ฒฝ๋กœ๋‚˜ URL๋กœ ๋ฆฌ๋‹ค์ด๋ ‰์…˜์„ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ด๋Ÿฌํ•œ ์ƒ๊ฐ์„ ๋”ฐ๋ฅด๋ฉด ์ผ๋ฐ˜์ ์œผ๋กœ ์ปจํŠธ๋กค๋Ÿฌ ์•ก์…˜๊ณผ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ๋„ˆ๋ฌด ๋งŽ์€ ์ผ์ด ์ผ์–ด๋‚˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ปจํŠธ๋กค๋Ÿฌ ์ˆ˜์ค€์—์„œ ๋‹จ์ˆœํ•˜๊ฒŒ ์œ ์ง€ํ•˜๋ฉด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ๋‹ค๋ฅธ ์˜์—ญ์— ์ž‘์—…์„ ์œ„์ž„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฑ…์ž„์„ ์œ„์ž„ํ•˜๊ณ  ํ•˜๋‚˜์”ฉ ํ…Œ์ŠคํŠธํ•˜๋ฉด ์•ฑ์„ ๊ฒฌ๊ณ ํ•˜๊ฒŒ ๊ฐœ๋ฐœํ• ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฌผ๋ก  ์œ„์˜ ์›์น™์„ ๋”ฐ๋ฅผ์ˆ˜๋„ ์žˆ์ง€๋งŒ, ๋ช‡ ๊ฐ€์ง€ ์˜ˆ์‹œ๋ฅผ ๋“ค์–ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด์ œ ์–ด๋–ค ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜์—ฌ ์ปจํŠธ๋กค๋Ÿฌ์˜ ๋ถ€๋‹ด์„ ๋œ์–ด์ค„ ์ˆ˜ ์žˆ๋Š”์ง€ ์‚ดํŽด๋ด…์‹œ๋‹ค.

Query Objects

์ปจํŠธ๋กค๋Ÿฌ ์•ก์…˜ ๋‚ด๋ถ€์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ ์ค‘ ํ•˜๋‚˜๋Š” ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•œ ์ง€๋‚˜์นœ ์ฟผ๋ฆฌ์ž…๋‹ˆ๋‹ค. Rails Model anti-patterns and patterns(๋ฒˆ์—ญ๋ณธ) ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŒ…์„ ๋ณด์…จ๋‹ค๋ฉด, ๋ชจ๋ธ์— ๋„ˆ๋ฌด ๋งŽ์€ ์ฟผ๋ฆฌ ๋กœ์ง์ด ์žˆ๋Š” ๋น„์Šทํ•œ ๋ฌธ์ œ๋ฅผ ๊ฒช์€ ์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด๋ฒˆ์—๋Š” Query Object ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. Query Object๋Š” ๋ณต์žกํ•œ ์ฟผ๋ฆฌ๋ฅผ ํ•˜๋‚˜์˜ ๊ฐ์ฒด๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ธฐ๋ฒ•์ž…๋‹ˆ๋‹ค.

๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์ฟผ๋ฆฌ ๊ฐ์ฒด๋Š” “ActiveRecord” ๊ด€๊ณ„๋กœ ์ดˆ๊ธฐํ™” ๋˜๋Š” ํ‰๋ฒ”ํ•œ ๋ฃจ๋น„ ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ์ฟผ๋ฆฌ ๊ฐ์ฒด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

# 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

You can also try out another approach of the query object:

๋˜ํ•œ ๋„ˆ๋Š” Query Object๋ฅผ ๋‹ค๋ฅธ ๋ฐฉ์‹์œผ๋กœ ์ž‘์„ฑํ•ด๋ณผ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

rb

# 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

ํ›„์ž์˜ ์ ‘๊ทผ ๋ฐฉ์‹์€ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์„ ํƒ์‚ฌํ•ญ์œผ๋กœ ๋งŒ๋“ค์–ด Query Object๋ฅผ ๋” ๊ฐ•๋ ฅํ•˜๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๋˜ํ•œ ์ด์ œ AllSongQuery.new.call์„ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฐฉ๋ฒ•์ด ๋งˆ์Œ์— ๋“ค์ง€ ์•Š๋Š”๋‹ค๋ฉด Class Method๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Class Method๋กœ ์ž‘์„ฑํ•˜๋ฉด ๋” ์ด์ƒ Object๋Š” ์•„๋‹ˆ์ง€๋งŒ, ๊ฐœ์ธ์ ์ธ ์ทจํ–ฅ ์ฐจ์ด์ผ ๋ฟ์ž…๋‹ˆ๋‹ค. ์˜ˆ์‹œ๋ฅผ ์œ„ํ•ด makeAllSongsQuery๋ฅผ ๋” ๊ฐ„๋‹จํ•˜๊ฒŒ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

# 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์™€ ํ•จ๊ป˜ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์–ด๋–ค ์ด์œ ๋กœ ๋ณ€๊ฒฝํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ ์ดˆ๊ธฐ ๋ฒ”์œ„๋ฅผ ์ „๋‹ฌํ• ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. QueryClass๋ฅผ ํ†ตํ•ด new๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์„ ์ •๋ง ํ”ผํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ์ด ํŠธ๋ฆญ์„ ์‹œ๋„ํ•ด๋ณด์„ธ์š”.

# app/queries/application_query.rb

class ApplicationQuery
  def self.call(*params)
    new(*params).call
  end
end

ApplicationQuery”๋ฅผ ๋งŒ๋“ค์–ด์„œ, ์ด๊ฑธ ์ƒ์†๋ฐ›์•„์„œ ๋˜ ๋‹ค๋ฅธ QueryClass๋ฅผ ๋งŒ๋“ค์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
  ...
end

์—ฌ์ „ํžˆ “AllSongsQuery.call”์„ ์œ ์ง€ํ–ˆ์ง€๋งŒ, ๋” ์šฐ์•„ํ•˜๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

Query Object์˜ ์žฅ์ ์€ ๊ฐ์ฒด๋ฅผ ๊ฐœ๋ณ„์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•˜๊ณ  ๊ฐ์ฒด๊ฐ€ ์ œ๋Œ€๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ ์ด๋Ÿฌํ•œ Query Class๋ฅผ ํ™•์žฅํ•˜์—ฌ ์ปจํŠธ๋กค๋Ÿฌ์˜ ๋กœ์ง์— ๋Œ€ํ•ด ํฌ๊ฒŒ ๊ฑฑ์ •ํ•˜์ง€ ์•Š๊ณ  ํ…Œ์ŠคํŠธ๋ฅผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•œ ๊ฐ€์ง€ ์ฃผ์˜ํ•  ์ ์€ ์š”์ฒญ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ Query Object์— ์˜์กดํ•˜์ง€ ๋ง๊ณ  ๋‹ค๋ฅธ ๊ณณ์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ์ƒ๊ฐํ•˜์‹œ๋‚˜์š”? Query Object๋ฅผ ์‚ฌ์šฉํ•ด ๋ณด์‹œ๊ฒ ์–ด์š”?

Ready To Serve

์ง€๊ธˆ๊นŒ์ง€ 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

์ด์ œ ๋‘ ๊ฐœ์˜ ์„œ๋น„์Šค๊ฐ€ ๋ชจ๋“  ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๊ณ  ์ปจํŠธ๋กค๋Ÿฌ๋Š” ์ด๋ฅผ ํ˜ธ์ถœํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ๊ฐœ๋ณ„์ ์œผ๋กœ ํ…Œ์ŠคํŠธ ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ๋ฌธ์ œ๋Š” ์„œ๋น„์Šค๊ฐ„์˜ ๋ช…ํ™•ํ•œ ์—ฐ๊ฒฐ์ด ์—†๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋ก ์ ์œผ๋กœ๋Š” ๋ชจ๋‘ ํ•˜๋‚˜์˜ ๋น„์ฆˆ๋‹ˆ์Šค ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ดํ•ด๊ด€๊ณ„์ž์˜ ๊ด€์ ์—์„œ ์ถ”์ƒํ™” ์ˆ˜์ค€์„ ๊ณ ๋ คํ•œ๋‹ค๋ฉด, ์˜์ˆ˜์ฆ์„ ์ƒ์„ฑํ•˜๋Š” ์ž‘์—…์€ ์˜์ˆ˜์ฆ์„ ์ด๋ฉ”์ผ๋กœ ๋ณด๋‚ด๋Š” ๊ฒƒ๊ณผ ๊ด€๋ จ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋ˆ„๊ตฌ์˜ ์ถ”์ƒํ™” ์ˆ˜์ค€์ด ์˜ณ์€๊ฐ€์š”?

์ด ์‚ฌ๊ณ  ์‹คํ—˜์„ ์ข€ ๋” ๋ณต์žกํ•˜๊ฒŒ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ์˜์ˆ˜์ฆ์„ ์ƒ์„ฑํ•˜๋Š” ๋™์•ˆ ์˜์ˆ˜์ฆ์˜ ์ด์•ก์„ ๊ณ„์‚ฐํ•˜๊ฑฐ๋‚˜ ์–ด๋”˜๊ฐ€์—์„œ ๊ฐ€์ ธ์™€์•ผ ํ•œ๋‹ค๋Š” ์š”๊ตฌ ์‚ฌํ•ญ์„ ์ถ”๊ฐ€ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ• ๊นŒ์š”? ์ดํ•ฉ์˜ ํ•ฉ๊ณ„๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋‹ค๋ฅธ ์„œ๋น„์Šค๋ฅผ ์ž‘์„ฑํ•ด์•ผ ํ• ๊นŒ์š”? ์ •๋‹ต์€ ๋‹จ์ผ ์ฑ…์ž„ ์›์น™(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
end

SRP๋ฅผ ๋”ฐ๋ฆ„์œผ๋กœ์จ ์šฐ๋ฆฌ๋Š” ์„œ๋น„์Šค๋ฅผ “ReceiptCreation” ํ”„๋กœ์„ธ์Šค์™€ ๊ฐ™์€ ๋” ํฐ ์ถ”์ƒํ™”๋กœ ํ•จ๊ป˜ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด class๋ฅผ ๋งŒ๋“ค๋ฉด ํ”„๋กœ์„ธ์Šค๋ฅผ ์™„๋ฃŒํ•˜๋Š” ๋ฐ ํ•„์š”ํ•œ ๋ชจ๋“  ์ž‘์—…์„ ๊ทธ๋ฃนํ™” ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์•„์ด๋””์–ด์— ๋Œ€ํ•ด ์–ด๋–ป๊ฒŒ ์ƒ๊ฐํ•˜์‹œ๋‚˜์š”? ์ฒ˜์Œ์—๋Š” ๋„ˆ๋ฌด ์ถ”์ƒ์ ์œผ๋กœ ๋“ค๋ฆด ์ˆ˜๋„ ์žˆ์ง€๋งŒ, ์ด๋Ÿฌํ•œ ์•ก์…˜์„ ์—ฌ๊ธฐ์ €๊ธฐ์„œ ํ˜ธ์ถœํ•  ๋•Œ ์œ ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฐฉ๋ฒ•์ด ๋งˆ์Œ์— ๋“œ์‹ ๋‹ค๋ฉด “์„ ๊ตฌ์ž์˜ ์ž‘์ „”์„ ํ™•์ธํ•ด๋ณด์„ธ์š”.

์š”์•ฝํ•˜์ž๋ฉด, ์ƒˆ๋กœ์šด “CalculateReceiptTotalService”๋Š” ๋ชจ๋“  ์ˆซ์ž์˜ ๊ณ„์‚ฐ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜์ˆ˜์ฆ์„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ๊ธฐ๋กํ•˜๋Š” ๊ฒƒ์€ “CreateReceiptService”๊ฐ€ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. ์˜์ˆ˜์ฆ์— ๋Œ€ํ•œ ์ด๋ฉ”์ผ์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐœ์†กํ•˜๋Š” ๊ฒƒ์€ “SendReceiptService”๊ฐ€ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ์ž‘๊ณ  ์ง‘์ค‘๋œ ํด๋ž˜์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋‹ค๋ฅธ ์‚ฌ์šฉ ์‚ฌ๋ก€์—์„œ ์‰ฝ๊ฒŒ ๊ฒฐํ—™ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์œ ์ง€ ๊ด€๋ฆฌ๊ฐ€ ์‰ฝ๊ณ  ์ฝ”๋“œ๋ฒ ์ด์Šค๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฌ์›Œ์ง‘๋‹ˆ๋‹ค.

์„œ๋น„์Šค ๋ฐฐ๊ฒฝ ์ด์•ผ๊ธฐ

๋ฃจ๋น„ ์„ธ๊ณ„์—์„œ๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ ‘๊ทผ ๋ฐฉ์‹์„ ์•ก์…˜, ์—ฐ์‚ฐ ๋“ฑ์œผ๋กœ๋„ ๋ถ€๋ฆ…๋‹ˆ๋‹ค. ์ด ๋ชจ๋“  ๊ฒƒ์ด **Command ํŒจํ„ด**์œผ๋กœ ์„ค๋ช…๋ฉ๋‹ˆ๋‹ค.

  • Command์˜ ์ด๋ฆ„
  • Command ๊ฐ์ฒด/ํด๋ž˜์Šค์—์„œ ํ˜ธ์ถœํ•  ๋ฉ”์„œ๋“œ ์ด๋ฆ„
  • ๋ฉ”์„œ๋“œ ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌํ•  ๊ฐ’

๋”ฐ๋ผ์„œ ์ด ๊ฒฝ์šฐ Command๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์€ ์ปจํŠธ๋กค๋Ÿฌ์ž…๋‹ˆ๋‹ค. ์ ‘๊ทผ ๋ฐฉ์‹์€ ๋งค์šฐ ์œ ์‚ฌํ•˜์ง€๋งŒ Ruby์˜ ์ด๋ฆ„์ด ์„œ๋น„์Šค๋ผ๋Š” ์ ๋งŒ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.

์ž‘์—… ๋ถ„ํ• ํ•˜๊ธฐ

๋งŒ์•ฝ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ผ๋ถ€ ๋‹ค๋ฅธ ์„œ๋น„์Šค๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  ์žˆ๋Š”๋ฐ ํ•ด๋‹น ์„œ๋น„์Šค๊ฐ€ ๋žœ๋”๋ง์„ ์ฐจ๋‹จํ•˜๋Š” ๊ฒฝ์šฐ ์ด๋Ÿฌํ•œ ํ˜ธ์ถœ์„ ์ถ”์ถœํ•˜์—ฌ ๋‹ค๋ฅธ ์ปจํŠธ๋กค๋Ÿฌ ์•ก์…˜์œผ๋กœ ๋ณ„๋„๋กœ ๋žœ๋”๋งํ•ด์•ผ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ๋“ค์–ด ์ฑ… ์ •๋ณด๋ฅผ ๋žœ๋”๋ง ํ•˜๊ณ  Goodreads์™€ ๊ฐ™์ด ์‹ค์ œ๋กœ ์ œ์–ด๋ฅผ ํ•  ์ˆ˜ ์—†๋Š” ๋‹ค๋ฅธ ์„œ๋น„์Šค์—์„œ ํ‰์ ์„ ๊ฐ€์ ธ์˜ค๋ ค๊ณ  ํ•  ๋•Œ๋ฅผ ๋“ค์ˆ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

# app/controllers/books_controller.rb

class BooksController < ApplicationController
  def show
    @book = Book.find(params[:id])

    @rating = GoodreadsRatingService.new(book).call
  end
end

Goodreads์˜ ์„œ๋ฒ„๊ฐ€ ๋‹ค์šด๋˜์—ˆ๊ฑฐ๋‚˜ ์ด์™€ ์œ ์‚ฌํ•œ ์ƒํ™ฉ์œผ๋กœ ์žฅ์• ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์‚ฌ์šฉ์ž๋Š” Goodreads ์„œ๋ฒ„์— ๋Œ€ํ•œ ์š”์ฒญ์ด ์‹œ๊ฐ„ ์ดˆ๊ณผ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ ค์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋˜๋Š” ์„œ๋ฒ„์— ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ํŽ˜์ด์ง€๊ฐ€ ๋Š๋ฆฌ๊ฒŒ ๋กœ๋“œ๋ฉ๋‹ˆ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํƒ€์‚ฌ ์„œ๋น„์Šค ํ˜ธ์ถœ์„ ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ถ”์ถœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

# 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 path๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ํ•˜์ง€๋งŒ, show ์•ก์…˜์—์„œ๋Š” ๋”์ด์ƒ ๋ฌธ์ œ๊ฐ€ ๋  ๋ถ€๋ถ„์ด ์—†์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ “book_rating”์ด๋ผ๋Š” partial๋„ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์ด ์ž‘์—…์„ ๋” ์‰ฝ๊ฒŒ ์ˆ˜ํ–‰ํ•˜๋ ค๋ฉด render_async gem์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฑ…์˜ ๋“ฑ๊ธ‰์„ ๋žœ๋”๋งํ•˜๋Š” ์œ„์น˜์— ๋‹ค์Œ ๋ฌธ์žฅ๋งŒ ๋„ฃ๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

erb

<%= render_async book_rating_path %>

์‹ค์ œ rating์„ ๋žœ๋”๋ง ํ•˜๊ธฐ ์œ„ํ•œ GTML์„ book_rating partial๋กœ ์ถ”์ถœํ•ด ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค.

<%= content_for :render_async %>

๋˜๋Š”, ์›ํ•˜๋Š” ๊ฒฝ์šฐ Basecamp์˜ Turbo Frames๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•„์ด๋””์–ด๋Š” ๋™์ผํžˆ์ž๋งŒ ๋งˆํฌ์—…์œผ๋กœ “<turbo-frame>”์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์‚ฌ์šฉํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>

์–ด๋–ค ์„ ํƒ์„ ํ•˜๋“ , ๊ธฐ๋ณธ์ ์œผ๋กœ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ๋ฌด๊ฒ๊ฑฐ๋‚˜ ๊ฐ€๋ฒผ์šด ์ž‘์—…์„ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ฐ€๋Šฅํ•œ ํ•œ ๋นจ๋ฆฌ ์‚ฌ์šฉ์ž์—๊ฒŒ ํŽ˜์ด์ง€๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

๋งˆ์ง€๋ง‰ ์ƒ๊ฐ

์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ๋‹ค๋ฅธ ๋ฉ”์†Œ๋“œ์˜ ํ˜ธ์ถœ์ž(callers)๋กœ๋งŒ ์ƒ๊ฐ€ํ•œ๋‹ค๋ฉด ์ด ํฌ์ŠคํŒ…์„ ํ†ตํ•ด ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ์–‡๊ฒŒ ์œ ์ง€ํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ์ƒˆ๋กœ์šด ๊ด€์ ์„ ์–ป์„ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋ฌผ๋ก  ์—ฌ๊ธฐ์„œ ์–ธ๊ธ‰ํ•œ ๋ช‡ ๊ฐ€์ง€ ํŒจํ„ด๊ณผ ์•ˆํ‹ฐ ํŒจํ„ด์ด ๋‹ค ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. ๋” ๋‚˜์€ ๋ฐฉ๋ฒ•์ด๋‚˜ ์„ ํ˜ธํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ์•„์ด๋””์–ด๊ฐ€ ์žˆ๋‹ค๋ฉด ํŠธ์›Œํ„ฐ๋กœ ์—ฐ๋ฝํ•ด์ฃผ์‹œ๋ฉด ํ•จ๊ป˜ ๋…ผ์˜ํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

์ด ์‹œ๋ฆฌ์ฆˆ๋ฅผ ๊ณ„์† ์ง€์ผœ๋ด ์ฃผ์‹œ๊ธฐ ๋ฐ”๋ผ๋ฉฐ, ์•ž์œผ๋กœ ํ•œ ๋ฒˆ ๋” ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŒ…์„ ํ†ตํ•ด ๊ณตํ†ต Rails ๋ฌธ์ œ์™€ ์ด๋ฒˆ ์‹œ๋ฆฌ์ฆˆ์—์„œ ์–ป์€ ์‹œ์‚ฌ์ ์„ ์ •๋ฆฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๋‹ค์Œ ์‹œ๊ฐ„๊นŒ์ง€ ํ™”์ดํŒ…

PS. Ruby Magic ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด๋„๋˜๋Š” ๋Œ€๋กœ ์ฝ๊ณ  ์‹ถ์œผ์‹œ๋ฉด Ruby Magic ๋‰ด์Šค๋ ˆํ„ฐ๋ฅผ ๊ตฌ๋…ํ•˜์‹œ๊ณ  ๋‹จ ํ•˜๋‚˜์˜ ๊ฒŒ์‹œ๋ฌผ๋„ ๋†“์น˜์ง€ ๋งˆ์„ธ์š”!

728x90
728x90