ApolloLink = GraphQL 요청/응답 미들웨어
Express.js의 미들웨어와 완전히 같은 개념이에요:
Express.js 미들웨어
// Express
app.use((req, res, next) => {
console.log('Request:', req.url) // 로깅
next() // 다음 미들웨어로
})
app.use((req, res, next) => {
req.user = getCurrentUser() // 인증
next()
})
app.use((req, res, next) => {
if (!req.user) {
return res.status(401).send('Unauthorized') // 권한 체크
}
next()
})
Apollo Link (같은 패턴!)
// Apollo Link
const loggerLink = new ApolloLink((operation, forward) => {
console.log('Request:', operation.operationName) // 로깅
return forward(operation) // 다음 링크로
})
const authLink = new ApolloLink((operation, forward) => {
const token = getToken() // 인증
operation.setContext({
headers: { authorization: `Bearer ${token}` }
})
return forward(operation)
})
const errorLink = onError(({ networkError }) => {
if (networkError?.statusCode === 401) {
logout() // 권한 체크
}
})
미들웨어 체인의 흐름
요청/응답 흐름
요청 시작
↓
[Logger Link] → console.log('GetCampaigns')
↓
[Auth Link] → 헤더에 토큰 추가
↓
[Retry Link] → 실패 시 재시도 준비
↓
[Error Link] → 에러 감지 준비
↓
[HTTP Link] → 실제 서버로 요청
↓
──────────────────────────────────
서버 처리
──────────────────────────────────
↓
[HTTP Link] → 응답 받음
↓
[Error Link] → 에러 있나 체크
↓
[Retry Link] → 재시도 필요한가?
↓
[Auth Link] → (응답에는 보통 작업 없음)
↓
[Logger Link] → console.log('Took 234ms')
↓
컴포넌트로 반환
실무에서 자주 쓰는 Link들
1. 인증 Link (가장 기본)
import { setContext } from '@apollo/client/link/context'
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token')
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
}
}
})
사용 이유:
- 모든 요청에 토큰 자동 추가
- 컴포넌트에서 신경 안 써도 됨
- Hasura의 JWT 인증에 필수!
// 이렇게 안 해도 됨! ❌
useQuery(GET_CAMPAIGNS, {
context: {
headers: { authorization: `Bearer ${token}` }
}
})
// authLink가 자동으로 해줌 ✅
useQuery(GET_CAMPAIGNS)
2. 에러 처리 Link
import { onError } from '@apollo/client/link/error'
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
// GraphQL 에러 (서버가 응답은 했지만 에러)
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
console.error('로그인 필요!')
// 로그인 페이지로 리다이렉트
window.location.href = '/login'
}
if (extensions?.code === 'FORBIDDEN') {
toast.error('권한이 없습니다')
}
if (message.includes('unique constraint')) {
toast.error('이미 존재하는 데이터입니다')
}
})
}
// 네트워크 에러 (서버 응답 자체가 없음)
if (networkError) {
console.error('네트워크 에러:', networkError)
toast.error('서버 연결 실패')
}
})
사용 이유:
- 에러를 한 곳에서 중앙 집중 처리
- 컴포넌트마다 에러 처리 코드 반복 안 해도 됨
// 이렇게 매번 안 해도 됨 ❌
const { data, error } = useQuery(GET_CAMPAIGNS)
if (error) {
if (error.message.includes('auth')) {
window.location.href = '/login'
}
if (error.message.includes('unique')) {
toast.error('중복')
}
// 반복 반복 반복...
}
// errorLink가 자동 처리 ✅
const { data } = useQuery(GET_CAMPAIGNS)
3. 재시도 Link
import { RetryLink } from '@apollo/client/link/retry'
const retryLink = new RetryLink({
delay: {
initial: 300, // 첫 재시도: 300ms 후
max: 5000, // 최대 대기: 5초
jitter: true // 랜덤 지연 추가 (서버 부하 분산)
},
attempts: {
max: 3, // 최대 3번 재시도
retryIf: (error, operation) => {
// 네트워크 에러만 재시도
return !!error && !error.statusCode
// 또는 특정 상태 코드만
// return error?.statusCode === 503 // 서버 점검 중
}
}
})
사용 이유:
- 일시적 네트워크 문제 자동 해결
- 사용자 경험 개선
// 수동 재시도 ❌
const [getCampaigns, { loading, error }] = useLazyQuery(GET_CAMPAIGNS)
const handleClick = async () => {
try {
await getCampaigns()
} catch (error) {
// 재시도 로직
await new Promise(r => setTimeout(r, 1000))
try {
await getCampaigns()
} catch {
// 또 재시도...
}
}
}
// retryLink가 자동 처리 ✅
const { data } = useQuery(GET_CAMPAIGNS)
4. 로딩 상태 Link
import { makeVar } from '@apollo/client'
// 전역 상태
export const isLoadingVar = makeVar(false)
export const activeQueriesVar = makeVar<string[]>([])
const loadingLink = new ApolloLink((operation, forward) => {
// 요청 시작
const operationName = operation.operationName
activeQueriesVar([...activeQueriesVar(), operationName])
isLoadingVar(true)
return forward(operation).map(response => {
// 응답 완료
const active = activeQueriesVar().filter(name => name !== operationName)
activeQueriesVar(active)
if (active.length === 0) {
isLoadingVar(false)
}
return response
})
})
// 컴포넌트에서 사용
function GlobalLoader() {
const isLoading = useReactiveVar(isLoadingVar)
const activeQueries = useReactiveVar(activeQueriesVar)
return (
<div>
{isLoading && <Spinner />}
<div>Active: {activeQueries.join(', ')}</div>
</div>
)
}
사용 이유:
- 전역 로딩 스피너
- 활성 쿼리 추적
5. 조건부 Link (WebSocket vs HTTP)
import { split } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:8080/v1/graphql',
connectionParams: {
headers: {
authorization: `Bearer ${getToken()}`
}
}
})
)
const httpLink = new HttpLink({
uri: 'http://localhost:8080/v1/graphql'
})
// subscription은 WebSocket, 나머지는 HTTP
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsLink, // true면 WebSocket
httpLink // false면 HTTP
)
사용 이유:
- 실시간 subscription은 WebSocket
- 일반 query/mutation은 HTTP
- 자동으로 적절한 프로토콜 선택
6. 캐시 무효화 Link
const invalidationLink = new ApolloLink((operation, forward) => {
return forward(operation).map(response => {
// mutation 성공 시 관련 쿼리 무효화
if (operation.query.definitions[0].operation === 'mutation') {
const mutationName = operation.operationName
// 특정 mutation 후 캐시 무효화
if (mutationName === 'UpdateCampaign') {
client.cache.evict({
id: 'ROOT_QUERY',
fieldName: 'campaigns'
})
}
if (mutationName === 'CreateApplication') {
client.refetchQueries({
include: ['GetApplications', 'GetCampaignStats']
})
}
}
return response
})
})
사용 이유:
- mutation 후 자동으로 관련 데이터 갱신
- 수동 refetch 불필요
7. 분석/모니터링 Link
const analyticsLink = new ApolloLink((operation, forward) => {
const start = performance.now()
return forward(operation).map(response => {
const duration = performance.now() - start
// 분석 서비스로 전송
analytics.track('GraphQL Query', {
operationName: operation.operationName,
operationType: operation.query.definitions[0].operation,
duration,
variables: operation.variables,
slow: duration > 1000 // 1초 이상이면 slow
})
// 느린 쿼리 경고
if (duration > 3000) {
console.warn(`⚠️ Slow query: ${operation.operationName} (${duration}ms)`)
// Sentry 등으로 전송
Sentry.captureMessage(`Slow GraphQL query: ${operation.operationName}`, {
level: 'warning',
extra: { duration, variables: operation.variables }
})
}
return response
})
})
8. Request ID Link (디버깅용)
import { v4 as uuid } from 'uuid'
const requestIdLink = new ApolloLink((operation, forward) => {
const requestId = uuid()
// 요청에 ID 추가
operation.setContext({
headers: {
'x-request-id': requestId
}
})
console.log(`[${requestId}] ${operation.operationName} started`)
return forward(operation).map(response => {
console.log(`[${requestId}] ${operation.operationName} completed`)
return response
})
})
사용 이유:
- 요청 추적
- 서버 로그와 매칭
- 디버깅 용이
실전 Link 조합
기본 구성
import { ApolloClient, from, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
// 1. 인증
const authLink = setContext((_, { headers }) => ({
headers: {
...headers,
authorization: `Bearer ${getToken()}`
}
}))
// 2. 에러 처리
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
window.location.href = '/login'
}
})
}
if (networkError) {
toast.error('네트워크 에러')
}
})
// 3. HTTP
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_HASURA_URL
})
// 조합 (순서 중요!)
const client = new ApolloClient({
cache: new InMemoryCache(),
link: from([
errorLink, // 에러 처리 먼저
authLink, // 그 다음 인증
httpLink // 마지막에 HTTP
])
})
프로덕션 구성
import { RetryLink } from '@apollo/client/link/retry'
const client = new ApolloClient({
cache: new InMemoryCache(),
link: from([
// 개발 환경에서만 로거
...(process.env.NODE_ENV === 'development' ? [loggerLink] : []),
// 에러 처리
errorLink,
// 분석
analyticsLink,
// 인증
authLink,
// 재시도
retryLink,
// HTTP/WebSocket 분기
splitLink
])
})
Link 작성 패턴
패턴 1: 단순 변환
const uppercaseLink = new ApolloLink((operation, forward) => {
// 요청 변환
operation.operationName = operation.operationName.toUpperCase()
return forward(operation).map(response => {
// 응답 변환
return {
...response,
data: transformData(response.data)
}
})
})
패턴 2: 조건부 실행
const cacheFirstLink = new ApolloLink((operation, forward) => {
const cached = checkCache(operation)
if (cached) {
// 캐시 있으면 요청 안 함
return Observable.of({ data: cached })
}
// 캐시 없으면 진행
return forward(operation)
})
패턴 3: 비동기 처리
const asyncAuthLink = new ApolloLink((operation, forward) => {
// 비동기로 토큰 갱신
return new Observable(observer => {
refreshTokenIfNeeded()
.then(() => {
const token = getToken()
operation.setContext({
headers: { authorization: `Bearer ${token}` }
})
return forward(operation)
})
.then(observable => {
observable.subscribe(observer)
})
.catch(observer.error.bind(observer))
})
})
TanStack Query와 비교
TanStack Query의 미들웨어 스타일
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 전역 설정 (Link와 비슷한 역할)
retry: 3,
staleTime: 5000,
onError: (error) => {
console.error(error)
},
// 하지만 체인은 안 됨
}
}
})
// 개별 커스터마이징
useQuery({
queryKey: ['campaigns'],
queryFn: fetchCampaigns,
onSuccess: (data) => {
// 성공 처리
},
onError: (error) => {
// 에러 처리
}
})
차이점:
- TanStack Query: 옵션 기반
- Apollo Link: 체인 기반 (더 유연)
정리
// ApolloLink = 미들웨어 체인
// ├─ 로거 (미들웨어의 한 예시)
// ├─ 인증 (토큰 추가)
// ├─ 에러 처리
// ├─ 재시도
// ├─ 분석
// └─ ... 무한 확장 가능!
// Express 미들웨어와 동일한 개념
app.use(logger)
app.use(auth)
app.use(errorHandler)
// Apollo Link
from([loggerLink, authLink, errorLink, httpLink])
로거는 Link의 활용 예시 중 하나일 뿐! ✨
'TIL' 카테고리의 다른 글
[250929 TIL] ApolloClient 디버거 직접 만들기 (0) | 2025.09.29 |
---|---|
[250929 TIL] ApolloLink Next.js 설정 (0) | 2025.09.29 |
[250929 TIL] tanstack vs apollo 쿼리캐시 비교 (0) | 2025.09.29 |
[250929 TIL] (cache)typePolicies, Apollo Link (0) | 2025.09.29 |
[250929 TIL] Hasura의 특별한 점 (0) | 2025.09.29 |