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 또한 다시 한 번 짚고 넘어갈 수 있어 도움이 되었다. 

+ Recent posts