작업일2026. 07

서버가 하는 일의 대부분은 기다리는 것입니다. DB 응답을 기다리고, 외부 API 응답을 기다리고, 모델 추론 결과를 기다립니다. 그 기다림을 어떻게 다루느냐가 서버가 버티는 요청 수를 결정합니다.
화면을 만들든 서버를 만들든, 무언가를 기다렸다가 결과를 받아 처리하는 일은 똑같이 일어납니다.
JavaScript를 브라우저에서 화면 움직이는 언어로만 알고 있으면 이 부분이 왜 중요한지 잘 안 보입니다. 그런데 비동기 처리와 모듈 시스템은 언어를 옮겨도 그대로 따라오는 개념입니다. Python의 asyncio나 Java의 CompletableFuture로도 같은 문제를 풀 수 있습니다. JavaScript는 그 개념이 문법에 가장 노골적으로 드러나 있어서 처음 이해하기에 좋습니다.
그래서 이 글에서는 화면을 다루는 부분은 빼고, 서버에서 데이터를 주고받을 때 쓰는 것을 중심으로 정리했습니다. DOM 조작이나 브라우저 저장소는 다루지 않겠습니다.
앞 글에서 정리한 HTTP 요청과 응답을 이번엔 코드로 직접 주고받아 보겠습니다.
왜 비동기인가
JavaScript는 한 번에 하나의 작업만 실행하는 싱글 스레드입니다. 만약 DB 조회나 외부 API 호출처럼 시간이 오래 걸리는 작업을 끝날 때까지 기다린다면, 그 동안 다른 요청은 처리하지 못합니다. 서버에서 오래 걸리는 작업 대부분은 CPU 연산이 아니라 I/O를 기다리는 시간입니다. 비동기 처리는 이런 작업을 요청한 뒤 기다리지 않고 다른 작업을 먼저 수행하며, 작업이 완료되면 이벤트 루프가 다시 실행 순서를 조정해 이어서 처리합니다.
동기 방식은 요청마다 대시 시간이 누적되지만, 비동기 방식은 기다리는 시간을 겹쳐 사용해 다른 요청을 처리합니다.
이벤트 루프
그럼 기다리던 작업이 끝났을 때 결과는 어떻게 돌아올까요. 이 흐름을 관리하는 것이 이벤트 루프입니다.
여기서 비동기의 핵심을 이해할 수 있습니다. 콜 스택이 비어야 큐의 작업이 들어옵니다. 비동기는 시다리는 시간을 다른 요청 처리에 사용할 순 있지만, CPU가 수행하는 연산은 끝날 때까지 콜 스택을 점유하므로 다른 작업을 처리할 수 없습니다.
순서 따라가 보기
이벤트 루프를 코드로 확인해봅시다. 아래가 어떤 순서로 찍힐지 먼저 예상해 보세요.
console.log("1");
setTimeout(() => console.log("2"), 0); // 0초인데도 바로 안 나옵니다
Promise.resolve().then(() => console.log("3"));
console.log("4");
결과는 1 → 4 → 3 → 2 입니다.
console.log("1") 을 바로 실행합니다.
setTimeout 과 Promise.then 은 지금 실행하지 않고 넘깁니다.
console.log("4") 가 실행됩니다 → 출력: 4처음 보면 2가 먼저 출력될 것 같지만, setTimeout(fn, 0) 은 "지금 실행"이 아니라 "나중에 실행해 달라"고 등록만 합니다. Promise 쪽 줄이 타이머 줄보다 우선입니다.
Promise
먼저 어떤 문제를 푸는지부터 보겠습니다. 함수가 DB 조회를 시작했다고 해봅시다. 결과는 200ms 뒤에나 나옵니다. 그럼 이 함수는 지금 당장 무엇을 반환해야 할까요. 빈손으로 돌려보낼 수도 없고, 200ms 동안 붙잡아 둘 수도 없습니다.
그래서 나온 답이 "결과를 담을 빈 상자를 먼저 준다"입니다. 그 상자가 Promise입니다. 호출한 쪽은 일단 상자를 받아 들고 자기 할 일을 합니다. 나중에 결과가 채워지면 그때 꺼내 쓰면 됩니다.
Promise는 비동기 작업의 결과를 나중에 알려주겠다고 약속하는 객체입니다. 결과는 아직 없습니다. 언젠가 성공하거나 실패한다는 사실만 먼저 받아 쥐는 셈입니다.
상태는 세 가지이고, 한 번 정해지면 바뀌지 않습니다.
직접 만들 때는 resolve와 reject를 받습니다. 성공하면 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));
resolve는 return이 아닙니다. 상태와 결과값만 정해 둘 뿐, 함수의 실행 흐름을 끊지 않습니다. 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()이 바로 실행됩니다. 그래서 step3이 step2보다 먼저 끝나는 일이 생깁니다.
.then() 안에 .then()을 중첩하는 방식은 피합니다. 들여쓰기가 깊어지고 에러 처리가 어려워집니다. 이 문제를 문법 차원에서 없앤 것이 다음에 나올 async와 await입니다.
async와 await
Promise를 사용하면 비동기 작업을 처리할 수 있지만, .then() 이 계속 이어지면 코드가 길어지고 읽기 어려워집니다.
이 문제를 해결하기 위해 나온 것이 async와 await입니다.
await는 Promise가 끝날 때까지 기다렸다가 결과값을 꺼내 줍니다. 덕분에 비동기 코드를 위에서 아래로 자연스럽게 작성할 수 있습니다.
// .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);
}
규칙은 두 가지입니다. await는 async가 붙은 함수 안에서만 쓸 수 있고, 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 세 번Promise.all 로 병렬| 메서드 | 동작 | 쓰는 곳 |
|---|---|---|
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의 가장 헷갈리는 동작입니다.
서버가 "그런 주소 없다"고 답한 것도 서버는 제대로 답한 것이라는 관점입니다. 그래서 상태 코드는 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이 아니면 파싱에 실패하며 에러가 발생합니다.
모듈
코드가 늘어나면 파일을 나누게 됩니다. 모듈은 파일마다 독립된 공간을 주고, 필요한 것만 export와 import로 주고받게 하는 방식입니다. 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 | 모듈 |
|---|---|---|
| 스코프 | 전역 공유 | 파일마다 격리 |
| 이름 충돌 | 위험 (같으면 버그) | 없음 |
| 소통 방식 | 전역 변수를 통해 간접적으로 | import / export로 명시적으로 |
| 실행 시점 | 태그를 만나는 즉시 | 문서를 다 읽은 뒤 (defer) |
Named와 Default
내보내는 방식은 두 가지입니다. 섞어 쓸 수도 있습니다.
| 방식 | 문법 | 특징 |
|---|---|---|
| Named | export const foo = ...import { foo } from './m.js' |
여러 개 내보낼 수 있고 이름이 명확합니다. 대신 이름을 정확히 써야 합니다. |
| Default | export default fooimport 아무이름 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(). - 모듈은 파일을 나누는 게 아니라 공간을 나눕니다. 그래서 같은 이름을 써도 안 부딪힙니다.
참고
- MDN: Promise 사용하기
- MDN: Fetch API 사용하기
- MDN: JavaScript 모듈
- W3Schools: JS Async
'소프트웨어 개발 > 웹' 카테고리의 다른 글
| 묵시적 커밋과 AUTO COMMIT | TRUNCATE와 DELETE 차이, 롤백 안 되는 자리 (0) | 2026.08.07 |
|---|---|
| 웹 개발을 위한 네트워크 기초 (0) | 2026.07.26 |
| [개발 기초 정리] HTML·CSS·JavaScript (0) | 2026.07.23 |