[Nuxt 3] 사이드 프로젝트 만들기 - 개발 환경 설정편

저번 사이드 프로젝트 만들기 - 기획편의 다음 편입니다. 이번엔 nuxt3의 주요 변경사항 일부를 알아보고, 쾌적한 개발 환경을 위해 몇 가지 세팅을 해보도록 하겠습니다.

일단 새로운 Nuxt3 프로젝트를 생성합시다. 터미널을 열고 아래 명령어를 입력합시다.

저는 웹 애플리케이션이면 프로젝트 이름을 보통 도메인 이름과 매칭해서 만듭니다. www 도메인은 이미 사용 중이니, app.drawbeat.com 이라는 이름으로 만들겠습니다. 여러분들은 아무거나 하셔도 됩니다.

bash
npx nuxi init app.drawbeat.com
cd app.drawbeat.com
yarn && yarn dev -o

image

이렇게 하면 개발 서버가 열리게 되고, nuxt3의 첫 화면이 보이게 됩니다.

image

폴더 구조를 살펴볼까요? nuxt2 와는 다르게 파일이 몇 개 없습니다. 그리고 페이지 렌더링을 위해 필수였던 pages/ 폴더가 사라졌고, app.vue 파일만 있습니다.

nuxt3의 주요 변경점 중 하나는 pages/ 폴더가 옵션이라는 점입니다.

왜 옵션으로 바뀌었을까요? 프로젝트에 따라 페이지 라우팅이 필요 없는 경우, pages/ 폴더를 만들지 않으면 vue-router 패키지를 결과물에 포함시키지 않게되서 용량을 줄일 수 있기 때문입니다. 이제 랜딩 페이지만 필요한 경우엔 굳이 pages/ 폴더를 만들지 않아도 되겠네요.

그리고 tsconfig.json 파일이 있는걸로 봐선 typescript가 이제 기본 언어입니다. 이 기회에 타입스크립트를 사용하지 않으셨던 분들이라면 꼭 도입해보세요. 개발할 때 도움이 많이 됩니다.

근데 저희는 페이지를 여러개 만들거라 라우팅이 꼭 필요합니다. pages/index.vue 파일을 만들어주고, app.vue 파일을 수정해줍시다.

app.vue
<template>
  <div class="text-lg">
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>
pages/index.vue
<template>
  <div>index</div>
</template>

image

웰컴 페이지가 사라지고, pages/index.vue 파일을 잘 읽고 있네요.

<NuxtPage> 태그는 pages/ 폴더를 가져와서 렌더링한다는 뜻이겠고, <NuxtLayout>은 나중에 layouts/ 폴더를 만들면 그 때 자세히 설명하겠습니다.

여기서 또 하나 추가 변경사항이 있다면 app.vue가 모든 페이지의 진입점이 되는 컴포넌트 역할을 하기 때문에 전역적으로 js 파일이나 css 파일을 적용하고 싶다면 app.vue 에 적용해도 됩니다.

기존에는 전역으로 적용되는 코드를 넣기 위해 nuxt.config 파일에 객체 형식으로 CDN 링크 등을 넣어주거나 plugin 을 사용하기도 했었는데, 이 부분은 .vue 파일을 이용해 컴포넌트를 활용한다는 점에서 기존보다 조금 더 일관적으로 프로젝트를 관리할 수 있어서 좋아진 것 같습니다.

그러면 라우팅이 잘 되는지 확인을 위해 로그인 페이지도 만들어보고, 라우팅을 위한 a 태그도 만들어보겠습니다.

pages/login/index.vue
  <template>
    <div>
      <nav>
        <NuxtLink to="/">Go to index</NuxtLink>
      </nav>

      <div>login</div>
    </div>
  </template>
pages/index.vue
  <template>
    <div>
      <nav>
        <NuxtLink to="/login">Go to login</NuxtLink>
      </nav>

      <div>index</div>
    </div>
  </template>

image image

링크를 눌러보면 페이지 이동도 잘 되고, /login URL에서 새로고침을 하더라도 페이지가 잘 렌더링됩니다.

<NuxtLink>nuxt 에서 기본으로 내장되어있는 페이지 라우팅용 컴포넌트입니다. 웹사이트 내부 페이지 이동을 위해 <a> 태그 대신 사용하시면 됩니다.

참고로 페이지 내부 이동을 <a> 태그 대신 <NuxtLink> 컴포넌트를 사용하는 이유는 라우팅이 훨씬 빠르기 때문입니다. 페이지 전체를 다시 불러오지 않고, 바뀐 부분만 렌더링하기 때문입니다.

그럼 동적 페이지를 만드려면 어떻게 하면 될까요? 여기서도 nuxt3의 변경사항이 있습니다.

/blog/1, /blog/2 ... 같이 동적 URL 파라미터 값을 사용하려면 기존에는 폴더명 맨 앞에 _ 기호를 이용했었습니다. 예를 들어 이렇게요.

nuxt2
proejct/
└── pages/
    └── _blogId/
        └── index.vue

하지만 nuxt3에서는 이렇게 사용합니다.

nuxt3
proejct/
└── pages/
    └── [blogId]/
        └── index.vue

nuxt3 에서만 사용가능한 방법도 새로 추가됐는데요, [blogId] 말고 blog-[id] 이런식으로 파라미터의 일부 텍스트만 동적으로 받을 수도 있습니다.

동적 path 파라미터 값을 가져올 땐 기존과 똑같습니다.

vue
<template>
  <!-- [blogId] 인 경우 -->
  {{ $route.params.blogId }}

  <!-- blog-[id] 인 경우 -->
  {{ $route.params.id }}
</template>

쉽죠? nuxt3쪽이 훨씬 더 가독성이 좋습니다. 아 참고로 이제는 <template>쪽에서 $route를 사용하는 건 자제하는게 좋습니다. 이유는 나중에 Vue3에서 새롭게 추가된 Composition API를 사용할 때 알려드릴게요.

image image image

거의 대부분 웹사이트가 가장 상단에 주요 페이지들로 이동할 수 있는 링크들을 배치하고 있습니다. 국룰인 것 같으니 따라하면 됩니다.

크게 나누면 왼쪽엔 서비스 로고, 오른쪽엔 링크를 배치하네요. 보통 이 네비게이션 바는 모든 페이지에서 동일하게 보여지니까 컴포넌트로 만들면 좋겠다는 생각이 듭니다.

nuxtcomponents/ 폴더 아래에 존재하는 모든 폴더, 파일을 자동으로 import합니다. 사실 이게 좋은건지는 의문이긴 한데, 실제로 사용해보면 편하기는 합니다. 왜 좋은지 의문이라고 하냐면 이런식으로 프레임워크가 다른 코드를 불러오는 과정을 자동으로 처리해버리면 나중에 프레임워크 이해도가 없는 사람이 코드를 봤을 때 왜 import 없이 렌더링되지? 이런식으로 직관적으로 이해가 안갈 수 있기 때문입니다. 이거 하나는 괜찮을 수 있지만 다른 부분을 계속해서 프레임워크가 자동으로 처리해주는 부분이 많아지면 프로젝트 유지보수가 힘들어지기 때문입니다.

components/ 폴더를 만들고, NavigationBar 라는 이름의 폴더도 만들어주겠습니다.

components/NavigationBar/index.vue
<template>
  <nav>
    <NuxtLink to="/">Home</NuxtLink>
    <NuxtLink to="/login">Login</NuxtLink>
  </nav>
</template>

그리고 랜딩 페이지랑 로그인 페이지에서 방금 만든 네비게이션 바 컴포넌트를 불러주세요.

pages/index.vue
  <template>
    <div>
      <NavigationBar />

      <div>index</div>
    </div>
  </template>
pages/login/index.vue
  <template>
    <div>
     <NavigationBar />

      <div>login</div>
    </div>
  </template>

image image

NavigationBar가 잘 렌더링 됐습니다.

근데 한 가지 문제가 있습니다. 지금대로라면 새로운 페이지가 늘어날 때 마다 <NavigationBar/> 를 계속해서 페이지 상단에 불러야합니다. 어떻게 하면 코드 중복을 피할 수 있을까요?

이건 nuxtlayouts 폴더를 활용하면 쉽게 해결 가능합니다. layouts/ 폴더를 만들고, default.vue 파일을 만들어주세요.

layouts/default.vue
<template>
  <div>
    <NavigationBar />

    <slot />
  </div>
</template>

image

이렇게 layouts 폴더를 만드는 것만으로도 default 레이아웃이 전역으로 적용됩니다. 사실 이건 app.vue 에서 <NuxtLayout> 태그를 이미 감싸줘서 그렇습니다.

<slot> 태그는 일단 <NuxtPage> 태그랑 역할이 비슷하다고 생각하시면 됩니다.

layouts/ 폴더에서 네비게이션 바를 불러오고 있으니 이제 pages/ 폴더 밑에 있는 파일에선 <NavigationBar/> 를 지워주세요.

pages/index.vue
 <template>
   <div>
     <NavigationBar />

     <div>index</div>
   </div>
 </template>
pages/login/index.vue
 <template>
   <div>
     <NavigationBar />

     <div>login</div>
   </div>
 </template>

Tailwind CSS

본격적으로 css 를 사용하기 전에 스타일링을 더 편하고 이쁘게 만들어 줄 패키지인 tailwindcss를 먼저 설치하도록 하겠습니다.

bash
yarn add -D tailwindcss postcss@latest autoprefixer@latest @nuxt/postcss8
npx tailwindcss init

이러면 tailwind.config.js 파일이 생기게 됩니다. 그 다음 tailwindcss 를 사용하기위해 assets/ 폴더 아래 css/ 폴더 아래 tailwind.css 파일까지 만들어주세요.

assets/css/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;

다음은 nuxt.config.ts 파일을 수정해줄게요.

nuxt.config.ts
import { defineNuxtConfig } from 'nuxt3'

export default defineNuxtConfig({
  css: ['~/assets/css/tailwind.css'],
  build: {
    postcss: {
      postcssOptions: {
        plugins: {
          tailwindcss: {},
          autoprefixer: {},
        },
      },
    },
  },
})

그리고 마지막으로 tailwind.config.js 파일을 수정해주면 끝입니다.

tailwind.config.js
module.exports = {
  content: [
    './components/**/*.{js,vue,ts}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './plugins/**/*.{js,ts}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

잘 적용됐는지 확인하기위해 pages/index.vue 를 조금 수정합시다.

pages/index.vue
<template>
  <div>
    <!-- font-weight: bold; font-size: 1.875rem; -->
    <h1 class="font-bold text-3xl">Home</h1>
  </div>
</template>

image

tailwindcss 가 잘 적용됐네요! tailwindcss 는 이런식으로 미리 만들어진 클래스를 이용해 css 작성없이 스타일링을 적용하는 라이브러리입니다.

그럼 네비게이션 바를 스타일링 하겠습니다.

components/NavigationBar/index.vue
<template>
  <nav>
    <div class="flex justify-between">
      <NuxtLink to="/">
        <img
          src="https://pbsmtipexzqvbentyzuw.supabase.co/storage/v1/object/public/drawbeat.com/public/logo1.svg"
          alt="app.drawbeat.com"
          class="w-[180px]"
        />
      </NuxtLink>

      <ul>
        <li>
          <NuxtLink to="/login">Login</NuxtLink>
        </li>
      </ul>
    </div>
  </nav>
</template>

image

참고로 tailwind 에서 인라인 커스텀 스타일링을 사용하고 싶으면 w-[180px] 처럼 대괄호로 감싸서 사용할 수도 있습니다.

로고를 누르면 홈으로 가도록 <NuxtLink> 태그로 감싸주었고, 로그인 페이지로 가는 메뉴는 오른쪽에 붙여서 배치했습니다. 아까 참고한거랑 구조는 비슷해졌죠?

통신

다음으로 설정해볼 건 HTTP Request 입니다. 보통 다른 서버에 데이터나 작업을 요청하기 위해 사용합니다. 보통은 axios를 사용하는데, nuxt3는 내장된 useFetch() 함수가 있습니다.

문서를 살펴보니 이 fetch 함수는 ohmyfetch를 사용하더라구요.

ohmyfetch
// ESM / Typescript
import { $fetch } from 'ohmyfetch'

// CommonJS
const { $fetch } = require('ohmyfetch')

저도 처음봤습니다. 브라우저랑 노드에서 둘 다 사용 가능하다고 합니다. axios에 비해 어떤 이점이 있는지 문서를 읽어봤는데 크게 어떤 이점이 있는지는 잘 모르겠습니다. 하나 꼽자면 타입스크립트 친화적이라는 점 정도 있을 것 같습니다.

nuxt3 에서는 이렇게 사용하면 됩니다.

nuxt3
<script setup lang="ts">
const { data, error, pending, refresh } = await useFetch('https://...')
</script>

역시 프레임워크답게 여러가지 편의 기능을 많이 제공하고 있습니다.

  • data: HTTP 응답 데이터
  • error? HTTP 요청 에러 데이터
  • pending: Boolean: 요청에 대한 응답을 기다리고 있는지 여부를 가지고 있습니다.
  • refresh: (force?: Boolean) => Promise<void>: 같은 요청을 새로 보내고 싶을 때 컴포넌트 내에서 refresh() 하면 요청을 또 보낼 수 있습니다.

pending 은 생각보다 엄청 코드량을 줄여줍니다.

nuxt3
<template>
  <div>
    <h1 class="text-3xl font-bold">Home</h1>
    <div v-if="pending">Loading..</div>
    <template v-else>
      <div v-if="error">Sorry, error occured.</div>
      <div v-else>{{ data }}</div>
    </template>

    <button @click="refresh()">Refresh</button>
  </div>
</template>

<script setup lang="ts">
const { data, pending, error, refresh } = await useFetch(
  'https://jsonplaceholder.typicode.com/todos/1'
)
</script>

프론트엔드에서 HTTP 요청을 보낼 땐 각 상황에 맞는 올바른 UI가 필요합니다. 응답 대기 중에는 로딩이라고 표시하고, 응답이 왔지만 에러가 발생했다면 에러라고 보여줘야하겠죠. 에러가 없다면 그 때서야 원하는 데이터를 보여줄 수 있습니다.

이렇게 nuxt3 에서는 스크립트 한 줄로 응답 대기, 에러와 재요청을 모두 처리 가능합니다. 참 편합니다.

근데 이 useFetch 함수의 설정을 전역으로 설정하고 싶을 수 있습니다.

그 전에 알아야 할 개념이 있는데요, 일단 use- 어쩌구로 시작하는거는 전부 vue3composition API 입니다. Reacthooks와 같은 개념입니다. 이 둘의 핵심은 프레임워크 기능을 모듈화가 가능하다는 점입니다.

뭐 예를들면 vuemounted() 같은 건 컴포넌트 내에서만 사용 가능했는데, 일부 컴포넌트가 마운트 때 마다 로그를 찍어주고 싶으면 매 컴포넌트마다 mounted 훅에 로그를 찍었어야 했습니다. 하지만 composition 이라는 이름의 기능이 생기면서 이런 mounted 같은 함수를 모듈화해서 재사용이 가능하도록 만들어줬습니다.

어쨌든 useFetchuse-로 시작하는걸로 봐선 composition이고, nuxt3 에서는 이런 커스텀 composition을 만드려면 composables/ 폴더 아래에 파일을 생성하면 자동으로 만들어주도록 되어있습니다. 프로젝트 루트에 composables/ 폴더를 만들고, useApi.ts 파일을 만들어주세요. 이 역시 컴포넌트 폴더처럼 파일 이름을 읽어서 자동으로 전역으로 import 해줍니다.

composables/useApi.ts
export default (url: string) => {
  return useFetch(url, {
    baseURL: 'https://api.example.com',
    onRequest: (context) => {
      const isDev = process.env.NODE_ENV === 'development'
      if (isDev) {
        // 이 부분은 왜 이렇게밖에 못하는지 모르겠는데, 차후 개선이 되면 좋겠네요.
        // 참고: https://github.com/nuxt/framework/issues/2557#issuecomment-1003865620
        context.options.headers = new Headers(context.options.headers)
        context.options.headers.append('Authrization', 'Bearer TOKEN_FOR_DEV')
      }

      return null
    },
  })
}

이렇게 해주고 프로젝트를 재시작하면 아래처럼 커스텀 composition을 전역에서 사용이 가능합니다.

nuxt3
  <script setup lang="ts">
  const { data, pending, error, refresh } = await useApi('/todos/1')
  const { data, pending, error, refresh } = await useFetch(
    'https://jsonplaceholder.typicode.com/todos/1'
  )
  </script>

아까 컴포넌트쪽에서 말했듯이 useFetch 같은건 nuxt 프레임워크에서 전역으로 사용 가능하니까 이대로도 import 오류가 발생하진 않습니다.

상태 관리

모든 페이지, 컴포넌트에서 같은 데이터를 참조하고 싶을 수 있습니다. 대표적으로 로그인을 한 유저의 정보를 어디서든 가져오고 싶은 경우죠. 기존에는 Vuex를 공식 라이브러리로 사용했지만, 이제는 레거시가 되었습니다.

새로운 뷰 코어 팀이 준비한 상태 관리 라이브러리는 바로 Pinia 입니다. Vuex도 이미 너무 잘 만든 상태 관리 라이브러리지만 새로 만든 이유는 공식 문서에 따르면 다음과 같습니다.

Compared to Vuex, Pinia provides a simpler API with less ceremony, offers Composition-API-style APIs, and most importantly, has solid type inference support when used with TypeScript.

요약하자면 컴포지션 API와 타입스크립트를 더 잘 지원하기 위함입니다. 직접 사용해보니까 더 사용하기 편리한 것도 맞고, 타입스크립트 친화적인 것도 맞습니다. 기존에도 좋았는데 지금은 더 좋아졌습니다. 도입하지 않을 이유가 없습니다. 간단하게 사용하기 위해 프로젝트 루트 폴더에 stores/ 폴더를 만들고 user.ts 파일을 만들어줍니다.

Pinia의 주요 변경 사항 중 하나는 기존에는 데이터에 변화를 줄 때 비동기 여부에 따라 actionscommit 함수를 나누어 사용했는데, 이제는 actions에 모두 통합되었습니다. 그거 말고는 똑같습니다.

stores/user.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
  }),
  getters: {
    doubleCount: (state) => state.user,
  },
  actions: {
    save(user?: any) {
      this.user = user
    },
  },
})

사용하는 방법도 조금 다른데요, 기존에는 최상위 store 객체를 가져와서 미리 정의된 고유한 문자열을 키로 삼아 전역 상태 값을 가져오거나 변경했었습니다.

이제는 타입스크립트를 통해 완벽한 자동완성을 지원받을 수 있고, 각 모듈화된 store를 개별적으로 가져오면 됩니다. 이건 정말 좋아진 것 같습니다.

pages/index.vue
  <template>
    <div>
      <h1 class="font-bold text-3xl">Home</h1>
      <div v-if="pending">Loading..</div>
      <template v-else>
        <div v-if="error">Sorry, error occured.</div>
        <div v-else>{{ data }}</div>
      </template>

      <div>{{ userStore.user }}</div>

      <button @click="refresh()">Refresh</button>
      <button @click="userStore.save({ email: 'peterkimzz69@gmail.com' })">Increment</button>
    </div>
  </template>
pages/index.vue
  <script setup lang="ts">
  import { useUserStore } from '~~/stores/users'

  const { data, pending, error, refresh } = await useApi('/todos/1')

  const userStore = useUserStore()
  </script>

마무리

이번 글에서는 스타일링 도구인 tailwindcss와 HTTP 통신을 위한 ohmyfetch에 대해 알아봤습니다. 그리고 nuxt3의 주요 변경점도 알아봤습니다.

이 정도면 저희가 하고싶은 개발 환경 설정은 끝이 났습니다. 이것만으로도 적당히 작동하는 앱을 충분히 만들 수 있습니다. 별거 없죠? 요샌 프레임워크들이 편의성을 너무 잘 제공해주고 있어서 창작자들이 개발에 쓰는 시간을 줄여줘서 너무 좋은 것 같습니다. 그 시간 아껴서 고객들이 겪는 문제점을 해결하는데 시간을 더 쓰면 좋겠네요.

최근 글

[Nuxt 3] 사이드 프로젝트 만들기 - 기획편

올해 첫 개발 관련 주제를 뭘로할까 고민하다가 사이드 프로젝트 아이디어가 떠올라서 그걸 같이 만들어볼까 합니다. 하지만 이미 잘 알고 있던 기술을 사용해서 만들면 재미없겠죠. 사이드 프로젝트는 역시 신기술을 이용해서 만드는게 가장 좋습니다. 나만의 프로젝트를 만들면서 최신 기술도 마음껏 써볼 수 있으니까요. 신기술로 알아볼 것은 바로 Nuxt 3입니다. 아직 베타 버전이기는 하지만, 클라우드 플랫폼인 Vercel과 조합해서 사용해보니 아주 쉽게 서버 없이 서버 사이드 렌더링을 구현할 수가 있더라구요. 서버 없이 서버 사이드 렌더링을 한다는 게 이해가 안가실 수도 있어서 간단히 설명해드리면, 그냥 서버리스 함수 1개로 기존 서버를 대체한다는 말입니다. 뭐 이해 안가셔도 괜찮습니다.