useRef와 전역 변수의 차이

useRef와 전역 변수의 차이

한눈에 보기

useRef와 모듈 전역 변수는 값을 바꿔도 React 렌더를 요청하지 않는다는 점만 같다. ref는 특정 컴포넌트 인스턴스가 소유하고 그 인스턴스의 생명주기를 따른다. 모듈 변수는 같은 JavaScript 실행 환경의 모든 인스턴스와, 서버에서는 여러 요청이 공유할 수 있다. 화면에 영향을 주는 값은 state, 인스턴스별 비시각적 값은 ref, 정말 애플리케이션 전체가 공유해야 하는 값만 별도 저장소에 둔다.

목차

둘 다 렌더를 일으키지 않는다는 오해

렌더 사이에 값을 보존하되 값이 바뀌어도 재렌더를 원하지 않을 때 useRef를 쓴다는 설명을 자주 접한다. 그러면 다음 질문이 생긴다.

모듈 위에 변수 하나를 선언해도 값이 유지되고 재렌더되지 않는데, 굳이 ref를 써야 할까?

표면적으로 두 코드는 비슷해 보인다.

let renderCount = 0;

function GlobalCounter() {
  renderCount += 1;
  return <p>{renderCount}</p>;
}
function RefCounter() {
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;
  return <p>{renderCountRef.current}</p>;
}

두 번째 코드도 렌더 중 ref를 수정하므로 좋은 예제는 아니다. 여기서 중요한 것은 “값이 남는다”가 아니라 어디에 몇 개가 만들어지고 언제 폐기되는가다.

선택 기준

렌더 여부가 아니라 값의 소유권을 먼저 묻는다. 컴포넌트 인스턴스마다 따로 있어야 하면 ref, 화면이 반응해야 하면 state, 모든 소비자가 정말 하나를 공유해야 할 때만 외부 저장소를 고려한다.

값의 소유자가 가장 큰 차이다

같은 SearchBox를 두 개 렌더해 보자.

function SearchPage() {
  return (
    <>
      <SearchBox label="상품 검색" />
      <SearchBox label="브랜드 검색" />
    </>
  );
}

각 컴포넌트에서 ref를 만들면 서로 다른 ref 객체를 갖는다.

function SearchBox({ label }: { label: string }) {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFocus() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <label>
        {label}
        <input ref={inputRef} />
      </label>
      <button type="button" onClick={handleFocus}>
        입력창으로 이동
      </button>
    </div>
  );
}

상품 검색 버튼은 상품 입력에, 브랜드 검색 버튼은 브랜드 입력에 focus한다. ref는 각 컴포넌트가 차지한 트리 위치에 연결되어 있다.

반면 모듈 변수는 하나다.

let inputElement: HTMLInputElement | null = null;

function SearchBox({ label }: { label: string }) {
  function handleFocus() {
    inputElement?.focus();
  }

  return (
    <div>
      <label>
        {label}
        <input ref={(node) => { inputElement = node; }} />
      </label>
      <button type="button" onClick={handleFocus}>
        입력창으로 이동
      </button>
    </div>
  );
}

마지막으로 mount되거나 ref callback이 실행된 input이 전역 변수를 덮어쓴다. 두 버튼 모두 같은 input에 focus할 수 있고, 한 인스턴스가 unmount되며 null을 기록하면 남아 있는 인스턴스도 영향을 받는다.

속성 useRef 모듈 전역 변수
생성 단위 컴포넌트 인스턴스마다 모듈 실행 환경마다
생명주기 mount된 인스턴스와 함께 모듈이 메모리에 있는 동안
여러 인스턴스 서로 격리됨 하나를 공유함
변경 시 렌더 발생하지 않음 발생하지 않음
SSR 요청 격리 컴포넌트 렌더별 프로세스 안에서 공유될 수 있음
React가 소유권을 앎 알고 있음 알지 못함

ref가 적합한 세 가지 사용 사례

1. DOM 노드 참조

focus, scroll, media 재생처럼 imperative DOM API를 호출할 때 사용한다.

function ErrorSummary({ errors }: { errors: FormError[] }) {
  const summaryRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (errors.length > 0) {
      summaryRef.current?.focus();
    }
  }, [errors.length]);

  return (
    <div ref={summaryRef} tabIndex={-1} aria-live="polite">
      {errors.map((error) => (
        <p key={error.field}>{error.message}</p>
      ))}
    </div>
  );
}

React가 DOM을 commit한 뒤 current에 노드를 넣고, 노드가 제거되면 null로 되돌린다. 렌더 중에는 아직 새 DOM이 commit되지 않았을 수 있으므로 event handler나 Effect에서 접근한다.

2. 외부 자원의 handle

timer ID, WebSocket 인스턴스, observer처럼 화면에 직접 표시되지 않는 mutable handle을 보관한다.

function AutoSaveEditor() {
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  function scheduleSave(content: string) {
    if (timeoutRef.current !== null) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = setTimeout(() => {
      void saveDraft(content);
    }, 800);
  }

  useEffect(() => {
    return () => {
      if (timeoutRef.current !== null) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return <Editor onChange={scheduleSave} />;
}

두 Editor 인스턴스가 있으면 timer도 각각 따로 관리된다. 전역 timer ID를 쓰면 한 편집기의 입력이 다른 편집기의 저장 예약을 취소할 수 있다.

3. 렌더 결과와 무관한 이전 정보

이전 prop과 현재 prop을 비교해 분석 로그를 남기는 용도로 사용할 수 있다.

function ProductView({ productId }: { productId: string }) {
  const previousProductIdRef = useRef<string | null>(null);

  useEffect(() => {
    const previous = previousProductIdRef.current;

    if (previous !== null && previous !== productId) {
      analytics.track("product_changed", {
        from: previous,
        to: productId,
      });
    }

    previousProductIdRef.current = productId;
  }, [productId]);

  return <ProductDetail productId={productId} />;
}

이 값은 UI를 결정하지 않고 Effect의 부수 작업에만 쓰인다.

ref 변경이 화면에 반영되지 않는 이유

ref는 일반 JavaScript 객체다.

type MutableRef<T> = {
  current: T;
};

ref.current를 바꾸는 것은 React state update queue에 아무 작업도 추가하지 않는다.

function HiddenCounter() {
  const countRef = useRef(0);

  function handleClick() {
    countRef.current += 1;
    console.log(countRef.current);
  }

  return (
    <button onClick={handleClick}>
      화면에는 계속 {countRef.current}
    </button>
  );
}

클릭할 때 로그는 증가하지만 렌더가 다시 일어나지 않아 버튼 텍스트는 그대로다. 다른 이유로 부모가 재렌더하면 그때 변경된 값이 우연히 보일 수 있다. 이처럼 화면과 ref가 불규칙하게 동기화되는 코드는 피한다.

화면에 보여야 하는 값은 state다.

function VisibleCounter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((current) => current + 1)}>
      {count}
    </button>
  );
}
ref는 더 빠른 state가 아니다

ref로 렌더를 피하면서 UI도 갱신하려는 요구는 모순이다. 사용자가 변경을 봐야 한다면 React가 다음 UI를 계산하도록 state를 사용한다.

렌더 중 ref를 읽거나 쓰지 않는다

React의 렌더 함수는 같은 입력에 대해 같은 JSX를 반환하는 순수 계산이어야 한다. 렌더 중 ref.current를 변경하면 호출 횟수에 따라 결과가 달라진다.

function RenderSequence() {
  const sequenceRef = useRef(0);
  sequenceRef.current += 1;

  return <p>render {sequenceRef.current}</p>;
}

개발 Strict Mode에서 컴포넌트가 추가 호출되거나 concurrent rendering에서 render가 버려지면 화면에 commit된 횟수와 ref 값이 다를 수 있다.

렌더 중 DOM ref를 읽는 것도 안전하지 않다.

function Panel() {
  const panelRef = useRef<HTMLDivElement>(null);
  const width = panelRef.current?.getBoundingClientRect().width ?? 0;

  return <div ref={panelRef}>너비: {width}</div>;
}

렌더 시점에는 새 DOM 변경이 아직 commit되지 않았고 ref가 null이거나 이전 DOM 측정값일 수 있다. layout 측정은 useLayoutEffect에서 하고, 측정 결과가 UI에 필요하면 state로 옮긴다.

function Panel() {
  const panelRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    const panel = panelRef.current;
    if (!panel) return;

    const observer = new ResizeObserver(([entry]) => {
      setWidth(entry.contentRect.width);
    });

    observer.observe(panel);
    return () => observer.disconnect();
  }, []);

  return <div ref={panelRef}>너비: {Math.round(width)}</div>;
}

예외적으로 초기값을 만드는 코드가 순수하고 결과가 항상 같다면 렌더 중 current === null 검사로 지연 초기화할 수 있지만, 외부 부수 효과는 포함하지 않는다.

전역 변수가 여러 인스턴스를 섞는 과정

검색 요청의 순번을 전역으로 관리하는 코드를 보자.

let latestRequestId = 0;

function ProductSearch({ storeId }: { storeId: string }) {
  const [products, setProducts] = useState<Product[]>([]);

  async function search(query: string) {
    const requestId = ++latestRequestId;
    const result = await fetchProducts(storeId, query);

    if (requestId === latestRequestId) {
      setProducts(result);
    }
  }

  return <SearchView products={products} onSearch={search} />;
}

화면에 매장 A와 매장 B 검색 컴포넌트가 동시에 있으면 서로 같은 latestRequestId를 쓴다. B에서 새 검색을 시작했다는 이유로 A의 정상 응답이 오래된 것으로 판정될 수 있다.

ref로 바꾸면 각 인스턴스가 자신의 요청 순서를 가진다.

function ProductSearch({ storeId }: { storeId: string }) {
  const latestRequestIdRef = useRef(0);
  const [products, setProducts] = useState<Product[]>([]);

  async function search(query: string) {
    const requestId = ++latestRequestIdRef.current;
    const result = await fetchProducts(storeId, query);

    if (requestId === latestRequestIdRef.current) {
      setProducts(result);
    }
  }

  return <SearchView products={products} onSearch={search} />;
}

요청 자체를 취소할 수 있다면 AbortController가 더 분명할 수 있다. ref에는 controller를 보관하고 다음 검색이나 unmount 때 취소한다.

const controllerRef = useRef<AbortController | null>(null);

async function search(query: string) {
  controllerRef.current?.abort();

  const controller = new AbortController();
  controllerRef.current = controller;

  const result = await fetchProducts(query, {
    signal: controller.signal,
  });

  setProducts(result);
}

SSR에서 사용자별 값을 전역에 두면 안 되는 이유

브라우저에서는 탭마다 JavaScript 환경이 대체로 분리되어 있다. 서버 렌더링 환경에서는 하나의 Node.js 프로세스가 여러 사용자의 요청을 처리하고 모듈 cache를 공유한다.

let currentUser: User | null = null;

export async function renderPage(request: Request) {
  currentUser = await authenticate(request);
  return renderToString(<App user={currentUser} />);
}

요청 A가 currentUser = Alice를 기록한 뒤 렌더 중 대기하고, 요청 B가 Bob으로 덮어쓰면 A의 후속 코드가 Bob을 읽을 수 있다.

sequenceDiagram
    participant A as 요청 A
    participant G as 모듈 전역
    participant B as 요청 B
    A->>G: currentUser = Alice
    B->>G: currentUser = Bob
    A->>G: currentUser 읽기
    G-->>A: Bob

사용자 정보, locale, 권한, request ID를 모듈 변수에 두면 요청 간 데이터 유출로 이어질 수 있다. 요청 값을 props나 request-scoped context로 명시적으로 전달한다.

export async function renderPage(request: Request) {
  const user = await authenticate(request);
  const requestContext = createRequestContext({ request, user });

  return renderToString(
    <RequestContext.Provider value={requestContext}>
      <App />
    </RequestContext.Provider>,
  );
}

프레임워크가 제공하는 request scope나 AsyncLocalStorage를 사용할 수도 있지만 생명주기와 비동기 경계가 정확히 전파되는지 확인해야 한다.

전역 cache와 사용자 상태는 다르다

변경 불가능한 설정이나 사용자와 무관한 제한된 cache는 모듈 범위에 둘 수 있다. 사용자별 인증·권한·입력은 절대 프로세스 전역 공유 상태로 두지 않는다.

Strict Mode와 초기화 비용

다음 코드는 useRef(new VideoPlayer())를 호출할 때마다 생성자 표현식을 평가한다.

function Video() {
  const playerRef = useRef(new VideoPlayer());
  return <PlayerView player={playerRef.current} />;
}

React는 첫 렌더 이후 전달한 initial value를 무시하지만 new VideoPlayer() 표현식 자체는 함수가 호출될 때 실행된다. 생성 비용이 크거나 자원을 여는 생성자라면 문제가 된다.

순수한 지연 초기화는 다음처럼 할 수 있다.

function Video() {
  const playerRef = useRef<VideoPlayer | null>(null);

  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }

  return <PlayerView player={playerRef.current} />;
}

이 패턴은 결과가 예측 가능하고 렌더 외부에 부수 효과를 만들지 않을 때만 사용한다. DOM listener 등록, network connection, 전역 registry 변경은 Effect에서 만들고 cleanup한다.

useEffect(() => {
  const player = new RemotePlayer();
  player.connect();
  playerRef.current = player;

  return () => {
    player.disconnect();
    playerRef.current = null;
  };
}, []);

Strict Mode에서 ref 객체가 개발 중 두 번 만들어질 수 있지만 한쪽은 버려진다. 컴포넌트가 순수하다면 결과에 영향을 주지 않는다.

ref로 최신 값을 읽는 패턴의 한계

오래 유지되는 외부 callback이 최신 props를 읽게 하려고 ref를 쓸 수 있다.

function KeyboardShortcut({ onSave }: { onSave: () => void }) {
  const onSaveRef = useRef(onSave);

  useEffect(() => {
    onSaveRef.current = onSave;
  }, [onSave]);

  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      if ((event.metaKey || event.ctrlKey) && event.key === "s") {
        event.preventDefault();
        onSaveRef.current();
      }
    }

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);
}

이렇게 하면 onSave 함수 참조가 바뀔 때마다 전역 listener를 다시 등록하지 않는다. 하지만 남용하면 Effect가 어떤 값 변화에 반응하는지 코드에서 보이지 않는다.

다음 경우에는 다른 해법이 낫다.

사용하는 React 버전이 Effect Event를 안정적으로 제공한다면 최신 callback 읽기를 더 명시적으로 표현할 수 있다. 버전별 API 상태는 발행 시 공식 문서를 확인한다.

공유 상태가 필요하면 명시적인 저장소를 사용한다

전역 변수가 항상 나쁜 것은 아니다. 애플리케이션 전체에 하나만 있어야 하는 logger, 변경 불가능한 설정, 명시적인 cache 인스턴스는 모듈 범위가 자연스러울 수 있다.

문제는 변경 가능한 전역 값을 UI에서 직접 읽고 쓰면서 구독과 소유권이 없는 경우다.

let selectedWorkspaceId = "";

export function selectWorkspace(id: string) {
  selectedWorkspaceId = id;
}

값이 바뀌어도 React는 어떤 컴포넌트를 다시 렌더해야 하는지 모른다. 테스트 사이에 값도 남을 수 있다.

공유 상태라면 변경 API, 구독, snapshot 읽기를 가진 store로 만든다.

type Listener = () => void;

class WorkspaceStore {
  private selectedId: string | null = null;
  private readonly listeners = new Set<Listener>();

  getSnapshot = () => this.selectedId;

  subscribe = (listener: Listener) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  select(id: string) {
    if (this.selectedId === id) return;
    this.selectedId = id;
    this.listeners.forEach((listener) => listener());
  }
}

React에서는 useSyncExternalStore로 구독할 수 있다.

function useSelectedWorkspace(store: WorkspaceStore) {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getSnapshot,
  );
}

실제로는 검증된 상태 관리 라이브러리나 Context가 더 적합할 수 있다. 핵심은 “전역 변수라서 어디서나 접근 가능”이 아니라 소유권, 변경 규칙, 구독, SSR 격리를 명시하는 것이다.

테스트와 리뷰 체크리스트

여러 인스턴스가 격리되는지 테스트하면 전역 변수 실수를 잡을 수 있다.

it("각 검색창의 focus 대상이 서로 독립적이다", async () => {
  const user = userEvent.setup();
  render(
    <>
      <SearchBox label="상품 검색" />
      <SearchBox label="브랜드 검색" />
    </>,
  );

  await user.click(
    screen.getByRole("button", { name: "브랜드 입력창으로 이동" }),
  );

  expect(screen.getByLabelText("브랜드 검색")).toHaveFocus();
  expect(screen.getByLabelText("상품 검색")).not.toHaveFocus();
});

timer와 observer는 unmount 후 정리되는지 확인한다. SSR 관련 코드는 서로 다른 사용자 요청을 동시에 실행해 데이터가 섞이지 않는지 통합 테스트한다.

값을 저장하기 전에

  • 값이 바뀌면 화면도 바뀌어야 하는가? → state
  • 컴포넌트 인스턴스마다 따로 필요한가? → ref
  • DOM 또는 외부 자원의 handle인가? → ref + cleanup
  • 여러 컴포넌트가 변경을 구독해야 하는가? → Context/store
  • 서버 요청별 데이터인가? → request scope
  • 정말 프로세스 전체에 하나여야 하는가? → 명시적인 global service/cache

정리

useRef와 모듈 전역 변수는 모두 변경 시 렌더를 요청하지 않지만 소유 범위가 완전히 다르다. ref는 React 트리 안의 특정 컴포넌트 인스턴스에 속하고, 모듈 변수는 같은 실행 환경의 모든 인스턴스가 공유한다.

ref는 렌더를 피하기 위한 전역 변수 대체품이 아니라, 컴포넌트 인스턴스가 소유하는 비시각적 mutable 값을 보관하는 escape hatch다.

관련 노트와 참고 자료