Node | npm | React | Next.js |
---|---|---|---|
v19.8.0 | 9.5.1 | React 19 | Next.js 15 |
ํ๋ ์์ํฌ๋ฅผ ํ์ฅํ๋ ๊ธฐ๋ฅ๋ค์ ๋ฏธ๋ฆฌ ์กฐํฉํ ๊ฒ
๐ Framework Extension Layer
๐ Frontend Architecture Pattern
Next.js์์ Axios๋ฅผ ๋งค๋ฒ ์ง์ ํธ์ถํ๋ ๋์ , ์ต์ ํ๋ ์ ํธ๋ฆฌํฐ ํจ์๋ก ๊ฐ๋ฐํ๋ฉด ์ฌ์ฌ์ฉ์ฑ์ด ๋์์ง๊ณ , ์ ์ง๋ณด์๊ฐ ํธ๋ฆฌํด์ง. ์ฆ, Axios ์ธ์คํด์ค๋ฅผ ๋ง๋ค๊ณ , API ํธ์ถ์ ๋ชจ๋ํํ์ฌ ์ ์ญ์์ ์ฝ๊ฒ ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ๊ฒ์ด ํต์ฌ.
axios.create()
ํ์ฉ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
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
}
}
'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>
)
}
Next.js์์๋ SWR์ ์ฌ์ฉํ๋ฉด API ์์ฒญ์ ์บ์ฑํ๊ณ , ์๋์ผ๋ก ๊ฐฑ์ ํ๋ ๊ธฐ๋ฅ์ ์ถ๊ฐํ ์ ์์.
๊ณต์ ํํ์ด์ง: 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>
)
}
error.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}</>
}
Zustand
(next 15 / react 19)
ํญ๋ชฉ | Zustand | Recoil |
---|---|---|
์ค๊ณ์ฒ ํ | ์ต์ํ์ API ๋ก ๋จ์ํ๊ณ ์ ์ฐํ ์ํ๊ด๋ฆฌ |
React ์ ๋ ๋๋ง ํ๋ฆ์ ์ต์ ํ๋ ์ํ๊ด๋ฆฌ |
์ค์ฌ๊ฐ๋ | ์ ์ญ ์ํ๋ฅผ JS ๊ฐ์ฒด์ฒ๋ผ ์ ์ (store) |
์ํฐ(Atom), ์
๋ ํฐ(Selector) ๋ก ์ํ๋ฅผ ์ชผ๊ฐ๊ณ ์กฐํฉ |
๊ธฐ๋ฐ๊ตฌ์กฐ | vanilla JS ์ ๊ฐ๊น์ด ์์ ๋ก์ด ๊ตฌ์กฐ |
React ๋ด๋ถ ์๋ ๋ฐฉ์๊ณผ ๋ฐ์ ํ๊ฒ ์ฐ๊ฒฐ |
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 }),
}))
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools
์ํฉ | ๊ฒ์ฆํ์๋ |
---|---|
์๋ฒ๊ฐ ์ธ๋ถ API ์ผ ๋ | โ ๋ฌด์กฐ๊ฑด ๊ฒ์ฆ |
๋ฐฑ์๋๋ ๊ณ์ฝ์ด ๋ช ํํ์ง ์์๋ | โ ๋ฌด์กฐ๊ฑด ๊ฒ์ฆ |
๋ก๊ทธ์ธ, ๊ถํ, ๊ฒฐ์ , ์ ์ ์ ๋ณด | โ ๋ฌด์กฐ๊ฑด ๊ฒ์ฆ |
์บ์ ์ ์ฅ ์ /DB์ ๋ฃ๊ธฐ ์ | โ ๊ฒ์ฆ ๊ถ์ฅ (๋ฐฑ์๋์์ ํ ๊ฒ์ด๋ฏ๋ก ์ค์๋์ ๋ฐ๋ผ ๋๋ธ์ฒดํฌ์ฌ๋ถ ๊ฒฐ์ ) |
๋จ์ ์กฐํ + ํ๋ก ํธ์์๋ง ์ฌ์ฉํ๋ UI ๋ฐ์ดํฐ | โ์๋ต๊ฐ๋ฅ(๋ฌด์กฐ๊ฑด ์๋ต์ ์๋.) |
{
"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,
})
// /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])
๊ธฐ๋ฅ | Yup | Zod | T3 Env ์ ์ฉ |
---|---|---|---|
TypeScript ์ง์ | โ ๋ฐํ์ ๊ธฐ๋ฐ (ํ์ ์ง์ ์ฝํจ) | โ ์์ ํ TypeScript ์ง์ | โ Zod๊ฐ T3 Env์ ์๋ฒฝํ๊ฒ ํธํ๋จ |
๋ฐ์ดํฐ ๋ณํ (preprocess ) |
โ ์ง์ ์ฒ๋ฆฌํด์ผ ํจ | โ
preprocess() ๋ก ๋ณํ ๊ฐ๋ฅ |
โ
PORT , IS_PRODUCTION ๊ฐ์ ๊ฐ ๋ณํ ์ฉ์ด |
๊ธฐ๋ณธ๊ฐ (default ) |
โ ๊ฐ๋ฅ | โ ๊ฐ๋ฅ | โ ๋ ๋ค ์ฌ์ฉ ๊ฐ๋ฅ |
์ฑ๋ฅ | โก ๋น ๋ฆ | โก ๋น ๋ฆ | ๋น์ทํจ |
์กฐํฉ (Composition) | โ ์คํค๋ง ์กฐํฉ์ด ์ด๋ ค์ | โ ์คํค๋ง ์กฐํฉ ์ฉ์ด | โ ํ๊ฒฝ ๋ณ์ ๊ฒ์ฆ์ ์ ํฉ |
์ฌ์ฉ ํธ์์ฑ | โ ์ฝ์ง๋ง ํ์ ์ง์ ๋ถ์กฑ | โ TypeScript์ ์๋ฒฝ ํธํ | โ Zod๊ฐ ๋ ์์ฐ์ค๋ฌ์ |
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
}
}
// 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])
T3 env
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
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node
npm install -D @types/jest
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()
})
})
Playwright install ๋ฐ 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'] },
},
],
})
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))
})
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')
})
๋ชฉ์ | ์ค๋ช |
---|---|
์ค์ ์ ์ ํ๋ฆ ๊ฒ์ฆ | ์ฌ์ฉ์๊ฐ 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) |
npm i pino pino-pretty
types/custom.d.ts
declare module 'pino/browser' {
import type { LoggerOptions } from 'pino'
export function browser(opts?: LoggerOptions): import('pino').Logger
}
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)
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)
next-intl
//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)
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,
}
})
clerk? / keycloak?
๊ธฐ๋ฅ | 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) | โ ์ง์ ๋ณด์ ๊ด๋ฆฌ ํ์ (์ค์ ์ด ์ค์) |
Perfect Lighthouse Score
Bundle analyzer plugin
.prettierrc.json
{
"singleQuote": true,
"semi": false,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "avoid",
"jsxSingleQuote": false
}
ESLint
๊ธ๋ก๋ฒ ํ๊ฒฝ์์ ์ฌ์ฉํ ์ ์๋ ์ ๊ท์
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, // ๋ชจ๋ ์ธ์ด์ ๋ฌธ์ & ์ซ์๋ง ํ์ฉ (๊ตญ์ ํ ์ง์)
}