2 / 3

존재하면 안 되는 조합은 타입으로 막는다

사람의 주의력에 기대던 규칙은, 주의력이 없는 쪽이 코드를 쓰는 순간 무너진다.

처음

프로필 화면의 상태는 필드 세 개였다. 로딩 중인지, 데이터, 에러.

만든 사람은 꼼꼼했다. 성공하면 loading을 끄고, 실패하면 loading을 끄고 error를 넣었다. 한 번도 빠뜨린 적이 없었다.

profileState.ts
type State = {
  loading: boolean;
  data?: User;
  error?: Error;
};

그 사이

상태를 바꾸는 곳이 늘었다. 첫 로딩, 당겨서 새로고침, 30초마다 폴링, 편집 저장 후 다시 불러오기, 다른 탭에서 로그아웃했을 때의 초기화.

다섯 군데, 다섯 명. 어떤 사람은 새로고침 중임을 표시하려고 isRefreshing을 따로 만들었다. 이제 "로딩 중"을 뜻하는 필드가 두 개다. 이 다섯 군데가 전부 같은 규칙을 지켜야 한다는 걸 아는 사람은 처음 만든 사람 하나였다.

profileState.ts
type State = {
  loading: boolean;
  isRefreshing: boolean;
  data?: User;
  error?: Error;
  lastFetchedAt?: number;
};
useProfile.ts
setState({ ...state, loading: true });
const data = await fetchProfile();
setState({ ...state, loading: false, data, lastFetchedAt: Date.now() });
PullToRefresh.tsx
setState({ ...state, isRefreshing: true });
await refetch();
setState({ ...state, isRefreshing: false });
usePolling.ts
useEffect(() => {
  const id = setInterval(refetch, 30_000);
  return () => clearInterval(id);
}, []);
EditProfile.tsx
await saveProfile(form);
setState({ ...state, loading: true });
refetch();
AuthListener.ts
onSignOut(() => {
  setState({ loading: false, isRefreshing: false });
});

3개월 뒤, 여섯 번째

"에러가 나면 재시도 버튼을 보여주세요."

새로 온 개발자는 AI에게 부탁했다. AI는 다섯 군데 중 하나를 참고해 catch 블록을 깔끔하게 다시 써줬다. 그럴듯했고, 타입 체크도 통과했다.

useProfile.ts
catch (e) {
  setState({ ...state, error: e });
  // loading은 true 그대로
}

금요일 저녁

API 서버가 잠깐 흔들렸다. 사용자 화면에는 스피너가 계속 돌고, 그 바로 아래에 에러 메시지와 재시도 버튼이 같이 떠 있었다.

재시도를 누르면 스피너 아래에 스피너가 하나 더. 슬랙에 스크린샷이 올라왔다.

왜 이렇게 됐나

타입은 "로딩 중이면서 에러"인 상태를 허락하고 있었다. 필드가 다섯 개, 바꾸는 곳이 여섯 군데가 되면, 둘이 동시에 있으면 안 된다는 규칙을 매번 기억하는 건 사람에게도 어렵다.

AI에게는 그 기억조차 없었다. 허락된 것 중에서 가장 그럴듯한 코드를 골랐을 뿐이다.

터지기 전에

가능한 상태만 유니언으로 나열한다. 로딩 중이면서 에러인 상태는 이제 쓸 수조차 없다.

AI가 같은 실수를 해도, 이번엔 빌드가 먼저 멈춘다.

profileState.ts
type State =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };
ProfileView.tsx
switch (state.status) {
  case "loading": return <Spinner />;
  case "success": return <Profile user={state.data} />;
  case "error":   return <ErrorView error={state.error} />;
}