들어가며: useEffect와 SSR의 만남 🌱
최근 Next.js를 활용한 프로젝트에서 햄버거 메뉴를 구현하던 중 흥미로운 문제에 직면했습니다. 단순해 보이는 햄버거 메뉴 기능이었지만, Next.js의 서버 사이드 렌더링(SSR) 환경에서 예상치 못한 도전 과제를 마주하게 되었죠.
처음에는 단순히 다음과 같이 구현했습니다:
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
하지만 이 코드는 SSR 환경에서 문제를 일으켰고, 결국 다음과 같이 수정해야 했습니다:
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
export default function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// 클라이언트에서만 렌더링되도록 설정
setIsClient(true);
}, []);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
if (!isClient) {
return; // 서버에서 렌더링 방지
}
}
이러한 경험을 통해 Next.js의 SSR 환경에서 클라이언트 사이드 로직을 다루는 방법과 useEffect의 올바른 활용법에 대해 깊이 고민하게 되었습니다. 특히 'use client' 지시문의 사용과 클라이언트 사이드 렌더링의 안전한 처리 방법에 대해 많은 것을 배웠습니다.
이 글에서는 제가 겪은 이러한 문제들과 해결 과정을 통해 배운 useEffect의 다양한 활용 방법과 SSR 환경에서의 주의사항을 상세히 공유하고자 합니다.
1. 기본 개념 이해하기 📚
1.1 useEffect가 필요한 이유
React 애플리케이션에서 useEffect는 컴포넌트의 생명주기와 관련된 부수 효과를 처리하는 Hook입니다.
useEffect는 SSR(Server-Side Rendering) 환경에서 클라이언트 사이드 로직을 안전하게 처리하기 위해 사용할 수 있습니다.
주요 사용 사례:
- 브라우저 API 접근
- 데이터 페칭
- 구독 설정 및 해제
- DOM 직접 조작
// ❌ 잘못된 예시: 서버사이드에서 오류 발생
function BadComponent() {
// window 객체는 서버에서 존재하지 않음
const windowWidth = window.innerWidth;
return <div>{windowWidth}px</div>;
}
// ✅ 올바른 예시: useEffect로 안전하게 처리
function GoodComponent() {
const [windowWidth, setWindowWidth] = useState<number>(0);
useEffect(() => {
// 클라이언트에서만 실행됨
setWindowWidth(window.innerWidth);
}, []);
return <div>{windowWidth}px</div>;
}
1.2 useEffect의 생명주기
useEffect는 컴포넌트의 여러 생명주기 단계에서 실행되며, 각 단계별로 특정 작업을 수행할 수 있습니다.
실행 시점:
- 마운트 (Mount): 컴포넌트가 처음 렌더링될 때
- 업데이트 (Update): 의존성 배열의 값이 변경될 때
- 언마운트 (Unmount): 컴포넌트가 제거될 때
useEffect(() => {
// 1. 마운트/업데이트 시 실행
console.log("컴포넌트가 마운트되었습니다.");
// 2. 이벤트 리스너 등록
const handleResize = () => {
console.log("창 크기가 변경되었습니다.");
};
window.addEventListener("resize", handleResize);
// 3. 클린업 함수: 언마운트 시 실행
return () => {
console.log("컴포넌트가 언마운트됩니다.");
window.removeEventListener("resize", handleResize);
};
}, [/* 의존성 배열 */]);
2. 실전 활용 패턴 💡
2.1 안전한 클라이언트 사이드 렌더링
SSR 환경에서 클라이언트 전용 코드를 안전하게 실행하기 위한 패턴입니다.
주요 특징:
- 서버/클라이언트 렌더링 차이 해결
- 하이드레이션 문제 방지
- 깜빡임 현상 최소화
// 클라이언트 사이드 렌더링을 위한 커스텀 훅
function useClientSideRendering() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
}
// 실제 사용 예시
function ClientSideComponent() {
const isClient = useClientSideRendering();
if (!isClient) {
return <div>로딩 중...</div>; // 서버 사이드 폴백 UI
}
return (
<div>
클라이언트에서만 보이는 내용
</div>
);
}
2.2 데이터 페칭 패턴
API 호출과 같은 비동기 데이터 페칭을 처리하는 패턴입니다.
구현 고려사항:
- 로딩 상태 관리
- 에러 핸들링
- 메모리 누수 방지
- 요청 취소 처리
interface User {
id: number;
name: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (isMounted) {
setUser(data);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err as Error);
setUser(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}
fetchUser();
// 클린업 함수로 메모리 누수 방지
return () => {
isMounted = false;
};
}, [userId]);
// 조건부 렌더링
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
if (!user) return <div>사용자를 찾을 수 없습니다.</div>;
return (
<div>
<h1>{user.name}의 프로필</h1>
{/* 추가 프로필 정보 */}
</div>
);
}
3. 성능 최적화 전략 🔥
3.1 의존성 배열 최적화
의존성 배열을 효율적으로 관리하여 불필요한 리렌더링을 방지하는 전략입니다.
주요 최적화 포인트:
- 불필요한 의존성 제거
- useRef를 활용한 값 보존
- 콜백 함수 메모이제이션
function OptimizedComponent({ data, onUpdate }: Props) {
// ❌ 비효율적인 방식
useEffect(() => {
const processedData = heavyProcessing(data);
onUpdate(processedData);
}, [data, onUpdate]); // 불필요한 재실행 가능성
// ✅ 최적화된 방식
const dataRef = useRef(data);
const onUpdateRef = useRef(onUpdate);
// 참조 값 업데이트
useEffect(() => {
dataRef.current = data;
onUpdateRef.current = onUpdate;
}, [data, onUpdate]);
// 실제 로직 실행
useEffect(() => {
const timer = setInterval(() => {
const processedData = heavyProcessing(dataRef.current);
onUpdateRef.current(processedData);
}, 1000);
return () => clearInterval(timer);
}, []); // 의존성 없음
}
3.2 메모이제이션 활용
useMemo
와 useCallback
을 활용한 성능 최적화 전략입니다.
메모이제이션 사용 시점:
- 복잡한 계산이 필요한 경우
- 자식 컴포넌트에 전달되는 콜백
- 큰 객체나 배열을 다룰 때
function MemoizedComponent({ items }: { items: Item[] }) {
// 계산 값 메모이제이션
const processedItems = useMemo(() => {
console.log('Heavy calculation running...');
return items.map(item => ({
...item,
processed: heavyProcessing(item)
}));
}, [items]);
// 콜백 메모이제이션
const handleItemClick = useCallback((id: string) => {
console.log(`Item clicked: ${id}`);
}, []); // 의존성 없음
useEffect(() => {
console.log('Processed items updated:', processedItems);
}, [processedItems]);
return (
<ul>
{processedItems.map(item => (
<ListItem
key={item.id}
item={item}
onClick={handleItemClick}
/>
))}
</ul>
);
}
4. 문제 해결과 디버깅 🐛
4.1 일반적인 문제와 해결 방법
주요 문제점들:
- 무한 루프
- 메모리 누수
- 경쟁 상태 (Race Condition)
- 초기 렌더링 문제
// 1. 무한 루프 문제
function InfiniteLoopExample() {
const [count, setCount] = useState(0);
// ❌ 잘못된 방법
useEffect(() => {
setCount(count + 1); // 매번 새로운 렌더링 발생
}, [count]);
// ✅ 올바른 방법
useEffect(() => {
setCount(prevCount => prevCount + 1);
}, []); // 마운트 시에만 실행
}
// 2. 경쟁 상태 해결
function RaceConditionExample() {
const [data, setData] = useState(null);
const [id, setId] = useState(1);
useEffect(() => {
let isCurrent = true;
async function fetchData() {
const response = await fetch(`/api/data/${id}`);
const result = await response.json();
if (isCurrent) { // 현재 컴포넌트가 유효한 경우에만 상태 업데이트
setData(result);
}
}
fetchData();
return () => {
isCurrent = false;
};
}, [id]);
}
4.2 디버깅 도구와 테크닉
디버깅 방법:
- 콘솔 로깅
- React DevTools 활용
- 의존성 배열 검사
- 타이밍 이슈 추적
function DebugComponent() {
const [state, setState] = useState(0);
useEffect(() => {
// 디버깅을 위한 로그 그룹화
console.group('Effect Debug Info');
console.log('Effect executed at:', new Date().toISOString());
console.log('Current state:', state);
console.log('Dependencies:', { state });
console.groupEnd();
// 클린업 함수에서도 로깅
return () => {
console.group('Cleanup Debug Info');
console.log('Cleanup executed at:', new Date().toISOString());
console.log('State at cleanup:', state);
console.groupEnd();
};
}, [state]);
// 개발 환경에서만 실행되는 디버깅 코드
if (process.env.NODE_ENV === 'development') {
useEffect(() => {
console.log('Development only debug info');
}, []);
}
}
5. 고급(?) 활용 사례 🚀
5.1 커스텀 훅 패턴
자주 사용되는 useEffect 로직을 재사용 가능한 커스텀 훅으로 분리하는 패턴입니다.
주요 활용 사례:
- 윈도우 이벤트 관리
- API 요청 로직
- 로컬 스토리지 동기화
- 폼 상태 관리
// 윈도우 크기 추적 훅
function useWindowSize() {
const [size, setSize] = useState({
width: 0,
height: 0
});
useEffect(() => {
function updateSize() {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', updateSize);
updateSize(); // 초기값 설정
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}
// 로컬 스토리지 동기화 훅
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue] as const;
}
5.2 비동기 작업 관리
복잡한 비동기 작업을 효율적으로 관리하는 패턴입니다.
주요 기능:
- 요청 취소
- 재시도 로직
- 캐싱
- 에러 바운더리 연동
// API 요청 관리 훅
function useApi<T>(url: string, options?: RequestInit) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const retryCount = useRef(0);
useEffect(() => {
const abortController = new AbortController();
const maxRetries = 3;
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, {
...options,
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
setError(null);
retryCount.current = 0;
} catch (err) {
if (err.name === 'AbortError') {
return;
}
if (retryCount.current < maxRetries) {
retryCount.current += 1;
console.log(`Retrying... Attempt ${retryCount.current}`);
setTimeout(fetchData, 1000 * retryCount.current);
} else {
setError(err as Error);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => {
abortController.abort();
};
}, [url]);
return { data, error, loading };
}
5.3 상태 동기화
여러 상태나 외부 시스템과의 복잡한 동기화를 처리하는 패턴입니다.
function useSyncedState<T>(
externalSystem: {
subscribe: (callback: (value: T) => void) => void;
getValue: () => T;
cleanup: () => void;
}
) {
const [state, setState] = useState<T>(() => externalSystem.getValue());
useEffect(() => {
const unsubscribe = externalSystem.subscribe((newValue) => {
setState(newValue);
});
return () => {
unsubscribe();
externalSystem.cleanup();
};
}, []);
return state;
}
// 실제 사용 예시
function SyncedComponent() {
const webSocketState = useSyncedState({
subscribe: (callback) => {
const ws = new WebSocket('ws://example.com');
ws.onmessage = (event) => callback(JSON.parse(event.data));
return () => ws.close();
},
getValue: () => ({}),
cleanup: () => console.log('WebSocket 연결 종료')
});
return (
<div>
<h2>실시간 데이터:</h2>
<pre>{JSON.stringify(webSocketState, null, 2)}</pre>
</div>
);
}
이것으로 React useEffect의 주요 활용 사례와 패턴에 대한 전체적인 가이드가 완성되었습니다.
추가적인 설명이나 다른 예제가 필요하시다면 말씀해 주세요!
6. SSR 환경에서의 주의사항 🚨
6.1 'use client' 지시문의 이해와 활용
Next.js 13 이상에서는 서버 컴포넌트가 기본값이므로, 클라이언트 사이드 기능이 필요한 경우 명시적으로 표시해야 합니다.
// ❌ 잘못된 사용
export default function Component() {
const [state, setState] = useState(false); // 오류 발생
// Error: useState is not defined in server component
}
// ✅ 올바른 사용
"use client";
export default function Component() {
const [state, setState] = useState(false); // 정상 작동
}
6.2 하이드레이션 이슈 해결하기
서버와 클라이언트 간의 초기 렌더링 불일치를 방지하기 위한 전략입니다.
function HydrationSafeComponent() {
const [isMounted, setIsMounted] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
useEffect(() => {
setIsMounted(true);
setWindowWidth(window.innerWidth);
}, []);
// 서버 사이드 렌더링 시 기본 UI
if (!isMounted) {
return <div>Loading...</div>;
}
// 클라이언트 사이드에서만 보이는 실제 UI
return <div>Window width: {windowWidth}px</div>;
}
6.3 브라우저 API 안전하게 사용하기
서버 사이드에서 접근할 수 없는 브라우저 API를 다루는 방법입니다.
function BrowserAPIComponent() {
const [localStorage, setLocalStorage] = useState(null);
useEffect(() => {
// 브라우저 API는 useEffect 내부에서만 사용
const storedData = window.localStorage.getItem('key');
setLocalStorage(storedData);
}, []);
// 조건부 렌더링으로 안전하게 처리
return (
<div>
{typeof window !== 'undefined' && (
<div>브라우저에서만 보이는 내용</div>
)}
</div>
);
}
6.4 동적 임포트 활용하기
클라이언트 사이드에서만 필요한 모듈을 동적으로 임포트하는 방법입니다.
import dynamic from 'next/dynamic';
// 클라이언트 사이드에서만 로드될 컴포넌트
const ClientOnlyComponent = dynamic(
() => import('../components/ClientComponent'),
{
ssr: false, // 서버 사이드 렌더링 비활성화
loading: () => <p>Loading...</p> // 로딩 중 표시할 컴포넌트
}
);
6.5 SSR 환경에서의 데이터 페칭
서버와 클라이언트에서의 데이터 페칭 전략을 구분하여 사용합니다.
// pages/[id].tsx
export async function getServerSideProps({ params }) {
// 서버 사이드에서의 데이터 페칭
const initialData = await fetchData(params.id);
return { props: { initialData } };
}
function DataComponent({ initialData }) {
const [data, setData] = useState(initialData);
useEffect(() => {
// 클라이언트 사이드에서의 추가 데이터 페칭
async function fetchAdditionalData() {
const newData = await fetchMoreData();
setData(prev => ({ ...prev, ...newData }));
}
fetchAdditionalData();
}, []);
return <div>{/* 데이터 표시 */}</div>;
}
6.6 SSR 디버깅 전략
서버와 클라이언트 사이드에서 발생하는 문제를 효과적으로 디버깅하는 방법입니다.
function DebugComponent() {
useEffect(() => {
console.log('Client side rendered');
}, []);
// 개발 환경에서만 동작하는 디버깅 코드
if (process.env.NODE_ENV === 'development') {
console.log('Component rendered:', {
isServer: typeof window === 'undefined',
timestamp: new Date().toISOString()
});
}
return (
<div>
{/*
React DevTools에서 확인할 수 있도록
데이터를 props로 전달
*/}
<DebugInfo
renderEnvironment={typeof window === 'undefined' ? 'server' : 'client'}
timestamp={new Date().toISOString()}
/>
</div>
);
}
6.7 성능 최적화 고려사항
SSR 환경에서의 성능 최적화를 위한 주요 고려사항들입니다.
function OptimizedComponent() {
// 1. 불필요한 상태 업데이트 방지
const [data, setData] = useState(() => {
// 초기값 계산은 한 번만 실행
return heavyComputation();
});
// 2. 컴포넌트 지연 로딩
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Skeleton />,
ssr: false
});
// 3. 이벤트 핸들러 최적화
const handleScroll = useCallback(() => {
// 스크롤 이벤트 처리
}, []);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return (
<div>
{/* 4. 조건부 렌더링으로 불필요한 렌더링 방지 */}
{process.browser && <ClientOnlyFeature />}
<HeavyComponent />
</div>
);
}
참고 자료 📚
공식 문서 및 가이드
유용한 블로그 포스트
- Dan Abramov - useEffect 완벽 가이드
- Kent C. Dodds - useEffect vs useLayoutEffect
- LogRocket - React useEffect 심층 분석
- Why Did You Render
성능 최적화 관련
- React 공식 문서 - 성능 최적화
- Web.dev - React 성능 최적화
- React Query 공식 문서 - 데이터 페칭 최적화
한국어 자료
- 벨로퍼트와 함께하는 모던 리액트
- 카카오 기술 블로그 - React Query와 함께하는 효과적인 리액트 비동기 데이터 관리
- TOAST UI - React의 useEffect 제대로 사용하기
GitHub 레포지토리
'개발 분야 (Development Area) > 프론트엔드 (Frontend)' 카테고리의 다른 글
[React] React Query와 Storybook: "No QueryClient set" 오류 해결 방법 (0) | 2025.02.24 |
---|---|
[React] React의 `createRoot`와 `hydrateRoot`이해하기 (0) | 2024.08.25 |
🛑 React에서 key={index} 사용의 위험성: 쉽게 이해하는 key 값과 React Reconciliation (3) | 2024.07.30 |
JavaScript reduce 메서드 활용하기 (1) | 2024.07.20 |