GraphQL— 나는 그동안 URL이 /graphql인 REST를 만들고 있었다

도메인 노드, ResolveField, 3개의 레이어, 진입점과 도메인 이벤트 — REST 사고방식을 벗어나 그래프로 사고하기 위한 모든 것.

Backend·

이 글의 코드 예시는 모두 가상의 블로그 도메인(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)다.

요구사항이 와도 컨트롤러를 새로 만들지 않는다. 대신:

  1. "내 도메인엔 어떤 타입이 있는가?" (User, Post, Comment…)
  2. "각 타입엔 어떤 필드가 있는가?" (User.posts, Post.author, Post.comments…)
  3. 각 필드를 독립적으로 어떻게 가져올지만 정의 (= Resolver)
  4. 클라이언트가 그래프 위를 자유롭게 걸어다님
// ❌ 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) {}

이 한 줄이 동시에 세 가지를 한다:

  1. TS 관점: PostOutput은 PostEntity의 부분집합 클래스 → entity 인스턴스를 반환해도 TS가 OK 한다.
  2. 런타임 관점: 아무것도 변환하지 않는다. Entity 인스턴스가 그대로 흐른다.
  3. 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가 되는 게 아니다.

2. Output은 Entity에서 OmitType으로 파생된다

cover.entity.ts가 Cover 모듈에 있으면, CoverOutput = OmitType(CoverEntity, ...)도 같은 모듈에 있어야 한다. 안 그러면 다른 모듈에서 CoverEntity를 가져와서 OmitType 파생을 시도하는 이상한 의존이 생긴다.

→ Output은 Entity의 외부 표현이다. Entity가 사는 곳에 Output도 산다.

3. 만약 정말 다른 모양이 필요하다면 그건 다른 개념이다

"Post에서는 Cover를 살짝 다른 모양으로 보고 싶다"는 욕구가 든다면, 두 가지 중 하나다:

  • (a) 같은 Cover지만 클라이언트가 다른 필드만 선택 → GraphQL이 자동으로 해결
  • (b) 정말로 다른 도메인 개념 → 새 타입 (예: CoverThumbnail, CoverPreview)

(b)의 경우에도 그건 "Post가 소유한 Cover의 변형"이 아니라 "Cover 도메인의 새 표현"이다. 여전히 Cover 모듈에 산다.

┌─────────────────────────────────────────────────────────┐
│ schema.gql (그래프의 지도 — 모든 도메인이 모여 그려짐) │
│ │
│ type Post { │
│ postId: ID! │
│ cover: Cover ◄──┐ PostResolver가 │
│ comments: [Comment!]! ◄──┤ ResolveField로 추가 │
│ } │
│ │
│ type Cover { │
│ coverId: ID! │
│ uploadedBy: User ◄──┐ CoverResolver가 │
│ post: Post ◄──┤ ResolveField로 추가 │
│ } │
└─────────────────────────────────────────────────────────┘
modules/post/ modules/cover/
├── post.entity.ts ├── cover.entity.ts
├── post.output.ts ├── cover.output.ts ◄── 여기!
├── post.resolver.ts ├── cover.resolver.ts
│ @Resolver(() => PostOutput) │ @Resolver(() => CoverOutput)
│ @ResolveField cover() │ @ResolveField uploadedBy()
│ @ResolveField comments() │ @ResolveField post()
└── post.service.ts └── cover.service.ts
↓ ↓
CoverOutput을 import해서 PostOutput을 import해서
ResolveField의 반환 타입으로 선언 ResolveField의 반환 타입으로 선언

원칙:

  1. 타입은 도메인이 소유한다 (Cover는 Cover 모듈에)
  2. 다른 도메인은 import해서 참조한다 (Post가 CoverOutput을 import)
  3. 모서리는 양쪽 도메인이 각자 자기 쪽에서 추가한다
  4. schema.gql은 모두의 기여로 자동 조립된다 (Code-first의 마법)

5. 한 도메인 = 한 Output (1:1:1)

내가 가장 늦게 깨달은 부분이다.

REST에서는 응답 DTO가 endpoint마다 늘어난다. PostListResponse, PostDetailResponse, PostEditResponse... 같은 Post인데 응답이 5개로 갈라진다.

GraphQL에서는 도메인 노드 하나당 Output 하나다.

도메인 노드 ↔ GraphQL 타입 ↔ Output 클래스
───────────────────────────────────────────────────────
User ↔ type User ↔ UserOutput
Post ↔ type Post ↔ PostOutput
Comment ↔ type Comment ↔ CommentOutput
Tag ↔ type Tag ↔ TagOutput
Cover ↔ type Cover ↔ CoverOutput

1:1:1 매핑이 정상 상태다. 그래프의 한 노드는 한 곳에만 산다.

새 화면이 와도 새 Output을 만들지 않는다

  • 가벼운 화면: post { postId title } — 같은 PostOutput, 적게 select
  • 상세 화면: post { postId title comments { ... } cover { ... } } — 같은 PostOutput, 많이 select
  • 통계 화면: post { postId commentCount likeCount } — 같은 PostOutput, 다른 ResolveField select

→ 타입은 하나, 클라이언트가 select로 다르게 본다.

이게 schema의 "wide, narrow query" 철학:

  • Schema는 넓다 (가능한 모든 필드 + ResolveField가 다 있다)
  • Query는 좁다 (클라이언트가 필요한 것만 골라간다)

안티패턴: 화면 단위 타입 분리

가장 흔한 REST 잔재가 이거다.

type Post {
postId, title, status
}
type PostWithCommentsOutput { # ← 화면 전용 타입
postId, title, status
comments: [Comment!]!
}
type Article {
articleId, headline, ...
}
type ArticleWithTagsOutput { # ← 또 화면 전용 타입
articleId, headline, ...
tags: [Tag!]!
author: UserOutput!
}

이건 정확히 "REST식 사고"의 정의다 — "Post 상세 화면에선 댓글이 필요하니까 별도 타입을 만들자."

GraphQL식으로 바꾸면:

type Post {
postId, title, status
comments: [Comment!]! # ResolveField
myReaction: Reaction # ResolveField
}
type Query {
post(input: PostInput!): Post! # 한 타입
myPosts(input: MyPostsInput!): [Post!]! # 같은 타입
}

이러면 클라이언트가:

  • 목록 화면: myPosts { postId title } — comments 안 부르니 SQL 안 나감
  • 상세 화면: post { postId title comments { ... } myReaction } — 필요한 만큼만

→ 화면 단위로 타입을 가르면 그래프가 산산조각난다.

"그러면 새 필드가 필요할 때 새 Output을 만들면 안 되나?"

자주 받는 질문이다.

@ObjectType()
export class PostOutput extends OmitType(PostEntity, ['id'], ObjectType) {}
@ObjectType()
export class PostWithStatsOutput extends PostOutput {
@Field(() => Int)
commentCount!: number;
}

이렇게 하면 정적 필드는 상속되지만, ResolveField는 따라오지 않는다.

type PostWithStatsOutput {
postId: ID! # ✅ 상속됨
title: String! # ✅ 상속됨
commentCount: Int! # ✅ 새 필드
# ❌ cover 없음 (ResolveField는 자동 상속 안 됨)
# ❌ comments 없음
}

이걸 해결하려면 추상 베이스 Resolver 패턴을 써야 한다 (NestJS 공식 권장):

function createPostFieldsResolver<T>(typeRef: Type<T>) {
@Resolver(() => typeRef, { isAbstract: true })
abstract class PostFieldsResolverHost {
@ResolveField(() => CoverOutput, { nullable: true })
cover(@Parent() post, @Loaders() loaders) {
return loaders.postCover.load(post.id);
}
@ResolveField(() => [CommentOutput])
comments(@Parent() post, @Loaders() loaders) {
return loaders.postComments.load(post.id);
}
}
return PostFieldsResolverHost;
}
@Resolver(() => PostOutput)
export class PostResolver extends createPostFieldsResolver(PostOutput) {}
@Resolver(() => PostWithStatsOutput)
export class PostWithStatsResolver extends createPostFieldsResolver(PostWithStatsOutput) {
@ResolveField(() => Int)
commentCount(@Parent() post) { ... }
}

가능하지만 — 이런 짓을 해야 한다는 것 자체가 설계 신호다.

"왜 두 개의 타입이 같은 ResolveField를 공유해야 하지?"

대부분의 경우 답은: 그러지 않아야 한다. 한 타입에 ResolveField로 추가하고, 클라이언트가 select로 다르게 보면 된다.

// ✅ 더 좋은 답
@Resolver(() => PostOutput)
export class PostResolver {
// 기존 ResolveField들...
// 새 필드는 그냥 ResolveField 추가
@ResolveField(() => Int)
commentCount(@Parent() post) {
return loaders.postCommentCount.load(post.id);
}
@ResolveField(() => Boolean)
isMine(@Parent() post, @User() user) {
return post.authorId === user.userId;
}
}
type PostOutput {
postId: ID!
title: String!
commentCount: Int! # ← 새 필드, 한 타입에 그냥 추가
isMine: Boolean! # ← 새 필드
}

클라이언트는 필요한 화면에서만 select한다:

# 가벼운 화면
query { postsByAuthor { postId title } }
# 통계가 필요한 화면
query { postsByAuthor { postId title commentCount } }

타입을 가르고 싶은 충동이 들 때마다 멈추고 자문해야 한다:

"정말 다른 도메인 개념인가? 아니면 같은 노드의 새 모서리인가?"

9할은 후자다.


6. ResolveField는 도메인 노드의 모서리다

@ResolveField는 단순한 "lazy loading 기법"이 아니다. 도메인 노드에 모서리(edge)를 다는 행위다.

@Resolver(() => Post)
class PostResolver {
// 정적 필드는 entity의 컬럼에서 자동
// 모든 파생/관계/계산은 ResolveField로 한 타입에 모인다
@ResolveField(() => CoverOutput, { nullable: true })
cover(...) { ... } // ← 1:1 관계
@ResolveField(() => [CommentOutput])
comments(...) { ... } // ← 1:N 관계
@ResolveField(() => UserOutput)
author(...) { ... } // ← N:1 관계
@ResolveField(() => Int)
commentCount(...) { ... } // ← 집계 파생
@ResolveField(() => Boolean)
isMine(...) { ... } // ← 권한 파생
@ResolveField(() => Boolean)
canEdit(...) { ... } // ← 권한 파생
@ResolveField(() => PostStats)
stats(...) { ... } // ← 통계 묶음 (또 다른 노드)
}

→ 모든 게 한 Output에 모인다. 클라이언트는 그 중 필요한 것만 select. 이게 그래프의 본질이다.

새 데이터가 필요할 때 자문하라

새 데이터 요구사항
│
▼
어느 도메인 노드의 무엇인가?
│
┌───┴───┐
속성? 완전히 새 도메인?
│ │
▼ ▼
ResolveField 새 Output + 새 Query
추가 (드물어야 함)
│
▼
끝.

90%는 ResolveField 추가로 끝난다.

"commentCount는 새 Query로 만들어야 하나?"

이 충동이 REST 사고방식의 마지막 잔재다.

# ❌ REST 사고방식 — 새 entry point 추가
type Query {
postsByAuthor(input: ...): [PostOutput!]!
commentCount(postId: ID!): Int! # ← 새 query
postAuthor(postId: ID!): UserOutput! # ← 또 새 query
postCommentList(postId: ID!): [Comment!]! # ← 또 새 query
}
# ✅ GraphQL 사고방식 — 기존 노드에 모서리 추가
type Query {
postsByAuthor(input: ...): [PostOutput!]! # 진입점은 그대로
}
type PostOutput {
postId: ID!
title: String!
commentCount: Int! # ← ResolveField로 추가
author: UserOutput! # ← ResolveField
comments: [CommentOutput!]! # ← ResolveField
}

왜 ResolveField가 답인가? — 4가지 이유

1. 그래프의 의미를 보존한다

commentCount는 Post라는 노드의 속성이다. 노드와 분리된 자유로운 함수가 아니다.

REST에서는 모든 게 함수처럼 보이지만, GraphQL에서는 모든 게 노드의 속성/모서리다. "이 post의 comment 개수"는 그 post에 묻어있는 정보지, 독립적으로 호출하는 함수가 아니다.

2. 한 번의 라운드트립으로 함께 가져올 수 있다

# ResolveField일 때 — 한 번의 query
query {
postsByAuthor(input: ...) {
postId
title
commentCount # 같이 가져옴
}
}
# 별도 Query일 때 — N+1 라운드트립
query Step1 { postsByAuthor { postId } }
# 그 다음 클라이언트가 ID마다 따로:
query Step2($id: ID!) { commentCount(postId: $id) }
# 100개 post면 101번의 네트워크 호출

3. 클라이언트가 원할 때만 호출된다

# 카운트 필요 없는 화면
query { postsByAuthor { postId title } }
# → SQL: posts WHERE ... 만 실행. count 쿼리 0회.
# 카운트 필요한 화면
query { postsByAuthor { postId title commentCount } }
# → SQL: posts + COUNT batch. 정확히 필요한 만큼.

4. 진입점이 무한 증식하지 않는다

Query 타입을 뭐라고 부르는지 다시 생각해보자:

Query 필드 = 그래프의 "진입점(entry point)" = 외부에서 그래프 안으로 들어가는 문

Query 타입에 필드가 100개로 불어나기 시작하면 그래프가 망가지는 신호다.


7. @Args는 함수에만 붙는다

작은 통찰이지만 그래프 사고방식을 완성한다.

@ObjectType()
class PostOutput {
@Field()
title: string; // ← DB 컬럼. 함수 없음. 인자 받을 수가 없음.
@Field()
status: PostStatus; // ← 같음. 그냥 데이터.
}
// vs
@Resolver(() => PostOutput)
class PostResolver {
@ResolveField(() => [CommentOutput])
comments(
@Parent() post,
@Args('limit', { nullable: true }) limit?: number, // ← 함수가 있으니 인자 받음
) { ... }
}

왜 그런가:

  • 정적 @Field는 데이터 자체다. Entity의 컬럼에서 그대로 읽힌다. 받을 함수가 없으니 인자 줄 곳이 없다.
  • @ResolveField는 함수다. 호출될 때 인자를 받아서 결과를 계산한다.

→ 인자는 계산이 있을 때만 의미가 있다. 이미 결정된 값(컬럼)에는 인자를 줄 수 없다.

강력한 결과: 한 부모, 다른 인자, 한 라운드트립

type User {
userId: ID!
name: String!
posts(status: PostStatus, orderBy: PostOrder): [Post!]! # ResolveField에 자기 인자
followers(limit: Int): [User!]! # 자기만의 인자
}
query {
user(input: { userId: "U1" }) { # ← Query 진입점에 인자 1번
name # ← 정적 필드 (인자 없음)
publishedPosts: posts(status: PUBLISHED) { # ← 모서리에 인자 + alias
postId
title
}
drafts: posts(status: DRAFT) { # ← 같은 모서리 다른 인자
postId
title
}
followers(limit: 10) { # ← 또 다른 모서리에 또 다른 인자
userId
name
}
}
}

REST에서는 이게 3번의 라운드트립이었다:

  • GET /users/U1/posts?status=published
  • GET /users/U1/posts?status=draft
  • GET /users/U1/followers?limit=10

GraphQL은 한 번의 query에 모서리마다 다른 인자를 준다.

Alias로 같은 모서리를 두 번

심지어 같은 모서리에 다른 인자를 동시에 줄 수도 있다 (위 예시에서 publishedPosts, drafts).

응답:

{
"user": {
"publishedPosts": [...],
"drafts": [...],
"followers": [...]
}
}

→ 같은 query 안에서 같은 ResolveField를 여러 번, 각기 다른 인자로 부를 수 있다. REST에서는 N번 라운드트립이 필요한 일이 GraphQL에선 1번에 처리된다.

인자는 자기 필드에만 적용된다

부모의 인자가 자식에게 전파되지 않는다.

query {
user(input: { userId: "U1" }) { # ← 이 인자는 user에만
name # ← 인자 못 받음 (정적 필드)
posts(status: PUBLISHED) { # ← 이 인자는 posts에만
postId # ← 인자 못 받음 (정적 필드)
title # ← 인자 못 받음 (정적 필드)
comments(limit: 10) { # ← 이 인자는 comments에만
content # ← 인자 못 받음
}
}
}
}

→ 각 모서리는 자기 호출에만 책임진다. 이게 ResolveField의 격리 원칙이 인자에도 똑같이 적용되는 모습이다.


8. Query는 진입점만 (~10개)

Query 필드는 schema.gql에서 자기도 모르게 폭발한다. REST 시대처럼 새 화면마다 새 Query를 추가하면 100개를 금방 넘는다.

Query는 그래프의 입구다. 입구에서 시작해 모서리를 따라 그래프 안의 모든 곳에 도달할 수 있다. 그래서 입구는 적어야 한다.

좋은 진입점:

  • me — "지금 로그인한 사용자"라는 그래프의 진입점
  • post(id) — "이 ID의 post"라는 진입점
  • postsByAuthor(input) — "이 작성자의 글들"이라는 진입점
  • search(query) — 다중 타입 검색의 진입점

나쁜 진입점 (REST sprawl):

  • commentCount(postId) ❌ — 이미 post로 들어와서 도달 가능
  • postAuthor(postId) ❌ — 같음
  • userById(id) + userByEmail(email) + userByPhone(phone) ❌ — 차라리 user(input: UserInput)으로 통합

같은 노드, 여러 lookup → 한 Query에 통합

// ❌ REST식 — 진입점이 Input마다 늘어남
type Query {
userById(id: ID!): User
userByEmail(email: String!): User
userByPhone(phone: String!): User
userByGoogleId(googleId: String!): User
}
// ✅ GraphQL식 — 한 진입점에 유연한 Input
type Query {
user(input: UserInput!): User # 진입점 1개
}
input UserInput {
userId: ID
email: String
phone: String
googleId: String
}
# 4개 중 하나만 채우면 됨 (서비스에서 분기)

→ 진입점의 단위는 "어느 도메인 노드로 들어가느냐"이지 "어떻게 찾느냐"가 아니다. User로 들어가는 길이 4개여도 진입점은 1개다.

다른 의도면 별도 진입점

다만 다른 의도(scope)라면 다른 Query를 만든다:

  • me (현재 사용자) vs user(input) (임의 사용자) — 의도가 다름. 둘 다 OK
  • myPosts(filter) vs posts(filter) (admin이 모든 post 조회) — 권한 의도가 다름

규칙: 노드는 같지만 의도가 다르면 별도 진입점.

진입점 자격 기준표

상황Query? ResolveField?
도메인의 새 진입점✅ Query (post(id), me, postsByAuthor)
다중 타입 검색✅ Query (search(query): [Searchable!]!)
함수형 (부모 노드 없음)✅ Query (uploadUrl, presignedUrl)
기존 노드의 파생 값❌ ResolveField (Post.commentCount)
기존 노드의 관계❌ ResolveField (Post.author)
기존 노드의 권한 체크❌ ResolveField (Post.canEdit)
기존 노드의 통계 묶음❌ ResolveField (Post.stats: PostStats)

내 경험상 잘 짜인 GraphQL 서버의 Query 필드는 20~30개 이내다. 도메인 진입점 수만큼.


9. Mutation은 도메인 이벤트, CRUD가 아니다

Query가 적어야 하는 만큼, Mutation은 도메인 이벤트 수만큼 자연스럽게 늘어난다. 이 비대칭이 GraphQL의 본성이다.

QueryMutation
본질그래프의 위치로 이동그래프에 변화를 일으킴
재사용 메커니즘Field selection + ResolveField args없음 (각자가 고유 의도)
합성 가능?✅ select로 자유 조합❌ 의도는 합칠 수 없음
부작용없음 (read-only)있음 (state change, notification, billing 등)
자연스러운 수도메인 입구 수 (~10)도메인 이벤트 수 (~수십)

CRUD가 아니라 도메인 동사로

REST는 HTTP 메서드(POST/PUT/PATCH/DELETE)에 갇혀 createX, updateX, deleteX로 흘렀다.

GraphQL Mutation은 자유롭다 — 도메인 언어를 그대로 동사로 쓸 수 있다.

도메인 사건REST식 (CRUD)GraphQL식 (도메인 동사)
회원가입createUsersignUp
비밀번호 변경updateUser({password})changePassword
계정 비활성화deleteUserdeactivateAccount
주문 결제createOrderplaceOrder
게시글 발행updatePost({status:'published'})publishPost
게시글 보관updatePost({status:'archived'})archivePost

updatePost({status: 'published'})는 "필드를 바꿔라"이지만 publishPost는 "이 글을 발행 절차에 태워라"이다. 검증, 권한, 알림이 자연스럽게 따라온다.

Mutation 사고방식의 4가지 원칙

원칙 1: CRUD보다 도메인 동사

// ❌
@Mutation()
updateUser(input: UpdateUserInput) { ... } // input에 password, email 등 다 들어있음
// ✅ — 의도별로 분리
@Mutation() changeEmail(...)
@Mutation() changePassword(...)
@Mutation() updateProfile(...) // name, avatar 등 비민감 필드만

이유: 각 동작이 다른 검증, 다른 권한, 다른 사이드이펙트를 가진다. CRUD 하나에 다 묶으면 코드가 if문 천국이 된다.

원칙 2: 결과로 영향받은 노드 반환

// ❌
@Mutation(() => DeleteCommentOutput) // { success, message }
async deleteComment(...) { ... }
// ✅
@Mutation(() => Post) // 영향받은 부모 반환
async deleteComment(...) {
await this.commentService.deleteComment(...);
return this.postService.findById(commentPostId); // 갱신된 post
}

올바른 패턴으로 바꾸면:

mutation {
deleteComment(input: { commentId: "C1" }) {
postId
comments { # ← 갱신된 comment 리스트가 한 번에 옴
commentId
content
}
}
}

Mutation 결과로도 그래프를 펼칠 수 있다. 이게 GraphQL의 강점이다.

success: true는 GraphQL에서 정보량 0이다. 실패하면 어차피 errors 배열에 담겨 따로 반환되니까. 메시지는 i18n도 안 되고, 클라이언트가 결정할 일이다.

원칙 3: 한 Mutation = 한 의도 단위 (트랜잭션 경계)

// ❌ 호출 측에서 두 개를 묶는다
mutation {
createUser(...) { ... }
createOrganization(...) { ... }
}
// → 첫 번째가 성공하고 두 번째가 실패하면? 부분 성공의 지옥
// ✅ 백엔드에서 한 트랜잭션으로 묶어 새 mutation 제공
mutation {
signUpWithOrganization(input: ...) { user { ... } organization { ... } }
}

→ Mutation은 원자성의 단위다. 부분 실패 가능성이 있다면 새 도메인 동사로 묶어야 한다.

원칙 4: 가능하면 멱등(idempotent)하게

// ❌ 중복 호출 시 2번 결제됨
@Mutation()
async chargePayment(input: { amount: Money }) { ... }
// ✅ 클라이언트 idempotency key
@Mutation()
async chargePayment(input: {
amount: Money,
idempotencyKey: ID! // 같은 키로 재호출 시 같은 결과 반환
}) { ... }

중요한 곳: 결제, 외부 API 호출, 이메일/SMS 발송, 상태 변경. 네트워크 재시도가 안전해야 하는 모든 곳.

그러나 — Mutation도 무한정 늘어나면 안 된다

안티패턴 1: 한 의도를 여러 Mutation으로 쪼갬

// ❌
@Mutation() validatePost(...) { ... } // 1단계
@Mutation() changeStatus(...) { ... } // 2단계
@Mutation() sendNotification(...) { ... } // 3단계
// → 클라이언트가 3번 라운드트립. 부분 실패 위험.
// ✅
@Mutation() publishPost(...) {
// 검증 + 상태변경 + 알림을 한 트랜잭션으로
}

안티패턴 2: 여러 의도를 한 Mutation으로 묶음 (REST식 generic update)

// ❌ — REST PATCH 방식
@Mutation()
updateUser(input: UpdateUserInput) {
// input에 email, password, profile, settings, role 다 받음
// 안에서 if/else로 분기
}
// ✅ — 의도별로 분리
@Mutation() changeEmail(...)
@Mutation() changePassword(...)
@Mutation() updateProfile(...)
@Mutation() updateSettings(...)
@Mutation() changeRole(...) // 권한 다름

→ 즉 Mutation 1개 = 도메인 이벤트 1개라는 1:1 매핑을 지킨다. 적정 수는 도메인 이벤트 수에 의해 자동으로 결정된다.


10. DataLoader — 격리된 모서리들이 자동으로 batch로 묶인다

ResolveField로 모서리를 격리하면 N+1 문제가 자연스럽게 발생한다. DataLoader가 이걸 해결한다.

// modules/post/loaders/post-comments.loader.ts
@Injectable()
export class PostCommentsLoader {
create(): DataLoader<number, CommentEntity[]> {
return new DataLoader<number, CommentEntity[]>(async (postIds: readonly number[]) => {
const comments = await this.commentRepository.find({
where: { postId: In(postIds) },
order: { createdAt: 'ASC' },
});
const byPost = new Map<number, CommentEntity[]>();
for (const comment of comments) {
const list = byPost.get(comment.postId) ?? [];
list.push(comment);
byPost.set(comment.postId, list);
}
return postIds.map((postId) => byPost.get(postId) ?? []);
// ^^^^^
// 빈 배열은 여기서 만들어진다
});
}
}

흐름

[1] 클라이언트
query { postsByAuthor(...) { postId comments { content } } }
│
▼
[2] PostResolver.postsByAuthor()
→ PostEntity[] 10개 반환 (예: id 1~10)
│
▼
[3] NestJS가 각 Post에 대해 PostResolver.comments()를 10번 호출
comments(post: Post#1) → loaders.postComments.load(1)
comments(post: Post#2) → loaders.postComments.load(2)
...
comments(post: Post#10) → loaders.postComments.load(10)
│
▼ DataLoader가 같은 tick의 .load() 호출들을 배치로 묶음
[4] DataLoader batch 함수가 1번 호출됨
keys = [1, 2, 3, ..., 10]
│
▼
[5] SQL: SELECT * FROM comments WHERE post_id IN (1,2,...,10)
│
▼
[6] Map에 그룹화: byPost = { 1: [c1,c2,c3], 3: [c4] }
│
▼
[7] return postIds.map((id) => byPost.get(id) ?? [])
→ [
[c1,c2,c3], // post 1
[], // post 2 ← 빈 배열! (Map에 없으니 ??가 발동)
[c4], // post 3
[], // post 4 ← 빈 배열
...
]

?? []의 의미 — 빈 배열은 어디서 만들어지나

return postIds.map((postId) => byPost.get(postId) ?? []);
// ^^^^^
// ← 여기. 댓글 없는 post는 빈 배열

→ DataLoader의 batch 함수 마지막, Map에서 찾았는데 없으면 ?? []로 빈 배열을 만든다. 이게 "그 post는 댓글이 0개"를 표현하는 방식이다.

To-many vs To-one 컨벤션

To-many 관계 (1:N)는 반드시 배열을 반환한다, 비어있어도.

스키마에서 comments: [CommentOutput!]! (외부 non-null) 선언했다면:

  • 절대 null을 돌려주면 안 됨 → GraphQL 런타임이 에러
  • 0개일 때는 [] 반환

이유:

  1. 클라이언트가 단순해진다. post.comments.map(...)을 항상 안전하게 쓸 수 있다.
  2. "존재하지 않음"과 "비어있음"이 같다. 도메인 의미상 "이 post는 댓글이 0개"는 자연스러운 상태다.
  3. GraphQL 직렬화 안전성. non-null 위반은 즉시 에러로 이어진다.

To-one 관계 (1:1, 1:0..1)는 반대로 null을 쓴다.

cover: CoverOutput (외부 nullable)는:

  • 있으면 객체, 없으면 null
  • []라는 개념 자체가 없음 (배열이 아니니까)
// To-one loader
return ids.map((id) => byId.get(id) ?? null);
// ^^^^^^^
// ← 여기는 null (1:1 관계니까)

효율 비교

postsByAuthor 10개 가져오면서 cover, comments, author, tags까지 select하면:

단계SQL횟수
1. postsByAuthor (root query)SELECT * FROM posts WHERE ...1회
2. cover (DataLoader batch)SELECT * FROM covers WHERE post_id IN (...)1회
3. comments (DataLoader batch)SELECT * FROM comments WHERE post_id IN (...)1회
4. tags (DataLoader batch)SELECT * FROM post_tags JOIN tags WHERE post_id IN (...)1회
5. author of post (DataLoader batch)SELECT * FROM users WHERE id IN (...)1회
6. comments의 author (DataLoader batch)SELECT * FROM users WHERE id IN (...)1회

→ 총 5~6회의 SQL. 만약 DataLoader 없이 N+1로 돌면 1 + 10 + 10 + 10 + 10 + (10×N) = 50회 이상이 됐을 거다.

→ 이게 ResolveField + DataLoader 조합의 마법. 그래프의 격리된 모서리들이 뒤에서 자동으로 batch로 묶인다.


11. 클라이언트 query 문법 — 왜 이름이 두 번?

query PostsByAuthor($input: PostsByAuthorInput!) {
postsByAuthor(input: $input) {
postId
...
}
}

처음 보면 헷갈린다 — "왜 PostsByAuthor가 두 번 쓰여있지?"

둘은 완전히 다른 것이다.

위치이름정체누가 결정
query **PostsByAuthor**($input: ...)Operation Name클라이언트 측 라벨프론트 개발자가 자유롭게
**postsByAuthor**(input: $input)Field Nameschema의 Query.postsByAuthor 필드 호출백엔드 schema에서 고정

Operation Name (대문자, 첫 번째)

query PostsByAuthor($input: ...) { ... }
# ^^^^^^^^^^^^^
# 이건 그냥 라벨. 자유롭게 바꿔도 됨.

용도:

  1. 디버깅 — 서버 로그에 어떤 query가 실행됐는지 표시
  2. Apollo Client cache key의 일부 — 같은 operation 이름으로 호출하면 캐시 매칭
  3. Codegen — graphql-codegen이 이 이름으로 TS 타입과 hook을 생성 (usePostsByAuthorQuery)
  4. 하나의 .graphql 파일에 여러 query 구분 — 같은 document에 여러 operation을 넣을 때 식별자

이름을 바꿔도 동작은 똑같다:

query MyAwesomeQuery($input: ...) { # ← 이름만 바꿔도 OK
postsByAuthor(input: $input) { ... } # ← 이건 schema의 실제 필드라 못 바꿈
}

Field Name (소문자, 두 번째)

{ postsByAuthor(input: $input) { ... } }
# ^^^^^^^^^^^^^
# schema.gql의 type Query 안에 정의된 진짜 필드. 백엔드의 @Query() 메서드명과 매칭.

→ 이건 절대 못 바꾼다. schema에서 정의한 그대로 써야 한다.

비유

query PostsByAuthor($input: PostsByAuthorInput!) {
# "이번 요청의 별명" "이번 요청의 입력 변수"
postsByAuthor(input: $input) {
# "서버의 어떤 함수를 부르는가" "그 함수의 인자"
...
}
}

= 편지 봉투에 "이번 편지: 회사 분기 보고서"라고 라벨을 붙이고, 안의 내용은 "수신: 영업팀, 본문: ..."이라고 쓰는 것과 같다. 라벨은 자유, 수신처와 내용은 고정.


12. Schema는 클라이언트와의 계약이다

schema.gql은 단순한 자동 생성 파일이 아니다. 그래프의 지도다. Code-first에서는 클래스에 @ObjectType, @Field, @ResolveField를 붙이면 자동으로 schema가 조립된다.

Schema가 정신없어 보이면, 그건 실제로 도메인 모델이 정리 안 된 거다. 클라이언트는 이 schema만 보고 빌드한다. 일관성 없는 schema는 "이 백엔드를 만든 사람들이 합의를 안 했다"는 신호로 읽힌다.

REST에서는 엔드포인트마다 따로 봐서 일관성을 놓치기 쉬웠다. GraphQL은 schema 한 파일에 모든 게 모인다 — 그래서 일관성 부족이 즉시 가시화된다.

이게 GraphQL의 부담이자 강점이다.

정돈된 schema는 이런 모양이다

type PostOutput {
postId: ID!
title: String!
content: String
status: PostStatus!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
# 관계 — ResolveField, 모두 XxxOutput으로 통일
author: UserOutput!
cover: CoverOutput
tags: [TagOutput!]!
comments: [CommentOutput!]!
}

→ 이게 클라이언트에게 보여줄 일관된 명함이다.

도메인 vs 노출의 분리 — Entity를 schema에 직접 노출하지 마라

흔한 안티패턴 중 하나:

@ObjectType('Tag') // ← Entity를 GraphQL 타입으로 직접 등록
@Entity('tags')
export class TagEntity {
// ...
}

이렇게 하면 다음 일이 벌어진다:

  1. Entity의 모든 @Field 필드가 schema에 노출된다. id(internal PK), deletedAt(soft-delete 구현 디테일), searchVector(전문검색 컬럼) 등 클라이언트가 알면 안 되는 것까지 새어나간다.

  2. schema가 DB 모델에 결합된다. 컬럼 이름을 바꾸면 schema가 바뀐다 → 클라이언트가 깨진다. 내부 구조 리팩토링이 외부 계약 변경이 된다.

  3. @HideField()로 일일이 숨겨야 한다. 화이트리스트가 아닌 블랙리스트 방식. 새 컬럼 추가할 때마다 "이거 노출해도 되나?" 판단해야 한다. 실수로 secret 컬럼을 노출하기 쉽다.

→ 그래서 OmitType으로 Output을 만든다. Output은 "외부 계약"의 화이트리스트다.

@ObjectType()
export class TagOutput extends OmitType(
TagEntity,
['id', 'deletedAt', 'searchVector'] as const, // 숨길 필드만 명시
ObjectType
) {}

이러면 schema에는 TagOutput만 남고 Tag(Entity 직노출)는 사라진다.


13. 백엔드 개발자의 일이 바뀐다

REST 시대:

  • 새 화면 → 새 엔드포인트 → 새 컨트롤러 → 새 응답 DTO
  • 모든 게 평면적으로 증식한다

GraphQL 시대:

  • 새 화면 → 기존 Output에 ResolveField 추가 (필요시) → 클라이언트가 다르게 select
  • 타입은 도메인 수만큼만 존재한다. 그 안에서 모서리(필드)가 늘어난다

즉, 백엔드 개발자의 일이:

  • ❌ "이 화면에 어떤 응답을 만들까?" (REST)
  • ✅ "내 도메인은 어떻게 생겼는가? 어떤 모서리가 있는가?" (GraphQL)

→ 도메인 모델링과 백엔드 작업이 한 번에 일치한다. 이게 GraphQL이 진짜 강력한 이유다.

비유로 — "도시"

Query는 도시의 입구(門):

  • 입구는 적게, 잘 디자인되어야 한다
  • 한 입구에서 도시 안의 모든 곳에 도달 가능해야 한다 (도로 = ResolveField)
  • 입구가 너무 많으면 도시 지도가 산만해진다

Mutation은 도시에서 일어나는 사건(event):

  • 결혼식, 화재, 회의, 거래, 출산, 사망 — 각자 고유한 사건
  • 사건들을 합치거나 분리할 수 없다
  • 각각이 고유한 의미와 결과를 가진다
  • 사건이 많아도 도시가 산만해지지 않는다 (각자 명확하니까)

→ 입구는 적게, 사건은 풍부하게. 이게 GraphQL 설계의 본질이다.


14. 정리 — 그래프 사고방식의 6원칙

  1. 도메인 노드 = GraphQL 타입 = Output (1:1:1) — 한 도메인은 한 곳에만 산다
  2. 모든 파생/관계/계산은 ResolveField로 한 노드에 모인다 — 새 데이터 = 새 모서리
  3. @Args는 함수에만 붙는다 — 한 부모 아래 다른 인자로 다른 자식, 한 라운드트립
  4. Query는 도메인 입구만 둔다 (~10개) — 진입점은 적게
  5. Mutation은 도메인 이벤트 1:1, CRUD 동사 대신 비즈니스 동사로 — 사건은 풍부하게
  6. Mutation 결과로 영향받은 노드를 반환해 클라이언트가 즉시 그래프를 갱신한다 — 그래프는 양방향

새 데이터 요구가 오면 — 결정 트리

새 데이터 요구사항
│
▼
어느 도메인 노드의 무엇인가?
│
┌───┴───┐
속성? 완전히 새 도메인?
│ │
▼ ▼
ResolveField 새 Output + 새 Query
추가 (드물어야 함)
│
▼
끝.

새 타입을 만들고 싶을 때 — 결정 트리

새 타입을 만들고 싶다
│
▼
정말 새 도메인 개념인가?
│
┌───┴───┐
YES NO (같은 도메인의 다른 표현)
│ │
▼ ▼
새 Output 원래 Output에 ResolveField 추가
+ 모서리 + 클라이언트가 select로 모양 결정
선언 (여기서 90% 해결)

새 Query를 만들고 싶을 때 — 결정 트리

새 Query를 만들고 싶다
│
▼
이게 진입점인가, 기존 노드의 속성인가?
│
┌───┴───┐
진입점 기존 노드 속성
│ │
▼ ▼
새 Query ResolveField로 추가
추가 (Query에 추가하지 않음)
│
▼
같은 도메인의 여러 lookup이면
하나의 Query + 유연한 Input으로 통합

15. 마지막으로 — 의심하는 능력

이 글의 시작이 그랬듯, 이 깨달음도 단순한 위화감에서 시작했다.

schema.gql을 열어봤을 때 "이거 정신없는데?"

같은 도메인인데 응답이 5개로 갈라져 있었다. success: true 같은 의미 없는 필드가 박혀있었다. commentsByPost 같은 Query는 사실 Post.comments ResolveField로 이미 도달 가능했다.

이 위화감을 무시하지 않고 끝까지 파고든 게 시작이었다.

당신의 schema가 정신없어 보인다면 — 그건 실제로 도메인 모델이 정리 안 된 거다. REST에서는 엔드포인트마다 따로 봐서 일관성을 놓치기 쉬웠다. GraphQL은 한 schema 파일에 모든 게 모인다. 일관성 부족이 즉시 가시화된다.

이게 GraphQL의 부담이자 강점이다.

다음에 새 데이터 요구가 들어왔을 때, 자동으로 새 Query를 만들고 싶은 충동이 들 거다. 새 Output을 만들고 싶은 충동도. 그 충동을 잡고 자문해라:

"이게 어떤 노드의 모서리인가?"

답이 보일 거다. 그게 ResolveField인지, 진짜로 새 도메인인지.

도메인을 그래프로 모델링한다는 것 — 그게 GraphQL의 본질이다.


부록: 흔히 보이는 5개의 REST 잔재 패턴

이런 패턴이 schema에 보이면 한 번 멈춰서 다시 생각해볼 만하다.

(1) 자식 컬렉션 전용 Query — Quick win

type Query {
commentsByPost(input: CommentsByPostInput!): [CommentOutput!]!
}
type PostOutput {
comments: [CommentOutput!]! # ← 같은 데이터에 도달하는 다른 길
}

둘 다 같은 데이터를 가져온다. Post를 이미 가지고 있는데 comment만 따로 또 다른 쿼리를 쏘는 건 라운드트립 낭비. 후자로 통합.

(2) 화면 단위 타입 분리 — 가장 큰 구조적 영향

  • PostWithCommentsOutput → Post.comments ResolveField로 통합
  • ArticleWithTagsOutput → Article.tags ResolveField로 통합
  • 진입점도 단일화

(3) Delete의 success/message 패턴

@ObjectType()
export class DeleteCommentOutput {
@Field() success!: boolean; // 항상 true
@Field() message!: string; // 안내 문구
}

GraphQL은 errors 배열로 에러 처리하므로 success는 항상 true (정보량 0). 메시지는 클라이언트가 결정할 일. 영향받은 부모 노드 반환으로 변경.

(4) Mutation count-only 응답

@Mutation(() => PublishDraftsOutput) // { publishedCount }
async publishMyDrafts(...) { ... }

영향받은 entity array를 안 돌려줌. 비슷한 다른 mutation은 [PostOutput!]!로 잘 반환하는데 이것만 count만 주는 식의 일관성 깨짐. array 반환으로 통일.

(5) Entity 직노출 + Output 이중 등록 Entity에 @ObjectType('Tag') 붙이고 schema에 Tag로 직노출 + TagOutput도 따로 존재. 클라이언트는 둘 중 어느 걸 써야 하는지 헷갈림. OmitType으로 Output 하나로 통일.