Node.js2025년 9월 15일4분 읽기

Jira API를 10번 순차 호출하던 코드, Promise.allSettled로 바꾼 이야기 (10s → 1.8s)

단순히 Promise.all로 바꾼 게 아니라, 부분 실패 허용·응답 순서 보장·Rate Limit 대응까지 병렬 처리의 트레이드오프를 하나씩 해결한 과정.

#Node.js#Promise#비동기#API최적화#Jira#Backend

Jira API를 10번 순차 호출하던 코드, Promise.allSettled로 바꾼 이야기 (10s → 1.8s)


상황

협업 SaaS에서 Jira 연동 플러그인을 개발할 때였습니다. 플랫폼 안에서 Jira 이슈 목록을 불러오는 기능이 있었는데, 응답이 10초나 걸렸습니다.

협업툴에서 10초는 그냥 느린 게 아닙니다. 사용자가 뭔가 고장난 줄 알고 새로고침을 누르는 수준입니다. 당연히 고객 불만이 들어왔고, 개선이 필요했습니다.


코드를 뜯어보니

문제의 코드는 이렇게 생겼습니다.

javascript
// 기존 코드 (단순화)
async function fetchJiraIssues(projectIds) {
  const results = [];
  for (const projectId of projectIds) {
    const issues = await jiraClient.getIssues(projectId); // 순차 호출
    results.push(...issues);
  }
  return results;
}

for...of 루프 안에서 await를 쓰면 각 요청이 끝날 때까지 기다렸다가 다음 요청을 보냅니다. 프로젝트가 10개라면 각 Jira API 응답 시간(약 1초)이 그대로 더해집니다.

10개 × 1초 = 10초.

거기다 페이지네이션도 없었습니다. 이슈가 많은 프로젝트에서는 한 번에 수백 건을 가져오려다 타임아웃이 났습니다.


왜 그냥 Promise.all이 아닌가

가장 먼저 떠오르는 해결책은 Promise.all입니다.

javascript
const results = await Promise.all(
  projectIds.map(id => jiraClient.getIssues(id))
);

하지만 Promise.all에는 결정적인 문제가 있습니다. 하나라도 실패하면 전체가 실패합니다.

10개 프로젝트 중 하나에서 Jira API가 오류를 반환하면 나머지 9개 결과도 날아갑니다. 협업툴에서 "A 프로젝트 이슈는 볼 수 없지만 나머지는 보여줌"이 "아무것도 못 봄"보다 훨씬 낫습니다.

부분 실패를 허용해야 했습니다.


Promise.allSettled 선택

Promise.allSettled는 각 프로미스의 성공/실패 여부와 무관하게 모두 완료될 때까지 기다린 뒤, 각각의 결과 상태를 반환합니다.

javascript
const settled = await Promise.allSettled(
  projectIds.map(id => jiraClient.getIssues(id))
);
 
const results = settled
  .filter(result => result.status === 'fulfilled')
  .flatMap(result => result.value);
 
const failed = settled
  .filter(result => result.status === 'rejected')
  .map(result => result.reason);
 
if (failed.length > 0) {
  console.warn(`${failed.length}개 프로젝트 조회 실패:`, failed);
}

실패한 프로젝트는 경고 로그를 남기고, 성공한 것만 결과에 포함합니다. 사용자는 로드할 수 있는 이슈는 볼 수 있고, 실패한 것은 별도 안내 메시지로 처리했습니다.


그런데 Jira Rate Limit이 있었다

10개를 동시에 보내면 Jira의 Rate Limit에 걸릴 수 있습니다. Jira Cloud는 사용자별 초당 요청 수에 제한이 있고, 초과하면 429 Too Many Requests가 납니다.

전부 동시에 보내는 대신, 5개씩 배치로 나눠 호출하는 방식을 선택했습니다.

javascript
async function fetchAllSettledInBatches(items, batchSize, fetcher) {
  const results = [];
 
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchResults = await Promise.allSettled(
      batch.map(item => fetcher(item))
    );
    results.push(...batchResults);
  }
 
  return results;
}
 
// 5개씩 배치로 처리
const settled = await fetchAllSettledInBatches(projectIds, 5, id =>
  jiraClient.getIssues(id)
);

10개라면 5개씩 2번 → 총 2번의 Promise.allSettled. 각 배치는 병렬이지만 배치 간에는 순차입니다.


응답 순서 보장

병렬 처리를 하면 응답이 도착하는 순서가 보장되지 않습니다. Promise.allSettled는 입력 순서대로 결과를 반환하기 때문에 이슈 목록 자체의 순서는 지켜지지만, 이슈 내부의 정렬 기준(우선순위, 생성일 등)은 별도로 처리해야 했습니다.

javascript
const allIssues = results
  .filter(r => r.status === 'fulfilled')
  .flatMap(r => r.value);
 
// 생성일 기준 정렬
allIssues.sort((a, b) => new Date(b.created) - new Date(a.created));

페이지네이션도 같이 적용

순차 호출 구조를 바꾸면서 페이지네이션도 함께 도입했습니다. Jira API는 startAtmaxResults 파라미터로 페이지를 나눌 수 있습니다.

javascript
async function fetchIssuesWithPagination(projectId, pageSize = 50) {
  let startAt = 0;
  let allIssues = [];
 
  while (true) {
    const response = await jiraClient.getIssues(projectId, {
      startAt,
      maxResults: pageSize,
    });
 
    allIssues = [...allIssues, ...response.issues];
 
    if (allIssues.length >= response.total || response.issues.length === 0) {
      break;
    }
 
    startAt += pageSize;
  }
 
  return allIssues;
}

처음에는 전체를 한 번에 가져오려다 타임아웃이 나던 구조였는데, 50건씩 나눠 가져오면서 타임아웃 문제도 함께 해소됐습니다.


재시도 정책 추가

외부 API는 언제든지 일시적으로 실패할 수 있습니다. 특히 Jira Cloud는 간헐적인 503을 반환하는 경우가 있었습니다. 명시적인 재시도 로직을 추가했습니다.

javascript
async function fetchWithRetry(fetcher, maxRetries = 3, delay = 500) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fetcher();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      if (error.status === 429 || error.status >= 500) {
        await new Promise(resolve => setTimeout(resolve, delay * attempt));
      } else {
        throw error; // 4xx는 재시도 의미 없음
      }
    }
  }
}

429(Rate Limit)와 5xx(서버 오류)는 재시도하고, 그 외 4xx는 클라이언트 오류이므로 즉시 실패 처리합니다.


결과

항목변경 전변경 후
응답 시간10초1.8초
실패 처리전체 실패부분 성공 허용
Rate Limit 대응없음배치(5개) 처리
타임아웃빈번없음
재시도없음최대 3회

마치며

이 작업에서 배운 건 비동기 전환은 단순히 빠르게 만드는 게 아니라 여러 트레이드오프를 함께 설계해야 한다는 것입니다.

  • Promise.all vs Promise.allSettled → 전체 실패 vs 부분 허용
  • 완전 병렬 vs 배치 → 속도 vs Rate Limit
  • 재시도 대상 → 일시적 오류 vs 영구적 오류

for...of + await가 나쁜 코드는 아닙니다. 순서가 중요하거나 앞 결과가 뒤에 영향을 주는 경우엔 맞는 선택입니다. 다만 독립적인 요청들을 병렬로 처리할 수 있는 상황에서 순차로 처리하면 병목이 생깁니다. 코드를 보기 전에 "이 요청들이 서로 의존하는가?"를 먼저 묻는 습관이 생긴 계기였습니다.

#Node.js#Promise#비동기#API최적화#Jira#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js