최근 Nuxt 3 프로젝트에서 @tanstack/vue-query (이하 vue-query)를 이용해 API 데이터를 받아오는 기능을 구현하면서 흥미로운 문제를 겪었습니다. 데이터는 console.log에 찍히지만 화면에는 아무것도 보이지 않는 현상이 발생한 것입니다.
이번 글에서는 이 문제의 원인과 해결 과정을 공유하며, Vue 3의 반응형 시스템과 ref의 동작 방식에 대해 깊이 있게 다뤄보겠습니다.
<template>
<div v-if="postsQuery.data && postsQuery.data.length > 0">
<ul>
<li v-for="post in postsQuery.data" :key="post.id">
{{ post.title }}
</li>
</ul>
</div>
<p v-else>데이터가 없습니다.</p>
</template>
위 코드는 일반적으로 문제가 없어 보입니다. console.log(postsQuery.data)를 찍어보면 아래처럼 데이터도 잘 도착합니다.
[
{ "id": 1, "title": "Hello Vue Query" },
{ "id": 2, "title": "Nuxt 3 is Awesome" }
]
하지만 실제 화면에서는 "데이터가 없습니다." 라는 문구만 출력되었습니다. 왜일까요?
vue-query의 useQuery()는 Vue의 반응형 시스템을 기반으로 동작하며, 내부적으로 반환하는 값들은 모두 ref입니다. 대표적으로 다음과 같은 속성들이 있습니다:
속성명 | 설명 | 타입 |
---|---|---|
data | 쿼리 결과 | Ref<T> |
isLoading | 로딩 상태 | Ref<boolean> |
error | 에러 정보 | Ref<Error> |
즉, postsQuery.data는 실제 배열이 아니라 Ref<Array> 형태입니다.
Vue 3의 템플릿은 일반적으로 ref를 자동 언래핑 해줍니다. 그래서 {{ postsQuery.data }}처럼 쓸 수 있죠.
하지만 v-if, v-for 같은 디렉티브 내부에서는 자동 언래핑이 적용되지 않습니다. 즉, 다음 코드는 작동하지 않습니다.
<!-- 잘못된 예시 -->
<div v-if="postsQuery.data && postsQuery.data.length > 0"> ... </div>
이유는 postsQuery.data가 ref이기 때문에 length는 undefined이고, 조건이 false가 되기 때문입니다.
해결책은 간단합니다. ref 내부의 값을 직접 접근해주면 됩니다:
<!-- 올바른 예시 -->
<div v-if="postsQuery.data?.value?.length > 0">
<ul>
<li v-for="post in postsQuery.data.value" :key="post.id">
{{ post.title }}
</li>
</ul>
</div>
<p v-else>데이터가 없습니다.</p>
템플릿에서 .value를 계속 반복하는 것은 보기 좋지 않습니다. 이를 computed로 추출하면 더 깔끔하게 관리할 수 있습니다:
<script setup>
import { usePosts } from '~/composables/usePosts'
import { computed } from 'vue'
const { fetchPostsQuery } = usePosts()
const postsQuery = fetchPostsQuery()
const posts = computed(() => postsQuery.data.value ?? [])
const isLoading = computed(() => postsQuery.isLoading.value)
const error = computed(() => postsQuery.error.value)
</script>
<template>
<div>
<p v-if="isLoading">로딩 중...</p>
<p v-else-if="error">에러 발생: {{ error.message }}</p>
<div v-else-if="posts.length > 0">
<ul>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</div>
<p v-else>데이터가 없습니다.</p>
</div>
</template>
아래는 실제 사용한 composable 예시입니다:
// ~/composables/usePosts.ts
import { useNuxtApp } from '#app'
export const usePostApi = () => {
const axios = useNuxtApp().$axios
return {
fetchPosts: async () => {
const response = await axios.get('/posts')
return response.data
}
}
}
export const usePosts = () => {
const api = usePostApi()
const fetchPostsQuery = () => useQuery({
queryKey: ['posts'],
queryFn: api.fetchPosts
})
return { fetchPostsQuery }
}
이번 경험을 통해, vue-query를 사용할 때 단순히 data만 보는 것이 아니라, 그 반환값의 구조가 ref인지 reactive인지까지도 고려해야 함을 배웠습니다.
Vue 3의 자동 언래핑이 편리하긴 하지만, 디렉티브나 조건문에서는 여전히 .value 명시 접근이 필요하다는 사실을 기억하세요.