Nuxt 3 마스터클래스

SQLite + 로그인 세션 인증 + Tailwind/Nuxt UI

(웹 완전 초보도 따라할 수 있는 초상세 교육용 매뉴얼)

이 문서는 “한 번도 웹앱을 끝까지 만들어본 적 없는 사람” 기준으로 작성되었습니다.
목표는 단순 설명이 아니라, 복붙/실행/이해/응용이 모두 가능하도록 만드는 것입니다.


목차

  1. 왜 Nuxt 3인가?
  2. 완전 초보 용어 사전
  3. 오늘 만들 결과물(완성 스펙)
  4. 개발 환경 준비
  5. 프로젝트 생성과 기본 실행
  6. 전체 아키텍처(그림처럼 이해)
  7. 1단계: SQLite DB 연동 (Drizzle ORM)
  8. 2단계: 인증(회원가입/로그인/로그아웃) + 세션 유지
  9. 3단계: 메모(Todo) CRUD를 로그인 사용자 단위로 보호
  10. 4단계: Tailwind + Nuxt UI로 화면 품질 올리기
  11. 페이지 보호(미로그인 차단) 안전하게 하기
  12. 실수하기 쉬운 포인트 20개
  13. 보안 체크리스트(실무 기준)
  14. 배포 전 체크리스트
  15. 부록: 전체 파일 구조 + 명령어 모음

1) 왜 Nuxt 3인가?

Nuxt 3는 Vue 3 기반 풀스택 프레임워크입니다. 초보자가 빠르게 실무형 웹앱을 만들기 좋은 이유는:

  • 폴더 규칙 기반 자동화 (pages, server/api, components)
  • 템플릿/로직/스타일이 명확히 분리된 .vue 파일 구조
  • 서버 API를 같은 프로젝트에서 자연스럽게 작성 가능
  • SSR/SEO/배포 친화적

즉, “설정 싸움”보다 “기능 구현과 학습”에 시간을 쓸 수 있습니다.


2) 완전 초보 용어 사전

2-1. 프론트엔드 / 백엔드

  • 프론트엔드: 사용자 화면(버튼, 입력창, 목록)
  • 백엔드: 서버 로직(DB 저장, 인증 검사)

2-2. API

  • 프론트가 서버에 요청할 때 쓰는 통로
  • 예: /api/auth/login, /api/memos

2-3. 세션(Session)

  • “이 브라우저는 로그인한 사용자다”를 기억하는 서버 측 상태
  • 보통 쿠키에 토큰을 저장

2-4. 쿠키(Cookie)

  • 브라우저에 저장되는 작은 데이터
  • 로그인 상태 유지에 자주 사용

2-5. SQLite

  • 파일 기반 DB
  • 설치/운영이 간단하고 학습용·소규모 서비스에 강함

2-6. ORM

  • SQL을 코드로 다루게 해주는 도구
  • 여기서는 Drizzle ORM 사용

2-7. CRUD

  • Create(생성), Read(조회), Update(수정), Delete(삭제)

3) 오늘 만들 결과물(완성 스펙)

사용자 기능

  • 회원가입
  • 로그인
  • 로그아웃
  • 로그인 유지(새로고침해도 유지)

메모 기능

  • 메모 목록 조회
  • 메모 추가
  • 메모 수정
  • 메모 삭제
  • 사용자별 데이터 분리(내 계정 메모만 보임)

UI

  • Tailwind CSS 기반 스타일
  • Nuxt UI 컴포넌트 사용 (UButton, UInput, UCard, UForm)

4) 개발 환경 준비

4-1. 필수 설치

  • Node.js 20+
  • npm

확인:

node -v
npm -v

4-2. 프로젝트 생성

npx nuxi@latest init nuxt3-masterclass
cd nuxt3-masterclass
npm install
npm run dev

브라우저: http://localhost:3000


5) 패키지 설치 (DB/인증/UI)

npm i better-sqlite3 drizzle-orm bcryptjs zod
npm i -D drizzle-kit @types/better-sqlite3
npm i @nuxt/ui
npm i -D tailwindcss @tailwindcss/vite

설치 이유:

  • better-sqlite3: SQLite 연결
  • drizzle-orm: 타입 안전 DB 처리
  • bcryptjs: 비밀번호 해시
  • zod: 요청 검증
  • @nuxt/ui: 고품질 UI 컴포넌트
  • tailwindcss: 유틸리티 기반 스타일

6) 최종 파일 구조

nuxt3-masterclass/
  assets/
    css/main.css
  pages/
    index.vue
    login.vue
    signup.vue
    memos.vue
  server/
    api/
      auth/
        signup.post.ts
        login.post.ts
        logout.post.ts
        me.get.ts
      memos/
        index.get.ts
        index.post.ts
        [id].patch.ts
        [id].delete.ts
    db/
      client.ts
      schema.ts
    utils/
      password.ts
      session.ts
      auth.ts
  drizzle.config.ts
  nuxt.config.ts
  .env

7) 1단계: SQLite DB 연동 (Drizzle)

7-1. drizzle 설정

drizzle.config.ts

import type { Config } from 'drizzle-kit'

export default {
  schema: './server/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  dbCredentials: {
    url: './app.db',
  },
} satisfies Config

7-2. 스키마 작성

server/db/schema.ts

import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
})

export const memos = sqliteTable('memos', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id').notNull(),
  text: text('text').notNull(),
  done: integer('done', { mode: 'boolean' }).notNull().default(false),
  createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})

설명:

  • users.email.unique: 중복 가입 방지
  • memos.userId: 어떤 사용자 메모인지 구분
  • done: 완료 상태

7-3. DB 클라이언트

server/db/client.ts

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'

const sqlite = new Database('./app.db')
export const db = drizzle(sqlite, { schema })

7-4. 마이그레이션 실행

package.json에 스크립트 추가:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  }
}

실행:

npm run db:generate
npm run db:migrate

결과:

  • app.db 파일 생성
  • 테이블 생성 완료

8) 2단계: 인증 + 세션 유지

8-1. 환경변수

.env

SESSION_SECRET="replace-with-very-long-random-secret"

운영환경에서는 반드시 강한 랜덤 문자열 사용

8-2. 비밀번호 유틸

server/utils/password.ts

import bcrypt from 'bcryptjs'

export function hashPassword(raw: string) {
  return bcrypt.hash(raw, 10)
}

export function verifyPassword(raw: string, hashed: string) {
  return bcrypt.compare(raw, hashed)
}

8-3. 세션 유틸(서명 쿠키 방식)

server/utils/session.ts

import { createHmac, timingSafeEqual } from 'node:crypto'
import { getCookie, setCookie, deleteCookie } from 'h3'

const COOKIE_NAME = 'nuxt_sid'
const secret = process.env.SESSION_SECRET || 'dev-secret-change-me'

function sign(raw: string) {
  return createHmac('sha256', secret).update(raw).digest('hex')
}

function encode(payload: { userId: number; email: string }) {
  const raw = Buffer.from(JSON.stringify(payload)).toString('base64url')
  const sig = sign(raw)
  return `${raw}.${sig}`
}

function decode(token: string) {
  const [raw, sig] = token.split('.')
  if (!raw || !sig) return null

  const expected = sign(raw)
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null

  try {
    return JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as {
      userId: number
      email: string
    }
  } catch {
    return null
  }
}

export function setSession(event: any, payload: { userId: number; email: string }) {
  const token = encode(payload)

  setCookie(event, COOKIE_NAME, token, {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })
}

export function getSession(event: any) {
  const token = getCookie(event, COOKIE_NAME)
  if (!token) return null
  return decode(token)
}

export function clearSession(event: any) {
  deleteCookie(event, COOKIE_NAME, { path: '/' })
}

8-4. 인증 헬퍼

server/utils/auth.ts

import { getSession } from './session'

export function requireUserSession(event: any) {
  const session = getSession(event)
  if (!session) {
    throw createError({ statusCode: 401, statusMessage: '로그인이 필요합니다.' })
  }
  return session
}

8-5. 회원가입 API

server/api/auth/signup.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { users } from '~/server/db/schema'
import { eq } from 'drizzle-orm'
import { hashPassword } from '~/server/utils/password'
import { setSession } from '~/server/utils/session'

const Body = z.object({
  email: z.string().email('이메일 형식이 아닙니다.'),
  password: z.string().min(8, '비밀번호는 최소 8자 이상이어야 합니다.'),
})

export default defineEventHandler(async (event) => {
  const body = Body.parse(await readBody(event))

  const exists = await db
    .select({ id: users.id })
    .from(users)
    .where(eq(users.email, body.email))
    .limit(1)

  if (exists.length > 0) {
    throw createError({ statusCode: 409, statusMessage: '이미 가입된 이메일입니다.' })
  }

  const passwordHash = await hashPassword(body.password)

  const [user] = await db
    .insert(users)
    .values({
      email: body.email,
      passwordHash,
      createdAt: new Date(),
    })
    .returning({ id: users.id, email: users.email })

  setSession(event, { userId: user.id, email: user.email })

  return { ok: true, user }
})

8-6. 로그인 API

server/api/auth/login.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { users } from '~/server/db/schema'
import { eq } from 'drizzle-orm'
import { verifyPassword } from '~/server/utils/password'
import { setSession } from '~/server/utils/session'

const Body = z.object({
  email: z.string().email(),
  password: z.string().min(1),
})

export default defineEventHandler(async (event) => {
  const body = Body.parse(await readBody(event))

  const [user] = await db
    .select({ id: users.id, email: users.email, passwordHash: users.passwordHash })
    .from(users)
    .where(eq(users.email, body.email))
    .limit(1)

  if (!user) {
    throw createError({ statusCode: 401, statusMessage: '이메일/비밀번호가 올바르지 않습니다.' })
  }

  const ok = await verifyPassword(body.password, user.passwordHash)
  if (!ok) {
    throw createError({ statusCode: 401, statusMessage: '이메일/비밀번호가 올바르지 않습니다.' })
  }

  setSession(event, { userId: user.id, email: user.email })

  return { ok: true, user: { id: user.id, email: user.email } }
})

8-7. 로그아웃 API

server/api/auth/logout.post.ts

import { clearSession } from '~/server/utils/session'

export default defineEventHandler((event) => {
  clearSession(event)
  return { ok: true }
})

8-8. 현재 로그인 사용자 조회 API

server/api/auth/me.get.ts

import { getSession } from '~/server/utils/session'

export default defineEventHandler((event) => {
  const session = getSession(event)
  if (!session) return null

  return {
    userId: session.userId,
    email: session.email,
  }
})

9) 3단계: 메모 CRUD를 로그인 사용자 단위로 보호

9-1. 메모 목록 조회

server/api/memos/index.get.ts

import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { eq, desc } from 'drizzle-orm'
import { requireUserSession } from '~/server/utils/auth'

export default defineEventHandler(async (event) => {
  const session = requireUserSession(event)

  return db
    .select()
    .from(memos)
    .where(eq(memos.userId, session.userId))
    .orderBy(desc(memos.id))
})

9-2. 메모 생성

server/api/memos/index.post.ts

import { z } from 'zod'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'

const Body = z.object({
  text: z.string().trim().min(1, '메모를 입력하세요.').max(200),
})

export default defineEventHandler(async (event) => {
  const session = requireUserSession(event)
  const body = Body.parse(await readBody(event))

  const [memo] = await db
    .insert(memos)
    .values({
      userId: session.userId,
      text: body.text,
      done: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    .returning()

  return memo
})

9-3. 메모 수정(완료 토글/텍스트 수정)

server/api/memos/[id].patch.ts

import { z } from 'zod'
import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'

const Body = z.object({
  text: z.string().trim().min(1).max(200).optional(),
  done: z.boolean().optional(),
})

export default defineEventHandler(async (event) => {
  const session = requireUserSession(event)
  const id = Number(getRouterParam(event, 'id'))
  if (!Number.isInteger(id) || id <= 0) {
    throw createError({ statusCode: 400, statusMessage: '잘못된 ID입니다.' })
  }

  const body = Body.parse(await readBody(event))

  await db
    .update(memos)
    .set({
      ...(body.text !== undefined ? { text: body.text } : {}),
      ...(body.done !== undefined ? { done: body.done } : {}),
      updatedAt: new Date(),
    })
    .where(and(eq(memos.id, id), eq(memos.userId, session.userId)))

  return { ok: true }
})

9-4. 메모 삭제

server/api/memos/[id].delete.ts

import { and, eq } from 'drizzle-orm'
import { db } from '~/server/db/client'
import { memos } from '~/server/db/schema'
import { requireUserSession } from '~/server/utils/auth'

export default defineEventHandler(async (event) => {
  const session = requireUserSession(event)
  const id = Number(getRouterParam(event, 'id'))

  if (!Number.isInteger(id) || id <= 0) {
    throw createError({ statusCode: 400, statusMessage: '잘못된 ID입니다.' })
  }

  await db
    .delete(memos)
    .where(and(eq(memos.id, id), eq(memos.userId, session.userId)))

  return { ok: true }
})

핵심 보안 포인트: 어떤 수정/삭제든 userId 조건으로 반드시 제한


10) 4단계: Tailwind + Nuxt UI 적용

10-1. Nuxt 설정

nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
  css: ['~/assets/css/main.css'],
})

10-2. Tailwind CSS 엔트리

assets/css/main.css

@import "tailwindcss";

11) 페이지 구현 (회원가입/로그인/메모)

11-1. 홈

pages/index.vue

<template>
  <main class="mx-auto max-w-3xl p-6">
    <h1 class="text-3xl font-bold">Nuxt 3 SQLite 인증 메모앱</h1>
    <p class="mt-2 text-gray-600">회원가입 후 로그인하여 메모를 관리하세요.</p>

    <div class="mt-6 flex gap-2">
      <UButton to="/signup">회원가입</UButton>
      <UButton to="/login" color="neutral" variant="outline">로그인</UButton>
      <UButton to="/memos" color="primary" variant="soft">메모로 이동</UButton>
    </div>
  </main>
</template>

11-2. 회원가입

pages/signup.vue

<template>
  <main class="min-h-screen bg-gray-50 p-6">
    <div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
      <h1 class="mb-4 text-2xl font-bold">회원가입</h1>

      <UForm :state="state" @submit="onSubmit" class="space-y-4">
        <UFormField label="이메일" name="email">
          <UInput v-model="state.email" type="email" />
        </UFormField>

        <UFormField label="비밀번호 (8자 이상)" name="password">
          <UInput v-model="state.password" type="password" />
        </UFormField>

        <UButton type="submit" block :loading="pending">가입하기</UButton>
      </UForm>

      <p class="mt-4 text-sm">
        이미 계정이 있으신가요?
        <NuxtLink class="text-blue-600" to="/login">로그인</NuxtLink>
      </p>
    </div>
  </main>
</template>

<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const pending = ref(false)

async function onSubmit() {
  try {
    pending.value = true
    await $fetch('/api/auth/signup', { method: 'POST', body: state })
    await navigateTo('/memos')
  } catch (e: any) {
    alert(e?.data?.statusMessage ?? '회원가입 실패')
  } finally {
    pending.value = false
  }
}
</script>

11-3. 로그인

pages/login.vue

<template>
  <main class="min-h-screen bg-gray-50 p-6">
    <div class="mx-auto max-w-md rounded-2xl bg-white p-6 shadow">
      <h1 class="mb-4 text-2xl font-bold">로그인</h1>

      <UForm :state="state" @submit="onSubmit" class="space-y-4">
        <UFormField label="이메일" name="email">
          <UInput v-model="state.email" type="email" />
        </UFormField>

        <UFormField label="비밀번호" name="password">
          <UInput v-model="state.password" type="password" />
        </UFormField>

        <UButton type="submit" block :loading="pending">로그인</UButton>
      </UForm>

      <p class="mt-4 text-sm">
        아직 계정이 없으신가요?
        <NuxtLink class="text-blue-600" to="/signup">회원가입</NuxtLink>
      </p>
    </div>
  </main>
</template>

<script setup lang="ts">
const state = reactive({ email: '', password: '' })
const pending = ref(false)

async function onSubmit() {
  try {
    pending.value = true
    await $fetch('/api/auth/login', { method: 'POST', body: state })
    await navigateTo('/memos')
  } catch (e: any) {
    alert(e?.data?.statusMessage ?? '로그인 실패')
  } finally {
    pending.value = false
  }
}
</script>

11-4. 메모 페이지 (조회/생성/수정/삭제 + 로그아웃)

pages/memos.vue

<template>
  <main class="mx-auto max-w-2xl p-6">
    <div class="mb-6 flex items-center justify-between">
      <div>
        <h1 class="text-2xl font-bold">내 메모</h1>
        <p class="text-sm text-gray-600">{{ me?.email }}</p>
      </div>
      <UButton color="neutral" variant="outline" @click="logout">로그아웃</UButton>
    </div>

    <div class="mb-4 flex gap-2">
      <UInput v-model="newText" placeholder="새 메모를 입력하세요" class="flex-1" />
      <UButton @click="addMemo">추가</UButton>
    </div>

    <div class="space-y-2">
      <UCard v-for="m in memos" :key="m.id">
        <div class="flex items-center gap-2">
          <UCheckbox :model-value="m.done" @update:model-value="(v) => toggleDone(m, !!v)" />

          <UInput
            class="flex-1"
            :model-value="m.text"
            @update:model-value="(v) => updateText(m, String(v))"
          />

          <UButton color="error" variant="soft" size="xs" @click="removeMemo(m.id)">
            삭제
          </UButton>
        </div>
      </UCard>
    </div>
  </main>
</template>

<script setup lang="ts">
type Memo = {
  id: number
  userId: number
  text: string
  done: boolean
  createdAt: string
  updatedAt: string
}

const { data: me } = await useFetch<{ userId: number; email: string } | null>('/api/auth/me')
if (!me.value) {
  await navigateTo('/login')
}

const newText = ref('')
const { data: memos, refresh } = await useFetch<Memo[]>('/api/memos', {
  default: () => [],
})

async function addMemo() {
  if (!newText.value.trim()) return
  await $fetch('/api/memos', { method: 'POST', body: { text: newText.value } })
  newText.value = ''
  await refresh()
}

async function toggleDone(m: Memo, done: boolean) {
  await $fetch(`/api/memos/${m.id}`, { method: 'PATCH', body: { done } })
  await refresh()
}

let updateTimer: any = null
function updateText(m: Memo, text: string) {
  if (updateTimer) clearTimeout(updateTimer)
  updateTimer = setTimeout(async () => {
    await $fetch(`/api/memos/${m.id}`, { method: 'PATCH', body: { text } })
    await refresh()
  }, 400)
}

async function removeMemo(id: number) {
  await $fetch(`/api/memos/${id}`, { method: 'DELETE' })
  await refresh()
}

async function logout() {
  await $fetch('/api/auth/logout', { method: 'POST' })
  await navigateTo('/login')
}
</script>

12) 페이지 보호를 더 안전하게: 라우트 미들웨어

로그인 체크를 페이지마다 수동으로 넣는 대신, 미들웨어로 통일할 수 있습니다.

middleware/auth.ts

export default defineNuxtRouteMiddleware(async () => {
  const me = await $fetch('/api/auth/me')
  if (!me) {
    return navigateTo('/login')
  }
})

pages/memos.vue 상단에 적용

<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
// ...
</script>

13) 실행 순서 (처음부터 끝까지)

  1. 패키지 설치
  2. DB/스키마/마이그레이션 구성
  3. 인증 API 작성
  4. 메모 API 작성
  5. Tailwind + Nuxt UI 설정
  6. 페이지 작성
  7. 실행/테스트

명령어:

npm install
npm run db:generate
npm run db:migrate
npm run dev

14) 테스트 시나리오 (꼭 해보기)

  1. /signup에서 계정 생성
  2. /memos 이동 확인
  3. 메모 3개 추가
  4. 하나 완료 체크
  5. 하나 텍스트 수정
  6. 하나 삭제
  7. 로그아웃
  8. 다시 로그인
  9. 메모 데이터 유지 확인
  10. 다른 계정 생성 후 데이터 분리 확인

15) 초보자가 자주 하는 실수 20개

  1. .env 없이 세션 시크릿 누락
  2. 마이그레이션 안 돌리고 API부터 실행
  3. API에서 zod 검증 누락
  4. 로그인 체크를 프론트에서만 수행
  5. DB 쿼리에서 userId 조건 누락
  6. 비밀번호 원문 저장
  7. 에러 메시지를 그대로 사용자에게 출력
  8. httpOnly 쿠키 미사용
  9. 운영에서 secure: false
  10. 비밀번호 최소 길이 없음
  11. 중복 이메일 체크 누락
  12. 로그아웃 시 쿠키 삭제 누락
  13. PATCH에서 null/undefined 구분 실수
  14. 라우트 미들웨어 누락
  15. pending 상태 처리 누락(중복 제출)
  16. 입력 trim 미적용
  17. SQL 파일/DB 파일 git 업로드
  18. 개발/운영 환경변수 혼동
  19. 배포 시 CORS/도메인 쿠키 정책 미확인
  20. 백업 전략 없음

16) 보안 체크리스트 (실무 필수)

  • SESSION_SECRET 강력 랜덤값
  • 쿠키 httpOnly, sameSite=lax, 운영시 secure=true
  • 로그인/회원가입 rate limit
  • 비밀번호 정책(길이/복잡도)
  • 입력 검증(zod) 전 API 적용
  • 사용자별 데이터 접근 제한(userId where)
  • 에러 로깅과 사용자 메시지 분리
  • DB 백업/복구 리허설

17) 배포 전 점검

  • NODE_ENV=production
  • SESSION_SECRET 설정
  • app.db 저장 위치/권한 확인
  • 백업 스케줄링(cron)
  • 로그 수집(에러 추적)
  • HTTPS 인증서 적용

18) 확장 로드맵

  1. 사용자 프로필/비밀번호 변경
  2. 메모 검색/필터/태그
  3. 팀 공유 메모(권한 모델)
  4. 첨부파일 업로드
  5. SQLite -> PostgreSQL 전환
  6. e2e 테스트(Playwright)

19) 핵심 요약

스카이님 요청하신 1/2/3은 아래로 완결됩니다.

  1. DB 연동: SQLite + Drizzle로 영구 저장
  2. 인증: 회원가입/로그인 + 세션 쿠키 유지
  3. UI: Tailwind + Nuxt UI로 빠르게 실무형 화면 구성

이 구조를 정확히 이해하면, 단순 메모앱을 넘어 대부분의 SaaS 기본 골격(회원/권한/데이터/UI)을 확장 가능하게 됩니다.


부록 A) 명령어 모음

# 초기 설치
npm install

# DB 반영
npm run db:generate
npm run db:migrate

# 개발 서버
npm run dev

부록 B) .gitignore 권장

node_modules/
.nuxt/
.output/
app.db
.env

필요하시면 다음 문서로
**“Nuxt 3 운영 배포 마스터편 (Docker + Nginx + HTTPS + 백업 + 장애복구)”**까지 이어서 작성해드리겠습니다.