Provider 경계 우회

Provider 경계 우회

이 사례의 핵심은 “provider를 잘 감싸자"가 아니라
provider 경계 밖에서는 UI가 아예 동작할 수 없게 만드는 것이다.

개요

렌더링, 조합, 상태, 스타일, 레이아웃, action 경로를 모두 통제해도
UI는 여전히 구조를 무너뜨릴 수 있다.

그 이유는 많은 제약이 결국 provider 경계 위에 서 있기 때문이다.

  • StoreProvider
  • ThemeProvider
  • ActionProvider
  • LayoutProvider
  • EditorContextProvider

이 경계가 무너지면 다음 우회가 발생한다.

  • context가 없으면 기본값으로 계속 동작
  • 전역 singleton을 fallback으로 사용
  • 임시 mock 객체를 넣어 구조를 흉내 냄
  • provider 없이도 “일단 보이게” 만드는 코드 추가

즉 문제는 provider 사용 자체가 아니라
provider 경계가 선택 사항으로 바뀌는 것이다.

Glif에서는 다음을 목표로 한다.

  • provider는 선택이 아니라 필수 경계다
  • provider 밖에서는 컴포넌트가 동작하지 않는다
  • 위반은 코드 리뷰가 아니라 런타임 또는 테스트에서 실패한다

이 문서의 질문

이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 contract flattening에 가장 가깝다. 핵심 질문은 다음과 같다.

provider와 ambient boundary가 선택 사항이 아니라 필수 계약으로 남아 있는가

문제

다음과 같은 코드가 반복적으로 생성된다.

const ThemeContext = createContext({
  color: {
    text: "#111111"
  }
});

또는

function useEditorStore() {
  return useContext(EditorStoreContext) ?? globalEditorStore;
}

또는

function useActions() {
  const actions = useContext(ActionContext);

  if (!actions) {
    return {
      save: async () => {},
      publish: async () => {},
    };
  }

  return actions;
}

이 방식은 “유연한 fallback"처럼 보인다.
하지만 구조적으로는 다음 문제를 만든다.

  • 경계가 깨져도 조용히 진행됨

  • 잘못된 배치가 실패하지 않음

  • 테스트가 실제 구조와 달라짐

  • provider의 존재 이유가 사라짐

  • AI가 provider 없이도 되는 경로를 계속 재생산함

즉 문제는 fallback 자체가 아니라
경계 위반을 성공처럼 보이게 만드는 것이다.

문제의 본질

사람과 AI는 provider를 구조적 계약보다
값을 얻기 위한 편의 장치로 보는 경향이 있다.

기준은 단순하다.

  • 값만 있으면 된다

  • context가 없으면 기본값으로 채우면 된다

  • 테스트가 통과하면 된다

  • 화면이 보이면 된다

그래서 다음과 같은 우회가 쉽게 발생한다.

  • createContext(defaultValue)에 실제 동작 객체를 넣음

  • useContext(...) ?? globalSingleton 패턴 추가

  • provider 미존재 시 noop action 반환

  • provider 없이도 렌더되는 story/test 작성

즉 사람과 AI는 경계를 보존하기보다
경계가 없어도 굴러가는 상태를 만든다.

목표 구조

Glif에서는 provider 경계를 다음처럼 고정한다.

D2 diagram
핵심 규칙은 단순하다.
  • context 기본값은 null이다

  • typed hook은 provider 밖에서 즉시 실패한다

  • global singleton은 fallback으로 사용되지 않는다

하네스 적용 위치

이 문제는 네 레벨에서 동시에 막는다.

구조 레벨

provider를 필수 경계로 만든다.

  • createContext(null)만 허용

  • context 직접 사용 금지, typed hook만 허용

  • global singleton fallback 금지

  • mock/stub은 provider를 통해서만 주입

즉 컴포넌트는 값을 “어디선가 가져오는” 것이 아니라
provider 안에서만 성립하는 구조물이 된다.

검증 레벨

정적 분석으로 provider 우회를 탐지한다.

  • createContext({ ... }) 같은 실동작 기본값 금지

  • useContext(...) ?? ... 패턴 금지

  • globalStore, globalTheme, globalActions fallback 금지

  • UI에서 context object 직접 import 금지

실행 레벨

런타임에서는 provider 경계 밖 사용을 즉시 실패시킨다.

  • typed hook에서 null 검사 후 throw

  • unknown provider tree 감지 시 오류

  • noop fallback 금지

테스트 레벨

테스트와 story에서도 실제 provider 구조를 강제한다.

  • provider 없는 렌더 helper 금지

  • story는 반드시 app providers로 감싸기

  • “없어도 보이는” story 금지

실제 구현

context 기본값을 실동작 값이 아니라 null로 둔다

provider boundary의 핵심은 ambient dependency가 없을 때도 조용히 동작하지 않게 만드는 것이다.

type ThemeValue = {
  color: {
    textPrimary: string;
    textDanger: string;
  };
};

export const ThemeContext = React.createContext<ThemeValue | null>(null);

실동작 기본값을 넣는 순간 provider 부재는 실패가 아니라 fallback 성공으로 바뀐다.

raw context 대신 typed hook만 노출한다

export function useTheme() {
  const value = React.useContext(ThemeContext);
  if (!value) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return value;
}

이제 UI는 useContext(ThemeContext) 대신 useTheme()만 보게 되고, provider 밖 접근도 즉시 드러난다.

fallback과 global rescue path를 닫는다

?? globalStore, || defaultActions, noop action 반환 같은 우회는 모두 provider contract를 무너뜨린다. 경계 위반은 복구하지 말고 실패시켜야 한다.

story와 테스트에도 합법 경로를 제공한다

export function AppProviders({ theme, actions, store, children }: AppProvidersProps) {
  return (
    <ThemeContext.Provider value={theme}>
      <ActionContext.Provider value={actions}>
        <StoreProvider store={store}>{children}</StoreProvider>
      </ActionContext.Provider>
    </ThemeContext.Provider>
  );
}

provider 없는 임시 렌더보다 AppProviders 경로가 더 짧아야 실제 코드도 같은 구조를 유지한다.

테스트 helper도 같은 경계를 반복하게 만든다

export function renderWithAppProviders(ui: React.ReactElement, options: AppProviderOptions) {
  return render(<AppProviders {...options}>{ui}</AppProviders>);
}

테스트가 경계를 풀어주기 시작하면 그 예외가 곧 production 우회로로 역수입된다.

위반 예시와 실패 결과

다음 코드는 반드시 실패해야 한다.

export const StoreContext = createContext({
  getState: () => ({})
});

실패 결과:

error: Provider-bound context must use null default. Do not provide live fallback values.

또는

function useStore() {
  return useContext(StoreContext) ?? globalStore;
}

실패 결과:

error: Global fallback is forbidden. Use providers.

또는 provider 없이 컴포넌트 렌더:

Error: useTheme must be used within ThemeProvider

이 순간부터 provider는 문서상의 규칙이 아니라
실행 가능한 경계가 된다.

보조 강제 수단

이 케이스의 보조 강제 수단은 ambient dependency를 조용한 기본값이 아니라 명시적 provider contract로 바꾸는 데 있다. createContext(null)만 두면 raw context import가 남고, typed hook만 두면 noop fallback이 남고, story/test helper가 없으면 개발자가 global singleton으로 돌아가게 된다.

  • createContext(null) 규칙 → 기본 경계 고정
  • typed hook → provider 밖 사용 차단
  • ESLint import restriction → raw context direct access 차단
  • fallback pattern lint → global/noop 우회 차단
  • app provider helper → 테스트와 story의 합법 경로 제공

핵심은 provider를 optional wrapper가 아니라 값 접근의 유일한 문으로 만드는 것이다.

결과

하네스 적용 이후 변화는 명확하다.

  • provider가 없는 경로는 조용히 동작하지 않고 즉시 실패한다.
  • global singleton과 default value rescue path가 사라진다.
  • ambient dependency boundary가 눈에 보이는 구조 계약으로 돌아온다.

실무 적용 팁

기본값이 편해 보일수록 위험하다

실동작 기본값은 거의 항상 경계를 무력화한다.

global singleton을 rescue path로 두지 않는다

한 번 열리면 그 경로가 가장 짧은 길이 된다.

typed hook은 편의 함수가 아니라 경계 장치다

useTheme, useStore, useActions는 단순 wrapper가 아니다.
provider 밖 사용을 실패시키는 관문이다.

테스트를 핑계로 경계를 풀지 않는다

테스트에서 느슨해진 구조는
곧 실제 코드 생성 경로로 역수입된다.

요약

이 케이스에서 하네스는 다음과 같이 구성된다.

  • context 기본값은 null로 고정된다

  • typed hook은 provider 밖에서 즉시 실패한다

  • raw context direct import는 정적 분석으로 차단된다

  • global singleton과 noop fallback은 lint로 금지된다

  • story와 테스트도 실제 provider 구조를 통해서만 렌더된다

결과적으로

  • provider 경계는 선택이 아니라 필수가 되고

  • fallback 기반 우회 경로는 제거되며

  • UI 제약은 실제 트리 구조 위에서만 성립하게 된다

“provider를 잘 감싸라"가 아니라
“provider 경계 밖에서는 UI가 아예 동작할 수 없다"가 하네스다.