DataLoader vs JPA

DataLoader vs JPA 1차 캐시: 성능 최적화 전략 비교
JPA 에 익숙한 사람 입장에서 JPA 1차 캐시와 GraphQL DataLoader 의 성능 최적화 전략을 비교해보았습니다.
키워드: Logical Join
📊 비교 요약
특성 | DataLoader | JPA 1차 캐시 |
스코프 | HTTP 요청 단위 | 트랜잭션 단위 |
배치 처리 | Event Loop 기반 자동 배치 | 수동 배치 처리 필요 |
캐시 전략 | 요청별 메모이제이션 | 엔티티 동일성 보장 |
언어/플랫폼 | JavaScript,TypeScript / GraphQL | Java / 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 무한 렌더링처럼 대재앙이 일어날 수 있습니다.
동작 순서
동일한 실행 컨텍스트에서 여러
dataLoader.load()
호출각 호출은 같은 배치에 키를 추가하고 Promise 반환
현재 실행 스택과 모든 Promise microtask 완료 후 배치 실행
배치 함수가 모든 키를 한 번에 처리
소스 코드
// 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
}
🎉 결론
DataLoader와 JPA 1차 캐시는 성능 최적화 문제를 다른 방식으로 해결합니다:
DataLoader: Event Loop의 특성을 활용한 명시적이고 유연한 배치 처리
JPA 1차 캐시: 영속성 컨텍스트 기반의 자동화된 엔티티 관리
성능 최적화를 위해서는 단순히 도구를 도입하는 것이 아니라,
애플리케이션의 데이터 접근 패턴을 이해하는게 더 중요합니다.
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.