권한 매트릭스 — Admin/운영자/사용자 3역할 설계
📚 교육용 풀스택 SaaS 개발기 시리즈 (10편)
B2B SaaS 플랫폼의 RBAC 인가 체계를 설계한 과정. 4역할 계층 구조, 리소스별 권한 매트릭스, Multi-tenancy 격리 전략, NestJS Guard/Decorator 구현까지 — 권한 설계 문서 1,292줄이 코드가 되기까지의 기록.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 4역할 RBAC 계층 설계: PLATFORM_ADMIN → ACADEMY_OWNER → TEACHER → STUDENT, 상위 역할이 하위 권한 상속
- 권한 매트릭스 문서 1,292줄 먼저 작성 — 코드 없이 “누가 뭘 할 수 있는지” 전부 정의
- User + 역할 테이블 분리 패턴: 인증(User)과 인가(Admin/Teacher/Student)를 1:1로 분리
- NestJS Guard 2단 체인: JwtAuthGuard(인증) → RolesGuard(인가),
@Public()+@Roles()데코레이터 조합- Multi-tenancy 격리:
academyId기반 필터링으로 테넌트 간 데이터 완전 분리- 리소스 소유권 패턴: ClassTeacher 중간 테이블로 “이 반은 이 운영자 것” 추적
📋 왜 권한 설계부터 했나
이전 편에서 27개 테이블의 Prisma 스키마를 완성했다. 그런데 스키마만으로는 “누가 이 데이터에 접근할 수 있는지”가 정의되지 않는다.
만들고 있는 건 B2B SaaS 플랫폼이다. 요구사항을 다시 보면:
- 플랫폼 관리자: 전체 시스템 관리, 마스터 데이터 CRUD
- 테넌트 소유자: 자기 고객사 안에서 운영자/사용자 관리
- 운영자: 담당 그룹의 사용자 관리, 활동 기록 조회
- 사용자: 본인 프로필, 본인 활동 기록만 접근
4가지 역할이 각각 접근할 수 있는 범위가 다르다. 이걸 코딩하면서 즉흥으로 정하면 어떻게 될까?
⚠️ 주의: 권한 로직을 코드에서 먼저 만들면, 나중에 “이 API는 누가 쓸 수 있어요?”라는 질문에 코드를 뒤져봐야 한다. 문서가 먼저여야 코드가 문서를 따른다.
그래서 코드를 한 줄도 안 치고, 권한 설계 문서부터 썼다.
🔍 1,292줄의 권한 매트릭스 문서
12월 3일. 하루 종일 문서만 썼다.
docs/domain/authorization.md — 1,292줄
docs/domain/authentication.md — 1,748줄
총 3,040줄의 인증/인가 설계 문서. 커밋 메시지는 이랬다:
docs :: 권한 인증 모듈 설계 및 권한 매트릭스 작성
왜 이렇게 길어졌냐면, 권한 설계는 “역할 4개 정의”로 끝나는 게 아니기 때문이다.
정의해야 할 것들
- 역할 계층: 상위 역할이 하위 역할의 권한을 상속하는가?
- 리소스별 CRUD 매트릭스: 각 역할이 각 테이블에서 뭘 할 수 있는가?
- 소유권 규칙: “운영자가 반을 만들면 그 반은 운영자 것”을 어떻게 추적하는가?
- Multi-tenancy 격리: 다른 고객사의 데이터를 절대 볼 수 없게 하는 규칙
- 예외 케이스: 운영자가 담당 반이 아닌 사용자도 CRUD할 수 있는가?
특히 5번이 까다로웠다. 운영자는 자기 반만 관리하는 게 아니라, 고객사 전체 사용자를 CRUD할 수 있어야 했다. 사용자 등록은 운영자가 하는데, 아직 반에 배정되지 않은 사용자도 있으니까.
이런 결정을 코딩하면서 내리면, 나중에 반드시 API 권한이 꼬인다.
📌 핵심: 권한 매트릭스는 “누가 뭘 할 수 있는가”의 진실의 원천(SSoT)이다. 이 문서가 없으면 코드 리뷰 때 “이거 왜 OWNER만 접근 가능하죠?”에 답할 근거가 없다.
🏗️ 4역할 계층 구조
역할은 4개. 위에서 아래로 권한이 좁아진다.

PLATFORM_ADMIN (전체 시스템)
└→ ACADEMY_OWNER (고객사 범위)
└→ TEACHER (반 범위)
└→ STUDENT (개인 범위)
Prisma enum으로 정의하면 이렇다:
enum UserRole {
PLATFORM_ADMIN // 플랫폼 최고 관리자
ACADEMY_OWNER // 고객사 소유자
TEACHER // 운영자
STUDENT // 엔드유저
}
핵심 설계 원칙은 3가지:
1. 상위 역할은 하위 역할의 모든 권한을 상속한다.
ACADEMY_OWNER는 TEACHER가 할 수 있는 모든 것 + 추가 권한을 갖는다. 코드에서 @Roles('TEACHER')라고 적으면 ACADEMY_OWNER도 접근할 수 있어야 하는가? — 우리는 명시적 역할 매칭을 선택했다. 상속은 문서에서만 정의하고, 코드에서는 @Roles('ACADEMY_OWNER', 'TEACHER')처럼 접근 가능한 역할을 전부 나열한다.
🔍 단서: 역할 상속을 코드 레벨에서 자동화하면 편하지만, “이 API에 누가 접근할 수 있는지”를 코드만 보고 파악하기 어려워진다. 명시적 나열이 디버깅에 유리하다.
2. Multi-tenancy는 academyId로 격리한다.
PLATFORM_ADMIN은 academyId가 null이다 — 어떤 고객사에도 속하지 않는다. 나머지 3역할은 반드시 academyId가 있고, 자기 고객사의 데이터만 볼 수 있다.
3. 인증(Authentication)과 인가(Authorization)를 분리한다.
“너 누구야?” (인증)와 “너 이거 할 수 있어?” (인가)는 다른 문제다. 이걸 코드에서도 분리해야 한다.
🔬 User + 역할 테이블 분리 패턴
#3편에서 잠깐 언급했던 패턴이다. User 테이블은 인증 전용, 역할별 프로필은 별도 테이블로 분리했다.
// User — 인증 전용 (로그인 정보만)
model User {
id String @id @default(cuid())
email String? @unique
loginId String? @unique
passwordHash String
role UserRole
academyId Int?
// 역할별 1:1 관계
admin Admin?
academyOwner AcademyOwner?
teacher Teacher?
student Student?
}
// Admin — 플랫폼 관리자 프로필
model Admin {
id String @id @default(cuid())
userId String @unique
adminLevel AdminLevel
name String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// Teacher — 운영자 프로필
model Teacher {
id String @id @default(cuid())
userId String @unique
academyId Int
name String
phone String?
user User @relation(...)
academy Academy @relation(...)
classTeachers ClassTeacher[]
}
왜 이렇게 했을까?
❌ Before: 모든 걸 User에 넣는 방식
// 이렇게 하면 안 되는 이유
model User {
id String @id
role UserRole
// Admin 전용 필드
adminLevel AdminLevel?
// Teacher 전용 필드
phone String?
// Student 전용 필드
grade Int?
parentPhone String?
nickname String?
// ... nullable 지옥
}
역할마다 필요한 필드가 다르다. Admin은 adminLevel이 필요하고, Student는 grade, parentPhone이 필요하다. 이걸 한 테이블에 넣으면 대부분의 컬럼이 null이 된다.
✅ After: 역할별 테이블 분리
User (인증) ──1:1──→ Admin (관리자 프로필)
──1:1──→ AcademyOwner (소유자 프로필)
──1:1──→ Teacher (운영자 프로필)
──1:1──→ Student (사용자 프로필)
장점:
- User 테이블은 인증에만 집중 (email, passwordHash, role)
- 역할별 필수 필드가 nullable이 아닌 실제 필수값이 된다
- Teacher의
classTeachers관계처럼 역할 특화 관계를 깔끔하게 정의할 수 있다
📌 핵심: User 테이블에 모든 역할의 필드를 넣으면 “이 필드가 null인 건 원래 없는 건가, 아직 안 채운 건가?”를 구분할 수 없다. 테이블 분리하면 이 문제가 사라진다.
🛠️ 리소스별 권한 매트릭스
authorization.md에서 가장 핵심적인 부분. 각 역할이 각 리소스에서 CRUD 중 뭘 할 수 있는지 표로 정의했다.
| 리소스 | PLATFORM_ADMIN | ACADEMY_OWNER | TEACHER | STUDENT |
|---|---|---|---|---|
| Academy | CRUD (전체) | RU (자신) | R (자신) | — |
| Teacher | R (전체) | CRUD (자기 고객사) | R (동료) | — |
| Class | R (전체) | CRUD (전체) | CRUD (본인 소유) | R (소속만) |
| Student | R (전체) | CRUD (전체) | CRUD (전체) | RU (본인) |
| Assignment | R (전체) | R (자기 고객사) | R (본인 반) | CRUD (본인) |
| ContentItem | CRUD | R | R | R |
| Curriculum | CRUD (기본값) | CRUD (자기 고객사) | R (본인 반) | R (본인) |
이 표에서 재미있는 부분이 Teacher의 Student CRUD다.
Teacher는 자기 반(Class)에 한정된 권한을 갖는 게 원칙이다. 그런데 Student에 대해서는 고객사 전체 CRUD가 가능하다.
왜? 사용자 등록은 운영자가 한다. 사용자를 먼저 등록하고, 나중에 반에 배정한다. 아직 반에 배정되지 않은 사용자도 운영자가 관리할 수 있어야 한다. 그래서 Teacher의 Student 접근은 반 범위가 아니라 고객사 범위다.
⚠️ 주의: “Teacher는 Class 범위”라는 원칙에 Student CRUD가 예외라는 걸 문서에 명시하지 않으면, 나중에 API 구현할 때 반드시 혼동이 생긴다. 이런 예외야말로 문서가 필요한 이유다.
리소스 소유권: ClassTeacher 패턴
“이 반은 이 운영자 것”을 추적하는 방법. Class와 Teacher의 관계를 중간 테이블로 연결했다.
enum ClassTeacherRole {
MAIN // 주 담당
ASSISTANT // 보조 (추후 확장)
}
model ClassTeacher {
id Int @id @default(autoincrement())
classId Int
teacherId String
role ClassTeacherRole @default(MAIN)
assignedAt DateTime @default(now())
unassignedAt DateTime? // null이면 현재 담당
class Class @relation(...)
teacher Teacher @relation(...)
@@unique([classId, teacherId])
}
왜 단순한 Class.teacherId FK가 아니라 중간 테이블인가?
- 한 반에 여러 운영자 배정 가능 (MAIN + ASSISTANT)
- 이력 추적:
unassignedAt이 null이면 현재 담당, 값이 있으면 이전 담당 - 역할 구분: 주 담당과 보조를 구분할 수 있다
실제로 운영자가 반에 접근할 때, 이 테이블을 조회해서 권한을 확인한다:
// academy-class.controller.ts — 운영자 권한 체크
private async validateClassAccess(
classId: number,
payload: JwtPayload,
): Promise<void> {
// OWNER는 모든 반 접근 가능
if (payload.role === 'ACADEMY_OWNER') return;
// TEACHER는 담당 반만 접근 가능
const teacher = await this.prisma.teacher.findFirst({
where: { userId: payload.sub },
});
if (!teacher) {
throw new ForbiddenException('Teacher not found');
}
const assignment = await this.prisma.classTeacher.findFirst({
where: { classId, teacherId: teacher.id, unassignedAt: null },
});
if (!assignment) {
throw new ForbiddenException('이 반에 대한 접근 권한이 없습니다');
}
}
unassignedAt: null 조건이 핵심이다. 과거에 담당했지만 현재는 아닌 운영자는 접근할 수 없다.
✅ NestJS Guard 구현 — 인증과 인가의 2단 체인
설계 문서를 12월에 썼고, 실제 코드 구현은 1월 15일이었다. 약 6주 뒤.
커밋 메시지:
feat: Task 15 JWT Guard 구현 완료
310줄이 26개 컨트롤러에 걸쳐 추가됐다. 핵심은 Guard 2단 체인 패턴이다.
Guard 체인 아키텍처
HTTP 요청
│
▼
┌─────────────────┐
│ JwtAuthGuard │ ← 1단: "너 누구야?"
│ (인증) │ 토큰 검증 → request.user에 페이로드 저장
└────────┬────────┘
│ 통과
▼
┌─────────────────┐
│ RolesGuard │ ← 2단: "너 이거 할 수 있어?"
│ (인가) │ @Roles() 메타데이터 vs user.role 비교
└────────┬────────┘
│ 통과
▼
Controller
JwtAuthGuard — 1단: 인증
// common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly jwtSecret: string;
constructor(private reflector: Reflector) {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET environment variable is not set');
}
this.jwtSecret = secret;
}
canActivate(context: ExecutionContext): boolean {
// @Public() 데코레이터가 있으면 인증 건너뜀
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException(
'Missing or invalid authorization header',
);
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, this.jwtSecret) as JwtPayload;
// request.user에 페이로드 저장
request.user = {
id: payload.sub || payload.id,
role: payload.role,
academyId: payload.academyId,
name: payload.name,
studentId: payload.studentId,
teacherId: payload.teacherId,
academyOwnerId: payload.academyOwnerId,
adminId: payload.adminId,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid or expired token');
}
}
}
JwtPayload에 role, academyId, 그리고 역할별 ID(studentId, teacherId 등)가 모두 들어있다. 토큰 하나로 이 사용자가 누구이고, 어떤 역할이고, 어느 고객사 소속인지 전부 알 수 있다.
📌 핵심: JWT 페이로드에
academyId를 넣은 건 Multi-tenancy의 핵심이다. 모든 API에서 “이 요청은 어느 고객사에서 온 건지”를 토큰만으로 판단할 수 있다.
RolesGuard — 2단: 인가
// common/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// @Public() 데코레이터가 있으면 역할 검사 건너뜀
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) return true;
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
// @Roles() 없으면 인증만 확인 (역할 무관)
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException('User not found');
}
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException(
`Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}
주의할 점: RolesGuard에서도 @Public() 체크를 한다. 이건 나중에 버그 픽스로 추가한 건데 — 처음에는 빠져 있었다. JwtAuthGuard가 @Public()이면 통과시키니까 request.user가 없는 상태로 RolesGuard에 들어온다. 거기서 user.role을 읽으려다 터진 거다.
// 커밋 메시지
fix: RolesGuard에 @Public() 체크 추가
🔍 단서: Guard 체인에서 앞단이 통과시켰다고 뒷단이 안전한 게 아니다. 각 Guard는 독립적으로 자기 전제조건을 확인해야 한다.
데코레이터 3종 세트
Guard와 함께 쓰는 데코레이터 3개:
// @Public() — 인증 없이 접근 가능
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// @Roles() — 필요한 역할 지정
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// @CurrentUser() — 컨트롤러에서 현재 사용자 주입
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
실제 컨트롤러에서 이렇게 쓴다:
// 관리자 전용 API
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
@Controller('admin/academies')
export class AdminAcademyController {
// 로그인은 인증 없이 접근 가능
@Post('login')
@Public()
async login(@Body() dto: LoginDto) { ... }
}
// 소유자 + 운영자가 접근하는 API
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ACADEMY_OWNER', 'TEACHER')
@Controller('academy/classes')
export class AcademyClassController {
@Get()
async getClasses(@CurrentUser() user: JwtPayload) {
// user.role로 분기 — OWNER는 전체, TEACHER는 담당 반만
}
}
📌 핵심:
@Roles()를 클래스 레벨에 붙이면 해당 컨트롤러의 모든 엔드포인트에 적용된다. 특정 엔드포인트만 예외를 두려면 메서드 레벨에@Public()이나 다른@Roles()를 붙이면 된다. NestJS Reflector의getAllAndOverride가 메서드 → 클래스 순으로 찾아서 오버라이드해준다.
🛡️ Multi-tenancy 격리 전략
4역할 중 PLATFORM_ADMIN만 academyId가 null이다. 나머지 3역할은 항상 특정 고객사에 소속되어 있다.
이걸 스키마에서 보면:
model User {
...
role UserRole
academyId Int? // Admin: null, 나머지: 소속 고객사 ID
...
}
모든 데이터 조회 API에서 이 패턴이 반복된다:
// PLATFORM_ADMIN: academyId 필터 없음 (전체 조회)
if (user.role === 'PLATFORM_ADMIN') {
return this.prisma.academy.findMany();
}
// 나머지: 자기 고객사만
return this.prisma.student.findMany({
where: { academyId: user.academyId },
});
이 academyId 필터를 빠뜨리면 다른 고객사의 데이터가 노출된다. 보안 사고다.
이걸 방지하기 위해 authorization.md에 격리 규칙을 명시했다:
- ACADEMY_OWNER, TEACHER, STUDENT의 모든 조회 쿼리에
academyIdWHERE 절 필수 - PLATFORM_ADMIN의 수정(C/U/D) 쿼리는 마스터 데이터로 제한 — 특정 고객사의 운영 데이터 직접 수정 불가
- JWT 페이로드의
academyId를 클라이언트가 변조할 수 없으므로 (서버에서 검증) 신뢰 가능
⚠️ 주의: Prisma에서
findMany()를where없이 호출하면 전체 테이블을 반환한다. 코드 리뷰 때 “이 쿼리에 academyId 필터 있나요?”를 반드시 체크해야 한다.
📋 정리 — 핵심 요약
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 역할 정의 | ❌ 코딩하면서 즉흥으로 | ✅ 권한 매트릭스 문서 먼저 |
| User 모델 | ❌ 모든 역할 필드를 한 테이블에 | ✅ User(인증) + 역할 테이블(인가) 분리 |
| Guard 구현 | ❌ 인증+인가를 한 Guard에 | ✅ JwtAuthGuard → RolesGuard 2단 체인 |
| 역할 상속 | ❌ 코드에서 자동 상속 | ✅ 접근 가능 역할 명시적 나열 |
| Multi-tenancy | ❌ 쿼리마다 수동 필터 | ✅ JWT academyId + 모든 쿼리 WHERE 필수 |
| 소유권 추적 | ❌ FK 하나로 | ✅ 중간 테이블 (이력 + 역할 구분) |
이 편에서 배운 것
- 권한 설계는 문서가 먼저다. 코드 없이 “누가 뭘 할 수 있는지”를 전부 테이블로 정리하면, 구현할 때 고민이 사라진다.
- 인증과 인가를 분리하면 디버깅이 쉬워진다. 401(인증 실패)과 403(인가 실패)이 명확히 갈린다.
- Guard 체인의 각 단계는 독립적이어야 한다. 앞단이 통과시켰다고 뒷단이 안전하다는 보장이 없다.
- Multi-tenancy에서
academyId필터는 생명줄이다. 빠뜨리면 데이터 유출이다.
🔜 다음에 할 것
- BigInt PK에서 Int PK로의 첫 번째 스키마 리팩토링
- 왜 ID 타입을 바꿔야 했는지, 마이그레이션은 어떻게 했는지
📚 교육용 풀스택 SaaS 개발기 시리즈 (10편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지