Provider 경계 우회
이 사례의 핵심은 “provider를 잘 감싸자"가 아니라
provider 경계 밖에서는 UI가 아예 동작할 수 없게 만드는 것이다.
개요
렌더링, 조합, 상태, 스타일, 레이아웃, action 경로를 모두 통제해도
UI는 여전히 구조를 무너뜨릴 수 있다.
그 이유는 많은 제약이 결국 provider 경계 위에 서 있기 때문이다.
StoreProviderThemeProviderActionProviderLayoutProviderEditorContextProvider
이 경계가 무너지면 다음 우회가 발생한다.
- 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 경계를 다음처럼 고정한다.
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,globalActionsfallback 금지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가 아예 동작할 수 없다"가 하네스다.