[Nuxt.js-8] Nuxt.js 서버 엔진 (Server engine) 활용

  • 카카오톡 공유하기
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 링크 복사하기

Nuxt.js의 8번째 포스트입니다. 지난 번에는 Pinia를 활용한 상태 관리 과정을 살펴보았는데요. 이번에는 Nuxt.js의 강력한 기능 중 하나인 서버 엔진(Server engine)를 활용하여 자체 API 엔드포인트를 구축하는 방법에 대해 알아보겠습니다.

Nitro 서버 엔진 소개

Nuxt 3부터는 Nitro라는 강력한 서버 엔진이 내장되어 있습니다. Nitro는 Nuxt의 서버 사이드 로직을 처리하는 엔진으로, 다음과 같은 특징을 가지고 있습니다:

  • 크로스 플랫폼 지원 (Node.js, Deno, Cloudflare Workers 등)
  • 서버리스 환경에 최적화
  • API 라우트 자동 생성
  • 서버 미들웨어 지원
  • 코드 분할 및 지연 로딩

이러한 Nitro 엔진을 활용하면 별도의 백엔드 서버 없이도 Nuxt 애플리케이션 내에서 API를 구축할 수 있습니다.

서버 디렉토리 구조 이해하기

Nuxt 3에서는 프로젝트 루트에 server 디렉토리를 생성하여 서버 관련 코드를 관리합니다. 서버 디렉토리의 기본 구조는 다음과 같습니다:

server/
  ├── api/        # API 엔드포인트
  ├── routes/     # 커스텀 서버 라우트
  ├── middleware/ # 서버 미들웨어
  └── plugins/    # 서버 플러그인

각 디렉토리는 특정 역할을 담당하며, 파일 기반 라우팅 시스템을 따릅니다.

API 엔드포인트 생성하기

Nuxt의 서버 디렉토리를 활용하면 쉽게 API 엔드포인트를 생성할 수 있습니다. server/api 디렉토리에 파일을 생성하면 자동으로 API 라우트로 등록됩니다.

기본 API 엔드포인트 생성

간단한 API 엔드포인트를 만들어 보겠습니다. server/api/hello.ts 파일을 생성합니다:

//server/api/hello.ts
export default defineEventHandler((event) => {
  return {
    message: 'Hello, Nuxt Server API!'
  }
})

이제 /api/hello 경로로 접근하면 JSON 응답을 받을 수 있습니다.

동적 라우트 파라미터 사용하기

파일 이름에 대괄호를 사용하여 동적 라우트 파라미터를 정의할 수 있습니다. server/api/users/[id].ts 파일을 생성해 보겠습니다:

//server/api/users/[id].ts
export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  
  return {
    id,
    name: `User ${id}`,
    email: `user${id}@example.com`
  }
})

이제 /api/users/1, /api/users/2 등의 경로로 접근하면 해당 ID에 맞는 사용자 정보를 받을 수 있습니다.

HTTP 메서드 처리하기

API 엔드포인트에서 다양한 HTTP 메서드(GET, POST, PUT, DELETE 등)를 처리할 수 있습니다. server/api/posts/index.ts 파일을 생성해 보겠습니다:

//server/api/posts/index.ts
export default defineEventHandler(async (event) => {
  const method = getMethod(event)
  
  // GET 요청 처리
  if (method === 'GET') {
    return [
      { id: 1, title: '첫 번째 포스트' },
      { id: 2, title: '두 번째 포스트' }
    ]
  }
  
  // POST 요청 처리
  if (method === 'POST') {
    const body = await readBody(event)
    return { 
      id: 3, 
      title: body.title,
      created: true 
    }
  }
})

또는 각 HTTP 메서드별로 핸들러를 분리할 수도 있습니다:

// server/api/posts/index.get.ts
export default defineEventHandler(() => {
  return [
    { id: 1, title: '첫 번째 포스트' },
    { id: 2, title: '두 번째 포스트' }
  ]
})
// server/api/posts/index.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  return { 
    id: 3, 
    title: body.title,
    created: true 
  }
})

요청 데이터 처리하기

Nitro는 요청 데이터를 쉽게 처리할 수 있는 유틸리티 함수를 제공합니다.

쿼리 파라미터 가져오기

// /api/search?query=nuxt&limit=10
export default defineEventHandler((event) => {
  const query = getQuery(event)
  
  return {
    searchTerm: query.query,
    limit: query.limit,
    results: ['결과 1', '결과 2']
  }
})

요청 본문(Body) 읽기

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  
  // 요청 본문 데이터 처리
  return {
    received: true,
    data: body
  }
})

서버 미들웨어 활용하기

서버 미들웨어를 사용하면 API 요청이나 페이지 렌더링 전에 실행되는 로직을 추가할 수 있습니다. server/middleware 디렉토리에 파일을 생성하여 미들웨어를 정의할 수 있습니다.

기본 미들웨어 생성

server/middleware/logger.ts 파일을 생성해 보겠습니다:

export default defineEventHandler((event) => {
  console.log(`[${new Date().toISOString()}] ${getMethod(event)} ${getRequestURL(event)}`)
})

이 미들웨어는 모든 요청에 대해 로그를 출력합니다.

인증 미들웨어 구현

API 엔드포인트에 접근 제어를 추가하는 인증 미들웨어를 구현해 보겠습니다. server/middleware/auth.ts 파일을 생성합니다:

//server/middleware/auth.ts
export default defineEventHandler((event) => {
  // API 경로에만 적용
  if (!getRequestURL(event).pathname.startsWith('/api/')) {
    return
  }
  
  // 공개 API 경로는 제외
  if (getRequestURL(event).pathname.startsWith('/api/public')) {
    return
  }
  
  // Authorization 헤더 확인
  const token = getHeader(event, 'Authorization')
  
  if (!token || !token.startsWith('Bearer ')) {
    return sendError(event, createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    }))
  }
  
  // 토큰 검증 로직 (실제로는 더 복잡한 검증이 필요)
  const actualToken = token.split('Bearer ')[1]
  if (actualToken !== 'my-secret-token') {
    return sendError(event, createError({
      statusCode: 403,
      statusMessage: 'Forbidden'
    }))
  }
  
  // 인증된 사용자 정보를 이벤트 컨텍스트에 추가
  event.context.auth = { userId: 1 }
})

서버 라우트 활용하기

server/routes 디렉토리를 사용하면 API 이외의 커스텀 서버 라우트를 정의할 수 있습니다. 이는 API와 유사하지만 URL 경로에 /api/ 접두사가 붙지 않습니다.

// server/routes/sitemap.xml.ts
export default defineEventHandler((event) => {
  setHeader(event, 'Content-Type', 'application/xml')
  return `<xml version="1.0" encoding="UTF-8"?>

  
    https://example.com/
  
  
    https://example.com/about
  
`
})

이 파일은 /sitemap.xml 경로에 접근할 때 XML 형식의 사이트맵을 제공합니다.

외부 API와 통신하기

서버 API 내에서 외부 API와 통신해야 할 때는 Nuxt에서 제공하는 $fetch 유틸리티를 사용할 수 있습니다.

// server/api/weather.ts
export default defineEventHandler(async (event) => {
  const { city } = getQuery(event)
  
  if (!city) {
    return sendError(event, createError({
      statusCode: 400,
      statusMessage: 'City parameter is required'
    }))
  }
  
  try {
    // 외부 API 호출 (예시 URL)
    const response = await $fetch(`https://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q=${city}`)
    
    return {
      city,
      temperature: response.current.temp_c,
      condition: response.current.condition.text
    }
  } catch (error) {
    return sendError(event, createError({
      statusCode: 500,
      statusMessage: 'Failed to fetch weather data'
    }))
  }
})

실전 예제: ToDoList 관리 API 구현하기

지금까지 배운 내용을 활용하여 간단한 할일 관리 API를 구현해 보겠습니다. 실제 프로덕션 환경에서는 데이터베이스를 사용하겠지만, 이 예제에서는 메모리 내 저장소를 사용하겠습니다.

먼저 서버 스토리지를 정의합니다. server/utils/todos.ts 파일을 생성합니다:

//server/utils/todos.ts
// 간단한 인메모리 저장소
let todos = [
  { id: 1, text: 'Nuxt.js 학습하기', completed: false },
  { id: 2, text: 'Server API 구현하기', completed: false }
]

let nextId = 3

export const getTodos = () => todos

export const getTodoById = (id: number) => {
  return todos.find(todo => todo.id === id)
}

export const addTodo = (text: string) => {
  const newTodo = { id: nextId++, text, completed: false }
  todos.push(newTodo)
  return newTodo
}

export const updateTodo = (id: number, updates: Partial<{ text: string, completed: boolean }>) => {
  const todo = getTodoById(id)
  if (!todo) return null
  
  Object.assign(todo, updates)
  return todo
}

export const deleteTodo = (id: number) => {
  const index = todos.findIndex(todo => todo.id === id)
  if (index === -1) return false
  
  todos.splice(index, 1)
  return true
}

이제 API 엔드포인트를 구현합니다:

// server/api/todos/index.get.ts
import { getTodos } from '~/server/utils/todos'

export default defineEventHandler(() => {
  return getTodos()
})
// server/api/todos/index.post.ts
import { addTodo } from '~/server/utils/todos'

export default defineEventHandler(async (event) => {
  const { text } = await readBody(event)
  
  if (!text) {
    return sendError(event, createError({
      statusCode: 400,
      statusMessage: 'Text is required'
    }))
  }
  
  return addTodo(text)
})
// server/api/todos/[id].get.ts
import { getTodoById } from '~/server/utils/todos'

export default defineEventHandler((event) => {
  const id = Number(getRouterParam(event, 'id'))
  
  const todo = getTodoById(id)
  if (!todo) {
    return sendError(event, createError({
      statusCode: 404,
      statusMessage: 'Todo not found'
    }))
  }
  
  return todo
})
// server/api/todos/[id].patch.ts
import { updateTodo } from '~/server/utils/todos'

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, 'id'))
  const updates = await readBody(event)
  
  const updatedTodo = updateTodo(id, updates)
  if (!updatedTodo) {
    return sendError(event, createError({
      statusCode: 404,
      statusMessage: 'Todo not found'
    }))
  }
  
  return updatedTodo
})
// server/api/todos/[id].delete.ts
import { deleteTodo } from '~/server/utils/todos'

export default defineEventHandler((event) => {
  const id = Number(getRouterParam(event, 'id'))
  
  const success = deleteTodo(id)
  if (!success) {
    return sendError(event, createError({
      statusCode: 404,
      statusMessage: 'Todo not found'
    }))
  }
  
  return { success: true }
})

클라이언트에서 서버 API 사용하기

이제 클라이언트 측에서 방금 만든 API를 사용해 보겠습니다. Nuxt에서는 useFetch, useAsyncData, $fetch 등의 유틸리티를 사용하여 API와 통신할 수 있습니다.

//app/pages/todos.vue

<template>
  <div class="todo-app">
    <h1>Todo 애플리케이션</h1>
    
    <div class="add-todo">
      <input 
        v-model="newTodoText" 
        @keyup.enter="addTodo"
        placeholder="할 일을 입력하세요"
      />
      <button @click="addTodo">추가</button>
    </div>
    
    <div class="filters">
      <button 
        :class="{ active: filter === 'all' }"
        @click="filter = 'all'"
      >모두</button>
      <button 
        :class="{ active: filter === 'active' }"
        @click="filter = 'active'"
      >미완료</button>
      <button 
        :class="{ active: filter === 'completed' }"
        @click="filter = 'completed'"
      >완료</button>
    </div>
    
    <ul class="todo-list">
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input 
          type="checkbox" 
          :checked="todo.completed"
          @change="toggleTodo(todo)"
        />
        <span :class="{ completed: todo.completed }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">삭제</button>
      </li>
    </ul>
    
    <div class="todo-stats">
      <span>{{ completedCount }} / {{ totalCount }} 완료</span>
      <button v-if="completedCount > 0" @click="clearCompleted">
        완료된 항목 삭제
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

// useAsyncData와 $fetch를 사용하여 서버에서 할 일 목록을 가져옵니다.
// 'todos'는 고유 키이며, refresh 함수는 목록을 다시 불러오는 데 사용됩니다.
const { data: todos, refresh } = await useAsyncData('todos', () => $fetch('/api/todos'))

// 컴포넌트의 로컬 상태
const newTodoText = ref('')
const filter = ref('all') // 'all', 'active', 'completed'

// 필터링된 할 일 목록을 계산하는 computed 속성
const filteredTodos = computed(() => {
  if (!todos.value) return []
  switch (filter.value) {
    case 'active':
      return todos.value.filter(t => !t.completed)
    case 'completed':
      return todos.value.filter(t => t.completed)
    default: // 'all'
      return todos.value
  }
})

// 할 일 추가: POST 요청으로 새 할 일을 서버에 전송하고 목록을 갱신합니다.
async function addTodo() {
  if (!newTodoText.value.trim()) return
  
  await $fetch('/api/todos', {
    method: 'POST',
    body: { text: newTodoText.value }
  })
  
  newTodoText.value = ''
  await refresh() // 목록 새로고침
}

// 할 일 완료/미완료 토글: PATCH 요청으로 상태를 업데이트합니다.
// 전체 목록을 새로고침하는 대신, 응답으로 받은 업데이트된 항목만 로컬 데이터에 반영합니다.
async function toggleTodo(todo) {
  const updatedTodo = await $fetch(`/api/todos/${todo.id}`, {
    method: 'PATCH',
    body: { completed: !todo.completed }
  })
  
  const index = todos.value.findIndex(t => t.id === todo.id)
  if (index !== -1) {
    todos.value[index] = updatedTodo
  }
}

// 할 일 삭제: DELETE 요청으로 특정 할 일을 삭제합니다.
// API 호출 성공 후, 로컬 목록에서 해당 항목을 즉시 제거합니다.
async function removeTodo(id) {
  await $fetch(`/api/todos/${id}`, {
    method: 'DELETE'
  })
  todos.value = todos.value.filter(t => t.id !== id)
}

// 완료된 모든 항목 삭제: 완료된 항목들에 대해 각각 DELETE 요청을 보냅니다.
async function clearCompleted() {
  const completedTodos = todos.value.filter(t => t.completed)
  
  // Promise.all을 사용하여 모든 삭제 요청을 병렬로 처리합니다.
  await Promise.all(
    completedTodos.map(todo => 
      $fetch(`/api/todos/${todo.id}`, { method: 'DELETE' })
    )
  )
  
  await refresh() // 모든 삭제가 완료된 후 목록을 새로고침합니다.
}

// 통계 정보를 계산하는 computed 속성
const completedCount = computed(() => todos.value?.filter(t => t.completed).length ?? 0)
const totalCount = computed(() => todos.value?.length ?? 0)

</script>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.completed {
  text-decoration: line-through;
  color: #888;
}

.todo-list li {
  display: flex;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid #eee;
}

.filters button {
  margin-right: 8px;
}

.filters button.active {
  font-weight: bold;
  color: #42b883;
}

.todo-stats {
  margin-top: 20px;
  display: flex;
  justify-content: space-between;
}
</style>

<실행 결과>

서버 API 배포 고려사항

Nuxt의 서버 API를 배포할 때 고려해야 할 몇 가지 사항이 있습니다:

  • 환경 변수: API 키, 데이터베이스 연결 문자열 등의 민감한 정보는 환경 변수로 관리해야 합니다.
  • CORS 설정: 다른 도메인에서 API에 접근해야 하는 경우 CORS 설정이 필요합니다.
  • 캐싱 전략: 성능 최적화를 위한 적절한 캐싱 전략을 수립해야 합니다.
  • 에러 처리: 프로덕션 환경에서는 자세한 에러 메시지가 노출되지 않도록 주의해야 합니다.
  • 로깅: 문제 해결을 위한 적절한 로깅 시스템을 구축해야 합니다.

정리

이번 포스트에서는 Nuxt.js의 서버 디렉토리를 활용하여 자체 API 엔드포인트를 구축하는 방법에 대해 알아보았습니다. Nitro 서버 엔진을 활용하면 별도의 백엔드 서버 없이도 풀스택 애플리케이션을 개발할 수 있습니다. API 엔드포인트 생성, 서버 미들웨어 활용, 요청/응답 처리 등 다양한 서버 사이드 기능을 익혔습니다.

서버 디렉토리를 활용하면 프론트엔드와 백엔드를 하나의 프로젝트에서 관리할 수 있어 개발 효율성이 크게 향상됩니다. 또한 서버리스 환경에 최적화되어 있어 다양한 호스팅 환경에 쉽게 배포할 수 있습니다.

다음 포스트 미리보기

다음 9회차에서는 “폼(Form) 처리 및 유효성 검사”에 대해 알아볼 예정입니다. 사용자 입력을 처리하고 데이터 유효성을 검사하는 방법을 배우게 됩니다. v-model을 활용한 양방향 바인딩, VeeValidate 등의 라이브러리 연동 방법 등을 다룰 예정이니 많은 기대 부탁드립니다!

그럼 다음 포스트에서 만나요! 😊


게시됨

카테고리

작성자

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다