권한 매트릭스 — Admin/운영자/사용자 3역할 설계

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개 정의”로 끝나는 게 아니기 때문이다.

정의해야 할 것들

  1. 역할 계층: 상위 역할이 하위 역할의 권한을 상속하는가?
  2. 리소스별 CRUD 매트릭스: 각 역할이 각 테이블에서 뭘 할 수 있는가?
  3. 소유권 규칙: “운영자가 반을 만들면 그 반은 운영자 것”을 어떻게 추적하는가?
  4. Multi-tenancy 격리: 다른 고객사의 데이터를 절대 볼 수 없게 하는 규칙
  5. 예외 케이스: 운영자가 담당 반이 아닌 사용자도 CRUD할 수 있는가?

특히 5번이 까다로웠다. 운영자는 자기 반만 관리하는 게 아니라, 고객사 전체 사용자를 CRUD할 수 있어야 했다. 사용자 등록은 운영자가 하는데, 아직 반에 배정되지 않은 사용자도 있으니까.

이런 결정을 코딩하면서 내리면, 나중에 반드시 API 권한이 꼬인다.

📌 핵심: 권한 매트릭스는 “누가 뭘 할 수 있는가”의 진실의 원천(SSoT)이다. 이 문서가 없으면 코드 리뷰 때 “이거 왜 OWNER만 접근 가능하죠?”에 답할 근거가 없다.


🏗️ 4역할 계층 구조

역할은 4개. 위에서 아래로 권한이 좁아진다.

직접 정리한 RBAC 4역할 계층 구조 흐름도
직접 정리한 RBAC 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_ADMINACADEMY_OWNERTEACHERSTUDENT
AcademyCRUD (전체)RU (자신)R (자신)
TeacherR (전체)CRUD (자기 고객사)R (동료)
ClassR (전체)CRUD (전체)CRUD (본인 소유)R (소속만)
StudentR (전체)CRUD (전체)CRUD (전체)RU (본인)
AssignmentR (전체)R (자기 고객사)R (본인 반)CRUD (본인)
ContentItemCRUDRRR
CurriculumCRUD (기본값)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가 아니라 중간 테이블인가?

  1. 한 반에 여러 운영자 배정 가능 (MAIN + ASSISTANT)
  2. 이력 추적: unassignedAt이 null이면 현재 담당, 값이 있으면 이전 담당
  3. 역할 구분: 주 담당과 보조를 구분할 수 있다

실제로 운영자가 반에 접근할 때, 이 테이블을 조회해서 권한을 확인한다:

// 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에 격리 규칙을 명시했다:

  1. ACADEMY_OWNER, TEACHER, STUDENT의 모든 조회 쿼리에 academyId WHERE 절 필수
  2. PLATFORM_ADMIN의 수정(C/U/D) 쿼리는 마스터 데이터로 제한 — 특정 고객사의 운영 데이터 직접 수정 불가
  3. JWT 페이로드의 academyId를 클라이언트가 변조할 수 없으므로 (서버에서 검증) 신뢰 가능

⚠️ 주의: Prisma에서 findMany()where 없이 호출하면 전체 테이블을 반환한다. 코드 리뷰 때 “이 쿼리에 academyId 필터 있나요?”를 반드시 체크해야 한다.


📋 정리 — 핵심 요약

상황안티패턴권장 패턴
역할 정의❌ 코딩하면서 즉흥으로✅ 권한 매트릭스 문서 먼저
User 모델❌ 모든 역할 필드를 한 테이블에✅ User(인증) + 역할 테이블(인가) 분리
Guard 구현❌ 인증+인가를 한 Guard에✅ JwtAuthGuard → RolesGuard 2단 체인
역할 상속❌ 코드에서 자동 상속✅ 접근 가능 역할 명시적 나열
Multi-tenancy❌ 쿼리마다 수동 필터✅ JWT academyId + 모든 쿼리 WHERE 필수
소유권 추적❌ FK 하나로✅ 중간 테이블 (이력 + 역할 구분)

이 편에서 배운 것

  1. 권한 설계는 문서가 먼저다. 코드 없이 “누가 뭘 할 수 있는지”를 전부 테이블로 정리하면, 구현할 때 고민이 사라진다.
  2. 인증과 인가를 분리하면 디버깅이 쉬워진다. 401(인증 실패)과 403(인가 실패)이 명확히 갈린다.
  3. Guard 체인의 각 단계는 독립적이어야 한다. 앞단이 통과시켰다고 뒷단이 안전하다는 보장이 없다.
  4. Multi-tenancy에서 academyId 필터는 생명줄이다. 빠뜨리면 데이터 유출이다.

🔜 다음에 할 것

  • BigInt PK에서 Int PK로의 첫 번째 스키마 리팩토링
  • 왜 ID 타입을 바꿔야 했는지, 마이그레이션은 어떻게 했는지

다음 글: #5 BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링