Vue Query에서 데이터가 안 보이는 이유? Nuxt 3와 Ref 구조의 함정

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-queryuseQuery()는 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.dataref이기 때문에 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로 분리하면 코드가 더 깔끔해진다.