Skip to main content
  1. Posts 리스트/

웹 개발 배우기 16편 - 클라이언트와 서버의 만남, Node.js로 나만의 웹 서버 만들기

·1264 words·6 mins·
웹 개발 배우기 - This article is part of a series.
Part : This Article
웹 개발 배우기 16편 - 클라이언트와 서버의 만남, Node.js로 나만의 웹 서버 만들기

이 글은 프로그래밍 경험이 없는 분들을 대상으로 자바스크립트 웹 앱 제작법을 알려드리는 ‘웹 개발 배우기’ 시리즈의 일부인데요.

이번 시간에는 드디어 우리만의 웹 서버를 직접 만들어 볼 건데요.

파일을 제공하고, 브라우저 앱을 위한 데이터를 관리하는 진짜 서버의 역할을 수행하게 될 겁니다.

용어 정리 브라우저 vs. 서버
#

다음의 반대말 쌍들은 모두 서로 관련이 있는데요.

  • 브라우저 vs. 서버

  • 로컬 vs. 원격

  • 프론트엔드 vs. 백엔드

  • 클라이언트 vs. 서버

‘클라이언트’라는 용어는 ‘브라우저’라는 용어보다 더 일반적이기 때문에 흥미로운데요.

서버에 연결하는 모든 앱(웹 앱, 모바일 앱 등)을 가리킵니다.

웹 개발에서는 보통 ‘브라우저’나 ‘웹 앱’을 의미합니다.

웹 리소스 제공의 배경 지식
#

우리가 직접 첫 웹 서버를 만들기 전에, 먼저 웹에서 리소스(대략 ‘파일’이라고 생각하시면 됩니다)가 어떻게 제공되는지 그 원리를 좀 더 알아야 하는데요.

브라우저는 서버에게 리소스를 달라는 ‘요청(request)‘을 보냅니다.

서버는 그 요청에 대한 ‘응답(response)‘으로 해당 리소스의 데이터를 보내주는 구조거든요.

HTTP 요청과 응답을 ‘HTTP 메시지’라고 부릅니다.

HTTP 응답
#

HTTP/1.1 프로토콜 버전에서는 HTML 페이지를 다음과 같은 텍스트 형식으로 제공하는데요.

HTTP/1.1 200 OK
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 등)를 알려주는 ‘상태 코드’를 명시합니다.

그 뒤에는 0개 이상의 헤더 필드로 구성된 ‘헤더(header)‘가 따라오거든요.

하나의 중요한 헤더 필드는 content-type인데, 제공되는 리소스의 미디어 타입을 지정합니다.

text/html은 HTML의 미디어 타입입니다.

헤더는 빈 줄로 끝나고요.

그 뒤에 ‘본문(body)‘이 응답의 실제 데이터를 포함합니다.

헤더 필드는 메타데이터(데이터에 대한 데이터)이고, 본문은 데이터입니다.

현재 HTTP 버전인 HTTP/3와 1.1 이후의 HTTP 버전들은 더 이상 텍스트 형식을 사용하지 않지만, 프로토콜 버전, 상태 코드, 헤더, 본문과 같은 핵심 부분은 동일합니다.

상태 코드
#

각 상태 코드는 숫자와 설명을 가지고 있는데요.

숫자는 다음과 같은 범위를 가집니다.

  • 1xx: 정보 - 요청을 받았으며, 프로세스를 계속합니다.

  • 2xx: 성공 - 동작이 성공적으로 수신되고, 이해되었으며, 수용되었습니다.

  • 3xx: 리디렉션 - 요청을 완료하기 위해 추가 조치가 필요합니다.

  • 4xx: 클라이언트 오류 - 요청에 잘못된 문법이 있거나 수행될 수 없습니다.

  • 5xx: 서버 오류 - 서버가 유효해 보이는 요청을 이행하지 못했습니다.

몇 가지 예시는 다음과 같습니다.

200 OK, 400 Bad Request, 404 Not Found, 500 Internal Server Error

미디어 타입 데이터의 종류
#

대부분의 운영체제에서는 파일 확장자가 파일에 저장된 데이터의 종류를 나타내는데요.

웹은 리소스를 위해 다른 종류의 메커니즘을 사용합니다.

바로 ‘미디어 타입(Media Type, MIME 타입)‘입니다.

몇 가지 예시는 다음과 같은데요.

  • 일반 텍스트: text/plain

  • HTML: text/html

  • CSS: text/css

  • 자바스크립트: text/javascript

  • JSON: application/json

프로젝트 simple-server-html.js
#

이제 문자열에서 콘텐츠를 가져오는 단일 웹 페이지만을 제공하는 간단한 웹 서버를 작성해 보겠습니다.

다음과 같이 서버를 실행할 수 있는데요.

node --watch simple-server-html.js

--watch 옵션은 자바스크립트 파일이 변경될 때마다 노드제이에스(Node.js)가 파일을 다시 시작하게 만듭니다.

simple-server-html.js는 다음과 같은데요.

import { createServer } from 'node:http';

const hostname = 'localhost';
const port = 3000;

const server = createServer(
  (request, response) => {
    // ...
  }
);

server.listen(port, hostname, () => { // (A)
  console.log(`Server running at http://${hostname}:${port}/`);
});

먼저 서버를 만드는데요.

그 인자는 요청에 대한 리스너입니다.

이벤트 리스너와 유사한데, 이 경우 이벤트는 HTTP 요청입니다.

그런 다음 서버를 시작하고(A), 어떤 포트에서 수신 대기할지 지정합니다.

요청은 다음과 같이 처리되는데요.

const server = createServer(
  (request, response) => {
    response.statusCode = 200;
    response.setHeader('Content-Type', 'text/html');
    const content = `<!DOCTYPE html>... Path: ${request.url}`;
    response.end(content.join('\n'));
  }
);

먼저 상태 코드와 콘텐츠 타입이라는 두 가지 메타데이터를 설정하고요.

콘텐츠를 단일 텍스트 덩어리로 제공한 다음 응답을 끝냅니다(닫습니다).

request.url
#

제공된 웹 페이지는 웹 서버가 요청을 받을 때 수신하는 경로를 검토할 기회를 주는데요.

request.url은 약간 잘못된 이름입니다.

URL에서 호스트 뒤에 오는 부분일 뿐이거든요.

http://localhost:3000/file.html이라는 URL의 request.url/file.html입니다.

URL의 기능 검색 파라미터
#

‘검색 파라미터(search parameter)’ 또는 ‘쿼리 문자열(query string)‘은 URL의 경로 뒤에 추가할 수 있는 것인데요.

물음표(?) 뒤에 하나 이상의 키=값 쌍이 앰퍼샌드(&)로 구분되어 구성됩니다.

검색 파라미터는 서버에 지침을 보내는 데 사용될 수 있습니다.

URLSearchParams
#

URLSearchParams 클래스는 검색 파라미터를 파싱하는 데 도움이 되는데요.

new URLSearchParams('?k1=v1&k2=v2')와 같이 인스턴스를 생성하면, .get('k1')로 값을 가져오거나 .has('k1')로 키의 존재 여부를 확인할 수 있습니다.

URL 객체는 .searchParams 속성을 통해 편리하게 URLSearchParams 인스턴스를 제공하거든요.

new URL('...?key=value').searchParams와 같이 접근할 수 있습니다.

퍼센트 인코딩
#

공백과 같은 일부 문자는 URL에 허용되지 않기 때문에 인코딩되는데요.

공백은 더하기 기호(+)로, 다른 문자들은 퍼센트 기호(%)와 문자의 코드 포인트에 해당하는 두 자리 16진수로 인코딩됩니다.

인터페이스 vs. 구현 그리고 API
#

프로그래밍에서 우리는 ‘인터페이스’와 ‘구현’을 구분하는데요.

‘인터페이스’는 함수, 클래스 등의 모음의 표면입니다.

그들의 구조(이름, 파라미터 수 등)를 설명하죠.

‘API(Application Programming Interface)‘는 특정 목적을 위한 인터페이스인데요.

예를 들어 브라우저가 제공하는 기능은 종종 ‘웹 API’라고 불립니다.

프로젝트 simple-server-api.js
#

이 프로젝트에서는 API 서버를 작성하는데요.

서버에서 실행되는 기능을 호출할 수 있게 해줍니다.

const server = createServer(
  (request, response) => {
    response.statusCode = 200;
    response.setHeader('Content-Type', 'text/plain'); // (A)
    const url = new URL('file:' + request.url); // (B)
    const params = url.searchParams;
    // ...
    response.end(/* ... */);
  }
);

이번에는 HTML 페이지 대신 일반 텍스트를 제공하는데요(A).

request.url에서 검색 파라미터를 추출하기 위해, file: 프로토콜을 접두사로 붙여 실제 URL로 변환하는 트릭을 사용합니다(B).

원격 함수 호출
#

지금 일어나고 있는 일은 (원격) 함수 호출과 같은데요.

입력은 검색 파라미터, 출력은 일반 텍스트입니다.

JSON을 반환할 수도 있었겠죠.

자바스크립트 객체를 [키, 값] 쌍으로 변환하고 그 반대도 하기
#

Object.entries()는 객체를 [키, 값] 쌍의 배열로 변환하고요.

Object.fromEntries()는 그 반대 작업을 수행합니다.

우리는 다음 프로젝트에서 이 두 가지 모두를 필요로 할 겁니다.

프로젝트 todo-list-server
#

이전 챕터에서는 브라우저에서 Todo 리스트를 편집하는 웹 앱을 구현했는데요.

이 프로젝트에서는 추가로 웹 서버를 구현하고 Todo 리스트를 그곳에 저장합니다.

웹 앱은 서버와 어떻게 상호작용해야 할까요?
#

우리는 Todo 리스트 모델을 서버에 유지하고, 브라우저는 API 호출을 통해 모델을 변경하며, 변경 후에는 현재 모델을 다시 받는 두 번째 접근 방식을 사용할 건데요.

이를 통해 서버 측 API와 사용자 인터페이스의 비동기 업데이트를 탐색할 수 있습니다.

파일 시스템 구조
#

이제 우리는 클라이언트 앱과 서버 앱, 두 개의 자바스크립트 앱을 갖게 되는데요.

  • 공유: package.json, site/(서버가 제공하고 클라이언트 앱이 빌드되는 디렉토리)

  • 클라이언트: node_modules/, html/, client/(번들링될 자바스크립트 모듈)

  • 서버: server/, data/(모델이 저장되는 곳)

server/server.js
#

요청의 경로가 /api/로 시작하는지에 따라 서버가 수행하는 작업이 결정되는데요.

/api/로 시작하면 API 호출이고, 그렇지 않으면 서버는 파일을 제공합니다.

const server = createServer(
  async (request, response) => {
    const webPath = request.url;
    if (webPath.startsWith(API_PATH_PREFIX)) {
      await handleApiRequest(request, response, webPath);
      return;
    }
    await handleFileRequest(request, response, webPath);
  }
);

server/handle-file-request.js
#

파일을 제공하는 방법은 다음과 같은데요.

웹 경로가 서버의 최상위 디렉토리를 참조하면, 해당 디렉토리의 index.html을 제공합니다.

그런 다음 절대 경로를 site/ 디렉토리 내 파일의 URL로 변환하고, 해당 URL에 파일이 있으면 제공합니다.

파일 확장자를 기반으로 콘텐츠 타입을 조회하고, fs.readFile()로 파일 내용을 읽어 응답에 추가합니다.

파일을 찾을 수 없으면, 404 오류 메시지로 응답합니다.

server/handle-api-request.js
#

서버가 시작되면 스토리지에서 모델을 읽어오는데요.

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 params = Object.fromEntries(/* ... */);
    
    if (functionName === 'addTodo') {
      coreModel.todos.push({ text: params.text, checked: false });
      await writeCoreModelFile(coreModel);
      serveCoreModel(response, coreModel);
      return;
    }
    // ...
  } catch (err) {
    // ...
  }
};

먼저 경로에서 functionName을 추출하고, 검색 파라미터를 객체로 변환합니다.

각 함수는 coreModel을 업데이트하고, 새 모델을 스토리지에 저장한 다음, 클라이언트에 제공하는 동일한 패턴을 따릅니다.

client/main.js
#

클라이언트 측에서는 한 가지 딜레마에 직면하는데요.

UI를 가능한 한 빨리 표시하고 싶지만, 그러려면 먼저 서버에서 모델을 로드해야 합니다.

따라서 우리는 다음과 같은 접근 방식을 사용하거든요.

const appModel = signal(undefined);

function App() {
  if (appModel.value === undefined) {
    return html`<div>Loading...</div>`;
  }
  // ...
}

render(html`<${App} />`, document.body); // (A)

const coreModel = await loadCoreModel(); // (B)
appModel.value = coreModel;

appModel.value는 초기에 undefined인데요.

따라서 (A)에서 처음 렌더링될 때 App 컴포넌트는 “Loading…” 메시지를 표시합니다.

초기 렌더링 후, (B)에서 모델을 로드하고 appModel.value에 할당하거든요.

이는 App을 다시 렌더링하게 하고, 그러면 Todo 리스트가 표시됩니다.

client/app-model.js
#

todo-list-browserapp-model.js가 모델 변경을 직접 수행했던 것과 달리, todo-list-server 버전의 해당 모듈은 그 작업을 API 서버에 위임하는데요.

export const addTodo = async (appModel, text) => {
  const coreModel = await sendApiRequest('addTodo', { text });
  appModel.value = coreModel;
};

API 요청을 보내고, 서버가 새 모델을 반환할 때까지 기다린 다음, 그 모델을 appModel.value에 할당합니다.

그러면 사용자 인터페이스가 다시 렌더링되는 거죠.

웹 개발 배우기 - This article is part of a series.
Part : This Article