서버와 클라이언트 다루기
작은 웹 서버를 만들고 fetch로 요청을 보내며 HTTP 동작을 직접 확인합니다. query 파싱과 인코딩, GET과 POST의 차이, 폼 전송, Post-Redirect-Get 패턴, JSON API를 다룹니다.
1편에서는 요청과 응답을 들여다봤습니다. 이번에는 직접 작은 웹 서버를 만들어 요청을 받아 보고, fetch로 클라이언트가 되어 요청을 보내봅니다. 양쪽을 모두 다뤄 보면 HTTP의 동작이 더 분명해집니다. 예제는 Node.js와 브라우저 기준이지만, 핵심은 언어와 무관한 HTTP 개념입니다.
가장 단순한 서버
웹 서버는 두 부분으로 나눌 수 있습니다. 포트를 열고 요청을 받는 부분, 그리고 받은 요청을 처리해 응답을 만드는 핸들러입니다. Node.js 내장 http 모듈로 표현하면 다음과 같습니다.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
// 1. 상태 코드와 헤더를 보낸다
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
// 2. 본문을 쓰고 응답을 끝낸다
res.end('Hello, HTTP!\n');
});
server.listen(8000, () => console.log('http://localhost:8000'));핸들러 (req, res) => { ... }가 요청 하나를 처리합니다. 1편에서 본 응답의 세 부분(상태 코드, 헤더, 본문)이 그대로 코드에 대응됩니다. writeHead로 상태 코드와 헤더를, end로 본문을 보냅니다. 어떤 경로로 접속해도 항상 Hello, HTTP!만 응답하는 서버입니다.
같은 포트는 한 번에 한 프로그램만 사용할 수 있습니다. 서버 두 개를 동시에 띄우려면 포트를 다르게 지정해야 합니다.
요청을 활용하기: echo 서버
요청 정보를 읽어 응답에 반영해 보겠습니다. req.url에는 요청 경로가 들어 있습니다. 경로를 그대로 돌려주는 echo 서버입니다.
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(req.url.slice(1)); // '/bears' → 'bears'
});http://localhost:8000/bears로 접속하면 bears가 보입니다. 여기서 몇 가지를 확인할 수 있습니다.
http://localhost:8000/spiders#stardust→stardust는 보이지 않습니다. fragment(#)는 서버로 전송되지 않기 때문입니다.http://localhost:8000/squid?color=green→squid?color=green처럼 query까지 함께 보입니다. query는 경로의 일부로 전송됩니다.
echo 서버에 http://localhost:8000/page?tab=info#top 으로 접속하면 응답에 나타나지 않는 값은?
query 파싱과 URL 인코딩
검색 같은 기능에서는 ? 뒤에 여러 query 파라미터가 붙습니다. key=value 형태로 &로 구분합니다.
GET /search?q=gray+squirrel&tbm=isch HTTP/1.1
Host: www.google.com이 query에는 q(값 gray squirrel)와 tbm(값 isch) 두 파라미터가 있습니다. 직접 문자열을 쪼개는 대신 URLSearchParams로 파싱합니다.
const url = new URL(req.url, 'http://localhost');
url.searchParams.get('q'); // 'gray squirrel'
url.searchParams.get('tbm'); // 'isch'gray+squirrel이 gray squirrel로 바뀐 점에 주목하세요. URL에는 공백이나 특수문자를 그대로 넣을 수 없어, 전송 전에 URL 인코딩(퍼센트 인코딩)을 거칩니다. 공백은 + 또는 %20으로, 그 밖의 특수문자는 % 뒤 16진수로 바뀝니다. 파싱 단계에서 다시 원래 문자로 복원됩니다.
encodeURIComponent('gray squirrel & fox'); // 'gray%20squirrel%20%26%20fox'코드에서 URI를 직접 조립할 때는 값에 인코딩이 필요합니다. 예상치 못한 곳에
%나+가 보인다면, 어딘가에서 인코딩·디코딩이 빠졌거나 중복됐을 가능성이 큽니다.
GET과 POST
대부분의 query는 사용자가 직접 입력하기보다 폼(form) 전송으로 만들어집니다. 폼의 전송 방식에는 GET과 POST가 있습니다.
<form action="/search" method="GET">
<input type="text" name="q" />
<button type="submit">검색</button>
</form>- GET 전송 — 폼 데이터를 URI의 query에 담아 보냅니다(
/search?q=...). 주소에 남으므로 북마크·새로고침이 가능합니다. 검색처럼 조회하는 동작에 적합합니다. - POST 전송 — 폼 데이터를 URI가 아닌 요청 본문(body) 에 담아 보냅니다. 장바구니 담기, 글쓰기처럼 리소스를 생성·변경하는 동작에 적합합니다.
멱등성(idempotence)
멱등(idempotent) 한 동작은 한 번 하든 여러 번 하든 결과가 같습니다. “검색 결과 보기”는 멱등하지만 “장바구니에 담기”는 두 번 하면 두 개가 담기므로 멱등하지 않습니다.
GET은 멱등한 조회에, POST는 멱등하지 않은 동작에 씁니다. 브라우저가 새로고침 시 “양식을 다시 제출하시겠습니까?”라고 묻는 것은, 멱등하지 않은 POST를 다시 보낼지 확인하는 것입니다.
다음 중 POST로 처리하는 것이 가장 적절한 동작은?
POST 요청의 본문 읽기
GET에서는 데이터가 URI에 있지만, POST에서는 본문에 있습니다. POST 요청에는 Content-Type과 Content-Length 헤더가 함께 들어옵니다 — 1편에서 응답에만 봤던 헤더가 요청에도 붙는 것입니다.
const server = createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
const data = new URLSearchParams(body); // 'magic=mystery&secret=spooky'
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`magic=${data.get('magic')}`);
});
}
});HTTP 헤더 이름은 대소문자를 구분하지 않습니다.
Content-Length,content-length는 같습니다. 다만 사람이 읽기 좋게 일관된 표기를 쓰는 편이 좋습니다.
Post-Redirect-Get 패턴
폼을 다루는 서버에서 자주 쓰는 설계가 PRG(Post-Redirect-Get)입니다. 댓글 작성 같은 흐름을 예로 들면 다음과 같습니다.
- 사용자가
/에 GET 요청 → 서버가200 OK와 폼·목록 HTML 응답. - 폼을 작성해 POST 전송 → 서버가 데이터를 저장.
- 서버가
200이 아니라303리다이렉트로 응답하며Location: /지정. - 브라우저가 리다이렉트를 따라
/로 다시 GET → 갱신된 목록을 받음.
// POST 처리 끝부분
res.writeHead(303, { Location: '/' });
res.end();이 패턴의 장점은 사용자가 실제로 보는 모든 페이지가 GET 결과라는 점입니다. 따라서 새로고침하거나 북마크해도 POST가 다시 전송되지 않아, 댓글이 중복 등록되는 사고를 막을 수 있습니다.
클라이언트 되기: fetch
이번엔 반대로 코드가 클라이언트가 되어 요청을 보냅니다. 브라우저와 최신 Node.js에는 fetch가 내장되어 있습니다.
const res = await fetch('https://api.example.com/users');
res.status; // 200
res.ok; // status가 200~299면 true
const text = await res.text(); // 본문을 문자열로fetch는 응답을 받기만 하면 성공으로 간주합니다. 404나 500을 받아도 reject되지 않으며, 네트워크 자체가 끊긴 경우에만 reject됩니다. 따라서 상태 코드는 직접 확인해야 합니다.
const res = await fetch(url);
if (!res.ok) {
throw new Error(`요청 실패: ${res.status}`);
}fetch로 존재하지 않는 페이지를 요청해 404를 받았다. 코드 동작으로 옳은 것은?
JSON API 다루기
웹 API에서 가장 널리 쓰는 데이터 형식은 JSON(JavaScript Object Notation)입니다. 이름처럼 JavaScript 객체 문법을 기반으로 하며, 서버는 Content-Type: application/json으로 응답합니다.
const res = await fetch('https://swapi.dev/api/people/1/');
const data = await res.json(); // JSON 문자열을 객체로 파싱
data.name; // 'Luke Skywalker'res.json()은 본문이 JSON일 때 객체로 변환합니다. 본문이 JSON이 아닐 때(예: HTML 에러 페이지) 호출하면 파싱 오류가 납니다. 그래서 상태 코드를 먼저 확인하고 파싱하는 것이 안전합니다.
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();POST로 JSON을 보낼 때는 본문과 Content-Type 헤더를 함께 지정합니다.
await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: '안녕하세요' }),
});정리 · 서버는 요청을 받아 상태 코드·헤더·본문으로 응답하고, 클라이언트(
fetch)는 그 응답을 받아 처리합니다. GET은 조회, POST는 변경에 쓰며, 폼 흐름에서는 PRG로 중복 전송을 막습니다. API 데이터는 대부분 JSON으로 주고받습니다.
이 레슨을 다 읽으셨나요?
완료 표시는 이 브라우저에 저장됩니다.