메인 콘텐츠로 건너뛰기
Sonamu의 Guards 시스템을 사용하여 API 엔드포인트에 권한 기반 접근 제어를 구현하는 방법을 알아봅니다.

Guards 시스템 개요

선언적 권한

@api guards 옵션간단한 권한 설정

Guard 타입

admin, user, query타입 정의 제공

커스텀 Guards

비즈니스 로직유연한 확장

계층적 권한

역할 기반 제어RBAC 구현

Guards의 이해

Guards란?

Guards는 API 메서드가 실행되기 전에 권한을 검증하는 함수입니다. 다음과 같은 상황에서 사용합니다:
  • 로그인한 사용자만 접근 가능한 API
  • 관리자만 접근 가능한 API
  • 특정 조건을 만족하는 사용자만 접근 가능한 API
Guards의 장점:
  1. 선언적: @api({ guards: ["admin"] })처럼 간단하게 선언
  2. 재사용 가능: 여러 API에서 동일한 Guards 사용
  3. 테스트 용이: Guards를 독립적으로 테스트 가능
  4. 관심사 분리: 권한 로직과 비즈니스 로직 분리

Guards의 동작 흐름

1. 클라이언트 → API 요청
2. Sonamu → Context 생성 (user 정보 포함)
3. Guards → 권한 검증
   ↓ 성공
4. API 메서드 실행

5. 응답 반환

   ↓ 실패
3. 403 Forbidden 응답

Guard 타입

Sonamu는 세 가지 Guard 타입(user, admin, query)이 정의되어 있습니다. 이들은 타입 안전성을 위한 키이며, 실제 검증 로직은 sonamu.config.tsguardHandler에서 구현해야 합니다.

user Guard

user Guard는 로그인한 사용자만 접근할 수 있도록 합니다. 가장 기본적인 인증 Guard입니다.
class PostModel extends BaseModelClass {
  /**
   * 자신의 게시글 목록 조회
   * 
   * user Guard: 로그인 필수
   */
  @api({ httpMethod: "GET", guards: ["user"] })
  async getMyPosts(): Promise<{
    posts: Post[];
  }> {
    const context = Sonamu.getContext();
    
    // user Guard 덕분에 context.user가 반드시 존재함
    const userId = context.user!.id;
    
    const rdb = this.getPuri("r");
    const posts = await rdb
      .table("posts")
      .where("user_id", userId)
      .orderBy("created_at", "desc")
      .select("*");
    
    return { posts };
  }
  
  /**
   * 게시글 작성
   */
  @api({ httpMethod: "POST", guards: ["user"] })
  async create(params: {
    title: string;
    content: string;
  }): Promise<{ postId: number }> {
    const context = Sonamu.getContext();
    const { title, content } = params;
    
    const wdb = this.getPuri("w");
    const [post] = await wdb
      .table("posts")
      .insert({
        user_id: context.user!.id,
        title,
        content,
        created_at: new Date(),
      })
      .returning({ id: "id" });
    
    return { postId: post.id };
  }
}
언제 사용하나요?
  • 로그인이 필요한 모든 API
  • 사용자별 데이터를 다루는 API (내 프로필, 내 주문 등)
  • 콘텐츠 생성/수정/삭제

admin Guard

admin Guard는 관리자만 접근할 수 있도록 합니다. 민감한 관리 기능에 사용합니다.
class UserModel extends BaseModelClass {
  /**
   * 모든 사용자 목록 (관리자 전용)
   */
  @api({ httpMethod: "GET", guards: ["admin"] })
  async getAllUsers(params: {
    page?: number;
    pageSize?: number;
  }): Promise<{
    users: User[];
    total: number;
  }> {
    const { page = 1, pageSize = 20 } = params;
    
    const rdb = this.getPuri("r");
    
    const users = await rdb
      .table("users")
      .limit(pageSize)
      .offset((page - 1) * pageSize)
      .select("*");
    
    const [{ count }] = await rdb
      .table("users")
      .count("* as count");
    
    return {
      users,
      total: count,
    };
  }
  
  /**
   * 사용자 강제 비활성화 (관리자 전용)
   */
  @api({ httpMethod: "POST", guards: ["admin"] })
  async deactivateUser(userId: number): Promise<{ success: boolean }> {
    const wdb = this.getPuri("w");
    
    await wdb
      .table("users")
      .where("id", userId)
      .update({
        is_active: false,
        deactivated_at: new Date(),
      });
    
    return { success: true };
  }
}
언제 사용하나요?
  • 시스템 설정 변경
  • 모든 사용자 데이터 접근
  • 사용자 관리 (활성화/비활성화, 권한 변경)
  • 통계 및 분석 데이터
  • 민감한 비즈니스 로직

query Guard

query Guard는 쿼리 스트링 파라미터로 간단한 인증을 구현합니다. 주로 공개 API나 임시 링크에 사용합니다.
class FileModel extends BaseModelClass {
  /**
   * 파일 다운로드 (쿼리 토큰 인증)
   * 
   * 사용 예: GET /api/file/download?fileId=123&token=abc...
   */
  @api({ httpMethod: "GET", guards: ["query"] })
  async download(params: {
    fileId: number;
    token: string;
  }): Promise<{
    url: string;
  }> {
    const { fileId, token } = params;
    
    // 토큰 검증
    const rdb = this.getPuri("r");
    const downloadToken = await rdb
      .table("download_tokens")
      .where("file_id", fileId)
      .where("token", token)
      .where("expires_at", ">", new Date())
      .first();
    
    if (!downloadToken) {
      throw new Error("Invalid or expired download token");
    }
    
    // 파일 정보 조회
    const file = await rdb
      .table("files")
      .where("id", fileId)
      .first();
    
    if (!file) {
      throw new Error("File not found");
    }
    
    // Signed URL 생성
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getSignedUrl(file.key, 3600);
    
    return { url };
  }
}
언제 사용하나요?
  • 이메일 링크로 전송되는 임시 다운로드 링크
  • 공유 가능한 공개 API
  • Webhook 콜백 (API 키 검증)
  • 단순한 인증이 필요한 경우
query Guard의 구체적인 구현은 프로젝트마다 다를 수 있습니다. API 키, 토큰, 서명 등 다양한 방식으로 확장 가능합니다.

여러 Guards 조합

하나의 API에 여러 Guards를 적용할 수 있습니다. 모든 Guards를 통과해야 API가 실행됩니다.
class ReportModel extends BaseModelClass {
  /**
   * 민감한 리포트 생성 (admin + 추가 검증)
   */
  @api({ httpMethod: "POST", guards: ["admin", "superAdmin"] })
  async generateSensitiveReport(): Promise<{ reportId: number }> {
    // admin Guard와 superAdmin Guard를 모두 통과해야 실행됨
    // ...
  }
}

Guards 구현

Guards는 sonamu.config.tsguardHandler에서 구현합니다. Sonamu가 각 API 호출 시 자동으로 guardHandler를 실행합니다.

guardHandler 구현

// sonamu.config.ts
import type { GuardKey } from "sonamu";
import type { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";

export default {
  // ... 다른 설정

  api: {
    route: {
      prefix: "/api",
    },
    server: {
      custom: (server) => {
        // Fastify 서버 커스터마이징
      },
    },
    apiConfig: {
      contextProvider: (defaultContext, request) => ({
        ...defaultContext,
        // Context 확장
      }),

      /**
       * Guards 검증 로직
       *
       * @param guard - Guard 키 ("user", "admin", "query" 등)
       * @param request - Fastify Request 객체
       * @param api - API 메타데이터
       * @returns true면 통과, false나 예외 발생 시 차단
       */
      guardHandler: (guard: GuardKey, request: FastifyRequest, api) => {
        // Sonamu Context에서 사용자 정보 가져오기
        const context = Sonamu.getContext();

        switch (guard) {
          case "user":
            // 로그인한 사용자만 접근 가능
            if (!context.user) {
              throw new Error("Authentication required");
            }
            return true;

          case "admin":
            // 관리자만 접근 가능
            if (!context.user || context.user.role !== "admin") {
              throw new Error("Admin access required");
            }
            return true;

          case "query":
            // 쿼리 파라미터로 API 키 검증
            const apiKey = request.query.apiKey;
            if (apiKey !== process.env.API_KEY) {
              throw new Error("Invalid API key");
            }
            return true;

          default:
            // 알 수 없는 Guard는 차단
            throw new Error(`Unknown guard: ${guard}`);
        }
      },
    },
  },
};

커스텀 Guard 타입 추가

프로젝트에 특화된 Guard를 추가하려면 GuardKeys 인터페이스를 확장합니다.
// types/guards.ts
declare module "sonamu" {
  interface GuardKeys {
    verified: true;      // 이메일 인증된 사용자
    premium: true;       // 프리미엄 회원
    moderator: true;     // 중재자
  }
}
이제 guardHandler에서 새로운 Guard를 구현합니다.
// sonamu.config.ts
guardHandler: (guard, request, api) => {
  const context = Sonamu.getContext();

  switch (guard) {
    case "user":
      if (!context.user) {
        throw new Error("Authentication required");
      }
      return true;

    case "admin":
      if (!context.user || context.user.role !== "admin") {
        throw new Error("Admin access required");
      }
      return true;

    case "verified":
      // 이메일 인증된 사용자만
      if (!context.user || !context.user.email_verified) {
        throw new Error("Email verification required");
      }
      return true;

    case "premium":
      // 프리미엄 회원만
      if (!context.user || context.user.subscription_tier !== "premium") {
        throw new Error("Premium subscription required");
      }
      return true;

    case "moderator":
      // 중재자 또는 관리자
      if (!context.user ||
          (context.user.role !== "moderator" && context.user.role !== "admin")) {
        throw new Error("Moderator access required");
      }
      return true;

    default:
      throw new Error(`Unknown guard: ${guard}`);
  }
},

동적 검증이 필요한 경우

API 파라미터에 따라 동적으로 권한을 검증해야 하는 경우, guardHandler에서 api 파라미터를 활용할 수 있습니다.
guardHandler: (guard, request, api) => {
  const context = Sonamu.getContext();

  if (guard === "owner") {
    // API 메타데이터를 활용한 동적 검증
    // 예: 게시글 ID가 파라미터에 있는지 확인
    const postId = request.query.postId || request.body?.postId;

    if (!postId) {
      throw new Error("Resource ID required");
    }

    // 여기서는 간단한 검증만 수행
    // 복잡한 소유권 검증은 API 메서드 내부에서 수행하는 것이 좋습니다
    if (!context.user) {
      throw new Error("Authentication required");
    }

    return true;
  }

  // ... 다른 Guards
},
주의: 복잡한 비즈니스 로직이나 데이터베이스 조회가 필요한 권한 검증은 Guards가 아닌 API 메서드 내부에서 처리하는 것이 좋습니다. Guards는 간단한 인증/권한 확인만 담당해야 합니다.

역할 기반 접근 제어 (RBAC)

더 복잡한 권한 시스템을 구현하려면 RBAC(Role-Based Access Control)를 사용합니다.

역할과 권한 정의

// types/permissions.ts
export enum Permission {
  // 사용자 관리
  USER_READ = "user:read",
  USER_WRITE = "user:write",
  USER_DELETE = "user:delete",
  
  // 게시글 관리
  POST_READ = "post:read",
  POST_WRITE = "post:write",
  POST_DELETE = "post:delete",
  
  // 관리
  ADMIN_DASHBOARD = "admin:dashboard",
  ADMIN_SETTINGS = "admin:settings",
}

export const rolePermissions: Record<string, Permission[]> = {
  admin: [
    Permission.USER_READ,
    Permission.USER_WRITE,
    Permission.USER_DELETE,
    Permission.POST_READ,
    Permission.POST_WRITE,
    Permission.POST_DELETE,
    Permission.ADMIN_DASHBOARD,
    Permission.ADMIN_SETTINGS,
  ],
  moderator: [
    Permission.POST_READ,
    Permission.POST_WRITE,
    Permission.POST_DELETE,
  ],
  user: [
    Permission.POST_READ,
    Permission.POST_WRITE,
  ],
  guest: [
    Permission.POST_READ,
  ],
};

/**
 * 사용자가 특정 권한을 가지고 있는지 확인
 */
export function hasPermission(
  user: User | null,
  permission: Permission
): boolean {
  if (!user) return false;
  
  const permissions = rolePermissions[user.role] || [];
  return permissions.includes(permission);
}

권한 기반 Guard 구현

RBAC 시스템을 Guard에 통합하려면 guardHandler에서 권한을 검증합니다.
// sonamu.config.ts
import { Permission, hasPermission } from "./types/permissions";

// GuardKeys에 권한 기반 Guard 추가
declare module "sonamu" {
  interface GuardKeys {
    "user:write": true;
    "user:delete": true;
    "post:delete": true;
    "admin:settings": true;
  }
}

export default {
  // ... 설정
  api: {
    apiConfig: {
      guardHandler: (guard, request, api) => {
        const context = Sonamu.getContext();

        // 기본 Guards
        switch (guard) {
          case "user":
            if (!context.user) {
              throw new Error("Authentication required");
            }
            return true;

          case "admin":
            if (!context.user || context.user.role !== "admin") {
              throw new Error("Admin access required");
            }
            return true;

          // 권한 기반 Guards
          case "user:write":
            if (!hasPermission(context.user, Permission.USER_WRITE)) {
              throw new Error("USER_WRITE permission required");
            }
            return true;

          case "user:delete":
            if (!hasPermission(context.user, Permission.USER_DELETE)) {
              throw new Error("USER_DELETE permission required");
            }
            return true;

          case "post:delete":
            if (!hasPermission(context.user, Permission.POST_DELETE)) {
              throw new Error("POST_DELETE permission required");
            }
            return true;

          case "admin:settings":
            if (!hasPermission(context.user, Permission.ADMIN_SETTINGS)) {
              throw new Error("ADMIN_SETTINGS permission required");
            }
            return true;

          default:
            throw new Error(`Unknown guard: ${guard}`);
        }
      },
    },
  },
};

권한 Guard 사용

class UserModel extends BaseModelClass {
  /**
   * 사용자 정보 수정 (관리자 또는 본인)
   */
  @api({ httpMethod: "PUT", guards: ["user:write"] })
  async updateUser(params: {
    userId: number;
    username?: string;
    email?: string;
  }): Promise<{ user: User }> {
    const context = Sonamu.getContext();
    const { userId, username, email } = params;
    
    // 본인이 아니고 관리자도 아니면 거부
    if (
      context.user!.id !== userId &&
      !hasPermission(context.user, Permission.USER_WRITE)
    ) {
      throw new Error("Permission denied");
    }
    
    const wdb = this.getPuri("w");
    await wdb
      .table("users")
      .where("id", userId)
      .update({
        username,
        email,
        updated_at: new Date(),
      });
    
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
}

리소스 기반 권한 제어

특정 리소스에 대한 소유권을 확인하는 패턴입니다.
class PostModel extends BaseModelClass {
  /**
   * 게시글 수정 (작성자 또는 관리자만)
   */
  @api({ httpMethod: "PUT", guards: ["user"] })
  async update(params: {
    postId: number;
    title?: string;
    content?: string;
  }): Promise<{ post: Post }> {
    const context = Sonamu.getContext();
    const { postId, title, content } = params;
    
    // 게시글 조회
    const rdb = this.getPuri("r");
    const post = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    if (!post) {
      throw new Error("Post not found");
    }
    
    // 소유권 확인
    const isOwner = post.user_id === context.user!.id;
    const isAdmin = context.user!.role === "admin";
    
    if (!isOwner && !isAdmin) {
      throw new Error("You can only edit your own posts");
    }
    
    // 수정
    const wdb = this.getPuri("w");
    await wdb
      .table("posts")
      .where("id", postId)
      .update({
        title,
        content,
        updated_at: new Date(),
      });
    
    const updatedPost = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    return { post: updatedPost };
  }
  
  /**
   * 게시글 삭제 (작성자 또는 관리자만)
   */
  @api({ httpMethod: "DELETE", guards: ["user"] })
  async remove(postId: number): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    const rdb = this.getPuri("r");
    const post = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    if (!post) {
      throw new Error("Post not found");
    }
    
    // 소유권 확인
    if (
      post.user_id !== context.user!.id &&
      context.user!.role !== "admin"
    ) {
      throw new Error("Permission denied");
    }
    
    const wdb = this.getPuri("w");
    await wdb.table("posts").where("id", postId).delete();
    
    return { success: true };
  }
}

Guards 테스트

Guards를 독립적으로 테스트할 수 있습니다.
// guards/__tests__/guards.test.ts
import { describe, it, expect } from "vitest";
import { guards } from "../index";
import type { AppContext } from "../../types/context";

describe("Guards", () => {
  describe("user guard", () => {
    it("로그인한 사용자는 통과", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 1,
          email: "test@example.com",
          role: "user",
        } as any,
      };
      
      expect(guards.user(context as AppContext)).toBe(true);
    });
    
    it("로그인하지 않은 사용자는 차단", () => {
      const context: Partial<AppContext> = {
        user: null,
      };
      
      expect(guards.user(context as AppContext)).toBe(false);
    });
  });
  
  describe("admin guard", () => {
    it("관리자는 통과", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 1,
          email: "admin@example.com",
          role: "admin",
        } as any,
      };
      
      expect(guards.admin(context as AppContext)).toBe(true);
    });
    
    it("일반 사용자는 차단", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 2,
          email: "user@example.com",
          role: "user",
        } as any,
      };
      
      expect(guards.admin(context as AppContext)).toBe(false);
    });
  });
});

주의사항

Guards 사용 시 주의사항:
  1. Guards는 인증(Authentication)만 담당, 인가(Authorization)는 비즈니스 로직에서 처리
  2. 민감한 데이터는 Guards 통과 후에도 추가 검증 필수
  3. Guards 실패 시 403 Forbidden 반환 (401 Unauthorized 아님)
  4. 여러 Guards 사용 시 순서 고려 (일반적인 것부터 구체적인 순서로)
  5. Guards에서는 복잡한 비즈니스 로직 지양 (단순 권한 확인만)
  6. 리소스 소유권 확인은 API 메서드 내부에서 처리

다음 단계