Vue 3 ์ปดํฌ์ ๋ธ ์ฌํ ๊ฐ์ด๋: ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ํจํด๊ณผ ๊ธฐ์ ๋ฉด์ ์ง๋ฌธ 2026
Vue 3 ๊ณ ๊ธ ์ปดํฌ์ ๋ธ ํจํด์ ์ฒด๊ณ์ ์ผ๋ก ๋ถ์ํฉ๋๋ค. ๋น๋๊ธฐ ์ฒ๋ฆฌ, ์์กด์ฑ ์ฃผ์ , ํผ ์ ํจ์ฑ ๊ฒ์ฌ, ํ ์คํธ ์ ๋ต, ๊ทธ๋ฆฌ๊ณ 2026๋ ๊ธฐ์ ๋ฉด์ ์์ ์์ฃผ ์ถ์ ๋๋ ์ง๋ฌธ๊ณผ ๋ต๋ณ์ ๋ค๋ฃน๋๋ค.

์ปดํฌ์ ๋ธ์ Vue 3์์ ๋ก์ง ์ฌ์ฌ์ฉ์ ์ํ ํต์ฌ ๋ฉ์ปค๋์ฆ์
๋๋ค. Composition API๊ฐ ๊ด๋ฒ์ํ๊ฒ ๋์
๋ ํ์ฌ, ๊ฒฌ๊ณ ํ๊ณ ํ์
์์ ํ๋ฉฐ ํ
์คํธ ๊ฐ๋ฅํ ์ปดํฌ์ ๋ธ์ ์ค๊ณํ๋ ๋ฅ๋ ฅ์ ๊ธฐ์ ๋ฉด์ ์์ ์๋์ด ๊ฐ๋ฐ์์ ์ค๊ธ ๊ฐ๋ฐ์๋ฅผ ๋ช
ํํ๊ฒ ๊ตฌ๋ถํ๋ ๊ธฐ์ค์ด ๋ฉ๋๋ค. 2026๋
์๋ ๋จ์ํ ref์ computed์ ์ฌ์ฉ๋ฒ์ ๋์ด, ๋ผ์ดํ์ฌ์ดํด์ ์ ๋ฐํ ๊ด๋ฆฌ, ๋ฆฌ์กํฐ๋ธ ํจ์์ ํฉ์ฑ, ๋ณต์กํ ์ ํ๋ฆฌ์ผ์ด์
์ ๋ชจ๋๋ฌ ์ํคํ
์ฒ๊น์ง ์๊ตฌ๋ฉ๋๋ค.
์ด ๊ธ์์๋ Vue 3 ์ปดํฌ์ ๋ธ์ ๊ณ ๊ธ ํจํด์ ์ฒด๊ณ์ ์ผ๋ก ๋ถ์ํฉ๋๋ค. ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌ์กฐํ๋ ์ปดํฌ์ ๋ธ์ ์ค๊ณ ์์น๋ถํฐ ๋น๋๊ธฐ ์ฒ๋ฆฌ, ์์กด์ฑ ์ฃผ์ , ํ ์คํธ ์ ๋ต๊น์ง ํ๋ก๋์ ํ๊ฒฝ๊ณผ ๊ธฐ์ ๋ฉด์ ๋ชจ๋์์ ์ฆ์ ํ์ฉํ ์ ์๋ ๊ตฌ์ฒด์ ์ธ ๊ตฌํ ์์๋ฅผ ์ ๊ณตํฉ๋๋ค.
์ปดํฌ์ ๋ธ์ Vue์ Composition API๋ฅผ ํ์ฉํ์ฌ ์ํ๋ฅผ ๊ฐ์ง ๋ก์ง์ ์บก์ํํ๊ณ ์ฌ์ฌ์ฉํ๋ ํจ์์
๋๋ค. ๊ด๋ก์ ์ผ๋ก use ์ ๋์ฌ๋ฅผ ์ฌ์ฉํฉ๋๋ค(์: useCounter, useFetchData). Vue 2์ ๋ฏน์ค์ธ๊ณผ ๋ฌ๋ฆฌ ์ปดํฌ์ ๋ธ์ ๋ช
์์ ์ธ ํ์
์ง์ , ์ด๋ฆ ์ถฉ๋ ๋ฐฉ์ง, ์์กด์ฑ์ ํฌ๋ช
์ฑ์ ์ ๊ณตํฉ๋๋ค.
์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌ์กฐํ๋ ์ปดํฌ์ ๋ธ์ ์ค๊ณ ์์น
ํ๋ก๋์
์์ค์ ์ปดํฌ์ ๋ธ์ ๋ช ๊ฐ์ง ์ํคํ
์ฒ ์์น์ ๋ฐ๋ฆ
๋๋ค. ์
์ถ๋ ฅ์ ๋ํ ์๊ฒฉํ ํ์
์ ์, ๊ด์ฌ์ฌ์ ๋ถ๋ฆฌ, ์์ธก ๊ฐ๋ฅํ ์ธํฐํ์ด์ค๊ฐ ๊ทธ๊ฒ์
๋๋ค. ์๋์ useCounter ์ปดํฌ์ ๋ธ์ ์ด๋ฌํ ๊ธฐ๋ณธ ๊ท์น์ ๋ณด์ฌ์ค๋๋ค.
import { ref, computed, type Ref } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
initialValue?: number
}
interface UseCounterReturn {
count: Ref<number>
doubled: Ref<number>
increment: () => void
decrement: () => void
reset: () => void
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { min = 0, max = Infinity, initialValue = 0 } = options
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() {
if (count.value < max) count.value++
}
function decrement() {
if (count.value > min) count.value--
}
function reset() {
count.value = initialValue
}
return { count, doubled, increment, decrement, reset }
}์ด ๊ตฌํ์์ ์ฃผ๋ชฉํ ์ค๊ณ ์์๊ฐ ์์ต๋๋ค. UseCounterOptions ์ธํฐํ์ด์ค๋ ์ค์ ์ ๋ํ ๋ช
ํํ ๊ณ์ฝ์ ์ ์ํ๊ณ , UseCounterReturn์ ์ปดํฌ์ ๋ธ์ด ๋
ธ์ถํ๋ ๋ด์ฉ์ ์ ํํ๊ฒ ๋ฌธ์ํํฉ๋๋ค. ํจ์ ๋ณธ๋ฌธ์์ ๊ธฐ๋ณธ๊ฐ์ ํฌํจํ ๊ตฌ์กฐ ๋ถํด ํ ๋น์ ์ฌ์ฉํจ์ผ๋ก์จ, ๋ช
์์ ์ค์ ์์ด๋ ์์ธก ๊ฐ๋ฅํ ๋์์ ๋ณด์ฅํฉ๋๋ค.
๋ช ๋ช ๋ ๊ฐ์ฒด(๋ฐฐ์ด์ด ์๋)๋ฅผ ๋ฐํํ๋ฉด ํธ์ถ ์ธก์์ ๊ตฌ์กฐ ๋ถํด ํ ๋น์ ํตํด ํ์ํ ์์ฑ๋ง ์ ํํ ์ ์์ต๋๋ค. ์ด ๊ท์น์ Vue ์ํ๊ณ ์ ๋ฐ์ ๊ฑธ์ณ ์ฑํ๋์ด ํธ์ถ ์ฝ๋์ ๊ฐ๋ ์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ ํฅ์์ํต๋๋ค.
min๊ณผ max ๊ฒฝ๊ณ๊ฐ์ ํ๋ก๋์
์ปดํฌ์ ๋ธ์์ ์์ฃผ ๋ํ๋๋ ํจํด์ ๋ณด์ฌ์ค๋๋ค. ๋น์ฆ๋์ค ๋ก์ง์ ์ ์ฝ ์กฐ๊ฑด์ ๋ฆฌ์กํฐ๋ธ ๋ก์ง ๋ด๋ถ์์ ์ง์ ๊ฒ์ฆํจ์ผ๋ก์จ, ์๋น์ ์ปดํฌ๋ํธ์ ๊ท์น์ด ๋ถ์ฐ๋๋ ๊ฒ์ ๋ฐฉ์งํฉ๋๋ค.
๋น๋๊ธฐ ์ปดํฌ์ ๋ธ๊ณผ ์๋ฌ ์ฒ๋ฆฌ
HTTP ์์ฒญ ๊ด๋ฆฌ๋ ์ปดํฌ์ ๋ธ์ ๊ฐ์ฅ ๋ํ์ ์ธ ์ฌ์ฉ ์ฌ๋ก์
๋๋ค. ์ ์ค๊ณ๋ ๋น๋๊ธฐ ์ปดํฌ์ ๋ธ์ ์์ฒญ์ ์ ์ฒด ๋ผ์ดํ์ฌ์ดํด์ ๊ด๋ฆฌํด์ผ ํฉ๋๋ค. ๋ก๋ฉ ์ํ, ์ฑ๊ณต, ์๋ฌ, ์ทจ์๊ฐ ํฌํจ๋ฉ๋๋ค. ๋ค์ ํจํด์ ์๋ ๋ฆฌ์กํฐ๋นํฐ๋ฅผ ์ํ watchEffect์ ์์ฒญ์ ์ ์ ํ ์ทจ์๋ฅผ ์ํ AbortController๋ฅผ ํตํฉํฉ๋๋ค.
import { ref, watchEffect, onUnmounted, toValue, type Ref, type MaybeRefOrGetter } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<string | null>
isLoading: Ref<boolean>
refresh: () => Promise<void>
}
export function useFetchData<T>(
url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<string | null>(null)
const isLoading = ref(false)
let abortController: AbortController | null = null
async function fetchData() {
// Cancel any in-flight request
abortController?.abort()
abortController = new AbortController()
isLoading.value = true
error.value = null
try {
const response = await fetch(toValue(url), {
signal: abortController.signal
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
isLoading.value = false
}
}
// Re-fetch when URL changes reactively
watchEffect(() => {
fetchData()
})
onUnmounted(() => abortController?.abort())
return { data, error, isLoading, refresh: fetchData }
}url ๋งค๊ฐ๋ณ์์ MaybeRefOrGetter<string>์ ์ฌ์ฉํ๋ฉด ์ต๋ํ์ ์ ์ฐ์ฑ์ ํ๋ณดํ ์ ์์ต๋๋ค. ํธ์ถ ์ธก์ ์ ์ ๋ฌธ์์ด, ref, ๋๋ getter ํจ์๋ฅผ ์ ๋ฌํ ์ ์์ต๋๋ค. toValue() ํจ์๋ ์์ ๊ฐ์ด๋ ๋ฆฌ์กํฐ๋ธ ๊ฐ์ด๋ ๊ธฐ์ ํ์
์ ์๋์ผ๋ก ํด์ํฉ๋๋ค.
AbortController๋ฅผ ํ์ฉํ ์ทจ์ ๋ฉ์ปค๋์ฆ์ ์ด์ ์์ฒญ์ด ์๋ฃ๋๊ธฐ ์ ์ ์ ์์ฒญ์ด ๋ฐ์ํ ๋ ๊ฒฝ์ ์กฐ๊ฑด(race condition)์ ๋ฐฉ์งํฉ๋๋ค. onUnmounted ํ
์ ์ปดํฌ๋ํธ ํ๊ดด ์ ์งํ ์ค์ธ ์์ฒญ์ ํ์คํ๊ฒ ์ ๋ฆฌํ์ฌ, ๋ฉ๋ชจ๋ฆฌ ๋์์ ์ธ๋ง์ดํธ๋ ์ปดํฌ๋ํธ์ ๋ํ ์ํ ์
๋ฐ์ดํธ๋ฅผ ๋ฐฉ์งํฉ๋๋ค.
๋ฐํ๊ฐ์ ๋
ธ์ถ๋ refresh ๋ฉ์๋๋ฅผ ํตํด ์๋น์ ์ปดํฌ๋ํธ๊ฐ ์๋์ผ๋ก ๋ฐ์ดํฐ ์ฌ๋ก๋ฉ์ ํธ๋ฆฌ๊ฑฐํ ์ ์์ต๋๋ค. "์๋ก๊ณ ์นจ" ๋ฒํผ๊ณผ ๊ฐ์ ์ฌ์ฉ์ ์ก์
์ ํ์์ ์ธ ํจํด์
๋๋ค.
๋ผ์ดํ์ฌ์ดํด ํ
(onMounted, onUnmounted ๋ฑ)์ ์ปดํฌ์ ๋ธ ๋ณธ๋ฌธ์์ ๋๊ธฐ์ ์ผ๋ก ํธ์ถํด์ผ ํฉ๋๋ค. ๋น๋๊ธฐ ์ฝ๋ฐฑ์ด๋ setTimeout ์์์๋ ์ ๋ ํธ์ถํ๋ฉด ์ ๋ฉ๋๋ค. Vue๋ ํธ์ถ ์์ ์ ํ์ฑํ๋ ์ปดํฌ๋ํธ ์ธ์คํด์ค์ ์ด๋ฌํ ํ
์ ์ฐ๊ฒฐํฉ๋๋ค. ์ง์ฐ๋ ํธ์ถ์ ๋ฌด์ ์๋ฌ๋ฅผ ๋ฐ์์ํค๊ฑฐ๋ ์๋ชป๋ ์ปดํฌ๋ํธ์ ํ
์ ์ฐ๊ฒฐํ ์ ์์ต๋๋ค.
์ปดํฌ์ ๋ธ์ ํฉ์ฑ
์ปดํฌ์ ๋ธ์ ์ง์ ํ ๊ฐ์ ์ ์๋ก ์กฐํฉํ๋ ๋ฅ๋ ฅ์ ์์ต๋๋ค. ์์ ์์ค์ ์ปดํฌ์ ๋ธ์ด ์ฌ๋ฌ ์ ๋ฌธํ๋ ์ปดํฌ์ ๋ธ์ ํตํฉํ์ฌ, ๊ด์ฌ์ฌ์ ๋ช ํํ ๋ถ๋ฆฌ๋ฅผ ์ ์งํ๋ฉด์ ๋ณต์กํ ๊ธฐ๋ฅ์ ๊ตฌ์ถํ ์ ์์ต๋๋ค. ๋ค์ ํจํด์ ๋๋ฐ์ด์ค ๊ฒ์, ํ์ด์ง๋ค์ด์ , ๋ฐ์ดํฐ ํ์นญ์ ๊ฒฐํฉํฉ๋๋ค.
import { ref, computed, watch, type Ref } from 'vue'
import { useFetchData } from './useFetchData'
import { useDebouncedRef } from './useDebouncedRef'
interface UsePaginatedSearchReturn<T> {
query: Ref<string>
page: Ref<number>
results: Ref<T[] | null>
totalPages: Ref<number>
isLoading: Ref<boolean>
error: Ref<string | null>
nextPage: () => void
prevPage: () => void
}
export function usePaginatedSearch<T>(
baseUrl: string,
perPage = 20
): UsePaginatedSearchReturn<T> {
const query = useDebouncedRef('', 300)
const page = ref(1)
const totalPages = ref(1)
const apiUrl = computed(
() => `${baseUrl}?q=${encodeURIComponent(query.value)}&page=${page.value}&limit=${perPage}`
)
const { data, error, isLoading } = useFetchData<{ items: T[]; total: number }>(apiUrl)
const results = computed(() => data.value?.items ?? null)
watch(data, (response) => {
if (response) {
totalPages.value = Math.ceil(response.total / perPage)
}
})
// Reset to page 1 when query changes
watch(query, () => { page.value = 1 })
function nextPage() {
if (page.value < totalPages.value) page.value++
}
function prevPage() {
if (page.value > 1) page.value--
}
return { query, page, results, totalPages, isLoading, error, nextPage, prevPage }
}์ด ์ปดํฌ์ ๋ธ์ ์ฌ๋ฌ ๊ณ ๊ธ ํฉ์ฑ ๊ธฐ๋ฒ์ ๋ณด์ฌ์ค๋๋ค. useDebouncedRef๋ ํ์ด๋ฐ ์ ์ด ๋ก์ง์ ์บก์ํํ์ฌ, ํค ์
๋ ฅ๋ง๋ค API์ ๊ณผ๋ํ ์์ฒญ์ด ์ ์ก๋๋ ๊ฒ์ ๋ฐฉ์งํฉ๋๋ค. computed์ธ apiUrl์ ์ฟผ๋ฆฌ ๋๋ ํ์ด์ง๊ฐ ๋ณ๊ฒฝ๋๋ฉด ์๋์ผ๋ก URL์ ์ฌ๊ตฌ์ฑํ์ฌ, useFetchData๋ฅผ ํตํด ์๋ก์ด ๋ฐ์ดํฐ ํ์นญ์ ํธ๋ฆฌ๊ฑฐํฉ๋๋ค.
query์ ๋ํ watch๋ ์๋ก์ด ๊ฒ์์ด ์ด๋ฃจ์ด์ง ๋๋ง๋ค ํ์ด์ง๋ฅผ 1๋ก ์ด๊ธฐํํฉ๋๋ค. ์ฌ์ฉ์๊ฐ ๊ธฐ๋ํ๋ ๋์์ด์ง๋ง, ๋จ์ํ ๊ตฌํ์์๋ ์ข
์ข
๊ฐ๊ณผ๋๋ ๋ถ๋ถ์
๋๋ค. ์ด๋ฌํ ์ธ๋ถ ์ฌํญ์ ๋ก์ง์ ์ปดํฌ๋ํธ์ ๋ถ์ฐ์ํค์ง ์๊ณ ์ปดํฌ์ ๋ธ์ ์บก์ํํ๋ ๊ฒ์ด ์ค์ํ ์ด์ ๋ฅผ ๋ณด์ฌ์ค๋๋ค.
์ปดํฌ์ ๋ธ์ ํฉ์ฑ์ ๋จ์ผ ์ฑ
์ ์์น์ ๋ฐ๋ฆ
๋๋ค. useDebouncedRef๋ ๋๋ฐ์ด์ฑ์ ๊ด๋ฆฌํ๊ณ , useFetchData๋ ์์ฒญ-์๋ต ์ฃผ๊ธฐ๋ฅผ ๊ด๋ฆฌํ๋ฉฐ, usePaginatedSearch๊ฐ ์ ์ฒด๋ฅผ ์กฐ์จํฉ๋๋ค. ์ด ์ํคํ
์ฒ ๋๋ถ์ ๊ฐ ๋ ์ด์ด์ ๋จ์ ํ
์คํธ๋ฅผ ๋
๋ฆฝ์ ์ผ๋ก ์ํํ ์ ์์ต๋๋ค.
Vue.js / Nuxt.js ๋ฉด์ ์ค๋น๊ฐ ๋์ จ๋์?
์ธํฐ๋ํฐ๋ธ ์๋ฎฌ๋ ์ดํฐ, flashcards, ๊ธฐ์ ํ ์คํธ๋ก ์ฐ์ตํ์ธ์.
provide/inject๋ฅผ ํ์ฉํ ์์กด์ฑ ์ฃผ์
์ปดํฌ๋ํธ ํธ๋ฆฌ์์ ๋ฉ๋ฆฌ ๋จ์ด์ง ์ปดํฌ๋ํธ ๊ฐ์ ์ํ๋ฅผ ๊ณต์ ํด์ผ ํ๋ ๊ฒฝ์ฐ, Vue์ provide/inject ์์คํ
์ props ๋๋ฆด๋ง์ ๋ํ ์ฐ์ํ ๋์์ ์ ๊ณตํฉ๋๋ค. ์ปดํฌ์ ๋ธ์ ์ด ๋ฉ์ปค๋์ฆ์ ์บก์ํํ์ฌ ๊น๋ํ๊ณ ํ์
์์ ํ API๋ฅผ ์ ๊ณตํ ์ ์์ต๋๋ค.
import { provide, inject, ref, readonly, type InjectionKey, type Ref } from 'vue'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContext {
theme: Readonly<Ref<Theme>>
setTheme: (t: Theme) => void
resolvedTheme: Readonly<Ref<'light' | 'dark'>>
}
const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
export function provideTheme(initial: Theme = 'system') {
const theme = ref<Theme>(initial)
const resolvedTheme = computed<'light' | 'dark'>(() => {
if (theme.value !== 'system') return theme.value
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
function setTheme(t: Theme) {
theme.value = t
}
const context: ThemeContext = {
theme: readonly(theme),
setTheme,
resolvedTheme: readonly(resolvedTheme)
}
provide(ThemeKey, context)
return context
}
export function useTheme(): ThemeContext {
const context = inject(ThemeKey)
if (!context) {
throw new Error('useTheme() requires a parent component to call provideTheme()')
}
return context
}Symbol๊ณผ ํจ๊ป ํ์
์ด ์ง์ ๋ InjectionKey๋ฅผ ์ฌ์ฉํ๋ฉด ์ฃผ์
ํค์ ๊ณ ์ ์ฑ์ด ๋ณด์ฅ๋๊ณ , inject ํธ์ถ ์ ์๋์ผ๋ก ํ์
์ด ์ถ๋ก ๋ฉ๋๋ค. ์ด ํจํด์ ๋ฌธ์์ด ๊ธฐ๋ฐ ํค์์ ๋ฐ์ํ๋ ํค ์ถฉ๋ ์ํ์ ์ ๊ฑฐํฉ๋๋ค.
provideTheme๊ณผ useTheme์ ๋ถ๋ฆฌ๋ ๋ช
ํํ ์ํคํ
์ฒ ๊ฒฝ๊ณ๋ฅผ ํ๋ฆฝํฉ๋๋ค. ๋ฃจํธ ์ปดํฌ๋ํธ(๋๋ ๋ ์ด์์)๊ฐ provideTheme์ ํธ์ถํ์ฌ ์ปจํ
์คํธ๋ฅผ ์ด๊ธฐํํ๊ณ , ํ์ ์ปดํฌ๋ํธ๊ฐ useTheme์ ํธ์ถํ์ฌ ์๋นํฉ๋๋ค. ๋
ธ์ถ๋๋ ref์ readonly๋ฅผ ์ ์ฉํ๋ฉด ์๋น์ ์ธก์์์ ์๋์น ์์ ๋ณ์ด๋ฅผ ๋ฐฉ์งํฉ๋๋ค.
resolvedTheme computed๋ ์์ฃผ ์ฌ์ฉ๋๋ ํจํด์ ๋ณด์ฌ์ค๋๋ค. ์ถ์์ ์ธ ์ค์ ๊ฐ('system')์ ์์คํ
ํ๊ฒฝ ์ค์ ์ ๋ฐ๋ฅธ ๊ตฌ์ฒด์ ์ธ ๊ฐ('light' ๋๋ 'dark')์ผ๋ก ๋ณํํ๋ ๊ฒ์
๋๋ค. ์ด ์์ค์ ์ถ์ํ๋ฅผ ํตํด ์๋น์ ์ปดํฌ๋ํธ์ ๋ก์ง์ด ์๋นํ ๋จ์ํ๋ฉ๋๋ค.
ํผ ์ ํจ์ฑ ๊ฒ์ฌ ์ปดํฌ์ ๋ธ
ํผ ์ ํจ์ฑ ๊ฒ์ฌ๋ ์ปดํฌ์ ๋ธ๋ก์ ์บก์ํ์์ ํฐ ์ด์ ์ ์ป๋ ๋ณต์กํ ์ฌ์ฉ ์ฌ๋ก์ ๋๋ค. ๋ค์ ํจํด์ ๊ท์น ๊ธฐ๋ฐ์ ์ ์ธ์ ์ ํจ์ฑ ๊ฒ์ฌ, ์๋ฌ์ ๋ฆฌ์กํฐ๋ธ ๊ด๋ฆฌ, ์ ์ฒด ์ ํจ ์ํ๋ฅผ ์ ๊ณตํฉ๋๋ค.
import { reactive, computed, type UnwrapNestedRefs } from 'vue'
type ValidationRule<T> = (value: T) => string | true
type FieldRules<T> = { [K in keyof T]?: ValidationRule<T[K]>[] }
interface UseFormReturn<T extends Record<string, any>> {
fields: UnwrapNestedRefs<T>
errors: Record<keyof T, string>
isValid: Ref<boolean>
validate: () => boolean
resetErrors: () => void
}
export function useFormValidation<T extends Record<string, any>>(
initialValues: T,
rules: FieldRules<T>
): UseFormReturn<T> {
const fields = reactive({ ...initialValues }) as UnwrapNestedRefs<T>
const errors = reactive(
Object.keys(initialValues).reduce(
(acc, key) => ({ ...acc, [key]: '' }),
{} as Record<keyof T, string>
)
)
function validate(): boolean {
let valid = true
for (const key of Object.keys(rules) as (keyof T)[]) {
const fieldRules = rules[key] || []
errors[key] = '' as any
for (const rule of fieldRules) {
const result = rule(fields[key])
if (result !== true) {
errors[key] = result as any
valid = false
break // Stop at first error per field
}
}
}
return valid
}
function resetErrors() {
for (const key of Object.keys(errors)) {
(errors as any)[key] = ''
}
}
const isValid = computed(() =>
Object.values(errors).every((e) => e === '')
)
return { fields, errors, isValid, validate, resetErrors }
}์ด ์ปดํฌ์ ๋ธ์ fields ๊ฐ์ฒด์ ref ๋์ reactive๋ฅผ ์ฌ์ฉํฉ๋๋ค. ์ด๋ฅผ ํตํด .value ์์ด ์์ฑ์ ์ง์ ์ ๊ทผํ ์ ์์ต๋๋ค. ์ ํจ์ฑ ๊ฒ์ฌ ๊ท์น ์์คํ
์ ๊ฐ๋จํ ๊ท์น์ ๋ฐ๋ฆ
๋๋ค. ๊ฐ ๊ท์น์ ์ฑ๊ณต ์ true๋ฅผ, ์คํจ ์ ์๋ฌ ๋ฉ์์ง ๋ฌธ์์ด์ ๋ฐํํฉ๋๋ค. break ๋ฌธ์ ํ๋๋น ์ฒซ ๋ฒ์งธ ์๋ฌ์์ ์ ํจ์ฑ ๊ฒ์ฌ๋ฅผ ์ค๋จํ์ฌ, ์๋ฌ ๋ฉ์์ง๊ฐ ๋์ ๋๋ ๊ฒ์ ๋ฐฉ์งํฉ๋๋ค.
FieldRules<T> ํ์
์ TypeScript์ ๋งคํ๋ ํ์
(mapped types)์ ํ์ฉํ์ฌ ๊ท์น์ด ํผ ํ๋์ ์ ํํ๊ฒ ๋์ํ๋๋ก ๋ณด์ฅํฉ๋๋ค. ์ด ์ ๊ทผ ๋ฐฉ์์ ์ปดํ์ผ ํ์์ ํ์
์๋ฌ๋ฅผ ๊ฐ์งํ์ฌ ํ๋ก๋์
๋ฒ๊ทธ๋ฅผ ์ค์
๋๋ค.
isValid computed๋ ํผ ์ ์ฒด ์ํ์ ๋ํ ๋ฆฌ์กํฐ๋ธ ์ธ๋์ผ์ดํฐ๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ ์ถ ๋ฒํผ์ ํ์ฑํ/๋นํ์ฑํ์ ์ปดํฌ๋ํธ ์ธก์ ์ถ๊ฐ ๋ก์ง์ด ํ์ ์์ต๋๋ค.
์ปดํฌ์ ๋ธ ํ ์คํธ
์ปดํฌ์ ๋ธ์ ๋ฆฌ์กํฐ๋นํฐ ์์คํ
๊ณผ ๋ผ์ดํ์ฌ์ดํด ํ
์ ์์กดํ๋ฏ๋ก, ์ฌ๋ฐ๋ฅด๊ฒ ๋์ํ๋ ค๋ฉด ํ์ฑํ๋ Vue ์ปจํ
์คํธ๊ฐ ํ์ํฉ๋๋ค. ํ์ค ๊ธฐ๋ฒ์ setup ์์์ ์ปดํฌ์ ๋ธ์ ์ธ์คํด์คํํ๋ ์ต์ํ์ ๋ํผ ์ปดํฌ๋ํธ๋ฅผ ์์ฑํ๋ ๊ฒ์
๋๋ค. Vitest์ Vue Test Utils๊ฐ ํ์ํ ๋๊ตฌ๋ฅผ ์ ๊ณตํฉ๋๋ค.
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useCounter } from './useCounter'
function withSetup<T>(composable: () => T): { result: T; unmount: () => void } {
let result!: T
const wrapper = mount(
defineComponent({
setup() {
result = composable()
return () => h('div')
}
})
)
return { result, unmount: () => wrapper.unmount() }
}
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = withSetup(() => useCounter())
expect(result.count.value).toBe(0)
})
it('respects min and max boundaries', () => {
const { result } = withSetup(() =>
useCounter({ min: 0, max: 3, initialValue: 3 })
)
result.increment()
expect(result.count.value).toBe(3) // Capped at max
result.count.value = 0
result.decrement()
expect(result.count.value).toBe(0) // Capped at min
})
it('computes doubled value reactively', () => {
const { result } = withSetup(() => useCounter({ initialValue: 5 }))
expect(result.doubled.value).toBe(10)
result.increment()
expect(result.doubled.value).toBe(12)
})
})์ ํธ๋ฆฌํฐ ํจ์ withSetup์ ๋ชจ๋ ์ปดํฌ์ ๋ธ์ ํ
์คํธ์ ์ฌ์ฌ์ฉํ ์ ์๋ ํจํด์
๋๋ค. ์ ํจํ ๋ฆฌ์กํฐ๋ธ ์ปจํ
์คํธ ๋ด์์ ์ปดํฌ์ ๋ธ์ ์คํํ๋ ๊ฒ์ด ์ ์ผํ ์ญํ ์ธ ์ต์ํ์ ์ปดํฌ๋ํธ๋ฅผ ๋ง์ดํธํฉ๋๋ค. unmount ๋ฉ์๋์ ๋ฐํ์ ํตํด onUnmounted ํ
์ ์ ๋ฆฌ ๋์๋ ํ
์คํธํ ์ ์์ต๋๋ค.
ํ ์คํธ๋ ์ธ ๊ฐ์ง ํต์ฌ ์ธก๋ฉด์ ๋ค๋ฃน๋๋ค. ๊ธฐ๋ณธ๊ฐ์ผ๋ก์ ์ด๊ธฐํ, ๋น์ฆ๋์ค ์ ์ฝ ์กฐ๊ฑด(min/max ๊ฒฝ๊ณ)์ ์ค์, ๊ณ์ฐ๋ ๊ฐ์ ๋ฆฌ์กํฐ๋นํฐ์ ๋๋ค. ์ด ์์ค์ ์ปค๋ฒ๋ฆฌ์ง๋ ํ๋ก๋์ ์ปดํฌ์ ๋ธ์ ๊ธฐ๋๋๋ ์ต์ํ์ ํ ์คํธ์ ๋๋ค.
useFetchData์ ๊ฐ์ ๋น๋๊ธฐ ์ปดํฌ์ ๋ธ์ ๊ฒฝ์ฐ, fetch์ ๋ชจํน๊ณผ flushPromises()๋ฅผ ์ฌ์ฉํ์ฌ ์ด์์
์ ์ Promise ํด๊ฒฐ์ ๋๊ธฐํด์ผ ํฉ๋๋ค. ์ด ์ฃผ์ ๋ Vue์์์ ๋น๋๊ธฐ ํ
์คํธ ์๋ จ๋๋ฅผ ํ๊ฐํ๋ ๋ํ์ ์ธ ๋ฉด์ ์ง๋ฌธ์
๋๋ค.
VueUse๋ ๋ธ๋ผ์ฐ์ ์ธํฐ๋์ , ์ผ์, ์ ๋๋ฉ์ด์ , ๋ฆฌ์กํฐ๋ธ ์ ํธ๋ฆฌํฐ๋ฅผ ํฌ๊ดํ๋ 200๊ฐ ์ด์์ ์ปดํฌ์ ๋ธ์ ์ ๊ณตํฉ๋๋ค. ์ปค์คํ ์ปดํฌ์ ๋ธ์ ๊ฐ๋ฐํ๊ธฐ ์ ์ VueUse๊ฐ ์ด๋ฏธ ํด๊ฒฐ์ฑ ์ ์ ๊ณตํ๊ณ ์๋์ง ํ์ธํ๋ ๊ฒ์ด ๊ถ์ฅ๋ฉ๋๋ค. ๊ทธ๋ฌ๋ ํ๋ก๋์ ํ๊ฒฝ์์ ์ด๋ฌํ ์ปดํฌ์ ๋ธ์ ์ ์, ํ์ฅ, ๋๋ฒ๊น ํ๋ ค๋ฉด ๊ธฐ๋ฐ์ด ๋๋ ํจํด์ ๋ํ ์ดํด๊ฐ ํ์์ ์ ๋๋ค.
๊ธฐ์ ๋ฉด์ ์์ฃผ ์ถ์ ๋๋ ์ง๋ฌธ
Vue 3 ์ปดํฌ์ ๋ธ์ 2026๋ ๊ธฐ์ ๋ฉด์ ์์ ๋น ์ง ์ ์๋ ์ฃผ์ ์ ๋๋ค. ๋ค์ ์ง๋ฌธ๋ค์ ์ค๊ธ๋ถํฐ ์๋์ด ๋ ๋ฒจ๊น์ง ๋ฉด์ ๊ด์ด ํ๊ฐํ๋ ํต์ฌ ํฌ์ธํธ๋ฅผ ๋ค๋ฃน๋๋ค.
์ปดํฌ์ ๋ธ๊ณผ ๋ฏน์ค์ธ์ ์ฐจ์ด์ ์ ๋ฌด์์ ๋๊น? Vue 2์ ๋ฏน์ค์ธ์ ์ต์ ์ ์ปดํฌ๋ํธ์ ๋ณํฉํ์ฌ ์ด๋ฆ ์ถฉ๋, ๋ฐ์ดํฐ ์ถ์ฒ์ ๋ถ๋ช ํํจ, ํ์ ์ง์ ์ ๋ถ์ฌ๋ฅผ ์ด๋ํฉ๋๋ค. ์ปดํฌ์ ๋ธ์ ํ์ ์ด ์ง์ ๋ ๋ฐํ๊ฐ์ ํตํด ๋ช ์์ ์ธ ์ธํฐํ์ด์ค๋ฅผ ๋ ธ์ถํ๊ณ , ๊ตฌ์กฐ ๋ถํด ํ ๋น์ผ๋ก ์ด๋ฆ ๋ณ๊ฒฝ์ด ๊ฐ๋ฅํ๋ฉฐ, ์์กด์ฑ์ ํฌ๋ช ํ๊ฒ ๋ง๋ญ๋๋ค. ์ ์ง๋ณด์์ฑ, ํ ์คํธ ์ฉ์ด์ฑ, TypeScript ํธํ์ฑ ๋ชจ๋ ์ธก๋ฉด์์ ์ปดํฌ์ ๋ธ์ด ์ฐ์ํฉ๋๋ค.
์ปดํฌ์ ๋ธ ๋ด์ ๋ถ์ ํจ๊ณผ(side effect)๋ ์ด๋ป๊ฒ ๊ด๋ฆฌํด์ผ ํฉ๋๊น?
๋ชจ๋ ๋ถ์ ํจ๊ณผ(์ด๋ฒคํธ ๋ฆฌ์ค๋, ํ์ด๋จธ, WebSocket ๊ตฌ๋
)๋ onUnmounted ํ
๋๋ watchEffect์ ํด๋ฆฐ์
๋ฉ์ปค๋์ฆ์์ ์ ๋ฆฌ๋์ด์ผ ํฉ๋๋ค. ์ด ์ ๋ฆฌ๋ฅผ ์๋ตํ๋ฉด ๋ฉ๋ชจ๋ฆฌ ๋์์ ์ปดํฌ๋ํธ ์ฌ๋ง์ดํธ ์ ์์ธกํ ์ ์๋ ๋์์ด ๋ฐ์ํฉ๋๋ค.
setup() ์ธ๋ถ์์ ์ปดํฌ์ ๋ธ์ ํธ์ถํ ์ ์์ต๋๊น?
๋ถ๊ฐ๋ฅํฉ๋๋ค. ์ปดํฌ์ ๋ธ์ ์ปดํฌ๋ํธ์ setup() ํจ์ ๋ด๋ถ ๋๋ ๋ค๋ฅธ ์ปดํฌ์ ๋ธ ๋ด๋ถ์์ ๋๊ธฐ์ ์ผ๋ก ํธ์ถ๋์ด์ผ ํฉ๋๋ค. ์ด ์ปจํ
์คํธ ์ธ๋ถ์์์ ํธ์ถ์ Vue๊ฐ ๋ผ์ดํ์ฌ์ดํด ํ
๊ณผ ๋ฆฌ์กํฐ๋นํฐ๋ฅผ ์ปดํฌ๋ํธ ์ธ์คํด์ค์ ์ฐ๊ฒฐํ๋ ๊ฒ์ ๋ฐฉํดํฉ๋๋ค.
์ปดํฌ์ ๋ธ๋ก ์ ์ญ ์ํ๋ฅผ ๊ณต์ ํ๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผ ํฉ๋๊น?
์ฃผ๋ก ๋ ๊ฐ์ง ์ ๊ทผ ๋ฐฉ์์ด ์์ต๋๋ค. ์ฑ๊ธํค ํจํด์ ์ปดํฌ์ ๋ธ ํจ์ ์ธ๋ถ(๋ชจ๋ ์์ค)์์ ๋ฆฌ์กํฐ๋ธ ์ธ์คํด์ค๋ฅผ ์์ฑํ์ฌ ๋ชจ๋ ์๋น์ ๊ฐ์ ๊ณต์ ํฉ๋๋ค. provide/inject ํจํด์ ์ปดํฌ๋ํธ ํธ๋ฆฌ๋ฅผ ํ์ฉํ์ฌ ์ํ๋ฅผ ์ปจํ
์คํธ์ ์ผ๋ก ์ ํํฉ๋๋ค. ์ง์ ํ ์ ์ญ ์ํ์๋ ์ฑ๊ธํค์, ํ์ ํธ๋ฆฌ์ ๋ฒ์๊ฐ ์ง์ ๋ ์ํ์๋ provide/inject๊ฐ ์ ํฉํฉ๋๋ค.
๋ผ์ดํ์ฌ์ดํด ํ
์ ์ฌ์ฉํ๋ ์ปดํฌ์ ๋ธ์ ์ด๋ป๊ฒ ํ
์คํธํฉ๋๊น?
์์ ์๊ฐํ withSetup ๊ธฐ๋ฒ์ ํ์ํ Vue ์ปจํ
์คํธ๋ฅผ ์ ๊ณตํ๋ ์ต์ํ์ ๋ํผ ์ปดํฌ๋ํธ๋ฅผ ์์ฑํฉ๋๋ค. onMounted์ ๊ฐ์ ํ
์ ๊ฒฝ์ฐ Vue Test Utils๋ฅผ ์ฌ์ฉํ์ฌ ์ค์ ๋๋ ๊ฐ์ DOM์ ์ปดํฌ๋ํธ๋ฅผ ๋ง์ดํธํด์ผ ํฉ๋๋ค. ๋ผ์ดํ์ฌ์ดํด ํ
์ ์ฌ์ฉํ์ง ์๋ ์์ ๋ฆฌ์กํฐ๋ธ ์ปดํฌ์ ๋ธ์ Vue์ effectScope๋ก ๋ ๊ฐ๊ฒฐํ๊ฒ ํ
์คํธํ ์ ์์ต๋๋ค.
SSR ํ๊ฒฝ์์ ๋น๋๊ธฐ ์ปดํฌ์ ๋ธ์๋ ์ด๋ค ์ ๋ต์ด ํ์ํฉ๋๊น?
SSR ์ปจํ
์คํธ(Nuxt ๋๋ Vue ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง)์์๋ ๋น๋๊ธฐ ์ปดํฌ์ ๋ธ์ ํน๋ณํ ์ฃผ์๊ฐ ํ์ํฉ๋๋ค. onMounted๋ ์๋ฒ ์ธก์์ ์คํ๋์ง ์์ผ๋ฉฐ, fetch ํธ์ถ์ ํ๋ ์์ํฌ์ SSR ๋ฉ์ปค๋์ฆ(Nuxt์ useAsyncData ๋ฑ)์ ํตํด ๊ด๋ฆฌ๋์ด์ผ ํฉ๋๋ค. ์ปดํฌ์ ๋ธ์ ์คํ ํ๊ฒฝ์ ๊ฐ์งํ๊ณ ๊ทธ์ ๋ฐ๋ผ ๋์์ ์กฐ์ ํด์ผ ํฉ๋๋ค.
์ฐ์ต์ ์์ํ์ธ์!
๋ฉด์ ์๋ฎฌ๋ ์ดํฐ์ ๊ธฐ์ ํ ์คํธ๋ก ์ง์์ ํ ์คํธํ์ธ์.
๊ฒฐ๋ก
Vue 3์ ์ปดํฌ์ ๋ธ์ ๋ฏน์ค์ธ์์์ ๋จ์ํ ๊ตฌ๋ฌธ์ ์งํ๊ฐ ์๋๋๋ค. ํ๋ก ํธ์๋ ์ ํ๋ฆฌ์ผ์ด์ ์์ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ๋ก์ง์ ๊ตฌ์กฐํํ๋ ๋ฐฉ๋ฒ์ ๊ดํ ํจ๋ฌ๋ค์์ ์ ํ์ ๊ตฌํํฉ๋๋ค. ๊ทธ ์๋ฌ์ ํ๋ก๋์ ๊ฐ๋ฐ๊ณผ ๊ธฐ์ ๋ฉด์ ๋ชจ๋์์ ์๋นํ ๊ฒฝ์๋ ฅ์ด ๋ฉ๋๋ค.
ํต์ฌ ์ฌํญ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ์๊ฒฉํ ํ์ ์ ์: ์ต์ ๊ณผ ๋ฐํ๊ฐ์ ๋ช ์์ ์ธํฐํ์ด์ค๋ฅผ ์ ์ํ๋ฉด ์ ๋ขฐ์ฑ๊ณผ ๊ฐ๋ฐ์ ๊ฒฝํ์ด ํฅ์๋ฉ๋๋ค
- ๋ผ์ดํ์ฌ์ดํด ๊ด๋ฆฌ: ๋ฉ๋ชจ๋ฆฌ ๋์๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด ๋ถ์ ํจ๊ณผ๋ ํญ์
onUnmounted์์ ์ ๋ฆฌํด์ผ ํฉ๋๋ค - ํฉ์ฑ: ๋ชจ๋๋ฆฌ์ ์ปดํฌ์ ๋ธ์ด ์๋, ์กฐํฉ ๊ฐ๋ฅํ ์ ๋ฌธํ๋ ์ปดํฌ์ ๋ธ์ ์ค๊ณํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค
- ์ ๋ฐํ ๋ฆฌ์กํฐ๋นํฐ:
MaybeRefOrGetter,toValue,watchEffect๋ฅผ ํ์ฉํ์ฌ ์ ์ฐ์ฑ์ ๊ทน๋ํํฉ๋๋ค - ์์กด์ฑ ์ฃผ์
: ์ปดํฌ๋ํธ ํธ๋ฆฌ ๋ด ์ํ ๊ณต์ ์๋ ํ์
์ด ์ง์ ๋
InjectionKey์provide/inject๋ฅผ ์ฌ์ฉํฉ๋๋ค - ํ
์คํธ ์ฉ์ด์ฑ:
withSetup๊ธฐ๋ฒ์ ๊ฐ ์ปดํฌ์ ๋ธ์ ๋ ๋ฆฝ์ ์ผ๋ก ๊ฒ์ฆํ๊ธฐ ์ํ ์ต์ํ์ ๋ฆฌ์กํฐ๋ธ ์ปจํ ์คํธ๋ฅผ ์ ๊ณตํฉ๋๋ค - ์ ํจ์ฑ ๊ฒ์ฌ: ๋น์ฆ๋์ค ๊ท์น์ ์ ํจ์ฑ ๊ฒ์ฌ ์ปดํฌ์ ๋ธ์ ์บก์ํํ๋ฉด ์ ํ๋ฆฌ์ผ์ด์ ์ ์ฒด์ ์ผ๊ด์ฑ์ด ๋ณด์ฅ๋ฉ๋๋ค
๊ฒฌ๊ณ ํ ์ปดํฌ์ ๋ธ์ ์ค๊ณํ๊ณ , ํฉ์ฑํ๋ฉฐ, ํ ์คํธํ๋ ๋ฅ๋ ฅ์ 2026๋ Vue ์์ฅ์์ ์๋์ด ๋ ๋ฒจ์ ์ธ์ฌ๋ฅผ ๊ตฌ๋ณํ๋ ๊ธฐ์ค์ ๋๋ค. ์ด๋ฌํ ํจํด์ Vue 3 ๋ฐ Nuxt 3 ํ๋ก๋์ ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ธฐ์ ์ ํ ๋๊ฐ ๋ฉ๋๋ค.
์ฐ์ต์ ์์ํ์ธ์!
๋ฉด์ ์๋ฎฌ๋ ์ดํฐ์ ๊ธฐ์ ํ ์คํธ๋ก ์ง์์ ํ ์คํธํ์ธ์.
ํ๊ทธ
๊ณต์
๊ด๋ จ ๊ธฐ์ฌ

Vue 3 Pinia vs Vuex ์๋ฒฝ ๋น๊ต: 2026๋ ์ํ ๊ด๋ฆฌ ์ ๋ต๊ณผ ๋ฉด์ ํต์ฌ ์ง๋ฌธ
Vue 3 ์ํ๊ณ์์ Pinia์ Vuex๋ฅผ ๋น๊ต ๋ถ์ํฉ๋๋ค. Options Store์ Setup Store ํจํด, TypeScript ํตํฉ, ํฌ๋ก์ค ์คํ ์ด ๊ตฌ์ฑ, SSR ์ง์, Vuex์์ Pinia๋ก์ ๋ง์ด๊ทธ๋ ์ด์ ์ ๋ต, ๊ทธ๋ฆฌ๊ณ 2026๋ ๋ฉด์ ์์ ์์ฃผ ์ถ์ ๋๋ ์ํ ๊ด๋ฆฌ ์ง๋ฌธ์ ์ฝ๋ ์์ ์ ํจ๊ป ์ ๋ฆฌํฉ๋๋ค.

2026๋ Nuxt 4 ์๋ฒฝ ๊ฐ์ด๋: ์๋ก์ด ๋๋ ํฐ๋ฆฌ ๊ตฌ์กฐ์ Nuxt 3 ๋ง์ด๊ทธ๋ ์ด์ ์ ๋ต
Nuxt 4์์ ๋์ ๋ app/ ๋๋ ํฐ๋ฆฌ ๊ตฌ์กฐ, ์ฑ๊ธํค ๋ฐ์ดํฐ ํจ์นญ ๋ ์ด์ด, shallow reactivity, TypeScript ์ปจํ ์คํธ ๋ถ๋ฆฌ๋ฅผ ์ฝ๋ ์์ ์ ํจ๊ป ์์ธํ ๋ถ์ํฉ๋๋ค.

Vue.js ๋ฉด์ ํต์ฌ ์ง๋ฌธ: ํฉ๊ฒฉ์ ์ํ 25๋ฌธํญ
Vue.js ๋ฉด์ ์ ์ํ 25๊ฐ์ ํต์ฌ ์ง๋ฌธ. ๋ฐ์์ฑ๋ถํฐ composables๊น์ง, ๋ค์ ๋ฉด์ ์์ ๋น๋ ํต์ฌ ๊ฐ๋ ์ ์ ๋ฆฌํฉ๋๋ค.