문제 상황
일반적인 Next.js + Apollo 설정
// lib/apollo-client-rsc.ts (Server Component용)
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: from([
loggerLink, // 중복 1
errorLink, // 중복 2
authLink, // 중복 3
httpLink // 중복 4
])
})
})
// lib/apollo-client.tsx (Client Component용)
'use client'
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'
function makeClient() {
return new ApolloClient({
cache: new InMemoryCache(),
link: from([
loggerLink, // 또 중복 1
errorLink, // 또 중복 2
authLink, // 또 중복 3
httpLink // 또 중복 4
])
})
}
😰 Link 설정을 두 번 작성해야 함!
해결 방법들
방법 1: Link 팩토리 함수로 공통화 (추천! ⭐)
// lib/apollo-links.ts
import { ApolloLink, from, HttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
import { RetryLink } from '@apollo/client/link/retry'
// 공통 Link 생성 함수
export function createApolloLinks(options?: {
isServer?: boolean
getToken?: () => string | null
}) {
const { isServer = false, getToken } = options || {}
// 1. 로거 (개발 환경에서만)
const loggerLink = new ApolloLink((operation, forward) => {
if (process.env.NODE_ENV === 'development') {
console.log(`🚀 [${isServer ? 'Server' : 'Client'}] ${operation.operationName}`)
const start = Date.now()
return forward(operation).map(response => {
console.log(`✅ [${isServer ? 'Server' : 'Client'}] ${operation.operationName} (${Date.now() - start}ms)`)
return response
})
}
return forward(operation)
})
// 2. 에러 처리
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
console.error(`❌ [GraphQL error] ${operation.operationName}:`, message)
// 클라이언트에서만 토스트
if (!isServer && typeof window !== 'undefined') {
if (extensions?.code === 'UNAUTHENTICATED') {
window.location.href = '/login'
}
// toast 등
}
})
}
if (networkError) {
console.error(`🔌 [Network error]:`, networkError)
}
})
// 3. 인증
const authLink = setContext((_, { headers }) => {
// 토큰 가져오기 (서버/클라이언트 분기)
let token: string | null = null
if (isServer) {
// 서버: cookies()에서 가져오기 (Next.js 15)
// getToken 함수로 주입받음
token = getToken ? getToken() : null
} else {
// 클라이언트: localStorage
if (typeof window !== 'undefined') {
token = localStorage.getItem('token')
}
}
return {
headers: {
...headers,
...(token && { authorization: `Bearer ${token}` })
}
}
})
// 4. 재시도 (클라이언트에서만)
const retryLink = !isServer
? new RetryLink({
delay: { initial: 300, max: 5000, jitter: true },
attempts: { max: 3 }
})
: null
// 5. HTTP
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_HASURA_URL,
// 서버에서는 fetch 명시
...(isServer && {
fetch: fetch,
fetchOptions: {
cache: 'no-store' // SSR에서 캐시 제어
}
})
})
// Link 체인 조합
return from([
loggerLink,
errorLink,
authLink,
...(retryLink ? [retryLink] : []),
httpLink
])
}
// 공통 캐시 설정도 함수화
export function createApolloCache() {
return new InMemoryCache({
typePolicies: {
Query: {
fields: {
campaign_application: {
keyArgs: ['where', 'order_by'],
merge(existing, incoming) {
return incoming
}
}
}
}
}
})
}
// lib/apollo-client-rsc.ts (Server)
import { ApolloClient } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
import { cookies } from 'next/headers'
import { createApolloLinks, createApolloCache } from './apollo-links'
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
cache: createApolloCache(),
link: createApolloLinks({
isServer: true,
getToken: () => {
// Next.js 15의 cookies()
const cookieStore = cookies()
return cookieStore.get('token')?.value || null
}
})
})
})
// lib/apollo-client.tsx (Client)
'use client'
import { ApolloClient } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'
import { createApolloLinks, createApolloCache } from './apollo-links'
function makeClient() {
return new ApolloClient({
cache: createApolloCache(),
link: createApolloLinks({
isServer: false
})
})
}
export function ApolloWrapper({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
)
}
✨ 이제 Link 설정은 한 곳에서만 관리!
방법 2: 환경별 Link 선택 (고급)
// lib/apollo-links.ts
export function createApolloLinks(options: {
isServer: boolean
}) {
const { isServer } = options
const commonLinks = [
createLoggerLink(isServer),
createErrorLink(isServer),
createAuthLink(isServer)
]
const clientOnlyLinks = isServer ? [] : [
createRetryLink(),
createLoadingLink(),
createAnalyticsLink()
]
const serverOnlyLinks = isServer ? [
createServerCacheLink()
] : []
return from([
...commonLinks,
...clientOnlyLinks,
...serverOnlyLinks,
createHttpLink(isServer)
])
}
function createLoggerLink(isServer: boolean) {
return new ApolloLink((operation, forward) => {
const prefix = isServer ? '[SSR]' : '[CSR]'
console.log(`${prefix} ${operation.operationName}`)
return forward(operation)
})
}
function createRetryLink() {
return new RetryLink({ /* ... */ })
}
// ... 각 Link를 함수로 분리
방법 3: Config 객체로 관리
// lib/apollo-config.ts
export const apolloLinkConfig = {
common: {
logger: {
enabled: process.env.NODE_ENV === 'development'
},
error: {
redirectOnAuth: true,
showToast: true
},
auth: {
headerKey: 'authorization',
tokenPrefix: 'Bearer'
}
},
server: {
retry: false,
cache: 'no-store'
},
client: {
retry: {
max: 3,
delay: 300
},
analytics: true
}
}
// lib/apollo-links.ts
export function createApolloLinks(isServer: boolean) {
const config = isServer
? { ...apolloLinkConfig.common, ...apolloLinkConfig.server }
: { ...apolloLinkConfig.common, ...apolloLinkConfig.client }
return from([
...(config.logger.enabled ? [createLoggerLink(isServer)] : []),
createErrorLink(config.error, isServer),
createAuthLink(config.auth, isServer),
...(config.retry ? [createRetryLink(config.retry)] : []),
createHttpLink(isServer)
])
}
서버/클라이언트 차이점 처리
1. 토큰 저장 위치
function getAuthToken(isServer: boolean): string | null {
if (isServer) {
// 서버: cookies 또는 headers
const { cookies } = require('next/headers')
return cookies().get('token')?.value || null
} else {
// 클라이언트: localStorage 또는 cookies
if (typeof window !== 'undefined') {
return localStorage.getItem('token')
}
return null
}
}
2. 에러 처리
const errorLink = onError(({ graphQLErrors, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
if (isServer) {
// 서버: 로그만
console.error('Unauthorized request:', operation.operationName)
} else {
// 클라이언트: 리다이렉트
window.location.href = '/login'
}
}
})
}
})
3. 재시도 정책
// 서버: 재시도 없음 (SSR 속도 중요)
// 클라이언트: 재시도 있음 (UX 중요)
const retryLink = !isServer ? new RetryLink({
delay: { initial: 300, max: 5000 },
attempts: { max: 3 }
}) : null
4. 캐싱 전략
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_HASURA_URL,
fetchOptions: {
// 서버: 캐시 안 함 (항상 최신 데이터)
// 클라이언트: 브라우저 캐시 활용
...(isServer && { cache: 'no-store' })
}
})
실전 예시: 완전한 설정
// lib/apollo/links.ts
import { ApolloLink, from, HttpLink, Observable } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
import { RetryLink } from '@apollo/client/link/retry'
export interface CreateLinksOptions {
isServer: boolean
getToken?: () => string | Promise<string> | null
}
export function createApolloLinks(options: CreateLinksOptions) {
const { isServer, getToken } = options
const prefix = isServer ? '🖥️ [Server]' : '💻 [Client]'
// 1. Logger Link
const loggerLink = new ApolloLink((operation, forward) => {
if (process.env.NODE_ENV !== 'development') {
return forward(operation)
}
console.log(`${prefix} 🚀 ${operation.operationName}`, {
variables: operation.variables
})
const start = Date.now()
return forward(operation).map(response => {
const duration = Date.now() - start
const emoji = duration > 1000 ? '🐌' : '⚡'
console.log(`${prefix} ${emoji} ${operation.operationName} (${duration}ms)`)
return response
})
})
// 2. Error Link
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions, path }) => {
const errorMsg = `${prefix} ❌ [GraphQL] ${operation.operationName}: ${message}`
console.error(errorMsg, { path, extensions })
// 클라이언트에서만 UI 처리
if (!isServer && typeof window !== 'undefined') {
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('token')
window.location.href = '/login'
} else if (extensions?.code === 'FORBIDDEN') {
// toast.error('권한이 없습니다')
}
}
})
}
if (networkError) {
console.error(`${prefix} 🔌 [Network]:`, networkError)
if (!isServer && typeof window !== 'undefined') {
// toast.error('네트워크 오류가 발생했습니다')
}
}
})
// 3. Auth Link
const authLink = setContext(async (_, { headers }) => {
let token: string | null = null
if (getToken) {
const tokenResult = getToken()
token = tokenResult instanceof Promise ? await tokenResult : tokenResult
} else if (!isServer && typeof window !== 'undefined') {
token = localStorage.getItem('token')
}
return {
headers: {
...headers,
...(token && { authorization: `Bearer ${token}` }),
'x-request-from': isServer ? 'server' : 'client'
}
}
})
// 4. Retry Link (클라이언트만)
const retryLink = !isServer
? new RetryLink({
delay: {
initial: 300,
max: 5000,
jitter: true
},
attempts: {
max: 3,
retryIf: (error) => {
// 네트워크 에러만 재시도
return !!error && !error.statusCode
}
}
})
: null
// 5. HTTP Link
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_HASURA_URL,
credentials: 'include',
...(isServer && {
fetch: fetch,
fetchOptions: {
cache: 'no-store'
}
})
})
// Link 체인 조합
const links = [
loggerLink,
errorLink,
authLink,
...(retryLink ? [retryLink] : []),
httpLink
]
return from(links)
}
// lib/apollo/cache.ts
import { InMemoryCache } from '@apollo/client'
export function createApolloCache() {
return new InMemoryCache({
typePolicies: {
Query: {
fields: {
campaign_application: {
keyArgs: ['where', 'order_by'],
merge(existing, incoming, { args }) {
if (args?.offset === 0) return incoming
return [...(existing ?? []), ...incoming]
}
}
}
}
}
})
}
// lib/apollo/client-rsc.ts
import { ApolloClient } from '@apollo/client'
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc'
import { cookies } from 'next/headers'
import { createApolloLinks } from './links'
import { createApolloCache } from './cache'
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
cache: createApolloCache(),
link: createApolloLinks({
isServer: true,
getToken: async () => {
const cookieStore = await cookies()
return cookieStore.get('token')?.value || null
}
})
})
})
// lib/apollo/client.tsx
'use client'
import { ApolloClient } from '@apollo/client'
import { ApolloNextAppProvider } from '@apollo/experimental-nextjs-app-support/ssr'
import { createApolloLinks } from './links'
import { createApolloCache } from './cache'
function makeClient() {
return new ApolloClient({
cache: createApolloCache(),
link: createApolloLinks({
isServer: false
})
})
}
export function ApolloWrapper({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
)
}
사용 예시
// app/campaigns/[id]/page.tsx (Server Component)
import { getClient } from '@/lib/apollo/client-rsc'
import { gql } from '@apollo/client'
export default async function CampaignPage({ params }) {
const client = getClient()
const { data } = await client.query({
query: GET_CAMPAIGN,
variables: { id: params.id }
})
// → 서버 전용 Link 사용 (로깅에 🖥️ [Server] 표시)
return <div>{data.campaign.title}</div>
}
// app/campaigns/[id]/applications.tsx (Client Component)
'use client'
import { useQuery } from '@apollo/client'
export default function Applications({ campaignId }) {
const { data } = useQuery(GET_APPLICATIONS, {
variables: { campaignId }
})
// → 클라이언트 전용 Link 사용 (로깅에 💻 [Client] 표시)
return <ul>{/* ... */}</ul>
}
콘솔 출력 예시
🖥️ [Server] 🚀 GetCampaign { variables: { id: 'abc-123' } }
🖥️ [Server] ⚡ GetCampaign (45ms)
💻 [Client] 🚀 GetApplications { variables: { campaignId: 'abc-123' } }
💻 [Client] ⚡ GetApplications (156ms)
정리
문제: Server/Client Apollo Client를 따로 만들면 Link 중복
해결: Link 생성 로직을 팩토리 함수로 공통화
// ❌ 중복
const serverClient = new ApolloClient({ link: from([...]) })
const clientClient = new ApolloClient({ link: from([...]) })
// ✅ 공통화
const serverClient = new ApolloClient({
link: createApolloLinks({ isServer: true })
})
const clientClient = new ApolloClient({
link: createApolloLinks({ isServer: false })
})
핵심: isServer
플래그로 서버/클라이언트 동작만 분기하고, Link 로직 자체는 하나로 통합!
from()
은 Apollo Client의 Link 체이닝 함수
import 위치
import { from } from '@apollo/client'
// 또는
import { from } from '@apollo/client/link/core'
역할: 여러 Link를 하나로 연결
// 여러 개의 Link를 하나로 합침
const combinedLink = from([
loggerLink,
errorLink,
authLink,
httpLink
])
// 이렇게 쓰는 것과 동일
const combinedLink = loggerLink
.concat(errorLink)
.concat(authLink)
.concat(httpLink)
동작 원리
from()
의 내부 구현 (단순화)
// Apollo Client 내부 코드 (개념적으로)
function from(links: ApolloLink[]): ApolloLink {
if (links.length === 0) {
throw new Error('링크가 없습니다')
}
if (links.length === 1) {
return links[0]
}
// 배열을 역순으로 순회하며 연결
return links.reduceRight((rest, link) => {
return link.concat(rest)
})
}
즉, from은 여러 Link를 연결 리스트처럼 이어주는 함수!
실제 동작 예시
코드
const loggerLink = new ApolloLink((operation, forward) => {
console.log('1. Logger Start')
return forward(operation).map(response => {
console.log('4. Logger End')
return response
})
})
const authLink = new ApolloLink((operation, forward) => {
console.log('2. Auth Start')
operation.setContext({ headers: { authorization: 'Bearer token' } })
return forward(operation).map(response => {
console.log('3. Auth End')
return response
})
})
const httpLink = new HttpLink({ uri: '/graphql' })
// from()으로 연결
const link = from([loggerLink, authLink, httpLink])
실행 흐름
요청 시작
↓
1. Logger Start (loggerLink의 forward 호출 전)
↓
2. Auth Start (authLink의 forward 호출 전)
↓
[HTTP 요청] (httpLink가 실제 요청)
↓
[서버 응답]
↓
3. Auth End (authLink의 map 내부)
↓
4. Logger End (loggerLink의 map 내부)
↓
컴포넌트로 반환
from()
과 concat()
의 관계
방법 1: from()
사용 (권장)
const link = from([
link1,
link2,
link3
])
방법 2: concat()
사용 (동일한 결과)
const link = link1.concat(link2).concat(link3)
// 또는
const link = ApolloLink.from([link1, link2, link3])
from()
이 더 읽기 쉬움!
왜 배열로 받을까?
조건부 Link 추가가 쉬워짐
const links = [
loggerLink,
errorLink,
authLink
]
// 개발 환경에서만 추가
if (process.env.NODE_ENV === 'development') {
links.push(debugLink)
}
// 클라이언트에서만 추가
if (!isServer) {
links.push(retryLink)
}
// HTTP는 항상 마지막
links.push(httpLink)
// 한 번에 연결
const link = from(links)
배열 조작으로 동적 구성이 가능!
실전 패턴
패턴 1: 스프레드 연산자 활용
const commonLinks = [loggerLink, errorLink, authLink]
const clientOnlyLinks = [retryLink, analyticsLink]
const serverOnlyLinks = [cacheLink]
const link = from([
...commonLinks,
...(isServer ? serverOnlyLinks : clientOnlyLinks),
httpLink
])
패턴 2: filter로 조건부 제거
const link = from([
process.env.NODE_ENV === 'development' && loggerLink,
errorLink,
authLink,
!isServer && retryLink,
httpLink
].filter(Boolean)) // falsy 값 제거
패턴 3: 명시적 분기
function createLinks(isServer: boolean) {
const baseLinks = [errorLink, authLink]
if (isServer) {
return from([...baseLinks, httpLink])
} else {
return from([loggerLink, ...baseLinks, retryLink, httpLink])
}
}
TypeScript 타입
// from의 타입 정의
function from(links: ApolloLink[]): ApolloLink
// ApolloLink 타입
class ApolloLink {
constructor(
request?: (
operation: Operation,
forward: NextLink
) => Observable<FetchResult> | null
)
concat(next: ApolloLink | RequestHandler): ApolloLink
static from(links: ApolloLink[]): ApolloLink
}
from()
의 대안들
대안 1: concat()
체이닝
// from() 사용
const link = from([link1, link2, link3])
// concat() 사용 (동일)
const link = link1.concat(link2).concat(link3)
대안 2: ApolloLink.from()
(정적 메서드)
// from() 함수
import { from } from '@apollo/client'
const link = from([link1, link2])
// ApolloLink.from() 정적 메서드 (동일)
import { ApolloLink } from '@apollo/client'
const link = ApolloLink.from([link1, link2])
(같은 것임)
내부 동작 깊게 이해하기
forward(operation)
의 의미
const myLink = new ApolloLink((operation, forward) => {
console.log('Before')
// forward = "다음 Link로 넘기기"
const observable = forward(operation)
return observable.map(response => {
console.log('After')
return response
})
})
forward(operation)
은 체인의 다음 Link를 실행하는 함수예요.
체인의 끝 (httpLink)
// httpLink는 forward를 호출하지 않고, 실제 HTTP 요청을 함
const httpLink = new HttpLink({ uri: '/graphql' })
// 내부적으로 이런 느낌
new ApolloLink((operation, forward) => {
// forward 호출 안 함!
return makeHttpRequest(operation) // 실제 요청
})
그래서 httpLink는 항상 마지막에 와야 함 !important
잘못된 사용 예시
❌ httpLink를 중간에 넣으면?
const link = from([
loggerLink,
httpLink, // ❌ 중간에!
authLink // 실행 안 됨!
])
결과: authLink는 절대 실행 안 됨 (httpLink에서 체인이 끝남)
❌ from에 빈 배열
const link = from([]) // ❌ 에러!
// Error: from() requires at least one link
❌ Link가 아닌 것 넣기
const link = from([
loggerLink,
'not a link', // ❌ 타입 에러!
httpLink
])
디버깅 팁
Link 체인 확인하기
const debugLink = new ApolloLink((operation, forward) => {
console.log('📍 Current Link')
console.log('Operation:', operation.operationName)
console.log('Variables:', operation.variables)
console.log('Context:', operation.getContext())
return forward(operation).map(response => {
console.log('📍 Response received')
console.log('Data:', response.data)
return response
})
})
// 여러 곳에 삽입해서 흐름 확인
const link = from([
debugLink, // 1번째 체크포인트
loggerLink,
debugLink, // 2번째 체크포인트
authLink,
debugLink, // 3번째 체크포인트
httpLink
])
비유로 이해하기
// from()은 "도미노 연결"과 유사
from([link1, link2, link3])
// = link1 → link2 → link3 → 결과
// 각 Link는 도미노 한 칸
// forward()는 "다음 도미노 밀기"
// 마지막 Link(httpLink)가 쓰러지면 응답이 돌아옴
실무 예시
// lib/apollo/links.ts
import { ApolloLink, from, HttpLink } from '@apollo/client'
export function createApolloLinks(isServer: boolean) {
// 동적으로 Link 배열 구성
const links: ApolloLink[] = []
// 개발 환경에서만 로거
if (process.env.NODE_ENV === 'development') {
links.push(createLoggerLink(isServer))
}
// 공통 Link들
links.push(
createErrorLink(isServer),
createAuthLink(isServer)
)
// 클라이언트에서만 재시도
if (!isServer) {
links.push(createRetryLink())
}
// 프로덕션에서만 분석
if (process.env.NODE_ENV === 'production' && !isServer) {
links.push(createAnalyticsLink())
}
// HTTP는 항상 마지막
links.push(createHttpLink(isServer))
// from()으로 체인 생성
return from(links)
}
정리
// from()은 Apollo Client의 Link 체이닝 함수
import { from } from '@apollo/client'
// 배열을 받아서 하나의 Link로 연결
const link = from([link1, link2, link3])
// Array.from()과는 완전 다른 함수!
Array.from([1, 2, 3]) // 배열 변환
from([link1, link2]) // Link 연결
핵심:
- ✅
from()
은 Apollo의 Link 체이닝 함수 - ✅ 여러 Link를 하나로 연결
- ✅ 배열 순서 = 실행 순서
- ✅ 마지막은 항상 httpLink
'library' 카테고리의 다른 글
[251006 TIL] Terraform + Neon + Hasura 구축기 (1) | 2025.10.06 |
---|---|
[250929 TIL] ApolloClient 디버거 직접 만들기 (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 |