웹 개발 학습: 웹 서버 구현 | Axel Rauschmayer


웹 개발 학습: 웹 서버 구현

개요

이 문서는 “Learning web development” 시리즈의 일부로, JavaScript를 사용하여 웹 애플리케이션을 만드는 방법을 처음 프로그래밍을 배우는 사람들에게 가르치는 내용입니다. 이번 장에서는 파일을 제공하고 브라우저 앱의 데이터를 관리하는 자체 웹 서버를 작성합니다.

핵심 용어 및 개념

브라우저 vs 서버 용어 정리

대응 관계:

  • 브라우저 ↔ 서버
  • 로컬 ↔ 원격
  • 프론트엔드 ↔ 백엔드
  • 클라이언트 ↔ 서버

중요 포인트: "클라이언트"는 "브라우저"보다 더 일반적인 용어로, 서버에 연결하는 모든 앱(웹 앱, 모바일 앱 등)을 의미합니다.

웹 리소스 제공 배경

기본 작동 원리:

  1. 브라우저가 서버에 요청을 보냄 (보통 리소스 제공 요청)
  2. 서버가 응답을 보냄 (보통 해당 리소스의 데이터)
  3. 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>
...

구성 요소:

  1. 시작 라인: HTTP 프로토콜 버전과 상태 코드 (성공: 200, 오류: 404 등)
  2. 헤더: 키-값 쌍으로 구성된 메타데이터 (키는 콜론으로 끝남)
  3. 빈 줄: 헤더의 끝을 표시
  4. 본문: 실제 응답 데이터

중요한 헤더 필드:

  • 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'));
  }
);

처리 단계:

  1. 메타데이터 설정 (상태 코드, 콘텐츠 타입)
  2. 콘텐츠를 단일 텍스트 청크로 제공
  3. 응답 종료 (닫기)

실용적 팁: 최소한의 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

웹 앱과 서버 상호작용 설계

두 가지 접근 방식:

  1. 브라우저에서 todo 목록 모델 유지, 변경 후 서버에 저장
  2. 선택된 방식: 서버에서 todo 목록 모델 유지, 브라우저가 API 호출로 변경

선택 이유:

  • 서버 측 API 탐구 가능
  • 사용자 인터페이스의 비동기 업데이트 학습

파일 시스템 구조

프로젝트 구성:

package.json              # 패키지 스크립트 및 의존성
site/                     # 서버가 제공하는 파일들
node_modules/             # 클라이언트 앱 패키지들
html/                     # 빌드 시 site/로 복사되는 파일들
client/                   # 클라이언트 JavaScript 모듈들
server/                   # 서버 앱 코드
data/                     # 서버 모델 저장소

package.json 스크립트:

"scripts": {
  "start": "node --watch ./server/server.js"
}

개발 실행 방법:

  1. 서버 터미널: npm start
  2. 클라이언트 터미널: 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;
}

핵심 처리 단계:

  1. 웹 경로에서 절대 경로 추출
  2. 최상위 디렉터리 요청 시 index.html 제공
  3. 파일 존재 확인
  4. 확장자 기반 콘텐츠 타입 설정
  5. 바이너리 데이터로 파일 읽기 및 제공

콘텐츠 타입 매핑:

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 함수 패턴:

  1. coreModel 업데이트
  2. coreModel을 저장소에 저장
  3. 클라이언트에 모델 제공

오류 처리:

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; // 재렌더링 트리거

로딩 과정:

  1. appModel.valueundefined로 초기화
  2. 초기 렌더링 시 “Loading…” 메시지 표시
  3. 서버에서 모델 로드 후 appModel.value 할당
  4. 자동 재렌더링으로 실제 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;
};

처리 과정:

  1. 매개변수 객체를 검색 매개변수로 변환
  2. JSON 문자열화로 JavaScript 값을 문자열로 변환
  3. API 요청 전송
  4. 새 모델 반환

실용적 팁과 주의사항

개발 도구 활용

  • curl 명령어: curl -i <url>로 응답 헤더 확인 가능
  • Node.js --watch: 파일 변경 시 자동 재시작으로 개발 효율성 향상

웹 서버 개선 사항

현재 구현은 간단한 웹 서버이며, 실제 운영을 위해서는 다음 기능들이 추가로 필요합니다:

  • 디렉터리 인덱스: 모든 레벨의 디렉터리에서 index.html 제공
  • 디렉터리 목록: 개발 중 index.html이 없는 디렉터리의 내용 표시
  • 향상된 오류 처리: 예외 포착 및 보고
  • 파일 변경 감시: 개발 중 웹 페이지 자동 새로고침

성능 및 보안 고려사항

  • 바이너리 데이터 처리: readFile()의 두 번째 매개변수 없이 사용하여 텍스트와 비텍스트 파일 모두 제공
  • URL 파싱: file: 프로토콜 접두사를 사용한 URL 파싱 트릭 활용
  • JSON 파싱: 검색 매개변수 값의 다양한 데이터 타입 지원

학습 리소스 및 참고 자료

공식 문서

GitHub 저장소

개발 환경 설정

  • Node.js 설치: 최신 LTS 버전 권장
  • curl 설치: 운영체제에 미설치된 경우 별도 설치 필요