GraphQL— 나는 그동안 URL이 /graphql인 REST를 만들고 있었다
도메인 노드, ResolveField, 3개의 레이어, 진입점과 도메인 이벤트 — REST 사고방식을 벗어나 그래프로 사고하기 위한 모든 것.
이 글의 코드 예시는 모두 가상의 블로그 도메인(Post, Comment, Author, Tag, Cover)으로 짠 것이다. 패턴 설명을 위한 예시일 뿐이니 그대로 복붙해서 쓰지 말 것.
발단 — 위화감
NestJS + GraphQL로 1년 넘게 개발했다. 그러던 어느 날 schema.gql을 열어봤다. 일반화하면 이런 모양이었다.
type Post { ... }type PostWithCommentsOutput { ... } # 같은 도메인인데 다른 타입?type Article { ... }type ArticleWithTagsOutput { ... } # 또?
type Query { commentsByPost(...): [CommentOutput!]! # Post.comments로 이미 도달 가능한데?}
type DeleteCommentOutput { success: Boolean! # 항상 true message: String! # 한국어로 박혀있음}
type PostOutput { tags: [Tag!] # 어떤 건 Entity 직노출 comments: [CommentOutput!]! # 어떤 건 Output 접미사 cover: CoverOutput # 일관성 없음}"이거 정신없는데?"
위화감이 들었다. 같은 도메인인데 응답이 여러 갈래로 갈라져 있었다. 타입 네이밍이 들쑥날쑥했다. success: true처럼 의미 없는 필드가 박혀있었다.
이 글은 그 위화감을 무시하지 않고 끝까지 파고든 결과의 기록이다.
결론부터 말하면 — 나는 그동안 URL이 /graphql인 REST를 만들고 있었다.
응답 형태가 JSON이 아니라 GraphQL일 뿐, 머릿속 흐름은 REST 컨트롤러와 똑같았다. 새 화면 요구사항이 오면 → 새 Query를 만들고 → 응답 모양에 맞춰 새 Output을 만들고 → resolver에서 한 번에 join해서 반환했다.
이게 잘못됐다는 걸 알게 되기까지 1년이 걸렸다.
결론부터 — 그래프 사고방식의 한 줄
REST는 "요구사항 → 엔드포인트"를 그리고, GraphQL은 "도메인 → 타입과 필드"를 그린다. 새 화면이 와도 그래프는 그대로다 — 클라이언트가 다른 경로를 걸을 뿐이다.
이 한 줄을 진짜로 받아들이면 백엔드 개발의 본질이 바뀐다.
1. 사고 단위가 다르다
REST의 사고 단위는 엔드포인트(Endpoint)다.
요구사항이 오면 URL을 디자인하고, 컨트롤러를 만들고, 그 안에서 필요한 데이터를 다 join해서 응답한다. 유스케이스 단위로 코드를 짠다. 화면이 5개면 엔드포인트도 5개다.
GraphQL의 사고 단위는 타입(Type)과 필드(Field)다.
요구사항이 와도 컨트롤러를 새로 만들지 않는다. 대신:
- "내 도메인엔 어떤 타입이 있는가?" (User, Post, Comment…)
- "각 타입엔 어떤 필드가 있는가?" (User.posts, Post.author, Post.comments…)
- 각 필드를 독립적으로 어떻게 가져올지만 정의 (= Resolver)
- 클라이언트가 그래프 위를 자유 롭게 걸어다님
// ❌ REST 사고 — 화면 단위 응답@Query(() => DashboardOutput)async getDashboard(@Args('userId') userId: string) { const user = await this.userService.findOne(userId); const posts = await this.postService.findByUser(userId); const comments = await this.commentService.findByPosts(posts.map(p => p.id)); return { user, posts, comments }; // 화면 전용 응답}
// ✅ GraphQL 사고 — 도메인 모델 그대로 노출@Resolver(() => User)class UserResolver { @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postLoader.load(user.id); }}
@Resolver(() => Post)class PostResolver { @ResolveField(() => [Comment]) comments(@Parent() post: Post) { return this.commentLoader.load(post.id); }}각 resolver는 자기 필드만 책임진다. 클라이언트가 user { posts { comments } }로 깊이 파고들든 user { name }만 가져가든, 같은 코드가 동작한다.
2. 가장 깊은 통찰 — 3개의 레이어가 동시에 존재한다
NestJS code-first GraphQL을 쓸 때 가장 헷갈리는 지점이다. "Post"라는 한 개념이 세 군데에 동시에 존재한다.
┌────────────────────────────────────────────────────────────┐│ Layer 1: 런타임 JavaScript 객체 (= PostEntity 인스턴스) ││ → DB에서 SELECT한 결과. 모든 컬럼 + 관계가 다 채워질 수 있음 ││ → 메모리에 떠다니는 "그 데이터 그 자체" │└────────────────────────────────────────────────────────────┘ ↓ 변환 없음, 그대로 전달┌────────────────────────────────────────────────────────────┐│ Layer 2: TypeScript 타입 (= 코드에서 보는 클래스) ││ → @Parent() post: PostEntity ││ → 개발자가 IDE에서 자동완성, 컴파일 체크 받는 용도 │└────────────────────────────────────────────────────────────┘ ↓ 클라이언트에 노출할 때만 변환┌────────────────────────────────────────────────────────────┐│ Layer 3: GraphQL Schema 타입 (= schema.gql의 type) ││ → type PostOutput { ... } ││ → 클라이언트가 보는 계약. JS 객체 중 일부 필드만 보여줌 │└────────────────────────────────────────────────────────────┘비유로 풀면 명확하다:
- Layer 1 (Entity 인스턴스) = 당신이라는 사람. 모든 속성을 다 가진 실체.
- Layer 2 (TS 타입) = 이력서. 코드에서 "이 사람은 이런 속성이 있다"고 적어둔 것.
- Layer 3 (GraphQL 타입) = 회사 명함. 외부에 보여줄 필드만 골라 인쇄한 것.
당신은 사람 자체(Entity)지, 명함(Output)이 아니다. 그런데 외부에서는 명함만 본다.
데이터는 한 군데에만 존재한다
이게 가장 중요한 통찰이다.
PostEntity 인스턴스 하나가 처음부터 끝까지 메모리에 떠다닌다. 그게 전부다. "Output에 값을 채운다" 같은 별도 단계는 없다.
흐름을 따라가보자:
// 1. Service: DB에서 Entity를 끌어온다async findByAuthor(authorId: string): Promise<PostEntity[]> { return this.repo.findBy({ authorId }); // → PostEntity 인스턴스 배열. 메모리에 실재.}
// 2. Resolver: Entity를 그대로 반환. 단지 "선언"만 Output으로@Query(() => [PostOutput])async postsByAuthor(...): Promise<PostOutput[]> { return this.postService.findByAuthor(...); // → 실제로는 PostEntity[]를 반환. // → TS는 OK (PostOutput extends OmitType(PostEntity))}
// 3. NestJS GraphQL: Entity 인스턴스를 받아서 schema의 PostOutput에 따라 직렬화// → schema에 선언된 필드만 골라서 응답에 넣는다// → ResolveField가 있는 필드는 메서드 호출해서 채운다핵심: Layer 1의 데이터(Entity 인스턴스)는 변환되지 않는다. Layer 3(GraphQL 응답)는 그 데이터에서 필요한 부분만 추출한다.
OmitType의 천재성
@ObjectType()export class PostOutput extends OmitType(PostEntity, ['id'], ObjectType) {}이 한 줄이 동시에 세 가지를 한다:
- TS 관점:
PostOutput은PostEntity의 부분집합 클래스 → entity 인스턴스를 반환해도 TS가 OK 한다. - 런타임 관점: 아무것도 변환하지 않는다. Entity 인스턴스가 그대로 흐른다.
- GraphQL 관점: schema에서
PostOutput이라는 별도 타입이 등록됐고, 클라이언트는 이 타입을 본다.
→ 세 레이어가 모두 만족하는 한 줄 마법.
이 패턴 덕분에 Service는 GraphQL을 모르고, Resolver는 Output만 선언하면 된다. 컨벤션이 코드 레벨로 강제된다.
3. @Resolver(() => X)의 진짜 의미
이 데코레이터는 두 개의 완전히 다른 역할을 한 클래스 안에서 동시에 한다. 이게 헷갈림의 근원이다.
@Resolver(() => PostOutput) // ← 이 인자가 어떤 의미를 갖는가?export class PostResolver {
@Query(() => [PostOutput]) postsByAuthor(...) { ... } // (A) 그래프 진입점
@Mutation(() => PostOutput) publishPost(...) { ... } // (A) 그래프 진입점
@ResolveField(() => CoverOutput) cover(@Parent() post) { ... } // (B) PostOutput 타입의 한 필드}(A) Query/Mutation에게 () => PostOutput 인자는 의미가 없다. 그냥 NestJS가 클래스 한 개 정도는 줘야 한다고 요구하니까 채워넣은 것뿐. Query/Mutation은 schema의 Query/Mutation 타입에 등록되지, PostOutput에 등록되는 게 아니다.
단, 여기서 헷갈리면 안되는 것은 해당 Query(()=>PostOutput)에서 반환하는 값은 PostOutput 타입이니, 반환값에 데이터를 넣어줘서 ResovleField에서 사용할 수 있다.
(B) ResolveField에게는 () => PostOutput이 결정적이다. "이 클래스의 @ResolveField 메서드들은 PostOutput 타입의 필드를 채운다"는 선언이다. 즉:
@Resolver(() => X)는 "이 클래스 안의 ResolveField들은 X 타입을 확장한다"는 뜻이다.
# 결과적으로 schema는 이렇게 된다type PostOutput { postId: ID! title: String! ... # Entity의 정적 필드 cover: CoverOutput # ← PostResolver.cover() 메서드가 채움 comments: [Comment!]! # ← PostResolver.comments() 메서드가 채움}한 타입은 여러 Resolver가 확장할 수 있다
이게 가장 중요한 부수 통찰이다. @ResolveField는 "타입에 모서리를 추가하는 행위"이고, 각 도메인이 자기 쪽에서 추가한다.
// modules/post/post.resolver.ts@Resolver(() => PostOutput)export class PostResolver { @ResolveField(() => CoverOutput) cover(@Parent() post) { ... } // Post → Cover 모서리}
// modules/cover/cover.resolver.ts@Resolver(() => CoverOutput)export class CoverResolver { @ResolveField(() => PostOutput, { nullable: true }) post(@Parent() cover) { ... } // Cover → Post 역방향 모서리
@ResolveField(() => UserOutput) uploadedBy(@Parent() cover) { ... }}→ CoverOutput에 모서리를 추가하는 사람: CoverResolver. → PostOutput에 "cover" 모서리를 추가하는 사람: PostResolver.
각자 자기 도메인의 진입점을 자기 파일에서 관리한다. 그래프는 여러 도메인의 기여로 조립된다.
4. 타입 소유 권 — Output은 어느 모듈에 살아야 하나?
새 도메인을 만들 때 자주 헷갈리는 부분이다.
"CoverOutput을 Post의 ResolveField에서 쓰니까, PostOutput과 같이 두는 게 맞나? 아니면 Cover 도메인에 둬야 하나?"
답: Cover 도메인에 산다. 이유 3가지.
1. 도메인 = 타입의 소유자다
현실 비유: User 타입을 생각해보자.
Post에서도 author로 User를 쓰고, Comment에서도 쓰고, Like에서도 쓴다. 그렇다고 PostUserOutput, CommentUserOutput, LikeUserOutput을 만드나? 절대 안 만든다.
→ User는 하나의 도메인 개념이고, 하나의 표현(UserOutput)이 있다. 모든 곳이 이 하나를 참조한다.
Cover도 동일하다. Cover는 Cover다. Post에서 본다고 다른 Cover가 되는 게 아니다.