Skip to content

Vercel Next.js - Open Source Contribution

Next.js의 서버 컴포넌트 동작 방식이 궁금해 관련 코드를 살펴보던 중, server.ts에서 서버 컴포넌트 환경에서 특정 React API의 사용을 제한하는 로직을 확인했다.

ts
// packages/next/src/server/typescript/constant.ts
export const DISALLOWED_SERVER_REACT_APIS: string[] = [
  'useState',
  'useEffect',
  ...
  'useOptimistic',
  'useActionState',
]

// packages/next/src/server/typescript/rules/server.ts
  if (DISALLOWED_SERVER_REACT_APIS.includes(name)) {
    diagnostics.push({ messageText: `"${name}" is not allowed in Server Components.` })
  }

그런데 DISALLOWED_SERVER_REACT_APIS 목록에 useMemo, useCallback, useEffectEvent는 포함되어 있지 않았다.

서버 컴포넌트는 요청 시 서버에서 실행되어 결과가 RSC Payload(직렬화된 JSON 구조) 형태로 전달되며, 클라이언트 컴포넌트처럼 리렌더링이 발생하지 않는다.

따라서 서버 컴포넌트 내에서 useMemouseCallback을 사용하는 것은 매 요청마다 컴포넌트가 새로 호출되는 서버의 특성상 성능최적화 관점에서 의미가 없다.

그럼에도 불구하고 제한되지 않은 이유가 무엇인지 궁금해졌고, 이벤트와 렌더링 분리를 목적으로 도입된 useEffectEvent가 없는 이유도 생각해봤을 때 Next.js가 서버 컴포넌트에서 React API를 제한하는 기준이 무엇인지 궁금하여 시작됐다.

useMemo와 useCallback은 왜 선언이 가능한가?

Next.js의 DISALLOWED_SERVER_REACT_APIS 목록에 useMemouseCallback이 포함되지 않은 이유는 React 서버 컴포넌트 구현체가 이 Hook들을 아무런 동작도 하지 않는(No-op) 상태로 처리하고 있기 때문이다.

즉, 서버 환경에서는 단순히 인자로 받은 함수나 값을 그대로 반환하도록 되어있다.

javascript
// React Server - packages/react-server/src/ReactFlightHooks.js
export const Dispatcher: DispatcherType = {
  useMemo<T>(create: () => T, deps: Array<mixed> | void | null): T {
    return create();
  },
  useCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
    return callback;
  },
  // ...
};

React 내부 코드를 보면 서버용 Dispatcher는 의존성 배열을 비교하거나 값을 메모리상에 유지하는 로직이 생략되어 있다.

javascript
// React Client - packages/react/src/ReactHooks.js
function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  // ...
  return ((dispatcher: any): Dispatcher);
}

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}

반면, 클라이언트 환경의 Hook은 현재 렌더링 상태를 관리하는 Dispatcher에 의존하며 복잡한 생명주기 로직을 수행한다.
클라이언트용 Dispatcher는 서버와 달리 이전 렌더링의 의존성 비교, 메모리 내 값 보존, 리렌더링 시 최적화 로직 등을 모두 포함하여 동작한다.

결국 React 서버 구현체 내에서 이 Hook들이 이미 아무런 동작도 하지 않는(No-op) 상태로 처리되고 있기에 Next.js도 이를 별도의 에러로 분류하지 않은 것이 아닐까 싶은 생각이다.

useEffectEvent는 왜 금지해야 하는가?

useMemouseCallback과 달리 useEffectEventuseEffect내 의존성 관리를 위해 생긴 것이기에 서버에서 허용하는 것은 설계 원칙과 일관성 측면에서 어긋난다고 생각한다.

따라서 useEffectEvent에 대한 것만 Pull request를 올리기로 결정했다.

첫 번째 Pull Request 실패

깔끔하게 잘 마무리하고 싶었다..
하지만, 리뷰 과정에서 수정해야 할 부분이 더 있다는 피드백을 받았다. 😂

First Pull request

어디가 수정되어야 하는지 다시 살펴보았고, 나는 Pull Request에서 가장 중요한 것도 놓쳤다..

가독성 좋은 코드와 설명으로 리뷰어의 시간을 절약하는 것

결국 처음 올렸던 Pull Request를 닫았다.

두 번째 Pull Request 성공

놓쳤던 코드를 수정한 뒤 다시 Pull Request를 올렸다.

하지만, 이번에는 이전과 다르게 내가 리뷰어가 되었다고 생각하며 확실하게 작업했다.

  • 간결하게 정리한 내용
  • 예상 결과
  • 현재 동작 방식과 기대 동작의 차이
  • 어떤 코드를 작업했는지

모든 스타일을 바꿨다.

Second Pull request

그 결과, 이전과는 다르게 좋은 코멘트를 받을 수 있었다.

두 번째 오픈소스 기여였는데 ReactNext.js의 코드 분석도 좋았지만, 다른 사람이 남긴 히스토리를 추적하며 코드와 스타일, 오픈소스 컨트리뷰션 규칙 등을 지키면서 작업하니 회사에서 경험했던 협업과는 또 다른 느낌이었다.