서비스를 개발하면서 정책이 점점 많아지고, 프론트에서 이를 처리하기 위한 비즈니스 로직이 점점 많아졌다.
- 일반 유저와 Core Plan 유저는 캐릭터를 최대 5개까지 만들 수 있다.
- Prime 유저는 캐릭터를 최대 최대 50개까지 만들 수 있다.
- Infinity 유저는 캐릭터를 제한 없이 만들 수 있다.
이 정책을 프론트엔드에서 직접 관리하다 보니, 캐릭터 생성이라는 단일 행위(action) 임에도 불구하고 진입점마다 비즈니스 로직이 흩어지기 시작했다.
그리고 위 정책 말고도 많은 정책들이 생기면서 프론트 코드가 점점 지저분해졌다.
const characterCreatedCount = user.characterCreatedCount ?? 0
const subscriptionPlan = user.subscriptionPlan ?? 'FREE'
if(
(subscriptionPlan === 'FREE' || subscriptionPlan === 'CORE') &&
characterCreatedCount >= 5
){
return
}
if(subscriptionPlan === 'PRIME' && characterCreatedCount >= 50){
return
}
router.push('/character/create')백엔드에서도 동일한 검증 로직이 중복되었고, 정책 변경 시 프론트/백엔드 모두 재배포 해야했다.
그리고 정책이 늘수록 코드 가독성과 유지보수성이 급격히 저하되는 문제도 있었다.
그래서 나는 유저의 권한을 RBAC (Role Based Access Control) 방식으로 제어해서, 프론트엔드와 백엔드는 “이 유저가 이 액션을 할 수 있는가?”만 질의하고, 판단은 하나의 서버에서 책임지도록 만들고자 했다.
나는 AWS IAM(Identity and Access Management)에서 영감을 얻어, RBAC 기반으로 권한을 정의하고 평가하는 서버를 개발하기로 했다.
동시에 Over-Enginnering은 자제하고 우리 서비스에 맞게 단순화한 형태의 서버를 만들기로 했다.
이름은 IAM Server 라고 붙였다!

IAM 서버의 목표
user_id와action으로 이 유저가action을 할 수 있는지 없는지 여부를 반환하는 것
[예시]
"user_id": "test_user_123"
"action" : "CreateCharacter"
---
"is_allowed" = trueIAM 서버 리소스 개념
AWS를 보면, 아래와 같이 각 리소스를 ARN으로 관리하고, User, Role, Policy 등 다양한 IAM Resource가 존재한다.


나는 AWS IAM에서 아이디어를 얻어 ARN 대신 아래와 같이 Resource Name으로 관리하기로 했다.
Action:CreateCharacterState:UserSubscriptionPlan{type}:{name}
{type}:{name} 형식으로 리소스가 관리되어서 모든 리소스를 겹치지 않게 ARN과 같은 원리로 관리할 수 있다.
그리고 나는 아래와 같은 리소스를 정의했다.
- User
- Attribute
- Action
- State
- Policy
- Role
- Role Group
1. User
User는 우리 서비스의 유저와 1:1 매칭된다.
2. Attribute
Attribute는 상수이다.
아래와 같이 Attribute는 Integer 또는 String으로 정의되어서 후에 Condition 비교에 사용된다.

3. Action
우리가 최종적으로 사용자가 이 Action을 하는 것을 허용할 것인지의 대상이된다.
예를 들어 아래 사진에서는 최종적으로 아래 쿼리를 날릴 것이다.
- User ID가
test_user_123인 유저가CreateCharacter라는 Action을 할 수 있는가?

4. State
동적으로 데이터베이스에서 불러올 수 있는 변수이다.
UserCharacterCreatedCount는 사용자가 캐릭터를 몇 개 만들었는지 실제로 데이터베이스를 조회해야 한다.
후에 조건 비교 시 State와 Attribute를 비교해서 Action을 할 수 있는지 판단한다.

5. Policy
State와 Attribute로 특정 Action을 허용하는 조건을 명시한다.
Condition을 정의하고, 이 조건을 만족시키면 해당 Action을 하는 것을 허용하는 Policy이다.
아래 예시는 User의 CharacterCreatedCount가 CoreCharacterCreateLimit보다 작은 경우에만 CreateCharacter을 허용한다는 의미이다.

6. Role
Policy의 집합으로, User에게 Attach 될 수 있다.
User에게 Role을 Attach 하면, User는 해당 Action이 붙어있는 Policy의 조건을 만족시킬 때 해당 Action을 하는 것을 허용한다.
Policy 정의만으로는 아무 의미가 없고, Role을 User에게 Attach 했을 때 의미가 생긴다.
그 User가 해당 Action을 할 수 있는 Role을 부여하는 것이기 때문이다.
예를 들어, CreateCharacterPolicy, CharacterImageCreatePolicy 두 개의 Policy를 CharacterCreateRole로 묶어서 관리하면 User에게 이 Role을 Attach 하는 것만으로 캐릭터 생성 관련 정책을 한 번에 관리할 수 있다.
7. Role Group
Role의 집합으로, User에게 Attach 될 수 있다.
특정 플랜에 공통적으로 여러 Role이 적용되는 경우, User에게 각 Role을 부착하지 않고, 특정 상태에 적용되는 Role들을 Role Group으로 묶어서 User에게 Attact 하면 된다.
예를 들어, Prime 플랜을 가지고 있는 유저에게 PrimeCharacterCreateRole, PrimePersonaCreateRole, PrimeMemoryCreateRole 세 개의 Role을 적용해야 하면, 이 세 개를 묶어서 PrimeRoleGroup을 만들어서 User에게 Attach하면 Prime 유저는 하위 Role을 모두 적용받게 된다.
구현
AWS IAM을 참고했기 때문에 리소스 정의는 어렵지 않았다.
용어는 살짝씩 다르지만 원리는 AWS와 비슷하기 때문이다.
하지만 이를 모두 구현하는 것은 상당한 개발 공수가 들 것이라고 생각했다.
그래서 내가 찾아낸 것이 오픈소스 Access Control 라이브러리인 Casbin이다.

1. 다양한 언어에서 SDK 형태로 쉽게 사용할 수 있다.

2. 아래와 같이 자체 Conf 문법으로 리소스 구조, 검증 방식을 Customization 할 수 있다.
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && keyMatch5(r.obj, p.obj) && regexMatch(r.act, p.act)3. 다양한 데이터베이스와 통합되어 Casbin Rule을 쉽게 관리할 수 있다.
우리는 MongoDB를 사용하는데, casbin_rule이라는 컬렉션에 아래와 같이 정책들과 정책 연결 등 데이터가 저장되는 것을 알 수 있다.

따라서 Casbin이 제공하는 Conf 를 활용해서 내가 도입한 리소스 구조를 입히기만 하면 구현은 어렵지 않게 할 수 있다.
Casbin Rule
아래는 실제로 IAM Server에 적용되어 있는 Conf 파일이다.
[request_definition]
r = sub, obj, state, attribute
[policy_definition]
p = sub, obj, cond
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && p.obj == r.obj && eval(p.cond)Casbin Conf 문법은 아래 공식문서를 참고한다.
1. request_definition
- 요청 주체 : sub
- 리소스 : obj
- 상태 : state
- 속성 : attribute
으로 이루어진다.
2. policy_definition
- 주체 : sub
- 리소스 : obj
- 조건식 : cond
으로 이루어진다.
3. role_definition
표준 RABC Role 관계인 g(user, role)이다.
4. policy_effect
하나라도 allow 정책이 있으면 허용한다.matchers에서 요청 정책을 확인하기 때문에, “요청 정책이 allow이면 허용한다”와 같은 말이기도 하다.
5. matchers
g(r.sub, p.sub): 요청 주체r.sub가 정책 주체p.sub와 role 관계로 연결되어 있는가p.obj == r.obj: 정책이 정의한 리소스와 요청 리소스가 동일한가eval(p.cond): 정책 조건식을 실행해서 true인지 평가
1. Casbin의 구조를 몰라도 쉽게 RBAC를 할 수 있도록 AWS IAM를 클론한 GUI를 만들었다.
AWS IAM에 대한 이해만 있으면 어렵지 않게 GUI를 통해 모든 권한을 관리할 수 있다.



2. State는 동적으로 데이터베이스에서 값을 가져와야 하므로 Casbin만으로는 처리가 어렵다.
직접 코드를 넣어서 데이터베이스에서 원하는 값을 리턴하도록 할 수 있다.

GUI에서 State를 만들 때, Function을 직접 작성할 수 있고, 작성법과 함께 User ID로 테스트 할 수 있는 기능까지 넣어서 쉽게 State를 만들 수 있도록 하였다.

아래와 같이 함수를 작성하고, User ID로 제대로 값이 넘어오는 것을 테스트 한 후 State를 만들 수 있다.

3. Policy는 프로그래밍 언어 문법으로 직관적으로 설정할 수 있다.
C++ 문법이랑 비슷하게 ||나 && 을 지원하므로, State와 Attribute를 조합해서 만들 수 있다.
실제 권한 판단 시에는 State:ImageGenerationCount는 State에 정의한 함수대로 데이터베이스를 조회하고 실제 값이 대체되고, Attribute:PrimeImageGenerationLimit는 Attribute에 정의된 상수 값으로 대체되어 실제 값으로 비교된다.

구현 결과
작성한 API 엔드포인트는 31개이고, 각 리소스에 대한 기본적인 CRUD가 주를 이룬다.


실제 사용할 때는 /api/iam/v1/permission으로 User ID와 Action으로 허용 여부를 판단하게 된다.
테스트 시나리오
Plan에 따라 만들 수 있는 캐릭터가 정해져 있는데, 특정 User가 캐릭터를 만들(Action:CreateCharacter) 수 있는지 판단하는 시나리오를 테스트해보자.
정책은 아래와 같다.
- 일반 유저와 Core Plan 유저는 캐릭터를 최대 5개까지 만들 수 있다.
- Prime 유저는 캐릭터를 최대 최대 50개까지 만들 수 있다.
- Infinity 유저는 캐릭터를 제한 없이 만들 수 있다.
1. Attribute 정의
- Default(일반 유저), Core의 경우에는 Value를 5로 설정
- Prime은 50으로 설정
- Infinity는 99999로 설정 (사실상 무한으로 취급)

2. State 정의
아래와 같이 User 테이블에 테스트 데이터가 있다고 하자.

State Function은 아래와 같이 정의했다.
def main(user_id, context):
user = context.find(
collection="user",
filter={"user_id": user_id},
find_one=True
)
result = user["createdCharacter"]
return result if result else 0아래와 같이 State:UserCharacterCreatedCount라는 이름의 리소스가 생성되었다.

3. Action 정의
우리의 목표는 “캐릭터 생성”이 가능한지 판단하는 것이므로 Action:CreateCharacter를 생성해준다.

4. Policy 정의
State와 Attribute를 사용하여 조건 만족 시 Action을 허용하는 정책을 만들어준다.
기본 유저와 정책 별로 총 네 개의 Policy를 만들었다.
State:UserCharacterCreatedCount가 플랜에 맞는 Attribute의 Character Create Limit을 참조하는 것을 알 수 있다.




5. Role, Role Group 생성
플랜 별로 DefaultCharacterCreateRole, …, InfinityCharacterCreateRole을 생성한다.

그리고 플랜 별로 공통된 Role을 갖도록 하기 위해 플랜 별 Role을 묶어 Role Group을 생성한다.

6. 최종 결과
모든 리소스를 생성한 후 현황은 아래와 같다.

7. Role Group Attach
Attach는 GUI에서 지원하지 않으므로 API Call을 직접 날렸다.
실제 운영환경에서는 결제가 일어날 때 백엔드에서 호출된다.
POST /api/iam/v1/attach
[Request]
{
"user_id": "694acb00e5c5f1ef50812f2a",
"resource_name": "RoleGroup:InfinityRoleGroup"
}
[Response]
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694acb00e5c5f1ef50812f2a",
"name": "InfinityRoleGroup",
"resource_type": "RoleGroup"
}
}
테스트
[테스트 1]

- User ID :
694ab1bae5c5f1ef50812f23 - 구독은 하지 않은 상태이고, 3개의 캐릭터를 만들었다.
- 아래와 같이 5개까지 만들 수 있는데, 3개 만든 상태이므로
CreateCharacter를 수행할 수 있다.
GET /api/iam/v1/permission
[Query Params]
user_id = 694ab1bae5c5f1ef50812f23
action = CreateCharacter
[Response]
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694ab1bae5c5f1ef50812f23",
"action": "CreateCharacter",
"result": "allowed",
"is_allowed": true,
"state": {
"UserCharacterCreatedCount": 3
},
// ...
}
}
[테스트 2]

- 같은 유저가 캐릭터를 더 만들어 5개 만들었다고 하자.
- 아래와 같이 5개를 모두 만들었으므로
denied가 뜬 것을 알 수 있다.
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694ab1bae5c5f1ef50812f23",
"action": "CreateCharacter",
"result": "denied",
"is_allowed": false,
"state": {
"UserCharacterCreatedCount": 5
},
...
}
}
[테스트 3]

- User ID :
694acb00e5c5f1ef50812f2a - Infinity 구독 중이고, 7,314개의 캐릭터를 만들었다.
- 무한으로 만들 수 있으므로
CreateCharacter를 수행할 수 있다.
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694acb00e5c5f1ef50812f2a",
"action": "CreateCharacter",
"result": "allowed",
"is_allowed": true,
"state": {
"UserCharacterCreatedCount": 7314
},
...
}
}

[테스트 4]

- User ID :
694ab1f9e5c5f1ef50812f24 - Core 구독 중이고, 4개의 캐릭터를 만들었다.
- 5개까지 만들 수 있는데, 4개 만들었으므로
CreateCharacter를 수행할 수 있다.
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694ab1f9e5c5f1ef50812f24",
"action": "CreateCharacter",
"result": "allowed",
"is_allowed": true,
"state": {
"UserCharacterCreatedCount": 4
},
...
}
}
[테스트 5]

- 같은 유저가 캐릭터를 하나 더 만들어서 5개가 되었다.
- 아래와 같이 5개를 모두 만들었으므로
denied가 뜬 것을 알 수 있다.
{
"status_code": 200,
"response_code": 0,
"msg": "succeed",
"data": {
"user_id": "694ab1f9e5c5f1ef50812f24",
"action": "CreateCharacter",
"result": "denied",
"is_allowed": false,
"state": {
"UserCharacterCreatedCount": 5
},
...
}
}
배포
IAM 서버 API는 노출되면 안 되므로 GUI는 Private Subnet에 배치하고, Permission을 제외한 모든 API는 JWT 기반 Admin 검증을 거쳐서 관리자 권한을 가진 사용자만 IAM 관리를 할 수 있도록 하였다.
마무리
위 서버는 우리 서비스에 바로 적용되어 캐릭터 생성 개수 제한 관련 정책부터 교체해가며 비즈니스 로직을 줄이고 정책 관리를 중앙화하였다.
특히 PrimeRoleGroup과 같이 구독 플랜으로 Role Group을 만들어 관리하니 매우 편했다.
백엔드에서는 구독 플랜이 바뀔 때 RoleGroup을 업데이트 해주기만 하면 바로 적용되므로 공수도 크지 않았다.
이외에도 AdminUserRoleGroup, DefaultUserRoleGroup 등을 정의해서 유저의 권한에 따라서도 Role Group을 관리할 수 있었다.
GUI 상에서 정책을 변경하면 바로 Production에 적용되었기 때문에 재배포를 할 필요 없이 매우 편하게 정책을 변경할 수 있었다.
새로운 정책이 생겼을 때도 기존 Attribute와 State를 재사용 할 수 있다면 역시 재배포 없이 새로운 정책을 Role에 붙여서 변경할 수도 있었다.
구현 하면서 느낀 것이 모든 기능을 전부 개발할 수는 없다.
나와 같은 고민을 하고 있는 사람이 분명히 있기 때문에 내가 원하는 기능을 구현해놓은 오픈소스 프로젝트가 있을 가능성이 매우 높다.
나도 이 사실을 알고 있었기 때문에 무작정 개발을 시작하기 보다는, 내가 원하는 기능을 구현해놓은 오픈소스 프로젝트가 있는지 먼저 찾아보았고, 그 결과로 Casbin을 사용하게 된 것이다.
Casbin이 제공하는 구조 아래에서 내가 구상한 리소스를 얹는 느낌으로, 복잡한 구현은 Casbin을 사용하고, 우리 서비스에 Fit 한 RBAC 리소스 구현은 내가 직접 커스텀해서 하루만에 구현할 수 있었다.
나는 AWS 신봉자이기 때문에 AWS의 서비스들을 상당히 좋아하는 편인데, 옛날부터 IAM은 매우 직관적인 RBAC 라고 생각해서 꼭 한 번 활용할 기회가 있었으면 좋겠다고 생각했다.
이번 IAM 서버를 직접 개발하고 Production 환경에도 직접 적용해볼 수 있어서 무척 의미있는 경험이었다.

댓글 남기기