// SSR 방식

import { ReactNode, useEffect } from "react";
import SearchableLayout from "@/components/SearchableLayout";
import { InferGetServerSidePropsType } from "next";
import BookItem from "@/components/BookItem";
import { GetServerSidePropsContext } from "next";
import fetchBooks from "@/library/fetchBooks";
import { BookData } from "@/types";

 export const getServerSideProps = async (
   context: GetServerSidePropsContext
 ) => {
   const q = context.query.q;
   const books = await fetchBooks(q as string);

   return {
     props: { books },
   };
 };

 export default function SearchPage({
   books,
 }: InferGetServerSidePropsType<typeof getServerSideProps>) {
   return (
     <div>
       {books.map((book) => (
         <BookItem key={book.id} {...book} />
       ))}

     </div>
   );
 }

 

// SSG 방식

import { ReactNode, useState, useEffect } from "react";
import SearchableLayout from "@/components/SearchableLayout";
import BookItem from "@/components/BookItem";
import fetchBooks from "@/library/fetchBooks";
import { BookData } from "@/types";
import { useRouter } from "next/router";


export default function SearchPage() {
  const [books, setBooks] = useState<BookData[]>([]);

  const router = useRouter();
  const q = router.query.q;

  const fetchSearchResult = async () => {
    const data = await fetchBooks(q as string);
    setBooks(data);
  };

  useEffect(() => {
    if(q) {
      fetchSearchResult()
    }
  }, [q]);

  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

SearchPage.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};

 

 

GetServerSidePropsContext

  • Next.js에서 서버 측 렌더링(SSR) 시 사용되는 컨텍스트 객체로, 요청과 관련된 정보를 제공
  • 서버 측에서만 사용 가능

특징

  • getServerSideProps 함수의 첫 번째 매개변수로 제공됨
  • URL의 쿼리 파라미터, 동적 경로 매개변수, 요청/응답 객체 등 서버 측 정보를 포함
  • 주로 SSR에서 데이터를 페칭하거나 URL에 따라 조건부 렌더링을 구현할 때 사용

 

 

 

useRouter()

  • React Hook으로, Next.js의 클라이언트 측에서 라우팅 정보를 가져오고 라우팅을 제어하기 위해 사용
  • 클라이언트 측에서만 사용 가능

특징

  • 브라우저 환경에서 실행되며, 현재 경로, 쿼리 파라미터, 라우팅 관련 메서드 등을 제공
  • 주로 페이지 컴포넌트나 클라이언트 사이드 렌더링(CSR)에서 사용

 

 

 

 

 

 

 

Next.js에서 SSR은 대표적인 장점이다.

그러나 페이지 요청이 있을 때 매번 정적 HTML을 생성/렌더링 하고, JS 번들을 생성하고, 필요하다면 백엔드 서버 데이터를 불러온다.

 

이 때 호출하는 데이터가 오래 걸리거나 크기가 크다면, 그만큼 대기 시간이 발생해 유저 경험을 저해할 수 있다.

시시각각 변하는 데이터가 아니고서는 매번 다시 렌더링하는 것은 비효율적이다.

 

 

이 때 SSG(Static Site Generation)을 적용해줄 수 있다.

  SSR (Server-Side Rendering) SSG (Static Site Generation)
HTML 생성 시점 해당 페이지 요청 시 빌드 시점
서버 부하 요청할 때마다 리소스 사용 서버 부하 없음
(CDN(Content Delivery Network에서 정적 파일 제공)
사용 함수 getServerSideProps getStaticProps
타입 InferGetServerSidePropsType
<typeof getServerSideProps>
InferGetStaticPropsType
<typeof getStaticProps>
데이터 항상 최신 빌드 시점 기준 데이터
사용자 맞춤 대시보드, 검색 결과 페이지 정적 콘텐츠, 자주 변하지 않는 데이터, 블로그, 회사 소개 페이지

 

 

 

 

 

사용 방법은 아래와 같다.

// SSR

export const getServerSideProps = async () => {
  const [allData, randomData] = await Promise.all([
    fetchData(),
    fetchRandomData(),
  ]);

  return {
    props: {
      allData,
      randomData,
    },
  };
};


export default function ExamplePage({
  allData,
  randomData,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  
  return (
  //후략
// SSG

export const getStaticProps = async () => {
  const [allData, randomData] = await Promise.all([
    fetchData(),
    fetchRandomData(),
  ]);

  return {
    props: {
      allData,
      randomData,
    },
  };
};


export default function ExamplePage({
  allData,
  randomData,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  
  return (
  //후략

 

 

 

이 상태 그대로 개발 모드에서 확인한다면 SSG가 적용되지 않는다.

SSG는 빌드 시점에 정적 페이지를 생성하기 때문이다.

 

 

프로젝트 빌드 후 다시 프로덕션 모드로 확인해보자.

npm run build // 빌드
npm run start // 프로덕션 모드

 

이 과정을 따르면 해당 ExamplePage는 SSG가 적용되어 요청한 백엔드 데이터를 미리 불러와 적용시킨 정적 페이지로 동작하게 된다.

 

 

 

 

빌드 후 로그 살펴보기

빌드 후 로그 끝부분에 다음과 같은 내용이 포함되어 있다.

각 페이지가 SSR, SSG 중 어떤 방식으로 처리되는지 보여준다.

 

○, ●, ƒ 이 세가지 기호에 주목하자.

 

 

● (SSG) : 조금 전 살펴본 SSG 방식이다.

ƒ (Dynamic) : SSR. 페이지가 요청될 때마다 다이나믹하게 렌더링 된다.

 

 

그렇다면 Static은 뭘까?

SSR, SSG 중 어떤 방식도 명시하지 않은 페이지의 경우, 자동으로 빌드 시 정적 페이지로 생성한다.

 

 

 

그럼 SSG가 Next.js의 디폴트라는 뜻인가?

조금 다르다.

SSG는 해당 페이지의 정적 페이지에 API 요청, 데이터베이스 쿼리, 파일 읽기 등 필요한 모든 처리를 포함시킨다.

말하자면 A 데이터를 뿌려주는 페이지라면, A 데이터가 포함된 페이지가 보여진다는 것이다.

 

그러나 Static의 경우, 데이터 호출 처리 없이 단순한 HTML 정적 페이지를 생성한다.

 

 

 

 

 

따라서 각 페이지마다 자신이 필요한 기능 및 사용자 경험에 맞추어 SSR, SSG, Static 중 선택하고 적용해야 한다.

 

 

 

 

 

 

 

Next.js 강의를 들으며 페이지 라우팅을 학습하던 도중, <Link> 태그와 useRouter() 사용법을 배웠다.

 

이 때 의문이 들었다.

 

 

 

아래처럼 쓰면 결국 똑같지 않나?
왜 두가지 방식이 존재할까?

//_app.tsx

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import Link from "next/link";
import { useRouter } from "next/router";

export default function App({ Component, pageProps }: AppProps) {
  
  const router = useRouter()
  
  const onClickButton = () => {
    router.push("/home")
  };


  return (
    <>
      <header>
        <Link href={"/home"}>	// 이 태그도 홈으로 이동이고,
          홈
        </Link>
       
        <div>
          <button onClick={onClickButton}>홈</button> // 이 버튼도 동일하게 홈으로 이동한다.
        </div>
      </header>
      
      <Component {...pageProps} />
    </>
  );
}

 

 

 

우선 a, Link 태그의 차이점을 살펴보자.

 

<a>

  • 브라우저가 완전히 새로운 HTML 문서를 가져온다.
  • 기존 페이지의 상태(State)를 초기화하고, 새로운 페이지를 렌더링한다.
  • 추가적인 서버 요청이 있기 때문에 성능 저하가 발생할 수 있다.
  • 외부 라우팅에 사용한다.

 

<Link>

  • Next.js에서 제공하는 컴포넌트로, 클라이언트 측에서 즉시 라우팅한다.
  • 자바스크립트 상태(State)가 유지된다.
  • 클라이언트 측에서 새로고침 없이, 필요한 데이터만 로드하여 속도가 빠르다.
  • 내부 라우팅에 사용한다.
  • Pre-Fetching이 일어난다.
    • Pre-Fetching: 현재 페이지 외에 이동할 가능성이 있는 페이지를 사전에 미리 불러오는 것. (페이지 이동속도 개선)

 

 

 


 

 

 

 

위 특징을 읽어보면 컴포넌트 간 이동을 하는 경우에는 Link 태그가 더 많은 장점을 가진 것으로 보인다.

그렇다면 맨 처음 질문처럼, useRouter()와는 어떤 차이가 있는지 알아보자.

 

 

useRouter()

  • 프로그래밍적 페이지 전환이 필요할 때 사용한다.
    • 프로그래밍적 페이지 전환?
      • 사용자가 직접 링크를 클릭하지 않아도 로직에 따라 페이지가 이동되는 경우.
      • 로그인 후 리다이렉트, 특정 조건이나 이벤트에 따라 페이지 전환이 되는 경우
  • push, replace, back 등 옵션을 통해 브라우저 히스토리를 조작할 수 있다.
  • 동적 URL 생성 등 더 유연한 라우팅이 가능하다.
  • 기본적으로 Pre-Fetching이 일어나지 않는다. (별도 처리로 가능)

 

 

 

따라서, 컴포넌트 간 페이지 전환은  Link를 사용하되, 어떤 로직이나 조건이 필요한 경우는 useRouter가 적합하다.

 

 

 

 

 


 

 

 

 

 

 

위에 잠시 언급된 Pre-Fetching에 대해 알아보자.

 

Pre-Fetching이란?

 

우선 Next.js가 페이지를 렌더링하는 방식은 아래와 같다.

  1. 프로젝트를 빌드하면 서버에서 페이지 별로 정적 HTML파일JavaScript 번들을 생성한다.
  2. 요청된 URL과 일치하는 페이지 컴포넌트의 HTML을 렌더링하고, 클라이언트에 보낸다.
  3. 이후 필요한 기능을 Javascript 번들로 전달하고 Hydration하여 동적 기능을 추가한다.
  4. 다른 페이지로 전환하는 경우, 해당 페이지에 필요한 HTML을 서버에서 추가로 전달한다.
  5. 이동한 페이지에 해당하는 JavaScript 번들을 받아와 Hydration을 진행한다.
  6. 반복...

 

 

 

그러나 페이지 전환이 잦을 경우, 이 과정이 짧은 시간동안 반복되면 서버 요청이 많아지고, 효율적이지 않다.

 

 

 

가령, 아래와 같은 페이지가 있다.

home/
├── menu/      
├── search/    
├── option/    
└── error/

 

  • menu와 search 페이지로의 이동 가능성이 높다.
  • option으로의 이동은 적다.
  • error은 특정 조건을 만족시켜야만 이동하는데, 이동 가능성이 높다. (useRouter() 사용)

 

그렇다면 자주 방문하게 되는 페이지를 미리 받아와두면 더욱 빠른 페이지 전환이 되겠구나!

 

그것이 Pre-Fetching이다.

  • 브라우저가 미리 데이터를 가져와 캐싱하거나, 리소스(스크립트, 이미지 등)를 다운로드해 사용자의 다음 행동에 대비한다.
  • 보통 다음과 같이 사용된다.
    • 최적화된 빌드 파일(e.g., bundle.js)을 대상으로 동작.
    • 특정 경로나 리소스를 미리 요청하고 캐시에 저장.
    • 사용자가 해당 리소스가 필요한 시점에 즉시 제공.

 

 

 

특히, Link 태그는 디폴트로 Pre-Fetching 기능이 적용되어있다.

따라서 자주 이동하는 페이지는 Pre-Fetching을 적용시키고, 그렇지 않는 페이지는 이를 방지함으로써 효율을 증대시킬 수 있다.

 

 

 

그럼 위에서 제시한 예시 구조는 아래처럼 생각할 수 있다.

home/
├── menu/      // Pre-fetching 적용
├── search/    // Pre-fetching 적용
├── option/    // Pre-fetching 해제
└── error/     // Pre-fetching 적용
import "@/styles/globals.css";
import { useEffect } from "react";
import type { AppProps } from "next/app";
import Link from "next/link";
import { useRouter } from "next/router";

export default function App({ Component, pageProps }: AppProps) {

  const router = useRouter();
  
  const onClickButton = () => {	// error 페이지로 이동하는 조건
    router.push("/error");
  };

  useEffect(() => {	// prefetch 강제 적용
    router.prefetch("/error"); 
  }, []);

  return (
    <>
      <header>
        <Link href={"/menu"}>
          메뉴
        </Link>
        <Link href={"/search"}>
          검색
        </Link>
        <Link href={"/option"} prefetch={false}> // prefetch 해제
          옵션
        </Link>
        <div>
          <button onClick={onClickButton}>
          	error페이지로 이동
          </button>
        </div>
      </header>
      
      <Component {...pageProps} />
    </>
  );
}

 

 

 

 

 


 

 

 

 

 

참고로 Pre-Fetching 관련 수정은 개발 환경에서 곧바로 확인할 수 없다.

npm run dev

 

개발 환경에서는 저장하더라도 리소스가 번들로 합쳐지지 않고, 각 파일이 별도로 요청되기 때문이다.

또한 브라우저가 Pre-Fetching한 파일이 변경될 가능성이 높고, 이에 따라 캐싱과 Pre-Fetching의 효율이 모두 떨어진다.

 

 

그러므로 꼭 빌드를 하고 Prod 환경에서 Pre-Fetching 여부를 확인해보자:)

개발자 도구 네트워크 탭에서 쉽게 확인할 수 있다.

// ctrl + D 로 서버 중지
npm run build
npm run start

 

 

 

 

Next.js의 최대 장점은 SSR(Server-Side-Rendering)을 통한 빠른 렌더링이 된다는 점이다.

 

 

그래서 필자는 Next.js를 쓰면 전부 Server Component이고, 빠르게 동작할 것이라고 짐작했다.

 

 

그러나 Server Component와 Client Component의 기능과 목적이 각각 다르고, 경우에 따라 선택해야 한다는 것을 알았다.

 

왜일까?

 

 

우선 각각의 기능을 살펴보자.

 

Client Component란?

 

  • 브라우저에서 클라이언트 측에서 실행되는 컴포넌트.
  • React의 기존 방식처럼 상태 관리, 이벤트 핸들링브라우저 상호작용에 필요한 기능을 제공.
  • 컴포넌트 최상단에 "use client" 지시자를 추가해야 클라이언트 컴포넌트로 동작한다.
'use client'

 

Server Client란?

  • 서버에서 렌더링되는 컴포넌트.
  • 클라이언트에 최적화된 HTML을 전달하여 초기 로드 시간을 단축하고 SEO 성능을 향상시킨다.
  • 클라이언트에 불필요한 JavaScript를 줄이고 서버에서 데이터를 처리할 수 있다.
  • React 훅(useState, useEffect, useReducer 등)을 사용할 수 없다.

 

 

그게 무슨 말인가...

말하자면 정적 컨텐츠는 Server, 동적 컨텐츠는 Client에 작성하는 것이다.

 

 

 

기본적으로 Server Component와 Client Component가 분리된 이유는 다음과 같다.

  • 성능 최적화
  • 사용자 경험 개선
  • 개발 생산성 증대

 

 

이렇게 쓰고 보니, Server Component로 작성할 경우가 많지 않아보인다.

예를 들면 어떤 경우에 써야할까?

특징 Client Component Server Component
렌더링 위치 브라우저 서버
Hook 사용 가능 불가능
브라우저 API 접근 가능 (window, localStorage, document 등) 불가능
데이터 Fetch 클라이언트에서 처리 서버에서 처리
SEO 클라이언트 렌더링 후 콘텐츠 제공 서버에서 정적 HTML 제공 (SEO에 더욱 유리함)
JS 번들 크기 클라이언트로 전달 JS 번들 전달 없이 정적 HTML만 전달
예시 상태 관리, 브라우저 전용 기능, 반응형 UI, 버튼 클릭 및 모달 팝업, 드롭다운, form 등 정적 페이지, 최초 로드가 중요한 페이지, 데이터 Fetching 및 이후 정적 렌더링할 페이지, SEO 최적화 등

 

 

따라서 어느 한쪽을 무조건 사용한다기 보다는, 혼합하여 사용하는 것이 권장된다.

 

// Server Component
export default function Page() {
  const data = await fetch("https://api.example.com/data").then((res) =>
    res.json()
  );

  return (
    <div>
      <h1>Server Component</h1>
      <p>Fetched Data: {data.title}</p>
      <ClientComponent />
    </div>
  );
}

// Client Component
"use client";

import { useState } from "react";

function ClientComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

 

 

 

 

 

input 태그에 유저 입력을 받고, 식물 데이터 중 검색한 단어와 일부 일치하는 이름의 식물만을 필터링 하려 한다.

 

 

컴포넌트 구조는 아래와 같다.

src/
├── Pages
│   ├── Home.tsx
│   └── DetailPage.tsx
├── Components
├── Header
│   ├── Nav.tsx
│   └── Input.tsx
├── Content.tsx
│   └── GardenList.tsx
└── ...

 

 

덧붙여, 라우팅 구조는 아래와 같다.

<Router>
  <Header /> //모든 페이지에 동일하게 렌더링 
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/detail/:name" element={<DetailPage />} />
  </Routes>
</Router>

 

 

말하자면,

input에 들어간 입력값(State)을 최상위 컴포넌트(App) 까지 끌어올리고, 그 아래 요소들에 최소 3번 이상의 뎁스를 거쳐 전달해야한다는

것이다.

 

Props Drilling이 발생하고 말았다.

 

이것을 해결하기 위해 상태관리 라이브러리를 사용한다.

Redux, Recoil, Zustand 등 여러 라이브러리가 있는데, 현 시점에서 사용량이 가장 많은 Redux를 써보기로 했다.

 

 

 

 

 

 


 

 

 

 

 

Redux 어떻게 쓰는걸까?

 

  1. Redux 설치하기
  2. Store 생성하기
    • 2-1 초기상태 정의
    • 2-2 Action type 정의 및 Action 객체 생성
    • 2-3 Reducer 정의
    • 2-4 Store 내보내기
    • 2-5 기타
  3. Provider로 프로젝트와 스토어 연결하기
  4. useDispatch로 상태관리 하려던 부분 연결하기

 

1. Redux 설치하기

우선 해당 라이브러리를 설치한다.

// npm 사용 시
npm install redux react-redux

// yarn 사용 시
yarn add redux react-redux

 

 

설치가 되었다면 package.json 파일에 의존성이 추가되었는지 확인한다.

"dependencies": {
  "redux": "^4.2.1",
  "react-redux": "^8.0.5",
}

 

 

 

 

알아보니 원래 redux만 설치하는 것이 표준이었던 모양인데... 호환이 잘 되지 않는다.
최근에는 불변성 관리 등을 보다 효율적으로 해낼 수 있도록 업데이트된 redux toolkit 사용을 권장한다고 한다.
다음 글에서는 redux toolkit를 토대로 이 글을 다시 작성하고, 이 둘의 차이점을 짚어보려 한다.

 

 

 

 


 

 

 

 

2-1. Store 생성하기 - 초기상태 정의

src 폴더 하위에 redux 폴더를 생성하고, store.ts 파일을 추가한다.

 

모든 파일을 종류별로 묶어 폴더에 각각 저장하는 것이 좋지만, 이 글 작성의 편의를 위해 우선 store.ts에 모두 쓰도록 하겠다.

 

 

우선 나의 프로젝트의 경우, 관리하고자 하는 상태는 2가지이다.

  1. input 입력값
  2. 렌더링될 gardenData 배열

 

이를 바탕으로 초기값(initialState)을 할당해준다.

// 초기상태
const initialState = {
  gardenData: [],
  searchQuery: "",
};

 

 

2-2. Store 생성하기 - Action type 정의 및 Action 객체 생성

Redux에서 action 객체란?

상태 변경에 필요한 정보를 담은 상자라고 생각하면 쉽다.

이 상자 안에는 type, payload 두가지 상자가 있다.

 

// action 객체 기본형 예제

// action type 선언
const EXAMPLE_ACTION_OBJECT = "EXAMPLE_ACTION_OBJECT" // 이 둘의 철자가 반드시 같아야 한다

// action 생성
export const exampleActionObject = (payload: any) => ({ // payload의 타입은 임시로 선언했다
  type: EXAMPLE_ACTION_OBJECT,
  payload,
});

 

이로써 action 객체를 성공적으로 생성한 것이다!

 

 

세부 설명은 접은 글을 참고하기 바란다.

더보기

그래서 대체 Action 객체가 뭐냐고!

 

필자는 type과 payload의 역할이 무엇인지부터 이해하는데 며칠 헤매고 말았다.

 

type : 무슨 작업을 해야하는지 작성하는 칸

payload : 그 작업에 필요한 데이터를 넣는 칸

 

아무래도 type이 Typescript의 "type"과 헷갈린 듯하다.

그 타입이 아니라... "어떤 함수랑 연결할래?" 라는 뜻이다.

 

 

또한 첫 줄에 action type 선언부를 보면, 왼쪽 상수의 이름과 오른쪽 문자열 값이 동일해야한다.

이런 형태 또한 의아했다... '왜 그냥 string이라고 하지 않고?'

당연하다...

연결할 함수의 이름을 달아두는 것이다.

 

type이 아니라 name이라고 이해하면 빠르겠다.

 

풀어 쓰자면-

  type: action을 식별하는 역할. Reducer에서 이 type을 기준으로 상태를 변경한다.

  payload: action을 수행하는 데에 전달되는 매개변수

 

 

 

Action 객체에 대한 예제를 하나 덧붙인다.

const SET_SEARCH_QUERY = "SET_SEARCH_QUERY"	// 액션 타입 선언

const setSearchQuery = (example) => ({
  type: "SET_SEARCH_QUERY", // 액션 타입 기재
  payload: example          // 업데이트할 데이터를 payload로 전달
});

const action = setSearchQuery("장미")

console.log(action)
// 출력: { type: "SET_SEARCH_QUERY", payload: "장미" }

 

거듭 강조하자면 액션 타입 선언부의 좌항과 우항의 철자는 반드시 같아야 한다.

 

왜 상수로 선언할까?

그냥 문자열로 지정한다면 오타로 인한 오류 추적이 어려워진다.

이것을 상수로 선언함으로써 오타 가능성을 낮추고, 안정성을 높일 수 있다.

 

 

그렇다면 action 객체와 action type의 이름 또한 같아야 할까?

그렇지는 않다.

다만 코드 가독성을 높이기 위해 네이밍을 통일할 뿐이다.

 

 

 

 

 

이제 내 프로젝트에 맞춰 2가지 상태에 대한 action 객체를 생성해준다.

// 액션 타입 선언
const SET_GARDEN_DATA = "SET_GARDEN_DATA"; 	// 렌더링할 데이터 상태
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY";	// 인풋 데이터 상태

// 액션 객체 생성
export const setGardenData = (payload: string) => ({
  type: SET_GARDEN_DATA,
  payload,
});

export const setSearchQuery = (payload: string) => ({
  type: SET_SEARCH_QUERY,
  payload,
});

 

 

 

 

 

2-3. Store 생성하기 - Reducer 정의

Reducer는 Redux에서 상태를 변경하는 순수 함수이다.

쉽게 말해 action 객체를 일종의 명령으로 받고, state새로운 값으로 받아 새로운 상태를 반환한다.

 

 

아래는 reducer 작성 예제이다.

// reducer 작성 방법

const reducer = (state, action) => {
  switch (action.type) {
    case "ACTION_TYPE_1":	// 액션 타입에 따라 다른 동작 수행
      // 상태 업데이트 로직
      return { ...state, updatedKey: newValue };

    case "ACTION_TYPE_2":
      // 상태 업데이트 로직
      return { ...state, anotherKey: anotherValue };

    default:
      // 아무 액션도 매칭되지 않으면 기존 상태 반환
      return state;
  }
};

 

 

단순히 switch 문을 통해 각 타입(실행할 함수)에 따라 분기처리를 하면 된다.

주의할 점은, Reducer는 불변성을 지키기 때문에 상태를 직접 변경하지 않으므로, 배열을 업데이트 하는 경우 스프레드 연산자를 통해 배열을 복사한 뒤 업데이트한다.

 

// 리듀서 정의
const gardenReducer = (state = initialState, action: any) => { // 초기값 적용, action의 타입은 임시
  switch (action.type) {
    case SET_GARDEN_DATA:	// 렌더링 데이터 배열 상태의 경우
      return {
        ...state, // 기존 상태를 복사한 후
        gardenData: action.payload, // gardenData의 상태를 업데이트
      };
    case SET_SEARCH_QUERY:	// 인풋 상태가 바뀐 경우
      return {
        ...state,
        searchQuery: action.payload, // searchQuery의 상태를 업데이트
      };
    default:
      return state;
  }
};

 

 

2-4. Store 생성하기 - Store 내보내기

createStore을 임포트하여 생성한 리듀서를 담아 내보낸다.

import { createStore } from "redux";

const store = createStore(gardenReducer);

export default store;

 

 

 

 

2-5. Store 생성하기 - 기타

더보기

이 부분은 optional하니 스킵해도 좋다.

 

 

reducer 함수가 여러개인 경우

combineReducers 를 임포트하여 합쳐준다.

현재 프로젝트의 경우 리듀서가 하나만 필요하지만, 두개라고 가정한다면 아래와 같다.

import { combineReducers } from "redux";

const userReducer = (state = { isLoggedIn: false }, action) => { // 로그인 여부 상태관리를 가정
  switch (action.type) {
    case "LOGIN":
      return { ...state, isLoggedIn: true };
    default:
      return state;
  }
};

const gardenReducer = (state = { gardenData: [] }, action) => {
  switch (action.type) {
    case "SET_GARDEN_DATA":
      return { ...state, gardenData: action.payload };
    default:
      return state;
  }
};

const rootReducer = combineReducers({ // 두개의 리듀서를 하나의 객체로 모아주기
  user: userReducer, // user 상태 관리
  garden: gardenReducer, // garden 상태 관리
});



// 이렇게 작성하면 전체 상태 트리는 아래와 같다.
{
  user: { isLoggedIn: false },
  garden: { gardenData: [] },
}

 

 

Store 내보내기 수정

createStore를 임포트하고, 리듀서를 담아준다.

import { createStore } from "redux";

const store = createStore(rootReducer);

export default store; 	// 완성된 스토어 내보내기

 

 

 

RootState 타입 정의

이대로 내보내고 사용하려 하면 RootState의 타입을 지정하라는 타입스크립트 에러가 나타난다.

따라서 아래 코드를 추가해줌으로써 해결한다.

 

export type RootState = ReturnType<typeof store.getState>;

 

 

 

 

아래는 GPT의 도움을 받은 부분으로, 추후 내용을 추가할 수 있다.

RootState란?
- Redux 스토어의 전체 상태 트리의 타입을 정의합니다.
- store.getState()의 반환 타입을 TypeScript가 추론하여, 전체 상태 구조를 RootState로 정의합니다.

ReturnType이란?
- TypeScript의 유틸리티 타입으로, 함수가 반환하는 값의 타입을 추론합니다.typeof store.getState의 반환 타입이 전체 - 상태 구조이므로 이를 RootState로 사용합니다.

 

 

const userReducer = (state = { isLoggedIn: false }, action) => {
  switch (action.type) {
    case "LOGIN":
      return { ...state, isLoggedIn: true };
    default:
      return state;
  }
};

const gardenReducer = (state = { gardenData: [] }, action) => {
  switch (action.type) {
    case "SET_GARDEN_DATA":
      return { ...state, gardenData: action.payload };
    default:
      return state;
  }
};

const rootReducer = combineReducers({
  user: userReducer, // user 상태 관리
  garden: gardenReducer, // garden 상태 관리
});

 

 

 

 

최종 코드

이로써 아래와 같이 store.ts 가 완성되었다.

그러나 이것은 현재 지원되지 않을 수 있는 구버전의 방식이기 때문에, 다음 글에서 신버전으로 다시 써보도록 하겠다.

(그렇다고 개념 자체가 달라지는 것은 아니라, 오히려 간소화시키는 작업이기 때문에 이 내용을 숙지하고 넘어가는 것이 좋다)

import { createStore, combineReducers } from "redux";

// 초기상태
const initialState = {
  gardenData: [],
  searchQuery: "",
};

// 액션 타입
const SET_GARDEN_DATA = "SET_GARDEN_DATA";
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY";

// 액션 생성
export const setGardenData = (payload: string) => ({
  type: SET_GARDEN_DATA,
  payload,
});

export const setSearchQuery = (payload: string) => ({
  type: SET_SEARCH_QUERY,
  payload,
});


//리듀서 정의
const gardenReducer = (state = initialState, action: any) => {
  switch (action.type) {
    case SET_GARDEN_DATA:
      return {
        ...state, // 기존 상태를 복사한 후
        gardenData: action.payload, // gardenData 상태 업데이트
      };
    case SET_SEARCH_QUERY:
      return {
        ...state,
        searchQuery: action.payload, // searchQuery 상태 업데이트
      };
    default:
      return state;
  }
};

// 여러 리듀서를 관리할 수 있도록 combineReducers 사용
const rootReducer = combineReducers({
  garden: gardenReducer,
});

const store = createStore(rootReducer);

export default store;

// RootState 타입 정의
export type RootState = ReturnType<typeof store.getState>;

 

 

 

 

 

농사로 API를 활용하여 식물별 난이도, 성장 속도, 광도, 습도에 따라 필터링 할 수 있는 서비스를 만들고 있다.

총 3개의 API를 불러올텐데, 이들은 동일한 cntntsNo(컨텐츠 번호) 항목을 가진다.

따라서 이들을 각각 불러와 cntntsNo을 기준으로 합치고, 매핑해보도록 한다.

 

 

 


 

 

 

API 호출 로직은 아래와 같다.

우선 실내정원용 식물 목록을 호출한다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 

  const fetchGardenList = async () => {
    setLoading(true); //작업이 끝날 때까지 로딩
    try {
      const apiKey = process.env.REACT_APP_API_KEY;
      const parser = new XMLParser();

      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );

      // XML을 JSON으로 변환
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];


      setGardenList(gardenListItems);
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false); //로딩 끝
    }
  };

 

 

이 글에서 자세히 설명하지는 않겠지만, XML을 JSON으로 변환하는 데에는 fast-xml-parser을 사용했다.

 

 

 

 

 

 

이제 실내정원용 식물 상세, 실내정원용 식물 첨부파일 목록도 똑같이 불러온다.

(가독성을 위해 타입 선언은 잠시 생략했다)

//상세정보 목록
  const gardenDetailResponse = await axios.get(
    `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
  );

  const gardenDetailJson = parser.parse(gardenDetailResponse.data);
  const detailInfo = gardenDetailJson?.response?.body?.item || {};

//첨부파일 목록
  const gardenFileListResponse = await axios.get(
    `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
  );

  const gardenFileListJson = parser.parse(gardenFileListResponse.data);
    let fileList = gardenFileListJson?.response?.body?.items?.item || [];

 

 

 

 

 

 

이것을 위 fetchGardenList 함수에 옮겨심는다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 

  const fetchGardenList = async () => {
    setLoading(true); //작업이 끝날 때까지 로딩
    try {
      const apiKey = process.env.REACT_APP_API_KEY;
      const parser = new XMLParser();
      
      //실내정원용 식물 목록
      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );
      // XML을 JSON으로 변환
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];
    
    
      //상세정보 목록
      const gardenDetailResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
      );
      // XML을 JSON으로 변환
      const gardenDetailJson = parser.parse(gardenDetailResponse.data);
      const detailInfo = gardenDetailJson?.response?.body?.item || {};

      //첨부파일 목록
      const gardenFileListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
      );
      // XML을 JSON으로 변환
      const gardenFileListJson = parser.parse(gardenFileListResponse.data);
        let fileList = gardenFileListJson?.response?.body?.items?.item || [];

      setGardenList(gardenListItems);
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false); //로딩 끝
    }
  };

 

 

지금 fetchGardenList를 호출하여 gardenList를 확인하면 어떻게 될까?

당연히 실내정원용 식물 목록만 나온다.

마지막 setGardenList에 GardenListItems (가공된 GardenListResponse) 가 반환되도록 써두었기 때문이다.

 

 

 

이제 이 3개의 API로 불러들인 데이터를 합쳐보자.

 

gardenListItems의 각 항목을 item으로 받고, 이 중 공통으로 가지고 있는 값인 cntntsNo를 요청 변수에 넣어준다.

이 점을 토대로 map 메소드와 Promise.all을 실행하면 일종의 반복문이 수행된다.

 

Promise.all이란?

Promise.all은 여러 개의 비동기 작업을 병렬로 실행하고, 모든 작업이 완료될 때까지 기다리는 메소드이다.

모든 작업이 fufilled 상태여야만 성공으로 간주하고 각각의 작업을 배열로 반환한다.

반면 하나라도 실패한다면 rejected를 반환한다.

 

아래처럼 여러 개의 비동기 작업을 수행할 때, 유용하게 사용할 수 있다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 


  const fetchGardenList = async () => {
    setLoading(true);
    try {
      const apiKey = process.env.REACT_APP_API_KEY;

      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );

      const parser = new XMLParser();
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];

      //각 item에 대해 gardenDtl와 gardenFileList API를 동시에 호출하여 병렬 작업
      const detailedItems: GardenItemProps[] = await Promise.all(
        gardenListItems.map(async (item: any) => {
          const cntntsNo = item.cntntsNo;

          const gardenDetailResponse = await axios.get(
            `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
          );

          const gardenDetailJson = parser.parse(gardenDetailResponse.data);
          const detailInfo: DetailInfoProps = gardenDetailJson?.response?.body?.item || {};

          const gardenFileListResponse = await axios.get(
            `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
          );

          const gardenFileListJson = parser.parse(gardenFileListResponse.data);
          let fileList: FileItemProps[] =
            gardenFileListJson?.response?.body?.items?.item || [];
            
          if (!Array.isArray(fileList)) {
            fileList = [fileList];
          }

          return { //새로운 배열로 반환
            ...item,
            detailInfo,
            fileList,
          };
        })
      );

      setGardenList((prev) => [...prev, ...detailedItems]); //모든 데이터 묶기
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false);
    }
  };

 

 

 

 

코드 흐름

gardenListResponse를 호출하여 JSON으로 변환한 값을 gardenListItems에 저장한다.

나머지 2개의 호출을 각 gardenListItems의 개별 item의 cntntNo(식물 고유코드)에 매핑하여 새로운 배열을 만든다.

+ 이 때 fileList 데이터가 객체 형태인 경우가 있어, 별도로 배열화하는 로직을 추가했다.

마지막으로 기존 배열에 detailedItems 배열을 밀어넣어 3개 호출이 모두 포함된 배열을 완성한다.

 

 

 

 

 

 

 

 

setList((prev) => [...prev, ...additionalItems]);

 

위와 같은 스프레드 연산자가 나열된 구조만 보면 머리가 하얘져서 남겨본다.

 

 

쉽게 생각하자.

스프레드 연산자는 배열, 또는 객체의 요소를 펼치거나 복사할 때 사용한다.

 

 

예시로 두개의 배열을 합쳐보자.

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

const arrays = [...arr1, ...arr2];
console.log(arrays); 
// [1, 2, 3, 4, 5, 6]

 

이번에는 복사해보자. 말하자면 똑같이 생긴 새로운 배열을 만드는 것이다.

const originalArr = [1, 2, 3];
const copiedArr = [...originalArr];	// 복사

console.log(copiedArr); 
// [1, 2, 3]

 

 

이번에는 객체를 합쳐보자.

const obj1 = { name: 'A', age: 25 };
const obj2 = { job: 'Developer', city: 'Seoul' };
const result = { ...obj1, ...obj2 };

console.log(result); 
// { name: 'A', age: 25, job: 'Developer', city: 'Seoul' }

 

굳이 객체 + 객체 형태가 아니어도, 내용을 직접 넣을 수도 있다.

const originalObj = { name: 'A', age: 25 };
const updatedObj = { ...originalObj, age: 30, city: 'Seoul' }; // 직접 추가

console.log(updatedObj); 
// { name: 'A', age: 30, city: 'Seoul' }

 

 

또한 함수 호출 시, 배열의 각 요소를 함수의 개별 인자로 전달할 때 사용할 수 있다.

const numbers = [1, 2, 3];

function add(a, b, c) {
  return a + b + c;
}

console.log(add(...numbers)); 
// 6

 

 

 

 

다시 맨 처음으로 돌아가서-

스프레드 연산자는 무한스크롤 구현할 때 반드시 필요한데, 침착하게 대응해보자.

const [list, setList] = useState<listItemProps[]>([])
// listItemProps에 지정한 타입형태의 요소로 이루어진 배열. 초기값은 빈 배열

setList((list) => [...list, ...additionalItems]);
// 기존 list에 additionalItems을 더한다.

 

prev는 상태값 list에 들어있던 배열, 또는 객체를 말한다.

말하자면 그냥 상태값을 넣어도 똑같은 식이 된다.

 

 

 

 

 

 

 

공공 API를 호출하는 로직을 작성했는데, 뒤이어 CORS 에러가 발생했다.

 

import { useEffect, useState } from "react";
import axios from "axios";

const GardenList = () => {
  const [gardenList, setGardenList] = useState<GardenItem[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchGardenList = async () => {
      try {
        const apiKey = process.env.REACT_APP_API_KEY;
        const response = await axios.get(`http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&pageNo=1&numOfRows=50`);
        
        const items = response.data?.response?.body?.items?.item || [];
        setGardenList(items);
      } catch (error) {
        console.error('Error fetching garden list:', error);
        setError("An error occurred while fetching the data.");
      } finally {
        setLoading(false);
      }
    };

    fetchGardenList();
  }, []);

 

 

 

CORS 에러란?

CORS (Cross Origin Resource Sharing): 교차 출처 리소스 공유

 

간단히 말해 출처가 같은 경우에만 서버간 정보 공유를 할 수 있다는 정책이다.

 

 

 

URL은 아래와 같은 구조로 이루어져있다.

http://www.example.com:80/about?q=likes

프로토콜   Host   포트번호   Path   Query string

 

 

추가로, 특정 번호로 명시된게 아니라면 기본 포트번호는 보통 생략되어있다.

http는 80번, https는 443번이 디폴트이다.

 

 

 

그렇다면 이 중 동일 출처는 무엇일까?

프로토콜, 호스트, 포트번호가 같아야 동일 출처로 인정된다.

브라우저 콘솔창에 location.origin을 입력하면 출처를 확인할 수 있다.

 

 

 

+

출처가 같은지 비교하는 로직은 서버가 아닌 브라우저에서 수행한다.

따라서 CORS 정책을 위반하는 요청을 보내더라도, 서버에서는 정상적으로 응답한다.

후에 브라우저에서 이 응답을 분석하여 CORS 정책을 준수했는지 확인한다.

 

 

 

 

 

해결 방법

CORS 헤더를 통해 서버가 요청을 허용하겠다는 신호를 브라우저에게 보내준다.

 

서버단에서 Access-Control-Allow-Origin이라는 헤더에 요청을 허용할 출처(Origin)를 명시할 수 있다.

Access-Control-Allow-Origin 헤더에 유효한 값을 포함하면, 브라우저는 이를 신뢰한다.

  • 사용 예: Access-Control-Allow-Origin:http://www.example.com (특정 도메인만 허용함)
  • Access-Control-Allow-Origin: * 을 사용하여 모든 출처의 요청을 허용할 수도 있지만, 보안 상 권장되지 않는다.

 

위 방법은 백엔드 지식이 있어야 사용 가능해 보인다.

(Node.js를 아직 접해보지 못한 필자는 2시간 정도 애쓰다가... 다음 방법을 선택했다)

 

 

 

그러나 나는 프론트엔드 밖에 모른다면...

이미 만들어져있는 프록시 서버, 그 중 cors-anywhere 를 이용해보자.

 

 

cors-anywhere란?

브라우저가 아닌, 중간의 프록시 서버에서 요청을 중계하는 방식으로 CORS 에러를 해결해준다.

cors-anywhere 서버가 요청을 대신 보내고, 서버에서 응답을 받아 클라이언트로 전달한다...

즉, 브라우저가 다른 출처에 직접적으로 접근하지 않아 CORS 에러를 우회하는 것.

 

 

작동 방식은 아래와 같다.

  1. 클라이언트에서 API 요청을 보내려 하는 주소 앞에 cors-anywhere 프록시 URL을 추가한다.
  2. 프록시 서버가 요청을 대신 보내고, 받은 응답을 클라이언트로 다시 전송한다.
  3. 브라우저는 응답을 받기만 하기 때문에 CORS 에러 없이 데이터를 받아올 수 있게 된다.

 

다음은 설치 및 사용 방법이다.

// 현재 프로젝트 루트 디렉토리로 이동
git clone https://github.com/Rob--W/cors-anywhere.git
cd cors-anywhere // 내부 cors-anywhere 디렉토리로 이동
npm install // 의존성 설치

node server.js // 서버 실행

 

위처럼 설치 및 실행에 성공했다면, 기존 코드에 헤더를 덧붙인다.

import { useEffect, useState } from "react";
import axios from "axios";

const GardenList = () => {
  const [gardenList, setGardenList] = useState<GardenItem[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchGardenList = async () => {
      try {
        const apiKey = process.env.REACT_APP_API_KEY;
        const response = await axios.get(`http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&pageNo=1&numOfRows=50`);
        				// 헤더를 덧붙인다!
        const items = response.data?.response?.body?.items?.item || [];
        setGardenList(items);
      } catch (error) {
        console.error('Error fetching garden list:', error);
        setError("An error occurred while fetching the data.");
      } finally {
        setLoading(false);
      }
    };

    fetchGardenList();
  }, []);

 

 

이제 평소처럼 로컬 서버를 실행하면, CORS 에러가 해결된 것을 확인할 수 있다.

yarn start

 

 

 

 

 

+ 한가지 더

참고로 로컬서버를 내린다거나, 브라우저를 끈 뒤 다시 실행하는 경우 CORS 에러가 다시 나타난다.

이것은 cors-anywhere 프록시 서버 또한 내려갔는데, 다시 실행하지 않았기 때문이다.

침착하게 프록시 서버를 올리고, 그 후 로컬 서버를 올려 테스트하자.

 

 

 

 

참고

Brie.log

이모저모

+ Recent posts