Node.js 개발환경 설정 완전 가이드. TypeScript, ESLint, Prettier 등 실무 필수 개발 도구 8가지를 단계별로 설치하고 설정하는 방법을 초보자도 따라할 수 있게 설명합니다.
Node.js 프로젝트를 시작할 때 어떤 개발 도구를 선택할지는 흔히 부딪히는 의사결정입니다.
이 글에서는 실무에서 검증된 8가지 핵심 도구를 소개하지만, 모든 도구가 모든 프로젝트에 필수는 아닙니다. 프로젝트 규모와 팀 상황에 따라 선택적으로 도입하여 생산성을 높이는 것이 목표입니다.
📦 완성된 샘플 프로젝트: 이 가이드의 모든 설정이 적용된 샘플 프로젝트를 GitHub에서 확인할 수 있습니다.
"JavaScript로도 충분히 개발하고 있는데..." "설정이 복잡할 것 같아서 부담스러워..."
이러한 우려는 자연스럽습니다. 하지만 처음 며칠만 어색하고, 일주일 후엔 없으면 불편해집니다.
작은 프로젝트에서 먼저 시험해보고, 익숙해지면 메인 프로젝트에 적용하세요.
복잡한 타입 시스템
TypeScript의 고급 타입 기능들은 초보자에게 진입 장벽으로 작용할 수 있습니다:
// 복잡한 유틸리티 타입 예시
type ExtractArrayType<T> = T extends (infer U)[] ? U : never;
interface User<T = string> {
id: T;
name: string;
permissions: Array<'read' | 'write' | 'admin'>;
}기존 JavaScript 생태계의 만족도
이미 JavaScript로 안정적인 개발을 하고 있는 팀의 경우, 추가적인 도구 도입의 필요성을 느끼지 못할 수 있습니다. 특히 소규모 프로젝트에서는 TypeScript의 장점이 명확하게 드러나지 않을 수 있습니다.
초기 설정 비용
tsconfig.json 구성, 타입 정의 파일 관리, 빌드 프로세스 통합 등 초기 환경 구축에 소요되는 시간과 노력이 부담으로 작용할 수 있습니다.
정적 타입 검사를 통한 오류 예방
향상된 개발자 경험 (DX)
코드 가독성 및 유지보수성
팀 개발 효율성
학습 곡선 및 도입 비용
설정 및 도구 체인 복잡성
초기 개발 생산성 영향
빌드 성능 오버헤드
단점들이 존재함에도 불구하고 TypeScript를 권장하는 근거:
장기적 개발 효율성
프로덕션 안정성 향상
현대 개발 생태계 표준
JavaScript에서 빈번하게 발생하는 문제들
// 1. 런타임에만 발견되는 오타
function getUserInfo(user) {
return user.naem; // 'name' 오타, 런타임에 undefined 반환
}
// 2. 예상치 못한 타입 변환
function addNumbers(a, b) {
return a + b;
}
addNumbers("5", 3); // "53" (문자열 연결) - 예상: 8
// 3. 객체 구조 변경 시 놓치는 부분들
function processOrder(order) {
return {
total: order.price * order.quantity, // price가 amount로 변경되었는데 놓침
discount: order.discount || 0
};
}TypeScript를 통한 문제 해결
// 1. 컴파일 시점에 오타 발견
interface User {
name: string;
email: string;
}
function getUserInfo(user: User): string {
return user.naem; // ❌ 컴파일 에러: Property 'naem' does not exist
}
// 2. 타입 안전성 보장
function addNumbers(a: number, b: number): number {
return a + b;
}
addNumbers("5", 3); // ❌ 컴파일 에러: Argument of type 'string' is not assignable
// 3. 인터페이스 변경 시 모든 사용처 자동 감지
interface Order {
amount: number; // price에서 amount로 변경
quantity: number;
discount?: number;
}
function processOrder(order: Order) {
return {
total: order.price * order.quantity, // ❌ 컴파일 에러: Property 'price' does not exist
discount: order.discount || 0
};
}실제 사용 전후 비교:
// Before: 불안한 API 호출
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => {
// data가 어떤 구조인지 확신할 수 없음
console.log(data.name); // 혹시 data.username일까?
return data;
});
}
// After: 명확한 타입 정의
interface ApiUser {
id: number;
name: string;
email: string;
createdAt: string;
}
async function fetchUser(id: number): Promise<ApiUser> {
const response = await fetch(`/api/users/${id}`);
const data: ApiUser = await response.json();
console.log(data.name); // ✅ 확실히 존재하는 속성
// data.username; // ❌ 컴파일 에러로 즉시 발견
return data;
}타입 오류가 과도하게 발생하는 경우
TypeScript 도입 초기에는 타입 오류가 많이 발생할 수 있습니다. 이는 기존에 숨어있던 잠재적 버그들이 드러나는 과정으로 이해해야 합니다.
점진적 도입 전략
// 1단계: any 타입으로 시작 (기존 JS 코드 그대로)
function processData(data: any): any {
return data.map((item: any) => item.name);
}
// 2단계: 점진적으로 구체적 타입 추가
function processData(data: any[]): string[] {
return data.map((item: any) => item.name);
}
// 3단계: 완전한 타입 정의
interface DataItem {
name: string;
id: number;
}
function processData(data: DataItem[]): string[] {
return data.map(item => item.name);
}초보자를 위한 단계별 접근법
설치:
pnpm add -D typescript @types/node tsxtsconfig.json:
tip
TypeScript 설정이 복잡하다면 tsconfig/bases에서 프로젝트 타입별 기본 설정을 확인해보세요. Node.js, React, Vue 등 다양한 환경에 최적화된 설정을 제공합니다.{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}주요 설정 설명:
NodeNext도 고려하세요.package.json 스크립트:
{
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}JavaScript/TypeScript 코드의 잠재적 오류를 사전에 감지하고 코드 품질을 향상시키는 정적 분석 도구입니다. 런타임 에러로 이어질 수 있는 문제 패턴을 개발 단계에서 미리 발견하여 안정성을 높입니다.
잠재적 런타임 오류
// 1. 미선언 변수 사용
function calculateTotal() {
return price * quantity; // ❌ price, quantity가 선언되지 않음
}
// 2. 의도하지 않은 전역 변수 생성
function processUser() {
userName = "김철수"; // ❌ var/let/const 없이 할당
}
// 3. 할당과 비교 연산자 혼동
if (user = null) { // ❌ 할당(=) vs 비교(===) 실수
return "사용자 없음";
}
// 4. 접근 불가능한 코드
function checkStatus() {
return true;
console.log("이 코드는 실행되지 않음"); // ❌ Unreachable code
}
// 5. 사용되지 않는 변수 (메모리 누수 가능성)
function getData() {
const unusedData = fetchExpensiveData(); // ❌ 사용되지 않는 변수
const result = fetchOtherData();
return result;
}사전 오류 감지
코드 품질 향상
팀 개발 효율성
초기 설정 복잡성
개발 초기 생산성 영향
도구 의존성
단점들이 존재함에도 불구하고 ESLint를 권장하는 근거:
장기적 코드 품질 향상
프로덕션 안정성
초기 도입 시 고려사항
ESLint 도입 초기에는 기존 코드베이스에서 다수의 린팅 오류가 발생할 수 있습니다. 점진적 접근을 통해 팀의 적응도를 높이면서 규칙을 단계적으로 강화하는 것이 효과적입니다.
단계적 도입 방법론
js.configs.recommended)설치:
pnpm add -D eslint globals @eslint/js typescript-eslinteslint.config.js (Flat Config):
import { defineConfig } from "eslint/config";
import globals from "globals";
import eslintJs from "@eslint/js";
import eslintTs from "typescript-eslint";
export default defineConfig([
{
files: ["**/*.{js,ts}"],
plugins: {
js: eslintJs,
ts: eslintTs.plugin,
},
extends: [
eslintJs.configs.recommended,
eslintTs.configs.recommended,
],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'no-console': 'warn',
// ... 추가 규칙들
},
},
]);package.json 스크립트:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
}개발 초기에 ESLint가 잡아내는 실제 문제들
ESLint는 런타임에서 발생할 수 있는 오류를 개발 단계에서 미리 감지합니다:
// 실제로 자주 발생하는 문제 패턴들
// 1. 가장 흔한 실수: 할당과 비교 혼동
function checkUserPermission(user) {
if (user.role = 'admin') { // ❌ = 대신 === 를 써야 함
return true;
}
return false;
}
// 결과: user.role이 항상 'admin'으로 덮어써져서 모든 사용자가 관리자가 됨!
// 2. 프로덕션에서 터지는 null/undefined 오류
function calculateDiscount(user) {
return user.membership.discount * 100; // ❌ user.membership가 null일 수 있음
}
// ESLint + TypeScript가 사전에 경고
// 3. 스코프 관련 버그
function processItems(items) {
for (var i = 0; i < items.length; i++) {
setTimeout(() => {
console.log(items[i]); // ❌ 항상 마지막 아이템만 출력됨
}, 100);
}
}
// ESLint가 var 사용을 경고하고 let으로 자동 수정
// 4. 의도치 않은 전역 변수 생성
function saveUserData() {
userName = 'John'; // ❌ var/let/const 없이 할당
// 실수로 전역 변수 생성, 다른 코드에 영향줄 수 있음
}
// 5. 접근할 수 없는 코드 (데드 코드)
function validateEmail(email) {
if (!email.includes('@')) {
return false;
console.log('유효하지 않은 이메일'); // ❌ 절대 실행되지 않는 코드
}
return true;
}팀 개발에서 경험하는 변화
코드 리뷰 효율성 향상
개발 단계별 오류 감지 시점 변화
신규 팀원 온보딩 가속화
코드 포맷팅에 소요되는 시간을 최소화하여 핵심 로직 개발에 집중할 수 있도록 지원하는 자동화 도구입니다.
일관성 없는 코드 스타일
팀 개발 환경에서 개발자마다 다른 포맷팅 스타일로 인해 발생하는 문제:
// 수동 포맷팅으로 인한 가독성 저하
function getData(){return fetch('/api').then(res=>res.json()).then(data=>{console.log(data);return data;}).catch(err=>console.error(err));}
// Prettier 적용 후 향상된 가독성
function getData() {
return fetch('/api')
.then(res => res.json())
.then(data => {
console.log(data)
return data
})
.catch(err => console.error(err))
}Prettier 도입의 핵심 가치
개인적 코딩 스타일에 대한 애착
개발자마다 선호하는 코딩 스타일이 있어 자동 포맷팅 적용을 부담스러워할 수 있습니다:
// 개발자 A가 선호하는 스타일
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
};
// 개발자 B가 선호하는 스타일
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
};도구 의존성에 대한 우려
"코드 포맷팅까지 도구에 의존해야 하나?"라는 의구심과 함께 개발 환경의 복잡성 증가를 우려할 수 있습니다.
기존 코드베이스의 대량 변경
Prettier 도입 시 기존 코드의 전면적인 포맷팅 변경으로 인한 Git 히스토리 혼란과 코드 리뷰 부담을 걱정할 수 있습니다.
의사결정 피로도 제거
팀 협업 효율성 극대화
장기적 유지보수성 향상
도구 생태계 호환성
개인 선호도 무시
제한된 설정 옵션
초기 적응 비용
단점들이 존재함에도 불구하고 Prettier를 권장하는 근거:
팀 생산성의 압도적 향상
장기적 관점에서의 이익
현대 개발 환경의 표준
설치:
pnpm add -D prettier eslint-config-prettierprettier.config.js:
export default {
semi: false, // 세미콜론 제거
singleQuote: true, // 싱글 쿼터 사용
trailingComma: 'es5', // 후행 쉼표
tabWidth: 2, // 탭 크기
printWidth: 80, // 줄 길이
bracketSpacing: true, // 객체 괄호 공간
arrowParens: 'avoid', // 화살표 함수 괄호
}ESLint와 통합 (eslint.config.js 수정):
import { defineConfig } from "eslint/config";
import globals from "globals";
import eslintJs from "@eslint/js";
import eslintPrettier from "eslint-plugin-prettier/recommended";
import eslintTs from "typescript-eslint";
export default defineConfig([
{
files: ["**/*.{js,ts}"],
plugins: {
js: eslintJs,
ts: eslintTs.plugin,
},
extends: [
eslintJs.configs.recommended,
eslintTs.configs.recommended,
eslintPrettier,
],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'no-console': 'warn',
// ... 추가 규칙들
// 포맷팅은 Prettier가 처리하므로 관련 ESLint 규칙은 사용하지 않습니다
},
},
]);package.json 스크립트:
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}.vscode/settings.json:
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}코드의 동작을 검증하고 리팩토링 시 안전성을 보장하는 테스트 도구입니다. 중요한 함수부터 간단한 테스트를 추가하면, 리팩토링 시 안심하고 변경할 수 있습니다.
test:ui) 제공으로 피드백 루프가 짧습니다.기존 Jest 테스트도 import 대상만 바꾸면 동작하는 경우가 많습니다:
// 기존 Jest 테스트와 거의 동일
import { describe, it, expect } from "vitest";
describe("calculateTotal", () => {
it("should calculate total price correctly", () => {
const items = [
{ name: "apple", price: 100 },
{ name: "banana", price: 200 },
];
expect(calculateTotal(items)).toBe(300);
});
it("should return 0 for empty array", () => {
expect(calculateTotal([])).toBe(0);
});
it("should handle items without price", () => {
const items = [{ name: "apple" }];
expect(calculateTotal(items)).toBe(0);
});
});테스트 작성이 막막한 경우
처음에는 가장 중요한 함수 하나만 테스트하는 것부터 시작하세요. 완벽한 테스트보다는 간단한 테스트라도 있는 것이 훨씬 효과적입니다.
// 복잡한 테스트 대신
describe("UserService", () => {
beforeEach(() => {
// 복잡한 설정들...
})
it("should handle all edge cases with mocks and spies...", () => {
// 100줄 넘는 테스트 코드
})
})
// 간단한 테스트부터 시작
test("add function works", () => {
expect(add(2, 3)).toBe(5)
})
test("user name validation", () => {
expect(isValidName("김철수")).toBe(true)
expect(isValidName("")).toBe(false)
})"언제 테스트를 써야 할까?"
1단계: 행복한 경우만 (Happy Path)
test("계산기 덧셈", () => {
expect(add(2, 3)).toBe(5)
})2단계: 에러 케이스 추가
test("잘못된 입력 처리", () => {
expect(add("abc", 3)).toBeNaN()
expect(add(null, 3)).toBeNaN()
})3단계: 엣지 케이스 추가
test("극한 값 처리", () => {
expect(add(0, 0)).toBe(0)
expect(add(-1, 1)).toBe(0)
expect(add(Infinity, 1)).toBe(Infinity)
})설치:
pnpm add -D vitest @vitest/ui @vitest/coverage-v8vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts'],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
},
},
},
})note
Vitest 3.x 새로운 기능: Vitest 3.x에서는 개선된 성능, 더 나은 타입 추론, 그리고 새로운pool 옵션으로 테스트 실행 방식을 최적화할 수 있습니다. pool: 'threads'는 멀티스레드로 테스트를 병렬 실행하여 성능을 향상시킵니다.tip
브라우저 환경 테스트: DOM 조작이나 브라우저 API를 테스트해야 한다면environment: 'jsdom'으로 변경하고 pnpm add -D jsdom을 설치하세요. 이렇게 하면 document, window 등의 브라우저 객체를 테스트에서 사용할 수 있습니다.package.json 스크립트:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}// src/services/__tests__/userService.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { UserService } from "../userService";
describe("UserService", () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
it("should create user successfully", async () => {
const userData = {
name: "김철수",
email: "kim@example.com",
};
const user = await userService.createUser(userData);
expect(user.name).toBe("김철수");
expect(user.email).toBe("kim@example.com");
expect(user.isActive).toBe(true);
expect(typeof user.id).toBe("number");
});
it("should throw error for duplicate email", async () => {
const userData = {
name: "김철수",
email: "kim@example.com",
};
await userService.createUser(userData);
await expect(userService.createUser(userData)).rejects.toThrow(
"이미 존재하는 이메일입니다",
);
});
});코드 리뷰 전에 문제 있는 코드가 저장소에 올라가는 것을 방지하는 도구 조합입니다.
# 문제 있는 코드를 커밋하려고 할 때
git add .
git commit -m "add user feature"
# Husky + lint-staged가 자동으로 실행:
# 1. 타입 체크 실행
# 2. 포맷팅 자동 수정
# 3. ESLint 검사 실행
# 4. 테스트 실행 (관련 테스트가 있는 경우)
# 5. 모든 검사 통과해야만 커밋 완료# Husky만 사용하면
git commit → 전체 프로젝트 검사 (느림, 1000개 파일)
# Husky + lint-staged 사용하면
git commit → 변경된 파일만 검사 (빠름, 3개 파일)자동화된 품질 관리
효율적인 검사 범위
팀 협업 효율성
커밋 프로세스 지연
개발 워크플로우 제약
설정 복잡성 및 의존성
"커밋이 막히면 어떡하죠?"
변경된 파일만 검사하며, 자동 수정 가능한 항목은 자동으로 처리됩니다. 치명적 오류만 커밋이 차단됩니다.
긴급 상황 대응 방법:
git commit --no-verify: Git hook을 우회하여 커밋 (임시 방편)git commit -n: 위와 동일한 단축 명령어단점들이 존재함에도 불구하고 도입을 권장하는 근거:
품질 게이트의 자동화
장기적 개발 효율성
팀 확장성
1단계: 기본 설정 (추천)
{
"lint-staged": {
"*.{js,ts}": "prettier --write"
}
}2단계: 린트 추가
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"]
}
}3단계: 완전한 설정 (테스트 포함)
// lint-staged.config.js
export default {
"*.{js,ts}": (stagedFiles) => [
`eslint --fix ${stagedFiles.join(" ")}`,
`prettier --write ${stagedFiles.join(" ")}`,
`vitest related --run ${stagedFiles.join(" ")}`,
],
"*.{json,md,yaml,yml}": "prettier --write",
};설치:
pnpm add -D husky lint-staged
pnpm exec husky initGit Hook 설정:
# .husky/pre-commit 파일 생성
cat > .husky/pre-commit << 'EOF'
pnpm exec tsc --noEmit
pnpm exec lint-staged
EOFpackage.json에 lint-staged 설정:
{
"lint-staged": {
"*.{js,ts}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yaml,yml}": "prettier --write",
"*.ts": "tsc --noEmit"
}
}고급 설정 (별도 파일):
// lint-staged.config.js
export default {
"*.{js,ts}": (stagedFiles) => [
`eslint --fix ${stagedFiles.join(' ')}`,
`prettier --write ${stagedFiles.join(' ')}`,
`vitest related --run ${stagedFiles.join(' ')}`
],
"*.{json,md,yaml,yml}": "prettier --write",
}# 1. 문제 있는 코드와 테스트 작성
const user = {name:"John",age:30} // 포맷팅 문제
const unusedVar = 'hello' // 사용하지 않는 변수
# test 파일도 함께 수정
test('user creation', () => {
expect(createUser()).toBe(true) // 포맷팅 문제
})
# 2. 커밋 시도
git add .
git commit -m "add user feature with tests"
# 3. Husky + lint-staged 자동 실행
✓ Preparing lint-staged...
✓ Running tasks for staged files...
✓ prettier --write on 2 files
✓ eslint --fix on 2 files
✓ vitest run --reporter=verbose --run on test files
✓ Applying modifications from tasks...
✓ Cleaning up temporary files...
# 4. 자동으로 수정된 코드
const user = { name: 'John', age: 30 } // 포맷팅 자동 수정
// unusedVar 삭제됨
test('user creation', () => {
expect(createUser()).toBe(true) // 포맷팅 자동 수정
})
# 5. 모든 검사와 테스트 통과 후 커밋 완료사용자의 실제 브라우저 환경에서 애플리케이션이 올바르게 동작하는지 검증하는 도구입니다.
수동으로 매번 확인하던 것들:
// Playwright로 자동화
import { test, expect } from "@playwright/test";
test("user can login successfully", async ({ page }) => {
await page.goto("/login");
await page.fill("[data-testid=email]", "user@example.com");
await page.fill("[data-testid=password]", "password123");
await page.click("[data-testid=login-button]");
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("[data-testid=username]")).toHaveText("김철수");
});사용자 관점에서의 전체 시스템 검증
단위 테스트와 통합 테스트로는 놓치기 쉬운 실제 사용자 경험을 검증합니다. 개별 컴포넌트는 완벽해도 전체 플로우에서 문제가 발생할 수 있습니다.
단위 테스트로는 검증하기 어려운 것들:
Playwright vs Cypress
Playwright 장점:
Cypress 장점:
// Playwright - 멀티 브라우저 테스트
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } }
]
// Cypress - 단일 브라우저 중심
// 브라우저별 테스트를 위해서는 별도 설정 필요✅ 장점:
실제 브라우저 환경 테스트
// 실제 사용자가 겪을 수 있는 문제들을 사전 발견
test("responsive design works", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 }); // 모바일
await page.goto("/dashboard");
await expect(page.locator(".mobile-menu")).toBeVisible();
});복잡한 사용자 플로우 자동화
// 수동으로 하기 번거로운 반복 테스트 자동화
test("complete shopping flow", async ({ page }) => {
await page.goto("/products");
await page.click("[data-product-id='123']");
await page.click("[data-testid='add-to-cart']");
await page.goto("/cart");
await page.click("[data-testid='checkout']");
// ... 전체 구매 프로세스 검증
});다양한 브라우저 호환성 검증
❌ 단점:
높은 복잡성과 유지보수 비용
긴 실행 시간
불안정성 (Flaky Tests)
await expect(page.locator('.loading')).toBeHidden(); // 타이밍 이슈 가능Playwright가 필요한 경우:
Playwright가 과한 경우:
현실적 조언: 대부분의 경우 Vitest + 통합 테스트로 충분합니다. Playwright는 정말 필요한 핵심 기능에만 선별적으로 적용하세요.
1단계: 핵심 기능 우선
// 가장 중요한 비즈니스 플로우 1-2개부터 시작
test("user login and dashboard access", async ({ page }) => {
await page.goto("/login");
// 로그인 → 대시보드 접근 확인
});2단계: 점진적 확장
// 중요도 순으로 테스트 케이스 추가
test("product purchase flow", async ({ page }) => {
// 상품 구매 전체 프로세스
});3단계: 최적화 및 안정화
// 불안정한 테스트 개선 및 성능 최적화
test.use({
timeout: 30000,
actionTimeout: 5000,
navigationTimeout: 15000
});설치:
pnpm add -D @playwright/test
pnpm exec playwright installplaywright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})// e2e/user-flow.spec.ts
import { test, expect } from "@playwright/test";
test.describe("User Management", () => {
test("should create and display new user", async ({ page }) => {
// 사용자 생성 페이지로 이동
await page.goto("/users/create");
// 폼 작성
await page.fill("[data-testid=name-input]", "김철수");
await page.fill("[data-testid=email-input]", "kim@example.com");
await page.selectOption("[data-testid=role-select]", "admin");
// 저장 버튼 클릭
await page.click("[data-testid=save-button]");
// 성공 메시지 확인
await expect(page.locator(".success-message")).toHaveText(
"사용자가 생성되었습니다",
);
// 사용자 목록 페이지로 이동
await page.goto("/users");
// 새로 생성된 사용자 확인
await expect(page.locator("[data-testid=user-list]")).toContainText(
"김철수",
);
await expect(page.locator("[data-testid=user-list]")).toContainText(
"kim@example.com",
);
});
test("should validate required fields", async ({ page }) => {
await page.goto("/users/create");
// 빈 폼으로 저장 시도
await page.click("[data-testid=save-button]");
// 검증 에러 메시지 확인
await expect(page.locator(".error-message")).toHaveText(
"이름은 필수입니다",
);
await expect(page.locator(".error-message")).toHaveText(
"이메일은 필수입니다",
);
});
});package.json 스크립트:
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
}
}개발, 테스트, 프로덕션 환경마다 다른 데이터베이스 URL, API 키, 포트 번호 등을 안전하게 관리하는 도구입니다.
// 잘못된 방식 - 코드에 직접 하드코딩
const config = {
database: "mongodb://localhost:27017/myapp", // 개발 환경만 가능
jwtSecret: "123456", // 보안에 취약
port: 3000, // 환경별로 다를 수 있음
};// 올바른 방식 - 환경변수로 관리
import { config as dotenvConfig } from "dotenv";
dotenvConfig();
const config = {
database: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: parseInt(process.env.PORT || "3000"),
};코드와 설정의 분리 원칙
애플리케이션 코드는 환경에 무관하게 동일해야 하고, 환경별 차이는 설정으로 관리해야 합니다. 이는 12-Factor App의 핵심 원칙 중 하나입니다.
// ❌ 환경별로 다른 코드를 관리하는 번거로움
if (NODE_ENV === 'development') {
const config = { database: 'mongodb://localhost:27017/myapp_dev' };
} else if (NODE_ENV === 'production') {
const config = { database: 'mongodb://prod-server:27017/myapp' };
}
// ✅ 코드는 동일하고 설정만 변경
const config = { database: process.env.DATABASE_URL };환경변수란?
운영체제가 제공하는 프로그램 실행 시 사용할 수 있는 설정값들입니다. 프로그램 코드와 별도로 관리되어 보안과 유연성을 제공합니다.
// 1단계: 가장 기본적인 사용
const port = process.env.PORT || 3000;
console.log(`서버가 ${port}번 포트에서 실행됩니다`);
// 2단계: 여러 환경변수 사용
const config = {
port: process.env.PORT || 3000,
database: process.env.DATABASE_URL || 'mongodb://localhost:27017/app',
environment: process.env.NODE_ENV || 'development'
};
// 3단계: .env 파일로 관리
// .env 파일에 설정하면 process.env로 자동 로드✅ 장점:
보안성 향상
// API 키, 비밀번호 등을 코드에서 분리
// Git에 올리지 않고 서버에만 설정
const apiKey = process.env.EXTERNAL_API_KEY; // 안전
// const apiKey = 'sk-1234567890abcdef'; // 위험환경별 설정 간소화
// 동일한 코드로 여러 환경 지원
// 개발: .env.development
// 프로덕션: .env.production
// 테스트: .env.test배포 및 설정 관리 용이성
# 서버에서 환경변수만 변경하면 즉시 반영
export DATABASE_URL="mongodb://new-server:27017/app"
# 코드 재배포 불필요팀 협업 효율성
// .env.example로 필요한 설정 명시
// 각 개발자는 자신의 .env 파일 생성
// 설정 충돌 없이 협업 가능❌ 단점:
초기 설정의 복잡성
// 환경변수 개념이 낯선 초보자에게는 진입장벽
// .env 파일, .gitignore 설정 등 추가 학습 필요타입 안전성 부족
// process.env는 모두 string 타입
const port = process.env.PORT; // string | undefined
const portNum = parseInt(process.env.PORT || '3000'); // 변환 필요환경변수 누락 시 런타임 오류
// 설정이 없으면 실행 중에 오류 발생 가능
const dbUrl = process.env.DATABASE_URL; // undefined일 수 있음
// 애플리케이션 시작 시점에 검증 필요dotenv가 필요한 경우:
// 민감한 정보가 있는 경우
const config = {
jwtSecret: process.env.JWT_SECRET, // API 키, 비밀번호
databaseUrl: process.env.DATABASE_URL, // DB 연결 정보
};여러 환경에 배포하는 경우:
팀 프로젝트:
클라우드 배포:
dotenv가 불필요한 경우:
1단계: 기본 포트 설정부터
// 가장 간단한 시작
const PORT = process.env.PORT || 3000;
app.listen(PORT);2단계: 보안 관련 설정 분리
// 민감한 정보를 환경변수로
const config = {
port: process.env.PORT || 3000,
jwtSecret: process.env.JWT_SECRET || 'dev-secret',
};3단계: 완전한 환경별 분리
// 모든 환경 설정을 .env 파일로 관리
import { config as dotenvConfig } from 'dotenv';
dotenvConfig({ path: `.env.${process.env.NODE_ENV}` });1순위 (보안 필수):
2순위 (환경별 차이):
3순위 (편의성):
# ❌ 절대 Git에 올리면 안 되는 것들
.env
.env.local
.env.production
.env.*.local
# ✅ Git에 올려도 되는 것들 (예시용)
.env.example
.env.template필수 보안 수칙:
// ✅ 올바른 예시
const config = {
jwtSecret: process.env.JWT_SECRET || (() => {
throw new Error('JWT_SECRET is required');
})(),
};
// ❌ 잘못된 예시
const config = {
jwtSecret: 'my-super-secret-key-123', // Git에 노출됨
};설치:
pnpm add dotenv
pnpm add -D @types/node환경별 파일 생성:
# .env.development
NODE_ENV=development
DATABASE_URL=mongodb://localhost:27017/myapp_dev
JWT_SECRET=dev-secret-key-12345
PORT=3000
LOG_LEVEL=debug
# .env.production
NODE_ENV=production
DATABASE_URL=mongodb://production-cluster/myapp
JWT_SECRET=super-secure-production-key-67890
PORT=8080
LOG_LEVEL=error
# .env.test
NODE_ENV=test
DATABASE_URL=mongodb://localhost:27017/myapp_test
JWT_SECRET=test-secret
PORT=3001
LOG_LEVEL=silent타입 안전한 환경변수 설정:
// src/config/env.ts
import { config as dotenvConfig } from 'dotenv'
// 환경에 따라 다른 .env 파일 로드
const envFile = `.env.${process.env.NODE_ENV || 'development'}`
dotenvConfig({ path: envFile })
interface Config {
nodeEnv: string
port: number
databaseUrl: string
jwtSecret: string
logLevel: string
}
function validateEnv(): Config {
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'] as const
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Environment variable ${envVar} is required`)
}
}
return {
nodeEnv: process.env.NODE_ENV || 'development',
port: Number.parseInt(process.env.PORT || '3000', 10),
databaseUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
logLevel: process.env.LOG_LEVEL || 'info',
}
}
export const config = validateEnv()실제 사용:
// src/app.ts
import express from 'express'
import morgan from 'morgan'
import { config } from './config/env.js'
const app = express()
// 환경별 설정 적용
if (config.nodeEnv === 'development') {
app.use(morgan('dev'))
} else {
app.use(morgan('combined'))
}
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`)
console.log(`Environment: ${config.nodeEnv}`)
}).gitignore에 추가:
# Environment files
.env
.env.local
.env.development
.env.production
.env.test.env.example (팀원들을 위한 템플릿):
NODE_ENV=development
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-secret-key-here
PORT=3000
LOG_LEVEL=info모든 도구를 함께 사용하는 완전한 설정 예시입니다.
{
"name": "modern-node-project",
"version": "1.0.0",
"type": "module",
"packageManager": "pnpm@10.18.3",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build tsconfig.build.json",
"clean": "tsc --build tsconfig.build.json --clean",
"start": "node dist/index.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "tsc --noEmit",
"prepare": "husky"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@playwright/test": "^1.56.1",
"@tsconfig/node24": "^24.0.1",
"@types/express": "^5.0.3",
"@types/node": "^24.8.1",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.4",
"prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"
}
}모든 도구를 함께 사용하는 완전한 설정 예시입니다. (완성된 프로젝트 보기)
modern-node-project/
├── src/
│ ├── types/
│ │ └── user.ts
│ ├── services/
│ │ ├── userService.ts
│ │ └── __tests__/
│ │ └── userService.test.ts
│ └── index.ts
├── e2e/
│ └── conn.spec.ts
├── .env.example
├── .gitignore
├── .prettierignore
├── .husky/
│ └── pre-commit
├── eslint.config.js
├── lint-staged.config.js
├── package.json
├── playwright.config.ts
├── prettier.config.js
├── tsconfig.json
└── vitest.config.ts
1. 개발 시작:
pnpm dev # tsx로 자동 재시작 개발 서버 실행2. 코드 작성 후:
pnpm lint:fix # ESLint로 코드 검사 및 자동 수정
pnpm format # Prettier로 코드 포맷팅
pnpm type-check # TypeScript 타입 검사3. 테스트 실행:
pnpm test # 단위 테스트
pnpm test:coverage # 커버리지 포함 테스트
pnpm test:e2e # E2E 테스트4. 커밋 시:
git add .
git commit -m "feat: add user management"
# Husky가 자동으로 lint-staged 실행
# 문제 있으면 커밋 차단, 자동 수정 가능하면 수정 후 커밋모든 도구를 한번에 도입하기보다는 프로젝트 상황에 맞게 선택적으로 접근하는 것이 현실적입니다.
TypeScript, ESLint, Prettier
dotenv: 환경 설정이 있는 프로젝트라면 필수 Vitest: 복잡한 로직이나 팀 협업 시 권장 Husky + lint-staged: 팀 프로젝트나 코드 품질이 중요한 경우
Playwright: 사용자 대면 서비스, 중요한 비즈니스 로직이 있는 경우
Playwright 주의사항:
개인 프로젝트/학습용:
팀 프로젝트:
프로덕션 서비스:
개인 학습 프로젝트 (1-2주 진행):
사이드 프로젝트 (1-3개월 진행):
팀 협업 프로젝트:
프로덕션 서비스:
❌ 완벽주의의 함정
❌ 과도한 규칙 설정
❌ 도구 의존성 과다
1단계: 개인 습관 형성 (1-2주)
매일 실천할 것들:
목표: 도구 사용이 자연스러워질 때까지
2단계: 워크플로우 자동화 (2-3주차)
설정할 것들:
목표: 수동 작업 최소화
3단계: 고도화 및 최적화 (1개월 후)
개선할 것들:
목표: 팀 생산성 극대화
Q: TypeScript 도입 후 개발 속도가 느려졌어요
// 해결책 1: strict 모드 점진적 적용
"strict": false, // 처음엔 false로 시작
"noImplicitAny": true, // 하나씩 활성화
// 해결책 2: any 타입 임시 허용
const data: any = fetchData(); // 초기엔 허용, 나중에 개선
// 해결책 3: 유틸리티 타입 활용
type UserInput = Pick<User, 'name' | 'email'>; // 복잡한 타입 간소화Q: ESLint가 너무 까다로워서 개발이 힘들어요
// 해결책 1: 규칙 단계적 적용
"rules": {
"@typescript-eslint/no-unused-vars": "warn", // error → warn
"@typescript-eslint/no-explicit-any": "off" // 임시 비활성화
}
// 해결책 2: 특정 파일/폴더 제외
// .eslintignore
legacy-code/
third-party/Q: 테스트 작성이 어렵고 시간이 오래 걸려요
// 해결책 1: 간단한 함수부터 시작
function add(a: number, b: number) {
return a + b;
}
// 이런 순수 함수부터 테스트 시작
// 해결책 2: 핵심 비즈니스 로직만 우선 테스트
// 모든 코드가 아닌 중요한 기능만 선별적으로
// 해결책 3: 테스트 도구 활용
import { vi } from 'vitest';
const mockFetch = vi.fn(); // mock으로 복잡한 의존성 단순화"설정이 너무 복잡해 보여서 시작하기 어려워요"
→ 해결책: 이 가이드의 설정을 그대로 복사해서 사용하세요. 처음엔 동작 원리를 몰라도 괜찮습니다.
"기존 프로젝트에 적용하기엔 변경 사항이 너무 많아요"
→ 해결책: 새로운 파일부터 적용하고, 기존 파일은 수정할 때만 점진적으로 적용하세요.
"팀원들이 거부감을 보여요"
→ 해결책: 작은 프로젝트나 개인 작업에서 먼저 효과를 보여주고, 자연스럽게 확산시키세요.
"도구 설정에 너무 많은 시간을 쓰는 것 같아요"
→ 해결책: 완벽한 설정보다는 "동작하는 설정"을 목표로 하세요. 개선은 나중에 점진적으로 하면 됩니다.
개발 도구는 목적이 아닌 수단입니다.
이 글을 읽는 것에서 멈추지 마시고, 오늘 당장 한 가지 도구라도 적용해보세요.
작은 시작이 큰 변화를 만듭니다. 완벽하지 않더라도 시작하는 것이 가장 중요합니다.
이 가이드가 여러분의 개발 여정에 도움이 되기를 바랍니다. 더 나은 코드, 더 효율적인 개발, 더 즐거운 협업을 위한 첫걸음을 함께 시작해보세요.