본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 7장: 인증과 인가
2026년 2월 18일·웹 개발·

7장: 인증과 인가

GraphQL API에서의 인증과 인가 전략을 다룹니다. JWT 인증 미들웨어, 컨텍스트 기반 사용자 주입, 리졸버 수준 인가, 디렉티브 기반 선언적 인가, RBAC/ABAC 모델을 학습합니다.

13분899자9개 섹션
graphqlapi-designfrontend
공유
graphql-architecture7 / 11
1234567891011
이전6장: 실시간 구독(Subscriptions)다음8장: 캐싱 전략

학습 목표

  • 인증과 인가의 차이를 명확히 구분한다
  • JWT 기반 인증과 컨텍스트 주입 패턴을 구현할 수 있다
  • 리졸버 수준, 디렉티브 기반, Facade 패턴의 인가 전략을 비교한다
  • RBAC과 ABAC 모델의 적용 방법을 이해한다

인증과 인가의 구분

인증(Authentication) 과 인가(Authorization) 는 혼용되기 쉽지만 명확히 다른 개념입니다.

  • 인증: "당신은 누구인가?" — 사용자의 신원을 확인하는 과정
  • 인가: "당신은 이것을 할 수 있는가?" — 확인된 사용자의 권한을 검증하는 과정

GraphQL에서 인증은 보통 리졸버 실행 이전에 처리하고, 인가는 리졸버 내부에서 처리합니다.


인증 미들웨어

JWT 기반 인증

가장 일반적인 GraphQL 인증 방식입니다. HTTP 헤더의 Bearer 토큰을 검증하여 사용자 정보를 추출합니다.

auth-middleware.ts
typescript
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장에서 다룬 컨텍스트 팩토리에 인증 로직을 통합합니다.

auth-context.ts
typescript
async function createContext({ req }: { req: Request }): Promise<Context> {
  const currentUser = await authenticateRequest(req);
 
  return {
    currentUser,
    dataSources: createDataSources(prisma),
    loaders: createLoaders(prisma),
    prisma,
  };
}
Info

인증 실패(토큰 없음, 만료 등)는 에러를 던지지 않고 currentUser를 null로 설정합니다. 공개 쿼리(예: 게시글 목록)는 인증 없이도 접근 가능해야 하기 때문입니다. 인증이 필요한 리졸버에서 개별적으로 검증합니다.


리졸버 수준 인가

가장 직접적인 인가 방법은 각 리졸버에서 권한을 검사하는 것입니다.

resolver-auth.ts
typescript
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);
    },
  },
};

이 방식은 간단하고 명시적이지만, 권한 검사 로직이 리졸버마다 반복됩니다.


디렉티브 기반 인가

스키마 디렉티브를 사용하면 인가 규칙을 선언적으로 정의할 수 있습니다.

커스텀 @auth 디렉티브 정의

auth-directive.graphql
graphql
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)
}

디렉티브 구현

auth-directive-impl.ts
typescript
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;
    },
  });
}

필드 수준 권한

민감한 필드를 특정 사용자에게만 노출해야 하는 경우가 있습니다.

field-auth.graphql
graphql
type User {
  id: ID!
  name: String!
  email: String! @auth                    # 본인 또는 관리자만
  phone: String @auth(requires: ADMIN)    # 관리자만
  posts: [Post!]!                         # 공개
}
field-auth-resolver.ts
typescript
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과 ABAC

역할 기반 접근 제어(RBAC)

RBAC(Role-Based Access Control) 은 사용자의 역할에 따라 접근을 제어합니다.

rbac.ts
typescript
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)

ABAC(Attribute-Based Access Control) 은 사용자, 리소스, 환경의 속성 조합으로 접근을 제어합니다. 더 세밀한 제어가 가능합니다.

abac.ts
typescript
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));
}
Tip

RBAC은 구현이 단순하고 대부분의 애플리케이션에 충분합니다. ABAC은 "특정 시간대에만", "자신의 부서 데이터만", "30일 이내 생성된 리소스만" 같은 복잡한 정책이 필요할 때 선택합니다.


Authorization Facade 패턴

인가 로직이 복잡해지면 리졸버에서 분리하여 별도 레이어로 추상화하는 것이 좋습니다.

auth-facade.ts
typescript
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-design#frontend

관련 글

웹 개발

8장: 캐싱 전략

GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.

2026년 2월 20일·13분
웹 개발

6장: 실시간 구독(Subscriptions)

GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.

2026년 2월 16일·13분
웹 개발

9장: 코드 생성과 타입 안전성

GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.

2026년 2월 22일·13분
이전 글6장: 실시간 구독(Subscriptions)
다음 글8장: 캐싱 전략

댓글

목차

약 13분 남음
  • 학습 목표
  • 인증과 인가의 구분
  • 인증 미들웨어
    • JWT 기반 인증
    • 컨텍스트에 사용자 주입
  • 리졸버 수준 인가
  • 디렉티브 기반 인가
    • 커스텀 @auth 디렉티브 정의
    • 디렉티브 구현
  • 필드 수준 권한
  • RBAC과 ABAC
    • 역할 기반 접근 제어(RBAC)
    • 속성 기반 접근 제어(ABAC)
  • Authorization Facade 패턴
  • 정리
    • 다음 장 미리보기