
HTTP 상태 코드: 200, 404, 500의 의미
서버가 보내는 신호등. 200번대는 성공, 400번대는 네 탓, 500번대는 내 탓.

서버가 보내는 신호등. 200번대는 성공, 400번대는 네 탓, 500번대는 내 탓.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

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

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

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

첫 프로젝트에서 API 를 만들고 있었다. 그런데 커뮤니티의 다른 개발자가 물었다. "로그인 실패하면 뭘 보내야 해? 401? 403?" 나는 그냥 "에러면 다 400 아니야?"라고 답했다. 그 순간 상대방의 표정이 미묘했다. 나는 HTTP 상태 코드를 제대로 이해하지 못하고 있었다.
프론트엔드 개발자가 계속 물었다. "왜 200인데 에러 메시지가 와?" 내가 만든 API는 무조건 200을 보내고, body에 { success: false, error: "..." } 같은 걸 넣었다. 그때 깨달았다. 상태 코드를 무시하면 안 된다는 걸.
결국 이 공부는 "서버가 클라이언트에게 보내는 신호등"을 제대로 이해하기 위한 것이었다. 숫자 세 자리가 얼마나 많은 정보를 담고 있는지 몰랐다.
초보 때 내 생각이었다. "어차피 통신 성공했으면 200 보내고, 에러는 JSON body에 넣으면 되지 않나?" 실제로 몇몇 API가 그렇게 설계되기도 했다. 하지만 이건 HTTP 의 철학을 무시하는 것이었다.
브라우저, 프록시, 로드밸런서, 모니터링 시스템 모두 상태 코드를 본다. 전부 200이면 이들은 "문제없음"으로 판단한다. 내 서버가 실제로는 500 에러를 뿜고 있어도, 외부 시스템들은 "정상"으로 기록한다. 디버깅 악몽의 시작이다.
이게 진짜 헷갈렸다. 둘 다 "접근 안 돼"인데 왜 나눠놨을까?
나는 이렇게 이해했다. 401은 "너 누군지 모르겠어, 신분증 보여줘"이고, 403은 "신분은 확인했는데, 너는 여기 못 들어와"다.
편의점 비유가 와닿았다. 담배 사러 가면 직원이 신분증 확인한다. 신분증 안 보여주면? 401 Unauthorized. 미성년자라는 걸 확인했는데 못 팔면? 403 Forbidden. 신분은 확인됐지만 권한이 없다.
둘 다 리다이렉트인데 왜 두 개일까? 이것도 한참 고민했다.
301은 "영구 이사"다. 브라우저가 이걸 보면 "아, 이 주소는 이제 저기로 바뀌었구나" 하고 기억한다. 다음번엔 아예 새 주소로 직접 간다. 302는 "임시 이동"이다. "지금은 여기로 가지만, 원래 주소는 계속 유효해. 나중에 다시 확인해봐."
회사 이전 비유로 받아들였다. 완전히 본사를 옮기면 301, 공사 중이라 임시로 다른 건물 쓰면 302.
어느 날 깨달았다. HTTP 상태 코드는 클라이언트와 서버 사이의 계약서다.
"나는 이런 상황이면 이 숫자를 보낼게. 너는 이 숫자 보면 이렇게 처리해." 이런 약속이다. 이 약속을 지키면 모든 게 명확해진다. 프론트엔드 개발자는 401 보면 "아, 로그인 페이지로 보내야겠네", 404 보면 "Not Found 페이지 띄워야지" 자동으로 판단한다.
상태 코드를 제대로 쓰지 않으면, 이 계약이 깨진다. 모든 응답이 200이면, 클라이언트는 매번 body를 열어봐야 한다. 캐싱도 안 되고, 에러 처리도 복잡해진다.
솔직히 실제로 1xx를거의 못 봤다. 이론적으로는 "처리 중이야, 기다려"라는 의미다.
대부분의 개발자는 1xx를직접 다룰 일이 없다. 브라우저와 웹서버가 알아서 처리한다.
GET 요청 성공하면 200. 데이터를 응답 body에 담아서 보낸다. 이게 가장 기본이다.
// GET /api/users/123
fetch('/api/users/123')
.then(res => {
console.log(res.status); // 200
return res.json();
})
.then(data => console.log(data));
POST로 리소스 생성하면 201을 보낸다. "잘 받았고, 새로 만들었어"라는 명확한 신호다.
// POST /api/posts
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: 'New Post' })
})
.then(res => {
console.log(res.status); // 201
// Location 헤더에 새 리소스 URL이 온다
console.log(res.headers.get('Location')); // /api/posts/456
});
처음엔 "그냥 200 보내면 되지 않나?" 했는데, 201을 받으면 클라이언트가 "아, 새 리소스네" 하고 즉시 인지한다. 캐싱 정책도 달라진다.
DELETE 성공하면 204를 많이 쓴다. "삭제 완료. 근데 보여줄 데이터는 없어."
// DELETE /api/posts/456
fetch('/api/posts/456', { method: 'DELETE' })
.then(res => {
console.log(res.status); // 204
console.log(res.body); // null
});
204는 body가 없다. 처음엔 이게 어색했는데, 생각해보면 삭제했는데 뭘 돌려받겠나? "삭제 성공"이라는 메시지조차 불필요하다. 204 자체가 그 메시지다.
URL 구조 바꿨을 때 쓴다. /old-page를 /new-page로 완전히 옮겼으면 301.
// 서버 응답: 301, Location: /new-page
fetch('/old-page')
.then(res => {
console.log(res.status); // 301
console.log(res.redirected); // true
console.log(res.url); // /new-page (브라우저가 자동으로 따라감)
});
브라우저는 301을 캐싱한다. 다음번엔 /old-page 요청을 아예 /new-page로 바꿔서 보낸다. 서버 부하가 줄어든다.
로그인 안 한 유저를 로그인 페이지로 보낼 때 302.
// 로그인 필요한 페이지 접근 시
// 서버: 302, Location: /login?redirect=/dashboard
302와 307의 차이는 미묘하다. 302는 POST 요청을 GET으로 바꿀 수 있지만, 307은 메서드를 유지한다. 나는 주로 302를쓴다.
클라이언트가 "내가 가진 버전이 최신이야?" 물으면 서버가 "ㅇㅇ 그거 써" 하는 게 304.
// 두 번째 요청 시
fetch('/api/data', {
headers: { 'If-None-Match': 'etag-12345' }
})
.then(res => {
console.log(res.status); // 304
// 브라우저가 캐시된 데이터 사용
});
304는 트래픽을 엄청 줄여준다. body를 안 보내니까. 실제로 캐싱 전략 세울 때 304를 제대로 쓰면 서버 부하가 확 준다.
요청 형식이 잘못됐을 때. JSON 파싱 실패, 필수 필드 누락 등.
// 잘못된 JSON
fetch('/api/users', {
method: 'POST',
body: '{ invalid json }'
})
.then(res => console.log(res.status)); // 400
나는 400을 validation 실패에도 쓴다. "이메일 형식 아님", "비밀번호 너무 짧음" 같은 거.
인증이 필요한데 안 했을 때.
fetch('/api/private-data')
.then(res => {
if (res.status === 401) {
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
}
});
나는 axios interceptor로 401을 전역 처리한다.
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
이렇게 하면 모든 401 응답을 한 곳에서 처리한다. 코드 중복이 사라졌다.
로그인은 했는데 권한이 없을 때.
// 일반 유저가 관리자 페이지 접근
fetch('/api/admin/users')
.then(res => {
if (res.status === 403) {
alert('권한이 없습니다');
}
});
실제로 403과 401을 헷갈리는 경우가 많다. 나는 이렇게 구분한다. 토큰 없음 → 401, 토큰 있지만 권한 부족 → 403.
URL이 잘못됐거나 리소스가 없을 때. 가장 유명한 에러다.
fetch('/api/users/99999')
.then(res => {
if (res.status === 404) {
console.log('사용자를 찾을 수 없습니다');
}
});
나는 404를두 가지 의미로 쓴다. 엔드포인트 자체가 없음 (라우팅 실패), 리소스를 찾을 수 없음 (ID가 DB에 없음). 어떤 사람들은 후자를 200으로 보내고 { found: false }를 body에 넣는다. 나는 404가 더 명확하다고 본다.
GET만 허용하는데 POST를 보냈을 때.
fetch('/api/read-only-resource', { method: 'DELETE' })
.then(res => console.log(res.status)); // 405
실제에선 잘 안 쓴다. 대부분 404로 처리된다. 하지만 RESTful API를 엄격하게 설계하면 405가 의미 있다.
중복된 데이터 생성 시도할 때.
// 이미 있는 이메일로 회원가입
fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({ email: 'existing@example.com' })
})
.then(res => {
if (res.status === 409) {
alert('이미 사용 중인 이메일입니다');
}
});
처음엔 400을 썼는데, 409가 더 구체적이다. "형식은 맞는데, 충돌이 났어"라는 뉘앙스.
Rate limiting 걸렸을 때.
fetch('/api/data')
.then(res => {
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After');
console.log(`${retryAfter}초 후 재시도하세요`);
}
});
나는 이걸 봇 방어용으로 쓴다. 1초에 100번 요청 오면 429 보낸다. 클라이언트는 Retry-After 헤더를 보고 대기한다.
서버 코드에 버그가 있을 때. 가장 무서운 에러다.
fetch('/api/something')
.then(res => {
if (res.status === 500) {
alert('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
});
실제로 500은 "개발자 호출" 신호다. 모니터링 시스템이 500을 감지하면 즉시 알림 온다. 나는 Sentry 같은 도구로 500 에러를 추적한다.
프록시나 게이트웨이가 업스트림 서버한테 응답 못 받을 때.
// Nginx가 백엔드 서버에 연결 못 함
fetch('/api/data')
.then(res => console.log(res.status)); // 502
이건 내 코드 문제가 아니라 인프라 문제다. 백엔드 서버가 다운됐거나, 네트워크 장애거나.
서버가 일시적으로 과부하 상태일 때.
fetch('/api/data')
.then(res => {
if (res.status === 503) {
const retryAfter = res.headers.get('Retry-After');
console.log('서버가 과부하 상태입니다');
}
});
나는 배포 중일 때 503을 보낸다. 헬스 체크 실패하면 로드밸런서가 해당 서버로 트래픽 안 보낸다.
502와 비슷한데, 연결은 됐지만 응답이 너무 늦을 때.
// 백엔드가 30초 동안 응답 안 함
fetch('/api/slow-query')
.then(res => console.log(res.status)); // 504
무거운 쿼리 날렸을 때 자주 본다. 이것도 인프라 이슈다.
나는 fetch wrapper를 만들어서 상태 코드별로 처리한다.
async function apiFetch(url, options = {}) {
try {
const response = await fetch(url, options);
// 2xx 성공
if (response.ok) {
// 204는 body 없음
if (response.status === 204) {
return null;
}
return await response.json();
}
// 4xx 클라이언트 에러
if (response.status >= 400 && response.status < 500) {
const errorData = await response.json();
switch (response.status) {
case 400:
throw new Error(`잘못된 요청: ${errorData.message}`);
case 401:
// 로그인 페이지로 리다이렉트
localStorage.removeItem('token');
window.location.href = '/login';
return;
case 403:
throw new Error('권한이 없습니다');
case 404:
throw new Error('리소스를 찾을 수 없습니다');
case 409:
throw new Error(`중복: ${errorData.message}`);
case 429:
const retryAfter = response.headers.get('Retry-After');
throw new Error(`너무 많은 요청. ${retryAfter}초 후 재시도하세요`);
default:
throw new Error(`클라이언트 에러 (${response.status})`);
}
}
// 5xx 서버 에러
if (response.status >= 500) {
// 에러 추적 서비스에 보고
logErrorToService({
status: response.status,
url,
timestamp: new Date()
});
switch (response.status) {
case 502:
case 503:
case 504:
throw new Error('서버가 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요');
default:
throw new Error('서버 오류가 발생했습니다');
}
}
} catch (error) {
if (error instanceof TypeError) {
// 네트워크 에러 (서버 연결 실패)
throw new Error('네트워크 연결을 확인해주세요');
}
throw error;
}
}
// 사용 예
try {
const users = await apiFetch('/api/users');
console.log(users);
} catch (error) {
console.error(error.message);
}
이 패턴을 쓰면서 디버깅 시간이 절반으로 줄었다. 에러 메시지가 명확해졌다.
Express로 API를 만들 때 상태 코드를 제대로 설정한다.
const express = require('express');
const app = express();
// 사용자 생성
app.post('/api/users', async (req, res) => {
const { email, name } = req.body;
// validation
if (!email || !name) {
return res.status(400).json({
error: '이메일과 이름은 필수입니다'
});
}
// 중복 체크
const existingUser = await db.findUserByEmail(email);
if (existingUser) {
return res.status(409).json({
error: '이미 사용 중인 이메일입니다'
});
}
try {
const newUser = await db.createUser({ email, name });
// 201 Created + Location 헤더
res.status(201)
.location(`/api/users/${newUser.id}`)
.json(newUser);
} catch (error) {
console.error('User creation failed:', error);
res.status(500).json({
error: '서버 오류가 발생했습니다'
});
}
});
// 사용자 조회
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.findUserById(req.params.id);
if (!user) {
return res.status(404).json({
error: '사용자를 찾을 수 없습니다'
});
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({
error: '서버 오류가 발생했습니다'
});
}
});
// 사용자 삭제
app.delete('/api/users/:id', async (req, res) => {
// 인증 체크
if (!req.user) {
return res.status(401).json({
error: '로그인이 필요합니다'
});
}
// 권한 체크
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({
error: '삭제 권한이 없습니다'
});
}
try {
const deleted = await db.deleteUser(req.params.id);
if (!deleted) {
return res.status(404).json({
error: '사용자를 찾을 수 없습니다'
});
}
// 204 No Content (body 없음)
res.status(204).send();
} catch (error) {
res.status(500).json({
error: '서버 오류가 발생했습니다'
});
}
});
// Rate limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 100, // 최대 100 요청
handler: (req, res) => {
res.status(429)
.set('Retry-After', '60')
.json({
error: '너무 많은 요청. 1분 후 재시도하세요'
});
}
});
app.use('/api/', limiter);
이 코드를 도입한 후 프론트엔드 개발자들이 행복해졌다. "에러 처리가 명확해요!"
일시적인 에러는 재시도한다.
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
// 성공하면 바로 리턴
if (response.ok) {
return response;
}
// 재시도 가능한 에러인지 체크
const shouldRetry =
response.status === 429 || // Rate limit
response.status === 502 || // Bad Gateway
response.status === 503 || // Service Unavailable
response.status === 504; // Gateway Timeout
if (!shouldRetry) {
// 재시도 불가능한 에러 (4xx 등)
return response;
}
// Retry-After 헤더 확인
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter
? parseInt(retryAfter) * 1000
: Math.pow(2, i) * 1000; // Exponential backoff
console.log(`재시도 ${i + 1}/${maxRetries}, ${waitTime}ms 대기...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
} catch (error) {
// 네트워크 에러는 재시도
if (i === maxRetries - 1) {
throw error;
}
console.log(`네트워크 에러, 재시도 ${i + 1}/${maxRetries}`);
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
// 사용 예
const response = await fetchWithRetry('/api/important-data');
if (response.ok) {
const data = await response.json();
console.log(data);
}
이 패턴 덕분에 일시적인 네트워크 문제로 인한 에러가 90% 줄었다.
예전엔 "API가 안 돼요" 신고가 오면, 로그를 뒤져야 했다. 지금은 상태 코드만 보면 된다.
문제 영역이 즉시 좁혀진다.
API 스펙을 논의할 때, "이 경우엔 뭘 보낼까?" 고민이 사라졌다. HTTP 표준을 따르면 된다. 새로운 협업자가 와도 금방 이해한다.
상태 코드별로 알림 설정을 했다.
이전엔 수동으로 확인했는데, 이제는 시스템이 알아서 판단한다.
HTTP 상태 코드는 표준화된 의사소통 수단이다. 서버와 클라이언트가 같은 언어로 말하게 해준다.
핵심 정리:이 숫자 세 자리가 내 디버깅 시간을 절반으로 줄여줬다. HTTP를 제대로 쓰니 모든 게 명확해졌다.