주 3천건이 찍히는 에러가 발생했다
DataDog에서 주 단위 약 3천 건의 특정 에러가 찍히고 있는 것을 발견했다.
Server Functions cannot be called during initial render. This would create a fetch waterfall. Try to use a Server Component to pass data to Client Components instead.
왜 서버 함수가 왜 렌더 중에 찍히는걸까. 원인은 useSuspenseQuery를 사용하는 부분에 있었다. AI에게 검수를 시키자 suspensQuery의 queryFn에 ServerAction이 들어가있기에 이걸 useQuery로 바꾸라고 했다. 근데 뭔가 의아했다.
어떻게 useQuery로 변경하는게 문제를 해결해주는거지?
그 이유를 한 번 TanStack Query의 코드를 통해서 알아보자. 일단 useQuery와 useSuspenseQuery의 차이를 먼저 살펴봤다. (@tanstack/react-query@5.103.3)
// useQuery.ts
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}
// useSuspenseQuery.ts
export function useSuspenseQuery<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseSuspenseQueryResult<TData, TError> {
if (process.env.NODE_ENV !== 'production') {
if ((options.queryFn as any) === skipToken) {
console.error('skipToken is not allowed for useSuspenseQuery')
}
}
return useBaseQuery(
{
...options,
enabled: true,
suspense: true, // useQuery와 다르게 별도의 옵션들이 존재함. suspense가 true로 들어가있음.
throwOnError: defaultThrowOnError,
placeholderData: undefined,
},
QueryObserver,
queryClient,
) as UseSuspenseQueryResult<TData, TError>
}
useSuspenseQuery에는 useQuery와 다르게 suspense가 true로 들어가고 있다. 이 옵션 값이 baseQuery에서 어떻게 쓰이는지 알아보기 위해 useBaseQuery 코드를 확인해보자.
// useBaseQuery.ts
export function useBaseQuery<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey extends QueryKey,
>(
options: UseBaseQueryOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
Observer: typeof QueryObserver,
queryClient?: QueryClient,
): QueryObserverResult<TData, TError> {
// ...
// note: this must be called before useSyncExternalStore
const result = observer.getOptimisticResult(defaultedOptions)
const shouldSubscribe = !isRestoring && subscribed
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: noop
// Update result to make sure we did not miss any query updates
// between creating the observer and subscribing to it.
observer.updateResult()
return unsubscribe
},
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)
React.useEffect(() => {
observer.setOptions(defaultedOptions)
}, [defaultedOptions, observer])
// Handle suspense -> 여기서 suspense의 경우, 먼저 처리되고 있음
if (shouldSuspend(defaultedOptions, result)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
// ...
}
export const shouldSuspend = (
defaultedOptions:
DefaultedQueryObserverOptions<any, any, any, any, any> | undefined,
result: QueryObserverResult<any, any>,
) => defaultedOptions?.suspense && result.isPending
ReactQuery는 코드가 많고 복잡해보이지만 핵심은 QueryCache 객체를 Observer 패턴으로 관리하는게 전부이다. 여기서보면 useSyncExternalStore를 사용해서 외부 객체들을 관리하는데, 이 subscribe는 커밋 이후에 실행된다. 그런데 shouldSuspend 검사와 throw는 그보다 앞인 렌더 중에 일어난다. shouldSuspend가 true가 되면, 바로 fetchOptimistic으로 Promise를 throw하는걸 코드에서 확인할 수 있다. 즉, useQuery의 queryFn은 커밋 후, subscribe 안에서 실행되고, useSuspenseQuery는 커밋 전, optimistic하게 이 컴포넌트가 렌더되고 있는 과정에서 queryFn을 fetch해버리는 것이다.
useSuspenseQuery의 실행 순서를 다시 정리해보자면 아래와 같다.
- 렌더 시작, 캐시 비어 있음 -> 기본 pending 상태
- throw fetchOptimistic() -> 이 안에서 query.fetch() -> queryFn() 실행
- throw로 인해 렌더 중단 -> 커밋, subscribe 단계 없음
- Promise가 settle되면 React가 처음부터 다시 렌더함
- 더 이상 상태가 pending이 아님 -> if 통과 -> return result -> 커밋 -> subscribe
내 문제는 여기서 2번, 렌더 단계에 Server Funtion이 호출된 것이었다. 도대체 어떤 서버 함수가 들어갔을지 파보니 원인은 유저 정보를 가져오는 부분에 있었다. 서비스에 두 종류의 유저 정보를 가져오는 로직이 존재하는데, 이때 두 유저 로직이 한 파일에 응집되어 있었다. 로그인 시에 쿠키 정보를 업데이트하는 부분들을 모아둔 'use server' 파일이었는데, 여기에 쿠키와 무관한 불필요한 get 요청이 있었다. 그게 바로 useSuspenseQuery에 들어가게되는게 문제였다. 이 함수가 SSR 패스 중 useSuspenseQuery로 인해 렌더 단계에서 실행되므로 에러를 발생시켰던 것이다. 그럼에도 화면에 에러로 보이지 않은 이유는 SSR에서 브라우저로 넘어오면 같은 쿼리가 진짜 fetch로 재실행되어 성공 후, 화면은 정상이 되고 로그만 남기 때문이다. 이것도 어떻게 SSR의 Error를 브라우저에서의 재실행으로 만드는 건지 궁금해서 찾아봤다.
// react/packages/react-server/src/ReactFizzServer.js
renderSuspenseBoundary(){
//...
} catch (thrownValue: mixed) {
newBoundary.status = CLIENT_RENDERED;
let error: mixed;
if (request.aborted) {
contentRootSegment.status = ABORTED;
error = request.fatalError;
} else {
contentRootSegment.status = ERRORED;
error = thrownValue;
}
const thrownInfo = getThrownInfo(task.componentStack);
const errorDigest = logRecoverableError(
request,
error,
thrownInfo,
__DEV__ ? task.debugTask : null,
);
encodeErrorForBoundary(...)
Fiber는 브라우저에서 돌아가는 Client 렌더러이고, Fizz는 SSR에서 HTML을 만들어주는 렌더러이다. 그리고 Flight는 RSC 페이로드를 직렬화하는 역할을 하는데, 뭔가 지금 이렇게 모아보니 Flight는 브라우저 Preflight처럼 먼저 실행하는 느낌이고, Fizz는 fizzy하게 뭔가 부글부글 실행되는 느낌을 주려고하는 것같고, Fiber는 실 한올 한올 꿰서 옷 만들듯이 지은 이름같이 느껴진다.
여튼 다시 돌아와서 위에서 보이듯 Fizz는 경계 안에서 throw가 나면 요청 전체를 실패시키지 않는다. 그 경계만 CLIENT_RENDERED로 표시하고, 에러는 logRecoverableError로 onError 콜백에 넘긴 뒤(이게 Datadog에 찍힌 로그다), 돌려받은 digest를 경계에 저장한다. 그리고 flush 단계에서 이 status와 HTML에 특정 주석을 함께 넘기는데, 주석의 종류는 아래와 같다.
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
const SUSPENSE_START_DATA = '$'; // 정상 완료된 경계
const SUSPENSE_END_DATA = '/$';
const SUSPENSE_PENDING_START_DATA = '$?'; // 아직 스트리밍 중
const SUSPENSE_FALLBACK_START_DATA = '$!'; // 서버에서 실패, 클라가 렌더해야 함
HTML에서 SUSPENSE_FALLBACK_START_DATA 를 만나면 하이드레이션을 하지 않고 그 경계를 클라이언트에서 처음부터 렌더한다. 아래 로직과 같이retrySuspenseComponentWithoutHydrating를 하도록 만들어져있다.
// packages/react-reconciler/src/ReactFiberBeginWork.js
if (isSuspenseInstanceFallback(suspenseInstance)) {
// This boundary is in a permanent fallback state. In this case, we'll never
// get an update and we'll never be able to hydrate the final content.
// Let's just try the client side render instead.
const { digest } = getSuspenseInstanceFallbackErrorDetails(suspenseInstance);
const error = new Error(
'The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering. Switched to client rendering.',
);
error.digest = digest;
const capturedValue = createCapturedValueFromError(error, digest, stack);
return retrySuspenseComponentWithoutHydrating(
current, workInProgress, renderLanes, capturedValue,
);
}
이 로직으로 인해서 화면에 에러가 발생하지 않고, 로그로만 기록되고 복구되었던 것이다. Suspense를 쓰지않았다면 그냥 다른 에러들처럼 에러가 발생했겠지. Suspense를 사용하면 데이터가 존재한다는 확신아래에 코드를 작성할 수 있는 장점과 로딩 Fallback 처리가 가능해서 좋은 것이라고만 생각했는데, 이걸보니 Suspense는 에러와 렌더 처리를 가능하게 하는 경계이기도 하구나라는 생각이 든다. 그 내부를 SSR에서 HTML에 주석을 포함시켜보내고, 그 주석을 만나면 여기부터는 어떻게 처리할지 결정이 가능하기 때문이다.
이제 다시 돌아가, 글의 첫 번째 의문을 돌아가 답해보자. 왜 useQuery를 사용하면 이 문제가 발생하지 않는 것일까? 정답은 아까 useQuery와 useSuspenseQuery의 코드에서 알 수 있다. useQuery에는 suspense 옵션이 존재하지 않았고, 그 결과 render 단계에서 queryFn의 실행도 없다. SSR에서는 subscribe 자체가 일어나지 않으니, 함수를 호출하지 않고 브라우저 패스에서 첫 실행되기 때문이다.
그런데 또 의문점이 생긴다. ServerFunction에 필요없는 함수를 분리하는게 우선일 것 같은데, 왜 AI는 useQuery를 추천한걸까? Suspense를 사용할 수 없는 것은 고사하고, 단순히 render 단계에서 서버 함수의 호출이 없다고 useQuery를 사용해도 되는 것인가? 나는 Server Function에 대해 얼마나 알고 있는가? 공식 문서를 다시 찬찬히 읽어봤다.


Server Funtion을 단순한 db에 접근하거나 민감 데이터를 조작하기 위한 서버 함수라고만 생각했지, 이 뜻을 과거에는 제대로 알지 못했던 것 같다. Server Function을 클라이언트 컴포넌트에서 호출하는 것엔 문제가 없다. 하지만 제대로 써야한다. 그 조건은 크게 POST, 캐시 없음, 순차 실행이라는 성질을 갖는다.
왜 그런지 알아보자. 두 개의 공식 문서에 나와있듯 Server Action은 서버 측 상태를 업데이트하는 Mutation을 위해 설계되었다. 데이터를 가져오는 GET의 경우, 서버 컴포넌트에서 이미 데이터를 읽어와 HTML을 만들어주는 그 역할을 해주고 있기 때문이다. POST인 이유는 Next.js 코드의 아랫부분에 보면 자세히 나와있지만, GET은 HTTP상 안전하다고 판단돼서 캐시, 프리페치가 동작하기 때문이다. 그래서 Mutation을 GET에 실으면 캐시가 대신 응답해서 변경이 반영이 안되고, CDN 캐시는 유저를 구분 못 해 남의 응답을 받게된다.
그런데 지금 나의 경우는 어떠한가. useQuery를 사용해 브라우저에서 queryFn을 호출한들, 이는 서버에서 아예 호출을 안 해서 에러를 없앤 것일 뿐이다. 그리고 브라우저에서는 여전히 읽기를 POST로 보낼 것이다. 증상만 지운 것이다. 그래서 useQuery로 바꾸는 것이 아니라, 'use server' 파일에서 단순 유저 읽기 요청이었던 메서드를 분리했다. 이제 이 함수는 서버에서도 브라우저에서도 그냥 GET이고, useSuspenseQuery의 queryFn에 넣어도 렌더 중 호출이 문제가 되지 않는다. 배포 후 같은 에러는 바로 0건이 됐다. 그런데 파일을 분리하고 배포하자 다른 SSR 에러가 next-intl에서 발생했다. 다음 글에서 계속....
p.s 근데 이거 3천건에서 0건으로 줄이면 비용이 꽤 아껴질줄알았는데, 계산해보니 얼마안되서 성과에 넣을 수 없어서 아쉬웠다. 에러 민감도는 해결됐겠지만 말이다.