TanStack Query DevTools vs Apollo DevTools 비교
TanStack Query DevTools ✨
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
// 기능:
// ✅ 모든 쿼리 상태 실시간 확인
// ✅ 캐시 데이터 탐색
// ✅ 쿼리 무효화/리페치 버튼
// ✅ 타임라인
// ✅ 직관적인 UI
Apollo DevTools 😢
// 브라우저 확장 프로그램 설치 필요
// Chrome/Firefox Extension
// 기능:
// ⚠️ 쿼리 목록 (기본적)
// ⚠️ 캐시 탐색 (복잡함)
// ⚠️ Mutation 추적 (불편함)
// ⚠️ UI가 투박함
// ❌ 타임라인 없음
// ❌ 실시간 업데이트 약함
해결책: Custom DevTools Link 만들기!
1. 기본 디버깅 Link
// lib/apollo/debug-link.ts
import { ApolloLink } from '@apollo/client'
import { makeVar } from '@apollo/client'
// 전역 상태로 쿼리 히스토리 관리
export const queryHistoryVar = makeVar<QueryLog[]>([])
interface QueryLog {
id: string
operationName: string
operationType: 'query' | 'mutation' | 'subscription'
variables: any
startTime: number
endTime?: number
duration?: number
status: 'pending' | 'success' | 'error'
error?: any
result?: any
cached: boolean
}
export function createDebugLink() {
return new ApolloLink((operation, forward) => {
const id = `${operation.operationName}-${Date.now()}`
const startTime = performance.now()
// 로그 생성
const log: QueryLog = {
id,
operationName: operation.operationName,
operationType: operation.query.definitions[0]?.operation || 'query',
variables: operation.variables,
startTime,
status: 'pending',
cached: false
}
// 히스토리에 추가
queryHistoryVar([log, ...queryHistoryVar()])
return forward(operation).map(response => {
const endTime = performance.now()
const duration = endTime - startTime
// 로그 업데이트
const updatedLog: QueryLog = {
...log,
endTime,
duration,
status: 'success',
result: response.data,
cached: duration < 10 // 10ms 이하면 캐시로 간주
}
queryHistoryVar(
queryHistoryVar().map(l => l.id === id ? updatedLog : l)
)
// 콘솔에도 출력
console.groupCollapsed(
`%c${log.operationType.toUpperCase()} %c${operation.operationName} %c${duration.toFixed(2)}ms`,
'color: #FF6B6B; font-weight: bold',
'color: #4ECDC4',
duration > 1000 ? 'color: #FF6B6B' : 'color: #95E1D3'
)
console.log('Variables:', operation.variables)
console.log('Result:', response.data)
console.log('Cached:', updatedLog.cached)
console.groupEnd()
return response
})
})
}
2. Custom DevTools UI 컴포넌트
// components/apollo-devtools.tsx
'use client'
import { useReactiveVar } from '@apollo/client'
import { queryHistoryVar } from '@/lib/apollo/debug-link'
import { useState } from 'react'
export function ApolloDevTools() {
const history = useReactiveVar(queryHistoryVar)
const [isOpen, setIsOpen] = useState(false)
const [selectedLog, setSelectedLog] = useState<string | null>(null)
if (process.env.NODE_ENV !== 'development') {
return null
}
const selected = history.find(log => log.id === selectedLog)
return (
<>
{/* 플로팅 버튼 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-4 right-4 z-[9999] bg-purple-600 text-white rounded-full w-14 h-14 shadow-lg hover:bg-purple-700 transition-all"
title="Apollo DevTools"
>
🚀
</button>
{/* DevTools 패널 */}
{isOpen && (
<div className="fixed inset-0 z-[9998] pointer-events-none">
<div className="absolute bottom-20 right-4 w-[600px] h-[500px] bg-gray-900 rounded-lg shadow-2xl pointer-events-auto flex flex-col">
{/* 헤더 */}
<div className="bg-purple-600 text-white px-4 py-3 rounded-t-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg font-bold">Apollo DevTools</span>
<span className="text-xs bg-purple-700 px-2 py-1 rounded">
{history.length} queries
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => queryHistoryVar([])}
className="text-xs bg-purple-700 hover:bg-purple-800 px-3 py-1 rounded"
>
Clear
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white hover:text-gray-300"
>
✕
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* 쿼리 목록 */}
<div className="w-1/2 border-r border-gray-700 overflow-y-auto">
{history.map(log => (
<div
key={log.id}
onClick={() => setSelectedLog(log.id)}
className={`
px-4 py-3 border-b border-gray-800 cursor-pointer hover:bg-gray-800 transition-colors
${selectedLog === log.id ? 'bg-gray-800' : ''}
`}
>
<div className="flex items-center justify-between mb-1">
<span className={`
text-xs font-mono px-2 py-0.5 rounded
${log.operationType === 'query' ? 'bg-blue-900 text-blue-300' : ''}
${log.operationType === 'mutation' ? 'bg-green-900 text-green-300' : ''}
${log.operationType === 'subscription' ? 'bg-purple-900 text-purple-300' : ''}
`}>
{log.operationType.toUpperCase()}
</span>
<span className={`
text-xs
${log.duration! > 1000 ? 'text-red-400' : 'text-green-400'}
`}>
{log.duration?.toFixed(0)}ms
</span>
</div>
<div className="text-sm text-white font-medium">
{log.operationName}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`
text-xs px-1.5 py-0.5 rounded
${log.status === 'pending' ? 'bg-yellow-900 text-yellow-300' : ''}
${log.status === 'success' ? 'bg-green-900 text-green-300' : ''}
${log.status === 'error' ? 'bg-red-900 text-red-300' : ''}
`}>
{log.status}
</span>
{log.cached && (
<span className="text-xs bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded">
cached
</span>
)}
</div>
</div>
))}
</div>
{/* 상세 정보 */}
<div className="w-1/2 overflow-y-auto p-4 text-white">
{selected ? (
<div className="space-y-4">
<div>
<h3 className="text-lg font-bold text-purple-400 mb-2">
{selected.operationName}
</h3>
<div className="text-sm text-gray-400">
{new Date(selected.startTime).toLocaleTimeString()}
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">
Variables
</h4>
<pre className="bg-gray-800 p-3 rounded text-xs overflow-x-auto">
{JSON.stringify(selected.variables, null, 2)}
</pre>
</div>
{selected.result && (
<div>
<h4 className="text-sm font-semibold text-gray-400 mb-2">
Result
</h4>
<pre className="bg-gray-800 p-3 rounded text-xs overflow-x-auto max-h-64">
{JSON.stringify(selected.result, null, 2)}
</pre>
</div>
)}
{selected.error && (
<div>
<h4 className="text-sm font-semibold text-red-400 mb-2">
Error
</h4>
<pre className="bg-red-900 text-red-100 p-3 rounded text-xs overflow-x-auto">
{JSON.stringify(selected.error, null, 2)}
</pre>
</div>
)}
</div>
) : (
<div className="text-center text-gray-500 mt-20">
Select a query to see details
</div>
)}
</div>
</div>
</div>
</div>
)}
</>
)
}
3. 사용하기
// lib/apollo/links.ts
export function createApolloLinks(options: CreateLinksOptions) {
const { isServer } = options
const links = [
// 개발 환경 + 클라이언트에서만 DevTools Link
...(process.env.NODE_ENV === 'development' && !isServer
? [createDebugLink()]
: []
),
createErrorLink(options),
createAuthLink(options),
createHttpLink(options)
]
return from(links)
}
// app/layout.tsx
import { ApolloDevTools } from '@/components/apollo-devtools'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ApolloWrapper>
{children}
</ApolloWrapper>
<ApolloDevTools />
</body>
</html>
)
}
고급 기능 추가
4. 캐시 탐색기
// components/apollo-cache-explorer.tsx
'use client'
import { useApolloClient } from '@apollo/client'
import { useState } from 'react'
export function ApolloCacheExplorer() {
const client = useApolloClient()
const [cache, setCache] = useState<any>(null)
const extractCache = () => {
const extracted = client.cache.extract()
setCache(extracted)
}
const clearCache = () => {
client.cache.reset()
setCache(null)
}
return (
<div className="p-4">
<div className="flex gap-2 mb-4">
<button
onClick={extractCache}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
📦 Extract Cache
</button>
<button
onClick={clearCache}
className="bg-red-600 text-white px-4 py-2 rounded"
>
🗑️ Clear Cache
</button>
</div>
{cache && (
<div className="bg-gray-900 text-white p-4 rounded">
<h3 className="text-lg font-bold mb-2">Cache Contents</h3>
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(cache, null, 2)}
</pre>
</div>
)}
</div>
)
}
5. 성능 차트
// components/apollo-performance-chart.tsx
'use client'
import { useReactiveVar } from '@apollo/client'
import { queryHistoryVar } from '@/lib/apollo/debug-link'
export function ApolloPerformanceChart() {
const history = useReactiveVar(queryHistoryVar)
const slowQueries = history.filter(log => (log.duration || 0) > 1000)
const avgDuration = history.length > 0
? history.reduce((sum, log) => sum + (log.duration || 0), 0) / history.length
: 0
return (
<div className="p-4 bg-gray-900 rounded">
<h3 className="text-white font-bold mb-4">Performance Metrics</h3>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-gray-800 p-3 rounded">
<div className="text-gray-400 text-sm">Total Queries</div>
<div className="text-white text-2xl font-bold">{history.length}</div>
</div>
<div className="bg-gray-800 p-3 rounded">
<div className="text-gray-400 text-sm">Avg Duration</div>
<div className="text-white text-2xl font-bold">
{avgDuration.toFixed(0)}ms
</div>
</div>
<div className="bg-gray-800 p-3 rounded">
<div className="text-gray-400 text-sm">Slow Queries</div>
<div className="text-red-400 text-2xl font-bold">
{slowQueries.length}
</div>
</div>
</div>
{/* 차트 */}
<div className="space-y-2">
{history.slice(0, 10).map(log => (
<div key={log.id} className="flex items-center gap-2">
<div className="text-xs text-gray-400 w-32 truncate">
{log.operationName}
</div>
<div className="flex-1 bg-gray-800 rounded h-6 relative">
<div
className={`h-full rounded transition-all ${
(log.duration || 0) > 1000 ? 'bg-red-500' : 'bg-green-500'
}`}
style={{
width: `${Math.min((log.duration || 0) / 30, 100)}%`
}}
/>
</div>
<div className="text-xs text-gray-400 w-16 text-right">
{log.duration?.toFixed(0)}ms
</div>
</div>
))}
</div>
</div>
)
}
6. Network Inspector Link
// lib/apollo/network-inspector-link.ts
import { ApolloLink } from '@apollo/client'
export function createNetworkInspectorLink() {
return new ApolloLink((operation, forward) => {
const startTime = performance.now()
console.group(
`%c🌐 Network Request`,
'color: #61DAFB; font-weight: bold; font-size: 14px'
)
console.log('%cOperation:', 'font-weight: bold', operation.operationName)
console.log('%cType:', 'font-weight: bold', operation.query.definitions[0]?.operation)
console.log('%cVariables:', 'font-weight: bold', operation.variables)
// Context 정보
const context = operation.getContext()
console.log('%cContext:', 'font-weight: bold', {
headers: context.headers,
uri: context.uri
})
console.groupEnd()
return forward(operation).map(response => {
const duration = performance.now() - startTime
const durationColor = duration > 1000 ? '#FF6B6B' : '#51CF66'
console.group(
`%c✅ Network Response %c${duration.toFixed(2)}ms`,
'color: #51CF66; font-weight: bold; font-size: 14px',
`color: ${durationColor}; font-weight: bold`
)
console.log('%cOperation:', 'font-weight: bold', operation.operationName)
console.log('%cData:', 'font-weight: bold', response.data)
if (response.errors) {
console.log('%cErrors:', 'font-weight: bold; color: #FF6B6B', response.errors)
}
// Extensions (Hasura의 경우 유용)
if (response.extensions) {
console.log('%cExtensions:', 'font-weight: bold', response.extensions)
}
console.groupEnd()
return response
})
})
}
완성된 DevTools 통합
// lib/apollo/links.ts
export function createApolloLinks(options: CreateLinksOptions) {
const { isServer } = options
const isDev = process.env.NODE_ENV === 'development'
const links = []
// 개발 환경 + 클라이언트
if (isDev && !isServer) {
links.push(
createDebugLink(), // Custom DevTools
createNetworkInspectorLink() // 네트워크 로깅
)
}
// 공통 Link
links.push(
createErrorLink(options),
createAuthLink(options)
)
// 클라이언트 전용
if (!isServer) {
links.push(createRetryLink())
}
// HTTP
links.push(createHttpLink(options))
return from(links)
}
// app/layout.tsx
import { ApolloDevTools } from '@/components/apollo-devtools'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ApolloWrapper>
{children}
</ApolloWrapper>
{/* 개발 환경에서만 표시 */}
{process.env.NODE_ENV === 'development' && (
<ApolloDevTools />
)}
</body>
</html>
)
}
콘솔 출력 예시
🌐 Network Request
Operation: GetCampaigns
Type: query
Variables: { status: "open", limit: 10 }
Context: { headers: { authorization: "Bearer ..." } }
✅ Network Response 234.56ms
Operation: GetCampaigns
Data: { campaigns: [...] }
Extensions: { hasura_execution_time: 0.012 }
추가 기능 아이디어
7. Query 재실행 버튼
// DevTools에 추가
<button
onClick={() => {
client.refetchQueries({
include: [selected.operationName]
})
}}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm"
>
🔄 Refetch
</button>
8. Cache 무효화 버튼
<button
onClick={() => {
client.cache.evict({
id: 'ROOT_QUERY',
fieldName: selected.operationName
})
}}
className="bg-orange-600 text-white px-3 py-1 rounded text-sm"
>
💥 Evict Cache
</button>
9. Subscription 모니터링
const subscriptionLink = new ApolloLink((operation, forward) => {
if (operation.query.definitions[0]?.operation === 'subscription') {
console.log('🔔 Subscription started:', operation.operationName)
return forward(operation).map(response => {
console.log('🔔 Subscription data:', operation.operationName, response.data)
return response
})
}
return forward(operation)
})
정리
Apollo의 공식 DevTools는 부족하지만:
- ✅ Custom Link로 더 나은 디버깅 환경 구축 가능
- ✅ TanStack Query DevTools 스타일로 만들 수 있음
- ✅ 프로젝트에 맞는 커스텀 기능 추가
- ✅ 콘솔 로깅도 훨씬 예쁘게
장점:
- 원하는 대로 커스터마이징 가능
- 프로덕션 빌드에서 자동 제거
- 성능 모니터링 내장
- 브라우저 확장 프로그램 불필요
'TIL' 카테고리의 다른 글
[251006 TIL] Terraform + Neon + Hasura 구축기 (1) | 2025.10.06 |
---|---|
[250929 TIL] ApolloLink Next.js 설정 (0) | 2025.09.29 |
[250929 TIL] ApolloLink (0) | 2025.09.29 |
[250929 TIL] tanstack vs apollo 쿼리캐시 비교 (0) | 2025.09.29 |
[250929 TIL] (cache)typePolicies, Apollo Link (0) | 2025.09.29 |