RBAC 및 JWT 검증으로 Laravel API 보호하기
이 가이드는 Logto에서 발급한 역할 기반 접근 제어 (RBAC) 및 JSON Web Token (JWT)을 사용하여 Laravel API를 안전하게 보호할 수 있도록 인가 (Authorization)를 구현하는 방법을 안내합니다.
시작하기 전에
클라이언트 애플리케이션은 Logto에서 액세스 토큰 (Access token)을 받아야 합니다. 아직 클라이언트 통합을 설정하지 않았다면, React, Vue, Angular 또는 기타 클라이언트 프레임워크를 위한 빠른 시작이나 서버 간 접근을 위한 기계 간 (M2M) 가이드를 확인하세요.
이 가이드는 Laravel 애플리케이션에서 이러한 토큰의 서버 측 검증에 중점을 둡니다.

학습 내용
- JWT 검증: 액세스 토큰 (Access token)을 검증하고 인증 (Authentication) 정보를 추출하는 방법 학습
- 미들웨어 구현: API 보호를 위한 재사용 가능한 미들웨어 생성
- 권한 모델: 다양한 인가 (Authorization) 패턴 이해 및 구현:
- 애플리케이션 전체 엔드포인트를 위한 글로벌 API 리소스
- 테넌트별 기능 제어를 위한 조직 권한
- 다중 테넌트 데이터 접근을 위한 조직 수준 API 리소스
- RBAC 통합: API 엔드포인트에서 역할 기반 권한 (Role) 및 스코프 (Scope) 적용
사전 준비 사항
- 최신 안정 버전의 PHP 설치
- Laravel 및 웹 API 개발에 대한 기본 이해
- Logto 애플리케이션 구성 완료 (빠른 시작 참고)
권한 모델 개요
보호를 구현하기 전에, 애플리케이션 아키텍처에 맞는 권한 모델을 선택하세요. 이는 Logto의 세 가지 주요 인가 (Authorization) 시나리오와 일치합니다:
- 글로벌 API 리소스
- 조직 (비-API) 권한
- 조직 수준 API 리소스

- 사용 사례: 애플리케이션 전체에서 공유되는 API 리소스를 보호 (조직별이 아님)
- 토큰 유형: 글로벌 대상이 포함된 액세스 토큰 (Access token)
- 예시: 공개 API, 핵심 제품 서비스, 관리자 엔드포인트
- 적합 대상: 모든 고객이 사용하는 API가 있는 SaaS 제품, 테넌트 분리가 없는 마이크로서비스
- 자세히 알아보기: 글로벌 API 리소스 보호하기

- 사용 사례: 조직별 동작, UI 기능 또는 비즈니스 로직 제어 (API 아님)
- 토큰 유형: 조직별 대상이 포함된 조직 토큰 (Organization token)
- 예시: 기능 게이팅, 대시보드 권한, 멤버 초대 제어
- 적합 대상: 조직별 기능과 워크플로우가 있는 다중 테넌트 SaaS
- 자세히 알아보기: 조직 (비-API) 권한 보호하기

- 사용 사례: 특정 조직 컨텍스트 내에서 접근 가능한 API 리소스 보호
- 토큰 유형: API 리소스 대상 + 조직 컨텍스트가 포함된 조직 토큰 (Organization token)
- 예시: 다중 테넌트 API, 조직 범위 데이터 엔드포인트, 테넌트별 마이크로서비스
- 적합 대상: API 데이터가 조직 범위인 다중 테넌트 SaaS
- 자세히 알아보기: 조직 수준 API 리소스 보호하기
💡 진행하기 전에 모델을 선택하세요 - 이 가이드 전반에서 선택한 접근 방식을 참고하게 됩니다.
빠른 준비 단계
Logto 리소스 및 권한 구성
- 글로벌 API 리소스
- 조직(비-API) 권한
- 조직 수준 API 리소스
- API 리소스 생성: 콘솔 → API 리소스로 이동하여 API를 등록하세요 (예:
https://api.yourapp.com
) - 권한 정의:
read:products
,write:orders
와 같은 스코프를 추가하세요 – 권한과 함께 API 리소스 정의하기 참고 - 글로벌 역할 생성: 콘솔 → 역할로 이동하여 API 권한이 포함된 역할을 생성하세요 – 글로벌 역할 구성 참고
- 역할 할당: API 접근이 필요한 사용자 또는 M2M 애플리케이션에 역할을 할당하세요
- 조직 권한 정의: 조직 템플릿에서
invite:member
,manage:billing
과 같은 비-API 조직 권한을 생성하세요 - 조직 역할 설정: 조직 템플릿에 조직별 역할을 구성하고, 해당 역할에 권한을 할당하세요
- 조직 역할 할당: 각 조직 컨텍스트 내에서 사용자에게 조직 역할을 할당하세요
- API 리소스 생성: 위와 같이 API 리소스를 등록하되, 조직 컨텍스트에서 사용될 것입니다
- 권한 정의: 조직 컨텍스트에 범위가 지정된
read:data
,write:settings
와 같은 스코프를 추가하세요 - 조직 템플릿 구성: API 리소스 권한이 포함된 조직 역할을 설정하세요
- 조직 역할 할당: API 권한이 포함된 조직 역할에 사용자 또는 M2M 애플리케이션을 할당하세요
- 멀티 테넌트 설정: API가 조직 범위의 데이터 및 검증을 처리할 수 있도록 하세요
역할 기반 접근 제어 가이드에서 단계별 설정 방법을 시작하세요.
클라이언트 애플리케이션 업데이트
클라이언트에서 적절한 스코프를 요청하세요:
- 사용자 인증: 앱 업데이트 →에서 API 스코프 및 / 또는 조직 컨텍스트를 요청하세요
- 기계 간: M2M 스코프 구성 →로 서버 간 접근을 설정하세요
이 과정에는 일반적으로 클라이언트 설정을 다음 중 하나 이상을 포함하도록 업데이트하는 것이 포함됩니다:
- OAuth 플로우의
scope
파라미터 - API 리소스 접근을 위한
resource
파라미터 - 조직 컨텍스트를 위한
organization_id
테스트 중인 사용자 또는 M2M 앱에 API에 필요한 권한이 포함된 적절한 역할 또는 조직 역할이 할당되어 있는지 확인하세요.
API 프로젝트 초기화하기
새로운 Laravel 프로젝트를 초기화하려면 Laravel 인스톨러 또는 Composer를 사용할 수 있습니다:
Laravel 인스톨러 사용 (권장):
composer global require laravel/installer
laravel new your-api-name
cd your-api-name
또는 Composer를 직접 사용:
composer create-project laravel/laravel your-api-name
cd your-api-name
개발 서버를 시작하세요:
php artisan serve
이렇게 하면 기본적인 Laravel 프로젝트 구조가 생성됩니다. API 개발을 위해서는 일부 웹 전용 미들웨어와 라우트를 제거하는 것이 좋습니다:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// 필요에 따라 API 미들웨어를 구성하세요
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
컨트롤러, 미들웨어 및 기타 기능 설정 방법에 대한 자세한 내용은 Laravel 공식 문서를 참고하세요.
상수 및 유틸리티 초기화하기
코드에서 필요한 상수와 유틸리티를 정의하여 토큰 추출 및 유효성 검사를 처리하세요. 유효한 요청에는 Authorization
헤더가 반드시 Bearer <액세스 토큰 (Access token)>
형식으로 포함되어야 합니다.
<?php
class AuthConstants
{
public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
<?php
class AuthInfo
{
public function __construct(
public readonly string $sub,
public readonly ?string $clientId = null,
public readonly ?string $organizationId = null,
public readonly array $scopes = [],
public readonly array $audience = []
) {}
public function toArray(): array
{
return [
'sub' => $this->sub,
'client_id' => $this->clientId,
'organization_id' => $this->organizationId,
'scopes' => $this->scopes,
'audience' => $this->audience,
];
}
}
<?php
class AuthorizationException extends Exception
{
public function __construct(
string $message,
public readonly int $statusCode = 403
) {
parent::__construct($message);
}
}
<?php
trait AuthHelpers
{
protected function extractBearerToken(array $headers): string
{
$authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;
if (!$authorization) {
throw new AuthorizationException('Authorization 헤더가 없습니다', 401);
}
if (!str_starts_with($authorization, 'Bearer ')) {
throw new AuthorizationException('Authorization 헤더는 "Bearer "로 시작해야 합니다', 401);
}
return substr($authorization, 7); // 'Bearer ' 접두사 제거
}
}
Logto 테넌트 정보 가져오기
Logto에서 발급한 토큰을 검증하려면 다음 값들이 필요합니다:
- JSON Web Key Set (JWKS) URI: JWT 서명을 검증하는 데 사용되는 Logto의 공개 키 URL입니다.
- 발급자 (Issuer): 예상되는 발급자 값 (Logto의 OIDC URL).
먼저, Logto 테넌트의 엔드포인트를 찾아야 합니다. 다음 위치에서 확인할 수 있습니다:
- Logto 콘솔에서 설정 → 도메인에서 확인하세요.
- Logto에서 구성한 애플리케이션 설정의 설정 → 엔드포인트 & 자격 증명에서 확인하세요.
OpenID Connect 디스커버리 엔드포인트에서 가져오기
이 값들은 Logto의 OpenID Connect 디스커버리 엔드포인트에서 가져올 수 있습니다:
https://<your-logto-endpoint>/oidc/.well-known/openid-configuration
예시 응답입니다 (다른 필드는 생략):
{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}
코드에 하드코딩하기 (권장하지 않음)
Logto는 JWKS URI 또는 발급자 (Issuer)를 커스터마이즈할 수 없으므로, 이 값들을 코드에 하드코딩할 수 있습니다. 하지만, 향후 설정이 변경될 경우 유지보수 부담이 커질 수 있으므로 프로덕션 애플리케이션에서는 권장하지 않습니다.
- JWKS URI:
https://<your-logto-endpoint>/oidc/jwks
- 발급자 (Issuer):
https://<your-logto-endpoint>/oidc
토큰 및 권한 검증하기
토큰을 추출하고 OIDC 구성을 가져온 후, 다음을 검증하세요:
- 서명: JWT는 유효해야 하며 Logto(JWKS를 통해)에서 서명되어야 합니다.
- 발급자 (Issuer): Logto 테넌트의 발급자와 일치해야 합니다.
- 대상 (Audience): Logto에 등록된 API의 리소스 지표 또는 해당되는 경우 조직 컨텍스트와 일치해야 합니다.
- 만료: 토큰이 만료되지 않아야 합니다.
- 권한 (스코프, Permissions): 토큰에는 API/동작에 필요한 스코프가 포함되어야 합니다. 스코프는
scope
클레임에 공백으로 구분된 문자열입니다. - 조직 컨텍스트: 조직 수준의 API 리소스를 보호하는 경우,
organization_id
클레임을 검증하세요.
JWT 구조와 클레임에 대해 더 알아보려면 JSON Web Token 을 참고하세요.
각 권한 모델별로 확인해야 할 사항
클레임과 검증 규칙은 권한 모델에 따라 다릅니다:
- 글로벌 API 리소스
- 조직(비-API) 권한
- 조직 수준 API 리소스
- Audience 클레임 (
aud
): API 리소스 지표 - Organization 클레임 (
organization_id
): 없음 - 확인할 스코프(권한) (
scope
): API 리소스 권한
- Audience 클레임 (
aud
):urn:logto:organization:<id>
(조직 컨텍스트가aud
클레임에 있음) - Organization 클레임 (
organization_id
): 없음 - 확인할 스코프(권한) (
scope
): 조직 권한
- Audience 클레임 (
aud
): API 리소스 지표 - Organization 클레임 (
organization_id
): 조직 ID(요청과 일치해야 함) - 확인할 스코프(권한) (
scope
): API 리소스 권한
비-API 조직 권한의 경우, 조직 컨텍스트는 aud
클레임(예: urn:logto:organization:abc123
)으로
표현됩니다. organization_id
클레임은 조직 수준 API 리소스 토큰에만 존재합니다.
안전한 멀티 테넌트 API를 위해 항상 권한(스코프)과 컨텍스트(대상, 조직)를 모두 검증하세요.
검증 로직 추가하기
우리는 firebase/php-jwt 를 사용하여 JWT 를 검증합니다. Composer 를 사용하여 설치하세요:
composer require firebase/php-jwt
먼저, JWT 검증을 처리하기 위한 다음과 같은 공용 유틸리티를 추가하세요:
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
class JwtValidator
{
use AuthHelpers;
private static ?array $jwks = null;
public static function fetchJwks(): array
{
if (self::$jwks === null) {
$jwksData = file_get_contents(AuthConstants::JWKS_URI);
if ($jwksData === false) {
throw new AuthorizationException('Failed to fetch JWKS', 401);
}
self::$jwks = json_decode($jwksData, true);
}
return self::$jwks;
}
public static function validateJwt(string $token): array
{
try {
$jwks = self::fetchJwks();
$keys = JWK::parseKeySet($jwks);
$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;
// 발급자 (Issuer) 검증
if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
throw new AuthorizationException('Invalid issuer', 401);
}
self::verifyPayload($payload);
return $payload;
} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
}
}
public static function createAuthInfo(array $payload): AuthInfo
{
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
$audience = $payload['aud'] ?? [];
if (is_string($audience)) {
$audience = [$audience];
}
return new AuthInfo(
sub: $payload['sub'],
clientId: $payload['client_id'] ?? null,
organizationId: $payload['organization_id'] ?? null,
scopes: $scopes,
audience: $audience
);
}
private static function verifyPayload(array $payload): void
{
// 권한 (Permission) 모델에 따라 검증 로직을 여기에 구현하세요
// 아래의 권한 (Permission) 모델 섹션에서 예시를 확인할 수 있습니다
}
}
그 다음, 액세스 토큰 (Access token) 을 검증하는 미들웨어를 구현하세요:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class VerifyAccessToken
{
use AuthHelpers;
public function handle(Request $request, Closure $next): Response
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
// 인증 (Authentication) 정보를 요청 속성에 저장하여 범용적으로 사용
$request->attributes->set('auth', JwtValidator::createAuthInfo($payload));
return $next($request);
} catch (AuthorizationException $e) {
return response()->json(['error' => $e->getMessage()], $e->statusCode);
}
}
}
미들웨어를 app/Http/Kernel.php
에 등록하세요:
protected $middlewareAliases = [
// ... 다른 미들웨어
'auth.token' => \App\Http\Middleware\VerifyAccessToken::class,
];
권한 (Permission) 모델에 따라, JwtValidator
에서 적절한 검증 로직을 구현하세요:
- 글로벌 API 리소스
- 조직 (Organization) 권한 (Permission)
- 조직 단위 API 리소스
private static function verifyPayload(array $payload): void
{
// 대상 (Audience) 클레임이 API 리소스 지표와 일치하는지 확인
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience');
}
// 글로벌 API 리소스에 필요한 스코프 (Scope) 확인
$requiredScopes = ['api:read', 'api:write']; // 실제 필요한 스코프로 교체하세요
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// 대상 (Audience) 클레임이 조직 (Organization) 형식과 일치하는지 확인
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
$hasOrgAudience = false;
foreach ($audiences as $aud) {
if (str_starts_with($aud, 'urn:logto:organization:')) {
$hasOrgAudience = true;
break;
}
}
if (!$hasOrgAudience) {
throw new AuthorizationException('Invalid audience for organization permissions');
}
// 조직 ID 가 컨텍스트와 일치하는지 확인 (요청 컨텍스트에서 추출해야 할 수 있음)
$expectedOrgId = 'your-organization-id'; // 요청 컨텍스트에서 추출
$expectedAud = "urn:logto:organization:{$expectedOrgId}";
if (!in_array($expectedAud, $audiences)) {
throw new AuthorizationException('Organization ID mismatch');
}
// 필요한 조직 스코프 (Scope) 확인
$requiredScopes = ['invite:users', 'manage:settings']; // 실제 필요한 스코프로 교체하세요
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// 대상 (Audience) 클레임이 API 리소스 지표와 일치하는지 확인
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience for organization-level API resources');
}
// 조직 ID 가 컨텍스트와 일치하는지 확인 (요청 컨텍스트에서 추출해야 할 수 있음)
$expectedOrgId = 'your-organization-id'; // 요청 컨텍스트에서 추출
$orgId = $payload['organization_id'] ?? null;
if ($expectedOrgId !== $orgId) {
throw new AuthorizationException('Organization ID mismatch');
}
// 조직 단위 API 리소스에 필요한 스코프 (Scope) 확인
$requiredScopes = ['api:read', 'api:write']; // 실제 필요한 스코프로 교체하세요
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization-level API scopes');
}
}
}
미들웨어를 API에 적용하기
이제, 보호된 API 라우트에 미들웨어를 적용하세요.
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth.token')->group(function () {
Route::get('/api/protected', function (Request $request) {
// 요청 속성에서 인증 (Authentication) 정보에 접근
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
});
});
또는 컨트롤러를 사용하는 방법:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ProtectedController extends Controller
{
public function __construct()
{
$this->middleware('auth.token');
}
public function index(Request $request)
{
// 요청 속성에서 인증 (Authentication) 정보에 접근
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
}
public function show(Request $request)
{
// 보호된 엔드포인트 로직
$auth = $request->attributes->get('auth');
return [
'auth' => $auth->toArray(),
'message' => '보호된 데이터에 성공적으로 접근했습니다'
];
}
}
보호된 API 테스트하기
액세스 토큰 (Access token) 받기
클라이언트 애플리케이션에서: 클라이언트 통합을 설정했다면, 앱이 토큰을 자동으로 획득할 수 있습니다. 액세스 토큰 (Access token)을 추출하여 API 요청에 사용하세요.
curl / Postman으로 테스트할 때:
-
사용자 토큰: 클라이언트 앱의 개발자 도구에서 localStorage 또는 네트워크 탭에서 액세스 토큰 (Access token)을 복사하세요.
-
기계 간 (Machine-to-machine) 토큰: 클라이언트 자격 증명 플로우를 사용하세요. 다음은 curl을 사용한 비공식 예시입니다:
curl -X POST https://your-tenant.logto.app/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=your-m2m-client-id" \
-d "client_secret=your-m2m-client-secret" \
-d "resource=https://your-api-resource-indicator" \
-d "scope=api:read api:write"API 리소스 (API resource)와 권한 (Permission)에 따라
resource
및scope
파라미터를 조정해야 할 수 있습니다. API가 조직 범위라면organization_id
파라미터도 필요할 수 있습니다.
토큰 내용을 확인해야 하나요? 우리의 JWT 디코더를 사용하여 JWT를 디코드하고 검증하세요.
보호된 엔드포인트 테스트하기
유효한 토큰 요청
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected
예상 응답:
{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
토큰 없음
curl http://localhost:3000/api/protected
예상 응답 (401):
{
"error": "Authorization header is missing"
}
잘못된 토큰
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected
예상 응답 (401):
{
"error": "Invalid token"
}
권한 (Permission) 모델별 테스트
- 글로벌 API 리소스
- 조직 (비-API) 권한
- 조직 수준 API 리소스
글로벌 스코프로 보호된 API 테스트 시나리오:
- 유효한 스코프: 필요한 API 스코프(예:
api:read
,api:write
)가 포함된 토큰으로 테스트하세요. - 스코프 누락: 토큰에 필요한 스코프가 없으면 403 Forbidden을 예상하세요.
- 잘못된 대상 (Audience): 대상이 API 리소스와 일치하지 않으면 403 Forbidden을 예상하세요.
# 스코프가 누락된 토큰 - 403 예상
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected
조직별 접근 제어 테스트 시나리오:
- 유효한 조직 토큰: 올바른 조직 컨텍스트(조직 ID 및 스코프)가 포함된 토큰으로 테스트하세요.
- 스코프 누락: 사용자가 요청한 작업에 대한 권한이 없으면 403 Forbidden을 예상하세요.
- 잘못된 조직: 대상이 조직 컨텍스트(
urn:logto:organization:<organization_id>
)와 일치하지 않으면 403 Forbidden을 예상하세요.
# 잘못된 조직의 토큰 - 403 예상
curl -H "Authorization: Bearer token-for-different-organization" \
http://localhost:3000/api/protected
API 리소스 검증과 조직 컨텍스트를 결합한 테스트 시나리오:
- 유효한 조직 + API 스코프: 조직 컨텍스트와 필요한 API 스코프가 모두 포함된 토큰으로 테스트하세요.
- API 스코프 누락: 조직 토큰에 필요한 API 권한이 없으면 403 Forbidden을 예상하세요.
- 잘못된 조직: 다른 조직의 토큰으로 API에 접근하면 403 Forbidden을 예상하세요.
- 잘못된 대상 (Audience): 대상이 조직 수준 API 리소스와 일치하지 않으면 403 Forbidden을 예상하세요.
# API 스코프가 없는 조직 토큰 - 403 예상
curl -H "Authorization: Bearer organization-token-without-api-scopes" \
http://localhost:3000/api/protected
추가 자료
실전 RBAC: 애플리케이션을 위한 안전한 인가 (Authorization) 구현하기
멀티 테넌트 SaaS 애플리케이션 구축: 설계부터 구현까지 완벽 가이드