웹 개발 학습: 웹 서버 구현
개요
이 문서는 “Learning web development” 시리즈의 일부로, JavaScript를 사용하여 웹 애플리케이션을 만드는 방법을 처음 프로그래밍을 배우는 사람들에게 가르치는 내용입니다. 이번 장에서는 파일을 제공하고 브라우저 앱의 데이터를 관리하는 자체 웹 서버를 작성합니다.
핵심 용어 및 개념
브라우저 vs 서버 용어 정리
대응 관계:
- 브라우저 ↔ 서버
- 로컬 ↔ 원격
- 프론트엔드 ↔ 백엔드
- 클라이언트 ↔ 서버
중요 포인트: "클라이언트"는 "브라우저"보다 더 일반적인 용어로, 서버에 연결하는 모든 앱(웹 앱, 모바일 앱 등)을 의미합니다.
웹 리소스 제공 배경
기본 작동 원리:
- 브라우저가 서버에 요청을 보냄 (보통 리소스 제공 요청)
- 서버가 응답을 보냄 (보통 해당 리소스의 데이터)
- HTTP 요청과 응답을 HTTP 메시지라고 함
HTTP 응답 구조
HTTP/1.1 형식 분석
HTTP/1.1 200 0K
content-type: text/html
last-modified: Mon, 13 Jan 2025 20:11:20 GMT
date: Thu, 11 Sep 2025 09:55:04 GMT
content-length: 1256
<!doctype html>
<html>
...
구성 요소:
- 시작 라인: HTTP 프로토콜 버전과 상태 코드 (성공: 200, 오류: 404 등)
- 헤더: 키-값 쌍으로 구성된 메타데이터 (키는 콜론으로 끝남)
- 빈 줄: 헤더의 끝을 표시
- 본문: 실제 응답 데이터
중요한 헤더 필드:
content-type
: 제공되는 리소스의 미디어 타입 지정
프로토콜 버전 참고:
- HTTP/3이 현재 버전
- HTTP/1.1 이후 버전들은 텍스트 형식을 사용하지 않지만 핵심 구조는 동일
상태 코드 체계
번호 범위별 의미:
- 1xx: 정보 제공 - 요청 수신됨, 프로세스 계속 진행
- 2xx: 성공 - 요청이 성공적으로 수신, 이해, 수락됨
- 3xx: 리다이렉션 - 요청 완료를 위해 추가 작업 필요
- 4xx: 클라이언트 오류 - 요청에 잘못된 구문이 있거나 이행할 수 없음
- 5xx: 서버 오류 - 유효한 요청을 서버가 이행하지 못함
주요 상태 코드 예시:
- 200 OK
- 400 Bad Request
- 404 Not Found
- 500 Internal Server Error
미디어 타입 (MIME 타입)
웹의 데이터 타입 식별 방식:
운영체제의 파일 확장자와는 다른 방식으로, HTTP 헤더 필드를 통해 리소스에 미디어 타입을 첨부합니다.
주요 미디어 타입:
- 일반 텍스트:
text/plain
- HTML:
text/html
- CSS:
text/css
- JavaScript:
text/javascript
- JSON:
application/json
- JPEG 이미지:
image/jpeg
프로젝트 1: simple-server-html.js
기본 서버 구조
서버 실행 방법:
node --watch simple-server-html.js
--watch
옵션의 중요성:
- Node.js가 파일 변경 시마다 자동으로 재시작
- 서버만 재로드되며, 브라우저는 별도로 새로고침 필요
핵심 코드 구조:
import { createServer } from 'node:http';
const hostname = 'localhost';
const port = 3000;
const server = createServer(
(request, response) => {
// 요청 처리 로직
}
);
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
요청 처리 메커니즘
응답 생성 과정:
const server = createServer(
(request, response) => {
response.statusCode = 200;
response.setHeader('Content-Type', 'text/html');
const content = [
'<!DOCTYPE html>',
'<meta charset="UTF-8">',
'<title>Simple Web Server</title>',
`Path: ${request.url}`
];
response.end(content.join('\n'));
}
);
처리 단계:
- 메타데이터 설정 (상태 코드, 콘텐츠 타입)
- 콘텐츠를 단일 텍스트 청크로 제공
- 응답 종료 (닫기)
실용적 팁: 최소한의 HTML로 웹 페이지 제공 가능 (브라우저가 처리 가능)
request.url 분석
URL과 request.url의 관계:
웹 페이지 URL | request.url |
---|---|
http://localhost:3000 | / |
http://localhost:3000/dir/ | /dir/ |
http://localhost:3000/file.html | /file.html |
중요 특징: request.url
은 항상 슬래시(/)로 시작
URL 검색 매개변수 (Search Parameters)
기본 구조 및 사용법
검색 매개변수 형식:
https://example.com/home.html?key1=value1&key2=value2
구성 요소:
- 물음표(?) 뒤에 위치
key=value
쌍들을 앰퍼샌드(&)로 구분- 값은 선택사항
URL 객체에서의 접근:
const url = new URL('https://example.com/home.html?k1=v1&k2=v2');
console.log(url.search); // '?k1=v1&k2=v2'
console.log(url.pathname); // '/home.html'
URLSearchParams 클래스 활용
기본 사용법:
const usp = new URLSearchParams('?k1=v1&k2=v2');
Array.from(usp.entries()); // [ [ 'k1', 'v1' ], [ 'k2', 'v2' ] ]
usp.get('k1'); // 'v1'
usp.has('k1'); // true
usp.has('KEY'); // false
URL 객체와의 연동:
const url = new URL('https://example.com/home.html?key=value');
Array.from(url.searchParams.entries()); // [ [ 'key', 'value' ] ]
특수 상황 처리
값 생략 시:
Array.from(new URLSearchParams('?k1&k2').entries());
// [ [ 'k1', '' ], [ 'k2', '' ] ]
동일 키 여러 번 사용:
const usp = new URLSearchParams('?k=v1&k=v2');
Array.from(usp.entries()); // [ [ 'k', 'v1' ], [ 'k', 'v2' ] ]
usp.getAll('k'); // [ 'v1', 'v2' ]
usp.get('k'); // 'v1' (첫 번째 값 반환)
URL 인코딩:
- 공백: 플러스(+)로 인코딩
- 기타 문자: 퍼센트 인코딩 사용 (% + 16진수)
// 공백 처리
Array.from(new URLSearchParams('?key=with+space').entries());
// [ [ 'key', 'with space' ] ]
// 퍼센트 인코딩
Array.from(new URLSearchParams('?key=one%2Bone').entries());
// [ [ 'key', 'one+one' ] ]
검색 매개변수 생성:
const usp = new URLSearchParams();
usp.append('key1', 'value1');
usp.append('key2', 'value with spaces');
usp.append('key3', 'one+one');
usp.toString();
// 'key1=value1&key2=value+with+spaces&key3=one%2Bone'
인터페이스와 구현, API
핵심 개념 구분
인터페이스:
- 함수, 클래스 등의 집합의 표면
- 구조(이름, 매개변수 수 등)와 사용 규칙 설명
- 여러 구현이 가능
구현:
- 인터페이스에 맞는 실제 코드 작성
- 동일한 인터페이스의 여러 구현 존재 가능
- 모듈 간 쉬운 교체 가능
API (Application Programming Interface):
- 특정 목적을 위한 인터페이스
- 예시: 웹 API, JSON API, DOM API
프로젝트 2: simple-server-api.js
API 서버 구현
핵심 특징:
- HTML 대신 일반 텍스트 제공 (
text/plain
) - 브라우저도 일반 텍스트 표시 가능
- 출력 생성이 더 간단
서버 구현 코드:
const server = createServer(
(request, response) => {
response.statusCode = 200;
response.setHeader('Content-Type', 'text/plain');
const url = new URL('file:' + request.url); // URL 파싱 트릭
const params = url.searchParams;
const content = [
'Number of search parameters: ' + Array.from(params.entries()).length
];
for (const [key, value] of params.entries()) {
content.push(key + '=' + value);
}
response.end(content.join('\n'));
}
);
중요 기법: file:
프로토콜을 접두사로 사용하여 request.url
을 실제 URL로 변환
원격 함수 호출 개념
작동 방식:
- 입력: 검색 매개변수
- 출력: 일반 텍스트 (또는 JSON 등)
예시 사용법:
http://example.com/add?num=3&num=4
Node.js REPL에서 API 접근:
await (await fetch('http://localhost:3000/?key=value')).text()
// 'Number of search parameters: 1\nkey=value'
JavaScript 객체-배열 변환
핵심 메서드
Object.entries(): 객체를 [키, 값] 쌍 배열로 변환
Object.entries({a: 1}); // [ [ 'a', 1 ] ]
Object.fromEntries(): [키, 값] 쌍 배열을 객체로 변환
Object.fromEntries([['a', 1]]); // { a: 1 }
실용적 예시
Map 초기화:
const map = new Map(Object.entries({
one: 1,
two: 2,
}));
속성 키 변경:
function changePropKeys(obj) {
return Object.fromEntries(
Object.entries(obj).map(
([key, value]) => ['_' + key, value]
)
);
}
changePropKeys({one: 1, two: 2});
// {_one: 1, _two: 2}
프로젝트 3: todo-list-server
웹 앱과 서버 상호작용 설계
두 가지 접근 방식:
- 브라우저에서 todo 목록 모델 유지, 변경 후 서버에 저장
- 선택된 방식: 서버에서 todo 목록 모델 유지, 브라우저가 API 호출로 변경
선택 이유:
- 서버 측 API 탐구 가능
- 사용자 인터페이스의 비동기 업데이트 학습
파일 시스템 구조
프로젝트 구성:
package.json # 패키지 스크립트 및 의존성
site/ # 서버가 제공하는 파일들
node_modules/ # 클라이언트 앱 패키지들
html/ # 빌드 시 site/로 복사되는 파일들
client/ # 클라이언트 JavaScript 모듈들
server/ # 서버 앱 코드
data/ # 서버 모델 저장소
package.json 스크립트:
"scripts": {
"start": "node --watch ./server/server.js"
}
개발 실행 방법:
- 서버 터미널:
npm start
- 클라이언트 터미널:
npm run watch
변경 사항 반영:
- 서버 변경: node가 자동 재시작
- 클라이언트 변경: 자동 리빌드 후 브라우저 수동 새로고침 필요
server/server.js 구조
요청 라우팅:
const server = createServer(
async (request, response) => {
const webPath = request.url;
console.log('Request: '+ webPath);
if (webPath.startsWith(API_PATH_PREFIX)) {
await handleApiRequest(request, response, webPath);
return;
}
await handleFileRequest(request, response, webPath);
}
);
라우팅 로직:
/api/
로 시작하는 경로: API 호출 처리- 그 외: 파일 제공
server/handle-file-request.js
파일 제공 로직:
// 검색 매개변수 등 제거
let absPath = new URL('file:' + request.url).pathname;
if (absPath === '/') {
absPath = '/index.html';
}
const fileUrl = new URL(SITE_DIR + absPath);
if (existsSync(fileUrl)) {
response.statusCode = 200;
const ext = path.extname(absPath).toLowerCase();
const contentType = extensionToContentType.get(ext) ?? 'text/plain';
response.setHeader('Content-Type', contentType);
const content = await fs.readFile(fileUrl); // 바이너리
response.end(content);
return;
}
핵심 처리 단계:
- 웹 경로에서 절대 경로 추출
- 최상위 디렉터리 요청 시
index.html
제공 - 파일 존재 확인
- 확장자 기반 콘텐츠 타입 설정
- 바이너리 데이터로 파일 읽기 및 제공
콘텐츠 타입 매핑:
const extensionToContentType = new Map([
['.js', 'text/javascript'],
['.html', 'text/html'],
['.jpg', 'image/jpeg'],
]);
파일 없을 때 오류 처리:
response.statusCode = 404; // Not Found
response.setHeader('Content-Type', 'text/plain');
const content = ['File not found: ' + absoluteWebPath];
response.end(content.join('\n'));
server/handle-api-request.js
모델 초기화:
const DATA_DIR = new URL('../data/', import.meta.url);
const CORE_MODEL_FILE = new URL('todos.json', DATA_DIR);
const coreModel = await readCoreModelFile();
API 요청 처리:
export const handleApiRequest = async (request, response) => {
try {
const url = new URL('file:' + request.url);
const functionName = url.pathname.slice(API_PATH_PREFIX.length);
const searchParams = url.searchParams;
const entries = Array.from(searchParams.entries());
const params = Object.fromEntries(
entries.map(
([key, value]) => [key, JSON.parse(value)]
)
);
if (functionName === 'addTodo') {
coreModel.todos.push(
{ text: params.text, checked: false }
);
await writeCoreModelFile(coreModel);
serveCoreModel(response, coreModel);
return;
}
throw new Error('Could not parse API request: ' + request.url);
} catch (err) {
// 오류 처리
}
};
API 함수 패턴:
coreModel
업데이트- 새
coreModel
을 저장소에 저장 - 클라이언트에 모델 제공
오류 처리:
response.statusCode = 400; // Bad Request
response.setHeader('Content-Type', 'text/plain');
let content = 'An error happened: ' + String(err);
if (err.stack !== undefined) {
content += '\n' + err.stack;
}
response.end(content);
Node.js REPL에서 API 테스트:
const api = 'http://localhost:3000/api/';
await (await fetch(api + 'loadCoreModel')).json()
// { todos: [] }
await (await fetch(api + 'addTodo?text=%22Groceries%22')).json()
// { todos: [ { text: 'Groceries', checked: false } ] }
client/main.js
비동기 로딩 전략:
const appModel = signal(undefined);
function App() {
if (appModel.value === undefined) {
return html`<div>Loading...</div>`;
}
// 실제 앱 렌더링
}
render(html`<${App} />`, document.body); // 초기 렌더링
const coreModel = await loadCoreModel(); // 모델 로드
appModel.value = coreModel; // 재렌더링 트리거
로딩 과정:
appModel.value
가undefined
로 초기화- 초기 렌더링 시 “Loading…” 메시지 표시
- 서버에서 모델 로드 후
appModel.value
할당 - 자동 재렌더링으로 실제 todo 목록 표시
client/app-model.js
모델 연산 위임:
export const addTodo = async (appModel, text) => {
const coreModel = await sendApiRequest('addTodo', { text });
appModel.value = coreModel;
};
API 요청 전송:
const sendApiRequest = async (functionName, params) => {
const usp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
usp.append(key, JSON.stringify(value));
}
const response = await fetch(
`/api/` + functionName + '?' + usp.toString()
);
const coreModel = await response.json();
return coreModel;
};
처리 과정:
- 매개변수 객체를 검색 매개변수로 변환
- JSON 문자열화로 JavaScript 값을 문자열로 변환
- API 요청 전송
- 새 모델 반환
실용적 팁과 주의사항
개발 도구 활용
- curl 명령어:
curl -i <url>
로 응답 헤더 확인 가능 - Node.js --watch: 파일 변경 시 자동 재시작으로 개발 효율성 향상
웹 서버 개선 사항
현재 구현은 간단한 웹 서버이며, 실제 운영을 위해서는 다음 기능들이 추가로 필요합니다:
- 디렉터리 인덱스: 모든 레벨의 디렉터리에서
index.html
제공 - 디렉터리 목록: 개발 중
index.html
이 없는 디렉터리의 내용 표시 - 향상된 오류 처리: 예외 포착 및 보고
- 파일 변경 감시: 개발 중 웹 페이지 자동 새로고침
성능 및 보안 고려사항
- 바이너리 데이터 처리:
readFile()
의 두 번째 매개변수 없이 사용하여 텍스트와 비텍스트 파일 모두 제공 - URL 파싱:
file:
프로토콜 접두사를 사용한 URL 파싱 트릭 활용 - JSON 파싱: 검색 매개변수 값의 다양한 데이터 타입 지원
학습 리소스 및 참고 자료
공식 문서
GitHub 저장소
- 프로젝트 다운로드: learning-web-dev-code 저장소의 지침 따르기
개발 환경 설정
- Node.js 설치: 최신 LTS 버전 권장
- curl 설치: 운영체제에 미설치된 경우 별도 설치 필요