API 설계 안티패턴 10가지 완벽 가이드 - 실무에서 피해야 할 치명적 실수들
API는 서비스의 얼굴이자 다른 시스템과 소통하는 핵심 인터페이스예요. 하지만 실무에서 많은 개발자들이 API 설계 시 반복적인 실수를 범하고, 이는 나중에 유지보수 악몽으로 돌아오곤 해요. 저도 5년간 수십 개의 API를 설계하고 리팩토링하면서 수많은 시행착오를 겪었죠. 이 글에서는 실제 프로덕션 환경에서 자주 발생하는 10가지 API 설계 안티패턴을 구체적인 코드 예시와 함께 소개하고, 각각의 올바른 해결책을 제시할게요. 이 내용을 숙지하면 확장 가능하고 유지보수하기 쉬운 API를 설계할 수 있을 거예요.
1. 동사 기반 엔드포인트 사용하기
RESTful API 설계에서 가장 흔한 실수는 URL에 동사를 사용하는 거예요. REST는 자원(Resource) 중심으로 설계되어야 하는데, 동사를 사용하면 RPC 스타일이 되어버려요.
❌ 잘못된 예시:
// 동사 기반 엔드포인트 - 안티패턴
POST /api/createUser
GET /api/getUser/123
POST /api/deleteUser/123
GET /api/getUserOrders/123✅ 올바른 예시:
// 명사 기반 자원 중심 설계
POST /api/users // 사용자 생성
GET /api/users/123 // 사용자 조회
DELETE /api/users/123 // 사용자 삭제
GET /api/users/123/orders // 사용자의 주문 목록 조회
// Express.js 구현 예시
const express = require('express');
const router = express.Router();
// 자원 중심 설계 - HTTP 메서드가 액션을 나타냄
router.post('/users', async (req, res) => {
// 사용자 생성 로직
const user = await createUser(req.body);
res.status(201).json(user);
});
router.get('/users/:id', async (req, res) => {
// 사용자 조회 로직
const user = await findUserById(req.params.id);
res.json(user);
});
router.delete('/users/:id', async (req, res) => {
// 사용자 삭제 로직
await deleteUser(req.params.id);
res.status(204).send();
});왜 이렇게 해야 할까요?
자원 중심 설계는 API를 직관적이고 예측 가능하게 만들어요. HTTP 메서드(GET, POST, PUT, DELETE)가 이미 동작을 표현하기 때문에 URL에 동사를 중복해서 넣을 필요가 없죠. 또한 캐싱, 권한 관리, 미들웨어 적용이 훨씬 쉬워져요.
2. 일관성 없는 네이밍 컨벤션
API 전체에서 네이밍 규칙이 뒤죽박죽이면 개발자 경험(DX)이 최악이 돼요. camelCase, snake_case, PascalCase를 섞어 쓰는 건 금물이에요.
❌ 잘못된 예시:
{
"user_id": 123,
"userName": "홍길동",
"EmailAddress": "hong@example.com",
"phone_number": "010-1234-5678",
"created_at": "2024-01-15",
"UpdatedDate": "2024-01-20"
}✅ 올바른 예시:
{
"userId": 123,
"userName": "홍길동",
"emailAddress": "hong@example.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-20T14:22:00Z"
}// API 응답 포맷터 미들웨어 예시
const formatResponse = (data) => {
// 모든 키를 camelCase로 변환
const toCamelCase = (str) => {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
};
if (Array.isArray(data)) {
return data.map(formatResponse);
}
if (data !== null && typeof data === 'object') {
return Object.keys(data).reduce((acc, key) => {
const camelKey = toCamelCase(key);
acc[camelKey] = formatResponse(data[key]);
return acc;
}, {});
}
return data;
};
// 사용 예시
router.get('/users/:id', async (req, res) => {
const user = await getUserFromDB(req.params.id); // DB는 snake_case
res.json(formatResponse(user)); // 클라이언트에는 camelCase
});팁: JSON API에서는 camelCase가 표준이고, 데이터베이스에서는 snake_case가 일반적이에요. 중간에 변환 레이어를 두면 각 계층의 관례를 존중하면서도 일관성을 유지할 수 있어요.
3. 적절한 HTTP 상태 코드 미사용
모든 응답에 200 OK만 사용하거나, 에러 상황에서도 200을 반환하는 건 심각한 안티패턴이에요. HTTP 상태 코드는 이미 표준화된 의미 체계를 가지고 있어요.
❌ 잘못된 예시:
// 모든 응답이 200 - 안티패턴
router.post('/users', async (req, res) => {
const result = await createUser(req.body);
if (result.error) {
// 에러인데 200 반환!
res.status(200).json({
success: false,
error: result.error
});
} else {
res.status(200).json({
success: true,
data: result
});
}
});✅ 올바른 예시:
// 적절한 HTTP 상태 코드 사용
router.post('/users', async (req, res) => {
try {
// 입력 유효성 검사
if (!req.body.email || !req.body.password) {
return res.status(400).json({
error: 'Bad Request',
message: '이메일과 비밀번호는 필수입니다.'
});
}
const user = await createUser(req.body);
// 생성 성공 - 201 Created
res.status(201)
.header('Location', `/api/users/${user.id}`)
.json(user);
} catch (error) {
if (error.code === 'DUPLICATE_EMAIL') {
// 중복 - 409 Conflict
return res.status(409).json({
error: 'Conflict',
message: '이미 존재하는 이메일입니다.'
});
}
// 서버 에러 - 500 Internal Server Error
res.status(500).json({
error: 'Internal Server Error',
message: '사용자 생성 중 오류가 발생했습니다.'
});
}
});
// 404 Not Found 처리
router.get('/users/:id', async (req, res) => {
const user = await findUserById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'Not Found',
message: '사용자를 찾을 수 없습니다.'
});
}
res.json(user);
});
// 401 Unauthorized vs 403 Forbidden
router.delete('/users/:id', authenticate, async (req, res) => {
// 인증되지 않은 사용자 - 401
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
message: '인증이 필요합니다.'
});
}
// 권한 없음 - 403
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({
error: 'Forbidden',
message: '이 작업을 수행할 권한이 없습니다.'
});
}
await deleteUser(req.params.id);
res.status(204).send(); // No Content
});주요 상태 코드 정리:
- 2xx (성공): 200 OK, 201 Created, 204 No Content
- 4xx (클라이언트 에러): 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict
- 5xx (서버 에러): 500 Internal Server Error, 503 Service Unavailable
4. 페이지네이션 없는 대량 데이터 반환
몇천, 몇만 건의 데이터를 한 번에 반환하면 서버 메모리 부족과 네트워크 타임아웃이 발생해요. 페이지네이션은 선택이 아닌 필수예요.
❌ 잘못된 예시:
// 모든 데이터를 한 번에 반환 - 메모리 폭탄!
router.get('/products', async (req, res) => {
const products = await Product.find({}); // 100만 건 조회 가능
res.json(products); // 응답 크기: 수백 MB
});✅ 올바른 예시:
// 커서 기반 페이지네이션 (추천)
router.get('/products', async (req, res) => {
const limit = parseInt(req.query.limit) || 20; // 기본 20개
const cursor = req.query.cursor; // 마지막 항목의 ID
// limit보다 1개 더 조회해서 다음 페이지 존재 여부 확인
const query = cursor ? { _id: { $gt: cursor } } : {};
const products = await Product.find(query)
.sort({ _id: 1 })
.limit(limit + 1)
.lean();
const hasNext = products.length > limit;
const items = hasNext ? products.slice(0, limit) : products;
const nextCursor = hasNext ? items[items.length - 1]._id : null;
res.json({
items,
pagination: {
limit,
hasNext,
nextCursor
}
});
});
// 오프셋 기반 페이지네이션 (단순한 경우)
router.get('/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
// 총 개수와 데이터를 병렬로 조회
const [total, users] = await Promise.all([
User.countDocuments(),
User.find()
.skip(skip)
.limit(limit)
.select('-password') // 비밀번호 제외
.lean()
]);
const totalPages = Math.ceil(total / limit);
res.json({
items: users,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
});
// 무한 스크롤용 타임스탬프 기반 페이지네이션
router.get('/posts', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const before = req.query.before; // ISO 타임스탬프
const query = before ? { createdAt: { $lt: new Date(before) } } : {};
const posts = await Post.find(query)
.sort({ createdAt: -1 }) // 최신순
.limit(limit + 1)
.populate('author', 'name avatar');
const hasNext = posts.length > limit;
const items = hasNext ? posts.slice(0, limit) : posts;
const nextBefore = hasNext ? items[items.length - 1].createdAt : null;
res.json({
items,
pagination: {
limit,
hasNext,
nextBefore
}
});
});페이지네이션 방식 비교:
- 오프셋 기반: 구현 간단, UI 친화적 (페이지 번호), 하지만 대량 데이터에서 느림
- 커서 기반: 대량 데이터에 효율적, 실시간 데이터 변경에 강함, 무한 스크롤에 적합
5. 버전 관리 전략 부재
API 버전 관리 없이 기존 엔드포인트를 수정하면 클라이언트가 갑자기 망가져요. 하위 호환성은 API 설계의 핵심이에요.
❌ 잘못된 예시:
// 버전 없이 기존 API 수정 - 클라이언트 파괴!
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
// 갑자기 응답 형식 변경
res.json({
id: user._id,
fullName: `${user.firstName} ${user.lastName}`, // 기존: name
contact: { // 기존: email, phone
email: user.email,
phone: user.phone
}
});
});✅ 올바른 예시:
// URL 버전 관리 (가장 명확함)
const v1Router = express.Router();
const v2Router = express.Router();
// v1 - 기존 응답 유지 (하위 호환성)
v1Router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user._id,
name: user.name,
email: user.email,
phone: user.phone
});
});
// v2 - 새로운 응답 형식
v2Router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json({
id: user._id,
fullName: `${user.firstName} ${user.lastName}`,
contact: {
email: user.email,
phone: user.phone
},
metadata: {
createdAt: user.createdAt,
updatedAt: user.updatedAt
}
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Header 기반 버전 관리 (대안)
router.get('/users/:id', async (req, res) => {
const version = req.headers['api-version'] || 'v1';
const user = await User.findById(req.params.id);
if (version === 'v2') {
return res.json({
id: user._id,
fullName: `${user.firstName} ${user.lastName}`,
contact: { email: user.email, phone: user.phone }
});
}
// v1 기본 응답
res.json({
id: user._id,
name: user.name,
email: user.email,
phone: user.phone
});
});
// Deprecation 경고 헤더 추가
v1Router.use((req, res, next) => {
res.set('X-API-Warn', 'v1 is deprecated. Please migrate to v2 by 2024-12-31');
res.set('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT'); // RFC 8594
next();
});버전 관리 전략:
- URL 버전:
/api/v1/users- 가장 명확하고 캐싱 친화적 - 헤더 버전:
Accept: application/vnd.api.v2+json- URL이 깔끔함 - 쿼리 파라미터:
/api/users?version=2- 간단하지만 캐싱 어려움
6. 에러 응답 표준화 실패
에러마다 응답 형식이 다르면 클라이언트에서 에러 처리가 지옥이 돼요. 일관된 에러 응답 형식은 필수예요.
❌ 잘못된 예시:
// 제각각인 에러 응답
router.post('/users', async (req, res) => {
if (!req.body.email) {
return res.status(400).send('Email required'); // 문자열
}
try {
const user = await createUser(req.body);
res.json(user);
} catch (error) {
if (error.code === 'DUPLICATE') {
res.status(409).json({ msg: 'Duplicate user' }); // msg 키
} else {
res.status(500).json({ error_message: error.message }); // error_message 키
}
}
});✅ 올바른 예시:
// 표준화된 에러 응답 미들웨어
class ApiError extends Error {
constructor(statusCode, message, errors = null) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
}
}
// 중앙 집중식 에러 핸들러
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const response = {
error: {
status: statusCode,
message: err.message,
timestamp: new Date().toISOString(),
path: req.path
}
};
// 유효성 검사 에러인 경우 상세 정보 추가
if (err.errors) {
response.error.details = err.errors;
}
// 개발 환경에서만 스택 트레이스 노출
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
// 로깅
if (statusCode >= 500) {
console.error('[500 Error]', err);
}
res.status(statusCode).json(response);
};
app.use(errorHandler);
// 사용 예시
router.post('/users', async (req, res, next) => {
try {
// Joi 또는 express-validator로 유효성 검사
const { error, value } = userSchema.validate(req.body);
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
throw new ApiError(400, '유효성 검사 실패', errors);
}
const existingUser = await User.findOne({ email: value.email });
if (existingUser) {
throw new ApiError(409, '이미 존재하는 이메일입니다.');
}
const user = await createUser(value);
res.status(201).json({ data: user });
} catch (error) {
next(error); // 에러 핸들러로 전달
}
});
// 표준 에러 응답 예시
/*
{
"error": {
"status": 400,
"message": "유효성 검사 실패",
"timestamp": "2024-03-13T10:30:00Z",
"path": "/api/users",
"details": [
{
"field": "email",
"message": "올바른 이메일 형식이 아닙니다."
},
{
"field": "password",
"message": "비밀번호는 최소 8자 이상이어야 합니다."
}
]
}
}
*/7. 과도한 데이터 노출 (Over-fetching)
클라이언트가 필요하지 않은 데이터까지 모두 반환하면 대역폭 낭비와 보안 문제가 발생해요.
❌ 잘못된 예시:
// 모든 필드 노출 - 비밀번호까지!
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // password, salt, tokens 등 모두 노출
});✅ 올바른 예시:
// 필드 선택 (Sparse Fieldsets) 지원
router.get('/users/:id', async (req, res) => {
const fields = req.query.fields; // ?fields=name,email,avatar
let select = '-password -salt -__v'; // 기본적으로 민감한 필드 제외
if (fields) {
// 클라이언트가 원하는 필드만 선택
const allowedFields = ['name', 'email', 'avatar', 'bio', 'createdAt'];
const requestedFields = fields.split(',')
.filter(field => allowedFields.includes(field));
if (requestedFields.length > 0) {
select = requestedFields.join(' ');
}
}
const user = await User.findById(req.params.id).select(select);
if (!user) {
return res.status(404).json({
error: { message: '사용자를 찾을 수 없습니다.' }
});
}
res.json({ data: user });
});
// DTO (Data Transfer Object) 패턴 사용
class UserDTO {
static toPublic(user) {
return {
id: user._id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt
};
}
static toPrivate(user) {
return {
...this.toPublic(user),
phone: user.phone,
address: user.address,
preferences: user.preferences
};
}
}
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
const isOwner = req.user && req.user.id === user._id.toString();
// 본인이면 상세 정보, 타인이면 공개 정보만
const dto = isOwner ? UserDTO.toPrivate(user) : UserDTO.toPublic(user);
res.json({ data: dto });
});
// GraphQL 스타일의 포함/제외 패턴
router.get('/posts', async (req, res) => {
const include = req.query.include?.split(',') || [];
let query = Post.find();
// ?include=author,comments
if (include.includes('author')) {
query = query.populate('author', 'name avatar');
}
if (include.includes('comments')) {
query = query.populate({
path: 'comments',
options: { limit: 5, sort: '-createdAt' }
});
}
const posts = await query.lean();
res.json({ data: posts });
});보안 팁: 민감한 필드는 스키마 레벨에서 기본 제외 설정하고, DTO를 통해 명시적으로 노출할 필드만 선택하세요.
8. 적절하지 않은 HTTP 메서드 사용
POST로 모든 걸 처리하거나, GET으로 데이터를 수정하는 건 RESTful 원칙을 무시하는 거예요.
❌ 잘못된 예시:
// 모든 작업을 POST로 처리
router.post('/getUserData', ...); // GET이어야 함
router.post('/updateUser', ...); // PUT/PATCH여야 함
router.post('/removeUser', ...); // DELETE여야 함
// GET으로 데이터 수정 - 심각한 보안 문제!
router.get('/users/:id/activate', async (req, res) => {
await User.updateOne({ _id: req.params.id }, { active: true });
res.json({ success: true });
});
// 이메일의 링크를 클릭만 해도 계정이 활성화됨 (CSRF 취약점)✅ 올바른 예시:
// HTTP 메서드의 의미에 맞게 사용
const router = express.Router();
// GET - 조회 (멱등성, 캐시 가능, 안전)
router.get('/users', async (req, res) => {
const users = await User.find().select('-password');
res.json({ data: users });
});
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id).select('-password');
res.json({ data: user });
});
// POST - 생성 (멱등성 없음, 캐시 불가)
router.post('/users', async (req, res) => {
const user = await User.create(req.body);
res.status(201)
.header('Location', `/api/users/${user._id}`)
.json({ data: user });
});
// PUT - 전체 교체 (멱등성)
router.put('/users/:id', async (req, res) => {
// 모든 필드를 새 값으로 교체
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, overwrite: true, runValidators: true }
);
res.json({ data: user });
});
// PATCH - 부분 수정 (멱등성)
router.patch('/users/:id', async (req, res) => {
// 제공된 필드만 수정
const user = await User.findByIdAndUpdate(
req.params.id,
{ $set: req.body },
{ new: true, runValidators: true }
);
res.json({ data: user });
});
// DELETE - 삭제 (멱등성)
router.delete('/users/:id', async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.status(204).send();
});
// 특수 작업 - POST 사용 (상태 변경)
router.post('/users/:id/activate', authenticate, async (req, res) => {
// CSRF 토큰 검증
if (!req.body.csrf_token || req.body.csrf_token !== req.session.csrf) {
return res.status(403).json({
error: { message: 'Invalid CSRF token' }
});
}
const user = await User.findByIdAndUpdate(
req.params.id,
{ active: true, activatedAt: new Date() },
{ new: true }
);
res.json({ data: user });
});
// 멱등성이 중요한 결제 처리
router.post('/payments', async (req, res) => {
const { idempotencyKey } = req.headers;
if (!idempotencyKey) {
return res.status(400).json({
error: { message: 'Idempotency-Key header required' }
});
}
// 같은 키로 이미 처리된 요청인지 확인
const existing = await Payment.findOne({ idempotencyKey });
if (existing) {
return res.json({ data: existing }); // 같은 결과 반환
}
const payment = await processPayment({ ...req.body, idempotencyKey });
res.status(201).json({ data: payment });
});HTTP 메서드 특성:
- GET: 안전(Safe), 멱등(Idempotent), 캐시 가능
- POST: 안전하지 않음, 멱등하지 않음
- PUT/PATCH/DELETE: 안전하지 않음, 멱등함
9. Rate Limiting 및 보안 헤더 부재
API를 무방비로 노출하면 DDoS 공격과 남용에 취약해요. Rate limiting과 보안 헤더는 기본 방어선이에요.
❌ 잘못된 예시:
// 보안 조치 없는 API - 무제한 요청 허용
router.post('/users/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
// 브루트 포스 공격에 취약
if (user && user.comparePassword(req.body.password)) {
res.json({ token: generateToken(user) });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});✅ 올바른 예시:
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
// Helmet으로 보안 헤더 설정
app.use(helmet());
// NoSQL Injection 방지
app.use(mongoSanitize());
// 전역 Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // IP당 최대 100개 요청
message: {
error: {
status: 429,
message: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.'
}
},
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', globalLimiter);
// 로그인 엔드포인트용 엄격한 Rate Limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 15분당 5회 시도만 허용
skipSuccessfulRequests: true, // 성공한 요청은 카운트하지 않음
keyGenerator: (req) => {
// IP + 이메일 조합으로 키 생성
return `${req.ip}-${req.body.email}`;
}
});
router.post('/users/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
// 실패 횟수 기록
await LoginAttempt.create({
email,
ip: req.ip,
success: false
});
return res.status(401).json({
error: { message: '이메일 또는 비밀번호가 올바르지 않습니다.' }
});
}
// 계정 잠금 확인
if (user.isLocked) {
return res.status(423).json({
error: { message: '계정이 잠겼습니다. 관리자에게 문의하세요.' }
});
}
const token = generateToken(user);
res.json({ data: { token, user: UserDTO.toPrivate(user) } });
});
// API Key 기반 Rate Limiting
const apiKeyLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1시간
max: async (req) => {
const apiKey = req.headers['x-api-key'];
const client = await ApiClient.findOne({ apiKey });
// 클라이언트 등급에 따라 다른 제한
if (client?.tier === 'premium') return 10000;
if (client?.tier === 'standard') return 1000;
return 100; // free tier
},
keyGenerator: (req) => req.headers['x-api-key'] || req.ip
});
router.use('/api/v1', apiKeyLimiter);
// CORS 설정
const cors = require('cors');
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
maxAge: 86400 // 24시간
}));
// 요청 크기 제한
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));추가 보안 팁:
- API Key는 환경변수로 관리하고 절대 하드코딩하지 마세요
- HTTPS 필수 사용 (HTTP는 개발 환경에서만)
- JWT 토큰에는 짧은 만료 시간 설정 (15분~1시간)
- Refresh Token 패턴으로 보안과 편의성 균형
10. 문서화 부재와 불명확한 계약
API 문서가 없거나 오래되면 협업이 불가능해요. OpenAPI/Swagger로 자동화된 문서를 유지하세요.
❌ 잘못된 예시:
// 문서 없음, 주석도 없음
router.post('/users', async (req, res) => {
const user = await User.create(req.body);
res.json(user);
});
// 클라이언트는 어떤 필드가 필요한지, 어떤 응답이 오는지 알 수 없음✅ 올바른 예시:
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
// Swagger 설정
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'User Management API',
version: '1.0.0',
description: '사용자 관리 API 문서',
contact: {
name: 'API Support',
email: 'support@example.com'
}
},
servers: [
{
url: 'http://localhost:3000/api/v1',
description: '개발 서버'
},
{
url: 'https://api.example.com/api/v1',
description: '프로덕션 서버'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
}
},
apis: ['./routes/*.js']
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
/**
* @openapi
* /users:
* post:
* summary: 새 사용자 생성
* description: 이메일과 비밀번호로 새로운 사용자 계정을 생성합니다.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - name
* properties:
* email:
* type: string
* format: email
* example: user@example.com
* password:
* type: string
* format: password
* minLength: 8
* example: SecurePass123!
* name:
* type: string
* example: 홍길동
* phone:
* type: string
* example: 010-1234-5678
* responses:
* 201:
* description: 사용자 생성 성공
* headers:
* Location:
* schema:
* type: string
* description: 생성된 사용자의 URI
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* $ref: '#/components/schemas/User'
* 400:
* description: 잘못된 요청
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: 이미 존재하는 이메일
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/users', async (req, res) => {
// 구현...
});
/**
* @openapi
* components:
* schemas:
* User:
* type: object
* properties:
* id:
* type: string
* example: 507f1f77bcf86cd799439011
* email:
* type: string
* format: email
* name:
* type: string
* createdAt:
* type: string
* format: date-time
* Error:
* type: object
* properties:
* error:
* type: object
* properties:
* status:
* type: integer
* message:
* type: string
* details:
* type: array
* items:
* type: object
*/
// Postman Collection 자동 생성도 고려
// https://learning.postman.com/docs/developer/collection-format/문서화 모범 사례:
- 각 엔드포인트의 목적과 사용 사례 설명
- 모든 파라미터와 응답 형식 명시
- 예시 요청/응답 제공
- 에러 코드와 처리 방법 설명
- Changelog로 변경 이력 관리
흔한 실수와 주의사항
실수 1: N+1 쿼리 문제
// ❌ 각 게시물마다 작성자 조회 - N+1 쿼리!
const posts = await Post.find();
for (const post of posts) {
post.author = await User.findById(post.authorId); // N번 쿼리
}
// ✅ 한 번에 조인
const posts = await Post.find().populate('author', 'name avatar');실수 2: 동기 처리로 인한 블로킹
// ❌ 순차 처리 - 느림
const user = await getUser(id);
const orders = await getOrders(user.id);
const preferences = await getPreferences(user.id);
// ✅ 병렬 처리
const [user, orders, preferences] = await Promise.all([
getUser(id),
getOrders(id),
getPreferences(id)
]);실수 3: 민감한 정보 로깅
// ❌ 비밀번호, 토큰 로그에 노출
console.log('Login attempt:', req.body); // password 포함
// ✅ 민감 정보 제거
const { password, ...safeBody } = req.body;
console.log('Login attempt:', safeBody);결론: 확장 가능한 API를 위한 실무 체크리스트
API 설계 안티패턴 10가지를 살펴봤어요. 이 패턴들을 피하면 유지보수가 쉽고 확장 가능한 API를 만들 수 있어요. 핵심을 요약하면:
- RESTful 원칙 준수: 자원 중심 설계, 적절한 HTTP 메서드, 상태 코드 사용
- 일관성 유지: 네이밍 컨벤션, 에러 응답 형식, URL 구조
- 성능 최적화: 페이지네이션, 필드 선택, 캐싱 전략
- 보안 강화: Rate limiting, 입력 검증, HTTPS, 민감 정보 보호
- 개발자 경험: 명확한 문서화, 버전 관리, 예측 가능한 동작
실무 적용 팁:
- 새 API 개발 전에 이 체크리스트로 설계 검토하기
- 기존 API는 점진적으로 리팩토링 (한 번에 다 고치려 하지 말기)
- API 가이드라인 문서를 팀에서 공유하고 지속 업데이트
- 코드 리뷰 시 API 설계 안티패턴 체크 항목 포함
다음 단계로 학습하면 좋은 주제:
- GraphQL vs REST: 언제 어떤 걸 선택할지
- API Gateway 패턴: 마이크로서비스 환경에서의 API 관리
- API 모니터링과 관찰성: 로깅, 메트릭, 분산 추적
이 가이드가 더 나은 API를 설계하는 데 도움이 되길 바라요. 궁금한 점이나 실무에서 겪은 다른 안티패턴이 있다면 댓글로 공유해 주세요!
'API|통신' 카테고리의 다른 글
| API 모니터링과 로깅 전략 - 장애를 사전에 감지하는 실전 가이드 (0) | 2026.03.12 |
|---|