Code

Refactoring Series – use scope access

August 19, 2016

author:

Array

Refactoring Series – use scope access

가끔은 의식적으로 이유를 붙여가며 리팩토링해보는 것도 좋은 경험이 될 것 같아서 레포트 형식으로 작성하는 시리즈입니다. 소재는 여기저기서 제가 보던 코드를 가져오고 있으며, 물론 그대로 가져오진 않고 필요한 부분만 가져다가 새로 예제로 만듭니다. 필자가 게으르기 때문에 비정기로 연재될 예정입니다. 의견 및 오타 제보는 언제나 감사하게 받고 있습니다. ?

배경

최근에 할 일 관리를 위해서 새 레일스 프로젝트를 진행하고 있습니다. 여기에서는 당연하지만 현재 로그인한 사용자만이 자기 자신의 할 일만을 CRUD할 수 있습니다. 이 명세를 평소대로 구현하고 보니 Rails Best Practice에서 잔소리를 하는 것을 발견했습니다.

문제점

코드는 대략 이랬습니다.

class TasksController < ApplicationController
  before_action :set_task, only: :show
  before_action :permission_check, only: :show
  
  def show
  end
  
  private
  
  def set_task
    @task = Task.find(params[:id])
  end
  
  def permission_check
    raise User::NoPermissionError \
      unless @task.user_id == current_user.id
  end
end

class User
  # ...
  NoPermissionError = Class.new(StandardError)
end

필요한 액션에서 권한 체크라는 before_action을 추가하고, 해당하는 Task의 소유자가 현재 로그인한 유저가 아니라면 에러를 던집니다. 이 에러는 ApplicationController에서 잡아서 권한이 없다는 에러 메시지와 함께 사용자를 root_path로 리다이렉트 해줍니다. 지금 중요한 건 이 부분이 아니니 넘어가죠.

아무튼, Rails Best Practice 왈, 스코프를 사용하랍니다.

If you check the permission by comparing the owner of object with current_user,
it's verbose and ugly, you can use scope access to avoid this.

과연, 이제와서 생각해보면 좀 그렇습니다. 묘하게 길고, 이 작업 때문에 before_action을 두개나 걸어야 하고, 오만가지 생각이 머리 속을 스칩니다. 그래서 어떻게 스코프를 활용하라는 걸까요? 제시하는 코드는 이렇습니다.

@post = Post.find(params[:id])
if @post.user != current_user
  flash[:warning] = 'Access denied'
  redirect_to posts_url
end

위와 같은 코드를,

# raise RecordNotFound exception (404 error) if not found
@post = current_user.posts.find(params[:id])

이와 같이 개선할 수 있답니다. 실제 코드를 보니 깔끔하네요. 이와 같이 코드를 작성하게 되면 에러 처리에 대한 부분을 한 곳에서 모아 깔끔하게 정리할 수 있고, 코드는 읽기 쉬워진다는 장점이 생깁니다. 무엇보다 Task에서 가져온 할 일을 사용자의 것인지 확인하는게 아니라, 현재 사용자의 할 일을 가져오는 것으로 바뀐 덕분에, 초점이 할 일에서 사용자 중심으로 바뀐 부분이 마음에 듭니다. 그러면 이 규칙을 코드에 적용해보죠.

개선하기

before_action을 하나로 뭉치기

이 리팩토링의 초점은 현재 라우팅에서 요구하는 Task가 현재 사용자의 것이 맞는지 아닌지 판단하는 로직을 컨트롤러에서 SQL로 옮기는 것입니다. 하지만 지금 코드에서는 set_task에서 일단 가져오고, permission_check에서 권한을 확인하고 있습니다. 이래서는 작업이 불편해지니 하나의 before_action으로 합쳐보죠.

class TasksController < ApplicationController
  before_action :set_task, only: :show
  
  def show
  end
  
  private
  
  def set_task
    @task = Task.find(params[:id])
    raise User::NoPermissionError \
      unless @task.user_id == current_user.id
  end
end

class User
  # ...
  NoPermissionError = Class.new(StandardError)
end

하나로 뭉쳤습니다. 이번엔 스코프를 사용하도록 코드를 좀 고쳐보죠. 이 시점에서 우리가 원하는 에러는 RecordNotFound가 아니니까 find를 직접 사용할 수는 없습니다. 그러니 검색하지 못하더라도 에러를 던지지 않는 find_by_id를 사용해보죠.

class TasksController < ApplicationController
  before_action :set_task, only: :show
  
  def show
  end
  
  private
  
  def set_task
    @task = current_user.tasks.find_by_id(params[:id])
    raise User::NoPermissionError if @task.nil?
  end
end

class User
  has_many :tasks
  # ...
  NoPermissionError = Class.new(StandardError)
end

얍. Rails Best Practice가 요구하는 리팩토링은 완료되었습니다. 그런데 막상 읽다보니 이런 생각이 듭니다.

권한이 없는 사용자에게 자신의 것이 아닌 할 일은 없는 거랑 마찬가지 아닌가?
굳이 권한이 없다는 에러를 줘야하나?

왠지 RecordNotFound로 충분할 것 같다는 생각이 마구 들기 시작합니다. 기획팀에게 문의해보지 않으면…은 나잖아? 사양을 변경합시다. XD

class TasksController < ApplicationController
  before_action :set_task, only: :show
  
  def show
  end
  
  private
  
  def set_task
    @task = current_user.tasks.find(params[:id])
  end
end

class User
  has_many :tasks
  # ...
end

이제 커스텀 에러가 아닌 RecordNotFound를 던지게 되었으니, find를 쓰게끔 고쳤습니다. set_task가 한 줄로 돌아오고, 권한 체크가 깔끔해져서 우리 모두~~제~~가 행복해졌습니다.

결론

너무 당연히 습관적으로 짜고 있던 코드에서도 줄일 곳은 나오는 법이네요[..]
특히 권한 문제는 대부분의 웹 서비스에서 볼 수 있으니 더 주의깊게 살펴 보는 것도 좋겠습니다.

참고자료

관련 스타일 가이드와 이 코드 전체가 포함되어 있는 PR입니다. 커밋 전에 변경한 부분이라서 구체적인 변경 순서를 확인할 수는 없지만, 해당 부분은 포스팅으로 충분하리라 생각합니다.

Array