API 설계 리뷰를 하다 보면 "OAuth 붙였으니 권한 관리는 끝났다"는 말을 종종 듣습니다. 반대로 사내에서 만든 RBAC 테이블을 외부 연동에도 그대로 쓰려는 경우도 있습니다. 둘 다 '권한'이라는 말로 묶이다 보니 생기는 혼동이라, 이번에 층위를 나눠 정리해 둡니다.
OAuth 2.0(RFC 6749)은 사용자가 비밀번호를 넘기지 않고 제3자 애플리케이션에 제한된 접근을 위임하는 인가 프레임워크입니다. RBAC는 한 시스템 안에서 사용자에게 역할을, 역할에 권한을 할당해 작업 허용 여부를 판단하는 접근 제어 모델입니다. OAuth는 "이 토큰으로 이 클라이언트가 이 자원에 접근해도 되는가"에, RBAC는 "이 역할을 가진 사용자가 이 작업을 해도 되는가"에 답합니다. 둘은 대체재가 아니라 층위가 다른 보완재이고, 실무에서는 함께 쓰는 경우가 많습니다.
혼동이 실제 문제로 번지는 경우를 예로 들어 보겠습니다. 주문 API가 read:orders 스코프만 확인하도록 만들어져 있다면, 이 스코프가 담긴 토큰을 가진 사용자는 일반 직원 계정이든 관리자 계정이든 똑같이 통과할 수 있습니다. 스코프는 사용자 개인의 권한 한도를 표현하지 않기 때문입니다.
내 코드가 어느 쪽인지는 지금 무엇을 확인하는지 보면 알 수 있습니다. 토큰의 서명, 만료, 스코프를 확인하고 있다면 OAuth 계층이고, 사용자 ID로 역할과 권한을 조회하고 있다면 RBAC 계층입니다. 선택지는 OAuth 스코프만 쓰기, RBAC만 쓰기, 두 계층을 조합하기 세 가지입니다. 아래에서는 OAuth 2.0과 RBAC의 용어를 먼저 짚고, 비교표, 흔한 오해, 두 계층을 조합하는 요청 처리 순서를 차례로 살펴봅니다.
1. OAuth 2.0은 위임을 위한 인가 프레임워크다
OAuth 2.0은 IETF가 RFC 6749로 발행한 인가 프레임워크입니다. 사용자가 제3자 앱에 비밀번호를 직접 주던 문제를 해결하려고 만들어졌습니다. RFC 6749는 이 문제를 이렇게 풀었다고 설명합니다.
OAuth addresses these issues by introducing an authorization layer and separating the role of the client from that of the resource owner.
클라이언트가 사용자의 자격증명 대신 접근 토큰을 받아서 쓰는 방식입니다. RFC 6749는 이 토큰을 "a string denoting a specific scope, lifetime, and other access attributes"라고 정의합니다. 범위와 유효기간이 정해진 문자열이라는 뜻입니다.
RFC 6749의 네 가지 역할
- Resource Owner: 보호된 자원에 대한 접근을 승인할 수 있는 주체입니다. 최종 사용자인 경우가 많습니다.
- Client: 자원 소유자를 대신해, 그 승인을 받아 보호된 자원에 요청하는 애플리케이션입니다.
- Authorization Server: 자원 소유자를 인증하고 승인을 받은 뒤 클라이언트에 접근 토큰을 발급하는 서버입니다.
- Resource Server: 보호된 자원을 호스팅하고, 접근 토큰이 담긴 요청을 받아 응답하는 서버입니다.
토큰 발급과 스코프
스코프(scope)는 이 토큰으로 허용되는 접근 범위입니다. 클라이언트가 scope 파라미터로 요청하고, 사용자가 동의한 범위 안에서 인가 서버가 발급 시점에 정합니다. 아래는 authorization code를 토큰으로 교환하는 요청 예시입니다. 도메인과 값은 예시입니다.
$ curl -s -X POST https://auth.example.com/oauth/token \
-u my-client-id:my-client-secret \
-d grant_type=authorization_code \
-d code=SplxlOBeZQQYbYS6WxSbIA \
-d redirect_uri=https://app.example.com/callback
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:orders"
}
이 토큰에는 read:orders만 담겨 있습니다. 이 토큰으로 쓰기 API를 호출하면 리소스 서버가 거절합니다. Bearer 토큰 사용법을 정의한 RFC 6750에는 스코프가 모자랄 때 쓰는 insufficient_scope 에러 코드가 있습니다.
$ curl -i -X POST https://api.example.com/orders/1001/refund \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="write:orders"
여기서 막히는 지점이 있습니다. 스코프가 발급 시점에 정해지기 때문에, 이미 발급된 토큰의 범위를 서버에서 늘릴 수는 없습니다. 범위를 넓히려면 사용자에게 다시 동의를 받고 토큰을 새로 발급받아야 할 수 있습니다.
2. RBAC는 시스템 내부의 접근 제어 모델이다
RBAC(Role-Based Access Control)는 권한을 사람에게 직접 주지 않고 역할을 거쳐 주는 접근 제어 모델입니다. 사람마다 권한을 일일이 관리하면 인사 이동이나 퇴사 때마다 누락이 생기니, 권한 묶음을 역할로 만들어 관리하자는 발상입니다.
David Ferraiolo와 Rick Kuhn이 1992년에 공식화했고, 2004년 ANSI/INCITS 359로 채택된 뒤 2012년에 개정되었습니다. NIST 설명에 따르면 이 표준은 두 부분으로 나뉩니다. 하나는 RBAC 참조 모델(Reference Model)이고, 다른 하나는 시스템 및 행정 기능 명세(System and Administrative Functional Specification)입니다.
참조 모델의 기본 요소는 users, roles, permissions, operations, objects입니다. 각 사용자에게 하나 이상의 역할을, 각 역할에 하나 이상의 권한을 할당합니다. 권한은 어떤 객체(object)에 어떤 작업(operation)을 할 수 있는지를 나타냅니다.
짚어 둘 점은 RBAC가 통신 프로토콜이 아니라는 것입니다. 네트워크로 주고받는 메시지 형식이 정해져 있지 않습니다. DB 테이블로 만들 수도 있고, LDAP 그룹이나 정책 엔진으로 만들 수도 있습니다.
PostgreSQL 테이블로 만든 최소 RBAC
제가 작은 서비스에서 RBAC를 처음 잡을 때는 테이블 네 개로 시작합니다.
CREATE TABLE roles ( id serial PRIMARY KEY, name text UNIQUE NOT NULL ); CREATE TABLE permissions ( id serial PRIMARY KEY, operation text NOT NULL, -- refund, read, update ... object text NOT NULL, -- order, member ... UNIQUE (operation, object) ); CREATE TABLE user_roles ( user_id bigint NOT NULL, role_id int NOT NULL REFERENCES roles(id), PRIMARY KEY (user_id, role_id) ); CREATE TABLE role_permissions ( role_id int NOT NULL REFERENCES roles(id), permission_id int NOT NULL REFERENCES permissions(id), PRIMARY KEY (role_id, permission_id) );
요청이 들어오면 사용자 ID, 작업, 객체로 허용 여부를 조회합니다. 42번 사용자에게 store_manager 역할이 있고, 이 역할에 refund/order 권한이 할당되어 있다고 가정합니다.
sarc=# SELECT EXISTS (
SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = 42
AND p.operation = 'refund'
AND p.object = 'order'
) AS allowed;
allowed
---------
t
(1 row)
관리자가 역할을 회수하면 다음 요청부터 바로 결과가 바뀝니다.
sarc=# DELETE FROM user_roles
WHERE user_id = 42
AND role_id = (SELECT id FROM roles WHERE name = 'store_manager');
DELETE 1
-- 같은 SELECT를 다시 실행
allowed
---------
f
(1 row)
요청 시점에 역할 저장소를 조회하기 때문에 토큰을 새로 발급하지 않아도 변경이 반영될 수 있습니다. 스코프가 발급 시점에 고정되는 OAuth와 가장 크게 다른 점입니다.
3. OAuth와 RBAC 비교표
| 구분 | OAuth 2.0 | RBAC |
|---|---|---|
| 표준 | IETF RFC 6749, 통신 프로토콜 | ANSI/INCITS 359, 접근 제어 모델(프로토콜 아님) |
| 다루는 범위 | 시스템이나 애플리케이션 사이의 위임된 접근, 주로 API 경계 | 시스템 내부의 사용자별 세부 권한 |
| 핵심 단위 | 토큰과 스코프 | 역할과 권한 |
| 결정 시점 | 토큰 발급 시점에 고정, 바꾸려면 재발급이 필요할 수 있음 | 요청 시점에 조회, 관리자가 바꾸면 곧바로 반영될 수 있음 |
| 답하는 질문 | 이 토큰으로 이 클라이언트가 이 자원에 접근해도 되는가 | 이 역할을 가진 사용자가 이 작업을 해도 되는가 |
| 관계 | 대체재가 아니라 층위가 다른 보완재 |
4. OAuth 2.0은 로그인 프로토콜이 아니다
"OAuth로 로그인을 구현했다"는 표현은 엄밀히 말하면 틀립니다. RFC 6749가 정의하는 것은 인가(authorization)이고, 사용자가 누구인지 확인하는 인증(authentication)이 아닙니다. 접근 토큰은 "이 범위의 접근이 허락되었다"는 뜻이지, 클라이언트에게 사용자의 신원을 증명해 주는 용도가 아닙니다.
사용자 신원 확인이 필요하면 OpenID Connect를 씁니다. OpenID Connect는 OpenID Foundation이 OAuth 2.0 위에 만든 별도 표준이고, oauth.net에서도 OAuth 2.0 위에 구축된 프로토콜로 분류합니다. 소셜 로그인 버튼 뒤에서 실제로 신원을 확인하는 것은 OpenID Connect인 경우가 많으니, 문서나 코드 주석에서 둘을 같은 것처럼 쓰지 않아야 합니다.
5. 실무에서는 두 계층을 함께 쓴다
널리 쓰이는 구조 중 하나는 OAuth/OIDC가 신원과 대략의 범위를 증명하고, API 서버 내부에서 RBAC가 세부 권한을 판단하는 두 계층 조합입니다. 환불 API 호출 한 건을 기준으로 요청 처리 순서를 나누면 다음과 같습니다.
- 사용자가 클라이언트 앱에서 로그인하고 동의합니다. 인가 서버가 사용자를 인증한 뒤 접근 토큰을 발급합니다(OAuth/OIDC 계층).
- 클라이언트가
Authorization: Bearer헤더에 토큰을 담아POST /orders/1001/refund를 호출합니다. - 리소스 서버가 토큰의 서명, 발급자(
iss), 대상(aud), 만료(exp)를 검증합니다. 실패하면 401을 반환합니다. - 토큰의 스코프에
write:orders가 있는지 확인합니다. 없으면 403과insufficient_scope를 반환합니다. 여기까지가 "이 클라이언트가 이 API에 접근해도 되는가"입니다. - 토큰의
sub(사용자 식별자)로 역할 저장소를 조회해refund/order권한이 있는지 확인합니다(RBAC 계층). 없으면 403을 반환합니다. - 필요하면 "자기 매장 주문인가" 같은 데이터 단위 조건을 추가로 확인한 뒤 요청을 처리합니다.
JWT 형식 접근 토큰의 페이로드를 열어 보면 아래와 같은 모양입니다. 클레임 이름과 값은 인가 서버 구현에 따라 다릅니다. OAuth 2.0용 JWT 프로필인 RFC 9068은 jti를 필수 클레임으로 두고, roles나 groups는 선택(optional) 클레임으로 정의합니다 - 6절에서 말한 "OAuth 표준의 요구사항이 아니다"는 정확히는 "필수가 아니라 RFC 9068이 정의하는 선택 클레임"이라는 뜻입니다.
{
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"sub": "42",
"client_id": "my-client-id",
"scope": "read:orders write:orders",
"iat": 1700000000,
"exp": 1700003600,
"jti": "b3f1c2a4-..."
}
API 서버 미들웨어로 옮기면 다음과 같습니다. verifyAccessToken과 rbac.can은 사용하는 라이브러리와 저장소에 맞게 구현해야 하는 자리이고, 구조를 보여 주기 위한 단순화한 예시입니다.
async function requireRefund(req, res, next) {
// 1) OAuth 계층: 토큰 자체가 유효한가
if (!req.headers.authorization) {
// RFC 6750: 인증을 아예 시도하지 않은 요청에는 error 파라미터 없이 401만 반환
return res.status(401).set('WWW-Authenticate', 'Bearer').end();
}
let claims;
try {
claims = await verifyAccessToken(req.headers.authorization); // 서명, iss, aud, exp
} catch (e) {
return res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"').end();
}
// 2) OAuth 계층: 이 클라이언트에 위임된 범위인가
const scopes = (claims.scope || '').split(' ');
if (!scopes.includes('write:orders')) {
return res.status(403)
.set('WWW-Authenticate', 'Bearer error="insufficient_scope", scope="write:orders"')
.end();
}
// 3) RBAC 계층: 이 사용자가 이 작업을 할 수 있는가 (요청 시점 조회)
const allowed = await rbac.can(claims.sub, 'refund', 'order');
if (!allowed) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
2번과 3번을 모두 통과해야 요청이 처리됩니다. 스코프는 "사용자가 이 앱에 맡긴 범위"이고 역할은 "사용자 본인이 원래 가진 권한"이라, 둘 중 좁은 쪽이 실제 한도가 됩니다.
6. 두 계층을 섞을 때 자주 막히는 함정
토큰에 실은 역할 클레임이 늦게 반영된다
토큰에 roles나 groups 클레임을 실어 보내는 구현이 많습니다. 이것은 OAuth 표준이 요구하는 것이 아니라 인가 서버 구현체가 선택한 방식입니다.
막상 운영해 보면 여기서 걸립니다. 관리자가 역할을 회수해도 이미 발급된 토큰에는 예전 역할이 남아 있어서, 만료 전까지는 API 서버가 예전 권한을 믿고 통과시킬 수 있습니다. 환불, 권한 변경, 개인정보 조회 같은 민감한 작업은 토큰 클레임을 믿지 말고 위 예시처럼 요청 시점에 역할 저장소를 조회하도록 합니다. 클레임에 역할을 싣는다면 토큰 유효기간을 짧게 잡아 두는 것이 좋습니다.
스코프로 역할을 흉내 낸다
처음엔 admin, manager 같은 스코프를 만들어 역할처럼 쓰는 설계를 자주 봅니다. 이렇게 하면 역할 하나를 바꿀 때마다 재동의와 토큰 재발급이 따라오고, 동의 화면에 사내 조직 구조가 그대로 드러납니다. 스코프는 read:orders처럼 API 단위의 거친 범위로만 쓰고, 누가 무엇을 할 수 있는지는 RBAC에 맡기는 편이 관리하기 쉽습니다.
사용자 없는 토큰에 RBAC 조회를 건다
RFC 6749의 client credentials 그랜트로 받은 토큰은 사용자 없이 클라이언트 자신의 권한으로 발급됩니다. 이 토큰의 sub를 사용자 ID로 간주해 RBAC 테이블을 조회하면 엉뚱한 사용자와 매칭되거나 항상 거절될 수 있습니다. 서비스 간 호출용 클라이언트는 별도의 서비스 계정 역할로 관리하고, 미들웨어에서 사용자 토큰과 구분해서 처리합니다.
7. 언제 무엇을 쓰나
- 외부 앱이나 모바일 앱이 사용자 대신 API를 호출해야 한다: OAuth 2.0을 붙입니다. 로그인과 신원 확인까지 필요하면 OpenID Connect를 함께 씁니다.
- 사내 관리 화면처럼 한 시스템 안에서 직원별 권한만 나누면 된다: RBAC부터 설계합니다. 위임할 제3자가 없는데 OAuth부터 도입할 이유는 없습니다.
- 외부에 API를 열면서 사용자별 세부 권한도 필요하다: OAuth로 토큰과 스코프를 검증한 뒤, API 서버 내부에서 RBAC로 작업 단위 권한을 판단하는 두 계층 구조로 갑니다.
정리하면, OAuth는 시스템 경계에서 "누구에게 얼마만큼 맡겼는가"를 다루는 프로토콜이고, RBAC는 시스템 안에서 "누가 무엇을 할 수 있는가"를 다루는 모델입니다. 어느 한쪽을 골라야 하는 문제가 아니라 층위를 나눠 각자 맡은 질문에만 답하게 하면 됩니다. 권한 사고는 흔히 한쪽이 다른 쪽의 질문까지 떠안았을 때 나타납니다.