실전적 Apollo Client 구현기
1. 문제 상황
기술 스택
- Next.js 15 (App Router)
- Hasura GraphQL
- Apollo Client with
@apollo/client-integration-nextjs
해결해야 했던 문제들
- 중복 코드 문제: 서버용, 어드민용, 클라이언트용 총 3개의 Apollo Client 인스턴스가 필요했는데, 각각에 대해 Apollo Links를 별도로 작성하면 코드 중복이 심각함
- 서버/클라이언트 경계 처리: RSC(React Server Components) 환경에서 서버와 클라이언트의 인증 방식이 달라 각각 다른 처리가 필요
- 토큰 갱신 로직: 클라이언트에서만 토큰 갱신이 가능하므로 환경별로 다른 에러 처리 필요
2. 해결 방안: 팩토리 패턴과 의존성 주입
2.1 팩토리 함수 설계
export interface CreateLinksOptions {
isServer: boolean;
getToken?: () => Promise<string | null | undefined> | string | null | undefined;
hasuraAdminSecret?: string;
hasuraGraphQLEndpoint?: string;
refreshTokenManager?: {
refreshAccessToken: () => Promise<boolean>;
};
}
의존성을 외부에서 주입받아 다양한 환경에 대응할 수 있도록 설계
2.2 핵심 구현 포인트
1) 환경별 분기 처리
const prefix = isServer ? "🖥️ [Server]" : "💻 [Client]";
2) 조건부 링크 구성
const links = [
loggerLink,
errorLink,
...(retryLink ? [retryLink] : []), // 클라이언트만
...(ssrMultipartLink ? [ssrMultipartLink] : []), // 서버만
authLink.concat(httpLink),
];
3) 토큰 갱신 처리 (클라이언트 전용)
if (!isServer && extensions?.code === "invalid-jwt") {
return new Observable((observer) => {
refreshTokenManager.refreshAccessToken()
.then((success) => {
if (success) {
forward(operation).subscribe(observer); // 재시도
}
});
});
}
3. 실제 사용 예시
3.1 서버 컴포넌트용 클라이언트
export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: createApolloLinks({
isServer: true,
getToken: async () => {
const token = (await cookies()).get('access-token')?.value;
return token;
},
hasuraGraphQLEndpoint: env.HASURA_GRAPHQL_ENDPOINT,
}),
incrementalHandler: new Defer20220824Handler(),
});
});
3.2 클라이언트 컴포넌트용 클라이언트
const apolloClient = new ApolloClient({
cache: new InMemoryCache(),
link: createApolloLinks({
isServer: false,
hasuraGraphQLEndpoint: env.NEXT_PUBLIC_HASURA_GRAPHQL_ENDPOINT,
refreshTokenManager: {
refreshAccessToken: async () => {
// 토큰 갱신 로직
return await refreshToken();
}
},
}),
});
3.3 어드민용 클라이언트 (서버 전용)
const adminClient = new ApolloClient({
cache: new InMemoryCache(),
link: createApolloLinks({
isServer: true,
hasuraAdminSecret: env.HASURA_ADMIN_SECRET,
hasuraGraphQLEndpoint: env.HASURA_GRAPHQL_ENDPOINT,
}),
});
3.4 Apollo-Links 전체 예시
import { ApolloLink, HttpLink, Observable } from "@apollo/client";
import {
CombinedGraphQLErrors,
CombinedProtocolErrors,
} from "@apollo/client/errors";
import { SetContextLink } from "@apollo/client/link/context";
import { ErrorLink } from "@apollo/client/link/error";
import { RetryLink } from "@apollo/client/link/retry";
import { SSRMultipartLink } from "@apollo/client-integration-nextjs";
import { tap } from "rxjs/operators";
export interface CreateLinksOptions {
isServer: boolean;
getToken?: () =>
| Promise<string | null | undefined>
| string
| null
| undefined;
hasuraAdminSecret?: string;
hasuraGraphQLEndpoint?: string;
refreshTokenManager?: {
refreshAccessToken: () => Promise<boolean>;
};
}
// 아폴로 링크 팩토리 함수
export function createApolloLinks(options: CreateLinksOptions) {
const {
isServer,
getToken,
hasuraAdminSecret,
hasuraGraphQLEndpoint,
refreshTokenManager,
} = options;
const prefix = isServer ? "🖥️ [Server]" : "💻 [Client]";
const endpoint = hasuraGraphQLEndpoint;
// 1. Auth Link
// 서버: 쿠키에서 토큰을 읽어 Authorization 헤더 설정
const authLink = new SetContextLink(async (prevContext, _operation) => {
let token: string | null | undefined;
// 명시적으로 토큰을 헤더에 추가
if (getToken) {
console.log(`${prefix} 🔑 apollo-links에서 헤더에 토큰 추가 시도 시작`);
token = await getToken();
console.log(
`${prefix} 🔑 apollo-links에서 토큰 조회 성공:`,
token ? `${token.substring(0, 20)}...` : "null",
);
} else {
console.log(`${prefix} 🔑 getToken 함수가 제공되지 않았습니다`);
}
const headers = {
...prevContext.headers,
...(token && { authorization: `Bearer ${token}` }),
"x-request-from": isServer ? "server" : "client",
};
return {
headers,
};
});
// 2. Error Link - 인증 에러 발생 시 토큰 갱신 및 재시도
const errorLink = new ErrorLink(({ error, operation, forward }) => {
if (CombinedGraphQLErrors.is(error)) {
for (const err of error.errors) {
const { message, locations, path, extensions } = err;
console.log(
`${prefix} ❌ [GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
);
// ** 클라이언트 토큰 갱신 처리 **
if (!isServer && typeof window !== "undefined") {
// invalid-jwt 또는 UNAUTHENTICATED 에러 감지
if (
extensions?.code === "invalid-jwt" ||
extensions?.code === "UNAUTHENTICATED"
) {
console.log(
`${prefix} 🔄 apollo-links에서 토큰 갱신 시도 시작: ${operation.operationName}`,
);
// Observable을 반환하여 토큰 갱신 후 재시도
return new Observable((observer) => {
if (!refreshTokenManager) {
console.error(`${prefix} ❌ refreshTokenManager 없음`);
observer.error(error);
return;
}
refreshTokenManager
.refreshAccessToken()
.then((success: boolean) => {
if (success) {
// 토큰 갱신 성공 - 원래 요청 재시도
console.log(
`${prefix} ♻️ 토큰 갱신 성공, ${operation.operationName} 재시도 시작`,
);
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
};
forward(operation).subscribe(subscriber);
} else {
// 토큰 갱신 실패 - 에러 전달
observer.error(error);
}
})
.catch((refreshError: unknown) => {
console.error(`${prefix} ❌ 토큰 갱신 에러:`, refreshError);
observer.error(error);
});
});
}
if (extensions?.code === "FORBIDDEN") {
// 권한 부족 에러
console.warn(`${prefix} ⛔ Forbidden 인가 검토 필요: ${message}`);
// TODO: forbidden 일때 처리 방법 논의 필요 ***
// toast.error('권한이 없습니다?')
}
} else {
// 서버에서는 토큰 갱신 불가 - 클라이언트에서 처리해야 함
// 1. 서버는 브라우저 쿠키에 직접 접근 불가
// 2. RSC는 이미 렌더링 중이라 쿠키 수정 불가
// 3. 에러를 자동으로 전파하여 클라이언트에서 재인증 처리
// (ErrorLink에서 아무것도 반환하지 않으면 에러가 자동 전파됨)
}
}
} else if (CombinedProtocolErrors.is(error)) {
for (const err of error.errors) {
const { message, extensions } = err;
console.log(
`${prefix} ❌ [Protocol] ${operation.operationName}: ${message}`,
{ extensions },
);
}
} else {
console.error(`${prefix} 🔌 [Network error]:`, error);
}
});
// 3. HTTP Link
const httpLink = new HttpLink({
uri: endpoint,
credentials: "include",
...(isServer && {
fetch: fetch,
fetchOptions: {
cache: "no-store",
},
}),
...(!!hasuraAdminSecret && {
headers: {
"x-hasura-admin-secret": hasuraAdminSecret,
},
}),
});
// 4. Retry Link (클라이언트만)
const retryLink = !isServer
? new RetryLink({
delay: {
initial: 300,
max: 5000,
jitter: true,
},
attempts: {
max: 3,
retryIf: (error) =>
!!error && error.message.includes("Network error"),
},
})
: null;
// 5. SSR Multipart Link
const ssrMultipartLink = isServer
? new SSRMultipartLink({
stripDefer: true,
})
: null;
// 6. Logger Link - 개발 환경에서만 GraphQL 작업 로깅
// Apollo Client 4.0에서 asyncMap이 제거되어 rxjs의 tap 연산자 사용
// tap은 사이드 이펙트(로깅)만 처리하고 응답은 그대로 전달
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();
// 응답 완료 시 소요 시간 로그
// 1초 이상 걸리면 🐌, 그 이하면 ⚡ 이모지 표시
return forward(operation).pipe(
tap(() => {
const duration = Date.now() - start;
const emoji = duration > 1000 ? "🐌" : "⚡";
console.log(
`${prefix} ${emoji} ${operation.operationName} (${duration}ms)`,
);
}),
);
});
// 최종적으로 링크들 배열 생성
const links = [
loggerLink,
errorLink,
...(retryLink ? [retryLink] : []),
...(ssrMultipartLink ? [ssrMultipartLink] : []),
authLink.concat(httpLink),
];
return ApolloLink.from(links);
}
4. 이 접근 방식의 장점
팩토리 패턴과 의존성 주입을 통해 복잡한 Apollo Client 설정을 깔끔하게 관리할 수 있었습니다. 특히 Next.js 15의 App Router와 RSC 환경에서 서버/클라이언트 경계를 명확히 구분하여 처리한 것이 핵심이었습니다.
4.1 코드 재사용성
- 하나의 팩토리 함수로 3가지 클라이언트 구성을 모두 처리
- 링크 구성 로직의 중복 제거
4.2 유지보수성
- 의존성이 명확히 정의되어 있어 테스트 용이
- 새로운 링크 추가나 기존 링크 수정이 한 곳에서만 이루어짐
4.3 타입 안정성
- TypeScript 인터페이스로 옵션을 정의하여 컴파일 타임에 오류 방지
- IDE 자동완성 지원으로 개발 생산성 향상
4.4 환경별 최적화
- 서버: SSR Multipart Link 사용으로 스트리밍 지원
- 클라이언트: Retry Link로 네트워크 안정성 향상
- 개발 환경: Logger Link로 디버깅 편의성 제공
6. 주의사항
- 서버에서의 토큰 갱신 불가: RSC는 이미 렌더링 중이므로 쿠키 수정 불가능. 에러를 클라이언트로 전파하여 처리해야 함
- rxjs 의존성: Apollo Client 4.0에서
asyncMap이 제거되어 rxjs의tap연산자 사용 필요 - 캐싱 전략: 서버에서는
cache: "no-store"설정으로 항상 최신 데이터 fetch
'TIL' 카테고리의 다른 글
| [251024 TIL] Hasura(GQL) 사용시 3rd-Party 쿠키 정책 문제 (0) | 2025.10.24 |
|---|---|
| [251024 TIL] Server 용 Apollo Client 생성시 주의점! (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 |