GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.
인증(Authentication) 과 인가(Authorization) 는 혼용되기 쉽지만 명확히 다른 개념입니다.
GraphQL에서 인증은 보통 리졸버 실행 이전에 처리하고, 인가는 리졸버 내부에서 처리합니다.
가장 일반적인 GraphQL 인증 방식입니다. HTTP 헤더의 Bearer 토큰을 검증하여 사용자 정보를 추출합니다.
import jwt from 'jsonwebtoken';
interface JWTPayload {
sub: string; // 사용자 ID
role: string;
iat: number;
exp: number;
}
interface AuthenticatedUser {
id: string;
role: 'ADMIN' | 'USER' | 'EDITOR';
permissions: string[];
}
async function authenticateRequest(req: Request): Promise<AuthenticatedUser | null> {
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
// 필요 시 DB에서 추가 정보 조회
const user = await prisma.user.findUnique({
where: { id: decoded.sub },
select: { id: true, role: true, permissions: true },
});
if (!user) return null;
return {
id: user.id,
role: user.role as AuthenticatedUser['role'],
permissions: user.permissions,
};
} catch {
// 토큰 만료, 위조 등
return null;
}
}3장에서 다룬 컨텍스트 팩토리에 인증 로직을 통합합니다.
async function createContext({ req }: { req: Request }): Promise<Context> {
const currentUser = await authenticateRequest(req);
return {
currentUser,
dataSources: createDataSources(prisma),
loaders: createLoaders(prisma),
prisma,
};
}인증 실패(토큰 없음, 만료 등)는 에러를 던지지 않고 currentUser를 null로 설정합니다. 공개 쿼리(예: 게시글 목록)는 인증 없이도 접근 가능해야 하기 때문입니다. 인증이 필요한 리졸버에서 개별적으로 검증합니다.
가장 직접적인 인가 방법은 각 리졸버에서 권한을 검사하는 것입니다.
import { GraphQLError } from 'graphql';
function requireAuth(context: Context): AuthenticatedUser {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.currentUser;
}
function requireRole(context: Context, roles: string[]): AuthenticatedUser {
const user = requireAuth(context);
if (!roles.includes(user.role)) {
throw new GraphQLError('Insufficient permissions', {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
const resolvers = {
Query: {
// 공개 쿼리 — 인증 불필요
posts: async (_parent: unknown, args: PaginationArgs, context: Context) => {
return context.dataSources.posts.findPublished(args);
},
// 인증 필요
me: async (_parent: unknown, _args: unknown, context: Context) => {
const user = requireAuth(context);
return context.dataSources.users.findById(user.id);
},
// 관리자 전용
adminDashboard: async (_parent: unknown, _args: unknown, context: Context) => {
requireRole(context, ['ADMIN']);
return context.dataSources.admin.getDashboardStats();
},
},
Mutation: {
updatePost: async (
_parent: unknown,
args: { id: string; input: UpdatePostInput },
context: Context,
) => {
const user = requireAuth(context);
const post = await context.dataSources.posts.findById(args.id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
// 소유자 또는 관리자만 수정 가능
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized to update this post', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.posts.update(args.id, args.input);
},
},
};이 방식은 간단하고 명시적이지만, 권한 검사 로직이 리졸버마다 반복됩니다.
스키마 디렉티브를 사용하면 인가 규칙을 선언적으로 정의할 수 있습니다.
directive @auth(
requires: Role = USER
) on FIELD_DEFINITION | OBJECT
enum Role {
ADMIN
EDITOR
USER
}
type Query {
posts: [Post!]! # 공개
me: User! @auth # 인증 필요 (기본 USER)
adminDashboard: Dashboard! @auth(requires: ADMIN) # ADMIN만
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
publishPost(id: ID!): Post! @auth(requires: EDITOR)
}import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema, GraphQLError } from 'graphql';
function authDirectiveTransformer(schema: GraphQLSchema): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const requiredRole = authDirective['requires'] ?? 'USER';
const originalResolve = fieldConfig.resolve ?? defaultFieldResolver;
fieldConfig.resolve = async (parent, args, context, info) => {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const roleHierarchy: Record<string, number> = {
USER: 1,
EDITOR: 2,
ADMIN: 3,
};
const userLevel = roleHierarchy[context.currentUser.role] ?? 0;
const requiredLevel = roleHierarchy[requiredRole] ?? 0;
if (userLevel < requiredLevel) {
throw new GraphQLError(
`Role ${requiredRole} required`,
{ extensions: { code: 'FORBIDDEN' } },
);
}
return originalResolve(parent, args, context, info);
};
}
return fieldConfig;
},
});
}민감한 필드를 특정 사용자에게만 노출해야 하는 경우가 있습니다.
type User {
id: ID!
name: String!
email: String! @auth # 본인 또는 관리자만
phone: String @auth(requires: ADMIN) # 관리자만
posts: [Post!]! # 공개
}const resolvers = {
User: {
email: (parent: User, _args: unknown, context: Context) => {
// 본인이거나 관리자면 실제 이메일 반환
if (
context.currentUser?.id === parent.id ||
context.currentUser?.role === 'ADMIN'
) {
return parent.email;
}
// 그 외에는 마스킹
return maskEmail(parent.email);
},
},
};
function maskEmail(email: string): string {
const [local, domain] = email.split('@');
const masked = local.slice(0, 2) + '***';
return `${masked}@${domain}`;
}RBAC(Role-Based Access Control) 은 사용자의 역할에 따라 접근을 제어합니다.
const ROLE_PERMISSIONS: Record<string, string[]> = {
ADMIN: ['read', 'write', 'delete', 'manage_users', 'view_analytics'],
EDITOR: ['read', 'write', 'delete'],
USER: ['read', 'write'],
GUEST: ['read'],
};
function hasPermission(user: AuthenticatedUser, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[user.role] ?? [];
return permissions.includes(permission);
}
// 리졸버에서 사용
const resolvers = {
Mutation: {
deletePost: async (_parent: unknown, args: { id: string }, context: Context) => {
const user = requireAuth(context);
if (!hasPermission(user, 'delete')) {
throw new GraphQLError('Delete permission required', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.posts.delete(args.id);
},
},
};ABAC(Attribute-Based Access Control) 은 사용자, 리소스, 환경의 속성 조합으로 접근을 제어합니다. 더 세밀한 제어가 가능합니다.
interface AccessContext {
user: AuthenticatedUser;
resource: Record<string, unknown>;
action: string;
environment: {
time: Date;
ipAddress: string;
};
}
type PolicyRule = (ctx: AccessContext) => boolean;
const policies: Record<string, PolicyRule[]> = {
'post:update': [
// 작성자 본인이거나 관리자
(ctx) =>
ctx.resource['authorId'] === ctx.user.id || ctx.user.role === 'ADMIN',
// 업무 시간 외 관리자 수정 차단 (선택)
(ctx) => {
if (ctx.user.role !== 'ADMIN') return true;
const hour = ctx.environment.time.getHours();
return hour >= 6 && hour <= 22;
},
],
'post:delete': [
// 관리자만 삭제 가능
(ctx) => ctx.user.role === 'ADMIN',
// 발행 후 30일이 지난 글은 삭제 불가
(ctx) => {
const publishedAt = ctx.resource['publishedAt'] as Date | undefined;
if (!publishedAt) return true;
const daysSincePublish =
(Date.now() - publishedAt.getTime()) / (1000 * 60 * 60 * 24);
return daysSincePublish <= 30;
},
],
};
function checkAccess(ctx: AccessContext): boolean {
const rules = policies[ctx.action];
if (!rules) return false;
return rules.every((rule) => rule(ctx));
}RBAC은 구현이 단순하고 대부분의 애플리케이션에 충분합니다. ABAC은 "특정 시간대에만", "자신의 부서 데이터만", "30일 이내 생성된 리소스만" 같은 복잡한 정책이 필요할 때 선택합니다.
인가 로직이 복잡해지면 리졸버에서 분리하여 별도 레이어로 추상화하는 것이 좋습니다.
class AuthorizationFacade {
constructor(
private currentUser: AuthenticatedUser | null,
private prisma: PrismaClient,
) {}
requireAuthenticated(): AuthenticatedUser {
if (!this.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return this.currentUser;
}
async canUpdatePost(postId: string): Promise<boolean> {
const user = this.requireAuthenticated();
if (user.role === 'ADMIN') return true;
const post = await this.prisma.post.findUnique({
where: { id: postId },
select: { authorId: true },
});
return post?.authorId === user.id;
}
async canViewAnalytics(teamId: string): Promise<boolean> {
const user = this.requireAuthenticated();
if (user.role === 'ADMIN') return true;
const membership = await this.prisma.teamMember.findFirst({
where: { userId: user.id, teamId, role: 'MANAGER' },
});
return membership !== null;
}
}
// 컨텍스트에 통합
async function createContext({ req }: { req: Request }): Promise<Context> {
const currentUser = await authenticateRequest(req);
return {
currentUser,
auth: new AuthorizationFacade(currentUser, prisma),
dataSources: createDataSources(prisma),
loaders: createLoaders(prisma),
prisma,
};
}
// 리졸버가 깔끔해짐
const resolvers = {
Mutation: {
updatePost: async (
_parent: unknown,
args: { id: string; input: UpdatePostInput },
context: Context,
) => {
const canUpdate = await context.auth.canUpdatePost(args.id);
if (!canUpdate) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.posts.update(args.id, args.input);
},
},
};이번 장에서는 GraphQL API의 인증과 인가 전략을 깊이 있게 다루었습니다. JWT 기반 인증과 컨텍스트 주입, 리졸버 수준과 디렉티브 기반 인가, 필드 수준 권한 제어, RBAC/ABAC 모델, 그리고 Authorization Facade 패턴을 학습했습니다.
보안은 "추가 기능"이 아니라 "기본 요구사항"입니다. 프로젝트 초기부터 인증/인가 아키텍처를 설계하고, 모든 리졸버에서 일관된 권한 검사를 적용해야 합니다.
8장에서는 GraphQL API의 캐싱 전략을 다룹니다. HTTP 캐싱의 어려움, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, 리졸버 수준 Redis 캐싱, 그리고 캐시 무효화 전략을 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.
GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.
GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.