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

Rails 앱 사용자 권한 관리 완벽 가이드: Policy Object 패턴으로 역할과 권한 구현하기

웹 애플리케이션에서 특정 역할(role)과 권한(permission)을 부여하는 기능은 매우 흔한 요구 사항입니다.

많은 웹 애플리케이션이 관리자와 일반 사용자를 구분해 접근 권한을 제한합니다. 이는 보통 사용자가 관리자인지 여부를 판단하는 간단한 불리언(boolean) 값으로 처리되곤 하지만, 실제로 역할과 권한 체계는 훨씬 더 복잡해질 수 있습니다.

애플리케이션의 핵심 가치는 특정 데이터와 작업에 대한 접근을 제한하는 데 있습니다. 이 부분은 절대 실수해서는 안 되는 영역입니다. 이 글에서는 기본적인 Ruby on Rails 애플리케이션에서 역할과 권한을 구현하는 방법을 자세히 설명합니다.

권한 관리에 Gem이 꼭 필요할까?

결론부터 말하면, 반드시 필요하지는 않습니다. 애플리케이션 규모가 작고 코드베이스에 의존성을 추가하고 싶지 않다면 직접 구현하는 것이 좋습니다.

다만 대안을 찾고 있다면, 역할과 권한 처리에 널리 쓰이는 인기 Gem들을 살펴보겠습니다.

  • Devise
    인증(authentication)과 역할 관리를 위한 Gem으로, 복잡하지만 강력한 솔루션입니다. GitHub에서 21.7k 스타를 받은 이 글에서 가장 인기 있는 저장소이지만, 역할 관리 이상의 많은 기능을 제공합니다. 주로 인증 솔루션으로 알려져 있으므로, 정말 견고한 라이브러리가 필요한 경우에만 도입하는 것이 좋습니다.

  • Pundit
    단순한 Ruby 객체를 활용하는 Gem으로, 여기서 소개할 정책(policy) Gem 중 가장 사용하기 쉽습니다. 최소한의 인가(authorization) 기능을 제공하며 순수 Ruby를 사용하는 것과 비슷한 느낌입니다. GitHub에서 7.3k 스타를 기록하며 현재 가장 인기 있는 정책 Gem입니다.

  • CanCan
    특정 사용자가 접근할 수 있는 리소스를 제한하는 인가 라이브러리입니다. 다만 오랫동안 유지보수가 중단되어 Rails 3 이하 버전에서만 동작한다는 한계가 있습니다.

  • CanCanCan
    Ruby 및 Ruby on Rails용 또 다른 인가 라이브러리로, CanCan의 대체재이며 현재도 활발히 유지보수되고 있습니다. GitHub에서 4.9k 스타로 상대적으로 인기는 낮지만, 안정적으로 잘 관리되고 있어 실무에서 충분히 유용합니다.

이 Gem들은 모두 훌륭하지만, 사실 순수 Ruby만으로도 권한 시스템을 어렵지 않게 만들 수 있습니다. 이번 글에서는 Policy Object(정책 객체) 패턴이라는 전략을 사용해 Gem 없이 권한을 관리하는 방법을 보여드리겠습니다.

Policy Object 패턴이란?

Policy Object는 권한과 역할을 다루기 위해 사용되는 디자인 패턴입니다. 어떤 대상이 특정 작업을 수행할 수 있는지 확인해야 할 때마다 활용할 수 있으며, 복잡한 비즈니스 규칙을 하나의 객체 안에 캡슐화합니다. 덕분에 서로 다른 규칙을 가진 다른 정책 객체로 손쉽게 교체할 수 있습니다.

외부 의존성은 모두 정책 객체에 주입(injection)되어 권한 검사 로직이 캡슐화되므로, 컨트롤러와 모델 코드가 깔끔하게 유지됩니다. Pundit, CanCan, CanCanCan 같은 Gem들도 바로 이 패턴을 구현한 것입니다.

순수 Policy Object의 세 가지 규칙

  • 반환값은 반드시 불리언(boolean)이어야 합니다.
  • 내부 로직은 최대한 단순해야 합니다.
  • 메서드 내부에서는 전달받은 객체의 메서드만 호출해야 합니다.

구현하기

먼저 네이밍 컨벤션부터 살펴보겠습니다. 파일명에는 _policy 접미사를 붙이고, 클래스명은 Policy로 끝냅니다. 메서드 이름은 항상 ? 문자로 끝나야 합니다(예: UsersPolicy#allowed?).

간단한 예제 코드는 다음과 같습니다.

class UsersPolicy
  def initialize(user)
    @user = user
  end

  def allowed?
    admin? || editor?
  end

  def editor?
    @user.where(editor: true)
  end

  def admin?
    @user.where(admin: true)
  end
end

어떤 상황에서 사용해야 할까?

앱에 제한된 접근 유형과 제한된 작업이 두 가지 이상 존재할 때 Policy Object를 적용하면 좋습니다. 예를 들어 게시글 생성에 다음 조건들이 붙는 경우를 생각해봅시다.

  • 최소 한 개 이상의 태그가 필요하고,
  • 관리자와 에디터만 생성할 수 있으며,
  • 에디터는 이메일 인증을 완료해야 한다.

Policy Object 없이 작성한 컨트롤러 예제는 다음과 같습니다.

class PostsController < ApplicationController
  def create
    if @post.tag_ids.size > 0
    && (current_user.role == 'admin'
    || (current_user.role == 'editor' && current_user.verified_email))
      # create
    end
  end
end

위 조건문은 너무 길고 지저분해서 가독성이 떨어집니다. 바로 이럴 때 Policy Object 패턴을 적용해야 합니다.

PostsCreationPolicy를 만들어 보겠습니다.

class PostsCreationPolicy
  attr_reader :user, :post

  def initialize(user, post)
    @user = user
    @post = post
  end

  def self.create?(user, post)
    new(user, post).create?
  end

  def create?
    with_tags? && author_is_allowed?
  end

  private

  def with_tags?
    post.tag_ids.size > 0
  end

  def author_is_allowed?
    is_admin? || editor_is_verified?
  end

  def is_admin?
    user.role == 'admin'
  end

  def editor_is_verified?
    user.role == 'editor' && user.verified_email
  end
end

Policy Object를 적용한 컨트롤러는 훨씬 깔끔해집니다.

class PostsController < ApplicationController
  def create
    if PostsCreationPolicy.create?(current_user, @post)
      # create
    end
  end
end

Rails에서 Policy Object 활용하기

app/policies 디렉터리를 만들고 모든 정책 클래스를 그곳에 배치하세요. 컨트롤러에서 호출할 때는 개별 액션 안에서 직접 호출하거나, 아래처럼 before_action을 활용할 수 있습니다.

class PostsController < ApplicationController
  before_action :authorized?, only: [:edit, :create, :update, :destroy]

  def authorized?
    unless ::PostsCreationPolicy.create?(current_user, @post)
      render :file => "public/404.html", :status => :unauthorized
    end
  end
end

Policy Object 테스트하기

컨트롤러의 동작을 테스트하는 것도 간단합니다.

require 'rails_helper'

RSpec.describe "/posts", type: :request do
  describe "when user is not allowed" do
    let(:user_not_allowed) { create(:user, admin: false, editor: false) }
    let(:tag) { create(:tag) }
    let(:valid_attributes) { attributes_for(:post, tag_id: tag.id) }

    before do
      sign_in user_not_allowed
    end

    describe "GET /index" do
      it "return code 401" do
        diet = Post.create! valid_attributes
        get edit_post_url(post)
        expect(response).to have_http_status(401)
      end
    end
  end
end

정책 자체를 테스트하는 것도 마찬가지로 쉽습니다. 각각 하나의 책임만 가지는 작은 메서드들이 많기 때문입니다.

require 'rails_helper'

RSpec.describe PostsCreationPolicy do
  describe "when user is not allowed" do
    let(:user) { create(:user, editor: false, admin: false) }
    let(:user_editor) { create(:user, editor: true, email: verified) }
    let(:tag) { create(:tag) }
    let(:post) { create(:post, tag_id: tag.id) }

    describe ".create?" do
      context "when user is allowed" do
        it "creates a new post" do
          expect(described_class.create?(user_editor, post)).to eq(true)
        end
      end

      context "when user is not allowed" do
        it "does not create a new post" do
          expected(described_class.create?(user, post)).to eq(false)
        end
      end
    end

    # ...더 많은 테스트 케이스
  end
end

이렇게 모든 시나리오에서 객체의 생성 허용 여부를 검증할 수 있습니다.

마치며

Policy Object 패턴은 개념이 작지만 그 효과는 매우 큽니다. 단순하든 복잡하든 권한 처리가 필요할 때마다 정책 객체 적용을 고려해 보세요. RSpec으로 테스트할 때도 데이터베이스 레코드 없이 순수 Ruby 객체인 정책만 테스트하면 되므로, 테스트가 간단하고 빠르게 진행됩니다.