jh-next-tailwind-boilderplate Tailwind Templates

Jh Next Tailwind Boilderplate

I used a free version of 'tailAdmin' and it should be optimized for BO.

Next-Frontend-CCC

๐Ÿ“Œ Frontend Cross-Cutting Concern (CCC) ๋ฌธ์„œ

๐Ÿ”น CC / CCC ๋ž€ ๋ฌด์—‡์ธ๊ฐ€?

1. AOP (Aspect-Oriented Programming) ๊ด€์  ์ง€ํ–ฅ ํ”„๋กœ๊ทธ๋ž˜๋ฐ

  • CC (Core Concern): ์ฃผ ๊ด€์‹ฌ์‚ฌํ•ญ
  • CCC (Cross-Cutting Concern): ํšก๋‹จ ๊ด€์‹ฌ์‚ฌ (์˜ˆ: Logging, Transaction ๋“ฑ)
  • ๊ณตํ†ต ๊ด€์‹ฌ์‚ฌํ•ญ: ์—ฌ๋Ÿฌ ๋ชจ๋“ˆ์—์„œ ๊ณตํ†ต์ ์œผ๋กœ ์ ์šฉ๋˜๋Š” ๊ธฐ๋Šฅ๋“ค
  • CI/CD ์™€ ๊ด€๋ จํ•œ ๋‚ด์šฉ์€ ํฌํ•จํ•˜์ง€ ์•Š์Œ.

๐Ÿ”น ๊ธฐ์ˆ  ์Šคํƒ

Node npm React Next.js
v19.8.0 9.5.1 React 19 Next.js 15

๐Ÿ”น ๋ฌด์—‡์„ ์œ„ํ•œ ํ”„๋กœ์ ํŠธ์ธ๊ฐ€?

โœ… ์ƒ์‚ฐ์„ฑ ํ–ฅ์ƒ

  • ์ž‘์—… ์†๋„ ์ฆ๊ฐ€
  • ์žฌ์‚ฌ์šฉ์„ฑ ๊ฐ•ํ™”
  • ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
    • ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ์นœ์ˆ™ํ•œ๊ฐ€?
    • ์ด๋ฏธ ์ถฉ๋ถ„ํžˆ ๊ฒ€์ฆ๋˜์—ˆ๋Š”๊ฐ€?

๐Ÿ”น ์šฐ๋ฆฌ๊ฐ€ ์ถ”๊ตฌํ•˜๋Š” ๊ฒƒ.

ํ”„๋ ˆ์ž„์›Œํฌ ํ™•์žฅ ๋ ˆ์ด์–ด(Framework Extension Layer)

ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ํ™•์žฅํ•˜๋Š” ๊ธฐ๋Šฅ๋“ค์„ ๋ฏธ๋ฆฌ ์กฐํ•ฉํ•œ ๊ฒƒ

  • โœ… Next.js + React์˜ ๋ถ€์กฑํ•œ ๋ถ€๋ถ„์„ ์ฑ„์›Œ์ฃผ๋Š” ๋ ˆ์ด์–ด
  • โœ… SSR, ์ƒํƒœ ๊ด€๋ฆฌ, API ํ˜ธ์ถœ, ์ธ์ฆ, ๋ฐ์ดํ„ฐ ์ •๊ทœํ™” ๋“ฑ ํ•„์ˆ˜ ๊ธฐ๋Šฅ์„ ํฌํ•จ

๐Ÿš€ Framework Extension Layer

  • "ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ๋” ์‰ฝ๊ฒŒ, ๋” ๊ฐ•๋ ฅํ•˜๊ฒŒ ํ™•์žฅํ•˜๋Š” ๊ณ„์ธต"
  • ๊ธฐ์กด ํ”„๋ ˆ์ž„์›Œํฌ์— ์ง์ ‘ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๋Š” ์ปค์Šคํ…€ ๋ ˆ์ด์–ด

ํ”„๋ก ํŠธ์—”๋“œ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด(Frontend Architecture Pattern)

  • โœ… ์ด๋Ÿฐ ์กฐํ•ฉ์ด ๋‹จ์ˆœํ•œ ์œ ํ‹ธ์ด ์•„๋‹ˆ๋ผ ํ•˜๋‚˜์˜ "์„ค๊ณ„ ํŒจํ„ด"์œผ๋กœ ์ž๋ฆฌ ์žก์Œ
  • โœ… ๋ชจ๋“ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋ฅผ ์ •์˜ํ•˜๊ณ  ๊ฐœ๋ฐœ ๊ทœ์น™์„ ๋งŒ๋“ ๋‹ค๋Š” ์ ์—์„œ "์•„ํ‚คํ…์ฒ˜"์™€ ์œ ์‚ฌ
  • โœ… ์˜ˆ: MVC ํŒจํ„ด, Atomic Design, Clean Architecture ์ฒ˜๋Ÿผ ํ•˜๋‚˜์˜ ์„ค๊ณ„ ๋ฐฉ์‹์ด ๋  ์ˆ˜ ์žˆ์Œ

๐Ÿš€ Frontend Architecture Pattern

  • "ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ์„ ์ฒด๊ณ„์ ์œผ๋กœ ๊ตฌ์กฐํ™”ํ•˜๋Š” ํŒจํ„ด"
  • Next.js + React ๊ฐœ๋ฐœ ์‹œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•ด์•ผ ํ•  ์•„ํ‚คํ…์ฒ˜ ๋ ˆ์ด์–ด

๐Ÿ”น ํ”„๋ ˆ์ž„์›Œํฌ ํ™•์žฅ ๋ ˆ์ด์–ด(Framework Extension Layer) ์—ฐ๊ตฌ๋…ธํŠธ

  • ์‹œ์ž‘์ผ: 2025.03.19

1) ์ธ์ฆ / ๊ถŒํ•œ ๊ด€๋ฆฌ

๐Ÿš€ API ์š”์ฒญ ๊ณตํ†ต ์ฒ˜๋ฆฌ โ†’ Axios ์‚ฌ์šฉ

โœ… ์™œ Axios์ธ๊ฐ€?

Next.js์—์„œ Axios๋ฅผ ๋งค๋ฒˆ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋Š” ๋Œ€์‹ , ์ตœ์ ํ™”๋œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋กœ ๊ฐœ๋ฐœํ•˜๋ฉด ์žฌ์‚ฌ์šฉ์„ฑ์ด ๋†’์•„์ง€๊ณ , ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ํŽธ๋ฆฌํ•ด์ง. ์ฆ‰, Axios ์ธ์Šคํ„ด์Šค๋ฅผ ๋งŒ๋“ค๊ณ , API ํ˜ธ์ถœ์„ ๋ชจ๋“ˆํ™”ํ•˜์—ฌ ์ „์—ญ์—์„œ ์‰ฝ๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด ํ•ต์‹ฌ.

โœ… Axios ์œ ํ‹ธ๋ฆฌํ‹ฐ ์„ค๊ณ„์›์น™

  1. React + Next.js ์ตœ์ ํ™” โ†’ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ & ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ ๋ชจ๋‘ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  2. ์žฌ์‚ฌ์šฉ์„ฑ ๋†’์€ API ์š”์ฒญ ํ•จ์ˆ˜ โ†’ ๋ชจ๋“  API ์š”์ฒญ์„ ๊ณตํ†ต ํ•จ์ˆ˜๋กœ ์ฒ˜๋ฆฌ
  3. ์—๋Ÿฌ ํ•ธ๋“ค๋ง ํ†ตํ•ฉ โ†’ ์ผ๊ด€๋œ ๋ฐฉ์‹์œผ๋กœ API ์—๋Ÿฌ ์ฒ˜๋ฆฌ
  4. SSR(์„œ๋ฒ„์‚ฌ์ด๋“œ ๋ Œ๋”๋ง) ๋Œ€์‘ โ†’ axios.create() ํ™œ์šฉ
  5. ์ธํ„ฐ์…‰ํ„ฐ ํ™œ์šฉ โ†’ JWT ํ† ํฐ ์ž๋™ ์ถ”๊ฐ€
  6. SWR, React Query์™€ ์‰ฝ๊ฒŒ ๊ฒฐํ•ฉ ๊ฐ€๋Šฅ

Axios ์ธ์Šคํ„ด์Šค ์„ค์ • (lib/axios.ts)

import axios from 'axios'

// Axios ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ (SSR, CSR ๋‘˜ ๋‹ค ๊ฐ€๋Šฅ)
const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, // ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ API ์ฃผ์†Œ ๊ฐ€์ ธ์˜ค๊ธฐ
  timeout: 10000, // 10์ดˆ ์ œํ•œ
  headers: {
    'Content-Type': 'application/json',
  },
})

// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ (Request Interceptor)
api.interceptors.request.use(
  config => {
    if (typeof window !== 'undefined') {
      // ํด๋ผ์ด์–ธํŠธ์—์„œ ์‹คํ–‰๋  ๋•Œ๋งŒ localStorage ์ ‘๊ทผ
      const token = localStorage.getItem('token')
      if (token) {
        config.headers.Authorization = `Bearer ${token}`
      }
    }
    return config
  },
  error => Promise.reject(error),
)

// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ (Response Interceptor)
api.interceptors.response.use(
  response => response,
  error => {
    console.error('API ์š”์ฒญ ์‹คํŒจ:', error)
    return Promise.reject(error)
  },
)

export default api
  • ๋ชจ๋“  API ์š”์ฒญ์— Authorization ํ—ค๋” ์ž๋™ ์ถ”๊ฐ€
  • ์š”์ฒญ ์‹คํŒจ ์‹œ ๊ณตํ†ต ์—๋Ÿฌ ํ•ธ๋“ค๋ง ์ ์šฉ
  • CSR(ํด๋ผ์ด์–ธํŠธ)์—์„œ๋งŒ localStorage ์‚ฌ์šฉํ•˜๋„๋ก ๋ถ„๊ธฐ ์ฒ˜๋ฆฌ

API ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ (lib/api.ts)

import api from './axios'
// GET ์š”์ฒญ (๋ฐ์ดํ„ฐ ์กฐํšŒ)
export async function getData<T>(url: string, params?: object): Promise<T> {
  try {
    const response = await api.get<T>(url, { params })
    return response.data
  } catch (error) {
    throw error
  }
}

// POST ์š”์ฒญ (๋ฐ์ดํ„ฐ ์ƒ์„ฑ)
export async function postData<T>(url: string, data?: object): Promise<T> {
  try {
    const response = await api.post<T>(url, data)
    return response.data
  } catch (error) {
    throw error
  }
}

// PUT ์š”์ฒญ (๋ฐ์ดํ„ฐ ์ˆ˜์ •)
export async function putData<T>(url: string, data?: object): Promise<T> {
  try {
    const response = await api.put<T>(url, data)
    return response.data
  } catch (error) {
    throw error
  }
}

// DELETE ์š”์ฒญ (๋ฐ์ดํ„ฐ ์‚ญ์ œ)
export async function deleteData<T>(url: string): Promise<T> {
  try {
    const response = await api.delete<T>(url)
    return response.data
  } catch (error) {
    throw error
  }
}
  • getData(), postData(), putData(), deleteData() ํ•จ์ˆ˜๋กœ API ์š”์ฒญ์„ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌ
  • ๋ชจ๋“  API ์š”์ฒญ์—์„œ ๊ณตํ†ต ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅ
  • TypeScript์˜ ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ํƒ€์ž… ์•ˆ์ „์„ฑ ๋ณด์žฅ

React + Next.js ์—์„œ API ์œ ํ‹ธ ์‚ฌ์šฉ

'use client'

import { useEffect, useState } from 'react'
import { getData } from '@/lib/api'

export default function UserProfile() {
  const [user, setUser] = useState<any>(null)

  useEffect(() => {
    async function fetchUser() {
      try {
        const data = await getData('/user')
        setUser(data)
      } catch (error) {
        console.error('์œ ์ € ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', error)
      }
    }
    fetchUser()
  }, [])

  if (!user) return <p>๋กœ๋”ฉ ์ค‘...</p>

  return (
    <div>
      <h1>์‚ฌ์šฉ์ž ํ”„๋กœํ•„</h1>
      <p>์ด๋ฆ„: {user.name}</p>
      <p>์ด๋ฉ”์ผ: {user.email}</p>
    </div>
  )
}
  • getData('/user') ํ•œ ์ค„๋กœ ์‰ฝ๊ฒŒ API ์š”์ฒญ ๊ฐ€๋Šฅ
  • API ์‘๋‹ต์„ ์ž๋™์œผ๋กœ setUser()์— ์ €์žฅ

SWR ๊ณผ Axios ๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ ์ž๋™์บ์‹ฑ

Next.js์—์„œ๋Š” SWR์„ ์‚ฌ์šฉํ•˜๋ฉด API ์š”์ฒญ์„ ์บ์‹ฑํ•˜๊ณ , ์ž๋™์œผ๋กœ ๊ฐฑ์‹ ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Œ.

โœ… SWR ์ด๋ž€?
  1. SWR(๋˜๋Š” Stale-While-Revalidate)์€ Vercel์—์„œ ๊ฐœ๋ฐœํ•œ React ํ›… ๊ธฐ๋ฐ˜์˜ ๋ฐ์ดํ„ฐ ํŽ˜์นญ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ํด๋ผ์ด์–ธํŠธ ์ธก์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ฐ€์ ธ์˜ค๊ณ  ์บ์‹ฑํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.
  2. SWR์€ "Stale-While-Revalidate" ์ „๋žต์„ ์‚ฌ์šฉํ•˜์—ฌ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋™์ž‘์„ ํ•ฉ๋‹ˆ๋‹ค.
  • Stale (์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ) ํ‘œ์‹œ: ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋จผ์ € ๋ณด์—ฌ์คŒ (UI๊ฐ€ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ๋จ).
  • Revalidate (์žฌ๊ฒ€์ฆ) ์ง„ํ–‰: ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€์„œ ์—…๋ฐ์ดํŠธ.
  1. ์ž๋™ ์บ์‹ฑ ๋ฐ ๊ฐฑ์‹ : ๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ ์ตœ์†Œํ™”ํ•˜๋ฉด์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์ตœ์‹  ์ƒํƒœ๋กœ ์œ ์ง€.
โœ… SWR ์ฃผ์š” ํŠน์ง•
1. ์ž๋™ ์บ์‹ฑ & ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋™๊ธฐํ™”
  • ๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ ์ตœ์†Œํ™”ํ•˜๊ณ , UI๋ฅผ ๋น ๋ฅด๊ฒŒ ๋ Œ๋”๋ง.
  • ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์กด์žฌํ•˜๋ฉด ๋จผ์ € ๋ณด์—ฌ์ฃผ๊ณ , ์ดํ›„ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€ ์—…๋ฐ์ดํŠธ.
2. ์ž๋™ ์žฌ๊ฒ€์ฆ (Revalidation)
  • ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ, ์ƒˆ๋กœ๊ณ ์นจ ์‹œ, ๋˜๋Š” ์ดˆ์ ์ด ๋ณ€๊ฒฝ๋  ๋•Œ(Focus Revalidation) ์ž๋™์œผ๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ด.
3. ์‚ฌ์šฉ์ž ์ธํ„ฐ๋ž™์…˜์— ๋ฐ˜์‘
  • ํƒญ์„ ๋‹ค์‹œ ์—ด๊ฑฐ๋‚˜ ์ธํ„ฐ๋„ท์ด ๋‹ค์‹œ ์—ฐ๊ฒฐ๋˜๋ฉด ์ž๋™์œผ๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ด.
4. ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ (Optimistic UI)
  • API ํ˜ธ์ถœ ์ „ UI๋ฅผ ๋จผ์ € ๋ณ€๊ฒฝํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณต.
5. ๋ฌดํ•œ ์Šคํฌ๋กค & ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ง€์›
  • ๋ฐ์ดํ„ฐ๋ฅผ ์—ฐ์†์ ์œผ๋กœ ๋กœ๋“œํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์‰ฝ๊ฒŒ ๊ตฌํ˜„ ๊ฐ€๋Šฅ.
6. TypeScript ์ง€์›
  • ๊ฐ•๋ ฅํ•œ ํƒ€์ž… ์ง€์›์œผ๋กœ ์•ˆ์ „ํ•œ ์ฝ”๋“œ ์ž‘์„ฑ ๊ฐ€๋Šฅ.
7. ์ฐธ๊ณ ์ž๋ฃŒ

๊ณต์‹ ํ™ˆํŽ˜์ด์ง€: https://swr.vercel.app/

GitHub ์ €์žฅ์†Œ: https://github.com/vercel/swr

์ฝ”๋“œ ์˜ˆ์‹œ
'use client'

import useSWR from 'swr'
import { getData } from '@/lib/api'

export default function UserProfile() {
  const { data: user, error } = useSWR('/user', getData)

  if (error) return <p>์—๋Ÿฌ ๋ฐœ์ƒ: {error.message}</p>
  if (!user) return <p>๋กœ๋”ฉ ์ค‘...</p>

  return (
    <div>
      <h1>์‚ฌ์šฉ์ž ํ”„๋กœํ•„</h1>
      <p>์ด๋ฆ„: {user.name}</p>
      <p>์ด๋ฉ”์ผ: {user.email}</p>
    </div>
  )
}

2) ์—๋Ÿฌ ์ฒ˜๋ฆฌ (์ „์—ญ ์—๋Ÿฌ ์ฒ˜๋ฆฌ)

  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ ํŒŒ์ผ: error.tsx
  • ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๋ฅผ ์ „์—ญ์ ์œผ๋กœ ์ฒ˜๋ฆฌ
  • ๋ช‡๊ฐ€์ง€ ๋ฐฉ๋ฒ•์ด ์žˆ์ง€๋งŒ zustand ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋ฏ€๋กœ ์ด๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ „์—ญ ์—๋Ÿฌ์ฒ˜๋ฆฌ๋ฅผ ํ•˜๋„๋ก ํ•จ.
  • ์ˆœ์„œ๋Š” 1) ์ „์—ญ์—๋Ÿฌ์ƒํƒœ store ๋ฅผ ๋งŒ๋“ค๊ณ , 2) ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ƒํƒœ์— ๋„ฃ๊ณ  3) ๊ณตํ†ต layout.tsx ์— ๋„ฃ์–ด์ฃผ๋ฉด ๋จ.

์˜ˆ์‹œ์ฝ”๋“œ

// ์ „์—ญ ์—๋Ÿฌ์ƒํƒœ store ๋งŒ๋“ค๊ธฐ
// stores/errorStore.ts
import { create } from 'zustand'

type ErrorState = {
  error: string | null
  setError: (message: string) => void
  clearError: () => void
}

export const useErrorStore = create<ErrorState>(set => ({
  error: null,
  setError: message => set({ error: message }),
  clearError: () => set({ error: null }),
}))
// ์—๋Ÿฌ๋ฐœ์ƒ์‹œ ์ƒํƒœ์— ๋„ฃ๊ธฐ
// ์˜ˆ: api ํ˜ธ์ถœ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ
import { useErrorStore } from '@/stores/errorStore'

const fetchData = async () => {
  try {
    const res = await fetch('/api/something')
    if (!res.ok) throw new Error('์„œ๋ฒ„ ์‘๋‹ต์ด ์ด์ƒํ•ฉ๋‹ˆ๋‹ค.')
    const data = await res.json()
    // ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ
  } catch (err: any) {
    useErrorStore.getState().setError(err.message || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜')
  }
}
// ์ „์—ญ ์—๋Ÿฌ ๊ณตํ†ต๋ ˆ์ด์•„์›ƒ์— ์ ์šฉ (layout.tsx)
// app/layout.tsx (๋˜๋Š” ๊ณตํ†ต ๋ ˆ์ด์•„์›ƒ)

'use client'

import { useErrorStore } from '@/stores/errorStore'
import { useEffect } from 'react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const { error, clearError } = useErrorStore()

  useEffect(() => {
    if (error) {
      console.error('์ „์—ญ ์—๋Ÿฌ:', error)
      // ํ† ์ŠคํŠธ ๋ณด์—ฌ์ฃผ๊ธฐ ๋“ฑ
      alert(error) // ์˜ˆ์‹œ
      clearError() // ํ•œ ๋ฒˆ ๋ณด์—ฌ์ค€ ํ›„ ์ดˆ๊ธฐํ™”
    }
  }, [error])

  return <>{children}</>
}

3) ์ „์—ญ ์ƒํƒœ ๊ด€๋ฆฌ

Zustand (next 15 / react 19)

  • ๋Ÿฌ๋‹์ปค๋ธŒ๊ฐ€ ๋†’์€ Redux๋Š” ๋ฐฐ์ œํ•œ๋‹ค.
  • Zustand vs Recoil
ํ•ญ๋ชฉ Zustand Recoil
์„ค๊ณ„์ฒ ํ•™ ์ตœ์†Œํ•œ์˜ API ๋กœ ๋‹จ์ˆœํ•˜๊ณ  ์œ ์—ฐํ•œ ์ƒํƒœ๊ด€๋ฆฌ React์˜ ๋ Œ๋”๋ง ํ๋ฆ„์— ์ตœ์ ํ™”๋œ ์ƒํƒœ๊ด€๋ฆฌ
์ค‘์‹ฌ๊ฐœ๋… ์ „์—ญ ์ƒํƒœ๋ฅผ JS ๊ฐ์ฒด์ฒ˜๋Ÿผ ์ •์˜ (store) ์•„ํ†ฐ(Atom), ์…€๋ ‰ํ„ฐ(Selector)๋กœ ์ƒํƒœ๋ฅผ ์ชผ๊ฐœ๊ณ  ์กฐํ•ฉ
๊ธฐ๋ฐ˜๊ตฌ์กฐ vanilla JS์— ๊ฐ€๊นŒ์šด ์ž์œ ๋กœ์šด ๊ตฌ์กฐ React ๋‚ด๋ถ€ ์ž‘๋™ ๋ฐฉ์‹๊ณผ ๋ฐ€์ ‘ํ•˜๊ฒŒ ์—ฐ๊ฒฐ
  • Zustand ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ „์—ญ์—๋Ÿฌ์™€ ์ „์—ญ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค.
  • Recoil ์€ nextjs 15 ์™€ ํ˜ธํ™˜์ด ์ข‹์ง€ ์•Š์Œ. (Recoil v0.7.7 ํ™•์ธ)
  • ๊ฐ€๋ณ๊ณ  ํ˜ธํ™˜์ด ์ข‹์•„ ์›ํ•˜๋Š” ๋Œ€๋กœ ์ปค์Šคํ…€์ด ๊ฐ€๋Šฅํ•œ zustand ๋ฅผ ๊ณต์‹์ ์œผ๋กœ ์ฑ„ํƒํ•˜๊ณ  ์Šคํ‚ฌ์—…ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์„ ๊ฒƒ์œผ๋กœ ๋ณด์ž„.
  • Recoil์ด ๋ฐ˜๋“œ์‹œ ํ•„์š”ํ•œ ์ด์œ ์— ๋Œ€ํ•˜์—ฌ ๊ธฐ์ˆ ์ ์ธ ์ฆ๋ช…์ด ๋˜์ง€ ์•Š๋Š” ์ด์ƒ ์ƒํƒœ๊ด€๋ฆฌ๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ํ†ต์ผํ•˜์—ฌ ์‚ฌ์šฉํ•œ๋‹ค.
  • Recoil ๋˜๋Š” Redux ๋ฅผ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์›ํ•  ๊ฒฝ์šฐ๋Š” ์˜ˆ์™ธ. ์ด ๋•Œ์—๋Š” ๋‹ค๋ฅธ ๋ชจ๋“ˆ์˜ ์˜์กด์„ฑ์„ ๋‹ค์‹œ ๊ฒ€ํ† ํ•˜์—ฌ ์ง„ํ–‰ํ•œ๋‹ค.
  • ์ด๋ฏธ ๊ฐœ๋ฐœ ๋œ ์ œํ’ˆ์˜ ๊ฒฝ์šฐ ๊ธฐ์กด ์ฝ”์–ด ๋ชจ๋“ˆ์€ ์œ ์ง€ํ•˜๋ฉฐ ๊ทธ์— ๋”ฐ๋ฅธ ์˜์กด์„ฑ์— ๋”ฐ๋ผ ๋ชจ๋“ˆ์„ ์„ ์ •ํ•œ๋‹ค.
  • ํด๋” ์ •์˜ (store ํด๋” ํ•˜์œ„์— ์ฃผ์ œ๋ณ„๋กœ ํ•˜์œ„ ํด๋”๋ฅผ ๊ตฌ์„ฑํ•˜์—ฌ๋„ ์ƒ๊ด€์—†์Œ.)

    store/nameStore.ts

'use client'

import { set } from 'react-hook-form'
import { create } from 'zustand'

export interface TenantContact {
  id: string
  fullName: string
  firstName: string
  lastName: string
  phoneNumber: string
  invoiceEmailAddress: string
  contactTypeEnum: string
  contactTypeDisplayName: string
  fullAddress: string
  zipCode: string
  city: string
  country: string
}

export interface TenantData {
  id: string
  tenantName: string
  isActive: boolean
  phoneNumber: string
  websiteUrl: string
  emailAddress: string
  fullAddress: string
  region: string | null
  district: string | null
  city: string
  address1Street: string
  address2House: string | null
  zipCode: string
  tenantContacts: TenantContact[]
  createdDateTimeUTC: string
  creatorUserId: string
  creatorUserName: string
  updatedDateTimeUTC: string
  updaterUserId: string
  updaterUserName: string
}

export interface TenantResponse {
  success: boolean
  status: number
  totalCount: number | null
  hasMore: boolean | null
  error: string | null
  data: TenantData
}

// โœ… Zustand ์Šคํ† ์–ด ์ •์˜
interface TenantStoreState {
  tenantsIdData: TenantResponse | null
  setTenantsIdData: (data: TenantResponse | null) => void
  reset: () => void
}

export const useTenantsStore = create<TenantStoreState>(set => ({
  tenantsIdData: null,
  setTenantsIdData: data => set({ tenantsIdData: data }),
  reset: () => set({ tenantsIdData: null }),
}))

Zustand ๊ณต์‹๋ฌธ์„œ


4) API ์š”์ฒญ ์บ์‹ฑ

  • React Query? ๋ณต์žกํ•œ ์•ฑ ์บ์‹œ์ „๋žต๊นŒ์ง€ ์ปค๋ฒ„๊ฐ€๋Šฅ.
  • SWR? vercel, ์†Œํ˜•ํ”„๋กœ์ ํŠธ์— ์ ํ•ฉ. mutation ์ด ๋”ฐ๋กœ ์žˆ์ง€ ์•Š๊ณ  ์ง์ ‘ ์ง€์ •ํ•ด์•ผํ•จ.
  • React-Query v5 (2025-03-25 ๊ธฐ์ค€)

    npm install @tanstack/react-query

  • dev tools

    npm install @tanstack/react-query-devtools

  • ๊ณต์‹๋ฌธ์„œ

    ReactQuery ๊ณต์‹๋ฌธ์„œ

5) API ์‘๋‹ต ๊ฒ€์ฆ

  • ๊ฒ€์ฆ์€ zod ๋กœ ์ง„ํ–‰
  • ๋ชจ๋“  api ๋ฅผ ๊ฒ€์ฆํ•ด์•ผํ• ๊นŒ? Nope! Cause ๋ถˆํ•„์š”ํ•œ ์‹œ๊ฐ„์†Œ๋ชจ + ์ฝ”๋“œ๋Ÿ‰ ์ฆ๊ฐ€.
  • ๋”ฐ๋ผ์„œ, ๊ธฐ์ค€์— ๋”ฐ๋ผ ์•„๋ž˜์˜ ๋‚ด์šฉ์€ API ๋ฅผ ํ†ตํ•ด ๋ฐ›์€ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค.
์ƒํ™ฉ ๊ฒ€์ฆํ•„์š”๋„
์„œ๋ฒ„๊ฐ€ ์™ธ๋ถ€ API ์ผ ๋•Œ โœ…๋ฌด์กฐ๊ฑด ๊ฒ€์ฆ
๋ฐฑ์—”๋“œ๋ž‘ ๊ณ„์•ฝ์ด ๋ช…ํ™•ํ•˜์ง€ ์•Š์„๋•Œ โœ… ๋ฌด์กฐ๊ฑด ๊ฒ€์ฆ
๋กœ๊ทธ์ธ, ๊ถŒํ•œ, ๊ฒฐ์ œ, ์œ ์ €์ •๋ณด โœ… ๋ฌด์กฐ๊ฑด ๊ฒ€์ฆ
์บ์‹œ ์ €์žฅ ์ „/DB์— ๋„ฃ๊ธฐ ์ „ โ“ ๊ฒ€์ฆ ๊ถŒ์žฅ (๋ฐฑ์—”๋“œ์—์„œ ํ•  ๊ฒƒ์ด๋ฏ€๋กœ ์ค‘์š”๋„์— ๋”ฐ๋ผ ๋”๋ธ”์ฒดํฌ์—ฌ๋ถ€ ๊ฒฐ์ •)
๋‹จ์ˆœ ์กฐํšŒ + ํ”„๋ก ํŠธ์—์„œ๋งŒ ์‚ฌ์šฉํ•˜๋Š” UI ๋ฐ์ดํ„ฐ โŒ์ƒ๋žต๊ฐ€๋Šฅ(๋ฌด์กฐ๊ฑด ์ƒ๋žต์€ ์•„๋‹˜.)
  • ํƒ€์ž…์Šคํฌ๋ฆฝํŠธ๋Š” ๊ฐœ๋ฐœ์ค‘์— ๊ฒ€์ฆ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค.
  • zod ๋ฅผ ํ†ตํ•ด ํ•˜๋Š” ๊ฒ€์ฆ์€ ๋Ÿฐํƒ€์ž„ ์ƒํ™ฉ์—์„œ ๋งํ•˜๋Š” ๊ฒƒ์ด๋‹ค.
  • type์„ ์ง€์ •ํ•œ ๊ฒƒ ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ•˜๋ฉด ๋œ๋‹ค.
  • ์•„๋ž˜์™€ ๊ฐ™์ด ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด์˜จ๋‹ค๊ณ  ๊ฐ€์ •ํ•œ๋‹ค.
{
  "success": true,
  "status": 200,
  "totalCount": null,
  "hasMore": null,
  "error": null,
  "data": {
    "id": "51ff5710-5a6a-49d3-88dc-71ec6e2d8043",
    "tenantName": "Blanda Group",
    "isActive": true,
    "phoneNumber": "2342342342342342222",
    "websiteUrl": "https://olga.name",
    "emailAddress": "[email protected]",
    "fullAddress": "3523 Kassandra Mission",
    "region": null,
    "district": null,
    "city": "Mariettaville",
    "address1Street": "34819 Powlowski Mountains",
    "address2House": null,
    "zipCode": "33",
    "tenantContacts": [
      {
        "id": "66d030b7d8eb62249663818c",
        "fullName": "Minjung Kim",
        "firstName": "Minjung",
        "lastName": "Kim",
        "phoneNumber": "821087958795",
        "invoiceEmailAddress": "[email protected]",
        "contactTypeEnum": "Billing",
        "contactTypeDisplayName": "Billing",
        "fullAddress": "2450 massachusetts avenue n.w.",
        "zipCode": "20008",
        "city": "Washington, D.C.",
        "country": "United States of America"
      }
    ],
    "createdDateTimeUTC": "2024-08-29T08:25:36.936Z",
    "creatorUserId": "4a971da2-b165-4672-947a-6ce163660b50",
    "creatorUserName": "4a971da2-b165-4672-947a-6ce163660b50",
    "updatedDateTimeUTC": "2024-08-30T06:48:38.276Z",
    "updaterUserId": "9bec2a88-6fc1-40e6-a974-38c4732ea930",
    "updaterUserName": "dealer365 dealer365"
  }
}
  • ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ํƒ€์ž… ์„ ์–ธ๊ณผ ๋™์ผํ•˜๊ฒŒ ์ž‘์€ ๋ธ”๋ก์œผ๋กœ ๋‚˜๋ˆ„์–ด ์„ ์–ธํ•œ๋‹ค.
// lib/validation/tenantIdData.ts
import { z } from 'zod'

export const TenantContact = z.object({
  id: z.string(),
  fullName: z.string(),
  firstName: z.string(),
  lastName: z.string(),
  phoneNumber: z.string(),
  invoiceEmailAddress: z.string().email(),
  contactTypeEnum: z.string(),
  contactTypeDisplayName: z.string(),
  fullAddress: z.string(),
  zipCode: z.string(),
  city: z.string(),
  country: z.string(),
})

export const TenantId = z.object({
  id: z.string(),
  tenantName: z.string(),
  isActive: z.boolean(),
  phoneNumber: z.string(),
  websiteUrl: z.string().url(),
  emailAddress: z.string().email(),
  fullAddress: z.string(),
  region: z.string().nullable(),
  district: z.string().nullable(),
  city: z.string(),
  address1Street: z.string(),
  address2House: z.string().nullable(),
  zipCode: z.string(),
  tenantContacts: z.array(TenantContact),
  createdDateTimeUTC: z.string().datetime(),
  creatorUserId: z.string(),
  creatorUserName: z.string(),
  updatedDateTimeUTC: z.string().datetime(),
  updaterUserId: z.string(),
  updaterUserName: z.string(),
})

export const TenantIdAll = z.object({
  success: z.boolean(),
  status: z.number(),
  totalCount: z.number().nullable(),
  hasMore: z.boolean().nullable(),
  error: z.any().nullable(), // or refine further if needed
  data: TenantId,
})
  • ์‹ค์ œ ์ ์šฉ. ( reactQuery + logger + zod )
// /app/(admin)/(others-pages)/(tables)/tenants/detail/[id]/page.tsx
import { TenantIdAll } from '@/lib/validation/tenantIdData'
import { logger } from '@/lib/logger'
const { tenantsIdData, setTenantsIdData, reset } = useTenantsStore()
const { data, isLoading: loading } = useQuery({
  queryKey: ['tenantIdData', id],
  queryFn: () => getData<TenantResponse>(`/data/${id}.json`),
  enabled: !!id,
})
useEffect(() => {
  if (data) {
    const validation = TenantIdAll.safeParse(data)
    console.log('validation', validation?.data)
    if (!validation.success) {
      logger.error(`๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์‹คํŒจ`, validation.error)
    } else {
      setTenantsIdData({
        ...validation.data,
        error: validation.data.error ?? null,
      })
    }
  }
}, [data])

6) ํผ ๋ฐ์ดํ„ฐ ์ •๊ทœํ™” & ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ

  • yup? ๋ฆฌ์•กํŠธ ํ›… ํผ์—์„œ ๊ณต์‹์ ์œผ๋กœ ์ง€์›ํ•ด์„œ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ•˜์˜€์œผ๋‚˜, typescript ์ง€์›์ด ์•ฝํ•˜๊ธฐ ๋•Œ๋ฌธ์— zod ๋กœ ํ†ตํ•ฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์„๋“ฏ?
  • zod?
๊ธฐ๋Šฅ Yup Zod T3 Env ์ ์šฉ
TypeScript ์ง€์› โŒ ๋Ÿฐํƒ€์ž„ ๊ธฐ๋ฐ˜ (ํƒ€์ž… ์ง€์› ์•ฝํ•จ) โœ… ์™„์ „ํ•œ TypeScript ์ง€์› โœ… Zod๊ฐ€ T3 Env์™€ ์™„๋ฒฝํ•˜๊ฒŒ ํ˜ธํ™˜๋จ
๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ (preprocess) โŒ ์ง์ ‘ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•จ โœ… preprocess()๋กœ ๋ณ€ํ™˜ ๊ฐ€๋Šฅ โœ… PORT, IS_PRODUCTION ๊ฐ™์€ ๊ฐ’ ๋ณ€ํ™˜ ์šฉ์ด
๊ธฐ๋ณธ๊ฐ’ (default) โœ… ๊ฐ€๋Šฅ โœ… ๊ฐ€๋Šฅ โœ… ๋‘˜ ๋‹ค ์‚ฌ์šฉ ๊ฐ€๋Šฅ
์„ฑ๋Šฅ โšก ๋น ๋ฆ„ โšก ๋น ๋ฆ„ ๋น„์Šทํ•จ
์กฐํ•ฉ (Composition) โŒ ์Šคํ‚ค๋งˆ ์กฐํ•ฉ์ด ์–ด๋ ค์›€ โœ… ์Šคํ‚ค๋งˆ ์กฐํ•ฉ ์šฉ์ด โœ… ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๊ฒ€์ฆ์— ์ ํ•ฉ
์‚ฌ์šฉ ํŽธ์˜์„ฑ โœ… ์‰ฝ์ง€๋งŒ ํƒ€์ž… ์ง€์› ๋ถ€์กฑ โœ… TypeScript์™€ ์™„๋ฒฝ ํ˜ธํ™˜ โœ… Zod๊ฐ€ ๋” ์ž์—ฐ์Šค๋Ÿฌ์›€

7) LocalStorage ๊ด€๋ฆฌ (storage.ts)

  • LocalStorage ๋ฅผ util ๋กœ ๋งŒ๋“ค์–ด์•ผ ํ•˜๋Š” ์ด์œ  (setItem/getItem)

  • ํƒ€์ž…์•ˆ์ •์„ฑ ์œ ์ง€

  • JSON ํŒŒ์‹ฑ/๋ฌธ์ž์—ดํ™”๊ฐ€ ๊ท€์ฐฎ์Œ (๋งค๋ฒˆ ํ•ด์•ผํ•˜์ง€)

  • ํ‚ค๊ด€๋ฆฌ๊ฐ€ ์•ˆ๋จ.

  • ์ด ๋ชจ๋“ ๊ฒŒ ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ๊ณผ ์—ฐ๊ฒฐ์ด ๋œ๋‹ค!

  • ๋จผ์ € StorageUtil ์„ ๋งŒ๋“ค์ž

export default class StorageUtil {
  static get<T>(key: string): T | null {
    if (typeof window === 'undefined') return null
    const value = localStorage.getItem(key)
    try {
      return value ? (JSON.parse(value) as T) : null
    } catch {
      return null
    }
  }
  static set<T>(key: string, value: T) {
    localStorage.setItem(key, JSON.stringify(value))
  }
  static remove(key: string) {
    localStorage.removeItem(key)
  }
  static clear() {
    localStorage.clear()
  }
  static has(key: string): boolean {
    return localStorage.getItem(key) !== null
  }
}
  • ์ตœ์ƒ์œ„ ์ปดํฌ๋„ŒํŠธ(Layout.tsx)์— ์ ์šฉ
  • ๊ธฐ์กด์ฝ”๋“œ
// src/app/layout.tsx
const [token, setToken] = useState<string | null>(null)
useEffect(() => {
  const storedToken = localStorage.getItem('token')
  setToken(storedToken)
  if (token === null) {
    alert('Your token has expired.')
    router.push('/signin')
    setLoading(false)
  }
}, [token, router])
  • ์ˆ˜์ •์ฝ”๋“œ
import StorageUtil from '@/lib/storage'
interface Itoken {
  token: string | null
}
useEffect(() => {
  const storedToken = StorageUtil.get<Itoken>('token')
  const [token, setToken] = useState<Itoken | null>(null)

  setToken(storedToken)
  if (token === null) {
    alert('Your token has expired.')
    router.push('/signin')
    setLoading(false)
  }
}, [token, router])

8) Debounce (debounce.ts)

9) ๋‚ ์งœ ํฌ๋งท ๋ณ€๊ฒฝ (date.ts)

10) ๊ฐ์ฒด ๋น„๊ต (compare.ts)

11) UUID ์ƒ์„ฑ (uuid.ts)

12) Query String ๋ณ€ํ™˜ (query.ts)

13) env

T3 env

  • process.env๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•˜๋ฉด ํƒ€์ž… ๊ฒ€์ฆ์ด ์–ด๋ ต๊ณ , ๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Œ.
  • T3 Env๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ™˜๊ฒฝ ๋ณ€์ˆ˜์˜ ํƒ€์ž…์„ ์ •์˜ํ•˜๊ณ , ์˜ฌ๋ฐ”๋ฅธ ๊ฐ’์ธ์ง€ ๊ฒ€์ฆ ๊ฐ€๋Šฅ!
  • zod ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ
  • src/lib/validation/env.ts
import { z } from 'zod'

const envSchema = z.object({
  NEXT_PUBLIC_BASE_URL: z.string().url(),
  NEXT_PUBLIC_PORT: z.coerce.number(), // ๋ฌธ์ž์—ด์„ number๋กœ ๊ฐ•์ œ ๋ณ€ํ™˜
})

const parsed = envSchema.safeParse({
  NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
  NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT,
})

if (!parsed.success) {
  console.error(
    `์œ ํšจํ•˜์ง€ ์•Š์€ ์•Š์€ ํ™˜๊ฒฝ ๋ณ€์ˆ˜: ${JSON.stringify(parsed.error.format(), null, 2)}`,
  )
  throw new Error(`ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ์˜ค๋ฅ˜!`)
}

export const env = parsed.data

14) ๋‹จ์œ„, ํ†ตํ•ฉํ…Œ์ŠคํŠธ

  • Jest
  • React Testing Library
  • ์„ค์น˜

    npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node

  • ํƒ€์ž…์Šคํฌ๋ฆฝํŠธ

    npm install -D @types/jest

  • config file

    jest.config.ts / jest.setup.ts

  • ๋‹จ์œ„ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ ์ฝ”๋“œ
//__test__/useGlobalErrorStore.test.ts

import { useGlobalErrorStore } from '@/store/errorStore'

describe('Global Error Store (zustand)', () => {
  beforeEach(() => {
    useGlobalErrorStore.setState({ error: null }) // ์ดˆ๊ธฐํ™”
  })

  it('์ดˆ๊ธฐ ์ƒํƒœ๋Š” error๊ฐ€ null์ด์–ด์•ผ ํ•œ๋‹ค', () => {
    expect(useGlobalErrorStore.getState().error).toBeNull()
  })

  it('setGlobalError ํ˜ธ์ถœ ์‹œ error ๊ฐ’์ด ์„ค์ •๋œ๋‹ค', () => {
    useGlobalErrorStore.getState().setGlobalError('์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ')
    expect(useGlobalErrorStore.getState().error).toBe('์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ')
  })

  it('clearError ํ˜ธ์ถœ ์‹œ error๊ฐ€ null๋กœ ์ดˆ๊ธฐํ™”๋œ๋‹ค', () => {
    useGlobalErrorStore.getState().setGlobalError('์—๋Ÿฌ ๋ฐœ์ƒ')
    useGlobalErrorStore.getState().clearError()
    expect(useGlobalErrorStore.getState().error).toBeNull()
  })
})

15) e2e test : Playwright (next.js[๊ณต์‹์ถ”์ฒœ]) / Cypress

  • Playwright? (next.js) ์„ ํƒ! ์™œ cypress ๊ฐ€ ์•„๋‹ˆ๋ผ playwright ๋ฅผ ์ฑ„ํƒ ํ–ˆ๋‚˜?
  • Cypress?

    Playwright install ๋ฐ playwright.config.ts ์ž‘์„ฑ.

  • ์˜ˆ์‹œ์ฝ”๋“œ ์ž‘์„ฑ.
  • playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './src/app/tests', // ํ…Œ์ŠคํŠธ ํด๋”
  timeout: 30 * 1000,
  expect: {
    timeout: 5000,
  },
  use: {
    baseURL: process.env.NEXT_PUBLIC_BASE_URL,
    headless: true,
    viewport: { width: 1280, height: 720 },
    actionTimeout: 0,
    ignoreHTTPSErrors: true,
  },
  webServer: {
    command: 'npm run dev',
    port: Number(process.env.NEXT_PUBLIC_PORT),
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
})
  • login.spec.ts(common)
import { test as setup, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
setup('๋กœ๊ทธ์ธ ํ›„ ์„ธ์…˜ ์ €์žฅ.', async ({ page }) => {
  // 1.๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ์ง„์ž…
  await page.goto('/')

  // 2. ์ด๋ฉ”์ผ์ด๋ž‘ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ
  await page.getByPlaceholder('userId').fill('admin')
  await page.getByPlaceholder('password').fill('1234')

  // 3. ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•œ๋‹ค.
  await page.locator('button[type="submit"]').click()

  // 4. ๋ฃจํŠธํŽ˜์ด์ง€๋กœ ์ด๋™
  await expect(page).toHaveURL('/')

  // 5. ์„ธ์…˜ ์ €์žฅ.
  const storageState = await page.context().storageState()

  // ๊ฒฝ๋กœ ์ƒ์„ฑ + ํŒŒ์ผ ์ €์žฅ
  const authDir = path.join(process.cwd(), 'playwright/.auth')
  if (!fs.existsSync(authDir)) {
    fs.mkdirSync(authDir, { recursive: true })
  }
  // ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅ.
  fs.writeFileSync('playwright/.auth/admin.json', JSON.stringify(storageState))
})
  • tenants.spec.ts (์นดํ…Œ๊ณ ๋ฆฌ , ๊ธฐ๋Šฅ ๋“ฑ ์ฃผ์ œ๋ณ„๋กœ e2e ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ •ํ•˜๊ณ  ์ง„ํ–‰.)
import { test } from '@playwright/test'
import path from 'path'

//์ €์žฅ ๋œ ์„ธ์…˜์„ ๊ฐ€์ ธ์™€์„œ ์‚ฌ์šฉํ•œ๋‹ค.
test.use({
  storageState: path.join(process.cwd(), 'playwright/.auth/admin.json'),
})

test('ํ…Œ๋„ŒํŠธ ํŽ˜์ด์ง€', async ({ page }) => {
  await page.goto('/tenants')
})
  • e2e ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ•๋ก 
  • ๋ถˆํ•„์š”ํ•œ ํ…Œ์ŠคํŠธ๋กœ ์ธํ•œ ์‹œ๊ฐ„ ๋‚ญ๋น„๋ฅผ ๋ฐฉ์ง€ํ•œ๋‹ค.
  • ํ…Œ์ŠคํŠธ์˜ ๋ชฉํ‘œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์•„์•ผ ํ•œ๋‹ค.
๋ชฉ์  ์„ค๋ช…
์‹ค์ œ ์œ ์ € ํ๋ฆ„ ๊ฒ€์ฆ ์‚ฌ์šฉ์ž๊ฐ€ UI ์—์„œ ์–ด๋–ค ํ–‰๋™์„ ํ•  ๋•Œ ์‹œ์Šคํ…œ์ด ์ œ๋Œ€๋กœ ๋™์ž‘ํ•˜๋Š”์ง€
๋ณด์•ˆ ํ๋ฆ„ ์ฒดํฌ ๋กœ๊ทธ์ธ, ์ ‘๊ทผ์ œํ•œ, ๊ถŒํ•œ ์ฒ˜๋ฆฌ ๋“ฑ
ํ†ตํ•ฉ ์‹œ๋‚˜๋ฆฌ์˜ค ์—ฌ๋ŸฌํŽ˜์ด์ง€, ์—ฌ๋Ÿฌ๊ธฐ๋Šฅ ๊ฐ„์˜ ํ๋ฆ„์ด ์—ฐ๊ฒฐ๋˜์–ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€
ํšŒ๊ท€๋ฐฉ์ง€ ๋ณ€๊ฒฝ ์‹œ ๊ธฐ์กด ๊ธฐ๋Šฅ์ด ๊บ ์ง€์ง€ ์•Š์•˜๋Š”์ง€ ์ž๋™์œผ๋กœ ํ™•์ธ
  • ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ๋Š” ์•„๋ž˜ ์™€ ๊ฐ™์ด ํ•œ๋‹ค.
๐Ÿ“ src
โ””โ”€โ”€ ๐Ÿ“ app
    โ””โ”€โ”€ ๐Ÿ“ tests
        โ”œโ”€โ”€ ๐Ÿ“ auth             # ๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž… ํ๋ฆ„
        โ”‚   โ””โ”€โ”€ login.spec.ts
        โ”œโ”€โ”€ ๐Ÿ“ tenants          # ํ…Œ๋„ŒํŠธ ๊ด€๋ฆฌ ํ๋ฆ„
        โ”‚   โ””โ”€โ”€ tenants.spec.ts
        โ”œโ”€โ”€ ๐Ÿ“ users            # ์œ ์ € ๊ด€๋ฆฌ ํ๋ฆ„
        โ”œโ”€โ”€ ๐Ÿ“ dashboard        # ๋Œ€์‹œ๋ณด๋“œ ์‹œ๋‚˜๋ฆฌ์˜ค
        โ””โ”€โ”€ ๐Ÿ“„ setup.ts         # ๋กœ๊ทธ์ธ ์„ธ์…˜ ์ €์žฅ์šฉ
  • ํ…Œ์ŠคํŠธ ๋„ค์ด๋ฐ์€ ์•„๋ž˜์™€ ๊ฐ™์€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์—ฌ ์ง„ํ–‰ํ•œ๋‹ค.
  • ์ƒˆ๋กœ์šด ๋„ค์ด๋ฐ ๊ทœ์น™์ด ์ƒ๊ธธ ๊ฒฝ์šฐ ์ถ”๊ฐ€ํ•˜๊ณ  ๊ต์ฒดํ•  ๊ฒฝ์šฐ ์ „์ฒด ๋„ค์ด๋ฐ ์ˆ˜์ •์„ ํ•˜์—ฌ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•œ๋‹ค.
ํŒŒ์ผ๋ช… ์˜ˆ์‹œ์„ค๋ช…๋ช…
login.spec.ts ๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ ๋‹จ๋… ์ผ ๊ฒฝ์šฐ. (XXXX.spec.ts)
login-to-tenants.spec.ts ๋กœ๊ทธ์ธ->ํ…Œ๋„ŒํŠธ๋กœ ์ด๋™์‹œ๋‚˜๋ฆฌ์˜ค (XXXX-to-XXXXX.spec.ts)
tenant-crud.spec.ts ํ…Œ๋„ŒํŠธ ๋“ฑ๋ก, ์ˆ˜์ •, ์‚ญ์ œ ํ†ตํ•ฉ ์‹œ๋‚˜๋ฆฌ์˜ค (XXXX-crud.spec.ts)

16) Logger (console)

  • pino? (next ๊ณต์‹์ถ”์ฒœ)
  • winston?
  • nextjs ์—์„œ ์ถ”์ฒœํ•˜๊ณ  ์—ฌ๋Ÿฌ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ์—์„œ ์‚ฌ์šฉ์ค‘์ธ ๋ชจ๋“ˆ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ฑ„ํƒ. ํ”„๋ก ํŠธ์—์„œ ๋กœ๊น… ์ž์ฒด๋Š” ํฌ๊ฒŒ ์˜๋ฏธ๋ฅผ ๋ถ€์—ฌํ•˜๊ธฐ ์–ด๋ ต๊ณ  console ์˜ ๋Œ€์ฒด ์ •๋„๋กœ ์ƒ๊ฐํ•˜๋ฉด ๋  ๊ฒƒ.
  • ์„ค์น˜

    npm i pino pino-pretty

  • install ํ›„ 'pino/browser' ์—”ํŠธ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋˜๋Š”๋ฐ ๊ธฐ๋ณธ์ ์œผ๋กœ ํƒ€์ž…์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๊ณ  ์ด ๋ถ€๋ถ„์€ ๋ชจ๋“ˆ์— ๋Œ€ํ•œ ํƒ€์ž…์„ declare ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

    types/custom.d.ts

declare module 'pino/browser' {
  import type { LoggerOptions } from 'pino'
  export function browser(opts?: LoggerOptions): import('pino').Logger
}
  • lib/logger.ts ์ž‘์„ฑ.
  • ์ถ”๊ฐ€ ์˜ต์…˜์€ ํ•„์š”์— ๋”ฐ๋ผ ์ถ”๊ฐ€.
import { browser } from 'pino/browser'

export const logger = browser({
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
    },
  },
})

-์ ์šฉ์˜ˆ์‹œ

import { logger } from '@/lib/logger'
//console.error(`API ์š”์ฒญ ์‹คํŒจ:`, error)
logger.error(`API ์š”์ฒญ ์‹คํŒจ:`, error)
  • ์›นํŒฉ๊ณผ ๊ณ„์†ํ•˜์—ฌ ์ถฉ๋Œ.
  • ํƒ€์ž…์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์•„ ๋ชจ๋“ˆ์—์„œ ์ง€์†์ ์œผ๋กœ ์ถฉ๋Œ๋ฐœ์ƒ์œผ๋กœ ์ธํ•ด ์‚ฌ์šฉ์ค‘์ง€. (2025-03-26, v9.6.0)
  • console ์„ ๋Œ€์ฒดํ•  logger ๋ฅผ ๋งŒ๋“ค๋„๋ก ํ•œ๋‹ค.
  • ์˜ˆ์‹œ์ฝ”๋“œ
type LogLevel = 'info' | 'warn' | 'error' | 'debug'

export const logger = {
  log: (level: LogLevel, ...args: unknown[]) => {
    const prefix = `[${level.toUpperCase()}]`
    if (level === 'debug' && process.env.NODE_ENV !== 'development') {
      return
    }
    console[level](prefix, ...args)
  },
  info: (...args: unknown[]) => logger.log('info', ...args),
  warn: (...args: unknown[]) => logger.log('warn', ...args),
  error: (...args: unknown[]) => logger.log('error', ...args),
  debug: (...args: unknown[]) => logger.log('debug', ...args),
}
  • ์‚ฌ์šฉ์˜ˆ์‹œ๋Š” ๋™์ผ.
import { logger } from '@/lib/logger'
//console.error(`API ์š”์ฒญ ์‹คํŒจ:`, error)
logger.error(`API ์š”์ฒญ ์‹คํŒจ:`, error)

17) Sitemap.xml and robot.txt

18) Error monitoring

19) Multi-language(i18n)

next-intl

  • ๋ฒˆ์—ญ ์—ฐ๋™์ด ํ•„์š”ํ•˜๋‹ค๋ฉด? Crowdin (์œ ๋ฃŒ)
  • config.ts ์ˆ˜์ •
//next.config.ts
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'

const nextConfig: NextConfig = {
  /* config options here */
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack'],
    })
    return config
  },
}
const withNextIntl = createNextIntlPlugin()

export default withNextIntl(nextConfig)
  • request.ts ์ž‘์„ฑ(๊ฒฝ๋กœ์ฃผ์˜์˜)
import { getRequestConfig } from 'next-intl/server'

export default getRequestConfig(async () => {
  // Provide a static locale, fetch a user setting,
  // read from `cookies()`, `headers()`, etc.
  const locale = 'en'

  return {
    locale,
    messages: (await import(`/src/locale/${locale}.json`)).default,
  }
})
  • ๊ฒฝ๋กœ์— ๋งž๋Š” json ํŒŒ์ผ ์ƒ์„ฑ (request.ts ์— ์ž‘์„ฑํ•œ ๊ฒฝ๋กœ.)
  • ๋‚˜๋จธ์ง€๋Š” ๊ณต์‹๋ฌธ์„œ๋ฅผ ๋”ฐ๋ผ ์ž‘์„ฑ.

    next-intl ๊ณต์‹๋ฌธ์„œ

  • ์ฃผ์˜ ํ•  ์  ์„œ๋ฒ„์‚ฌ์ด๋“œ์—์„œ ์‹คํ–‰๋˜์ง€ ์•Š๋Š” ๋ฉ”์„œ๋“œ๊ฐ€ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ์—์„œ ์‹คํ–‰๋˜๋Š” ๋ฉ”์„œ๋“œ ๋˜๋Š” ๋ชจ๋“ˆ์ด ์žˆ๋Š” ๊ฒฝ์šฐ ์ปดํฌ๋„ŒํŠธํ™” ํ•ด์„œ ๋ถ„๋ฆฌํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค. ์ด ๋ถ€๋ถ„ ์œ ์˜ํ•  ๊ฒƒ.

20) Authentication

clerk? / keycloak?

  • passwordless login : magic link,passkeys,Optimistic
  • multi-factor Authentication (๋‹ค์ค‘์ธ์ฆ)
  • TOTP, SMS, Email, Hardware security keys
  • Social Auth (SNS)
๊ธฐ๋Šฅ Clerk Keycloak
์„ค์น˜ ๋ฐฉ์‹ โœ… ํด๋ผ์šฐ๋“œ ๊ธฐ๋ฐ˜ (SaaS) โŒ ์ž์ฒด ์„œ๋ฒ„์— ์„ค์น˜ํ•ด์•ผ ํ•จ (Self-hosted)
์šด์˜ ๋ฐฉ์‹ โœ… Clerk์ด ๋ชจ๋“  ์ธ์ฆ์„ ๊ด€๋ฆฌ โœ… ์˜จํ”„๋ ˆ๋ฏธ์Šค(์ž์ฒด ์„œ๋ฒ„)์—์„œ ์ง์ ‘ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ
OAuth / Social Login โœ… Google, GitHub, Apple, Facebook ๋“ฑ ์ง€์› โœ… ์ง€์› (์ง์ ‘ ์„ค์ • ํ•„์š”)
MFA (๋‹ค์ค‘ ์ธ์ฆ) โœ… ๊ธฐ๋ณธ ์ œ๊ณต โœ… ์ง์ ‘ ์„ค์ •ํ•ด์•ผ ํ•จ
User Management โœ… ํด๋ผ์šฐ๋“œ ๋Œ€์‹œ๋ณด๋“œ ์ œ๊ณต โœ… ๊ด€๋ฆฌ ์ฝ˜์†” ์ œ๊ณต (์„ค์ •์ด ๋ณต์žก)
SSO (Single Sign-On) โœ…โŒ ์—”ํ„ฐํ”„๋ผ์ด์ฆˆ ํ”Œ๋žœ์—์„œ ์ง€์› โœ… ๊ธฐ๋ณธ ์ œ๊ณต (OIDC, SAML ์ง€์›)
API & Webhooks โœ… ๊ฐ„๋‹จํ•œ API ์ œ๊ณต โœ… REST API ๋ฐ Admin API ์ œ๊ณต
๋ฐ์ดํ„ฐ ์ €์žฅ ์œ„์น˜ โŒ Clerk ์„œ๋ฒ„์— ์ €์žฅ (SaaS ๋ฐฉ์‹) โœ… ์ž์ฒด DB์— ์ €์žฅ ๊ฐ€๋Šฅ (PostgreSQL, MySQL ๋“ฑ)
์‚ฌ์šฉ์ž ๋งž์ถค ์„ค์ • (Customization) โœ… UI ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ๊ฐ€๋Šฅ โœ… ์™„์ „ํ•œ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ๊ฐ€๋Šฅ
OpenID Connect (OIDC) โŒ ์ง€์›ํ•˜์ง€ ์•Š์Œ โœ… OIDC ํ‘œ์ค€ ์ง€์›
Role-based Access Control (RBAC) โœ… ๊ธฐ๋ณธ ์ œ๊ณต โœ… ๊ฐ•๋ ฅํ•œ RBAC ์ง€์›
ํ˜ธํ™˜์„ฑ โœ… Next.js, React, Vue ๋“ฑ๊ณผ ๊ฐ„ํŽธํ•˜๊ฒŒ ์—ฐ๋™ ๊ฐ€๋Šฅ โœ… ๋ชจ๋“  ํ”Œ๋žซํผ๊ณผ ์—ฐ๋™ ๊ฐ€๋Šฅ (์„ค์ •์ด ๋ณต์žก)
๋ฌด๋ฃŒ ํ”Œ๋žœ โœ… ๋ฌด๋ฃŒ ์ œ๊ณต (๊ธฐ๋ณธ ๊ธฐ๋Šฅ) โœ… 100% ๋ฌด๋ฃŒ (์˜คํ”ˆ์†Œ์Šค)
์œ ๋ฃŒ ํ”Œ๋žœ โœ… ์žˆ์Œ (์ถ”๊ฐ€ ๊ธฐ๋Šฅ ์‚ฌ์šฉ ์‹œ) โŒ ์—†์Œ (์ž์ฒด ์šด์˜ ๋น„์šฉ ํ•„์š”)
๋ณด์•ˆ ๊ด€๋ฆฌ โœ… Clerk์ด ๋ณด์•ˆ ๊ด€๋ฆฌ (SaaS) โœ… ์ง์ ‘ ๋ณด์•ˆ ๊ด€๋ฆฌ ํ•„์š” (์„ค์ •์ด ์ค‘์š”)

21) ํ…Œ์ŠคํŠธ ์ž๋™๊ฒ€์ฆ (ํ•„์ˆ˜X)

  • Codecov? ์ผ๋ถ€์œ ๋ฃŒ

22) ์„ฑ๋Šฅ, ์ ‘๊ทผ์„ฑ, SEO ์ตœ์ ํ™” (ํ•„์ˆ˜X)

Perfect Lighthouse Score

23) ๋ฒˆ๋“คํฌ๊ธฐ ๋ถ„์„

Bundle analyzer plugin

  • CI/CD ์—ฐ๋™์‹œ PR ๋งˆ๋‹ค ๋ฒˆ๋“ค ํฌ๊ธฐ๋ฅผ ์ž๋™์œผ๋กœ ์ฒดํฌ ๊ฐ€๋Šฅ.

24) ์ฝ”๋“œ Formatter

  • Prettier

    .prettierrc.json

{
  "singleQuote": true,
  "semi": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "arrowParens": "avoid",
  "jsxSingleQuote": false
}

25) Linter

ESLint

26) ์ •๊ทœ์‹(lib/regexUtils.ts)

๊ธ€๋กœ๋ฒŒ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ •๊ทœ์‹

export const regexUtils = {
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // ๊ตญ์ œ ์ด๋ฉ”์ผ ํ˜•์‹
  phone: /^\+?\d{1,4}?[-.\s]?(\d{1,4}[-.\s]?){1,4}\d{1,4}$/, // ๊ตญ์ œ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹
  url: /^(https?:\/\/)?([\w\d-]+\.)+\w{2,}(\/.*)?$/, // ๊ตญ์ œ ๋„๋ฉ”์ธ URL ๊ฒ€์ฆ
  password: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, // ์˜๋ฌธ+์ˆซ์ž+ํŠน์ˆ˜๋ฌธ์ž 8์ž ์ด์ƒ
  username: /^[a-zA-Z0-9_-]{3,16}$/, // ๊ธ€๋กœ๋ฒŒ ์‚ฌ์šฉ์ž๋ช… (3~16์ž, ์˜๋ฌธ+์ˆซ์ž+์–ธ๋”๋ฐ”+ํ•˜์ดํ”ˆ ํ—ˆ์šฉ)
  hexColor: /^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/, // HEX ์ƒ‰์ƒ ์ฝ”๋“œ
  slug: /^[a-z0-9]+(?:-[a-z0-9]+)*$/, // URL ์Šฌ๋Ÿฌ๊ทธ (์˜ˆ: my-page-title)
  number: /^\d+$/, // ์ˆซ์ž๋งŒ ํ—ˆ์šฉ
  ipv4: /^(?:\d{1,3}\.){3}\d{1,3}$/, // IPv4 ์ฃผ์†Œ ๊ฒ€์ฆ
  ipv6: /^([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}$/, // IPv6 ์ฃผ์†Œ ๊ฒ€์ฆ
  noSpecialChars: /^[\p{L}\p{N}]+$/u, // ๋ชจ๋“  ์–ธ์–ด์˜ ๋ฌธ์ž & ์ˆซ์ž๋งŒ ํ—ˆ์šฉ (๊ตญ์ œํ™” ์ง€์›)
}

Top categories

Loading Svelte Themes