GraphQL Yoga + Pothos Schema Builder: 코드 제너레이션 없이 TypeScript로 타입 안전한 GraphQL API 구축하기
REST API에서 GraphQL로 이전하는 팀이 공통으로 겪는 장벽이 있습니다. .graphql SDL 파일과 resolver 타입을 별도로 관리하면서, 이 둘을 동기화하는 graphql-codegen 빌드 단계까지 운영해야 하는 구조입니다. SDL이 바뀔 때 codegen을 빠뜨리면 컴파일은 통과하는데 런타임에서 타입 오류가 터집니다. 빌드 파이프라인에서 "codegen → tsc → 빌드" 순서가 어긋나면 CI가 모호한 이유로 실패합니다. 이런 마찰이 쌓이면 팀은 GraphQL의 이점보다 운영 비용을 먼저 체감하게 됩니다.
GraphQL Yoga와 Pothos Schema Builder를 함께 쓰면 이 문제의 근본 원인 자체를 제거할 수 있습니다. TypeScript 코드 자체가 스키마의 단일 진실 공급원(source of truth)이 되기 때문입니다. 별도의 .graphql 파일도, graphql-codegen 빌드 단계도, reflect-metadata를 위한 데코레이터도 필요 없습니다. Pothos가 TypeScript의 타입 추론을 활용해 스키마 정의와 resolver 구현 사이의 타입을 컴파일 타임에 검증하고, Yoga가 이 스키마를 받아 어떤 JS 런타임에서도 동작하는 GraphQL 서버로 서빙합니다.
이 글에서는 Pothos의 코드-퍼스트 방식이 현재 TypeScript 생태계에서 유력한 선택인 이유, 실제 프로젝트에서 어떻게 설정하고 활용하는지, 그리고 어떤 상황에서 이 조합이 맞지 않는지를 순서대로 살펴봅니다. 특히 Prisma와의 통합에서 N+1 문제를 자동으로 해결하는 패턴과, 스키마 레벨 인증/인가를 선언적으로 구현하는 방법을 코드와 함께 다룹니다.
핵심 개념
GraphQL Yoga: 런타임에 얽매이지 않는 GraphQL 서버
GraphQL Yoga는 The Guild 팀이 개발한 GraphQL HTTP 서버입니다. 다른 서버들과 결정적으로 다른 점은 WHATWG Fetch API 스펙을 핵심 아키텍처로 채택했다는 것입니다. Request/Response 객체를 Node.js 전용 http.IncomingMessage가 아니라 웹 표준 Request/Response로 다루기 때문에, 동일한 코드가 Node.js, Bun, Deno, Cloudflare Workers, AWS Lambda에서 그대로 실행됩니다.
공식 graphql-http 적합성 테스트 스위트 기준으로 GraphQL over HTTP 사양의 필수·선택 항목을 완전히 충족합니다. Apollo Server보다 번들이 가볍고, SSE(Server-Sent Events) 기반 구독이 내장되어 있습니다. Envelop 플러그인 시스템을 통해 depth limit, 응답 캐시, rate limiting을 레이어로 추가할 수도 있습니다.
Pothos: 코드-퍼스트 스키마 빌더
Pothos(이전 이름: GiraphQL)는 TypeScript의 타입 추론과 제네릭을 활용해 GraphQL 스키마를 코드로 정의하는 라이브러리입니다.
코드 제너레이션이 없습니다. graphql-codegen 같은 빌드 단계 없이 resolver 파라미터(args, context, parent)의 타입이 자동으로 추론됩니다. CI 파이프라인에서 "codegen 먼저, 빌드 다음" 순서를 맞출 필요가 없어집니다.
데코레이터가 없습니다. TypeGraphQL은 @ObjectType(), @Field() 같은 데코레이터로 스키마를 선언하는데, 이를 위해 experimentalDecorators와 reflect-metadata가 필요합니다. Pothos는 builder.objectType(), builder.queryField() 같은 순수 함수 호출만 사용합니다. tsconfig.json에 특별한 컴파일러 옵션을 추가할 필요가 없습니다.
Pothos 코어 패키지는 builder.toSchema() 호출이 서버 시작 시 실행된다는 점을 제외하면 런타임 오버헤드가 무시할 수준입니다. graphql 패키지만을 peer dependency로 가지며, 타입 검증은 전부 컴파일 타임에 일어납니다.
코드-퍼스트 vs 스키마-퍼스트
| 항목 | 스키마-퍼스트 | 코드-퍼스트 (Pothos) |
|---|---|---|
| 진실 공급원 | .graphql SDL 파일 |
TypeScript 코드 |
| 타입 동기화 | codegen 빌드 단계 필요 | 컴파일 타임 자동 추론 |
| Resolver 타입 | codegen 생성 타입 참조 | builder에서 자동 유도 |
| 협업 방식 | SDL 파일로 API 설계 주도 가능 | 코드 변경이 곧 스키마 변경 |
| 학습 곡선 | SDL 문법은 직관적 | TypeScript 제네릭 이해 필요 |
| CI 파이프라인 | codegen → tsc → 빌드 | tsc → 빌드 |
스키마-퍼스트가 나쁘다는 게 아닙니다. 프론트엔드 팀이 SDL 파일로 API 설계를 주도하거나, 스키마를 먼저 공유하고 각 팀이 구현을 병렬로 진행하는 방식이라면 스키마-퍼스트가 여전히 유효합니다. 코드-퍼스트는 "TypeScript 코드가 중심이고, 스키마는 그 결과물"인 팀에 자연스럽게 맞아떨어집니다.
유사 라이브러리와 비교
| Pothos | TypeGraphQL | Nexus | |
|---|---|---|---|
| 방식 | 코드-퍼스트, 함수형 | 코드-퍼스트, 데코레이터 | 코드-퍼스트, 함수형 |
| 데코레이터 의존 | 없음 | 있음 (reflect-metadata 필요) |
없음 |
| 유지보수 상태 | 활발 | 활발 | 사실상 중단 (2022년 이후) |
| 커뮤니티 자료 | 상대적으로 적음 | 상대적으로 풍부 | 낮음 |
| 주요 사용처 | Prisma 통합, 그린필드 프로젝트 | NestJS 생태계, 데코레이터 코드베이스 | 신규 프로젝트 비권장 |
| Prisma 공식 통합 | @pothos/plugin-prisma (공식) |
서드파티 통합 | 과거 공식, 현재 deprecated |
TypeGraphQL은 커뮤니티 자료가 더 풍부하고 NestJS와 궁합이 좋습니다. Nexus는 Prisma 팀이 2022년 이후 사실상 유지보수를 중단했으므로 신규 프로젝트에서는 선택하지 않는 편이 좋습니다.
타입 안전성의 두 계층
Pothos는 서버 내부의 타입 안전성을 책임집니다. Resolver가 반환하는 값과 GraphQL 타입이 불일치하면 tsc가 컴파일 시점에 잡아냅니다. 클라이언트까지의 엔드-투-엔드 타입 안전성이 필요하다면, Pothos가 런타임에 생성하는 스키마를 graphql-codegen이 소비해 클라이언트용 타입을 별도로 생성하는 혼합 방식을 선택할 수 있습니다.
실전 적용
1단계: 설치와 기본 셋업
npm install graphql-yoga @pothos/core graphqlSchemaBuilder를 만들 때 주의할 점이 있습니다. t.exposeString("title") 같은 expose* 메서드는 부모 타입의 프로퍼티 키를 컴파일 타임에 검증하는데, 이 검증이 동작하려면 Objects 제네릭에 backing 타입을 명시적으로 등록해야 합니다.
// src/builder.ts
import SchemaBuilder from "@pothos/core";
export interface Post {
id: string;
title: string;
published: boolean;
}
interface Context {
userId?: string;
}
const builder = new SchemaBuilder<{
Context: Context;
Objects: { Post: Post }; // expose* 메서드의 키 검증에 필요
}>({});
export default builder;Objects를 등록하지 않으면 t.exposeString("title")이 Post 타입에 title: string이 존재하는지 검증하지 못합니다. 이 등록이 "컴파일 타임 검증"의 실질적 기반입니다.
// src/schema.ts
import builder from "./builder";
import type { Post } from "./builder";
builder.objectType("Post", {
description: "블로그 게시글",
fields: (t) => ({
id: t.exposeID("id"),
title: t.exposeString("title"), // Post에 title: string이 없으면 컴파일 에러
published: t.exposeBoolean("published"),
}),
});
builder.queryType({
fields: (t) => ({
posts: t.field({
type: ["Post"],
resolve: async (): Promise<Post[]> => {
return [{ id: "1", title: "첫 번째 게시글", published: true }];
},
}),
}),
});
export const schema = builder.toSchema();Yoga 서버에 연결하면 끝입니다.
// src/index.ts
import { createYoga } from "graphql-yoga";
import { createServer } from "http";
import { schema } from "./schema";
const yoga = createYoga({ schema });
const server = createServer(yoga);
server.listen(4000, () => {
console.log("GraphQL server: http://localhost:4000/graphql");
});서버를 시작하면 http://localhost:4000/graphql에서 GraphiQL 2.0이 자동으로 제공됩니다.
2단계: Prisma 플러그인으로 N+1 자동 해결
GraphQL에서 N+1 쿼리 문제는 단골 골칫거리입니다. @pothos/plugin-prisma는 query 인자 패턴으로 이 문제를 자동으로 해결합니다.
npm install @pothos/plugin-prisma @prisma/clientPrisma 플러그인이 타입 파일을 생성하려면 schema.prisma에 generator 블록을 추가하고 prisma generate를 실행해야 합니다. 이 단계를 건너뛰면 다음에 나오는 import type PrismaTypes from "@pothos/plugin-prisma/generated" 경로가 존재하지 않아 컴파일부터 실패합니다.
// schema.prisma
generator client {
provider = "prisma-client-js"
}
generator pothos {
provider = "prisma-pothos-types"
}npx prisma generate1단계에서 만든 builder.ts에 세 가지를 추가합니다. PrismaPlugin 임포트, PrismaTypes 제네릭, 플러그인 설정입니다.
// src/builder.ts — Prisma 플러그인 추가 버전
import SchemaBuilder from "@pothos/core";
import PrismaPlugin from "@pothos/plugin-prisma"; // 추가
import type PrismaTypes from "@pothos/plugin-prisma/generated"; // 추가
import { PrismaClient } from "@prisma/client"; // 추가
export const prisma = new PrismaClient();
interface Context {
prisma: PrismaClient; // 추가
userId?: string;
}
const builder = new SchemaBuilder<{
Context: Context;
PrismaTypes: PrismaTypes; // 추가
}>({
plugins: [PrismaPlugin], // 추가
prisma: { // 추가
client: prisma,
exposeDescriptions: true,
filterConnectionTotalCount: true,
},
});
export default builder;prismaObject를 사용하면 Prisma 모델에서 GraphQL 타입이 자동으로 만들어집니다.
// src/schema/post.ts
import { builder, prisma } from "../builder";
builder.prismaObject("Post", {
fields: (t) => ({
id: t.exposeID("id"),
title: t.exposeString("title"),
author: t.relation("author"), // Prisma 관계를 GraphQL 필드로 노출
}),
});
builder.queryField("posts", (t) =>
t.prismaField({
type: ["Post"],
resolve: async (query, _root, _args, ctx) => {
// console.log(query) 결과 예시: { include: { author: true } }
// Pothos가 클라이언트 요청 필드를 분석해 자동으로 계산합니다
return ctx.prisma.post.findMany({
...query, // 필요한 include/select가 자동으로 포함됨
where: { published: true },
});
},
})
);t.relation("author")와 t.prismaField는 쓰임새가 다릅니다. t.prismaField는 Prisma를 직접 호출하는 entry-point resolver에 사용하며, ...query를 스프레드해야 N+1 최적화가 동작합니다. t.relation은 Prisma 모델 관계를 선언하는 것으로, 플러그인이 부모 타입의 query 계산 시 해당 관계에 필요한 include를 자동으로 포함시키기 때문에 추가 Prisma 호출이 발생하지 않습니다.
3단계: scope-auth로 선언적 인증/인가
@pothos/plugin-scope-auth를 사용하면 인증 로직을 스키마 정의 단계에서 선언적으로 붙일 수 있습니다.
npm install @pothos/plugin-scope-auth// src/builder.ts — scope-auth 추가
import ScopeAuthPlugin from "@pothos/plugin-scope-auth";
// 실제 프로젝트에서는 DB 조회나 캐시를 활용하는 구현체로 대체합니다
declare function isAdminUser(userId: string): Promise<boolean>;
const builder = new SchemaBuilder<{
Context: Context;
AuthScopes: {
isAuthenticated: boolean;
isAdmin: boolean;
};
}>({
plugins: [ScopeAuthPlugin],
authScopes: async (ctx) => ({
isAuthenticated: !!ctx.userId,
isAdmin: ctx.userId !== undefined && (await isAdminUser(ctx.userId)),
}),
});각 스코프는 요청당 한 번, 해당 스코프를 필요로 하는 필드가 실제로 조회될 때 지연 평가됩니다. isAdmin처럼 DB 조회가 필요한 스코프도 isAdmin 보호 필드가 쿼리에 포함된 요청에서만 실행되므로, 모든 요청에 DB 조회 비용이 발생하지는 않습니다.
// src/schema/admin.ts
import { builder } from "../builder";
// 실제 프로젝트에서는 구현체로 대체합니다
declare function getUserById(userId: string): Promise<string>;
builder.queryField("adminDashboard", (t) =>
t.field({
type: "String",
authScopes: {
isAdmin: true, // isAdmin이 false면 자동으로 접근 거부
},
resolve: () => "관리자 전용 데이터",
})
);
builder.queryField("myProfile", (t) =>
t.field({
type: "String",
authScopes: {
isAuthenticated: true,
},
resolve: (_root, _args, ctx) => {
// authScopes가 isAuthenticated를 보장하지만 TypeScript는 string으로 좁히지 못합니다
return getUserById(ctx.userId as string);
},
})
);ctx.userId as string은 의도적인 타입 단언입니다. authScopes: { isAuthenticated: true } 조건이 충족되어야 이 resolver가 실행되므로 userId가 truthy임은 보장되지만, TypeScript가 이를 string으로 자동으로 좁히지는 못합니다.
relay 플러그인과 함께 사용할 때 주의사항: relay 플러그인(Relay 스펙의 Connection/Node 인터페이스를 지원하는 Pothos 공식 플러그인)을 scope-auth와 함께 쓴다면 플러그인 배열의 순서가 중요합니다. 공식 문서는 relay를 scope-auth 앞에 등록하도록 권장합니다. 순서가 달라지면 authScopes 함수가 파싱된 GlobalID를 올바르게 받지 못할 수 있습니다.
const builder = new SchemaBuilder({
plugins: [RelayPlugin, ScopeAuthPlugin], // relay를 먼저 등록
// ...
});장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 코드 제너레이션 없음 | CI 파이프라인 단순화, SDL↔resolver 동기화 문제 해소 |
| 런타임 오버헤드 무시할 수준 | Pothos 코어는 graphql만 의존, 타입 검증은 컴파일 타임 |
| 플러그인 생태계 | Prisma, Relay, Scope-Auth, DataLoader, Mocks 등 공식 지원 |
| 런타임 중립 | Node, Bun, Deno, Cloudflare Workers 등 동일 코드로 실행 |
| 번들 경량 | Apollo Server 대비 가벼운 번들 크기 |
| 유지보수성 | 스키마와 resolver를 함께 관리, 코드 지역성 유지 |
| GraphQL 스펙 준수 | GraphQL over HTTP 사양 완전 충족, SSE 구독 내장 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| 학습 곡선 | TypeScript 제네릭 기반 API, 복잡한 union·인터페이스에서 타입 에러 메시지가 난해함 |
| 클라이언트 타입은 별도 | Pothos 자체는 클라이언트 타입을 생성하지 않음, graphql-codegen 조합 필요 |
| 스키마-퍼스트 협업에 부적합 | 프론트엔드 팀이 SDL로 API 설계를 주도하는 방식과 맞지 않음 |
| 커뮤니티 자료 수 | TypeGraphQL보다 다운로드 수가 적어 예제와 자료가 적음 |
| 플러그인 초기화 순서 | relay + scope-auth 조합 시 순서 의존성 있음 |
| Apollo Federation | 대규모 마이크로서비스 페더레이션이 핵심이라면 Apollo 생태계가 더 성숙함 |
실무에서 흔한 실수
query 인자를 빠뜨리는 경우
@pothos/plugin-prisma를 쓰면서 resolve 함수에서 query 인자를 스프레드하지 않으면 N+1 최적화가 동작하지 않습니다. prismaField를 사용할 때는 반드시 { ...query } 형태로 Prisma 쿼리 인자에 포함시켜야 합니다. 디버그할 때 console.log(query)를 찍어보면 { include: { author: true } } 같은 객체가 어떻게 계산되는지 확인할 수 있습니다.
클라이언트 타입을 Pothos에서 기대하는 경우
Pothos는 서버 resolver의 타입 안전성을 제공하지, 클라이언트 쿼리 응답 타입을 생성하지 않습니다. 프론트엔드에서 타입 안전한 쿼리 훅이 필요하다면 별도로 graphql-codegen을 구성해야 합니다.
Nexus와 혼동하는 경우 Pothos의 이전 이름이 GiraphQL이었고, Nexus와 API 스타일이 유사해 처음에 헷갈릴 수 있습니다. Nexus는 현재 사실상 deprecated 상태이므로 신규 프로젝트에서는 Pothos를 선택하는 것이 좋습니다.
복잡한 타입 에러 메시지
Pothos의 TypeScript 제네릭 에러 메시지는 처음에 다소 길고 난해하게 보일 수 있습니다. union 타입이나 인터페이스 상속에서 타입 에러가 나면, 에러 메시지 끝부분의 핵심 라인을 찾아 SchemaTypes 제네릭 정의와 대조해보는 방식이 효과적입니다.
마치며
Prisma를 사용하고, 코드 중심으로 팀이 움직이며, 런타임 이식성을 고려한다면 GraphQL Yoga + Pothos는 현재 TypeScript 생태계에서 가장 응집도 높은 선택입니다. codegen 빌드 단계를 제거해 CI를 단순하게 유지하고, resolver와 스키마 정의를 같은 위치에 두어 코드 지역성을 높이며, Prisma 플러그인을 통해 N+1 문제를 보일러플레이트 없이 해결합니다.
반면 NestJS 기반 코드베이스나 스키마-퍼스트 협업 방식을 쓰는 팀, 또는 대규모 Apollo Federation 아키텍처가 필요한 환경이라면 이 조합이 최선이 아닐 수 있습니다.
시작한다면 이 세 단계로 충분합니다.
- 기본 스택 설치:
graphql-yoga,@pothos/core,graphql세 패키지로 시작합니다.Objects제네릭을 올바르게 등록해expose*메서드의 컴파일 타임 검증이 실제로 동작하는지 확인합니다. - Prisma 플러그인 추가:
schema.prisma에prisma-pothos-typesgenerator를 등록하고prisma generate를 실행한 뒤...query패턴으로 N+1 없는 데이터 조회를 구현합니다. - scope-auth로 인가 레이어 추가: 각 필드에 선언적 권한 제어를 적용합니다. 스코프가 지연 평가되는 특성 덕분에 DB 조회 비용을 최소화하면서도 필드 수준 인가를 유지할 수 있습니다.
기존 SDL-first 스키마에서 점진적으로 마이그레이션하는 경우라면, 기존 resolver를 그대로 유지하면서 새 도메인 영역부터 Pothos 방식으로 작성하는 전략이 현실적입니다. SDL 파일을 제거하는 시점은 해당 도메인의 모든 타입이 Pothos로 전환된 이후입니다.
참고 자료
- Pothos GraphQL 공식 문서
- Pothos v4 공식 문서
- Pothos SchemaBuilder 가이드
- Pothos Prisma 플러그인 문서
- Pothos Scope-Auth 플러그인 문서
- GraphQL Yoga 공식 문서
- GraphQL Yoga Cloudflare Workers 통합 가이드
- GraphQL Yoga 다른 서버 라이브러리와 비교
- Pothos GitHub 저장소
- Revisiting GraphQL in 2025: A Type-Safe Stack with Pothos and Relay — DEV Community
- Pothos vs. TypeGraphQL for TypeScript schema building — LogRocket Blog
- Code-first GraphQL with Pothos — graphql.wtf Episode #60
- Achieving end-to-end type safety in a modern JS GraphQL stack — Escape.tech
- Pothos Evaluation Report — DEV Community
- Schema-First vs Code-Only GraphQL — Apollo Blog