
HTTP: 웹의 택배 시스템
Request하면 Response가 옵니다. 하지만 기억상실증(Stateless)이 있어서 1초 전에 누구였는지 기억 못 합니다. 그래서 쿠키라는 메모지를 만들었습니다.

Request하면 Response가 옵니다. 하지만 기억상실증(Stateless)이 있어서 1초 전에 누구였는지 기억 못 합니다. 그래서 쿠키라는 메모지를 만들었습니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

첫 API를 만들 때였습니다. Node.js + Express로 간단한 게시판 백엔드를 만들고 있었는데, 팀원이 물었습니다.
"글 작성은 POST로 해야 하는 거 맞죠? 근데 GET이랑 뭐가 달라요?"
저는 순간 당황했습니다. "음... GET은 주소창에 보이고 POST는 안 보이고..." 그게 다였습니다. 왜 그런지는 몰랐습니다. 그냥 "게시물 조회는 GET, 생성은 POST"라는 패턴을 암기해서 쓰고 있었을 뿐이었습니다.
그러다가 로그인 기능을 만들면서 더 큰 벽을 만났습니다. 로그인은 성공했는데, 다음 요청에서 "인증되지 않은 사용자"라고 나오는 겁니다. 서버가 1초 전에 로그인한 사실을 까먹는 것 같았습니다. "서버가 치매 걸렸나?" 싶었습니다.
이때 저는 깨달았습니다. HTTP를 제대로 이해하지 못하고는 웹 개발을 할 수 없다는 것을요. 그래서 제대로 파보기 시작했습니다.
"서버가 상태를 기억하지 않는다"
이게 뭔 소리인지 이해가 안 갔습니다. 로그인하면 서버가 "얘는 로그인한 사용자"라고 기억해야 하는 거 아닌가요? 그런데 기억을 못 한다고? 그럼 로그인은 어떻게 유지되는 건지 도무지 이해가 안 갔습니다.
HTTP 앞에 S만 붙었는데, "보안"이 된다는 게 신기했습니다. 뭘 어떻게 하길래 안전해지는 건지 궁금했습니다.
그러다가 선배 개발자가 해준 이 비유를 듣고 완전히 이해했습니다:
HTTP는 우체부입니다.
- 당신: "편지 배달해줘" (Request)
- 우체부: "여기요" (Response)
그런데 이 우체부는 기억상실증이 있습니다. 1분 전에 당신한테 편지 줬는데, 1분 후에 다시 오면 "누구세요?" 합니다.
그래서 당신은 메모지(Cookie)를 씁니다: "나 철수야. 회원번호 12345." 우체부한테 이 메모지를 보여주면, 우체부는 "아 철수구나" 하고 인식합니다.
이 순간 모든 게 이해됐습니다. "결국 이거였다." Stateless는 버그가 아니라 의도된 설계였고, Cookie/Session은 그 한계를 극복하기 위한 해결책이었던 겁니다.
팀 버너스-리가 처음 만든 HTTP는 진짜 단순했습니다:
GET /page.html
끝. 이게 전부였습니다. GET만 있었고, 헤더도 없었고, HTML만 전송할 수 있었습니다. 진짜로 "HyperText를 Transfer하는 Protocol"이었던 겁니다.
인터넷이 커지면서 HTML만으로는 부족했습니다. 이미지도 보내야 하고, 어떤 브라우저인지도 알아야 했습니다. 그래서 헤더(Header)가 생겼습니다:
GET /page.html HTTP/1.0
User-Agent: Mozilla/1.0
Accept: text/html
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...</html>
이제 "뭘 보낼지(Content-Type)", "얼마나 보낼지(Content-Length)"를 알려줄 수 있게 됐습니다.
HTTP/1.0의 문제는 요청 하나마다 연결을 새로 맺었다는 겁니다. 웹페이지 하나에 이미지 10개가 있으면 11번 연결하고 끊고를 반복했습니다. 비효율적이었죠.
HTTP/1.1에서 Keep-Alive가 생기면서 연결을 재사용할 수 있게 됐습니다. 그리고 Host 헤더가 필수가 되면서 한 IP에서 여러 도메인을 호스팅할 수 있게 됐습니다(가상 호스팅).
GET / HTTP/1.1
Host: example.com
Connection: keep-alive
이 버전이 2015년까지 18년간 웹의 표준이었습니다. 지금도 많이 쓰입니다.
HTTP/2(2015)는 속도를 더 빠르게 만들었습니다. 한 연결에서 여러 요청을 동시에 보낼 수 있게 됐고(멀티플렉싱), 헤더를 압축했습니다.
HTTP/3(2022)는 아예 TCP를 버리고 QUIC(UDP 기반)을 씁니다. 더 빠릅니다.
하지만 기본 개념은 HTTP/1.1과 똑같습니다. Request-Response, Stateless, 헤더-바디 구조. 결국 핵심은 변하지 않았습니다.
제가 처음 HTTP 요청을 봤을 때는 "뭔가 복잡하다"고 느꼈습니다. 하지만 뜯어보면 간단합니다.
POST /api/posts HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Cookie: session_id=abc123; theme=dark
Content-Length: 45
{"title": "새 글", "content": "안녕하세요"}
이걸 네 부분으로 나눌 수 있습니다:
POST /api/posts HTTP/1.1
부가 정보들입니다. 제가 자주 쓰는 헤더들:
Host: 어느 도메인으로 보낼지 (필수)
Host: example.com
User-Agent: 어떤 브라우저/클라이언트인지
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
서버가 이걸 보고 모바일/데스크톱을 구분합니다.
Accept: 어떤 형식으로 받고 싶은지
Accept: application/json
"나는 JSON으로 주세요"라는 뜻입니다. text/html, image/png 같은 것도 됩니다.
Content-Type: 내가 보내는 데이터 형식
Content-Type: application/json
POST/PUT 요청에서 필수입니다. 서버가 "아 JSON을 보냈구나" 하고 파싱합니다.
Authorization: 인증 토큰
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT 토큰 같은 걸 여기 담습니다.
Cookie: 쿠키들
Cookie: session_id=abc123; theme=dark; user_id=456
브라우저가 자동으로 붙여줍니다. 세미콜론으로 구분합니다.
헤더와 본문을 구분하는 빈 줄입니다. 이게 없으면 어디서 헤더가 끝나는지 모릅니다.
실제 데이터입니다. GET/DELETE는 보통 본문이 없고, POST/PUT은 있습니다.
{"title": "새 글", "content": "안녕하세요"}
서버의 응답도 비슷한 구조입니다:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600, public
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Set-Cookie: session_id=xyz789; HttpOnly; Secure; SameSite=Lax
Content-Encoding: gzip
Content-Length: 512
{"id": 123, "title": "새 글", "author": "철수"}
HTTP/1.1 200 OK
Content-Type: 뭘 보내는지
Content-Type: application/json
Cache-Control: 캐싱 설정
Cache-Control: max-age=3600, public
"3600초(1시간) 동안 캐시해도 돼. 공개 캐시(CDN 같은 곳)도 OK"
ETag: 리소스의 버전
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
콘텐츠의 해시값입니다. 다음에 요청할 때 "이 버전 가지고 있는데 바뀌었어?"라고 물을 수 있습니다.
Set-Cookie: 쿠키 설정
Set-Cookie: session_id=xyz789; HttpOnly; Secure; SameSite=Lax
브라우저한테 "이 쿠키 저장해줘"라고 명령합니다.
Content-Encoding: 압축 방식
Content-Encoding: gzip
"gzip으로 압축했어. 풀어서 써"
요청과 똑같습니다.
처음엔 GET/POST만 알았는데, 파보니 더 많았습니다.
GET /posts/123
특징:
GET /search?q=검색어&page=1&limit=10
언제 쓰나: 게시물 조회, 검색, 프로필 보기
POST /posts
Content-Type: application/json
{"title": "새 글", "content": "내용"}
특징:
언제 쓰나: 게시물 작성, 회원가입, 파일 업로드
PUT /posts/123
Content-Type: application/json
{"title": "수정된 제목", "content": "수정된 내용"}
특징:
주의: 일부만 보내면 나머지는 삭제됩니다.
PUT /posts/123
{"title": "제목만 수정"}
이렇게 하면 content 필드가 사라집니다!
PATCH /posts/123
Content-Type: application/json
{"title": "제목만 수정"}
특징:
content는 그대로 유지됨언제 쓰나: 게시물 제목만 수정, 프로필 사진만 변경
DELETE /posts/123
특징:
HEAD /posts/123
GET과 똑같은데 본문 없이 헤더만 받습니다.
언제 쓰나: 파일 크기만 알고 싶을 때, 리소스 존재 여부만 확인할 때
// 다운로드 전에 파일 크기 확인
const response = await fetch('/large-file.zip', { method: 'HEAD' });
const size = response.headers.get('Content-Length');
console.log(`파일 크기: ${size} bytes`);
OPTIONS /posts/123
HTTP/1.1 200 OK
Allow: GET, PUT, PATCH, DELETE
Access-Control-Allow-Methods: GET, PUT, DELETE
언제 쓰나: CORS preflight 요청에서 자동으로 날아갑니다. 브라우저가 "이 메서드 써도 돼?" 물어볼 때 씁니다.
멱등성이 있다 = 같은 요청을 여러 번 해도 결과가 같다
이게 왜 중요하냐면, 네트워크 오류 시 재시도를 해도 되는지 판단할 수 있기 때문입니다.
// DELETE는 멱등성 있으니까 재시도 OK
async function deletePost(id) {
let retries = 3;
while (retries > 0) {
try {
await fetch(`/posts/${id}`, { method: 'DELETE' });
break;
} catch (err) {
retries--;
if (retries === 0) throw err;
}
}
}
처음엔 "200은 성공, 404는 없음, 500은 서버 죽음" 정도만 알았습니다. 파보니 훨씬 다양했습니다.
100 Continue: "요청 헤더 받았어, 본문 보내도 돼"
큰 파일 업로드할 때 헤더만 먼저 보내고 서버가 100을 주면 본문을 보냅니다. 서버가 거부하면 본문을 안 보내도 되니까 대역폭 절약.
200 OK: 성공
GET /posts/123
→ 200 OK
201 Created: 생성 성공
POST /posts
→ 201 Created
Location: /posts/456
Location 헤더에 새로 만든 리소스 URL을 담아줍니다.
204 No Content: 성공했는데 돌려줄 내용 없음
DELETE /posts/123
→ 204 No Content
삭제 성공했으면 돌려줄 게 없잖아요? 그럴 때 204.
206 Partial Content: 일부만 전송
GET /video.mp4
Range: bytes=0-1023
→ 206 Partial Content
Content-Range: bytes 0-1023/5000000
이게 바로 동영상 스트리밍의 비밀입니다. YouTube가 동영상 전체를 다운로드 안 하고 필요한 부분만 받는 게 이겁니다.
301 Moved Permanently: 영구 이동
GET /old-page
→ 301 Moved Permanently
Location: /new-page
검색엔진이 "아 이제 이 URL로 바뀌었구나" 하고 인덱스를 업데이트합니다.
302 Found: 임시 이동
GET /admin
→ 302 Found
Location: /login
"지금은 로그인 페이지로 가. 나중에 다시 /admin으로 와"
307 Temporary Redirect: 302와 비슷한데 메서드 유지
POST /old-api
→ 307 Temporary Redirect
Location: /new-api
302는 브라우저가 POST를 GET으로 바꿀 수 있습니다. 307은 POST 그대로 유지.
308 Permanent Redirect: 301의 307 버전
메서드를 바꾸지 않는 영구 이동입니다.
400 Bad Request: 요청이 잘못됨
{"error": "title is required"}
필수 필드 누락, JSON 문법 오류 같은 거.
401 Unauthorized: 인증 필요
GET /api/profile
→ 401 Unauthorized
WWW-Authenticate: Bearer
"로그인 먼저 해"
403 Forbidden: 권한 없음
DELETE /posts/999
→ 403 Forbidden
{"error": "You are not the author"}
로그인은 했는데, 남의 글 지우려고 할 때.
404 Not Found: 없음
GET /posts/99999
→ 404 Not Found
429 Too Many Requests: 요청 너무 많음
POST /api/login
→ 429 Too Many Requests
Retry-After: 60
{"error": "Too many login attempts. Try again in 60 seconds"}
Rate limiting. DDoS 방어, 브루트포스 공격 방어에 씁니다.
500 Internal Server Error: 서버 죽음
// 예외 처리 안 해서 서버가 죽었을 때
app.get('/posts', (req, res) => {
const posts = null;
res.json(posts.filter(p => p.published)); // 💥 Cannot read property 'filter' of null
});
502 Bad Gateway: 게이트웨이(프록시) 문제
nginx (리버스 프록시)
→ 백엔드 서버 (응답 없음)
→ 502 Bad Gateway
백엔드가 죽었거나 응답이 너무 느릴 때.
503 Service Unavailable: 서버 과부하
→ 503 Service Unavailable
Retry-After: 120
서버가 점검 중이거나 트래픽이 너무 많을 때. Retry-After로 "120초 후에 다시 와"라고 알려줍니다.
이 개념이 제일 이해하기 어려웠습니다. "왜 일부러 불편하게 만들었지?"
1. 클라이언트: "로그인할게요. ID: admin, PW: 1234"
2. 서버: "확인했어요. 로그인 성공!"
(1초 후)
3. 클라이언트: "내 프로필 보여줘"
4. 서버: "누구세요? 로그인 먼저 해주세요"
서버가 1초 전에 로그인한 사실을 까먹습니다. 이게 Stateless입니다.
Stateful이면 이런 문제가 생깁니다:
서버A: "철수가 로그인했어. 기억하고 있어야지"
→ 메모리에 { user: "철수", logged_in: true } 저장
(다음 요청이 서버B로 감)
서버B: "철수? 누구세요? 모르는데요?"
로드밸런서가 요청을 여러 서버에 분산하면, 서버A가 기억한 걸 서버B는 모릅니다.
해결책 1: Sticky Session로드밸런서: "철수는 항상 서버A로 보내야지"
문제: 서버A가 죽으면? 철수는 로그아웃됩니다.
해결책 2: Stateless + 외부 저장소서버A, 서버B 모두 Redis를 봄
Redis: { session_id: "abc123" → { user: "철수" } }
아무 서버나 요청을 받아도 Redis를 보면 알 수 있습니다. 이게 현대 웹의 방식입니다.
서버 1대 → 10대 → 100대
모두 똑같이 동작
2. 서버 재시작해도 OK
서버 업데이트 → 재시작
사용자는 로그아웃 안 됨 (세션은 DB/Redis에 있으니까)
3. 단순함
각 요청이 독립적
디버깅 쉬움
결국 이해했습니다. Stateless는 확장성을 위한 트레이드오프였습니다.
서버가 기억 못 하니까 클라이언트가 기억해야 합니다. 그게 쿠키입니다.
1. 로그인 성공
서버 → 클라이언트: "Set-Cookie: session_id=abc123"
2. 브라우저가 쿠키 저장
3. 다음 요청
클라이언트 → 서버: "Cookie: session_id=abc123"
(브라우저가 자동으로 붙임)
4. 서버: "abc123? 아 철수구나!" (DB 조회)
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=3600; Path=/; Domain=.example.com
처음엔 "뭐가 이렇게 많아?" 싶었는데, 하나하나 중요합니다.
HttpOnly: JavaScript 접근 차단
// ❌ 안 됨
document.cookie; // session_id 안 보임
XSS 공격 방어. 해커가 자바스크립트로 쿠키를 훔치는 걸 막습니다.
Secure: HTTPS만
HTTP로 요청하면 쿠키 안 보냄
HTTPS로 요청해야 쿠키 보냄
와이파이 해킹 방어.
SameSite: CSRF 방어
SameSite=Strict: 같은 사이트에서만 쿠키 전송
SameSite=Lax: 링크 클릭은 OK, POST는 안 됨
SameSite=None: 다른 사이트에서도 쿠키 전송 (Secure 필수)
Max-Age: 수명 (초)
Max-Age=3600 → 1시간 후 삭제
Max-Age=0 → 즉시 삭제 (로그아웃)
Expires: 만료 날짜
Expires=Wed, 21 Oct 2026 07:28:00 GMT
Max-Age와 비슷한데 날짜로 지정. Max-Age가 우선순위 높음.
Domain: 어느 도메인에서 쓸지
Domain=.example.com
→ example.com, www.example.com, api.example.com 모두 OK
Path: 어느 경로에서 쓸지
Path=/api
→ /api/users OK, /login 안 됨
쿠키에 모든 정보를 담으면 보안 위험이 있습니다. 그래서 Session ID만 쿠키에 담고, 실제 데이터는 서버(DB/Redis)에 저장합니다.
// 로그인 처리
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !user.checkPassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 세션 생성
const sessionId = crypto.randomUUID();
await redis.set(sessionId, JSON.stringify({ userId: user.id }), 'EX', 3600); // 1시간
// 쿠키 설정
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000 // 1시간 (밀리초)
});
res.json({ message: 'Login successful' });
});
// 인증 미들웨어
async function authenticate(req, res, next) {
const sessionId = req.cookies.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await redis.get(sessionId);
if (!session) {
return res.status(401).json({ error: 'Session expired' });
}
req.user = JSON.parse(session);
next();
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json(user);
});
처음 웹사이트 만들고 "왜 이미지가 매번 다운로드되지?"라고 생각했습니다. 캐싱을 몰랐거든요.
Cache-Control: max-age=3600, public
// 정적 파일은 1년 캐싱
app.use('/static', express.static('public', {
maxAge: '1y'
}));
// API 응답은 캐싱 안 함
app.get('/api/posts', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(posts);
});
1. 첫 요청
GET /api/posts
→ 200 OK
ETag: "abc123"
[게시물 데이터]
2. 다음 요청 (ETag 포함)
GET /api/posts
If-None-Match: "abc123"
3a. 바뀌지 않았으면
→ 304 Not Modified
(본문 없음, 브라우저가 캐시 사용)
3b. 바뀌었으면
→ 200 OK
ETag: "def456"
[새 데이터]
304 Not Modified가 진짜 혁신입니다. 본문을 안 보내니까 대역폭을 엄청 절약합니다.
app.get('/api/posts', async (req, res) => {
const posts = await Post.findAll();
const etag = crypto.createHash('md5').update(JSON.stringify(posts)).digest('hex');
// 클라이언트가 가진 ETag와 비교
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // 본문 없이 304만
}
res.set('ETag', etag);
res.set('Cache-Control', 'max-age=60');
res.json(posts);
});
1. 첫 요청
GET /api/posts
→ 200 OK
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
2. 다음 요청
GET /api/posts
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT
3. 바뀌지 않았으면
→ 304 Not Modified
ETag보다 덜 정확하지만 더 단순합니다.
같은 URL에서 JSON도 줄 수 있고 HTML도 줄 수 있습니다. 클라이언트가 뭘 원하는지에 따라.
GET /api/posts
Accept: application/json
→ JSON 응답
GET /api/posts
Accept: text/html
→ HTML 응답
app.get('/api/posts', async (req, res) => {
const posts = await Post.findAll();
if (req.accepts('json')) {
res.json(posts);
} else if (req.accepts('html')) {
res.render('posts', { posts });
} else {
res.status(406).send('Not Acceptable');
}
});
GET /api/posts
Accept-Encoding: gzip, br
→ 응답을 gzip 또는 Brotli로 압축해줘
const compression = require('compression');
app.use(compression()); // 자동으로 gzip 압축
텍스트는 70~90% 압축됩니다. JSON 10KB → gzip 1KB. 엄청난 절약.
GET /api/posts
Accept-Language: ko-KR, en-US
→ 한국어 있으면 한국어, 없으면 영어
카페 와이파이에서 로그인
클라이언트 → 라우터: "GET /login?password=1234"
↓
해커가 와이파이 패킷 캡처
↓
해커: "비밀번호가 1234네? ㅋㅋ"
평문 전송의 위험:
클라이언트 → 서버: (암호화된 데이터)
↓
해커가 패킷 캡처
↓
해커: "aX93jK2... 이게 뭐야? 못 읽겠네"
HTTPS가 해결하는 것:
1. 클라이언트: "HTTPS 연결 시작할게"
2. 서버: "내 인증서야" (공개키 포함)
3. 클라이언트: 인증서 검증 (CA가 서명했나?)
4. 클라이언트: 대칭키 생성 → 서버 공개키로 암호화 → 전송
5. 서버: 자기 개인키로 복호화 → 대칭키 획득
6. 이제 대칭키로 암호화 통신
제가 실제로 만든 API 서버 코드입니다:
const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const compression = require('compression');
const redis = require('redis');
const crypto = require('crypto');
const app = express();
const redisClient = redis.createClient();
// 미들웨어
app.use(express.json());
app.use(cookieParser());
app.use(compression()); // gzip 압축
app.use(cors({
origin: 'https://example.com',
credentials: true // 쿠키 허용
}));
// 로그인
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// 유효성 검사
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// 사용자 확인 (실제론 DB 조회)
const user = { id: 1, email: 'user@example.com', name: '철수' };
// 세션 생성
const sessionId = crypto.randomUUID();
await redisClient.setEx(
`session:${sessionId}`,
3600, // 1시간
JSON.stringify(user)
);
// 쿠키 설정
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
res.status(200).json({ message: 'Login successful', user });
});
// 인증 미들웨어
async function authenticate(req, res, next) {
const sessionId = req.cookies.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const session = await redisClient.get(`session:${sessionId}`);
if (!session) {
return res.status(401).json({ error: 'Session expired' });
}
req.user = JSON.parse(session);
next();
}
// 게시물 목록 (캐싱 + ETag)
app.get('/api/posts', async (req, res) => {
const posts = [
{ id: 1, title: '첫 글', author: '철수' },
{ id: 2, title: '둘째 글', author: '영희' }
];
// ETag 생성
const etag = crypto.createHash('md5')
.update(JSON.stringify(posts))
.digest('hex');
// 304 Not Modified 처리
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'ETag': etag,
'Cache-Control': 'max-age=300, public' // 5분 캐싱
});
res.json(posts);
});
// 게시물 생성 (인증 필요)
app.post('/api/posts', authenticate, async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Title and content required' });
}
const post = {
id: Date.now(),
title,
content,
author: req.user.name,
createdAt: new Date()
};
// DB 저장 (생략)
res.status(201)
.set('Location', `/api/posts/${post.id}`)
.json(post);
});
// 게시물 조회 (캐싱)
app.get('/api/posts/:id', async (req, res) => {
const post = { id: req.params.id, title: '글 제목' };
res.set({
'Cache-Control': 'max-age=3600, public',
'Last-Modified': new Date('2025-01-01').toUTCString()
});
res.json(post);
});
// 게시물 수정 (인증 필요)
app.patch('/api/posts/:id', authenticate, async (req, res) => {
const { title } = req.body;
// 권한 확인 (생략)
const updatedPost = { id: req.params.id, title };
res.json(updatedPost);
});
// 게시물 삭제 (인증 필요)
app.delete('/api/posts/:id', authenticate, async (req, res) => {
// 삭제 처리 (생략)
res.status(204).end(); // No Content
});
// Rate limiting
const rateLimits = new Map();
app.post('/api/login-limited', async (req, res) => {
const ip = req.ip;
const now = Date.now();
const windowMs = 60000; // 1분
const maxRequests = 5;
if (!rateLimits.has(ip)) {
rateLimits.set(ip, []);
}
const requests = rateLimits.get(ip).filter(time => now - time < windowMs);
if (requests.length >= maxRequests) {
return res.status(429)
.set('Retry-After', '60')
.json({ error: 'Too many requests' });
}
requests.push(now);
rateLimits.set(ip, requests);
// 로그인 처리...
res.json({ message: 'OK' });
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
프론트엔드에서 HTTP 요청하는 코드:
// GET 요청
async function getPosts() {
const response = await fetch('https://api.example.com/posts', {
method: 'GET',
headers: {
'Accept': 'application/json'
},
credentials: 'include' // 쿠키 포함
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 캐싱 헤더 확인
const cacheControl = response.headers.get('Cache-Control');
const etag = response.headers.get('ETag');
console.log('Cache:', cacheControl, 'ETag:', etag);
return response.json();
}
// POST 요청 (로그인)
async function login(email, password) {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include', // 쿠키 받기
body: JSON.stringify({ email, password })
});
if (response.status === 401) {
throw new Error('Invalid credentials');
}
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
// 인증이 필요한 요청
async function getProfile() {
const response = await fetch('https://api.example.com/profile', {
credentials: 'include' // 쿠키 자동 전송
});
if (response.status === 401) {
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
return;
}
return response.json();
}
// ETag를 활용한 조건부 요청
let cachedPosts = null;
let cachedETag = null;
async function getPostsWithCache() {
const headers = {
'Accept': 'application/json'
};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch('https://api.example.com/posts', {
headers,
credentials: 'include'
});
if (response.status === 304) {
console.log('Using cached data');
return cachedPosts; // 캐시된 데이터 사용
}
cachedETag = response.headers.get('ETag');
cachedPosts = await response.json();
return cachedPosts;
}
// 에러 처리
async function robustFetch(url, options) {
try {
const response = await fetch(url, options);
// 상태 코드별 처리
switch (response.status) {
case 200:
case 201:
return response.json();
case 204:
return null; // No Content
case 304:
return { cached: true };
case 400:
const error = await response.json();
throw new Error(`Validation error: ${error.message}`);
case 401:
window.location.href = '/login';
break;
case 403:
throw new Error('Permission denied');
case 404:
throw new Error('Resource not found');
case 429:
const retryAfter = response.headers.get('Retry-After');
throw new Error(`Rate limited. Retry after ${retryAfter}s`);
case 500:
case 502:
case 503:
throw new Error('Server error. Please try again later.');
default:
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
if (err.name === 'TypeError') {
// 네트워크 에러
throw new Error('Network error. Check your connection.');
}
throw err;
}
}
처음 로그인 기능 만들 때 이렇게 했습니다:
// ❌ 절대 이러지 마세요
async function login(email, password) {
const response = await fetch(`/login?email=${email}&password=${password}`);
return response.json();
}
문제:
[GET] /login?password=1234)// ✅ 올바른 방법
async function login(email, password) {
const response = await fetch('https://example.com/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return response.json();
}
프론트엔드(localhost:3000)에서 백엔드(localhost:4000)로 요청했는데:
Access to fetch at 'http://localhost:4000/api' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
처음엔 "왜 같은 컴퓨터인데 안 되지?"라고 생각했습니다. 포트가 다르면 다른 origin입니다.
해결:
// 백엔드
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000',
credentials: true // 쿠키 허용
}));
// 프론트엔드
fetch('http://localhost:4000/api', {
credentials: 'include' // 쿠키 보내기
});
// ❌ 이상하게 섞음
fetch('/api/posts?category=tech', {
method: 'POST',
body: JSON.stringify({ title: '제목' })
});
"category는 왜 쿼리스트링에 넣었지?" 나중에 보면 헷갈립니다.
정리:
GET /api/posts?category=tech&page=1
GET /api/posts/123
DELETE /api/users/456
POST /api/posts
{ "title": "제목", "content": "내용" }
게시물 수정했는데 프론트엔드에서 안 바뀌는 겁니다. 브라우저가 캐시를 쓰고 있었습니다.
해결:
// 수정/삭제 후 캐시 무효화
app.patch('/api/posts/:id', async (req, res) => {
// 수정...
res.set('Cache-Control', 'no-cache'); // 캐시 쓰지 마
res.json(updatedPost);
});
또는 ETag를 바꿔서 브라우저가 "아 바뀌었구나" 알게 합니다.
// ❌ 안 됨
const sessionId = document.cookie; // session_id 안 보임
HttpOnly 쿠키는 JavaScript 접근이 막혀 있습니다. 브라우저가 자동으로 붙여주니까 신경 안 써도 됩니다.
// ❌ 예외 처리 없음
app.get('/api/posts', (req, res) => {
const posts = null;
res.json(posts.filter(p => p.published)); // 💥 서버 죽음
});
// ✅ try-catch
app.get('/api/posts', async (req, res) => {
try {
const posts = await Post.findAll();
res.json(posts);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
HTTP를 공부하면서 이해했던 핵심:
처음엔 "그냥 데이터 주고받는 거"였지만, 지금은 HTTP는 웹의 언어라는 걸 이해했습니다. Request 헤더 하나, Cookie 속성 하나에도 이유가 있고, 그걸 이해하면 더 안전하고 빠른 웹을 만들 수 있습니다.
"기억상실증 우체부"라는 비유가 모든 걸 이해하게 해줬습니다. 결국 HTTP를 이해한다는 건, 웹이 어떻게 동작하는지 이해하는 것이었습니다.