[React] TanStack Query v5 마이그레이션 준비

React Query를 v3에서 v5로 마이그레이션하기 위해 React Query의 전반적인 개념들을 깊이 있게 학습했고 기존 버전과 호환되지 않는 변경점들을 파악했습니다.

 

Install

v3에서는 React Query, v5에서는 TanStack Query라고 표현하지만 편의상 React Query라고 통일합니다.

yarn remove react-query
yarn add @tanstack/react-query

 

Important Defaults

React Query를 사용하여 개발할 때 알아둘 필요가 있는 주요 기본값들이 있습니다. 이 값들은 React Query의 기본적인 동작 원리와 관련이 있습니다.

  • staleTime
  • gcTime
  • retry
  • structuralSharing

staleTime

stale 상태란 쿼리가 무효화된 상태를 말합니다. React Query는 기본적으로 useQuery 또는 useInfiniteQuery 를 통해 생성된 쿼리 인스턴스는 캐시된 데이터를 stale 상태로 간주합니다. 즉, 데이터를 받아오는 즉시 stale 상태가 되는겁니다.

 

이 기본적인 동작을 변경하려면 staleTime 옵션 사용하여 전역적(루트)으로 또는 쿼리별로 쿼리를 구성할 수 있다. staleTime 을 길게 지정하면 쿼리가 데이터를 자주 리프레시하지 않습니다.

 

전역에서 설정

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
    },
  },
})

 

쿼리에서 설정

import { useQuery } from '@tanstack/react-query'

const todos = useQuery({ 
    queryKey: ['todos'], 
    queryFn: fetchTodoList, 
    staleTime: Infinity  
})

 

stale 상태가 되면 활성화 쿼리에 한해서 백그라운드에서 자동으로 쿼리를 리패치합니다. 자동으로 리패치되는 시점은 다음와 같습니다.

  • 새로운 쿼리 인스턴스가 마운트되었을 때(refetchOnMount)
  • 윈도우 창이 다시 포커스되었을 때(refetchOnWindowFocus)
  • 네트워크가 다시 연결되었을 때(refetchOnReconnect)

이 시점들은 쿼리 옵션을 통해 비활성화할 수도 있습니다. 추가로 refetchInterval 옵션을 사용하여 인터벌로 리패치를 수행할 수 있으며, useQuery 가 반환하는 refetch 함수를 호출하여 리패치를 실행할 수도 있습니다.

 

gcTime

gcTime이란 가비지타임을 의미합니다. 가비지타임이란 쿼리가 비활성되었을 때 캐시에 쿼리를 저장하고 있는 시간을 의미합니다. 해당 쿼리를 구독하고 있는 모든 컴포넌트가 언마운트되었을 때 쿼리는 비활성됩니다.

 

gcTime 의 기본값은 1000 * 60 * 5 밀리초(5분)로 쿼리가 비활성되었을 때 5분동안 캐시에 저장하고 있다가 삭제합니다.

staleTime 와 마찬가지로 gcTime 옵션을 전역적 또는 쿼리별로 구성하여 기본 동작을 변경할 수 있습니다.

 

retry

실패한 쿼리는 exponential backoff delay(기하급수적인 백오프 지연)와 함께 자동으로 3번 재시도되며, 오류를 갭처하여 UI에 표시하기 전에 기하급수적인 백오프 지연이 발생합니다.

 

기하급수적인 백오프 지연은 네트워크 요청이나 작업이 실패했을 때, 재시도하기 전에 점점 증가하는 시간 간격을 두는 전략입니다. 서버에 과부하를 주지 않으면서도 요청이 성공할 가능성을 높이기 위해서 사용합니다. 일반적으로 다음과 같은 방식으로 구현됩니다.

  1. 요청이 실패하면 초기 대기 시간(예: 1초)을 설정한다.
  2. 재시도할 때마다 대기 시간을 두 배로 늘린다.
  3. 최대 대기 시간이나 최대 재시도 횟수에 도달하면 중단한다.

이를 변경하려면 retry , retryDelay 옵션을 변경하거나 기본 백오프 함수를 3이 아닌 다른 값으로 변경하면 됩니다.

 

structuralSharing(구조적인 공유)

React Query는 기본적으로(설정을 통해 변경 가능) 구조적인 공유를 통해 쿼리가 반환하는 dataerror 가 실제로 변경되었는지 감지하고, 변경되지 않은 경우 참조는 변경되지 않은 채로 유지됩니다.

 

그렇기 때문에 dataerror 를 의존성 배열에 포함해도 실제로 변경되었을 때만 콜백 함수가 실행되는 것을 보장받을 수 있습니다. v5에서 useQuery 의 콜백이 제거된 상황에서 이는 굉장히 유용합니다.

구조적인 공유는 JSON 호환 값에서만 작동합니다. 따라서 서버 응답을 JSON 형식으로 반환해야 됩니다.

 

변경점

  • 가비지 타입의 옵션 이름이 cacheTime 이었지만 gcTime 으로 변경되었습니다.

 

Queries

쿼리는 고유 키(쿼리 키)에 연결된 비동기 데이터 소스에 대한 선언적 종속성입니다. 쿼리는 서버에서 데이터를 가져오기 위해 모든 프로미스 기반 메서드(GET 및 POST 메서드 포함)와 함께 사용할 수 있습니다. 다만, 서버의 데이터를 수정하는 경우 Mutations를 사용해야 합니다.

 

쿼리가 프로미스 형태로 반환하는 데이터는 고유한 쿼리 키와 1대 1로 대응하여 캐시에 저장됩니다. 쿼리에는 GET 메서드 외에 POST 메서드를 사용할 수도 있지만 서버의 데이터를 변경하는 경우에는 Mutations을 사용해야 합니다.

 

useQuery

컴포넌트 또는 사용자 정의 훅에서 쿼리를 구독하려면 useQuery 를 사용해야 합니다. useQuery 의 쿼리 키는 고유해야 하고 쿼리 함수는 반드시 프로미스 기반의 해결된 데이터 또는 에러를 반환해야 합니다.

import { useQuery } from '@tanstack/react-query'

function App() {
  const todos = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
}

 

useQuery 의 상태를 나타내는 프로퍼티에는 statusfetchStatus 가 있습니다. status 는 데이터의 존재 여부를 나타내며 fetchStatus 는 쿼리 함수의 실행 여부를 나타냅니다. 이들은 useQuery 의 반환값 중 일부입니다.

 

 status:

  • isPending or status === 'pending' - 쿼리에 아직 데이터가 없다.
  • isError or status === 'error - 쿼리에서 오류가 발생했다.
  • isSuccess or status === 'success' - 쿼리가 성공했으며 데이터를 사용할 수 있다.

 fetchStatus:

  • fetchStatus === 'fetching' - 현재 쿼리를 가져오는 중이다.
  • fetchStatus === 'paused' - 쿼리를 가져오려고 했지만 일시 중지되었다.
  • fetchStatus === 'idle' - 쿼리가 현재 아무 작업도 수행하지 않는다.

useQuery 는 상태 외에도 많은 값을 반환합니다. 그 중 일부입니다.

  • error - 쿼리가 isError 상태인 경우 error 프로퍼티를 통해 오류를 확인할 수 있다.
  • data - 쿼리가 isSuccess 상태인 경우 data 프로퍼티를 통해 데이터를 사용할 수 있다.
  • isFetching - 어떤 상태에서든 쿼리가 백그라운드 리패치를 포함하여 래패치 중이면 true 이다.
  • isLoading - isFetching && isPending

 

stale-while-revalidate

쿼리가 stale 상태가 되면 refetchOnMount , refetchOnWindowFocus , refetchOnReconnect 등의 시점에서 쿼리가 리패치됩니다. 리패치되는 동안 UI에서는 기존의 stale 상태가 된 데이터를 사용할 수 있습니다. 이는 쿼리가 stale 상태가 되는 동시에 유효성을 다시 검사하는(stale-while-revalidate) 캐싱 메커니즘을 수용하기 때문이다.

 

이에 따라 이전 요청에서 데이터를 정상적으로 받은 경우 리패치에서 오류가 발생했을 때 error 와 stale 상태인 이전 data 를 모두 사용할 수 있습니다.

 

따라서, errordata 가 동시에 존재할 경우 data 를 우선적으로 사용하여 렌더링할 수 있습니다.

const todos = useTodos();

if (todos.data) {
  return <div>{todos.data.map(renderTodo)}</div>;
}
if (todos.error) {
  return 'An error has occurred: ' + todos.error.message;
}

return 'Loading...';

 

변경점

  • useQuery 의 인터페이스가 변경되었습니다.
const result = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
  • status 값에 변경이 있었습니다.
    • isLoading - 쿼리에 데이터가 없으며 현재 가져오는 중이다. → isFetching && isPending
    • isIdle - 쿼리가 현재 비활성화되어 있다. → 삭제
  • 쿼리 함수 의 실행 여부를 나타내는 fetchStatus 가 추가되었습니다.
  • useQuery의 onSuccess , onError , onSettled 콜백이 제거되었습니다.

 

Query Key

React Query는 쿼리 키를 기반으로 쿼리 캐싱을 관리합니다. 쿼리 키는 최상위 수준에서 배열이어야 하며, 단일 문자열이 포함된 배열처럼 단순할 수 있고, 여러 문자열과 중첩된 객체가 포함된 배열처럼 복잡할 수도 있습니다. 쿼리 키가 직렬화 가능하고 쿼리 데이터에 고유한 것이면 사용할 수 있습니다.

 

쿼리 키는 결정론적인 방식으로 해싱되어 있어서 객체를 사용할 수 있고 쿼리 키에 포함된 객체는 프로퍼티 순서에 상관없이 모두 동일한 쿼리 키로 간주된다. (객체의 프로퍼티의 값 자체가 다르면 다른 쿼리 키입니다.)

useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })

 

리액트 쿼리는 내부적으로 쿼리 키를 해시할 때 직렬화를 수행하며 객체의 경우 프로퍼티 키를 기준으로 정렬을 수행합니다.

/**
 * Default query & mutation keys hash function.
 * Hashes the value into a stable hash.
 */
export function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =>
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) => {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}

 

변경점

  • 기존에는 쿼리 키로 문자열과 배열 모두 가능했습니다. 문자열을 받으면 내부적으로 쿼리 키를 배열로 변환했습니다. 이제 쿼리 키는 반드시 최상위 수준에서 배열이어야 합니다.

 

Query Functions

쿼리 함수는 반드시 Promise 를 반환해야 합니다. 반환되는 Promise 는 데이터를 해결하거나 오류를 발생시켜야 합니다.

 

데이터를 해결하는 경우 데이터가 data 에 유지되고 오류가 발생하는 경우 error 에 유지됩니다. 쿼리에 오류가 발생했다고 판단하려면 쿼리 함수가 거부된 Promise를 던지거나 반환해야 합니다.

const { error } = useQuery({
  queryKey: ['todos', todoId],
  queryFn: async ({ queryKey, client }) => {
    if (somethingGoesWrong) {
      throw new Error('Oh no!')
    }
    if (somethingElseGoesWrong) {
      return Promise.reject(new Error('Oh no!'))
    }

    return data
  },
})

 

QueryFunctionContext

QueryFunctionContext는 각 쿼리 함수에 전달되는 객체입니다. 객체의 구성 요소입니다.

  • queryKey: QueryKey - 쿼리 키
  • client: QueryClient - 쿼리 클라이언트
  • signal?: AbortSignal - API 호출을 중단할 때 사용
  • meta: Record<string, unknown> | undefined - 추가 정보를 채울 수 있는 선택적 필드

이 값들은 쿼리 함수의 파라미터를 통해 사용할 수 있습니다.

function Todos({ status, page }) {
  const result = useQuery({
    queryKey: ['todos', { status, page }],
    queryFn: fetchTodoList,
  })
}

// Access the key, status and page variables in your query function!
function fetchTodoList({ queryKey }) {
  const [_key, { status, page }] = queryKey
  return new Promise()
}

 

useInfiniteQuery 의 쿼리 함수에만 추가적으로 전달되는 요소도 있습니다.

  • pageParam: TPageParam - 현재 페이지를 가져오는 데 사용되는 페이지 매개변수

 

Infinite Queries

리액트 쿼리는 인피니티 스크롤이라는 UI 패턴을 쉽게 구현할 수 있게 useInfiniteQuery 훅을 제공합니다. 인피니티 스크롤 전용 useQuery 라고 생각하면 됩니다.

 

useInfiniteQuery 가 반환하는 값은 useQuery 가 반환하는 값와 다릅니다.

  • data - 인피니트 쿼리 데이터를 포함하는 객체
    • data.pages - 가져온 페이지를 포함하는 배열
    • data.pageParams - 페이지를 가져오는 데 사용되는 페이지 매개변수가 포함된 배열
  • fetchNextPage - 다음 페이지를 가져오는 함수
  • fetchPreviousPage - 이전 페이지를 가져오는 함수
  • getNextPageParam - 더 로드할 다음 페이지가 있는지 여부와 가져올 정보를 결정
  • getPreviousPageParam - 더 로드할 이전 페이지가 있는지 여부와 가져올 정보를 결정
  • hasNextPage - 더 로드할 다음 페이지가 있는지 여부를 나타내는 부울값으로 getNextPageParamnull 또는 undefined 이외의 값을 반환하면 true이다.
  • hasPreviousPage - 더 로드할 이전 페이지가 있는지 여부를 나타내는 부울값으로 getPreviousPageParamnull 또는 undefined 이외의 값을 반환하면 true이다.
  • isFetchingNextPage , isFetchingPreviousPage - 백그라운드 리패치와 구분하기 위해 사용하는 부울값이다.

 

동시 요청 문제

fetchNextPage 를 호출할 때, 이미 데이터가 백그라운드에서 리프래시되고 있다면, 두 개의 요청이 동시에 진행될 수 있습니다. 따라서 데이터가 덮어쓰여질 수도 있습니다. 이는 특히 리스트를 렌더링하면서 동시에 fetchNextPage 를 호출할 때 문제가 될 수 있습니다.

 

기본적으로 fetchNextPagecancelRefetch: true 로 설정되어 있어 진행 중인 요청을 취소하고 새로운 요청을 시작합니다. 해당 옵션은 변경할 수 있습니다.

 

변경점

  • 이제 initialPageParam 옵션을 필수로 설정해야 합니다.

 

Mutations

리액트 쿼리에서는 서버 데이터를 생성/업데이트/삭제하거나 서버 사이드 이펙트를 수행해야 할 때 useMutation 훅을 사용합니다.

 

뮤테이션은 특정 순간에 아래의 상태 중 하나에만 존재할 수 있습니다.

  • isIdle or status === 'idle' - 유휴 상태이거나 fresh/reset 상태이다.
  • isPending or status === 'pending' - 실행 중이다.
  • isError or status === 'error' - 오류가 발생했다.
  • isSuccess or status === 'success - 성공했으며 반환된 데이터를 사용할 수 있다.

뮤테이션이 반환하는 주요 값:

  • error - 오류 상태인 경우 error 프로퍼티를 통해 오류를 확인할 수 있다.
  • data - 성공 상태인 경우 data 프로퍼티를 통해 데이터를 사용할 수 있다.
  • mutate - 뮤테이션을 실행하는 비동기 함수로 이 함수를 통해 mutationFn에 값을 전달할 수 있다.
const mutation = useMutation({
  mutationFn: (newTodo) => {
    return axios.post('/todos', newTodo)
  },
})

// 값 전달은 단일 객체로만 할 수 있다.
mutation.mutate({ id: new Date(), title: 'Do Laundry' })

 

useMutation의 콜백과 mutate의 콜백

useMutation 은 실질적으로 뮤테이션을 실행시키는 mutate 비동기 함수를 반환합니다. useMutationmutate 에는 사이드 이팩트를 처리하기 위해 onMutate , onError , onSuccess , onSettled 콜백을 제공합니다.

useMutation 가 제공하는 콜백과 mutate 함수가 제공하는 콜백 간에는 약간의 차이가 있습니다.

  • useMutation 의 콜백이 mutate 의 콜백 전에 실행된다.
  • mutation이 완료되기 전에 컴포넌트가 언마운트되면 mutate 의 콜백이 실행되지 않을 수 있다.

 

그래서 콜백에서 관심사를 분리하는 것이 중요합니다.

  • 쿼리 무효화와 같이 무조건 필요하며 로직과 관련된 작업은 useMutation 콜백에서 수행한다.
  • 리다이렉션 또는 토스트 알림과 같은 UI 관련 작업은 mutate 콜백에서 수행한다. mutation이 완료되기 전에 사용자가 현재 화면에서 벗어났다면, 이러한 작업은 의도적으로 실행되지 않는다.

 

연속적으로 mutation(Consecutive mutations)을 수행할 때도 차이가 있습니다.

  • mutate 의 콜백은 컴포넌트가 아직 마운트되어 있는 경우에만 한 번만 실행된다. 이는 mutation observer가 뮤테이션 함수가 호출될 때마다 제거되었다가 다시 구독되기 때문이다.
  • useMutation 의 콜백은 각 mutate 호출마다 실행된다.
useMutation({
  mutationFn: addTodo,
  onSuccess: (data, variables, context) => {
    // Will be called 3 times
  },
})

const todos = ['Todo 1', 'Todo 2', 'Todo 3']
todos.forEach((todo) => {
  mutate(todo, {
    onSuccess: (data, variables, context) => {
      // Will execute only once, for the last mutation (Todo 3),
      // regardless which mutation resolves first
    },
  })
})

 

변경점

  • v4부터 Mutations도 쿼리와 마찬가지로 자동으로 가비지 수집이 될 수 있습니다. useMutation 의 기본 gcTimeuseQuery 와 동일하게 5분입니다.
  • v5에서 useMutation 의 인터페이스가 변경되었습니다.

 

Query Invalidation

mutation은 설계상 쿼리와 직접 연결되어 있지 않습니다. mutation을 통해 서버 데이터를 변경했었도 쿼리 데이터는 이전 데이터일 수 있습니다. mutation으로 인한 변경사항을 쿼리에 반영하기 위해 리액트 쿼리는 몇가지 방법을 제공하며 그 중 하나가 쿼리 무효화(Query Invalidation)입니다.

 

쿼리를 무효화하기 위해서는 queryClient.invalidateQueries 함수를 사용합니다.

// Invalidate every query in the cache
queryClient.invalidateQueries()
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })

 

쿼리가 무효화되면 활성 쿼리는 백그라운드에서 리패치되며 비활성 쿼리는 stale 상태로만 표시되며 다시 활성 상태가 되었을 때 리패치됩니다. 쿼리 무효화는 staleTime 을 무시합니다.

 

쿼리 무효화는 쿼리 키에 대해 퍼지 매칭(fuzzy matching)을 사용합니다. 즉, 쿼리 키에 대해 접두사를 기준으로 여러 쿼리를 매칭하거나 매우 구체적으로 정확한 쿼리를 매칭할 수 있습니다.

import { useQuery, useQueryClient } from '@tanstack/react-query'

// Get QueryClient from the context
const queryClient = useQueryClient()

queryClient.invalidateQueries({ queryKey: ['todos'] })

// Both queries below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: fetchTodoList,
})

 

대기중인 프로미스

뮤테이션 콜백에서 프로미스를 반환하면 React Query에 의해 대기(awaited) 상태가 됩니다. 즉, 프로미스가 완전히 반환될 때까지 loading 상태가 됩니다.

 

invalidateQueries 함수는 프로미스를 반환하기 때문에 뮤테이션 콜백에서 invalidateQueries 함수의 결과를 반환하면 관련된 쿼리가 업데이트되는 동안 뮤테이션을 loading 상태에 머물게 할 수 있습니다. (즉, isPending === true 인 상태)

{
  // 쿼리 무효화가 끝날 때 까지 대기할 것입니다.
  onSuccess: () => {
    return queryClient.invalidateQueries({
      queryKey: ['posts', id, 'comments'],
    });
  };
}
{
  // 실행하고 끝입니다. - 대기하지 않을 것입니다.
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ['posts', id, 'comments'],
    });
  };
}

 

변경점

  • v5에서 queryClient.invalidateQueries 인터페이스가 변경되었습니다.
queryClient.invalidateQueries({ queryKey: ['todos'] })

 

setQueryData

Mutation 이후 쿼리 무효화를 통해 데이터 리패치를 실행하고 싶지 않을 수 있습니다. 만약, 간단한 토글 기능이거나 서버에서 변경된 값을 받아서 변경된 값을 이미 알고있는 경우 setQueryData 를 통해 쿼리 캐시를 직접 업데이트할 수 있습니다. (다만, 대부분의 경우 쿼리 무효화를 선호해야 한다는 의견이 있다.)

 

setQueryData 는 쿼리의 캐시된 데이터를 즉시 업데이트하는 데 사용할 수 있는 동기 함수이며 두 가지 방식으로 사용할 수 있습니다.

  • 함수(updater)가 아닌 값을 전달하면 데이터가 이 값으로 업데이트된다.
  • 함수(updater)가 전달되면 이전 데이터 값을 수신하고 새 값을 반환할 것으로 예상된다.
 queryClient.setQueryData(queryKey, newData)
 queryClient.setQueryData(queryKey, (oldData) => newData)

 

사용 시 주의사항:

  • queryClient.invalidateQueries 함수와 다르게 쿼리 키가 정확히 일치해야 한다.
  • 업데이트 값이 undefined 또는 업데이터 함수가 반환하는 값이 undefined 이면 쿼리 데이터를 업데이트하지 않는다.
  • 기존에 쿼리 키에 대한 쿼리 데이터가 존재하지 않으면 쿼리가 생성된다.

 

setQueriesData

한 번에 여러 쿼리를 업데이트하고 쿼리 키를 부분적으로 일치시키려면 setQueriesData 를 사용해야 합니다. 전달된 쿼리 키 또는 쿼리 필터와 일치하는 쿼리만 업데이트되며 setQueryData 와 다르게 새 캐시 항목은 생성되지 않습니다.

queryClient.setQueriesData(filters, updater)

 

내부적으로 각 기존 쿼리에 대해 setQueryData 가 호출됩니다.

return notifyManager.batch(() =>
  this.#queryCache
    .findAll(filters)
    .map(({ queryKey }) => [
      queryKey,
      this.setQueryData<TInferredQueryFnData>(queryKey, updater, options),
    ]),
)

 

변경점

  • v4부터 setQueryDataonSuccess 를 호출하지 않습니다.
  • v4부터 업데이터에서 undefined 를 반환하면 쿼리 데이터를 업데이트하지 않습니다.

 

제거된 callback의 대안

v5부터 useQuery의 콜백이 사라지면서 이에 대한 적절한 대안이 필요해졌습니다.

 

가장 단순한 방법은 useEffect 를 사용하는 것입니다. React Query는 구조적 공유를 통해 dataerror 의 참조값에 대해 안정성을 보장합니다.

 

그러나 이 방식에는 2개 이상의 컴포넌트에서 쿼리가 호출되면 useEffect 의 콜백 함수도 여러번 실행된다는 문제가 있습니다.

export function useTodos() {
  const query = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  React.useEffect(() => {
    if (query.error) {
      toast.error(query.error.message)
    }
  }, [query.error])

  return query
}

 

최선의 대안은 QueryClient를 설정할 때 전역적인 캐시 레벨의 콜백을 사용하는 것입니다. 여기서의 콜백은 쿼리당 한 번만 호출됩니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  }),
})

'React' 카테고리의 다른 글

[React] TanStack Query 캐시 레벨과 옵저버 레벨  (0) 2025.03.02
[React] 폰트 최적화 알아보기  (0) 2024.05.25