Props로 받은 객체가 undefined일 때
왜 이 문제를 만났나?
제 서비스에서 사용자 프로필 페이지를 만들고 있었습니다. 부모 컴포넌트에서 API로 사용자 데이터를 불러온 다음, 자식 컴포넌트에 전달하는 구조였죠.
function ProfilePage() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(data => setUser(data));
}, []);
return <UserCard user={user} />;
}
function UserCard({ user }) {
return (
<div>
<h1>{user.name}</h1> {/* 🔥 Cannot read property 'name' of null */}
<p>{user.email}</p>
</div>
);
}
이 코드를 실행하면 앱이 크래시됩니다. 콘솔에 빨간 에러가 뜨면서 "Cannot read property 'name' of null"이라고 나오죠.
처음엔 "분명히 API에서 데이터를 받아오는데 왜 null이지?"라고 생각했습니다. 하지만 문제는 타이밍이었습니다. API 응답이 오기 전에 컴포넌트가 먼저 렌더링되는 거였어요.
처음엔 뭐가 이해가 안 갔나?
제가 가진 오개념은 이거였습니다: "useEffect에서 데이터를 불러오면 컴포넌트가 렌더링될 때 이미 데이터가 있다"
하지만 React의 렌더링 순서는 이렇습니다:
- 컴포넌트 렌더링 (이때
user는null) - 화면에 표시
useEffect실행- API 호출
- 응답 받으면
setUser호출 - 리렌더링 (이제
user에 데이터 있음)
문제는 1번 단계에서 이미 user.name을 읽으려고 한다는 겁니다. 이때는 아직 user가 null이니까 에러가 나는 거죠.
"그럼 어떻게 해야 하지?"라는 생각이 들었습니다. 데이터가 없을 때는 어떻게 처리해야 할까요?
어떤 포인트에서 이해가 됐나?
이 문제를 이해한 건 이런 비유를 들었을 때였습니다:
"컴포넌트는 레스토랑 주방이고, props는 재료다. 재료가 아직 안 왔는데 요리를 시작하면 당연히 문제가 생긴다. 재료가 올 때까지 기다리거나, 대체 재료를 써야 한다."
아! 그래서 로딩 상태나 옵셔널 체이닝을 써야 하는 거구나.
해결책은 여러 가지입니다:
해결책 1: 로딩 상태 추가
function ProfilePage() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser()
.then(data => setUser(data))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return <UserCard user={user} />;
}
해결책 2: 옵셔널 체이닝
function UserCard({ user }) {
return (
<div>
<h1>{user?.name ?? 'Unknown'}</h1>
<p>{user?.email ?? 'No email'}</p>
</div>
);
}
해결책 3: 기본값 설정
function UserCard({ user = {} }) {
const { name = 'Unknown', email = 'No email' } = user;
return (
<div>
<h1>{name}</h1>
<p>{email}</p>
</div>
);
}
저는 보통 해결책 1을 선호합니다. 사용자에게 로딩 중임을 명확히 보여주니까요.
깊이 파고들기
React의 렌더링 라이프사이클
React 컴포넌트의 렌더링 순서를 정확히 이해하는 게 중요합니다:
function Component() {
console.log('1. Render phase');
useEffect(() => {
console.log('3. Effect phase (after render)');
});
return <div>2. JSX returned</div>;
}
실행 순서:
- "1. Render phase" 출력
- JSX 반환
- 화면에 그리기
- "3. Effect phase" 출력
useEffect는 렌더링 후에 실행됩니다. 그래서 첫 렌더링 때는 아직 데이터가 없는 거죠.
TypeScript로 타입 안전하게 만들기
TypeScript를 쓰면 이런 문제를 컴파일 타임에 잡을 수 있습니다:
interface User {
name: string;
email: string;
}
interface UserCardProps {
user: User | null; // null 가능성 명시
}
function UserCard({ user }: UserCardProps) {
if (!user) {
return <div>Loading...</div>;
}
// 이제 user는 확실히 User 타입
return (
<div>
<h1>{user.name}</h1> {/* ✅ 타입 안전 */}
<p>{user.email}</p>
</div>
);
}
TypeScript가 "user가 null일 수 있으니 체크하세요"라고 경고해줍니다.
중첩된 객체 처리
실제 API 응답은 보통 중첩된 객체입니다:
const user = {
profile: {
personal: {
name: 'John',
age: 30
},
contact: {
email: 'john@example.com'
}
}
};
이럴 때 옵셔널 체이닝이 빛을 발합니다:
// 🔥 위험: 중간에 하나라도 undefined면 크래시
const name = user.profile.personal.name;
// ✅ 안전: 중간에 undefined 있으면 undefined 반환
const name = user?.profile?.personal?.name;
배열 props 처리
배열도 마찬가지입니다:
function UserList({ users }) {
return (
<ul>
{users.map(user => ( // 🔥 users가 undefined면 크래시
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// ✅ 안전한 버전
function UserList({ users = [] }) {
if (users.length === 0) {
return <div>No users</div>;
}
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Suspense와 Error Boundary
React 18부터는 Suspense를 써서 더 우아하게 처리할 수 있습니다:
function ProfilePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ErrorBoundary fallback={<div>Error!</div>}>
<UserCard />
</ErrorBoundary>
</Suspense>
);
}
function UserCard() {
const user = use(fetchUser()); // React 19의 use hook
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
이렇게 하면 로딩과 에러 처리를 선언적으로 할 수 있습니다.
내 코드에 어떻게 적용했나?
프로필 페이지 개선
제 프로필 페이지를 이렇게 개선했습니다:
function ProfilePage() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!user) {
return <div>User not found</div>;
}
return <UserCard user={user} />;
}
이제 모든 경우를 처리합니다: 로딩 중, 에러, 데이터 없음, 정상.
커스텀 훅으로 재사용
이 패턴을 자주 쓰니까 커스텀 훅으로 만들었습니다:
function useFetch(fetchFn) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchFn()
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}
// 사용
function ProfilePage() {
const { data: user, loading, error } = useFetch(fetchUser);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
if (!user) return <div>User not found</div>;
return <UserCard user={user} />;
}
훨씬 깔끔하죠!
폼 데이터 처리
폼에서도 비슷한 문제가 있었습니다:
function EditProfile({ initialData }) {
const [formData, setFormData] = useState(initialData);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<form>
<input
name="name"
value={formData.name} // 🔥 initialData가 undefined면 크래시
onChange={handleChange}
/>
</form>
);
}
기본값을 설정해서 해결했습니다:
function EditProfile({ initialData = {} }) {
const [formData, setFormData] = useState({
name: '',
email: '',
...initialData // 있으면 덮어쓰기
});
// 이제 안전!
}
한 줄 요약
Props로 받은 객체는 첫 렌더링 때 undefined일 수 있으므로, 로딩 상태를 추가하거나 옵셔널 체이닝과 기본값을 사용해서 안전하게 처리해야 한다.