
앞선 글에서:
tenant / user / tenant_user 도메인 패턴 까지 개념 위주로 정리했다.
이번 4편은 이 개념들을 실제 프로젝트에 어떻게 녹였는지,
그리고 구현 레벨에서 어떤 선택지가 있고 나는 무엇을 택했는지까지 함께 정리한 글이다.
가상의 이름을 쓰면,
“여러 서비스가 입점해서 공통 회원을 쓰는 통합회원 서비스 지원 플랫폼”
에서 이 구조들을 어떻게 적용했는지 소개하는 형태다.
tenant / user / tenant_user를 어떻게 매핑했는지까지 담아보려고 한다.
요구사항 한 줄 요약:
“여러 입점 서비스가 하나의 회원 풀을 공유하지만,
각 서비스별로 가입/약관/권한/상태는 따로 관리하고 싶다.”
도메인 모델:
tenant → 입점 서비스user → 통합 회원(공통 계정)tenant_user → 서비스별 회원 관계(상태/약관/역할)인증:
tenant_id, tenant_user_id, roles 포함auth:refresh:{tenantId}:{userId}:{deviceId}RBAC:
PLATFORM_ADMINTENANT_ADMIN, TENANT_USER구현 레벨 설계:
@PreAuthorize("@authz.hasPermission('ORDER_READ')"))user_role–role_menu–menu_api 조인)도 썼었고,처음부터 “모든 걸 커스터마이징 가능한 IAM”을 만들기보다는,
플랫폼 컨셉은 대략 이랬다.
추가 요구도 있었다.
이 흐름에서 자연스럽게:
라는 멀티테넌트 관점으로 바라보는 게 설계하기 편했다.
tenant / user / tenant_user 매핑플랫폼의 기본 도메인 구조는 다음과 같이 정리했다.
user -- 통합 회원(공통 계정)
---------
id (PK)
email
password_hash
name
phone
status -- 통합 계정 단위의 상태(정상/잠김/삭제 등)
...
tenant -- 입점 서비스
---------
id (PK)
code
name
status -- 서비스 상태(정상/중지 등)
...
tenant_user -- 서비스별 회원 관계
---------
id (PK)
tenant_id (FK -> tenant.id)
user_id (FK -> user.id)
status -- 가입, 휴면, 탈퇴, 차단 등 (서비스 단위)
role_code -- TENANT_ADMIN / TENANT_USER 등
agreed_terms_ver -- 서비스별 약관 동의 버전
agreed_marketing -- 서비스별 마케팅 동의 여부
joined_at
...
역할/권한까지 확장하면:
role
---------
id
code -- ex) PLATFORM_ADMIN, TENANT_ADMIN, TENANT_USER
scope -- GLOBAL / TENANT
name
permission
---------
id
code -- ex) ORDER_READ, ORDER_MANAGE, USER_MANAGE
name
role_permission
---------------
role_id (FK)
permission_id (FK)
tenant_user_role
-----------------
tenant_user_id (FK)
role_id (FK)
의미는 단순하다.
user:
tenant:
tenant_user:
“user가 특정 tenant에 가입했는가?”
role, permission, role_permission, tenant_user_role:
2편에서 정리한 패턴을 그대로 적용했다.
Access Token
Authorization: Bearer ... 로 사용Refresh Token
입점 서비스 기준으로는
“어떤 테넌트 컨텍스트에서 로그인했는가”가 핵심이라, Access Token에는 다음 정보를 넣었다.
{
"sub": "user-12345",
"tenant_id": "svc-001",
"tenant_user_id": "svc001-user-6789",
"roles": ["TENANT_USER"],
"iat": 1730000000,
"exp": 1730003600
}
tenant_idtenant_user_idtenant + user 조합을 나타내는 키 (있으면 도메인 조회가 편해짐)roles통합회원 플랫폼은 하나의 user가 여러 tenant에 가입할 수 있고,
각 테넌트마다 다른 상태/역할을 가질 수 있다.
그래서 토큰은 항상 특정 tenant 컨텍스트를 기준으로 발급하도록 했다.
여러 서비스에서 동시에 로그인할 수 있기 때문에,
Refresh Token은 tenantId + userId + deviceId 단위로 관리했다.
auth:refresh:{tenantId}:{userId}:{deviceId}
-> { refreshToken, issuedAt, expiresAt, userAgent, ip, ... }
Access Token은 TTL을 짧게 가져가고,
Refresh Token을 삭제하는 것만으로도 대부분의 요구사항을 커버할 수 있었다.
입점 구조 특성상, 역할은 두 레벨로 나눴다.
PLATFORM_ADMINTENANT_ADMINTENANT_USER초기에는 TENANT_ADMIN, TENANT_USER 정도의 작은 세트만으로도
입점 서비스들이 필요로 하는 대부분의 시나리오는 커버할 수 있었다.
권한(permissions)은 API 하나하나까지 잘게 쪼갰다기보다는,
기능 묶음 단위로 정의했다.
예:
USER_MANAGE – 서비스 내 사용자 관리/조회ORDER_MANAGE – 주문 관련 관리STATS_VIEW – 통계 조회그리고 역할은 이러한 permission을 묶어서 구성했다.
요구는 이랬다.
컨트롤러 단위로
이런 정책을 운영 중에 바꿀 수 있으면 좋겠다.
그래서 먼저 설계 옵션들을 정리해 보고,
그중 실제로 취한 선택과, 과거에 경험했던 다른 방식까지 비교해보는 식으로 접근했다.
로그인 시:
장점
단점
JWT에는 roles만 담고,
“이 role이 어떤 permission을 가지는지”는 서버에서 관리
장점
단점
GET /api/orders → ORDER_READ
POST /api/orders → ORDER_MANAGE
같은 매핑을 endpoint_permission 테이블로 관리하는 방식
장점
단점
→ 이 정도까지 유연성이 꼭 필요하지 않다면,
보통은 A/B 조합 선에서 정리하는 편이 더 현실적이다.
이 플랫폼에서는 다음과 같이 정리했다.
API → permission 매핑은 코드에서 고정
ORDER_READ, ORDER_MANAGE, USER_MANAGE 등role → permission은 DB에서 관리 + 애플리케이션 로컬 캐시
JWT에는 role만 포함
// 주문 목록 조회 – ORDER_READ 권한 필요
@PreAuthorize("@authz.hasPermission('ORDER_READ')")
@GetMapping("/api/orders")
public List<OrderDto> listOrders() { ... }
// 주문 생성 – ORDER_MANAGE 권한 필요
@PreAuthorize("@authz.hasPermission('ORDER_MANAGE')")
@PostMapping("/api/orders")
public OrderDto createOrder(...) { ... }
여기서 "ORDER_READ", "ORDER_MANAGE"는 API의 책임이고,
“어떤 ROLE이 이 permission을 가지는가”는 DB/관리화면에서 결정한다.
@Component("authz")
public class AuthorizationHelper {
private final PermissionService permissionService;
public AuthorizationHelper(PermissionService permissionService) {
this.permissionService = permissionService;
}
public boolean hasPermission(String requiredPermission) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return false;
}
TenantUserPrincipal principal = (TenantUserPrincipal) auth.getPrincipal();
// 1) 이 tenant_user가 가진 permission 목록 조회 (캐시 활용)
Set<String> userPermissions =
permissionService.getPermissionsOfTenantUser(principal.getTenantUserId());
// 2) requiredPermission 포함 여부 확인
return userPermissions.contains(requiredPermission);
}
}
TenantUserPrincipal은 JWT에서 읽어온 userId, tenantId, tenantUserId, roles 등을 들고 있는 커스텀 Principal이라고 보면 된다.
public interface PermissionService {
Set<String> getPermissionsOfTenantUser(Long tenantUserId);
}
구현체에서는:
tenant_user_id가 가진 role 목록 조회정도까지만 하면,
권한 체크 시에는 Set.contains() 정도의 비용으로 판단할 수 있다.
RBAC 정보는 “자주 바뀌지 않는 데이터”라서,
매 요청마다 Redis/DB를 조회하는 구조는 의도적으로 피했다.
정리한 전략은 다음과 같다.
role → permission 매핑
애플리케이션 로컬 캐시에 올려둔다.
(예: Caffeine, ConcurrentHashMap + 리로드)
권한 설정이 바뀌면:
tenant_user → role 목록
tenant_user_id 기준으로 짧은 TTL 로컬 캐시 사용Redis의 역할
위 구조를 정리하기 전에,
실제로는 메뉴 중심의 권한 구조를 먼저 운영했던 적도 있다.
당시 상황은 이랬다.
“어떤 API가 어떤 책임인지도 아직 명확히 정리되지 않은 상태였고,
화면(UI) 기준으로 ‘어떤 메뉴에 어떤 사람이 들어갈 수 있는지’를 먼저 정의한 다음,
그 메뉴가 내부적으로 호출하는 API를 매핑해서 권한을 풀어갔다.”
도메인/테이블 구조는 대략 이런 느낌이었다.
user_role -- 사용자별 역할
---------
user_id
role_id
role_menu -- 역할별 메뉴 접근 권한
---------
role_id
menu_id
menu_api -- 메뉴에서 호출되는 API 매핑
---------
menu_id
http_method -- GET / POST ...
path_pattern -- /api/orders, /api/users/** 등
권한 체크 로직은:
로그인한 사용자의 역할 목록 조회 (user_role)
그 역할들이 접근 가능한 메뉴 목록 조회 (role_menu)
그 메뉴들이 호출하는 API 목록 조회 (menu_api)
이 세 테이블을 조인해서 user별로 허용된 API 리스트를 계산
그 리스트에 없는 API들은
즉,
라는 구조였고,
메뉴 + 역할만 관리하면 자연스럽게 API 접근 제어가 따라오는 형태였다.
UI 관점과 권한 관점이 일치
“API 책임이 아직 정리되지 않은 상황”에서
당장 권한 관리 기능을 만들어야 할 때 현실적인 타협안이 된다.
메뉴에 안 매핑된 API는
지나고 보니, 몇 가지 아쉬운 점도 분명했다.
권한 모델이 UI 구조에 너무 종속된다
API 단위 책임이 선명하게 보이진 않는다
화이트리스트 관리에 신경 써야 한다
메뉴에 매핑되지 않은 API를 다 화이트리스트로 눌러버리면,
어느 순간 의도치 않게 열려 있는 엔드포인트가 생길 수 있다.
“내부 호출만 있어야 하는 API인데,
외부에서 직접 호출이 가능한 상태로 남아 있는” 케이스를 피하려면
조인이 복잡하고, 성능/복잡도도 신경 써야 한다
그럼에도 불구하고,
“메뉴 중심 사고가 강한 조직/프로젝트에서,
권한 체계와 UI를 한 번에 다룰 수 있었다”는 점에서
당시로서는 현실적인 선택이었던 방식이기도 했다.
이번 시리즈에서 정리한 RBAC 구조는,
이런 메뉴 기반 구조의 장단점을 한 번 겪고 나서:
그 위에 다시 정리해 본 버전에 가깝다.
입점/통합 구조 설명이 쉬웠다
도메인 모델을 tenant / user / tenant_user로 정리해놓으니,
둘 다 “하나의 user, 여러 tenant_user 관계”라는 그림 하나로 전달이 가능했다.
입점 서비스 추가/제거가 구조적으로 안전했다
tenant 레코드 하나 추가tenant 관련 데이터(tenant_user, 로그 등)만 정리다양한 입점 서비스가 들어왔다 나가는 구조에서
“서비스가 도메인 모델의 1급 시민”이라는 것이 꽤 큰 장점이었다.
권한 정책 변경을 배포 없이 처리할 수 있었다
“이 API를 이제 MANAGER도 쓸 수 있게 해달라” 같은 요구가 들어오면:
JWT에는 role만 포함하고 permission은 캐시에서 계산하기 때문에
토큰 재발급 없이도 정책 변경을 반영할 수 있었다.
메뉴 기반 권한 구조를 거쳐 온 덕분에, RBAC를 “현실적인 감각”으로 설계할 수 있었다
실제로는 메뉴 기반 user_role–role_menu–menu_api 조인 구조를 먼저 써봤고,
그 경험 덕분에
모든 곳에 tenant를 끌고 다녀야 한다
서비스/리포지토리/쿼리마다
userId만 사용하고 싶은 순간에도tenantId를 함께 받아야 했다.코드가 조금 더 길어지지만,
플랫폼 운영자(PLATFORM_ADMIN) 권한은 별도 고려가 필요하다
대부분의 권한 체크는 tenant_user 기준으로 돌아가지만,
플랫폼 운영자는 여러 테넌트 데이터를 한 번에 봐야 한다.
그래서:
메뉴 기반 권한 구조와 RBAC 구조를 혼합 사용할 때의 복잡도
“여러 서비스가 입점하는 통합회원 플랫폼”을 새로 설계한다면
아래 질문들을 한 번씩 점검해 볼 만하다.
우리 서비스에서 tenant에 해당하는 단위는 무엇인가?
(입점 서비스인지, 고객사인지, 브랜드인지)
한 사용자가 여러 tenant에 가입할 수 있는가?
서비스별/조직별로
→ 그렇다면 tenant / user / tenant_user 패턴을 그대로 적용하기 좋다.
클라이언트 입장에서:
로그아웃/강제 로그아웃을
→ 필요 수준에 따라
auth:refresh:{tenantId}:{userId}:{deviceId}와 비슷한 키 스킴을 참고할 수 있다.
최소한 필요한 역할은 어떤 것들인가?
(플랫폼 운영자 / 서비스 관리자 / 일반 사용자 정도면 충분한가?)
role → permission 관계를
메뉴 구조와 권한 구조를
이번 4편에서는
1~3편에서 정리한 개념들을
“여러 서비스가 입점하는 통합회원 서비스 지원 플랫폼”
이라는 시나리오에 실제로 적용해 보면서,
tenant / user / tenant_user)user_role–role_menu–menu_api) 조인 구조를 사용했던 경험까지함께 정리했다.
개념적으로는 선택지가 여러 가지였지만,
이 플랫폼의 요구사항과 규모에서는 적당한 균형점이라고 판단했다.
메뉴 기반 권한 구조는 “UI와 권한을 동시에 다루기 쉬운 현실적인 선택지”였고,
그 경험 위에서 보다 도메인 친화적인 RBAC 구조를 재정의할 수 있었다.
다음 편에서는 이 구조를 인프라 레벨까지 확장해서,
B2B SaaS 관점에서의 테넌트별 스케일링·분리·Rate Limit 전략을 정리해 볼 예정이다.
Spring 공식 문서, “Method Security”, Spring Security Reference.
Spring Security API Docs, “@PreAuthorize Annotation”.
Baeldung, “Introduction to Spring Method Security”.
Okta Developer Blog, “Spring Method Security with PreAuthorize”.
Kraksaprofessional, “Spring Boot – Implementing Method Level Security with @PreAuthorize”, Medium.