상태-UI 바인딩 우회
이 사례의 핵심은 “상태를 잘 쓰자"가 아니라
상태를 거치지 않으면 UI가 데이터를 가질 수 없게 만드는 것이다.
개요
렌더링 경로를 고정하고 컴포넌트 조합까지 제한해도
UI는 여전히 무너질 수 있다.
그 이유는 UI가 상태 계층을 우회해
외부 데이터나 임시 값을 직접 소비할 수 있기 때문이다.
Glif에서는 다음을 목표로 한다.
- UI는 Store와 selector를 통해서만 데이터를 읽는다
- 외부 데이터는 UI에 직접 들어오지 않는다
- 위반은 코드 리뷰가 아니라 정적 분석 또는 런타임에서 실패한다
이 문서의 질문
이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 state & meaning bypass에 가장 가깝다.
핵심 질문은 다음과 같다.
UI가 상태 계층 밖에서 데이터를 직접 소비하는 경로를 닫았는가
문제
다음과 같은 코드가 반복적으로 생성된다.
- 컴포넌트 내부에서
fetch(...)호출 axios,queryClient,service를 UI 컴포넌트에서 직접 사용- API 응답을 Store에 넣지 않고 바로 렌더링
- 렌더 중 임시 객체를 만들어 표현에 직접 사용
- 동일한 값을 Store와 local state 두 군데에서 동시에 관리
예를 들면 다음과 같다.
function TitleView() {
const [title, setTitle] = useState("");
useEffect(() => {
fetch("/api/doc/1")
.then((r) => r.json())
.then((data) => setTitle(data.title));
}, []);
return <h1>{title}</h1>;
}또는
function StatusView({ data }: { data: { label: string } }) {
return <span>{data.label}</span>;
}이 방식은 빠르게 화면을 만든다.
하지만 구조적으로는 다음 문제를 만든다.
데이터 흐름이 여러 갈래로 분기됨
어떤 값이 진짜 상태인지 알 수 없음
동일 상태 입력으로 동일 UI를 재현할 수 없음
디버깅과 테스트가 어려워짐
AI가 가장 짧은 데이터 경로를 계속 재생산함
즉 문제는 값을 읽는 행위가 아니라
UI가 상태 계층 밖에서 데이터를 가져오는 것이다.
문제의 본질
사람과 AI는 상태 계층을 보존하는 것보다
현재 필요한 화면을 가장 빨리 만드는 쪽으로 움직인다.
기준은 대체로 단순하다.
화면에 값이 보이기만 하면 된다
기존 Store 구조를 이해하지 않아도 된다
selector, action, effect 흐름을 생략할 수 있다
그래서 다음과 같은 우회가 쉽게 발생한다.
컴포넌트 안에서 직접 데이터 로딩
외부 service를 바로 import
props로 raw data 전달
로컬 state를 임시 저장소처럼 사용
즉 사람과 AI는 상태 구조를 재사용하기보다
상태를 생략한 최소 경로를 만든다.
목표 구조
Glif에서는 데이터 흐름을 다음처럼 고정한다.
외부 데이터는 먼저 상태 계층으로 들어간다
UI는 selector를 통해서만 값을 읽는다
UI는 데이터를 보관하거나 정규화하지 않는다
하네스 적용 위치
이 문제는 네 레벨에서 동시에 막는다.
구조 레벨
UI 레이어의 import 경로를 닫는다.
ui/는store/,selectors/,view-model/만 참조 가능services/,infra/,api/직접 참조 금지UI 컴포넌트는 effect 실행 책임을 가지지 않음
즉 UI는 데이터를 가져오는 곳이 아니라
상태를 표현하는 곳으로 고정한다.
검증 레벨
정적 분석으로 우회 경로를 탐지한다.
UI 컴포넌트 내부
fetch금지axios,queryClient,services/*import 금지selector hook 외 상태 접근 금지
raw data prop 전달 금지
이 규칙은 warning이 아니라 error로 강제한다.
실행 레벨
런타임에서도 구조를 확인한다.
Store Provider 밖에서 selector hook 호출 시 즉시 throw
selector 없이 임의 source를 읽는 hook 금지
개발 모드에서 unstable prop source 경고 또는 실패
테스트 레벨
테스트도 우회를 학습시키지 않게 만든다.
컴포넌트 테스트는 반드시 state fixture를 통해 렌더
service mock 결과를 컴포넌트 props로 직접 주입 금지
동일 state 입력에 대해 동일 출력만 검증
실제 구현
UI의 읽기 경로를 selector와 provider로 고정한다
이 패턴의 핵심은 UI가 데이터를 직접 가져오지 않고, state layer가 허용한 읽기 surface만 통과하게 만드는 것이다.
export function selectDocTitle(state: AppState) {
return state.doc.title;
}
export function useAppSelector<T>(selector: (state: AppState) => T): T {
const store = React.useContext(StoreContext);
if (!store) {
throw new Error("useAppSelector must be used within StoreProvider");
}
return selector(store.getState());
}이제 UI는 fetch, service import, raw store traversal 대신 selector hook만 안다.
컴포넌트는 raw data가 아니라 view surface를 소비한다
type DocTitleViewModel = { title: string };
export function useDocTitleView(): DocTitleViewModel {
const title = useAppSelector(selectDocTitle);
return { title };
}표현 컴포넌트는 API 응답 타입이나 infra 구조를 직접 알지 않는다. 그래서 data path leak가 줄어든다.
로딩과 외부 읽기는 action/effect layer로 밀어낸다
export async function loadDoc(params: { docId: string }, deps: {
api: { readDoc: (docId: string) => Promise<{ title: string; content: string }> };
store: { setDoc: (doc: { title: string; content: string }) => void };
}) {
const doc = await deps.api.readDoc(params.docId);
deps.store.setDoc(doc);
}UI는 “언제 읽을지”를 요청할 수는 있어도, “어떻게 읽을지”는 결정하지 않는다.
Provider 밖 접근은 즉시 실패해야 한다
const StoreContext = React.createContext<{ getState: () => AppState } | null>(null);기본 store나 fallback 값을 두지 않으면, 경계가 깨졌을 때 문제가 조용히 숨지 않고 바로 드러난다.
테스트도 같은 읽기 경계를 따라야 한다
export function renderWithState(ui: React.ReactElement, state: AppState) {
const store = { getState: () => state };
return render(<StoreProvider store={store}>{ui}</StoreProvider>);
}테스트가 API 응답을 컴포넌트에 직접 꽂기 시작하면 production 코드도 곧 같은 우회를 배운다.
위반 예시와 실패 결과
다음 코드는 반드시 실패해야 한다.
import { readDoc } from "@/services/docService";
function DocTitleView() {
const doc = readDoc("1");
return <h1>{doc.title}</h1>;
}실패 결과:
error: UI must not import data/infra modules directly. Use selectors and actions.또는
function DocTitleView() {
useEffect(() => {
fetch("/api/doc/1");
}, []);
return <h1>...</h1>;
}실패 결과:
error: UI must not fetch data directly. Use action/effect layer.또는
function UnsafeView() {
const title = useAppSelector(selectDocTitle);
return <h1>{title}</h1>;
}Provider 없이 렌더되면:
Error: useAppSelector must be used within StoreProvider이 순간부터 상태 규칙은 문서가 아니라
실행 가능한 제약이 된다.
보조 강제 수단
이 케이스의 보조 강제 수단은 read path를 selector와 state layer에 묶어 두는 데 있다.
import boundary만 두면 global fetch가 남고, selector hook만 두면 raw service access가 남고, test helper가 없으면 테스트가 다시 direct data path를 학습한다.
- import boundary lint → data/service direct access 차단
- global restriction lint → fetch direct call 차단
- typed selector hook → 상태 읽기 경로 고정
- provider guard → runtime 경계 확인
- state fixture render helper → 테스트 우회 차단
핵심은 UI가 값 자체보다 값을 읽는 합법 경로를 먼저 배우게 만드는 것이다.
결과
하네스 적용 이후 변화는 명확하다.
- UI는 selector나 view-model을 통해서만 상태를 읽게 된다.
- 외부 데이터 로딩 경로가 UI 바깥으로 수렴해 로딩과 에러 의미가 다시 일관된다.
- 같은 데이터를 여러 화면이 공유해도 읽기 경로가 분기하지 않는다.
실무 적용 팁
UI에 “편한 데이터 접근"을 남기지 않는다
fetch, service, query client가 열려 있으면
AI는 반드시 그쪽으로 간다.
selector는 값 추출이 아니라 경로 고정 장치다
selector를 두는 이유는 편의를 위해서가 아니라
UI의 데이터 진입점을 하나로 만들기 위해서다.
raw response 타입을 UI에 올리지 않는다
UI가 외부 타입을 알기 시작하면
상태 계층은 점점 장식으로 변한다.
fallback으로 복구하지 않는다
Provider가 없거나 경계가 깨졌을 때
조용히 기본값을 넣어주면 우회는 정상 경로로 굳어진다.
요약
이 케이스에서 하네스는 다음과 같이 구성된다.
UI 레이어의 data/service import는 lint로 차단된다
fetch같은 직접 로딩 경로는 정적 분석으로 막힌다UI는 selector hook을 통해서만 상태를 읽는다
Provider 밖 상태 접근은 런타임에서 즉시 실패한다
테스트도 state fixture 기반 렌더만 허용한다
결과적으로
데이터 흐름은 Store 중심으로 고정되고
UI는 상태를 표현하는 역할로 제한되며
직접 로딩과 raw data binding이라는 우회 경로는 제거된다
즉
“상태를 잘 사용하라"가 아니라
“상태를 거치지 않으면 UI가 데이터를 가질 수 없다"가 하네스다.