1+N 문제
Graphql 로 company 목록을 요청할 때, company 별 subsidiary 목록을 포함한다면 별다른 처리가 없는 경우
우선 company 조회 쿼리가 1번 실행되고, 결과 개수가 N개라면 각 company 의 id를 companyId로 갖는 subsidiary 를 조회하는 쿼리가 N번 실행된다.
아이디어
이 문제는 데이터베이스에 부하를 줄 수 있으므로, subsidiary 조회를 한 번만 실행(batch processing)하고 추후에 company 별로 분류하면 효율적이다.
사용 예
const subsidiariesByCompanyIdLoader = new DataLoader((companyIds: number[]) => {
const docs = await this.prismaReader.subsidiary.findMany({
where: {
companyId: {
in: ids
}
})
const hmap = new HashMap();
docs.forEach(subsidiary => {
if (!hmap[subsidiary.companyId]) map[subsidiary.companyId] = [];
hmap[subsidiary.companyId).push(subsidiary);
})
return companyIds.map(companyId => hmap.get(companyId));
});
//...
getSubsidiariesByCompanyId(companyId: number) {
return subsidiariesByCompanyIdLoader().load(companyId);
}
DataLoader 가 이 아이디어를 구현한 방법
조사 방법
깃헙 레포의 메인 코드를 따라가며 읽었다.
테스트 코드를 참고하여 이해를 도왔다
주요 로직
세팅
1. 데이터를 fetch 하고 key 별 분리하는 함수(batchLoadFn)를 인자로 받아 loader 를 만든다
2. loader 의 batch(요청그룹의 역할) 를 만든다
3. batch 가 현재 이벤트루프 사이클의 promise의 콜백이 모두 완료된 후 실행되도록 예약한다.
그 다음 (대부분 반복적으로) loader는 특정 key 에 해당하는 데이터 요청을 받으면 (=load 함수 call)
1. key를 batch.keys 배열에 넣는다
2. 새로운 Promise 객체를 생성하고, 이 요청의 성공 또는 실패 시 실행할 resolve 및 reject 콜백을 batch.callbacks 배열에 추가한다
3. 이 promise를 리턴한다 (*)
최종적으로
1. 현재 사이클의 promise 의 콜백(microtask)이 모두 완료되면 batch 에 있는 key들을 인자로 하여 batchLoadFn함수를 실행한다
2. 이 함수의 실행결과를 순회하며 같은 인덱스에 해당하는 callbacks 배열의 원소인 resolve, reject 중 알맞은 함수를 호출한다
3. (*) 호출부에서 promise의 이행 결과를 얻을 수 있다.
단순화한 코드
원본 코드의 예외처리와 성능개선 코드를 제외하고, 분리된 로직을 합쳤다.
class DataLoader<K, V = K> {
constructor(batchLoadFn: BatchLoadFn<K, V>) {
this._batchLoadFn = batchLoadFn;
}
load(key: K): Promise<V> {
const batch = getCurrentBatch(this);
batch.keys.push(key);
const promise = new Promise((resolve, reject) => {
batch.callbacks.push({ resolve, reject });
});
return promise;
}
}
function getCurrentBatch<K, V>(loader: DataLoader<K, V, any>): Batch<K, V> {
const newBatch = { hasDispatched: false, keys: [], callbacks: [] };
Promise.resolve().then(() => process.nextTick(() => {
loader._batchLoadFn(batch.keys)
.then(values => {
for (let i = 0; i < batch.callbacks.length; i++) {
const value = values[i];
if (value instanceof Error) {
batch.callbacks[i].reject(value);
} else {
batch.callbacks[i].resolve(value);
}
}
});
});
return newBatch;
}
디스커션 (추가)
프로미스 이행 결과를 처리할 때, 왜 키 중심이 아니라 배열의 순서(인덱스)에 의존하는지 궁금해서 찾아보았다. 요청별 처리 결과를 추적하기 위해서라고 한다. 의도대로 동작하기 위해서는 개발자가 batchLoadFn 을 반드시 키 배열에 따른 결과를 map 함수를 사용하여 응답하도록 해야 하는데, 이 처리도 내부 함수로 처리하면 어떨까 생각이 든다.
후기
Node.js 의 이벤트루프 동작과 promise 또한 다시 한 번 짚고 넘어갈 수 있어 도움이 되었다.
'기술 조사 > 기타' 카테고리의 다른 글
| Elasticsearch 에 인덱싱할 때 CPU 이슈 (2) | 2026.02.24 |
|---|---|
| prisma의 마이그레이션 히스토리 관리와 개발 생산성 사이의 고민 (0) | 2026.02.08 |
| Nestjs(TypeScript) GraphQL 을 사용할 때 Enum 타입 아규먼트의 기본값 설정 이슈 (2) | 2025.04.12 |
| reflect-metadata 와 Nest.js (4) | 2025.02.15 |