JavaScript는 기본적으로 하나의 메인 스레드에서 코드를 실행하는 싱글 스레드 기반 언어다. 그런데도 네트워크 요청, 파일 입출력, 데이터베이스 조회와 같은 작업을 수행하는 동안 다른 작업을 처리할 수 있다.
이러한 동작을 이해하려면 단순히 Promise나 async/await의 문법을 익히는 것만으로는 부족하다. 이벤트 루프(Event Loop), 마이크로태스크(Microtask), 태스크 큐(Task Queue), 비동기 I/O의 실행 구조를 함께 이해해야 한다.
특히 Node.js 기반 게임 서버에서는 데이터베이스 조회, Redis 캐시 접근, WebSocket 메시지 처리, 외부 API 호출 등이 빈번하게 발생한다. 비동기 처리의 원리를 이해하지 못하면 요청 지연, 예외 누락, 상태 변경 경쟁 조건과 같은 문제를 해결하기 어렵다.
Promise와 async/await
Promise란?
Promise는 비동기 작업의 완료 또는 실패 결과를 나타내는 객체다.
일반적인 동기 함수는 실행 결과를 즉시 반환한다. 반면 비동기 작업은 결과가 나중에 도착할 수 있으므로, 결과를 표현하고 후속 작업을 연결할 수 있는 별도의 구조가 필요하다.
Promise는 다음 세 가지 상태를 가진다.
| 상태 | 의미 |
|---|---|
| Pending | 작업이 아직 완료되지 않음 |
| Fulfilled | 작업이 성공적으로 완료됨 |
| Rejected | 작업이 실패함 |
Promise는 Pending 상태에서 Fulfilled 또는 Rejected 상태로 전환된다. 한 번 확정된 상태는 다시 변경되지 않는다.
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("작업 성공");
} else {
reject(new Error("작업 실패"));
}
});
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error(error);
});
resolve()는 Promise를 성공 상태로 전환하고, reject()는 실패 상태로 전환한다.
then()은 성공 결과를 처리하고, catch()는 실패를 처리한다.
다만 Promise를 생성하는 순간 작업이 반드시 비동기로 실행되는 것은 아니다. Promise 생성자 내부의 실행 함수는 즉시 동기적으로 실행된다. 비동기 작업의 완료를 기다리는 후속 콜백이 마이크로태스크로 처리되는 것이다.
console.log("A");
const promise = new Promise((resolve) => {
console.log("B");
resolve("C");
});
promise.then((value) => {
console.log(value);
});
console.log("D");
// 출력
// A
// B
// D
// C
Promise 생성자 내부의 console.log("B")는 즉시 실행된다. 반면 then()에 등록한 콜백은 현재 동기 코드가 끝난 후 실행된다.
Promise 체이닝
Promise의 중요한 특징 중 하나는 비동기 작업을 연속적으로 연결할 수 있다는 점이다.
fetch("/api/user")
.then((response) => {
if (!response.ok) {
throw new Error("사용자 조회 실패");
}
return response.json();
})
.then((user) => {
return fetch(`/api/inventory/${user.id}`);
})
.then((response) => {
if (!response.ok) {
throw new Error("인벤토리 조회 실패");
}
return response.json();
})
.then((inventory) => {
console.log("인벤토리:", inventory);
})
.catch((error) => {
console.error("처리 실패:", error);
});
각 then()은 새로운 Promise를 반환한다. 따라서 이전 단계의 결과를 다음 단계에 전달하면서 작업을 연결할 수 있다.
또한 체인 중간에서 예외가 발생하면 이후 성공 처리 단계는 건너뛰고 가장 가까운 후속 오류 처리 단계로 전달된다.
이 구조는 사용자 조회 이후 인벤토리 조회, 결제 검증 이후 주문 생성처럼 작업 간 선후 관계가 존재하는 비동기 흐름에 유용하다.
async/await란?
async/await는 Promise 기반 비동기 처리를 동기 코드와 유사한 형태로 작성할 수 있도록 해주는 문법이다.
async function getUserInventory(userId) {
const response = await fetch(`/api/inventory/${userId}`);
if (!response.ok) {
throw new Error("인벤토리 조회 실패");
}
const inventory = await response.json();
return inventory;
}
getUserInventory("user-001")
.then((inventory) => {
console.log(inventory);
})
.catch((error) => {
console.error(error);
});
async 함수는 항상 Promise를 반환한다. 함수 내부에서 일반 값을 반환해도 해당 값은 이행된 Promise의 결과가 된다.
await는 Promise가 완료될 때까지 해당 비동기 함수의 다음 실행을 일시 중단한다. 이때 JavaScript 메인 스레드 전체가 멈추는 것은 아니다. 함수의 실행이 중단된 동안 다른 작업이 실행될 수 있다.
예를 들어 데이터베이스 조회를 기다리는 동안 다른 사용자의 WebSocket 메시지를 처리할 수 있다. 단, 이는 해당 데이터베이스 드라이버가 실제로 비동기 I/O를 제공하고, 다른 작업을 막는 동기 코드가 없다는 전제에서 성립한다.
async/await와 Promise의 관계
async/await는 Promise를 대체하는 별개의 비동기 시스템이 아니다. Promise를 기반으로 동작하는 문법이다.
| 구분 | Promise 체이닝 | async/await |
|---|---|---|
| 작성 방식 | then()으로 연결 | await로 순차 표현 |
| 오류 처리 | catch() | try/catch |
| 순차 작업 | 체이닝으로 표현 | 동기 코드처럼 표현 |
| 병렬 작업 | Promise.all() 등 | 동일하게 사용 가능 |
둘 중 어느 방식이 항상 더 빠른 것은 아니다. 일반적으로 작업의 선후 관계가 명확하면 async/await가 읽기 쉽고, 여러 Promise를 조합할 때는 Promise.all()과 같은 기능이 유용하다.
순차 실행과 병렬 실행
비동기 코드에서 자주 발생하는 성능 문제는 서로 독립적인 작업을 불필요하게 순차 실행하는 것이다.
async function loadPlayerData(userId) {
const profile = await getProfile(userId);
const inventory = await getInventory(userId);
const quests = await getQuests(userId);
return { profile, inventory, quests };
}
세 작업이 서로 독립적이라면 첫 번째 작업이 끝난 후 두 번째 작업을 시작할 이유가 없다.
async function loadPlayerData(userId) {
const [profile, inventory, quests] = await Promise.all([
getProfile(userId),
getInventory(userId),
getQuests(userId),
]);
return { profile, inventory, quests };
}
Promise.all()은 전달된 Promise들을 병렬로 진행시키고 모든 작업이 성공하면 결과를 배열로 반환한다.
하나라도 거부되면 반환된 Promise는 거부된다. 다만 나머지 작업이 자동으로 취소되는 것은 아니다.
여기서 병렬 실행은 여러 작업을 동시에 진행한다는 의미다. JavaScript 콜백이 메인 스레드에서 동시에 실행된다는 뜻은 아니다. 실제 I/O의 동시성은 런타임과 해당 API의 구현 방식에 따라 달라진다.
반대로 작업 간 의존성이 있다면 순차 실행이 필요하다.
async function purchaseItem(userId, itemId) {
const player = await getPlayer(userId);
const item = await getItem(itemId);
return createPurchase(player, item);
}
이 경우 getPlayer()와 getItem()이 서로 독립적인지, 이후 작업에 앞선 결과가 필요한지를 확인한 뒤 실행 구조를 결정해야 한다.
이벤트 루프와 비동기 I/O
JavaScript의 실행 구조
JavaScript의 비동기 처리를 이해하려면 다음 구성 요소를 구분해야 한다.
- Call Stack: 현재 실행 중인 함수와 실행 컨텍스트를 관리한다.
- Heap: 객체와 함수 등 동적으로 할당되는 데이터를 저장한다.
- Host APIs: 런타임이 제공하는 타이머, 네트워크, 파일 시스템 등의 기능이다.
- Task Queue: 태스크 실행을 기다리는 작업을 관리한다.
- Microtask Queue: Promise 반응 처리와 같은 마이크로태스크를 관리한다.
- Event Loop: 실행 가능한 작업을 선택해 진행하는 실행 모델의 핵심 요소다.
브라우저와 Node.js는 런타임의 세부 구조가 다르지만, 공통적으로 현재 실행 중인 JavaScript 코드가 끝난 뒤 대기 중인 작업을 처리하는 방식으로 비동기 동작을 제공한다.
이벤트 루프의 동작 원리
다음 코드를 살펴보자.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
일반적인 브라우저 환경에서 출력 순서는 다음과 같다.
A
D
C
B
이유는 다음과 같다.
console.log("A")를 실행한다.setTimeout()의 콜백을 타이머 태스크로 예약한다.- Promise의
then()콜백을 마이크로태스크로 예약한다. console.log("D")를 실행한다.- 현재 동기 실행이 끝나면 마이크로태스크를 처리한다.
- 이후 실행 가능한 타이머 태스크를 처리한다.
setTimeout(callback, 0)은 콜백을 즉시 실행하라는 의미가 아니다. 최소 지연 시간 조건을 만족한 뒤 실행 가능한 태스크로 등록한다는 의미다. 실제 실행 시점은 현재 작업과 런타임의 스케줄링에 따라 달라진다.
비동기 I/O는 어떻게 처리되는가?
네트워크 요청이나 파일 읽기를 수행할 때 JavaScript가 모든 대기 시간을 직접 점유한다면, 서버는 한 작업이 끝날 때까지 다른 요청을 처리하기 어려워진다.
비동기 I/O는 런타임과 운영체제 또는 런타임 내부의 작업 처리 메커니즘을 활용해 이러한 대기를 효율적으로 관리한다.
예를 들어 Node.js에서 파일을 비동기로 읽는 경우를 살펴보자.
const fs = require("node:fs");
console.log("파일 읽기 시작");
fs.readFile("./data.json", "utf8", (error, data) => {
if (error) {
console.error("파일 읽기 실패:", error);
return;
}
console.log("파일 내용:", data);
});
console.log("다른 작업 실행");
가능한 출력 순서는 다음과 같다.
파일 읽기 시작
다른 작업 실행
파일 내용: ...
파일 읽기가 완료되기 전에 다른 JavaScript 코드를 실행할 수 있기 때문이다.
Node.js의 파일 시스템 API는 내부적으로 libuv의 스레드 풀 등을 활용할 수 있다. 네트워크 I/O는 운영체제의 비동기 I/O 및 이벤트 통지 메커니즘 등을 활용한다. 모든 비동기 작업이 동일한 스레드 풀에서 처리되는 것은 아니다.
핵심은 비동기 I/O가 대기 시간을 활용해 다른 작업을 진행할 수 있도록 한다는 것이다.
다만 비동기 함수 내부에서 CPU를 오래 점유하는 계산을 실행하면 이야기가 달라진다.
async function calculate() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
이 함수는 async로 선언되어 있지만 반복문은 동기적으로 실행된다. await가 없는 CPU 집약적인 작업은 메인 스레드를 점유할 수 있다.
따라서 CPU 집약적인 계산은 작업을 분할하거나 Node.js의 worker_threads 등 별도의 실행 수단을 고려해야 한다.
마이크로태스크와 태스크 큐
태스크와 마이크로태스크의 차이
JavaScript 런타임은 모든 비동기 콜백을 하나의 큐에서 동일하게 처리하지 않는다.
브라우저의 일반적인 이벤트 루프에서는 태스크를 처리한 뒤 마이크로태스크를 비우는 동작이 중요하다.
| 구분 | 태스크(Task) | 마이크로태스크(Microtask) |
|---|---|---|
| 대표적인 예 | 타이머 콜백, 일부 이벤트 콜백 | Promise.then(), queueMicrotask() |
| 실행 시점 | 이벤트 루프가 해당 태스크를 선택할 때 | 현재 실행이 끝난 뒤 마이크로태스크 처리 시 |
| 처리 방식 | 태스크 단위로 실행 | 일반적으로 큐가 빌 때까지 처리 |
| 주요 특징 | 다른 태스크와 실행 순서를 조율 | 다음 태스크보다 먼저 처리될 수 있음 |
브라우저에서는 마이크로태스크 큐가 비워진 후 렌더링이 이루어질 수 있다. 마이크로태스크를 무한히 추가하면 렌더링이나 다음 태스크가 지연될 수 있다.
마이크로태스크의 실행 순서
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
queueMicrotask(() => {
console.log("D");
});
console.log("E");
일반적인 브라우저 환경에서 출력 결과는 다음과 같다.
A
E
C
D
B
실행 순서를 분석하면 다음과 같다.
A,E는 동기 코드이므로 먼저 실행된다.C와D는 각각 마이크로태스크 큐에 등록된다.- 먼저 등록된
C가 실행되고 다음으로D가 실행된다. - 이후 타이머 콜백인
B가 실행된다.
여기서 중요한 점은 Promise 콜백이 등록 즉시 실행되지 않는다는 것이다. 현재 실행 중인 동기 코드가 끝난 뒤 실행된다.
마이크로태스크가 마이크로태스크를 생성하는 경우
console.log("A");
Promise.resolve().then(() => {
console.log("B");
Promise.resolve().then(() => {
console.log("C");
});
});
Promise.resolve().then(() => {
console.log("D");
});
console.log("E");
출력 결과는 다음과 같다.
A
E
B
D
C
처음에는 B와 D가 마이크로태스크 큐에 등록된다.
B가 실행되면서 C를 새로 등록한다. 하지만 D는 이미 큐에서 대기 중이므로 C보다 먼저 실행된다.
따라서 마이크로태스크는 하나씩 실행되며, 실행 도중 새롭게 등록된 마이크로태스크도 큐의 순서에 따라 처리된다.
Node.js의 큐 처리에서 주의할 점
Node.js에는 브라우저의 이벤트 루프와 다른 세부 실행 규칙이 존재한다.
예를 들어 Node.js는 process.nextTick()을 위한 별도의 큐를 관리한다. 일반적인 Node.js 환경에서는 process.nextTick() 콜백이 Promise 마이크로태스크보다 먼저 처리되는 경우가 많다.
또한 CommonJS와 ES Modules의 실행 맥락에 따라 관찰되는 순서가 달라질 수 있다.
따라서 브라우저에서 관찰한 실행 순서를 Node.js에 그대로 적용해서는 안 된다. 정확한 동작을 확인하려면 실행 환경과 모듈 시스템을 함께 고려해야 한다.
오류 전파와 예외 처리
비동기 코드는 실행 시점과 오류 발생 시점이 다를 수 있다. 따라서 동기 코드의 try/catch만으로 모든 비동기 오류를 처리할 수 있는 것은 아니다.
try/catch와 await
await를 사용하는 비동기 함수에서는 try/catch로 Promise 거부를 처리할 수 있다.
async function getPlayer(userId) {
try {
const player = await findPlayer(userId);
return player;
} catch (error) {
console.error("플레이어 조회 실패:", error);
throw error;
}
}
findPlayer()가 거부된 Promise를 반환하면 await 표현식에서 예외가 발생한 것처럼 동작한다. 따라서 catch 블록에서 오류를 처리할 수 있다.
여기서 오류를 기록한 뒤 throw error를 실행하면 호출자에게도 오류가 전파된다.
반대로 오류를 기록한 후 아무것도 다시 던지지 않으면 함수는 정상적으로 완료된 것으로 처리될 수 있다. 이 경우 호출자는 조회 실패를 성공과 구분하지 못할 수 있으므로 주의해야 한다.
await 없이 발생하는 비동기 오류
다음 코드는 오류 처리에 실패할 수 있다.
async function loadData() {
try {
fetchData();
} catch (error) {
console.error("오류 발생:", error);
}
}
fetchData()가 나중에 거부되는 Promise를 반환하더라도, await하지 않았기 때문에 해당 거부는 위 try/catch에서 처리되지 않는다.
다음과 같이 작성해야 한다.
async function loadData() {
try {
const data = await fetchData();
return data;
} catch (error) {
console.error("오류 발생:", error);
throw error;
}
}
핵심은 try/catch가 자신이 실행되는 범위에서 발생한 예외와 await를 통해 관찰한 Promise 거부를 처리한다는 점이다.
Promise 체이닝에서의 오류 전파
function processOrder() {
return validateOrder()
.then((order) => {
return saveOrder(order);
})
.then((savedOrder) => {
return publishOrderEvent(savedOrder);
})
.catch((error) => {
console.error("주문 처리 실패:", error);
throw error;
});
}
중간 단계에서 예외가 발생하거나 Promise가 거부되면 이후 성공 처리 단계는 건너뛰고 catch()로 전달된다.
catch() 내부에서 예외를 다시 던지면 반환된 Promise도 거부 상태가 된다. 따라서 최종 호출자도 오류를 처리해야 한다.
processOrder()
.then((result) => {
console.log("주문 처리 결과:", result);
})
.catch((error) => {
console.error("최종 오류 처리:", error);
});
Promise.all의 오류 처리
여러 작업을 동시에 수행하는 경우에도 오류 처리 전략이 필요하다.
async function loadGameData(userId) {
try {
const [profile, inventory, quests] = await Promise.all([
getProfile(userId),
getInventory(userId),
getQuests(userId),
]);
return { profile, inventory, quests };
} catch (error) {
console.error("게임 데이터 로드 실패:", error);
throw error;
}
}
Promise.all()은 입력 Promise 중 하나라도 거부되면 반환된 Promise를 거부한다. 하지만 이미 시작된 나머지 작업을 자동으로 취소하지는 않는다.
각 작업의 성공과 실패를 독립적으로 확인하고 싶다면 Promise.allSettled()를 사용할 수 있다.
async function loadOptionalGameData(userId) {
const results = await Promise.allSettled([
getProfile(userId),
getInventory(userId),
getQuests(userId),
]);
return results.map((result) => {
if (result.status === "fulfilled") {
return {
success: true,
data: result.value,
};
}
return {
success: false,
error: result.reason,
};
});
}
이 방식은 일부 데이터가 실패해도 나머지 결과를 활용해야 하는 경우에 적합하다.
단, 결제 처리나 재화 지급처럼 모든 작업이 성공해야 하는 비즈니스 로직에서는 단순히 실패한 작업만 무시해서는 안 된다. 트랜잭션, 보상 처리, 멱등성 등의 별도 설계가 필요할 수 있다.
오류를 어디서 처리해야 하는가?
오류 처리는 크게 세 가지 관점에서 설계할 수 있다.
하위 계층
데이터베이스 연결 오류나 외부 API 응답 오류를 식별하고, 필요한 경우 기술적인 정보를 추가한다.
서비스 계층
유스케이스를 중단해야 하는지, 재시도해야 하는지, 대체 처리가 가능한지 판단한다.
최상위 경계
HTTP 요청 핸들러나 WebSocket 메시지 핸들러 등에서 처리되지 않은 오류를 기록하고 적절한 오류 응답을 반환한다.
모든 계층에서 동일한 오류를 무조건 잡아서 다시 던지는 것은 불필요하다. 오류를 실제로 처리하거나 의미 있는 정보를 추가하는 지점에서 잡는 편이 낫다.
실시간 게임 서버에서의 활용
Node.js 기반 게임 서버에서는 비동기 처리가 성능과 데이터 정합성에 직접적인 영향을 준다.
데이터베이스 조회와 캐시
async function getPlayerInventory(userId) {
const cacheKey = `inventory:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached);
}
const inventory = await inventoryRepository.findByUserId(userId);
if (inventory === null) {
throw new Error("인벤토리를 찾을 수 없습니다.");
}
await redis.set(
cacheKey,
JSON.stringify(inventory),
"EX",
300
);
return inventory;
}
캐시 조회가 끝난 뒤 데이터베이스를 조회하는 것은 의도적인 순차 처리다. 캐시 적중 여부에 따라 데이터베이스 조회가 필요할지 결정되기 때문이다.
다만 캐시 저장 실패 때문에 정상적인 데이터베이스 조회 결과까지 실패 처리할 것인지, 캐시를 선택적 최적화 계층으로 취급할 것인지는 서비스의 요구사항에 따라 결정해야 한다.
비동기 처리와 경쟁 조건
비동기 함수는 코드가 순차적으로 작성되어 있어도 여러 요청이 서로 교차하여 실행될 수 있다.
let gold = 100;
async function purchaseItem(price) {
if (gold < price) {
throw new Error("골드가 부족합니다.");
}
await savePurchaseLog(price);
gold -= price;
}
두 요청이 거의 동시에 purchaseItem(80)을 호출하면 두 요청 모두 gold가 100이라고 판단할 수 있다. 이후 각각 80을 차감하면서 잘못된 상태가 발생할 수 있다.
이는 await 이후에 다시 실행된다는 사실 자체가 문제가 아니라, 검증과 상태 변경 사이에 다른 요청이 끼어들 수 있다는 점이 문제다.
실제 게임 서버에서는 서버 권한을 유지하면서 재화 검증과 차감을 하나의 원자적 작업으로 처리해야 한다. 예를 들어 데이터베이스 트랜잭션과 조건부 갱신, 적절한 잠금 또는 Redis의 원자적 연산을 활용할 수 있다.
요청 핸들러에서의 오류 처리
async function handlePurchaseRequest(client, message) {
try {
const result = await purchaseItem(
client.userId,
message.itemId
);
client.send("purchase:success", result);
} catch (error) {
console.error("구매 요청 실패:", {
userId: client.userId,
itemId: message.itemId,
error,
});
client.send("purchase:error", {
code: "PURCHASE_FAILED",
message: "구매를 처리하지 못했습니다.",
});
}
}
이 예제는 요청 단위에서 오류를 처리하고 클라이언트에 결과를 전달하는 기본적인 형태다.
실제 운영 환경에서는 내부 오류 메시지를 클라이언트에 그대로 노출하지 않고, 오류 유형에 따라 400, 401, 403, 409, 500 등의 적절한 응답이나 게임 프로토콜 오류 코드를 선택해야 한다.
또한 메시지 핸들러를 등록하는 라이브러리가 반환된 Promise의 거부를 자동으로 처리하는지 확인해야 한다. 비동기 이벤트 핸들러에서 발생한 오류가 호출 측으로 자동 전파된다고 가정해서는 안 된다.