도라의 개발 블로그

소프트웨어 개발/웹

JavaScript 비동기 처리| 이벤트 루프, Promise, async/await, Fetch

boradora 2026. 7. 26. 20:56

작업일2026. 07

이벤트 루프 Promise async / await Fetch JSON 모듈

서버가 하는 일의 대부분은 기다리는 것입니다. DB 응답을 기다리고, 외부 API 응답을 기다리고, 모델 추론 결과를 기다립니다. 그 기다림을 어떻게 다루느냐가 서버가 버티는 요청 수를 결정합니다.

비동기와 모듈은 직무를 가리지 않고 필요한 부분입니다.
화면을 만들든 서버를 만들든, 무언가를 기다렸다가 결과를 받아 처리하는 일은 똑같이 일어납니다.

JavaScript를 브라우저에서 화면 움직이는 언어로만 알고 있으면 이 부분이 왜 중요한지 잘 안 보입니다. 그런데 비동기 처리와 모듈 시스템은 언어를 옮겨도 그대로 따라오는 개념입니다. Python의 asyncio나 Java의 CompletableFuture로도 같은 문제를 풀 수 있습니다. JavaScript는 그 개념이 문법에 가장 노골적으로 드러나 있어서 처음 이해하기에 좋습니다.

그래서 이 글에서는 화면을 다루는 부분은 빼고, 서버에서 데이터를 주고받을 때 쓰는 것을 중심으로 정리했습니다. DOM 조작이나 브라우저 저장소는 다루지 않겠습니다.

앞 글에서 정리한 HTTP 요청과 응답을 이번엔 코드로 직접 주고받아 보겠습니다.

왜 비동기인가

JavaScript는 한 번에 하나의 작업만 실행하는 싱글 스레드입니다. 만약 DB 조회나 외부 API 호출처럼 시간이 오래 걸리는 작업을 끝날 때까지 기다린다면, 그 동안 다른 요청은 처리하지 못합니다. 서버에서 오래 걸리는 작업 대부분은 CPU 연산이 아니라 I/O를 기다리는 시간입니다. 비동기 처리는 이런 작업을 요청한 뒤 기다리지 않고 다른 작업을 먼저 수행하며, 작업이 완료되면 이벤트 루프가 다시 실행 순서를 조정해 이어서 처리합니다.

동기: 앞 작업이 끝나야 다음 작업이 시작됩니다
요청 A
DB 대기 200ms
 
요청 B
 
DB 대기 200ms
 
요청 C
 
DB 대기 200ms
 
총 600ms
 
비동기: 기다리는 동안 다음 요청을 받습니다
요청 A
DB 대기 200ms
 
요청 B
DB 대기 200ms
 
요청 C
DB 대기 200ms
 
총 200ms

동기 방식은 요청마다 대시 시간이 누적되지만, 비동기 방식은 기다리는 시간을 겹쳐 사용해 다른 요청을 처리합니다.

이벤트 루프

그럼 기다리던 작업이 끝났을 때 결과는 어떻게 돌아올까요. 이 흐름을 관리하는 것이 이벤트 루프입니다.

① 콜 스택
지금 실행 중인 코드가 쌓이는 곳. 한 번에 하나만 처리합니다.
② 백그라운드
DB 조회·네트워크 요청·타이머를 대신 기다려 주는 곳. 여기서 시간이 흐릅니다.
③ 큐
끝난 작업의 후속 코드가 줄을 서는 곳.
이벤트 루프: 콜 스택이 비면 큐에서 하나를 꺼내 스택으로 올립니다. 이 확인을 계속 반복합니다.

여기서 비동기의 핵심을 이해할 수 있습니다. 콜 스택이 비어야 큐의 작업이 들어옵니다. 비동기는 시다리는 시간을 다른 요청 처리에 사용할 순 있지만, CPU가 수행하는 연산은 끝날 때까지 콜 스택을 점유하므로 다른 작업을 처리할 수 없습니다. 

순서 따라가 보기

이벤트 루프를 코드로 확인해봅시다. 아래가 어떤 순서로 찍힐지 먼저 예상해 보세요.

console.log("1");
setTimeout(() => console.log("2"), 0);      // 0초인데도 바로 안 나옵니다
Promise.resolve().then(() => console.log("3"));
console.log("4");

결과는 1 → 4 → 3 → 2 입니다. 

1
콜 스택에서 console.log("1") 을 바로 실행합니다.
출력: 1
2
setTimeoutPromise.then지금 실행하지 않고 넘깁니다.
둘 다 큐에서 대기. 이때 console.log("4") 가 실행됩니다  → 출력: 4
3
콜 스택이 비었습니다. 큐에서 꺼낼 차례인데, Promise 쪽 줄이 먼저입니다.
출력: 3
4
Promise 줄이 다 비면 그제야 타이머 줄을 처리합니다.
출력: 2

처음 보면 2가 먼저 출력될 것 같지만, setTimeout(fn, 0) 은 "지금 실행"이 아니라 "나중에 실행해 달라"고 등록만 합니다. Promise 쪽 줄이 타이머 줄보다 우선입니다.

Promise

먼저 어떤 문제를 푸는지부터 보겠습니다. 함수가 DB 조회를 시작했다고 해봅시다. 결과는 200ms 뒤에나 나옵니다. 그럼 이 함수는 지금 당장 무엇을 반환해야 할까요. 빈손으로 돌려보낼 수도 없고, 200ms 동안 붙잡아 둘 수도 없습니다.

그래서 나온 답이 "결과를 담을 빈 상자를 먼저 준다"입니다. 그 상자가 Promise입니다. 호출한 쪽은 일단 상자를 받아 들고 자기 할 일을 합니다. 나중에 결과가 채워지면 그때 꺼내 쓰면 됩니다.

Promise는 비동기 작업의 결과를 나중에 알려주겠다고 약속하는 객체입니다. 결과는 아직 없습니다. 언젠가 성공하거나 실패한다는 사실만 먼저 받아 쥐는 셈입니다.

상태는 세 가지이고, 한 번 정해지면 바뀌지 않습니다.

Pending
대기
시작은 했고 아직 끝나지 않은 상태
Fulfilled
이행
성공. 결과값을 들고 있습니다
Rejected
거부
실패. 에러를 들고 있습니다

직접 만들 때는 resolvereject를 받습니다. 성공하면 resolve, 실패하면 reject를 부릅니다.

const myPromise = new Promise((resolve, reject) => {
  // 시간이 걸리는 작업
  if (성공) resolve(값);     // → .then() 으로 감
  else      reject(에러);    // → .catch() 로 감
});

myPromise
  .then(value => console.log(value))
  .catch(error => console.log(error));
resolvereturn이 아닙니다. 상태와 결과값만 정해 둘 뿐, 함수의 실행 흐름을 끊지 않습니다. resolve() 아래에 코드를 두면 그 코드도 실행됩니다. 끊고 싶으면 return resolve(값)처럼 직접 return을 써야 합니다.

체인을 만들 때는 return이 필요합니다

비동기 작업 세 개를 순서대로 실행해야 하면 .then()을 이어 붙입니다. 이때 안에서 return을 빼먹으면 순서가 보장되지 않습니다.

step1()
  .then(value => { return step2(value); })   // return 필수
  .then(value => { return step3(value); })
  .then(value => console.log(value))
  .catch(error => console.log(error));       // 앞의 어느 단계에서 터져도 여기로

return이 있어야 하는지 짚고 가겠습니다. .then()앞 단계가 돌려준 값을 다음 단계로 넘겨주는 역할을 합니다. 이때 넘어온 것이 Promise면, 그게 끝날 때까지 기다렸다가 다음 .then()을 실행합니다.

return을 빼면 넘어가는 값이 undefined가 됩니다. step2()는 시작만 된 채 아무도 기다려주지 않고, 다음 .then()이 바로 실행됩니다. 그래서 step3step2보다 먼저 끝나는 일이 생깁니다.

.then() 안에 .then()을 중첩하는 방식은 피합니다. 들여쓰기가 깊어지고 에러 처리가 어려워집니다. 이 문제를 문법 차원에서 없앤 것이 다음에 나올 asyncawait입니다.

async와 await

Promise를 사용하면 비동기 작업을 처리할 수 있지만, .then() 이 계속 이어지면 코드가 길어지고 읽기 어려워집니다.

이 문제를 해결하기 위해 나온 것이 async와 await입니다.

awaitPromise가 끝날 때까지 기다렸다가 결과값을 꺼내 줍니다. 덕분에 비동기 코드를 위에서 아래로 자연스럽게 작성할 수 있습니다.

// .then() 체인
function getUser() {
  return fetchUser()
    .then(res => res.json())
    .then(user => fetchOrders(user.id));
}

// async / await : 같은 동작
async function getUser() {
  const res  = await fetchUser();
  const user = await res.json();
  return await fetchOrders(user.id);
}

규칙은 두 가지입니다. awaitasync가 붙은 함수 안에서만 쓸 수 있고, async 함수는 무엇을 반환하든 항상 Promise를 반환합니다.

async function getNumber() {
  return 42;                    // 숫자를 반환한 것 같지만
}

const result = getNumber();
console.log(result);            // Promise { 42 }   ← 숫자가 아닙니다

const value = await getNumber();
console.log(value);             // 42               ← await 를 붙여야 꺼내집니다

그래서 async 함수를 부를 때는 await가 거의 항상 붙어 다닙니다. 이 규칙 하나만 잡고 있으면 바로 아래 함정도 같이 풀립니다.

기다리는 동안 서버는 뭘 하나

여기서 헷갈리기 쉽습니다. await를 만나면 그 자리에서 멈춘다고 했는데, 그럼 서버 전체가 멈추는 걸까요. 아닙니다. 멈추는 것은 그 함수 하나뿐입니다.

조금 더 정확히 말하면 이렇습니다. await를 만나는 순간 JavaScript는 그 함수의 남은 부분을 "결과 오면 이어서 할 일"로 접어서 큐에 맡겨 두고, 콜 스택에서 빠져나옵니다. 스택이 비었으니 그 사이에 들어온 다른 요청을 처리할 수 있습니다. 그러다 결과가 도착하면, 접어뒀던 나머지가 큐에서 꺼내져 멈췄던 자리부터 이어집니다.

앞에서 본 이벤트 루프가 하는 일이 바로 이겁니다. await문법만 동기처럼 보일 뿐 하는 일은 여전히 비동기입니다. 읽기 쉬우라고 모양만 바꾼 것이지, 서버를 멈춰 세우는 게 아닙니다.

에러 처리

.catch() 대신 try / catch를 씁니다. 동기 코드와 같은 방식이라 읽기 편합니다.

async function getUser(id) {
  try {
    const res  = await fetch(`/api/users/${id}`);
    const user = await res.json();
    return user;
  } catch (error) {
    console.error("사용자 조회 실패:", error);
    throw error;             // 삼키지 말고 위로 올립니다
  }
}
await를 빼먹으면 조용히 망가집니다. await 없이 부르면 결과값이 아니라 Promise 객체가 그대로 들어옵니다. 에러도 try / catch에 안 잡힙니다. undefined가 찍히는데 원인을 못 찾겠다면 await부터 확인해 보세요.

동시에 실행하기

await를 줄줄이 쓰면 앞의 것이 끝나야 다음이 시작됩니다. 서로 관계없는 작업이라면 이건 시간 낭비입니다. 백엔드에서 외부 API 여러 개를 부를 때 특히 그렇습니다.

// 순차 : 300ms (100 + 100 + 100)
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

// 병렬 : 100ms (셋을 동시에 시작하고 다 끝나기를 기다림)
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
순차 await 세 번
A 100ms
B 100ms
C 100ms
300ms
Promise.all 로 병렬
A 100ms
B 100ms
C 100ms
 
100ms
메서드 동작 쓰는 곳
Promise.all 하나라도 실패하면 즉시 전체 실패 전부 성공해야 의미 있을 때
Promise.allSettled 실패해도 끝까지 기다리고 결과를 모두 반환 일부 실패를 감수할 때
Promise.race 가장 먼저 끝난 하나만 반환 타임아웃 걸 때

외부 API 세 곳 중 하나가 죽어도 나머지는 보여줘야 하는 상황이라면 Promise.allSettled가 맞습니다. Promise.all을 쓰면 하나 때문에 전부 날아갑니다.

Fetch로 API 호출하기

fetch()는 서버에 HTTP 요청을 보내는 함수입니다. 비동기라서 Promise를 반환합니다. 2편에서 표로 봤던 요청과 응답이 여기서는 코드로 나옵니다.

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

await가 두 번 나오는 이유가 있습니다. 첫 번째는 응답 헤더가 도착할 때까지, 두 번째는 본문을 다 받아 JSON으로 파싱할 때까지 기다립니다. 2편에서 본 헤더와 바디가 따로 오는 구조가 여기 그대로 드러납니다.

404가 나도 catch로 안 갑니다

이게 fetch의 가장 헷갈리는 동작입니다.

성공으로 침
서버가 응답을 보내기만 하면 전부
404 Not Found, 500 Internal Server Error 포함
실패로 침
응답 자체가 오지 못한 경우
네트워크 끊김, DNS 조회 실패, CORS 차단

서버가 "그런 주소 없다"고 답한 것도 서버는 제대로 답한 것이라는 관점입니다. 그래서 상태 코드는 response.ok로 직접 봐야 합니다.

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);

  if (!res.ok) {                                  // 이 줄이 없으면 404를 그냥 지나칩니다
    throw new Error(`요청 실패: ${res.status}`);   // res.status 에 404, 500 이 들어옵니다
  }

  return await res.json();
}

2편에서 본 상태 코드가 res.status에 그대로 들어옵니다. 4xx면 내가 보낸 요청을, 5xx면 상대 서버를 의심하세요.

POST로 보낼 때

기본값은 GET입니다. 데이터를 보내려면 옵션을 넘깁니다.

const res = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },   // 바디 형식을 알려줍니다
  body: JSON.stringify({ name: "보라도라", role: "backend" })
});

body에 객체를 그대로 넣으면 안 됩니다. 네트워크로는 문자열만 오갑니다. 그래서 JSON.stringify()로 바꿔서 실어 보내고, 받는 쪽은 JSON.parse()로 되돌립니다.

함수 방향 결과
JSON.stringify(obj) 객체 → 문자열 '{"name":"보라도라"}'
JSON.parse(str) 문자열 → 객체 { name: "보라도라" }

res.json()이 이 JSON.parse를 대신 해줍니다. 응답이 JSON이 아니면 파싱에 실패하며 에러가 발생합니다.

모듈

코드가 늘어나면 파일을 나누게 됩니다. 모듈은 파일마다 독립된 공간을 주고, 필요한 것만 exportimport로 주고받게 하는 방식입니다. ES6부터 표준입니다.

// math.js : 내보내기
export function add(a, b) {
  return a + b;
}

// main.js : 가져오기
import { add } from './math.js';
const result = add(2, 3);

파일 분리와 모듈의 차이

예전처럼 <script src="file.js">로 파일만 나누면 모든 파일이 전역 공간을 같이 씁니다. 파일은 분리했지만 변수는 한 통에 담겨 있는 셈이라, 이름이 겹치면 그대로 충돌합니다.

일반 외부 JS  전역 공유
// a.js
const count = 10;

// b.js
const count = 20; ← 충돌
모듈  파일마다 격리
// a.js
const count = 10;

// b.js
const count = 20; ← 문제 없음
구분 일반 외부 JS 모듈
스코프 전역 공유 파일마다 격리
이름 충돌 위험 (같으면 버그) 없음
소통 방식 전역 변수를 통해 간접적으로 import / export로 명시적으로
실행 시점 태그를 만나는 즉시 문서를 다 읽은 뒤 (defer)

Named와 Default

내보내는 방식은 두 가지입니다. 섞어 쓸 수도 있습니다.

방식 문법 특징
Named export const foo = ...
import { foo } from './m.js'
여러 개 내보낼 수 있고 이름이 명확합니다. 대신 이름을 정확히 써야 합니다.
Default export default foo
import 아무이름 from './m.js'
가져올 때 이름을 자유롭게 붙일 수 있습니다. 대신 한 파일에 하나만 가능합니다.
// 섞어 쓰기 : 주요 기능은 default, 보조 기능은 named
export const helper = () => {};
export default mainFunction;

import mainFunction, { helper } from './module.js';

둘 중 어느 쪽을 쓸지는 파일의 역할에 따라 결정하면 됩니다. 로그인만 처리하거나 데이터베이스 연결처럼 하나의 대표 기능을 담은 파일이라면 Default, 반면 날짜 변환, 문자열 처리 등 여러 함수나 변수를 함께 관리하는 파일이라면 Named 를 사용하는 경우가 많습니다.

 

정리하면

서버에서 데이터를 다룰 때 기준으로 골라 담으면 이렇습니다.

  • 비동기가 벌어주는 건 기다림(I/O)입니다. CPU를 오래 사용하는 작업은 async를 붙여도 다른 요청을 막습니다.
  • await를 줄줄이 쓰면 그 시간이 전부 더해지기 때문에, 서로 상관없는 호출이라면 Promise.all로 함께 실행하세요.
  • fetch는 HTTP 404나 500을 받아도 성공적으로 응답을 받은 것으로 처리합니다. res.ok로 직접 확인해야 합니다.
  • 네트워크로는 문자열만 오갑니다. 보낼 땐 JSON.stringify, 받을 땐 res.json().
  • 모듈은 파일을 나누는 게 아니라 공간을 나눕니다. 그래서 같은 이름을 써도 안 부딪힙니다.
한 줄 정리: 서버는 기다리는 시간이 대부분이고 → 이벤트 루프가 그 시간을 겹쳐 쓰게 해주며 → Promiseasync/await로 그 흐름을 코드에 적고 → fetchJSON으로 주고받고 → 모듈로 파일을 나눕니다.

참고