← 목록으로

Next.js API 정리

Next.js API 완벽 가이드: API Routes부터 RESTful 설계까지

서론

Next.js는 풀스택 프레임워크로, 프론트엔드와 백엔드 API를 하나의 프로젝트에서 개발할 수 있습니다. API Routes를 통해 서버리스 함수를 만들고, RESTful API를 설계하며, 외부 API와 통신하는 방법을 이해하는 것은 현대 웹 개발에서 필수적입니다. 이 글에서는 Next.js의 API Routes를 중심으로 API 개발의 모든 것을 다루겠습니다.

1. Next.js API Routes 기본

1.1 API Routes란?

Next.js API Routes는 서버 사이드 API 엔드포인트를 만들 수 있게 해주는 기능입니다. pages/api 또는 app/api 디렉토리에 파일을 생성하면 자동으로 API 엔드포인트가 됩니다.

1.2 기본 API Route 생성

// pages/api/hello.js (Pages Router)
export default function handler(req, res) {
  res.status(200).json({ message: 'Hello API' });
}

// app/api/hello/route.js (App Router)
export async function GET(request) {
  return Response.json({ message: 'Hello API' });
}

1.3 HTTP 메서드 처리

// pages/api/users.js
export default function handler(req, res) {
  const { method } = req;

  switch (method) {
    case 'GET':
      res.status(200).json({ users: [] });
      break;
    case 'POST':
      const { name, email } = req.body;
      res.status(201).json({ id: 1, name, email });
      break;
    default:
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`Method ${method} Not Allowed`);
  }
}
// app/api/users/route.js (App Router)
export async function GET(request) {
  return Response.json({ users: [] });
}

export async function POST(request) {
  const body = await request.json();
  const { name, email } = body;
  return Response.json({ id: 1, name, email }, { status: 201 });
}

2. RESTful API 설계 원칙

2.1 RESTful API 기본 원칙

  • 리소스 중심: URL은 리소스를 나타냄
  • HTTP 메서드: GET, POST, PUT, DELETE 등으로 동작 정의
  • 상태 코드: 적절한 HTTP 상태 코드 사용
  • 일관성: 일관된 URL 구조와 응답 형식

2.2 RESTful API 예제

// app/api/posts/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = searchParams.get('page') || 1;
  const limit = searchParams.get('limit') || 10;

  // 데이터베이스에서 포스트 조회
  const posts = await getPosts({ page, limit });

  return Response.json({
    data: posts,
    pagination: { page, limit, total: posts.length },
  });
}

export async function POST(request) {
  const body = await request.json();
  const { title, content } = body;

  // 유효성 검사
  if (!title || !content) {
    return Response.json(
      { error: '제목과 내용은 필수입니다.' },
      { status: 400 }
    );
  }

  // 포스트 생성
  const post = await createPost({ title, content });

  return Response.json(post, { status: 201 });
}
// app/api/posts/[id]/route.js
export async function GET(request, { params }) {
  const { id } = params;

  const post = await getPostById(id);

  if (!post) {
    return Response.json({ error: '포스트를 찾을 수 없습니다.' }, { status: 404 });
  }

  return Response.json(post);
}

export async function PUT(request, { params }) {
  const { id } = params;
  const body = await request.json();

  const post = await updatePost(id, body);

  return Response.json(post);
}

export async function DELETE(request, { params }) {
  const { id } = params;

  await deletePost(id);

  return Response.json({ message: '삭제되었습니다.' }, { status: 200 });
}

3. 요청과 응답 처리

3.1 요청 데이터 파싱

// app/api/users/route.js
export async function POST(request) {
  // JSON 데이터
  const jsonData = await request.json();

  // FormData
  const formData = await request.formData();
  const name = formData.get('name');

  // URL 쿼리 파라미터
  const { searchParams } = new URL(request.url);
  const page = searchParams.get('page');

  return Response.json({ success: true });
}

3.2 응답 형식 표준화

// lib/api-response.js
export function successResponse(data, status = 200) {
  return Response.json(
    {
      success: true,
      data,
    },
    { status }
  );
}

export function errorResponse(message, status = 400) {
  return Response.json(
    {
      success: false,
      error: message,
    },
    { status }
  );
}

// 사용 예시
import { successResponse, errorResponse } from '@/lib/api-response';

export async function GET(request) {
  try {
    const data = await fetchData();
    return successResponse(data);
  } catch (error) {
    return errorResponse(error.message, 500);
  }
}

3.3 파일 업로드 처리

// app/api/upload/route.js
export async function POST(request) {
  const formData = await request.formData();
  const file = formData.get('file');

  if (!file) {
    return Response.json({ error: '파일이 없습니다.' }, { status: 400 });
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // 파일 저장
  const filename = `${Date.now()}-${file.name}`;
  const filepath = path.join(process.cwd(), 'public', 'uploads', filename);
  
  await writeFile(filepath, buffer);

  return Response.json({
    url: `/uploads/${filename}`,
  });
}

4. 인증과 보안

4.1 JWT 인증

// app/api/auth/login/route.js
import jwt from 'jsonwebtoken';

export async function POST(request) {
  const { email, password } = await request.json();

  // 사용자 인증
  const user = await authenticateUser(email, password);

  if (!user) {
    return Response.json(
      { error: '이메일 또는 비밀번호가 올바르지 않습니다.' },
      { status: 401 }
    );
  }

  // JWT 토큰 생성
  const token = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  return Response.json({
    token,
    user: { id: user.id, email: user.email },
  });
}

4.2 미들웨어로 인증 검증

// lib/auth.js
import jwt from 'jsonwebtoken';

export function verifyToken(request) {
  const authHeader = request.headers.get('authorization');
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return null;
  }

  const token = authHeader.substring(7);

  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (error) {
    return null;
  }
}

// app/api/protected/route.js
import { verifyToken } from '@/lib/auth';

export async function GET(request) {
  const user = verifyToken(request);

  if (!user) {
    return Response.json(
      { error: '인증이 필요합니다.' },
      { status: 401 }
    );
  }

  return Response.json({ message: '인증된 사용자입니다.', user });
}

4.3 CORS 설정

// app/api/cors-example/route.js
export async function GET(request) {
  return Response.json(
    { message: 'CORS enabled' },
    {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    }
  );
}

5. 데이터베이스 연동

5.1 Prisma와 연동

// app/api/posts/route.js
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function GET(request) {
  const posts = await prisma.post.findMany({
    include: {
      author: true,
      comments: true,
    },
  });

  return Response.json(posts);
}

export async function POST(request) {
  const body = await request.json();
  const { title, content, authorId } = body;

  const post = await prisma.post.create({
    data: {
      title,
      content,
      authorId,
    },
    include: {
      author: true,
    },
  });

  return Response.json(post, { status: 201 });
}

5.2 MongoDB와 연동

// lib/mongodb.js
import { MongoClient } from 'mongodb';

const uri = process.env.MONGODB_URI;
const client = new MongoClient(uri);

export async function connectDB() {
  if (!client.topology || !client.topology.isConnected()) {
    await client.connect();
  }
  return client.db('myapp');
}

// app/api/posts/route.js
import { connectDB } from '@/lib/mongodb';

export async function GET(request) {
  const db = await connectDB();
  const posts = await db.collection('posts').find({}).toArray();
  return Response.json(posts);
}

6. 외부 API 호출

6.1 서버 사이드에서 외부 API 호출

// app/api/external/route.js
export async function GET(request) {
  try {
    const response = await fetch('https://api.example.com/data', {
      headers: {
        'Authorization': `Bearer ${process.env.API_KEY}`,
      },
    });

    if (!response.ok) {
      throw new Error('API 호출 실패');
    }

    const data = await response.json();
    return Response.json(data);
  } catch (error) {
    return Response.json(
      { error: error.message },
      { status: 500 }
    );
  }
}

6.2 API 캐싱

// app/api/cached/route.js
export async function GET(request) {
  const response = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }, // 1시간 캐시
  });

  const data = await response.json();

  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  });
}

7. 에러 처리

7.1 전역 에러 핸들러

// app/api/posts/route.js
export async function GET(request) {
  try {
    const posts = await getPosts();
    return Response.json(posts);
  } catch (error) {
    console.error('API Error:', error);
    
    return Response.json(
      {
        error: '서버 오류가 발생했습니다.',
        message: process.env.NODE_ENV === 'development' ? error.message : undefined,
      },
      { status: 500 }
    );
  }
}

7.2 유효성 검사

// lib/validation.js
import { z } from 'zod';

export const postSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  authorId: z.number().int().positive(),
});

// app/api/posts/route.js
import { postSchema } from '@/lib/validation';

export async function POST(request) {
  const body = await request.json();

  // 유효성 검사
  const validationResult = postSchema.safeParse(body);

  if (!validationResult.success) {
    return Response.json(
      {
        error: '유효성 검사 실패',
        details: validationResult.error.errors,
      },
      { status: 400 }
    );
  }

  const post = await createPost(validationResult.data);
  return Response.json(post, { status: 201 });
}

8. 실전 예제: 블로그 API

8.1 완전한 블로그 API

// app/api/posts/route.js
import { PrismaClient } from '@prisma/client';
import { postSchema } from '@/lib/validation';
import { verifyToken } from '@/lib/auth';

const prisma = new PrismaClient();

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get('page') || '1');
  const limit = parseInt(searchParams.get('limit') || '10');
  const skip = (page - 1) * limit;

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      skip,
      take: limit,
      include: {
        author: {
          select: { id: true, name: true, email: true },
        },
        _count: {
          select: { comments: true },
        },
      },
      orderBy: { createdAt: 'desc' },
    }),
    prisma.post.count(),
  ]);

  return Response.json({
    data: posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
}

export async function POST(request) {
  const user = verifyToken(request);

  if (!user) {
    return Response.json(
      { error: '인증이 필요합니다.' },
      { status: 401 }
    );
  }

  const body = await request.json();
  const validationResult = postSchema.safeParse(body);

  if (!validationResult.success) {
    return Response.json(
      {
        error: '유효성 검사 실패',
        details: validationResult.error.errors,
      },
      { status: 400 }
    );
  }

  const post = await prisma.post.create({
    data: {
      ...validationResult.data,
      authorId: user.userId,
    },
    include: {
      author: {
        select: { id: true, name: true, email: true },
      },
    },
  });

  return Response.json(post, { status: 201 });
}

9. 성능 최적화

9.1 데이터베이스 쿼리 최적화

// N+1 문제 해결
export async function GET(request) {
  // ❌ 나쁜 예: N+1 문제
  const posts = await prisma.post.findMany();
  for (const post of posts) {
    post.author = await prisma.user.findUnique({ where: { id: post.authorId } });
  }

  // ✅ 좋은 예: include 사용
  const posts = await prisma.post.findMany({
    include: { author: true },
  });

  return Response.json(posts);
}

11. 결론

Next.js API Routes는 풀스택 애플리케이션을 구축하는 강력한 도구입니다. RESTful 원칙을 따르고, 적절한 에러 처리와 인증을 구현하며, 데이터베이스와 효율적으로 연동하면 견고한 API를 만들 수 있습니다.

주요 포인트:

  • RESTful 설계: 일관된 URL 구조와 HTTP 메서드 사용
  • 인증과 보안: JWT를 활용한 인증 구현
  • 에러 처리: 명확한 에러 메시지와 상태 코드
  • 성능 최적화: 캐싱과 쿼리 최적화
  • 유효성 검사: Zod 등을 활용한 데이터 검증

Next.js의 API Routes를 활용하면 별도의 백엔드 서버 없이도 완전한 풀스택 애플리케이션을 구축할 수 있으며, 서버리스 환경에서도 효율적으로 동작합니다.