← 목록으로

챕터 27: API 라우트 만들기

서론

지금까지 우리는 사용자에게 보여지는 프론트엔드 페이지를 만드는 방법을 배웠습니다. 하지만 현대적인 웹 애플리케이션은 단순히 정적인 페이지만으로는 충분하지 않습니다. 데이터를 저장하고, 처리하고, 외부 서비스와 통신하는 백엔드 기능이 필요합니다. Next.js의 놀라운 점은 프론트엔드와 백엔드를 하나의 프로젝트에서 함께 구현할 수 있다는 것입니다.

API 라우트는 Next.js에서 제공하는 강력한 기능으로, 별도의 서버 없이도 API 엔드포인트를 만들 수 있게 해줍니다. 이번 챕터에서는 API 라우트의 개념을 이해하고, GET과 POST 요청을 처리하는 방법, 그리고 간단한 데이터베이스 연동까지 시도해보겠습니다. 이제 여러분도 풀스택 개발자가 되는 첫걸음을 내딛게 됩니다.

본론

API 라우트란 무엇인가

API 라우트는 서버에서 실행되는 함수입니다. 클라이언트(브라우저)에서 요청을 보내면, 서버에서 처리한 후 응답을 돌려줍니다. Next.js에서는 app/api 폴더 안에 route.js 파일을 만들어 API 엔드포인트를 생성할 수 있습니다.

// app/api/hello/route.js
export async function GET(request) {
  return Response.json({ 
    message: '안녕하세요! 첫 번째 API입니다.',
    timestamp: new Date().toISOString()
  })
}

이제 브라우저에서 http://localhost:3000/api/hello로 접속하면 JSON 응답을 볼 수 있습니다. 이렇게 간단하게 API를 만들 수 있다는 것이 놀랍지 않습니까?

GET 요청 처리하기

GET 요청은 데이터를 가져올 때 사용합니다. URL 파라미터나 쿼리 스트링을 통해 추가 정보를 전달할 수 있습니다:

// app/api/users/route.js
const users = [
  { id: 1, name: '김철수', email: 'kim@example.com', role: '개발자' },
  { id: 2, name: '이영희', email: 'lee@example.com', role: '디자이너' },
  { id: 3, name: '박민수', email: 'park@example.com', role: '기획자' },
  { id: 4, name: '정수진', email: 'jung@example.com', role: '마케터' }
]

export async function GET(request) {
  // URL에서 쿼리 파라미터 가져오기
  const { searchParams } = new URL(request.url)
  const role = searchParams.get('role')
  const search = searchParams.get('search')
  
  let filteredUsers = users
  
  // 역할로 필터링
  if (role) {
    filteredUsers = filteredUsers.filter(user => user.role === role)
  }
  
  // 이름으로 검색
  if (search) {
    filteredUsers = filteredUsers.filter(user => 
      user.name.toLowerCase().includes(search.toLowerCase())
    )
  }
  
  return Response.json({
    success: true,
    count: filteredUsers.length,
    data: filteredUsers
  })
}

이제 다양한 방법으로 API를 호출할 수 있습니다:

  • /api/users - 모든 사용자
  • /api/users?role=개발자 - 개발자만
  • /api/users?search=김 - 이름에 '김'이 포함된 사용자

POST 요청 처리하기

POST 요청은 새로운 데이터를 생성할 때 사용합니다. 요청 본문(body)에서 데이터를 받아 처리합니다:

// app/api/users/route.js (POST 메서드 추가)
let users = [
  { id: 1, name: '김철수', email: 'kim@example.com', role: '개발자' },
  { id: 2, name: '이영희', email: 'lee@example.com', role: '디자이너' }
]

export async function POST(request) {
  try {
    // 요청 본문에서 데이터 가져오기
    const body = await request.json()
    
    // 데이터 유효성 검사
    if (!body.name || !body.email) {
      return Response.json(
        { 
          success: false, 
          error: '이름과 이메일은 필수입니다.' 
        },
        { status: 400 }
      )
    }
    
    // 이메일 중복 검사
    const emailExists = users.some(user => user.email === body.email)
    if (emailExists) {
      return Response.json(
        { 
          success: false, 
          error: '이미 존재하는 이메일입니다.' 
        },
        { status: 409 }
      )
    }
    
    // 새 사용자 생성
    const newUser = {
      id: users.length + 1,
      name: body.name,
      email: body.email,
      role: body.role || '미정',
      createdAt: new Date().toISOString()
    }
    
    users.push(newUser)
    
    return Response.json(
      { 
        success: true, 
        message: '사용자가 성공적으로 생성되었습니다.',
        data: newUser 
      },
      { status: 201 }
    )
  } catch (error) {
    return Response.json(
      { 
        success: false, 
        error: '서버 오류가 발생했습니다.' 
      },
      { status: 500 }
    )
  }
}

동적 API 라우트

특정 리소스를 다루는 API를 만들 때는 동적 라우트를 사용합니다:

// app/api/users/[id]/route.js
export async function GET(request, { params }) {
  const userId = parseInt(params.id)
  
  const user = users.find(u => u.id === userId)
  
  if (!user) {
    return Response.json(
      { success: false, error: '사용자를 찾을 수 없습니다.' },
      { status: 404 }
    )
  }
  
  return Response.json({ success: true, data: user })
}

export async function PUT(request, { params }) {
  const userId = parseInt(params.id)
  const body = await request.json()
  
  const userIndex = users.findIndex(u => u.id === userId)
  
  if (userIndex === -1) {
    return Response.json(
      { success: false, error: '사용자를 찾을 수 없습니다.' },
      { status: 404 }
    )
  }
  
  // 사용자 정보 업데이트
  users[userIndex] = {
    ...users[userIndex],
    ...body,
    id: userId, // ID는 변경 불가
    updatedAt: new Date().toISOString()
  }
  
  return Response.json({
    success: true,
    message: '사용자 정보가 업데이트되었습니다.',
    data: users[userIndex]
  })
}

export async function DELETE(request, { params }) {
  const userId = parseInt(params.id)
  
  const userIndex = users.findIndex(u => u.id === userId)
  
  if (userIndex === -1) {
    return Response.json(
      { success: false, error: '사용자를 찾을 수 없습니다.' },
      { status: 404 }
    )
  }
  
  const deletedUser = users.splice(userIndex, 1)[0]
  
  return Response.json({
    success: true,
    message: '사용자가 삭제되었습니다.',
    data: deletedUser
  })
}

프론트엔드에서 API 호출하기

이제 만든 API를 실제로 사용해봅시다:

// app/users/page.js
'use client'

import { useState, useEffect } from 'react'

export default function UsersPage() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    role: ''
  })
  
  // 사용자 목록 가져오기
  const fetchUsers = async () => {
    try {
      const response = await fetch('/api/users')
      const data = await response.json()
      if (data.success) {
        setUsers(data.data)
      }
    } catch (error) {
      console.error('사용자 목록을 가져오는데 실패했습니다:', error)
    } finally {
      setLoading(false)
    }
  }
  
  // 새 사용자 추가
  const handleSubmit = async (e) => {
    e.preventDefault()
    
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(formData)
      })
      
      const data = await response.json()
      
      if (data.success) {
        alert('사용자가 추가되었습니다!')
        setFormData({ name: '', email: '', role: '' })
        fetchUsers() // 목록 새로고침
      } else {
        alert(data.error)
      }
    } catch (error) {
      alert('오류가 발생했습니다.')
    }
  }
  
  // 사용자 삭제
  const handleDelete = async (id) => {
    if (!confirm('정말 삭제하시겠습니까?')) return
    
    try {
      const response = await fetch(`/api/users/${id}`, {
        method: 'DELETE'
      })
      
      const data = await response.json()
      
      if (data.success) {
        fetchUsers() // 목록 새로고침
      }
    } catch (error) {
      alert('삭제 중 오류가 발생했습니다.')
    }
  }
  
  useEffect(() => {
    fetchUsers()
  }, [])
  
  if (loading) {
    return <div className="p-8">로딩 중...</div>
  }
  
  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-8">사용자 관리</h1>
        
        {/* 사용자 추가 폼 */}
        <div className="bg-white rounded-lg shadow-md p-6 mb-8">
          <h2 className="text-xl font-semibold mb-4">새 사용자 추가</h2>
          <form onSubmit={handleSubmit} className="space-y-4">
            <div>
              <label className="block text-sm font-medium mb-1">이름</label>
              <input
                type="text"
                value={formData.name}
                onChange={(e) => setFormData({...formData, name: e.target.value})}
                className="w-full px-3 py-2 border rounded-lg"
                required
              />
            </div>
            <div>
              <label className="block text-sm font-medium mb-1">이메일</label>
              <input
                type="email"
                value={formData.email}
                onChange={(e) => setFormData({...formData, email: e.target.value})}
                className="w-full px-3 py-2 border rounded-lg"
                required
              />
            </div>
            <div>
              <label className="block text-sm font-medium mb-1">역할</label>
              <select
                value={formData.role}
                onChange={(e) => setFormData({...formData, role: e.target.value})}
                className="w-full px-3 py-2 border rounded-lg"
              >
                <option value="">선택하세요</option>
                <option value="개발자">개발자</option>
                <option value="디자이너">디자이너</option>
                <option value="기획자">기획자</option>
                <option value="마케터">마케터</option>
              </select>
            </div>
            <button
              type="submit"
              className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
            >
              추가하기
            </button>
          </form>
        </div>
        
        {/* 사용자 목록 */}
        <div className="bg-white rounded-lg shadow-md overflow-hidden">
          <table className="w-full">
            <thead className="bg-gray-100">
              <tr>
                <th className="px-6 py-3 text-left">ID</th>
                <th className="px-6 py-3 text-left">이름</th>
                <th className="px-6 py-3 text-left">이메일</th>
                <th className="px-6 py-3 text-left">역할</th>
                <th className="px-6 py-3 text-left">작업</th>
              </tr>
            </thead>
            <tbody>
              {users.map(user => (
                <tr key={user.id} className="border-t">
                  <td className="px-6 py-4">{user.id}</td>
                  <td className="px-6 py-4">{user.name}</td>
                  <td className="px-6 py-4">{user.email}</td>
                  <td className="px-6 py-4">{user.role}</td>
                  <td className="px-6 py-4">
                    <button
                      onClick={() => handleDelete(user.id)}
                      className="text-red-600 hover:text-red-800"
                    >
                      삭제
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  )
}

실제 데이터베이스 연동 준비

지금까지는 메모리에 데이터를 저장했지만, 실제 프로젝트에서는 데이터베이스를 사용해야 합니다. 간단한 JSON 파일 기반 저장소를 만들어봅시다:

// lib/db.js
import fs from 'fs/promises'
import path from 'path'

const DB_PATH = path.join(process.cwd(), 'data', 'db.json')

// 데이터베이스 초기화
async function initDB() {
  try {
    await fs.access(DB_PATH)
  } catch {
    // 파일이 없으면 생성
    await fs.mkdir(path.dirname(DB_PATH), { recursive: true })
    await fs.writeFile(DB_PATH, JSON.stringify({ users: [] }))
  }
}

// 데이터 읽기
export async function readDB() {
  await initDB()
  const data = await fs.readFile(DB_PATH, 'utf-8')
  return JSON.parse(data)
}

// 데이터 쓰기
export async function writeDB(data) {
  await fs.writeFile(DB_PATH, JSON.stringify(data, null, 2))
}

// 사용자 관련 함수들
export async function getUsers() {
  const db = await readDB()
  return db.users || []
}

export async function createUser(userData) {
  const db = await readDB()
  const newUser = {
    id: Date.now(),
    ...userData,
    createdAt: new Date().toISOString()
  }
  db.users.push(newUser)
  await writeDB(db)
  return newUser
}

export async function updateUser(id, userData) {
  const db = await readDB()
  const index = db.users.findIndex(u => u.id === id)
  if (index === -1) return null
  
  db.users[index] = { ...db.users[index], ...userData }
  await writeDB(db)
  return db.users[index]
}

export async function deleteUser(id) {
  const db = await readDB()
  const index = db.users.findIndex(u => u.id === id)
  if (index === -1) return null
  
  const deleted = db.users.splice(index, 1)[0]
  await writeDB(db)
  return deleted
}

이제 API 라우트에서 이 함수들을 사용할 수 있습니다:

// app/api/users/route.js (데이터베이스 버전)
import { getUsers, createUser } from '@/lib/db'

export async function GET() {
  try {
    const users = await getUsers()
    return Response.json({ success: true, data: users })
  } catch (error) {
    return Response.json(
      { success: false, error: '데이터를 가져오는데 실패했습니다.' },
      { status: 500 }
    )
  }
}

export async function POST(request) {
  try {
    const body = await request.json()
    const newUser = await createUser(body)
    return Response.json(
      { success: true, data: newUser },
      { status: 201 }
    )
  } catch (error) {
    return Response.json(
      { success: false, error: '사용자 생성에 실패했습니다.' },
      { status: 500 }
    )
  }
}

결론

축하합니다! 이제 여러분은 API를 만들 수 있는 백엔드 개발자가 되었습니다. 이번 챕터에서 우리는 API 라우트의 개념을 이해하고, GET과 POST 요청을 처리하는 방법을 배웠습니다. 또한 동적 라우트를 활용한 CRUD 작업과 간단한 파일 기반 데이터베이스까지 구현해보았습니다.

API 라우트를 사용하면 프론트엔드와 백엔드를 하나의 프로젝트에서 관리할 수 있어 개발이 훨씬 간편해집니다. 배포할 때도 하나의 애플리케이션으로 배포하면 되므로 복잡성이 크게 줄어듭니다. 이것이 Next.js가 풀스택 프레임워크로 불리는 이유입니다.

다음 챕터에서는 다양한 데이터 페칭 전략에 대해 알아보겠습니다. 서버 사이드 렌더링, 정적 생성, 클라이언트 사이드 페칭 등 상황에 맞는 최적의 데이터 가져오기 방법을 배워보겠습니다!