
REST API: 개발자를 위한 완벽 가이드 (아키텍처부터 구현까지)
REST의 6가지 제약조건, 리차드슨 성숙도 모델, 상태 코드 가이드, HATEOAS, 캐싱 전략, 보안 가이드(JWT, OAuth), 그리고 GraphQL 비교.

REST의 6가지 제약조건, 리차드슨 성숙도 모델, 상태 코드 가이드, HATEOAS, 캐싱 전략, 보안 가이드(JWT, OAuth), 그리고 GraphQL 비교.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

제 첫 번째 API 설계는 정말 끔찍했습니다. /getUserData, /deleteUserById, /updateUserPassword 같은 엔드포인트가 즐비했죠. HTTP 메서드는 전부 POST였고요. "어차피 데이터 보내는 거 아니야?"라고 생각했거든요.
상태 코드는? 전부 200 OK였습니다. 에러가 나도 { "success": false, "error": "뭔가 잘못됨" }을 200으로 내려보냈습니다. 프론트엔드 개발자가 제게 따졌습니다. "이게 에러야 성공이야?" 저는 답했죠. "바디에 success 필드 봐요!"
그 개발자는 화면을 끄고 나갔습니다.
그제서야 받아들였습니다. API는 "나만 쓰는 코드"가 아니라, 남이 쓰는 제품이라는 걸요. 그리고 그 제품에는 약속된 규칙이 있어야 한다는 걸요. 바로 REST였습니다.
REST API를 처음 배울 때, 이런 비유가 확 와닿았습니다.
레스토랑을 생각해보세요.이렇게 정리해본다면, REST API는 웹이라는 거대한 레스토랑의 표준 서비스 방식입니다. 손님(클라이언트)과 주방(서버)이 같은 언어(HTTP)로 대화하는 거죠.
로이 필딩(Roy Fielding)이 2000년 박사 논문에서 정의한 REST의 철학입니다. 저는 이걸 "인터넷 규칙"처럼 받아들였습니다.
관심사의 분리. UI는 클라이언트가, 데이터는 서버가 관리합니다. 이렇게 하면 각자 독립적으로 발전할 수 있습니다.
서버는 클라이언트의 상태를 저장하지 않습니다. 모든 요청은 자체적으로 완결된 정보를 담아야 합니다. 예를 들어 인증 토큰을 매번 헤더에 넣어야 합니다.
왜 중요할까요? 서버 A가 죽어도, 서버 B가 즉시 대신할 수 있습니다. 서버에 "기억"이 없으니까요. 이게 무한 확장의 비밀입니다.
제 서비스도 처음엔 세션을 썼습니다. 서버를 2대로 늘렸더니 "로그인이 풀렸다 생겼다"하는 버그가 생겼죠. 그때 이해했습니다. Stateless가 왜 필수인지요.
HTTP 헤더(Cache-Control, ETag)를 통해 응답을 캐싱할 수 있어야 합니다.
CDN에 이미지를 캐싱하면 서버는 한 번만 보내고, 1억 명이 같은 이미지를 받아도 서버는 무사합니다.
이게 REST의 심장입니다. 4가지 규칙:
/users/1은 "1번 사용자"를 가리킵니다.Content-Type: application/json 같은 헤더만 봐도 메시지 해석이 가능해야 합니다.클라이언트는 서버 앞에 로드 밸런서가 있는지, CDN이 있는지 몰라야 합니다. 투명하게 통신하는 거죠.
서버가 클라이언트에게 실행 가능한 코드(JavaScript)를 보낼 수 있습니다. 거의 안 쓰는 옵션이라 넘어가겠습니다.
/users, /orders, /products/getUser, /createOrder, /deleteProduct처음에 저는 /createUser 같은 URI를 썼습니다. 그런데 프론트엔드 개발자가 물었습니다. "그럼 수정은 /updateUser예요?"
그때 깨달았습니다. 동사를 URI에 넣으면 끝도 없이 늘어난다는 걸요. /createUser, /updateUser, /deleteUser, /softDeleteUser...
결국 이거였다. 자원은 명사로 표현하고, 행위는 HTTP 메서드로 표현하는 거.
/user/1보다 /users/1이 낫습니다. 컬렉션(Collection)이라는 느낌이 명확하거든요.
/users/1/orders: 1번 사용자의 주문 목록/orders/5/items: 5번 주문의 상품 목록이렇게 하면 자원 간 관계가 URI에 드러납니다.
GET /users/1
POST /users
Content-Type: application/json
{
"name": "김철수",
"email": "chulsoo@example.com"
}
성공하면 201 Created와 함께 Location: /users/123 헤더를 돌려줘야 합니다.
PUT /users/1
Content-Type: application/json
{
"name": "김철수",
"email": "new@example.com",
"age": 30
}
자원이 없으면 생성하고, 있으면 전부 교체합니다.
PATCH /users/1
Content-Type: application/json
{
"email": "new@example.com"
}
DELETE /users/1
성공하면 204 No Content (바디 없음)를 주로 씁니다.
처음에 저는 모든 응답을 200으로 보냈습니다. 에러 정보는 JSON 안에 담았고요.
{
"success": false,
"error": "유저 없음"
}
이게 왜 문제일까요? HTTP 표준을 무시한 겁니다. 프록시, 로드밸런서, 모니터링 툴은 전부 200만 보고 "정상"이라고 판단합니다. 로그에서 에러를 찾으려면 JSON 바디를 일일이 파싱해야 했죠.
와닿았다. 상태 코드는 "편의 기능"이 아니라 HTTP의 핵심 문법이라는 게.
| 코드 | 의미 | 사용 사례 |
|---|---|---|
200 OK | 성공 | GET 요청 성공 |
201 Created | 생성 성공 | POST로 자원 생성 완료 |
204 No Content | 성공했지만 바디 없음 | DELETE 성공 |
400 Bad Request | 클라이언트 실수 | 파라미터 형식 오류 |
401 Unauthorized | 인증 안 됨 | 로그인 필요 |
403 Forbidden | 권한 없음 | 접근 금지 자원 |
404 Not Found | 자원 없음 | 존재하지 않는 URI |
429 Too Many Requests | 요청 과다 | Rate Limit 초과 |
500 Internal Server Error | 서버 에러 | 코드 버그 |
503 Service Unavailable | 서비스 불가 | 점검 중 |
이론만 보면 안 와닿습니다. 코드로 보죠.
const express = require('express');
const app = express();
app.use(express.json());
// 가짜 DB
let users = [
{ id: 1, name: "김철수", email: "chulsoo@example.com" },
{ id: 2, name: "이영희", email: "younghee@example.com" }
];
let nextId = 3;
// GET: 전체 사용자 조회
app.get('/users', (req, res) => {
res.status(200).json(users);
});
// GET: 특정 사용자 조회
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.status(200).json(user);
});
// POST: 사용자 생성
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: "Name and email required" });
}
const newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201)
.location(`/users/${newUser.id}`)
.json(newUser);
});
// PUT: 사용자 전체 수정
app.put('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const { name, email } = req.body;
const index = users.findIndex(u => u.id === id);
if (index === -1) {
// 없으면 생성 (Idempotent)
const newUser = { id, name, email };
users.push(newUser);
return res.status(201).json(newUser);
}
// 있으면 교체
users[index] = { id, name, email };
res.status(200).json(users[index]);
});
// DELETE: 사용자 삭제
app.delete('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
users = users.filter(u => u.id !== id);
res.status(204).send();
});
app.listen(3000, () => {
console.log('REST API server running on port 3000');
});
이 코드에서 주목할 점:
/usersGET, POST, PUT, DELETE201은 Location 헤더와 함께, 404는 자원 없을 때결제 API를 만들 때, 저는 큰 실수를 했습니다. 사용자가 "결제" 버튼을 클릭했는데 타임아웃이 났습니다. 결과를 모르니 다시 클릭했죠. 그런데... 두 번 결제됐습니다.
이해했다. POST는 멱등하지 않다는 게. 같은 요청을 보내면 자원이 계속 생깁니다.
해결책은 Idempotency Key였습니다.
Idempotency-Key: uuid 헤더를 보냅니다.const redis = require('redis');
const client = redis.createClient();
app.post('/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
// 캐시 확인
const cached = await client.get(`idempotency:${idempotencyKey}`);
if (cached) {
console.log("중복 요청 감지. 캐시된 응답 반환.");
return res.status(200).json(JSON.parse(cached));
}
// 실제 결제 로직
const payment = processPayment(req.body); // 가상 함수
// 캐시 저장 (24시간)
await client.setex(
`idempotency:${idempotencyKey}`,
86400,
JSON.stringify(payment)
);
res.status(201).json(payment);
});
이 패턴 덕분에 결제 중복 사고가 사라졌습니다.
HATEOAS(Hypermedia As The Engine Of Application State)는 REST의 최종 레벨입니다.
아이디어는 간단합니다. API 응답에 "다음에 뭘 할 수 있는지" 링크를 포함하는 겁니다.
{
"id": 1,
"status": "pending_payment",
"amount": 50000,
"_links": {
"self": { "href": "/orders/1" },
"pay": { "href": "/orders/1/payment", "method": "POST" },
"cancel": { "href": "/orders/1", "method": "DELETE" }
}
}
주문 상태가 pending_payment이면, 클라이언트는 "결제(pay)"나 "취소(cancel)"를 할 수 있다는 걸 알 수 있습니다.
만약 상태가 shipped(배송중)라면?
{
"id": 1,
"status": "shipped",
"_links": {
"self": { "href": "/orders/1" },
"track": { "href": "/orders/1/tracking", "method": "GET" }
}
}
"결제" 링크가 사라지고 "배송 추적" 링크만 남습니다.
결국 이거였다. 비즈니스 로직이 서버에만 있고, 클라이언트는 링크를 따라가기만 하면 되는 구조. 프론트엔드가 상태 분기 처리를 안 해도 됩니다.
제 서비스는 JWT로 인증합니다.
로그인 성공 시 서버가 토큰을 줍니다:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
이후 모든 요청에 이 토큰을 헤더에 넣습니다:
GET /users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
서버는 토큰을 검증하고, 유효하면 요청을 처리합니다.
누군가 제 API를 1초에 10000번 호출했습니다. 서버가 죽었습니다.
그때 받아들였습니다. 보안은 "좋으면 좋은 것"이 아니라 필수라는 걸요.
Rate Limiting을 걸었습니다. IP당 1분에 100회로 제한.
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 100, // 최대 100회
message: { error: "Too many requests. Try again later." },
statusCode: 429
});
app.use('/api/', limiter);
위반 시 429 Too Many Requests를 반환합니다.
REST가 만능은 아닙니다. 상황에 따라 다른 선택이 나을 수 있습니다.
| 특징 | REST | GraphQL | gRPC |
|---|---|---|---|
| 철학 | 자원(Resource) | 쿼리(Query) | 함수 호출(RPC) |
| 데이터 형식 | JSON (고정) | JSON (선택적) | Protobuf (바이너리) |
| 엔드포인트 | 여러 개 | 단일 (/graphql) | 여러 개 (함수별) |
| 오버/언더페칭 | 있음 | 없음 (정확히 요청) | 없음 (함수 정의) |
| 성능 | 보통 | 좋음 | 최고 (압축 효율) |
| 캐싱 | 쉬움 (HTTP) | 어려움 (POST 단일) | 어려움 (바이너리) |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 적합한 용도 | 공개 API, 웹 서비스 | 복잡한 모바일 앱 | 내부 MSA 통신 |
정리해본다면, REST는 "웹의 성공 방식을 API에 적용한 것"입니다.
제 첫 API는 엉망이었습니다. 하지만 REST 원칙을 배우고, 실수하고, 고치면서 이해했습니다.
API는 그냥 "데이터 주고받는 통로"가 아니라, 남이 쓰는 제품이라는 걸요. 그리고 그 제품의 품질은 설계에서 결정된다는 걸요.
My very first API design was a complete mess. Endpoints like /getUserData, /deleteUserById, /updateUserPassword were everywhere. Every single request? POST. I thought, "We're sending data anyway, right?"
And status codes? Everything was 200 OK. Even when errors occurred, I'd return { "success": false, "error": "something went wrong" } with a 200 status.
A frontend developer confronted me: "Is this an error or success?"
I replied confidently: "Check the success field in the body!"
They closed their laptop and walked away.
That's when it hit me. An API isn't "code only I use" — it's a product others rely on. And that product needs rules, standards, conventions. That's REST.
When learning REST, this metaphor made everything click:
Think of a restaurant.REST API is the standard service model of the web's giant restaurant. Customers (clients) and kitchens (servers) speak the same language (HTTP).
Roy Fielding defined REST in his 2000 PhD dissertation. I think of these as "internet bylaws".
Separation of concerns. UI handled by client, data by server. Each evolves independently.
The server stores no client state between requests. Every request must be self-contained with all necessary information (like authentication tokens in headers).
Why does this matter? If Server A crashes, Server B can immediately take over. No "memory" to transfer. This is the secret to infinite scaling.
My service used sessions initially. When I scaled to 2 servers, users complained: "I keep getting logged out randomly." That's when I understood why Stateless is non-negotiable.
Responses should indicate if they can be cached via HTTP headers (Cache-Control, ETag).
Cache an image on a CDN once, and the server remains untouched even if 100 million users download it.
This is REST's heart. Four rules:
/users/1 points to "user #1".Content-Type: application/json make messages interpretable without external docs.The client shouldn't know if there's a load balancer or CDN in front of the server. Communication should be transparent.
Servers can send executable code (JavaScript) to clients. Rarely used, so we'll skip this.
/users, /orders, /products/getUser, /createOrder, /deleteProductInitially, I used URIs like /createUser. Then a frontend dev asked: "So updates are /updateUser?"
That's when I realized: if you put verbs in URIs, they multiply endlessly. /createUser, /updateUser, /deleteUser, /softDeleteUser...
The insight: Resources are nouns. Actions are HTTP methods.
/users/1 is better than /user/1. It clearly signals a collection.
/users/1/orders: Orders for user #1/orders/5/items: Items in order #5This structure reveals relationships between resources directly in the URI.
GET /users/1
POST /users
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com"
}
On success, return 201 Created with Location: /users/123 header.
PUT /users/1
Content-Type: application/json
{
"name": "John Doe",
"email": "newemail@example.com",
"age": 30
}
If the resource doesn't exist, create it. If it exists, replace it entirely.
PATCH /users/1
Content-Type: application/json
{
"email": "newemail@example.com"
}
DELETE /users/1
Typically returns 204 No Content (no body).
Initially, I returned everything as 200. Error details were buried in JSON:
{
"success": false,
"error": "User not found"
}
Why is this wrong? It ignores HTTP standards. Proxies, load balancers, monitoring tools see 200 and assume "success". Finding errors required parsing every JSON body.
That's when it clicked: status codes aren't a "nice-to-have" — they're core HTTP grammar.
| Code | Meaning | Use Case |
|---|---|---|
200 OK | Success | GET request succeeded |
201 Created | Created | POST created resource |
204 No Content | Success, no body | DELETE succeeded |
400 Bad Request | Client error | Malformed parameters |
401 Unauthorized | Not authenticated | Login required |
403 Forbidden | Not authorized | Insufficient permissions |
404 Not Found | Resource missing | Non-existent URI |
429 Too Many Requests | Rate limited | Exceeded quota |
500 Internal Server Error | Server error | Code bug |
503 Service Unavailable | Service down | Maintenance mode |
Theory only goes so far. Let's see code.
const express = require('express');
const app = express();
app.use(express.json());
// Mock database
let users = [
{ id: 1, name: "John Doe", email: "john@example.com" },
{ id: 2, name: "Jane Smith", email: "jane@example.com" }
];
let nextId = 3;
// GET: List all users
app.get('/users', (req, res) => {
res.status(200).json(users);
});
// GET: Get single user
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.status(200).json(user);
});
// POST: Create user
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: "Name and email required" });
}
const newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201)
.location(`/users/${newUser.id}`)
.json(newUser);
});
// PUT: Full update
app.put('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const { name, email } = req.body;
const index = users.findIndex(u => u.id === id);
if (index === -1) {
// Create if doesn't exist (Idempotent)
const newUser = { id, name, email };
users.push(newUser);
return res.status(201).json(newUser);
}
// Replace if exists
users[index] = { id, name, email };
res.status(200).json(users[index]);
});
// DELETE: Remove user
app.delete('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
users = users.filter(u => u.id !== id);
res.status(204).send();
});
app.listen(3000, () => {
console.log('REST API server running on port 3000');
});
Key takeaways:
/usersGET, POST, PUT, DELETE201 with Location header, 404 when not foundWhen building a payment API, I made a critical mistake. A user clicked "Pay". The request timed out. They clicked again. Result? Charged twice.
That's when I learned: POST is not idempotent. Send the same request twice, you create the resource twice.
The solution: Idempotency Keys.
Idempotency-Key: uuid header with the request.const redis = require('redis');
const client = redis.createClient();
app.post('/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
// Check cache
const cached = await client.get(`idempotency:${idempotencyKey}`);
if (cached) {
console.log("Duplicate request detected. Returning cached response.");
return res.status(200).json(JSON.parse(cached));
}
// Actual payment logic
const payment = processPayment(req.body); // Mock function
// Store in cache (24h TTL)
await client.setex(
`idempotency:${idempotencyKey}`,
86400,
JSON.stringify(payment)
);
res.status(201).json(payment);
});
This pattern eliminated double-charge incidents completely.
HATEOAS (Hypermedia As The Engine Of Application State) is REST's final level.
The idea is simple: Include "what you can do next" links in API responses.
{
"id": 1,
"status": "pending_payment",
"amount": 50000,
"_links": {
"self": { "href": "/orders/1" },
"pay": { "href": "/orders/1/payment", "method": "POST" },
"cancel": { "href": "/orders/1", "method": "DELETE" }
}
}
If the order status is pending_payment, clients know they can "pay" or "cancel".
What if status is shipped?
{
"id": 1,
"status": "shipped",
"_links": {
"self": { "href": "/orders/1" },
"track": { "href": "/orders/1/tracking", "method": "GET" }
}
}
The "pay" link disappears, only "track" remains.
The insight: Business logic stays on the server. Clients just follow links. Frontend doesn't need complex state branching.
My service uses JWT for authentication.
On successful login, server returns a token:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
All subsequent requests include this token in headers:
GET /users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Server validates the token and processes the request if valid.
Someone called my API 10,000 times per second. The server died.
That's when I learned: security isn't "nice to have" — it's mandatory.
I implemented rate limiting: 100 requests per minute per IP.
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // max 100 requests
message: { error: "Too many requests. Try again later." },
statusCode: 429
});
app.use('/api/', limiter);
Violations return 429 Too Many Requests.
REST isn't a silver bullet. Different tools for different contexts.
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Philosophy | Resource-oriented | Query-oriented | Function call (RPC) |
| Data Format | JSON (fixed) | JSON (selective) | Protobuf (binary) |
| Endpoints | Multiple | Single (/graphql) | Multiple (per function) |
| Over/Under-fetching | Yes | No (precise queries) | No (function-defined) |
| Performance | Medium | Good | Excellent (compression) |
| Caching | Easy (HTTP) | Hard (POST single) | Hard (binary) |
| Learning Curve | Low | Medium | High |
| Best For | Public APIs, web services | Complex mobile apps | Internal microservices |