영감을 (inspire) 주고픈 개발 블로그

GraphQL스러운 코드는 무엇일까? 4편: 도메인 표현 N+1 문제 해결하기 본문

개발/유저 서비스 flow

GraphQL스러운 코드는 무엇일까? 4편: 도메인 표현 N+1 문제 해결하기

inspire12 2026. 7. 23. 15:58
반응형

Preview

1편: REST와 GraphQL의 같은 의도와 다른 표현

 

GraphQL 스러운 코드는 무엇일까? 1편: REST와 GraphQL의 같은 의도, 다른 표현

회사에서 GraphQL 기반의 서버-클라이언트 통신을 적극적으로 사용하고 있습니다. GraphQL은 REST와 다른 타입 시스템과 요청 문법을 가진 API 기술입니다. 서버-클라이언트 통신에서 널리 사용되는 RE

inspire12.tistory.com

2편: 다른 표현을 위한 GraphQL에서 요청을 처리하는 방식

 

GraphQL스러운 코드는 무엇일까? 2편: 다른 표현을 위한 GraphQL에서 요청을 처리하는 방식 (Schema, Reso

1편: REST와 GraphQL의 같은 의도 GraphQL 스러운 코드는 무엇일까? 1편: REST와 GraphQL의 같은 의도와 다른 표현회사에서 GraphQL 기반으로 클라이언트-서버 통신을 적극적으로 사용하고 있습니다. GraphQL

inspire12.tistory.com

3편: GraphQL의 철학 이해하기

 

GraphQL 스러운 코드는 무엇일까? 3편: GraphQL의 철학 이해하기

1편: REST와 GraphQL의 같은 의도와 다른 표현 GraphQL 스러운 코드는 무엇일까? 1편: REST와 GraphQL의 같은 의도와 다른 표현회사에서 GraphQL 기반으로 클라이언트-서버 통신을 적극적으로 사용하고 있습

inspire12.tistory.com

예제코드

 

 

앞선 글들에서는 GraphQL스럽다는 것이 Query 문법을 사용하는 것이 아니라, Schema와 데이터를 중심으로 사고하는 것이라는 점을 살펴봤습니다.

GraphQL Schema에서는 User, Post, Comment와 같은 객체와 관계를 자연스럽게 표현할 수 있습니다.

하지만 관계를 풍부하게 표현하는 것만으로 좋은 서버 구조가 완성되지는 않습니다. 오히려 서버 내부에서는 새로운 문제가 생기기 시작합니다.

User에서 posts를 조회하고, 각 Post에서 comments를 조회하고, 각 Comment에서 author를 조회하기 시작하면 서버 내부에서는 조회 횟수가 빠르게 증가할 수 있습니다.

GraphQL은 데이터를 자유롭게 표현할 수 있게 해주지만, 서버는 그 표현을 효율적으로 실행하기 위한 구조를 함께 고민해야 합니다.

이번 글에서는 GraphQL의 표현력을 유지하면서 대표적인 조회 성능 문제인 N+1과 이를 해결하는 DataLoader를 먼저 살펴보겠습니다.

N + 1 문제

Field Resolver는 관계를 표현하지만 조회 비용을 숨깁니다. 서버를 개발하면서 이 조회 비용은 매우 중요한 포인트입니다.

GraphQL에서는 관계를 자유롭게 표현할 수 있지만, 서버는 그 관계를 실제 조회로 바꿔야 합니다.

type Query {
  users: [User!]!
}

type User {
  id: Int!
  name: String!
  posts: [Post!]!
}

type Post {
  id: Int!
  title: String!
  authorId: Int!
}

Resolver는 다음처럼 구성할 수 있습니다.

@Resolver(() => UserType)
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly postService: PostService,
  ) {}

  @Query(() => [UserType])
  users(): Promise<User[]> {
    return this.userService.getUsers();
  }

  @ResolveField(() => [PostType])
  posts(
    @Parent() user: User,
  ): Promise<Post[]> {
    return this.postService.getPostsByUserId(user.id);
  }
}

클라이언트 입장에서는 자연스럽고, 원하는 값을 얻을 수 있습니다. 

그러나 서버 입장에서는 매 유저를 조회할 때마다 posts 를 조회하게 됩니다. 

사용자(User)가 N명이면 Posts는 N번을 추가로 조회합니다
유저 조회 1번 + Posts 조회 N번 총 N+1 조회가 이루어집니다

N+1은 GraphQL만의 문제는 아닙니다

여기까지 보면서 REST를 많이 사용해보신 분들은 REST에서도 동일한 문제를 겪었다고 느끼실텐데요. 정상입니다. 

도메인을 객체로 표현하는 방식등에서는 공통적으로 나타나는 문제점입니다. 대표적으로 ORM(JPA 등)에서도 이런 문제는 해결하고 가야하는 점입니다.

 

REST에서도 다음 코드라면 동일한 문제가 발생합니다.

async getUsersWithPosts() {
  const users = await this.userRepository.findAll();

  return Promise.all(
    users.map(async (user) => ({
      ...user,
      posts: await this.postRepository.findByUserId(user.id),
    })),
  );
}

 

N+1은 GraphQL의 고유한 문제가 아니라, 객체 관계를 개별 조회하는 실행 방식에서 발생하는 문제입니다.

GraphQL이 N+1을 새로 만든 것은 아닙니다.

다만 Field Resolver 구조에서는 관계 필드를 독립적으로 조회하기 쉽고, 개발자가 놓치기 쉬워 N+1 문제가 더 자주 드러납니다.

DataLoader로 조회를 요청 단위로 묶습니다

GraphQL에서는 DataLoader를 이용해 이 N+1 문제를 좀 더 구조적으로 해결할 수 있습니다. 

Resolver는 서로 독립적으로 실행됩니다.

DataLoader는 이 독립적인 Resolver들이 발생시키는 조회를 한 번의 조회로 합쳐줍니다.

 

어떻게 사용하는지 코드로 먼저 보여드리겠습니다. 

먼저 Repository에 배치 조회를 추가합니다.

findByUserIds(userIds: number[]): Promise<Post[]> {
  return this.posts.filter((post) =>
    userIds.includes(post.authorId),
  );
}

 

DataLoader를 만듭니다.

DataLoader가 하는 일은 생각보다 단순합니다. Resolver가 각각 요청한 userId를 모아서 한 번에 조회한 뒤(IN Query) 다시 각 Resolver에게 결과를 매핑해 나눠주는 것입니다.

@Injectable()
export class PostsByUserLoader {
  constructor(
    private readonly postService: PostService,
  ) {}

  readonly loader = new DataLoader(async (userIds) => {
    const posts = await postService.findByUserIds(userIds);

    return groupByUser(posts, userIds);
  });
}

# groupByUser는 조회된 Post를 authorId별로 나눈 뒤, DataLoader가 전달한 userIds 순서에 맞춰 결과를 반환하는 예제 함수입니다.

 

Field Resolver에서는 개별 조회 대신 DataLoader에 조회를 위임하도록 설정해줍니다.

@ResolveField(() => [PostType])
posts(
  @Parent() user: User,
  @Context() context: GraphqlContext,
): Promise<Post[]> {
  return context.loaders.postsByUser.load(user.id);
}

 

이제 서버에서는

-- Before: users 조회 1회 + posts 조회 N회

SELECT users
SELECT posts WHERE userId = 1
SELECT posts WHERE userId = 2
SELECT posts WHERE userId = 3
...
-- Before: users 조회 1회 + posts 조회 N회

SELECT users
SELECT posts WHERE userId IN (...)

두 번의 조회로 SQL쿼리로 바꿔서 처리가 됩니다. (IN Query를 통해 데이터베이스와의 왕복 횟수를 크게 줄일 수 있습니다)

DataLoader는 요청마다 새로 만들어집니다

앞에서 DataLoader를 사용해 N+1 문제를 해결했습니다.

그렇다면 DataLoader는 애플리케이션이 시작할 때 한 번만 생성해서 계속 사용하면 되지 않을까요?

결론부터 말하면 그렇지 않습니다.

DataLoader는 하나의 GraphQL 요청(Request)을 기준으로 생성되고 함께 종료되는 Request Scope의 생명주기를 가집니다

하나의 요청 안에서는 여러 Field Resolver가 같은 DataLoader를 공유하며, 발생한 조회를 모아 한 번에 처리합니다.

하지만 새로운 GraphQL 요청이 들어오면 새로운 DataLoader가 생성됩니다.

이는 DataLoader의 목적이 데이터를 오래 보관하는 것이 아니라, 하나의 요청 안에서 발생하는 조회를 모으고 중복을 제거하는 것이기 때문입니다.

 

DataLoader의 라이프 사이클을 좀 더 들여다 보겠습니다. 

GraphQL Request
        ↓
 DataLoader 생성
        ↓
 Resolver들이 공유
        ↓
 Batch 조회
        ↓
 요청 종료
        ↓
 DataLoader 제거

 

만약 하나의 DataLoader를 애플리케이션 전체에서 공유(Singleton)한다면, 서로 다른 요청의 조회 결과가 같은 DataLoader를 통해 처리될 수 있습니다. 사용자 권한, Locale, Tenant, A/B 테스트 그룹처럼 요청마다 달라질 수 있는 정보가 섞여 예상하지 못한 문제가 발생할 수 있습니다.

즉 DataLoader는 하나의 요청 안에서 여러 Resolver의 조회를 효율적으로 묶어주는 Request Scope의 Batching 도구라고 이해하는 것이 더 정확합니다.

마무리

GraphQL은 객체와 관계를 자연스럽게 표현할 수 있는 강력한 도구입니다.

하지만 표현력이 좋아질수록 서버는 그 관계를 실제 데이터 조회로 변환해야 하고, 이 과정에서 N+1과 같은 성능 문제가 발생할 수 있습니다.

DataLoader는 이러한 문제를 해결하기 위한 대표적인 방법으로, 독립적으로 실행되는 Field Resolver의 조회를 요청 단위로 묶어 데이터베이스 접근을 효율적으로 줄여줍니다.

하지만 조회 횟수만 줄인다고 해서 GraphQL 서버가 잘 설계되는 것은 아닙니다.

 

GraphQL의 표현력은 유지하면서 서버 구조는 어떻게 복잡해지지 않게 만들까요?

 

다음 글에서는 GraphQL의 표현력을 유지하면서도 도메인 간 경계를 어떻게 나누고, DDD 관점에서 서버 구조를 어떻게 설계할 수 있는지 이야기해보겠습니다.

반응형