🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

GraphQL API 설계 패턴 - 슬라이드 1/13
A

AI Generated

2025. 10. 29. · 12 Views

GraphQL API 설계 패턴 완벽 가이드

REST API의 한계를 극복하는 GraphQL API 설계 패턴을 배워봅니다. 스키마 설계부터 쿼리 최적화, 에러 핸들링까지 실무에서 바로 적용할 수 있는 패턴들을 소개합니다. 초급 개발자도 쉽게 따라할 수 있도록 구체적인 예제와 함께 설명합니다.


목차

  1. 스키마 우선 설계 - API의 청사진을 먼저 그리기
  2. 리졸버 패턴 - 데이터 소스와 스키마 연결하기
  3. DataLoader 패턴 - N+1 쿼리 문제 해결하기
  4. 인풋 타입 패턴 - 변경 작업 구조화하기
  5. 에러 핸들링 패턴 - 사용자 친화적 오류 처리
  6. 페이지네이션 패턴 - 대용량 데이터 효율적으로 다루기
  7. 필드 레벨 권한 패턴 - 세밀한 접근 제어
  8. Fragment Colocation 패턴 - 컴포넌트별 데이터 요구사항 관리

1. 스키마 우선 설계 - API의 청사진을 먼저 그리기

시작하며

여러분이 API를 개발할 때 백엔드와 프론트엔드 팀이 서로 다른 데이터 구조를 기대하면서 커뮤니케이션 오류가 발생한 적 있나요? "이 필드는 문자열인가요, 숫자인가요?" 같은 질문이 슬랙에 끊임없이 올라오고, 결국 배포 직전에 인터페이스가 맞지 않아 급하게 수정하는 상황 말이죠.

이런 문제는 API 설계가 코드보다 나중에 정의되기 때문에 발생합니다. 개발자들이 각자 구현하다가 나중에 맞춰보니 서로 다른 모양이 되어버리는 거죠.

이는 개발 시간 낭비는 물론이고, 버그와 기술 부채로 이어집니다. 바로 이럴 때 필요한 것이 스키마 우선 설계(Schema First Design)입니다.

코드를 작성하기 전에 GraphQL 스키마를 먼저 정의하면, 팀 전체가 동일한 계약서를 보고 개발할 수 있습니다.

개요

간단히 말해서, 스키마 우선 설계는 GraphQL API의 타입과 구조를 코드보다 먼저 정의하는 접근 방식입니다. 실제 프로젝트에서 프론트엔드 팀과 백엔드 팀이 동시에 작업해야 하는 경우가 많죠.

스키마를 먼저 정의하면 백엔드가 완성되기 전에도 프론트엔드는 Mock 데이터로 개발을 시작할 수 있습니다. 예를 들어, 사용자 프로필 기능을 개발할 때 스키마만 합의되면 양쪽 팀이 병렬로 작업할 수 있습니다.

기존 REST API 방식에서는 백엔드가 엔드포인트를 만들 때까지 프론트엔드가 기다려야 했다면, 이제는 스키마 정의만으로 개발을 시작할 수 있습니다. 스키마 우선 설계의 핵심 특징은 명확한 타입 정의, 자동 문서화, 그리고 컴파일 타임 검증입니다.

이러한 특징들이 개발 초기에 많은 버그를 예방하고, 팀 간 커뮤니케이션 비용을 대폭 줄여줍니다.

코드 예제

# schema.graphql
# User 타입 정의 - 사용자의 기본 정보를 담습니다
type User {
  id: ID!
  email: String!
  name: String!
  posts: [Post!]!
  createdAt: DateTime!
}

# Post 타입 정의 - 사용자가 작성한 게시글
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  published: Boolean!
}

# Query 정의 - 데이터 조회 작업
type Query {
  user(id: ID!): User
  posts(published: Boolean): [Post!]!
}

설명

이것이 하는 일: 스키마 우선 설계는 API의 데이터 구조와 타입을 선언적으로 정의하여 백엔드와 프론트엔드 간의 계약을 명확하게 만듭니다. 첫 번째로, User와 Post 타입을 정의합니다.

느낌표(!)는 해당 필드가 null이 될 수 없음을 의미합니다. 예를 들어 email: String!은 이메일이 반드시 존재해야 한다는 것을 타입 레벨에서 보장합니다.

이렇게 하면 런타임 전에 null 체크 누락을 방지할 수 있죠. 그 다음으로, 타입 간의 관계를 정의합니다.

User의 posts 필드는 Post 배열을 반환하고, Post의 author 필드는 User를 반환합니다. 이런 양방향 참조는 GraphQL의 강력한 기능으로, 클라이언트가 한 번의 쿼리로 연관된 데이터를 모두 가져올 수 있게 합니다.

내부적으로는 리졸버가 이 관계를 해석하고 적절한 데이터를 조합합니다. 마지막으로, Query 타입에서 실제로 호출할 수 있는 작업들을 정의합니다.

user(id: ID!): User는 "ID를 받아서 User를 반환하는 쿼리"를 의미하며, 반환 타입에 !가 없으므로 사용자를 찾지 못하면 null을 반환할 수 있습니다. 여러분이 이 스키마를 사용하면 GraphQL 도구들이 자동으로 문서를 생성하고, TypeScript 타입을 만들어주며, IDE에서 자동완성을 제공합니다.

실무에서는 프론트엔드 개발자가 이 스키마만 보고도 어떤 데이터를 어떻게 요청해야 하는지 정확히 알 수 있어, 백엔드 개발자에게 일일이 물어볼 필요가 없습니다.

실전 팁

💡 스키마 파일을 git에 먼저 커밋하고 리뷰받으세요. 코드보다 스키마 변경이 훨씬 큰 영향을 미치므로 팀 전체의 합의가 필요합니다.

💡 nullable 필드(!)를 신중하게 결정하세요. 나중에 non-null을 nullable로 바꾸는 것은 안전하지만, 그 반대는 breaking change입니다.

💡 GraphQL Code Generator를 사용하면 스키마에서 자동으로 TypeScript 타입을 생성할 수 있습니다. 수작업으로 타입을 맞출 필요가 없어집니다.

💡 스키마 변경 시 deprecated 디렉티브를 활용하세요. field: String @deprecated(reason: "Use newField instead")처럼 표시하면 클라이언트가 마이그레이션할 시간을 벌 수 있습니다.


2. 리졸버 패턴 - 데이터 소스와 스키마 연결하기

시작하며

여러분이 완벽한 GraphQL 스키마를 정의했는데, 막상 쿼리를 날리면 아무 데이터도 안 나오는 경험을 해보셨나요? 스키마는 단지 "이런 데이터가 있다"고 선언하는 것일 뿐, 실제로 어디서 어떻게 데이터를 가져올지는 알려주지 않습니다.

이런 문제는 GraphQL 초보자들이 가장 많이 겪는 혼란입니다. REST API에서는 라우터 핸들러가 자연스럽게 데이터 로직을 담당했는데, GraphQL에서는 이 역할을 누가 하는지 명확하지 않죠.

그 결과 스키마와 데이터 로직이 뒤섞여 유지보수가 어려운 코드가 됩니다. 바로 이럴 때 필요한 것이 리졸버(Resolver) 패턴입니다.

리졸버는 각 필드가 어떻게 데이터를 해결(resolve)할지 정의하여, 스키마와 실제 데이터 소스를 깔끔하게 연결합니다.

개요

간단히 말해서, 리졸버는 GraphQL 필드가 실제 값을 반환하는 방법을 정의하는 함수입니다. 실무에서 데이터는 다양한 곳에서 옵니다.

사용자 정보는 PostgreSQL에서, 게시글 통계는 Redis에서, 추천 데이터는 외부 API에서 가져와야 할 수 있죠. 리졸버 패턴을 사용하면 이런 복잡한 데이터 소싱 로직을 각 필드별로 독립적으로 관리할 수 있습니다.

예를 들어, User.posts 리졸버는 DB 쿼리를, User.fullName 리졸버는 단순 문자열 조합을 담당하는 식으로 책임을 분리할 수 있습니다. 기존 REST에서는 하나의 엔드포인트가 모든 데이터를 한꺼번에 조합했다면, GraphQL에서는 각 필드의 리졸버가 필요한 만큼만 실행됩니다.

클라이언트가 name만 요청하면 posts 리졸버는 실행되지 않아 불필요한 DB 쿼리가 발생하지 않습니다. 리졸버의 핵심 특징은 지연 실행(lazy execution), 컨텍스트 공유, 그리고 parent-child 체이닝입니다.

이러한 특징들이 효율적인 데이터 페칭과 모듈화된 코드 구조를 가능하게 합니다.

코드 예제

// resolvers.ts
import { UserRepository } from './repositories/UserRepository';
import { PostRepository } from './repositories/PostRepository';

// Query 리졸버 - 최상위 쿼리 진입점
const Query = {
  user: async (_parent: any, { id }: { id: string }, context: Context) => {
    // 데이터베이스에서 사용자 조회
    return await context.userRepo.findById(id);
  },

  posts: async (_parent: any, { published }: { published?: boolean }, context: Context) => {
    // 필터 조건에 맞는 게시글 조회
    return await context.postRepo.findAll({ published });
  },
};

// User 타입 리졸버 - User 필드별 해결 방법
const User = {
  posts: async (parent: any, _args: any, context: Context) => {
    // parent는 상위 리졸버에서 반환된 User 객체
    // 해당 사용자의 게시글만 조회
    return await context.postRepo.findByAuthorId(parent.id);
  },
};

// Post 타입 리졸버
const Post = {
  author: async (parent: any, _args: any, context: Context) => {
    // 게시글 작성자 정보 조회
    return await context.userRepo.findById(parent.authorId);
  },
};

export const resolvers = { Query, User, Post };

설명

이것이 하는 일: 리졸버는 스키마에 정의된 각 필드에 대해 실제 데이터를 반환하는 구현체를 제공합니다. 첫 번째로, Query 리졸버가 실행됩니다.

클라이언트가 query { user(id: "123") { name } }를 요청하면, Query.user 리졸버가 호출되어 해당 ID의 사용자를 데이터베이스에서 찾습니다. 여기서 context 객체는 모든 리졸버가 공유하는 환경으로, 데이터베이스 연결, 인증 정보, 데이터 로더 등을 담고 있습니다.

이렇게 하면 각 리졸버가 매번 DB 커넥션을 새로 만들 필요가 없어 효율적입니다. 그 다음으로, 클라이언트가 중첩된 데이터를 요청하면 parent-child 체이닝이 작동합니다.

예를 들어 query { user(id: "123") { name, posts { title } } }를 요청하면, 먼저 Query.user가 User 객체를 반환하고, 그 객체가 User.posts 리졸버의 parent 인자로 전달됩니다. User.posts는 이 parent.id를 사용해 해당 사용자의 게시글만 조회하죠.

이런 체이닝 구조 덕분에 각 리졸버는 자신의 책임만 집중할 수 있습니다. 마지막으로, GraphQL 엔진이 필요한 리졸버만 선택적으로 실행합니다.

클라이언트가 posts를 요청하지 않으면 User.posts 리졸버는 아예 실행되지 않습니다. 이는 REST의 over-fetching 문제를 근본적으로 해결하는 메커니즘입니다.

여러분이 이 패턴을 사용하면 각 필드의 데이터 로직을 독립적으로 테스트하고 최적화할 수 있습니다. 실무에서는 User.posts에 DataLoader를 적용해 N+1 문제를 해결하거나, 특정 필드에만 캐싱을 적용하는 등 세밀한 최적화가 가능합니다.

또한 새로운 필드를 추가할 때 기존 리졸버를 건드리지 않아도 되어 유지보수가 쉽습니다.

실전 팁

💡 리졸버는 항상 순수 함수처럼 작성하세요. 부작용이 있는 로직은 Mutation 리졸버로 분리하고, Query는 읽기 전용으로 유지해야 캐싱이 안전합니다.

💡 parent 인자를 활용해 불필요한 DB 쿼리를 줄이세요. 상위 리졸버가 이미 가져온 데이터를 parent에서 꺼내 쓸 수 있으면 중복 쿼리를 피할 수 있습니다.

💡 context에 인증 정보를 넣어두고 리졸버마다 권한 체크를 하세요. if (!context.user) throw new AuthenticationError()처럼 일관된 보안 검사를 적용할 수 있습니다.

💡 리졸버가 길어지면 별도의 서비스 레이어로 분리하세요. 리졸버는 얇게 유지하고 비즈니스 로직은 서비스 클래스가 담당하게 하면 테스트와 재사용이 쉬워집니다.


3. DataLoader 패턴 - N+1 쿼리 문제 해결하기

시작하며

여러분이 사용자 목록을 조회하는데 10명의 사용자가 있다면 데이터베이스 쿼리가 몇 번 실행될까요? 많은 초보 개발자들이 1번이라고 생각하지만, 각 사용자의 게시글까지 조회한다면 실제로는 11번(사용자 1번 + 각 사용자별 게시글 10번)이 실행됩니다.

이런 문제는 GraphQL의 리졸버가 독립적으로 실행되기 때문에 발생합니다. 각 User.posts 리졸버가 자기 차례에 개별 쿼리를 날리는 거죠.

사용자가 100명이면 101번, 1000명이면 1001번의 쿼리가 발생해 성능이 급격히 저하됩니다. 이를 N+1 문제라고 부릅니다.

바로 이럴 때 필요한 것이 DataLoader 패턴입니다. DataLoader는 같은 요청 내에서 발생하는 중복 쿼리를 자동으로 배칭하고 캐싱하여, 데이터베이스 부하를 획기적으로 줄입니다.

개요

간단히 말해서, DataLoader는 같은 타입의 데이터를 한 번에 가져오는 배칭 유틸리티입니다. 실무에서 N+1 문제는 성능 병목의 주범입니다.

특히 모바일 앱처럼 네트워크가 느린 환경에서는 수십 번의 쿼리 대기 시간이 누적되어 사용자 경험을 크게 해칩니다. DataLoader를 사용하면 100개의 개별 쿼리가 1개의 배치 쿼리로 변환됩니다.

예를 들어, 각 게시글의 작성자를 조회할 때 SELECT * FROM users WHERE id = ?를 100번 실행하는 대신, SELECT * FROM users WHERE id IN (?, ?, ..., ?)를 1번만 실행합니다. 전통적인 ORM의 lazy loading은 각 관계마다 즉시 쿼리를 실행했다면, DataLoader는 현재 이벤트 루프가 끝날 때까지 요청을 모았다가 한 방에 처리합니다.

DataLoader의 핵심 특징은 자동 배칭, 요청별 캐싱, 그리고 순서 보장입니다. 이러한 특징들이 복잡한 쿼리 최적화를 개발자가 거의 신경 쓰지 않아도 되게 만들어줍니다.

Facebook이 내부적으로 사용하던 패턴을 오픈소스로 공개한 것이죠.

코드 예제

// dataloader.ts
import DataLoader from 'dataloader';
import { UserRepository } from './repositories/UserRepository';

// DataLoader 생성 - 사용자 ID로 일괄 조회
export const createUserLoader = (userRepo: UserRepository) => {
  return new DataLoader<string, User>(async (userIds: readonly string[]) => {
    // 배치로 한 번에 조회 - WHERE id IN (...)
    const users = await userRepo.findByIds([...userIds]);

    // ID 순서에 맞게 정렬해서 반환 (DataLoader 요구사항)
    const userMap = new Map(users.map(u => [u.id, u]));
    return userIds.map(id => userMap.get(id) || new Error(`User ${id} not found`));
  });
};

// context에 DataLoader 추가
export const createContext = (userRepo: UserRepository) => ({
  userRepo,
  loaders: {
    userLoader: createUserLoader(userRepo),
  },
});

// 리졸버에서 사용
const Post = {
  author: async (parent: any, _args: any, context: Context) => {
    // load()를 호출하면 자동으로 배칭됨
    // 여러 리졸버가 동시에 호출해도 한 번만 DB 조회
    return await context.loaders.userLoader.load(parent.authorId);
  },
};

설명

이것이 하는 일: DataLoader는 단일 요청 내에서 발생하는 동일 타입의 데이터 로딩을 지능적으로 모아서 배치 처리합니다. 첫 번째로, DataLoader 인스턴스를 생성할 때 배치 함수를 정의합니다.

이 함수는 ID 배열을 받아서 해당하는 객체 배열을 반환하는데, 핵심은 순서가 정확히 일치해야 한다는 점입니다. DataLoader는 내부적으로 각 load() 호출을 추적하고 있다가, 이벤트 루프의 다음 틱에 모든 ID를 모아 배치 함수를 한 번만 호출합니다.

이렇게 하면 리졸버가 언제 어떻게 호출되든 자동으로 최적화됩니다. 그 다음으로, context 생성 시 DataLoader 인스턴스를 포함시킵니다.

DataLoader는 반드시 요청마다 새로 만들어야 합니다. 여러 요청 간에 공유하면 캐시가 꼬여서 A 사용자가 B 사용자의 데이터를 보는 보안 문제가 발생할 수 있습니다.

context는 각 GraphQL 요청마다 새로 생성되므로 여기에 넣는 것이 안전합니다. 마지막으로, 리졸버에서 직접 DB를 조회하는 대신 loader.load(id)를 호출합니다.

코드는 거의 똑같아 보이지만 내부 동작은 완전히 다릅니다. 100개의 게시글 리졸버가 각각 load()를 호출하면, DataLoader는 이들을 모아 단 한 번의 findByIds([id1, id2, ..., id100])로 처리합니다.

여러분이 이 패턴을 적용하면 쿼리 개수가 10배, 100배 줄어드는 것을 직접 확인할 수 있습니다. 실무에서는 DB 모니터링 도구로 쿼리 로그를 보면 DataLoader 적용 전후의 차이가 극명하게 나타납니다.

또한 DataLoader는 같은 요청 내에서 중복 ID를 자동으로 제거하고 캐싱하므로, user(id: "123")을 여러 곳에서 호출해도 실제 조회는 한 번만 발생합니다.

실전 팁

💡 DataLoader는 꼭 요청마다 새로 생성하세요. 전역 싱글톤으로 만들면 캐시가 여러 사용자 간에 공유되어 심각한 보안 문제가 됩니다.

💡 배치 함수는 반드시 입력 순서와 동일한 순서로 결과를 반환해야 합니다. 순서가 틀리면 데이터가 뒤바뀌어 버그가 발생합니다.

💡 존재하지 않는 ID는 null이 아닌 Error 객체를 반환하세요. 그래야 DataLoader가 캐시하지 않고 다음 번에 다시 시도합니다.

💡 관계가 복잡한 모델은 여러 DataLoader를 조합하세요. userLoader, postLoader, commentLoader를 따로 만들어 각각 최적화하면 더 효과적입니다.

💡 DataLoader 통계를 로깅하면 성능을 모니터링할 수 있습니다. cache hit ratio가 낮다면 배치 전략을 재검토해야 합니다.


4. 인풋 타입 패턴 - 변경 작업 구조화하기

시작하며

여러분이 회원가입 API를 만드는데 이메일, 비밀번호, 이름, 전화번호, 주소 등 10개가 넘는 필드를 받아야 한다면 어떻게 하시겠어요? Mutation에 인자를 10개 나열하면 코드가 지저분해지고, 순서도 헷갈리며, 나중에 필드를 추가하기도 어렵습니다.

이런 문제는 GraphQL을 처음 사용할 때 많이 겪는 구조적 혼란입니다. REST에서는 자연스럽게 request body에 JSON 객체를 넣었는데, GraphQL에서는 각 필드를 개별 인자로 받다 보니 코드가 장황해지고 유지보수가 힘들어집니다.

특히 중첩된 객체를 생성하는 경우 더욱 복잡해지죠. 바로 이럴 때 필요한 것이 인풋 타입(Input Type) 패턴입니다.

관련된 필드들을 하나의 인풋 객체로 그룹화하면, 코드가 깔끔해지고 재사용성도 높아지며 유효성 검증도 쉬워집니다.

개요

간단히 말해서, 인풋 타입은 Mutation이나 Query의 인자로 전달될 복잡한 객체 구조를 정의하는 특별한 타입입니다. 실무에서 데이터 생성/수정 작업은 보통 여러 필드를 한꺼번에 처리합니다.

예를 들어, 블로그 게시글을 작성할 때 제목, 내용, 태그 배열, 공개 여부, 카테고리 등을 함께 보냅니다. 인풋 타입을 사용하면 이런 관련 필드들을 CreatePostInput이라는 하나의 객체로 묶어 전달할 수 있습니다.

이는 코드를 읽기 쉽게 만들 뿐만 아니라, 같은 인풋 타입을 여러 Mutation에서 재사용할 수도 있습니다. 기존 방식에서는 createPost(title: String!, content: String!, tags: [String!]!, ...)처럼 인자가 끝없이 나열되었다면, 이제는 createPost(input: CreatePostInput!)처럼 하나의 인자로 깔끔하게 정리됩니다.

인풋 타입의 핵심 특징은 명확한 구조화, 재사용성, 그리고 타입 안정성입니다. 일반 타입과 달리 인풋 타입은 순환 참조가 없고 스칼라 값과 다른 인풋 타입만 포함할 수 있어, 직렬화가 보장됩니다.

코드 예제

# schema.graphql
# 게시글 생성용 인풋 타입 - 필수 필드와 선택 필드 구분
input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]!
  published: Boolean = false  # 기본값 지정 가능
  categoryId: ID
}

# 게시글 수정용 인풋 타입 - 부분 업데이트 지원
input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  published: Boolean
}

# 중첩된 인풋 타입 - 작성자 정보 포함
input CreatePostWithAuthorInput {
  post: CreatePostInput!
  authorEmail: String!
}

# Mutation 정의 - 인풋 타입 사용
type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  createPostWithAuthor(input: CreatePostWithAuthorInput!): Post!
}

설명

이것이 하는 일: 인풋 타입은 클라이언트가 서버로 보내는 데이터의 구조를 명확하게 정의하고 검증합니다. 첫 번째로, CreatePostInput은 새 게시글을 만들 때 필요한 모든 정보를 하나로 묶습니다.

느낌표로 필수 필드를 표시하고, 기본값(published: Boolean = false)을 지정할 수도 있습니다. 클라이언트는 { title: "...", content: "...", tags: ["a", "b"] }처럼 일반 JSON 객체를 보내면 되고, GraphQL이 자동으로 타입을 검증합니다.

누락된 필수 필드나 잘못된 타입이 있으면 쿼리 실행 전에 에러가 발생하죠. 그 다음으로, UpdatePostInput은 부분 업데이트를 위해 모든 필드를 선택적(nullable)으로 정의합니다.

이렇게 하면 클라이언트가 변경하고 싶은 필드만 보낼 수 있습니다. { title: "new title" }만 보내면 제목만 바뀌고 나머지는 그대로 유지됩니다.

리졸버에서는 Object.keys(input)로 어떤 필드가 전달되었는지 확인하고 해당 필드만 업데이트하면 됩니다. 마지막으로, 인풋 타입은 중첩될 수 있습니다.

CreatePostWithAuthorInput은 다른 인풋 타입(CreatePostInput)을 포함하여 복잡한 트랜잭션을 표현합니다. 이는 "게시글과 작성자를 동시에 생성"하는 것처럼 여러 엔티티를 원자적으로 처리할 때 유용합니다.

여러분이 이 패턴을 사용하면 API 인터페이스가 훨씬 명확해집니다. 실무에서는 프론트엔드 개발자가 "게시글 만들려면 뭘 보내야 해?"라고 물으면 CreatePostInput 정의만 보여주면 됩니다.

또한 유효성 검증 로직을 인풋 타입 레벨에서 일관되게 적용할 수 있어, 같은 검증을 여러 곳에 중복해서 쓸 필요가 없습니다. GraphQL 스키마가 자체적으로 문서 역할을 하므로 별도의 API 문서 작성 부담도 줄어듭니다.

실전 팁

💡 Create와 Update용 인풋 타입을 분리하세요. Update는 보통 필드를 선택적으로 만들어 부분 수정을 지원해야 하므로 별도 타입이 더 명확합니다.

💡 인풋 타입에 기본값을 적극 활용하세요. published: Boolean = false처럼 설정하면 클라이언트가 생략해도 안전한 기본 동작이 보장됩니다.

💡 인풋 타입 이름은 용도를 명확히 나타내세요. CreateUserInput, UpdateUserInput, UserFilterInput처럼 네이밍하면 의도가 분명해집니다.

💡 중첩 깊이를 2-3단계로 제한하세요. 너무 깊은 중첩은 오히려 복잡도를 높이고 프론트엔드에서 다루기 어렵습니다.

💡 민감한 필드는 인풋 타입에서 제외하세요. 예를 들어 createdAt, updatedAt 같은 시스템 필드는 서버에서 자동 생성해야 하므로 인풋에 포함하면 안 됩니다.


5. 에러 핸들링 패턴 - 사용자 친화적 오류 처리

시작하며

여러분이 로그인을 시도했는데 "Internal Server Error"만 덩그러니 표시된다면 어떤 기분이 드나요? 비밀번호가 틀렸는지, 계정이 없는지, 서버가 다운됐는지 전혀 알 수 없어 답답할 겁니다.

개발자 입장에서도 프론트엔드 팀이 "로그인이 안 된대요"라고 하면 어디서부터 디버깅해야 할지 막막하죠. 이런 문제는 에러를 단순히 throw하고 끝내는 습관 때문에 발생합니다.

예외가 발생하면 GraphQL이 generic한 에러 메시지만 반환하고, 정작 사용자나 프론트엔드 개발자가 필요한 정보는 제공하지 않습니다. 그 결과 사용자 경험이 나빠지고, 지원 요청이 늘어나며, 디버깅 시간이 길어집니다.

바로 이럴 때 필요한 것이 구조화된 에러 핸들링 패턴입니다. 에러 코드, 상세 메시지, 그리고 관련 필드 정보를 체계적으로 전달하면, 클라이언트가 적절하게 대응하고 사용자에게 유용한 피드백을 제공할 수 있습니다.

개요

간단히 말해서, 에러 핸들링 패턴은 GraphQL 응답에 구조화된 에러 정보를 포함시켜 클라이언트가 상황별로 적절히 처리할 수 있게 하는 방식입니다. 실무에서 에러는 다양한 레벨과 타입으로 나뉩니다.

사용자 입력 오류(VALIDATION_ERROR), 권한 문제(FORBIDDEN), 데이터 없음(NOT_FOUND), 서버 내부 오류(INTERNAL_ERROR) 등 각각 다른 UI 처리가 필요합니다. 예를 들어, 이메일 중복 에러는 입력 필드 아래 빨간 텍스트로 표시하면 되지만, 서버 오류는 전체 화면 다이얼로그를 띄워야 할 수 있습니다.

구조화된 에러를 사용하면 프론트엔드가 에러 코드를 보고 자동으로 적절한 UI를 선택할 수 있습니다. 기존 방식에서는 에러 메시지 문자열을 파싱해서 타입을 추측해야 했다면, 이제는 errorCode 필드를 직접 확인하면 됩니다.

"Email already exists"를 포함하는지 체크하는 대신 error.code === 'EMAIL_DUPLICATE'처럼 명확하게 판단할 수 있죠. 에러 핸들링의 핵심 특징은 명확한 분류, 상세한 컨텍스트, 그리고 국제화 지원입니다.

에러 코드는 영어 고정이고 UI에서 로케일별 메시지로 변환하므로, 다국어 지원이 쉬워집니다.

코드 예제

// errors.ts
import { GraphQLError } from 'graphql';

// 커스텀 에러 클래스 - 에러 타입별 분류
export class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: {
        code: 'VALIDATION_ERROR',  // 클라이언트가 체크할 코드
        field,  // 어떤 필드에서 발생했는지
        http: { status: 400 },
      },
    });
  }
}

export class AuthenticationError extends GraphQLError {
  constructor(message: string = 'You must be logged in') {
    super(message, {
      extensions: {
        code: 'UNAUTHENTICATED',
        http: { status: 401 },
      },
    });
  }
}

// 리졸버에서 사용
const Mutation = {
  createPost: async (_parent: any, { input }: any, context: Context) => {
    // 인증 체크
    if (!context.user) {
      throw new AuthenticationError();
    }

    // 입력 검증
    if (input.title.length < 5) {
      throw new ValidationError('Title must be at least 5 characters', 'title');
    }

    // 비즈니스 로직
    try {
      return await context.postRepo.create(input);
    } catch (error) {
      // 예상치 못한 에러는 로깅하고 일반 메시지 반환
      console.error('Failed to create post:', error);
      throw new GraphQLError('Failed to create post', {
        extensions: { code: 'INTERNAL_ERROR' },
      });
    }
  },
};

설명

이것이 하는 일: 에러 핸들링 패턴은 예외 상황을 클라이언트가 프로그래밍 방식으로 처리할 수 있는 구조화된 데이터로 변환합니다. 첫 번째로, 커스텀 에러 클래스를 정의하여 에러 타입을 분류합니다.

ValidationError, AuthenticationError 등을 만들면 리졸버에서 의도를 명확히 표현할 수 있습니다. extensions 객체에 code를 넣으면 GraphQL 응답의 errors[].extensions.code로 전달되어, 클라이언트가 if (error.extensions.code === 'VALIDATION_ERROR')처럼 체크할 수 있습니다.

이는 에러 메시지가 바뀌어도 코드 로직에 영향을 주지 않아 안정적입니다. 그 다음으로, 에러에 추가 컨텍스트를 담습니다.

field: 'title'을 포함하면 프론트엔드가 어떤 입력 필드에 에러를 표시해야 하는지 정확히 알 수 있습니다. 여러 필드에서 동시에 에러가 발생하면 각각 별도의 에러 객체로 던져서 모든 문제를 한 번에 알려줄 수도 있습니다.

사용자는 폼을 제출하고 한 번에 모든 오류를 확인할 수 있어 편리합니다. 마지막으로, 예상치 못한 에러는 로깅하고 일반적인 메시지로 바꿉니다.

데이터베이스 연결 실패 같은 내부 에러의 상세 내용을 클라이언트에 노출하면 보안 위험이 있으므로, "Failed to create post"처럼 안전한 메시지만 반환하고 실제 에러는 서버 로그에 남깁니다. 개발 환경에서는 stackTrace를 포함하고, 프로덕션에서는 제외하는 식으로 환경별 설정도 가능합니다.

여러분이 이 패턴을 적용하면 프론트엔드와의 협업이 훨씬 수월해집니다. 실무에서는 "이 에러 코드가 나오면 이렇게 처리하세요"라는 문서를 공유하면, 프론트엔드 팀이 독립적으로 에러 UI를 구현할 수 있습니다.

또한 모니터링 시스템이 에러 코드별로 통계를 내어, "오늘 VALIDATION_ERROR가 급증했네, 사용자들이 뭘 잘못 입력하고 있나?" 같은 인사이트를 얻을 수 있습니다.

실전 팁

💡 에러 코드는 SCREAMING_SNAKE_CASE로 일관되게 작성하세요. VALIDATION_ERROR, NOT_FOUND처럼 명확한 상수 형태가 코드에서 다루기 쉽습니다.

💡 민감한 정보를 에러 메시지에 포함하지 마세요. "User with email john@example.com not found" 대신 "User not found"처럼 일반화하세요.

💡 개발 환경과 프로덕션 환경의 에러 상세도를 다르게 설정하세요. 개발 시에는 stack trace가 유용하지만 프로덕션에서는 보안 위험입니다.

💡 여러 필드 검증 에러는 배열로 반환하세요. 사용자가 한 번 제출로 모든 문제를 확인할 수 있어 UX가 좋습니다.

💡 에러 코드 문서를 자동 생성하세요. TypeScript enum으로 에러 코드를 관리하고 JSDoc으로 설명을 달면, 도구가 자동으로 문서를 만들어줄 수 있습니다.


6. 페이지네이션 패턴 - 대용량 데이터 효율적으로 다루기

시작하며

여러분이 소셜 미디어 피드에서 게시글 10,000개를 한 번에 불러온다면 어떻게 될까요? 브라우저는 먹통이 되고, 서버는 과부하가 걸리며, 사용자는 몇 초간 하얀 화면만 바라봐야 합니다.

심지어 대부분의 데이터는 사용자가 스크롤도 안 해서 보지도 않을 것들이죠. 이런 문제는 데이터를 "전부 아니면 전무"로 다루기 때문에 발생합니다.

REST API에서 흔히 쓰는 limit/offset 방식을 GraphQL에 그대로 적용하면 동작은 하지만 비효율적입니다. 특히 실시간으로 데이터가 추가/삭제되는 환경에서 offset은 중복이나 누락을 일으킬 수 있습니다.

바로 이럴 때 필요한 것이 커서 기반 페이지네이션 패턴입니다. Relay 스펙을 따르는 Connection 패턴을 사용하면 안정적이고 일관된 방식으로 대용량 데이터를 나눠 전달할 수 있습니다.

개요

간단히 말해서, 커서 기반 페이지네이션은 각 아이템의 고유한 위치(커서)를 기준으로 다음/이전 데이터를 가져오는 방식입니다. 실무에서 무한 스크롤이나 "더보기" 기능은 필수입니다.

사용자가 스크롤하면 자동으로 다음 페이지를 로드하는데, 이때 커서 방식을 쓰면 새 게시글이 추가되어도 중복이나 누락 없이 안정적으로 다음 데이터를 가져올 수 있습니다. 예를 들어, 여러분이 10번 아이템까지 봤다면 서버에 "10번 다음부터 20개 주세요"라고 요청하는데, 그 사이에 새 아이템이 추가돼도 커서는 정확히 10번 다음을 가리키고 있어 문제가 없습니다.

기존 offset 방식에서는 "30번째부터 10개"를 요청했는데 그 사이에 5개가 추가되면 실제로는 35번째부터 가져와 버려 5개가 중복됩니다. 커서 방식은 절대적인 위치가 아닌 상대적 포인터를 사용하므로 이런 문제가 없습니다.

커서 페이지네이션의 핵심 특징은 안정성, 방향성(앞/뒤 탐색), 그리고 메타데이터 제공입니다. hasNextPage, hasPreviousPage 같은 정보로 클라이언트가 UI를 정확히 제어할 수 있습니다.

코드 예제

# schema.graphql
# Relay 스펙 Connection 타입 - 페이지네이션 표준
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!  # 전체 개수 (선택사항)
}

# Edge - 아이템과 커서를 함께 담음
type PostEdge {
  node: Post!  # 실제 데이터
  cursor: String!  # 이 아이템의 위치
}

# 페이지네이션 메타데이터
type PageInfo {
  hasNextPage: Boolean!  # 다음 페이지 존재 여부
  hasPreviousPage: Boolean!
  startCursor: String  # 첫 아이템의 커서
  endCursor: String  # 마지막 아이템의 커서
}

type Query {
  # first: 처음부터 N개, after: 특정 커서 다음부터
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

// 리졸버 예시
const Query = {
  posts: async (_parent: any, args: PaginationArgs, context: Context) => {
    const { first = 10, after } = args;

    // 커서 디코딩 (보통 base64로 인코딩된 ID)
    const afterId = after ? decodeCursor(after) : null;

    // 커서 다음부터 N+1개 조회 (hasNextPage 판단용)
    const items = await context.postRepo.findMany({
      afterId,
      limit: first + 1,
    });

    // 실제 반환은 N개만
    const hasNextPage = items.length > first;
    const nodes = hasNextPage ? items.slice(0, -1) : items;

    return {
      edges: nodes.map(node => ({
        node,
        cursor: encodeCursor(node.id),
      })),
      pageInfo: {
        hasNextPage,
        hasPreviousPage: afterId !== null,
        startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
        endCursor: nodes[nodes.length - 1] ? encodeCursor(nodes[nodes.length - 1].id) : null,
      },
      totalCount: await context.postRepo.count(),
    };
  },
};

설명

이것이 하는 일: 커서 페이지네이션은 대용량 데이터를 작은 청크로 나눠 전달하면서도 탐색 위치를 정확히 추적합니다. 첫 번째로, Connection 타입 구조를 정의합니다.

Relay 스펙에 따라 edges와 pageInfo를 포함하는데, 이는 GraphQL 생태계의 표준이라 많은 라이브러리가 자동으로 지원합니다. edges는 실제 데이터(node)와 커서를 함께 담고, pageInfo는 페이징 상태를 알려줍니다.

이 구조가 처음에는 복잡해 보이지만, 클라이언트 라이브러리(Apollo, Relay)가 자동으로 페이징을 처리해줘서 오히려 구현이 간단해집니다. 그 다음으로, 리졸버에서 커서를 처리합니다.

커서는 보통 아이템 ID를 base64로 인코딩한 문자열입니다. 클라이언트가 after: "abc123"을 보내면 디코딩해서 해당 ID 다음부터 조회합니다.

핵심 트릭은 실제 필요한 개수보다 1개 더 조회하는 것입니다. 10개를 요청받으면 11개를 가져와서, 11번째가 있으면 hasNextPage를 true로 설정하고 10개만 반환합니다.

이렇게 하면 별도 카운트 쿼리 없이도 다음 페이지 존재 여부를 알 수 있습니다. 마지막으로, PageInfo를 채워서 반환합니다.

hasNextPage와 hasPreviousPage는 클라이언트가 "더보기" 버튼을 표시할지 결정하는 데 사용됩니다. startCursor와 endCursor는 양방향 페이징에서 이전 페이지로 돌아갈 때 필요합니다.

totalCount는 선택사항이지만 "100개 중 10개 표시" 같은 UI에 유용합니다. 여러분이 이 패턴을 적용하면 무한 스크롤이 부드럽고 안정적으로 작동합니다.

실무에서는 Apollo Client의 fetchMore 함수가 자동으로 다음 페이지를 이어붙여주므로, 프론트엔드 코드가 매우 간결해집니다. 또한 데이터베이스 인덱스를 커서 필드(보통 id나 createdAt)에 걸어두면 페이징 쿼리가 매우 빠르게 실행됩니다.

실전 팁

💡 커서는 opaque string으로 취급하세요. 클라이언트가 커서 내용을 파싱하거나 조작하면 안 됩니다. base64로 인코딩해서 내부 구조를 숨기세요.

💡 id 대신 timestamp나 복합 키를 커서로 쓸 수도 있습니다. 시간순 정렬이 필요하면 createdAt을 커서로 사용하되, 동일 시간 처리를 위해 id를 함께 포함하세요.

💡 first/after와 last/before를 동시에 지원하면 양방향 페이징이 가능합니다. 채팅처럼 최신 메시지에서 위로 스크롤하는 UI에 유용합니다.

💡 캐싱을 고려해서 페이지 크기를 일정하게 유지하세요. 10개씩 가져오다가 갑자기 50개를 요청하면 캐시가 꼬일 수 있습니다.

💡 매우 큰 데이터셋에서는 totalCount 계산을 생략하세요. COUNT(*) 쿼리는 수백만 행에서 느릴 수 있으므로, "1000+ 이상"처럼 대략적인 수치만 보여주는 것이 나을 수 있습니다.


7. 필드 레벨 권한 패턴 - 세밀한 접근 제어

시작하며

여러분이 사용자 프로필을 조회하는데 이메일, 전화번호, 주소까지 모든 정보가 다 보인다면 개인정보 문제가 생기겠죠? 자기 프로필은 모든 정보를 볼 수 있지만, 남의 프로필은 이름과 프로필 사진만 봐야 합니다.

REST API에서는 엔드포인트를 여러 개 만들어 /users/me와 /users/:id를 구분했을 겁니다. 이런 문제는 GraphQL의 유연성 때문에 더 복잡해집니다.

클라이언트가 원하는 필드를 자유롭게 선택할 수 있는데, 그 중 일부는 권한이 필요할 수 있습니다. 모든 필드 조합에 대해 권한을 체크하려면 리졸버 곳곳에 if문을 추가해야 해서 코드가 지저분해집니다.

바로 이럴 때 필요한 것이 필드 레벨 권한 패턴입니다. 디렉티브나 미들웨어를 사용해 선언적으로 권한을 정의하면, 리졸버 로직과 권한 로직을 깔끔하게 분리할 수 있습니다.

개요

간단히 말해서, 필드 레벨 권한은 GraphQL 스키마의 각 필드에 접근 제어 규칙을 붙여 자동으로 검증하는 방식입니다. 실무에서 권한은 복잡하고 다층적입니다.

예를 들어, 게시글의 published 필드는 누구나 볼 수 있지만, draft 필드는 작성자만, analytics 필드는 관리자만 볼 수 있어야 합니다. 각 리졸버에서 이를 일일이 체크하면 실수하기 쉽고, 권한 로직이 비즈니스 로직과 뒤섞여 가독성이 떨어집니다.

필드 레벨 권한을 사용하면 스키마에 @auth(requires: ADMIN)처럼 표시하기만 하면 자동으로 검증됩니다. 기존 방식에서는 리졸버 시작 부분에 if (!user.isAdmin) throw new ForbiddenError()를 반복했다면, 이제는 스키마 디렉티브나 메타데이터로 선언하고 프레임워크가 자동으로 처리하게 합니다.

필드 레벨 권한의 핵심 특징은 선언적 표현, 자동 검증, 그리고 재사용성입니다. 동일한 권한 규칙을 여러 필드에 일관되게 적용할 수 있어, 보안 정책을 중앙에서 관리할 수 있습니다.

코드 예제

# schema.graphql
# 커스텀 디렉티브 정의 - 필드 권한 지정
directive @auth(requires: Role!) on FIELD_DEFINITION

enum Role {
  ADMIN
  USER
  GUEST
}

type User {
  id: ID!
  name: String!
  email: String! @auth(requires: USER)  # 로그인 필요
  phone: String! @auth(requires: USER)

  # 자기 자신이거나 관리자만 볼 수 있는 필드
  privateNotes: String! @auth(requires: ADMIN)
}

type Post {
  id: ID!
  title: String!
  content: String!

  # 통계는 작성자나 관리자만
  viewCount: Int! @auth(requires: USER)
  earnings: Float! @auth(requires: ADMIN)
}

// 디렉티브 구현
import { GraphQLSchema } from 'graphql';
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

export function authDirectiveTransformer(schema: GraphQLSchema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
      if (!authDirective) return fieldConfig;

      const { requires } = authDirective;
      const { resolve = defaultFieldResolver } = fieldConfig;

      // 원본 리졸버를 래핑
      fieldConfig.resolve = async (source, args, context, info) => {
        // 권한 체크
        if (!context.user) {
          throw new AuthenticationError('Not authenticated');
        }

        if (requires === 'ADMIN' && !context.user.isAdmin) {
          throw new ForbiddenError('Admin access required');
        }

        if (requires === 'USER' && !context.user.id) {
          throw new ForbiddenError('User access required');
        }

        // 권한 통과하면 원본 리졸버 실행
        return resolve(source, args, context, info);
      };

      return fieldConfig;
    },
  });
}

설명

이것이 하는 일: 필드 레벨 권한은 GraphQL 디렉티브를 사용해 각 필드의 접근 조건을 스키마 레벨에서 정의하고, 런타임에 자동으로 검증합니다. 첫 번째로, 스키마에 @auth 디렉티브를 정의합니다.

directive @auth(requires: Role!) on FIELD_DEFINITION은 "이 디렉티브는 필드에 붙을 수 있고, Role을 인자로 받는다"는 의미입니다. 그런 다음 User.email 같은 민감한 필드에 @auth(requires: USER)를 붙이면, 이 필드는 인증된 사용자만 접근할 수 있다고 선언하는 겁니다.

스키마만 봐도 어떤 필드가 보호되는지 한눈에 알 수 있죠. 그 다음으로, 디렉티브 구현체를 만듭니다.

authDirectiveTransformer는 스키마를 순회하면서 @auth 디렉티브가 붙은 필드를 찾아 원본 리졸버를 래핑합니다. 래퍼 함수는 리졸버가 실행되기 전에 context.user를 체크해서 권한이 없으면 에러를 던지고, 권한이 있으면 원본 리졸버를 호출합니다.

이는 데코레이터 패턴으로, 기존 리졸버 로직을 전혀 수정하지 않고 권한 체크를 추가하는 겁니다. 마지막으로, 더 복잡한 규칙도 지원할 수 있습니다.

예를 들어 "자기 자신의 데이터거나 관리자"라는 조건은 디렉티브에서 source(parent 객체)를 확인해서 source.id === context.user.id || context.user.isAdmin처럼 체크할 수 있습니다. 이런 로직도 한 곳에 집중되어 있어 수정이 쉽습니다.

여러분이 이 패턴을 적용하면 보안 취약점이 대폭 줄어듭니다. 실무에서는 새 필드를 추가할 때 권한 체크를 깜빡하는 실수가 자주 발생하는데, 디렉티브를 강제하면 이런 실수를 방지할 수 있습니다.

또한 권한 정책이 바뀌면 디렉티브 구현체만 수정하면 모든 필드에 일괄 적용되므로 유지보수가 훨씬 쉽습니다. 코드 리뷰 시에도 스키마만 봐도 보안 설정을 확인할 수 있어 효율적입니다.

실전 팁

💡 디렉티브는 조합 가능하게 설계하세요. @auth, @rateLimit, @deprecated를 동시에 붙일 수 있으면 유연성이 높아집니다.

💡 권한 체크 실패 시 필드를 null로 반환하는 옵션도 고려하세요. 에러를 던지면 전체 쿼리가 실패하지만, null을 반환하면 접근 가능한 다른 필드는 정상적으로 받을 수 있습니다.

💡 context에 권한 체커 함수를 넣어두고 재사용하세요. context.can('read', resource)처럼 CASL이나 Casbin 같은 권한 라이브러리와 통합하면 더 강력합니다.

💡 개발 환경에서는 권한 체크를 우회하는 옵션을 제공하세요. 테스트나 디버깅 시 매번 인증 토큰을 만들기 번거로울 수 있습니다.

💡 GraphQL 도구들이 디렉티브를 인식하도록 SDL을 공유하세요. 프론트엔드 IDE가 "이 필드는 인증 필요" 같은 힌트를 표시할 수 있습니다.


8. Fragment Colocation 패턴 - 컴포넌트별 데이터 요구사항 관리

시작하며

여러분이 대규모 React 앱을 만드는데 User 데이터를 여러 컴포넌트에서 사용한다면 어떤 필드를 쿼리해야 할까요? UserCard는 name과 avatar만, UserProfile은 email과 bio까지, AdminPanel은 모든 필드를 필요로 합니다.

최상위에서 모든 필드를 가져오면 over-fetching이고, 각 컴포넌트가 개별 쿼리를 날리면 성능이 나쁩니다. 이런 문제는 데이터 요구사항이 컴포넌트와 분리되어 있기 때문에 발생합니다.

페이지 레벨에서 큰 쿼리를 작성하면 어떤 컴포넌트가 무엇을 필요로 하는지 추적하기 어렵고, 컴포넌트를 재사용할 때마다 쿼리를 수정해야 합니다. 그 결과 유지보수가 어렵고 불필요한 데이터를 자주 가져오게 됩니다.

바로 이럴 때 필요한 것이 Fragment Colocation 패턴입니다. 각 컴포넌트가 자신이 필요한 데이터를 Fragment로 정의하면, 상위 컴포넌트는 이들을 조합하기만 하면 됩니다.

개요

간단히 말해서, Fragment Colocation은 컴포넌트가 필요한 데이터 구조를 GraphQL Fragment로 컴포넌트 파일 안에 함께 정의하는 패턴입니다. 실무에서 컴포넌트는 재사용되고 조합됩니다.

UserCard를 HomePage와 SearchPage에서 모두 쓴다면, 각 페이지가 UserCard에 필요한 필드를 알고 있어야 할까요? Fragment를 사용하면 UserCard가 UserCard_user Fragment로 자신의 요구사항을 선언하고, 페이지는 그냥 이 Fragment를 포함하기만 하면 됩니다.

예를 들어, UserCard에 birthday 필드가 추가 필요하면 UserCard 파일만 수정하고 모든 사용처는 자동으로 업데이트됩니다. 기존 방식에서는 페이지 쿼리에 모든 필드를 나열했다면, 이제는 ...UserCard_user처럼 Fragment를 스프레드하여 컴포넌트가 알아서 필요한 걸 선언하게 합니다.

Fragment Colocation의 핵심 특징은 지역성(locality), 재사용성, 그리고 타입 안정성입니다. 컴포넌트와 데이터가 함께 있어 변경이 쉽고, GraphQL Code Generator가 Fragment별로 TypeScript 타입을 생성해줘서 props 타입도 자동으로 맞습니다.

코드 예제

// UserCard.tsx
import { gql } from '@apollo/client';

// Fragment 정의 - UserCard가 필요한 필드만 선언
export const UserCard_user = gql`
  fragment UserCard_user on User {
    id
    name
    avatar
    bio
  }
`;

// Fragment 타입을 props로 사용 (codegen 자동 생성)
interface UserCardProps {
  user: UserCard_userFragment;  // 타입 안전
}

export const UserCard: React.FC<UserCardProps> = ({ user }) => {
  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.bio}</p>
    </div>
  );
};

// UserProfile.tsx
export const UserProfile_user = gql`
  fragment UserProfile_user on User {
    id
    name
    email
    phone
    createdAt
    # UserCard의 데이터도 필요하므로 중첩
    ...UserCard_user
  }
`;

// HomePage.tsx - Fragment들을 조합
const HOME_PAGE_QUERY = gql`
  ${UserCard_user}  # Fragment 포함
  ${UserProfile_user}

  query HomePage($userId: ID!) {
    user(id: $userId) {
      # Fragment 스프레드
      ...UserCard_user
      ...UserProfile_user
    }
  }
`;

설명

이것이 하는 일: Fragment Colocation은 컴포넌트의 데이터 의존성을 명시적으로 선언하고 상위 컴포넌트가 이를 조합하게 하여, 데이터 페칭 로직을 모듈화합니다. 첫 번째로, 각 컴포넌트가 Fragment를 정의합니다.

UserCard는 fragment UserCard_user on User로 "User 타입에서 이 필드들이 필요하다"고 선언합니다. 네이밍 컨벤션으로 ComponentName_fieldName 형식을 사용하면 어떤 컴포넌트의 Fragment인지 명확합니다.

이 Fragment는 gql 태그로 작성되어 GraphQL Code Generator가 인식하고 타입을 생성합니다. 그 다음으로, 상위 컴포넌트가 Fragment를 조합합니다.

HomePage는 UserCard와 UserProfile을 렌더링하므로, 쿼리에 두 Fragment를 모두 포함합니다. ${UserCard_user}로 Fragment를 임포트하고 ...UserCard_user로 스프레드하면, 실제 쿼리는 Fragment의 필드들이 펼쳐진 형태로 서버에 전달됩니다.

GraphQL 엔진이 중복 필드를 자동으로 제거하므로 여러 Fragment에서 같은 필드를 요청해도 문제없습니다. 마지막으로, Fragment는 중첩될 수 있습니다.

UserProfile이 내부적으로 UserCard를 렌더링한다면, UserProfile_user Fragment 안에서 ...UserCard_user를 스프레드하여 의존성을 표현합니다. 이렇게 하면 컴포넌트 트리와 Fragment 의존성 트리가 일치하여 직관적입니다.

여러분이 이 패턴을 적용하면 대규모 앱에서도 데이터 레이어가 깔끔하게 유지됩니다. 실무에서는 수십 개의 컴포넌트가 User 데이터를 사용하는데, 각자 Fragment를 가지고 있으면 서로 간섭하지 않고 독립적으로 발전할 수 있습니다.

또한 GraphQL Code Generator가 Fragment별로 정확한 TypeScript 타입을 만들어주므로, user.email을 사용하려는데 Fragment에 email이 없으면 컴파일 에러가 발생해 버그를 사전에 방지합니다. 새 개발자가 팀에 합류해도 컴포넌트 파일만 보면 데이터 요구사항을 바로 이해할 수 있어 온보딩이 빠릅니다.

실전 팁

💡 Fragment 이름은 ComponentName_propName 규칙을 따르세요. 여러 Fragment를 조합할 때 출처가 명확해집니다.

💡 Fragment는 최소한으로 유지하세요. 컴포넌트가 실제로 렌더링하는 필드만 포함하고, "혹시 필요할까봐" 추가하지 마세요.

💡 Relay의 @relay(plural: true) 같은 기능을 활용하면 리스트 렌더링도 타입 안전하게 처리할 수 있습니다.

💡 Fragment를 export해서 다른 파일에서 재사용하세요. 같은 Fragment를 여러 페이지가 조합할 수 있습니다.

💡 GraphQL Code Generator의 near-operation-file 프리셋을 쓰면 각 컴포넌트 옆에 타입 파일이 생성되어 import가 편리합니다.


#GraphQL#Schema#Resolver#Query#Mutation#TypeScript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

DynamoDB CRUD 완벽 가이드

AWS DynamoDB의 기본 CRUD 작업부터 조건부 작업까지, 초급 개발자를 위한 완벽한 실무 가이드입니다. 실제 프로젝트에서 바로 사용할 수 있는 패턴과 주의사항을 담았습니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.