본문 바로가기

소프트웨어-이야기/프로그래밍 언어와 프레임워크

레일즈에 Service/Decorator Layer 적용하기(1) - MVC 패턴

이 포스팅은 Build Sleek Rails Components With Plain Old Ruby Objects을 정리한 글입니다 :)


시작


루비온레일즈는 사용자를 필요로하는 서비스를 빠르게 앱을 만들고, 검증할 수 있다는 장점이 있습니다.

그러나 시스템 규모가 커지면 코드는 점점 거대해지고, 복잡해지게 됩니다. 그러다보면 모델이 너무 뚱뚱해져서, MVC에서 말하는 "fat model, skinny controller" 패턴을 버리고 싶어지기도 합니다.

그러나 루비온레일즈를 계속 사용해야하는 경우 어떻게 해야할까요? 

 

이 문제에 대한 답으로 OOP 개념을 사용해서, 코드의 커플링을 줄여주고, 가독성있게 변경하는 방법에 대해서 설명하고자 합니다. 

 

어떤 경우에 Service / Decorator Layer를 사용해야할까요?



저자는 리팩토링을 할 때, 리팩토링을 할만한 앱인지 판단하는 몇가지 기준이 있다고 합니다. 그 기준은 아래와 같습니다. 


1. 단위 테스트를 돌리는게 느릴때 

코드가 독립적으로 구성되어있는 PORO Unit Test는 보통 테스트가 빨리 돌아갑니다. 그러나 테스트를 돌리는게 오래 걸리는 코드는 클래스가 여러 책임을 갖고 있거나, 안좋은 설계를 암시하는 경우가 많습니다. 


2. 모델과 컨트롤러가 뚱뚱해진 경우

모델과 컨트롤러의 라인이 200줄을 넘는 경우, 리팩토링 할만 한 코드라고 볼 수 있습니다.


3. 코드가 너무 많을 때 

HTML / ERB 코드가 30,000 라인이 넘고, 루비 소스코드가 50,000 라인이 넘는 경우, 리팩토링 할만 한 코드라고 볼 수 있습니다.


레일즈 프로젝트에서 아래의 명령문을 실행하면 app 폴더의 라인 수를 알 수 있습니다. 


find app -iname "*.rb" -type f -exec cat {} \;| wc -l


웹 애플리케이션 디자인 패턴



이 글에서는 기존의 MVC 패턴에 서비스, 데코레이터 레이어를 추가하는 디자인 패턴에 대해서 설명할 예정입니다. 그전에 앞서, 두 디자인 패턴의 시퀀스 다이어그램을 살펴보겠습니다.

MVC ( Model + View + Controller )


MVC 패턴으로 구성된 앱에서 HTTP Request는 위에 있는 시퀀스 다이어그램처럼 처리됩니다.

 

1. 클라이언트에서 http://americanopeople.tistory.com/item/3746 이런식으로 API를 호출합니다.

2. 레일즈에서는 요청을 받게 되고, routes를 사용해서 어느 컨트롤러를 사용할지 결정합니다. 

3. controller는 request를 파싱하고, 데이터를 전달하고, 쿠키, 세션 등을 생성합니다.

4. 그리고 model에서 데이터를 얻습니다. model은 DB에서 데이터를 가져오는 루비 클래스를 의미합니다. 모델은 데이터를 저장하고, 데이터의 유효성체크를 합니다. 그리고 비즈니스 로직도 처리합니다.

5. view는 유저가 볼 수 있는 데이터를 내려줍니다. 보통 HTML, CSS, XML, Javascript, JSON 같은 포맷의 데이터를 내려줍니다.




MVC ( Service / Decorator Layer )

  

기존 MVC 구조에 Service, Decorator Layer를 추가하면, 이 디자인 패턴처럼 객체를 사용할 수 있습니다.

추가적인 추상화 클래스인 POLO (Plain Old Ruby Object)를 잘 사용하면, 레일즈를 잘 사용하는 것과 단일 책임 원칙 (SRP)을 모두 지킬 수 있게 됩니다. 

이 시퀀스 다이어그램에서 POLO는 Service, Decorator Class를 의미합니다. 




조깅 앱 - 예시 서비스


글에서는 mvc 패턴으로 작성된 애플리케이션을 예시로 설명하고 있습니다. 

사용자가 조깅을 한 시간을 보여주는 앱을 사례로 설명하고 있는데요. 이 앱의 주요 기능은 아래와 같습니다. 

-  앱에 접속했을 때, 유저가 달리기를 한 시간을 보여줘야 합니다. 그리고 이 페이지에는 날짜, 거리, 기간, 날씨, 평균 달리기 속도 등을 보여줘야 합니다. 

-  유저의 평균 달리기 속도, 평균 달리기 거리 등을 주마다 레포트 페이지에 보여줘야합니다. 

 - 전체의 평균 달리기 속도보다 평균 속도가 높은 유저에게 SMS으로 알려줘야합니다.

-  웹 사이트에서 달린 시간, 거리, 날짜를 직접 입력할 수 있어야 합니다. 





조깅 앱 - MVC 패턴 소스 코드 


예시로 설명하는 조깅앱의 디렉토리 구조와 소스코드는 아래와 같습니다. 


디렉토리


      ⇒  tree
    .
    ├── assets
    │   └── ...
    ├── controllers
    │   ├── application_controller.rb
    │   ├── entries_controller.rb
    │   └── statistics_controller.rb
    ├── helpers
    │   ├── application_helper.rb
    │   ├── entries_helper.rb
    │   └── statistics_helper.rb
    ├── mailers
    ├── models
    │   ├── entry.rb
    │   └── user.rb
    └── views
        ├── devise
        │   └── ...
        ├── entries
        │   ├── _entry.html.erb
        │   ├── _form.html.erb
        │   └── index.html.erb
        ├── layouts
        │   └── application.html.erb
        └── statistics
            └── index.html.erb



소스코드

models/entry.rb

class Entry < ActiveRecord::Base

    STATUS = {
      weather:  %w(Sunny Rainy Windy Dry),
      landform: %w(Beach Cliff Desert Flat)
    }

    belongs_to :user

    validates_presence_of :distance, :time_period, :date_time
    validates :status_weather, inclusion: { in: STATUS[:weather] }
    validates :status_landform, inclusion: { in: STATUS[:landform] }
    validates_numericality_of :distance, :time_period

    after_create :compare_speed_and_notify_user

    def week
      date = date_time.split(' ')[0]
      Date.strptime(date, '%m/%d/%Y').strftime('%W')
    end

    def speed
      distance / (time_period.to_f / 60)
    end

    private
    
    def compare_speed_and_notify_user
      entries_avg_speed = (Entry.all.sum(&:speed) / Entry.count).round(2)

      if speed > entries_avg_speed
        msg = 'You are doing great. Keep it up, superman. :)'
      else
        msg = 'Most of the users are faster than you. Try harder, dude! :('
      end

      NexmoClient.send_message(
        from: 'Toptal',
        to: user.mobile,
        text: msg
      )
    end
end


Entry Model ( entry.rb ) 은 앱의 비즈니스 로직이 들어가 있는 모델입니다. 그리고 각 Entry는 User 객체를 갖고 있고, distance, time_period, date_time 값에 대한 유효성 체크를 하고 있습니다. 

이 코드는 데이터 관리와 비즈니스 로직이 섞여 있습니다. 

유저가 entry를 새로 생성할 때마다, 해당 유저의 평균 속도와 전체 유저의 평균 속도를 비교하고, 전체 유저의 평균 속도보다 평균속도가 더 높은 경우 SMS로 알려주는 기능이 모델에 포함되어 있습니다. 

그리고 유효성 체크 로직도 모델에서 다루고 있습니다. 



controllers/entries_controller.rb

class EntriesController < ApplicationController
  before_action :authenticate_user!
 
  def index
    @entry   = Entry.new
    @entries = current_user.entries.order(created_at: :desc)
  end
 
  def create
    @entry = Entry.new(entry_params)
    @entry.user_id = current_user.id
    if @entry.save
      flash[:notice] = "Entry was successfully created."
    else
      flash[:error] = @entry.errors.full_messages.to_sentence
    end
 
    redirect_to root_path
  end
 
 
  def destroy
    @entry = Entry.find(params[:id])
 
    if @entry.destroy!
      flash[:notice] = "Entry was destroyed."
    else
      flash[:error] = @entry.errors.full_messages.to_sentence
    end
 
    redirect_to root_path
  end
 
 
  private
  def entry_params
    params.require(:entry).permit(:user_id, :distance, :time_period, :date_time,
:created_at, :updated_at, :distance_type, :status_weather, :status_landform)
  end
end

entries_controller에는 CRUD 액션들이 포함되어 있습니다.

index action에서는 조회하는 유저의 entry를 가져오고, 날짜 기준으로 결과물들을 정렬합니다. 그리고 create 액션은 새로운 entry를 생성하는 역할을 합니다.



controllers/statistics_controller.rb

class StatisticsController < ApplicationController
  WeeklyReport = Struct.new(
    :week_number,
    :date_from,
    :date_to,
    :count_entries,
    :avg_distance,
    :avg_speed
  )
 
  def index
    @weekly_reports = []
 
    current_user.entries.group_by(&:week).each do |week, entries|
 
      @weekly_reports << WeeklyReport.new.tap do |r|
         r.week_number   = week
         r.date_from     = weeks_to_date_from(week)
         r.date_to       = weeks_to_date_to(week)
         r.count_entries = entries.count
         r.avg_distance  = avg_distance(entries)
         r.avg_speed     = avg_speed(entries)
       end
    end
  end
 
  private
  def weeks_to_date_from(week)
    (Date.new + week.to_i.weeks).to_s.split(',')[0]
  end
 
  def weeks_to_date_to(week)
    (Date.new + week.to_i.weeks + 7.days).to_s.split(',')[0]
  end
 
  def avg_distance(entries)
    distances = entries.sum(&:distance)
 
    (distances / entries.count).round(2)
  end
 
  def avg_speed(entries)
    speeds = entries.sum(&:speed)
    (speeds / entries.count).round(2)
  end
end

statistics_controller은 주간 레포트 로직이 포함된 컨트롤러입니다. 이 컨트롤러에서는 데이터의 포맷과 관련된 로직들이 private 함수로 추출되어 있습니다. 



views/entries/index.html.erb

<!-- entries/index.html.erb -->
<div>
  <%= render 'form' %>
</div>
<hr>
<table>
    <tr>
      <th>Distance</th>
      <th>Time period</th>
      <th>Date time</th>
      <th>Avg Speed</th>
      <th>Weather</th>
      <th>Landform</th>
      <th>Created at</th>
      <th>Updated at</th>
      <th></th>
    </tr>
    <%= render @entries %>
</table>


views/entries/_entry.html.erb

<!-- entries/_entry.html.erb -->
<tr>
  <td><%= entry.distance %> <small>Km</small></td>
  <td><%= decorate(entry).readable_time_period %></td>
 
  <td><%= entry.date_time %></td>
  <td><%= readable_speed(entry) %> </td>
  <td><%= entry.status_weather %> </td>
  <td><%= entry.status_landform %> </td>
  <td><%= time_ago_in_words(entry.created_at) %></td>
  <td><%= time_ago_in_words(entry.updated_at) %></td>
  <td>
  <%= link_to "X", entry_path(entry), method: :delete %>
  </td>
</tr>


views/entries/_form.html.erb

<!-- entries/_form.html.erb -->
<div>
  <%= simple_form_for(@entry) do |f| %>
    <%= f.error_notification %>
 
    <div>
      <div>
        <div>
          <%= f.input :distance, hint: "In Kilometers." %>
        </div>
        <div>
          <%= f.input :time_period, hint: "In Minutes." %>
        </div>
        <div>
          <%= f.input :date_time %>
        </div>
        <div>
          <%= f.input :status_weather,
                collection: Entry::STATUS[:weather], selected: 1
          %>
        </div>
        <div>
          <%= f.input :status_landform,
                collection: Entry::STATUS[:landform], selected: 1
          %>
        </div>
      </div>
    </div>
 
    <br/>
    <div>
      <%= f.button :submit %>
    </div>
  <% end %>
</div> 

이 코드는 로그인된 유저의 entry 정보를 보여주는 erb 페이지입니다. 

index.html.erb에서는 partial을 이용해서, 여러 erb 파일을 불러오고 있습니다.  

그리고 아래처럼 @entry 인스턴스를 넘겨주면 _entry.html.erb 파일을 partial template으로 불러옵니다.


<%= render @entries%>

이렇게 하면, 코드의 DDY도 높아지고, 재사용도 높아집니다. 



views/statistics/index.html.erb

<!-- statistics/index.html.erb -->
<h3>Statistics</h3>
<br/>
 
<table class="table table-striped">
    <tr>
      <th>Week #</th>
      <th>From</th>
      <th>To</th>
      <th>Count</th>
      <th>Avg Distance <small>(Km)</small></th>
      <th>Avg Speed <small>(Km/H)</small></th>
    </tr>
 
    <% @weekly_reports.each do |weekly_report| %>
      <tr>
        <td><%= weekly_report.week_number %></td>
        <td><%= weekly_report.date_from %></td>
        <td><%= weekly_report.date_to %></td>
        <td><%= weekly_report.count_entries %></td>
        <td><%= weekly_report.avg_distance %></td>
        <td><%= weekly_report.avg_speed %></td>
      </tr>
    <% end %>
</table> 

statistics/index 주간 통계 데이터를 보여주는 뷰페이지 입니다.



helpers/entries_helper.rb

module EntriesHelper
  def readable_time_period(entry)
    mins = entry.time_period
    return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60
    Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe
  end
 
  def readable_speed(entry)
    "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe
  end
end 

entries_helper에는 뷰에서 사람이 읽기 편한 형식으로 값을 포맷팅해주는 함수들이 포함됩니다. 


글이 길어져서, MVC 패턴에 Service / Decorator Class 개념을 반영하여, 리팩토링하는 방법은 "Service Class를 사용해서, Rails를 컴포넌트로 만드는 방법! (2) - Value Object"에 이어서 설명하고자 합니다.