참조 안정성을 타입으로 증명하기: stableref
작성일:2026.10.03|수정일:2026.10.04|조회수:21

리액트에서 memo를 사용하다 보면 조금 이상한 상태에 놓인다. 컴포넌트는 같은 prop을 받으면 다시 렌더링하지 않도록 만들었는데, 정작 타입만 봐서는 그 prop이 다음 렌더링에서도 같은 참조로 전달될지 알 수 없다. Item[]은 배열 안에 무엇이 들어 있는지는 알려주지만, 그 배열을 매번 새로 만들고 있는지는 알려주지 않는다.
type ItemListProps = { items: Item[] onSelect: (id: string) => void}const ItemList = memo((props: ItemListProps) => { // ...})<ItemList items={items.filter(isVisible)} onSelect={(id) => select(id)}/>이 코드는 타입 검사도 통과하고 화면도 제대로 그린다. 다만 items와 onSelect는 렌더링할 때마다 새로 만들어지기 때문에 memo가 기대한 방식으로 동작하지 않는다. 이런 문제는 보통 코드 리뷰에서 찾아내거나, 성능 문제를 추적하다가 뒤늦게 발견한다. 타입은 데이터의 모양을 설명하지만, 이 값에 기대고 있는 최적화 계약까지 설명하지는 않기 때문이다.
stableref는 여기서 조금 낯선 질문을 던지는 패키지다. 참조를 안정적으로 유지하자는 규칙을 사람끼리 기억하는 대신, 그 사실을 타입으로 만들어 값과 함께 전달할 수는 없을까?
이 아이디어는 Jovi De Croock의 Making Referential Stability a Type에서 출발했다. 한국어로는 영서 님이 참조 안정성을 타입으로 만들기라는 제목으로 번역했다. 원문과 번역 글이 패키지의 아이디어를 이미 잘 설명하고 있으므로, 여기서는 stableref가 참조 안정성을 어떤 종류의 계약으로 다루는지, 그리고 내가 이 패키지에 기여하면서 그 계약의 경계를 어떻게 보게 되었는지를 함께 이야기해 보려고 한다.
참조 안정성은 타입 바깥에 있었다
리액트에서 참조 안정성은 꽤 익숙한 개념이다. useMemo로 객체와 배열을 메모이제이션하고, useCallback으로 함수를 메모이제이션한다. 컨텍스트 프로바이더에 인라인 객체를 넣지 말라는 이야기도 자주 한다. react-hooks/exhaustive-deps는 콜백이 캡처한 반응형 값을 의존성 배열에 빠뜨리지 않았는지 검사한다.
하지만 이 정보들은 대부분 값을 사용하는 곳까지 이어지지 않는다. 어떤 배열이 useMemo에서 만들어졌든 매 렌더링마다 새로 만들어졌든, 타입은 똑같이 Item[]이다. 컴포넌트의 prop 타입도 호출자에게 참조 안정성을 요구할 방법이 없다.
stableref의 중심에는 Stable<T>라는 작은 브랜드 타입이 있다.
declare const stableBrand: unique symboltype Stable<T> = T extends object ? T & { readonly [stableBrand]: true } : T객체와 함수에는 외부에서 재현할 수 없는 브랜드가 붙고, 원시값은 그대로 통과한다. 문자열이나 숫자는 리액트가 이미 값으로 비교하지만, 객체와 함수는 같은 내용을 가지고 있어도 새로 만들면 다른 참조가 되기 때문이다.
여기서 Stable<T>는 값을 안정적으로 만들어 주는 마법이 아니다. 해당 값이 안정적인 방식으로 만들어졌다는 증명에 가깝다. 그래서 stableref는 스스로를 “proof-carrying referential stability”, 즉 증명을 운반하는 참조 안정성이라고 설명한다.
이 증명은 불변성을 뜻하지도 않고, 프로그램이 끝날 때까지 같은 객체가 유지된다는 뜻도 아니다. 관련 없는 렌더링에서는 같은 참조가 유지되고, 상태 업데이트나 메모이제이션 의존성 변경처럼 그 값의 원인이 바뀌면 새로운 참조가 생길 수 있다는 뜻이다. Stable<State>라고 해서 상태가 변하지 않는 것은 아니다. 상태가 바뀌지 않았는데도 새 객체가 만들어지지는 않는다는 계약이다.
증명은 만드는 곳에서 사용하는 곳까지 이동한다
Stable<T>가 재미있는 이유는 브랜드 자체보다 그 브랜드가 이동하는 방식에 있다.
값을 안정적으로 만드는 지점 → Stable<T>라는 증명을 얻는다 → prop·컨텍스트·훅 반환값을 따라 전달한다 → 최적화에 기대는 경계가 그 증명을 요구한다stableref/react에서 가져온 useMemo와 useCallback은 안정성이 증명된 의존성만 받고, 결과에는 Stable<T>를 붙인다.
import { useCallback, useMemo, type Stable,} from 'stableref/react'type ItemListProps = { items: Stable<Item[]> onSelect: Stable<(id: string) => void> title: string}function Screen({ source }: { source: Stable<Item[]> }) { const items = useMemo( () => source.filter(isVisible), [source], ) const onSelect = useCallback( (id: string) => select(id), [], ) return ( <ItemList items={items} onSelect={onSelect} title="Visible items" /> )}ItemList는 배열과 함수가 어떻게 만들어졌는지 알 필요가 없다. 그저 자신이 기대는 최적화 계약을 prop 타입으로 요구한다. 값을 만드는 쪽에서 얻은 증명이 컴포넌트 경계를 넘어 사용하는 쪽까지 도착한다.
같은 원리는 컨텍스트에도 적용된다. createStableContext로 만든 컨텍스트의 프로바이더는 Stable<T>를 요구한다. 트리 아래의 컨슈머가 프로바이더 값의 생성 방식을 알 필요 없이, 값을 관리하는 쪽이 메모이제이션 책임을 진다.
useState, useReducer, useRef, useTransition처럼 리액트가 이미 안정성을 보장하는 값에도 그 사실을 타입으로 남긴다. 다만 useRef에서는 ref 컨테이너만 안정적이다. ref.current는 언제든 바뀔 수 있으므로 안정성 증명을 이어받지 않는다.
const ref = useRef({ id: 1 })useEffect(() => {}, [ref])useEffect(() => {}, [ref.current])// ^ type error이 구분은 사소해 보이지만 중요하다. 안정된 값 근처에 있다는 이유만으로 증명의 범위를 넓히기 시작하면 Stable<T>는 금방 “아마 괜찮을 것 같은 값”이라는 표시가 되어 버린다.
훅을 새로 만들지 않고 import 경계를 만들었다
처음 이 아이디어를 구현할 때는 모듈 보강으로 리액트 훅의 타입을 강화하는 방법도 시도되었다. 패키지를 한 번 import하면 기존 useMemo에 더 엄격한 오버로드가 추가되는 방식이다. 사용자는 import 경로를 바꾸지 않아도 되니 겉으로는 더 자연스러워 보인다.
문제는 타입스크립트의 모듈 보강이 기존 오버로드를 제거할 수 없다는 데 있다. 엄격한 오버로드와 리액트의 기존 오버로드가 함께 존재하면, 안정성이 증명되지 않은 의존성을 넘겼을 때 타입스크립트는 오류를 내는 대신 기존의 느슨한 오버로드를 선택할 수 있다. 가장 오류가 필요했던 자리에서 오류 없이 증명만 사라지는 셈이다.
그래서 stableref는 별도의 진입점을 사용한다.
import { useEffect, useMemo, useCallback,} from 'stableref/react'이 함수들은 리액트 훅을 감싼 래퍼가 아니다. 런타임에는 원래 훅과 같은 함수 참조이고, 타입 시그니처만 더 엄격하다.
import * as React from 'react'import { useMemo } from 'stableref/react'useMemo === React.useMemo // true나는 이 선택이 stableref의 성격을 잘 보여준다고 생각한다. 사용하는 사람에게 아무 변화도 없는 것처럼 보이게 만들면서 조용히 실패하는 대신, import 경계를 분명하게 만들고 그 경계 안에서는 실제로 계약을 강제한다. 런타임 동작을 추가하지 않으면서 타입이 말하는 보장은 더 강해진다.
타입 오류도 패키지의 API다
엄격한 타입을 만들 때 흔히 쓰는 방법은 허용하지 않을 값을 never로 바꾸는 것이다. 문제는 실제 오류 메시지가 “이 타입은 never에 할당할 수 없다” 정도로 끝난다는 데 있다. 타입을 설계한 사람에게는 이유가 보이지만, 사용하는 사람은 타입 정의를 거슬러 올라가야 한다.
stableref의 CheckedDeps는 안정성이 증명되지 않은 의존성을 해결 방법이 담긴 문자열 리터럴 타입으로 바꾼다.
type CheckedDeps<D extends readonly unknown[]> = { readonly [K in keyof D]: D[K] extends StableDependency ? D[K] : 'This dependency is not Stable<T>: memoize it with useMemo/useCallback, source it from useState, depend on a useRef container rather than its mutable current value, or wrap a module-scope constant with stable().'}그러면 오류 위치는 문제가 있는 의존성을 정확히 가리키고, 진단 메시지에는 useMemo나 useCallback으로 메모이제이션하라는 해결 방법까지 나타난다. 오류 메시지를 단순한 실패 보고가 아니라 사용자가 다음 행동을 결정할 수 있는 API로 다루는 셈이다.
이 선택은 코딩 에이전트가 작성하는 코드가 늘어나는 상황에서도 의미가 있다. 에이전트가 메모이제이션된 자식에게 인라인 배열을 넘기고도 불필요한 렌더링을 스스로 알아차릴 것이라고 기대하기는 어렵다. 반면 tsc가 정확한 위치와 수정 방향을 알려주면 에이전트는 그 피드백을 읽고 코드를 고칠 수 있다. 사람이 리뷰에서 발견하기를 기다리던 성능 계약이 빌드 피드백으로 바뀐다.
물론 타입 오류 하나로 좋은 리액트 코드를 보장할 수는 없다. 다만 사람이 읽는 속도보다 코드가 만들어지는 속도가 빨라질수록, 암묵적인 규칙보다 기계가 읽을 수 있는 계약이 더 오래 버틸 것이라고 생각한다.
기여하면서 본 엄격함의 경계
나는 stableref에 네 개의 PR을 기여했다. 새로운 기능을 많이 추가했다기보다, 패키지가 내세운 계약이 실제 리액트와 타입스크립트의 호출 방식에서도 무너지지 않도록 경계를 다듬는 작업이었다.
명시적 타입 인자가 평범하게 작동해야 했다
초기 훅 시그니처에서는 다음처럼 useMemo의 결과 타입을 명시하면 의존성 튜플의 타입 인자까지 함께 요구되는 문제가 있었다.
useMemo<Model>(() => buildModel(), [source])첫 번째 PR에서는 의존성 타입 인자의 기본값을 StableDeps로 두었다. 명시적 결과 타입이나 콜백 타입을 사용하는 평범한 호출을 복원하면서도, 브랜드가 없는 객체와 함수 의존성은 계속 거부하도록 만들었다.
엄격한 타입이라고 해서 기존 API를 낯선 API로 바꾸어도 된다는 뜻은 아니다. 계약은 강화하되 사용자가 이미 알고 있는 훅의 호출 형태는 최대한 보존해야 한다.
useRef에서는 컨테이너와 내용물을 구분해야 했다
두 번째 PR은 React 18의 ref 타입과 호환하면서, 초기화된 ref의 가변성과 null·undefined 타입을 정확하게 보존하는 작업이었다. 여기서 지켜야 할 원칙은 분명했다. 참조가 안정적인 것은 ref 컨테이너이지, 그 안의 current 값이 아니다.
이 구분을 놓치면 ref.current를 훅의 안정적인 의존성으로 사용할 수 있게 되고, 실제 런타임 계약보다 타입이 더 많은 것을 보장한다고 말하게 된다. 브랜드 타입은 붙이는 것보다 어디에 붙이지 않을지를 정하는 일이 더 중요할 때가 있다.
선택적인 의존성 배열도 전달할 수 있어야 했다
사용자 정의 훅은 의존성 배열을 선택적으로 받아 useEffect나 useImperativeHandle에 그대로 전달할 수 있다.
function useSomething(dependencies?: StableDeps) { useEffect(() => { // ... }, dependencies)}세 번째 PR은 StableDeps | undefined를 이런 훅에 전달할 수 있도록 수정했다. 배열이 없을 수 있다는 사실을 허용하되, 배열이 존재한다면 그 안의 객체와 함수는 여전히 안정성이 증명되어야 한다.
이 사례는 엄격한 타입과 불편한 타입이 같은 말은 아니라는 점을 보여준다. 실제 코드의 조합 방식을 지원하면서도 보장을 약화하지 않는 지점은 분명히 존재한다. 다만 그 지점을 찾으려면 긍정 테스트와 실패해야 하는 테스트를 함께 꽤 집요하게 작성해야 한다.
타입 패키지의 제품은 배포된 선언 파일이다
앞의 수정들은 저장소 안의 소스 타입 테스트로 검증할 수 있었다. 하지만 사용자가 실제로 설치하는 것은 소스 코드가 아니라 빌드하고 패키징한 결과다. export map이나 선언 파일 생성이 잘못되면 저장소 안의 테스트가 모두 통과해도 공개 API의 계약은 깨질 수 있다.
네 번째 PR에서는 패키지를 실제 tarball로 만든 뒤 임시 소비자 프로젝트에 오프라인으로 설치하고, 공개된 stableref, stableref/react, stableref/preact 진입점만 사용하는 테스트를 추가했다. 프레임워크 중립적인 루트는 React와 Preact 없이도 타입 검사를 통과하는지 확인하고, 프레임워크 진입점은 Bundler와 NodeNext 모듈 해석 방식에서 각각 검사한다.
이 작업을 하면서 타입 중심 패키지의 실제 제품은 저장소 안의 .ts 파일이 아니라 소비자가 받는 .d.mts 파일이라는 생각을 했다. 타입 계약을 설계하는 것만으로는 부족하다. 그 계약이 패키징을 거쳐 사용자에게 도착했는지도 확인해야 한다.
돌이켜 보면 네 개의 PR은 모두 같은 방향을 보고 있었다. Stable<T>라는 아이디어를 더 화려하게 만드는 작업이 아니라, 명시적 타입 인자, React 18의 ref 선언, 선택적 의존성, 실제 배포 패키지라는 평범한 현실에서도 그 아이디어가 거짓말하지 않도록 만드는 작업이었다.
리액트 컴파일러와는 다른 질문이다
참조 안정성과 메모이제이션을 이야기하면 리액트 컴파일러가 자연스럽게 따라온다. 컴파일러가 메모이제이션을 자동으로 처리한다면 이런 타입이 필요 없지 않느냐는 질문이다.
둘은 서로 다른 층에서 다른 질문에 답한다. 리액트 컴파일러는 빌드 단계에서 “이 구현을 안전하게 메모이제이션할 수 있는가”를 판단한다. Stable<T>는 소스 타입에서 “다른 코드가 이 값의 참조 안정성을 최적화 계약으로 믿어도 되는가”를 표현한다.
컴파일러가 어떤 값을 자동으로 메모이제이션한다고 해서 그 값에 Stable<T> 브랜드가 생기지는 않는다. 반대로 Stable<T>가 있다고 해서 컴파일러가 그 값을 특별히 최적화하는 것도 아니다. 컴파일러가 부분적으로 적용되거나 패키지 소비자마다 설정이 다르더라도, prop이나 컨텍스트와 같은 공개 경계의 타입 계약은 그대로 남는다.
그렇다고 모든 지역 변수에 Stable<T>를 붙여야 한다는 뜻은 아니다. 컴파일러 중심의 애플리케이션에서 평범한 지역 값은 컴파일러의 추론에 맡길 수 있다. stableref는 컴포넌트 prop, 컨텍스트 값, 커스텀 훅이나 패키지의 반환값처럼 계약 자체를 다른 코드에 전달할 필요가 있는 경계에서 더 의미가 있다.
타입은 언제든 거짓말할 수 있다
브랜드 타입은 고의적인 타입 단언을 막지 못한다.
value as Stable<typeof value>stable() 역시 런타임에서 값을 그대로 반환하는 항등 함수다. 실제 수명이 안정적인 모듈 스코프 상수에 증명을 붙이기 위한 탈출구이지, 타입 오류를 지우기 위해 컴포넌트 안에서 호출하는 함수가 아니다.
export const EMPTY_ITEMS = stable([] as Item[])메모이제이션도 애플리케이션의 정확성을 의존할 수 있는 저장소가 아니다. 리액트가 캐시를 버리더라도 프로그램은 올바르게 동작해야 한다. 값의 수명이 의미적으로 중요하다면 useMemo가 아니라 state나 ref처럼 그 수명을 표현하는 구조를 사용해야 한다.
이 한계까지 포함해야 Stable<T>를 어떤 곳에 사용해야 하는지도 분명해진다. 모든 객체에 브랜드를 붙이는 것이 목표가 아니다. memo 컴포넌트의 prop, 컨텍스트 프로바이더, 훅의 의존성, 패키지의 공개 API처럼 참조 안정성이 실제 계약으로 의미 있는 경계에서 사용해야 한다.
마무리하며
좋은 철학을 가지고 있다고 해서 반드시 좋은 라이브러리가 되는 것은 아니다. 아이디어가 아무리 그럴듯해도 실제 API가 불편할 수 있고, 프레임워크의 기존 동작과 어긋날 수도 있으며, 타입 선언과 배포 결과가 생각했던 계약을 제대로 전달하지 못할 수도 있다. 철학만으로는 이런 문제를 해결할 수 없다.
하지만 반대는 조금 다르다고 생각한다. 좋은 라이브러리는 대체로 자신이 무엇을 해결하려는지, 어디까지 책임질 것인지, 무엇은 하지 않을 것인지에 관한 분명한 철학 위에서 성장한다. 그래야 새로운 기능을 추가하거나 예상하지 못한 경계 사례를 만났을 때도 판단할 기준이 생긴다.
stableref의 철학은 비교적 선명하다. 참조 안정성을 사람의 기억에 맡기지 않고 타입으로 전달하되, 런타임 동작은 바꾸지 않는다. 증명할 수 있는 값에만 브랜드를 붙이고, 그 증명이 실제 계약보다 멀리 번지지 않게 한다. 내가 기여한 명시적 타입 인자, React 18의 ref 타입, 선택적 의존성, 배포 선언 파일 테스트도 결국 이 기준 안에서 방향을 찾을 수 있었다.
모든 좋은 철학이 좋은 라이브러리가 되는 것은 아니다. 그래도 좋은 라이브러리는 좋은 철학이 현실의 수많은 예외와 부딪히고, 그때마다 조금씩 형태를 다듬으면서 만들어지는 것이라고 생각한다. stableref는 아직 작은 패키지지만, 적어도 어떤 방향으로 성장하려는지는 분명하다. 나는 그 점이 이 라이브러리에서 가장 마음에 든다.
댓글
댓글을 불러오는 중...