VueUse로 자주 쓰는 Composable 가져다 쓰기: 상태·브라우저·센서·요소 유틸, 다크 모드 예제

이 글의 핵심

VueUse가 제공하는 Composable을 상태·브라우저·센서·요소·유틸리티로 나눠 자주 쓰는 것을 소개하고, useDark와 useLocalStorage로 다크 모드를 구현하는 예제를 다룹니다.

이 글의 핵심

VueUse에서 자주 쓰는 Composable을 정리한 글입니다. useFetch, useLocalStorage, useMouse 등을 예제로 다룹니다.

실무에서 마주치는 문제들

같은 로직을 반복해요

localStorage 값을 ref와 동기화하는 코드를 직접 쓰면, 초기값 읽기, JSON.parse 예외 처리, 값이 바뀔 때 저장하는 watch, 다른 탭에서 바뀐 값을 받는 storage 이벤트, 컴포넌트가 사라질 때 리스너 해제까지 매번 챙겨야 합니다. 프로젝트마다 비슷한 헬퍼를 만들다 보면 조금씩 다른 버전이 여러 개 생깁니다. VueUse는 이런 반복 패턴을 검증된 Composable로 모아 둔 라이브러리입니다.

브라우저 API가 복잡해요

IntersectionObserver, ResizeObserver, Clipboard API 같은 브라우저 API는 콜백 기반이라 Vue의 반응형 상태와 연결하려면 어댑터 코드가 필요하고, 해제를 잊으면 메모리 누수가 생깁니다. VueUse의 Composable은 결과를 ref로 돌려주고, 컴포넌트가 언마운트될 때 리스너와 옵저버를 자동으로 정리합니다.

반응형 유틸리티가 필요해요

디바운스, 쓰로틀, 토글 같은 작은 유틸리티도 반응형 값과 함께 쓰려면 생각보다 세부 처리가 많습니다. 예를 들어 디바운스된 함수는 컴포넌트가 사라진 뒤에 실행되지 않도록 타이머를 정리해야 합니다.

직접 만든 헬퍼와 비교했을 때의 대가는 의존성입니다. 필요한 Composable 하나 때문에 라이브러리를 추가하게 되고, 메이저 버전이 올라갈 때 일부 API 이름이나 옵션이 바뀌기도 합니다. 트리 셰이킹으로 쓰는 것만 번들에 들어가므로 크기 부담은 작은 편이지만, 몇 줄이면 되는 기능이라면 직접 만드는 쪽이 코드 흐름을 이해하기 쉬울 때도 있습니다. 각 Composable의 소스가 짧고 문서에 링크되어 있어서, 쓰기 전에 한 번 읽어 보면 동작을 정확히 알 수 있습니다.


VueUse가 제공하는 Composable 모음

VueUse는 Vue Composables 컬렉션입니다. 주요 장점:

  • 200+ Composables: 다양한 유틸리티
  • TypeScript: 완벽한 지원
  • Tree-shakable: 필요한 것만
  • SSR 친화적: Nuxt 호환
  • 잘 테스트됨: 안정적

설치와 기본 사용

설치

npm install @vueuse/core

기본 사용

<script setup lang="ts">
import { useMouse, useLocalStorage } from '@vueuse/core';
const { x, y } = useMouse();
const count = useLocalStorage('count', 0);
</script>
<template>
  <div>
    <p>Mouse: {{ x }}, {{ y }}</p>
    <p>Count: {{ count }}</p>
    <button @click="count++">Increment</button>
  </div>
</template>

VueUse의 Composable은 대부분 ref를 반환합니다. useMouse()의 x, y는 마우스가 움직일 때마다 바뀌는 ref이고, useLocalStorage('count', 0)의 count는 읽으면 저장된 값, 쓰면 localStorage에 자동으로 저장되는 ref입니다. 템플릿에서는 ref가 자동으로 풀리므로 count++처럼 쓰면 되고, 스크립트에서는 count.value++로 씁니다. const { x, y } = useMouse()처럼 구조 분해해도 반응성이 유지되는 것은 반환값이 ref들의 객체이기 때문인데, 반대로 reactive 객체를 구조 분해하면 반응성이 끊긴다는 Vue의 규칙과 헷갈리지 않아야 합니다.

useMouse는 window에 이벤트 리스너를 등록하므로 컴포넌트마다 호출하면 리스너가 그만큼 늘어납니다. 여러 컴포넌트에서 마우스 위치가 필요하다면 createSharedComposable(useMouse)로 감싸 하나의 리스너를 공유하게 할 수 있습니다.


useLocalStorage·useSessionStorage로 상태 저장

useLocalStorage

<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core';
const user = useLocalStorage('user', { name: '', email: '' });
</script>
<template>
  <input v-model="user.name" placeholder="Name" />
  <input v-model="user.email" placeholder="Email" />
</template>

기본값의 타입에 따라 직렬화 방식이 정해집니다. 숫자면 숫자로, 객체면 JSON으로 저장하고 읽어 오며, 객체 안의 속성을 v-model로 바꿔도 깊은 변경이 감지되어 저장됩니다. 같은 키를 쓰는 다른 컴포넌트와 다른 브라우저 탭까지 값이 동기화된다는 점도 직접 구현할 때 빠뜨리기 쉬운 부분입니다.

운영 중에 흔히 겪는 문제는 기본값 구조를 바꿀 때입니다. 처음에 { name, email }로 저장된 값이 있는 사용자에게 기본값을 { name, email, phone }으로 바꿔 배포하면, 저장된 이전 객체가 그대로 읽혀서 user.value.phone이 undefined가 됩니다. 기본값은 저장된 값이 없을 때만 쓰이기 때문입니다. useLocalStorage('user', defaults, { mergeDefaults: true }) 옵션을 주면 저장된 값에 새 기본 필드를 합쳐 줍니다. 또 localStorage는 같은 출처의 모든 스크립트가 읽을 수 있으므로 개인 정보나 인증 토큰을 두는 곳으로는 적합하지 않습니다.

useSessionStorage

<script setup lang="ts">
import { useSessionStorage } from '@vueuse/core';
const token = useSessionStorage('token', '');
</script>

사용법은 같고 저장소만 다릅니다. sessionStorage는 탭 단위로 분리되어 탭을 닫으면 사라지고, 새 탭에서는 공유되지 않습니다. 예제처럼 토큰을 두면 탭을 닫을 때 자동으로 로그아웃되는 효과는 있지만, localStorage와 마찬가지로 XSS 공격을 당하면 스크립트가 그대로 읽을 수 있습니다. 보안이 중요한 인증 토큰은 HttpOnly 쿠키에 두고 서버가 관리하는 방식이 더 안전합니다. 단계가 여러 개인 폼의 임시 입력값처럼, 새로고침에서는 살아남되 탭을 닫으면 사라져도 되는 데이터가 sessionStorage에 잘 맞습니다.


useFetch·useClipboard 브라우저 API

useFetch

<script setup lang="ts">
import { useFetch } from '@vueuse/core';
const { data, error, isFetching } = useFetch('/api/users').json();
</script>
<template>
  <div v-if="isFetching">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else>
    <li v-for="user in data" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

useFetch는 호출하는 즉시 요청을 보내고, data, error, isFetching을 ref로 돌려줍니다. 끝의 .json()은 응답을 JSON으로 파싱하라는 지정이라, 빠뜨리면 data에 문자열이 들어가 v-for가 글자 하나씩 순회하는 이상한 결과가 나옵니다. HTTP 상태 코드가 200번대가 아니면 error가 채워지므로 fetch처럼 response.ok를 따로 확인하지 않아도 됩니다.

URL에 ref를 넘기고 { refetch: true } 옵션을 주면 URL이 바뀔 때마다 자동으로 다시 요청합니다. 이전 요청이 끝나기 전에 새 요청이 시작되면 이전 요청을 취소해 응답 순서가 뒤바뀌는 문제도 막아 줍니다. immediate: false로 자동 실행을 끄고 execute()로 원하는 시점에 부를 수도 있습니다. 다만 useFetch에는 캐시, 중복 요청 제거, 창 포커스 시 재조회 같은 기능이 없어서, 여러 화면이 같은 데이터를 공유하는 앱이라면 TanStack Query(Vue Query) 같은 서버 상태 라이브러리가 더 맞습니다. Nuxt에서는 SSR과 연동되는 useFetch가 따로 있어 이름이 같은 두 함수를 혼동하기 쉬운데, Nuxt 페이지에서는 Nuxt의 것을 쓰는 것이 맞습니다.

useClipboard

<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
const { text, copy, copied } = useClipboard();
const handleCopy = () => {
  copy('Hello VueUse!');
};
</script>
<template>
  <button @click="handleCopy">
    {{ copied ? 'Copied!' : 'Copy' }}
  </button>
  <p>Clipboard: {{ text }}</p>
</template>

copy()를 호출하면 클립보드에 텍스트를 쓰고, copied가 잠시(기본 1.5초) true가 되었다가 돌아오므로 “복사됨” 표시를 타이머 없이 만들 수 있습니다. text는 기본 설정에서는 마지막으로 복사한 값을 담을 뿐, 다른 프로그램에서 복사한 클립보드 내용을 읽어 오지는 않습니다. 시스템 클립보드를 읽으려면 { read: true } 옵션이 필요하고, 브라우저가 권한을 물어봅니다.

Clipboard API는 보안 컨텍스트(HTTPS 또는 localhost)에서만 동작합니다. 개발 중에는 잘 되던 복사 버튼이 사내 테스트 서버의 http:// 주소에서는 아무 반응이 없는 경우가 흔한데, 이 때문입니다. isSupported 값으로 지원 여부를 확인할 수 있고, { legacy: true } 옵션을 주면 지원되지 않는 환경에서 예전 execCommand('copy') 방식으로 대체합니다.


useMouse·useScroll 센서

useMouse

<script setup lang="ts">
import { useMouse } from '@vueuse/core';
const { x, y } = useMouse();
</script>
<template>
  <div>Mouse: {{ x }}, {{ y }}</div>
</template>

좌표 기준은 기본적으로 페이지(page)이며, useMouse({ type: 'client' })로 뷰포트 기준으로 바꿀 수 있습니다. 터치 기기에서도 동작하고, 서버 렌더링 중에는 window가 없으므로 0으로 시작했다가 클라이언트에서 실제 값으로 바뀝니다. 특정 요소 안에서의 상대 좌표가 필요하다면 useMouseInElement(target)이 요소 기준 좌표와 요소 밖으로 나갔는지 여부(isOutside)를 함께 알려 줍니다.

useScroll

<script setup lang="ts">
import { useScroll } from '@vueuse/core';
import { ref } from 'vue';
const el = ref<HTMLElement>();
const { x, y, isScrolling, arrivedState } = useScroll(el);
</script>
<template>
  <div ref="el" style="height: 300px; overflow: auto;">
    <div style="height: 1000px;">
      <p>Scroll Y: {{ y }}</p>
      <p>Is Scrolling: {{ isScrolling }}</p>
      <p>At Top: {{ arrivedState.top }}</p>
      <p>At Bottom: {{ arrivedState.bottom }}</p>
    </div>
  </div>
</template>

ref="el"로 연결한 템플릿 ref를 넘기면, 요소가 마운트된 뒤 리스너가 붙고 언마운트되면 떨어집니다. Composable이 ref를 받는 이유가 이것으로, 호출 시점(setup)에는 아직 DOM이 없지만 ref가 채워지면 VueUse가 이를 감지해 동작을 시작합니다. window 전체 스크롤을 보고 싶다면 useScroll(window)를 씁니다.

arrivedState.bottom은 무한 스크롤을 만들 때 유용합니다. 다만 스크롤 이벤트는 초당 수십 번 발생하므로 y를 watch해서 매번 무거운 작업을 하면 버벅일 수 있고, 무한 스크롤 자체는 끝 도달 감지와 중복 요청 방지까지 처리해 주는 useInfiniteScroll을 쓰는 편이 간단합니다. { throttle: 100 } 옵션으로 갱신 빈도를 줄일 수도 있습니다.


useIntersectionObserver·useElementSize 요소 관찰

useIntersectionObserver

<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core';
import { ref } from 'vue';
const target = ref<HTMLElement>();
const isVisible = ref(false);
useIntersectionObserver(target, ([{ isIntersecting }]) => {
  isVisible.value = isIntersecting;
});
</script>
<template>
  <div ref="target">
    <p v-if="isVisible">I'm visible!</p>
  </div>
</template>

IntersectionObserver는 요소가 뷰포트에 들어오거나 나갈 때만 콜백을 호출하므로, 스크롤할 때마다 위치를 계산하는 방식보다 훨씬 가볍습니다. 콜백의 첫 인자가 관찰 항목 배열이라 ([{ isIntersecting }])처럼 구조 분해했습니다. 보이는지 여부만 필요하다면 useElementVisibility(target)이 ref 하나로 결과를 돌려주어 더 간단합니다.

이미지 지연 로딩이나 “화면에 보이면 조회수 집계” 같은 용도로 쓸 때는, 한 번 보인 뒤 관찰을 멈추는 것이 좋습니다. 반환값의 stop()을 콜백 안에서 호출하면 됩니다. 주의할 점은 관찰 대상 요소의 크기입니다. 예제처럼 내용이 v-if로 숨겨진 상태에서 div의 높이가 0이면, 브라우저에 따라 교차 판정이 기대와 다르게 나올 수 있으므로 대상 요소에 최소 높이를 주는 편이 안정적입니다.

useElementSize

<script setup lang="ts">
import { useElementSize } from '@vueuse/core';
import { ref } from 'vue';
const el = ref<HTMLElement>();
const { width, height } = useElementSize(el);
</script>
<template>
  <div ref="el">
    Size: {{ width }} x {{ height }}
  </div>
</template>

ResizeObserver를 감싼 것이라 창 크기 변화뿐 아니라 부모 레이아웃이나 내용이 바뀌어 요소 크기가 달라질 때도 반응합니다. 차트나 캔버스처럼 크기를 픽셀 값으로 알아야 그릴 수 있는 컴포넌트에 특히 유용합니다. 기본으로 반환되는 값은 content-box 크기라서 패딩과 테두리가 빠져 있으므로, 전체 크기가 필요하면 { box: 'border-box' } 옵션을 넘깁니다.

크기 값을 받아 다시 요소의 크기를 바꾸는 코드를 쓰면 크기 변화 → 콜백 → 크기 변화가 반복되며 콘솔에 ResizeObserver loop completed with undelivered notifications 경고가 뜰 수 있습니다. 대부분 무해하지만, 레이아웃이 계속 흔들린다면 크기에 따라 바뀌는 스타일을 CSS 컨테이너 쿼리로 옮기는 것도 방법입니다.


useDebounce·useToggle 유틸리티

useDebounce

<script setup lang="ts">
import { ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
const search = ref('');
const results = ref([]);
const debouncedSearch = useDebounceFn(async () => {
  const response = await fetch(`/api/search?q=${search.value}`);
  results.value = await response.json();
}, 500);
</script>
<template>
  <input v-model="search" @input="debouncedSearch" />
  <ul>
    <li v-for="result in results" :key="result.id">{{ result.name }}</li>
  </ul>
</template>

useDebounceFn은 마지막 호출 후 500ms 동안 다시 호출되지 않을 때만 함수를 실행합니다. 검색창에 “vueuse”를 치는 동안 요청이 여섯 번 나가는 대신 입력을 멈춘 뒤 한 번만 나갑니다. 컴포넌트가 사라지면 대기 중인 타이머도 정리됩니다.

이 예제에는 실무에서 자주 문제가 되는 두 가지가 있습니다. 하나는 검색어를 URL에 그대로 넣는다는 점으로, &나 #, 한글이 섞이면 쿼리가 깨지거나 서버가 다르게 해석하므로 encodeURIComponent(search.value)가 필요합니다. 다른 하나는 응답 순서입니다. 디바운스가 요청 수는 줄여 주지만, “vue”로 보낸 느린 요청이 “vueuse”로 보낸 빠른 요청보다 늦게 도착하면 결과 목록이 이전 검색어의 것으로 덮어써집니다. 요청마다 AbortController로 이전 요청을 취소하거나, 응답이 왔을 때 검색어가 여전히 같은지 확인하는 코드를 넣어야 합니다. 값 자체를 디바운스해 쓰고 싶다면 const debounced = refDebounced(search, 500) 후 useFetch에 computed URL과 refetch: true를 조합하는 방법도 있습니다. 이 경우 이전 요청 취소까지 useFetch가 처리해 줍니다.

useToggle

<script setup lang="ts">
import { useToggle } from '@vueuse/core';
const [isOpen, toggle] = useToggle();
</script>
<template>
  <button @click="toggle()">Toggle</button>
  <div v-if="isOpen">Content</div>
</template>

useToggle()을 인자 없이 호출하면 [상태 ref, 토글 함수] 배열을 돌려주고, 이미 있는 ref를 넘기면(다음 예제처럼) 토글 함수만 돌려줍니다. 두 형태의 반환값이 달라서 const [isDark, toggle] = useToggle(isDark)처럼 쓰면 구조 분해가 엉뚱하게 됩니다. 토글 함수에 값을 넘기면(toggle(true)) 뒤집는 대신 그 값으로 설정합니다. 템플릿에서 @click="toggle"처럼 괄호 없이 쓰면 클릭 이벤트 객체가 인자로 넘어가 참 값으로 설정되어 버리므로, 예제처럼 toggle()로 호출해야 합니다.


예제: 다크 모드 토글

<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core';
const isDark = useDark();
const toggleDark = useToggle(isDark);
</script>
<template>
  <button @click="toggleDark()">
    {{ isDark ? '🌙' : '☀️' }}
  </button>
</template>

useDark는 세 가지 일을 한 번에 합니다. 운영체제의 다크 모드 설정(prefers-color-scheme)을 기본값으로 읽고, 사용자가 바꾼 값을 localStorage(vueuse-color-scheme 키)에 저장하며, <html> 요소에 dark 클래스를 붙였다 뗍니다. Tailwind CSS의 darkMode: 'class' 설정과 그대로 맞물리므로, dark:bg-gray-900 같은 클래스를 쓰는 프로젝트라면 이 몇 줄로 다크 모드가 완성됩니다. 다른 클래스 이름이나 속성을 쓰려면 useDark({ selector: 'body', attribute: 'data-theme', valueDark: 'dark' })처럼 지정합니다.

직접 다크 모드를 구현해 보면 가장 까다로운 것이 첫 화면 깜빡임입니다. Vue 앱이 로드되어 useDark가 실행되기 전까지는 기본 라이트 테마로 그려지므로, 다크 모드를 선택한 사용자는 새로고침할 때마다 흰 화면이 잠깐 번쩍이는 것을 보게 됩니다. SPA라면 index.html의 <head>에 localStorage를 읽어 dark 클래스를 먼저 붙이는 짧은 인라인 스크립트를 넣어 해결하고, Nuxt라면 서버 렌더링 단계에서 처리해 주는 @nuxtjs/color-mode 모듈을 쓰는 것이 일반적입니다. 버튼이 아이콘만 표시하므로 스크린 리더를 위해 aria-label="다크 모드 전환"을 붙여 두는 것도 좋습니다.


VueUse 요약

  • VueUse: Vue Composables 컬렉션
  • 200+ Composables: 다양한 유틸리티
  • TypeScript: 완벽한 지원
  • Tree-shakable: 필요한 것만
  • SSR 친화적: Nuxt 호환
  • 잘 테스트됨: 안정적

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. React Hooks와 비슷한가요?

A. 로직을 함수로 묶어 재사용한다는 개념은 같습니다. 차이는 Vue의 Composable이 setup에서 한 번만 실행되고 이후에는 반응형 시스템이 변경을 추적한다는 점입니다. React Hooks처럼 렌더링마다 다시 실행되지 않으므로 의존성 배열이나 호출 순서 규칙을 신경 쓸 필요가 없습니다. 비슷한 역할의 React 라이브러리로는 react-use가 있습니다.

Q. Nuxt에서 사용할 수 있나요?

A. @vueuse/nuxt 모듈을 추가하면 자동 import까지 됩니다. 다만 브라우저 API를 쓰는 Composable은 서버 렌더링 중에 기본값(0, false 등)을 돌려주므로, 서버와 클라이언트 결과가 달라 hydration 경고가 나지 않도록 해당 부분을 <ClientOnly>로 감싸거나 마운트 이후 값만 쓰도록 설계해야 합니다.

Q. 번들 크기는 어떤가요?

A. 각 Composable이 독립된 함수라 트리 셰이킹으로 쓴 것만 번들에 들어갑니다. @vueuse/integrations처럼 외부 라이브러리(axios, focus-trap 등)를 감싼 패키지는 해당 라이브러리를 따로 설치해야 하므로 의존성이 늘어난다는 점만 주의하면 됩니다.

Q. 직접 Composable을 만들 때 참고할 점은?

A. VueUse의 관례를 따르면 됩니다. 인자로 값이나 ref를 모두 받을 수 있게 toValue()로 풀어 쓰고, 결과는 ref로 돌려주며, 등록한 리스너나 타이머는 tryOnScopeDispose로 정리합니다. 이렇게 만들면 VueUse의 Composable과 자연스럽게 조합됩니다.