raw
backend

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

2026.05.05·52분

이 글의 코드 예시는 모두 가상의 블로그 도메인(Post, Comment, Author, Tag, Cover)으로 짠 것이다. 패턴 설명을 위한 예시일 뿐이니 그대로 복붙해서 쓰지 말 것.

발단 — 위화감

NestJS + GraphQL로 1년 넘게 개발했다. 그러던 어느 날 schema.gql을 열어봤다. 일반화하면 이런 모양이었다.

graphql
1type Post { ... }
2type PostWithCommentsOutput { ... } # 같은 도메인인데 다른 타입?
3type Article { ... }
4type ArticleWithTagsOutput { ... } # 또?
5
6type Query {
7 commentsByPost(...): [CommentOutput!]! # Post.comments로 이미 도달 가능한데?
8}
9
10type DeleteCommentOutput {
11 success: Boolean! # 항상 true
12 message: String! # 한국어로 박혀있음
13}
14
15type PostOutput {
16 tags: [Tag!] # 어떤 건 Entity 직노출
17 comments: [CommentOutput!]! # 어떤 건 Output 접미사
18 cover: CoverOutput # 일관성 없음
19}

"이거 정신없는데?"

위화감이 들었다. 같은 도메인인데 응답이 여러 갈래로 갈라져 있었다. 타입 네이밍이 들쑥날쑥했다. 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. 클라이언트가 그래프 위를 자유롭게 걸어다님
typescript
1// ❌ REST 사고 — 화면 단위 응답
2@Query(() => DashboardOutput)
3async getDashboard(@Args('userId') userId: string) {
4 const user = await this.userService.findOne(userId);
5 const posts = await this.postService.findByUser(userId);
6 const comments = await this.commentService.findByPosts(posts.map(p => p.id));
7 return { user, posts, comments }; // 화면 전용 응답
8}
9
10// ✅ GraphQL 사고 — 도메인 모델 그대로 노출
11@Resolver(() => User)
12class UserResolver {
13 @ResolveField(() => [Post])
14 posts(@Parent() user: User) {
15 return this.postLoader.load(user.id);
16 }
17}
18
19@Resolver(() => Post)
20class PostResolver {
21 @ResolveField(() => [Comment])
22 comments(@Parent() post: Post) {
23 return this.commentLoader.load(post.id);
24 }
25}

각 resolver는 자기 필드만 책임진다. 클라이언트가 user { posts { comments } }로 깊이 파고들든 user { name }만 가져가든, 같은 코드가 동작한다.


2. 가장 깊은 통찰 — 3개의 레이어가 동시에 존재한다

NestJS code-first GraphQL을 쓸 때 가장 헷갈리는 지점이다. "Post"라는 한 개념이 세 군데에 동시에 존재한다.

text
1┌────────────────────────────────────────────────────────────┐
2│ Layer 1: 런타임 JavaScript 객체 (= PostEntity 인스턴스) │
3│ → DB에서 SELECT한 결과. 모든 컬럼 + 관계가 다 채워질 수 있음 │
4│ → 메모리에 떠다니는 "그 데이터 그 자체" │
5└────────────────────────────────────────────────────────────┘
6 ↓ 변환 없음, 그대로 전달
7┌────────────────────────────────────────────────────────────┐
8│ Layer 2: TypeScript 타입 (= 코드에서 보는 클래스) │
9│ → @Parent() post: PostEntity │
10│ → 개발자가 IDE에서 자동완성, 컴파일 체크 받는 용도 │
11└────────────────────────────────────────────────────────────┘
12 ↓ 클라이언트에 노출할 때만 변환
13┌────────────────────────────────────────────────────────────┐
14│ Layer 3: GraphQL Schema 타입 (= schema.gql의 type) │
15│ → type PostOutput { ... } │
16│ → 클라이언트가 보는 계약. JS 객체 중 일부 필드만 보여줌 │
17└────────────────────────────────────────────────────────────┘

비유로 풀면 명확하다:

  • Layer 1 (Entity 인스턴스) = 당신이라는 사람. 모든 속성을 다 가진 실체.
  • Layer 2 (TS 타입) = 이력서. 코드에서 "이 사람은 이런 속성이 있다"고 적어둔 것.
  • Layer 3 (GraphQL 타입) = 회사 명함. 외부에 보여줄 필드만 골라 인쇄한 것.

당신은 사람 자체(Entity)지, 명함(Output)이 아니다. 그런데 외부에서는 명함만 본다.

데이터는 한 군데에만 존재한다

이게 가장 중요한 통찰이다.

PostEntity 인스턴스 하나가 처음부터 끝까지 메모리에 떠다닌다. 그게 전부다. "Output에 값을 채운다" 같은 별도 단계는 없다.

흐름을 따라가보자:

typescript
1// 1. Service: DB에서 Entity를 끌어온다
2async findByAuthor(authorId: string): Promise<PostEntity[]> {
3 return this.repo.findBy({ authorId });
4 // → PostEntity 인스턴스 배열. 메모리에 실재.
5}
6
7// 2. Resolver: Entity를 그대로 반환. 단지 "선언"만 Output으로
8@Query(() => [PostOutput])
9async postsByAuthor(...): Promise<PostOutput[]> {
10 return this.postService.findByAuthor(...);
11 // → 실제로는 PostEntity[]를 반환.
12 // → TS는 OK (PostOutput extends OmitType(PostEntity))
13}
14
15// 3. NestJS GraphQL: Entity 인스턴스를 받아서 schema의 PostOutput에 따라 직렬화
16// → schema에 선언된 필드만 골라서 응답에 넣는다
17// → ResolveField가 있는 필드는 메서드 호출해서 채운다

핵심: Layer 1의 데이터(Entity 인스턴스)는 변환되지 않는다. Layer 3(GraphQL 응답)는 그 데이터에서 필요한 부분만 추출한다.

OmitType의 천재성

typescript
1@ObjectType()
2export class PostOutput extends OmitType(PostEntity, ['id'], ObjectType) {}

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

  1. TS 관점: PostOutputPostEntity의 부분집합 클래스 → entity 인스턴스를 반환해도 TS가 OK 한다.
  2. 런타임 관점: 아무것도 변환하지 않는다. Entity 인스턴스가 그대로 흐른다.
  3. GraphQL 관점: schema에서 PostOutput이라는 별도 타입이 등록됐고, 클라이언트는 이 타입을 본다.

세 레이어가 모두 만족하는 한 줄 마법.

이 패턴 덕분에 Service는 GraphQL을 모르고, Resolver는 Output만 선언하면 된다. 컨벤션이 코드 레벨로 강제된다.


3. @Resolver(() => X)의 진짜 의미

이 데코레이터는 두 개의 완전히 다른 역할을 한 클래스 안에서 동시에 한다. 이게 헷갈림의 근원이다.

typescript
1@Resolver(() => PostOutput) // ← 이 인자가 어떤 의미를 갖는가?
2export class PostResolver {
3
4 @Query(() => [PostOutput])
5 postsByAuthor(...) { ... } // (A) 그래프 진입점
6
7 @Mutation(() => PostOutput)
8 publishPost(...) { ... } // (A) 그래프 진입점
9
10 @ResolveField(() => CoverOutput)
11 cover(@Parent() post) { ... } // (B) PostOutput 타입의 한 필드
12}

(A) Query/Mutation에게 () => PostOutput 인자는 의미가 없다. 그냥 NestJS가 클래스 한 개 정도는 줘야 한다고 요구하니까 채워넣은 것뿐. Query/Mutation은 schema의 Query/Mutation 타입에 등록되지, PostOutput에 등록되는 게 아니다.

단, 여기서 헷갈리면 안되는 것은 해당 Query(()=>PostOutput)에서 반환하는 값은 PostOutput 타입이니, 반환값에 데이터를 넣어줘서 ResovleField에서 사용할 수 있다.

(B) ResolveField에게는 () => PostOutput이 결정적이다. "이 클래스의 @ResolveField 메서드들은 PostOutput 타입의 필드를 채운다"는 선언이다. 즉:

@Resolver(() => X)는 "이 클래스 안의 ResolveField들은 X 타입을 확장한다"는 뜻이다.

graphql
1# 결과적으로 schema는 이렇게 된다
2type PostOutput {
3 postId: ID!
4 title: String!
5 ... # Entity의 정적 필드
6 cover: CoverOutput # ← PostResolver.cover() 메서드가 채움
7 comments: [Comment!]! # ← PostResolver.comments() 메서드가 채움
8}

한 타입은 여러 Resolver가 확장할 수 있다

이게 가장 중요한 부수 통찰이다. @ResolveField는 "타입에 모서리를 추가하는 행위"이고, 각 도메인이 자기 쪽에서 추가한다.

typescript
1// modules/post/post.resolver.ts
2@Resolver(() => PostOutput)
3export class PostResolver {
4 @ResolveField(() => CoverOutput)
5 cover(@Parent() post) { ... } // Post → Cover 모서리
6}
7
8// modules/cover/cover.resolver.ts
9@Resolver(() => CoverOutput)
10export class CoverResolver {
11 @ResolveField(() => PostOutput, { nullable: true })
12 post(@Parent() cover) { ... } // Cover → Post 역방향 모서리
13
14 @ResolveField(() => UserOutput)
15 uploadedBy(@Parent() cover) { ... }
16}

→ 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 모듈에 산다.

text
1┌─────────────────────────────────────────────────────────┐
2│ schema.gql (그래프의 지도 — 모든 도메인이 모여 그려짐) │
3│ │
4│ type Post { │
5│ postId: ID! │
6│ cover: Cover ◄──┐ PostResolver가 │
7│ comments: [Comment!]! ◄──┤ ResolveField로 추가 │
8│ } │
9│ │
10│ type Cover { │
11│ coverId: ID! │
12│ uploadedBy: User ◄──┐ CoverResolver가 │
13│ post: Post ◄──┤ ResolveField로 추가 │
14│ } │
15└─────────────────────────────────────────────────────────┘
16
17modules/post/ modules/cover/
18├── post.entity.ts ├── cover.entity.ts
19├── post.output.ts ├── cover.output.ts ◄── 여기!
20├── post.resolver.ts ├── cover.resolver.ts
21│ @Resolver(() => PostOutput) │ @Resolver(() => CoverOutput)
22│ @ResolveField cover() │ @ResolveField uploadedBy()
23│ @ResolveField comments() │ @ResolveField post()
24└── post.service.ts └── cover.service.ts
25 ↓ ↓
26 CoverOutput을 import해서 PostOutput을 import해서
27 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 하나다.

text
1도메인 노드 ↔ GraphQL 타입 ↔ Output 클래스
2───────────────────────────────────────────────────────
3User ↔ type User ↔ UserOutput
4Post ↔ type Post ↔ PostOutput
5Comment ↔ type Comment ↔ CommentOutput
6Tag ↔ type Tag ↔ TagOutput
7Cover ↔ 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 잔재가 이거다.

graphql
1type Post {
2 postId, title, status
3}
4
5type PostWithCommentsOutput { # ← 화면 전용 타입
6 postId, title, status
7 comments: [Comment!]!
8}
9
10type Article {
11 articleId, headline, ...
12}
13
14type ArticleWithTagsOutput { # ← 또 화면 전용 타입
15 articleId, headline, ...
16 tags: [Tag!]!
17 author: UserOutput!
18}

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

GraphQL식으로 바꾸면:

graphql
1type Post {
2 postId, title, status
3 comments: [Comment!]! # ResolveField
4 myReaction: Reaction # ResolveField
5}
6
7type Query {
8 post(input: PostInput!): Post! # 한 타입
9 myPosts(input: MyPostsInput!): [Post!]! # 같은 타입
10}

이러면 클라이언트가:

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

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

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

자주 받는 질문이다.

typescript
1@ObjectType()
2export class PostOutput extends OmitType(PostEntity, ['id'], ObjectType) {}
3
4@ObjectType()
5export class PostWithStatsOutput extends PostOutput {
6 @Field(() => Int)
7 commentCount!: number;
8}

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

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

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

typescript
1function createPostFieldsResolver<T>(typeRef: Type<T>) {
2 @Resolver(() => typeRef, { isAbstract: true })
3 abstract class PostFieldsResolverHost {
4 @ResolveField(() => CoverOutput, { nullable: true })
5 cover(@Parent() post, @Loaders() loaders) {
6 return loaders.postCover.load(post.id);
7 }
8 @ResolveField(() => [CommentOutput])
9 comments(@Parent() post, @Loaders() loaders) {
10 return loaders.postComments.load(post.id);
11 }
12 }
13 return PostFieldsResolverHost;
14}
15
16@Resolver(() => PostOutput)
17export class PostResolver extends createPostFieldsResolver(PostOutput) {}
18
19@Resolver(() => PostWithStatsOutput)
20export class PostWithStatsResolver extends createPostFieldsResolver(PostWithStatsOutput) {
21 @ResolveField(() => Int)
22 commentCount(@Parent() post) { ... }
23}

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

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

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

typescript
1// ✅ 더 좋은 답
2@Resolver(() => PostOutput)
3export class PostResolver {
4 // 기존 ResolveField들...
5
6 // 새 필드는 그냥 ResolveField 추가
7 @ResolveField(() => Int)
8 commentCount(@Parent() post) {
9 return loaders.postCommentCount.load(post.id);
10 }
11
12 @ResolveField(() => Boolean)
13 isMine(@Parent() post, @User() user) {
14 return post.authorId === user.userId;
15 }
16}
graphql
1type PostOutput {
2 postId: ID!
3 title: String!
4 commentCount: Int! # ← 새 필드, 한 타입에 그냥 추가
5 isMine: Boolean! # ← 새 필드
6}

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

graphql
1# 가벼운 화면
2query { postsByAuthor { postId title } }
3
4# 통계가 필요한 화면
5query { postsByAuthor { postId title commentCount } }

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

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

9할은 후자다.


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

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

typescript
1@Resolver(() => Post)
2class PostResolver {
3 // 정적 필드는 entity의 컬럼에서 자동
4 // 모든 파생/관계/계산은 ResolveField로 한 타입에 모인다
5
6 @ResolveField(() => CoverOutput, { nullable: true })
7 cover(...) { ... } // ← 1:1 관계
8
9 @ResolveField(() => [CommentOutput])
10 comments(...) { ... } // ← 1:N 관계
11
12 @ResolveField(() => UserOutput)
13 author(...) { ... } // ← N:1 관계
14
15 @ResolveField(() => Int)
16 commentCount(...) { ... } // ← 집계 파생
17
18 @ResolveField(() => Boolean)
19 isMine(...) { ... } // ← 권한 파생
20
21 @ResolveField(() => Boolean)
22 canEdit(...) { ... } // ← 권한 파생
23
24 @ResolveField(() => PostStats)
25 stats(...) { ... } // ← 통계 묶음 (또 다른 노드)
26}

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

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

text
1새 데이터 요구사항
2
3
4어느 도메인 노드의 무엇인가?
5
6 ┌───┴───┐
7 속성? 완전히 새 도메인?
8 │ │
9 ▼ ▼
10ResolveField 새 Output + 새 Query
11추가 (드물어야 함)
12
13
14끝.

90%는 ResolveField 추가로 끝난다.

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

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

graphql
1# ❌ REST 사고방식 — 새 entry point 추가
2type Query {
3 postsByAuthor(input: ...): [PostOutput!]!
4 commentCount(postId: ID!): Int! # ← 새 query
5 postAuthor(postId: ID!): UserOutput! # ← 또 새 query
6 postCommentList(postId: ID!): [Comment!]! # ← 또 새 query
7}
8
9# ✅ GraphQL 사고방식 — 기존 노드에 모서리 추가
10type Query {
11 postsByAuthor(input: ...): [PostOutput!]! # 진입점은 그대로
12}
13
14type PostOutput {
15 postId: ID!
16 title: String!
17 commentCount: Int! # ← ResolveField로 추가
18 author: UserOutput! # ← ResolveField
19 comments: [CommentOutput!]! # ← ResolveField
20}

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

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

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

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

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

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

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

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

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

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

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

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


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

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

typescript
1@ObjectType()
2class PostOutput {
3 @Field()
4 title: string; // ← DB 컬럼. 함수 없음. 인자 받을 수가 없음.
5
6 @Field()
7 status: PostStatus; // ← 같음. 그냥 데이터.
8}
9
10// vs
11
12@Resolver(() => PostOutput)
13class PostResolver {
14 @ResolveField(() => [CommentOutput])
15 comments(
16 @Parent() post,
17 @Args('limit', { nullable: true }) limit?: number, // ← 함수가 있으니 인자 받음
18 ) { ... }
19}

왜 그런가:

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

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

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

graphql
1type User {
2 userId: ID!
3 name: String!
4 posts(status: PostStatus, orderBy: PostOrder): [Post!]! # ResolveField에 자기 인자
5 followers(limit: Int): [User!]! # 자기만의 인자
6}
graphql
1query {
2 user(input: { userId: "U1" }) { # ← Query 진입점에 인자 1번
3 name # ← 정적 필드 (인자 없음)
4
5 publishedPosts: posts(status: PUBLISHED) { # ← 모서리에 인자 + alias
6 postId
7 title
8 }
9
10 drafts: posts(status: DRAFT) { # ← 같은 모서리 다른 인자
11 postId
12 title
13 }
14
15 followers(limit: 10) { # ← 또 다른 모서리에 또 다른 인자
16 userId
17 name
18 }
19 }
20}

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).

응답:

json
1{
2 "user": {
3 "publishedPosts": [...],
4 "drafts": [...],
5 "followers": [...]
6 }
7}

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

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

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

graphql
1query {
2 user(input: { userId: "U1" }) { # ← 이 인자는 user에만
3 name # ← 인자 못 받음 (정적 필드)
4
5 posts(status: PUBLISHED) { # ← 이 인자는 posts에만
6 postId # ← 인자 못 받음 (정적 필드)
7 title # ← 인자 못 받음 (정적 필드)
8
9 comments(limit: 10) { # ← 이 인자는 comments에만
10 content # ← 인자 못 받음
11 }
12 }
13 }
14}

각 모서리는 자기 호출에만 책임진다. 이게 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에 통합

typescript
1// ❌ REST식 — 진입점이 Input마다 늘어남
2type Query {
3 userById(id: ID!): User
4 userByEmail(email: String!): User
5 userByPhone(phone: String!): User
6 userByGoogleId(googleId: String!): User
7}
8
9// ✅ GraphQL식 — 한 진입점에 유연한 Input
10type Query {
11 user(input: UserInput!): User # 진입점 1
12}
13
14input UserInput {
15 userId: ID
16 email: String
17 phone: String
18 googleId: String
19}
20# 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보다 도메인 동사

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

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

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

typescript
1// ❌
2@Mutation(() => DeleteCommentOutput) // { success, message }
3async deleteComment(...) { ... }
4
5// ✅
6@Mutation(() => Post) // 영향받은 부모 반환
7async deleteComment(...) {
8 await this.commentService.deleteComment(...);
9 return this.postService.findById(commentPostId); // 갱신된 post
10}

올바른 패턴으로 바꾸면:

graphql
1mutation {
2 deleteComment(input: { commentId: "C1" }) {
3 postId
4 comments { # ← 갱신된 comment 리스트가 한 번에 옴
5 commentId
6 content
7 }
8 }
9}

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

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

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

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

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

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

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

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

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

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

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

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

typescript
1// ❌ — REST PATCH 방식
2@Mutation()
3updateUser(input: UpdateUserInput) {
4 // input에 email, password, profile, settings, role 다 받음
5 // 안에서 if/else로 분기
6}
7
8// ✅ — 의도별로 분리
9@Mutation() changeEmail(...)
10@Mutation() changePassword(...)
11@Mutation() updateProfile(...)
12@Mutation() updateSettings(...)
13@Mutation() changeRole(...) // 권한 다름

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


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

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

typescript
1// modules/post/loaders/post-comments.loader.ts
2@Injectable()
3export class PostCommentsLoader {
4 create(): DataLoader<number, CommentEntity[]> {
5 return new DataLoader<number, CommentEntity[]>(async (postIds: readonly number[]) => {
6 const comments = await this.commentRepository.find({
7 where: { postId: In(postIds) },
8 order: { createdAt: 'ASC' },
9 });
10 const byPost = new Map<number, CommentEntity[]>();
11 for (const comment of comments) {
12 const list = byPost.get(comment.postId) ?? [];
13 list.push(comment);
14 byPost.set(comment.postId, list);
15 }
16 return postIds.map((postId) => byPost.get(postId) ?? []);
17 // ^^^^^
18 // 빈 배열은 여기서 만들어진다
19 });
20 }
21}

흐름

text
1[1] 클라이언트
2 query { postsByAuthor(...) { postId comments { content } } }
3
4
5[2] PostResolver.postsByAuthor()
6 → PostEntity[] 10개 반환 (예: id 1~10)
7
8
9[3] NestJS가 각 Post에 대해 PostResolver.comments()를 10번 호출
10 comments(post: Post#1) → loaders.postComments.load(1)
11 comments(post: Post#2) → loaders.postComments.load(2)
12 ...
13 comments(post: Post#10) → loaders.postComments.load(10)
14
15 ▼ DataLoader가 같은 tick의 .load() 호출들을 배치로 묶음
16[4] DataLoader batch 함수가 1번 호출됨
17 keys = [1, 2, 3, ..., 10]
18
19
20[5] SQL: SELECT * FROM comments WHERE post_id IN (1,2,...,10)
21
22
23[6] Map에 그룹화: byPost = { 1: [c1,c2,c3], 3: [c4] }
24
25
26[7] return postIds.map((id) => byPost.get(id) ?? [])
27 → [
28 [c1,c2,c3], // post 1
29 [], // post 2 ← 빈 배열! (Map에 없으니 ??가 발동)
30 [c4], // post 3
31 [], // post 4 ← 빈 배열
32 ...
33 ]

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

typescript
1return postIds.map((postId) => byPost.get(postId) ?? []);
2// ^^^^^
3// ← 여기. 댓글 없는 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
  • []라는 개념 자체가 없음 (배열이 아니니까)
typescript
1// To-one loader
2return ids.map((id) => byId.get(id) ?? null);
3// ^^^^^^^
4// ← 여기는 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 문법 — 왜 이름이 두 번?

graphql
1query PostsByAuthor($input: PostsByAuthorInput!) {
2 postsByAuthor(input: $input) {
3 postId
4 ...
5 }
6}

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

둘은 완전히 다른 것이다.

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

Operation Name (대문자, 첫 번째)

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

용도:

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

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

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

Field Name (소문자, 두 번째)

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

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

비유

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

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


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

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

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

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

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

정돈된 schema는 이런 모양이다

graphql
1type PostOutput {
2 postId: ID!
3 title: String!
4 content: String
5 status: PostStatus!
6 publishedAt: DateTime
7 createdAt: DateTime!
8 updatedAt: DateTime!
9
10 # 관계 — ResolveField, 모두 XxxOutput으로 통일
11 author: UserOutput!
12 cover: CoverOutput
13 tags: [TagOutput!]!
14 comments: [CommentOutput!]!
15}

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

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

흔한 안티패턴 중 하나:

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

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

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

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

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

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

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

이러면 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 결과로 영향받은 노드를 반환해 클라이언트가 즉시 그래프를 갱신한다 — 그래프는 양방향

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

text
1새 데이터 요구사항
2
3
4어느 도메인 노드의 무엇인가?
5
6 ┌───┴───┐
7 속성? 완전히 새 도메인?
8 │ │
9 ▼ ▼
10ResolveField 새 Output + 새 Query
11추가 (드물어야 함)
12
13
14끝.

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

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

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

text
1새 Query를 만들고 싶다
2
3
4이게 진입점인가, 기존 노드의 속성인가?
5
6 ┌───┴───┐
7 진입점 기존 노드 속성
8 │ │
9 ▼ ▼
10새 Query ResolveField로 추가
11추가 (Query에 추가하지 않음)
12
13
14같은 도메인의 여러 lookup이면
15하나의 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

graphql
1type Query {
2 commentsByPost(input: CommentsByPostInput!): [CommentOutput!]!
3}
4
5type PostOutput {
6 comments: [CommentOutput!]! # ← 같은 데이터에 도달하는 다른 길
7}

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

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

  • PostWithCommentsOutputPost.comments ResolveField로 통합
  • ArticleWithTagsOutputArticle.tags ResolveField로 통합
  • 진입점도 단일화

(3) Delete의 success/message 패턴

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

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

(4) Mutation count-only 응답

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

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

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