GraphQL Mutation에서 DataLoader를 사용해도 될까?

munilive
Written by munilive on (Updated: )

GraphQL API를 개발하다 보면 N+1 문제를 해결하기 위해 DataLoader를 사용하게 됩니다. Query에서는 매우 효과적이지만, Mutation에서도 같은 방식으로 사용해도 될까요? 이 글에서는 DataLoader의 동작 원리를 이해하고, Mutation에서 사용 시 발생할 수 있는 문제점들을 살펴보겠습니다.

DataLoader란?

DataLoader는 Facebook에서 만든 배칭(Batching)과 캐싱(Caching) 유틸리티로, GraphQL의 N+1 문제를 해결하기 위해 사용됩니다.

N+1 문제 예시

query {
  posts {           # 1번: 모든 posts 조회
    id
    title
    author {        # N번: 각 post마다 author 조회
      id
      name
    }
  }
}

DataLoader 없이 구현하면:

SELECT * FROM posts;              -- 1번 쿼리
SELECT * FROM users WHERE id=1;   -- post 1의 author
SELECT * FROM users WHERE id=2;   -- post 2의 author
SELECT * FROM users WHERE id=1;   -- post 3의 author (중복!)

DataLoader 사용 시:

SELECT * FROM posts;                    -- 1번 쿼리
SELECT * FROM users WHERE id IN (1,2);  -- 1번의 배치 쿼리

DataLoader 동작 원리

DataLoader는 Node.js의 process.nextTick을 활용하여 같은 이벤트 루프 틱(tick) 내에서 발생한 모든 load() 호출을 모아서 하나의 배치로 실행합니다.

const userLoader = new DataLoader(async (userIds) => {
  console.log('배치 실행:', userIds);
  const users = await User.find({ _id: { $in: userIds } });
  return userIds.map(id => users.find(u => u._id.equals(id)));
});

// 실행 과정
console.log('1. 시작');

userLoader.load('user-1').then(u => console.log('4. User 1:', u.name));
userLoader.load('user-2').then(u => console.log('5. User 2:', u.name));
userLoader.load('user-1').then(u => console.log('6. User 1 (캐시):', u.name));

console.log('2. load() 호출 완료');

// 출력:
// 1. 시작
// 2. load() 호출 완료
// 배치 실행: ['user-1', 'user-2']  ← 중복 제거됨
// 4. User 1: Alice
// 5. User 2: Bob
// 6. User 1 (캐시): Alice

핵심 메커니즘

  1. Request Queuing: load() 호출 시 즉시 실행하지 않고 큐에 추가
  2. Batch Execution: 현재 틱이 끝나면 process.nextTick()에서 배치 실행
  3. Caching: 결과를 캐시에 저장하여 중복 요청 방지

Query와 Mutation의 실행 방식 차이

Query: 병렬 실행

GraphQL Query는 같은 레벨의 필드들을 병렬로 실행합니다.

query {
  post1: post(id: 1) { author { name } }
  post2: post(id: 2) { author { name } }
  post3: post(id: 3) { author { name } }
}

실행 과정:

Tick 1: post1, post2, post3 resolver 동시 실행
Tick 2: author field resolver 모두 실행
        ├─ userLoader.load(user-1)
        ├─ userLoader.load(user-2)
        └─ userLoader.load(user-1)
Tick 3: DataLoader 배치 실행
        └─ SELECT * FROM users WHERE id IN (user-1, user-2)

결과: 1번의 배치 쿼리로 완료

Mutation: 순차 실행

GraphQL 스펙에 따라 Mutation은 순차적으로 실행됩니다. 데이터 일관성을 보장하기 위한 의도적인 설계입니다.

mutation {
  update1: updatePost(id: 1) { author { name } }
  update2: updatePost(id: 2) { author { name } }
  update3: updatePost(id: 3) { author { name } }
}

실행 과정:

=== Mutation 1 실행 ===
Tick 1-2: updatePost(id: 1) 완료
Tick 3: author field resolver
        └─ userLoader.load(user-1)
Tick 4: DataLoader 배치 실행
        └─ SELECT * FROM users WHERE id = user-1

⏸️  Mutation 1 완전히 끝날 때까지 대기

=== Mutation 2 실행 ===
Tick 5-6: updatePost(id: 2) 완료
Tick 7: author field resolver
        └─ userLoader.load(user-2)
Tick 8: DataLoader 배치 실행
        └─ SELECT * FROM users WHERE id = user-2

⏸️  대기

=== Mutation 3 실행 ===
Tick 9-11: updatePost(id: 3) 완료, author 조회
        └─ SELECT * FROM users WHERE id = user-1

결과: 3번의 개별 쿼리 실행 (배칭 실패)

Mutation에서 DataLoader 사용 시 문제점

문제 1: 배칭 효과 없음

여러 Mutation을 연속으로 실행하면 각각이 완전히 끝난 후 다음이 시작되므로, field resolver들이 서로 다른 틱에서 실행됩니다. DataLoader의 배칭이 작동하지 않습니다.

// Mutation Resolver
const resolvers = {
  Mutation: {
    updatePosts: async (_, { ids }, { loaders }) => {
      const results = [];
      
      // ❌ 순차 실행 - 배칭 안됨
      for (const id of ids) {
        const post = await Post.update(id);
        const author = await loaders.userLoader.load(post.authorId);
        results.push({ post, author });
      }
      
      return results;
    }
  }
}

문제 2: 캐시 무효화 (Stale Data)

더 심각한 문제는 캐시 무효화입니다. DataLoader는 요청당 하나의 인스턴스가 생성되고, 요청이 끝날 때까지 캐시를 유지합니다.

mutation {
  # 1. Author를 "Alice"로 업데이트
  updateAuthor1: updateAuthor(id: 1, name: "Alice") {
    id
    name  # "Alice" 반환
  }
  
  # 2. Post 조회
  getPost: post(id: 1) {
    author {
      id
      name  # ✅ 캐시에서 "Alice" 반환
    }
  }
  
  # 3. 같은 Author를 "Bob"으로 다시 업데이트
  updateAuthor2: updateAuthor(id: 1, name: "Bob") {
    id
    name  # "Bob" 반환
  }
  
  # 4. 다시 Post 조회
  getPost2: post(id: 1) {
    author {
      id
      name  # ❌ 캐시에서 "Alice" 반환! (DB는 "Bob")
    }
  }
}

문제 발생 원인:

// DataLoader 내부 동작
class DataLoader {
  constructor(batchLoadFn) {
    this._cache = new Map();
  }
  
  load(key) {
    // 캐시 확인
    if (this._cache.has(key)) {
      return Promise.resolve(this._cache.get(key)); // ⚠️ 캐시된 데이터 반환
    }
    // ... 배치에 추가 및 캐시 저장
  }
}
  1. updateAuthor1이 완료되면서 author가 조회되고 캐시에 “Alice” 저장
  2. getPost의 author field resolver가 캐시에서 “Alice” 반환
  3. updateAuthor2가 DB의 author를 “Bob”으로 변경 (캐시는 모름!)
  4. getPost2의 author field resolver가 여전히 캐시에서 “Alice” 반환

그렇다면 Mutation에서는 절대 사용하면 안 될까?

✅ 허용되는 경우: 단일 Mutation의 반환 타입

단일 Mutation의 반환 타입 내부에서는 field resolver들이 같은 틱에서 실행되므로 DataLoader가 정상 작동합니다.

mutation {
  createOrder(items: [
    { productId: 1, quantity: 2 },
    { productId: 2, quantity: 1 },
    { productId: 3, quantity: 5 }
  ]) {
    id
    items {
      product { name, price }   # ✅ 배칭 작동
      seller { name }           # ✅ 배칭 작동
    }
  }
}

실행 과정:

Tick 1: createOrder resolver 완료
Tick 2: items field resolver 실행
Tick 3: product, seller field resolver 동시 실행
        ├─ productLoader.load(1)
        ├─ productLoader.load(2)
        ├─ productLoader.load(3)
        ├─ sellerLoader.load(s1)
        └─ sellerLoader.load(s2)
Tick 4: DataLoader 배치 실행
        ├─ SELECT * FROM products WHERE id IN (1,2,3)
        └─ SELECT * FROM sellers WHERE id IN (s1,s2)

각 타입당 1번의 배치 쿼리로 완료

하지만 이 경우에도 캐시 무효화 문제는 여전히 존재합니다.

❌ 피해야 하는 경우: 여러 Mutation 연속 실행

mutation {
  order1: createOrder(...) {
    items { product { name } }  # Batch 1
  }
  order2: createOrder(...) {
    items { product { name } }  # Batch 2 (별도 쿼리)
  }
}

이 경우 각 Mutation이 순차 실행되므로 배칭 효과가 없습니다.

해결 방법

방법 1: Mutation 요청에서 캐시 비활성화

import DataLoader from 'dataloader';

export function createContext({ req }) {
  const query = req.body.query || '';
  const isMutation = query.trim().startsWith('mutation');
  
  return {
    loaders: {
      userLoader: new DataLoader(
        async (ids) => { /* ... */ },
        { 
          cache: !isMutation  // Mutation이면 캐시 비활성화
        }
      )
    }
  };
}

장점: 간단하고 확실한 해결책
단점: Mutation의 반환 타입에서도 캐싱 효과를 받지 못함

방법 2: 명시적 캐시 무효화

const resolvers = {
  Mutation: {
    updateUser: async (_, { id, input }, { loaders }) => {
      const user = await User.findByIdAndUpdate(id, input, { new: true });
      
      // ✅ 변경된 엔티티의 캐시 제거
      loaders.userLoader.clear(id);
      
      // 또는 관련된 모든 캐시 제거
      // loaders.userLoader.clearAll();
      
      return user;
    },
    
    deleteUser: async (_, { id }, { loaders }) => {
      await User.findByIdAndDelete(id);
      loaders.userLoader.clear(id);
      return { success: true };
    }
  }
};

장점: 세밀한 제어 가능
단점: 모든 Mutation에서 수동으로 관리해야 하며, 실수하기 쉬움

방법 3: 반환 타입 단순화 (권장)

// ❌ 피해야 할 패턴
const typeDefs = gql`
  type Mutation {
    updateUser(id: ID!, input: UserInput!): User
  }
  
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!      # 복잡한 중첩 타입
    friends: [User!]!    # 복잡한 중첩 타입
  }
`;

// ✅ 권장 패턴
const typeDefs = gql`
  type Mutation {
    updateUser(id: ID!, input: UserInput!): UpdateUserPayload!
  }
  
  type UpdateUserPayload {
    success: Boolean!
    userId: ID
    message: String
  }
`;

클라이언트에서 최신 데이터가 필요하면 별도 Query로 재조회:

# Mutation 실행
mutation {
  updateUser(id: "1", input: { name: "Alice" }) {
    success
    userId
  }
}

# 최신 데이터 조회
query {
  user(id: "1") {
    id
    name
    email
    posts { id, title }
  }
}

장점:

  • Mutation과 Query의 책임 분리
  • 캐시 문제 원천 차단
  • 명확한 API 설계

단점:

  • 클라이언트에서 2번의 요청 필요

방법 4: 여러 Mutation을 단일 Mutation으로 통합

//  배칭 안됨
mutation {
  order1: createOrder(userId: 1, items: [...]) { ... }
  order2: createOrder(userId: 2, items: [...]) { ... }
  order3: createOrder(userId: 3, items: [...]) { ... }
}

//  배칭됨
mutation {
  createOrders(orders: [
    { userId: 1, items: [...] },
    { userId: 2, items: [...] },
    { userId: 3, items: [...] }
  ]) {
    orders {
      id
      items { product { name } }  # 모두 같은 배치에서 처리
    }
  }
}

실무 권장 사항

1. Query에서는 적극 활용

// ✅ Query는 DataLoader의 최적 사용처
const resolvers = {
  Query: {
    posts: () => Post.find(),
  },
  Post: {
    author: (post, _, { loaders }) => {
      return loaders.userLoader.load(post.authorId);
    },
    comments: (post, _, { loaders }) => {
      return loaders.commentsByPostIdLoader.load(post.id);
    }
  }
};

2. Mutation에서는 신중하게 사용

// ⚠️ Mutation에서는 캐시 관리 필수
const resolvers = {
  Mutation: {
    updatePost: async (_, { id, input }, { loaders }) => {
      const post = await Post.findByIdAndUpdate(id, input);
      
      // 변경된 데이터 캐시 무효화
      loaders.postLoader.clear(id);
      if (input.authorId) {
        loaders.userLoader.clear(input.authorId);
      }
      
      return post;
    }
  }
};

3. 반환 타입은 단순하게

// ✅ 단순한 Payload 타입 사용
type MutationPayload {
  success: Boolean!
  message: String
  id: ID
}

결론

DataLoader는 읽기 작업(Query)에 최적화된 도구입니다. Query의 field resolver들이 병렬로 실행되면서 자연스럽게 배칭과 캐싱의 이점을 누릴 수 있도록 설계되었습니다.

Mutation에서 사용하면:

  • 배칭 효과는 제한적 (순차 실행으로 인해)
  • 캐시 관리가 복잡 (데이터 일관성 문제)
  • 예상치 못한 버그 발생 가능 (stale data)

따라서:

  1. 여러 Mutation을 연속 실행하는 경우: 배칭 효과 없으므로 사용 지양
  2. 단일 Mutation의 복잡한 반환 타입: 사용 가능하지만 캐시 무효화 필수
  3. 권장 패턴: Mutation은 단순한 결과만 반환하고, 클라이언트가 필요시 별도 Query로 최신 데이터 조회

DataLoader를 Mutation에서 사용할지는 “취향”의 문제가 아니라, 아키텍처와 데이터 일관성을 고려한 신중한 설계 결정이 필요한 영역입니다.

참고 자료

Comments