Skip to content
This page has been auto-translated and may contain errors.View in English

비동기 JavaScript

docs.scrimba.com

어떤 작업은 즉시 일어납니다. 두 숫자를 더하거나, 변수를 읽거나, 페이지의 텍스트를 바꾸는 일 같은 것들입니다. 하지만 어떤 작업은 시간이 걸립니다. 타이머는 3초 동안 카운트다운을 하고, 서버에 보낸 요청은 네트워크를 거쳐 잠시 후에 돌아옵니다. JavaScript가 이런 작업을 만날 때마다 멈춰서 기다린다면, 페이지 전체가 그대로 멈춰버릴 것입니다. 느린 작업이 끝나기 전까지는 클릭도, 스크롤도, 아무것도 할 수 없게 됩니다. 하지만 JavaScript는 그렇게 동작하지 않습니다. 이 장에서는 JavaScript가 시간이 걸리는 작업을 시작하면서도 페이지를 계속 반응하게 유지하고, 결과가 준비되면 다시 돌아와 처리하는 방법을 다룹니다.

비동기가 필요한 이유

대부분의 JavaScript 코드는 위에서 아래로, 한 줄이 끝나야 다음 줄이 시작되는 방식으로 실행됩니다. 빠르게 처리되는 작업이라면 이 방식으로 충분합니다. 하지만 어떤 작업은 실제로 시간이 걸리는데, JavaScript는 그 작업이 끝날 때까지 가만히 앉아서 기다리지 않습니다. 느린 작업을 시작한 뒤 다음 줄로 넘어가고, 그 작업이 끝나면 다시 돌아와 처리합니다. 이것이 바로 **비동기(asynchronous)**라는 말의 의미입니다. 순서대로, 지금 당장 일어나는 것이 아니라 결과가 준비됐을 때 나중에 일어난다는 뜻입니다.

가장 간단한 예가 타이머입니다. setTimeout은 일정 시간이 지난 뒤 코드를 실행합니다.

js
console.log("Start");
setTimeout(() => {
  console.log("3 seconds later");
}, 3000);
console.log("End");
// Start
// End
// 3 seconds later

순서를 잘 보세요. "End""3 seconds later"보다 먼저 출력되는데, 코드에서는 setTimeout 다음에 나옵니다. JavaScript는 그 줄에서 3초를 기다리지 않았습니다. 타이머를 설정하고 계속 진행한 뒤, 시간이 다 되면 지연된 코드를 실행한 것입니다.

JavaScript는 한 번에 하나씩, 위에서 아래로 실행됩니다. 이 방식은 작업이 빠를 때는 문제가 없습니다. 하지만 타이머, 네트워크 요청, 파일 읽기처럼 시간이 걸리는 작업을 만나면 문제가 생깁니다. 언어가 그런 작업에서 실행을 멈춰버린다면, 그 시간 동안 다른 어떤 것도 실행될 수 없어서 페이지가 그대로 멈춰버립니다. 비동기(Asynchronous) 코드가 그 해결책입니다. 느린 작업을 시작해두면 JavaScript는 나머지 코드를 계속 실행하고, 결과가 도착하면 나중에 처리합니다.

setTimeout이 가장 명확한 첫 번째 예시입니다. 함수와 밀리초 단위의 지연 시간을 넘겨주면, 그 시간이 지난 뒤 함수가 실행됩니다.

js
console.log("Start");
setTimeout(() => {
  console.log("3 seconds later");
}, 3000);
console.log("End");
// Start
// End
// 3 seconds later

setTimeout에 넘기는 함수는 **콜백(callback)**입니다. 지금이 아니라 나중에 호출되도록 넘겨주는 코드라는 뜻입니다. JavaScript는 타이머를 등록한 뒤 곧바로 "End"로 넘어가고, 지연 시간이 다 되고 현재 실행 중인 코드가 끝난 뒤에야 콜백을 실행합니다. "지금은 끝내고 나중에 다시 돌아온다"는 이 패턴이 비동기의 핵심 개념이며, 이 장에서 다루는 나머지 모든 내용이 이 개념 위에 세워집니다.

JavaScript는 단일 스레드로 동작합니다. 콜 스택이 하나뿐이고, 한 번에 하나의 작업만 실행됩니다. 바로 이 제약 때문에 비동기가 중요합니다. 네트워크 요청을 기다리며 블로킹된다면 그 하나뿐인 스레드가 멈춰버리고, 그와 함께 페이지의 모든 클릭, 애니메이션, 렌더링도 멈춰버립니다. 그래서 느린 작업은 코드 흐름 안에서 직접 처리하지 않습니다. 대신 런타임(브라우저나 Node)에 넘겨서, 여러분의 스레드 밖에서 대기를 처리하고 결과가 준비되면 코드를 다시 실행하도록 예약합니다. 비동기(Asynchronous) 코드는 바로 이 위임 과정입니다. 아직 갖고 있지 않은 결과를 가지고 무엇을 할지 미리 기술해두는 것입니다.

setTimeout은 가장 최소한의 예시이면서, 이 모델을 명확하게 드러냅니다.

js
console.log("Start");
setTimeout(() => {
  console.log("later");
}, 0);
console.log("End");
// Start
// End
// later

여기서 지연 시간은 0인데도 콜백은 여전히 마지막에 실행됩니다. 바로 이 점이 핵심입니다. 비동기 콜백은 동기 코드 실행 중간에 끼어드는 법이 없습니다. setTimeout은 0밀리초 뒤에 콜백을 실행하는 것이 아니라, 현재 실행 중인 동기 코드가 모두 끝난 뒤에 콜백이 실행되도록 예약할 뿐입니다. 이 순서를 만들어내는 메커니즘이 이벤트 루프이며, 이 장의 마지막 절에서 자세히 다룹니다. 지금은 이 규칙만 기억해두세요. 동기 코드가 먼저 끝까지 실행되고, 그 다음에 대기 중인 비동기 작업이 실행됩니다.

Juno비동기가 필요한 이유 타이머나 데이터 로딩처럼 시간이 걸리는 작업이 있어도, JavaScript는 기다리는 동안 멈추지 않습니다. 느린 작업을 시작해두고 다음 줄들을 계속 실행하다가, 그 작업이 준비되면 다시 돌아옵니다. 그래서 코드에서는 타이머가 먼저 나오는데도 "End"setTimeout 메시지보다 먼저 출력되는 것입니다.
Juno비동기가 필요한 이유 비동기란 느린 작업을 지금 시작하고, 그 결과는 나중에 처리하며, 그 사이에도 페이지가 계속 반응하도록 유지하는 것입니다. setTimeout이 가장 단순한 예입니다. 콜백을 넘겨주면 지연 시간이 지난 뒤에 그 콜백을 실행하며, 바로 실행하지는 않습니다. 지연된 메시지가 마지막에 출력되는 이유를 이해하면, 이 장의 나머지 내용은 모두 이 아이디어의 변형일 뿐입니다.
Juno비동기가 필요한 이유 JavaScript는 단일 스레드로 동작하므로, 느린 작업에서 블로킹되면 모든 것이 멈춰버립니다. 그래서 대기 작업을 런타임에 넘기고, 결과가 도착했을 때 무엇을 할지 미리 기술해둡니다. setTimeout(fn, 0) 사례가 이를 잘 보여줍니다. 대기 중인 비동기 작업은 현재 코드가 끝나기를 먼저 기다리기 때문에, 콜백은 동기 코드 다음에 실행됩니다.

프로미스

setTimeout은 단순히 지연시키는 용도로는 괜찮지만, 대부분의 비동기 작업은 여러분이 실제로 관심 있는 값을 만들어냅니다. 요청한 데이터일 수도 있고, 잘못됐을 때의 에러일 수도 있습니다. 이럴 때 JavaScript는 **프로미스(promise)**를 사용합니다. 프로미스는 아직 준비되지 않은 값을 위한, 자리가 미리 마련된 값입니다. 영수증과 비슷합니다. 음식을 아직 받지는 않았지만, 나중에 음식으로 바뀔 무언가를 손에 쥐고 있는 것입니다.

프로미스는 두 가지 방식 중 하나로 끝납니다. 작업이 성공하면 값과 함께 resolve되고, 실패하면 에러와 함께 reject됩니다. 각 경우에 무엇을 할지는 .then().catch()로 지정합니다.

js
loadUser()
  .then((user) => {
    console.log(`Loaded ${user.name}`);
  })
  .catch((error) => {
    console.log("Something went wrong");
  });

.then()은 프로미스가 resolve될 때 실행되며 그 값을 받습니다. .catch()는 프로미스가 reject될 때 실행되며 에러를 받습니다. 이 안의 코드는 결과가 도착한 뒤에 나중에 실행됩니다.

콜백은 잘 동작하지만, 쌓이면 문제가 됩니다. 한 비동기 작업이 다른 작업에 의존하고, 그 작업이 또 다른 작업에 의존하다 보면 콜백 안에 콜백이 계속 중첩되면서 한 단계씩 오른쪽으로 밀려나게 됩니다. 이런 모양에는 "지옥의 피라미드(pyramid of doom)"라는 이름까지 붙어 있는데, 읽기도 어렵고 에러를 처리하기는 더 어려워집니다. **프로미스(promise)**는 이 구조 자체를 바꿔줍니다.

프로미스는 아직 준비되지 않은 값을 대신 나타내는 객체입니다. 대기 중(pending, 아직 작업 중), 이행됨(fulfilled, 값과 함께 resolve됨), 거부됨(rejected, 에러와 함께 실패함) 세 가지 상태 중 하나에 있습니다. 성공 시에는 .then(), 실패 시에는 .catch()로 핸들러를 붙입니다.

js
loadUser()
  .then((user) => {
    console.log(`Loaded ${user.name}`);
    return loadPosts(user.id);
  })
  .then((posts) => {
    console.log(`Found ${posts.length} posts`);
  })
  .catch((error) => {
    console.log(`Failed: ${error.message}`);
  });

중첩된 콜백보다 이 방식이 나은 이유는 두 가지입니다. 첫째, 구조가 평평합니다. 각 .then()은 프로미스를 반환하므로 중첩시키는 대신 일직선으로 이어 붙일 수 있습니다. .then()에서 값을 반환하면 다음 .then()이 그 값을 받고, 프로미스를 반환하면 체인이 그 프로미스가 끝날 때까지 기다립니다. 둘째, 체인 끝에 있는 .catch() 하나가 체인 어디에서 실패가 나든 처리해줍니다. 그래서 에러 처리를 매 단계마다 작성할 필요 없이 한 번만 작성하면 됩니다.

프로미스가 등장하기 전에는 비동기 결과가 콜백을 통해 전달됐고, 의존적인 비동기 단계들은 콜백 안에 콜백을 중첩시키는 방식으로 처리해야 했습니다. 가독성 문제를 떠나서, 이 패턴에는 통일된 에러 처리 경로가 없습니다. 각 단계가 자신만의 실패를 처리해야 하고, 한 단계라도 빠뜨리면 에러가 조용히 사라져 버립니다. **프로미스(promise)**는 결국 도착할 결과를 어디든 전달하고, 체인으로 연결하고, 한 곳에서 처리할 수 있는 값으로 만들어서 이 두 문제를 모두 해결합니다.

프로미스는 대기(pending), 값과 함께 이행됨(fulfilled), 이유와 함께 거부됨(rejected) 세 가지 상태 중 하나에 있는 객체입니다. 한 번 확정되면(이행되든 거부되든) 다시는 바뀌지 않습니다. .then(onFulfilled).catch(onRejected)로 반응을 등록하는데, 진짜 강력한 부분은 체이닝에 있습니다.

js
loadUser()
  .then((user) => loadPosts(user.id)) // 프로미스를 반환하면 체인이 그것을 기다림
  .then((posts) => posts.filter((post) => post.published))
  .then((published) => console.log(`${published.length} published`))
  .catch((error) => console.log(`Failed: ${error.message}`));

.then()은 새로운 프로미스를 반환하며, 핸들러의 반환값이 그 프로미스를 결정합니다. 일반 값을 반환하면 다음 .then()이 그 값을 받고, 프로미스를 반환하면 체인이 그것을 받아들여 기다립니다. 어디서든 에러가 던져지거나 reject가 일어나면 나머지 .then() 핸들러를 모두 건너뛰고 다음 .catch()로 곧바로 넘어갑니다. 그래서 끝에 있는 핸들러 하나가 어느 단계의 실패든 잡아낼 수 있는 것입니다. 이것은 단순히 문법이 더 예뻐진 것이 아니라 콜백보다 실질적으로 나아진 부분입니다. 에러가 자동으로 전파되고, 평평한 체인이 실행 순서대로 읽힙니다. 실무에서는 프로미스 체인을 읽을 일이 많지만, 직접 작성하는 비동기 코드는 대부분 async/await로 쓰게 될 것입니다. 이는 바로 다음 절에서 다룰 내용이며, 바로 이 메커니즘 위에 세워져 있습니다.

Juno프로미스 프로미스는 아직 준비되지 않은 값입니다. 나중에 도착할 결과를 위한 영수증 같은 것입니다. 작업이 성공하면 값과 함께 resolve되고, 실패하면 에러와 함께 reject됩니다. 값에 대해서는 .then()으로, 에러에 대해서는 .catch()로 무엇을 할지 지정하며, 둘 다 결과가 도착한 뒤 나중에 실행됩니다.
Juno프로미스 프로미스는 아직 도착하지 않은 결과를 대신 나타내며, 오른쪽으로 계속 밀려나는 중첩 콜백보다 나은 이유는 .then()을 일직선으로 이어 붙일 수 있기 때문입니다. .then()에서 값을 반환하면 다음 것이 그 값을 받고, 프로미스를 반환하면 체인이 기다립니다. 체인 끝의 .catch() 하나가 어디서 실패가 나든 처리해주므로 에러 처리를 한 번만 작성하면 됩니다.
Juno프로미스 프로미스는 한 번만 확정되는 객체입니다. 이행되거나 거부되며, 그 이후에는 다시 바뀌지 않습니다. 각 .then()은 새로운 프로미스를 반환하므로, 값을 반환하면 그대로 전달되고 프로미스를 반환하면 체인이 기다리며, reject가 일어나면 다음 .catch()로 곧바로 넘어갑니다. 체인을 읽을 일은 많겠지만, 직접 작성하는 비동기 코드는 대부분 이 위에 세워진 asyncawait로 쓰게 될 것입니다.

async / await

프로미스 체인은 잘 동작하지만, 거의 평범한 위에서 아래로 흐르는 코드처럼 읽히는 더 깔끔한 작성 방식이 있습니다. async와 **await**라는 두 키워드를 사용합니다.

함수를 async로 표시하면 그 안에서 프로미스를 await할 수 있습니다. await는 프로미스가 resolve될 때까지 함수를 일시 정지시켰다가, 그 값을 바로 건네줍니다. .then()이 필요 없습니다.

js
async function showUser() {
  const user = await loadUser();
  console.log(`Loaded ${user.name}`);
}

위에서 아래로 읽어보세요. 사용자를 가져온 뒤 이름을 출력합니다. await 줄은 loadUser()가 끝나기를 기다렸다가 user를 건네줍니다. 함수는 그 지점에서 일시 정지하지만, 페이지의 나머지 부분은 계속 실행되므로 아무것도 멈추지 않습니다.

에러를 처리하려면 awaittrycatch로 감싸면 됩니다.

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`Loaded ${user.name}`);
  } catch (error) {
    console.log("Could not load the user");
  }
}

프로미스가 실패하면 코드가 멈춰버리는 대신 catch 블록으로 넘어갑니다.

async/await는 비동기 코드를 작성하는 현대적인 방식입니다. 프로미스 위에 세워져 있으므로 내부적으로 바뀌는 것은 없지만, 함수에서 이미 익숙해진 동기 코드처럼 읽히는 비동기 로직을 작성할 수 있게 해줍니다.

함수를 async로 표시하면 그 안에서 **await**를 사용할 수 있습니다. await는 프로미스를 받아서 resolve될 때까지 함수를 일시 정지시키고, resolve된 값으로 평가됩니다.

js
async function showUser() {
  const user = await loadUser();
  const posts = await loadPosts(user.id);
  console.log(`${user.name} has ${posts.length} posts`);
}

앞서 본 .then() 체인과 비교해 보세요. 같은 작업이지만 평평하고 선형적이며, 값들이 일반 const 바인딩에 담겨서 다음 줄에서 바로 사용할 수 있습니다. async 함수는 항상 프로미스 자체를 반환하므로, showUser()를 호출하면 다른 곳에서 await하거나 .then()할 수 있는 프로미스를 얻게 됩니다.

사람들이 흔히 놓치는 부분이 에러 처리입니다. async 함수 안에서 reject된 프로미스는 예외를 던지므로, 평범한 try/catch로 잡을 수 있습니다.

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`Loaded ${user.name}`);
  } catch (error) {
    console.log(`Failed: ${error.message}`);
  }
}

동기 코드의 에러를 처리할 때 쓰는 그 try/catch가 비동기 에러도 그대로 처리해준다는 점이, async/await가 널리 쓰이게 된 큰 이유 중 하나입니다.

async/await는 프로미스 위에 얹힌 문법입니다. async 함수는 항상 프로미스를 반환하며, await는 그것을 풀어냅니다. 함수를 일시 정지시키고, 스레드를 런타임에게 돌려준 뒤, 프로미스가 확정되면 resolve된 값으로 다시 실행을 재개합니다. 기다리는 동안 아무것도 블로킹되지 않습니다. 함수는 정지 상태지만 단일 스레드는 자유롭게 다른 작업을 실행할 수 있으며, 이것이 바로 핵심입니다.

이 방식의 이점은 콜백과 순수 체인이 희생했던 두 가지, 즉 선형적인 읽기 순서와 일반적인 제어 흐름을 되찾을 수 있다는 것입니다. **await**를 사용하면 비동기 값을 마치 동기 값처럼 const, 조건문, 반복문과 함께 쓸 수 있습니다.

js
async function publishReport(userId) {
  const user = await loadUser(userId);
  if (!user.active) {
    return null; // 조기 반환도 정상적으로 동작함
  }
  const posts = await loadPosts(user.id);
  return posts.filter((post) => post.published);
}

에러 처리는 try/catch로 단순해집니다. await된 프로미스가 reject되면 바로 그 await 지점에서 예외가 던져지기 때문입니다.

js
async function publishReport(userId) {
  try {
    const user = await loadUser(userId);
    return await loadPosts(user.id);
  } catch (error) {
    console.log(`Report failed: ${error.message}`);
    return [];
  }
}

새겨둘 만한 미묘한 포인트가 하나 있습니다. try 안에서 return await를 쓰는 것이 중요합니다. await 없이 return loadPosts(...)라고 쓰면, 함수는 프로미스가 확정되기 전에 그 프로미스를 반환하며 try를 빠져나가 버립니다. 그래서 reject가 일어나도 catch를 그냥 빠져나가게 됩니다. await를 쓰면 여전히 try 안에 있는 상태에서 reject가 예외로 던져지므로 catch가 이를 잡아낼 수 있습니다. 이런 세부사항이 "왜 내 에러 핸들러가 동작하지 않았지?" 같은 버그로 이어지곤 하는데, 이벤트 루프와 에러 패턴을 다루는 다음 절에서 더 깊이 살펴봅니다.

Junoasync와 await 함수를 async로 표시하면 그 안에서 프로미스를 await할 수 있습니다. await는 프로미스가 resolve될 때까지 그 함수를 일시 정지시키고, 값을 바로 건네주므로 .then() 없이도 코드가 위에서 아래로 읽힙니다. 실패를 처리하려면 trycatch로 감싸면 되고, 함수가 기다리는 동안에도 페이지는 계속 실행됩니다.
Junoasync와 awaitasync/await는 문법이 더 깔끔해진 프로미스입니다. await는 프로미스가 resolve될 때까지 함수를 일시 정지시키고 그 값을 일반 const로 건네줍니다. async 함수는 항상 프로미스를 반환하므로 다른 곳에서 await할 수 있습니다. 에러는 예외로 던져지므로 평범한 try/catch로 처리할 수 있고, 이것이 이 방식이 널리 쓰이게 된 주된 이유입니다.
Junoasync와 awaitawait는 함수를 일시 정지시키고 스레드를 넘겨준 뒤 resolve된 값으로 다시 실행을 재개하므로, 기다리는 동안 아무것도 블로킹되지 않습니다. 선형적인 읽기, 일반적인 제어 흐름, 에러를 위한 try/catch를 그대로 쓸 수 있습니다. try 안의 return await를 주의하세요. await를 빼면 catch가 잡아내기 전에 reject가 빠져나가 버립니다.

데이터 가져오기

여러분이 작성할 가장 흔한 비동기 작업은 서버에서 데이터를 불러오는 일입니다. 브라우저는 이를 위해 fetch를 제공합니다. URL을 넘겨주면 응답에 대한 프로미스를 반환합니다.

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  const users = await response.json();
  console.log(users);
}

await가 두 번 나오는데, 처음에는 이게 이상해 보일 수 있습니다. 첫 번째는 서버가 응답할 때까지 기다립니다. 두 번째는 그 응답의 본문을 읽어서 .json()으로 JavaScript 데이터로 바꿔주는데, 이 과정 역시 비동기입니다. 그러니까 데이터를 가져오는 일은 두 단계입니다. 응답을 받고, 그다음 그것을 읽는 것입니다.

서버는 "찾을 수 없음" 같은 에러로 응답할 수도 있습니다. fetch는 그런 경우를 자동으로 실패로 취급하지 않으므로, response.ok를 직접 확인해야 합니다.

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    console.log("Request failed");
    return;
  }
  const users = await response.json();
  console.log(users);
}

서버에서 데이터를 불러오는 일은 가장 자주 쓰게 될 비동기 작업이며, 브라우저의 fetch가 그 방법입니다. URL을 넘겨주면 Response 객체로 resolve되는 프로미스를 반환합니다.

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  const users = await response.json();
  return users;
}

두 단계로 이루어진 구조가 기억해둘 핵심입니다. 첫 번째 await는 응답 헤더가 도착하면 resolve되며, Response를 건네줍니다. 이 응답 자체는 데이터가 아니라 데이터에 접근할 수 있는 핸들일 뿐입니다. 본문을 읽는 것은 두 번째 비동기 단계입니다. response.json()은 파싱된 데이터로 resolve되는 프로미스를 반환합니다. 두 번의 await, 두 개의 단계입니다.

모두가 걸려 넘어지는 함정이 있습니다. fetch는 네트워크 실패에서만 reject되고, HTTP 에러 상태 코드에서는 reject되지 않습니다. 404나 500도 여전히 resolve되므로, response.ok를 직접 확인해야 합니다. response.ok는 상태 코드가 200번대일 때 true이므로, !response.ok를 확인하는 것이 잘못된 상태를 실제 에러로 바꿔주는 방법입니다. 이 지점에서 에러를 던져두면, 호출 부분을 감싸는 try/catch 하나로 네트워크 실패와 잘못된 응답을 모두 한 곳에서 처리할 수 있습니다.

fetch(url)은 브라우저의 비동기 HTTP 클라이언트이며, Response로 resolve되는 프로미스를 반환합니다. 이 설계에는 미리 알아둘 만한 날카로운 지점이 하나 있습니다. 이 프로미스는 네트워크 수준의 실패, 즉 연결이 끊기거나 요청이 차단된 경우에만 reject됩니다. 404나 500 같은 HTTP 에러 상태는 fetch 입장에서는 왕복이 성공한 것이므로 정상적으로 resolve됩니다. response.ok를 확인하는 것은 선택이 아닙니다.

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  return await response.json();
}

이 두 단계 구조는 스트리밍 방식을 반영한 것입니다. 첫 번째 await는 본문이 반드시 도착하지 않아도 응답 헤더가 도착하면 확정됩니다. Response는 스트림에 접근할 수 있는 핸들이고, response.json()은 그 스트림을 끝까지 읽어서 파싱합니다. 그래서 이 자체도 비동기이며 자신만의 await가 필요합니다. 함수 안에서 잘못된 상태를 에러로 던져두면, 호출 지점의 try/catch 하나로 네트워크 실패, 잘못된 상태, 파싱에 실패한 JSON이라는 세 가지 다른 실패 방식을 한 핸들러로 모을 수 있습니다.

js
try {
  const users = await loadUsers();
  render(users);
} catch (error) {
  console.log(`Could not load users: ${error.message}`);
}

이것이 fetch의 실질적인 구조 전체입니다. 응답을 위한 프로미스 하나, 상태 확인, 본문을 위한 두 번째 프로미스, 그리고 이 모든 것을 감싸는 하나의 에러 경계입니다. 다음 절에서는 이런 작업 여러 개를 한꺼번에 실행해야 할 때 무슨 일이 벌어지는지 다룹니다.

Juno데이터 가져오기fetch(url)은 서버 응답에 대한 프로미스를 반환하며, 데이터를 가져오는 일은 두 단계입니다. 응답을 위해 await fetch(...)를 하고, 그 데이터를 읽기 위해 await response.json()을 합니다. "찾을 수 없음" 같은 서버 에러는 그 자체로 실패로 처리되지 않으므로, 본문을 읽기 전에 response.ok를 확인하세요. 두 단계 모두 시간이 걸리기 때문에 await를 사용합니다.
Juno데이터 가져오기fetchResponse에 대한 프로미스를 반환하며, response.json()으로 본문을 읽는 것은 두 번째 비동기 단계이므로, 일반적인 fetch에는 두 번의 await가 필요합니다. 함정은 이렇습니다. fetch는 네트워크 실패에서만 reject되므로 404나 500도 여전히 resolve됩니다. response.ok를 확인하고 잘못된 상태에서 에러를 던지면, try/catch 하나로 모든 경우를 처리할 수 있습니다.
Juno데이터 가져오기fetch는 본문이 아니라 헤더가 도착하면 resolve되므로, response.json()은 스트림을 읽는 별도의 await 단계입니다. 네트워크 실패에서만 reject되므로 404도 정상적으로 resolve되며, response.ok를 직접 확인해야 합니다. 잘못된 상태에서 에러를 던져두면, 호출 지점의 try/catch 하나가 네트워크 에러, 잘못된 상태, 파싱 실패를 모두 함께 처리할 수 있습니다.

이벤트 루프와 병렬 작업 실행

지금까지 다룬 모든 내용은 하나의 주제로 통합됩니다. JavaScript는 느린 작업을 시작하고, 계속 진행하다가, 나중에 다시 돌아와 그 작업을 처리합니다. 이 절에서는 그 뒤에 있는 메커니즘과, 여러 비동기 작업을 잘 실행하는 패턴을 다룹니다.

JavaScript가 비동기 작업을 어떻게 추적하는지 그림으로 살펴봅시다. 지금 실행 중인 코드는 콜 스택(call stack), 즉 현재 실행 중인 작업들의 목록에 놓입니다. setTimeout을 호출하면 타이머는 카운트다운을 위해 브라우저에게 넘어가므로, 스택에 남아서 무언가를 막고 있지 않습니다. 타이머가 끝나면 그 콜백은 태스크 큐(task queue), 즉 실행을 기다리는 대기열에 들어갑니다.

**이벤트 루프(event loop)**가 이 둘을 연결해주는 부분입니다. 규칙은 하나입니다. 콜 스택에 있는 모든 것을 먼저 실행하고, 스택이 비었을 때만 큐에서 다음 태스크를 가져옵니다. 그래서 지연 시간이 0이어도 setTimeout(fn, 0)은 여전히 다른 코드보다 나중에 실행됩니다. 콜백은 현재 코드가 끝날 때까지 큐 안에서 기다려야 합니다.

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first
// second
// third

지연 시간 0은 "지금 당장"을 의미하지 않습니다. "현재 코드가 끝나는 즉시"를 의미합니다.

**이벤트 루프(event loop)**는 이 모든 실행 순서를 가능하게 만드는 엔진입니다. 세 부분으로 이루어져 있습니다. 콜 스택(지금 실행 중인 동기 코드), 태스크 큐(차례를 기다리는 비동기 콜백), 그리고 루프 자체입니다. 루프는 한 가지 일만 합니다. 콜 스택이 비면 큐에서 다음 태스크를 가져와서 실행합니다.

이 단 하나의 규칙이 setTimeout(fn, 0)의 실행 순서를 설명해줍니다. 지연 시간은 콜백이 큐에 들어가기까지 기다려야 할 최소 시간일 뿐, 즉시 실행하겠다는 약속이 아닙니다. 큐에 들어간 태스크는 현재 동기 코드가 끝나서 스택이 비기 전까지 실행될 수 없습니다.

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first
// second
// third

서로 독립적인 비동기 작업이 여러 개 있을 때, 하나씩 순서대로 실행하면 시간을 낭비하게 됩니다. 반복문 안에서 await하는 것이 흔히 저지르는 실수입니다.

js
// 느림: 각 요청이 이전 요청이 끝날 때까지 기다림
const users = [];
for (const id of ids) {
  users.push(await loadUser(id));
}

작업들이 서로 의존하지 않는다면, 한꺼번에 시작해서 Promise.all로 모두 함께 기다리세요. Promise.all은 프로미스 배열을 받아서, 각 결과를 담은 배열로 resolve되는 프로미스를 반환합니다.

js
// 빠름: 모든 요청이 동시에 실행됨
const users = await Promise.all(ids.map((id) => loadUser(id)));

독립적인 비동기 작업이 있을 때는 Promise.all을 쓰고, 반복문 안에서 순서대로 await하는 방식은 각 단계가 정말로 이전 단계의 결과를 필요로 할 때만 사용하세요.

**이벤트 루프(event loop)**는 단일 스레드에서 비동기가 가능하도록 만들어주는 스케줄러입니다. 콜 스택은 지금 실행 중인 동기 프레임들을 담고 있습니다. 비동기 콜백은 콜 스택에 직접 올라가지 않고 큐에서 대기합니다. 루프의 규칙은 단순합니다. 콜 스택이 비면 다음 태스크를 큐에서 꺼내 끝까지 실행하고, 이를 반복합니다. 태스크는 중간에 끊기지 않고 실행되므로, 비동기든 아니든 오래 걸리는 동기 작업은 여전히 모든 것을 막아버립니다.

이 모델이 setTimeout(fn, 0)을 완전히 설명해줍니다. 지연 시간은 최소 시간이 지난 뒤 콜백을 큐에 넣는 것일 뿐, 스택이 비기 전까지는 실행될 수 없으므로 항상 동기 코드 뒤에 옵니다.

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first, second, third

마음에 새겨둘 만한 세부 사항이 하나 있습니다. 프로미스 콜백(.then 핸들러와 await 다음의 모든 코드)은 별도의, 더 높은 우선순위를 가진 마이크로태스크 큐에 들어가며, 이 큐는 태스크 사이마다 완전히 비워집니다. 그래서 resolve된 프로미스의 이어지는 코드가 큐에 있는 setTimeout보다 먼저 실행됩니다. 이 부분을 직접 따져야 할 일은 거의 없지만, 간혹 프로미스 코드가 타이머보다 먼저 실행되는 것처럼 보이는 상황을 설명해줍니다.

실질적인 이점은 여러분의 작업을 잘 스케줄링할 수 있다는 것입니다. 독립적인 비동기 작업은 순차적이 아니라 병렬로 실행해야 합니다. 반복문 안에서 await하면 작업이 직렬화됩니다.

js
// 직렬: 전체 시간은 모든 요청 시간의 합
for (const id of ids) {
  results.push(await loadUser(id));
}

// 병렬: 전체 시간은 가장 느린 요청 하나의 시간과 비슷함
const results = await Promise.all(ids.map((id) => loadUser(id)));

Promise.all은 모든 프로미스를 즉시 시작시키고(.map이 이들을 모두 한꺼번에 시작시킵니다), 모두가 확정되면 resolve되며, 하나라도 reject되면 즉시 reject됩니다. 독립적인 작업에는 이것을 쓰고, 어떤 단계가 이전 결과를 필요로 할 때만 순차적인 await를 유지하세요. 경쟁 조건(race condition)에 대해 한 가지 주의할 점이 있습니다. 두 개의 비동기 작업이 같은 상태에 값을 쓰는데 그 순서를 여러분이 통제하지 못한다면, 나중에 끝나는 쪽이 이기게 되며, 그것이 여러분이 나중에 시작한 작업이 아닐 수도 있습니다. 순서가 중요하다면 첫 번째 작업을 await한 뒤 두 번째 작업을 시작하거나, 각 결과에 키를 부여해서 늦게 도착한 결과가 더 최신 결과를 덮어쓰지 못하게 해야 합니다.

Juno이벤트 루프 지금 실행 중인 코드는 콜 스택에 놓이고, 타이머가 끝나면 그 콜백은 태스크 큐에서 대기합니다. 이벤트 루프는 스택에 있는 것을 먼저 모두 실행한 뒤 다음 대기 태스크를 가져오는데, 바로 이 때문에 setTimeout(fn, 0)이 여전히 나머지 코드 뒤에 실행됩니다. 지연 시간 0은 "지금 당장"이 아니라 "현재 코드가 끝나는 즉시"를 의미합니다.
Juno이벤트 루프 이벤트 루프는 콜 스택이 빌 때까지 실행한 뒤 큐에서 다음 태스크를 가져오는데, 바로 이 때문에 setTimeout(fn, 0)이 동기 코드가 끝나기를 기다립니다. 비동기 작업들이 서로 의존하지 않는다면 반복문 안에서 하나씩 await하지 마세요. 한꺼번에 시작해서 Promise.all을 쓰고, 순차적인 await는 어떤 단계가 이전 결과를 필요로 할 때만 유지하세요.
Juno이벤트 루프 루프는 태스크를 끝까지 실행한 뒤 다음 태스크로 넘어가므로, 오래 걸리는 동기 작업은 모든 것을 막아버리고 setTimeout(fn, 0)은 항상 현재 코드 뒤에 옵니다. 프로미스의 이어지는 코드는 태스크 사이마다 더 높은 우선순위의 마이크로태스크 큐에서 비워지는데, 이것이 간혹 .then이 타이머보다 먼저 실행되는 것처럼 보이는 이유입니다. 독립적인 작업은 반복문에서 await하는 대신 Promise.all로 실행하고, 두 작업이 같은 상태를 다룰 때는 순서를 통제하지 않으면 늦게 끝난 쪽이 더 최신 결과를 덮어써 버립니다.

앞으로 비동기가 이어지는 방향

비동기는 느린 작업이 백그라운드에서 일어나는 동안에도 페이지를 살아있게 유지해주는 JavaScript의 부분입니다. 구조는 언제나 같습니다. 시간이 걸리는 작업을 시작하고, 나머지 코드는 계속 실행되게 두고, 결과가 도착하면 처리하는 것입니다. 프로미스는 그 결과를 어디든 전달할 수 있는 값으로 만들어주고, async/await는 그것을 평범한 위에서 아래로 흐르는 코드로 작성할 수 있게 해주며, fetch는 이 모든 것을 가장 많이 사용하게 될 서버에서 데이터를 불러오는 곳입니다.

여기서부터 비동기는 바깥 세계에 반응하는 언어의 다른 부분들과 이어집니다. 가져온 데이터를 DOM을 통해 페이지에 연결하게 될 것이고, 사람들이 페이지에서 하는 행동에 반응해서 비동기 작업을 시작하게 될 텐데, 이것이 바로 이벤트입니다. 돌려받는 결과는 거의 항상 객체이므로, 그것을 읽고 재구성하는 기술이 여기서 배운 모든 것과 짝을 이루는 다음 단계입니다.