개발로그

카드 게임 제작 ( 4 ) - JS 이벤트 루프와 비동기 처리의 이해

ddony8128 2026. 2. 16. 10:56

 카드 게임 제작 기록 처음부터 보기 :  https://helloworld.ai.kr/14

 

카드 게임 제작 ( 1 ) - 회고

게임 링크 : cardgame.perfect.ai.kr 개발 기간 : 2025.10.28 ~ 2025.11.27총 소요 시간 : 110시간Lovable에게 감탄 ( UI 작업 )https://blog.helloworld.ai.kr/15JWT 토큰은 json web token token이다 ( 로그인 구현 )https://blog.helloworl

helloworld.ai.kr

 

게임 링크 : cardgame.perfect.ai.kr

 

Magician Duel

 

cardgame.perfect.ai.kr

 


이 시점의 소요 시간 : 47시간 / 110시간

HTTP API 백엔드 파트 구현 완료 / 통합 테스트
자바스크립트의 이벤트 루프와 비동기 처리 문법에 대한 이해

자바스크립트의 기본적인 원리에 대해 짚고 넘어가는 시간이 반드시 필요했다.


백엔드 REST API 구분

게임 결과와 게임 로그 기록 관련 API를 클라이언트가 접근 가능한 API웹소켓 서버가 접근 가능한 API로 나누었다.

  • 전자: 다른 API들처럼 JWT cookie로 인증
  • 후자: .env의 INTERNAL_SECRET 값을 헤더에 담아서 인증. 웹소켓 서버가 게임 관련 기록을 작성하기 위한 API

백엔드 API 테스트

테스트 러너로 vitest를 활용했다. supabase 부분은 인메모리 객체로 모킹해서 테스트했다.

vitest에는 다음 문법들이 있다.

  • vi.mock : 모듈 모킹
    • 예) vi.mock("../lib/supabase", async () => await import("./__mocks__/supabase.js"));
  • describe : 테스트할 함수들의 모음
  • it : 테스트할 단위 함수 정의
  • expect : 특정 값이 적절한지 검증하는 함수

여기서 삽질 포인트 하나.

  • 테스트 코드에서 모듈을 동적으로 import하는 경우가 많은데,
    이때 상대 경로 확장자로 ts가 아니라 js를 써야 한다.
    import 시점이 js로 변환된 뒤이기 때문.

coverage는 70% 정도까지 채웠다.
테스트 코드가 생기니까 마음이 좀 편해졌다.


선행 세미콜론

;(mod as any).decksService.validateAndHydrate = orig;

이렇게 앞에 세미콜론을 박아둔 코드를 목격했다.
뭔 문법인가 했더니 선행 세미콜론이라는 것이다.

매 줄마다 세미콜론을 쓰지 않을 때,
(, [, { 등으로 시작하는 줄이 이전 줄과 이어져서 해석되는 걸 방지한다.

즉, 매 줄마다 세미콜론을 꼬박꼬박 쓰면 필요 없는 문법이다.

예전에 자바스크립트에서 “세미콜론 쓰지 않기 운동” 하던 사람들이 쓰던 문법이라는데
굳이 이렇게 해야 하나 싶다. 취향 차이겠지.


자바스크립트 이벤트 루프와 비동기 처리

커서가 작성해준 테스트 코드를 보다가
supabase를 모킹하는 객체에서 then 메서드를 정의하는 부분이 이해가 되지 않았다.

그걸 파고들다보니 고구마 줄기마냥

  • promise
  • async / await
  • 콜 스택
  • microtask queue
  • macrotask queue

까지 한꺼번에 딸려나왔다.
아래는 그 대장정을 정리한 내용이다.


1. 자바스크립트 실행 모델: 단일 스레드 + 이벤트 루프

자바스크립트는 단일 스레드 기반 + 이벤트 루프 구조로 동작한다.
I/O, 네트워크, 타이머 같은 작업을 비동기적으로 처리하기 위해 설계된 모델이다.

이벤트 루프는 세 가지 주요 구성 요소를 가진다:

  • 콜 스택(Call Stack): 현재 실행 중인 함수들의 스택 (일반 코드 실행)
  • Microtask Queue: Promise.then, await, queueMicrotask, MutationObserver
  • Macrotask Queue: setTimeout, setInterval, I/O callbacks 등

실행 순서:

  1. 콜 스택의 모든 코드 실행 → 스택이 빔
  2. microtask queue 전부 처리
  3. microtask가 비면 macrotask queue에서 하나 꺼내 실행
  4. 다시 2)로 반복

!!! 콜 스택이 비지 않으면(while(true) 같은 경우) microtask queue는 절대 실행되지 않는다.


2. Promise: “지금은 모르는 값”을 다루는 객체

Promise는 아직 결정되지 않은 값을 표현하는 객체다.
내부적으로 상태(state)와 값(value), 그리고 등록된 콜백 함수들을 가진다.

상태는 세 가지:

  • pending: 아직 결과가 정해지지 않음
  • fulfilled: 성공
  • rejected: 실패/예외

예시:

const p1 = new Promise((resolve, reject) => {
  setTimeout(() => resolve("1초 후 완료"), 1000);
});
  • resolve 호출 전까지 pending
  • resolve(value) → fulfilled(value)
  • reject(error) → rejected(error)

3. .then()과 .catch(): 콜백 등록과 체이닝

.then()은 Promise가 완료된 후 실행할 콜백을 등록한다.

p1.then(onFulfilled, onRejected);
  • onFulfilled: 성공 시
  • onRejected: 실패 시

.catch(onRejected)는 실패만 처리한다.

그리고 .then(), .catch()는 새 Promise를 반환하므로 체이닝 가능.

const p2 = p1
  .then(v => v + 1)
  .catch(err => console.log(err))
  .then(v => v + 1);

규칙:

  • then/catch 내부에서 return된 값은 Promise.resolve(value)로 감싸져 전달
  • throw가 발생하면 Promise.reject(error)로 전달되어 다음 catch로 넘어감

4. 이미 결정된 Promise 만들기

  • Promise.resolve(value) → fulfilled(value)
  • Promise.reject(error) → rejected(error)
Promise.resolve("ok").then(console.log);

콜백 등록 후 실행은 microtask 단계에서 이루어진다.


5. async: 비동기 함수를 만드는 문법

async를 붙이면 그 함수는 항상 Promise를 반환한다.

  • return 값 → Promise.resolve(값)
  • throw 에러 → Promise.reject(에러)
async function test() {
  await Promise.resolve();
  console.log("2");
  return 1;
}
  • 작업 중 pending
  • 완료 시 fulfilled(반환값)
  • 에러 시 rejected(에러)
  • 반환 없으면 Promise.resolve(undefined)

6. await: Promise를 “해소”하고 코드 흐름을 일시 중단

await은 Promise의 결과가 나올 때까지
해당 async 함수의 실행을 일시 중단(suspend)시킨다.

동작:

  • 내부적으로 promise.then(onFulfilled, onRejected) 호출
  • 결과가 나올 때까지 async 함수 실행을 멈춤
  • fulfilled면 값을 반환
  • rejected면 에러를 throw
  • “함수 실행을 멈춘다”는 건, 이벤트 루프의 다음 작업으로 넘어간다는 뜻이다.

문법적으로 await은 async 함수 내부(또는 ESM의 top-level await)에서만 가능.

예시 a)

async function test() {
  await Promise.resolve();
  console.log("2");
  return 1;
}

console.log("1");
const result = test(); // Promise { <pending> }
console.log("3");

출력: 1 3 2

예시 b)

async function test() {
  await Promise.resolve();
  console.log("2");
  return 1;
}

console.log("1");
const result = await test(); // 1
console.log("3");

출력: 1 2 3


7. Thenable: Promise가 아니어도 await 가능한 객체

객체에 .then(onFulfilled, onRejected)가 정의되어 있다면
그 객체는 thenable이며 await 가능하다.

const obj = { then(resolve) { resolve("thenable resolved!"); } };
await obj; // "thenable resolved!"

이유:

  • 내부적으로 Promise.resolve(obj)가 호출될 때 .then()이 자동 실행됨

즉, 비동기가 아닌 작업도 microtask로 보내는 효과를 낼 수 있다.


8. 네트워크 작업과 이벤트 루프

fetch, XHR, 파일 I/O, 타이머 같은 작업은
JS 엔진이 기다리는 게 아니라 런타임(브라우저/Node)의 백그라운드 스레드에서 처리된다.

V8은 단일 스레드지만,
브라우저/Node 런타임은 보조 스레드를 갖고 있어서
네트워크 대기 중에도 이벤트 루프가 계속 돈다.

fetch 예시:

console.log("1");
fetch("https://example.com").then(() => console.log("2"));
console.log("3");

순서:

  1. "1" 출력
  2. fetch 호출 → 네트워크 스레드로 요청 전달
  3. fetch는 pending Promise 즉시 반환
  4. "3" 출력
  5. 응답 도착 → Promise resolve해야 한다는 신호
  6. .then() 콜백이 microtask queue에 등록
  7. 콜 스택이 비면 microtask 실행 → "2" 출력

결과: 1 3 2


9. 메인 코드(script 실행)는 첫 번째 macrotask

자바스크립트 프로그램이 시작할 때,
“메인 코드(script)” 자체가 첫 번째 macrotask로 취급된다.

즉 이벤트 루프 시작 순서:

  • [1️⃣ macrotask #1] 전체 스크립트 실행
    • 동기 코드 실행
    • microtask 등록
    • 콜 스택이 비면 microtask 처리
  • [2️⃣ 다음 macrotask] setTimeout / I/O / 이벤트 등
  • microtask 처리
  • 이후 반복

우리가 작성한 “메인 코드”는
첫 번째 이벤트 루프 틱에서 실행되는 macrotask #1이다.

 


다음 단계

이 당시에 자바스크립트의 이벤트 루프와 비동기에 대해 확실히 이해하고 넘어간 것은 두고두고 도움이 되었다.

REST API 구현을 완료했다.
이제 프론트엔드와 연결 작업을 진행할 단계다.
다음 글 보러 가기 : https://helloworld.ai.kr/18

 

카드 게임 제작 ( 5 ) - zustand는 나가있어

카드 게임 제작 기록 처음부터 보기 : https://helloworld.ai.kr/14 카드 게임 제작 ( 1 ) - 회고게임 링크 : cardgame.perfect.ai.kr 개발 기간 : 2025.10.28 ~ 2025.11.27총 소요 시간 : 110시간Lovable에게 감탄 ( UI 작업 )ht

helloworld.ai.kr