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이 처음이라면 이전 글: 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는 "지금 체크한 값이 나중에 쓸 때도 같을까?"를 의심합니다.
"체크하고 바로 쓰는데 뭐가 바뀌는가?"라고 생각할 수 있습니다. React 개발에서 자주 겪는 상황들을 살펴보겠습니다.
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를 업데이트하면 connection이 undefined가 될 수 있기 때문입니다.
// 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는 이 가능성을 인식하고 에러를 냅니다.
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 팀의 입장은 다음과 같습니다:
"각 프로퍼티 접근이 다른 값을 반환할 수 있다고 가정하는 것이 유일하게 안전한 방법입니다."
React의 상태는 언제든 바뀔 수 있고, ref는 DOM과 함께 변하고, 비동기 작업은 "나중에" 실행됩니다. 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)의 특성:
state.user가 나중에 바뀌더라도, user 변수에 담긴 값은 그대로입니다. TypeScript는 이를 알고 있어서 안심하고 narrowing을 유지합니다.
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가 잘 이해하는 패턴이 있습니다.
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 } |
const user = state.user;
if (user) {
setTimeout(() => console.log(user.name), 100); // ✅
}
const { user } = state;
if (user) {
items.forEach(() => console.log(user.name)); // ✅
}
function process(state: State) {
const user = state.user;
if (!user) return;
// 이 아래 전체가 user가 있는 스코프
setTimeout(() => console.log(user.name), 100); // ✅
}
type State =
| { status: 'idle' }
| { status: 'loaded'; user: User }
| { status: 'error'; message: string };
정리하면 다음과 같습니다.
처음에는 TypeScript가 지나치게 의심이 많다고 느꼈습니다. "분명히 체크했는데"라고 생각했습니다.
하지만 생각해보면 맞는 말입니다. setTimeout 콜백이 실행되는 100ms 동안 상태가 바뀌고, 컴포넌트가 언마운트되고, 사용자가 로그아웃할 수도 있습니다.
지역 변수 하나를 더 만드는 작은 습관으로 잠재적인 런타임 에러를 방지할 수 있습니다.