노머스의 권한관리 시스템, ReBAC 도입기
- #ReBAC
- #RBAC
- #Permission
- #Role
- #권한관리
안녕하세요. 노머스 백엔드팀에서 fromm 서비스를 개발하는 김연태입니다.
들어가며
작년 하반기 fromm 내부와 fromm에 입점된 소속사들이 사용하는 내부시스템의 개편작업을 진행하였습니다. fromm 서비스에는 몇백개의 소속사가 입점되어 있고, 각 소속사에는 여러 부서들이 있으며 해당 부서에는 다시 여러 아티스트들과 매니저들이 소속되어 있습니다. 이런 상황에서 소속사간의 정보를 서로 볼 수 없거나, 부서간의 정보를 서로 볼 수 없는 당연한 경우 외에도 아티스트의 부서간 이동 혹은 소속사의 이적, 매니저의 부서이동 및 입퇴사 등 발생하는 다양한 이벤트를 일관되게 처리할 수 있는 권한관리 시스템의 필요성을 느꼈습니다. 이런 복잡한 권한관리 시스템을 처음에는 RBAC 로 구현하려 했지만 어느정도 한계를 느끼고 ReBAC로 구현을 시도했는데요, 이 글에서는 RBAC와 ReBAC가 무엇이고 어떤 특징을 가지는지, 어떤 문제를 어떻게 해결하려고 ReBAC를 사용하게 되었는지 예시와 코드를 가지고 알아보도록 하겠습니다.
RBAC(Role-Based Access Control)
RBAC(Role-Based Access Control)는 사용자에게 직접 권한을 부여하는 대신, 역할(Role)이라는 중간 계층을 통해 권한을 관리하는 접근 제어 모델입니다.
User → Role → Permission
권한관리 시스템에서 가장 흔히 사용되는 모델이라고 할 수 있어서 fromm 의 권한관리 시스템에서도 RBAC를 통한 접근제어를 하려고 했었습니다. RBAC에 대한 내용은 NIST(미국 국립표준기술연구소)에서 발행한 The NIST Model for Role-Based Access Control 논문에서 상세히 살펴볼 수 있습니다. 여기서 밝힌 RBAC의 4가지 레벨과 특징 및 한계점에 대해 알아보도록 하겠습니다.
Level 1: Flat RBAC
특징
- 가장 기본적인 형태의 RBAC
- 사용자-역할 간 N:M 관계를 가짐
- 역할-권한 간 N:M 관계를 가짐
- 세션에서 역할을 활성화 함

구현 예시
const managerRoles = {
'manager-001': ['department_viewer'],
'manager-002': ['department_admin'],
'manager-003': ['department_viewer', 'arti_viewer']
};
function hasPermission(managerId: string, permission: string): boolean {
const roles = managerRoles[managerId] || [];
return roles.some(role => rolePermissions[role]?.includes(permission));
}
한계: 역할 폭발 (Role Explosion)
시스템이 복잡해지면 필요한 역할의 수가 기하급수적으로 증가합니다.
필요한 시나리오:
- 소속사 A의 관리자
- 소속사 A의 조회자
- 소속사 B의 관리자
- 소속사 B의 조회자
- 부서 1의 관리자
- 부서 1의 조회자
...
역할 수 = 소속사 수 × 부서 수 × 권한 종류 × ...
실제 내부시스템에서 소속사 100개 × 부서 5개 × 권한 4종류 = 2,000개의 역할 이 필요하게 됩니다.
내부시스템에서 현재보다 작은 값으로 가정한게 이정도 역할의 개수이다 보니 실제 다른곳에 확장해서 쓰게되면 기하급수적으로 많은 역할의 개수가 늘어날 것으로 예상해볼 수 있습니다.
Level 2: Hierarchical RBAC
특징
- 역할 간 계층 구조를 도입
- 상위 역할이 하위 역할의 권한을 상속함
- 역할 관리 복잡도가 감소됨

구현 예시
// Hierarchical RBAC: 역할 계층 정의
const roleHierarchy: Record<string, string[]> = {
'super_admin': ['agency_admin', 'artist_admin', 'account_admin'],
'agency_admin': ['department_admin'],
'artist_admin': ['artist_viewer'],
'department_admin': ['department_viewer']
};
function getEffectiveRoles(role: string): string[] {
const inherited = roleHierarchy[role] || [];
return [role, ...inherited.flatMap(r => getEffectiveRoles(r))];
}
한계: 계층 구조가 경직됨
문제 1: 교차 권한 표현 어려움
소속사의 조직장 A 가 부서1 과 부서3의 부서관리 어드민 권한을 모두 가지고 있어야 한다고 가정해 보겠습니다. 이때 부서2는 제외하겠습니다.
계층구조로는 다음과 같이 시도해볼 수 있는데 부서간의 계층은 평등하므로 계층구조로 표현이 되지 않는것을 확인할 수 있습니다.
계층 구조로는 표현 불가:
- Agency Admin
- Department 1 Admin ✓
- Department 2 Admin ✗
- Department 3 Admin ✓
문제 2: 동적 관계 반영 불가
이번엔 소속사 내부에서 매니저가 담당부서를 변경하는 경우를 가정해 보겠습니다. 이때 매니저가 가지고 있는 Department 1 과 관련된 권한을 모두 끊어주어야 하고 새롭게 옮겨가는 Department 2 의 권한을 새로 할당해 줘야 합니다. 이런 로직은 서비스의 비즈니스로직에 포함될 수밖에 없습니다.
즉 관계 테이블과 역할이 동기화되지 않으므로 동적인 관계 반영이 불가능합니다.
Level 3: Constrained RBAC
특징
- SoD(Separation of Duties) -
직무 분리라는 제약이 추가됩니다. - SSD - 정적 직무 분리 : 사용자에게 상충되는 역할을 동시에 할당할 수 없습니다.
- DSD - 동적 직무 분리: 세션에서 상충되는 역할을 동시에 활성화할 수 없습니다.
| Constrained RBAC |
|---|
| SSD: 정적 직무 분리 - 결재 요청자 ⊗ 결재 승인자 |
| DSD: 동적 직무 분리 - 같은 세션에서 충돌 역할 활성화 금지 |
구현 예시
// Constrained RBAC: 직무 분리 규칙
const mutuallyExclusiveRoles = [
['account_admin', 'department_admin'], // 계정 관리자 ≠ 부서 관리자 (예시)
['something_requester', 'something_approver'] // 요청자 ≠ 승인자
];
function canAssignRole(userId: string, newRole: string): boolean {
const currentRoles = getUserRoles(userId);
for (const exclusiveSet of mutuallyExclusiveRoles) {
if (exclusiveSet.includes(newRole)) {
const hasConflict = exclusiveSet.some(
role => role !== newRole && currentRoles.includes(role)
);
if (hasConflict) return false;
}
}
return true;
}
한계: 복잡한 제약 규칙 관리
문제 1: 제약 규칙 폭발
예외적인 사항이 발생했을 때의 제약은 서비스 로직에 포함될 수밖에 없습니다. 예를들면 다음과 같은 제약이 있을 수 있습니다.
- 각 소속사별로 다른 SoD 규칙 적용
- 특정 금액 이상 정산은 추가 승인 필요
- 휴가 중인 매니저의 권한 위임
이러한 경우 각 서비스 로직을 어떻게 개발하느냐에 따라 관리포인트가 많아져 예상하지 못한 휴먼에러를 낼 가능성이 높아집니다.
문제 2: 컨텍스트 기반 제약 불가
fromm 과 소속사 간의 정산이 필요한 상황이라고 가정을 해보겠습니다. 이때 특정 금액이 넘어갈 때는 추가 승인이 필요하다고 했을 때 RBAC의 권한으로는 상세한 제약조건의 구현이 불가능합니다. 따라서 아래와 같이 서비스 로직으로 표현될 수밖에 없습니다.
async function approveSettlement(settlementId: string, approverId: string) {
const settlement = await getSettlement(settlementId);
if (settlement.amount >= 1_000_000_000) {
// RBAC 외부의 추가 검증 로직
if (!hasHighValueApprovalPermission(approverId)) {
throw new Error('고액 정산 승인 권한 없음');
}
}
// ...
}
Level 4: Symmetric RBAC
특징
-
권한-역할 리뷰 기능이 추가됩니다.
-
양방향 조회 가능:
- 정방향: “이 역할에 어떤 권한이 있는가?”
- 역방향: “이 권한을 가진 역할은 무엇인가?”
-
권한 감사 및 분석을 할 수 있습니다.
Symmetric RBAC 정방향 조회 :
Role:AccountAdmin → [계정정보변경, 계정생성, 계정삭제]역방향 조회 :
Permission:계정생성 → [AccountAdmin, SuperAdmin]
구현 예시
// Symmetric RBAC: 양방향 조회
class SymmetricRBAC {
private roleToPermissions: Map<string, Set<string>>;
private permissionToRoles: Map<string, Set<string>>; // 역방향 인덱스
// 정방향: 역할 → 권한
getPermissionsByRole(role: string): string[] {
return Array.from(this.roleToPermissions.get(role) || []);
}
// 역방향: 권한 → 역할
getRolesByPermission(permission: string): string[] {
return Array.from(this.permissionToRoles.get(permission) || []);
}
// 감사: "계정 조회 권한을 가진 모든 사용자"
async auditPermission(permission: string): Promise<AuditResult[]> {
const roles = this.getRolesByPermission(permission);
const users = await this.getUsersByRoles(roles);
return users.map(u => ({
userId: u.id,
userName: u.name,
roles: u.roles,
grantedVia: u.roles.filter(r => roles.includes(r))
}));
}
}
한계: 관계 기반 권한 표현 불가
누가 이 권한을 가지는가? 에 대한 정보는 알 수 있지만 누가 이 리소스에 대한 권한을 가지는가? 는 알 수 없습니다. 따라서 리소스의 위치가 변경되는 경우 해당 리소스에 대한 권한을 가지고 있는 사람의 권한을 전부 끊어주기 쉽지 않습니다.
예를들어 아티스트 A를 관리하는 부서가 소속사 내부에서 변경되었다고 가정해 보겠습니다.
아티스트 A를 관리할 수 있는 사용자에 대해서 권한 변경이 필요합니다.
이때 Symmetric RBAC로 가능한 것은 “아티스트 A 관리권한을 가진 모든 사용자” 라는 전역적인 권한 서칭입니다. 하지만 “아티스트 A를 담당하는 부서의 매니저 중 아티스트 A 관리 권한을 가진 사용자” 라는 특정 리소스의 관계기반 권한은 Symmetric RBAC 로 찾을 수 없습니다.
따라서 특정 부서의 특정 아티스트 관리 권한을 찾아서 변경하는 것은 서비스 로직에 녹일 수밖에 없습니다.
ReBAC (Relationship-Based Access Control)
ReBAC란?
ReBAC는 객체 간의 관계를 기반으로 접근 권한을 결정하는 모델입니다. 2019년 Google이 발표한 Zanzibar- Google’s Consistent, Global Authorization System 논문을 통해 자세히 소개되었으며, Google 에서는 Google Drive, YouTube, Google Cloud 등 모든 구글의 시스템에서 Zanzibar 시스템을 통해 권한인증을 하고 있으며 수 ms 안에 처리된다고 하고 있습니다.
RBAC: User → Role → Permission
ReBAC: User → Relationship → Object → Permission
핵심 개념
아래에서 표현되는 모든 방식은 Zanzibar- Google’s Consistent, Global Authorization System 논문의 내용을 차용하였습니다.
Object (객체)
권한의 대상이 되는 리소스입니다.
object_type:object_id
예시:
- agency:AG001 (소속사)
- department:DEPT001 (부서)
- arti:ARTI001 (아티스트)
Relation (관계)
객체 간의 연결을 정의합니다.
예시:
- owner (소유자)
- admin (관리자)
- member (멤버)
- viewer (조회자)
- parent (상위 객체)
Tuple (관계 튜플)
실제 관계 인스턴스를 표현합니다.
object#relation@subject
예시:
- department:DEPT001#member@manager:MGR001
→ "매니저 MGR001은 부서 DEPT001의 멤버이다"
- arti:ARTI001#managed_by@department:DEPT001
→ "아티스트 ARTI001은 부서 DEPT001에 의해 관리된다"
- agency:AG001#admin@manager:MGR002
→ "매니저 MGR002는 소속사 AG001의 관리자이다"
ReBAC의 권한 확인 방식
ReBAC는 관계 그래프를 탐색하여 권한을 동적으로 확인합니다.
매니저 M이 아티스트 A의 정산을 볼 수 있는가?

권한 도출:
1. manager:M은 department:D의 member
2. arti:A는 department:D에 의해 managed_by
3. department:D의 member는 해당 부서 아티스트의 구독 상태를 view 가능
4. 따라서 manager:M은 arti:A의 구독 상태를 view 가능 ✓
RBAC 문제의 ReBAC 해결
| RBAC 문제 | ReBAC 해결 방식 |
|---|---|
| 역할 폭발 | 관계로 권한 동적 도출, 역할 최소화 |
| 경직된 계층 | 유연한 관계 그래프 |
| 동적 관계 | 관계 변경 시 권한 자동 반영 |
| 리소스 특정 불가 | 객체 단위 관계 정의 |
Google 의 Zanzibar 아키텍쳐

Zanzibar serving cluster
- ACL server 의 클러스터로 Check, Read, Expand, Write 요청을 받습니다.
- 이때 각 요청은 클러스터 내 임의의 서버에서 처리되고 해당서버는 필요에 따라 작업을 클러스터의 다른 서버로 분산시킬 수 있습니다.
- 최종 Response 는 최초 서버에서 결과를 수집해 클라이언트에 리턴합니다.
Spanner DB
- ACL 과 Metadata 를 Spanner DB에 저장합니다.
- Spanner DB 는 다음과 같이 구성됩니다.
- 각 클라이언트 네임스페이스별 관계 튜플을 저장하는 DB N개
- 모든 네임스페이스 구성을 보관하는 DB 1개
- 모든 네임스페이스에서 공유되는 변경 로그 DB 1개
Watch Server
- Watch Server 는 Watch 요청에 응답하는 특수한 서버입니다.
- Change Log를 추적하며 네임스페이스 변경 사항을 거의 실시간으로 클라이언트에 제공합니다.
fromm 에서 ReBAC 사용하기 - postgreSQL
앞서 본 Google’s Zanzibar 아키텍쳐에서는 Spanner DB 와 Zookie 프로토콜을 사용해 ReBAC 를 구현했습니다. 이러한 아키텍쳐는 글로벌 가용성과 성능이 중시된 구글의 서비스 특성을 고려한 것으로 RBAC 의 한계를 극복하고 유연성과 정확성을 가지기 위한 fromm 의 서비스에서 이정도 아키텍쳐는 필요하지 않았습니다.
현재 fromm 에서 사용하고 있는 인프라의 구조를 최대한 그대로 활용하고자 했고, 이후 높은 가용성과 낮은 레이턴시를 요구하는 대규모 트래픽 처리 단계에서는 아키텍처 전환이 가능하다고 판단했습니다.
그 결과, 초기 도입 단계에서는 PostgreSQL을 활용한 ReBAC를 선택했습니다.
스키마 설계
관계 튜플 테이블
-- 기본 관계 튜플 테이블
CREATE TABLE relation_tuples (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Object (권한의 대상)
object_type VARCHAR(64) NOT NULL, -- 'department', 'arti', 'settlement'
object_id VARCHAR(256) NOT NULL, -- 'DEPT001', 'ARTI001'
-- Relation (관계 종류)
relation VARCHAR(64) NOT NULL, -- 'owner', 'member', 'viewer', 'managed_by'
-- Subject (권한을 가진 주체)
subject_type VARCHAR(64) NOT NULL, -- 'manager', 'department', 'agency'
subject_id VARCHAR(256) NOT NULL,
subject_relation VARCHAR(64), -- userset: 'member', 'admin'
-- 메타데이터
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(256),
-- 복합 유니크 제약
CONSTRAINT unique_tuple UNIQUE (
object_type, object_id, relation,
subject_type, subject_id, COALESCE(subject_relation, '')
)
);
-- 인덱스 최적화
CREATE INDEX idx_tuples_object ON relation_tuples(object_type, object_id);
CREATE INDEX idx_tuples_subject ON relation_tuples(subject_type, subject_id);
CREATE INDEX idx_tuples_relation ON relation_tuples(object_type, relation);
-- 복합 인덱스 (권한 체크 쿼리용)
CREATE INDEX idx_tuples_check ON relation_tuples(
object_type, object_id, relation, subject_type, subject_id
);
네임스페이스 설정 테이블
객체 타입별 관계 정의를 저장합니다.
-- 객체 타입과 관계 정의
CREATE TABLE namespace_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(64) UNIQUE NOT NULL, -- 'department', 'arti', 'settlement'
config JSONB NOT NULL, -- 관계 정의
version INT DEFAULT 1,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
네임스페이스 설정 예시:
{
"name": "arti",
"config": {
"relations": {
"managed_by": {
"directly_related": ["department"]
},
"viewer": {
"directly_related": ["manager"],
"inherited": {
"from": "managed_by",
"relation": "member"
}
}
}
}
}
권한 체크 함수 (재귀 CTE)
PostgreSQL의 재귀 CTE를 사용하여 관계 그래프를 탐색합니다.
-- 핵심 권한 체크 함수
CREATE OR REPLACE FUNCTION check_permission(
p_object_type VARCHAR,
p_object_id VARCHAR,
p_relation VARCHAR,
p_subject_type VARCHAR,
p_subject_id VARCHAR,
p_max_depth INT DEFAULT 10
) RETURNS BOOLEAN AS $$
DECLARE
v_result BOOLEAN;
BEGIN
WITH RECURSIVE permission_graph AS (
-- Base case: 직접 관계
SELECT
rt.object_type,
rt.object_id,
rt.relation,
rt.subject_type,
rt.subject_id,
rt.subject_relation,
1 as depth,
ARRAY[rt.id] as path
FROM relation_tuples rt
WHERE rt.object_type = p_object_type
AND rt.object_id = p_object_id
AND rt.relation = p_relation
UNION
-- Recursive case 1: 그룹 멤버십 확장
SELECT
pg.object_type,
pg.object_id,
pg.relation,
rt.subject_type,
rt.subject_id,
rt.subject_relation,
pg.depth + 1,
pg.path || rt.id
FROM permission_graph pg
JOIN relation_tuples rt ON (
rt.object_type = pg.subject_type
AND rt.object_id = pg.subject_id
AND rt.relation = COALESCE(pg.subject_relation, 'member')
)
WHERE pg.subject_type IN ('department', 'agency', 'group', 'role', 'team')
AND pg.depth < p_max_depth
AND NOT rt.id = ANY(pg.path) -- 사이클 방지
UNION
-- Recursive case 2: 상위 권한 상속 (owner → admin → member → viewer)
SELECT
pg.object_type,
pg.object_id,
p_relation as relation,
pg.subject_type,
pg.subject_id,
pg.subject_relation,
pg.depth + 1,
pg.path
FROM permission_graph pg
WHERE pg.relation IN (
SELECT implied_relation FROM get_implied_relations(p_object_type, p_relation)
)
AND pg.depth < p_max_depth
)
SELECT EXISTS (
SELECT 1 FROM permission_graph
WHERE subject_type = p_subject_type
AND subject_id = p_subject_id
AND subject_relation IS NULL
) INTO v_result;
RETURN v_result;
END;
$$ LANGUAGE plpgsql;
관계 상속 정의 함수
-- 상위 권한 매핑
CREATE OR REPLACE FUNCTION get_implied_relations(
p_object_type VARCHAR,
p_relation VARCHAR
) RETURNS TABLE(implied_relation VARCHAR) AS $$
BEGIN
-- 권한 계층: owner > admin > member > viewer
RETURN QUERY
SELECT unnest(
CASE
WHEN p_relation = 'viewer' THEN ARRAY['member', 'admin', 'owner']
WHEN p_relation = 'member' THEN ARRAY['admin', 'owner']
WHEN p_relation = 'admin' THEN ARRAY['owner']
ELSE ARRAY[]::VARCHAR[]
END
);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
내부시스템 튜플 예시
-- 소속사-부서 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
('department', 'DEPT001', 'parent', 'agency', 'AG001'),
('department', 'DEPT002', 'parent', 'agency', 'AG001');
-- 부서-매니저 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
('department', 'DEPT001', 'member', 'manager', 'MGR001'),
('department', 'DEPT001', 'admin', 'manager', 'MGR002'),
('department', 'DEPT002', 'member', 'manager', 'MGR001');
-- 아티스트-부서 관계
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
('arti', 'ARTI001', 'managed_by', 'department', 'DEPT001'),
('arti', 'ARTI002', 'managed_by', 'department', 'DEPT001'),
('arti', 'ARTI003', 'managed_by', 'department', 'DEPT002');
-- 아티스트에 대한 부서 멤버 권한 (viewer 상속)
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id, subject_relation)
VALUES
('arti', 'ARTI001', 'viewer', 'department', 'DEPT001', 'member'),
('arti', 'ARTI002', 'viewer', 'department', 'DEPT001', 'member'),
('arti', 'ARTI003', 'viewer', 'department', 'DEPT002', 'member');
-- 소속사 관리자 권한 (전체 조회)
INSERT INTO relation_tuples (object_type, object_id, relation, subject_type, subject_id)
VALUES
('agency', 'AG001', 'admin', 'manager', 'MGR003');
권한 체크 쿼리 실행
-- 매니저 MGR001이 아티스트 ARTI001을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR001');
-- 결과: true (DEPT001의 member이므로)
-- 매니저 MGR001이 아티스트 ARTI003을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR001');
-- 결과: true (DEPT002의 member이므로)
-- 매니저 MGR002가 아티스트 ARTI003을 볼 수 있는가?
SELECT check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR002');
-- 결과: false (DEPT001 admin이지만 DEPT002와 무관)
fromm 에서 ReBAC 사용하기 - nest.js
다음은 nest.js 를 사용하고 있는 fromm 서비스에서 ReBAC 를 적용하기 위한 코드 예시입니다.
ReBAC 서비스 구현
@Injectable()
export class ReBACService {
constructor(
@InjectRepository(RelationTuple)
private readonly tupleRepo: Repository<RelationTuple>,
private readonly dataSource: DataSource,
) {}
/**
* 관계 튜플 생성
*/
async createTuple(params: {
objectType: string;
objectId: string;
relation: string;
subjectType: string;
subjectId: string;
subjectRelation?: string;
}): Promise<RelationTuple> {
const tuple = this.tupleRepo.create({
object_type: params.objectType,
object_id: params.objectId,
relation: params.relation,
subject_type: params.subjectType,
subject_id: params.subjectId,
subject_relation: params.subjectRelation,
});
return this.tupleRepo.save(tuple);
}
/**
* 권한 체크 - PostgreSQL 함수 호출
*/
async checkPermission(params: {
objectType: string;
objectId: string;
relation: string;
subjectType: string;
subjectId: string;
}): Promise<boolean> {
const result = await this.dataSource.query(
`SELECT check_permission($1, $2, $3, $4, $5) as has_permission`,
[
params.objectType,
params.objectId,
params.relation,
params.subjectType,
params.subjectId,
],
);
return result[0]?.has_permission ?? false;
}
/**
* 특정 주체가 접근 가능한 객체 목록 조회
*/
async listAccessibleObjects(params: {
subjectType: string;
subjectId: string;
objectType: string;
relation: string;
}): Promise<string[]> {
const result = await this.dataSource.query(
`
WITH RECURSIVE accessible AS (
-- 직접 관계
SELECT DISTINCT object_id
FROM relation_tuples
WHERE object_type = $3
AND relation = $4
AND subject_type = $1
AND subject_id = $2
AND subject_relation IS NULL
UNION
-- 그룹을 통한 간접 관계
SELECT DISTINCT rt1.object_id
FROM relation_tuples rt1
JOIN relation_tuples rt2 ON (
rt1.subject_type = rt2.object_type
AND rt1.subject_id = rt2.object_id
AND rt2.relation = COALESCE(rt1.subject_relation, 'member')
)
WHERE rt1.object_type = $3
AND rt1.relation = $4
AND rt2.subject_type = $1
AND rt2.subject_id = $2
)
SELECT object_id FROM accessible
`,
[params.subjectType, params.subjectId, params.objectType, params.relation],
);
return result.map((r: { object_id: string }) => r.object_id);
}
}
ReBAC Guard 구현
export interface ReBACCheck {
objectType: string;
objectIdParam: string; // 요청에서 object_id를 가져올 파라미터명
relation: string;
}
export const REBAC_CHECK_KEY = 'rebac_check';
export const ReBACPermission = (check: ReBACCheck) =>
Reflect.metadata(REBAC_CHECK_KEY, check);
@Injectable()
export class ReBACGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly rebacService: ReBACService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const check = this.reflector.get<ReBACCheck>(
REBAC_CHECK_KEY,
context.getHandler(),
);
if (!check) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user?.managerId) {
throw new ForbiddenException('인증 정보가 없습니다');
}
const objectId =
request.params[check.objectIdParam] ||
request.query[check.objectIdParam] ||
request.body[check.objectIdParam];
if (!objectId) {
throw new ForbiddenException('대상 객체를 찾을 수 없습니다');
}
const hasPermission = await this.rebacService.checkPermission({
objectType: check.objectType,
objectId,
relation: check.relation,
subjectType: 'manager',
subjectId: user.managerId,
});
if (!hasPermission) {
throw new ForbiddenException('접근 권한이 없습니다');
}
return true;
}
}
컨트롤러 적용 예시
@Controller('settlements')
@UseGuards(JwtAuthGuard, ReBACGuard)
export class SettlementController {
constructor(private readonly settlementService: SettlementService) {}
/**
* 아티스트 정산 조회
* - ReBAC: arti 객체에 대한 viewer 권한 필요
*/
@Get('arti/:artiId')
@ReBACPermission({
objectType: 'arti',
objectIdParam: 'artiId',
relation: 'viewer',
})
async getArtiSettlement(@Param('artiId') artiId: string) {
return this.settlementService.getByArtiId(artiId);
}
/**
* 부서 정산 목록 조회
* - ReBAC: department 객체에 대한 member 권한 필요
*/
@Get('department/:deptId')
@ReBACPermission({
objectType: 'department',
objectIdParam: 'deptId',
relation: 'member',
})
async getDepartmentSettlements(@Param('deptId') deptId: string) {
return this.settlementService.getByDepartmentId(deptId);
}
}
관계 동기화 서비스
기존 엔티티 변경 시 ReBAC 튜플 자동 동기화:
@Injectable()
export class ReBACSyncService {
constructor(private readonly rebacService: ReBACService) {}
/**
* 부서-매니저 관계 생성 시 튜플 동기화
*/
@OnEvent('department.manager.added')
async onManagerAddedToDepartment(payload: {
departmentId: string;
managerId: string;
role: 'admin' | 'member';
}) {
// 부서 멤버십 튜플 생성
await this.rebacService.createTuple({
objectType: 'department',
objectId: payload.departmentId,
relation: payload.role,
subjectType: 'manager',
subjectId: payload.managerId,
});
}
/**
* 아티스트-부서 관계 생성 시 튜플 동기화
*/
@OnEvent('department.arti.added')
async onArtiAddedToDepartment(payload: {
departmentId: string;
artiId: string;
}) {
// 아티스트 관리 관계
await this.rebacService.createTuple({
objectType: 'arti',
objectId: payload.artiId,
relation: 'managed_by',
subjectType: 'department',
subjectId: payload.departmentId,
});
// 부서 멤버에게 아티스트 viewer 권한 부여
await this.rebacService.createTuple({
objectType: 'arti',
objectId: payload.artiId,
relation: 'viewer',
subjectType: 'department',
subjectId: payload.departmentId,
subjectRelation: 'member',
});
}
/**
* 아티스트 그룹에 멤버 추가 시
*/
@OnEvent('artigroup.member.added')
async onArtiAddedToGroup(payload: {
artiGroupId: string;
artiId: string;
}) {
await this.rebacService.createTuple({
objectType: 'arti',
objectId: payload.artiId,
relation: 'member_of',
subjectType: 'arti_group',
subjectId: payload.artiGroupId,
});
}
}
권한 체크 플로우 상세 분석
앞서 본 postgreSQL 쿼리와 nest.js 코드를 살펴보면 알 수 있듯 ReBAC 를 적용했을 때 권한체크는 postgreSQL 내부적으로 처리됩니다. 비즈니스 로직에 대한 코드가 시각적으로 잘 보이지 않는데, 권한체크를 어떻게 재귀적으로 진행하게 되는지 예시에 대한 흐름도를 통해 살펴보도록 하겠습니다.
권한 부여 흐름도

생성된 Tuples:
- department:DEPT001#parent@agency:AG001
- department:DEPT002#parent@agency:AG001
- department:DEPT001#member@manager:MGR001
- department:DEPT001#admin@manager:MGR002
- department:DEPT002#member@manager:MGR001
- agency:AG001#admin@manager:MGR003
- arti:ARTI001#managed_by@department:DEPT001
- arti:ARTI002#managed_by@department:DEPT001
- arti:ARTI003#managed_by@department:DEPT002
- arti:ARTI001#viewer@department:DEPT001#member (부서 멤버에게 viewer 부여)
- arti:ARTI002#viewer@department:DEPT001#member
- arti:ARTI003#viewer@department:DEPT002#member
권한 체크 플로우 - Case 1: 직접 관계
매니저 MGR002가 아티스트 ARTI001을 볼 수 있는가?
check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR002')

권한 체크 플로우 - Case 2: 간접 관계 (그룹 멤버십)
매니저 MGR001이 아티스트 ARTI001을 볼 수 있는가
check_permission('arti', 'ARTI001', 'viewer', 'manager', 'MGR001')

권한 체크 재귀 플로우 - Case 3: 권한 없음
매니저 MGR002가 아티스트 ARTI003을 볼 수 있는가?
check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR002')

권한 체크 재귀 플로우 - Case 4: 소속사 관리자 (전체 권한)
매니저 MGR003(소속사 관리자)이 아티스트 ARTI003을 볼 수 있는가?
check_permission('arti', 'ARTI003', 'viewer', 'manager', 'MGR003')

다음으로는
위와 같이 postgreSQL + nest.js 로 fromm 서비스에 ReBAC 를 적용해 보았습니다. ReBAC 를 통해 복잡한 권한 구조를 가진 시스템에서도 확장성있는 권한관리가 가능하게 되었고, 각 리소스별로 세부적인 권한부여가 가능해졌습니다. 또한 관계가 동적으로 변경되는 경우에도 권한을 일관되게 가져갈 수 있다는 장점이 있었습니다.
하지만 fromm 에는 상세한 권한관리가 필요한 부분이 많이 남아 있습니다.
- 노머스 내부개발자의 각 소속사의 기능관리 도움
- 아티스트가 자신과 그룹의 채팅 및 채널의 관리
- 팬이 구독하고 있는 채팅방을 구독기간에 따른 채팅 접근권한 관리
내부시스템 뿐만 아니라 트래픽이 많이 발생하는 아티스트와 팬의 앱에 권한관리를 확장하기 위해서는 성능관리가 필수적으로 이루어져야 합니다.
물론 Google 이 사용하는 Zanzibar 의 아키텍쳐를 그대로 활용할 수 있다면 성능적인 문제를 한번에 해결할 수 있지만, 이를 운영하기 위한 비용과 아키텍쳐의 복잡도 등을 고려하면 바로 적용하기엔 오버엔지니어링 일 수 있습니다. 따라서 fromm 백엔드팀에서는 ReBAC 성능 최적화를 위해 다음과 같은 방안들을 고려하고 있습니다.
- ElastiCache Redis 캐싱
- 동일 권한부여/체크가 많이 발생해 hit rate가 높다고 판단되는 경우 쓰기, 읽기 성능 향상
- Materialized View 활용
- postgreSQL 의 Materialized View 로 권한테이블을 조회해 읽기 성능 향상
성능개선을 할 정도로 서비스가 확장된다면 다시한번 상세한 이야기를 다뤄보도록 하겠습니다.
지금까지 fromm 내부시스템에 ReBAC 를 적용해 권한관리 시스템을 구축한 과정을 알아보았습니다.
여기까지 글을 관심있게 읽어주신 모든분께 감사드립니다🙇
당신의 예술이 세상을 바꾼다.
저희와 함께 서비스의 품질을 높이기 위해 고민하고 함께 성장해나갈 개발자를 찾습니다. 관심있으신분들은 우측상단 Recruit 버튼을 통해 언제든지 지원부탁드립니다😀