DataLoader vs JPA

고정완고정완
6 min read

DataLoader vs JPA 1차 캐시: 성능 최적화 전략 비교

JPA 에 익숙한 사람 입장에서 JPA 1차 캐시GraphQL DataLoader 의 성능 최적화 전략을 비교해보았습니다.

키워드: Logical Join

📊 비교 요약

특성DataLoaderJPA 1차 캐시
스코프HTTP 요청 단위트랜잭션 단위
배치 처리Event Loop 기반 자동 배치수동 배치 처리 필요
캐시 전략요청별 메모이제이션엔티티 동일성 보장
언어/플랫폼JavaScript,TypeScript / GraphQLJava / Spring

🎯 공통 목표: N+1 쿼리 문제 해결

두 기술 모두 N+1 쿼리 문제 해결이 목표입니다.

// N+1 문제 예시
const users = await getUsers(); // 1번의 쿼리
for (const user of users) {
  const orders = await getOrders(user.id); // N번의 쿼리 (사용자 수만큼)
}

// 더 구체적인 예시
async function getUsersWithOrders() {
  const users = await userRepository.findAll(); // 1번의 쿼리로 100명의 사용자 조회

  const result = [];
  for (const user of users) {
    const orders = await orderRepository.findByUserId(user.id); // 100번의 추가 쿼리!
    result.push({
      user,
      orders,
      totalAmount: orders.reduce((sum, order) => sum + order.amount, 0)
    });
  }

  return result; // 총 101번의 쿼리 실행 (1 + 100)
}

🔄 DataLoader: Event Loop 기반 배치 처리

핵심 메커니즘

DataLoader는 Event Loop의 특성을 활용하여 동일한 실행 컨텍스트에서 발생하는 개별 로드 요청들을 자동으로 배치로 묶습니다.

BatchLoadFn 끼리 순환 호출이 발생할 경우, React 무한 렌더링처럼 대재앙이 일어날 수 있습니다.

동작 순서

  1. 동일한 실행 컨텍스트에서 여러 dataLoader.load() 호출

  2. 각 호출은 같은 배치에 키를 추가하고 Promise 반환

  3. 현재 실행 스택과 모든 Promise microtask 완료 후 배치 실행

  4. 배치 함수가 모든 키를 한 번에 처리

소스 코드

// DataLoader 내부 스케줄링 메커니즘
var enqueuePostPromiseJob = typeof process === 'object' && typeof process.nextTick === 'function' 
  ? function (fn) {
      if (!resolvedPromise) {
        resolvedPromise = Promise.resolve();
      }
      resolvedPromise.then(function () {
        process.nextTick(fn);
      });
    }
  : typeof setImmediate === 'function' 
    ? function (fn) { setImmediate(fn); }
    : function (fn) { setTimeout(fn); };

구현 예시

// DataLoader 구현 예시
private readonly findUsersByIds_DataLoader = new DataLoader(
  async (userIds: string[]) => {
    const uniqueUserIds = uniq(userIds)

    // 한 번의 쿼리로 모든 사용자 조회
    const users = await this.userRepository.findManyByIds(uniqueUserIds)
    const userMap = new Map(users.map(user => [user.id, user]))

    // 요청된 순서대로 결과 반환
    return userIds.map(id => userMap.get(id) || null)
  },
)

private readonly findOrdersByUserIds_DataLoader = new DataLoader(
  async (userIds: string[]) => {
    const uniqueUserIds = uniq(userIds)

    // 한 번의 쿼리로 모든 사용자의 주문 조회
    const orders = await this.orderRepository.findManyByUserIds(uniqueUserIds)
    const groupedOrders = groupBy(orders, order => order.userId)

    // 각 사용자별 주문 목록 반환
    return userIds.map(userId => groupedOrders[userId] || [])
  },
)

// 사용 예시
async loadUser(userId: string) {
  return await this.findUsersByIds_DataLoader.load(userId)
}

async loadUserOrders(userId: string) {
  return await this.findOrdersByUserIds_DataLoader.load(userId)
}

// GraphQL Resolver에서 사용
async getUsersWithOrders(userIds: string[]) {
  // 여러 load() 호출이 자동으로 배치 처리됨
  const users = await Promise.all(
    userIds.map(id => this.loadUser(id))
  )

  const usersWithOrders = await Promise.all(
    users.map(async user => ({
      ...user,
      orders: await this.loadUserOrders(user.id) // 배치로 처리됨
    }))
  )

  return usersWithOrders
}

🗄️ JPA 1차 캐시: 영속성 컨텍스트 기반

핵심 메커니즘

JPA 1차 캐시는 영속성 컨텍스트(Persistence Context) 내에서 엔티티의 생명주기를 관리하며 캐싱을 제공합니다.

@Service
@Transactional
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public void processUsers() {
        User user1 = userRepository.findById(1L); // DB 쿼리 실행
        User user2 = userRepository.findById(1L); // 1차 캐시에서 반환 (쿼리 X)

        user1.setName("Updated Name");
        // 트랜잭션 커밋 시점에 UPDATE 쿼리 실행
    }
}

영속성 컨텍스트의 특징

// JPA 엔티티 정의
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    // getters, setters...
}

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;
    private BigDecimal amount;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // getters, setters...
}

// 서비스 레이어 - N+1 문제 발생 예시
@Service
@Transactional
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public List<UserDto> getUsersWithOrders(List<Long> userIds) {
        List<User> users = userRepository.findAllById(userIds); // 1번의 쿼리

        return users.stream()
            .map(user -> {
                // 지연 로딩으로 인한 N+1 문제 발생
                List<Order> orders = user.getOrders(); // 각 user마다 쿼리 실행
                return new UserDto(user.getName(), user.getEmail(), orders.size());
            })
            .collect(Collectors.toList());
    }
}

🚀 성능 최적화 전략

DataLoader 최적화

// HTTP 요청별 DataLoader 인스턴스 관리
export class DataLoaders {
  private readonly loaders: Map<string, DataLoader<unknown, unknown>> = new Map()

  static from(req: DataLoaderRequest): DataLoaders {
    if (!req.dataLoaders) {
      req.dataLoaders = new DataLoaders()
    }
    return req.dataLoaders
  }

  getOrCreate<K, V>(
    loaderKey: string,
    batchLoadFn: DataLoader.BatchLoadFn<K, V>,
  ): DataLoader<K, V> {
    if (!this.loaders.has(loaderKey)) {
      this.loaders.set(loaderKey, new DataLoader<K, V>(batchLoadFn))
    }
    return this.loaders.get(loaderKey) as DataLoader<K, V>
  }
}

JPA 최적화

// Repository 인터페이스 - Fetch Join을 활용한 N+1 문제 해결
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id IN :ids")
    List<User> findAllByIdWithOrders(@Param("ids") List<Long> ids);

    // 또는 @EntityGraph 사용
    @EntityGraph(attributePaths = {"orders"})
    List<User> findAllByIdWithOrdersUsingEntityGraph(Iterable<Long> ids);

    // 별도 쿼리로 주문만 조회하는 방법
    @Query("SELECT o FROM Order o WHERE o.user.id IN :userIds")
    List<Order> findOrdersByUserIds(@Param("userIds") List<Long> userIds);
}

// 서비스 레이어 - 최적화된 배치 처리
@Service
@Transactional
public class UserService {

    @Autowired
    private UserRepository userRepository;

    // 방법 1: Fetch Join 사용
    public List<UserDto> getUsersWithOrdersOptimized(List<Long> userIds) {
        List<User> users = userRepository.findAllByIdWithOrders(userIds); // 1번의 조인 쿼리

        return users.stream()
            .map(user -> new UserDto(
                user.getName(), 
                user.getEmail(), 
                user.getOrders().size() // 추가 쿼리 없음
            ))
            .collect(Collectors.toList());
    }

    // 방법 2: 별도 쿼리로 배치 처리
    public List<UserDto> getUsersWithOrdersBatch(List<Long> userIds) {
        List<User> users = userRepository.findAllById(userIds); // 1번의 쿼리
        List<Order> orders = userRepository.findOrdersByUserIds(userIds); // 1번의 쿼리

        Map<Long, List<Order>> ordersByUserId = orders.stream()
            .collect(Collectors.groupingBy(order -> order.getUser().getId()));

        return users.stream()
            .map(user -> {
                List<Order> userOrders = ordersByUserId.getOrDefault(user.getId(), Collections.emptyList());
                return new UserDto(user.getName(), user.getEmail(), userOrders.size());
            })
            .collect(Collectors.toList());
    }
}

🎯 사용 시나리오별 선택 가이드

DataLoader를 선택해야 하는 경우

  • GraphQL API에서 복잡한 쿼리 해결

  • 마이크로서비스 환경에서 여러 서비스 호출 최적화

  • 동적인 데이터 로딩 패턴이 필요한 경우

  • Node.js/TypeScript 기반 애플리케이션

JPA 1차 캐시를 활용해야 하는 경우

  • 전통적인 CRUD 애플리케이션

  • 트랜잭션 내에서의 엔티티 일관성이 중요한 경우

  • Spring Boot 기반 애플리케이션

  • ORM의 이점을 최대한 활용하고 싶은 경우

🔍 실제 프로덕션 고려사항

DataLoader 주의점

// ❌ 잘못된 사용: 전역 인스턴스
const globalLoader = new DataLoader(batchFn); // 메모리 누수 위험

// ✅ 올바른 사용: 요청별 인스턴스
@Injectable({scope: Scope.REQUEST})
export class UserDataLoaders {
  private readonly userLoader = new DataLoader(this.batchLoadUsers.bind(this));
}

JPA 주의점

// ❌ N+1 문제 발생
@Transactional
public void processUsers() {
    List<User> users = userRepository.findAll();
    users.forEach(user -> {
        user.getOrders().size(); // 각 user마다 쿼리 실행
    });
}

// ✅ 배치 로딩으로 해결
@Transactional
public void processUsersOptimized() {
    List<User> users = userRepository.findAllWithOrders(); // 한 번의 조인 쿼리
    users.forEach(user -> {
        user.getOrders().size(); // 추가 쿼리 없음
    });
}

// ❌ 1차 캐시 범위를 벗어난 사용
public void processUsersAcrossTransactions() {
    User user1 = getUserById(1L); // 첫 번째 트랜잭션
    User user2 = getUserById(1L); // 두 번째 트랜잭션 - 캐시 효과 없음
}

// ✅ 같은 트랜잭션 내에서 1차 캐시 활용
@Transactional
public void processUsersInSameTransaction() {
    User user1 = userRepository.findById(1L).orElse(null); // DB 쿼리
    User user2 = userRepository.findById(1L).orElse(null); // 1차 캐시에서 반환

    // user1과 user2는 같은 인스턴스 (동일성 보장)
    assert user1 == user2; // true
}

🎉 결론

DataLoaderJPA 1차 캐시는 성능 최적화 문제를 다른 방식으로 해결합니다:

  • DataLoader: Event Loop의 특성을 활용한 명시적이고 유연한 배치 처리

  • JPA 1차 캐시: 영속성 컨텍스트 기반의 자동화된 엔티티 관리

성능 최적화를 위해서는 단순히 도구를 도입하는 것이 아니라,
애플리케이션의 데이터 접근 패턴을 이해하는게 더 중요합니다.

0
Subscribe to my newsletter

Read articles from 고정완 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

고정완
고정완

I started my career as a IoT engineer for smart city solution. Then, I joined to a start-up company "Dreamfora" as one of starting member. I created an goal planning app for self-improvment. Now I am working at "MonyMony", making a couple diary application.