Skip to content

비동기 흐름 제어

INFO

이 게시물의 내용은 Mixu의 Node.js 책에서 많은 영감을 받았습니다.

JavaScript는 핵심적으로 "메인" 스레드에서 논블로킹으로 설계되었으며, 이는 뷰가 렌더링되는 곳입니다. 브라우저에서 이것이 얼마나 중요한지 상상할 수 있습니다. 메인 스레드가 블로킹되면 최종 사용자가 두려워하는 악명 높은 "멈춤"이 발생하고 다른 이벤트가 디스패치될 수 없어 예를 들어 데이터 획득이 손실됩니다.

이는 기능적 프로그래밍 스타일만이 해결할 수 있는 몇 가지 고유한 제약을 만듭니다. 여기서 콜백이 등장합니다.

그러나 콜백은 더 복잡한 절차에서 처리하기 어려울 수 있습니다. 이는 종종 여러 개의 중첩된 콜백 함수가 코드를 읽고, 디버그하고, 구성하는 것을 더 어렵게 만드는 "콜백 지옥"을 초래합니다.

js
async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // output으로 무언가를 수행
        });
      });
    });
  });
});

물론 실제로는 result1, result2 등을 처리하기 위한 추가 코드 줄이 있을 가능성이 높으므로 이 문제의 길이와 복잡성으로 인해 일반적으로 위의 예보다 훨씬 더 지저분해 보이는 코드가 생성됩니다.

이것이 함수가 매우 유용한 이유입니다. 더 복잡한 작업은 여러 함수로 구성됩니다:

  1. 시작 스타일 / 입력
  2. 미들웨어
  3. 종결자

"시작 스타일 / 입력"은 시퀀스의 첫 번째 함수입니다. 이 함수는 작업에 대한 원본 입력을 (있는 경우) 허용합니다. 작업은 실행 가능한 일련의 함수이며, 원본 입력은 주로 다음과 같습니다:

  1. 전역 환경의 변수
  2. 인수가 있거나 없는 직접 호출
  3. 파일 시스템 또는 네트워크 요청으로 얻은 값

네트워크 요청은 외부 네트워크, 동일한 네트워크의 다른 애플리케이션 또는 동일한 또는 외부 네트워크의 앱 자체에서 시작된 수신 요청일 수 있습니다.

미들웨어 함수는 다른 함수를 반환하고, 종결자 함수는 콜백을 호출합니다. 다음은 네트워크 또는 파일 시스템 요청에 대한 흐름을 보여줍니다. 여기에서는 이러한 모든 값이 메모리에서 사용 가능하므로 지연 시간이 0입니다.

js
function final(someInput, callback) {
  callback(`${someInput} 및 콜백 실행으로 종료됨 `);
}
function middleware(someInput, callback) {
  return final(`${someInput} 미들웨어에 의해 처리됨 `, callback);
}
function initiate() {
  const someInput = '안녕하세요 이 함수입니다 ';
  middleware(someInput, function (result) {
    console.log(result);
    // 결과를 `반환`하려면 콜백이 필요합니다.
  });
}
initiate();

상태 관리

함수는 상태에 의존적일 수도 있고 그렇지 않을 수도 있습니다. 상태 의존성은 함수의 입력 또는 다른 변수가 외부 함수에 의존할 때 발생합니다.

이러한 방식으로 상태 관리에는 두 가지 주요 전략이 있습니다.

  1. 변수를 함수에 직접 전달하는 방법, 그리고
  2. 캐시, 세션, 파일, 데이터베이스, 네트워크 또는 다른 외부 소스에서 변수 값을 가져오는 방법.

참고로 전역 변수는 언급하지 않았습니다. 전역 변수로 상태를 관리하는 것은 종종 상태를 보장하기 어렵게 만드는 부실한 안티패턴입니다. 복잡한 프로그램에서는 가능한 한 전역 변수를 피해야 합니다.

제어 흐름

객체를 메모리에서 사용할 수 있다면 반복이 가능하며 제어 흐름에 변화가 없을 것입니다.

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} 병의 맥주가 벽에 걸려 있네, 한 병을 꺼내서 돌리면, ${
      i - 1
    } 병의 맥주가 벽에 걸려 있네\n`;
    if (i === 1) {
      _song += "자, 맥주 더 가져오자";
    }
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("노래가 비어있어요, 노래를 넣어주세요!");
  console.log(_song);
}
const song = getSong();
// 이 코드는 작동합니다
singSong(song);

그러나 데이터가 메모리 외부에 있다면 반복은 더 이상 작동하지 않습니다.

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    /* eslint-disable no-loop-func */
    setTimeout(function () {
      _song += `${i} 병의 맥주가 벽에 걸려 있네, 한 병을 꺼내서 돌리면, ${
        i - 1
      } 병의 맥주가 벽에 걸려 있네\n`;
      if (i === 1) {
        _song += "자, 맥주 더 가져오자";
      }
    }, 0);
    /* eslint-enable no-loop-func */
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("노래가 비어있어요, 노래를 넣어주세요!");
  console.log(_song);
}
const song = getSong('맥주');
// 이 코드는 작동하지 않습니다.
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!

왜 이런 일이 일어났을까요? setTimeout은 CPU에게 명령을 버스의 다른 곳에 저장하도록 지시하고, 데이터가 나중에 수집될 예정임을 지시합니다. 함수가 0 밀리초 마크에서 다시 실행되기 전에 수천 번의 CPU 사이클이 지나면 CPU는 버스에서 명령을 가져와 실행합니다. 유일한 문제는 song('')이 수천 사이클 전에 반환되었다는 것입니다.

파일 시스템 및 네트워크 요청을 처리할 때도 동일한 상황이 발생합니다. 메인 스레드는 무기한으로 차단될 수 없습니다. 따라서 콜백을 사용하여 코드를 제어된 방식으로 시간 내에 실행하도록 예약합니다.

다음 세 가지 패턴으로 거의 모든 작업을 수행할 수 있습니다.

  1. 직렬: 함수는 엄격한 순차적 순서로 실행되며, 이는 for 루프와 가장 유사합니다.
js
// 다른 곳에 정의되어 실행 준비가 된 작업
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
  // 함수 실행
  const { args, func } = operation;
  func(args, callback);
}
function serialProcedure(operation) {
  if (!operation) process.exit(0); // 완료됨
  executeFunctionWithArgs(operation, function (result) {
    // 콜백 AFTER 이후 계속
    serialProcedure(operations.shift());
  });
}
serialProcedure(operations.shift());
  1. 완전 병렬: 1,000,000명의 이메일 수신자 목록에 이메일을 보내는 것과 같이 순서가 문제가 되지 않을 때 사용합니다.
js
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: '바트', email: 'bart@tld' },
  { name: '마지', email: 'marge@tld' },
  { name: '호머', email: 'homer@tld' },
  { name: '리사', email: 'lisa@tld' },
  { name: '매기', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
  // `sendEmail`은 가상의 SMTP 클라이언트입니다
  sendMail(
    {
      subject: '오늘 저녁 식사',
      message: '접시에 양배추가 많이 있어요. 오실 건가요?',
      smtp: recipient.email,
    },
    callback
  );
}
function final(result) {
  console.log(`결과: ${result.count}번 시도 \
      & ${result.success}개의 이메일이 성공했습니다.`);
  if (result.failed.length)
    console.log(`다음 주소로 전송하지 못했습니다. \
        \n${result.failed.join('\n')}\n`);
}
recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;
    if (count === recipients.length) {
      final({
        count,
        success,
        failed,
      });
    }
  });
});
  1. 제한된 병렬: 1,000만 명의 사용자 목록에서 1,000,000명의 수신자에게 이메일을 성공적으로 보내는 것과 같이 제한이 있는 병렬입니다.
js
let successCount = 0;
function final() {
  console.log(`${successCount}개의 이메일이 전송되었습니다`);
  console.log('완료되었습니다');
}
function dispatch(recipient, callback) {
  // `sendEmail`은 가상의 SMTP 클라이언트입니다
  sendMail(
    {
      subject: '오늘 저녁 식사',
      message: '접시에 양배추가 많이 있어요. 오실 건가요?',
      smtp: recipient.email,
    },
    callback
  );
}
function sendOneMillionEmailsOnly() {
  getListOfTenMillionGreatEmails(function (err, bigList) {
    if (err) throw err;
    function serial(recipient) {
      if (!recipient || successCount >= 1000000) return final();
      dispatch(recipient, function (_err) {
        if (!_err) successCount += 1;
        serial(bigList.pop());
      });
    }
    serial(bigList.pop());
  });
}
sendOneMillionEmailsOnly();

각각 고유한 사용 사례, 이점 및 문제가 있으며 더 자세히 실험하고 읽어볼 수 있습니다. 가장 중요한 것은 작업을 모듈화하고 콜백을 사용하는 것을 기억해야 한다는 것입니다! 의심스러우면 모든 것을 미들웨어처럼 취급하세요!