웹 통신의 핵심: HTTP 프로토콜 이해
네트워크 통신에서 양측이 데이터를 교환하기 위해선 일련의 약속, 즉 프로토콜을 따라야 합니다. 웹에서는 하이퍼텍스트 전송 프로토콜(HTTP, Hypertext Transfer Protocol)이 이 역할을 담당합니다. HTTP는 클라이언트가 서버에 정보를 요청하고 서버가 이에 응답하는 방식으로 동작합니다.
HTTP 요청 메시지 구조
클라이언트가 서버로 보내는 HTTP 요청은 다음과 같은 네 부분으로 구성됩니다.
- 요청 라인: 요청 메서드(GET, POST 등), 요청 대상 URL, HTTP 버전 정보(예: HTTP/1.1)를 포함합니다.
- 요청 헤더: 클라이언트(브라우저) 정보, 요청에 대한 추가 메타데이터(예: Content-Type, User-Agent, Accept) 등을 담습니다. 자세한 내용은 MDN HTTP 헤더 문서를 참조하세요.
- 빈 줄: 헤더의 끝을 나타내며, 필수적으로 포함되어야 합니다.
- 요청 본문: POST와 같은 메서드에서 서버로 전송할 실제 데이터(예: 폼 데이터, JSON 객체)를 담습니다. 형식은 매우 유연합니다.
HTTP 응답 메시지 구조
서버가 클라이언트 요청에 대해 보내는 HTTP 응답도 유사하게 네 부분으로 이루어집니다.
- 응답 라인: HTTP 버전, 요청 처리 결과(성공, 실패 등)를 나타내는 상태 코드(예: 200 OK, 404 Not Found), 상태 코드에 대한 간략한 설명으로 구성됩니다.
- 응답 헤더: 서버 정보, 응답 본문에 대한 메타데이터(예: Content-Type, Content-Length) 등을 포함합니다. 자세한 상태 코드 목록은 MDN HTTP 상태 코드 문서를 참조하세요.
- 빈 줄: 요청과 마찬가지로 헤더의 끝을 표시합니다.
- 응답 본문: 클라이언트에 전송될 실제 데이터(예: HTML 페이지, CSS, JavaScript 파일, 이미지, JSON 데이터)를 담습니다.
IP 주소 및 포트의 역할
컴퓨터 네트워크에서 장치 간 통신을 위해 IP 주소와 포트 번호가 사용됩니다.
- IP 주소: 네트워크에 연결된 장치(컴퓨터, 스마트폰 등)를 식별하는 고유한 숫자 식별자입니다. 예를 들어,
192.168.1.1과 같습니다.- 사설 IP (내부 네트워크):
10.0.0.0대역,172.16.0.0대역,192.168.0.0대역 등이 있습니다. - 루프백 IP:
127.0.0.1은 항상 자기 자신을 가리킵니다. - 공인 IP (외부 네트워크): 사설 IP 이외의 IP 주소로, 인터넷에서 직접 접근 가능합니다.
- 사설 IP (내부 네트워크):
- 포트: 특정 장치 내에서 실행 중인 애플리케이션을 식별하는 숫자 값입니다. 하나의 컴퓨터는 0부터 65535까지의 포트를 가질 수 있으며, 이를 통해 여러 애플리케이션이 동시에 네트워크 통신을 수행할 수 있습니다. 예를 들어, 웹 서버는 일반적으로 80번(HTTP) 또는 443번(HTTPS) 포트를 사용합니다.
Node.js HTTP 서버 구축
Node.js의 내장 http 모듈을 사용하여 웹 서버를 손쉽게 구축할 수 있습니다.
1. http 모듈 임포트
require 함수를 사용하여 http 모듈을 가져옵니다.
const http = require('http');
2. 서버 인스턴스 생성
http.createServer() 메서드를 호출하여 서버 인스턴스를 생성합니다. 이 메서드는 요청이 들어올 때마다 실행될 콜백 함수를 인자로 받습니다.
const webServer = http.createServer((request, response) => {
// 여기에 요청 처리 로직 작성
});
콜백 함수의 두 인자는 각각 클라이언트 요청(request, http.IncomingMessage 타입)과 서버 응답(response, http.ServerResponse 타입)을 나타내는 객체입니다.
3. 서버 포트 리스닝
server.listen() 메서드를 사용하여 서버가 특정 포트에서 요청을 수신하도록 설정합니다.
webServer.listen(8080, () => {
console.log('웹 서버가 8080 포트에서 실행 중입니다.');
});
listen 메서드의 인자는 다음과 같습니다.
- 포트 번호 (필수): 서버가 수신할 포트 번호입니다 (예:
8080).0으로 설정하면 시스템이 사용 가능한 임의의 포트를 할당합니다. - 호스트명 (선택): 기본값은
localhost(로컬 환경에서만 접근 가능)입니다. 외부에서 접근 가능하게 하려면0.0.0.0으로 설정합니다. - 콜백 함수 (선택): 서버가 성공적으로 시작되었을 때 실행됩니다. 주로 로그 메시지 출력에 사용됩니다.
예제 코드: 간단한 HTTP 서버
다음 코드는 "Hello Node.js Web Server!"를 응답하는 간단한 웹 서버를 생성합니다.
const http = require('http');
const simpleServer = http.createServer((clientRequest, serverResponse) => {
serverResponse.statusCode = 200;
serverResponse.setHeader('Content-Type', 'text/plain; charset=utf-8');
serverResponse.end('Hello Node.js Web Server!');
});
simpleServer.listen(3000, () => {
console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
클라이언트 요청 정보 분석 (request 객체)
http.IncomingMessage 타입의 request 객체는 클라이언트로부터 들어온 요청에 대한 다양한 정보를 담고 있습니다.
1. 요청 메서드 확인: request.method
요청의 HTTP 메서드(GET, POST, PUT, DELETE 등)를 대문자 문자열로 반환합니다.
const http = require('http');
const serverMethodCheck = http.createServer((req, res) => {
console.log('요청 메서드:', req.method); // 예: 'GET' 또는 'POST'
res.end('메서드 확인 완료');
});
serverMethodCheck.listen(3001, () => {
console.log('메서드 확인 서버 가동');
});
2. HTTP 버전 확인: request.httpVersion
클라이언트가 사용한 HTTP 프로토콜 버전을 문자열로 반환합니다 (예: '1.1').
const http = require('http');
const serverVersionCheck = http.createServer((req, res) => {
console.log('HTTP 버전:', req.httpVersion); // 예: '1.1'
res.end('버전 확인 완료');
});
serverVersionCheck.listen(3002, () => {
console.log('버전 확인 서버 가동');
});
3. 요청 URL 파싱 (URL API 활용)
request.url은 경로와 쿼리 문자열을 포함하는 요청 URL의 문자열입니다. Node.js의 내장 URL 객체를 사용하여 이 URL을 파싱하고 필요한 정보를 추출할 수 있습니다.
참고: request.url은 프로토콜, 호스트, 포트 정보를 포함하지 않는 상대 경로일 수 있으므로, new URL() 생성자 사용 시 기준 URL을 함께 제공하는 것이 좋습니다.
const http = require('http');
const urlParsingServer = http.createServer((req, res) => {
// req.url은 '/path/to?param1=value1' 형태일 수 있으므로, 기준 URL 필요
const fullUrl = new URL(req.url, `http://${req.headers.host}`); // 현재 호스트를 기준으로 URL 객체 생성
console.log('전체 URL 객체:', fullUrl);
console.log('경로:', fullUrl.pathname); // 예: '/path/to'
// 쿼리 파라미터 접근
console.log('쿼리 파라미터 "param1":', fullUrl.searchParams.get('param1'));
console.log('모든 쿼리 파라미터:', Object.fromEntries(fullUrl.searchParams));
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`경로: ${fullUrl.pathname}\n쿼리: ${fullUrl.search}`);
});
urlParsingServer.listen(3003, () => {
console.log('URL 파싱 서버 가동 (http://localhost:3003/test?param1=hello¶m2=world)');
});
4. 요청 헤더 접근: request.headers
요청 헤더는 request.headers 객체에 저장되며, 모든 헤더 이름은 소문자로 변환되어 키로 사용됩니다.
const http = require('http');
const headerCheckServer = http.createServer((req, res) => {
console.log('모든 요청 헤더:', req.headers);
console.log('User-Agent 헤더:', req.headers['user-agent']);
res.end('헤더 확인 완료');
});
headerCheckServer.listen(3004, () => {
console.log('헤더 확인 서버 가동');
});
5. 요청 본문 데이터 처리 (스트림 방식)
POST 요청과 같이 요청 본문에 데이터가 포함된 경우, request 객체는 읽기 가능한 스트림(Readable Stream)이므로 'data' 및 'end' 이벤트를 사용하여 데이터를 청크(chunk) 단위로 읽어 들여야 합니다.
const http = require('http');
const bodyProcessingServer = http.createServer((req, res) => {
if (req.method === 'POST') {
let incomingBody = '';
req.on('data', chunk => {
incomingBody += chunk.toString(); // 청크를 문자열로 변환하여 축적
});
req.on('end', () => {
console.log('수신된 요청 본문:', incomingBody);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`본문 데이터: ${incomingBody}`);
});
} else {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('GET 요청에는 본문이 없습니다.');
}
});
bodyProcessingServer.listen(3005, () => {
console.log('본문 처리 서버 가동 (POST 요청으로 테스트)');
});
favicon.ico 요청
브라우저는 웹 페이지를 로드할 때 자동으로 /favicon.ico 경로로 아이콘 파일을 요청합니다. 이 요청도 일반적인 HTTP 요청으로 처리되므로, 서버 로직에서 필요에 따라 이를 구분하여 처리할 수 있습니다.
서버 응답 구성 (response 객체)
http.ServerResponse 타입의 response 객체는 클라이언트에게 보낼 응답을 구성하는 데 사용됩니다.
1. 응답 상태 코드 설정: response.statusCode
응답의 HTTP 상태 코드를 설정합니다. 기본값은 200(OK)입니다.
const http = require('http');
const statusServer = http.createServer((req, res) => {
res.statusCode = 404; // 페이지를 찾을 수 없음
res.end('지정된 리소스를 찾을 수 없습니다.');
});
statusServer.listen(3006, () => {
console.log('상태 코드 설정 서버 가동');
});
2. 응답 상태 메시지 설정: response.statusMessage
상태 코드에 대한 사용자 정의 메시지를 설정할 수 있습니다. 설정하지 않으면 기본 메시지가 사용됩니다.
const http = require('http');
const statusMsgServer = http.createServer((req, res) => {
res.statusCode = 200;
res.statusMessage = '커스텀 성공 메시지';
res.end('응답이 전송되었습니다.');
});
statusMsgServer.listen(3007, () => {
console.log('상태 메시지 설정 서버 가동');
});
3. 응답 헤더 설정: response.setHeader(name, value)
클라이언트에 전송될 응답 헤더를 설정합니다. name은 헤더 이름(대소문자 무관), value는 헤더 값입니다.
- 다중 헤더 설정:
response.setHeader('Cache-Control', 'no-cache'); response.setHeader('X-Custom-Header', 'Value'); - 동일한 이름의 헤더 여러 개 설정: 값을 배열로 전달합니다.
response.setHeader('Set-Cookie', ['session=abc', 'pref=xyz']);
const http = require('http');
const headerSetServer = http.createServer((req, res) => {
res.setHeader('X-Powered-By', 'Node.js/CustomServer');
res.setHeader('Content-Type', 'text/html; charset=utf-8'); // 응답 본문 타입과 문자셋 지정
res.end('<h1>헤더가 설정된 페이지</h1>');
});
headerSetServer.listen(3008, () => {
console.log('헤더 설정 서버 가동');
});
참고: Content-Type 헤더로 charset을 지정하는 것이 HTML <meta> 태그로 지정하는 것보다 우선순위가 높습니다.
4. 응답 본문 전송: response.write() 및 response.end()
response.write(chunk[, encoding][, callback]): 응답 본문의 일부를 클라이언트로 전송합니다. 여러 번 호출하여 스트리밍할 수 있습니다.response.end([data][, encoding][, callback]): 응답 프로세스를 종료합니다. 선택적으로 최종 데이터를 포함하여 보낼 수 있습니다. 모든 요청에 대해 반드시 한 번 호출되어야 합니다.
const http = require('http');
const responseBodyServer = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.write('첫 번째 데이터 조각\n');
setTimeout(() => {
res.write('두 번째 데이터 조각 (2초 후)\n');
res.end('모든 데이터 전송 완료'); // 응답 종료
}, 2000);
});
responseBodyServer.listen(3009, () => {
console.log('응답 본문 전송 서버 가동');
});
정적 파일 서빙 구현
실제 웹 애플리케이션에서는 HTML, CSS, JavaScript, 이미지 등 다양한 정적 파일을 제공해야 합니다. Node.js 서버에서 이러한 파일을 관리하는 방법을 알아봅니다.
정적 자원 서빙 기본 원리
모든 정적 웹 자원(HTML, CSS, JS, 이미지 등)을 하나의 "웹 루트" 또는 "정적 자원 디렉토리"에 배치합니다. 클라이언트의 요청 URL 경로를 기반으로 해당 디렉토리에서 파일을 찾아 응답합니다.
예를 들어, http://localhost:4000/styles/main.css 요청이 오면, 서버는 [웹_루트_경로]/styles/main.css 파일을 찾아 전송합니다.
예제: 간단한 정적 파일 서버
아래 예제는 HTML 파일과 그에 포함된 CSS, JavaScript 파일을 제공하는 정적 서버를 보여줍니다.
파일 구조:
project-root/
├── server.js
└── public/
├── index.html
├── css/
│ └── page.css
└── js/
└── interactive.js
public/index.html:
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>정적 파일 예제</title>
<link rel="stylesheet" href="/css/page.css">
</head>
<body>
<h1>정적 자원 서빙 테스트</h1>
<p>이 페이지는 CSS와 JS 파일을 함께 로드합니다.</p>
<button id="testButton">클릭하세요</button>
<script src="/js/interactive.js"></script>
</body>
</html>
public/css/page.css:
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333;
margin: 20px;
}
h1 {
color: #0056b3;
}
button {
padding: 10px 15px;
background-color: #28a745;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
public/js/interactive.js:
document.getElementById('testButton').addEventListener('click', () => {
alert('버튼이 클릭되었습니다!');
document.body.style.backgroundColor = '#e9ecef';
});
server.js:
const http = require('http');
const fs = require('fs');
const path = require('path');
// MIME 타입 매핑 객체
const mimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.json': 'application/json',
'.txt': 'text/plain',
// ... 더 많은 타입 추가 가능
};
const staticFileServer = http.createServer((req, res) => {
// 요청 URL을 파싱하여 경로 추출
const requestedUrl = new URL(req.url, `http://${req.headers.host}`);
let filePath = requestedUrl.pathname === '/' ? '/index.html' : requestedUrl.pathname;
// 정적 파일의 루트 디렉토리 설정
const publicDir = path.join(__dirname, 'public');
const fullPath = path.join(publicDir, filePath);
// 파일 존재 여부 확인 및 응답
fs.readFile(fullPath, (err, data) => {
if (err) {
// 파일을 찾을 수 없거나 읽을 수 없는 경우
if (err.code === 'ENOENT') {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('404 Not Found: 요청하신 파일을 찾을 수 없습니다.');
} else {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`500 Internal Server Error: 파일을 읽는 중 오류 발생 - ${err.message}`);
}
return;
}
// 파일 확장자를 기반으로 MIME 타입 결정
const fileExt = path.extname(fullPath).toLowerCase();
const contentType = mimeTypes[fileExt] || 'application/octet-stream'; // 기본값은 바이너리 스트림
res.statusCode = 200;
res.setHeader('Content-Type', `${contentType}; charset=utf-8`);
res.end(data);
});
});
staticFileServer.listen(4000, () => {
console.log('정적 파일 서버가 http://localhost:4000 에서 실행 중입니다.');
});
MIME 타입 설정의 중요성
MIME(Multipurpose Internet Mail Extensions) 타입은 전송되는 데이터의 유형과 형식을 나타내는 표준입니다. 웹 서버에서는 Content-Type 응답 헤더를 통해 전송하는 리소스의 MIME 타입을 브라우저에 알려줍니다. 브라우저는 이 정보를 기반으로 해당 리소스를 어떻게 처리할지 결정합니다 (예: HTML로 렌더링, 이미지로 표시, JavaScript로 실행, 파일 다운로드 등).
위 코드에서 mimeTypes 객체를 사용하여 파일 확장자에 따라 적절한 Content-Type을 설정하는 것을 볼 수 있습니다. 만약 알 수 없는 타입의 파일이라면 application/octet-stream으로 설정하여 브라우저가 해당 파일을 다운로드하도록 유도할 수 있습니다.
HTTP 서버 에러 처리 전략
안정적인 웹 서버를 구축하기 위해서는 다양한 상황에서 발생할 수 있는 오류를 적절하게 처리하고 사용자에게 의미 있는 응답을 제공해야 합니다. 다음은 일반적인 오류 처리 예시입니다.
const http = require('http');
const fs = require('fs');
const path = require('path');
const fileMimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.json': 'application/json',
'.ico': 'image/x-icon' // favicon.ico 처리를 위해 추가
};
const enhancedWebServer = http.createServer((request, response) => {
// 1. 요청 메서드 유효성 검사
if (request.method !== 'GET') {
response.statusCode = 405;
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end('405 Method Not Allowed: 이 서버는 GET 요청만 지원합니다.');
return;
}
const requestPath = new URL(request.url, `http://${request.headers.host}`).pathname;
let targetFile = requestPath === '/' ? '/index.html' : requestPath;
// favicon.ico 요청 처리
if (targetFile === '/favicon.ico') {
// 실제 favicon 파일을 제공하거나, 204 No Content로 응답하여 무시할 수 있음
// 여기서는 예시로 빈 응답을 보냅니다.
response.statusCode = 204;
response.end();
return;
}
const baseDir = path.join(__dirname, 'static_content'); // 웹 루트 디렉토리
const fullResourcePath = path.join(baseDir, targetFile);
fs.readFile(fullResourcePath, (error, fileData) => {
// 2. 파일 읽기 오류 처리
if (error) {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
switch (error.code) {
case 'ENOENT': // No Entity: 파일이나 디렉토리를 찾을 수 없음
response.statusCode = 404;
response.end('404 Not Found: 요청하신 리소스를 찾을 수 없습니다.');
break;
case 'EACCES': // Access Denied: 파일 접근 권한 없음
case 'EPERM': // Operation Not Permitted: 권한 오류
response.statusCode = 403;
response.end('403 Forbidden: 해당 리소스에 접근할 권한이 없습니다.');
break;
default: // 기타 파일 시스템 오류
response.statusCode = 500;
response.end(`500 Internal Server Error: 서버 오류 발생 - ${error.message}`);
break;
}
return;
}
// 3. MIME 타입 설정 및 응답
const fileExtension = path.extname(fullResourcePath).toLowerCase();
const contentType = fileMimeTypes[fileExtension] || 'application/octet-stream';
response.statusCode = 200;
response.setHeader('Content-Type', `${contentType}; charset=utf-8`);
response.end(fileData);
});
});
enhancedWebServer.listen(5000, () => {
console.log('향상된 웹 서버가 http://localhost:5000 에서 실행 중입니다.');
console.log('웹 루트는 ' + path.join(__dirname, 'static_content') + ' 입니다.');
});