이 글은 트러블 슈팅 모음의 게시글이예요

API요청과 async/await를 잘못 생각하면 겪을 수 있는 트러블 슈팅 경험의 썸네일

문제를 겪었던 상황

기술 블로그에서 data를 확인하고 API요청을 보낸 후 수정하는 상황
const parsePosts = async (source: Source): Promise<Array<PostInfo>> => {
  const Posts: Array<PostInfo> = [];
  const Posts: Array<PostInfo> = [];
 
  const parseRecursively = async (source: Source) => {
    const allPath = getAllPath(source);
    for (const fileSource of allPath) {
      if (isDirectory(fileSource)) {
        await parseRecursively(fileSource);
      } else {
        if (isMDX(fileSource)) {
          const fileContent = fs.readFileSync(fileSource, 'utf8');
          /* data.postId 가 존재하지 않으면 PostID 를 생성한 후 Post 저장*/
          if (!data.postId) {
            data.postId = Math.ceil(Math.random() * 9 * 100000);
 
            const updatedContent = matter.stringify(content, data);
            fs.writeFileSync(fileSource, updatedContent, 'utf-8');
          }
            fs.writeFileSync(fileSource, updatedContent, 'utf-8');
          }
 
            if (!data.issueNumber) {
              const newIssue = await POST_issuePost(data);
              const { number } =  newIssue;
              data.issueNumber = number;
              const updatedContent =  matter.stringify(content, data);
              fs.writeFileSync(fileSource, updatedContent, 'utf-8');
              const { data: temp } = matter(filterContent(fileContent));
            }
          }
 
          const directoryPath = path.join(fileSource, '..');
          const relatevePath = directoryPath.split('public')[1];
    }
 
    await parseRecursively();
 
  return Posts;
};

해당 문제는 3시간씩이나 곤경에 빠뜨렸던 경험을 담은 것으로 해당 코드는 🪢 라이브러리 없이 깃허브 API를 이용해 댓글창을 구현해보자 를 구현하며 겪은 트러블 슈팅 내역이다.

전체적인 코드를 대략적으로 말하자면 parsePost 는 로컬 파일에서 mdx 파일들을 모두 불러온 후에

mdx 파일의 메타 데이터 영역인 data 부분을 가져와 확인한다.

mdx파일의 data영역의 예시, 자바스크립트 객체 형태로 변환되어 불러와진다.
---
title: API요청과 async/await를 잘못 생각하면 겪을 수 있는 트러블 슈팅 경험
description: 나중에 고치자!
tag:
  - 트러블슈팅
postId: 710088
date: Mon Jun 24 2024
time: 1719235023850
---

이 때 data 영역에 issueNumber 가 존재하지 않으면 깃허브 API를 호출하는 POST_issuePost 메소드를 호출하여 issue 를 생성하고 생성된 issue 의 번호를 가져와 data 영역에 저장하고 mdx 파일에 덮어씌운다.

parsePost 메소드는 다른 여러 메소드에서 반복적으로 호출되기 때문에 모든 서버 컴포넌트가 렌더링 되는 동안 총 4번이 호출된다.

호출 횟수는 크게 상관이 없을 것이라 생각했다. 왜냐면 issueNumber 가 존재하지 않던 mdx 파일의 data 는 첫 번째 호출 이후에는 mdx 파일 내에 issueNumber 가 존재 할 것이라 생각했기 때문이다. (첫 번째 호출 때 POST_issuePost 가 호출되기 때문이다.

그러나 이게 웬걸

동일한 이슈들이 연속적으로 생성되는 경우동일한 이슈들이 연속적으로 생성되는 경우

각 호출 때 마다 POST 요청을 보내 issue 들이 반복적으로 생성되었다. 그 말은 즉 첫 번째 호출 이후에도 지속적으로 data.issueNumber가 존재하지 않았다는 것이다.

분명 POST 요청을 보내고 나서 응답값이 도착하면 동기적으로 mdx 파일을 수정해주었기 때문에 분명 2~4번째 호출 시에는 mdx 파일에 issueNumber 가 존재 할 것이라 생각했다.

도저히 이해가 안가서 문제가 있을 경우 로깅을 하도록 디버깅을 하고 로그값을 살펴보았다.

이전에 생성했던 prasePost의 일부
if (
  /* 디버깅을 용이하게 하기 위해 한 포스트에만 적용 */
  data.title ===
  '라우팅 프로토콜 알고리즘 : 다익스트라 알고리즘을 활용한 Link state'
) {
  console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    const newIssue = await POST_issuePost(data);
    const { number } = newIssue;
    console.log(`POST_issuePost 로 받아온 number : ${number}`);
    data.issueNumber = number;
    const updatedContent = matter.stringify(content, data);
    fs.writeFileSync(fileSource, updatedContent, 'utf-8');
 
    const { data: temp } = matter(filterContent(fileContent));
    console.log(`write 작업이 완료된 후의 이슈 넘버 ${temp.issueNumber}`);
  }
}

POST_issuePost 메소드의 경우엔 호출 시 본인이 실행되었다는 로그 값을 남기고 실제 포스트 요청을 보내는게 아니라 1부터 시작하여 선형적으로 증가하는 숫자를 비동기적으로 반환하도록 수정해줬다.

해당 메소드가 4번이나 호출되면 어떤 값이 생성되는지 확인해보자

내가 기대하던 로깅 값

내가 기대하던 로깅 값
// 첫 번째 호출 시 로깅 값
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST_issuePost 로 받아온 number : 1
write 작업이 완료된 후의 이슈 넘버 2
/* 이후 호출들에선 분기문을 통과하지 못한다. */
실제로 로깅 된 값
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST_issuePost 로 받아온 number : 1
write 작업이 완료된 후의 이슈 넘버 1
POST_issuePost 로 받아온 number : 2
write 작업이 완료된 후의 이슈 넘버 2
POST_issuePost 로 받아온 number : 3
write 작업이 완료된 후의 이슈 넘버 3
POST_issuePost 로 받아온 number : 4
write 작업이 완료된 후의 이슈 넘버 4

처음 로그 된 값을 보고서 왜 이런 일이 발생하는지 도저히 이해가 가지 않았다.

로그의 호출 순서가 눈에 보이는 코드의 흐름과 동일하지 않고 뒤죽박죽이였기 때문이다.

왜 이런 문제가 발생했을까 ? : 슈도 코드를 통해 살펴보자

async/await 는 비동기 처리를 마치 동기적인 것처럼 코드를 입력 할 수 있게 해주는 Promise chainingSyntax Sugar 다.

비즈니스 로직을 모두 제외하고 해당 코드들을 자바스크립트 슈도 코드로 표현해보자

이전 오류가 발생하던 상황을 구현한 슈도 코드
const data = {};
 
let issueNumber = 1;
 
async function POST_issuePost() {
  // API 요청을 흉내내기 위해 10ms만큼의 딜레이를 추가
  console.log('POST 요청 실행 !');
  const response = await new Promise((res) =>
    setTimeout(() => {
      res({ number: issueNumber++ });
    }, 10),
  );
  return response;
}
 
async function parsePost() {
  console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    const { number } = await POST_issuePost();
    console.log(`POST_issuePost 로 받아온 number : ${number}`);
    data.issueNumber = number;
    console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
  }
}
 
/* 4번의 실행이 일어나는 행위를 시뮬레이션 한 모습 */
(async function () {
  await parsePost();
})();
(async function () {
  await parsePost();
})();
(async function () {
  await parsePost();
})();
(async function () {
  await parsePost();
})();
이전 상황과 동일한 실행 흐름을 갖는다.
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
POST_issuePost 로 받아온 number : 1
write 작업이 완료된 후의 이슈 넘버 1
POST_issuePost 로 받아온 number : 2
write 작업이 완료된 후의 이슈 넘버 2
POST_issuePost 로 받아온 number : 3
write 작업이 완료된 후의 이슈 넘버 3
POST_issuePost 로 받아온 number : 4
write 작업이 완료된 후의 이슈 넘버 4

이전 상황과 완전 같은 로그 값을 나타내는데 여전히 async/await 로 이뤄진 코드를 동기적인 함수처럼 생각하게 된다면 이해가 되지 않는 로깅 값일 것이다.

async/await 는 Promise chaining과 동일하다.

이전에 말했듯 async/await 는 단순히 Promise chaining 을 동기코드처럼 사용 할 수 있게 해줄 뿐 결국엔 프로미스 체이닝이다.

async/await 가 작동하는 원리와 Promise 에 대한 자세하 내용은 이전에 기술하던 블로그에 적어둔

🪢 async / await 는 어떻게 동작하는걸까 ? Promise 객체와 제네레이터 를 보면 좋을 것 같다. :)

만약 상단의 async/await동기적인 것 처럼 보이는게 아니라 동기적이였다면 기대했던 흐름과 동일 했을 것이다.

하지만 async/await 는 동기적인 것 처럼 보일뿐 엄연히 비동기 처리를 다루는 함수이다.

async/await 로 이뤄진 코드를 프로미스 체이닝으로 바꿔서 살펴보자

async/await로 이뤄져있던 함수를 프로미스 체이닝으로 바꿨다. 실행 결과는 같다.
const data = {};
 
let issueNumber = 1;
 
function POST_issuePost() {
  // API 요청을 흉내내기 위해 10ms만큼의 딜레이를 추가
 
  return new Promise((res) => {
    console.log('POST 요청 실행 !');
    setTimeout(() => {
      res({ number: issueNumber++ });
    }, 0);
  });
}
 
function parsePost() {
  console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
 
    POST_issuePost()
      .then((response) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
      .catch((error) => {
        console.error('Error occurred:', error);
      });
  }
}
 
parsePost();
parsePost();
parsePost();
parsePost();
프로미스 체이닝의 결과값, 이전과 동일하다.
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
POST_issuePost 로 받아온 number : 1
write 작업이 완료된 후의 이슈 넘버 1
POST_issuePost 로 받아온 number : 2
write 작업이 완료된 후의 이슈 넘버 2
POST_issuePost 로 받아온 number : 3
write 작업이 완료된 후의 이슈 넘버 3
POST_issuePost 로 받아온 number : 4
write 작업이 완료된 후의 이슈 넘버 4

결국 async/await 로 감싼 함수는 동기적으로 작동하는 함수가 아니라 거대한 Promise chaining 의 덩어리들이다.

async/await 로 감싸진 함수를 async 로 감싸진 다른 함수에서 호출 하는 행위는 Promise chain 에 다른 Promise chain 을 연결하는 것과 같다.

프로미스가 비동기적으로 호출되는 모습

프로미스 객체들이 호출되는 순서프로미스 객체들이 호출되는 순서

프로미스들은 비동기적으로 콜스택이 아닌 WEB API 에서 호출되며 먼저 호출된 순으로 마이크로 태스크 큐에 들어가 대기하며, 콜스택이 모두 비었을 때 콜스택에 올라가 호출된다.

호출 된 parsePost 메소드는 여러 체인들이 연결된 거대한 Promise 인데 해당 Promise 를 여러 번 호출하게 되면

호출 된 1,2,3,4 번 프로미스 내부 체이닝들이 본인의 호출 순서를 보장받으며 WEB API ~ micro task queue 내부에서 대기하는 것이 아니라

콜스택에 아무런 함수도 존재하지 않거나, 본인의 호출이 WEB APImicro task queue 에서 완료 되었을 때 콜스택에서 호출된다.

시뮬레이션을 통해 알아보자

프로미스들을 4번 호출 한 상황
parsePost(); // 1번 프로미스
parsePost(); // 2번 프로미스
parsePost(); // 3번 프로미스
parsePost(); // 4번 프로미스

콜스택에선 먼저 호출한 parsePost 부터 호출하여 1번 프로미스부터 4번 프로미스 순으로 생성한다.

생성 된 프로미스들은 모두 WEB API 에 담기게 되며 WEB API 에서 비동기적으로 처리된다.

프로미스 체인 덩어리들의 각 체인들은 다음과 같은 순서를 갖는다.

첫 번째 체인
 console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    POST_issuePost()
두 번째 체인 , 해당 체인부턴 POST_issuePOST가 반환한 Promise를 체이닝 한다.
      .then((response) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
});

각 체인들은 본인 이전의 체인이 호출 되기 이전까지 실행되지 않음이 보장됨을 기억하자

N 번째 체인은 본인 이전 체인이 반환하는 Promise 객체의 내부 슬롯인 [[PromiseReaction]] 에 저장된다.

N 번째 Promiseresolve 되고 나면 본인의 [[PromiseReaction]] 을 호출하여 N+1 번째 프로미스를 호출한다.

🪢 이벤트 루프와 Promise 객체

Promise 와 관련된 자세한 내용은 이전에 기술한 내용을 참고하도록 하자

WEB API 에 1~4 번째 Promise 들이 순서대로 들어옴에 맞춰 코드들이 실행되는데 각 Promise 들의 첫 번째 체인이 순서대로 마이크로 테스크 큐에 담긴다.

담긴 체인들의 모습은 마치 다음과 같을 것이다.

마이크로 태스크 큐에 담긴 첫 번째 체인들의 모습, 이후 콜스택으로 이동 되어 호출된다.
 console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    POST_issuePost()
---------------------------------------------------------------
 console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    POST_issuePost()
---------------------------------------------------------------
 console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    POST_issuePost()
---------------------------------------------------------------
 console.log(`현재의 이슈 넘버 ${data.issueNumber}`);
  if (!data.issueNumber) {
    console.log(`분기문에 들어온 이슈 넘버 ${data.issueNumber}`);
    POST_issuePost()

중요한 점은 각 모든 프로미스들의 첫 번째 체인들이 호출 되더라도 data.issueNumber는 변경되지 않는다는 점이다.

이로 인해 첫 번째 프로미스의 첫 번째 체인이 호출 되더라도 다음 체인들이 참조하는 data.issueNumber 는 여전히 undefined 이다. 각 체인들은 콜스택에 담겨 다음과 같은 로깅 값을 생성한다.

현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !
현재의 이슈 넘버 undefined
분기문에 들어온 이슈 넘버 undefined
POST 요청 실행 !

첫 번째 체인이 콜스택에서 호출 되면 POST_issuesPOST() 가 반환한 1~4번째 프로미스들인 두 번째 체인이 다시 WEB API 에 담기게 된다.

POST 요청에 의해 API 요청 시간만큼의 (나는 단순히 setTimeout 으로 구현했지만) 딜레이를 갖고 먼저 resolve 된 순서대로 마이크로 태스크 큐에 담기게 된다. 습습 resolve 된 순서대로 마이크로 태스큐에 담기게 된 후 콜스택에서 호출되어 다음과 같은 로깅 값들을 가지게 된다.

마이크로 태스크 큐에 담긴 두 번째 체인들의 모습, response는 체이닝 된 post_issuePost의 response 값을 참조한다.
      .then((response /* 첫 번째 post_issuePost */) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
--------------------------------------------------
      .then((response /* 두 번째 post_issuePost */) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
--------------------------------------------------
      .then((response /* 세 번째 post_issuePost */) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
--------------------------------------------------
      .then((response /* 네 번째 post_issuePost */) => {
        console.log(`POST_issuePost 로 받아온 number : ${response.number}`);
        data.issueNumber = response.number;
        console.log(`write 작업이 완료된 후의 이슈 넘버 ${data.issueNumber}`);
      })
--------------------------------------------------
POST_issuePost 로 받아온 number : 1
write 작업이 완료된 후의 이슈 넘버 1
POST_issuePost 로 받아온 number : 2
write 작업이 완료된 후의 이슈 넘버 2
POST_issuePost 로 받아온 number : 3
write 작업이 완료된 후의 이슈 넘버 3
POST_issuePost 로 받아온 number : 4
write 작업이 완료된 후의 이슈 넘버 4

다시 본문으로 돌아와보자

기술 블로그에서 API요청을 재귀적으로 보내는 상황
const parsePosts = async (source: Source): => {
  ...
            if (!data.issueNumber) {
              const newIssue = await POST_issuePost(data);
              const { number } =  newIssue;
              data.issueNumber = number;
              const updatedContent =  matter.stringify(content, data);
              fs.writeFileSync(fileSource, updatedContent, 'utf-8');
              const { data: temp } = matter(filterContent(fileContent));
            }
          }
  ...
};

다시 본문으로 돌아와 해당 분기문을 살펴보면 결국 4번의 호출 때 마다 프로미스들이 프로미스들이 생성되며

각 프로미스의 첫 번째 체인들의 data.issueNumber 는 변경되지 않기 때문에 !data.issueNumber 조건문을 통과했던 것이다.

이로 인해 4번의 호출마다 4번의 API 요청이 갔던 것이다.

어떻게 해결했을까 ? : 플래그를 넣자

어떻게 해결할까 생각했는데 예전 운영체제 CPU부분을 공부하다가 봤던 상호배제를 위한 flag 를 사용한 알고리즘에서 착안하여 flag 를 동기적으로 넣어주었다.

issueFlag 동기적으로 생성하여 다음 체인들이 flag를 보고 다음 체인을 실행하도록 변경
if (!data.issueNumber && !data.issueFlag) {
  // race condition 방지 위해 flag 설정하고 동기적으로 내용 수정
  data.issueFlag = true;
  const updatedContent = matter.stringify(content, data);
  fs.writeFileSync(fileSource, updatedContent, 'utf-8');
 
  // 깃허브 API를 이용해 새로운 이슈를 생성하고 이슈 넘버를 메타데이터에 저장
  try {
    const newIssue = await POST_issuePost(data);
    const { number } = newIssue;
    data.issueNumber = number;
  } catch (e) {
    console.error(`${data.title}의 이슈를 생성하지 못했습니다.`);
    data.issueFlag = false;
    data.issueNumber = undefined;
  } finally {
    const updatedContent = matter.stringify(content, data);
    fs.writeFileSync(fileSource, updatedContent, 'utf-8');
  }
}

첫 번째 실행에 의한 첫 번째 프로미스의 첫 번재 체인이 실행 되게 되면 동기적으로 data.issueFlag 를 변경하고

다음 프로미스의 첫 번째 체인들은 data.issueFlag 를 보고 다음 체인을 실행하지 않는다.

댓글을 가져오고 있어요