TypeScript는 왜 내 코드를 의심할까

sumi-0011·6일 전

들어가며

interface State {
  user?: { name: string };
}

function showGreeting(state: State) {
  if (state.user) {
    setTimeout(() => {
      console.log(`안녕하세요, ${state.user.name}님!`);
      // ❌ 'state.user' is possibly 'undefined'
    }, 100);
  }
}

"분명 if문에서 체크했는데, 왜 아직도 undefined인가?"

TypeScript를 사용하다 보면 이런 상황을 만나게 됩니다. 존재 여부를 확인했는데, 바로 다음 줄에서 "undefined일 수도 있다"고 말하는 TypeScript. 처음에는 버그인 줄 알았습니다.

그런데 지역 변수에 담으면 에러가 사라집니다.

function showGreeting(state: State) {
  const user = state.user;
  if (user) {
    setTimeout(() => {
      console.log(`안녕하세요, ${user.name}님!`); // ✅ 정상 동작
    }, 100);
  }
}

같은 값인데, 왜 결과가 다를까요?

이 글에서는 TypeScript가 왜 우리 코드를 "의심"하는지, 그 뒤에 숨은 설계 철학을 살펴보겠습니다.


Type Narrowing 복습

Type Narrowing이 처음이라면 이전 글: if문 하나로 TypeScript가 똑똑해지는 이유를 먼저 읽어보시기 바랍니다.

TypeScript는 코드의 흐름을 분석해서 타입을 좁혀나갑니다. 이를 Control Flow Analysis라고 부릅니다.

function greet(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // string으로 좁혀짐
  } else {
    console.log(value.toFixed(2)); // number로 좁혀짐
  }
}

if문을 통과하면 TypeScript가 타입을 좁혀줍니다. 덕분에 타입 단언 없이도 안전하게 코드를 작성할 수 있습니다.

그런데 이 기능이 어떤 상황에서는 왜 제대로 동작하지 않는 걸까요?


TypeScript의 의심, 그 합리적인 이유

결론부터 말하면, TypeScript는 "지금 체크한 값이 나중에 쓸 때도 같을까?"를 의심합니다.

"체크하고 바로 쓰는데 뭐가 바뀌는가?"라고 생각할 수 있습니다. React 개발에서 자주 겪는 상황들을 살펴보겠습니다.

상황 1: props로 받은 객체의 프로퍼티

interface ChatState {
  connection?: WebSocket;
  messages: Message[];
}

function ChatRoom({ state }: { state: ChatState }) {
  const sendMessage = (text: string) => {
    if (state.connection) {
      // 이 시점: connection이 있음

      setTimeout(() => {
        // ❌ 'state.connection' is possibly 'undefined'
        state.connection.send(text);
      }, 100);
    }
  };
}

state.connection을 체크했지만, setTimeout 콜백 안에서 다시 접근하면 에러가 발생합니다. 부모 컴포넌트에서 state를 업데이트하면 connectionundefined가 될 수 있기 때문입니다.

상황 2: 외부 store나 전역 객체

// Zustand나 전역 상태를 직접 참조하는 경우
const authStore = {
  user: null as User | null,
  logout() { this.user = null; }
};

function UserGreeting() {
  const handleClick = () => {
    if (authStore.user) {
      // 이 시점: user가 있음

      setTimeout(() => {
        // ❌ 'authStore.user' is possibly 'null'
        console.log(`안녕하세요, ${authStore.user.name}님!`);
      }, 100);
    }
  };
}

authStore.user를 체크했지만, setTimeout이 실행되기 전에 다른 곳에서 logout()이 호출될 수 있습니다. TypeScript는 이 가능성을 인식하고 에러를 냅니다.

상황 3: ref.current는 언제든 바뀔 수 있다

function VideoPlayer() {
  const videoRef = useRef<HTMLVideoElement | null>(null);

  const handlePlay = () => {
    if (videoRef.current) {
      // 이 시점: video 엘리먼트가 있음

      someAsyncOperation().then(() => {
        // 🤔 이 사이에 조건부 렌더링으로 video가 사라졌다면?
        videoRef.current.play();
      });
    }
  };
  // 조건에 따라 video가 렌더링되거나 안 될 수 있음
  return showVideo ? <video ref={videoRef} /> : <div>영상 없음</div>;
}

ref.current는 DOM이 업데이트되면 언제든 바뀔 수 있습니다. if로 체크한 시점과 실제 사용 시점이 다르면 null일 수 있습니다.

왜 TypeScript는 이렇게 설계됐을까?

TypeScript 팀의 입장은 다음과 같습니다:

"각 프로퍼티 접근이 다른 값을 반환할 수 있다고 가정하는 것이 유일하게 안전한 방법입니다."

React의 상태는 언제든 바뀔 수 있고, ref는 DOM과 함께 변하고, 비동기 작업은 "나중에" 실행됩니다. TypeScript는 이 모든 가능성을 고려해서 런타임 에러보다는 컴파일 타임의 불편함을 선택한 것입니다.

보수적으로 느껴질 수 있지만, 이것은 버그가 아니라 의도적인 설계 결정입니다.


언제 TypeScript가 의심할까?

모든 상황에서 의심하는 것은 아닙니다. 패턴을 알아두면 도움이 됩니다.

의심하는 경우: 나중에 실행되는 코드

if (state.user) {
  // ❌ setTimeout 콜백
  setTimeout(() => {
    console.log(state.user.name); // 에러
  }, 100);

  // ❌ 배열 메서드 콜백
  items.forEach(() => {
    console.log(state.user.name); // 에러
  });

  // ❌ 이벤트 핸들러
  button.addEventListener('click', () => {
    console.log(state.user.name); // 에러
  });

  // ❌ Promise 콜백
  fetchData().then(() => {
    console.log(state.user.name); // 에러
  });
}

이 코드들의 공통점은 모두 "나중에 실행되는" 콜백 함수라는 점입니다.

의심하지 않는 경우: 바로 실행되는 코드

if (state.user) {
  // ✅ 바로 다음 줄
  console.log(state.user.name);

  // ✅ 함수 호출 후에도
  doSomething();
  console.log(state.user.name);

  // ✅ await 후에도 (같은 함수 본문이므로)
  await fetchData();
  console.log(state.user.name);
}

동기적으로 실행되는 코드에서는 TypeScript가 narrowing을 유지합니다.


지역 변수는 왜 안전한가?

const user = state.user; // 이 시점의 값을 "스냅샷"으로 저장

if (user) {
  setTimeout(() => {
    console.log(user.name); // ✅ 안전
  }, 100);
}

지역 변수(특히 const)의 특성:

  • 재할당이 불가능함
  • 외부에서 값을 바꿀 방법이 없음
  • TypeScript가 완전히 추적 가능

state.user가 나중에 바뀌더라도, user 변수에 담긴 값은 그대로입니다. TypeScript는 이를 알고 있어서 안심하고 narrowing을 유지합니다.


실무에서 자주 만나는 상황

React에서 이벤트 핸들러

function UserProfile() {
  const { data } = useQuery(['user'], fetchUser);

  // ❌ 콜백에서 직접 접근
  const handleSave = () => {
    if (data?.user) {
      saveUser(data.user).then(() => {
        toast(`${data.user.name}님 저장 완료`); // 에러
      });
    }
  };

  // ✅ 지역 변수로 해결
  const handleSave = () => {
    const user = data?.user;
    if (user) {
      saveUser(user).then(() => {
        toast(`${user.name}님 저장 완료`); // OK
      });
    }
  };
}

배열 순회에서

function processUsers(state: State) {
  // ❌ forEach 콜백
  if (state.user) {
    items.forEach(item => {
      sendNotification(item, state.user.email); // 에러
    });
  }

  // ✅ 지역 변수로 해결
  const user = state.user;
  if (user) {
    items.forEach(item => {
      sendNotification(item, user.email); // OK
    });
  }
}

클래스 메서드에서

class NotificationService {
  private user?: User;

  // ❌ this 프로퍼티 + 콜백
  notify() {
    if (this.user) {
      setTimeout(() => {
        this.send(this.user.email); // 에러
      }, 1000);
    }
  }

  // ✅ 지역 변수로 해결
  notify() {
    const user = this.user;
    if (user) {
      setTimeout(() => {
        this.send(user.email); // OK
      }, 1000);
    }
  }
}

더 나은 타입 설계

지역 변수 외에도 TypeScript가 잘 이해하는 패턴이 있습니다.

Discriminated Union

type ApiResult =
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };

function handle(result: ApiResult) {
  if (result.status === 'success') {
    setTimeout(() => {
      console.log(result.data.name); // ✅ 콜백에서도 OK
    }, 100);
  }
}

status 프로퍼티가 전체 타입을 결정하는 "판별자" 역할을 합니다. TypeScript가 가장 잘 이해하는 패턴입니다. API 응답이나 상태 관리에서 이런 형태로 타입을 설계하면 좋습니다.


해결 방법 정리

방법예시
지역 변수에 담기 (권장)const user = state.user;
구조 분해 할당const { user } = state;
Early Return 패턴if (!user) return;
Discriminated Union{ status: 'loaded'; user: User }

1. 지역 변수에 담기

const user = state.user;
if (user) {
  setTimeout(() => console.log(user.name), 100); // ✅
}

2. 구조 분해 할당

const { user } = state;
if (user) {
  items.forEach(() => console.log(user.name)); // ✅
}

3. Early Return 패턴

function process(state: State) {
  const user = state.user;
  if (!user) return;

  // 이 아래 전체가 user가 있는 스코프
  setTimeout(() => console.log(user.name), 100); // ✅
}

4. Discriminated Union으로 타입 설계

type State =
  | { status: 'idle' }
  | { status: 'loaded'; user: User }
  | { status: 'error'; message: string };

마치며

정리하면 다음과 같습니다.

  • TypeScript가 "의심"하는 것은 버그가 아니라 의도적 설계입니다
  • "나중에 실행되는 코드"에서 값이 바뀔 가능성을 고려하는 것입니다
  • 지역 변수에 담으면 TypeScript가 안전하게 추적할 수 있습니다
  • Discriminated Union을 활용하면 더 나은 타입 추론을 받을 수 있습니다

처음에는 TypeScript가 지나치게 의심이 많다고 느꼈습니다. "분명히 체크했는데"라고 생각했습니다.

하지만 생각해보면 맞는 말입니다. setTimeout 콜백이 실행되는 100ms 동안 상태가 바뀌고, 컴포넌트가 언마운트되고, 사용자가 로그아웃할 수도 있습니다.

지역 변수 하나를 더 만드는 작은 습관으로 잠재적인 런타임 에러를 방지할 수 있습니다.


참고 자료

profile
안녕하세요 😚 썸네일을 쉽게 만들 수 있는 서비스를 운영중입니다. 많은 관심 부탁드립니다. https://thumbnail.ssumi.space/

0개의 댓글