들어가며
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를 업데이트하면 connection이 undefined가 될 수 있기 때문입니다.
상황 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 동안 상태가 바뀌고, 컴포넌트가 언마운트되고, 사용자가 로그아웃할 수도 있습니다.
지역 변수 하나를 더 만드는 작은 습관으로 잠재적인 런타임 에러를 방지할 수 있습니다.