Next.js 13 버전 이상(App Router)을 도입해 개발을 시작하면 가장 먼저 마주치는 거대한 벽이 있습니다. 바로 컴포넌트 파일 맨 상단에 습관처럼 붙이게 되는 'use client'; 선언입니다.

저 역시 초기에는 컴포넌트마다 에러(예: useState can not be used in Server Component)가 뜰 때마다 귀찮다는 이유로 파일마다 상단에 무지성으로 'use client';를 붙여가며 개발했습니다.

하지만 나중에 프로젝트 번들 사이즈(Bundle Size)를 측정해 보니, 서버에서 가볍게 처리되어야 할 정적 UI들까지 전부 클라이언트(브라우저)로 자바스크립트 코드가 내려가면서 초기 로딩 속도가 뚝 떨어지는 심각한 부작용을 마주쳤습니다.

오늘은 서버 컴포넌트(RSC)와 클라이언트 컴포넌트(RCC)의 근본적인 차이점과, 실무에서 어떤 기준으로 분리해야 하는지 명확한 가이드를 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 모든 컴포넌트에 'use client'를 붙이면 안 될까?"

Next.js App Router의 기본 원칙은 모든 컴포넌트가 기본적으로 서버 컴포넌트(RSC)라는 점입니다.

  • 서버 컴포넌트 (RSC): HTML 렌더링과 데이터베이스(DB) 조회를 서버에서 끝내고, 가벼운 HTML 결과물만 브라우저로 보냅니다. (자바스크립트 번들 사이즈 0KB!)
  • 클라이언트 컴포넌트 (RCC): 'use client';를 선언하는 순간, 해당 컴포넌트와 그 하위에 딸린 모든 자식 코드가 브라우저로 다운로드되어 실행됩니다.

만약 페이지 전체를 감싸는 최상단 부모나 단순 마크업 컴포넌트에 무심코 클라이언트 선언을 해버리면, 서버의 강력한 성능 이점을 전혀 살리지 못하고 브라우저 과부하를 유발하게 됩니다.

2. 언제 '서버 컴포넌트(RSC)'를 써야 할까? (기본값)

대부분의 일반적인 컴포넌트는 서버 컴포넌트로 두는 것이 가장 이상적입니다.

  • 가장 적합한 곳: 백엔드 API나 DB에서 데이터를 직접 가져와야 하는 경우, 민감한 시크릿 키(API Key)를 다뤄야 하는 경우, 무거운 서드파티 라이브러리를 서버에서 미리 렌더링하고 싶을 때.
TypeScript
 
// ⭕ 서버 컴포넌트 예시 (async/await로 서버에서 직접 데이터 페칭 가능)
// 'use client' 선언이 없음 = 기본 서버 컴포넌트
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  // 서버 내부에서 직접 안전하게 DB 또는 API 호출
  const res = await fetch(`https://api.example.com/products/${params.id}`);
  const product = await res.json();

  return (
    <div className="p-6">
      <h1 className="text-xl font-bold">{product.title}</h1>
      <p className="text-slate-400">{product.description}</p>
    </div>
  );
}
 

클라이언트 브라우저가 자바스크립트를 다운로드하고 실행할 필요 없이, 서버가 이미 완성된 HTML을 보내주므로 SEO 최적화와 초기 로딩 속도(FCP)가 극적으로 빨라집니다.

'use client' 선언이 없고 async/await로 데이터를 바로 가져오는 서버 컴포넌트 구조

3. 언제 '클라이언트 컴포넌트(RCC)'를 써야 할까?

브라우저의 동적 기능이나 사용자 인터랙션이 반드시 필요한 순간에만 제한적으로 'use client';를 붙여야 합니다.

  • 가장 적합한 곳: useState, useEffect, useContext 등의 훅을 써야 할 때, onClick, onChange 같은 브라우저 이벤트 리스너가 필요할 때, 브라우저 전용 API(localStorage, window)를 쓸 때.
TypeScript
 
'use client'; // 👈 브라우저 상호작용이 필요할 때만 명시!

import { useState } from 'react';

export default function CounterButton() {
  const [count, setCount] = useState<number>(0);

  return (
    <button
      onClick={() => setCount((prev) => prev + 1)}
      className="px-4 py-2 bg-indigo-600 text-white rounded-xl"
    >
      클릭 횟수: {count}
    </button>
  );
}

4. 실무 아키텍처 패턴: "서버 컴포넌트 안에 클라이언트 컴포넌트 배치하기"

실무에서 가장 권장되는 패턴은 큰 틀(뼈대와 데이터 페칭)은 서버 컴포넌트로 만들고, 클릭 이벤트가 발생하는 아주 작은 버튼 조각만 클라이언트 컴포넌트로 분리하는 것입니다.

TypeScript
 
// 1. 부모 페이지 (서버 컴포넌트 - 데이터 로딩 담당)
import CounterButton from './CounterButton';

export default async function Page() {
  const data = await fetch('...').then((res) => res.json());

  return (
    <div>
      <h1>{data.title}</h1>
      {/* 2. 인터랙션이 필요한 작은 조각만 클라이언트 컴포넌트로 주입 */}
      <CounterButton initialCount={data.count} />
    </div>
  );
}

이렇게 하면 전체 페이지의 90%는 서버에서 가볍게 렌더링되고, 오직 버튼 컴포넌트만 클라이언트에서 작동하여 번들 크기를 최소화할 수 있습니다.

서버 데이터와 클라이언트 인터랙션 버튼이 함께 동작

5. 결론 및 느낀 점

Next.js App Router를 다룰 때 'use client';는 마법의 열쇠가 아니라 성능을 깎아먹을 수 있는 양날의 검입니다.

  1. 데이터 조회와 정적 마크업은 기본 서버 컴포넌트(RSC)로 유지할 것
  2. 상태(State)나 이벤트(Event)가 필요한 최소한의 하위 조각에만 클라이언트 컴포넌트(RCC)를 적용할 것

이 원칙을 지키는 것만으로도 웹 사이트의 번들 사이즈를 대폭 줄이고 구글 성능 점수를 크게 끌어올릴 수 있습니다!

프론트엔드 개발을 하며 검색창 자동완성이나 탭 전환 메뉴, 좋아요 버튼 같은 기능을 만들다 보면 사용자가 버튼을 마구 연타하거나 키보드를 빠르게 칠 때가 있습니다.

과거 프로젝트에서 사용자가 탭 버튼을 'A ➔ B ➔ C'로 빠르게 연속 클릭했을 때 황당한 버그를 마주쳤습니다. 분명 사용자는 마지막에 'C 탭'을 눌렀는데, 네트워크 응답 지연으로 인해 가장 늦게 도착한 'A 탭'의 데이터가 화면을 덮어씌워 버리는 '경쟁 상태(Race Condition)'가 발생한 것입니다.

이로 인해 화면에는 잘못된 데이터가 표시되었고, 서버에는 이미 쓸모없어진 이전 탭들의 불필요한 API 요청이 그대로 쌓여 리소스를 낭비하고 있었습니다.

오늘은 브라우저의 표준 내장 API인 AbortController를 활용하여 이전 비동기 요청을 깔끔하게 취소하고 경쟁 상태를 방지했던 노하우를 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 마지막으로 클릭한 결과가 안 나올까?" (경쟁 상태)

네트워크 요청은 항상 보낸 순서대로 도착한다는 보장이 없습니다.

  • 1번 요청 (A 탭 클릭, 응답 시간 1.5초)
  • 2번 요청 (B 탭 클릭, 응답 시간 0.2초)

사용자가 A를 누르고 곧바로 B를 눌렀을 때, B의 응답이 먼저 화면에 반영되었다가 1초 뒤 뒤늦게 도착한 A의 응답이 화면을 덮어버리는 끔찍한 UX 참사가 발생합니다.

이 문제를 해결하려면 "새로운 요청이 발생하면, 아직 끝나지 않은 이전 요청은 브라우저 차원에서 즉시 취소(Abort)"해야 합니다.

2. 해결책: 브라우저 표준 AbortController란?

AbortController는 웹 브라우저에 내장된 객체로, 하나 이상의 웹 요청(예: fetch API)을 원하는 시점에 중단할 수 있는 신호(Signal)를 제공합니다.

  • controller.signal: 통신 함수(fetch)에 전달하여 취소 신호를 수신할 수 있게 연결합니다.
  • controller.abort(): 이 메서드를 호출하면 연결된 네트워크 요청이 브라우저 수준에서 즉시 취소됩니다.

3. 실무 적용 1: fetch에 취소 기능 적용하기

새로운 요청을 시작하기 직전, 이전 컨트롤러의 abort()를 실행해 이전 통신을 취소하도록 작성했습니다.

TypeScript
 
// ⭕ AbortController를 활용한 안전한 API 호출 함수
let previousController: AbortController | null = null;

async function fetchSearchResults(query: string) {
  // 1. 이미 진행 중인 이전 요청이 있다면 취소 신호 발송!
  if (previousController) {
    previousController.abort();
  }

  // 2. 새로운 요청을 위한 컨트롤러 생성
  previousController = new AbortController();
  const { signal } = previousController;

  try {
    const response = await fetch(`https://api.example.com/search?q=${query}`, {
      signal, // 👈 fetch 두 번째 옵션에 signal 전달!
    });
    const data = await response.json();
    return data;
  } catch (error: any) {
    // 취소로 인한 중단인 경우 에러 처리에서 안전하게 무시
    if (error.name === 'AbortError') {
      console.log('이전 요청이 안전하게 취소되었습니다.');
    } else {
      console.error('실제 네트워크 에러 발생:', error);
    }
  }
}

이렇게 하면 사용자가 검색어를 빠르게 연속 입력하더라도, 마지막 입력값에 대한 응답만 정상 처리되고 앞선 요청들은 네트워크 레벨에서 모두 취소됩니다.

new AbortController() 및 fetch(url, { signal }) 코드

4. 실무 적용 2: React useEffect 클린업(Clean-up)에서 요청 취소하기

React 컴포넌트 안에서 데이터를 불러올 때는 useEffect의 뒷정리(Clean-up) 함수에서 controller.abort()를 호출하는 것이 정석 패턴입니다.

컴포넌트가 언마운트되거나 의존성 변수가 변경될 때 이전 요청을 자동으로 끊어주어 메모리 누수(Memory Leak)를 완벽히 막아줍니다.

TypeScript
 
import { useState, useEffect } from 'react';

export default function UserDetail({ userId }: { userId: number }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then((data) => setData(data))
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    // 💡 컴포넌트가 사라지거나 userId가 바뀌면 이전 요청 즉시 취소!
    return () => {
      controller.abort();
    };
  }, [userId]);

  return
{data ? JSON.stringify(data) : '로딩 중...'}
;
}

연속 요청시 이전 요청이 취소됨

5. 결론 및 느낀 점

이전에는 비동기 통신 에러가 나면 무조건 백엔드나 네트워크 망 문제라고만 생각했습니다.

하지만 프론트엔드 단에서 AbortController를 통해 불필요한 요청을 선제적으로 취소해 주는 것만으로도, 예기치 않은 버그(경쟁 상태)를 원천 차단하고 서버 트래픽 비용을 획기적으로 줄일 수 있었습니다.

검색창 실시간 자동완성이나 탭 전환 컴포넌트를 만들고 계신다면, 지금 바로 AbortController를 도입해 안전하고 매끄러운 통신 환경을 구축해 보세요!

웹 사이트를 개발하다가 사용자가 실수로 브라우저 새로고침(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로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!

프론트엔드 개발을 처음 배우고 백엔드 API와 통신을 연결하던 날, 데이터가 화면에 멋지게 뜰 것이라는 기대와 달리 브라우저 콘솔창에는 시뻘건 에러 메시지가 떴습니다.

Access to fetch at 'http://localhost:5000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

분명 포스트맨(Postman)으로 테스트했을 때는 데이터가 아주 잘 넘어왔는데, 브라우저에서 fetch나 axios로 요청만 날리면 무조건 에러가 터지는 기이한 현상에 밤을 새워가며 삽질했던 기억이 납니다.

오늘은 초보 개발자들의 멘탈을 흔드는 CORS(Cross-Origin Resource Sharing)의 진짜 동작 원리와, 실무 및 개발 환경에서 이를 깔끔하게 해결하는 3가지 방법을 정리해 봅니다.

1. 내가 마주친 문제: "Postman에서는 되는데 왜 브라우저에서만 안 될까?"

당시 React 개발 서버는 http://localhost:3000에서 돌고 있었고, 백엔드 Expres

s 서버는 http://localhost:5000에서 돌고 있었습니다.

  • Postman 요청 시: 상태 코드 200 OK와 함께 유저 데이터가 정상 수신됨.
  • 리액트 앱에서 요청 시: 네트워크 요청 실패 및 빨간색 CORS 에러 발생.

💡 CORS는 '서버의 에러'가 아니라 '브라우저의 보안 정책'이다!

많은 분들이 CORS 에러가 뜨면 백엔드 서버가 다운되었거나 잘못 만들어졌다고 오해합니다. 하지만 CORS 에러를 띄우는 주체는 '웹 브라우저'입니다.

웹 브라우저는 보안상의 이유로 SOP(Same-Origin Policy, 동일 출처 정책)를 따릅니다. 즉, 현재 웹 페이지가 열려 있는 출처(Origin = 프로토콜 + 도메인 + 포트번호)와 다른 출처로 데이터를 요청할 경우, 서버가 명시적으로 *"이 출처의 요청을 허용한다"*는 응답 헤더를 내려주지 않으면 브라우저가 응답을 차단해 버리는 것입니다.

2. 해결 방법 1: 백엔드 서버에서 CORS 헤더 허용하기 (가장 정석)

가장 기본적이고 정석적인 해결법은 백엔드 서버의 응답 헤더에 Access-Control-Allow-Origin을 추가해 주는 것입니다.

Node.js(Express) 환경이라면 cors 미들웨어를 설치해 간단히 해결할 수 있습니다.

JavaScript
 
// Express 백엔드 서버 설정 (server.js)
const express = require('express');
const cors = require('cors');
const app = express();

// ⭕ 특정 프론트엔드 출처(Origin)만 안전하게 허용
app.use(
  cors({
    origin: 'http://localhost:3000', // 프론트엔드 주소
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    credentials: true, // 쿠키/인증 헤더 포함 허용
  })
);

app.get('/api/users', (req, res) => {
  res.json({ message: 'CORS 설정 성공!' });
});

app.listen(5000, () => console.log('백엔드 서버 가동 (Port 5000)'));

3. 해결 방법 2: 프론트엔드 개발 서버 프록시(Proxy) 설정하기

백엔드 코드를 당장 수정할 권한이 없거나, 외부 공공 API를 로컬에서 테스트해야 할 때는 프론트엔드 개발 서버의 프록시(Proxy) 기능을 활용하는 것이 최고입니다.

브라우저는 같은 출처인 localhost:3000/api로 요청을 보내고, 프론트엔드 개발 서버(Node.js)가 대신 백엔드로 요청을 전달하여 받아오기 때문에 브라우저의 CORS 검열을 완벽히 우회할 수 있습니다.

JSON
 
// package.json (Create React App 기준)
{
  "name": "my-frontend-app",
  "version": "0.1.0",
  "proxy": "http://localhost:5000"
}

또는 Vite를 사용 중이라면 vite.config.ts 파일에서 아래와 같이 깔끔하게 설정할 수 있습니다.

TypeScript
 
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
});

4. 해결 방법 3: Next.js API Routes (BFF 패턴) 활용하기

Next.js를 사용하는 풀스택/프론트엔드 환경이라면 프레임워크 자체의 API 라우트를 일종의 중간 서버(BFF, Backend For Frontend)로 사용할 수 있습니다.

브라우저 ➔ Next.js 서버(서버 사이드) ➔ 외부 백엔드 API 순서로 통신하면, 서버 간 통신에는 브라우저의 CORS 제약이 적용되지 않으므로 아주 안전하게 데이터를 가져올 수 있습니다.

TypeScript
 
// app/api/proxy-user/route.ts (Next.js App Router API Route)
import { NextResponse } from 'next/server';

export async function GET() {
  // 서버 사이드에서 외부 API 호출 (CORS 제약 없음)
  const response = await fetch('http://localhost:5000/api/users');
  const data = await response.json();

  return NextResponse.json(data);
}

200 OK 성공 응답 화면


5. 결론 및 느낀 점

CORS 에러는 처음에 만나면 당황스럽고 막막하지만, "브라우저가 사용자를 악의적인 크로스 사이트 요청(CSRF/XSS 등)으로부터 보호하기 위해 작동하는 정상적인 보안 방패"라는 점을 이해하면 대처 방법이 명확해집니다.

  • 개발 환경에서는 프론트엔드 Proxy 설정으로 빠르게 개발하고,
  • 배포 환경에서는 백엔드 서버의 CORS 미들웨어 및 Nginx 리버스 프록시로 명확한 도메인을 허용해 주는 것이 가장 이상적입니다.

이제 브라우저 콘솔창에 빨간색 CORS 에러가 떠도 당황하지 마시고, 프록시 설정이나 서버 헤더부터 침착하게 점검해 보세요!

리액트(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. 의존성 배열에 참조 타입(객체, 배열, 함수)을 넣을 때는 참조값 변경에 유의할 것

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

+ Recent posts