Chapter 12

비동기와 모듈

  • 12.1 동기 처리와 비동기 처리
  • 12.2 이벤트 루프와 태스크 큐
  • 12.3 Ajax와 JSON
  • 12.4 REST API
  • 12.5 프로미스의 생성과 상태
  • 12.6 프로미스 체이닝과 정적 메서드
  • 12.7 마이크로태스크 큐와 fetch
  • 12.8 제너레이터와 async/await
  • 12.9 에러 처리
  • 12.10 ES6 모듈(ESM)
  • 12.11 Babel과 Webpack

JS의 비동기 처리 메커니즘부터 프로미스, async/await, 에러 처리, 그리고 모듈 시스템빌드 도구까지 -- 모던 JS 개발의 마무리를 정리해볼게.

JS는 싱글 스레드야. 콜 스택이 딱 하나라서 한 번에 하나의 일만 할 수 있지. 그런데 서버 요청 보내면서 동시에 UI가 안 멈추는 이유는 이벤트 루프 덕분이야. JS 엔진 자체는 싱글 스레드지만, 브라우저(또는 Node.js) 환경은 멀티 스레드야. 비동기 처리는 브라우저가 담당해주는 거지.

**콜 스택(Call Stack)**에 실행 컨텍스트가 쌓이고, **태스크 큐(Task Queue)**에 비동기 함수의 콜백이 대기해. 이벤트 루프가 콜 스택이 비었는지 계속 감시하다가, 비어있으면 태스크 큐의 첫 번째 콜백을 콜 스택으로 옮겨줘.

서버와 비동기로 데이터를 주고받는 기술이 Ajax야. 페이지 전체 리로드 없이 일부만 동적으로 갱신하는 방식이고, 데이터 교환에는 JSON을 써. JSON.stringify로 직렬화, JSON.parse로 역직렬화하면 돼. XMLHttpRequest가 원조지만 실무에서는 fetchaxios를 쓰지.

API를 설계할 때 가장 기본이 되는 원칙이 REST야. URI로 리소스를 표현(명사)하고, HTTP 메서드로 행위를 표현하는 것. GET/POST/PUT/PATCH/DELETE 다섯 가지만 기억하면 돼.

GET    /todos       -> 전체 조회
POST   /todos       -> 새 todo 생성
PATCH  /todos/1     -> id가 1인 todo 수정
DELETE /todos/1     -> id가 1인 todo 삭제

콜백 안에 콜백 안에 콜백... 이른바 콜백 헬을 해결해주는 게 **프로미스(Promise)**야. 상태는 pending(초기), fulfilled(성공), rejected(실패) 세 가지고, 한번 settled되면 다시 안 바뀌어. **then**은 항상 프로미스를 반환해서 체이닝이 가능하고, 에러 처리는 then의 두 번째 인수보다 catch를 쓰는 게 권장돼.

fetch('/api/data')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err))
  .finally(() => console.log('완료'));

정적 메서드도 중요해. **Promise.all**은 모두 fulfilled되면 결과 배열을 반환하고 하나라도 rejected되면 즉시 reject해. **Promise.race**는 가장 먼저 settled된 결과를, **Promise.allSettled**는 모두 settled될 때까지 기다려서 각 결과의 status와 value/reason을 반환해.

면접 단골 문제인 마이크로태스크 큐 -- 프로미스의 후속 처리 메서드 콜백은 일반 태스크 큐가 아니라 마이크로태스크 큐에 들어가고, 우선순위가 더 높아.

setTimeout(() => console.log(1), 0); // 태스크 큐
Promise.resolve().then(() => console.log(2)); // 마이크로태스크 큐
console.log(3);
// 출력: 3 -> 2 -> 1

fetch는 XMLHttpRequest보다 간편하지만 HTTP 에러(404, 500)에서 reject되지 않아. response.ok를 직접 확인해야 하지.

프로미스의 문법적 설탕으로 등장한 async/await는 비동기 코드를 동기처럼 읽을 수 있게 해줘. 사실 제너레이터(function*, yield)에서 발전한 건데, next()에 인수를 전달해서 비동기 처리를 동기처럼 쓸 수 있었던 패턴을 문법 차원에서 깔끔하게 만든 거야.

async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    const user = await response.json();
    return user;
  } catch (err) {
    console.error(err);
  }
}

중요한 건 독립적인 비동기 처리를 순차적으로 await하면 느려진다는 거야. 그럴 때는 Promise.all로 병렬 실행하자.

// 순차 실행 (느림)
const a = await fetch('/api/a');
const b = await fetch('/api/b');

// 병렬 실행 (빠름)
const [a, b] = await Promise.all([fetch('/api/a'), fetch('/api/b')]);

에러 처리도 빼놓을 수 없어. try/catch로 에러를 잡으면 프로그램이 종료되지 않고 계속 실행돼. 에러는 콜 스택의 아래 방향(호출자 방향)으로 전파되고, 어디에서도 잡히지 않으면 프로그램이 종료되지. 비동기 함수의 콜백에서 발생한 에러는 일반 try/catch로 못 잡아 -- 그래서 프로미스의 catch나 async/await 패턴이 필요한 거야.

원래 JS에는 모듈 시스템이 없어서 모든 스크립트가 전역 스코프를 공유했지. ES6에서 드디어 **표준 모듈 시스템(ESM)**이 등장했어. export로 공개하고 import로 가져오는 구조야. named export는 여러 개, default export는 모듈당 하나.

// named export
export const pi = Math.PI;
export function square(x) { return x * x; }

// default export
export default function (x) { return x * x; }

// import
import { pi, square } from './lib.mjs';
import myFunc from './lib.mjs'; // default

코드를 최신 문법으로 짜도 구형 브라우저에서 안 돌아가면 소용없잖아. Babel이 ES6+ 코드를 ES5로 변환해주는 트랜스파일러이고, Webpack은 여러 파일의 의존 관계를 분석해서 번들 파일로 합쳐주는 모듈 번들러야. 요즘은 Vite나 esbuild 같은 차세대 도구들이 대체하고 있지만, entry -> 의존 분석 -> 번들 출력이라는 흐름은 어떤 도구든 동일해.


정리

12장 읽고 기억할 거 세 가지:

  1. 이벤트 루프가 콜 스택과 태스크 큐를 중재해. 마이크로태스크 큐(Promise)가 태스크 큐(setTimeout)보다 우선순위가 높다는 것까지 알면, JS 비동기 처리의 실행 순서를 예측할 수 있어.
  2. async/await는 프로미스의 문법적 설탕이야. 비동기 코드를 동기처럼 읽을 수 있고, try/catch로 에러도 잡을 수 있어. 독립적인 비동기 처리는 Promise.all로 병렬 실행하자.
  3. ESM이 JS의 표준 모듈 시스템이야. export/import로 모듈 스코프를 활용하면 전역 변수 오염이 없어지고, Babel/Webpack(또는 Vite) 같은 빌드 도구로 호환성과 번들링을 해결하면 돼.