JavaScript 이벤트 루프와 CORS 보안 정책에 대한 포괄적인 면접 준비 가이드
JavaScript 이벤트 루프는 JavaScript 엔진이 비동기 작업을 처리하는 메커니즘으로, Call Stack, Web API, Callback Queue, Microtask Queue를 순환하며 단일 스레드에서 비동기 처리를 가능하게 하는 핵심 구조입니다.
| 구성요소 | 설명 | 역할 |
|---|---|---|
| Call Stack | 함수 호출 스택 | 현재 실행 중인 함수들의 스택 구조 |
| Web API | 브라우저 제공 API | DOM, setTimeout, HTTP 요청 등 비동기 처리 |
| Callback Queue | 콜백 대기열 | 매크로태스크(Macrotask) 대기 공간 |
| Microtask Queue | 마이크로태스크 큐 | Promise, queueMicrotask 등 고우선순위 작업 |
| Event Loop | 이벤트 순환기 | Queue의 작업을 Call Stack으로 이동 |
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Call Stack │ │ Web API │ │ Callback Queue │
│ │ │ │ │ │
│ [executing...] │ │ setTimeout() │ │ [callback1] │
│ [function2] │ │ HTTP Request │ │ [callback2] │
│ [function1] │ │ DOM Events │ │ │
└─────────────────┘ └──────────────┘ └─────────────────┘
↑ ↓ ↑
│ │ │
└───────────Event Loop──────────────────────┘
(Stack이 비어있을 때만 이동)
┌─────────────────┐
│ Microtask Queue │
│ │
│ [Promise.then] │ ← 높은 우선순위 (Callback Queue보다 먼저 처리)
│ [queueMicro] │
└─────────────────┘
console.log('1'); // 동기 - 즉시 실행
setTimeout(() => {
console.log('2'); // 매크로태스크 - 4번째 실행
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 마이크로태스크 - 3번째 실행
});
console.log('4'); // 동기 - 2번째 실행
// 출력 순서: 1 → 4 → 3 → 2
| 구분 | 매크로태스크 | 마이크로태스크 |
|---|---|---|
| 종류 | setTimeout, setInterval, I/O | Promise.then, queueMicrotask, async/await |
| 우선순위 | 낮음 | 높음 |
| 실행 시점 | 한 번에 하나씩 처리 | 큐가 빌 때까지 모두 처리 |
| 예시 | 사용자 이벤트, 네트워크 응답 | Promise 체이닝, DOM 변경 |
// ❌ 나쁜 예: 블로킹 발생
function heavyTask() {
for (let i = 0; i < 1000000; i++) {
// 오래 걸리는 작업
}
}
// ✅ 좋은 예: 작업 분할
function heavyTaskOptimized() {
let i = 0;
function process() {
let start = Date.now();
while (i < 1000000 && Date.now() - start < 16) {
// 16ms 이내로 작업 제한
i++;
}
if (i < 1000000) {
setTimeout(process, 0); // 다음 이벤트 루프에서 계속
}
}
process();
}
// ❌ 나쁜 예: 무한 마이크로태스크
function badMicrotask() {
Promise.resolve().then(badMicrotask); // 무한 루프
}
// ✅ 좋은 예: 적절한 사용
function goodMicrotask() {
Promise.resolve().then(() => {
// 필요한 작업만 수행
console.log('작업 완료');
});
}
"JavaScript 이벤트 루프는 단일 스레드 환경에서 비동기 처리를 가능하게 하는 메커니즘입니다. Call Stack에서 동기 코드를 실행하고, Web API에서 비동기 작업을 처리한 후, Microtask Queue와 Callback Queue를 통해 작업을 순서대로 처리합니다. Microtask(Promise)가 Macrotask(setTimeout)보다 높은 우선순위를 가지며, 이를 통해 논블로킹 비동기 프로그래밍이 가능합니다."
CORS는 웹 브라우저에서 다른 출처(Origin)의 리소스에 접근할 수 있도록 허용하는 보안 정책입니다. Same-Origin Policy의 제약을 안전하게 완화하여, 신뢰할 수 있는 교차 출처 요청을 가능하게 하는 HTTP 헤더 기반 메커니즘입니다.
Origin = Protocol + Host + Port
| 구성요소 | 설명 | 예시 |
|---|---|---|
| Protocol | 통신 프로토콜 | http://, https:// |
| Host | 도메인 또는 IP | example.com, 192.168.1.1 |
| Port | 포트 번호 | :80, :443, :3000 |
| URL | Origin | 동일 출처? | 이유 |
|---|---|---|---|
https://example.com/page1 | 기준 URL | ⭕ | 모든 요소 동일 |
https://example.com:443/page2 | https://example.com | ⭕ | HTTPS 기본 포트 443 |
http://example.com/page3 | http://example.com | ❌ | 프로토콜 다름 (http ≠ https) |
https://sub.example.com/page4 | https://sub.example.com | ❌ | 호스트 다름 |
https://example.com:8080/page5 | https://example.com:8080 | ❌ | 포트 다름 |
Client Server
│ │
│ GET /api/data │
│ Origin: https://client.com │
│ ──────────────────────────► │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: │
│ https://client.com │
│ ◄────────────────────────── │
Client Server
│ │
│ OPTIONS /api/data │ ← 예비 요청
│ Origin: https://client.com │
│ Access-Control-Request-Method │
│ ──────────────────────────► │
│ │
│ 200 OK │ ← 예비 응답
│ Access-Control-Allow-* │
│ ◄────────────────────────── │
│ │
│ POST /api/data │ ← 실제 요청
│ Origin: https://client.com │
│ ──────────────────────────► │
│ │
│ 201 Created │ ← 실제 응답
│ Access-Control-Allow-Origin │
│ ◄────────────────────────── │
GET, HEAD, POSTapplication/x-www-form-urlencodedmultipart/form-datatext/plainPUT, DELETE, PATCH 등application/json, text/xml 등// 클라이언트 설정
fetch('https://api.example.com/data', {
method: 'POST',
credentials: 'include', // 쿠키, 토큰 포함
headers: {
'Content-Type': 'application/json'
}
});
| 헤더 | 설명 | 예시 |
|---|---|---|
Access-Control-Allow-Origin | 허용할 출처 | * 또는 https://example.com |
Access-Control-Allow-Methods | 허용할 HTTP 메서드 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 허용할 요청 헤더 | Content-Type, Authorization |
Access-Control-Allow-Credentials | 인증 정보 허용 여부 | true |
Access-Control-Max-Age | Preflight 캐시 시간 | 86400 (24시간) |
Access-Control-Expose-Headers | 클라이언트 접근 허용 헤더 | X-Total-Count |
| 헤더 | 설명 | 자동 설정 |
|---|---|---|
Origin | 요청 출처 | ✅ 브라우저 자동 |
Access-Control-Request-Method | 실제 요청 메서드 | ✅ Preflight 시 |
Access-Control-Request-Headers | 실제 요청 헤더 | ✅ Preflight 시 |
const express = require('express');
const app = express();
// CORS 미들웨어
app.use((req, res, next) => {
// 특정 출처 허용
res.header('Access-Control-Allow-Origin', 'https://trusted-site.com');
// 또는 동적 출처 허용
const allowedOrigins = ['https://app1.com', 'https://app2.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
}
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400');
// Preflight 요청 처리
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://trusted-site.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
// Express.js CORS 설정
const cors = require('cors');
app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
| 이유 | 설명 | 위험성 |
|---|---|---|
| 보안 정책의 본질 | 서버가 "누구에게 리소스 접근을 허용할지" 결정 | 클라이언트 우회 시 보안 정책 훼손 |
| 브라우저 보안 모델 | Origin 헤더 조작을 브라우저가 자동 차단 | 악성 사이트의 도메인 위장 방지 |
// ❌ 클라이언트에서 Origin 헤더 조작 시도 (불가능)
fetch('https://api.example.com/data', {
headers: {
'Origin': 'https://trusted-site.com' // 브라우저가 무시함
}
});
| 방법 | 서버 측 해결 | 클라이언트 측 해결 |
|---|---|---|
| 지속성 | ✅ 영구적 해결 | ❌ 임시적 해결 |
| 적용 범위 | ✅ 모든 클라이언트 | ❌ 특정 환경만 |
| 유지보수 | ✅ 중앙 집중적 | ❌ 분산된 관리 |
| 프로덕션 적용 | ✅ 가능 | ❌ 불가능 |
// ❌ 프로덕션에서 불가능한 클라이언트 측 해결책들
- Chrome 확장 프로그램: 모든 사용자가 설치할 수 없음
- 브라우저 설정 변경: 일반 사용자에게 요구할 수 없음
- 개발 서버 프록시: 프로덕션 배포 시 의미없음
// ✅ 프로덕션에서 유일한 해결책
res.header('Access-Control-Allow-Origin', 'https://myapp.com');
// ✅ 은행이 승인한 안전한 방식
// "우리 고객센터 웹사이트에서만 계좌 정보 접근 허용"
res.header('Access-Control-Allow-Origin', 'https://bank-customer.com');
// ❌ 클라이언트 측 우회 (위험한 방식)
// "브라우저 설정을 바꿔서 은행 API에 접근하자"
// → 은행의 의도와 무관하게 보안을 우회하는 것
// Vite 개발 서버 프록시 설정
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
});
// JSONP 콜백 방식 (보안상 권장하지 않음)
function handleResponse(data) {
console.log(data);
}
const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleResponse';
document.head.appendChild(script);
// ❌ 위험: 모든 출처 허용
res.header('Access-Control-Allow-Origin', '*');
// ✅ 안전: 특정 출처만 허용
const allowedOrigins = ['https://trusted1.com', 'https://trusted2.com'];
if (allowedOrigins.includes(req.headers.origin)) {
res.header('Access-Control-Allow-Origin', req.headers.origin);
}
// ❌ 에러 발생
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Credentials', 'true');
// ✅ 정확한 설정
res.header('Access-Control-Allow-Origin', 'https://trusted-site.com');
res.header('Access-Control-Allow-Credentials', 'true');
// ✅ 필요한 헤더만 노출
res.header('Access-Control-Expose-Headers', 'X-Total-Count, X-Page-Size');
| 정책 | 목적 | 동작 위치 | 설정 방법 |
|---|---|---|---|
| CORS | 교차 출처 리소스 공유 | 브라우저 | HTTP 헤더 |
| CSP | 콘텐츠 보안 정책 | 브라우저 | HTTP 헤더/메타 태그 |
| SOP | 동일 출처 정책 | 브라우저 | 브라우저 기본 정책 |
| CSRF Protection | 교차 사이트 요청 위조 방지 | 서버 | 토큰 검증 |
"CORS는 웹 브라우저의 Same-Origin Policy를 안전하게 완화하여 다른 출처의 리소스에 접근할 수 있게 하는 보안 정책입니다. 서버에서 Access-Control-Allow-Origin 등의 헤더를 설정하여 신뢰할 수 있는 출처만 허용합니다. Simple Request는 즉시 처리되지만, POST with JSON 같은 복잡한 요청은 Preflight 검사를 통해 사전 확인 후 실제 요청이 전송됩니다. 개발 시에는 프록시 서버를 활용하고, 운영에서는 특정 도메인만 허용하여 보안을 유지하는 것이 중요합니다."
// package.json에서 프록시 설정
{
"name": "my-app",
"proxy": "http://localhost:8080"
}
// 또는 Webpack Dev Server 설정
devServer: {
proxy: {
'/api/*': {
target: 'http://localhost:8080',
secure: false,
changeOrigin: true
}
}
}
// CORS 에러 로깅
window.addEventListener('unhandledrejection', (event) => {
if (event.reason.name === 'TypeError' &&
event.reason.message.includes('CORS')) {
console.error('CORS Error:', event.reason);
// 에러 리포팅 서비스로 전송
}
});
Web Workers는 별도의 백그라운드 스레드에서 스크립트를 실행할 수 있게 해주는 API로, 메인 스레드를 차단하지 않고 무거운 작업을 수행할 수 있습니다.
// main.js (메인 스레드)
const worker = new Worker('worker.js');
// 현대적인 번들러 환경에서는
const worker = new Worker(new URL('./worker.js', import.meta.url));
// worker.js (Worker 스레드)
self.addEventListener('message', function(e) {
const data = e.data;
// 무거운 작업 수행
const result = heavyComputation(data);
// 결과를 메인 스레드로 전송
self.postMessage(result);
});
// 메인 스레드 → Worker
worker.postMessage({
command: 'start',
data: [1, 2, 3, 4, 5]
});
// Worker → 메인 스레드
worker.addEventListener('message', function(e) {
console.log('Worker 결과:', e.data);
});
// Worker에서 메인 스레드로 응답
self.postMessage({
status: 'completed',
result: computedData
});
// Worker 내부에서 사용 가능한 전역 객체
self.console.log('Worker 내부');
self.setTimeout(() => {}, 1000);
self.fetch('https://api.example.com/data');
// ❌ Worker에서 사용 불가능
// document.getElementById() - DOM 접근 불가
// window.location - window 객체 접근 불가
// parent 객체 접근 불가
// main.js - 메인 스레드가 차단되지 않는 방식
const worker = new Worker('fibonacci-worker.js');
document.getElementById('calcBtn').addEventListener('click', () => {
document.getElementById('result').innerText = '계산 중...';
worker.postMessage(45); // Worker에게 숫자 전달
});
worker.onmessage = function(e) {
document.getElementById('result').innerText = `결과: ${e.data}`;
};
// fibonacci-worker.js
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
onmessage = function(e) {
const result = fibonacci(e.data);
postMessage(result); // 결과를 메인 스레드로 전달
};
// Comlink를 사용한 RPC 스타일 통신
import { wrap } from 'comlink';
const worker = new Worker('math-worker.js');
const mathWorker = wrap(worker);
// 프로미스 기반으로 간편하게 사용
const result = await mathWorker.calculatePi(1000000);
console.log('π 계산 결과:', result);
// math-worker.js
import { expose } from 'comlink';
const api = {
async calculatePi(iterations) {
// 몬테카를로 방법으로 π 계산
let insideCircle = 0;
for (let i = 0; i < iterations; i++) {
const x = Math.random() * 2 - 1;
const y = Math.random() * 2 - 1;
if (x * x + y * y <= 1) insideCircle++;
}
return (insideCircle / iterations) * 4;
}
};
expose(api);
// ⚠️ SharedArrayBuffer는 Spectre/Meltdown 보안 이슈로 인해
// Cross-Origin-Embedder-Policy와 Cross-Origin-Opener-Policy 헤더가 필요
// 서버에서 다음 헤더 설정 필요:
// Cross-Origin-Embedder-Policy: require-corp
// Cross-Origin-Opener-Policy: same-origin
// SharedArrayBuffer를 사용한 메모리 공유 (보안 설정 시에만 가능)
if (typeof SharedArrayBuffer !== 'undefined') {
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
// Worker로 공유 메모리 전달
worker.postMessage({ sharedBuffer });
// Worker에서 원자적 연산 수행
// worker.js
self.onmessage = function(e) {
const sharedArray = new Int32Array(e.data.sharedBuffer);
// 원자적 연산으로 경쟁 조건 방지
Atomics.add(sharedArray, 0, 1);
Atomics.store(sharedArray, 1, 42);
};
} else {
console.warn('SharedArrayBuffer는 지원되지 않습니다.');
}
Service Worker는 네트워크 프록시 역할을 하는 별도 스레드로, 웹 앱과 네트워크 사이에서 요청을 가로채고 캐싱을 관리합니다.
참고 출처:
// 메인 스레드에서 Service Worker 등록
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW 등록 성공:', registration.scope);
})
.catch(error => {
console.log('SW 등록 실패:', error);
});
}
// sw.js - Service Worker 라이프사이클 이벤트
self.addEventListener('install', event => {
console.log('Service Worker 설치');
// 정적 자원 캐싱
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/app.js',
'/style.css',
'/manifest.json'
]);
})
);
// 대기 단계 건너뛰기
self.skipWaiting();
});
self.addEventListener('activate', event => {
console.log('Service Worker 활성화');
// 이전 버전 캐시 정리
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== 'v1') {
return caches.delete(cacheName);
}
})
);
})
);
// 모든 클라이언트 제어하기
return self.clients.claim();
});
self.addEventListener('fetch', event => {
// 네트워크 요청 가로채기 (Cache First 전략)
event.respondWith(
caches.match(event.request).then(response => {
// 캐시에 있으면 캐시에서 반환, 없으면 네트워크 요청
return response || fetch(event.request).then(fetchResponse => {
// 새로 받은 응답을 캐시에 저장
if (fetchResponse.ok) {
const responseClone = fetchResponse.clone();
caches.open('v1').then(cache => {
cache.put(event.request, responseClone);
});
}
return fetchResponse;
});
}).catch(() => {
// 오프라인 상태일 때 기본 페이지 반환
return caches.match('/offline.html');
})
);
});
| 우선순위 | 큐 종류 | 처리 내용 | 예시 |
|---|---|---|---|
| 1순위 | Microtask Queue | Promise, queueMicrotask | Promise.resolve().then() |
| 2순위 | Animation Frames | requestAnimationFrame | 애니메이션 렌더링 |
| 3순위 | Task Queue (Macrotask) | setTimeout, DOM 이벤트 | setTimeout(), 클릭 이벤트 |
// 메인 스레드에서 백그라운드 동기화 등록
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('send-message');
});
// Service Worker에서 동기화 처리
self.addEventListener('sync', event => {
if (event.tag === 'send-message') {
event.waitUntil(
sendPendingMessages() // 대기 중인 메시지 전송
);
}
});
async function sendPendingMessages() {
const messages = await getStoredMessages();
return Promise.all(
messages.map(message =>
fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
}).then(() => removeStoredMessage(message.id))
)
);
}
// 푸시 이벤트 수신
self.addEventListener('push', event => {
const options = {
body: event.data ? event.data.text() : '새로운 알림',
icon: '/icon-192.png',
badge: '/badge-72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: '확인',
icon: '/check.png'
},
{
action: 'close',
title: '닫기',
icon: '/xmark.png'
}
]
};
event.waitUntil(
self.registration.showNotification('PWA 알림', options)
);
});
// 알림 클릭 처리
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'explore') {
// 특정 페이지로 이동
event.waitUntil(
clients.openWindow('/notifications')
);
}
});
// 메인 스레드에서 Service Worker로 메시지 전송
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'UPDATE_CACHE',
payload: { version: '2.0' }
});
}
// Service Worker에서 메시지 수신
self.addEventListener('message', event => {
if (event.data.type === 'UPDATE_CACHE') {
event.waitUntil(updateCache(event.data.payload.version));
}
// 응답 전송
event.ports[0].postMessage({
status: 'success',
message: 'Cache updated'
});
});
// Client API를 사용한 모든 클라이언트에게 메시지 전송
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'CACHE_UPDATED',
version: '2.0'
});
});
});
참고 출처:
| 구분 | 브라우저 | Node.js |
|---|---|---|
| JavaScript 엔진 | V8, SpiderMonkey 등 | V8 엔진 |
| 이벤트 루프 | 브라우저 내장 | libuv 라이브러리 |
| API 제공자 | Web APIs | Node.js APIs |
| 스레드 풀 | 브라우저 관리 | libuv 스레드 풀 (기본 4개) |
| 실행 환경 | DOM, Window 객체 | Global, Process 객체 |
// Node.js 이벤트 루프의 6개 페이즈
┌───────────────────────────┐
┌─>│ timers │ // setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ // I/O 콜백 지연 처리
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ // 내부적으로만 사용
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ // 새로운 I/O 이벤트 폴링
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ // setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ // close 이벤트 콜백
└───────────────────────────┘
참고 출처:
| 페이즈 | 담당 작업 | 큐 특성 |
|---|---|---|
| Timers | setTimeout, setInterval | Min-Heap 우선순위 큐 |
| Pending Callbacks | 이전 루프에서 지연된 I/O 콜백 | FIFO 큐 |
| Idle, Prepare | Node.js 내부 관리 | JavaScript 실행 없음 |
| Poll | 새로운 I/O 이벤트, 거의 모든 콜백 | Watcher Queue |
| Check | setImmediate 콜백 전용 | FIFO 큐 |
| Close Callbacks | socket.on('close') 등 | FIFO 큐 |
// Thread Pool 사용 (기본 4개 스레드)
fs.readFile('./file.txt', callback); // 파일 I/O
crypto.pbkdf2('secret', 'salt', callback); // CPU 집약적 작업
// UV_IO 사용 (Kernel Thread 통신)
http.get('https://api.com', callback); // 네트워크 I/O
db.query('SELECT * FROM users', callback); // 데이터베이스 I/O
"Node.js는 정확히 어떤 부분이 싱글 스레드인가요?"
→ "JavaScript 실행(V8 엔진)은 싱글 스레드이지만, I/O 작업은 libuv의 스레드 풀과 커널 스레드를 활용하여 멀티스레딩으로 처리됩니다."
"이벤트 루프에서 가장 중요한 페이즈는 무엇인가요?"
→ "Poll 페이즈입니다. 새로운 I/O 이벤트를 폴링하고 대부분의 콜백을 실행하며, 필요시 대기하여 효율적인 리소스 사용을 담당합니다."
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력: 1, 4, 3, 2
// Microtask(Promise)가 Macrotask(setTimeout)보다 우선
console.log('1');
setImmediate(() => console.log('2'));
setTimeout(() => console.log('3'), 0);
Promise.resolve().then(() => console.log('4'));
console.log('5');
// 출력: 1, 5, 4, 2, 3
// Node.js는 페이즈별로 처리하며, setImmediate는 check 페이즈에서 실행
// process.nextTick과 setImmediate의 차이
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
// 출력: nextTick, Promise, setImmediate
// process.nextTick이 가장 높은 우선순위를 가짐
// Node.js는 I/O 작업을 커널 또는 스레드 풀로 위임
const fs = require('fs');
const crypto = require('crypto');
// 파일 I/O - 스레드 풀 사용
fs.readFile('large-file.txt', (err, data) => {
console.log('파일 읽기 완료');
});
// CPU 집약적 작업 - 스레드 풀 사용 (CPU 집약적)
crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', (err, key) => {
console.log('암호화 완료');
});
// 네트워크 I/O - 이벤트 루프에서 처리
const https = require('https');
https.get('https://api.example.com', (res) => {
console.log('HTTP 요청 완료');
});
// 브라우저는 Web APIs를 통해 비동기 처리
// DOM 이벤트
document.addEventListener('click', () => {
console.log('클릭 이벤트');
});
// 네트워크 요청
fetch('https://api.example.com')
.then(response => response.json())
.then(data => console.log('Fetch 완료'));
// 타이머
setTimeout(() => console.log('Timer'), 1000);
참고 출처: Step-by-Step Guide to Fixing Node.js SSL Certificate Errors
// 원인: 중간 인증서(Intermediate Certificate)가 누락되거나
// 시스템의 신뢰할 수 있는 인증서 저장소에 루트 인증서가 없음
// ❌ 문제 상황
const https = require('https');
https.get('https://example.com', (res) => {
// Error: unable to get local issuer certificate
});
// ✅ 해결 방법 1: 인증서 체인 완성
process.env.NODE_EXTRA_CA_CERTS = './path/to/intermediate-ca.pem';
// ✅ 해결 방법 2: 개발환경에서는 rejectUnauthorized: false를 사용하세요.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// 원인: 자체 서명 인증서(Self-signed Certificate) 사용
// ❌ 문제 상황
https.get('https://localhost:3000', callback); // 자체 서명 인증서 에러
// ✅ 해결 방법 1: 특정 자체 서명 인증서 신뢰
const selfSignedCert = fs.readFileSync('./self-signed-cert.pem');
const options = {
hostname: 'localhost',
port: 3000,
rejectUnauthorized: false // 개발환경에서만 사용
};
// 원인: 인증서 만료
// ✅ 해결 방법: 인증서 갱신 및 확인
const tls = require('tls');
function checkCertExpiry(hostname, port = 443) {
return new Promise((resolve, reject) => {
const socket = tls.connect(port, hostname, () => {
const cert = socket.getPeerCertificate();
const now = new Date();
const expiry = new Date(cert.valid_to);
console.log(`인증서 만료일: ${expiry}`);
console.log(`현재 시간: ${now}`);
console.log(`만료까지: ${Math.floor((expiry - now) / (1000 * 60 * 60 * 24))}일`);
socket.destroy();
resolve(cert);
});
socket.on('error', reject);
});
}
checkCertExpiry('example.com');
// 원인: 인증서 체인이 불완전하거나 손상됨
// ✅ 해결 방법: 완전한 인증서 체인 구성
// 올바른 인증서 순서: 서버 인증서 → 중간 인증서 → 루트 인증서
const fullChain = `
-----BEGIN CERTIFICATE-----
(서버 인증서)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(중간 인증서 1)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(중간 인증서 2 - 필요시)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(루트 인증서)
-----END CERTIFICATE-----
`;
fs.writeFileSync('./fullchain.pem', fullChain);
process.env.NODE_EXTRA_CA_CERTS = './fullchain.pem';
// 원인: TLS 버전 호환성 문제
// ✅ 해결 방법: TLS 버전 명시적 설정
const tls = require('tls');
const secureOptions = {
hostname: 'old-server.example.com',
port: 443,
secureProtocol: 'TLSv1_2_method', // 특정 TLS 버전 강제
// 또는 최소/최대 버전 설정
minVersion: 'TLSv1.2',
maxVersion: 'TLSv1.3'
};
// config/ssl.js
const fs = require('fs');
const path = require('path');
const sslConfig = {
development: {
rejectUnauthorized: false, // 개발환경에서만
ca: []
},
staging: {
rejectUnauthorized: true,
ca: [fs.readFileSync(path.join(__dirname, 'staging-ca.pem'))]
},
production: {
rejectUnauthorized: true,
ca: [
fs.readFileSync(path.join(__dirname, 'prod-ca.pem')),
fs.readFileSync(path.join(__dirname, 'intermediate-ca.pem'))
],
secureProtocol: 'TLSv1_2_method'
}
};
module.exports = sslConfig[process.env.NODE_ENV] || sslConfig.development;
// utils/ssl-monitor.js
const tls = require('tls');
const EventEmitter = require('events');
class SSLMonitor extends EventEmitter {
constructor() {
super();
this.certificates = new Map();
}
async checkCertificate(hostname, port = 443) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const socket = tls.connect(port, hostname, {
servername: hostname,
timeout: 10000
}, () => {
const cert = socket.getPeerCertificate(true);
const connectTime = Date.now() - startTime;
const certInfo = {
hostname,
port,
subject: cert.subject,
issuer: cert.issuer,
validFrom: new Date(cert.valid_from),
validTo: new Date(cert.valid_to),
fingerprint: cert.fingerprint,
connectTime,
checkedAt: new Date()
};
// 만료 임박 경고 (30일 전)
const daysUntilExpiry = Math.floor(
(certInfo.validTo - new Date()) / (1000 * 60 * 60 * 24)
);
if (daysUntilExpiry <= 30) {
this.emit('certificateExpiring', {
...certInfo,
daysUntilExpiry
});
}
this.certificates.set(`${hostname}:${port}`, certInfo);
socket.destroy();
resolve(certInfo);
});
socket.on('error', reject);
});
}
async checkMultipleSites(sites) {
const results = await Promise.allSettled(
sites.map(site => this.checkCertificate(site.hostname, site.port))
);
return results.map((result, index) => ({
site: sites[index],
status: result.status,
data: result.status === 'fulfilled' ? result.value : result.reason
}));
}
}
// 사용 예시
const monitor = new SSLMonitor();
monitor.on('certificateExpiring', (cert) => {
console.warn(`⚠️ 인증서 만료 임박: ${cert.hostname} (${cert.daysUntilExpiry}일 남음)`);
// 슬랙, 이메일 등으로 알림 발송
});
monitor.on('sslError', (error) => {
console.error(`❌ SSL 오류: ${error.hostname} - ${error.code}`);
});
// 정기적인 인증서 검사
setInterval(async () => {
const sites = [
{ hostname: 'api.example.com', port: 443 },
{ hostname: 'admin.example.com', port: 443 }
];
const results = await monitor.checkMultipleSites(sites);
console.log('SSL 인증서 상태 검사 완료:', results.length);
}, 24 * 60 * 60 * 1000); // 24시간마다
// utils/secure-request.js
const https = require('https');
const fs = require('fs');
class SecureHTTPSClient {
constructor(options = {}) {
this.defaultOptions = {
timeout: 30000,
retries: 3,
retryDelay: 1000,
...options
};
}
async request(url, options = {}) {
const finalOptions = { ...this.defaultOptions, ...options };
for (let attempt = 1; attempt <= finalOptions.retries; attempt++) {
try {
return await this._makeRequest(url, finalOptions);
} catch (error) {
if (attempt === finalOptions.retries) {
throw this._enhanceError(error, url);
}
// SSL 관련 에러의 경우 재시도 간격 증가
if (this._isSSLError(error)) {
const delay = finalOptions.retryDelay * Math.pow(2, attempt - 1);
await this._sleep(delay);
}
}
}
}
_makeRequest(url, options) {
return new Promise((resolve, reject) => {
const req = https.request(url, options, (res) => {
res.on('data', chunk => {
// 응답 데이터 수집
});
res.on('end', () => {
// 응답 종료 후 처리
});
});
req.setTimeout(options.timeout, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.on('error', reject);
req.end();
});
}
_isSSLError(error) {
const sslErrorCodes = [
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'CERT_HAS_EXPIRED',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'EPROTO'
];
return sslErrorCodes.includes(error.code);
}
_enhanceError(error, url) {
const enhancedError = new Error(`SSL request failed for ${url}: ${error.message}`);
enhancedError.originalError = error;
enhancedError.code = error.code;
enhancedError.url = url;
// 에러별 해결책 제안
enhancedError.solution = this._getSolution(error.code);
return enhancedError;
}
_getSolution(errorCode) {
const solutions = {
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
'NODE_EXTRA_CA_CERTS 환경변수에 CA 인증서 경로를 설정하거나 ca 옵션을 사용하세요.',
'DEPTH_ZERO_SELF_SIGNED_CERT':
'자체 서명 인증서를 ca 옵션에 추가하거나 개발환경에서는 rejectUnauthorized: false를 사용하세요.',
'CERT_HAS_EXPIRED':
'인증서가 만료되었습니다. 새 인증서로 교체해주세요.',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
'완전한 인증서 체인을 구성하세요 (서버 → 중간 → 루트 인증서 순서).',
'EPROTO':
'TLS 버전 호환성을 확인하고 secureProtocol 옵션을 설정하세요.'
};
return solutions[errorCode] || '공식 문서를 참조하여 SSL 설정을 확인하세요.';
}
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = SecureHTTPSClient;
Q: Node.js에서 SSL handshake 에러가 발생했을 때 어떻게 해결하시나요?
A: "SSL handshake 에러는 주로 다음과 같은 원인으로 발생합니다:
인증서 체인 문제: UNABLE_TO_GET_ISSUER_CERT_LOCALLY 에러의 경우, NODE_EXTRA_CA_CERTS 환경변수로 중간 인증서를 지정하거나 요청 옵션에 ca 배열을 추가합니다.
자체 서명 인증서: DEPTH_ZERO_SELF_SIGNED_CERT 에러는 개발환경에서 흔하며, 해당 인증서를 ca 옵션에 명시적으로 추가하여 해결합니다.
TLS 버전 호환성: EPROTO 에러의 경우 secureProtocol 또는 minVersion/maxVersion 옵션으로 TLS 버전을 명시합니다.
프로덕션에서는 절대 rejectUnauthorized: false를 사용하지 않으며, 환경별로 다른 SSL 설정을 관리하고 인증서 만료를 모니터링하는 시스템을 구축합니다."