[Next.js 탐험기] 6. Next.js + 티스토리 API 블로그 만들기 - fetch API및 useState, useEffect를 사용하여 데이터 추가하기

서론

전 포스팅 까지는 src 폴더 밑에 data 폴더를 만들어서 사용했지만, 알고보니 next.js에서는 public 폴더 밑에 데이터를 fetch()로 쉽게 가져올 수 있을 뿐더러, require()를 사용한 파일 데이터 사용에 대해 권장하지 않는다는 것을 이제 알게되었다. 때문에 본 포스팅을 따라하고 있다면, 티스토리 OPEN API를 사용하여 json 형식의 데이터 파일을 만드는 장소를 next.js 프로젝트의 public으로 바꾸어 진행하자.

 

클라이언트 사이드에서 fetch를 사용한 json 데이터 읽어오기

클라이언트 사이드에서 JSON 파일을 불러오려면, 파일을 public 폴더에 저장하고 fetch API를 사용하여 요청할 수 있다. 이 방법은 클라이언트 사이드에서만 실행되며, 페이지가 로드될 때 실행된다. 사용법은 간단하다. useEffect를 사용하여 컴포넌트가 마운트될 때 JSON 파일을 불러온다. fetch 함수는 비동기적으로 파일 내용을 가져오고, then 메소드를 사용하여 응답을 JSON으로 변환한 다음, 이를 상태에 저장한다.

// pages/category/[categoryId].js
import React, { useState, useEffect } from 'react';

const CategoryPage = ({ categoryId }) => {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(`/data/categories/${categoryId}.json`)
      .then(response => response.json())
      .then(data => setData(data));
  }, [categoryId]);

  if (!data) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {/* 여기에서 JSON 데이터를 사용 */}
    </div>
  );
};

export default CategoryPage;


데이터 파일을 public으로 옮겨온 이유

Next.js 프로젝트에서 local project에 있는 파일 데이터를 읽어오기 위해  fetch API를 사용한다면, 그 경로는 public 폴더 안에 있는 정적 파일을 참조 한다. require 또는 import 구문은 서버 사이드에서 실행되거나 빌드 시간에 처리된다. 때문에 require 또는 Import 구문은 클라이언트 사이드에서 public 폴더의 정적 파일을 동적으로 불러올 때에는 적합하지 않다.

 

내가 next.js를 사용해서 본 토이 프로젝트를 진행하는 이유는, 서버 사이드 랜더링 보다는, 클라이언트 사이드 랜더링을 좀 더 체감하며 만들어보고 싶어서였다. 때문에 fetch API를 활용한 데이터 참조 및 가공 기법을 사용하기 위해 데이터 파일을 public 폴더로 바꾸었다.

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

const CategoryPage = ({ categoryId }) => {
  const [categoryLists, setCategoryLists] = useState(null);

  useEffect(() => {
    // fetch API를 사용하여 파일 불러오기
    fetch(`/data/categories/${categoryId}.json`)
      .then(response => response.json())
      .then(data => setCategoryLists(data))
      .catch(error => console.error('Error loading category lists:', error));
  }, [categoryId]);

  if (!categoryLists) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {/* 여기에서 categoryLists 데이터를 사용 */}
    </div>
  );
};

export default CategoryPage;

이 코드에서는 fetch를 사용하여 public/data/categories 디렉토리에서 JSON 파일을 불러온다. 파일의 내용은 비동기적으로 로드되며, 로드가 완료되면 응답을 JSON으로 변환하고 상태에 저장한다.

 

fetch API는 클라이언트 사이드에서 실행된다. 만약 우리가 서버 사이드에서 파일 데이터를 불러오고 싶다면, Node.js의 파일 시스템 모듈(fs)을 사용하여 getStaticProps 또는 getServerSideProps에서 처리해야 하는데, 이는 next.js 13 이하 버전의 page router 에서 작동하고, app router 방식의 13 이상 버전에서는 사용 방식이 변한것도 있고 없어진 기능도 있다. 당신이 서버 사이드에서 파일 데이터를 불르고 가공하는 연습을 하고 싶다면, 13 미만 버전을 사용해보는것을 권장한다.

 

public 폴더 내의 파일은 빌드 시 Next.js에 의해 정적 파일로 처리되어, 프로젝트의 루트 URL (/) 아래에서 직접 접근할 수 있다. 예를 들어, public/data/example.json 파일은 /data/example.json URL을 통해 쉽게 접근할 수 있게된다.

 

마지막으로, fetch API를 사용할 때 예외 처리를 고려하는 것이 좋다. (catch 구문 사용)

 

 

Fetch API 2중으로 사용해 data 가공하기

fetch를 사용하여 categories JSON 파일을 불러오고, 그 다음 각 카테고리에 해당하는 posts JSON 파일을 불러오는 예제 코드를 만들어 봤다. 이때, 각 포스트 파일에서 가져온 content 속성을 categoryList 객체의 description 필드로 추가하여 전 포스팅에서 허전하게 나왔던 title 밑에 붙여주도록 한다.

import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import styles from './[subCategoryName]/page.module.css';

const CategoryPage = () => {
    const pathname = decodeURI(usePathname());
    const params = useSearchParams();
    const [categoryLists, setCategoryLists] = useState(null);

    useEffect(() => {
        // 카테고리 JSON 파일을 불러옵니다.
        fetch(`/data/categories/${params.get('categoryId')}.json`)
            .then(response => response.json())
            .then(async data => {
                // 각 카테고리에 대한 추가적인 데이터를 불러옵니다.
                const updatedCategoryLists = await Promise.all(data.map(async categoryList => {
                    const response = await fetch(`/data/posts/${params.get('categoryId')}/${categoryList.id}.json`);
                    const postData = await response.json();
                    return { ...categoryList, description: postData.content };
                }));

                setCategoryLists(updatedCategoryLists);
            })
            .catch(error => console.error('Error loading category lists:', error));
    }, [params]);

    if (!categoryLists) {
        return <div>Loading...</div>;
    }

    return (
        <>
            <div className={styles.head}>여기는 {pathname.split("/").pop()} 카테고리 페이지입니다.</div>
            <div className={styles.container}>
                {categoryLists.map((categoryList, index) => (
                    <div key={index} className={styles.list}>
                        <div className={styles.listHead}>
                            <Link href={categoryList.postUrl} target='_blank'>{categoryList.title}</Link>
                        </div>
                        <div>
                            {categoryList.description}
                        </div>
                    </div>
                ))}
            </div>
        </>
    );
};

export default CategoryPage;

 

소스를 잠깐 설명하자면, 최초로 categories JSON 파일을 불러온다. 각 카테고리에 대해 해당 id를 사용하여 posts JSON 파일을 불러온 후, 각 posts JSON 파일에서 content 속성을 가져와 해당 카테고리의 description 필드로 추가한다.


최종적으로 모든 데이터가 포함된 categoryLists 상태를 업데이트 된다. 페이지에 각 카테고리와 해당 설명을 렌더링한다.


위에서 사용된 Promise.all과 async/await 구문을 사용하여, 모든 카테고리 데이터가 로드될 때까지 기다린 후 상태를 업데이트한다. 이 방법은 모든 네트워크 요청이 병렬로 수행되도록 하여 성능을 향상시킨다.

 

 

최종 결과물

마지막으로, 티스토리 블로그 포스팅 콘텐츠 관련하여 API로 제공된 데이터에서 특정 부분(예: 요약 글)만 추출하려면 HTML 문자열을 파싱하고 원하는 내용을 선택적으로 추출해야 한다. HTML 파싱은 클라이언트 사이드에서 JavaScript를 사용하여 수행할 수 있으며, 서버 사이드에서는 Node.js의 라이브러리를 활용할 수 있다.

HTML 문자열에서 특정 요소를 추출하기 위해 DOMParser API를 사용할 수 있는데, 이 API는 브라우저 환경에서 제공되므로 클라이언트 사이드에서만 사용할 수 있다.

'use client'

import React, {useState, useEffect} from 'react';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import styles from './page.module.css';

const CategoryPage = () => {
    const pathname = decodeURI(usePathname());
    const params = useSearchParams();
    const [categoryLists, setCategoryLists] = useState(null);

    useEffect(() => {
        // 카테고리 JSON 파일을 불러온다.
        fetch(`/data/categories/${params.get('categoryId')}.json`)
            .then(response => response.json())
            .then(async data => {
                // 각 카테고리에 대한 추가적인 데이터를 불러온다.
                const updatedCategoryLists = await Promise.all(data.map(async categoryList => {
                    const response = await fetch(`/data/posts/${params.get('categoryId')}/${categoryList.id}.json`);
                    const postData = await response.json();
                    // 원문 글에서 데이터 파싱하기
                    const parser = new DOMParser();
                    const htmlDocument = parser.parseFromString(postData.content, "text/html");
                    const summaryParagraph = htmlDocument.querySelectorAll("p")[0].textContent + "...";
                    return {...categoryList, description: summaryParagraph};
                }));

                setCategoryLists(updatedCategoryLists);
            })
            .catch(error => console.error('Error loading category lists:', error));
    }, [params]);

    if (!categoryLists) {
        return <div>Loading...</div>;
    }

    return (
        <>
            <div className={styles.head}>여기는 {pathname.split("/").pop()} 카테고리 페이지입니다.</div>
            <div className={styles.container}>
                {Object.entries(categoryLists).map(([key, categoryList]) => (
                    <Link href={categoryList.postUrl} target={'_blank'}>
                        <div className={styles.list}>
                            <div className={styles.listHead}>
                                {categoryList.title}
                            </div>
                            <div className={styles.listContent}>
                                {categoryList.description}
                            </div>
                        </div>
                    </Link>
                ))}
            </div>
        </>
    );
};

export default CategoryPage;

아래와 같이, 포스팅 ID에 해당하는 contents의 첫번째 <p> 태그를 description 형식으로 사용할 수 있게 되었다.