Vue Query에서 데이터가 안 보이는 이유? Nuxt 3와 Ref 구조의 함정
Nuxt 3 + vue-query를 사용할 때 API 데이터가 `v-if` 조건문에서 제대로 표시되지 않는 현상을 해결한 경험을 바탕으로, Ref 구조와 template 디렉티브에서의 차이를 자세히 설명합니다.
Vue Query에서 데이터가 안 보이는 이유? Nuxt 3와 Ref 구조의 함정
최근 Nuxt 3 프로젝트에서 @tanstack/vue-query (이하 vue-query)를 이용해 API 데이터를 받아오는 기능을 구현하면서 흥미로운 문제를 겪었습니다. 데이터는 console.log에 찍히지만 화면에는 아무것도 보이지 않는 현상이 발생한 것입니다.
이번 글에서는 이 문제의 원인과 해결 과정을 공유하며, Vue 3의 반응형 시스템과 ref의 동작 방식에 대해 깊이 있게 다뤄보겠습니다.
문제 상황: API 데이터는 있는데, 화면에는 안 보여요
<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" }
]
하지만 실제 화면에서는 "데이터가 없습니다." 라는 문구만 출력되었습니다. 왜일까요?
원인 분석: useQuery()는 ref를 반환한다
vue-query의 useQuery()는 Vue의 반응형 시스템을 기반으로 동작하며, 내부적으로 반환하는 값들은 모두 ref입니다. 대표적으로 다음과 같은 속성들이 있습니다:
| 속성명 | 설명 | 타입 |
|---|---|---|
| data | 쿼리 결과 | Ref<T> |
| isLoading | 로딩 상태 | Ref<boolean> |
| error | 에러 정보 | Ref<Error> |
즉, postsQuery.data는 실제 배열이 아니라 Ref<Array> 형태입니다.
문제의 핵심: 템플릿에서의 ref 언래핑과 디렉티브의 차이
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가 되기 때문입니다.
해결 방법: .value를 명시적으로 접근
해결책은 간단합니다. 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>
추가 개선: computed를 활용해 템플릿 정리하기
템플릿에서 .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>
실전 코드 예시: usePosts 및 useQuery 구성
아래는 실제 사용한 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 3의 반응형 시스템을 더 깊이 이해하기
이번 경험을 통해, vue-query를 사용할 때 단순히 data만 보는 것이 아니라, 그 반환값의 구조가 ref인지 reactive인지까지도 고려해야 함을 배웠습니다.
Vue 3의 자동 언래핑이 편리하긴 하지만, 디렉티브나 조건문에서는 여전히 .value 명시 접근이 필요하다는 사실을 기억하세요.
✅ 요약
- useQuery()는 모든 상태를 ref로 반환한다.
- 템플릿에서는 {{ }} 안에서 자동 언래핑이 되지만 v-if, v-for에서는 명시적인 .value 접근이 필요하다.
- computed로 분리하면 코드가 더 깔끔해진다.