이 글의 코드 예시는 모두 가상의 블로그 도메인(Post, Comment, Author, Tag, Cover)으로 짠 것이다. 패턴 설명을 위한 예시일 뿐이니 그대로 복붙해서 쓰지 말 것.
발단 — 위화감
NestJS + GraphQL로 1년 넘게 개발했다. 그러던 어느 날 schema.gql을 열어봤다. 일반화하면 이런 모양이었다.
1type Post { ... }2type PostWithCommentsOutput { ... } # 같은 도메인인데 다른 타입?3type Article { ... }4type ArticleWithTagsOutput { ... } # 또?5
6type Query {7 commentsByPost(...): [CommentOutput!]! # Post.comments로 이미 도달 가능한데?8}9
10type DeleteCommentOutput {11 success: Boolean! # 항상 true12 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)다.
요구사항이 와도 컨트롤러를 새로 만들지 않는다. 대신:
- "내 도메인엔 어떤 타입이 있는가?" (User, Post, Comment…)
- "각 타입엔 어떤 필드가 있는가?" (User.posts, Post.author, Post.comments…)
- 각 필드를 독립적으로 어떻게 가져올지만 정의 (= Resolver)
- 클라이언트가 그래프 위를 자 유롭게 걸어다님
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"라는 한 개념이 세 군데에 동시에 존재한다.
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에 값을 채운다" 같은 별도 단계는 없다.
흐름을 따라가보자:
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의 천재성
1@ObjectType()2export class PostOutput extends OmitType(PostEntity, ['id'], ObjectType) {}이 한 줄이 동시에 세 가지를 한다:
- TS 관점:
PostOutput은PostEntity의 부분집합 클래스 → entity 인스턴스를 반환해도 TS가 OK 한다. - 런타임 관점: 아무것도 변환하지 않는다. Entity 인스턴스가 그대로 흐른다.
- GraphQL 관점: schema에서
PostOutput이라는 별도 타입이 등록됐고, 클라이언트는 이 타입을 본다.
→ 세 레이어가 모두 만족하는 한 줄 마법.
이 패턴 덕분에 Service는 GraphQL을 모르고, Resolver는 Output만 선언하면 된다. 컨벤션이 코드 레벨로 강제된다.
3. @Resolver(() => X)의 진짜 의미
이 데코레이터는 두 개의 완전히 다른 역할을 한 클래스 안에서 동시에 한다. 이게 헷갈림의 근원이다.
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 타입을 확장한다"는 뜻이다.
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는 "타입에 모서리를 추가하는 행위"이고, 각 도메인이 자기 쪽에서 추가한다.
1// modules/post/post.resolver.ts2@Resolver(() => PostOutput)3export class PostResolver {4 @ResolveField(() => CoverOutput)5 cover(@Parent() post) { ... } // Post → Cover 모서리6}7
8// modules/cover/cover.resolver.ts9@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 모듈에 산다.
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.ts19├── post.output.ts ├── cover.output.ts ◄── 여기!20├── post.resolver.ts ├── cover.resolver.ts21│ @Resolver(() => PostOutput) │ @Resolver(() => CoverOutput)22│ @ResolveField cover() │ @ResolveField uploadedBy()23│ @ResolveField comments() │ @ResolveField post()24└── post.service.ts └── cover.service.ts25 ↓ ↓26 CoverOutput을 import해서 PostOutput을 import해서27 ResolveField의 반환 타입으로 선언 ResolveField의 반환 타입으로 선언원칙:
- 타입은 도메인이 소유한다 (Cover는 Cover 모듈에)
- 다른 도메인은 import해서 참조한다 (Post가 CoverOutput을 import)
- 모서리는 양쪽 도메인이 각자 자기 쪽에서 추가한다
- schema.gql은 모두의 기여로 자동 조립된다 (Code-first의 마법)
5. 한 도메인 = 한 Output (1:1:1)
내가 가장 늦게 깨달은 부분이다.
REST에서는 응답 DTO가 endpoint마다 늘어난다. PostListResponse, PostDetailResponse, PostEditResponse... 같은 Post인데 응답이 5개로 갈라진다.
GraphQL에서는 도메인 노드 하나당 Output 하나다.
1도메인 노드 ↔ GraphQL 타입 ↔ Output 클래스2───────────────────────────────────────────────────────3User ↔ type User ↔ UserOutput4Post ↔ type Post ↔ PostOutput5Comment ↔ type Comment ↔ CommentOutput6Tag ↔ type Tag ↔ TagOutput7Cover ↔ type Cover ↔ CoverOutput1: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 잔재가 이거다.
1type Post {2 postId, title, status3}4
5type PostWithCommentsOutput { # ← 화면 전용 타입6 postId, title, status7 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식으로 바꾸면:
1type Post {2 postId, title, status3 comments: [Comment!]! # ResolveField4 myReaction: Reaction # ResolveField5}6
7type Query {8 post(input: PostInput!): Post! # 한 타입9 myPosts(input: MyPostsInput!): [Post!]! # 같은 타입10}이러면 클라이언트가:
- 목록 화면:
myPosts { postId title }— comments 안 부르니 SQL 안 나감 - 상세 화면:
post { postId title comments { ... } myReaction }— 필요한 만큼만
→ 화면 단위로 타입을 가르면 그래프가 산산조각난다.