본문 바로가기

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

(번역)Command 아키텍처 기반으로 루비온 레일즈의 Controller Action 리팩토링하기

REFACTORING CONTROLLER ACTION IN RUBY ON RAILS

레일즈 컨트롤러가 뚱뚱해지는 현상을 꽤 많이 봤었다.

Medium 눈팅하다가, 레일즈 컨트롤러를 리팩토링했다는 글을 발견했다. 

뭔 내용인지 궁금해서 ㅋㅋ 읽어본 내용을 정리해보고자 한다. 




컨트롤러는 얄쌍하고, 모델은 뚱뚱하게 로직을 짜는게 루비온레일즈의 MVC 기본 원칙이다. 

그러나 시간이 흐르다 보면, 프로젝트는 점점더 커지고 메서드도 늘어난다.

그러다 보니 코드를 찾거나 신규 피쳐를 추가하는게 어려워진다.

그러다 결국 컨트롤러는 더이상 얄쌍해지지 않게 되고, 복잡해진다. 


이에 대한 해결 방법을 정리하고자 한다.


Controllers

우리는 컨트롤러에 코드가 몇백줄이나 되는걸 원하진 않는다.

그래서 가장 좋은 방법은 다른 책임을 가진 컨트롤러를 여러개 더 만드는거다. 

그리고 원글 작성자는 각 액션을 위한 Command 클래스를 만드는걸 선호한다고 한다. 

# Goal controller
class Goal::GoalController < Core::Controller
# Shows a goal
# @see Goal::GoalViewCommand
def view
command = Goal::GoalViewCommand.new(params)
run(command)
end

# Updates a goal
# @see Goal::GoalUpdateCommand
def update
command = Goal::GoalUpdateCommand.new(params)
run(command)
end

# Deletes a goal
# @see Goal::GoalDeleteCommand
def delete
command = Goal::GoalDeleteCommand.new(params)
run(command)
end

# Adds or removes points
# @see Goal::GoalPointsUpdateCommand
def points_update
command = Goal::GoalPointsUpdateCommand.new(params)
run(command)
end
end

이 코드를 보면, GoalViewCommand 클래스, GoalUpdateCommand 클래스 처럼 액션 개수만큼 클래스가 있다. 


Command 클래스 구조

Command 클래스에는 아래의 공통 기능들이 포함된다.

- 인증 기능

- 파라미터 유효성 체크

- 메인 로직

- 정상 케이스 렌더링

- 에러 발생 시, 리다이렉트 / 예외 메시지 렌더링


각 액션마다 위의 기능들이 다르게 동작하기 때문에, Command 클래스에 각각 기능을 나눠서 구현하는게 

로직을 더 쉽고, 깔끔하게 만든다. ( 그런데.. 내 취향은 아니다 ㅋㅋ )


그리고 이렇게 만든 Command 클래스 객체를 run 함수에서 처리를 해줘야한다.

Core::Controller 클래스에 구현되어야하는 run 함수는 아래와 같다.

# Common controller methods
class Core::Controller < ActionController::Base
around_action Core::Filter::ErrorRenderer

# Runs the action
# @param [Core] command
# @see Core::FilterChain
def run(command)
command.check_authorization
command.check_validation
result = command.execute
if result.nil?
render json: nil, status: 204
else
render json: result, status: 200
end
end
end

이 메서드는 각 액션에 있는 모든 기능들을 실행해준다.


우선 command.check_authorization, command.check_validation 함수로 인증 / 유효성 처처리를 해준다.

그리고 command.execute으로 메인 로직을 실행해준 후, render 결과를 리턴한다.


마지막으로 Core::Filter::ErrorRenderer: 으로 around_aciton을 감싸준다. (얘가 예외처리를 해주는듯)

# Error handler that render errors
class Core::Filter::ErrorRenderer

class << self
# Handle errors and process them
# @param [Core::Controller] controller
def around(controller)
@controller = controller
yield
rescue Core::Errors::UnauthorizedError
self.render 401, JSON.generate(:error => 'Unauthorized')
rescue Core::Errors::ForbiddenError
self.render 403, JSON.generate(:error => 'Forbidden')
rescue Core::Errors::ValidationError => e
self.render 422, e.command.errors.to_json
rescue StandardError => e
self.render 500, JSON.generate({ error: e.message, backtrace: e.backtrace })
end

# Handle errors and process them
# @param [Integer] status
# @param [Hash] json
def render(status, json)
@controller.response.status = status
@controller.response.body = json
end
end
end

Core::Filter::ErrorRenderer도 원글 작성자가 따로 만든 클래스인거같다.


Command Class

그리고 check_authrization, check_validation, execute 메서드를 Core Class에 만들어줘야한다.

# Rules for authorization
# @return [Hash]
def authorization_rules
{ token_type: :login }
end
# Checks that command can be executed by the user
def check_authorization
User::AuthorizationService.get.get_token_by_command self
end

요런식으로..


Validation

루비온레일즈는 model에 대한 유효성 체크만 기본 기능으로 갖고 있다. 

사실 액션에 대한 유효성 체크도 필요하다. 이 접근 방식으로 봤을 때, Command code의 메인 로직을 타기 전에, 유효한 파라미터인지 체크하고, 잘못된 파라미터면 예외처리를 해주는건 쉽게 할 수 있다. 

클래스에 model validation체크를 해주는 모듈인 ActiveModel::Validationsinclude 해주기만 하면 된다.

그러면 별도의 예외처리를 해주지 않아도 된다. 

원글 작성자는 아래의 공통 유효성 체크 로직을 사용했다고 한다. 

* Exist 

* Unique

* Content Type

* Owner

* Uri

validates :id, presence: true,
'Core::Validator::Exists' => ->(x) { x.goal_repository.find_not_deleted(x.id) }

요런식으로 사용했다고 한다


Execute

excute 안에 메인 로직을 넣으면 된다

# Contains common methods for commands
class Core::Command
include ActiveModel::Validations

attr_accessor :token

# Fills a command with attributes
# @param [Hash] attributes
# @raise Core::Errors::ValidationError
def initialize(attributes = {})
attributes.each do |name, value|
if methods.include? "#{name}=".to_sym
method("#{name}=".to_sym).call(value)
end
end
end

# Runs command
def execute
end

# Rules for authorization
# @return [Hash]
def authorization_rules
{ token_type: :login }
end

# Checks that command can be executed by the user
def check_authorization
User::AuthorizationService.get.get_token_by_command self
end

# Checks that all params are correct
def check_validation
raise(Core::Errors::ValidationError, self) if self.invalid?
end
end


예시

# Update family command
class Family::FamilyUpdateCommand < Core
attr_accessor :name, :photo_url

validates :name, length: { maximum: 50 }
validates :photo_url, length: { maximum: 100 }
validates :photo_url, 'Core::Validator::Uri' => true

# Sets all variables
# @param [Object] params
# @see User::AuthorizationService
# @see Family::FamilyRepository
def initialize(params)
super(params)
@authorization_service = User::AuthorizationService.get
@family_repository = Family::FamilyRepository.get
end

# Runs command
def execute
user = @authorization_service.get_user_by_token_code(token)
family = user.family
family.name = name unless name.nil?
family.photo_url = photo_url unless photo_url.nil?
@family_repository.save!(family)
nil
end
end
# Delete a goal command
class Goal::GoalDeleteCommand < Core
attr_accessor :id
attr_accessor :goal_repository

validates :id, presence: true,
'Core::Validator::Exists' => ->(x) { x.goal_repository.find_not_deleted(x.id) }
validates :id, 'Core::Validator::Owner' => ->(x) { x.goal_repository.find(x.id) }

# Sets all variables
# @param [Object] params
# @see Goal::GoalRepository
def initialize(params)
super(params)
@goal_repository = Goal::GoalRepository.get
end

# Runs command
# @return [Hash]
def execute
goal = @goal_repository.find(id)
@goal_repository.delete(goal)
nil
end
end
# View a family command
class Family::FamilyViewCommand < Core
# Sets all variables
# @param [Object] params
# @see User::AuthorizationService
# @see Family::FamilyPresenter
def initialize(params)
super(params)
@authorization_service = User::AuthorizationService.get
@family_presenter = Family::FamilyPresenter.get
end

# Runs command
# @return [Hash]
def execute
user = @authorization_service.get_user_by_token_code(token)
family = user.family
@family_presenter.family_to_hash(family)
end
end


command 라는 클래스가 낯설다. 원글 작성자가 command 기반의 아키텍처라는 글도 썼던데, 이게 원래 있던 개념인지 궁금하답