[아이티센 부트캠프] React 5

이언덕·2026년 5월 27일

아이티센 부트캠프

목록 보기
110/115
post-thumbnail

1. App3.jsx에서 useMemo와 React Router Query & Params 흐름 이해하기

App3.jsx는 상품 목록 화면을 만들면서 useMemo, NavLink, useSearchParams, Link, useParams를 함께 사용하는 예제이다.
이 구간에서는 먼저 useMemo로 필요한 순간에만 계산하는 흐름을 이해한다.
그다음 NavLink로 현재 페이지에 해당하는 메뉴를 표시하는 흐름을 이해한다.
마지막으로 상품 목록에서 상세 페이지로 이동하고, URL에 들어 있는 값을 꺼내 상세 화면을 구성하는 흐름까지 연결해서 본다.


여기서 Query와 Params는 둘 다 URL에 들어 있는 값이지만 쓰임이 다르다.
Query는 /products?keyword=api&category=backend처럼 물음표 뒤에 붙는 검색 조건이다.
주로 검색어, 카테고리, 탭처럼 화면 안의 조건을 표현할 때 사용한다.
Params는 /products/3처럼 주소 경로 안에 들어가는 값이다.
주로 상품 번호, 게시글 번호처럼 어떤 데이터를 보여줄지 결정하는 고유한 값을 표현할 때 사용한다.


이 예제에서 중요한 점은 기능이 따로따로 떨어져 있지 않다는 것이다.
NavLink는 현재 화면 위치를 메뉴에 표시한다.
useSearchParams는 검색어와 카테고리 조건을 URL에 저장한다.
useMemo는 그 조건을 기준으로 상품 목록을 다시 계산한다.
Link는 상품 상세 주소로 이동하는 링크를 만든다.
useParams는 상세 주소에 들어 있는 상품 번호를 꺼낸다.


즉, 이 예제의 핵심은 주소에 담긴 값을 기준으로 현재 화면과 데이터를 결정하는 흐름이다.
React Router는 화면 이동을 담당하고, useMemo는 현재 조건에 맞는 상품 목록 계산을 효율적으로 처리한다.


useMemo가 필요한 이유

컴포넌트가 다시 실행되면 계산도 다시 실행된다

React 컴포넌트는 state가 바뀌면 함수 전체가 다시 실행된다.
이 과정을 리렌더링이라고 한다.
리렌더링은 화면을 최신 상태로 다시 그리기 위해 필요한 과정이다.


문제는 컴포넌트 함수 안에 시간이 오래 걸리는 계산 로직이 있을 때 생긴다.
함수 전체가 다시 실행되면 그 안에 있는 계산 코드도 다시 실행된다.
상품 목록에서 검색 조건에 맞는 상품만 걸러내는 작업도 계산에 해당한다.


검색어와 카테고리가 실제 검색 조건으로 바뀌면 상품 목록을 다시 계산하는 것이 맞다.
조건이 바뀌었기 때문에 화면에 보여줄 상품도 달라질 수 있기 때문이다.
하지만 검색 조건과 직접 관련 없는 값 때문에 컴포넌트가 다시 실행되었는데도 매번 상품 목록을 다시 계산한다면 비효율적이다.


예를 들어 App3.jsx에서는 검색창에 입력 중인 값인 inputKeyword와 실제 URL에 반영된 검색 조건인 keyword가 구분된다.
사용자가 검색창에 글자를 입력하면 inputKeyword는 바뀌지만, 검색 버튼을 누르기 전까지 keyword는 아직 바뀌지 않는다.
이때 상품 목록 필터링 기준은 그대로인데도 컴포넌트는 다시 실행될 수 있다.


이런 상황에서 useMemo가 있으면 keyword와 category가 그대로일 때 이전 필터링 결과를 재사용할 수 있다.
즉, useMemo는 조건이 실제로 바뀐 경우에는 다시 계산하고, 조건이 그대로인 리렌더링에서는 이전 계산 결과를 재사용하게 해 준다.


이런 무거운 계산은 heavyCalculation(products)처럼 생각할 수 있다.
heavyCalculation은 시간이 오래 걸리는 복잡한 계산을 의미한다.
products가 그대로인데도 다른 값이 바뀔 때마다 heavyCalculation(products)가 계속 실행되면 같은 결과를 얻기 위해 같은 계산을 반복하는 셈이다.


이때 useMemo를 사용하면 계산 결과를 기억해 둘 수 있다.
계산에 영향을 주는 값이 바뀌지 않았다면 이전 결과를 다시 사용한다.
계산에 영향을 주는 값이 바뀌었을 때만 다시 계산한다.
useMemo는 계산을 없애는 문법이 아니라, 같은 조건에서 같은 계산을 반복하지 않도록 도와주는 훅이다.


useMemo는 계산 결과를 기억한다

useMemo에서 중요한 점은 함수 자체를 기억하는 것이 아니라 함수를 실행해서 나온 결과값을 기억한다는 것이다.
예를 들어 상품 목록을 필터링한 결과가 있으면, 그 결과 배열을 기억해 둔다.
그리고 다시 렌더링될 때 조건이 그대로라면 같은 필터링을 반복하지 않고 이전 배열을 재사용한다.


기본 흐름은 아래처럼 이해하면 된다.

// App3UseMemoConceptExample.jsx
const expensiveValue = useMemo(() => {
  // products가 바뀔 때만 무거운 계산을 다시 실행한다.
  return heavyCalculation(products);
}, [products]); // products가 바뀔 때만 다시 계산한다.

useMemo의 첫 번째 인자에는 계산을 실행하는 함수가 들어간다.
이 함수 안에서 heavyCalculation(products)가 실행되고, 그 결과가 expensiveValue에 저장된다.


두 번째 인자인 [products]는 의존성 배열이다.
의존성 배열은 이 계산이 어떤 값에 영향을 받는지 적어 두는 목록이다.
위 코드에서는 products가 바뀔 때만 계산을 다시 하겠다는 뜻이다.


정리하면 아래와 같다.

  • products가 그대로다.
  • 이전에 계산한 expensiveValue를 다시 사용한다.
  • products가 바뀐다.
  • heavyCalculation(products)를 다시 실행한다.

이것이 “필요할 때만 계산한다”는 말의 의미이다.


Link는 이동만 하고 NavLink는 현재 위치를 표시한다

Link와 NavLink는 둘 다 페이지 이동에 사용하는 React Router 컴포넌트이다.
하지만 역할이 완전히 같지는 않다.


Link는 단순히 다른 주소로 이동하는 역할을 한다.
사용자가 클릭하면 지정한 주소로 이동한다.
현재 사용자가 그 페이지에 있는지까지 표시해 주지는 않는다.


반면 NavLink는 이동 기능에 더해 현재 주소와 자신의 to 주소가 일치하는지 확인할 수 있다.
현재 주소와 일치하면 자동으로 active 상태가 된다.
이 active 상태를 이용하면 현재 선택된 메뉴에 굵은 글씨, 밑줄, 색상 변경 같은 스타일을 줄 수 있다.


NavLink는 사용자가 현재 어느 페이지에 있는지 메뉴에서 바로 알 수 있게 해 주는 링크 컴포넌트이다.
그래서 상단 메뉴인 GNB나 사이드바 메뉴처럼 현재 위치 표시가 필요한 곳에서 자주 사용한다.


NavLink는 React 코드에서 사용하는 컴포넌트이다.
하지만 브라우저에 렌더링될 때는 실제 HTML의 a 태그로 변환된다.
현재 경로와 일치하면 해당 태그에 active 클래스가 자동으로 붙는다.


그래서 CSS에서 .nav a.active처럼 작성하면 현재 메뉴만 다르게 꾸밀 수 있다.
예를 들어 현재 메뉴를 굵게 만들거나, 글자색을 바꾸거나, 밑줄을 표시할 수 있다.

// App3.jsx
<nav className="nav">
  {/* 현재 주소가 / 이면 홈 메뉴가 활성화된다. */}
  <NavLink to="/">홈</NavLink>

  {/* 현재 주소가 /products 이면 상품 목록 메뉴가 활성화된다. */}
  <NavLink to="/products">상품 목록</NavLink>
</nav>

위 코드에서 사용자가 /products 주소에 있다면 상품 목록 메뉴가 현재 페이지와 일치한다.
그러면 해당 NavLink에 active 클래스가 붙는다.
CSS에서 active 클래스에 스타일을 지정해 두면 현재 메뉴가 눈에 띄게 표시된다.


여기서 end 속성도 중요하다.
/ 경로는 다른 주소와 겹치기 쉽다.
예를 들어 /products도 /로 시작한다.
그래서 홈 메뉴처럼 짧은 주소를 사용할 때는 NavLink to="/" end처럼 작성하면 정확히 /일 때만 활성화되도록 제한할 수 있다.


NavLink는 isActive와 isPending 값도 사용할 수 있다.
isActive는 현재 브라우저 주소가 to 주소와 일치할 때 true가 되는 값이다.
이 값을 이용하면 현재 메뉴일 때만 글자색을 바꾸거나 굵게 표시할 수 있다.
isPending은 페이지가 전환되는 도중에 잠시 true가 될 수 있는 값이다.
데이터를 불러오거나 화면 이동이 진행 중일 때 로딩 중 스타일을 보여주는 데 사용할 수 있다.


정리하면 아래와 같다.

  • active 클래스는 현재 주소와 링크 주소가 일치할 때 자동으로 붙는 클래스이다.
  • end는 /처럼 짧은 주소가 하위 경로와 겹치지 않게 정확히 일치할 때만 활성화하도록 제한한다.
  • isActive는 현재 주소와 일치하는지 직접 판단해서 스타일을 조건부로 줄 때 사용한다.
  • isPending은 페이지 전환 중 상태를 표시할 때 사용할 수 있다.

NavLink의 핵심은 단순 이동이 아니라 현재 위치를 기준으로 메뉴 상태를 다르게 보여주는 것이다.


App3.jsx 전체 코드 구조 확인

전체 코드에서 봐야 하는 흐름

App3.jsx 전체 코드는 상품 목록 화면과 상품 상세 화면을 함께 구성한다.
코드를 한 번에 보면 길어 보이지만, 역할을 나누면 흐름이 단순해진다.


먼저 products 배열에는 상품 데이터가 들어 있다.
App3 컴포넌트는 전체 라우트 구조를 만든다.
ProductList는 상품 목록과 검색 조건을 처리한다.
ProductDetail은 URL에 들어 있는 상품 번호를 꺼내 상세 화면을 보여준다.
NotFound는 존재하지 않는 주소나 잘못된 상품 번호를 처리한다.

// App3.jsx
import { useMemo, useState } from "react";
import {
  Routes,
  Route,
  Link,
  NavLink,
  useParams,
  useSearchParams,
} from "react-router";

// 상품 목록에서 사용할 기본 데이터이다.
const products = [
  {
    id: 1,
    name: "React 입문 강의",
    category: "frontend",
    price: 30000,
    description: "컴포넌트, props, state를 학습하는 React 기초 강의입니다.",
    review: "초보자가 React의 기본 구조를 이해하기 좋습니다.",
  },
  {
    id: 2,
    name: "React Router 실습",
    category: "frontend",
    price: 35000,
    description: "React Router를 사용해서 SPA 라우팅을 학습합니다.",
    review: "useParams와 useSearchParams를 연습하기 좋습니다.",
  },
  {
    id: 3,
    name: "Spring Boot REST API",
    category: "backend",
    price: 40000,
    description: "Spring Boot로 REST API 서버를 구현하는 강의입니다.",
    review: "React와 fetch 연동 실습에 적합합니다.",
  },
  {
    id: 4,
    name: "MySQL 데이터베이스",
    category: "database",
    price: 25000,
    description: "테이블, SQL, JOIN을 학습하는 데이터베이스 강의입니다.",
    review: "백엔드 기초를 다지기에 좋습니다.",
  },
];

// 전체 라우트 구조를 만드는 최상위 컴포넌트이다.
function App3() {
  return (
    <div className="container">
      <h2>React Router Query & Params 예제</h2>

      {/* 현재 주소와 일치하는 메뉴에 active 상태를 적용한다. */}
      <nav className="nav">
        <NavLink to="/">홈</NavLink>
        <NavLink to="/products">상품 목록</NavLink>
      </nav>

      {/* URL 주소에 따라 보여줄 컴포넌트를 결정한다. */}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<ProductList />} />
        <Route path="/products/:productId" element={<ProductDetail />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

// 홈 화면 컴포넌트이다.
function Home() {
  return (
    <section className="card">
      <h2>홈</h2>
      <p>상품 목록에서 검색 조건과 상세 페이지 이동을 테스트해 보세요.</p>
    </section>
  );
}

// 상품 목록, 검색, 카테고리 필터링을 처리하는 컴포넌트이다.
function ProductList() {
  // URL의 query string을 읽고 수정한다.
  const [searchParams, setSearchParams] = useSearchParams();

  // URL에서 검색어와 카테고리를 꺼낸다.
  const keyword = searchParams.get("keyword") ?? "";
  const category = searchParams.get("category") ?? "all";

  // 검색창에 입력 중인 값을 저장한다.
  const [inputKeyword, setInputKeyword] = useState(keyword);

  // keyword 또는 category가 바뀔 때만 상품 목록을 다시 계산한다.
  const filteredProducts = useMemo(() => {
    return products.filter((product) => {
      // 상품 이름에 검색어가 포함되는지 확인한다.
      const matchedKeyword = product.name
        .toLowerCase()
        .includes(keyword.toLowerCase());

      // 전체 카테고리이거나 선택한 카테고리와 상품 카테고리가 같으면 통과한다.
      const matchedCategory =
        category === "all" || product.category === category;

      // 검색어 조건과 카테고리 조건을 모두 만족한 상품만 남긴다.
      return matchedKeyword && matchedCategory;
    });
  }, [keyword, category]);

  // 검색 버튼을 눌렀을 때 URL의 keyword 값을 변경한다.
  const search = () => {
    const params = {};

    // 입력값이 있으면 keyword 조건으로 추가한다.
    if (inputKeyword.trim() !== "") {
      params.keyword = inputKeyword.trim();
    }

    // 전체 카테고리가 아니면 현재 category 조건을 유지한다.
    if (category !== "all") {
      params.category = category;
    }

    // 검색 조건 객체를 URL query string으로 반영한다.
    setSearchParams(params);
  };

  // 카테고리 버튼을 눌렀을 때 URL의 category 값을 변경한다.
  const changeCategory = (nextCategory) => {
    const params = {};

    // 기존 검색어가 있으면 유지한다.
    if (keyword.trim() !== "") {
      params.keyword = keyword;
    }

    // 전체가 아닌 카테고리만 URL에 저장한다.
    if (nextCategory !== "all") {
      params.category = nextCategory;
    }

    // 변경된 카테고리 조건을 URL에 반영한다.
    setSearchParams(params);
  };

  // Enter 키를 누르면 검색 버튼을 누른 것과 같은 동작을 한다.
  const handleKeyDown = (e) => {
    if (e.key === "Enter") {
      search();
    }
  };

  return (
    <section>
      <h2>상품 목록</h2>

      {/* 검색어 입력 영역이다. */}
      <div className="searchBox">
        <input
          className="input"
          value={inputKeyword}
          onChange={(e) => setInputKeyword(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="상품명 검색"
        />

        <button onClick={search}>검색</button>
      </div>

      {/* 카테고리 필터 버튼 영역이다. */}
      <div className="categoryBox">
        <button
          className={category === "all" ? "selected" : ""}
          onClick={() => changeCategory("all")}

          전체
        </button>
        <button
          className={category === "frontend" ? "selected" : ""}
          onClick={() => changeCategory("frontend")}

          frontend
        </button>
        <button
          className={category === "backend" ? "selected" : ""}
          onClick={() => changeCategory("backend")}

          backend
        </button>
        <button
          className={category === "database" ? "selected" : ""}
          onClick={() => changeCategory("database")}

          database
        </button>
      </div>

      {/* 현재 URL에 반영된 검색 조건을 화면에 보여준다. */}
      <p className="urlInfo">
        현재 검색 조건: keyword={keyword || "없음"}, category={category}
      </p>

      {/* 필터링된 상품 목록만 화면에 출력한다. */}
      <div className="list">
        {filteredProducts.map((product) => (
          <div className="card" key={product.id}>
            <h3>{product.name}</h3>
            <p>분류: {product.category}</p>
            <p>가격: {product.price.toLocaleString()}원</p>

            {/* 상품 id를 URL에 넣어 상세 페이지로 이동한다. */}
            <Link to={`/products/${product.id}?tab=info`}>
              상세 보기
            </Link>
          </div>
        ))}
      </div>

      {/* 조건에 맞는 상품이 없을 때 보여주는 문장이다. */}
      {filteredProducts.length === 0 && <p>검색 결과가 없습니다.</p>}
    </section>
  );
}

// 상품 상세 화면을 처리하는 컴포넌트이다.
function ProductDetail() {
  // URL 경로에서 productId 값을 꺼낸다.
  const { productId } = useParams();

  // URL의 query string에서 tab 값을 읽고 수정한다.
  const [searchParams, setSearchParams] = useSearchParams();

  // tab 값이 없으면 기본으로 상품 정보 탭을 보여준다.
  const tab = searchParams.get("tab") ?? "info";

  // URL에서 꺼낸 productId는 문자열이므로 숫자로 바꿔 비교한다.
  const product = products.find((item) => item.id === Number(productId));

  // productId와 일치하는 상품이 없으면 NotFound 화면을 보여준다.
  if (!product) {
    return <NotFound />;
  }

  return (
    <section className="card">
      <h2>상품 상세</h2>

      {/* URL에서 꺼낸 값을 화면에서 확인한다. */}
      <p>URL 파라미터 productId: {productId}</p>
      <p>Query String tab: {tab}</p>

      <hr />

      {/* 선택된 상품의 기본 정보를 출력한다. */}
      <h3>{product.name}</h3>
      <p>분류: {product.category}</p>
      <p>가격: {product.price.toLocaleString()}원</p>

      {/* tab 값을 바꾸는 버튼 영역이다. */}
      <div className="tabBox">
        <button
          className={tab === "info" ? "selected" : ""}
          onClick={() => setSearchParams({ tab: "info" })}

          상품 정보
        </button>

        <button
          className={tab === "review" ? "selected" : ""}
          onClick={() => setSearchParams({ tab: "review" })}

          리뷰
        </button>
      </div>

      {/* tab 값에 따라 다른 내용을 출력한다. */}
      {tab === "info" && <p>{product.description}</p>}
      {tab === "review" && <p>{product.review}</p>}

      <Link to="/products">목록으로</Link>
    </section>
  );
}

// 잘못된 주소나 존재하지 않는 상품을 처리하는 컴포넌트이다.
function NotFound() {
  return (
    <section className="card">
      <h2>404</h2>
      <p>요청한 페이지를 찾을 수 없습니다.</p>
      <Link to="/">홈으로</Link>
    </section>
  );
}

export default App3;

이 전체 코드는 크게 네 부분으로 나누어 보면 된다.
App3는 전체 라우트 구조를 만든다.
ProductList는 상품 검색과 카테고리 필터링을 처리한다.
ProductDetail은 상품 상세 화면과 탭 전환을 처리한다.
NotFound는 잘못된 주소나 잘못된 상품 번호를 처리한다.


이제 전체 코드를 한 번에 외우려고 하지 말고, 값이 바뀌는 흐름을 기준으로 부분 코드를 나누어 보면 된다.


ProductList에서 검색 조건을 URL로 관리하기

1단계: URL에서 keyword와 category를 꺼낸다

ProductList에서는 검색어와 카테고리를 화면 안의 값으로만 관리하지 않는다.
현재 주소에 붙은 query string에서 검색 조건을 꺼낸다.
query string은 주소 뒤에 붙는 조건 값이다.
예를 들어 /products?keyword=api&category=backend라는 주소가 있으면 keyword는 api, category는 backend가 된다.

// App3.jsx
const [searchParams, setSearchParams] = useSearchParams();

const keyword = searchParams.get("keyword") ?? "";
const category = searchParams.get("category") ?? "all";
const [inputKeyword, setInputKeyword] = useState(keyword);

searchParams.get("keyword")는 주소에서 검색어를 꺼낸다.
값이 없으면 null이 나올 수 있으므로 ?? ""로 빈 문자열을 기본값으로 둔다.
searchParams.get("category")는 주소에서 카테고리를 꺼낸다.
값이 없으면 전체 상품을 보여줘야 하므로 ?? "all"을 기본값으로 둔다.


여기서 inputKeyword는 검색창에 입력 중인 값이다.
반면 keyword는 실제 URL에 반영된 검색어이다.
입력창에 적힌 값과 실제 검색 조건은 다르다.
검색 버튼을 눌러 URL이 바뀌어야 실제 검색 조건인 keyword도 바뀐다.


2단계: useMemo로 조건에 맞는 상품만 계산한다

filteredProducts는 화면에 실제로 보여줄 상품 목록이다.
전체 상품 목록이 아니라, 현재 keyword와 category 조건을 통과한 상품만 담긴 배열이다.

// App3.jsx
const filteredProducts = useMemo(() => {
  // products 배열에서 조건을 통과한 상품만 남긴다.
  return products.filter((product) => {
    // 상품 이름에 검색어가 포함되는지 확인한다.
    const matchedKeyword = product.name
      .toLowerCase()
      .includes(keyword.toLowerCase());

    // 전체 카테고리이거나 선택한 카테고리와 상품 카테고리가 같으면 통과한다.
    const matchedCategory =
      category === "all" || product.category === category;

    // 검색어 조건과 카테고리 조건을 모두 만족한 상품만 남긴다.
    return matchedKeyword && matchedCategory;
  });
}, [keyword, category]); // keyword 또는 category가 바뀔 때만 다시 계산한다.

이 코드에서 products.filter()는 전체 상품 중 조건을 통과한 상품만 골라 새 배열을 만든다.
filter()는 원본 배열을 직접 바꾸지 않는다.
조건을 통과한 값만 모아서 새로운 배열을 반환한다.


matchedKeyword는 상품 이름에 검색어가 포함되는지 확인한다.
상품 이름과 검색어는 모두 toLowerCase()로 소문자로 바꾼 뒤 비교한다.
이렇게 하면 사용자가 API, api, Api처럼 입력해도 같은 검색 의도로 처리할 수 있다.


예를 들어 상품 이름이 Spring Boot REST API이고 검색어가 api라고 하자.
상품 이름을 소문자로 바꾸면 spring boot rest api가 된다.
검색어도 소문자로 바꾸면 api가 된다.
spring boot rest api 안에는 api가 들어 있으므로 matchedKeyword는 true가 된다.


matchedCategory는 선택한 카테고리와 상품 카테고리가 맞는지 확인한다.
category가 "all"이면 모든 상품을 보여준다.
category가 "frontend"이면 frontend 상품만 보여준다.
category가 "backend"이면 backend 상품만 보여준다.
category가 "database"이면 database 상품만 보여준다.


마지막의 return matchedKeyword && matchedCategory;가 중요하다.
&&는 두 조건이 모두 맞아야 true가 된다.
그래서 검색어 조건만 맞거나 카테고리 조건만 맞으면 상품이 화면에 나오지 않는다.
검색어와 카테고리 조건을 모두 만족해야 filteredProducts에 들어간다.


3단계: 검색 버튼을 누르면 URL의 keyword가 바뀐다

검색창에 값을 입력하는 것만으로는 실제 검색 조건이 바뀌지 않는다.
검색 버튼을 눌렀을 때 search() 함수가 실행되고, 이 함수가 URL의 keyword 값을 바꾼다.

// App3.jsx
const search = () => {
  // URL에 넣을 검색 조건 객체를 만든다.
  const params = {};

  // 입력값이 비어 있지 않으면 keyword 조건을 추가한다.
  if (inputKeyword.trim() !== "") {
    params.keyword = inputKeyword.trim();
  }

  // 전체 카테고리가 아니면 category 조건을 유지한다.
  if (category !== "all") {
    params.category = category;
  }

  // URL 검색 조건을 변경한다.
  setSearchParams(params);
};

params는 URL에 넣을 검색 조건을 모아 두는 객체이다.
검색어가 있으면 params.keyword에 검색어를 넣고, 전체 카테고리가 아니면 params.category에 카테고리를 넣는다.
그다음 setSearchParams(params)를 실행하면 이 객체가 URL의 query string으로 바뀐다.


예를 들어 params가 아래처럼 만들어졌다고 하자.

  • params.keyword = "api"
  • params.category = "backend"

그러면 주소는 /products?keyword=api&category=backend처럼 바뀐다.
즉, params는 화면 안에서만 쓰는 임시 값이 아니라, 주소에 남길 검색 조건을 정리해 둔 값이다.


예를 들어 검색창에 api를 입력하고 검색 버튼을 누르면 params.keyword에 api가 들어간다.
그다음 setSearchParams(params)가 실행되면서 주소가 /products?keyword=api처럼 바뀐다.


주소가 바뀌면 keyword 값도 바뀐다.
keyword는 useMemo의 의존성 배열에 들어 있으므로, keyword가 바뀌는 순간 filteredProducts도 다시 계산된다.


4단계: 카테고리 버튼을 누르면 URL의 category가 바뀐다

카테고리 버튼을 누르면 changeCategory() 함수가 실행된다.
이 함수는 기존 검색어가 있으면 유지하면서, 새로 선택한 카테고리를 URL에 반영한다.

// App3.jsx
const changeCategory = (nextCategory) => {
  // URL에 넣을 검색 조건 객체를 만든다.
  const params = {};

  // 기존 검색어가 있으면 keyword 조건을 유지한다.
  if (keyword.trim() !== "") {
    params.keyword = keyword;
  }

  // 전체 카테고리가 아니면 선택한 카테고리를 URL에 넣는다.
  if (nextCategory !== "all") {
    params.category = nextCategory;
  }

  // URL 검색 조건을 변경한다.
  setSearchParams(params);
};

changeCategory()에서도 params는 주소에 남길 검색 조건을 모아 두는 객체이다.
기존 검색어가 있으면 keyword를 유지하고, 새로 선택한 카테고리가 전체가 아니면 category를 추가한다.


예를 들어 현재 검색어가 api인 상태에서 backend 버튼을 누르면 주소가 /products?keyword=api&category=backend로 바뀐다.
이때 keyword는 유지되고, category만 새 값으로 바뀐다.


category도 useMemo의 의존성 배열에 들어 있다.
그래서 카테고리 버튼을 누르면 filteredProducts가 다시 계산된다.
검색어와 카테고리는 둘 다 상품 목록을 다시 계산하게 만드는 기준 값이다.


Link와 useParams로 상품 상세 화면 만들기

Link는 상품 상세 주소를 만든다

상품 목록에서 각 상품 카드에는 상세 보기 링크가 있다.
이 링크는 상품의 id를 주소에 넣어서 상세 페이지로 이동시킨다.

// App3.jsx
<Link to={`/products/${product.id}?tab=info`}>
  상세 보기
</Link>

예를 들어 Spring Boot REST API 상품의 id가 3이면 링크 주소는 /products/3?tab=info가 된다.
여기서 /products/3의 3은 상품 번호이고, tab=info는 상세 화면에서 상품 정보 탭을 보여주겠다는 조건이다.


이 구조에서 중요한 점은 상품 상세 페이지를 상품마다 따로 만들지 않는다는 것이다.
/products/:productId라는 하나의 라우트가 있고, productId 값만 바뀐다.
그래서 /products/1, /products/2, /products/3, /products/4를 하나의 상세 컴포넌트가 처리할 수 있다.


useParams는 URL의 productId를 꺼낸다

ProductDetail에서는 useParams()를 사용해서 주소에 들어 있는 productId를 꺼낸다.
useParams()는 동적 라우트에 들어온 값을 객체 형태로 반환한다.

// App3.jsx
const { productId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();

const tab = searchParams.get("tab") ?? "info";

const product = products.find((item) => item.id === Number(productId));

if (!product) {
  return <NotFound />;
}

/products/:productId에서 :productId는 바뀌는 값을 받을 자리이다.
실제 주소가 /products/3?tab=info라면 productId는 "3"이 된다.


여기서 주의할 점은 URL에서 꺼낸 값은 문자열이라는 것이다.
products 배열 안의 id는 숫자이다.
문자열 "3"과 숫자 3은 그대로 비교하면 같지 않다.
그래서 Number(productId)로 숫자로 바꾼 뒤 비교한다.


find()는 배열에서 조건에 맞는 값을 하나 찾는 메서드이다.
item.id === Number(productId) 조건을 만족하는 상품을 찾아서 product에 저장한다.
만약 일치하는 상품이 없으면 product는 비어 있는 값이 되므로 NotFound 화면을 보여준다.


tab query string으로 상세 내용을 바꾼다

상품 상세 화면에서는 tab 값에 따라 보여줄 내용이 바뀐다.
tab=info이면 상품 설명을 보여주고, tab=review이면 리뷰를 보여준다.

// App3.jsx
<div className="tabBox">
  <button
    className={tab === "info" ? "selected" : ""}
    onClick={() => setSearchParams({ tab: "info" })}

    상품 정보
  </button>

  <button
    className={tab === "review" ? "selected" : ""}
    onClick={() => setSearchParams({ tab: "review" })}

    리뷰
  </button>
</div>

{tab === "info" && <p>{product.description}</p>}
{tab === "review" && <p>{product.review}</p>}

tab도 useSearchParams()로 읽는다.
주소에 tab 값이 없으면 기본값으로 "info"를 사용한다.
그래서 처음 상세 페이지에 들어가면 상품 정보가 먼저 보인다.


상품 정보 버튼을 누르면 setSearchParams({ tab: "info" })가 실행된다.
주소의 tab 값이 info로 바뀌고 상품 설명이 출력된다.
리뷰 버튼을 누르면 setSearchParams({ tab: "review" })가 실행된다.
주소의 tab 값이 review로 바뀌고 리뷰 내용이 출력된다.


여기서 productId와 tab은 둘 다 URL에서 가져오지만 위치와 역할이 다르다.
productId는 /products/4처럼 경로 안에 들어 있는 값이다.
그래서 useParams()로 꺼낸다.
이 값은 어떤 상품을 보여줄지 결정한다.


반면 tab은 /products/4?tab=review처럼 물음표 뒤에 붙는 값이다.
그래서 useSearchParams()로 꺼낸다.
이 값은 이미 선택된 상품 안에서 어떤 내용을 보여줄지 결정한다.


쉽게 정리하면 아래와 같다.

  • productId는 어떤 상품을 볼지 결정한다.
  • tab은 그 상품 안에서 상품 정보를 볼지, 리뷰를 볼지 결정한다.

Params는 보여줄 대상을 고르고, Query String은 그 대상 안에서 화면 조건을 조절한다.


여기서 카테고리 버튼의 selected와 상세 탭 버튼의 selected는 현재 선택된 버튼을 표시하기 위한 클래스이다.
반면 NavLink의 active는 현재 페이지 메뉴를 표시하기 위한 클래스이다.
둘 다 화면에서는 선택된 것처럼 보일 수 있지만 의미는 다르다.


실행 화면으로 URL과 화면 변경 흐름 확인하기

상품 목록과 상품 상세 흐름

처음에는 / 주소에서 홈 화면이 열린다.
상단에는 홈과 상품 목록 메뉴가 있고, 홈 화면에는 상품 목록에서 검색 조건과 상세 페이지 이동을 테스트해 보라는 안내 문구가 표시된다.
이 상태에서 상품 목록 메뉴를 클릭하면 주소가 /products로 바뀌고 상품 목록 화면이 렌더링된다.
NavLink는 현재 주소가 /products인지 확인해 상품 목록 메뉴를 현재 위치에 해당하는 메뉴처럼 표시한다.


상품 목록 화면의 기본 상태에서는 검색창이 비어 있고, 현재 검색 조건이 keyword=없음, category=all로 표시된다.
category=all은 전체 카테고리를 의미하므로 React 입문 강의, React Router 실습, Spring Boot REST API, MySQL 데이터베이스가 모두 출력된다.
이때는 검색어 조건이 없고 전체 카테고리 조건이므로 모든 상품이 filteredProducts에 들어간다.


검색창에 api를 입력하고 검색 버튼을 누르면 주소가 /products?keyword=api로 바뀐다.
화면의 현재 검색 조건도 keyword=api, category=all로 바뀐다.
이때 useMemo는 변경된 keyword 값을 기준으로 상품 목록을 다시 계산한다.
상품 이름을 소문자로 바꿔 비교했을 때 Spring Boot REST API만 api를 포함하므로 화면에는 Spring Boot REST API 하나만 남는다.


카테고리 버튼을 누르면 주소의 category 값도 함께 바뀐다.
frontend 버튼을 누르면 /products?keyword=api&category=frontend로 바뀌고, frontend 상품 중 이름에 api가 들어간 상품을 찾는다.
조건을 만족하는 상품이 없기 때문에 검색 결과가 없습니다.가 출력된다.
backend 버튼을 누르면 /products?keyword=api&category=backend로 바뀌고, Spring Boot REST API가 조건을 통과해 다시 출력된다.
database 버튼을 누르면 /products?keyword=api&category=database로 바뀌지만, MySQL 데이터베이스에는 api가 포함되어 있지 않으므로 검색 결과가 없다고 표시된다.


목록에서 상품의 상세 보기를 누르면 주소가 /products/상품번호?tab=info 형태로 바뀐다.
예를 들어 MySQL 데이터베이스의 상세 보기를 누르면 상품 번호가 주소에 들어가고, 상세 화면에서는 useParams()로 그 번호를 꺼낸다.
꺼낸 productId를 숫자로 바꾼 뒤 products 배열에서 같은 id를 가진 상품을 찾는다.
그 결과 선택한 상품의 이름, 분류, 가격이 상세 화면에 출력된다.


상세 화면에는 상품 정보 버튼과 리뷰 버튼이 있다.
상품 정보 상태에서는 주소의 tab 값이 info이고, 상품 설명인 description이 출력된다.
리뷰 버튼을 누르면 주소의 tab 값이 review로 바뀌고, 상품 리뷰인 review가 출력된다.
다시 상품 정보 버튼을 누르면 tab=info로 바뀌면서 상품 설명이 다시 출력된다.


이 실행 결과에서 중요한 점은 화면의 상태가 단순히 컴포넌트 안에만 숨어 있지 않다는 것이다.
검색어는 keyword, 카테고리는 category, 상세 상품 번호는 productId, 상세 탭은 tab으로 URL에 드러난다.
그래서 현재 화면이 어떤 조건과 어떤 상품을 기준으로 만들어졌는지 주소만 보고도 확인할 수 있다.


전체 흐름을 값 기준으로 정리하기

상품 목록 흐름

상품 목록 화면의 흐름은 아래처럼 이어진다.

  • 사용자가 상품 목록 메뉴를 클릭한다.
  • 주소가 /products로 바뀐다.
  • NavLink가 상품 목록 메뉴를 현재 위치로 표시한다.
  • ProductList가 렌더링된다.
  • useSearchParams()가 URL에서 keyword와 category를 읽는다.
  • useMemo가 keyword와 category를 기준으로 filteredProducts를 계산한다.
  • 화면에는 filteredProducts에 들어 있는 상품만 출력된다.

예를 들어 검색어로 api를 입력하고 검색하면 흐름은 아래처럼 바뀐다.

  • inputKeyword = "api"
  • 검색 버튼 클릭
  • setSearchParams({ keyword: "api" })
  • 주소가 /products?keyword=api로 변경
  • keyword = "api"
  • useMemo가 다시 실행
  • Spring Boot REST API만 filteredProducts에 남음
  • 화면에는 Spring Boot REST API만 출력됨

이 상태에서 frontend 버튼을 누르면 아래처럼 흐름이 바뀐다.

  • 현재 keyword = "api"
  • frontend 버튼 클릭
  • setSearchParams({ keyword: "api", category: "frontend" })
  • 주소가 /products?keyword=api&category=frontend로 변경
  • category = "frontend"
  • useMemo가 다시 실행
  • frontend 상품 중 이름에 api가 들어간 상품을 찾음
  • 조건을 만족하는 상품이 없어서 검색 결과가 없습니다.가 출력됨

이렇게 나눠서 보면 NavLink, useSearchParams, useMemo의 역할이 분명해진다.
NavLink는 현재 페이지 메뉴를 표시한다.
useSearchParams는 검색 조건을 URL에서 읽고 바꾼다.
useMemo는 그 조건을 기준으로 상품 목록을 다시 계산한다.


상품 상세 흐름

상품 상세 화면의 흐름은 아래처럼 이어진다.

  • 사용자가 상품 카드의 상세 보기를 클릭한다.
  • Link가 /products/상품번호?tab=info 주소로 이동시킨다.
  • Routes가 /products/:productId 라우트와 주소를 매칭한다.
  • ProductDetail 컴포넌트가 렌더링된다.
  • useParams()가 주소에서 productId를 꺼낸다.
  • Number(productId)로 문자열 값을 숫자로 바꾼다.
  • products.find()로 같은 id를 가진 상품을 찾는다.
  • 찾은 상품의 이름, 분류, 가격, 설명 또는 리뷰를 출력한다.

예를 들어 /products/4?tab=info로 이동하면 흐름은 아래처럼 볼 수 있다.

  • productId = "4"
  • Number(productId) = 4
  • products 배열에서 id가 4인 상품을 찾음
  • product.name = "MySQL 데이터베이스"
  • tab = "info"
  • 화면에는 MySQL 데이터베이스의 상품 설명이 출력됨

이 상태에서 리뷰 버튼을 누르면 흐름은 아래처럼 바뀐다.

  • setSearchParams({ tab: "review" })
  • 주소가 /products/4?tab=review로 변경
  • tab = "review"
  • 화면에는 product.review 값이 출력됨

상품 상세 화면에서는 productId가 어떤 상품을 보여줄지 결정하고, tab이 그 상품의 어떤 내용을 보여줄지 결정한다.


핵심 정리

App3.jsx에서 각 기능이 맡는 역할

App3.jsx는 단순히 상품 목록을 보여주는 예제가 아니다.
React Router의 주소 관리 기능과 useMemo의 계산 최적화 흐름을 함께 보여주는 예제이다.


각 기능의 역할은 아래처럼 정리할 수 있다.

  • NavLink는 현재 페이지 메뉴를 표시한다.
  • useSearchParams는 검색 조건과 탭 상태를 URL에서 읽고 수정한다.
  • useMemo는 검색어와 카테고리 조건을 기준으로 상품 목록을 필요한 순간에만 다시 계산한다.
  • Link는 상품 상세 페이지로 이동하는 주소를 만든다.
  • useParams는 상세 주소에 들어 있는 상품 번호를 꺼낸다.
  • find()는 상품 번호와 같은 id를 가진 상품 데이터를 찾는다.

ProductList에서는 keyword와 category가 상품 목록을 결정한다.
ProductDetail에서는 productId와 tab이 상세 화면 내용을 결정한다.


이 예제의 핵심은 URL에 담긴 값을 기준으로 화면과 데이터를 연결하는 것이다.
URL이 바뀌면 읽어 오는 값이 바뀐다.
읽어 오는 값이 바뀌면 계산 결과와 화면도 바뀐다.
그래서 App3.jsx는 React Router의 Query와 Params 흐름을 한 번에 이해하기 좋은 예제이다.




2. App4.jsx에서 useNavigate와 Outlet으로 게시판 라우터 흐름 이해하기

App4.jsx는 게시판 화면을 만들면서 useNavigate, Outlet, useParams, useSearchParams, useMemo, NavLink를 함께 사용하는 예제이다.
이 구간에서는 먼저 useNavigate()로 함수 안에서 화면을 이동시키는 흐름을 이해한다.
그다음 Outlet으로 공통 레이아웃은 유지하고, 현재 주소에 맞는 자식 화면만 바꾸는 흐름을 이해한다.


이 예제에서 중요한 점은 화면 이동 방식이 하나만 있는 것이 아니라는 점이다.
NavLink는 사용자가 메뉴를 클릭해서 이동할 때 사용한다.
반면 useNavigate()는 버튼 클릭이나 특정 로직이 끝난 뒤, 함수 안에서 직접 주소를 바꿀 때 사용한다.


또 하나의 핵심은 Outlet이다.
Outlet은 부모 컴포넌트 안에서 자식 컴포넌트가 들어갈 자리를 표시한다.
덕분에 Mini Board Router 제목과 메뉴는 계속 유지되고, 가운데 내용만 Home, BoardList, BoardDetail, NotFound로 바뀐다.
이 예제의 핵심은 공통 레이아웃은 유지하면서, 버튼 클릭과 주소 변경에 따라 알맞은 게시판 화면을 보여주는 흐름이다.


useNavigate가 필요한 이유

Link와 NavLink로 이동하는 경우

React Router에서 화면을 이동하는 가장 기본적인 방법은 Link나 NavLink를 사용하는 것이다.
Link는 특정 주소로 이동하는 링크를 만든다.
NavLink는 이동 기능에 더해 현재 주소와 일치할 때 active 상태를 표시할 수 있다.


예를 들어 상단 메뉴에서 홈이나 게시판을 클릭해서 이동하는 경우에는 NavLink가 적합하다.
사용자가 직접 메뉴를 누르기 때문이다.


하지만 모든 이동을 링크 태그로 처리할 수 있는 것은 아니다.
버튼을 누른 뒤 조건을 검사하고 이동해야 할 때가 있다.
로그인 성공 후 메인 화면으로 이동하거나, 저장이 끝난 뒤 목록 화면으로 이동하는 경우가 여기에 해당한다.


함수 로직 안에서 이동해야 하는 경우

useNavigate()는 JavaScript 함수 로직 안에서 주소를 바꾸고 싶을 때 사용하는 React Router 훅이다.
훅은 React 함수형 컴포넌트 안에서 특정 기능을 사용할 수 있게 해 주는 함수이다.


useNavigate()를 호출하면 navigate 함수를 얻을 수 있다.
그리고 navigate("/boards")처럼 실행하면 브라우저 주소가 /boards로 바뀐다.
주소가 바뀌면 React Router가 그 주소에 맞는 컴포넌트를 다시 찾아 화면에 보여준다.


쉽게 말하면 useNavigate()는 코드 안에서 화면 이동을 지시하는 리모컨 같은 역할을 한다.
사용자가 링크를 직접 누르는 것이 아니라, 버튼 클릭 함수 안에서 이동을 시키고 싶을 때 사용한다.

useNavigate()는 JavaScript 함수 안에서 원하는 주소로 화면을 이동시키는 기능을 담당한다.
Outlet은 부모 화면 안에 자식 화면이 들어올 자리를 미리 만들어 두는 컴포넌트이다.
이 두 기능을 함께 이해하면, 버튼을 눌러 주소를 바꾸고 그 주소에 맞는 자식 화면이 Outlet 자리에 렌더링되는 구조를 연결해서 볼 수 있다.


Outlet이 필요한 이유

부모 레이아웃은 유지하고 자식 화면만 바꾸는 구조

Outlet은 중첩 라우팅에서 사용하는 자리 표시 컴포넌트이다.
중첩 라우팅은 부모 라우트 안에 자식 라우트를 넣는 구조이다.
여기서 라우트는 특정 주소와 컴포넌트를 연결하는 설정이라고 이해하면 된다.


Outlet은 부모 컴포넌트 안에서 자식 컴포넌트가 들어갈 위치를 알려 준다.
부모 컴포넌트는 제목, 메뉴, 공통 영역을 계속 유지한다.
주소가 바뀌면 Outlet 자리의 내용만 바뀐다.


게시판 화면을 예로 들면 상단의 Mini Board Router 제목과 홈, 게시판 메뉴는 계속 보인다.
하지만 주소가 /이면 홈 내용이 보이고, /boards이면 게시글 목록이 보이고, /boards/1이면 게시글 상세가 보인다.
이때 바뀌는 부분이 바로 Outlet 자리이다.


Outlet이 없으면 자식 화면이 들어갈 자리가 없다

중첩 라우팅에서는 부모 라우트가 먼저 렌더링된다.
그다음 현재 주소에 맞는 자식 라우트가 부모 안에 들어간다.
그런데 부모 컴포넌트 안에 Outlet이 없으면 자식 컴포넌트가 어디에 그려져야 할지 알 수 없다.


그래서 Layout 컴포넌트 안에는 반드시 Outlet이 있어야 한다.
Outlet은 화면에 직접 어떤 내용을 만들어 내는 컴포넌트라기보다, 현재 주소에 맞는 자식 컴포넌트를 끼워 넣는 위치이다.

왼쪽은 라우트가 어떻게 계층 구조로 정의되는지 보여 주고, 오른쪽은 그 라우트가 실제 화면 안에서 어떻게 배치되는지 보여 준다.
/ 경로는 공통 레이아웃인 Layout으로 연결되고, 그 안에는 Header, Outlet, Footer 같은 공통 구조가 놓인다.
/posts로 이동하면 PostsLayout이 부모 Outlet 자리에 들어가고, 다시 그 안쪽 Outlet에 PostList나 Post가 들어간다.
이 구조는 App4.jsx의 /boards, /boards/:boardNo 흐름과 같은 방식이다.
즉, 부모 레이아웃은 유지되고 현재 주소에 맞는 자식 컴포넌트만 Outlet 자리에 바뀌어 들어간다.

Layout 컴포넌트는 화면 전체의 공통 틀을 담당한다.
상단에는 Mini Board Router 제목과 NavLink로 만든 홈, 게시판 메뉴가 고정되어 있다.
가운데 main 영역 안에는 Outlet이 들어가며, 이 자리가 현재 주소에 맞는 자식 컴포넌트가 렌더링되는 위치이다.
/로 접속하면 Home, /boards로 접속하면 BoardList, /boards/:boardNo로 접속하면 BoardDetail, 존재하지 않는 주소로 접속하면 NotFound가 이 Outlet 자리에 들어간다.
즉, 제목과 메뉴는 그대로 유지되고, Outlet 자리의 내용만 주소에 따라 바뀐다.


App4.jsx 전체 코드 구조 확인

전체 코드에서 봐야 하는 흐름

App4.jsx 전체 코드는 게시글 목록, 게시글 상세, 검색, 잘못된 주소 처리, 공통 레이아웃을 함께 구성한다.
코드를 한 번에 보면 길어 보이지만, 역할을 나누면 흐름이 단순해진다.


먼저 initialBoards 배열에는 게시글 데이터가 들어 있다.
App4 컴포넌트는 전체 라우트 구조를 만든다.
Layout은 공통 제목과 메뉴, 그리고 Outlet 자리를 만든다.
BoardList는 게시글 목록과 검색을 처리한다.
BoardDetail은 주소에 들어 있는 게시글 번호를 꺼내 상세 화면을 보여준다.
NotFound는 존재하지 않는 주소나 잘못된 게시글 번호를 처리한다.

// App4.jsx
import { useMemo, useState } from "react";
import {
  NavLink,
  Routes,
  Route,
  Outlet,
  useNavigate,
  useParams,
  useSearchParams,
} from "react-router";

// 게시글 목록에서 사용할 기본 데이터이다.
const initialBoards = [
  {
    boardNo: 1,
    title: "React Router 기초",
    writer: "둘리",
    content: "Routes, Route, Link를 사용해서 화면을 전환합니다.",
  },
  {
    boardNo: 2,
    title: "useParams 사용법",
    writer: "또치",
    content: "URL에 포함된 값을 컴포넌트에서 꺼낼 수 있습니다.",
  },
  {
    boardNo: 3,
    title: "useNavigate 사용법",
    writer: "도우너",
    content: "버튼 클릭이나 처리 완료 후 코드로 화면을 이동할 수 있습니다.",
  },
  {
    boardNo: 4,
    title: "검색 조건을 URL에 저장하기",
    writer: "마이콜",
    content: "useSearchParams를 사용하면 검색어를 URL에 남길 수 있습니다.",
  },
];

// 전체 라우트 구조를 만드는 최상위 컴포넌트이다.
function App4() {
  // 게시글 데이터를 state로 보관한다.
  const [boards] = useState(initialBoards);

  return (
    <Routes>
      {/* Layout은 공통 화면이고, 자식 라우트는 Outlet 자리에 들어간다. */}
      <Route path="/" element={<Layout />}>
        {/* / 주소와 정확히 일치할 때 Home을 보여준다. */}
        <Route index element={<Home />} />
        {/* /boards 주소에서는 게시글 목록을 보여준다. */}
        <Route path="boards" element={<BoardList boards={boards} />} />
        {/* /boards/게시글번호 주소에서는 게시글 상세를 보여준다. */}
        <Route path="boards/:boardNo" element={<BoardDetail boards={boards} />} />
        {/* 위에서 처리하지 못한 주소는 NotFound로 처리한다. */}
        <Route path="*" element={<NotFound />} />
      </Route>
    </Routes>
  );
}

// 공통 레이아웃을 담당하는 컴포넌트이다.
function Layout() {
  return (
    <div className="container">
      <h1>Mini Board Router</h1>

      {/* 현재 주소와 일치하는 메뉴에 active 상태를 적용한다. */}
      <nav className="nav">
        <NavLink to="/">홈</NavLink>
        <NavLink to="/boards">게시판</NavLink>
      </nav>

      {/* 현재 주소에 맞는 자식 컴포넌트가 이 자리에 렌더링된다. */}
      <main className="main">
        <Outlet />
      </main>
    </div>
  );
}

// 홈 화면 컴포넌트이다.
function Home() {
  return (
    <section className="card">
      <h2>홈</h2>
      <p>React Router의 기본 흐름을 학습하는 예제입니다.</p>
      <p>상단 메뉴에서 게시판으로 이동해 보세요.</p>
    </section>
  );
}

// 게시글 목록, 검색, 상세 이동을 처리하는 컴포넌트이다.
function BoardList({ boards }) {
  // URL의 query string을 읽고 수정한다.
  const [searchParams, setSearchParams] = useSearchParams();
  // 함수 안에서 주소를 이동시키기 위한 navigate 함수를 가져온다.
  const navigate = useNavigate();

  // URL에서 검색어를 꺼낸다.
  const keyword = searchParams.get("keyword") ?? "";
  // 검색창에 입력 중인 값을 저장한다.
  const [inputKeyword, setInputKeyword] = useState(keyword);

  // boards나 keyword가 바뀔 때만 게시글 목록을 다시 계산한다.
  const filteredBoards = useMemo(() => {
    return boards.filter((board) =>
      board.title.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [boards, keyword]);

  // 검색 버튼을 눌렀을 때 URL의 keyword 값을 변경한다.
  const search = () => {
    const value = inputKeyword.trim();

    // 검색어가 비어 있으면 query string을 비운다.
    if (value === "") {
      setSearchParams({});
      return;
    }

    // 검색어가 있으면 URL에 keyword 조건을 반영한다.
    setSearchParams({ keyword: value });
  };

  // Enter 키를 누르면 검색 버튼을 누른 것과 같은 동작을 한다.
  const handleKeyDown = (e) => {
    if (e.key === "Enter") {
      search();
    }
  };

  return (
    <section>
      <h2>게시글 목록</h2>

      {/* 게시글 제목 검색 영역이다. */}
      <div className="searchBox">
        <input
          className="input"
          value={inputKeyword}
          onChange={(e) => setInputKeyword(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="제목 검색"
        />

        <button onClick={search}>검색</button>
      </div>

      {/* 검색 조건을 통과한 게시글만 화면에 출력한다. */}
      <div className="list">
        {filteredBoards.map((board) => (
          <div className="card" key={board.boardNo}>
            <h3>{board.title}</h3>
            <p>작성자: {board.writer}</p>
            {/* 버튼 클릭 시 함수 안에서 상세 주소로 이동한다. */}
            <button onClick={() => navigate(`/boards/${board.boardNo}`)}>
              상세보기
            </button>
          </div>
        ))}
      </div>

      {/* 검색 결과가 없을 때 보여주는 문장이다. */}
      {filteredBoards.length === 0 && <p>검색 결과가 없습니다.</p>}
    </section>
  );
}

// 게시글 상세 화면을 처리하는 컴포넌트이다.
function BoardDetail({ boards }) {
  // URL 경로에서 boardNo 값을 꺼낸다.
  const { boardNo } = useParams();
  // 버튼 클릭으로 목록 화면에 돌아가기 위해 사용한다.
  const navigate = useNavigate();
  // URL에서 꺼낸 boardNo는 문자열이므로 숫자로 바꿔 비교한다.
  const board = boards.find((item) => item.boardNo === Number(boardNo));

  // boardNo와 일치하는 게시글이 없으면 NotFound 화면을 보여준다.
  if (!board) {
    return <NotFound />;
  }

  return (
    <section className="card">
      <h2>{board.title}</h2>
      <p>글번호: {board.boardNo}</p>
      <p>작성자: {board.writer}</p>
      <p>{board.content}</p>

      {/* 버튼 클릭 시 함수 안에서 목록 주소로 이동한다. */}
      <button onClick={() => navigate("/boards")}>목록으로</button>
    </section>
  );
}

// 잘못된 주소를 처리하는 컴포넌트이다.
function NotFound() {
  // 홈 화면으로 이동하기 위해 사용한다.
  const navigate = useNavigate();

  return (
    <section className="card">
      <h2>404</h2>
      <p>요청한 페이지를 찾을 수 없습니다.</p>
      <button onClick={() => navigate("/")}>홈으로 이동</button>
    </section>
  );
}

export default App4;

이 전체 코드는 크게 여섯 부분으로 나누어 보면 된다.
initialBoards는 게시글 데이터이다.
App4는 전체 라우트 구조를 만든다.
Layout은 공통 화면 틀과 Outlet 자리를 만든다.
BoardList는 게시글 검색과 상세 이동을 처리한다.
BoardDetail은 게시글 상세 화면을 처리한다.
NotFound는 잘못된 주소를 처리한다.


전체 코드를 한 번에 외우려고 하면 어렵다.
이제부터는 주소가 바뀌는 지점과 값이 이동하는 지점을 기준으로 부분 코드를 나누어 보면 된다.


App4.jsx의 라우트 구조 이해하기

부모 라우트와 자식 라우트를 나누어 본다

App4.jsx의 라우트 구조는 부모 라우트 안에 자식 라우트를 넣는 방식이다.
부모 라우트는 /이고, 이 주소에는 Layout이 연결된다.
자식 라우트는 Layout 안의 Outlet 자리에 들어간다.

// App4.jsx
<Routes>
  {/* Layout은 항상 유지되는 공통 화면이다. */}
  <Route path="/" element={<Layout />}>
    {/* 부모 주소 /와 정확히 일치하면 Home이 들어간다. */}
    <Route index element={<Home />} />
    {/* /boards 주소에서는 BoardList가 들어간다. */}
    <Route path="boards" element={<BoardList boards={boards} />} />
    {/* /boards/게시글번호 주소에서는 BoardDetail이 들어간다. */}
    <Route path="boards/:boardNo" element={<BoardDetail boards={boards} />} />
    {/* 존재하지 않는 주소는 NotFound가 들어간다. */}
    <Route path="*" element={<NotFound />} />
  </Route>
</Routes>

Route path="/" element={<Layout />}은 최상위 부모 라우트이다.
이 말은 /로 시작하는 화면 구조의 바깥 틀로 Layout을 사용하겠다는 뜻이다.


index 라우트는 부모 주소와 정확히 일치할 때 보여줄 기본 자식 화면이다.
여기서는 /로 접속하면 Home이 Outlet 자리에 들어간다.
path="boards"는 /boards를 의미한다.
부모 라우트 아래에 있는 자식 라우트이므로 앞에 /를 붙이지 않는다.


path="boards/:boardNo"는 게시글 번호가 들어가는 동적 라우트이다.
예를 들어 /boards/1, /boards/2, /boards/153처럼 번호가 바뀌어도 같은 BoardDetail 컴포넌트가 처리한다.
*는 앞에서 처리하지 못한 주소를 모두 받는 라우트이다.
잘못된 주소로 접근했을 때 NotFound를 보여주는 데 사용한다.


주소별로 Outlet에 들어가는 화면이 다르다

Outlet에 들어가는 컴포넌트는 현재 주소에 따라 달라진다.
부모인 Layout은 유지되고, 자식만 바뀐다.


주소별 흐름은 아래처럼 정리할 수 있다.

  • /로 접속한다.
  • Layout 안의 Outlet 자리에 Home이 들어간다.
  • /boards로 접속한다.
  • Layout 안의 Outlet 자리에 BoardList가 들어간다.
  • /boards/1로 접속한다.
  • Layout 안의 Outlet 자리에 BoardDetail이 들어간다.
  • 존재하지 않는 주소로 접속한다.
  • Layout 안의 Outlet 자리에 NotFound가 들어간다.

이 구조를 알면 Outlet이 왜 필요한지 더 쉽게 이해할 수 있다.
Outlet은 자식 화면이 실제로 들어오는 자리이다.


Layout과 Outlet으로 공통 화면 유지하기

Layout은 공통 틀을 만든다

Layout 컴포넌트는 모든 자식 화면이 공유하는 공통 틀이다.
이 예제에서는 제목, 메뉴, 본문 영역을 담당한다.

// App4.jsx
function Layout() {
  return (
    <div className="container">
      <h1>Mini Board Router</h1>

      {/* 현재 주소와 일치하는 메뉴에 active 상태를 적용한다. */}
      <nav className="nav">
        <NavLink to="/">홈</NavLink>
        <NavLink to="/boards">게시판</NavLink>
      </nav>

      {/* 현재 주소에 맞는 자식 컴포넌트가 이 자리에 렌더링된다. */}
      <main className="main">
        <Outlet />
      </main>
    </div>
  );
}

h1은 화면 제목이다.
nav 안의 NavLink는 상단 메뉴이다.
NavLink는 현재 주소와 메뉴 주소가 일치하면 active 상태가 될 수 있다.
그래서 사용자가 홈에 있는지, 게시판에 있는지 메뉴를 보고 확인할 수 있다.


main 안의 Outlet이 가장 중요하다.
Outlet은 실제 자식 화면이 들어오는 자리이다.
주소가 /이면 Home이 들어가고, /boards이면 BoardList가 들어가고, /boards/:boardNo이면 BoardDetail이 들어간다.


Layout은 바깥 틀을 유지하고, Outlet은 현재 주소에 맞는 안쪽 내용을 바꾸는 역할을 한다.


BoardList에서 검색 조건과 상세 이동 처리하기

검색 조건은 URL의 keyword로 관리한다

BoardList는 게시글 목록 화면이다.
이 컴포넌트에서는 useSearchParams()로 URL의 검색 조건을 읽고 수정한다.

// App4.jsx
function BoardList({ boards }) {
  // URL의 query string을 읽고 수정한다.
  const [searchParams, setSearchParams] = useSearchParams();
  // 함수 안에서 주소를 이동시키기 위한 navigate 함수를 가져온다.
  const navigate = useNavigate();

  // URL에서 검색어를 꺼낸다.
  const keyword = searchParams.get("keyword") ?? "";
  // 검색창에 입력 중인 값을 저장한다.
  const [inputKeyword, setInputKeyword] = useState(keyword);

  // boards나 keyword가 바뀔 때만 게시글 목록을 다시 계산한다.
  const filteredBoards = useMemo(() => {
    return boards.filter((board) =>
      board.title.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [boards, keyword]);
}

keyword는 현재 주소에서 꺼낸 검색어이다.
예를 들어 주소가 /boards?keyword=use라면 keyword는 use가 된다.
inputKeyword는 검색창에 입력 중인 값이다.


filteredBoards는 화면에 실제로 보여줄 게시글 목록이다.
전체 게시글이 아니라, 제목에 keyword가 포함된 게시글만 담긴 배열이다.
useMemo를 사용했기 때문에 boards나 keyword가 바뀔 때만 다시 계산한다.


검색 버튼을 누르면 URL의 keyword가 바뀐다

검색창에 값을 입력하는 것만으로 실제 검색 조건이 바뀌는 것은 아니다.
검색 버튼을 누르거나 Enter 키를 눌렀을 때 search() 함수가 실행된다.

// App4.jsx
const search = () => {
  const value = inputKeyword.trim();

  // 검색어가 비어 있으면 query string을 비운다.
  if (value === "") {
    setSearchParams({});
    return;
  }

  // 검색어가 있으면 URL에 keyword 조건을 반영한다.
  setSearchParams({ keyword: value });
};

inputKeyword.trim()은 검색창에 입력한 값의 앞뒤 공백을 제거한다.
검색어가 비어 있으면 setSearchParams({})를 실행해서 주소의 검색 조건을 비운다.
검색어가 있으면 setSearchParams({ keyword: value })를 실행해서 주소에 keyword 값을 남긴다.


예를 들어 검색창에 use를 입력하고 검색하면 주소가 /boards?keyword=use처럼 바뀐다.
주소가 바뀌면 keyword 값도 바뀐다.
keyword가 바뀌면 useMemo가 filteredBoards를 다시 계산한다.


상세보기 버튼은 navigate로 상세 주소로 이동한다

게시글 목록의 각 카드에는 상세보기 버튼이 있다.
이 버튼은 Link가 아니라 button이다.
그래서 버튼 클릭 함수 안에서 navigate()를 실행해 주소를 바꾼다.

// App4.jsx
{filteredBoards.map((board) => (
  <div className="card" key={board.boardNo}>
    <h3>{board.title}</h3>
    <p>작성자: {board.writer}</p>
    {/* 버튼 클릭 시 함수 안에서 상세 주소로 이동한다. */}
    <button onClick={() => navigate(`/boards/${board.boardNo}`)}>
      상세보기
    </button>
  </div>
))}

board.boardNo는 게시글 번호이다.
첫 번째 게시글의 boardNo가 1이면 navigate("/boards/1")가 실행된다.
두 번째 게시글의 boardNo가 2이면 navigate("/boards/2")가 실행된다.


이 흐름에서 중요한 점은 주소 문자열을 직접 고정하지 않는다는 것이다.
각 게시글의 번호를 주소에 넣어서 상세 페이지를 만든다.
그래서 게시글이 여러 개여도 하나의 BoardDetail 컴포넌트가 게시글 번호에 따라 다른 상세 내용을 보여줄 수 있다.


BoardDetail에서 URL 값으로 게시글 찾기

useParams로 boardNo를 꺼낸다

BoardDetail은 게시글 상세 화면이다.
이 컴포넌트는 주소에 들어 있는 게시글 번호를 꺼내서 해당 게시글을 찾는다.

// App4.jsx
function BoardDetail({ boards }) {
  // URL 경로에서 boardNo 값을 꺼낸다.
  const { boardNo } = useParams();
  // 버튼 클릭으로 목록 화면에 돌아가기 위해 사용한다.
  const navigate = useNavigate();
  // URL에서 꺼낸 boardNo는 문자열이므로 숫자로 바꿔 비교한다.
  const board = boards.find((item) => item.boardNo === Number(boardNo));

  // boardNo와 일치하는 게시글이 없으면 NotFound 화면을 보여준다.
  if (!board) {
    return <NotFound />;
  }

  return (
    <section className="card">
      <h2>{board.title}</h2>
      <p>글번호: {board.boardNo}</p>
      <p>작성자: {board.writer}</p>
      <p>{board.content}</p>

      {/* 버튼 클릭 시 함수 안에서 목록 주소로 이동한다. */}
      <button onClick={() => navigate("/boards")}>목록으로</button>
    </section>
  );
}

/boards/:boardNo에서 :boardNo는 바뀌는 값을 받을 자리이다.
실제 주소가 /boards/3이라면 boardNo는 "3"이 된다.


여기서 주의할 점은 URL에서 꺼낸 값은 문자열이라는 것이다.
boards 배열의 boardNo는 숫자이다.
그래서 Number(boardNo)로 숫자로 바꾼 뒤 비교한다.


find()는 배열에서 조건에 맞는 값을 하나 찾는 메서드이다.
item.boardNo === Number(boardNo) 조건을 만족하는 게시글을 찾아서 board에 저장한다.
게시글을 찾으면 제목, 글번호, 작성자, 내용을 출력한다.
일치하는 게시글이 없으면 NotFound 화면을 보여준다.


목록으로 버튼도 navigate로 이동한다

상세 화면에는 목록으로 버튼이 있다.
이 버튼을 누르면 navigate("/boards")가 실행된다.
즉, 현재 상세 주소에서 게시글 목록 주소로 돌아간다.


Link를 써도 이동할 수 있지만, 이 예제에서는 useNavigate()가 함수 안에서 화면을 이동시키는 흐름을 보여주기 위해 버튼에 navigate()를 연결했다.
이렇게 하면 버튼 클릭 후 다른 작업을 한 다음 이동하는 구조도 만들 수 있다.


NotFound에서 홈으로 이동하기

잘못된 주소는 NotFound로 처리한다

NotFound는 잘못된 주소를 처리하는 화면이다.
App4.jsx에서는 path="*"가 앞에서 처리하지 못한 주소를 받는다.
또는 BoardDetail에서 게시글 번호에 맞는 게시글을 찾지 못했을 때도 NotFound를 반환한다.

// App4.jsx
function NotFound() {
  // 홈 화면으로 이동하기 위해 사용한다.
  const navigate = useNavigate();

  return (
    <section className="card">
      <h2>404</h2>
      <p>요청한 페이지를 찾을 수 없습니다.</p>
      <button onClick={() => navigate("/")}>홈으로 이동</button>
    </section>
  );
}

NotFound 안에서도 useNavigate()를 사용한다.
홈으로 이동 버튼을 누르면 navigate("/")가 실행된다.
그러면 주소가 /로 바뀌고, Outlet 자리에는 Home이 렌더링된다.


이 흐름을 보면 useNavigate()는 게시글 상세 이동뿐 아니라, 잘못된 주소에서 홈으로 돌아가는 버튼에도 사용할 수 있다.


실행 화면으로 게시판 라우터 흐름 확인하기

App4.jsx 실행 결과

처음에는 / 주소에서 홈 화면이 열린다.
상단에는 Mini Board Router 제목과 홈, 게시판 메뉴가 고정되어 있다.
이 영역은 Layout 컴포넌트가 담당하는 공통 화면이다.
주소가 바뀌어도 제목과 메뉴는 그대로 유지되고, 가운데 Outlet 자리의 내용만 바뀐다.


게시판 메뉴를 클릭하면 주소가 /boards로 바뀌고 게시글 목록 화면이 Outlet 자리에 렌더링된다.
목록에는 여러 게시글 카드가 출력되고, 각 카드에는 상세보기 버튼이 있다.
이 버튼은 단순 링크가 아니라 navigate()를 사용해서 함수 안에서 /boards/게시글번호 주소로 이동시킨다.


게시글의 상세보기 버튼을 누르면 주소가 /boards/게시글번호 형태로 바뀐다.
예를 들어 첫 번째 게시글을 누르면 /boards/1로 이동한다.
BoardDetail 컴포넌트는 useParams()로 주소에 들어 있는 boardNo 값을 꺼낸다.
꺼낸 값은 문자열이므로 Number(boardNo)로 숫자로 바꾼 뒤, boards 배열에서 같은 번호를 가진 게시글을 찾는다.


상세 화면에서는 게시글 제목, 글번호, 작성자, 내용이 출력된다.
목록으로 버튼을 누르면 navigate("/boards")가 실행되어 다시 게시판 목록 화면으로 이동한다.
이 흐름에서 useNavigate()는 버튼 클릭 같은 함수 로직 안에서 주소를 바꾸는 역할을 한다.


검색창에 값을 입력하고 검색하면 주소에 keyword 조건이 붙는다.
예를 들어 use를 검색하면 /boards?keyword=use처럼 주소가 바뀐다.
이때 useSearchParams()가 keyword를 읽고, useMemo가 게시글 제목에 검색어가 포함된 글만 filteredBoards에 남긴다.
검색 조건에 맞는 게시글만 목록에 출력되고, 조건을 만족하는 글이 없으면 검색 결과가 없습니다.가 표시된다.


존재하지 않는 주소로 이동하면 NotFound 화면이 출력된다.
이 화면에서도 홈으로 이동 버튼을 누르면 navigate("/")가 실행되어 홈 화면으로 돌아간다.
따라서 이 예제는 Layout이 공통 틀을 유지하고, Outlet이 현재 주소에 맞는 자식 화면을 보여주며, useNavigate()가 버튼 클릭에 따라 주소를 바꾸는 흐름을 한 번에 보여준다.


전체 흐름을 값 기준으로 정리하기

게시판 목록 흐름

게시판 목록 화면의 흐름은 아래처럼 이어진다.

  • 사용자가 게시판 메뉴를 클릭한다.
  • 주소가 /boards로 바뀐다.
  • Layout은 그대로 유지된다.
  • Outlet 자리에 BoardList가 렌더링된다.
  • useSearchParams()가 URL에서 keyword를 읽는다.
  • useMemo가 boards와 keyword를 기준으로 filteredBoards를 계산한다.
  • 화면에는 filteredBoards에 들어 있는 게시글만 출력된다.

검색어를 입력하면 흐름은 아래처럼 바뀐다.

  • 검색창에 use를 입력한다.
  • 검색 버튼을 클릭한다.
  • setSearchParams({ keyword: "use" })가 실행된다.
  • 주소가 /boards?keyword=use로 바뀐다.
  • keyword = "use"가 된다.
  • useMemo가 다시 실행된다.
  • 제목에 use가 포함된 게시글만 filteredBoards에 남는다.

이 흐름에서 useSearchParams는 검색 조건을 주소에 저장하는 역할을 한다.
useMemo는 그 조건에 맞는 게시글 목록을 계산하는 역할을 한다.


게시글 상세 흐름

게시글 상세 화면의 흐름은 아래처럼 이어진다.

  • 사용자가 게시글 카드의 상세보기 버튼을 클릭한다.
  • navigate()가 /boards/게시글번호 주소로 이동시킨다.
  • 주소가 /boards/게시글번호로 바뀐다.
  • Routes가 boards/:boardNo 라우트와 주소를 매칭한다.
  • BoardDetail이 Outlet 자리에 렌더링된다.
  • useParams()가 주소에서 boardNo를 꺼낸다.
  • Number(boardNo)로 문자열 값을 숫자로 바꾼다.
  • boards.find()로 같은 boardNo를 가진 게시글을 찾는다.
  • 찾은 게시글의 제목, 글번호, 작성자, 내용이 출력된다.

예를 들어 /boards/3으로 이동하면 흐름은 아래처럼 볼 수 있다.

  • boardNo = "3"
  • Number(boardNo) = 3
  • boards 배열에서 boardNo가 3인 게시글을 찾는다.
  • board.title = "useNavigate 사용법"
  • 화면에는 해당 게시글 상세 내용이 출력된다.

목록으로 버튼을 누르면 navigate("/boards")가 실행된다.
주소가 다시 /boards로 바뀌고, Outlet 자리에는 BoardList가 렌더링된다.


Outlet 렌더링 흐름

Outlet 렌더링 흐름은 아래처럼 정리할 수 있다.

  • / → Layout 안의 Outlet에 Home 렌더링
  • /boards → Layout 안의 Outlet에 BoardList 렌더링
  • /boards/1 → Layout 안의 Outlet에 BoardDetail 렌더링
  • 잘못된 주소 → Layout 안의 Outlet에 NotFound 렌더링

Outlet은 현재 주소에 맞는 자식 컴포넌트를 부모 레이아웃 안에 끼워 넣는 자리이다.


핵심 정리

App4.jsx에서 각 기능이 맡는 역할

App4.jsx는 게시판 라우터 흐름을 보여주는 예제이다.
단순히 화면을 이동하는 것뿐 아니라, 공통 레이아웃 유지, 자식 화면 교체, 검색 조건 저장, 상세 게시글 조회까지 함께 다룬다.


각 기능의 역할은 아래처럼 정리할 수 있다.

  • NavLink는 현재 페이지 메뉴를 표시한다.
  • Routes와 Route는 주소와 컴포넌트를 연결한다.
  • Layout은 공통 화면 틀을 만든다.
  • Outlet은 현재 주소에 맞는 자식 화면이 들어갈 자리를 만든다.
  • useNavigate()는 함수 안에서 주소를 이동시킨다.
  • useParams()는 주소 경로에 들어 있는 boardNo를 꺼낸다.
  • useSearchParams()는 검색 조건인 keyword를 주소에서 읽고 수정한다.
  • useMemo는 검색 조건에 맞는 게시글 목록을 필요한 순간에만 다시 계산한다.

BoardList에서는 keyword가 게시글 목록을 결정한다.
BoardDetail에서는 boardNo가 어떤 게시글을 보여줄지 결정한다.
Layout과 Outlet은 이 모든 화면이 공통 틀 안에서 바뀌도록 연결한다.


이 예제의 핵심은 useNavigate()로 주소를 바꾸고, Outlet으로 현재 주소에 맞는 자식 화면을 보여주는 것이다.
주소가 바뀌면 React Router가 알맞은 컴포넌트를 찾고, 그 컴포넌트가 Outlet 자리에 렌더링된다.
그래서 App4.jsx는 코드 안에서 이동하는 방식과 중첩 라우팅 구조를 함께 이해하기 좋은 예제이다.




3. React 커스텀 훅 기본 개념과 역할별 분류 이해하기

React 커스텀 훅은 여러 컴포넌트에서 반복되는 로직을 한 곳으로 분리해서 재사용하기 위한 방법이다.
여기서 로직은 화면에 직접 보이는 UI 자체가 아니라, 상태 관리, 서버 요청, 입력값 처리, 라우터 이동 보조처럼 화면을 움직이게 만드는 코드 흐름을 의미한다.


컴포넌트 안에 모든 로직을 직접 넣으면 처음에는 편해 보인다.
하지만 같은 로직이 여러 컴포넌트에 반복되면 코드가 길어지고, 수정할 때도 여러 파일을 같이 고쳐야 한다.
커스텀 훅은 이런 반복 로직을 useFetch, useModal, useToggle처럼 따로 분리해서 필요한 컴포넌트에서 가져다 쓰게 만든다.


커스텀 훅의 핵심은 반복되는 상태 처리와 부수 효과 로직을 재사용 가능한 함수로 분리하는 것이다.
컴포넌트는 화면을 그리는 역할에 집중하고, 반복되는 동작은 커스텀 훅이 담당하게 된다.


커스텀 훅이 필요한 이유

컴포넌트 안에 로직이 계속 쌓이는 문제

React 컴포넌트는 화면을 그리는 함수이다.
하지만 실제 코드를 작성하다 보면 컴포넌트 안에는 화면 코드만 들어가지 않는다.
useState로 상태를 만들고, useEffect로 서버에 요청하고, loading과 error 상태를 관리하고, 버튼 클릭 함수까지 작성하게 된다.


처음에는 컴포넌트 하나 안에 모두 작성해도 크게 어렵지 않다.
하지만 같은 서버 요청 로직이나 같은 열림/닫힘 로직이 여러 컴포넌트에 반복되면 문제가 생긴다.
예를 들어 게시글 목록, 댓글 목록, 회원 목록이 모두 서버에서 데이터를 가져온다고 하자.
각 컴포넌트마다 data, loading, error, fetch 코드를 직접 쓰면 거의 같은 코드가 계속 반복된다.


반복되는 코드는 수정할 때 불리하다.
서버 요청에 JWT 토큰을 붙이는 방식이 바뀌면 모든 요청 코드를 찾아서 고쳐야 한다.
error 처리 문구를 바꾸고 싶어도 여러 컴포넌트를 확인해야 한다.
이렇게 되면 코드가 많아질수록 실수할 가능성이 커진다.


그래서 반복되는 로직은 컴포넌트 밖으로 빼는 것이 좋다.
이때 React 훅을 내부에서 사용하는 반복 로직이라면 일반 함수가 아니라 커스텀 훅으로 분리한다.


UI 코드와 데이터 처리 코드가 섞이면 생기는 문제

컴포넌트 안에는 사용자가 보는 화면 구조가 들어간다.
예를 들어 제목, 버튼, 목록, 모달 같은 화면 요소가 여기에 해당한다.
그런데 같은 컴포넌트 안에 서버 요청, 로딩 상태, 오류 처리, 입력값 검증까지 모두 들어가면 코드의 역할이 섞인다.


역할이 섞이면 코드를 읽을 때 흐름을 파악하기 어렵다.
화면을 보려고 코드를 읽는데 중간에 서버 요청 코드가 길게 나오고, 다시 화면 코드가 이어지고, 다시 상태 처리 코드가 나온다.
초보자 입장에서는 어느 부분이 화면이고 어느 부분이 로직인지 구분하기 어렵다.


커스텀 훅을 사용하면 이 구조를 나눌 수 있다.
컴포넌트는 화면을 보여주는 코드에 집중한다.
커스텀 훅은 반복되는 로직을 맡는다.
예를 들어 컴포넌트에서는 const { data, loading } = useFetch(url)처럼 필요한 값만 받아서 사용할 수 있다.

왼쪽 구조에서는 컴포넌트 안에 JSX, fetch 로직, loading과 error 상태, useEffect와 useState가 함께 들어 있다.
이렇게 작성하면 화면 코드와 데이터 처리 코드가 섞여서 컴포넌트가 복잡해진다.
오른쪽 구조에서는 반복되는 서버 요청 로직을 useFetch(url)이라는 커스텀 훅으로 분리한다.
컴포넌트는 const { data, loading } = useFetch(url)처럼 필요한 값만 받아서 사용하면 된다.
이렇게 분리하면 컴포넌트는 화면을 그리는 역할에 집중하고, 서버 요청 로직은 여러 컴포넌트에서 재사용할 수 있다.


커스텀 훅의 기본 개념

커스텀 훅은 직접 만드는 React Hook이다

커스텀 훅은 직접 만드는 React Hook이다.
일반 함수처럼 생겼지만 내부에서 useState, useEffect, useMemo, useCallback 같은 React 훅을 사용할 수 있다.


기본 형태는 아래처럼 이해하면 된다.

// CustomHookBasicExample.jsx
function useSomething() {
  // 상태를 만든다.
  const [state, setState] = useState(null);

  // 필요한 부수 효과를 처리한다.
  useEffect(() => {
    // 반복되는 로직을 여기에 작성한다.
  }, []);

  // 컴포넌트에서 사용할 값과 함수를 반환한다.
  return { state, setState };
}

커스텀 훅은 값을 직접 화면에 출력하지 않는다.
대신 컴포넌트가 사용할 상태와 함수를 반환한다.
그래서 컴포넌트는 커스텀 훅을 호출하고, 반환된 값을 이용해 화면을 만든다.


쉽게 말하면 커스텀 훅은 “화면은 직접 만들지 않고, 화면을 움직이는 재사용 로직을 제공하는 함수”이다.


커스텀 훅의 3가지 규칙

이름은 반드시 use로 시작해야 한다

커스텀 훅 이름은 반드시 use로 시작해야 한다.
예를 들어 useFetch, useModal, useToggle, useForm처럼 작성한다.


React는 함수 이름이 use로 시작하는지를 보고 훅 규칙을 적용한다.
이 규칙 덕분에 React는 이 함수 안에서 다른 훅이 사용된다는 것을 예상할 수 있다.


예를 들어 아래처럼 이름을 쓰는 것이 맞다.

// CustomHookNameExample.jsx
function useToggle() {
  // true와 false 상태를 관리한다.
  const [value, setValue] = useState(false);

  // 현재 값을 반대로 바꾼다.
  const toggle = () => setValue((prev) => !prev);

  // 필요한 값과 함수를 반환한다.
  return [value, toggle, setValue];
}

반대로 toggleState처럼 use로 시작하지 않는 이름으로 훅을 만들면 커스텀 훅이라는 의미가 흐려진다.
초보자 입장에서도 이 함수가 React 훅을 사용하는 함수인지 알기 어렵다.


커스텀 훅 이름은 반드시 use로 시작해야 한다.
이 규칙은 단순한 이름 규칙이 아니라, React 훅을 안전하게 사용하기 위한 기본 약속이다.


컴포넌트나 다른 훅의 최상위에서만 호출한다

커스텀 훅은 컴포넌트나 다른 커스텀 훅의 최상위에서 호출해야 한다.
최상위라는 말은 if, for, 중첩 함수 안쪽이 아니라 함수 본문 바로 안쪽을 의미한다.


잘못된 예는 아래와 같다.

// WrongCustomHookCallExample.jsx
function MyComponent({ isOpen }) {
  if (isOpen) {
    // 조건문 안에서 훅을 호출하면 안 된다.
    const [value, toggle] = useToggle();
  }

  return <div>잘못된 예시</div>;
}

이 코드가 잘못된 이유는 렌더링마다 훅 호출 순서가 달라질 수 있기 때문이다.
어떤 때는 useToggle()이 호출되고, 어떤 때는 호출되지 않는다.
React는 훅이 항상 같은 순서로 호출된다고 기대하기 때문에 이런 코드는 문제가 된다.


올바른 예는 아래와 같다.

// CorrectCustomHookCallExample.jsx
function MyComponent({ isOpen }) {
  // 훅은 조건문 밖에서 먼저 호출한다.
  const [value, toggle] = useToggle();

  if (!isOpen) {
    return null;
  }

  return <button onClick={toggle}>{String(value)}</button>;
}

조건에 따라 화면을 다르게 보여주는 것은 괜찮다.
하지만 훅 호출 자체는 항상 같은 위치에서 실행되어야 한다.


React 함수 안에서만 호출한다

커스텀 훅은 React 함수 안에서만 호출해야 한다.
여기서 React 함수란 함수형 컴포넌트나 다른 커스텀 훅을 의미한다.


일반 JavaScript 함수 안에서 훅을 호출하면 안 된다.
일반 함수는 React 렌더링 흐름 안에서 실행된다는 보장이 없기 때문이다.


아래처럼 일반 함수에서 호출하는 것은 잘못된 예이다.

// WrongNormalFunctionExample.jsx
function openMenu() {
  // 일반 함수 안에서 훅을 호출하면 안 된다.
  const [isOpen, toggle] = useToggle();
}

훅은 컴포넌트의 상태와 렌더링 흐름에 연결되어 있다.
그래서 React가 관리하는 함수 안에서 호출해야 한다.


커스텀 훅을 깔끔하게 설계하는 기준

필요한 값만 반환한다

커스텀 훅은 내부에서 여러 상태와 함수를 만들 수 있다.
하지만 컴포넌트에 모든 내부 값을 다 보여줄 필요는 없다.
컴포넌트가 실제로 사용할 값과 함수만 반환하면 된다.


예를 들어 useModal은 내부에서 isOpen, data, setIsOpen, setData를 사용할 수 있다.
하지만 컴포넌트에는 isOpen, data, open, close, toggle만 반환하면 충분하다.
컴포넌트가 직접 setIsOpen과 setData를 만지지 않게 하면 상태 변경 흐름을 커스텀 훅 안에서 통제할 수 있다.


이렇게 하면 컴포넌트는 더 단순해진다.
컴포넌트는 “모달을 연다”, “모달을 닫는다”처럼 의미 있는 함수만 사용하면 된다.


커스텀 훅을 역할별로 나누어 보기

역할별로 나누면 파일을 어디에 둘지 기준이 생긴다

커스텀 훅은 아무 기준 없이 만들면 금방 많아진다.
그래서 역할별로 나누어 이해하는 것이 좋다.
역할별로 나누면 어떤 로직을 훅으로 뺄지, 어느 파일에 둘지 판단하기 쉬워진다.


크게 보면 커스텀 훅은 서버 통신, 상태 관리, UI 제어, 라우터 보조, 공용 훅으로 나눌 수 있다.

커스텀 훅은 역할에 따라 서버 통신, 상태 관리, UI 제어, 라우터 보조, 공용 훅으로 나눌 수 있다.
서버 통신 영역에는 useFetch, usePagination, useInfiniteScroll처럼 데이터를 가져오거나 페이지 단위로 처리하는 훅이 들어간다.
상태 관리 영역에는 useAuth, useForm, useReducerState처럼 로그인 상태, 입력 폼 상태, 복잡한 객체 상태를 관리하는 훅이 들어간다.
UI 제어 영역에는 useModal, useToggle, useLocalStorage처럼 화면의 열림과 닫힘, 참과 거짓 전환, 새로고침 후 상태 유지와 관련된 훅이 들어간다.
라우터 보조 영역에는 useRequireAuth, useQueryParams, useNavigateBack처럼 인증 여부에 따른 이동, URL 쿼리 읽기와 쓰기, 안전한 뒤로 가기를 돕는 훅이 들어간다.
가운데의 useDebounce는 입력 지연을 통해 불필요한 API 호출을 줄이는 공용 훅으로 볼 수 있다.


서버 통신 훅

데이터를 가져오는 로직을 분리한다

서버 통신 훅은 API 요청과 관련된 반복 로직을 분리한다.
대표적으로 useFetch, usePagination, useInfiniteScroll이 있다.


useFetch는 서버에 요청을 보내고 응답 데이터를 저장하는 훅이다.
보통 data, loading, error 같은 상태가 함께 필요하다.
여러 컴포넌트에서 서버 요청을 반복한다면 이 로직을 useFetch로 분리할 수 있다.


usePagination은 페이지 번호를 기준으로 데이터를 나누어 가져올 때 사용한다.
예를 들어 게시글 목록을 한 번에 모두 가져오지 않고, 10개씩 페이지로 나누어 보여줄 때 사용한다.


useInfiniteScroll은 사용자가 화면 아래쪽으로 내려갔을 때 다음 데이터를 자동으로 불러오는 방식에 사용한다.
일반 게시판 페이지 이동보다 모바일 피드나 상품 목록에서 자주 사용되는 구조이다.


서버 통신 훅의 핵심은 서버 요청, 로딩 상태, 에러 처리, 응답 데이터 저장을 컴포넌트 밖으로 분리하는 것이다.


상태 관리 훅

화면 밖에서 반복되는 상태 흐름을 분리한다

상태 관리 훅은 여러 화면에서 반복되는 상태 처리 로직을 분리한다.
대표적으로 useAuth, useForm, useReducerState가 있다.


useAuth는 로그인, 로그아웃, 현재 사용자 정보, 로그인 여부를 관리하는 훅이다.
로그인 상태는 여러 페이지에서 필요하기 때문에 훅으로 분리하면 재사용하기 좋다.


useForm은 입력 폼 상태를 관리하는 훅이다.
회원가입, 로그인, 게시글 작성, 댓글 작성처럼 입력값을 관리하는 화면에서는 values, errors, handleChange, handleSubmit 같은 로직이 반복된다.
이런 로직을 useForm으로 분리하면 폼 컴포넌트가 훨씬 단순해진다.


useReducerState는 객체 상태가 복잡할 때 사용한다.
예를 들어 회원 정보처럼 name, email, age, address 등 여러 값을 가진 객체를 부분적으로 수정해야 할 때 도움이 된다.


UI 제어 훅

열림과 닫힘처럼 반복되는 화면 상태를 분리한다

UI 제어 훅은 화면 요소의 열림/닫힘, 보임/숨김, 켜짐/꺼짐 같은 상태를 관리한다.
대표적으로 useModal, useToggle, useLocalStorage가 있다.


useModal은 모달이 열려 있는지, 모달을 열 때 어떤 데이터를 같이 넘겼는지를 관리한다.
등록 모달처럼 데이터 없이 열리는 경우도 있고, 수정 모달처럼 선택한 게시글 데이터를 가지고 열리는 경우도 있다.


useToggle은 true와 false를 전환하는 단순한 훅이다.
단순하지만 드롭다운, 아코디언, 좋아요, 비밀번호 보기/숨기기, 다크모드처럼 사용할 수 있는 곳이 많다.


useLocalStorage는 localStorage와 React 상태를 함께 관리한다.
예를 들어 다크모드 설정이나 장바구니처럼 새로고침 후에도 유지되어야 하는 값을 다룰 때 사용할 수 있다.


라우터 보조 훅과 공용 훅

라우터 보조 훅은 이동과 URL 처리를 쉽게 만든다

라우터 보조 훅은 React Router와 관련된 반복 로직을 분리한다.
대표적으로 useRequireAuth, useQueryParams, useNavigateBack이 있다.


useRequireAuth는 로그인이 필요한 페이지에서 인증 여부를 확인하고, 인증되지 않은 사용자를 로그인 페이지로 이동시키는 훅이다.
마이페이지나 관리자 페이지처럼 보호가 필요한 화면에서 사용할 수 있다.


useQueryParams는 URL 쿼리 파라미터를 읽고 쓰는 로직을 분리한다.
검색어, 정렬 기준, 페이지 번호, 탭 상태처럼 주소에 남겨야 하는 조건을 관리할 때 유용하다.


useNavigateBack은 안전한 뒤로 가기를 담당한다.
이전 페이지 기록이 있으면 뒤로 가고, 기록이 없으면 정해 둔 기본 주소로 이동하게 만들 수 있다.


공용 훅에는 useDebounce가 있다.
useDebounce는 값이 바뀐 직후 바로 반영하지 않고, 일정 시간이 지난 뒤에만 반영한다.
검색창에서 키를 입력할 때마다 API를 호출하면 요청이 너무 많아진다.
이때 useDebounce를 사용하면 사용자가 입력을 멈춘 뒤에만 검색 요청을 보낼 수 있다.


핵심 정리

커스텀 훅은 로직을 재사용하는 단위이다

커스텀 훅은 화면을 직접 만드는 코드가 아니다.
반복되는 상태 처리, 서버 요청, 라우터 보조, UI 제어 로직을 재사용할 수 있게 분리하는 함수이다.


커스텀 훅을 사용할 때는 아래 기준을 기억하면 된다.

  • 이름은 반드시 use로 시작한다.
  • 컴포넌트나 다른 훅의 최상위에서만 호출한다.
  • 일반 JavaScript 함수 안에서는 호출하지 않는다.
  • 필요한 값만 반환해서 컴포넌트가 사용하는 흐름을 단순하게 만든다.
  • 반복되는 로직이 여러 컴포넌트에 생기면 커스텀 훅으로 분리할 수 있는지 확인한다.

커스텀 훅은 컴포넌트의 책임을 줄이고, 반복되는 로직을 한 곳에서 관리하게 해 주는 구조이다.
이 흐름을 이해하면 서버 통신, 모달, 토글, 폼, 인증 같은 기능을 더 깔끔하게 분리할 수 있다.




4. 응용예제: useModal과 useToggle로 UI 상태 로직 재사용하기

useModal과 useToggle은 UI 상태를 관리하는 커스텀 훅이다.
이 응용예제는 커스텀 훅이 실제 화면에서 어떻게 재사용되는지 보여준다.
useModal은 모달의 열림과 닫힘, 그리고 모달에 전달할 데이터를 관리한다.
useToggle은 true와 false로 나뉘는 상태를 쉽게 전환한다.


두 훅은 모두 화면에서 자주 반복되는 상태 로직을 분리한다.
모달을 여는 코드, 닫는 코드, 선택한 데이터를 저장하는 코드, 드롭다운을 열고 닫는 코드, 다크모드를 바꾸는 코드를 컴포넌트마다 직접 반복하지 않아도 된다.


응용예제의 핵심은 반복되는 UI 상태 로직을 커스텀 훅으로 분리하고, 여러 화면 요소에서 재사용하는 흐름을 확인하는 것이다.


응용예제 실행 구조 확인

customhookmain.jsx에서 두 예제를 함께 실행한다

customhookmain.jsx는 CustomHookApp1과 CustomHookApp2를 함께 렌더링하는 실행 연결 파일이다.
CustomHookApp1은 useModal 테스트 화면이고, CustomHookApp2는 useToggle 테스트 화면이다.

// customhookmain.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import CustomHookApp1 from './CustomHookApp1.jsx';
import CustomHookApp2 from './CustomHookApp2.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    {/* useModal 응용예제 화면이다. */}
    <CustomHookApp1 />

    <hr />

    {/* useToggle 응용예제 화면이다. */}
    <CustomHookApp2 />
  </StrictMode>
);

createRoot(document.getElementById('root'))는 HTML의 root 영역에 React 앱을 렌더링한다.
그 안에서 CustomHookApp1과 CustomHookApp2를 순서대로 출력한다.


이 구조 덕분에 한 실행 화면에서 useModal 예제와 useToggle 예제를 함께 확인할 수 있다.
첫 번째 화면에서는 모달 열림/닫힘과 데이터 전달을 확인하고, 두 번째 화면에서는 여러 true/false 상태 전환을 확인한다.


useModal 응용예제

useModal이 필요한 상황

모달은 화면 위에 떠서 사용자의 입력이나 확인을 받는 작은 창이다.
게시글 등록, 게시글 수정, 삭제 확인처럼 특정 작업을 할 때 자주 사용한다.


모달을 만들 때는 보통 아래 상태가 필요하다.

  • 지금 모달이 열려 있는지
  • 모달을 열 때 어떤 데이터를 같이 넘겼는지
  • 모달을 여는 함수
  • 모달을 닫는 함수
  • 같은 버튼으로 열고 닫는 함수

이 로직을 컴포넌트마다 직접 만들면 코드가 반복된다.
그래서 useModal로 모달 상태 관리 로직을 분리한다.


useModal 훅 코드

useModal은 모달의 열림 상태와 전달 데이터를 함께 관리한다.
isOpen은 모달이 열려 있는지를 나타내고, data는 모달을 열 때 함께 넘긴 값이다.

// useModal.jsx
import { useState, useCallback } from "react";

// 모달의 열림/닫힘 상태와 전달 데이터를 관리하는 커스텀 훅이다.
export function useModal() {
  // 모달이 열려 있는지 저장한다.
  const [isOpen, setIsOpen] = useState(false);

  // 모달을 열 때 같이 전달한 데이터를 저장한다.
  const [data, setData] = useState(null);

  // 모달을 연다. 전달된 값이 있으면 data에 저장한다.
  const open = useCallback((payload = null) => {
    setData(payload);
    setIsOpen(true);
  }, []);

  // 모달을 닫고, 전달 데이터도 비운다.
  const close = useCallback(() => {
    setIsOpen(false);
    setData(null);
  }, []);

  // 현재 열림 상태를 반대로 바꾼다.
  const toggle = useCallback(() => {
    setIsOpen((prev) => !prev);
  }, []);

  // 컴포넌트에서 필요한 값과 함수를 반환한다.
  return { isOpen, data, open, close, toggle };
}

open()은 모달을 여는 함수이다.
아무 값 없이 실행하면 data는 null이 된다.
새 글 작성처럼 빈 입력 폼을 보여줄 때 적합하다.


open(post)처럼 값을 넣어서 실행하면 post 객체가 data에 저장된다.
게시글 수정이나 삭제 확인처럼 “어떤 게시글을 대상으로 하는지” 알아야 할 때 적합하다.


close()는 모달을 닫고 data를 다시 null로 비운다.
모달이 닫힌 뒤 이전 데이터가 남아 있으면 다음 모달을 열 때 잘못된 데이터가 보일 수 있다.
그래서 닫을 때 데이터까지 초기화하는 흐름이 중요하다.


toggle()은 현재 열림 상태를 반대로 바꾼다.
닫혀 있으면 열고, 열려 있으면 닫는다.
인라인 패널처럼 같은 버튼으로 열고 닫는 구조에 사용할 수 있다.


CustomHookApp1에서 useModal을 여러 번 사용하는 흐름

CustomHookApp1.jsx에서는 useModal()을 세 번 호출한다.
각 호출은 서로 독립적인 모달 상태를 만든다.

// CustomHookApp1.jsx
export default function CustomHookApp1() {
  // 데이터 없이 열리는 등록 모달 상태이다.
  const createModal = useModal();

  // 게시글 데이터를 가지고 열리는 수정 모달 상태이다.
  const editModal = useModal();

  // 게시글 데이터를 가지고 열리는 삭제 확인 모달 상태이다.
  const deleteModal = useModal();
}

createModal, editModal, deleteModal은 모두 useModal()로 만들어졌지만 서로 다른 상태를 가진다.
createModal을 열어도 editModal이 열리지는 않는다.
editModal에 데이터가 들어가도 deleteModal의 데이터가 바뀌지는 않는다.


이 흐름은 커스텀 훅을 호출할 때마다 독립적인 상태 묶음이 새로 만들어진다는 것을 보여준다.
즉, 같은 useModal 훅을 재사용하더라도 모달마다 서로 다른 isOpen과 data를 가질 수 있다.


등록 모달은 데이터 없이 열린다

등록 모달은 새 글을 작성하는 화면이다.
아직 선택된 게시글이 없으므로 모달을 열 때 데이터를 넘길 필요가 없다.
그래서 open()을 그대로 실행한다.

// CustomHookApp1.jsx
<section className="test-card">
  <div className="test-label">
    <span className="badge">테스트 1</span>
    <code>open()</code> — 데이터 없이 열기
  </div>

  {/* 아무 데이터 없이 등록 모달을 연다. */}
  <button className="btn btn-primary" onClick={createModal.open}>
    새 글 작성
  </button>
</section>

<Modal isOpen={createModal.isOpen} onClose={createModal.close} title="새 글 작성">
  <form className="modal-form">
    <div className="field">
      <label>제목</label>
      <input className="input" autoFocus />
    </div>

    <div className="field">
      <label>작성자</label>
      <input className="input" />
    </div>

    <div className="modal-footer">
      <button type="button" className="btn btn-ghost" onClick={createModal.close}>
        취소
      </button>
      <button type="submit" className="btn btn-primary">
        등록
      </button>
    </div>
  </form>
</Modal>

createModal.open을 실행하면 isOpen이 true가 된다.
하지만 전달한 값이 없으므로 data는 null이다.
그래서 등록 모달은 기존 값을 채우지 않고 빈 입력 폼을 보여준다.


이 구조는 “새 데이터 생성”에 잘 맞다.
새 글을 작성할 때는 기존 게시글 정보가 필요하지 않기 때문이다.


수정 모달과 삭제 모달은 데이터와 함께 열린다

수정 모달과 삭제 모달은 특정 게시글을 대상으로 한다.
그래서 모달을 열 때 게시글 객체를 함께 전달해야 한다.

// CustomHookApp1.jsx
{POSTS.map((post) => (
  <tr key={post.id}>
    <td>{post.id}</td>
    <td>{post.title}</td>
    <td>{post.author}</td>
    <td>{post.category}</td>
    <td>
      {/* 선택한 게시글 데이터를 가지고 수정 모달을 연다. */}
      <button className="btn-sm" onClick={() => editModal.open(post)}>
        수정
      </button>

      {/* 선택한 게시글 데이터를 가지고 삭제 모달을 연다. */}
      <button className="btn-sm" onClick={() => deleteModal.open(post)}>
        삭제
      </button>
    </td>
  </tr>
))}

editModal.open(post)를 실행하면 선택한 게시글 객체가 editModal.data에 저장된다.
수정 모달은 이 data를 사용해서 제목, 작성자, 카테고리 값을 입력 폼에 채운다.


deleteModal.open(post)를 실행하면 선택한 게시글 객체가 deleteModal.data에 저장된다.
삭제 확인 모달은 이 data를 사용해서 어떤 게시글을 삭제하려는지 제목과 번호를 보여준다.


useModal의 장점은 모달을 여는 상태와 모달에 필요한 데이터를 함께 관리할 수 있다는 점이다.
단순히 “열림/닫힘”만 관리하는 것이 아니라, “무엇을 대상으로 열린 모달인지”까지 같이 관리한다.


useModal 응용예제 실행 결과

useModal을 사용해 등록 모달, 수정 모달, 삭제 확인 모달, 인라인 패널을 각각 제어하는 실행 화면이다.
새 글 작성 버튼을 누르면 open()이 실행되어 데이터 없이 등록 모달이 열린다.
이때 data는 null이므로 빈 입력 폼을 보여주는 등록 화면에 알맞다.


게시글 목록의 수정 버튼을 누르면 open(post)가 실행된다.
선택한 게시글 객체가 data에 저장되고, 수정 모달은 그 data를 이용해 제목, 작성자, 카테고리 값을 채워 보여준다.
삭제 버튼을 누를 때도 게시글 객체가 전달되기 때문에 삭제 확인 모달에서 어떤 게시글을 삭제하려는지 제목과 id를 보여줄 수 있다.


패널 열기 버튼은 toggle()을 사용한다.
toggle()은 현재 isOpen 값을 반대로 바꾸기 때문에 같은 버튼으로 열고 닫는 동작을 만들 수 있다.
즉, 이 실행 결과는 모달의 열림 상태뿐 아니라, 모달을 열 때 함께 전달한 데이터까지 커스텀 훅으로 관리할 수 있다는 점을 보여준다.


useToggle 응용예제

useToggle이 필요한 상황

useToggle은 true와 false로 나뉘는 상태를 전환할 때 사용한다.
예를 들어 드롭다운이 열렸는지, 좋아요를 눌렀는지, 비밀번호가 보이는지, 아코디언이 펼쳐졌는지 같은 상태는 모두 두 가지 값으로 나뉜다.


이런 상태를 매번 아래처럼 직접 작성할 수도 있다.

// ToggleBeforeExample.jsx
const [isOpen, setIsOpen] = useState(false);

const toggle = () => {
  setIsOpen((prev) => !prev);
};

하지만 이 로직은 여러 컴포넌트에서 계속 반복된다.
그래서 useToggle로 분리하면 훨씬 간단하게 재사용할 수 있다.


useToggle 훅 코드

useToggle은 현재 값, 값을 반대로 바꾸는 함수, 값을 직접 지정하는 함수를 반환한다.

// useToggle.jsx
import { useState, useCallback } from "react";

// true/false 값을 전환하는 커스텀 훅이다.
export function useToggle(initialValue = false) {
  // 현재 true/false 상태를 저장한다.
  const [value, setValue] = useState(initialValue);

  // 현재 값을 반대로 바꾼다.
  const toggle = useCallback(() => {
    setValue((prev) => !prev);
  }, []);

  // value, 반전 함수, 직접 지정 함수를 반환한다.
  return [value, toggle, setValue];
}

value는 현재 상태이다.
toggle()은 현재 값을 반대로 바꾼다.
false이면 true로 바꾸고, true이면 false로 바꾼다.


setValue는 원하는 값을 직접 지정할 때 사용한다.
예를 들어 무조건 닫고 싶으면 setValue(false)를 사용한다.
무조건 열고 싶으면 setValue(true)를 사용한다.


즉, toggle()은 “반대로 바꾸기”이고, setValue()는 “원하는 값으로 고정하기”이다.
이 둘의 차이를 구분하면 useToggle을 더 정확하게 사용할 수 있다.


CustomHookApp2에서 useToggle을 여러 번 사용하는 흐름

CustomHookApp2.jsx에서는 useToggle()을 여러 번 호출한다.
각 호출은 서로 독립적인 true/false 상태를 만든다.

// CustomHookApp2.jsx
export default function CustomHookApp2() {
  // 기본 true/false 반전 테스트이다.
  const [isOn, toggle1, setOn] = useToggle();

  // 다크모드 상태이다.
  const [isDark, toggleDark] = useToggle(false);

  // 드롭다운 열림 상태이다.
  const [isOpen, toggleMenu] = useToggle();

  // 좋아요 상태이다.
  const [liked, toggleLike] = useToggle();

  // 비밀번호 보기/숨기기 상태이다.
  const [showPwd, togglePwd] = useToggle();

  // 아코디언 펼침 상태이다.
  const [expanded, toggleExpand, setExp] = useToggle();
}

isOn, isDark, isOpen, liked, showPwd, expanded는 모두 useToggle()로 만들어졌지만 서로 독립적이다.
다크모드를 바꾼다고 좋아요 상태가 바뀌지 않는다.
드롭다운을 열어도 아코디언이 자동으로 열리지 않는다.


이 구조는 같은 커스텀 훅을 여러 번 호출해도 각각 독립적인 상태 묶음이 생긴다는 것을 보여준다.


toggle과 setValue를 구분해서 사용한다

toggle()은 현재 값을 반대로 바꾼다.
현재 값이 무엇인지 몰라도 “지금과 반대”로 만들 때 사용한다.

// CustomHookApp2.jsx
<TestCard title="toggle() — 기본 반전">
  <StateBar label="value" value={isOn} />

  {/* 현재 값을 true에서 false, false에서 true로 바꾼다. */}
  <button className="btn btn-primary" onClick={toggle1}>
    toggle() 호출
  </button>

  <div className="toggle-indicator">
    <div className={`indicator-light ${isOn ? "active" : ""}`} />
    <span>{isOn ? "켜짐 (true)" : "꺼짐 (false)"}</span>
  </div>
</TestCard>

버튼을 누를 때마다 isOn 값이 반대로 바뀐다.
isOn이 false이면 true가 되고, true이면 false가 된다.


반면 setValue()는 값을 직접 지정한다.
현재 값이 무엇이든 상관없이 원하는 값으로 고정한다.

// CustomHookApp2.jsx
<TestCard title="setValue(true / false) — 강제 지정">
  <StateBar label="value" value={isOn} />

  {/* 현재 값과 상관없이 true로 만든다. */}
  <button className="btn btn-success" onClick={() => setOn(true)}>
    setValue(true)
  </button>

  {/* 현재 값과 상관없이 false로 만든다. */}
  <button className="btn btn-danger" onClick={() => setOn(false)}>
    setValue(false)
  </button>
</TestCard>

setOn(true)는 현재 값이 false든 true든 무조건 true로 만든다.
setOn(false)는 현재 값이 무엇이든 무조건 false로 만든다.


이 차이는 중요하다.
토글 버튼은 toggle()이 자연스럽고, 닫기 버튼처럼 반드시 닫힌 상태를 만들어야 하는 경우에는 setValue(false)가 더 정확하다.


여러 UI 상태에 useToggle을 적용한다

useToggle은 단순하지만 실제 화면에서 많이 쓰인다.
다크모드, 드롭다운, 좋아요, 비밀번호 보기/숨기기, 아코디언은 모두 true/false 상태로 제어할 수 있다.


다크모드는 isDark 값에 따라 전체 화면 클래스를 바꾼다.
isDark가 true이면 어두운 테마를 적용하고, false이면 밝은 테마를 적용한다.


드롭다운은 isOpen 값이 true일 때만 메뉴 목록을 보여준다.
좋아요는 liked 값에 따라 하트 모양, 버튼 색상, 텍스트, 좋아요 수를 바꾼다.
비밀번호는 showPwd 값에 따라 입력창의 type을 password 또는 text로 바꾼다.
아코디언은 expanded 값이 true일 때만 본문을 보여준다.


이 흐름을 정리하면 아래와 같다.

  • isDark는 다크모드 여부를 결정한다.
  • isOpen은 드롭다운 메뉴가 보이는지 결정한다.
  • liked는 좋아요 상태를 결정한다.
  • showPwd는 비밀번호가 보이는지 결정한다.
  • expanded는 아코디언 본문이 펼쳐졌는지 결정한다.

이 값들은 모두 역할은 다르지만 구조는 같다.
현재 값이 true이면 보여주거나 켜고, false이면 숨기거나 끈다.
그래서 useToggle 하나로 여러 UI 상태를 재사용할 수 있다.


useToggle 응용예제 실행 결과

useToggle을 사용해 여러 UI 상태를 true와 false로 전환하는 실행 화면이다.
첫 번째 테스트에서는 toggle()을 누를 때마다 값이 false에서 true, 다시 true에서 false로 바뀐다.
두 번째 테스트에서는 setValue(true)와 setValue(false)를 사용해 현재 값과 상관없이 원하는 값으로 고정한다.


다크모드 테스트에서는 isDark 값에 따라 전체 화면 클래스가 바뀐다.
isDark가 true이면 어두운 화면이 되고, false이면 밝은 화면으로 돌아온다.
드롭다운 테스트에서는 isOpen 값이 true일 때만 메뉴 목록을 화면에 보여준다.
좋아요 테스트에서는 liked 값에 따라 하트 모양, 버튼 색상, 텍스트, 좋아요 수가 함께 바뀐다.


비밀번호 보기/숨기기 테스트에서는 showPwd 값에 따라 입력창의 type이 password와 text로 바뀐다.
아코디언 테스트에서는 expanded 값이 true일 때만 본문이 펼쳐지고, 닫기 버튼에서는 setValue(false)로 값을 직접 닫힌 상태로 만든다.
즉, 이 실행 결과는 열림/닫힘, 켜짐/꺼짐, 보임/숨김처럼 두 가지 상태로 나뉘는 UI 로직을 useToggle 하나로 재사용할 수 있다는 점을 보여준다.


useModal과 useToggle의 차이

useModal은 데이터 전달까지 필요할 때 사용한다

useModal과 useToggle은 모두 열림/닫힘 상태를 다룰 수 있다.
하지만 두 훅의 역할은 완전히 같지 않다.


useToggle은 단순히 true와 false를 바꾸는 데 집중한다.
그래서 드롭다운, 좋아요, 비밀번호 보기/숨기기처럼 상태 하나로 충분한 경우에 잘 맞는다.


반면 useModal은 열림/닫힘 상태뿐 아니라 data도 함께 관리한다.
수정 모달이나 삭제 모달처럼 “어떤 게시글을 대상으로 열렸는지” 알아야 하는 경우에는 단순 true/false만으로 부족하다.
이때 useModal을 사용하면 모달 상태와 선택 데이터를 함께 관리할 수 있다.


쉽게 정리하면 아래와 같다.

  • useToggle은 단순한 켜짐/꺼짐 상태에 적합하다.
  • useModal은 열림/닫힘 상태와 전달 데이터가 함께 필요할 때 적합하다.

상태만 바꾸면 되면 useToggle, 상태와 데이터를 함께 관리해야 하면 useModal을 사용한다.


핵심 정리

UI 상태 로직은 커스텀 훅으로 분리하면 재사용하기 쉽다

useModal과 useToggle은 둘 다 UI 상태 로직을 분리하는 커스텀 훅이다.
컴포넌트 안에 직접 상태와 함수를 계속 작성하지 않고, 반복되는 패턴을 훅으로 만들어 재사용한다.


useModal은 모달의 열림 여부와 전달 데이터를 함께 관리한다.
등록 모달처럼 데이터 없이 열리는 경우와 수정/삭제 모달처럼 데이터를 가지고 열리는 경우를 모두 처리할 수 있다.


useToggle은 true/false 상태 전환을 관리한다.
다크모드, 드롭다운, 좋아요, 비밀번호 보기/숨기기, 아코디언처럼 두 가지 상태로 나뉘는 UI에 적합하다.


마지막으로 흐름을 정리하면 아래와 같다.

  • useModal은 isOpen, data, open, close, toggle을 제공한다.
  • open()은 데이터 없이 모달을 연다.
  • open(post)는 데이터를 전달하면서 모달을 연다.
  • close()는 모달을 닫고 데이터를 초기화한다.
  • useToggle은 value, toggle, setValue를 제공한다.
  • toggle()은 현재 값을 반대로 바꾼다.
  • setValue(true/false)는 원하는 값으로 직접 고정한다.

커스텀 훅은 단순히 코드를 줄이는 도구가 아니라, 반복되는 상태 관리 흐름을 이름 있는 로직으로 분리하는 구조이다.
이렇게 분리하면 컴포넌트는 화면을 보여주는 역할에 집중하고, 상태 로직은 커스텀 훅이 담당하게 된다.




5. Spring Boot Project와 React 연동 구조 이해하기

Spring Boot와 React를 함께 사용할 때는 화면과 서버의 역할을 먼저 나누어 봐야 한다.
React는 사용자가 보는 화면을 만들고, 사용자의 입력을 받아 서버로 요청을 보낸다.
Spring Boot는 그 요청을 받아 회원가입, 로그인, 토큰 발급, 내 정보 조회 같은 실제 처리를 담당한다.


이 예제의 핵심은 JWT 인증 흐름이다.
JWT는 로그인한 사용자를 확인하기 위해 사용하는 토큰 문자열이다.
로그인에 성공하면 서버가 accessToken과 refreshToken을 발급한다.
React는 이 토큰을 저장해 두었다가, 인증이 필요한 요청을 보낼 때 Authorization 헤더에 붙여 보낸다.


이 예제의 핵심은 React가 화면에서 요청을 보내고, Spring Boot가 인증과 데이터 처리를 한 뒤, 다시 React 화면에 결과를 돌려주는 흐름이다.
이 흐름을 이해하면 프론트엔드와 백엔드가 실제로 어디에서 연결되는지 볼 수 있다.


React와 Spring Boot를 분리해서 보는 이유

React가 담당하는 역할

React는 사용자가 직접 보는 화면을 담당한다.
로그인 화면, 회원가입 화면, 마이페이지 화면처럼 브라우저에 표시되는 부분이 여기에 해당한다.


이 예제에서 React는 아래 역할을 맡는다.

  • 로그인 폼과 회원가입 폼을 보여준다.
  • 사용자가 입력한 아이디, 비밀번호, 이름, 이메일을 상태로 관리한다.
  • 회원가입 버튼이나 로그인 버튼을 누르면 Spring Boot 서버로 요청을 보낸다.
  • 로그인 성공 후 받은 accessToken과 refreshToken을 localStorage에 저장한다.
  • /me처럼 인증이 필요한 페이지에 접근할 때 토큰이 있는지 확인한다.
  • 인증 요청을 보낼 때 Authorization 헤더에 Bearer accessToken을 붙인다.

즉, React는 사용자의 행동을 받아 서버 요청으로 바꾸고, 서버 응답을 다시 화면에 보여주는 역할을 한다.


Spring Boot가 담당하는 역할

Spring Boot는 서버에서 실제 처리를 담당한다.
회원가입 요청이 오면 회원 정보를 검증하고 저장한다.
로그인 요청이 오면 아이디와 비밀번호를 확인하고 토큰을 발급한다.
내 정보 요청이 오면 토큰을 확인한 뒤, 현재 로그인한 사용자의 정보를 반환한다.


이 예제에서 Spring Boot는 아래 역할을 맡는다.

  • 회원가입 요청 값을 검증한다.
  • 아이디와 이메일 중복을 검사한다.
  • 비밀번호를 암호화해서 저장한다.
  • 로그인 요청의 아이디와 비밀번호를 인증한다.
  • accessToken과 refreshToken을 발급한다.
  • 인증이 필요한 요청에서 Authorization 헤더의 토큰을 검사한다.
  • 토큰이 유효하면 현재 사용자를 인증된 사용자로 등록한다.
  • /api/members/me 요청에서 현재 로그인한 사용자 정보를 반환한다.

즉, Spring Boot는 데이터 저장, 인증 처리, 토큰 발급과 검증을 담당한다.


화면과 서버가 API로 연결되는 흐름

React와 Spring Boot는 직접 하나의 코드처럼 섞이는 것이 아니다.
둘은 API 요청과 응답으로 연결된다.
API는 프론트엔드와 백엔드가 정해진 주소와 데이터 형식으로 통신하는 약속이다.


예를 들어 회원가입 흐름은 아래처럼 이어진다.

  • 사용자가 React 회원가입 화면에 값을 입력한다.
  • React가 /api/auth/signup 주소로 JSON 요청을 보낸다.
  • Spring Boot의 AuthController가 요청을 받는다.
  • AuthService가 중복 검사, 비밀번호 암호화, 회원 저장을 처리한다.
  • 서버가 성공 응답을 돌려준다.
  • React는 성공 응답을 받고 로그인 화면으로 이동한다.

로그인도 같은 방식이다.
React가 로그인 요청을 보내고, Spring Boot가 인증 후 토큰을 응답한다.
이 토큰이 이후 인증 요청의 기준이 된다.


Backend 서버 실행과 프로젝트 구조 확인

Spring Boot 서버 실행 확인

Spring Boot 서버가 실행되면서 Hibernate가 members 테이블을 생성하고, email과 username에 unique 제약조건을 추가하는 화면이다.
unique 제약조건은 같은 값이 중복 저장되지 않도록 막는 설정이다.
회원가입에서 아이디와 이메일 중복을 막아야 하므로 username과 email은 중복될 수 없는 값으로 관리된다.


아래쪽에는 Tomcat started on port 9000 로그가 보인다.
이 말은 백엔드 서버가 localhost:9000에서 정상 실행되었다는 뜻이다.
React의 auth.js도 기본 요청 주소를 http://localhost:9000/api로 사용하므로, 프론트엔드 요청은 이 서버로 들어간다.


application.yaml에서 서버 설정을 확인한다

application.yaml은 Spring Boot 서버 실행에 필요한 설정을 담는 파일이다.
이 예제에서는 데이터베이스 연결, JPA 설정, JWT 만료 시간, 서버 포트를 관리한다.

# application.yaml
spring:
  application:
    name: reactspring

  datasource:
    url: jdbc:mysql://localhost:3306/edudb?characterEncoding=UTF-8&serverTimezone=UTC
    username: jdbctest
    password: jdbctest
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

jwt:
  secret: mySecretKey1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
  access-expiration: 86400000
  refresh-expiration: 864000000

server:
  port: 9000

datasource는 MySQL 데이터베이스에 접속하기 위한 정보이다.
url은 접속할 데이터베이스 주소이고, username과 password는 데이터베이스 계정 정보이다.


jpa.hibernate.ddl-auto: update는 Entity 구조를 보고 테이블을 자동으로 맞추는 설정이다.
그래서 Member 엔티티에 맞춰 members 테이블이 생성되거나 수정될 수 있다.


jwt.access-expiration은 accessToken 만료 시간이다.
이 예제에서는 86400000으로 설정되어 있고, 1일을 의미한다.
jwt.refresh-expiration은 refreshToken 만료 시간이며, 10일을 의미한다.


server.port: 9000은 백엔드 서버를 9000번 포트에서 실행한다는 뜻이다.
그래서 프론트엔드의 auth.js는 http://localhost:9000/api로 요청을 보낸다.


Backend 폴더 역할을 나누어 본다

백엔드 코드는 역할별 폴더로 나누어져 있다.
각 폴더는 요청을 받는 역할, 실제 처리를 하는 역할, 데이터베이스와 연결하는 역할처럼 책임이 다르다.


핵심 폴더 역할은 아래처럼 볼 수 있다.

  • controller는 React에서 보낸 요청을 처음 받는다.
  • service는 회원가입, 로그인, 토큰 발급 같은 실제 로직을 처리한다.
  • repository는 DB에서 회원을 찾거나 저장한다.
  • entity는 데이터베이스 테이블과 연결되는 객체를 정의한다.
  • dto는 프론트엔드와 백엔드가 주고받는 요청과 응답 형식을 정의한다.
  • filter는 요청이 컨트롤러에 도착하기 전에 JWT를 검사한다.
  • config는 보안, CORS, 비밀번호 암호화 같은 설정을 관리한다.
  • util은 JWT 생성과 검증처럼 여러 곳에서 쓰이는 기능을 담당한다.

이 구조를 알면 요청이 들어왔을 때 어느 파일이 먼저 동작하고, 어떤 파일이 실제 처리를 하는지 따라갈 수 있다.


Backend 인증 코드 흐름 확인하기

AuthController는 API 요청을 받는 입구이다

AuthController는 React가 보낸 인증 관련 요청을 받는다.
@RequestMapping("/api")가 붙어 있기 때문에 이 컨트롤러의 주소는 모두 /api로 시작한다.

// AuthController.java
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {

    // 인증 관련 실제 처리는 AuthService에 맡긴다.
    private final AuthService authService;

    // 회원가입 요청을 받는다.
    @PostMapping("/auth/signup")
    public ResponseEntity<ApiResponse<MemberInfo>> signup(@Valid @RequestBody SignupRequest request) {
        // 회원가입 로직을 실행하고 회원 정보를 받는다.
        MemberInfo info = authService.signup(request);

        // 새 회원이 생성되었으므로 201 상태로 응답한다.
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(ApiResponse.ok("회원가입이 완료되었습니다", info));
    }

    // 로그인 요청을 받는다.
    @PostMapping("/auth/login")
    public ResponseEntity<ApiResponse<TokenResponse>> login(@Valid @RequestBody LoginRequest request) {
        // 로그인에 성공하면 토큰 응답을 받는다.
        TokenResponse token = authService.login(request);

        // accessToken, refreshToken, 회원 정보를 반환한다.
        return ResponseEntity.ok(ApiResponse.ok("로그인 성공", token));
    }

    // 토큰 재발급 요청을 받는다.
    @PostMapping("/auth/refresh")
    public ResponseEntity<ApiResponse<TokenResponse>> refresh(@RequestBody RefreshRequest request) {
        // refreshToken을 검증하고 새 토큰을 발급한다.
        TokenResponse token = authService.refresh(request);

        // 새 토큰 응답을 반환한다.
        return ResponseEntity.ok(ApiResponse.ok(token));
    }

    // 로그아웃 요청을 받는다.
    @PostMapping("/auth/logout")
    public ResponseEntity<ApiResponse<Void>> logout(@AuthenticationPrincipal UserDetails userDetails) {
        // 현재 로그인한 사용자의 refreshToken을 비운다.
        authService.logout(userDetails.getUsername());

        // 로그아웃 완료 응답을 반환한다.
        return ResponseEntity.ok(ApiResponse.ok("로그아웃 완료", null));
    }

    // 내 정보 조회 요청을 받는다.
    @GetMapping("/members/me")
    public ResponseEntity<ApiResponse<MemberInfo>> getMyInfo(@AuthenticationPrincipal UserDetails userDetails) {
        // 현재 인증된 username으로 회원 정보를 조회한다.
        MemberInfo info = authService.getMyInfo(userDetails.getUsername());

        // 현재 로그인한 회원 정보를 반환한다.
        return ResponseEntity.ok(ApiResponse.ok(info));
    }
}

@RestController는 반환값을 화면 이름이 아니라 응답 데이터로 보내는 컨트롤러이다.
즉, React가 요청하면 HTML 페이지를 돌려주는 것이 아니라 JSON 데이터를 돌려준다.


@RequestBody는 요청 본문에 들어온 JSON 데이터를 자바 객체로 바꾸는 역할을 한다.
예를 들어 React가 회원가입 정보를 JSON으로 보내면, 백엔드에서는 그 값이 SignupRequest 객체로 들어온다.


@Valid는 요청값에 붙은 검증 규칙을 실행한다.
아이디가 비어 있거나, 비밀번호 길이가 부족하거나, 이메일 형식이 맞지 않으면 컨트롤러 로직이 정상 진행되지 않고 검증 오류로 처리된다.


/auth/refresh는 refreshToken으로 새 토큰을 발급받는 요청을 받는다.
/auth/logout은 로그인한 사용자의 refreshToken을 비워 로그아웃 상태로 만드는 요청을 받는다.


logout()은 @AuthenticationPrincipal로 현재 로그인한 사용자의 username을 꺼낸다.
따라서 로그아웃 요청도 단순히 주소만 호출하는 것이 아니라, Authorization 헤더에 유효한 accessToken이 함께 전달되어야 현재 사용자를 찾을 수 있다.
프론트엔드의 auth.js는 저장된 accessToken이 있으면 모든 요청에 Authorization: Bearer 토큰을 자동으로 붙이므로 로그아웃 요청에서도 현재 사용자를 식별할 수 있다.


@AuthenticationPrincipal은 현재 인증된 사용자의 정보를 꺼내는 기능이다.
JwtAuthenticationFilter가 토큰을 검증해서 인증 객체를 등록해 두면, 컨트롤러에서는 userDetails.getUsername()으로 현재 로그인한 사용자의 아이디를 얻을 수 있다.


AuthDTO는 프론트와 백엔드가 주고받는 데이터 형식이다

AuthDTO는 인증 기능에서 사용하는 요청과 응답 구조를 모아 둔 클래스이다.
DTO는 데이터를 옮기기 위해 사용하는 객체이다.
여기서는 회원가입 요청, 로그인 요청, 토큰 응답, 회원 정보 응답, 토큰 갱신 요청, 공통 응답 형식을 정의한다.

// AuthDTO.java
public class AuthDTO {

    // 회원가입 요청 데이터를 담는다.
    @Getter
    @NoArgsConstructor
    public static class SignupRequest {
        // 아이디는 비어 있으면 안 되고 4~20자여야 한다.
        @NotBlank(message = "아이디를 입력하세요")
        @Size(min = 4, max = 20, message = "아이디는 4~20자입니다")
        private String username;

        // 비밀번호는 비어 있으면 안 되고 6자 이상이어야 한다.
        @NotBlank(message = "비밀번호를 입력하세요")
        @Size(min = 6, message = "비밀번호는 6자 이상입니다")
        private String password;

        // 이름은 비어 있으면 안 된다.
        @NotBlank(message = "이름을 입력하세요")
        private String name;

        // 이메일은 비어 있으면 안 되고 이메일 형식이어야 한다.
        @NotBlank(message = "이메일을 입력하세요")
        @Email(message = "이메일 형식이 아닙니다")
        private String email;
    }

    // 로그인 요청 데이터를 담는다.
    @Getter
    @NoArgsConstructor
    public static class LoginRequest {
        // 로그인 아이디를 담는다.
        @NotBlank private String username;

        // 로그인 비밀번호를 담는다.
        @NotBlank private String password;
    }

    // 로그인 성공 또는 토큰 재발급 성공 응답을 담는다.
    @Getter
    @AllArgsConstructor
    @Builder
    public static class TokenResponse {
        // 인증 요청에 사용할 토큰이다.
        private String accessToken;

        // accessToken 재발급에 사용할 토큰이다.
        private String refreshToken;

        // 로그인한 회원 정보이다.
        private MemberInfo member;
    }

    // 회원 정보를 응답으로 보낼 때 사용하는 구조이다.
    @Getter
    @AllArgsConstructor
    @Builder
    public static class MemberInfo {
        // 회원 고유 번호이다.
        private Long id;

        // 로그인 아이디이다.
        private String username;

        // 회원 이름이다.
        private String name;

        // 회원 이메일이다.
        private String email;

        // 회원 권한이다.
        private String role;

        // Member 엔티티를 응답용 MemberInfo로 바꾼다.
        public static MemberInfo from(Member member) {
            return MemberInfo.builder()
                    .id(member.getId())
                    .username(member.getUsername())
                    .name(member.getName())
                    .email(member.getEmail())
                    .role(member.getRole().name())
                    .build();
        }
    }

    // 토큰 재발급 요청 데이터를 담는다.
    @Getter
    @NoArgsConstructor
    public static class RefreshRequest {
        // 기존 refreshToken을 담는다.
        private String refreshToken;
    }

    // 공통 응답 형식이다.
    @Getter
    @AllArgsConstructor
    public static class ApiResponse<T> {
        // 요청 성공 여부이다.
        private boolean success;

        // 응답 메시지이다.
        private String message;

        // 실제 응답 데이터이다.
        private T data;

        // 메시지와 데이터를 함께 담은 성공 응답을 만든다.
        public static <T> ApiResponse<T> ok(String message, T data) {
            return new ApiResponse<>(true, message, data);
        }

        // 데이터만 담은 성공 응답을 만든다.
        public static <T> ApiResponse<T> ok(T data) {
            return new ApiResponse<>(true, null, data);
        }
    }
}

SignupRequest는 회원가입 요청에서 필요한 값을 담는다.
아이디, 비밀번호, 이름, 이메일이 들어간다.
각 필드에는 @NotBlank, @Size, @Email 같은 검증 규칙이 붙어 있다.


LoginRequest는 로그인 요청에서 필요한 값을 담는다.
로그인에는 아이디와 비밀번호만 필요하다.


TokenResponse는 로그인 성공 후 서버가 프론트엔드로 돌려주는 값이다.
여기에는 accessToken, refreshToken, member 정보가 들어간다.
React는 이 응답을 받아 토큰은 저장하고, 회원 정보는 화면 상태로 사용한다.


MemberInfo는 Member 엔티티를 그대로 응답하지 않고, 화면에 필요한 회원 정보만 골라 보내기 위한 응답 구조이다.
비밀번호 같은 민감한 값은 응답에 포함하지 않는다.


RefreshRequest는 토큰 재발급 요청에서 필요한 값을 담는다.
여기에는 기존 refreshToken이 들어가며, 서버는 이 값이 유효한지 확인한 뒤 새 토큰을 발급한다.


ApiResponse는 응답 형식을 일정하게 맞추기 위한 공통 응답 구조이다.
성공 여부, 메시지, 실제 데이터를 같은 형식으로 감싸서 프론트엔드에 돌려준다.


MemberRepository는 회원 조회와 중복 검사를 담당한다

MemberRepository는 members 테이블에서 회원을 찾거나 저장할 때 사용하는 저장소이다.
Repository는 데이터베이스에 직접 접근하는 역할을 맡는다.

// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {

    // username으로 회원을 찾는다.
    Optional<Member> findByUsername(String username);

    // refreshToken으로 회원을 찾는다.
    Optional<Member> findByRefreshToken(String refreshToken);

    // username 중복 여부를 확인한다.
    boolean existsByUsername(String username);

    // email 중복 여부를 확인한다.
    boolean existsByEmail(String email);
}

existsByUsername(username)은 회원가입에서 아이디 중복을 검사할 때 사용한다.
existsByEmail(email)은 이메일 중복을 검사할 때 사용한다.


findByUsername(username)은 로그인 후 회원 정보를 찾거나, 토큰에서 꺼낸 아이디로 사용자를 다시 조회할 때 사용한다.
findByRefreshToken(refreshToken)은 토큰 재발급 요청에서 전달된 refreshToken이 실제 저장된 토큰인지 확인할 때 사용한다.


MemberRepository는 인증 흐름에서 회원을 찾고, 중복 여부를 확인하고, 저장된 refreshToken과 요청 토큰을 비교하는 기준점이다.


AuthService는 실제 회원가입과 로그인 로직을 처리한다

AuthService는 컨트롤러가 받은 요청을 실제로 처리한다.
컨트롤러는 요청을 받고 응답을 만드는 입구이고, 서비스는 중복 검사, 비밀번호 암호화, 인증, 토큰 발급, 토큰 갱신, 로그아웃, 내 정보 조회 같은 핵심 로직을 담당한다.

// AuthService.java
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {

    // 회원 조회와 저장을 담당한다.
    private final MemberRepository memberRepository;

    // 비밀번호 암호화를 담당한다.
    private final PasswordEncoder passwordEncoder;

    // 로그인 인증 처리를 담당한다.
    private final AuthenticationManager authenticationManager;

    // JWT 생성과 검증을 담당한다.
    private final JwtUtil jwtUtil;

    // 회원가입을 처리한다.
    public MemberInfo signup(SignupRequest request) {
        // 아이디 중복을 검사한다.
        if (memberRepository.existsByUsername(request.getUsername()))
            throw new IllegalArgumentException("이미 사용 중인 아이디입니다");

        // 이메일 중복을 검사한다.
        if (memberRepository.existsByEmail(request.getEmail()))
            throw new IllegalArgumentException("이미 사용 중인 이메일입니다");

        // 저장할 회원 객체를 만든다.
        Member member = Member.builder()
                .username(request.getUsername())
                .password(passwordEncoder.encode(request.getPassword()))
                .name(request.getName())
                .email(request.getEmail())
                .role(Member.Role.USER)
                .build();

        // 회원을 저장하고 응답용 회원 정보로 바꾼다.
        return MemberInfo.from(memberRepository.save(member));
    }

    // 로그인을 처리한다.
    public TokenResponse login(LoginRequest request) {
        // 아이디와 비밀번호를 인증한다.
        Authentication auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );

        // 인증된 사용자의 username을 꺼낸다.
        String username = auth.getName();

        // accessToken과 refreshToken을 생성한다.
        String accessToken  = jwtUtil.generateAccessToken(username);
        String refreshToken = jwtUtil.generateRefreshToken(username);

        // username으로 회원을 조회한다.
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다"));

        // refreshToken을 회원 정보에 저장한다.
        member.updateRefreshToken(refreshToken);

        // 토큰과 회원 정보를 응답으로 반환한다.
        return TokenResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .member(MemberInfo.from(member))
                .build();
    }

    // refreshToken으로 새 토큰을 발급한다.
    public TokenResponse refresh(RefreshRequest request) {
        // 요청으로 받은 기존 refreshToken을 꺼낸다.
        String oldRefresh = request.getRefreshToken();

        // refreshToken 유효성을 검사한다.
        if (!jwtUtil.isValid(oldRefresh))
            throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다");

        // DB에 저장된 refreshToken과 일치하는 회원을 찾는다.
        Member member = memberRepository.findByRefreshToken(oldRefresh)
                .orElseThrow(() -> new IllegalArgumentException("Refresh Token이 일치하지 않습니다"));

        // 새 accessToken과 새 refreshToken을 만든다.
        String newAccess  = jwtUtil.generateAccessToken(member.getUsername());
        String newRefresh = jwtUtil.generateRefreshToken(member.getUsername());

        // 새 refreshToken을 다시 저장한다.
        member.updateRefreshToken(newRefresh);

        // 새 토큰과 회원 정보를 반환한다.
        return TokenResponse.builder()
                .accessToken(newAccess)
                .refreshToken(newRefresh)
                .member(MemberInfo.from(member))
                .build();
    }

    // 로그아웃을 처리한다.
    public void logout(String username) {
        // 현재 사용자를 조회한다.
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다"));

        // 저장된 refreshToken을 비운다.
        member.updateRefreshToken(null);
    }

    // 현재 로그인한 회원 정보를 조회한다.
    @Transactional(readOnly = true)
    public MemberInfo getMyInfo(String username) {
        // username으로 회원을 찾고 응답용 정보로 바꾼다.
        return memberRepository.findByUsername(username)
                .map(MemberInfo::from)
                .orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다"));
    }
}

회원가입에서는 먼저 아이디와 이메일 중복을 확인한다.
중복이 있으면 예외를 발생시켜 저장하지 않는다.
중복이 없으면 Member 객체를 만들고, 비밀번호는 passwordEncoder.encode()로 암호화해서 저장한다.


로그인에서는 AuthenticationManager가 아이디와 비밀번호를 인증한다.
인증이 성공하면 JwtUtil을 사용해 accessToken과 refreshToken을 만든다.
그다음 회원을 찾아 refreshToken을 저장하고, 토큰과 회원 정보를 TokenResponse로 반환한다.


refresh()는 기존 refreshToken으로 새 토큰을 발급하는 메서드이다.
먼저 기존 refreshToken이 유효한지 확인하고, DB에 저장된 refreshToken과 일치하는 회원을 찾는다.
일치하는 회원이 있으면 새 accessToken과 새 refreshToken을 만들고, 새 refreshToken을 다시 회원 정보에 저장한다.


logout()은 현재 사용자의 refreshToken을 null로 비운다.
이렇게 하면 서버에 저장된 재발급용 토큰이 사라지므로, 기존 refreshToken으로 다시 토큰을 갱신할 수 없게 된다.


getMyInfo()는 현재 로그인한 사용자의 아이디를 기준으로 회원 정보를 조회한다.
컨트롤러에서 @AuthenticationPrincipal로 꺼낸 username이 이 메서드로 전달된다.


여기서 중요한 점은 비밀번호가 그대로 저장되지 않는다는 것이다.
비밀번호는 암호화된 값으로 저장된다.
또 로그인 성공 후 프론트엔드가 사용할 토큰은 서버가 만들어서 응답한다.


SecurityConfig는 어떤 요청을 허용하고 막을지 정한다

SecurityConfig는 보안 설정을 담당한다.
이 예제에서는 /api/auth/** 주소를 우선 접근 가능하게 열어 두고, 그 외 요청은 인증이 필요하게 설정한다.
회원가입과 로그인은 아직 accessToken이 없는 상태에서도 요청할 수 있어야 한다.
토큰 재발급도 accessToken이 만료된 상황에서 refreshToken만으로 요청해야 하므로 인증 없이 접근할 수 있게 둔다.
다만 로그아웃은 주소 접근 자체는 허용되어 있어도, 현재 사용자를 찾기 위해 요청 헤더에 유효한 accessToken이 함께 전달되어야 한다.

// SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // React 개발 서버에서 오는 요청을 허용한다.
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))

        // JWT 방식에서는 CSRF 보호를 사용하지 않는다.
        .csrf(AbstractHttpConfigurer::disable)

        // 서버 세션을 만들지 않는다.
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

        // 요청별 접근 권한을 설정한다.
        .authorizeHttpRequests(auth -> auth
            // /api/auth/** 주소는 우선 접근을 허용한다.
            .requestMatchers("/api/auth/**").permitAll()

            // CORS 사전 요청은 인증 없이 허용한다.
            .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()

            // 나머지 요청은 인증이 필요하다.
            .anyRequest().authenticated()
        )

        // JWT 필터를 기존 로그인 필터 앞에 추가한다.
        .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    // 보안 설정 객체를 반환한다.
    return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    // CORS 설정 객체를 만든다.
    CorsConfiguration config = new CorsConfiguration();

    // React 개발 서버 주소를 허용한다.
    config.setAllowedOrigins(List.of("http://localhost:5173"));

    // 허용할 HTTP 메서드를 지정한다.
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));

    // 모든 요청 헤더를 허용한다.
    config.setAllowedHeaders(List.of("*"));

    // 인증 정보를 포함한 요청을 허용한다.
    config.setAllowCredentials(true);

    // 모든 경로에 CORS 설정을 적용한다.
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    // CORS 설정 소스를 반환한다.
    return source;
}

/api/auth/**는 인증 없이 접근할 수 있는 주소로 설정되어 있다.
회원가입과 로그인은 아직 accessToken이 없는 상태에서도 요청할 수 있어야 한다.
토큰 재발급도 accessToken이 만료된 상황에서 refreshToken만으로 요청해야 하므로 인증 없이 접근할 수 있게 둔다.


다만 로그아웃은 /api/auth/logout 주소에 포함되지만, 실제로 현재 사용자의 refreshToken을 비우려면 요청 헤더에 유효한 accessToken이 함께 전달되어야 한다.
그래야 JwtAuthenticationFilter가 현재 사용자를 인증하고, @AuthenticationPrincipal로 사용자 정보를 꺼낼 수 있다.


반대로 그 외 요청은 .anyRequest().authenticated() 때문에 인증이 필요하다.
예를 들어 /api/members/me는 로그인한 사용자만 접근해야 한다.


SessionCreationPolicy.STATELESS는 서버가 세션을 만들지 않는다는 뜻이다.
이 구조에서는 서버 세션 대신 JWT 토큰으로 사용자를 확인한다.
그래서 매 요청마다 토큰이 필요하다.


CORS 설정도 중요하다.
React 개발 서버는 localhost:5173에서 실행되고, Spring Boot 서버는 localhost:9000에서 실행된다.
브라우저 기준으로 포트가 다르면 다른 출처로 본다.
그래서 백엔드에서 http://localhost:5173 요청을 허용해 주어야 프론트엔드가 백엔드로 요청을 보낼 수 있다.


JwtUtil은 토큰을 만들고 검증한다

JwtUtil은 JWT 토큰을 직접 다루는 도구 클래스이다.
로그인 성공 시 accessToken과 refreshToken을 만들고, 인증 요청이 들어왔을 때 토큰이 유효한지 검사한다.
또 토큰 안에 들어 있는 username도 꺼낸다.

// JwtUtil.java
@Component
public class JwtUtil {

    // application.yaml의 jwt.secret 값을 가져온다.
    @Value("${jwt.secret}")
    private String secret;

    // accessToken 만료 시간을 가져온다.
    @Value("${jwt.access-expiration}")
    private long accessExpiration;

    // refreshToken 만료 시간을 가져온다.
    @Value("${jwt.refresh-expiration}")
    private long refreshExpiration;

    // JWT 서명에 사용할 키이다.
    private SecretKey key;

    // 객체 생성 후 secret 값을 기반으로 서명 키를 만든다.
    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // accessToken을 생성한다.
    public String generateAccessToken(String username) {
        return buildToken(username, accessExpiration);
    }

    // refreshToken을 생성한다.
    public String generateRefreshToken(String username) {
        return buildToken(username, refreshExpiration);
    }

    // 실제 JWT 문자열을 만든다.
    private String buildToken(String username, long expiration) {
        Date now = new Date();
        return Jwts.builder()
                .subject(username)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expiration))
                .signWith(key)
                .compact();
    }

    // 토큰에서 username을 꺼낸다.
    public String getUsername(String token) {
        return getClaims(token).getSubject();
    }

    // 토큰이 유효한지 검사한다.
    public boolean isValid(String token) {
        try {
            getClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // 토큰의 본문 정보를 꺼낸다.
    private Claims getClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

generateAccessToken(username)은 인증 요청에 사용할 accessToken을 만든다.
generateRefreshToken(username)은 토큰 재발급에 사용할 refreshToken을 만든다.


buildToken()은 실제 JWT 문자열을 만드는 내부 메서드이다.
토큰에는 사용자 아이디, 발급 시간, 만료 시간이 들어간다.


getUsername(token)은 토큰 안에 저장된 사용자 아이디를 꺼낸다.
isValid(token)은 토큰이 정상인지 확인한다.
토큰이 변조되었거나 만료되었거나 형식이 잘못되면 false를 반환한다.


JwtUtil은 로그인 성공 시 토큰을 만들고, 인증 요청 시 토큰을 검사하는 핵심 도구이다.


CustomUserDetailsService는 토큰에서 꺼낸 username으로 회원을 찾는다

JwtAuthenticationFilter는 토큰에서 username을 꺼낸 뒤, 실제 사용자 정보를 다시 조회해야 한다.
이때 사용하는 클래스가 CustomUserDetailsService이다.

// CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    // 회원 조회를 담당하는 저장소이다.
    private final MemberRepository memberRepository;

    // username으로 Spring Security용 사용자 정보를 만든다.
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // username으로 회원을 조회한다.
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));

        // Spring Security가 사용할 UserDetails 객체를 만든다.
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())))
                .build();
    }
}

loadUserByUsername(username)은 MemberRepository를 사용해 회원을 찾는다.
회원이 없으면 인증을 진행할 수 없으므로 예외를 발생시킨다.


회원이 있으면 UserDetails 객체를 만들어 반환한다.
UserDetails는 Spring Security가 인증된 사용자를 표현할 때 사용하는 사용자 정보 객체이다.
여기에는 아이디, 비밀번호, 권한 정보가 들어간다.


권한은 ROLE_ 접두어를 붙여 만든다.
예를 들어 회원의 역할이 USER이면 ROLE_USER 권한이 만들어진다.
이 정보가 있어야 Spring Security가 현재 사용자의 권한을 판단할 수 있다.


JwtAuthenticationFilter는 요청마다 토큰을 검사한다

JwtAuthenticationFilter는 컨트롤러에 요청이 도착하기 전에 실행된다.
요청 헤더에서 Authorization 값을 읽고, 그 안에 들어 있는 Bearer 토큰을 꺼낸다.

// JwtAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain chain) throws ServletException, IOException {

    // 요청 헤더에서 JWT 토큰을 꺼낸다.
    String token = resolveToken(request);

    // 토큰이 있고 유효하면 인증 정보를 만든다.
    if (StringUtils.hasText(token) && jwtUtil.isValid(token)) {
        // 토큰에서 username을 꺼낸다.
        String username = jwtUtil.getUsername(token);

        // username으로 사용자 상세 정보를 조회한다.
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        // Spring Security가 사용할 인증 객체를 만든다.
        UsernamePasswordAuthenticationToken auth =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        // 현재 요청의 인증 정보를 저장한다.
        SecurityContextHolder.getContext().setAuthentication(auth);
    }

    // 다음 필터 또는 컨트롤러로 요청을 넘긴다.
    chain.doFilter(request, response);
}

private String resolveToken(HttpServletRequest request) {
    // Authorization 헤더를 읽는다.
    String bearer = request.getHeader("Authorization");

    // Bearer 형식이면 실제 토큰 부분만 잘라낸다.
    if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
        return bearer.substring(7);
    }

    // 토큰이 없으면 null을 반환한다.
    return null;
}

resolveToken()은 Authorization 헤더에서 토큰을 꺼낸다.
헤더 값이 Bearer eyJ... 형태라면 앞의 Bearer 부분을 제거하고 실제 토큰 문자열만 반환한다.


토큰이 있고 유효하면 jwtUtil.getUsername(token)으로 사용자 아이디를 꺼낸다.
그다음 CustomUserDetailsService를 통해 사용자 정보를 불러오고, SecurityContextHolder에 인증 정보를 저장한다.


SecurityContextHolder는 현재 요청에서 인증된 사용자 정보를 보관하는 공간이다.
이 값이 저장되어야 컨트롤러에서 @AuthenticationPrincipal로 현재 사용자를 꺼낼 수 있다.


Frontend에서 인증 라우터 구조 만들기

App.jsx는 라우터와 인증 Provider를 연결한다

App.jsx는 프론트엔드 라우터 구조를 만든다.
BrowserRouter는 브라우저 주소를 기준으로 화면을 바꾸게 해 주고, AuthProvider는 로그인 상태를 전체 컴포넌트에서 사용할 수 있게 감싼다.

// App.jsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import { AuthProvider } from "./context/AuthContext";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import SignupPage from "./pages/SignupPage";
import MePage from "./pages/MePage";

export default function App() {
  return (
    <BrowserRouter>
      {/* 인증 상태를 전체 화면에서 사용할 수 있게 감싼다. */}
      <AuthProvider>
        <Routes>
          {/* 기본 주소로 접속하면 /me로 이동한다. */}
          <Route path="/" element={<Navigate to="/me" replace />} />

          {/* 로그인 화면이다. */}
          <Route path="/login" element={<LoginPage />} />

          {/* 회원가입 화면이다. */}
          <Route path="/signup" element={<SignupPage />} />

          {/* 마이페이지는 로그인한 사용자만 접근할 수 있다. */}
          <Route path="/me" element={
            <ProtectedRoute>
              <MePage />
            </ProtectedRoute>
          }/>
        </Routes>
      </AuthProvider>
    </BrowserRouter>
  );
}

/로 접속하면 바로 /me로 이동한다.
/login은 로그인 화면이고, /signup은 회원가입 화면이다.
/me는 로그인한 사용자만 볼 수 있어야 하므로 ProtectedRoute로 감싼다.


AuthProvider가 전체 라우터를 감싸고 있기 때문에 LoginPage, SignupPage, MePage, ProtectedRoute는 모두 로그인 상태와 로그인 함수를 사용할 수 있다.


AuthContext는 로그인 상태를 전역으로 관리한다

AuthContext는 로그인한 사용자 정보와 인증 관련 함수를 전역 상태로 제공한다.
전역 상태는 여러 컴포넌트에서 같이 사용할 수 있는 상태이다.

// AuthContext.jsx
export function AuthProvider({ children }) {
  // 현재 로그인한 사용자 정보를 저장한다.
  const [user, setUser] = useState(null);

  // 인증 확인 중인지 저장한다.
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 저장된 accessToken을 확인한다.
    const token = localStorage.getItem("accessToken");

    // 토큰이 없으면 인증 확인을 끝낸다.
    if (!token) {
      setLoading(false);
      return;
    }

    // 토큰이 있으면 내 정보를 다시 조회한다.
    authApi.me()
      .then((res) => {
        if (res?.success) setUser(res.data);
      })
      .finally(() => setLoading(false));
  }, []);

  // 로그인을 처리한다.
  const login = async (credentials) => {
    // 로그인 요청을 보낸다.
    const res = await authApi.login(credentials);

    // 실패 응답이면 에러를 발생시킨다.
    if (!res.success) throw new Error(res.message);

    // 토큰을 localStorage에 저장한다.
    localStorage.setItem("accessToken", res.data.accessToken);
    localStorage.setItem("refreshToken", res.data.refreshToken);

    // 회원 정보를 전역 상태에 저장한다.
    setUser(res.data.member);
    return res;
  };

  // 로그아웃을 처리한다.
  const logout = async () => {
    // 서버 로그아웃 요청을 보낸다.
    await authApi.logout().catch(() => {});

    // 저장된 토큰과 사용자 정보를 비운다.
    localStorage.clear();
    setUser(null);
  };

  // 회원가입을 처리한다.
  const signup = async (data) => {
    // 회원가입 요청을 보낸다.
    const res = await authApi.signup(data);

    // 실패 응답이면 에러를 발생시킨다.
    if (!res.success) throw new Error(res.message);

    return res;
  };

  // 하위 컴포넌트에서 인증 상태와 함수를 사용할 수 있게 제공한다.
  return (
    <AuthContext.Provider value={{ user, loading, login, logout, signup }}>
      {children}
    </AuthContext.Provider>
  );
}

앱이 처음 실행되면 localStorage에서 accessToken을 찾는다.
토큰이 없으면 로그인하지 않은 상태이므로 loading만 끝낸다.
토큰이 있으면 authApi.me()로 내 정보를 다시 조회해서 user 상태를 복원한다.


로그인에 성공하면 accessToken과 refreshToken을 localStorage에 저장한다.
그리고 응답으로 받은 member 정보를 user 상태에 저장한다.
이렇게 해야 새로고침 전에는 user 상태로 로그인 여부를 판단할 수 있고, 새로고침 후에는 저장된 토큰으로 다시 로그인 상태를 복원할 수 있다.


auth.js에서 Spring Boot API 요청 흐름 만들기

공통 fetch 래퍼가 필요한 이유

auth.js는 프론트엔드에서 백엔드 인증 API를 호출하는 파일이다.
여기서 중요한 것은 매번 fetch 코드를 직접 쓰지 않고, 공통 요청 함수인 request()로 묶었다는 점이다.


공통 요청 함수가 있으면 아래 작업을 한 곳에서 처리할 수 있다.

  • 기본 서버 주소를 붙인다.
  • 요청 본문을 JSON 문자열로 바꾼다.
  • Content-Type 헤더를 붙인다.
  • 저장된 accessToken이 있으면 Authorization 헤더를 붙인다.
  • 응답이 401이면 refreshToken으로 토큰 재발급을 시도한다.

즉, auth.js는 React와 Spring Boot를 실제로 연결하는 통신 파일이다.


request 함수는 모든 인증 요청의 공통 입구이다

// auth.js
const BASE = "http://localhost:9000/api";

async function request(url, options = {}) {
  // 저장된 accessToken을 꺼낸다.
  const token = localStorage.getItem("accessToken");

  // 백엔드 API로 요청을 보낸다.
  const res = await fetch(BASE + url, {
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
    ...options,
    body: options.body ? JSON.stringify(options.body) : undefined,
  });

  // 401 응답이면 refreshToken으로 재발급을 시도한다.
  if (res.status === 401) {
    const refreshed = await tryRefresh();
    if (!refreshed) {
      localStorage.clear();
      window.location.href = "/login";
      return;
    }
    return request(url, options);
  }

  // 응답 JSON을 반환한다.
  return res.json();
}

export const authApi = {
  // 회원가입 요청을 보낸다.
  signup: (body) => request("/auth/signup", { method: "POST", body }),

  // 로그인 요청을 보낸다.
  login: (body) => request("/auth/login", { method: "POST", body }),

  // 로그아웃 요청을 보낸다.
  logout: () => request("/auth/logout", { method: "POST" }),

  // 내 정보 조회 요청을 보낸다.
  me: () => request("/members/me"),
};

BASE는 백엔드 서버의 기본 주소이다.
request("/auth/login")을 호출하면 실제 요청 주소는 http://localhost:9000/api/auth/login이 된다.


localStorage.getItem("accessToken")은 저장된 토큰을 꺼낸다.
토큰이 있으면 Authorization: Bearer 토큰 형태로 요청 헤더에 붙인다.
이 헤더가 있어야 백엔드의 JwtAuthenticationFilter가 사용자를 인증할 수 있다.


res.status === 401은 토큰이 없거나, 잘못되었거나, 만료되었거나, 인증이 실패한 상황에서 받을 수 있는 응답이다.
이때 바로 로그아웃하지 않고 tryRefresh()로 토큰 재발급을 시도한다.
재발급이 성공하면 원래 요청을 다시 실행한다.
재발급도 실패하면 저장된 토큰을 모두 지우고 로그인 페이지로 이동한다.


회원가입 요청 흐름 이해하기

회원가입 화면에서 입력값을 보낸다

회원가입 화면에서 아이디, 비밀번호, 이름, 이메일을 입력한 상태이다.
오른쪽 Network 탭은 아직 요청이 발생하기 전이므로 비어 있다.
이 상태에서 회원가입 버튼을 누르면 React가 입력값을 JSON으로 묶어 백엔드의 /api/auth/signup 주소로 요청을 보낸다.


회원가입 화면에서는 useForm으로 입력값과 검증 오류를 관리한다.
useForm은 입력값이 바뀔 때 values를 수정하고, 제출할 때 검증 함수를 실행하는 커스텀 훅이다.

// SignupPage.jsx
// 회원가입 입력값과 에러를 useForm으로 관리한다.
const { values, errors, handleChange, handleSubmit } = useForm(
  { username: "", password: "", name: "", email: "" },
  validate
);

const onSubmit = async (vals) => {
  // 이전 서버 에러를 비운다.
  setServerError("");

  // 요청 중 상태로 바꾼다.
  setLoading(true);

  try {
    // 회원가입 요청을 보낸다.
    await signup(vals);

    // 성공 상태로 바꾼다.
    setSuccess(true);

    // 잠시 후 로그인 페이지로 이동한다.
    setTimeout(() => navigate("/login"), 1500);
  } catch (err) {
    // 서버 에러 메시지를 저장한다.
    setServerError(err.message);
  } finally {
    // 요청 종료 상태로 바꾼다.
    setLoading(false);
  }
};

values에는 사용자가 입력한 아이디, 비밀번호, 이름, 이메일이 들어간다.
handleChange는 입력값이 바뀔 때마다 values를 업데이트한다.
handleSubmit(onSubmit)은 폼 제출 시 검증을 먼저 실행하고, 문제가 없으면 onSubmit을 실행한다.


signup(vals)는 AuthContext에서 받은 회원가입 함수이다.
이 함수는 내부적으로 authApi.signup(data)를 호출하고, auth.js는 /auth/signup 주소로 요청을 보낸다.


회원가입 요청은 백엔드에서 저장 로직으로 이어진다

회원가입 요청이 백엔드에 도착하면 AuthController의 signup() 메서드가 실행된다.
그다음 AuthService.signup()에서 실제 저장 로직을 처리한다.


회원가입 데이터 흐름은 아래와 같다.

// SignupDataFlowExample.js
// React 입력값
// {
//   username: "duke",
//   password: "123456",
//   name: "듀크",
//   email: "duke@naver.com"
// }

// auth.js 요청 주소
// POST http://localhost:9000/api/auth/signup

// Spring Boot Controller
// AuthController.signup(SignupRequest request)

// Spring Boot Service
// AuthService.signup(request)

// DB 저장 대상
// members 테이블

SignupRequest에는 검증 규칙이 붙어 있다.
아이디가 비어 있거나, 비밀번호가 너무 짧거나, 이메일 형식이 맞지 않으면 요청은 저장 로직으로 이어지지 않는다.


검증을 통과하면 AuthService는 아이디와 이메일 중복을 검사한다.
중복이 없으면 비밀번호를 암호화하고 Member 객체를 저장한다.


회원가입 성공 결과 확인

회원가입 요청이 성공하면 Network 탭에 signup 요청이 201 상태로 표시된다.
201은 서버에서 새로운 데이터가 정상 생성되었다는 의미이다.
회원가입이 끝난 뒤 화면은 로그인 페이지로 이동하며, 사용자는 방금 만든 계정으로 로그인할 수 있다.


Network 탭에 preflight 요청도 함께 보인다.
preflight는 브라우저가 실제 요청을 보내기 전에 서버가 이 요청을 허용하는지 먼저 확인하는 요청이다.
프론트엔드 서버가 localhost:5173이고 백엔드 서버가 localhost:9000이기 때문에 CORS 확인 과정이 필요하다.


로그인 요청과 JWT 토큰 저장 흐름 이해하기

로그인 화면에서 아이디와 비밀번호를 보낸다

로그인 화면은 아이디와 비밀번호를 입력받는다.
사용자가 로그인 버튼을 누르면 login(vals)가 실행된다.

// LoginPage.jsx
// 로그인 입력값과 에러를 useForm으로 관리한다.
const { values, errors, handleChange, handleSubmit } = useForm(
  { username: "", password: "" },
  validate
);

const onSubmit = async (vals) => {
  // 이전 서버 에러를 비운다.
  setServerError("");

  // 요청 중 상태로 바꾼다.
  setLoading(true);

  try {
    // 로그인 요청을 보낸다.
    await login(vals);

    // redirect가 있으면 원래 가려던 페이지로 이동한다.
    const redirect = searchParams.get("redirect") || "/me";
    navigate(redirect, { replace: true });
  } catch (err) {
    // 로그인 실패 메시지를 저장한다.
    setServerError(err.message);
  } finally {
    // 요청 종료 상태로 바꾼다.
    setLoading(false);
  }
};

로그인에서도 useForm을 사용한다.
회원가입보다 필요한 값은 적다.
아이디와 비밀번호만 있으면 된다.


로그인에 성공하면 redirect 값을 확인한다.
redirect 값이 있으면 원래 가려던 페이지로 이동하고, 없으면 기본적으로 /me로 이동한다.
예를 들어 로그인하지 않은 상태로 /me에 접근했다면 /login?redirect=%2Fme로 이동한다.
로그인 성공 후에는 이 redirect 값을 읽어 다시 /me로 이동한다.


로그인 성공 후 서버는 토큰과 회원 정보를 반환한다

로그인 요청의 응답 본문에는 success, message, data가 들어 있다.
data 안에는 accessToken, refreshToken, member 정보가 함께 들어 있다.
React는 이 응답을 받아 토큰을 저장하고, member 정보를 화면 상태로 관리한다.
이후 인증이 필요한 요청을 보낼 때는 저장된 accessToken을 꺼내 Authorization 헤더에 붙인다.


로그인 성공 데이터 흐름은 아래처럼 볼 수 있다.

// LoginResponseFlowExample.js
// Spring Boot 로그인 성공 응답
// {
//   success: true,
//   message: "로그인 성공",
//   data: {
//     accessToken: "eyJhbGciOi...",
//     refreshToken: "eyJhbGciOi...",
//     member: {
//       id: 2,
//       username: "duke",
//       name: "듀크",
//       email: "duke@naver.com",
//       role: "USER"
//     }
//   }
// }

// React 저장 위치
// localStorage.accessToken = data.accessToken
// localStorage.refreshToken = data.refreshToken
// AuthContext.user = data.member

accessToken은 인증이 필요한 요청을 보낼 때 사용한다.
refreshToken은 accessToken이 만료되었을 때 새 토큰을 발급받는 데 사용한다.
member는 화면에서 현재 로그인한 사용자 정보를 보여줄 때 사용한다.


백엔드에서는 AuthService.login()이 이 응답을 만든다.
인증이 성공하면 JwtUtil로 두 토큰을 만들고, refreshToken은 회원 엔티티에 저장한다.
그다음 TokenResponse에 토큰과 회원 정보를 담아 반환한다.


보호 페이지 접근과 로그인 리다이렉트 흐름 이해하기

ProtectedRoute는 로그인 여부에 따라 접근을 막는다

ProtectedRoute는 로그인한 사용자만 접근할 수 있는 화면을 보호한다.
이 예제에서는 /me 페이지가 보호 대상이다.

// ProtectedRoute.jsx
export default function ProtectedRoute({ children }) {
  // 전역 인증 상태를 가져온다.
  const { user, loading } = useAuth();

  // 현재 접근하려는 경로를 가져온다.
  const location = useLocation();

  // 인증 확인 중이면 대기 화면을 보여준다.
  if (loading) {
    return (
      <div>
        인증 확인 중…
      </div>
    );
  }

  // 로그인하지 않은 상태면 로그인 페이지로 보낸다.
  if (!user) {
    return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
  }

  // 로그인 상태면 원래 화면을 보여준다.
  return children;
}

loading이 true이면 아직 인증 확인이 끝나지 않은 상태이다.
이때는 바로 로그인 페이지로 보내지 않고 “인증 확인 중” 화면을 잠깐 보여준다.


loading이 끝났는데 user가 없으면 로그인하지 않은 상태이다.
그래서 /login?redirect=현재경로로 이동한다.
이렇게 redirect 값을 붙이면 로그인 성공 후 원래 가려던 페이지로 돌아갈 수 있다.


user가 있으면 로그인한 상태이므로 children을 그대로 보여준다.
여기서는 children이 MePage이다.


useRequireAuth는 토큰이 없을 때 로그인으로 보낸다

MePage 안에서는 useRequireAuth()도 호출한다.
이 훅은 localStorage에 accessToken이 있는지 확인하고, 없으면 로그인 페이지로 이동시킨다.

// useRequireAuth.js
export function useRequireAuth() {
  // 화면 이동 함수를 가져온다.
  const navigate = useNavigate();

  // 현재 경로 정보를 가져온다.
  const location = useLocation();

  useEffect(() => {
    // 저장된 accessToken을 확인한다.
    const token = localStorage.getItem("accessToken");

    // 토큰이 없으면 로그인 페이지로 이동한다.
    if (!token) {
      navigate(`/login?redirect=${encodeURIComponent(location.pathname)}`, { replace: true });
    }
  }, [navigate, location.pathname]);
}

ProtectedRoute는 AuthContext의 user 상태를 기준으로 막는다.
useRequireAuth()는 localStorage의 accessToken을 기준으로 한 번 더 확인한다.
둘 다 목적은 같다.
로그인하지 않은 사용자가 보호 페이지에 들어오지 못하게 하는 것이다.

/me처럼 로그인한 사용자만 접근해야 하는 페이지에 토큰 없이 접근하면 /login?redirect=%2Fme 주소로 이동한다.
redirect=%2Fme는 로그인 성공 후 원래 접근하려던 /me 페이지로 다시 보내기 위한 값이다.
즉, 보호 페이지 접근, 토큰 없음 확인, 로그인 페이지 이동, 로그인 후 원래 페이지 복귀 흐름을 확인할 수 있다.


Authorization 헤더로 인증 요청 보내기

인증 요청에는 Bearer 토큰이 필요하다

로그인 후 /me 페이지에서 내 정보를 조회하려면 서버에 “나는 로그인한 사용자다”라는 증거를 보내야 한다.
이 증거가 accessToken이다.


auth.js는 요청을 보낼 때 localStorage에서 accessToken을 꺼낸다.
토큰이 있으면 요청 헤더에 아래 형태로 붙인다.

// AuthorizationHeaderExample.js
// Request Headers
// Authorization: Bearer eyJhbGciOi...

여기서 Authorization은 인증 정보를 담는 헤더 이름이다.
Bearer는 뒤에 오는 값이 토큰이라는 것을 나타내는 방식이다.
백엔드의 JwtAuthenticationFilter는 이 값을 읽고 토큰을 검증한다.

/me 요청의 Request Headers를 보면 Authorization 값에 Bearer accessToken 형식의 토큰이 들어 있다.
이 값은 React가 localStorage에 저장된 accessToken을 꺼내 요청 헤더에 자동으로 붙인 결과이다.
백엔드의 JwtAuthenticationFilter는 이 헤더에서 토큰을 꺼내 검증하고, 유효한 토큰이면 현재 요청을 인증된 사용자 요청으로 처리한다.


토큰이 유효하면 SecurityContextHolder에 인증 정보를 저장한다

백엔드의 JwtAuthenticationFilter는 요청마다 실행된다.
요청 헤더에서 토큰을 꺼내고, JwtUtil로 유효한 토큰인지 확인한다.
유효하면 토큰에서 username을 꺼내 사용자 정보를 조회한다.


그다음 UsernamePasswordAuthenticationToken을 만들고 SecurityContextHolder에 저장한다.
이 흐름이 끝나면 백엔드는 현재 요청을 로그인한 사용자 요청으로 인식한다.


인증 요청 흐름은 아래와 같다.

// JwtRequestFlowExample.js
// React 요청 헤더
// Authorization: Bearer accessToken

// JwtAuthenticationFilter
// resolveToken(request)

// jwtUtil.isValid(token)

// jwtUtil.getUsername(token)

// CustomUserDetailsService.loadUserByUsername(username)

// SecurityContextHolder.getContext().setAuthentication(auth)

// AuthController.getMyInfo(@AuthenticationPrincipal UserDetails userDetails)

@AuthenticationPrincipal이 동작하려면 먼저 SecurityContextHolder에 인증 정보가 들어 있어야 한다.
그래서 JwtAuthenticationFilter는 인증이 필요한 요청에서 매우 중요한 역할을 한다.


MePage에서 내 정보 조회하기

MePage는 토큰으로 현재 사용자 정보를 가져온다

MePage는 로그인한 사용자의 정보를 보여주는 페이지이다.
이 페이지는 authApi.me()를 호출해서 백엔드의 /api/members/me 주소로 요청을 보낸다.

// MePage.jsx
export default function MePage() {
  // 토큰이 없으면 로그인 페이지로 보낸다.
  useRequireAuth();

  // 전역 인증 상태와 로그아웃 함수를 가져온다.
  const { user, logout } = useAuth();

  // 화면 이동 함수를 가져온다.
  const navigate = useNavigate();

  // 서버에서 조회한 내 정보를 저장한다.
  const [me, setMe] = useState(null);

  // 내 정보 조회 중인지 저장한다.
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 내 정보 조회 요청을 보낸다.
    authApi.me()
      .then((res) => {
        // 성공하면 내 정보를 상태에 저장한다.
        if (res?.success) setMe(res.data);
      })
      .finally(() => setLoading(false));
  }, []);

  // 로그아웃 버튼을 눌렀을 때 실행한다.
  const handleLogout = async () => {
    // 로그아웃 요청을 보낸다.
    await logout();

    // 로그인 페이지로 이동한다.
    navigate("/login", { replace: true });
  };

  // accessToken을 일부만 보여주기 위해 꺼낸다.
  const token = localStorage.getItem("accessToken") || "";

  // 토큰 전체가 아니라 앞부분만 잘라서 보여준다.
  const tokenPreview = token
    ? token.slice(0, 40) + "…"
    : "—";
}

useRequireAuth()는 토큰이 없는 사용자를 로그인 페이지로 보낸다.
그다음 authApi.me()가 현재 사용자 정보를 요청한다.
요청이 성공하면 setMe(res.data)로 사용자 정보를 화면 상태에 저장한다.


tokenPreview는 전체 토큰을 그대로 보여주지 않고 앞부분만 잘라서 보여준다.
토큰은 길고 민감한 값이므로 화면에서는 일부만 확인용으로 보여주는 방식이 적절하다.


로그인 후 마이페이지 조회 결과 확인

로그인 후 /me 페이지에 접근하면 회원 정보가 화면에 출력된다.
왼쪽 화면에는 이름, 이메일, 아이디, 권한, 가입일, 현재 accessToken 일부가 표시된다.
오른쪽 Network 탭에는 signup, login, me 요청이 순서대로 남아 있어 회원가입, 로그인, 내 정보 조회가 실제 서버 요청으로 이어졌다는 것을 확인할 수 있다.


me 요청이 200 상태로 응답했다는 것은 토큰이 정상적으로 전달되었고, 백엔드에서 현재 사용자를 인증한 뒤 회원 정보를 반환했다는 뜻이다.


전체 JWT 인증 흐름 정리하기

회원가입 흐름

회원가입은 사용자를 새로 만드는 흐름이다.
토큰이 필요한 요청은 아니다.
아직 로그인 전이기 때문이다.


회원가입 흐름은 아래처럼 이어진다.

  • 사용자가 SignupPage에서 아이디, 비밀번호, 이름, 이메일을 입력한다.
  • useForm이 입력값을 values로 관리한다.
  • 회원가입 버튼을 누르면 signup(vals)가 실행된다.
  • AuthContext.signup()이 authApi.signup(data)를 호출한다.
  • auth.js가 /api/auth/signup으로 POST 요청을 보낸다.
  • AuthController.signup()이 요청을 받는다.
  • SignupRequest 검증이 실행된다.
  • AuthService.signup()이 아이디와 이메일 중복을 검사한다.
  • 비밀번호를 암호화하고 Member를 저장한다.
  • 성공하면 201 응답을 반환한다.
  • React는 성공 후 로그인 페이지로 이동한다.

이 흐름에서 중요한 점은 회원가입 성공이 바로 로그인 상태를 의미하지는 않는다는 것이다.
회원가입 후에는 로그인 페이지로 이동하고, 사용자가 다시 로그인해야 토큰을 받는다.


로그인 흐름

로그인은 토큰을 발급받는 흐름이다.
로그인에 성공해야 이후 인증 요청을 보낼 수 있다.


로그인 흐름은 아래처럼 이어진다.

  • 사용자가 LoginPage에서 아이디와 비밀번호를 입력한다.
  • 로그인 버튼을 누르면 login(vals)가 실행된다.
  • AuthContext.login()이 authApi.login(credentials)를 호출한다.
  • auth.js가 /api/auth/login으로 POST 요청을 보낸다.
  • AuthController.login()이 요청을 받는다.
  • AuthService.login()이 AuthenticationManager로 아이디와 비밀번호를 인증한다.
  • 인증이 성공하면 JwtUtil이 accessToken과 refreshToken을 만든다.
  • refreshToken은 회원 정보에 저장한다.
  • 서버는 TokenResponse를 반환한다.
  • React는 accessToken, refreshToken을 localStorage에 저장한다.
  • React는 member 정보를 AuthContext.user에 저장한다.
  • redirect가 있으면 원래 페이지로 이동하고, 없으면 /me로 이동한다.

로그인 흐름에서 가장 중요한 결과는 토큰 저장이다.
토큰이 저장되어야 이후 /me 같은 인증 요청을 보낼 수 있다.


인증이 필요한 API 요청 흐름

인증이 필요한 요청은 로그인 후에만 가능하다.
이 예제에서는 /api/members/me가 대표적인 인증 요청이다.


인증 요청 흐름은 아래처럼 이어진다.

  • 사용자가 /me 페이지에 접근한다.
  • ProtectedRoute와 useRequireAuth()가 로그인 여부를 확인한다.
  • MePage가 authApi.me()를 호출한다.
  • auth.js가 localStorage에서 accessToken을 꺼낸다.
  • 요청 헤더에 Authorization: Bearer accessToken을 붙인다.
  • Spring Boot의 JwtAuthenticationFilter가 토큰을 꺼낸다.
  • JwtUtil이 토큰 유효성을 검사한다.
  • 토큰에서 username을 꺼낸다.
  • CustomUserDetailsService가 사용자 정보를 조회한다.
  • SecurityContextHolder에 인증 정보를 저장한다.
  • AuthController.getMyInfo()가 @AuthenticationPrincipal로 사용자 정보를 받는다.
  • AuthService.getMyInfo()가 회원 정보를 조회한다.
  • 서버가 회원 정보를 JSON으로 반환한다.
  • MePage가 응답 데이터를 화면에 출력한다.

인증 요청의 핵심은 React가 토큰을 헤더에 붙이고, Spring Boot가 그 토큰을 검증해 현재 사용자를 알아내는 것이다.


토큰 재발급 흐름

accessToken은 인증 요청에 자주 사용되는 토큰이고, refreshToken보다 상대적으로 짧은 만료 시간을 가진다.
그래서 인증 요청 중 401 응답이 올 수 있다.
401은 인증이 실패했다는 뜻이다.


이 예제의 auth.js는 401 응답이 오면 바로 로그인 페이지로 보내지 않는다.
먼저 tryRefresh()를 실행해 refreshToken으로 새 토큰을 발급받으려고 한다.

// auth.js
async function tryRefresh() {
  // 저장된 refreshToken을 꺼낸다.
  const refreshToken = localStorage.getItem("refreshToken");

  // refreshToken이 없으면 재발급할 수 없다.
  if (!refreshToken) return false;

  try {
    // 백엔드에 토큰 재발급 요청을 보낸다.
    const res = await fetch(`${BASE}/auth/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refreshToken }),
    });

    // 재발급 요청이 실패하면 false를 반환한다.
    if (!res.ok) return false;

    // 새 토큰을 응답에서 꺼낸다.
    const json = await res.json();

    // 새 accessToken과 refreshToken을 다시 저장한다.
    localStorage.setItem("accessToken", json.data.accessToken);
    localStorage.setItem("refreshToken", json.data.refreshToken);

    return true;
  } catch {
    return false;
  }
}

토큰 재발급 흐름은 아래처럼 이어진다.

  • 인증 요청을 보낸다.
  • 서버가 401을 반환한다.
  • auth.js가 tryRefresh()를 실행한다.
  • localStorage에서 refreshToken을 꺼낸다.
  • /api/auth/refresh로 재발급 요청을 보낸다.
  • 백엔드의 AuthController.refresh()가 요청을 받는다.
  • AuthService.refresh()가 refreshToken을 검증한다.
  • MemberRepository.findByRefreshToken()으로 해당 토큰을 가진 회원을 찾는다.
  • 유효하면 새 accessToken과 새 refreshToken을 반환한다.
  • React는 새 토큰을 다시 저장한다.
  • 원래 요청을 다시 실행한다.

재발급이 실패하면 localStorage를 비우고 로그인 페이지로 이동한다.
이렇게 하면 만료된 토큰으로 계속 요청을 보내는 문제를 막을 수 있다.


핵심 정리

React와 Spring Boot는 API와 JWT로 연결된다

React와 Spring Boot는 역할이 다르다.
React는 화면을 만들고 사용자의 입력을 서버 요청으로 바꾼다.
Spring Boot는 요청을 받아 인증, 저장, 조회 같은 실제 처리를 담당한다.


이 예제에서 중요한 파일 역할은 아래처럼 정리할 수 있다.

  • App.jsx는 라우터 구조와 인증 Provider 연결을 담당한다.
  • AuthContext.jsx는 로그인 상태, 로그인, 로그아웃, 회원가입 함수를 전역으로 제공한다.
  • auth.js는 백엔드 API 요청을 보내고, 토큰 자동 첨부와 401 재발급을 처리한다.
  • SignupPage.jsx는 회원가입 입력값을 받아 회원가입 요청을 보낸다.
  • LoginPage.jsx는 로그인 요청을 보내고 성공 후 redirect 경로로 이동한다.
  • ProtectedRoute.jsx는 로그인하지 않은 사용자의 보호 페이지 접근을 막는다.
  • useRequireAuth.js는 accessToken이 없을 때 로그인 페이지로 이동시킨다.
  • MePage.jsx는 토큰을 사용해 현재 사용자 정보를 조회하고 화면에 보여준다.
  • AuthController.java는 인증 관련 API 요청을 받는다.
  • AuthService.java는 회원가입, 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 로직을 처리한다.
  • MemberRepository.java는 회원 조회, 중복 검사, refreshToken 조회를 담당한다.
  • JwtAuthenticationFilter.java는 요청마다 Authorization 헤더의 토큰을 검사한다.
  • JwtUtil.java는 JWT 생성, 검증, 사용자 이름 추출을 담당한다.
  • CustomUserDetailsService.java는 토큰에서 꺼낸 username으로 실제 회원 정보를 조회한다.
  • SecurityConfig.java는 인증 허용 범위, CORS, JWT 필터 연결을 설정한다.

전체 흐름은 회원가입으로 계정을 만들고, 로그인으로 토큰을 발급받고, 이후 요청마다 Authorization 헤더에 토큰을 붙여 인증된 사용자로 서버 기능을 사용하는 구조이다.
이 흐름을 이해하면 React 화면과 Spring Boot 서버가 실제 서비스처럼 연결되는 방식을 파악할 수 있다.




6. React의 프로젝트 구조 이해하기

React 프로젝트는 파일이 많아질수록 역할별로 나누어 관리하는 것이 중요하다.
처음에는 모든 코드를 한 파일에 작성해도 동작은 한다.
하지만 화면, 공통 컴포넌트, 서버 요청, 상태 관리, 이미지, 스타일 코드가 한곳에 섞이면 코드를 찾고 수정하기 어려워진다.


그래서 React 프로젝트는 보통 기능과 역할에 따라 폴더를 나눈다.
프로젝트 구조를 나누는 핵심 기준은 “이 파일이 화면을 담당하는가, 로직을 담당하는가, 서버 통신을 담당하는가, 공통 자원을 담당하는가”이다.
이 기준을 알면 파일이 많아져도 어디에 어떤 코드를 작성해야 하는지 판단하기 쉬워진다.


React 프로젝트 구조를 보는 이유

파일을 역할별로 나누어야 하는 이유

작은 예제에서는 App.jsx 하나에 화면 코드, 이벤트 코드, 서버 요청 코드를 모두 넣어도 실행된다.
하지만 프로젝트가 커지면 한 파일 안에 너무 많은 코드가 들어간다.
그러면 특정 기능을 수정하려고 할 때 어디를 봐야 하는지 찾기 어렵다.


예를 들어 로그인 화면을 수정해야 하는데 API 요청 코드, 공통 버튼 코드, 전체 라우터 코드가 모두 한 파일에 섞여 있으면 흐름을 따라가기 힘들다.
그래서 화면은 화면 폴더에, 공통 부품은 공통 컴포넌트 폴더에, 서버 요청은 api 폴더에 나누어 둔다.

React 프로젝트는 크게 프로젝트 루트와 src 폴더 안쪽 구조로 나누어 볼 수 있다.
프로젝트 루트는 앱을 실행하고 설정하는 파일들이 놓이는 바깥 영역이다.
src 폴더는 실제 화면 코드와 로직 코드 대부분이 들어가는 안쪽 영역이다.


프로젝트 루트에는 public, vite.config.js, package.json, .env, index.html, eslint.config.js, .gitignore 같은 파일이 놓인다.
public은 정적 파일을 보관하는 폴더이고, 그 안에는 favicon.ico, robots.txt 같은 파일이 들어갈 수 있다.
vite.config.js는 Vite 프로젝트 설정 파일이고, package.json은 프로젝트 실행 명령어와 설치된 라이브러리 정보를 관리한다.
.env는 환경 변수를 관리하고, index.html은 브라우저가 처음 읽는 기본 HTML 파일이다.
eslint.config.js는 코드 규칙 검사 설정이고, .gitignore는 Git에 올리지 않을 파일을 정하는 설정이다.


실제 화면 코드 대부분은 src 폴더 안에서 관리된다.
src 안에서는 main.jsx가 앱 진입점 역할을 한다.
앱 진입점은 React 앱이 처음 시작되는 파일이라는 뜻이다.
App.jsx는 라우트 트리를 정의한다.
라우트 트리는 주소에 따라 어떤 화면을 보여줄지 정리한 구조이다.


그 아래로 pages, components, layouts, routes, hooks, context, api, store, assets, utils, styles처럼 역할별 폴더를 나누어 관리한다.
이 구조를 사용하면 화면 코드, 재사용 UI, 공통 레이아웃, 라우터 보조 코드, 커스텀 훅, 전역 상태, 서버 통신 함수, 이미지, 공통 함수, 스타일을 서로 섞지 않고 관리할 수 있다.


이미지 아래쪽의 실제 파일 트리 예시는 src 폴더 안에 파일들이 어떤 순서로 배치되는지 보여준다.
main.jsx는 createRoot로 React 앱을 시작하고, App.jsx는 <Routes> 트리를 정의한다.
그 아래에 pages, components, layouts, routes, hooks, context, api, utils, assets, styles가 역할별로 나누어 배치된다.


이렇게 나누면 아래 장점이 있다.

  • 파일의 역할을 이름만 보고 예측할 수 있다.
  • 화면 코드와 서버 요청 코드가 섞이지 않는다.
  • 같은 컴포넌트를 여러 화면에서 재사용하기 쉽다.
  • 로그인 상태처럼 여러 곳에서 쓰는 값을 따로 관리할 수 있다.
  • 프로젝트 규모가 커져도 수정할 위치를 찾기 쉽다.

폴더 구조는 단순히 보기 좋게 정리하는 것이 아니라, 코드의 역할을 분리해서 유지보수하기 쉽게 만드는 기준이다.


화면을 담당하는 폴더

pages 폴더

pages 폴더는 화면 단위 컴포넌트를 모아 두는 곳이다.
화면 단위 컴포넌트는 사용자가 실제로 접속하는 한 페이지를 의미한다.
예를 들어 로그인 화면, 회원가입 화면, 마이페이지, 게시글 목록 화면처럼 주소와 연결되는 큰 화면이 여기에 들어간다.


pages 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • HomePage.jsx
  • LoginPage.jsx
  • PostPage.jsx
  • NotFoundPage.jsx

pages 폴더의 파일은 화면 전체의 흐름을 담당한다.
입력값을 받고, 필요한 컴포넌트를 배치하고, 버튼을 눌렀을 때 어떤 동작을 실행할지 연결한다.


다만 화면 컴포넌트가 모든 로직을 직접 들고 있으면 코드가 금방 복잡해진다.
그래서 데이터 요청, 로그인 확인, 폼 처리 같은 비즈니스 로직은 가능하면 hooks로 분리하고, pages는 화면 구성과 흐름 연결에 집중하는 것이 좋다.


예를 들어 LoginPage.jsx는 로그인 화면 전체를 담당한다.
아이디 입력칸, 비밀번호 입력칸, 로그인 버튼, 에러 메시지 출력 흐름이 이 화면 안에 들어갈 수 있다.
로그인 요청 로직은 useAuth나 authApi 같은 별도 로직으로 넘기면 화면 코드가 더 단순해진다.


pages는 사용자가 보는 “한 화면 전체”를 담당하고, 복잡한 로직은 훅이나 api 함수에 위임하는 폴더이다.


components 폴더

components 폴더는 여러 화면에서 재사용할 수 있는 작은 화면 조각을 모아 두는 곳이다.
component는 화면을 구성하는 부품이라는 뜻이다.
버튼, 입력창, 카드, 모달, 게시글 카드, 게시글 목록처럼 반복해서 쓰는 UI 조각이 여기에 들어간다.


components 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • common/Button.jsx
  • common/Modal.jsx
  • post/PostCard.jsx
  • post/PostList.jsx
  • user/UserProfile.jsx

components 안에서도 성격에 따라 다시 나눌 수 있다.
common 폴더에는 버튼, 모달처럼 여러 기능에서 공통으로 쓰는 범용 컴포넌트를 둔다.
post, user 같은 도메인 폴더에는 게시글이나 사용자처럼 특정 기능과 관련된 컴포넌트를 둔다.


pages와 components는 비슷해 보이지만 역할이 다르다.
pages는 한 화면 전체를 담당한다.
components는 그 화면 안에서 재사용되는 작은 부품을 담당한다.


예를 들어 게시글 목록 화면 전체는 PostPage.jsx에 둘 수 있다.
그 안에서 반복해서 사용하는 게시글 카드 하나는 PostCard.jsx로 분리할 수 있다.
이렇게 하면 다른 화면에서도 같은 게시글 카드를 다시 사용할 수 있다.


components는 여러 화면에서 반복되는 UI를 재사용하기 위해 분리하는 폴더이다.


layouts 폴더

layouts 폴더는 여러 페이지에서 공통으로 사용하는 화면 틀을 모아 두는 곳이다.
layout은 화면의 공통 배치 구조를 의미한다.
예를 들어 상단 헤더, 본문 영역, 하단 푸터처럼 여러 페이지에서 반복되는 큰 틀이 여기에 해당한다.


layouts 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • MainLayout.jsx
  • AuthLayout.jsx
  • Header.jsx
  • Footer.jsx

MainLayout은 일반 페이지에서 공통으로 쓰는 구조가 될 수 있다.
AuthLayout은 로그인, 회원가입처럼 인증 화면에서 쓰는 구조가 될 수 있다.


layout을 분리하면 페이지마다 헤더와 푸터를 반복해서 작성하지 않아도 된다.
페이지는 자기 내용만 담당하고, 공통 배치는 layout이 담당하게 된다.


여기서 중요한 컴포넌트가 <Outlet />이다.
<Outlet />은 중첩 라우팅에서 자식 화면이 들어올 자리를 미리 비워 두는 역할을 한다.
예를 들어 MainLayout 안에 Header, <Outlet />, Footer를 배치하면 헤더와 푸터는 유지되고, 주소에 따라 <Outlet /> 자리의 본문만 바뀐다.


layouts는 여러 페이지에서 반복되는 큰 화면 틀을 관리하고, <Outlet />을 통해 하위 화면이 들어올 자리를 제공하는 폴더이다.


routes 폴더

routes 폴더는 주소와 화면을 연결하는 라우팅 보조 코드를 관리하는 곳이다.
route는 사용자가 접속한 주소에 따라 어떤 화면을 보여줄지 정하는 규칙이다.


routes 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • ProtectedRoute.jsx
  • GuestRoute.jsx
  • index.jsx

ProtectedRoute.jsx는 로그인한 사용자만 접근할 수 있는 화면을 보호할 때 사용할 수 있다.
GuestRoute.jsx는 로그인하지 않은 사용자만 접근해야 하는 로그인, 회원가입 화면을 처리할 때 사용할 수 있다.
index.jsx에는 라우트 경로 상수를 모아 둘 수 있다.


예를 들어 PATHS.HOME = "/", PATHS.LOGIN = "/login"처럼 경로 문자열을 상수로 관리하면 오타를 줄일 수 있다.
나중에 주소가 바뀌어도 한 곳만 수정하면 되기 때문에 리팩터링도 편해진다.


라우터 관련 코드를 따로 분리하면 App.jsx가 너무 길어지는 것을 막을 수 있다.
또 어떤 주소가 어떤 조건으로 보호되는지 한곳에서 확인하기 쉽다.


routes는 주소와 화면 컴포넌트를 연결하거나, 접근 조건과 경로 상수를 관리하는 폴더이다.


로직과 상태를 담당하는 폴더

hooks 폴더

hooks 폴더는 여러 컴포넌트에서 재사용할 수 있는 로직을 모아 두는 곳이다.
hook은 React에서 상태나 생명주기 흐름을 함수 형태로 다룰 수 있게 해 주는 기능이다.


hooks 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • useFetch.js
  • useAuth.js
  • useForm.js
  • useModal.js
  • useToggle.js

예를 들어 로그인 여부를 확인하는 로직은 여러 화면에서 필요할 수 있다.
이 로직을 매번 화면 컴포넌트 안에 직접 작성하면 코드가 반복된다.
그래서 useAuth.js 같은 파일로 분리할 수 있다.


useFetch는 서버 통신 로직을 분리할 때 사용할 수 있다.
useForm은 폼 상태와 유효성 검사를 분리할 때 사용할 수 있다.
useModal은 모달 열림과 닫힘 상태를 분리할 때 사용할 수 있다.
useToggle은 true와 false를 바꾸는 단순 상태 전환 로직을 재사용할 때 사용할 수 있다.


화면 컴포넌트는 화면을 보여주는 데 집중하고, 반복되는 인증 확인이나 폼 처리 로직은 hook이 담당하게 된다.
이렇게 하면 로직을 재사용하기 쉽고, 테스트하기도 쉬워진다.


hooks는 여러 화면에서 반복되는 로직을 재사용하기 위해 분리하는 폴더이다.


context 폴더

context 폴더는 여러 컴포넌트가 함께 사용하는 전역 상태를 관리하는 곳이다.
전역 상태는 한 화면 안에서만 쓰는 값이 아니라, 여러 화면에서 함께 필요한 값이다.


예를 들어 로그인한 사용자 정보는 로그인 페이지, 마이페이지, 헤더, 보호 라우터에서 모두 필요할 수 있다.
이 값을 각 컴포넌트마다 따로 관리하면 로그인 상태가 서로 맞지 않을 수 있다.
그래서 AuthContext.jsx 같은 구조로 한곳에서 관리한다.


context 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • AuthContext.jsx
  • ThemeContext.jsx

AuthContext.jsx는 로그인 사용자 정보와 로그인, 로그아웃 함수를 제공할 수 있다.
ThemeContext.jsx는 다크 모드처럼 여러 화면에서 함께 쓰는 테마 상태를 제공할 수 있다.
이때 값을 하위 컴포넌트에 전달하는 Provider 컴포넌트도 같은 파일 안에 둘 수 있다.


context는 모든 상태를 넣는 곳이 아니다.
여러 컴포넌트에서 함께 필요하고, props로 계속 넘기기 번거로운 값만 넣는 것이 좋다.


context는 로그인 상태처럼 여러 화면에서 공유해야 하는 값을 관리하고, Provider로 하위 컴포넌트에 전달하는 폴더이다.


api 폴더

api 폴더는 서버와 통신하는 함수를 모아 두는 곳이다.
화면 컴포넌트 안에서 fetch()를 직접 여러 번 작성하면 서버 주소와 요청 방식이 여기저기 흩어진다.
그러면 주소가 바뀌었을 때 여러 파일을 모두 찾아 수정해야 한다.


그래서 서버 요청 함수는 api 폴더에 따로 둔다.
예를 들어 인증 요청은 auth.js, 게시글 요청은 post.js, 공통 요청 래퍼는 client.js처럼 나눌 수 있다.


api 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • client.js
  • auth.js
  • post.js

client.js에는 fetch 공통 래퍼를 둘 수 있다.
공통 래퍼는 매 요청마다 반복되는 설정을 한곳에 모아 둔 함수이다.
예를 들어 JWT 자동 첨부, 401 응답 처리, 공통 Content-Type 설정 같은 작업을 담당할 수 있다.


auth.js에는 로그인, 회원가입, 내 정보 조회 같은 인증 관련 API 함수를 모을 수 있다.
post.js에는 게시글 목록 조회, 상세 조회, 등록, 수정, 삭제 같은 게시글 관련 API 함수를 모을 수 있다.


이렇게 하면 화면 컴포넌트에서는 서버 주소를 몰라도 된다.
LoginPage는 authApi.login()만 호출하면 된다.
실제 주소와 요청 방식은 api 폴더에서 관리한다.


api는 서버 요청 코드를 화면 코드에서 분리해서 관리하는 폴더이다.


store 폴더

store 폴더는 규모가 커졌을 때 전역 상태 관리 도구를 사용하는 코드를 모아 두는 곳이다.
작은 프로젝트에서는 useState, useReducer, context만으로도 충분할 수 있다.
하지만 여러 화면에서 공유하는 상태가 많아지면 전용 상태 관리 도구를 사용할 수 있다.


예를 들어 Zustand, Jotai 같은 도구를 쓰거나, useReducer 기반으로 전역 store를 구성할 수 있다.
store는 항상 필요한 폴더는 아니다.
프로젝트 규모가 커지고 전역 상태가 복잡해졌을 때 선택적으로 둔다.


자원과 공통 기능을 담당하는 폴더

assets 폴더

assets 폴더는 이미지, 폰트, SVG 아이콘 같은 정적 자원을 모아 두는 곳이다.
정적 자원은 코드처럼 실행되는 것이 아니라 화면에서 사용되는 파일이다.


assets 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • 로고 이미지
  • 배너 이미지
  • SVG 아이콘 파일
  • 폰트 파일

이미지를 아무 곳에나 흩어 두면 나중에 파일을 찾기 어렵다.
그래서 화면에 쓰이는 정적 파일은 assets에 모아 두는 것이 좋다.


Vite 프로젝트에서는 src/assets 안에 둔 이미지나 아이콘이 빌드 과정에서 함께 처리된다.
빌드는 개발 중인 코드를 브라우저가 실행하기 좋은 형태로 묶고 최적화하는 과정이다.
그래서 assets 폴더에 정적 자원을 모아 두면 프로젝트 구조도 깔끔하고 빌드 흐름에서도 관리하기 쉽다.


utils 폴더

utils 폴더는 여러 곳에서 사용할 수 있는 공통 함수를 모아 두는 곳이다.
util은 특정 화면에만 묶이지 않는 작은 도구 함수라고 이해하면 된다.


예를 들어 날짜 형식을 바꾸는 함수, 문자열을 자르는 함수, 숫자에 콤마를 붙이는 함수는 여러 화면에서 사용할 수 있다.
이런 함수는 특정 page나 component 안에 두기보다 utils 폴더에 두는 것이 좋다.


utils 폴더의 함수는 화면을 직접 만들지 않는다.
대신 여러 화면에서 필요한 계산이나 변환 작업을 담당한다.
이 함수들은 가능하면 React에 의존하지 않는 순수 함수로 작성하는 것이 좋다.
순수 함수는 같은 값을 넣으면 항상 같은 결과를 돌려주는 함수이다.


utils는 여러 곳에서 반복해서 쓰는 공통 기능을 모아 두는 폴더이다.


styles 폴더

styles 폴더는 전역 스타일이나 공통 스타일 파일을 관리하는 곳이다.
스타일은 화면의 색상, 여백, 글자 크기, 배치 등을 정하는 코드이다.


styles 폴더에는 보통 아래와 같은 파일이 들어갈 수 있다.

  • global.css
  • variables.css
  • reset.css
  • 공통 레이아웃 스타일 파일

variables.css에는 색상, 여백, 글자 크기처럼 여러 곳에서 공통으로 쓸 CSS 변수를 둘 수 있다.
reset.css는 브라우저마다 다른 기본 스타일을 맞추기 위해 사용할 수 있다.
global.css는 전체 프로젝트에 공통으로 적용할 전역 스타일을 관리한다.


전역 스타일은 index.css에서 @import로 가져와 사용할 수 있다.
이렇게 하면 스타일 파일을 역할별로 나누면서도 앱 전체에 공통 스타일을 적용할 수 있다.


스타일 파일을 역할별로 나누면 화면 코드와 스타일 코드가 섞이지 않는다.
또 여러 페이지에서 같은 디자인 기준을 유지하기 쉽다.


프로젝트 구조를 나누는 기준 정리

화면, 로직, 통신, 자원으로 나누기

React 프로젝트 구조는 크게 네 가지 기준으로 나누어 볼 수 있다.

구분폴더역할
화면·레이아웃 계층pages, components, layouts, routes사용자가 보는 화면과 주소 연결을 담당한다
로직·상태·통신 계층hooks, context, api반복 로직, 전역 상태, 서버 요청을 담당한다
선택적 전역 상태 계층store규모가 커졌을 때 전역 상태를 별도로 관리한다
자원·유틸·스타일 계층assets, utils, styles이미지, 공통 함수, 전역 스타일을 담당한다

pages는 화면 전체를 담당한다.
components는 화면 안에서 재사용되는 작은 부품을 담당한다.
layouts는 여러 페이지가 함께 쓰는 큰 화면 틀을 담당한다.
routes는 주소와 화면 또는 접근 조건을 연결한다.


hooks는 여러 화면에서 반복되는 로직을 분리한다.
context는 로그인 상태처럼 여러 컴포넌트가 함께 쓰는 값을 관리한다.
api는 서버 요청을 한곳에 모아 화면 코드와 통신 코드를 분리한다.


assets는 이미지와 아이콘 같은 정적 파일을 관리한다.
utils는 공통 함수를 관리한다.
styles는 전역 스타일과 공통 스타일을 관리한다.


프로젝트 구조를 나누는 목적은 파일을 예쁘게 정리하는 것이 아니라, 각 코드의 책임을 분리해서 찾기 쉽고 수정하기 쉬운 구조를 만드는 것이다.


핵심 정리

React 프로젝트 구조에서 기억할 점

React 프로젝트는 화면이 커질수록 파일을 역할별로 나누어야 한다.
한 파일에 모든 코드를 넣으면 처음에는 편해 보이지만, 기능이 늘어날수록 수정과 재사용이 어려워진다.


핵심은 아래와 같다.

  • pages는 화면 전체를 담당한다.
  • components는 재사용 가능한 작은 UI를 담당한다.
  • layouts는 여러 페이지가 공유하는 큰 화면 틀을 담당한다.
  • routes는 주소와 화면 또는 접근 조건을 연결한다.
  • hooks는 반복되는 로직을 분리한다.
  • context는 여러 컴포넌트가 함께 쓰는 상태를 관리한다.
  • api는 서버 요청 함수를 모아 둔다.
  • store는 규모가 커졌을 때 전역 상태를 관리한다.
  • assets는 이미지와 아이콘 같은 정적 파일을 관리한다.
  • utils는 공통 함수를 관리한다.
  • styles는 전역 스타일과 공통 스타일을 관리한다.

React 프로젝트 구조는 “어떤 코드가 어떤 책임을 가지는지”를 기준으로 나누어야 한다.
이 기준을 잡아 두면 프로젝트가 커져도 파일을 찾고 수정하는 흐름이 훨씬 단순해진다.

0개의 댓글