2024. 10. 13. 17:37ㆍ공부내용 공유하기
아폴로 클라이언트를 사용하면서 가장 많이 사용하는 훅은 useQuery일 것이다.
선언한 graphql 규격에 맞춰, ApolloClient는 어떻게 데이터를 요청할까? 소스코드 흐름을 보며 따라가보자.
useQuery.ts
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/react/hooks/useQuery.ts
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/react/hooks/useQuery.ts#L155
function _useQuery<
TData = any,
TVariables extends OperationVariables = OperationVariables,
>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
options: QueryHookOptions<NoInfer<TData>, NoInfer<TVariables>>
) {
const { result, obsQueryFields } = useQueryInternals(query, options);
return React.useMemo(
() => ({ ...result, ...obsQueryFields }),
[result, obsQueryFields]
);
}
useQuery 훅은 _useQuery 구현체의 wrapper이고, _useQuery 구현체는 useQueryInternals의 반환값에 대한 메모이제이션임을 알 수 있다.
useQueryInternals
export function useQueryInternals<
TData = any,
TVariables extends OperationVariables = OperationVariables,
>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
options: QueryHookOptions<NoInfer<TData>, NoInfer<TVariables>>
) {
const client = useApolloClient(options.client);
const renderPromises = React.useContext(getApolloContext()).renderPromises;
const isSyncSSR = !!renderPromises;
const disableNetworkFetches = client.disableNetworkFetches;
const ssrAllowed = options.ssr !== false && !options.skip;
const partialRefetch = options.partialRefetch;
const makeWatchQueryOptions = createMakeWatchQueryOptions(
client,
query,
options,
isSyncSSR
);
const [{ observable, resultData }, onQueryExecuted] = useInternalState(
client,
query,
options,
renderPromises,
makeWatchQueryOptions
);
const watchQueryOptions: Readonly<WatchQueryOptions<TVariables, TData>> =
makeWatchQueryOptions(observable);
useResubscribeIfNecessary<TData, TVariables>(
resultData, // might get mutated during render
observable, // might get mutated during render
client,
options,
watchQueryOptions
);
const obsQueryFields = React.useMemo<
Omit<ObservableQueryFields<TData, TVariables>, "variables">
>(() => bindObservableMethods(observable), [observable]);
useRegisterSSRObservable(observable, renderPromises, ssrAllowed);
const result = useObservableSubscriptionResult<TData, TVariables>(
resultData,
observable,
client,
options,
watchQueryOptions,
disableNetworkFetches,
partialRefetch,
isSyncSSR,
{
onCompleted: options.onCompleted || noop,
onError: options.onError || noop,
}
);
return {
result,
obsQueryFields,
observable,
resultData,
client,
onQueryExecuted,
};
}
여러 설정과 옵션이 얽혀 실제 요청이 어디서 되는지 명확하진 않지만, useObservableSubscriptionResult 훅을 먼저 살펴보자. onError, onCompleted 등의 콜백도 해당 훅으로 넘어가는 것을 볼 수 있다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/react/hooks/useQuery.ts#L321
function useObservableSubscriptionResult<
TData = any,
TVariables extends OperationVariables = OperationVariables,
>(
resultData: InternalResult<TData, TVariables>,
observable: ObservableQuery<TData, TVariables>,
client: ApolloClient<object>,
options: QueryHookOptions<NoInfer<TData>, NoInfer<TVariables>>,
watchQueryOptions: Readonly<WatchQueryOptions<TVariables, TData>>,
disableNetworkFetches: boolean,
partialRefetch: boolean | undefined,
isSyncSSR: boolean,
callbacks: {
onCompleted: (data: TData) => void;
onError: (error: ApolloError) => void;
}
) {
const callbackRef = React.useRef<Callbacks<TData>>(callbacks);
React.useEffect(() => {
// Make sure state.onCompleted and state.onError always reflect the latest
// options.onCompleted and options.onError callbacks provided to useQuery,
// since those functions are often recreated every time useQuery is called.
// Like the forceUpdate method, the versions of these methods inherited from
// InternalState.prototype are empty no-ops, but we can override them on the
// base state object (without modifying the prototype).
callbackRef.current = callbacks;
});
const resultOverride =
(
(isSyncSSR || disableNetworkFetches) &&
options.ssr === false &&
!options.skip
) ?
// If SSR has been explicitly disabled, and this function has been called
// on the server side, return the default loading state.
ssrDisabledResult
: options.skip || watchQueryOptions.fetchPolicy === "standby" ?
// When skipping a query (ie. we're not querying for data but still want to
// render children), make sure the `data` is cleared out and `loading` is
// set to `false` (since we aren't loading anything).
//
// NOTE: We no longer think this is the correct behavior. Skipping should
// not automatically set `data` to `undefined`, but instead leave the
// previous data in place. In other words, skipping should not mandate that
// previously received data is all of a sudden removed. Unfortunately,
// changing this is breaking, so we'll have to wait until Apollo Client 4.0
// to address this.
skipStandbyResult
: void 0;
const previousData = resultData.previousData;
const currentResultOverride = React.useMemo(
() =>
resultOverride &&
toQueryResult(resultOverride, previousData, observable, client),
[client, observable, resultOverride, previousData]
);
return useSyncExternalStore(
React.useCallback(
(handleStoreChange) => {
// reference `disableNetworkFetches` here to ensure that the rules of hooks
// keep it as a dependency of this effect, even though it's not used
disableNetworkFetches;
if (isSyncSSR) {
return () => {};
}
const onNext = () => {
const previousResult = resultData.current;
// We use `getCurrentResult()` instead of the onNext argument because
// the values differ slightly. Specifically, loading results will have
// an empty object for data instead of `undefined` for some reason.
const result = observable.getCurrentResult();
// Make sure we're not attempting to re-render similar results
if (
previousResult &&
previousResult.loading === result.loading &&
previousResult.networkStatus === result.networkStatus &&
equal(previousResult.data, result.data)
) {
return;
}
setResult(
result,
resultData,
observable,
client,
partialRefetch,
handleStoreChange,
callbackRef.current
);
};
const onError = (error: Error) => {
subscription.current.unsubscribe();
subscription.current = observable.resubscribeAfterError(
onNext,
onError
);
if (!hasOwnProperty.call(error, "graphQLErrors")) {
// The error is not a GraphQL error
throw error;
}
const previousResult = resultData.current;
if (
!previousResult ||
(previousResult && previousResult.loading) ||
!equal(error, previousResult.error)
) {
setResult(
{
data: (previousResult && previousResult.data) as TData,
error: error as ApolloError,
loading: false,
networkStatus: NetworkStatus.error,
},
resultData,
observable,
client,
partialRefetch,
handleStoreChange,
callbackRef.current
);
}
};
// TODO evaluate if we keep this in
// React Compiler cannot handle scoped `let` access, but a mutable object
// like this is fine.
// was:
// let subscription = observable.subscribe(onNext, onError);
const subscription = { current: observable.subscribe(onNext, onError) };
// Do the "unsubscribe" with a short delay.
// This way, an existing subscription can be reused without an additional
// request if "unsubscribe" and "resubscribe" to the same ObservableQuery
// happen in very fast succession.
return () => {
setTimeout(() => subscription.current.unsubscribe());
};
},
[
disableNetworkFetches,
isSyncSSR,
observable,
resultData,
partialRefetch,
client,
]
),
() =>
currentResultOverride ||
getCurrentResult(
resultData,
observable,
callbackRef.current,
partialRefetch,
client
),
() =>
currentResultOverride ||
getCurrentResult(
resultData,
observable,
callbackRef.current,
partialRefetch,
client
)
);
}
뭔가 긴 코드가 나왔지만... 두려워 말고 한 부분씩 살펴보자.
const callbackRef = React.useRef<Callbacks<TData>>(callbacks);
React.useEffect(() => {
// Make sure state.onCompleted and state.onError always reflect the latest
// options.onCompleted and options.onError callbacks provided to useQuery,
// since those functions are often recreated every time useQuery is called.
// Like the forceUpdate method, the versions of these methods inherited from
// InternalState.prototype are empty no-ops, but we can override them on the
// base state object (without modifying the prototype).
callbackRef.current = callbacks;
});
전반적으로 주석도 친절하게 적혀있고, 패턴 자체도 생소하진 않아서 살펴보는게 어렵진 않다.
useRef로 onCompleted, onError 콜백을 callbackRef에 저장하고, 매 렌더링마다 callbackRef에 보관된 콜백들을 최신화하기 위해 deps가 없는 useEffect를 사용했다.
onError, onCompleted로 주입받은 콜백을 최신화 시켜주지 않으면 해당 콜백에서 이전 상태를 참조하고 있는 경우 예상치 못한 사이드 이펙트가 발생할 수 있다. 해당 코드를 보니 불현듯 useEvent RFC가 생각이 난다.
그 다음 resultOverride ~ currentResultOverride 코드는 생략. 살짝 보자면 ssr 여부, skip 여부 등에 따라 기본 반환 데이터의 타입을 덮어씌우는 코드로 보인다.
클라이막스인 반환부의 useSyncExternalStore + useCallback 코드로 넘어가자.
return useSyncExternalStore(
React.useCallback(
(handleStoreChange) => {
// reference `disableNetworkFetches` here to ensure that the rules of hooks
// keep it as a dependency of this effect, even though it's not used
disableNetworkFetches;
if (isSyncSSR) {
return () => {};
}
// ...
뜬금없이 disableNetworkFetches 를 적어두는 재미있는 코드가 보인다. 호출도 아니고, 사용도 아니고, 할당도 아닌 말 그대로 참조만 한다. 주석을 보니 disableNetworkFetches의 단순 참조로 해당 훅이 disableNetworkFetches에 대한 의존성을 가지는 것을 강제하는 코드라고 한다.
만약 훅의 컨텍스트가 disableNetworkFetches를 참조할 수 있는 범위를 벗어나게 되면 참조 에러를 발생시키려는 의도로 보인다! 재미있고 해키한 구현이다. 만약 useCallback으로 선언된 해당 함수가 현재 컨텍스트에 바인딩 되지 않았다면 에러가 날 수도? 근데 이게 어떤 상황에서 발생하는 것인지 유즈케이스까지는 리뷰 코멘트를 봐도 알기가 어려웠다…🤔
해당 변경사항에 대한 리뷰 코멘트
https://github.com/apollographql/apollo-client/pull/11869#discussion_r1664889946
아래로 내려가서 onNext 콜백 선언부를 보자.
const onNext = () => {
const previousResult = resultData.current;
// We use `getCurrentResult()` instead of the onNext argument because
// the values differ slightly. Specifically, loading results will have
// an empty object for data instead of `undefined` for some reason.
const result = observable.getCurrentResult();
// Make sure we're not attempting to re-render similar results
if (
previousResult &&
previousResult.loading === result.loading &&
previousResult.networkStatus === result.networkStatus &&
equal(previousResult.data, result.data)
) {
return;
}
setResult(
result,
resultData,
observable,
client,
partialRefetch,
handleStoreChange,
callbackRef.current
);
};
onNext 메소드의 이름을 정확한 컨텍스트에서 이해하려면 옵저버 패턴과 Observable 객체에 대한 이해가 필요하다. 간단하게 말하면 옵저버 패턴은 공급자(Provider or Observable)가 관찰자들(Observers)에게 알림을 보내는 디자인 패턴이라고 할 수 있는데, 관찰자들은 onNext, onError, onComplete 등의 이벤트 수신 콜백 메소드를 구현해서 제공되는 이벤트에 대한 처리를 어떻게 할 지 선언한다. 즉, 이 맥락에서 onNext 메소드는 쿼리의 응답 따위를 수신한 상황이라고 할 수 있다.
이전에 이미 가져온 결과가 있고, 이전 결과와 현재 상태의 로딩이 같으면서, 네트워크 status도 동일하고, 그 결과까지 같다면 아무것도 하지 않고 return을 해준다. 렌더링이 추가적으로 필요하지 않은 상황이라고 간주하는 것으로 보인다.
그 외의 경우에는 setResult 를 호출해서 현재의 결과를 저장하는 것으로 보이는데, setResult 구현은 다음과 같다.
function setResult<TData, TVariables extends OperationVariables>(
nextResult: ApolloQueryResult<TData>,
resultData: InternalResult<TData, TVariables>,
observable: ObservableQuery<TData, TVariables>,
client: ApolloClient<object>,
partialRefetch: boolean | undefined,
forceUpdate: () => void,
callbacks: Callbacks<TData>
) {
const previousResult = resultData.current;
if (previousResult && previousResult.data) {
resultData.previousData = previousResult.data;
}
if (!nextResult.error && isNonEmptyArray(nextResult.errors)) {
// Until a set naming convention for networkError and graphQLErrors is
// decided upon, we map errors (graphQLErrors) to the error options.
// TODO: Is it possible for both result.error and result.errors to be
// defined here?
nextResult.error = new ApolloError({ graphQLErrors: nextResult.errors });
}
resultData.current = toQueryResult(
unsafeHandlePartialRefetch(nextResult, observable, partialRefetch),
resultData.previousData,
observable,
client
);
// Calling state.setResult always triggers an update, though some call sites
// perform additional equality checks before committing to an update.
forceUpdate();
handleErrorOrCompleted(nextResult, previousResult?.networkStatus, callbacks);
}
현재의 응답 데이터를 internalStore의 previousData로 저장한 후, 현재 응답에 에러가 있다면 ApolloError 객체를 생성하여 error 필드에 넣어준다. 이 에러는 handleErrorOrCompleted로 넘겨서 처리된다.
forceUpdate() 는 setResult 호출시 전달된 handleStoreChange 메소드이다. 해당 메소드는 useSyncExternalStore의 첫 번째 인자로 넘긴 useCallback 함수의 인자에 해당하는 함수인데, 코드로 보면 헷갈리지만 useSyncExternalStore의 시그니처를 보면 쉽게 이해가 가능하다.
export function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot,
): Snapshot;
바로 저 onStoreChange 부분이 handleStoreChange 라는 이름으로, 그리고 forceUpdate 라는 이름으로 전달된 부분이다.
useSyncExternalStore는 subscribe 함수를 통해 전달한 onStoreChange 를 호출할 때, Snapshot 간의 비교를 통해 렌더링 여부를 결정하게 되니 렌더링을 트리거하는 역할을 한다고 볼 수 있겠다. 그러니 엄밀히 말하면 반환된 Snapshot에 대한 동등성 비교 이후 렌더링 여부를 리액트에서 결정하는 셈이니 forceUpdate는 아니다.
잠깐 밖으로 새서 handleErrorOrCompleted도 살펴보자.
function handleErrorOrCompleted<TData>(
result: ApolloQueryResult<TData>,
previousNetworkStatus: NetworkStatus | undefined,
callbacks: Callbacks<TData>
) {
if (!result.loading) {
const error = toApolloError(result);
// wait a tick in case we are in the middle of rendering a component
Promise.resolve()
.then(() => {
if (error) {
callbacks.onError(error);
} else if (
result.data &&
previousNetworkStatus !== result.networkStatus &&
result.networkStatus === NetworkStatus.ready
) {
callbacks.onCompleted(result.data);
}
})
.catch((error) => {
invariant.warn(error);
});
}
}
컴포넌트 렌더 중 handleErrorOrCompleted 메소드가 호출될 경우, 바로 콜백들을 호출하지 않고 렌더링을 기다리기 위해 한 틱을 보내는 Promise.resolve()를 사용해준 부분이 흥미롭다.
https://github.com/apollographql/apollo-client/pull/9801
다시 useObserverbleSubscriptionResult 훅으로 돌아와 이어서 살펴보자.
const onError = (error: Error) => {
subscription.current.unsubscribe();
subscription.current = observable.resubscribeAfterError(
onNext,
onError
);
if (!hasOwnProperty.call(error, "graphQLErrors")) {
// The error is not a GraphQL error
throw error;
}
const previousResult = resultData.current;
if (
!previousResult ||
(previousResult && previousResult.loading) ||
!equal(error, previousResult.error)
) {
setResult(
{
data: (previousResult && previousResult.data) as TData,
error: error as ApolloError,
loading: false,
networkStatus: NetworkStatus.error,
},
resultData,
observable,
client,
partialRefetch,
handleStoreChange,
callbackRef.current
);
}
};
onError에 대한 콜백이 보인다. 좀 전에 onNext 내부의 setResult에서도 에러에 대한 처리를 하고 onError 콜백을 실행했던 것 같은데... onNext에서 받았던 result 내부의 error와 onError 콜백을 통해 받는 error의 차이는 뭘까?
일단 에러가 발생하면 subscription을 끊어주고, resubscribeAfterError 메소드를 통해 재구독을 해주는 모습이 보인다. resubscribeAfterError 내부 구현을 보니 마지막 데이터를 지우고 새로 구독을 하는 단순한 동작이다.
Graphql에러가 아닌 경우에는 throw error를 하는걸 보니 GraphqlError인 경우에 onError 콜백이 실행되는 것인지? 의심을 해본다. 다시 한 번 렌더링이 필요한지 확인하는 로직이 있고(여기서는 onNext와 달리 AND 연산자가 아니라 OR 연산자를 사용하는데 단순 가독성의 이유인지?) 마찬가지로 setResult를 통해 렌더링을 트리거한다.
바로 아랫 부분을 추가로 살펴보면
// TODO evaluate if we keep this in
// React Compiler cannot handle scoped `let` access, but a mutable object
// like this is fine.
// was:
// let subscription = observable.subscribe(onNext, onError);
const subscription = { current: observable.subscribe(onNext, onError) };
// Do the "unsubscribe" with a short delay.
// This way, an existing subscription can be reused without an additional
// request if "unsubscribe" and "resubscribe" to the same ObservableQuery
// happen in very fast succession.
return () => {
setTimeout(() => subscription.current.unsubscribe());
};
subscription에 대한 컴파일러의 평가 문제로 current 객체로 바꾼 부분의 확인이 필요하다…는 주석이 있고, subscribe 함수의 반환 함수인 구독 해제 함수를 setTimeout으로 감싸두었다.
무슨 말인지 살펴보니, 일부러 짧은 지연시간을 두어 '구독 취소 → 재구독 → 구독취소' 와 같이 짧은 간격으로 구독 취소와 재구독 이벤트가 발생하는 경우, 구독 취소를 지연시켜 곧 제거될 subscription 이지만 아직 제거되지 않은 subscription을 재사용하기 위함으로 보인다.
정확한 유즈케이스는 모르겠지만 subscription 구독과 동시에 불필요한 네트워크 요청이 발생할 수 있으니 이러한 부분을 방지하기 위한 것 같다.
getSnapShot 부분에 해당하는 코드는 간단하다.
() =>
currentResultOverride ||
getCurrentResult(
resultData,
observable,
callbackRef.current,
partialRefetch,
client
),
특정 조건에 오버라이드 되어야 하는 응답이 있는게 아니라면 getCurrentResult를 사용해서 현재 상태를 가져온다. getCurrentResult는 resultData.current 와 같다.
여기까지의 대략적인 흐름을 정리해보면 다음과 같다.
- useQuery 훅은 _useQuery 구현체의 wrapper이고, _useQuery 구현체는 useQueryInternals의 반환값에 대한 메모이제이션이다.
- useQueryInternals 에서 의심스러운 부분들을 보다보니 쿼리 결과를 반환하는 useObservableSubscriptionResult 훅이 있었다.
- useObservableSubscriptionResult 훅을 보니 useSyncExternalStore 훅을 사용해서 쿼리 결과에 대한 구독과 onNext, onError 콜백을 가진 옵저버 구현체로 이어진다.
- 결국 요청을 보내는 곳을 찾으려면 observable을 구독하는 곳이 아니라 만드는 곳을 봐야 한다.
다시 생각해보니 이미 useObservableSubscriptionResult 라는 훅 이름 자체가 구독을 관리하고 결과를 통해 콜백 호출과 상태 업데이트를 하는 곳이라는 이름이 명확하다! 그렇다면 실질적인 요청을 보내는 곳은 인자로 전달되는 observable 객체를 만드는 부분일 것이다. 이를 명심하고 다시 한 번 useQueryInternals 훅을 살펴보니 의심스러운 곳이 보인다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/react/hooks/useQuery.ts#L271
const [{ observable, resultData }, onQueryExecuted] = useInternalState(
client,
query,
options,
renderPromises,
makeWatchQueryOptions
);
useInternalState 라는 훅의 이름만 대충 보고 내부 상태만 관리하는 줄 알고 넘겼는데 자세히 보니 반환값에 observable이 있다. 세부 구현을 살펴보니 훅 구현체에서 observable을 선언해주는 곳을 확인 할 수 있다!
observable:
// See if there is an existing observable that was used to fetch the same
// data and if so, use it instead since it will contain the proper queryId
// to fetch the result set. This is used during SSR.
(renderPromises &&
renderPromises.getSSRObservable(makeWatchQueryOptions())) ||
client.watchQuery(
getObsQueryOptions(void 0, client, options, makeWatchQueryOptions())
),
SSR 환경에서 renderPromises에 이미 보관된 SSR Observable이 있는지 확인하고, 있다면 해당 Observable을 반환한다. 클라이언트 기준으로는 client.watchQuery 를 호출하면 Observable이 반환되는 모양이다.
ApollocCient.watchQuery는 QueryManager의 watchQuery의 wrapper이다.
https://github.com/apollographql/apollo-client/v3.11.8//main/src/core/QueryManager.ts#L723
public watchQuery<
T,
TVariables extends OperationVariables = OperationVariables,
>(options: WatchQueryOptions<TVariables, T>): ObservableQuery<T, TVariables> {
const query = this.transform(options.query);
// assign variable default values if supplied
// NOTE: We don't modify options.query here with the transformed query to
// ensure observable.options.query is set to the raw untransformed query.
options = {
...options,
variables: this.getVariables(query, options.variables) as TVariables,
};
if (typeof options.notifyOnNetworkStatusChange === "undefined") {
options.notifyOnNetworkStatusChange = false;
}
const queryInfo = new QueryInfo(this);
const observable = new ObservableQuery<T, TVariables>({
queryManager: this,
queryInfo,
options,
});
observable["lastQuery"] = query;
this.queries.set(observable.queryId, queryInfo);
// We give queryInfo the transformed query to ensure the first cache diff
// uses the transformed query instead of the raw query
queryInfo.init({
document: query,
observableQuery: observable,
variables: observable.variables,
});
return observable;
}
드디어 observable을 실질적으로 만들어주는 코드를 만났다!
const observable = new ObservableQuery<T, TVariables>({
queryManager: this,
queryInfo,
options,
});
생성자 코드를 빠르게 살펴보면...
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/ObservableQuery.ts#L111
constructor({
queryManager,
queryInfo,
options,
}: {
queryManager: QueryManager<any>;
queryInfo: QueryInfo;
options: WatchQueryOptions<TVariables, TData>;
}) {
super((observer: Observer<ApolloQueryResult<TData>>) => {
// Zen Observable has its own error function, so in order to log correctly
// we need to provide a custom error callback.
try {
var subObserver = (observer as any)._subscription._observer;
if (subObserver && !subObserver.error) {
subObserver.error = defaultSubscriptionObserverErrorCallback;
}
} catch {}
const first = !this.observers.size;
this.observers.add(observer);
// Deliver most recent error or result.
const last = this.last;
if (last && last.error) {
observer.error && observer.error(last.error);
} else if (last && last.result) {
observer.next && observer.next(last.result);
}
// Initiate observation of this query if it hasn't been reported to
// the QueryManager yet.
if (first) {
// Blindly catching here prevents unhandled promise rejections,
// and is safe because the ObservableQuery handles this error with
// this.observer.error, so we're not just swallowing the error by
// ignoring it here.
this.reobserve().catch(() => {});
}
return () => {
if (this.observers.delete(observer) && !this.observers.size) {
this.tearDownQuery();
}
};
});
ObservableQuery 객체는 zen-observable 구현체를 상속받아 구현되어 있는데, 그에 따른 처리들이 되어 있는 것을 볼 수 있다.
아래로 좀 내려오면...
const first = !this.observers.size;
//...
// Initiate observation of this query if it hasn't been reported to
// the QueryManager yet.
if (first) {
// Blindly catching here prevents unhandled promise rejections,
// and is safe because the ObservableQuery handles this error with
// this.observer.error, so we're not just swallowing the error by
// ignoring it here.
this.reobserve().catch(() => {});
}
observable을 구독하는 observers가 아직 없다는 것은 해당 쿼리를 이제 막 구독하기 시작했다는 의미와 동일하다. 이 경우에 this.reobserve 가 호출된다. catch에 대한 처리가 없는것처럼 보이지만 안심하라는 주석이 있다.
reobserve 메소드는 reobserveAsConcast 의 wrapper이다. as TODO 타입 단언이 재미있었다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/ObservableQuery.ts#L1005-L1011
Concast라는 용어가 생소했는데 Concast 객체 선언부에 주석으로 설명이 잘 되어 있었다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/utilities/observables/Concast.ts#L22-L50
Concast<T> 옵저버블은 주어진 소스들을 하나의 겹치지 않는 T 시퀀스로 연결하고, 자동으로 모든 프로미스를 풀어내며, 그 시퀀스의 T 요소들을 여러 구독자에게 방송합니다. 이 과정에서 중간 옵저버블 래퍼 객체를 많이 생성하지 않습니다.
Concast에는 여러 옵저버가 구독할 수 있지만, 각 소스 옵저버블은 최대 한 번의 구독 호출만 보장받으며, 그 결과는 모든 옵저버에게 멀티캐스트됩니다.
이 Concast는 모든 다음/오류 메시지를 this.observers에 방송하는 것 외에도, 가장 최근의 메시지를 this.latest를 사용해 저장합니다. 따라서 새로운 옵저버는 과거에 전달된 메시지라도 즉시 최신 메시지를 받을 수 있습니다. 이 동작은 this.observers에 있는 모든 활성 옵저버가 동일한 최신 메시지를 받았음을 보장합니다.
이 최신 메시지 재생을 제외하고, Concast는 "핫" 옵저버블입니다. 이는 새로운 옵저버마다 과거의 결과를 처음부터 재생하지 않는다는 의미입니다.
기존의 RxJS 클래스를 사용할 수 있었을까요? Concast<T>는 BehaviorSubject<T>와 유사합니다. 왜냐하면 멀티캐스트되고 새로운 구독자에게 최신의 다음/오류 메시지를 다시 전달하기 때문입니다. Subject<T>와 달리, Concast<T>는 옵저버<T> 인터페이스를 노출하지 않습니다(this.handlers는 의도적으로 비공개입니다). Concast<T>는 연결된 소스에서 입력을 받기 때문입니다. 만약 우리가 RxJS로 전환하게 된다면 그들의 코드를 재사용하는 데 가치가 있을 수 있지만, 현재로서는 Subject 구현을 포함하지 않는 zen-observable을 사용하고 있습니다.
reobserveAsConcast 를 살펴보자.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/ObservableQuery.ts#L895
const { concast, fromLink } = this.fetch(options, newNetworkStatus, query);
뭔가 요청으로 보이는 fetch 코드가 reobserveAsConcast 함수 내부에서 보이는데...? 호출 결과로concast, fromLink 를 반환한다.
fetch 메소드는 queryManager의 fetchConcastWithInfo 함수를 호출하는데
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/QueryManager.ts#L1242
해당 함수에서는 앞서 살펴본 Concast 객체를 생성하고 fetchQueryByPolicy 를 호출해주는 역할을 하고 있다.
대체 실제 fetch는 언제쯤 하는걸까? fetchQueryByPolicy 구현체를 퀵하게 보니, 여러 fetchPolicy에 따른 실질적인 요청부분을 볼 수 있었다. (그 와중에 switch 최상단 default: 로 cache-first를 기본 설정으로 세팅하는 디테일)
private fetchQueryByPolicy<TData, TVars extends OperationVariables>(
queryInfo: QueryInfo,
{
query,
variables,
//...
switch (fetchPolicy) {
default:
case "cache-first": {
const diff = readCache();
if (diff.complete) {
return {
fromLink: false,
sources: [resultsFromCache(diff, queryInfo.markReady())],
};
}
if (returnPartialData || shouldNotify) {
return {
fromLink: true,
sources: [resultsFromCache(diff), resultsFromLink()],
};
}
return { fromLink: true, sources: [resultsFromLink()] };
}
case "cache-and-network": {
const diff = readCache();
if (diff.complete || returnPartialData || shouldNotify) {
return {
fromLink: true,
sources: [resultsFromCache(diff), resultsFromLink()],
};
}
return { fromLink: true, sources: [resultsFromLink()] };
}
이름을 보니 느낌이 온다.
resultsFromCache 는 캐시에서 쿼리 결과를 조회하는 동작으로 보이고, resultsFromLink 는 실질적인 요청을 통해서 데이터를 가져오는 것으로 보인다. resultsFromLink는 QueryManager의 getResultsFromLink로 이어진다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/QueryManager.ts#L1154
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/QueryManager.ts#L1169-L1183
return asyncMap(
this.getObservableFromLink(
linkDocument,
options.context,
options.variables
),
(result) => {
const graphQLErrors = getGraphQLErrorsFromResult(result);
const hasErrors = graphQLErrors.length > 0;
const { errorPolicy } = options;
// If we interrupted this request by calling getResultsFromLink again
// with the same QueryInfo object, we ignore the old results.
if (requestId >= queryInfo.lastRequestId) {
asyncMap의 첫번째 인자는 observable, 두 번째 인자는 map function 으로, Observable의 결과를 맵핑하는 함수이니 사실상 콜백이라고 러프하게 생각해도 된다. 그렇다면 getObservableFromLink를 살펴보자.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/core/QueryManager.ts#L1082
뭔가 요청단의 코드와 점점 가까워진다고 느껴진다.
const { serverQuery, clientQuery } = this.getDocumentInfo(query);
if (serverQuery) {
const { inFlightLinkObservables, link } = this;
const operation = {
query: serverQuery,
variables,
operationName: getOperationName(serverQuery) || void 0,
context: this.prepareContext({
...context,
forceFetch: !deduplication,
}),
extensions,
};
슬슬 실제 요청에 필요한 document와 operationName 등을 가져오기 시작한다.
import { execute } from "../link/core/index.js";
//...
const concast = new Concast([
execute(link, operation) as Observable<FetchResult<T>>,
]);
execute! 뭔가 의미심장한 메소드 이름이 등장했다 .심지어 link에 대한 의존성을 가지고 있으니 기대가 된다.
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/link/core/ApolloLink.ts#L65-L77
public static execute(
link: ApolloLink,
operation: GraphQLRequest
): Observable<FetchResult> {
return (
link.request(
createOperation(
operation.context,
transformOperation(validateOperation(operation))
)
) || Observable.of()
);
}
//...
public request(
operation: Operation,
forward?: NextLink
): Observable<FetchResult> | null {
throw newInvariantError("request is not implemented");
}
드디어 도착했다…!
ApolloLink의 request 인자로 query 오퍼레이션을 payload 형태로 변환해주는 프로세스가 있고, 실질적인 요청은 이 곳에서 이뤄진다.
요청 코드에서 fetch api 혹은 axios, XMLHttpRequest 등을 찾을 필요는 없다. 아폴로 클라이언트는 http 요청에 필요한 구현체들을 자유롭게 주입할 수 있는 구조로 되어 있으며 이러한 구현체는 아폴로 클라이언트 초기화시 설정하는 httpLink 구성에서 자유롭게 정의할 수 있기 때문이다.
물론 이러한 부분을 쉽게 하기 위해 ApolloLink를 상속한 HttpLink와 같은 객체 프리셋들도 이미 제공하고있다.
예시 코드
import fetch from 'cross-fetch';
//...
const httpLink = new HttpLink({ uri: ENV.APOLLO_API_END_POINT, fetch });
const authLink = setContext((_, { headers }) => {
//...
});
const link = ApolloLink.from([authLink, httpLink]);
//...
export const initializeApollo = () => {
return new ApolloClient({
link,
cache,
//...
HttpLink는 createHttpLink를 통해 생성되는데,
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/link/http/HttpLink.ts
https://github.com/apollographql/apollo-client/blob/v3.11.8/src/link/http/createHttpLink.ts#L29-L48
const backupFetch = maybe(() => fetch);
export const createHttpLink = (linkOptions: HttpOptions = {}) => {
let {
uri = "/graphql",
// use default global fetch if nothing passed in
fetch: preferredFetch,
print = defaultPrinter,
includeExtensions,
preserveHeaderCase,
useGETForQueries,
includeUnusedVariables = false,
...requestOptions
} = linkOptions;
if (__DEV__) {
// Make sure at least one of preferredFetch, window.fetch, or backupFetch is
// defined, so requests won't fail at runtime.
checkFetcher(preferredFetch || backupFetch);
}
httpLink 생성시 fetch를 명시적으로 주입하지 않으면 각 환경의 backupFetch를 사용한다.
return new Observable((observer) => {
// Prefer linkOptions.fetch (preferredFetch) if provided, and otherwise
// fall back to the *current* global window.fetch function (see issue
// #7832), or (if all else fails) the backupFetch function we saved when
// this module was first evaluated. This last option protects against the
// removal of window.fetch, which is unlikely but not impossible.
const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch;
const observerNext = observer.next.bind(observer);
currentFetch!(chosenURI, options)
.then((response) => {
operation.setContext({ response });
const ctype = response.headers?.get("content-type");
if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
return readMultipartBody(response, observerNext);
} else {
return parseAndCheckHttpResponse(operation)(response).then(
observerNext
);
}
})
.then(() => {
controller = undefined;
observer.complete();
})
.catch((err) => {
controller = undefined;
handleError(err, observer);
});
return () => {
// XXX support canceling this request
// https://developers.google.com/web/updates/2017/09/abortable-fetch
if (controller) controller.abort();
};
});
});
마침내 실질적인 fetch 요청을 보내는 곳을 발견했다. currentFetch의 평가에 대해 재미있는 부분이 있는데,
linkOptions.fetch(preferredFetch)가 제공된 경우 이를 사용하고, 그렇지 않으면 현재 전역 window.fetch 함수를 사용합니다(문제 #7832 참조). 만약 이것도 불가능하다면, 모듈이 처음 평가될 때 저장된 backupFetch 함수를 사용합니다. 이 마지막 옵션은 window.fetch가 제거되는 상황에 대비한 것으로, 가능성은 낮지만 완전히 배제할 수는 없습니다.
fallback으로 전역 window.fetch를 사용하는 것은 놀랍지 않았는데, window.fetch가 없어지는 상황까지 고려하여 backup용으로 모듈 평가 시점의 backupFetch를 가져와서 사용하는 점이 재미있었다. currentFetch 를 평가하는 시점의 전역 fetch를 살펴보고, 만약 존재하지 않는다면 backupFetch가 선언되어 해당 코드를 브라우저에서 읽고 평가하는 순간에 캡쳐된 fetch를 참조하는 세심함이 있었다.
이렇게 했을 때, 설령 `createHttpLink` 호출 시점의 전역 fetch가 어떠한 이유로든 전역 참조에서 제거되더라도 평가 시점에 참조한 fetch 메소드를 사용할 수 있다.
소감
개인적으로 ApolloClient 코드들이 읽기 쉬운 편이라고 생각하는데, 그 중에서도 쉬운 useQuery를 골랐음에도 참 읽을게 많아 후반부엔 좀 지치는 감이 있었다. 그래도 전체적인 흐름이나 Observable 패턴의 구현체들을 봐서 새로웠고, Concast 같은 새로운 개념들도 알게 되어 좋았다.
참고
useQuery의 인자는 왜 객체 형태가 아닌걸까?
https://github.com/apollographql/apollo-client/pull/11869#discussion_r1665511736