리액트(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로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!

리액트(React)로 프로젝트를 진행하며 API 데이터를 불러오거나 컴포넌트 생명주기(Lifecycle)를 다룰 때 가장 먼저 접하게 되는 훅(Hook)이 바로 useEffect입니다.

처음 useEffect를 배울 때는 "그냥 컴포넌트가 화면에 나타날 때 실행하고 싶은 코드를 넣어두는 함수" 정도로 가볍게 생각하곤 했습니다. 하지만 프로젝트 규모가 커지면서 어느 날 브라우저 탭이 갑자기 렉을 먹고 먹통이 되거나, 개발자 도구 콘솔에 수백 개의 API 요청 로그가 쏟아지는 '무한 리렌더링(Infinite Loop)' 참사를 겪게 되었습니다.

오늘은 제가 초보 시절 useEffect를 오용하면서 겪었던 무한 리렌더링의 원인과, 이를 안전하고 올바르게 다루기 위한 의존성 배열(Dependency Array) 관리 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "브라우저 콘솔창이 터져나간 이유"

특정 유저 정보를 서버에서 받아와 상태(State)에 저장하는 흔한 로직을 작성하던 중이었습니다.

별생각 없이 useEffect 내부에서 데이터를 받아와 setState를 호출했는데, 페이지에 진입하자마자 브라우저가 느려지더니 서버로 동일한 API 요청이 초당 수십 번씩 계속 날아가는 현상이 발생했습니다.

TypeScript
 
// ❌ 무한 리렌더링을 유발했던 과거의 위험한 코드
import { useState, useEffect } from 'react';

export default function UserProfile() {
  const [user, setUser] = useState(null);

  // 🚨 두 번째 인자인 의존성 배열(Dependency Array)을 빠뜨림!
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => {
        setUser(data); // 상태 변경 -> 컴포넌트 리렌더링 -> useEffect 재실행 -> 무한 반복!
      });
  });

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

💡 무한 루프가 발생한 원인

useEffect의 두 번째 인자인 의존성 배열([])을 완전히 생략해 버리면, 리액트는 컴포넌트가 리렌더링 될 때마다 useEffect 내부 콜백 함수를 매번 실행합니다.

useEffect 내부에서 setUser로 상태를 바꾸면 ➔ 리액트는 상태가 바뀌었으니 컴포넌트를 리렌더링하고 ➔ 리렌더링 되었으니 useEffect가 또 실행되어 setUser를 호출하는 지옥의 악순환이 만들어진 것입니다.

2. 해결책 1: 빈 의존성 배열([])로 '마운트 시 1회 실행' 보장하기

이 문제를 가장 쉽게 해결하는 방법은 useEffect 두 번째 인자로 빈 배열([])을 넘겨주는 것입니다.

TypeScript
 
// ⭕ 빈 배열 []을 넣어 컴포넌트 마운트 시 최초 1회만 실행되도록 수정
useEffect(() => {
  fetch('/api/user')
    .then((res) => res.json())
    .then((data) => setUser(data));
}, []); // 👈 빈 의존성 배열 추가!

이렇게 설정하면 컴포넌트가 화면에 처음 그려질 때(Mount) 딱 한 번만 API 요청을 수행하므로 안전합니다.

useEffect 두 번째 인자로 빈 배열 [] 이 들어간 코드

3. 해결책 2: 객체/함수를 의존성 배열에 넣을 때 발생하는 함정 피하기

빈 배열을 넣는 것만으로 모든 문제가 해결되지는 않았습니다. 특정 조건(예: 검색어 ID 변경)에 따라 데이터를 다시 불러와야 할 때는 의존성 배열에 변수를 넣어주어야 합니다.

이때 객체(Object)나 함수(Function)를 의존성 배열에 그대로 넣으면 다시 무한 루프가 터지는 함정에 빠질 수 있습니다.

TypeScript
 
// ❌ 객체를 참조 타입으로 넣어 무한 루프가 발생하는 잘못된 예시
export default function SearchList({ options }) { // options가 객체인 경우
  const [list, setList] = useState([]);

  useEffect(() => {
    fetchData(options).then((data) => setList(data));
  }, [options]); // 🚨 부모가 리렌더링될 때마다 options 객체의 참조값이 새로 바뀌어 계속 실행됨!
}

자바스크립트에서 객체나 함수는 리렌더링될 때마다 메모리 주소(참조값)가 매번 새로 생성됩니다. 따라서 내용물이 똑같아도 리액트는 "의존성 값이 바뀌었네?" 하고 useEffect를 또 실행해 버립니다.

💡 해결 방법 (원시 값 전달 또는 useCallback / useMemo 활용)

  • 객체 전체 대신 객체 내부의 원시 값(options.id 등)을 의존성 배열에 넣기
  • 함수를 의존성 배열에 넣어야 할 경우 useCallback으로 감싸 메모이제이션하기
TypeScript
 
// ⭕ 객체 전체가 아닌 원시 값(id)을 의존성 배열에 넣어 안전하게 관리
useEffect(() => {
  fetchData(options.id).then((data) => setList(data));
}, [options.id]); // 👈 객체의 속성값(number, string)을 지정!

의존성 배열 관련 코드

4. 정리 및 깨달은 점

useEffect는 단순히 코드를 실행해 주는 부수 효과 도구가 아닙니다. "컴포넌트의 상태 및 렌더링 주기와 외부 시스템을 동기화하는 도구"입니다.

  1. useEffect 내부에서 상태(state)를 변경한다면 의존성 배열이 제대로 설정되어 있는지 필수 점검할 것
  2. 의존성 배열에 참조 타입(객체, 배열, 함수)을 넣을 때는 참조값 변경에 유의할 것

이 두 가지만 명확히 기억해 두셔도 무한 리렌더링으로 브라우저가 다운되는 불상사를 완벽히 예방할 수 있습니다!

자바스크립트(JavaScript)로만 프로젝트를 진행하다가 안정성을 높이기 위해 타입스크립트(TypeScript)를 처음 도입했을 때, 기쁨보다 당혹감이 훨씬 컸습니다.

분명 자바스크립트에서는 아무 문제 없이 잘 돌아가던 코드인데, 타입스크립트를 적용하자마자 VS Code 에디터 온 동네에 빨간 줄(Type Error)이 그어지며 화면이 온통 경고 메시지로 뒤덮였기 때문입니다.

당시에는 "개발 속도만 늦어지는 것 같은데 왜 쓸까?" 싶었지만, 타입 에러를 하나씩 해결해 나가면서 런타임(실행 시점)에서 터질 위험한 버그를 코드 작성 단계에서 미리 잡아주는 강력함을 체감하게 되었습니다.

오늘은 제가 타입스크립트 초보 시절 가장 자주 마주치며 식은땀을 흘렸던 대표적인 타입 에러 3가지와 완벽한 해결법을 정리해 보려고 합니다.

1. 에러 1: Object is possibly 'null' or 'undefined'

API 데이터를 받아오거나 DOM 요소를 참조할 때 가장 흔하게 만나는 에러입니다.

TypeScript
 
// ❌ 빨간 줄이 뜨는 과거의 코드
const nameLength = response.user.name.length;
// 🚨 Error: 'response.user' is possibly 'undefined'.

💡 원인 및 해결책

타입스크립트는 response.user 데이터가 서버 응답 지연이나 에러로 인해 undefined 상태일 가능성을 미리 경고해 주는 것입니다. 만약 이 상태로 자바스크립트에서 실행되었다면 런타임에 그 악명 높은 Cannot read properties of undefined 에러가 터졌을 것입니다.

해결을 위해 옵셔널 체이닝(?.)이나 타입 가드(Type Guard)를 활용하면 깔끔하게 해결됩니다.

TypeScript
 
// ⭕ 옵셔널 체이닝으로 안전하게 접근
const nameLength = response.user?.name?.length;

// ⭕ 또는 타입 가드(if문)로 안전성 보장
if (response.user && response.user.name) {
  const nameLength = response.user.name.length;
}

2. 에러 2: Property 'X' does not exist on type 'Y' (이벤트 객체 / 객체 키 접근 에러)

리액트에서 onChange 이벤트를 처리하거나, 객체의 키(Key)로 동적 접근을 시도할 때 자주 발생하는 에러입니다.

TypeScript
 
// ❌ e.target.value에서 빨간 줄이 뜨는 코드
const handleChange = (e) => {
  console.log(e.target.value);
  // 🚨 Error: Parameter 'e' implicitly has an 'any' type.
};

 

타입을 잘 지정해 에러가 안나는 모습

💡 원인 및 해결책

타입스크립트는 매개변수 e가 어떤 이벤트 객체인지 알지 못하므로 암묵적인 any 타입 경고를 보냅니다. 리액트에서 제공하는 이벤트 타입(React.ChangeEvent 등)을 명시해 주어야 합니다.

TypeScript
 
// ⭕ React의 HTMLInputElement 타입을 정확히 명시
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value); // 이제 에러 없이 자동완성까지 지원됨!
};

3. 에러 3: Type 'X' is not assignable to type 'Y' (상태 초기값 타입 불일치)

useState를 사용할 때 초기값 타입과 나중에 넣어줄 API 데이터의 타입이 달라서 발생하는 에러입니다.

TypeScript
 
// ❌ null로 초기화해 두었더니 나중에 객체를 넣을 때 에러 발생
interface UserProfile {
  id: number;
  name: string;
}

const [user, setUser] = useState(null);

// 나중에 API 응답 후 setUser 호출 시 에러!
setUser({ id: 1, name: '김개발' }); 
// 🚨 Error: Argument of type '{ id: number; name: string; }' is not assignable to parameter of type 'null'.

💡 원인 및 해결책

useState(null)로 작성하면 타입스크립트는 user 변수의 타입을 오직 null로만 추론해 버립니다. 제네릭(<T>)을 사용해 "초기엔 null이지만 나중에 UserProfile 객체가 들어올 수 있다"라고 명시해 주어야 합니다.

TypeScript
 
// ⭕ 제네릭과 유니온 타입을 활용해 두 가지 가능성을 모두 열어둠
const [user, setUser] = useState<UserProfile | null>(null);

// 이제 객체를 넣어도 빨간 줄 없이 정상 동작!
setUser({ id: 1, name: '김개발' });

 

useState에 타입추가

4. 회고 및 느낀 점

타입스크립트를 처음 시작했을 때는 일일이 타입을 적어주는 행위가 개발 속도를 갉아먹는 걸림돌처럼 느껴졌습니다.

하지만 수많은 빨간 줄과 싸우고 해결해 나가는 과정에서 "내가 작성하는 코드의 데이터 흐름을 훨씬 객관적이고 안전하게 파악하는 습관"을 가질 수 있게 되었습니다.

지금 타입스크립트의 빨간 줄 때문에 스트레스를 받고 계신 초보 개발자분들이 계시다면, 에러 메시지를 무서워하지 마시고 "타입스크립트가 미래의 런타임 에러를 대신 막아주고 있구나!" 하고 긍정적으로 바라보시면 될 것 같습니다!

프런트엔드 개발을 처음 시작하고 자바스크립트(JavaScript)로 데이터를 다룰 때, 제 코드의 90%는 for문이나 forEach문으로 가득 차 있었습니다.

백엔드 API에서 받아온 리스트 데이터를 화면에 뿌려주거나, 특정 조건의 아이템만 걸러내고, 총합을 계산할 때도 항상 습관적으로 빈 배열을 하나 선언해 두고 for문을 돌리곤 했습니다.

동작은 잘 되었지만, 코드가 수십 줄로 길어지면서 변수가 오염되거나 가독성이 뚝 떨어지는 문제를 경험했습니다. 그러다 리액트(React)를 본격적으로 다루면서 map, filter, reduce 같은 고차 배열 함수의 진짜 위력을 깨닫게 되었습니다.

오늘은 제가 실무 및 프로젝트 코딩 중 for문 지옥에서 탈출해 배열 메서드 3대장을 실무에서 깔끔하게 활용하게 된 경험과 작성 팁을 정돈해 보려고 합니다.

1. map: 기존 배열을 변형해 새로운 UI 데이터 만들기

처음 리액트로 목록(List) 컴포넌트를 만들 때 map을 만나고 상당한 충격을 받았습니다.

기존에는 빈 배열을 만들어서 push()로 일일이 담아야 했던 작업이, map을 사용하니 원본 배열을 훼손하지 않으면서(불변성 유지) 가공된 새로운 배열을 반환해 주는 깔끔한 코드로 변했습니다.

JavaScript
 
// ❌ 옛날에 작성했던 지저분한 for문 방식
const rawUsers = [
  { id: 1, name: '김철수', age: 24 },
  { id: 2, name: '이영희', age: 29 },
];

const userNames = [];
for (let i = 0; i < rawUsers.length; i++) {
  userNames.push(rawUsers[i].name);
}

// ⭕ map을 활용해 단 한 줄로 깔끔하게 처리한 코드
const userNamesWithMap = rawUsers.map((user) => user.name);
// 결과: ['김철수', '이영희']

💡 실무 팁: 리액트 JSX 렌더링에서의 활용

리액트에서는 API로 받아온 객체 배열을 화면의 HTML 요소로 1:1 변환할 때 map이 필수적입니다. 고유한 key값만 잘 챙겨주면 선언적인 유려한 코드를 작성할 수 있습니다.

2. filter: 검색창과 조건별 필터링 기능 구현하기

쇼핑몰 카테고리 필터나 검색창 기능을 만들 때 가장 효자 역할을 해준 메서드가 바로 filter입니다.

조건식의 결과가 true인 요소들만 뽑아서 새로운 배열로 만들어주기 때문에, complex한 조건문 없이도 깔끔한 상태 업데이트가 가능해졌습니다.

JavaScript
 
const products = [
  { id: 1, name: '개발자 키보드', price: 150000, inStock: true },
  { id: 2, name: '버티컬 마우스', price: 75000, inStock: false },
  { id: 3, name: '모니터', price: 350000, inStock: true },
];

// ⭕ 재고가 있고(inStock), 가격이 20만원 이하인 상품만 필터링
const availableProducts = products.filter(
  (product) => product.inStock && product.price <= 200000
);
// 결과: [{ id: 1, name: '키보드', ... }]

체이닝 코드

3. reduce: 장바구니 총금액 계산과 데이터 그룹화의 최종병기

처음 reduce를 접했을 때는 (accumulator, currentValue) => ... 라는 생소한 문법 때문에 괜히 어렵게 느껴져 피하곤 했습니다.

하지만 장바구니에 담긴 상품들의 총결제 금액 계산이나 특정 기준에 따른 객체 데이터 그룹화를 처리할 때 reduce만큼 완벽한 도구가 없다는 것을 깨달았습니다.

JavaScript
 
const cartItems = [
  { name: '맥북 케이스', price: 35000, count: 2 },
  { name: 'C타입 젠더', price: 12000, count: 1 },
  { name: '장패드', price: 18000, count: 3 },
];

// ⭕ 장바구니 총금액을 한 번에 구하는 reduce
const totalPrice = cartItems.reduce((acc, item) => {
  return acc + item.price * item.count;
}, 0); // 초기값(initialValue)을 0으로 설정!

console.log(totalPrice); // 출력: 136000

💡 실무 팁: 초기값(0 또는 {})을 꼭 넣어주기

reduce를 사용할 때 가장 흔하게 범하는 실수가 두 번째 인자인 초기값을 빼먹는 것입니다. 빈 배열에서 초기값 없이 reduce를 호출하면 런타임 에러(TypeError)가 터질 수 있으므로, 습관적으로 초기값을 명시해 주는 것이 안전합니다.

4. 메서드 체이닝(Chaining)으로 완성하는 단단한 코드

map, filter, reduce의 진짜 매력은 이들을 사슬처럼 이어 붙이는 '메서드 체이닝'이 가능하다는 점입니다.

JavaScript
 
// ⭕ 재고 있는 상품 중 10만원 이상인 것들의 '총 가격'만 계산하기
const totalHighPriceInStock = products
  .filter((item) => item.inStock) // 1. 재고 있는 것만 추출
  .filter((item) => item.price >= 100000) // 2. 10만원 이상만 추출
  .reduce((acc, item) => acc + item.price, 0); // 3. 합계 계산

for문으로 작성했으면 20줄이 넘어갔을 로직이, 함수형 메서드를 이어 붙이니 단 몇 줄만으로 읽기 편한 코드가 되었습니다.

코드의 실행 결과

5. 회고 및 정리

for문 위주의 명령형 프로그래밍 방식에서 벗어나 자바스크립트의 고차 배열 함수들을 익히면서, 제 코드는 훨씬 선언적(Declarative)이고 가독성이 높은 코드로 발전할 수 있었습니다.

단순히 동작하는 코드를 넘어서 '읽기 편하고 유지보수하기 쉬운 코드'를 고민 중이신 자바스크립트 초보 개발자분들이라면, 오늘 당장 map, filter, reduce 체이닝 연습부터 시작해 보시길 권해드립니다!

이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 Lighthouse 성능 검사를 돌려보았습니다.

결과는 참담하게도 성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초.

스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.

"요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?" 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.

오늘은 기본 HTML <img> 태그를 버리고 Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정을 공유해 봅니다.

1. 내가 범했던 실수: "원본 PNG 이미지를 그냥 태그에 꽂아 넣었다"

처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.

TypeScript
 
// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    <div className="banner-container">
      <img src="/images/main-banner.png" alt="메인 배너" />
    </div>
  );
}

이 코드가 왜 문제였냐면:

  1. 모바일 단말기에서도 3.8MB 원본을 통째로 다운로드받고 있었습니다.
  2. 이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 아래 요소들을 강제로 밀어내는 불쾌한 현상이 일어났습니다.
  3. 브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.

2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기

Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.

TypeScript
 
import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    <div className="relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg">
      <Image
        src="/images/main-banner.png"
        alt="메인 프로모션 배너 이미지"
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
        className="object-cover"
        placeholder="blur" // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg=="
      />
    </div>
  );
}

💡 직접 적용해보며 깨달은 꿀팁 2가지

  • priority 속성의 중요성: 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.
  • fill과 부모 relative 관계: 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.

Image 컴포넌트 사용 코드

3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결

개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 Invalid src prop ... hostname is not configured under images 라는 빨간색 에러 화면을 만났습니다.

Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.

JavaScript
 
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;

4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!

수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.

  • 이미지 포맷 변화: 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.
  • 용량 감축: 3.8MB ➔ 320KB (약 90% 용량 감소)
  • Lighthouse 점수: 62점 ➔ 94점으로 폭등!

Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면

5. 회고 및 느낀 점

이전에는 "이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?" 하고 안일하게 생각했었습니다.

하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, 작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지 뼈저리게 체감할 수 있었습니다.

앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!

리액트(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