dev.syw

Nuxt 모듈 작성, 플러그인 실행 순서, 빌드/런타임 훅으로 프레임워크 확장.

모듈·플러그인·런타임 훅으로 확장하기

입문편에서 자동 임포트와 composable을 통해 애플리케이션 코드를 구조화하는 방법을 배웠다면, 이제는 한 단계 더 깊이 들어가 프레임워크 자체를 확장하는 방법을 다룰 차례입니다. Nuxt의 진정한 강점은 @nuxt/kit이 제공하는 빌드 타임 훅, 모듈 시스템, 플러그인 레이어를 통해 개발자가 프레임워크 동작을 선언적으로 제어할 수 있다는 점에 있습니다.

이 강에서는 플러그인의 실행 흐름부터 커스텀 모듈 작성, 훅 시스템의 구조, 그리고 런타임 설정 관리까지 — 실무에서 Nuxt 기반 라이브러리를 개발하거나 팀 내 공통 인프라를 구축할 때 반드시 알아야 할 심화 내용을 체계적으로 살펴봅니다.

학습 목표

  • 플러그인의 실행 순서dependsOn, provide/inject 패턴을 이해하고 올바르게 활용할 수 있다.
  • 서버/클라이언트 전용 플러그인의 파일 접미사 규칙과 실행 환경 분리 전략을 설명할 수 있다.
  • @nuxt/kitaddPlugin, addComponent, addImports 등을 사용해 커스텀 Nuxt 모듈을 작성할 수 있다.
  • 빌드 타임 훅런타임 훅의 차이를 이해하고 적절한 상황에 각각을 적용할 수 있다.
  • runtimeConfigapp.config의 사용 구분 기준과 보안 고려 사항을 설명할 수 있다.

플러그인의 실행 순서, dependsOn, provide/inject

Nuxt 플러그인은 plugins/ 디렉터리에 위치하며, 파일명의 알파벳 순서에 따라 자동으로 등록됩니다. 그러나 알파벳 순서에만 의존하면 플러그인 간 의존 관계를 명확하게 표현하기 어렵습니다. 이를 해결하기 위해 Nuxt 3는 defineNuxtPlugindependsOn 옵션을 제공합니다.

// plugins/01.logger.ts
export default defineNuxtPlugin({
  name: 'logger',
  setup(nuxtApp) {
    const logger = {
      log: (msg: string) => console.log(`[APP] ${msg}`),
      error: (msg: string) => console.error(`[APP ERROR] ${msg}`),
    }
    // provide로 등록 → useNuxtApp().$logger 로 접근 가능
    return { provide: { logger } }
  },
})
// plugins/02.api.ts
export default defineNuxtPlugin({
  name: 'api',
  // 'logger' 플러그인이 먼저 초기화되어야 함을 명시
  dependsOn: ['logger'],
  setup(nuxtApp) {
    const { $logger } = nuxtApp

    const api = {
      get: async (url: string) => {
        $logger.log(`GET ${url}`)
        return $fetch(url)
      },
    }
    return { provide: { api } }
  },
})

dependsOn에 나열된 플러그인 이름들은 반드시 해당 플러그인의 name 필드와 일치해야 합니다. 이름이 없는 플러그인은 dependsOn으로 참조할 수 없습니다.

provide로 반환한 값은 Vue 애플리케이션 전체에 inject되어, 컴포넌트 내에서 useNuxtApp() 헬퍼나 $ 접두어를 통해 접근할 수 있습니다.

<script setup lang="ts">
// composable 스타일로 접근
const { $logger, $api } = useNuxtApp()

// 또는 컴포넌트 Options API에서는 this.$logger
const data = await $api.get('/api/users')
$logger.log('데이터 로드 완료')
</script>

⚠️ 주의 provide로 노출된 값은 서버 렌더링 시 요청마다 새로운 인스턴스를 공유합니다. 요청 간 상태가 섞이지 않도록 플러그인 내부에서 전역 가변 상태(싱글턴 패턴)를 사용할 때는 서버 환경을 고려해야 합니다.

서버/클라이언트 전용 플러그인과 파일 접미사

Nuxt는 파일명 접미사를 통해 플러그인의 실행 환경을 선택적으로 제한할 수 있습니다.

파일명 패턴실행 환경
plugins/foo.ts서버 + 클라이언트 양쪽
plugins/foo.server.ts서버(SSR) 전용
plugins/foo.client.ts클라이언트(브라우저) 전용

이 메커니즘을 활용하면 브라우저 전용 API(예: localStorage, window)나 서버 전용 리소스(예: 데이터베이스 연결)에 안전하게 접근할 수 있습니다.

// plugins/analytics.client.ts — 브라우저에서만 실행
export default defineNuxtPlugin(() => {
  // window 객체 접근이 안전함
  window.addEventListener('unhandledrejection', (event) => {
    console.error('미처리 Promise 오류:', event.reason)
  })

  // 서드파티 분석 스크립트 초기화
  const analytics = {
    track: (event: string, props?: Record<string, unknown>) => {
      // 실제 환경에서는 gtag, mixpanel 등을 호출
      console.log('[Analytics]', event, props)
    },
  }
  return { provide: { analytics } }
})
// plugins/db-health.server.ts — 서버에서만 실행
export default defineNuxtPlugin(async () => {
  // 서버 사이드에서 DB 연결 상태 확인
  try {
    // 가상의 DB ping
    await $fetch('/api/_health/db')
    console.log('[Server] DB 연결 정상')
  } catch {
    console.error('[Server] DB 연결 실패 — 애플리케이션을 점검하세요.')
  }
})

💡 TIP 클라이언트 전용 플러그인에서 provide한 값은 서버에서는 provide 자체가 실행되지 않아 해당 키가 주입되지 않으므로, 서버 측 컴포넌트 코드에서 nuxtApp.$xxx 접근 시 undefined입니다. 따라서 서버에서도 호출될 수 있는 composable이나 컴포넌트에서 이를 사용할 때는 반드시 if (process.client) 가드나 optional chaining($analytics?.track(...))을 사용해야 합니다.

// ✅ 클라이언트 전용 provide 값의 안전한 사용
const { $analytics } = useNuxtApp()
if (import.meta.client) {
  $analytics.track('page_view', { path: route.path })
}

// ❌ SSR 중 런타임 오류 발생 가능
$analytics.track('page_view') // 서버에서 $analytics가 undefined

@nuxt/kit으로 커스텀 모듈 만들기

Nuxt 모듈은 defineNuxtModule로 정의하는 함수로, 빌드 타임에 한 번 실행됩니다. 모듈 안에서 @nuxt/kit의 헬퍼들을 사용해 플러그인, 컴포넌트, 자동 임포트 등을 프로그래밍 방식으로 등록할 수 있습니다.

// modules/my-ui/index.ts
import {
  defineNuxtModule,
  addPlugin,
  addComponent,
  addImports,
  createResolver,
} from '@nuxt/kit'

export interface ModuleOptions {
  prefix: string
  theme: 'light' | 'dark'
}

export default defineNuxtModule<ModuleOptions>({
  meta: {
    name: 'my-ui',
    configKey: 'myUi',      // nuxt.config.ts에서 myUi: { ... }로 설정
    compatibility: { nuxt: '^3.0.0' },
  },
  defaults: {
    prefix: 'My',
    theme: 'light',
  },
  setup(options, nuxt) {
    // 모듈 자신의 디렉터리를 기준으로 절대 경로를 생성
    const resolver = createResolver(import.meta.url)

    // 1) 플러그인 등록
    addPlugin(resolver.resolve('./runtime/plugin'))

    // 2) 컴포넌트 등록 (접두어 적용)
    addComponent({
      name: `${options.prefix}Button`,          // MyButton
      filePath: resolver.resolve('./runtime/components/Button.vue'),
    })
    addComponent({
      name: `${options.prefix}Modal`,           // MyModal
      filePath: resolver.resolve('./runtime/components/Modal.vue'),
    })

    // 3) Composable 자동 임포트 등록
    addImports({
      name: 'useMyUi',
      as: 'useMyUi',
      from: resolver.resolve('./runtime/composables/useMyUi'),
    })

    // 4) 모듈 옵션을 런타임에서도 접근 가능하도록 runtimeConfig에 노출
    nuxt.options.runtimeConfig.public.myUiTheme = options.theme
  },
})

createResolverimport.meta.url 기준의 절대 경로 해석기를 반환합니다. 이를 사용하지 않으면 빌드 환경에 따라 경로가 달라지는 문제가 발생할 수 있습니다.

모듈의 런타임 플러그인은 빌드 후에도 최종 번들에 포함됩니다. 플러그인 파일도 일반 플러그인과 동일한 방식으로 작성합니다.

// modules/my-ui/runtime/plugin.ts
export default defineNuxtPlugin((nuxtApp) => {
  const theme = useRuntimeConfig().public.myUiTheme
  console.log(`[my-ui] 테마 초기화: ${theme}`)
})

nuxt.config.ts에서 로컬 모듈은 경로로, npm 게시 모듈은 패키지명으로 등록합니다.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    './modules/my-ui',   // 로컬 모듈
    '@nuxtjs/tailwindcss', // npm 모듈
  ],
  myUi: {
    prefix: 'Ds',        // DsButton, DsModal
    theme: 'dark',
  },
})

빌드 타임 훅과 런타임 훅의 차이

Nuxt는 두 가지 서로 다른 훅 시스템을 제공합니다. 이 둘을 혼동하면 코드가 예상치 못한 시점에 실행되거나 전혀 실행되지 않는 문제가 생깁니다.

**빌드 타임 훅 (Nuxt hooks)**은 nuxt.config.ts 또는 모듈의 setup() 안에서 nuxt.hook()으로 등록하며, nuxi build / nuxi dev 실행 중 Node.js 프로세스에서 한 번만 호출됩니다.

// nuxt.config.ts — 빌드 훅 등록
export default defineNuxtConfig({
  hooks: {
    // Vite 설정을 프로그래밍 방식으로 수정
    'vite:extendConfig'(config, { isClient }) {
      if (isClient) {
        config.resolve!.alias = {
          ...config.resolve!.alias,
          'lodash': 'lodash-es',   // 트리 쉐이킹을 위해 교체
        }
      }
    },
    // 빌드 완료 후 훅
    'build:done'(builder) {
      console.log('빌드 완료:', builder.nuxt.options.buildDir)
    },
    // 라우트 생성 시 훅 — 동적으로 라우트 추가/수정 가능
    'pages:extend'(pages) {
      // 페이지 라우트를 프로그래밍 방식으로 추가
      pages.push({
        name: 'docs-catch-all',
        path: '/docs/:slug(.*)*',
        file: '~/pages/docs/[...slug].vue',
      })
    },
  },
  // 리다이렉트는 meta가 아니라 routeRules로 처리해야 동작합니다.
  // (meta는 임의 데이터 저장소일 뿐, Nuxt/Vue Router가 meta.redirect를 보고 리다이렉트하지 않습니다.)
  routeRules: {
    '/old-path': { redirect: '/' },
  },
})

**런타임 훅 (Nuxt App hooks / Nitro hooks)**은 브라우저나 서버(Nitro) 런타임에서 실행되며, 플러그인 안에서 nuxtApp.hook() 또는 서버 플러그인에서 Nitro의 훅 시스템으로 등록합니다.

// plugins/runtime-hooks.ts — 런타임 훅
export default defineNuxtPlugin((nuxtApp) => {
  // 페이지 전환 전 호출
  nuxtApp.hook('page:start', () => {
    console.log('페이지 전환 시작')
  })

  // Vue 앱 에러 캐처
  nuxtApp.hook('vue:error', (error, instance, info) => {
    console.error('Vue 오류:', error, info)
    // Sentry 등 에러 트래킹 서비스로 전송 가능
  })

  // SSR payload가 클라이언트에 도착했을 때
  nuxtApp.hook('app:mounted', () => {
    console.log('앱이 마운트되었습니다')
  })
})
// server/plugins/nitro-hook.ts — Nitro 런타임 훅
export default defineNitroPlugin((nitroApp) => {
  // 모든 서버 응답 후처리
  nitroApp.hooks.hook('render:response', (response) => {
    response.headers['X-Powered-By'] = 'MyApp/1.0'
  })

  // 에러 핸들링
  nitroApp.hooks.hook('error', (error) => {
    console.error('[Nitro] 처리되지 않은 오류:', error)
  })
})
구분Nuxt 빌드 훅Nuxt 앱 런타임 훅Nitro 훅
실행 시점nuxi dev/build브라우저·SSR 런타임Nitro 서버 런타임
등록 위치nuxt.config.ts, 모듈 setup()plugins/server/plugins/
APInuxt.hook()nuxtApp.hook()nitroApp.hooks.hook()
재실행빌드 시 1회요청/이벤트마다요청/이벤트마다

⚠️ 주의 빌드 훅 안에서 import한 런타임 의존성(예: 클라이언트 라이브러리)은 번들에 포함되지 않습니다. 빌드 훅은 순수하게 Nuxt/Vite 설정을 조작하는 용도로만 사용해야 합니다.

runtimeConfig와 app.config의 사용 구분 및 보안

이 두 가지는 모두 "설정을 런타임에 노출"하는 메커니즘이지만, 목적과 특성이 명확히 다릅니다.

runtimeConfig 는 환경 변수로 재정의할 수 있는 서버/클라이언트 설정 공간입니다. public 하위에 넣은 값만 클라이언트(브라우저)에 노출되며, 루트 레벨 값은 서버에서만 접근 가능합니다.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // ✅ 서버에서만 접근 가능 (클라이언트 번들에 포함 안 됨)
    databaseUrl: process.env.DATABASE_URL ?? '',
    apiSecret: process.env.API_SECRET ?? '',

    public: {
      // ✅ 클라이언트와 서버 양쪽에서 접근 가능
      apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'https://api.example.com',
      appVersion: '1.2.0',
    },
  },
})

환경 변수 재정의 규칙: NUXT_ 접두어 후 대문자+언더스코어로 변환한 키로 .env 파일 또는 프로세스 환경 변수에서 재정의됩니다.

# .env
NUXT_DATABASE_URL=postgresql://prod-server/mydb
NUXT_API_SECRET=super-secret-key
NUXT_PUBLIC_API_BASE_URL=https://api.prod.example.com

app.config 는 빌드 타임에 고정되는 공개 설정 공간으로, 환경 변수로 재정의할 수 없습니다. 테마 토큰, 기능 플래그, UI 설정처럼 배포 후 변경되지 않는 값에 적합합니다.

// app.config.ts
export default defineAppConfig({
  ui: {
    primary: 'indigo',
    gray: 'slate',
  },
  featureFlags: {
    newDashboard: true,
    betaEditor: false,
  },
})

컴포넌트나 composable에서는 useAppConfig()로 접근하며, 반응형으로 동작합니다(개발 중 HMR 지원).

<script setup lang="ts">
const appConfig = useAppConfig()
// appConfig.ui.primary → 'indigo'

const config = useRuntimeConfig()
// config.public.apiBaseUrl → 서버+클라이언트 가능
// config.apiSecret → 서버에서만 접근 가능, 클라이언트에서는 undefined
</script>

⚠️ 주의 runtimeConfig의 루트 레벨(non-public) 값은 절대로 컴포넌트에서 직접 사용하면 안 됩니다. 이 값들은 서버 API 라우트(server/api/)나 서버 플러그인(server/plugins/)에서만 사용해야 합니다. Nuxt는 SSR 페이로드에 이 값을 포함하지 않지만, 개발자가 실수로 provideuseState에 담아 클라이언트로 노출시킬 위험이 있습니다.

두 설정의 주요 차이를 정리하면 다음과 같습니다.

항목runtimeConfigapp.config
변경 가능 시점런타임(환경 변수)빌드 타임 고정
클라이언트 노출public 키만전체 (민감 정보 금지)
서버 전용 값루트 레벨 가능불가
주요 용도API 키, DB URL, 엔드포인트테마, 기능 플래그, UI 설정
반응형아니오예 (HMR)

모듈에서 타입 자동 생성 — addTypeTemplate

@nuxt/kitaddTypeTemplate을 사용하면 모듈이 등록한 플러그인, composable, 설정 등에 대한 TypeScript 타입을 빌드 타임에 자동으로 생성할 수 있습니다. 이는 라이브러리 소비자의 DX(개발자 경험)를 크게 향상시킵니다.

// modules/my-ui/index.ts (위 예제에서 이어서)
import {
  defineNuxtModule,
  addPlugin,
  addTypeTemplate,
  createResolver,
} from '@nuxt/kit'

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'my-ui', configKey: 'myUi' },
  defaults: { prefix: 'My', theme: 'light' },
  setup(options, nuxt) {
    const resolver = createResolver(import.meta.url)

    addPlugin(resolver.resolve('./runtime/plugin'))

    // $myUi 플러그인 헬퍼의 타입을 NuxtApp 인터페이스에 병합
    addTypeTemplate({
      filename: 'types/my-ui.d.ts',
      getContents: () => `
// 자동 생성된 파일 — 직접 수정하지 마세요
import type { MyUiInstance } from '${resolver.resolve('./runtime/types')}'

declare module '#app' {
  interface NuxtApp {
    $myUi: MyUiInstance
  }
}

declare module 'vue' {
  interface ComponentCustomProperties {
    $myUi: MyUiInstance
  }
}

export {}
`,
    })

    // runtimeConfig 타입 보강
    addTypeTemplate({
      filename: 'types/my-ui-config.d.ts',
      getContents: () => `
declare module 'nuxt/schema' {
  interface PublicRuntimeConfig {
    myUiTheme: 'light' | 'dark'
  }
}
export {}
`,
    })
  },
})

생성된 타입 파일은 .nuxt/types/ 디렉터리에 저장되며, tsconfig.json.nuxt/tsconfig.json을 확장하는 Nuxt 기본 설정 덕분에 자동으로 TypeScript 프로젝트에 포함됩니다.

// modules/my-ui/runtime/types.ts
export interface MyUiInstance {
  showToast: (message: string, type?: 'info' | 'error' | 'success') => void
  setTheme: (theme: 'light' | 'dark') => void
}

이제 소비자 코드에서 타입 추론이 완벽하게 작동합니다.

// 사용하는 프로젝트의 어느 파일에서든
const { $myUi } = useNuxtApp()
$myUi.showToast('저장되었습니다', 'success')  // ✅ 타입 자동완성 지원
$myUi.showToast('오류', 'warning')             // ❌ 타입 오류: 'warning'은 허용되지 않음

💡 TIP addTypeTemplategetContents는 함수이므로, 모듈 옵션(options)을 클로저로 캡처하여 동적으로 타입을 생성할 수 있습니다. 예를 들어 prefix 옵션에 따라 DsButton, DsModal 같은 컴포넌트 이름을 타입으로 생성할 수 있습니다.

요약

  • 플러그인은 name + dependsOn으로 실행 순서를 명시적으로 제어하고, provide로 앱 전체에 서비스를 주입할 수 있습니다.
  • .server.ts / .client.ts 접미사로 플러그인 실행 환경을 분리하며, 클라이언트 전용 provide 값은 SSR 환경에서 undefined이므로 반드시 가드가 필요합니다.
  • @nuxt/kitaddPlugin, addComponent, addImportsdefineNuxtModule 안에서 사용해 재사용 가능한 Nuxt 모듈을 작성할 수 있습니다.
  • 빌드 훅(nuxt.hook)은 Nuxt/Vite 설정을 조작하는 빌드 타임 도구이고, 런타임 훅(nuxtApp.hook, Nitro hooks.hook)은 요청/이벤트 단위로 실행되는 런타임 도구입니다.
  • runtimeConfig는 환경 변수 재정의가 가능한 서버/클라이언트 설정 공간이며, 민감 정보는 반드시 public 외부(루트 레벨)에 두어야 합니다. app.config는 빌드 타임 고정 공개 설정입니다.
  • addTypeTemplate으로 모듈이 등록한 플러그인·설정의 타입을 자동 생성하면 소비자의 TypeScript 개발 경험이 크게 향상됩니다.

연습문제

  1. plugins/ 디렉터리에 auth 플러그인과 http 플러그인을 작성하되, http 플러그인이 auth에 의존하도록 dependsOn을 설정하고, authprovide한 토큰을 http 플러그인에서 활용하세요.

  2. 브라우저의 localStorage를 이용해 사용자 설정을 저장/불러오는 기능을 클라이언트 전용 플러그인으로 구현하고, SSR 중 안전하게 동작하도록 처리하세요.

  3. @nuxt/kit을 사용하여 간단한 analytics 모듈을 작성하세요. 이 모듈은 trackingId: string 옵션을 받아, 클라이언트 전용 플러그인을 자동으로 등록하고, useAnalytics() composable을 자동 임포트로 등록해야 합니다.

  4. runtimeConfigapp.config를 활용하여 다음 두 가지를 올바른 위치에 설정하세요: (a) 서드파티 API의 시크릿 키, (b) 앱의 UI 테마 색상(primary, secondary). 그리고 각각이 왜 그 위치에 있어야 하는지 코드 주석으로 설명하세요.

힌트 클라이언트 번들에 포함되면 안 되는 값은 runtimeConfig의 루트 레벨에, 배포 후에도 재정의되어야 하는 공개 값은 public에, 빌드 타임 고정 UI 설정은 app.config에 놓는 원칙을 기억하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Nuxt.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.