Server 용 Apollo Client 생성시 주의점!
TL; DR
registerApolloClient는 싱글톤처럼 동작하지만,getToken을 함수로 전달하면 매 요청마다 토큰을 새로 읽어와서 안전할 수 있음. 참고- 하지만 캐시 오염 가능성은 여전히 존재하므로, 민감한 데이터는
fetchPolicy: 'no-cache'사용을 권장. getToken없이credentials: 'include'만으로는 절대 작동하지 않음!- 가장 안전한 방법은 요청별 클라이언트를 생성하는 것이지만,
getToken을 올바르게 구현하면registerApolloClient만으로 충분히 사용 가능.
코드 예시
// /lib/apollo/server.ts
import { Defer20220824Handler } from "@apollo/client/incremental";
import {
ApolloClient,
InMemoryCache,
registerApolloClient,
} from "@apollo/client-integration-nextjs";
import { env } from "@/env";
import { createApolloLinks } from "./apollo-links";
import { getTokenFromCookie } from "../auth/server-utils";
export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: createApolloLinks({
// 여기가 DI
isServer: true,
hasuraGraphQLEndpoint: env.GRAPHQL_ENDPOINT,
getToken: getTokenFromCookie,
incrementalHandler: new Defer20220824Handler(),
}),
});
});
이거 싱글톤인데 문제 안됨? >> 될 수 있음! 주의필요!
1. registerApolloClient의 동작 방식
registerApolloClient는 React의 cache() API를 사용합니다:
// apollo-client-integrations/packages/nextjs 내부
import { cache } from 'react';
export function registerApolloClient(makeClient) {
const getClient = cache(() => {
return makeClient();
});
return { getClient, ... };
}
cache()의 특성- 렌더링 단위로 캐싱됩니다
- 동일한 렌더링 사이클 내에서는 같은 인스턴스 반환
- 다른 요청에서는 새로운 인스턴스가 생성될 수도 있음
2. 문제가 되는 케이스와 안 되는 케이스
✅ 안전한 케이스 (getToken 사용)
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
link: createApolloLinks({
isServer: true,
hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
getToken: getTokenFromCookie, // 함수를 전달!
}),
});
});
// 사용
const { data } = await getClient().query({ query: userQuery });
왜 안전한가?
// createApolloLinks 내부 예시
const authLink = new SetContextLink(async (prevContext, operation) => {
const token = await getToken(); // 매 요청마다 실행!
return {
headers: {
...prevContext.headers,
...(token && { authorization: `Bearer ${token}` }),
}
};
});
getToken은 함수 참조로 전달됨- Apollo의
SetContextLink가 매 GraphQL 요청마다getToken()을 호출 getTokenFromCookie()는 그 순간의 쿠키를 읽어옴- 즉, 클라이언트 인스턴스는 공유되지만, 토큰은 매번 새로 읽어옴
⚠️ 위험한 케이스
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
link: createApolloLinks({
isServer: true,
hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
// getToken 없음!
}),
});
});
문제점:
- Authorization 헤더가 아예 전달되지 않음
- Hasura가 익명 권한으로 처리
- 사용자별 데이터 조회 불가
3. 하지만 여전히 주의해야 할 점들
3.1 캐시 오염 가능성
const { getClient } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(), // 이 캐시가 공유될 수 있음!
link: createApolloLinks({
getToken: getTokenFromCookie,
}),
});
});
시나리오:
- 사용자 A가 요청 → 클라이언트 생성 → 데이터 조회 → 캐시에 저장
- 같은 렌더링 사이클/컨텍스트에서 사용자 B가 요청
cache()가 같은 클라이언트 인스턴스 반환- 사용자 B의 토큰으로 요청하지만, 캐시에 사용자 A의 데이터가 남아있을 수 있음
3.2 getTokenFromCookie의 구현에 따라 다름
// ❌ 위험한 구현
function getTokenFromCookie() {
// 전역 변수나 모듈 레벨 상태에서 읽어오면 위험!
return globalToken;
}
// ✅ 안전한 구현
function getTokenFromCookie() {
// Next.js의 cookies()는 현재 요청 컨텍스트를 자동으로 추적
const cookieStore = cookies();
return cookieStore.get('access_token')?.value;
}
4. Next.js App Router의 Request Context
Next.js 13+ App Router에서는 Request Context가 중요!!
import { cookies } from 'next/headers';
// 서버 컴포넌트나 Route Handler에서
async function POST() {
const cookieStore = cookies(); // 현재 요청의 쿠키
const token = cookieStore.get('access_token');
// 이 시점에서 getClient() 호출
const client = getClient();
const { data } = await client.query({ query: userQuery });
}
cookies()는 현재 실행 중인 요청의 컨텍스트를 자동으로 추적getTokenFromCookie()내부에서cookies()를 호출하면, 그 순간의 요청 쿠키를 읽어옴- 이게 올바르게 동작하려면 반드시 async 컨텍스트 내에서 호출되어야 함
5. 공식 문서 예제의 의도
Apollo의 Next.js 통합 패키지는:
- SSR/RSC에서 Apollo Client 사용을 간편하게 만들기 위함
- 렌더링 단위 캐싱으로 불필요한 클라이언트 재생성 방지
- 하지만 보안은 개발자가 직접 관리해야 함
6. 결론 및 권장사항
"credentials: include이기 때문에 알아서 들어간다"?
- ❌ 땡!!
- Apollo Client의 HTTP Link는 쿠키를 자동으로 헤더에 변환하지 않음
credentials: 'include'는 브라우저가 쿠키를 전송하는 것이지, Authorization 헤더로 변환하는 게 아님
올바른 구현 예제
// ✅ 1. getToken 함수 구현 확인
function getTokenFromCookie() {
const cookieStore = cookies(); // Next.js의 cookies() 사용
const token = cookieStore.get('access_token')?.value;
return token;
}
// ✅ 2. createApolloLinks에 getToken 전달
export const { getClient } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: createApolloLinks({
isServer: true,
hasuraGraphQLEndpoint: env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
getToken: getTokenFromCookie, // 반드시 필요!
}),
});
});
// ✅ 3. 캐시 정책 고려
// 민감한 사용자 데이터는 fetchPolicy: 'no-cache' 사용
const { data } = await getClient().query({
query: userQuery,
fetchPolicy: 'no-cache', // 또는 'network-only'
});
7. 부록
query의 fetchPolicy 와 HttpLink의 fetchOptions 차이
- fetchOptions는 HTTP 레벨이고, fetchPolicy는 Apollo 레벨
- 두 개는 다른 레이어이므로 둘 다 설정해야 좋음
- Apollo의
fetchPolicy옵션들cache-first(기본값)- Apollo 캐시에 있으면 네트워크 요청 안 함
- 가장 빠르지만, 오래된 데이터 위험
network-only- 항상 네트워크 요청
- 응답은 캐시에 저장
- 다음 요청을 위해 캐시 업데이트
no-cache✅ (민감한 데이터용)- 항상 네트워크 요청
- 응답을 캐시에 저장하지 않음
- 사용자별 데이터에 적합
cache-and-network- 캐시에서 먼저 읽고, 동시에 네트워크 요청
- 빠른 응답 + 최신 데이터 보장
fetchOptions는 MDN 참고
예시
const httpLink = new HttpLink({
fetchOptions: {
cache: "no-store", // ← HTTP 레벨 캐시 설정하면 됨
},
});
const { data } = await getClient().query({
query: userQuery,
fetchPolicy: 'no-cache', // ← Apollo 레벨 캐시
});
'TIL' 카테고리의 다른 글
| [251026 TIL] 실전적 Apollo Client 구현기 (0) | 2025.10.26 |
|---|---|
| [251024 TIL] Hasura(GQL) 사용시 3rd-Party 쿠키 정책 문제 (0) | 2025.10.24 |
| [251024 TIL] GraphQL Yoga 역할(+Hasura Remote Schema) (0) | 2025.10.24 |
| [251016 TIL] 테스트 환경 구축(vitest, rtl, msw, playwright) (0) | 2025.10.16 |
| [251015 TIL] 문자인증(알리고, with GQL) (0) | 2025.10.15 |