제14강: API 개발과 문서화

AI로 RESTful API를 설계하고 자동으로 문서화하기

난이도: 중급 예상 시간: 50분 카테고리: 중급

학습 목표

  • AI를 활용한 RESTful API 설계 원칙 이해하기
  • OpenAPI(Swagger) 명세 자동 생성하기
  • API 보안과 인증 구현하기
  • 자동화된 API 테스트 작성하기
  • 실시간 API 문서 생성과 유지보수하기

AI 기반 API 설계

Cursor AI는 비즈니스 요구사항을 분석하여 RESTful 원칙을 따르는 API를 설계하고, 자동으로 문서화합니다. 엔드포인트 구조, 데이터 모델, 보안 정책까지 종합적으로 고려합니다.

API 설계 프로세스

요구사항: 블로그 API 설계

다음 기능이 필요한 블로그 API를 설계해주세요:

  • 사용자 등록/로그인
  • 블로그 포스트 CRUD
  • 댓글 기능
  • 카테고리와 태그
  • 검색과 필터링

AI가 생성한 API 구조

# API 엔드포인트 설계

## 인증 (Authentication)
POST   /api/auth/register     # 회원가입
POST   /api/auth/login        # 로그인
POST   /api/auth/refresh      # 토큰 갱신
POST   /api/auth/logout       # 로그아웃

## 사용자 (Users)
GET    /api/users            # 사용자 목록
GET    /api/users/:id        # 사용자 상세
PUT    /api/users/:id        # 사용자 정보 수정
DELETE /api/users/:id        # 계정 삭제

## 포스트 (Posts)
GET    /api/posts           # 포스트 목록 (페이징, 필터링)
POST   /api/posts           # 포스트 작성
GET    /api/posts/:id       # 포스트 상세
PUT    /api/posts/:id       # 포스트 수정
DELETE /api/posts/:id       # 포스트 삭제
GET    /api/posts/search    # 포스트 검색

## 댓글 (Comments)
GET    /api/posts/:postId/comments      # 댓글 목록
POST   /api/posts/:postId/comments      # 댓글 작성
PUT    /api/comments/:id                # 댓글 수정
DELETE /api/comments/:id                # 댓글 삭제

## 카테고리 (Categories)
GET    /api/categories      # 카테고리 목록
POST   /api/categories      # 카테고리 생성
PUT    /api/categories/:id  # 카테고리 수정
DELETE /api/categories/:id  # 카테고리 삭제

## 태그 (Tags)
GET    /api/tags           # 태그 목록
GET    /api/tags/popular   # 인기 태그

데이터 모델 (TypeScript)

// API 응답 타입 정의
interface ApiResponse {
    success: boolean;
    data?: T;
    error?: {
        code: string;
        message: string;
        details?: any;
    };
    meta?: {
        page?: number;
        limit?: number;
        total?: number;
    };
}

// 엔티티 모델
interface User {
    id: string;
    username: string;
    email: string;
    avatar?: string;
    bio?: string;
    createdAt: Date;
    updatedAt: Date;
}

interface Post {
    id: string;
    title: string;
    slug: string;
    content: string;
    excerpt?: string;
    coverImage?: string;
    author: User;
    category?: Category;
    tags: Tag[];
    status: 'draft' | 'published' | 'archived';
    publishedAt?: Date;
    createdAt: Date;
    updatedAt: Date;
    commentCount: number;
    viewCount: number;
}

interface Comment {
    id: string;
    content: string;
    author: User;
    post: Post;
    parentId?: string; // 대댓글 지원
    createdAt: Date;
    updatedAt: Date;
}

interface Category {
    id: string;
    name: string;
    slug: string;
    description?: string;
    parentId?: string; // 계층 구조 지원
}

interface Tag {
    id: string;
    name: string;
    slug: string;
}

Express.js API 구현

AI가 생성한 컨트롤러 코드

포스트 컨트롤러

// controllers/postController.ts
import { Request, Response, NextFunction } from 'express';
import { PostService } from '../services/postService';
import { ApiResponse } from '../types/api';
import { validatePost } from '../validators/postValidator';
import { cache } from '../middleware/cache';

export class PostController {
    constructor(private postService: PostService) {}

    /**
     * @swagger
     * /api/posts:
     *   get:
     *     summary: 포스트 목록 조회
     *     tags: [Posts]
     *     parameters:
     *       - in: query
     *         name: page
     *         schema:
     *           type: integer
     *           default: 1
     *       - in: query
     *         name: limit
     *         schema:
     *           type: integer
     *           default: 10
     *       - in: query
     *         name: category
     *         schema:
     *           type: string
     *       - in: query
     *         name: tags
     *         schema:
     *           type: array
     *           items:
     *             type: string
     *       - in: query
     *         name: status
     *         schema:
     *           type: string
     *           enum: [draft, published, archived]
     *     responses:
     *       200:
     *         description: 포스트 목록
     *         content:
     *           application/json:
     *             schema:
     *               $ref: '#/components/schemas/PostListResponse'
     */
    @cache({ ttl: 300 }) // 5분 캐싱
    async getPosts(req: Request, res: Response, next: NextFunction) {
        try {
            const {
                page = 1,
                limit = 10,
                category,
                tags,
                status = 'published',
                sort = '-publishedAt'
            } = req.query;

            const filters = {
                status,
                ...(category && { category }),
                ...(tags && { tags: { $in: Array.isArray(tags) ? tags : [tags] } })
            };

            const posts = await this.postService.findPosts({
                filters,
                page: Number(page),
                limit: Number(limit),
                sort
            });

            const response: ApiResponse = {
                success: true,
                data: posts.data,
                meta: {
                    page: posts.page,
                    limit: posts.limit,
                    total: posts.total
                }
            };

            res.json(response);
        } catch (error) {
            next(error);
        }
    }

    /**
     * @swagger
     * /api/posts:
     *   post:
     *     summary: 새 포스트 작성
     *     tags: [Posts]
     *     security:
     *       - bearerAuth: []
     *     requestBody:
     *       required: true
     *       content:
     *         application/json:
     *           schema:
     *             $ref: '#/components/schemas/CreatePostRequest'
     *     responses:
     *       201:
     *         description: 생성된 포스트
     *       400:
     *         description: 유효성 검사 실패
     *       401:
     *         description: 인증 필요
     */
    async createPost(req: Request, res: Response, next: NextFunction) {
        try {
            const validation = validatePost(req.body);
            if (!validation.valid) {
                return res.status(400).json({
                    success: false,
                    error: {
                        code: 'VALIDATION_ERROR',
                        message: 'Invalid post data',
                        details: validation.errors
                    }
                });
            }

            const post = await this.postService.createPost({
                ...req.body,
                authorId: req.user.id
            });

            res.status(201).json({
                success: true,
                data: post
            });
        } catch (error) {
            next(error);
        }
    }

    /**
     * @swagger
     * /api/posts/{id}:
     *   get:
     *     summary: 포스트 상세 조회
     *     tags: [Posts]
     *     parameters:
     *       - in: path
     *         name: id
     *         required: true
     *         schema:
     *           type: string
     *     responses:
     *       200:
     *         description: 포스트 상세
     *       404:
     *         description: 포스트를 찾을 수 없음
     */
    async getPost(req: Request, res: Response, next: NextFunction) {
        try {
            const { id } = req.params;
            const post = await this.postService.findPostById(id);

            if (!post) {
                return res.status(404).json({
                    success: false,
                    error: {
                        code: 'NOT_FOUND',
                        message: 'Post not found'
                    }
                });
            }

            // 조회수 증가 (비동기로 처리)
            this.postService.incrementViewCount(id).catch(console.error);

            res.json({
                success: true,
                data: post
            });
        } catch (error) {
            next(error);
        }
    }

    // 나머지 CRUD 메서드들...
}

미들웨어와 보안

인증 미들웨어

// middleware/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

interface JwtPayload {
    userId: string;
    email: string;
}

declare global {
    namespace Express {
        interface Request {
            user?: JwtPayload;
        }
    }
}

export const authenticate = async (
    req: Request,
    res: Response,
    next: NextFunction
) => {
    try {
        const token = req.headers.authorization?.split(' ')[1];
        
        if (!token) {
            return res.status(401).json({
                success: false,
                error: {
                    code: 'UNAUTHORIZED',
                    message: 'Authentication required'
                }
            });
        }

        const decoded = jwt.verify(
            token,
            process.env.JWT_SECRET!
        ) as JwtPayload;
        
        req.user = decoded;
        next();
    } catch (error) {
        if (error instanceof jwt.TokenExpiredError) {
            return res.status(401).json({
                success: false,
                error: {
                    code: 'TOKEN_EXPIRED',
                    message: 'Token has expired'
                }
            });
        }
        
        return res.status(401).json({
            success: false,
            error: {
                code: 'INVALID_TOKEN',
                message: 'Invalid authentication token'
            }
        });
    }
};

// Rate limiting
import rateLimit from 'express-rate-limit';

export const apiLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15분
    max: 100, // 최대 100개 요청
    message: {
        success: false,
        error: {
            code: 'RATE_LIMIT_EXCEEDED',
            message: 'Too many requests, please try again later'
        }
    },
    standardHeaders: true,
    legacyHeaders: false,
});

// 보안 헤더
import helmet from 'helmet';
export const securityHeaders = helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            styleSrc: ["'self'", "'unsafe-inline'"],
            scriptSrc: ["'self'"],
            imgSrc: ["'self'", "data:", "https:"],
        },
    },
});

// CORS 설정
import cors from 'cors';
export const corsOptions: cors.CorsOptions = {
    origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    exposedHeaders: ['X-Total-Count', 'X-Page-Count']
};

OpenAPI 문서 자동 생성

Swagger 설정과 문서화

// swagger.config.ts
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import { Express } from 'express';

const options: swaggerJsdoc.Options = {
    definition: {
        openapi: '3.0.0',
        info: {
            title: 'Blog API',
            version: '1.0.0',
            description: 'A comprehensive blog API with authentication, posts, comments, and more',
            contact: {
                name: 'API Support',
                email: 'api@example.com'
            }
        },
        servers: [
            {
                url: 'http://localhost:3000',
                description: 'Development server'
            },
            {
                url: 'https://api.example.com',
                description: 'Production server'
            }
        ],
        components: {
            securitySchemes: {
                bearerAuth: {
                    type: 'http',
                    scheme: 'bearer',
                    bearerFormat: 'JWT'
                }
            },
            schemas: {
                Error: {
                    type: 'object',
                    properties: {
                        success: { type: 'boolean', example: false },
                        error: {
                            type: 'object',
                            properties: {
                                code: { type: 'string' },
                                message: { type: 'string' },
                                details: { type: 'object' }
                            }
                        }
                    }
                },
                Post: {
                    type: 'object',
                    properties: {
                        id: { type: 'string', format: 'uuid' },
                        title: { type: 'string' },
                        slug: { type: 'string' },
                        content: { type: 'string' },
                        excerpt: { type: 'string' },
                        coverImage: { type: 'string', format: 'uri' },
                        author: { $ref: '#/components/schemas/User' },
                        category: { $ref: '#/components/schemas/Category' },
                        tags: {
                            type: 'array',
                            items: { $ref: '#/components/schemas/Tag' }
                        },
                        status: {
                            type: 'string',
                            enum: ['draft', 'published', 'archived']
                        },
                        publishedAt: { type: 'string', format: 'date-time' },
                        createdAt: { type: 'string', format: 'date-time' },
                        updatedAt: { type: 'string', format: 'date-time' }
                    }
                }
                // 더 많은 스키마 정의...
            }
        }
    },
    apis: ['./src/controllers/*.ts', './src/routes/*.ts']
};

const specs = swaggerJsdoc(options);

export function setupSwagger(app: Express) {
    app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs, {
        customCss: '.swagger-ui .topbar { display: none }',
        customSiteTitle: 'Blog API Documentation'
    }));
    
    // JSON 형식으로도 제공
    app.get('/api-docs.json', (req, res) => {
        res.setHeader('Content-Type', 'application/json');
        res.send(specs);
    });
}

API 문서 자동 생성 결과

Swagger UI Preview

Swagger UI를 통해 API를 시각적으로 탐색하고 테스트할 수 있습니다.

API 테스트 자동화

통합 테스트 작성

// __tests__/api/posts.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { setupTestDatabase, teardownTestDatabase } from '../utils/testDb';
import { generateAuthToken } from '../utils/auth';

describe('Posts API', () => {
    let authToken: string;
    let testUserId: string;
    
    beforeAll(async () => {
        await setupTestDatabase();
        const { token, userId } = await generateAuthToken();
        authToken = token;
        testUserId = userId;
    });
    
    afterAll(async () => {
        await teardownTestDatabase();
    });
    
    describe('POST /api/posts', () => {
        it('should create a new post with valid data', async () => {
            const postData = {
                title: 'Test Post',
                content: 'This is a test post content',
                categoryId: 'test-category-id',
                tags: ['test', 'api']
            };
            
            const response = await request(app)
                .post('/api/posts')
                .set('Authorization', `Bearer ${authToken}`)
                .send(postData)
                .expect(201);
            
            expect(response.body).toMatchObject({
                success: true,
                data: {
                    title: postData.title,
                    content: postData.content,
                    author: {
                        id: testUserId
                    }
                }
            });
            
            expect(response.body.data).toHaveProperty('id');
            expect(response.body.data).toHaveProperty('slug');
        });
        
        it('should return 400 for invalid post data', async () => {
            const invalidData = {
                // title is missing
                content: 'Content without title'
            };
            
            const response = await request(app)
                .post('/api/posts')
                .set('Authorization', `Bearer ${authToken}`)
                .send(invalidData)
                .expect(400);
            
            expect(response.body).toMatchObject({
                success: false,
                error: {
                    code: 'VALIDATION_ERROR'
                }
            });
        });
        
        it('should return 401 without authentication', async () => {
            const response = await request(app)
                .post('/api/posts')
                .send({ title: 'Test', content: 'Test' })
                .expect(401);
            
            expect(response.body.error.code).toBe('UNAUTHORIZED');
        });
    });
    
    describe('GET /api/posts', () => {
        it('should return paginated posts', async () => {
            const response = await request(app)
                .get('/api/posts')
                .query({ page: 1, limit: 10 })
                .expect(200);
            
            expect(response.body).toMatchObject({
                success: true,
                data: expect.any(Array),
                meta: {
                    page: 1,
                    limit: 10,
                    total: expect.any(Number)
                }
            });
        });
        
        it('should filter posts by category', async () => {
            const response = await request(app)
                .get('/api/posts')
                .query({ category: 'technology' })
                .expect(200);
            
            response.body.data.forEach(post => {
                expect(post.category.slug).toBe('technology');
            });
        });
        
        it('should search posts by keyword', async () => {
            const response = await request(app)
                .get('/api/posts/search')
                .query({ q: 'javascript' })
                .expect(200);
            
            expect(response.body.data).toBeInstanceOf(Array);
            // 검색 결과가 있다면 관련성 확인
            if (response.body.data.length > 0) {
                response.body.data.forEach(post => {
                    const hasKeyword = 
                        post.title.toLowerCase().includes('javascript') ||
                        post.content.toLowerCase().includes('javascript');
                    expect(hasKeyword).toBe(true);
                });
            }
        });
    });
});

// API 성능 테스트
describe('API Performance', () => {
    it('should respond within acceptable time', async () => {
        const start = Date.now();
        
        await request(app)
            .get('/api/posts')
            .expect(200);
        
        const duration = Date.now() - start;
        expect(duration).toBeLessThan(200); // 200ms 이내 응답
    });
    
    it('should handle concurrent requests', async () => {
        const requests = Array(10).fill(null).map(() => 
            request(app).get('/api/posts')
        );
        
        const responses = await Promise.all(requests);
        
        responses.forEach(response => {
            expect(response.status).toBe(200);
            expect(response.body.success).toBe(true);
        });
    });
});

API 버전 관리

버전 관리 전략

// routes/index.ts
import { Router } from 'express';
import v1Routes from './v1';
import v2Routes from './v2';

const router = Router();

// 버전별 라우팅
router.use('/api/v1', v1Routes);
router.use('/api/v2', v2Routes);

// 기본 버전 (최신)
router.use('/api', v2Routes);

// 버전 정보 엔드포인트
router.get('/api/version', (req, res) => {
    res.json({
        current: 'v2',
        supported: ['v1', 'v2'],
        deprecated: [],
        sunset: {
            v1: '2025-01-01'
        }
    });
});

// routes/v2/posts.ts
import { Router } from 'express';
import { PostControllerV2 } from '../../controllers/v2/postController';

const router = Router();
const controller = new PostControllerV2();

// V2에서 추가된 기능
router.get('/posts', controller.getPosts); // 향상된 필터링
router.get('/posts/:id', controller.getPost);
router.post('/posts', authenticate, controller.createPost);
router.put('/posts/:id', authenticate, controller.updatePost);
router.delete('/posts/:id', authenticate, controller.deletePost);

// V2 전용 새 기능
router.post('/posts/:id/publish', authenticate, controller.publishPost);
router.post('/posts/:id/schedule', authenticate, controller.schedulePost);
router.get('/posts/:id/analytics', authenticate, controller.getPostAnalytics);

export default router;

실습: 완전한 API 구축

할 일 관리 API 만들기

AI와 함께 완전한 Todo API를 구축해봅시다.

요구사항

  • 사용자 인증 (JWT)
  • Todo CRUD 작업
  • 카테고리별 분류
  • 우선순위 관리
  • 마감일 알림
  • 공유 기능

구현 단계

1. API 설계

Chat에 요청: "Todo 관리 API의 RESTful 엔드포인트를 설계해줘"

2. 데이터 모델

Composer 사용: "Todo, User, Category 모델과 관계를 정의해줘"

3. 컨트롤러 구현

Cmd+K: "Express 컨트롤러와 미들웨어를 구현해줘"

4. 문서화

AI에게: "Swagger 문서를 자동으로 생성하는 설정을 추가해줘"

5. 테스트

테스트 작성: "각 엔드포인트에 대한 통합 테스트를 작성해줘"

🌟 보너스 기능

  • WebSocket으로 실시간 업데이트
  • GraphQL 버전 추가
  • 파일 업로드 (첨부파일)
  • 이메일 알림 통합
  • OAuth2 소셜 로그인

핵심 정리

자동 API 설계

비즈니스 요구사항을 바탕으로 RESTful 원칙을 따르는 API를 자동으로 설계합니다.

문서 자동 생성

코드에서 OpenAPI 명세를 자동으로 추출하여 항상 최신 문서를 유지합니다.

보안 베스트 프랙티스

인증, 권한 부여, 속도 제한 등 보안 기능을 자동으로 구현합니다.

테스트 자동화

엔드포인트별 테스트 케이스를 자동으로 생성하여 API 품질을 보장합니다.

다음 강의 예고

다음 강의에서는 데이터베이스 설계와 최적화를 AI와 함께 수행하는 방법을 배웁니다.

다음 강의로 이동
14/30 완료