웹 서핑을 하다가 기사나 상품 페이지를 읽는 도중, 상단 이미지가 뒤늦게 다운로드되면서 화면 전체가 덜컹 내려앉아 엉뚱한 버튼을 잘못 클릭해 본 경험이 다들 한 번쯤 있으실 겁니다.

저 역시 초기 웹 프로젝트를 배포한 뒤 모바일 환경에서 테스트를 하다가 동일한 문제를 겪었습니다. 텍스트가 먼저 렌더링 된 후 용량이 큰 배너 이미지가 0.5초 뒤에 늦게 로딩되면서, 사용자가 클릭하려던 '장바구니 담기' 버튼이 아래로 쑥 밀려 내려가 '구매 취소' 버튼을 누르게 만드는 치명적인 UI 버그가 발생한 것입니다.

구글 라이트하우스(Lighthouse) 성능 점수를 측정해 보니 CLS(Cumulative Layout Shift, 누적 레이아웃 이동) 점수가 빨간색 경고(0.35 이상)를 기록하고 있었습니다.

오늘은 화면 깜빡임과 덜컹거림을 완벽히 잡고 부드러운 사용자 경험을 만들어 준 CLS 개선 노하우와 최신 CSS aspect-ratio 및 스켈레톤 UI 적용법을 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 화면이 덜컹거리며 밀려날까?"

브라우저가 HTML을 파싱할 때, 이미지 태그에 가로/세로 크기 정보가 미리 지정되어 있지 않으면 브라우저는 해당 이미지의 크기를 0 x 0 픽셀로 간주하고 아래 요소들을 먼저 위로 바짝 끌어올려 배치합니다.

HTML
 
<!-- ❌ 브라우저가 이미지 크기를 미리 알 수 없어 CLS를 유발하는 위험한 태그 -->
<img src="https://example.com/banner.jpg" alt="이벤트 배너" />

그 후 네트워크를 통해 실제 이미지 파일이 다운로드되면, 브라우저는 그제야 이미지의 진짜 높이(예: 400px)를 인식하고 아래에 있던 콘텐츠들을 강제로 밀어내는 재배치(Reflow / Relayout) 작업을 수행합니다. 이것이 바로 레이아웃 덜컹거림(Layout Shift)의 원인입니다.

2. 해결 방법 1: width와 height 속성으로 종횡비(Aspect Ratio) 미리 알려주기

가장 기본적이면서 강력한 해결법은 HTML <img> 태그에 반드시 원본 이미지의 width와 height 속성을 명시해 주는 것입니다.

HTML
 
<!-- ⭕ 브라우저가 이미지 비율을 미리 계산하여 공간을 확보함 -->
<img 
  src="https://example.com/banner.jpg" 
  alt="이벤트 배너" 
  width="800" 
  height="450" 
  style="width: 100%; height: auto;" 
/>

최신 브라우저는 태그에 적힌 width와 height 값을 기반으로 이미지가 로딩되기 전부터 종횡비(Aspect Ratio: 16/9)를 미리 계산해 정확한 높이의 빈 공간을 미리 예약해 둡니다. 따라서 이미지가 늦게 다운로드되어도 주변 레이아웃이 1픽셀도 흔들리지 않습니다.

3. 해결 방법 2: 최신 CSS aspect-ratio 속성 활용하기

동적으로 불러오는 외부 이미지나 반응형 카드 컴포넌트에는 최신 CSS 속성인 aspect-ratio를 사용하면 훨씬 유연하게 공간을 확보할 수 있습니다.

CSS
 
/* ⭕ 이미지가 다운로드되기 전에도 16:9 비율 공간을 강제로 유지 */
.banner-image-container {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #1e293b; /* 로딩 전 빈 공간에 어두운 배경색 지정 */
  border-radius: 12px;
  overflow: hidden;
}

.banner-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

과거에 padding-top: 56.25% 같은 복잡하고 난해한 핵(Hack)을 쓰던 방식에서 벗어나, 단 한 줄의 CSS로 완벽한 반응형 종횡비 공간을 예약할 수 있게 되었습니다.

📸 [사진 첨부 가이드 1]

여기에 VS Code에서 aspect-ratio: 16 / 9; 및 이미지 컨테이너 CSS 코드가 작성된 에디터 스크린샷을 넣으세요!

(캡처 팁: CSS 속성 중 aspect-ratio와 object-fit: cover 부분이 한눈에 들어오도록 에디터 화면을 스크린샷 찍어 첨부해 주세요.)

이미지 컨테이너 css

4. 해결 방법 3: 스켈레톤 UI(Skeleton)로 로딩 체감 속도 극대화

API 통신으로 데이터를 받아오는 동안에는 빈 화면 대신 스켈레톤(Skeleton UI)을 띄워 공간을 유지하면서 사용자에게 "데이터를 불러오는 중"이라는 시각적 피드백을 주는 것이 좋습니다.

CSS
 
/* 은은하게 반짝이는 스켈레톤 애니메이션 */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.skeleton-box {
  width: 100%;
  aspect-ratio: 16 / 9;
  background-color: #334155;
  animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  border-radius: 8px;
}

스켈레톤 UI를 적용하면 레이아웃 밀림 방지는 물론, 사용자가 대기 시간을 훨씬 짧게 체감하게 만드는 효과가 있습니다.

이미지 로드 전 스켈레톤
이미지 로드 후 상태

5. 결론 및 느낀 점

구글 검색 엔진은 이제 단순히 텍스트 내용뿐만 아니라 사용자가 화면을 조작할 때 시각적 안정성을 유지하는지(Core Web Vitals)를 중요한 랭킹 요소로 평가합니다.

  1. 모든 <img> 태그에 종횡비 정보(width/height 또는 CSS aspect-ratio)를 명시할 것
  2. 동적 콘텐츠 로딩 구간에는 미리 높이를 확보한 스켈레톤 UI를 배치할 것

이 두 가지 원칙만 지켜도 화면이 덜컹거리는 불쾌한 사용자 경험을 없애고 웹 사이트의 전문성과 완성도를 한 단계 끌어올릴 수 있습니다!

웹 사이트를 개발하다가 사용자가 실수로 브라우저 새로고침(F5)을 누르거나 페이지를 이동했을 때, 기껏 작성 중이던 폼 데이터나 다크 모드 설정값이 초기화되어 날아가는 문제를 겪어보신 적 있으신가요?

저 역시 초기 프로젝트에서 모든 상태를 React useState로만 관리했다가, 사용자가 브라우저를 껐다 켜거나 새로고침할 때마다 설정이 리셋되는 피드백을 받았습니다.

서버 데이터베이스(DB)에 일일이 저장하기에는 너무 자잘한 프론트엔드 설정값들(다크 모드 테마, 장바구니 임시 데이터, 팝업 '오늘 하루 보지 않기' 등)을 어디에 보관해야 할지 고민하다가 브라우저 저장소(Web Storage API)를 본격적으로 활용하게 되었습니다.

오늘은 브라우저의 대표적인 3대 저장소인 LocalStorage, SessionStorage, Cookie의 차이점과 실무에서 안전하게 활용했던 노하우를 공유해 보려고 합니다.

1. 3대 브라우저 저장소 핵심 비교: "어디에 무엇을 저장해야 할까?"

웹 브라우저가 제공하는 저장소는 수명(Lifecycle)과 용도에 따라 명확히 나뉩니다.

  • LocalStorage (로컬 스토리지): 브라우저 창을 닫아도 데이터가 영구적으로 유지됩니다. (용량 약 5MB) ➔ 다크 모드 테마, 사용자 맞춤 UI 설정에 적합
  • SessionStorage (세션 스토리지): 현재 탭(세션)이 열려 있는 동안에만 유지되며, 탭을 닫으면 즉시 삭제됩니다. ➔ 일회성 폼 입력 중간 저장, 결제 진행 단계 임시 데이터에 적합
  • Cookie (쿠키): 만료일을 설정할 수 있고 서버 요청 시 자동으로 헤더에 포함됩니다. (용량 약 4KB) ➔ 로그인 인증 토큰(HttpOnly) 등에 주로 사용

2. 실무 적용 1: LocalStorage로 다크 모드(Theme) 설정 영구 저장하기

사용자가 라이트 모드/다크 모드를 선택했을 때, 브라우저를 닫고 며칠 뒤에 다시 들어와도 그 설정을 기억하도록 localStorage를 적용했습니다.

TypeScript
 
// ⭕ 로컬스토리지에 테마 저장 및 불러오기 함수
const THEME_KEY = 'app_user_theme';

// 1. 테마 저장하기
export const saveTheme = (theme: 'light' | 'dark') => {
  try {
    localStorage.setItem(THEME_KEY, theme);
  } catch (error) {
    console.error('스토리지 저장 실패:', error);
  }
};

// 2. 테마 불러오기 (기본값: 'dark')
export const getSavedTheme = (): 'light' | 'dark' => {
  if (typeof window === 'undefined') return 'dark';
  const saved = localStorage.getItem(THEME_KEY);
  return saved === 'light' ? 'light' : 'dark';
};

3. 실무 적용 2: 객체(Object)나 배열 다룰 때 JSON.stringify 필수 사용

초보 시절 가장 많이 범했던 실수는 객체 데이터를 그대로 localStorage.setItem('user', { name: 'dev' })로 저장했던 것입니다.

브라우저 스토리지는 문자열(String)만 저장할 수 있기 때문에, 객체를 그냥 넣으면 [object Object]라는 깨진 문자열로 변환되어 저장됩니다.

JavaScript
 
const userProfile = { id: 1, name: '김개발', role: 'frontend' };

// ❌ 잘못된 저장 방식: [object Object]로 저장됨
localStorage.setItem('user', userProfile);

// ⭕ 올바른 저장 방식: JSON 문자열로 직렬화 후 저장
localStorage.setItem('user', JSON.stringify(userProfile));

// ⭕ 꺼내올 때: JSON.parse로 다시 자바스크립트 객체로 변환
const savedUser = JSON.parse(localStorage.getItem('user'));
console.log(savedUser.name); // '김개발' 정상 접근 가능!

localStorage.setItem 및 JSON.stringify

4. 실무 적용 3: 크롬 개발자 도구(Application 탭)로 데이터 확인하기

스토리지에 데이터가 제대로 들어갔는지 확인하려면 브라우저 개발자 도구의 Application 탭을 활용하면 됩니다.

  1. F12 개발자 도구 열기
  2. 상단 메뉴 중 Application (애플리케이션) 클릭
  3. 좌측 메뉴 Storage > Local Storage 클릭 후 내 도메인 선택

그러면 내가 코드로 저장한 Key와 Value가 실시간으로 표 형태로 직관적이게 표시되는 것을 확인할 수 있습니다.

local storage저장 확인

5. 결론 및 주의할 점

웹 스토리지는 사용하기 정말 편리하지만, 보안상 한 가지 중요한 원칙이 있습니다.

  • LocalStorage에는 절대 민감한 정보(비밀번호, 개인정보, 중요한 API 시크릿 키)를 저장하지 말 것!

XSS(Cross-Site Scripting) 공격에 노출될 경우 자바스크립트 코드로 쉽게 탈취될 수 있기 때문입니다. 따라서 UI 설정값, 캐시성 데이터, 비인증 임시 상태 위주로 활용하고, 중요한 인증 토큰은 안전한 HttpOnly Cookie를 활용하는 것이 모범 사례입니다.

리액트(React)로 컴포넌트를 분리하며 프로젝트를 개발하다 보면 누구나 한 번쯤 마주치는 악몽이 있습니다. 바로 '프롭스 드릴링(Props Drilling)'입니다.

로그인한 유저 정보나 다크모드 설정값처럼 앱 전체에서 쓰이는 데이터를 단 하나의 하위 컴포넌트에 전달하기 위해, 부모 ➔ 자식 ➔ 손자 ➔ 증손자 컴포넌트를 거쳐 불필요하게 props를 계속 넘겨주다 보면 코드가 지저분해지고 유지보수가 불가능해집니다.

처음에는 Redux(리덕스)를 도입해 보려 했지만, 단순한 상태 하나 만드는데 Action, Reducer, Store 등 보일러플레이트 코드가 너무 많아 배보다 배꼽이 더 큰 느낌을 받았습니다.

오늘은 보일러플레이트 없이 가볍고 직관적인 Zustand(주스탠드)를 도입하여 프롭스 드릴링을 단 10분 만에 해결했던 경험과 실무 활용 꿀팁을 공유해 보려고 합니다.

1. 내가 마주친 문제: "증손자 컴포넌트 하나 때문에 부모들이 고통받다"

헤더에 있는 장바구니 카운트와, 메인 상품 목록 페이지에 있는 '장바구니 담기' 버튼을 동기화해야 하는 상황이었습니다.

TypeScript
 
// ❌ 프롭스 드릴링으로 고통받던 과거 컴포넌트 구조
<App>
  <Header cartCount={cartCount} /> {/* 헤더에 전달 */}
  <MainPage>
    <ProductList>
      <ProductCard onAddToCart={handleAddToCart} /> {/* 3단계를 거쳐 함수 전달 */}
    </ProductList>
  </MainPage>
</App>

중간에 있는 MainPage나 ProductList는 onAddToCart 함수를 직접 쓰지도 않으면서 단순히 자식에게 넘겨주기 위해 props를 억지로 받아야 했습니다. 이 구조는 나중에 컴포넌트 위치가 바뀌거나 기능이 추가될 때마다 치명적인 리팩토링 비용을 발생시켰습니다.

2. 왜 Redux 대신 Zustand를 선택했는가?

전역 상태 라이브러리를 고민할 때 가장 먼저 떠오른 것은 Redux였지만, 작은 규모의 기능 하나를 추가하기 위해 작성해야 할 보일러플레이트 코드가 너무 많았습니다. 반면 Zustand는 다음과 같은 압도적인 장점이 있었습니다.

  1. 단순한 훅(Hook) 기반: 별도의 Context Provider로 앱 전체를 감쌀 필요가 없음.
  2. 극도로 가벼운 번들 크기: 수십 KB에 달하는 다른 라이브러리에 비해 약 1KB 수준으로 초경량.
  3. 타입스크립트(TypeScript) 완벽 지원: 복잡한 제네릭 설정 없이 직관적인 타입 추론 가능.

3. 실무 적용: 10줄로 만드는 Zustand 전역 스토어

Zustand의 핵심은 create 함수를 사용해 스토어(Store)를 정의하고, 필요한 컴포넌트에서 일반 커스텀 훅처럼 가져다 쓰는 것입니다.

TypeScript
 
// store/useCartStore.ts (장바구니 전역 스토어)
import { create } from 'zustand';

interface CartState {
  cartCount: number;
  addToCart: () => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  cartCount: 0,
  // set 함수를 사용해 상태를 불변성 유지하며 업데이트
  addToCart: () => set((state) => ({ cartCount: state.cartCount + 1 })),
  clearCart: () => set({ cartCount: 0 }),
}));

Provider로 최상단 App 컴포넌트를 감싸는 과정조차 필요 없이, 이 파일 하나로 전역 상태 세팅이 끝납니다.

인터페이스 정의와 Zustand create 스토어 구조


4. 컴포넌트에서 사용하기: "필요한 곳에서 바로 꺼내 쓴다"

이제 프롭스 드릴링 없이, 상태가 필

요한 컴포넌트에서 훅을 호출하기만 하면 됩니다.

TypeScript
 
// 1. 헤더 컴포넌트: cartCount 값만 구독
import { useCartStore } from '../store/useCartStore';

export function Header() {
  const cartCount = useCartStore((state) => state.cartCount);
  return <header>장바구니: 🛒 {cartCount}개</header>;
}

// 2. 상품 카드 컴포넌트: addToCart 함수만 호출
export function ProductCard() {
  const addToCart = useCartStore((state) => state.addToCart);
  return <button onClick={addToCart}>장바구니 담기</button>;
}

중간 컴포넌트들을 거칠 필요 없이 각자 필요한 데이터와 함수만 직접 구독하므로, 불필요한 리렌더링도 방지되고 코드가 엄청나게 깔끔해졌습니다.

담은 상품 수에 따라 달라지는 금액

5. 결론 및 느낀 점

Zustand를 도입하고 나서 리액트 상태 관리의 피로도가 절반 이하로 줄어들었습니다.

  • 상태 하나 만드는데 1분도 채 걸리지 않는 압도적인 생산성
  • 불필요한 props 전달이 사라져 깔끔해진 컴포넌트 트리 구조

컴포넌트 깊이가 깊어지면서 props를 넘겨주는 데 스트레스를 받고 계신다면, 무거운 라이브러리 대신 가볍고 직관적인 Zustand로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!

프런트엔드 개발을 처음 시작했을 때, 기본 설정 상태의 VS Code(Visual Studio Code)만 사용하며 일일이 닫는 태그를 직접 타이핑하고 코드 들여 쓰기(Indent) 줄 맞춤을 일일이 Tab키로 맞추곤 했습니다.

그러다 보니 단순 오타나 태그 짝이 맞지 않아 발생하는 소소한 에러를 잡는 데 많은 시간을 허비했고, 자연스럽게 개발 피로도도 높아졌습니다.

이후 실무 프로젝트를 거치며 나에게 딱 맞는 VS Code 확장 프로그램(Extensions)들을 하나씩 찾아 세팅하게 되었고, 반복적이고 소모적인 단순 작업량이 획기적으로 줄어드는 것을 경험했습니다.

오늘은 프론트엔드 개발자로서 실제 코딩 생산성을 2배 이상 끌어올려 주었던 VS Code 필수 익스텐션 5가지와 실무 추천 설정을 소개해 드립니다.

1. Prettier - Code formatter (코드 스타일 자동 정돈)

프런트엔드 개발자에게 가장 기본이자 필수적인 익스텐션입니다. 홑따옴표/큰따옴표 혼용, 세미콜론 누락, 들여 쓰기 불일치 등 지저분한 코드 스타일을 저장(Ctrl + S / Cmd + S) 한 번으로

깔끔하게 자동 정돈해 줍니다.

JSON
 

 

// .prettierrc (프로젝트 루트에 추가하는 추천 설정 파일)
{
  "singleQuote": true,
  "semi": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 80
}

💡 실무 팁: Format On Save 설정

VS Code 설정(Ctrl + ,)에서 Format On Save 항목을 체크해 두면, 저장할 때마다 자동으로 코드가 이쁘게 정렬되어 코드 리뷰 시 스타일 차이로 인한 소모적 논쟁을 완벽히 방지해 줍니다.

2. ESLint (자바스크립트/리액트 코드 오류 사전 감지)

Prettier가 코드 '모양(스타일)'을 잡아준다면, ESLint는 코드의 '문법 오류 및 잠재적 버그'를 사전에 감지해 주는 파수꾼입니다.

선언만 하고 쓰지 않은 변수, 리액트 useEffect 의존성 배열 누락, 잘못된 비동기 처리 등을 에디터 상에서 실시간 빨간 줄로 알려주어 런타임 에러를 획기적으로 줄여줍니다.

Prettier와 ESLint 설정

3. Auto Rename Tag & Auto Close Tag (HTML/JSX 태그 자동 관리)

HTML이나 리액트 JSX 작성 시 생산성을 폭발적으로 올려주는 효자 익스텐션 커플입니다.

  • Auto Close Tag: <div를 입력하면 자동으로 </div> 닫는 태그를 만들어 줍니다.
  • Auto Rename Tag: 여는 태그를 <div>에서 <section>으로 수정하면, 대응되는 닫는 태그도 자동으로 </section>으로 변경됩니다.

태그 이름 하나 바꾸려고 스크롤 내려서 닫는 태그를 찾아 다닐 필요가 없어져 타자 속도가 대폭 단축됩니다.

4. GitLens — Git supercharged (코드 작성자 및 변경 이력 추적)

팀 프로젝트나 협업 진행 시 강력한 빛을 발하는 도구입니다.

코드 각 라인 끝에 "누가, 언제, 어떤 커밋 메시지로 이 코드를 수정했는지" 가볍게 표시해 줍니다. 과거 코드를 고치거나 왜 이 로직이 들어갔는지 궁금할 때 Git 커밋 히스토리를 찾아볼 필요 없이 마우스만 올려도 맥락을 바로 파악할 수 있어 협업 생산성이 대폭 증가합니다.

5. Tailwind CSS IntelliSense (Tailwind 클래스 자동완성)

Tailwind CSS를 사용하는 프론트엔드 개발자라면 필수로 깔아야 할 확장 프로그램입니다.

className="flex items-center ..." 작성 시 사용 가능한 유틸리티 클래스 목록을 자동으로 추천해 주고, 해당 클래스가 실제 어떤 CSS 속성을 가졌는지 마우스를 올리면 미리 보기 패널로 보여줍니다.

Tailwind CSS 클래스 자동완성(IntelliSense) 팝업 창

💡 회고 및 요약

좋은 도구를 선택하고 내 개발 환경에 맞춰 세팅하는 것 역시 개발자의 중요한 역량 중 하나입니다.

오늘 소개해 드린 익스텐션들을 적극 활용하여 단순 오타나 스타일 정돈에 드는 시간을 아끼고, 진짜 중요한 핵심 비즈니스 로직 작성에 집중하는 쾌적한 개발 환경을 만들어 보시길 강력히 추천합니다!

리액트(React)를 활용해 웹 애플리케이션을 개발할 때, 가장 기본이 되면서도 개발자를 가장 흔하게 대혼란에 빠뜨리는 주범은 바로 상태 관리(useState)와 부작용 처리(useEffect)입니다.

문법 자체는 매우 단순해 보이지만, 리액트 내부의 렌더링 라이프사이클과 비동기 업데이트 메커니즘을 명확히 이해하지 못하면 눈에 잘 띄지 않는 치명적인 버그를 만들어내곤 합니다.

오늘은 제가 리액트 프로젝트를 진행하며 직접 경험했던 대표적인 실수 3가지와 이를 해결하며 깨달은 리액트의 핵심 원리를 상세히 정돈해 보려고 합니다.

1. 실수 1: useEffect 내부 비동기 호출 시 '무한 렌더링' 루프 발생

가장 식은땀을 흘렸던 버그는 컴포넌트가 마운트될 때 API 데이터를 가져오도록 작성한 useEffect 코드에서 발생했습니다.

TypeScript
 
// ❌ 무한 렌더링을 유발했던 과거의 코드
function UserProfile() {
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);

  useEffect(() => {
    fetchUserData().then((data) => {
      setUserInfo(data); // 2. 상태가 변경되어 컴포넌트가 재렌더링됨
    });
  }, [userInfo]); // 1. userInfo 상태를 의존성 배열에 넣음 ➔ 3. 다시 useEffect 실행 (무한 반복!)

  return <div>{userInfo?.name}</div>;
}

💡 원인 및 해결책

useEffect 내부에서 setUserInfo를 통해 상태를 변경하고 있는데, 그 변경되는 userInfo 상태를 의존성 배열(Dependencies)에 넣어둔 것이 원인이었습니다. 상태 변경 ➔ 재렌더링 ➔ useEffect 재실행 ➔ 상태 변경의 무한 루프가 발생하여 서버에 수천 번의 API 요청이 전송되는 참사가 일어났습니다.

마운트 시점에 한 번만 실행되어야 하는 API 요청은 의존성 배열을 빈 배열([])로 설정하여 해결했습니다.

TypeScript
 
// ⭕ 올바르게 수정한 코드
useEffect(() => {
  fetchUserData().then((data) => {
    setUserInfo(data);
  });
}, []); // 빈 배열을 전달하여 마운트 시 단 1회만 실행되도록 보장
useState, useEffect로 작성한 코드


2. 실수 2: setState의 비동기적 특성을 오해하고 '이전 값'을 참조한 문제

카운트 수량을 올리거나 이전 상태를 기반으로 연쇄 계산을 수행할 때 자주 범했던 실수입니다.

TypeScript
 
// ❌ 원하는 대로 스코어가 2씩 증가하지 않는 코드
const handleDoubleScoreUp = () => {
  setScore(score + 1); // 현재 score가 0이라면 0 + 1 = 1 설정 요청
  setScore(score + 1); // 여전히 이전 closure의 score(0)를 참조하여 0 + 1 = 1 설정 요청
};

💡 원인 및 해결책

리액트의 상태 변경 함수(setState)는 즉각적으로 변수의 값을 변경하는 것이 아니라, 성능 최적화를 위해 리액트 렌더링 스케줄러에 업데이트를 예약(Batching)하는 비동기 방식으로 동작합니다. 따라서 연속으로 setState를 호출하면 동기적으로 반영되지 않고 이전 렌더링 시점의 클로저(Closure) 스코프 값을 참조하게 됩니다.

이 문제는 최신 상태 보장을 위해 함수형 업데이트(Functional Update) 방식을 적용하여 해결했습니다.

TypeScript
 
// ⭕ 함수형 업데이트를 적용해 항상 최신 상태를 기반으로 계산하도록 수정
const handleDoubleScoreUp = () => {
  setScore((prevScore) => prevScore + 1);
  setScore((prevScore) => prevScore + 1); // 이전 상태(prevScore)를 인수로 받아 정확히 2가 증가함
};

3. 실수 3: useEffect의 Cleanup(정리) 함수 누락으로 인한 메모리 누수

페이지 상단으로 이동하는 버튼이나 스크롤 프로그레스 바를 만들기 위해 window 객체에 스크롤 이벤트를 등록할 때 발생했던 실수입니다.

TypeScript
 
// ❌ 컴포넌트가 언마운트되어도 이벤트가 남아 메모리 누수가 일어나는 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };
  
  window.addEventListener('scroll', handleScroll);
}, []);

💡 원인 및 해결책

useEffect 내에서 타이머(setInterval), Web API 이벤트 리스너(addEventListener), 혹은 WebSocket 연결을 등록해 두고 정리(Cleanup) 함수를 반환하지 않으면, 해당 컴포넌트가 화면에서 사라진(Unmount) 후에도 메모리에 잔재하여 성능 저하와 예기치 않은 동작을 일으킵니다.

useEffect의 return 구문에 리스너를 해제하는 클린업 로직을 추가하여 완벽

히 방지할 수 있었습니다.

TypeScript
 
// ⭕ 클린업 함수를 반환하여 메모리 누수를 방지한 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };

  window.addEventListener('scroll', handleScroll);

  // 컴포넌트가 언마운트되거나 재실행되기 전 기존 리스너를 깨끗이 제거
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

useEffect내부의 로그 출력

4. 결론 및 느낀 점

리액트의 useState와 useEffect는 프론트엔드 개발 시 매일 사용하는 기본적인 훅이지만, 그 내부의 '불변성(Immutability)', '클로저(Closure)', '비동기 배칭(Batching)' 원리를 깊이 이해하지 못하면 쉽게 버그에 노출될 수 있습니다.

단순히 라이브러리의 문법만 외우기보다는, 렌더링 주기에 따라 상태가 어떻게 변화하고 이펙트가 언제 실행되는지 지속적으로 관찰하고 디버깅하는 습관이 좋은 리액트 개발자로 성장하는 핵심 열쇠라고 생각합니다.

+ Recent posts