[React] 12. 프로젝트 3. 감정 일기장

DonaDona·2024년 10월 22일

12.1) 프로젝트 소개 및 준비

감정 일기장 프로젝트의 목표

  • 외부 폰트 사용법
  • 이미지 사용법 (+최적화)
  • 다양한 페이지를 제공하는 방법
  • 공통 컴포넌트로 UI 요소 모듈화
  • 복잡한 데이터를 다루는 방법
  • 리액트 앱을 실제로 배포하는 방법

12.2) 페이지 라우팅 1. 소개

페이지 라우팅

  • 경로에 따라 알맞은 페이지를 화면에 렌더링하는 것
    • 주소요청 -> 웹 서버의 페이지 반환 -> 브라우저의 페이지 렌더링

Multi Page Application (MPA)

  • 애초에 서버가 여러개의 페이지를 가지고 있음
  • 많은 서비스가 사용하는 전통적인 방식
  • 페이지의 이동을 빠르게 처리하기 힘듦 -> React.js는 이 방식을 따르지 않음
  • 공통으로 사용되는 동일한 요소가 있다 하더라도 페이지 이동 시에 원본을 전부 제거하고 아예 새로운 HTML 파일로 페이지를 다시 그려냄

Server Side Rendering (SSR)

  • 서버 측에서 페이지를 미리 렌더링 해주는 것을 서버사이드 렌더링이라고함

Single Page Application (SPA) ✅

  • 페이지 이동을 매끄럽고 효율적으로 처리
  • 다수의 사용자가 몰리더라도 서버의 부하가 심해지지 않는 방식으로 동작

React의 SPA(Single Page Appliction)

  • 화면에 렌더링되어야하는 요소를 html에 직접 작성하는게 아니라 JS에 컴포넌트로 작성
  • vite가 번들링을 담당
  • 브라우저에서 직접 자바스크립트 파일을 실행해서 화면을 직접 렌더링하도록 하는 클라이언트 사이드 렌더링을 수행
  • 셋팅 페이지로 페이지 이동이 발생하게 된다면 새로운 페이지를 매번 서버에게 요청했었던 MPA 방식과 달리, 리액트 앱을 이용해서 자체적으로 브라우저 내에서 새로운 페이지에 필요한 컴포넌트들로 화면을 교체

12.3) 페이지 라우팅 2. 라우팅 설정하기

React Router

  • 공식 문서 : https://reactrouter.com/en/main
  • npmjs.com에 등록되어 있는 라이브러리
  • 대다수의 리액트 앱이 사용하고 있는 대표격 라이브러리
  • npm i react-router-dom

main.jsx

import { BrowserRouter } from "react-router-dom";

createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
  • BrowserRouter -> Navigation.ProviderLocation.Provider를 모든 React 컴포넌트들에게 사용할 수 있게 함

App.jsx

import "./App.css";
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Diary from "./pages/Diary";
import New from "./pages/New";
import Notfound from "./pages/Notfound";

// 1. "/" : 모든 일기를 조회하는 Home 페이지
// 2. "/new" : 새로운 일기를 작성하는 New 페이지
// 3. "/diary" : 일기를 상세히 조회하는 Diary 페이지
function App() {
  return (
    <>
      <div>Hello</div>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/new" element={<New />} />
        <Route path="/diary" element={<Diary />} />
        <Route path="*" element={<Notfound />} />
      </Routes>
    </>
  );
}

export default App;
  • Routes 컴포넌트 안에는 Route 컴포넌트만 쓸 수 있음
  • Routes 컴포넌트 바깥에는 모든 페이지에 동일하게 렌더링이 되는 페이지를 작성할 수 있다.

12.4) 페이지 라우팅 3. 페이지 이동

  • HTML의 <a/>태그를 대체
  • 클라이언트 사이드 렌더링 방식으로 페이지를 이동시키기 때문에 필요한 컴포넌트만 교체
  • <a/>태그는 서버사이드렌더링

함수, 특정 이벤트

useNavigate

  • 이벤트 핸들러 안에서 특정 조건에 따라 페이지를 이동할 수 있는 navigate함수

App.jsx

import "./App.css";
import { Routes, Route, Link, useNavigate } from "react-router-dom";
import Home from "./pages/Home";
import Diary from "./pages/Diary";
import New from "./pages/New";
import Notfound from "./pages/Notfound";

// 1. "/" : 모든 일기를 조회하는 Home 페이지
// 2. "/new" : 새로운 일기를 작성하는 New 페이지
// 3. "/diary" : 일기를 상세히 조회하는 Diary 페이지
function App() {
  const nav = useNavigate();

  const onClickButton = () => {
    nav("/new");
  };
  return (
    <>
      <div>
        <Link to={"/"}>HOME</Link>
        <Link to={"/new"}>New</Link>
        <Link to={"/diary"}>Diary</Link>

        <button onClick={onClickButton}>New 페이지로 이동</button>
      </div>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/new" element={<New />} />
        <Route path="/diary" element={<Diary />} />
        <Route path="*" element={<Notfound />} />
      </Routes>
    </>
  );
}

export default App;

12.5) 페이지 라우팅 4. 동적 경로

동적 경로(Dynamic Segments)로 페이지를 라우팅하는법

동적 경로(Dynamic Segments)

  • 동적경로 = 위처럼 상품 ID같은 동적인 데이터를 포함하고 있는 경로

URL Parameter

  • ~/product/1
  • / 뒤에 아이템 id와 같이 변경되지 않는 값을 주소로 명시하기 위해 사용됨

Query String

  • ~/search?q=검색어
  • ? 뒤에 변수명과 값 표시
  • 검색어 등의 자주 변경되는 값을 주소로 명시하기 위해 사용된다.

동적 경로 적용 - useParams

App.jsx

...
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/new" element={<New />} />
    <Route path="/diary/:id" element={<Diary />} />
    <Route path="*" element={<Notfound />} />
  </Routes>
...

Diary.jsx

import { useParams } from "react-router-dom";

const Diary = () => {
  const params = useParams();
  console.log(params);
  return <div>{params.id}번 일기입니다 ~~</div>;
};

export default Diary;

QueryString 사용법 - useSearchParams

Home.jsx

import { useSearchParams } from "react-router-dom";

const Home = () => {
  const [params, setParams] = useSearchParams();
  console.log(params.get("value"));
  return <div>Home</div>;
};

export default Home;

12.6) 폰트, 이미지, 레이아웃 설정하기

폴더 위치

  • 폰트 : public
  • 이미지 : src/assets

정적 파일 위치를 분리하는 이유

  • vite가 내부적으로 진행하는 이미지 최적화 설정때문
  • public 폴더는 import를 통해 불러올 수 없지만 URL을 통해 불러올 수 있다.
  • 퍼블릭 폴더 안에 있는 이미지를 경로를 통해 불러오도록 설정해주면 vite가 제공하는 이미지 최적화가 동작하지 않게된다.
  • 최적화라는게 구체적으로 어떻게 이뤄지고 어떤 것들이 최적화가 되는 건지 확인해보기 위해서는 빌드한 다음 배포 모드로 직접 실행시켜보면 알 수 있다.

정적 파일 위치 차이 - 배포 모드 확인

  • npm run build -> 파일 탐색기에 dist 폴더가 생김
  • npm run preview -> 빌드된 결과물 보기
  • public폴더에 있던 이미지 파일은 /emotion1.png와 같은 일반적인 주소인 반면, src/assets에 있던 파일은 data:image/png:base64 ...와 같은 암호문 같은 포맷(Data URI)으로 설정됨
  • DataURI : 이미지와 같은 외부 데이터들을 문자열 형태로 브라우저의 메모리에 캐싱하기 위해 사용되는 포맷 => 새로고침 하더라도 다시 불러오지 않도록 최적화됨
  • 또 너무 많은 이미지 파일은 브라우저의 메모리 용량 과부화를 불러일으키기 때문에 public 폴더에 보관하는게 좋을 수 있다.
    -> 5개, 소수의 이미지는 src의 assets 폴더에 보관 캐싱되도록 설정. 그외의 다수 파일은 public이 좋을 수 있다.

이미지 불러오는 코드 분리

get-emotion-images.js

import emotion1 from "./../assets/emotion1.png";
import emotion2 from "./../assets/emotion2.png";
import emotion3 from "./../assets/emotion3.png";
import emotion4 from "./../assets/emotion4.png";
import emotion5 from "./../assets/emotion5.png";

export function getEmotionImage(emotionId) {
  switch (emotionId) {
    case 1:
      return emotion1;
    case 2:
      return emotion2;
    case 3:
      return emotion3;
    case 4:
      return emotion4;
    case 5:
      return emotion5;
    default:
      null;
  }
}

App.jsx

import { getEmotionImage } from "./util/get-emotion-images";

function App() {
  
  return (
    <>
      <div>
        <img src={getEmotionImage(1)} />
        <img src={getEmotionImage(2)} />
        <img src={getEmotionImage(3)} />
        <img src={getEmotionImage(4)} />
        <img src={getEmotionImage(5)} />
      </div>
	</>
}

12.7) 공통 컴포넌트 구현하기

프로젝트 개발 순서

  • 사람마다 다름
  • 강사님은
    페이지 라우팅
    글로벌 레이아웃 설정
    공통 컴포넌트 구현🌟
    개별 페이지 및 복잡한 기능 구현 순서로 진행

12.8) 일기 관리 기능 구현하기 1

감정 일기장 프로젝트

  • "일기"라는 형태의 데이터를 관리하는 프로그램
  • 수정화면 및 라우팅 설정
  • mockData설정
const mockData = [
  {
    id: 1,
    createdDate: new Date().getTime(),
    emotionId: 1,
    content: "1번 일기 내용",
  },
  {
    id: 2,
    createdDate: new Date().getTime(),
    emotionId: 2,
    content: "2번 일기 내용",
  },
];

function reducer(state, action) {
  return state;
}

function App() {
  const [data, dispatch] = useReducer(reducer, mockData);
}

12.9) 일기 관리 기능 구현하기 2

추가, 수정, 삭제 기능 구현


function reducer(state, action) {
  switch (action.type) {
    case "CREATE":
      return [action.data, ...state];
    case "UPDATE":
      return state.map((item) =>
        String(item.id) === String(action.data.id) ? action.data : item
      );
    case "DELETE":
      return state.filter((item) => String(item.id) !== String(action.id));
    default:
      state;
  }
}

...

// 새로운 일기 추가
const onCreate = (createdDate, emotionId, content) => {
  dispatch({
    type: "CREATE",
    data: {
      id: idRef.current++,
      createdDate,
      emotionId,
      content,
    },
  });
};

// 기존 일기 수정
const onUpdate = (id, createdDate, emotionId, content) => {
  dispatch({
    type: "UPDATE",
    data: {
      id,
      createdDate,
      emotionId,
      content,
    },
  });
};

// 기존 일기 삭제
const onDelete = (id) => {
  dispatch({
    type: "DELETE",
    id,
  });
};

기능 context 설정

const DiaryStateContext = createContext();
const DiaryDispatchContext = createContext();

<DiaryStateContext.Provider value={data}>
  <DiaryDispatchContext.Provider value={{ onCreate, onUpdate, onDelete }}>
    <Routes>
    <Route path="/" element={<Home />} />
      <Route path="/new" element={<New />} />
        <Route path="/diary/:id" element={<Diary />} />
          <Route path="/edit/:id" element={<Edit />} />
    <Route path="*" element={<Notfound />} />
              </Routes>
  </DiaryDispatchContext.Provider>
</DiaryStateContext.Provider>

12.10) Home 페이지 구현하기 1. UI

  • DiaryList.jsx, DiaryItem.jsx 생성 및 css 적용

12.11) Home 페이지 구현하기 2. 기능

  • Header의 leftChild와 rightChild를 클릭했을 때 날짜가 변하는 기능 구현
  • 날짜 변경시 해당 날짜에 해당하는 리스트만 출력되도록 기능 구현

날짜 비교를 위한 코드

const beginTime = new Date(
  pivotDate.getFullYear(), // 연
  pivotDate.getMonth(), // 월
  1, // 일
  0, // 시
  0, // 분
  0  // 초
).getTime();

const endTime = new Date(
  pivotDate.getFullYear(),
  pivotDate.getMonth() + 1,
  0, // 0일 = 이전달의 마지막 일
  23,
  59,
  59
).getTime();

정렬을 위한 코드

const getSortedDate = () => {
	return data.toSorted()	
}

12.12) Home 페이지 구현하기 3. 회고

12.13) New 페이지 구현하기 1. UI

New 페이지 구성

  • Header, Editor, EmotionItem 컴포넌트

Editor

  • <section/> : div 태그와 기능이 같음
.Editor > section {
  margin-bottom: 40px;
}
  • 하단에 여유를 주기 위해 위의 margin-bottom 속성 적용 시 body 태그가 전체를 덮지 못해 일부 잘리는 모습이 보임
    => body { display : flex; } 로 해결

12.14) New 페이지 구현하기 2. 기능

뒤로가기

  • nav(-1)
import Header from "../components/Header";
import Button from "../components/Button";
import Editor from "../components/Editor";
import { useNavigate } from "react-router-dom";

const New = () => {
  const nav = useNavigate();
  return (
    <div>
      <Header
        title={"새 일기쓰기"}
        leftChild={<Button onClick={() => nav(-1)} text={"< 뒤로 가기"} />}
      />
      <Editor />
    </div>
  );
};

export default New;

input 날짜

  • <input value={new Date()}/> 태그는 날짜를 인식할 수 없어서 문자열로 변환하여 value속성에 넣어주어야한다.

목록 화면 이동

  • nav("/", { replace: true });
    replace : 뒤로가기 방지하면서 페이지를 이동시키는 옵션

12.15) Edit 페이지 구현하기

  • Header, Editor 컴포넌트 (New 컴포넌트와 유사)

존재하지 않는 페이지 접근 시

Edit.jsx

const Edit = () => {
  ...
  const getCurrentDiaryItem = () => {
    const currentDiaryItem = data.find(
      (item) => String(item.id) === String(params.id)
    );
    if (!currentDiaryItem) {
      window.alert("존재하지 않는 일기입니다.");
      nav("/", { replace: true });
    }

    return currentDiaryItem;
  };
  ...
  
  return <> ... </>;
}

  • 위의 nav("/", { replace : true })는 Edit 컴포넌트가 마운트되기전에 실행되어 위와 같은 에러를 뱉는다.
  • nav 함수는 컴포넌트의 마운트 후에만 사용할 수 있다.

페이지 수정 시 기존 정보 로드

Edit.jsx


const Edit = () => {
  return (
    <div>
      <Editor initData={curDiaryItem} />
    </div>
  );
};
export default Edit;

Editor.jsx

  • createdDate : 타임스탬프 값인 숫자 값이 들어가 버리게 되면서 오류가 발생할 수 있음. getStringedData()같은 함수에서는 createdDate값을 데이트 객체라고 상정하기 때문.
    따라서 아래처럼 새 객체를 생성하여 전달한다.
const Editor = ({ initData, onSubmit }) => {
  const [input, setInput] = useState({ ... });
                                      
  useEffect(() => {
    if (initData) {
      setInput({
        ...initData,
        createdDate: new Date(Number(initData.createdDate)),
      });
    }
  }, [initData]);
  ...
}

12.16) Diary 페이지 구현하기

중복 코드 => 커스텀 훅으로 분리

useDiary.jsx

import { useContext, useState, useEffect } from "react";
import { DiaryStateContext } from "../App";
import { useNavigate } from "react-router-dom";

const useDiary = (id) => {
  const data = useContext(DiaryStateContext);
  const [curDiaryItem, setCurDiaryItem] = useState();
  const nav = useNavigate();

  useEffect(() => {
    const currentDiaryItem = data.find(
      (item) => String(item.id) === String(id)
    );
    if (!currentDiaryItem) {
      window.alert("존재하지 않는 일기입니다.");
      nav("/", { replace: true });
    }

    setCurDiaryItem(currentDiaryItem);
  }, [id, data]);

  return curDiaryItem;
};

export default useDiary;
  • useEffect는 컴포넌트가 렌더링 된 이후에만 실행되기 때문에 맨 처음에 그냥 컴포넌트가 호출되었을 때 당시에는 Undefined가 반환된다.

12.17) 웹 스토리지 이용하기

최적화는 언제 필요할까?

  • 비용이 많이 드는 계산, 매우 여러번, 반복적으로 실행되는 연산
    • ex) API를 호출해서 데이터를 가공하고 하는 등등의 작업들
  • 최적화에 사용되는 useMemo, useCallback, React.memo 기능들은 과하게 사용하게 되면 컴포넌트의 함수의 확장성이 줄어들어 버린다거나 프로젝트의 유지보수를 어렵게 많드는 등 독이 될 수 있음.

웹 스토리지

  • State는 Javascript 변수와 다름이 없다
  • 웹 브라우저에 기본적으로 내장되어 있는 데이터베이스
  • 별도의 프로그램 설치 필요 X, 라이브러리 설치 필요 X
  • 그냥 자바스크립트 내장함수만으로 접근 가능
  • SessionStorage, LocalStorage : 데이터를 어디 보관하는지, 언제 초기화되는지에 차이가 있음
  • 개발자도구 - Application탭에서 Storage 확인

SessionStorage

  • 브라우저 탭 별로 데이터를 보관
  • 탭이 종료되기 전에는 데이터 유지(새로고침)
  • 탭이 종료되거나 꺼지면 데이터 삭제

LocalStorage

  • 사이트 주소별로 데이터 보관
  • 사용자가 직접 삭제하기 전까지 데이터 보관

LocalStorage 사용법

localStorage.setItem("key", "value");

localStorage.getItem("key");

localStorage.removeItem("test");

JSON.stringify({})

  • 객체를 문자열로 변환시켜주는 역할

JSON.parse("")

  • 문자열을 객체로 변환
  • 인수가 null이나 undefined이면 오류를 반환

실습

App.jsx

function reducer(state, action) {
  let nextState;

  switch (action.type) {
    case "INIT":
      return action.data;
    case "CREATE":
      {
        nextState = [action.data, ...state];
      }
      break;
    case "UPDATE":
      {
        nextState = state.map((item) =>
          String(item.id) === String(action.data.id) ? action.data : item
        );
      }
      break;
    case "DELETE":
      {
        nextState = state.filter(
          (item) => String(item.id) !== String(action.id)
        );
      }
      break;
    default:
      return state;
  }

  localStorage.setItem("diary", JSON.stringify(nextState));
  return nextState;
}

...

function App() {
  const [data, dispatch] = useReducer(reducer, []);
  const idRef = useRef(0);

  useEffect(() => {
    const storedData = localStorage.getItem("diary");
    if (!storedData) {
      return;
    }
    const parsedData = JSON.parse(storedData);
    if (!Array.isArray(parsedData)) {
      return;
    }

    let maxId = 0;
    parsedData.forEach((item) => {
      if (Number(item.id) > maxId) {
        maxId = Number(item.id);
      }
    });

    idRef.current = maxId + 1;

    dispatch({
      type: "INIT",
      data: parsedData,
    });
  }, []);
  ...
}
  • 감정일기장의 페이지들은 const [data, dispatch = useReducer(reducer, []); 의 값을 참조함.
  • 이 값은 초기값이 빈 배열이며, 컴포넌트가 모두 렌더링된 후 useEffect가 동작하여 localStorage에 저장된 값이 할당됨
  • 때문에 초기값 빈 배열을 불러온 상태에서 컴포넌트가 렌더링되면 데이터가 존재하지않는다고 간주하게 됨.

App.jsx - 데이터 로딩 대기


function App() {
  const [isLoading, setIsLoading] = useState(true);
  useEffect(() => {
  	
    ... // data init
   
    setIsLoading(false);
  }, []);
  <DiaryStateContext.Provider value={data}>
	... // Contexts, Routes
  </DiaryStateContext.Provider>
}

12.18) 배포 준비하기

  • Favicon : 브라우저 탭에 표시되는 작은 아이콘
  • 오픈 그래프(Open Graph) : 웹사이트의 링크를 공유할 때 썸네일, 제목 등의 정보를 노출하는 것
  • 프로젝트 빌드 : 빌드 시 문제 없는지 확인

title 변경 커스텀 훅 설정

usePageTitle.jsx

import { useEffect } from "react";

const usePageTitle = (title) => {
  useEffect(() => {
    const $title = document.getElementsByTagName("title")[0];
    $title.innerText = title;
  }, [title]);
};

export default usePageTitle;

favicon 설정

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
	...
  </head>
</html>

favicon 적용이 안될때

  • Element 속성에서 index.html 파일이 바뀐걸 확인했지만 network 탭에서 favicon.ico 파일을 불러오지 않았다. 크롬 업데이트를 하니까 됐다.

오픈 그래프 설정 - meta 태그

<meta property="og:title" content="감정 일기장" />
<meta property="og:description" content="나만의 작은 감정 일기장" />
<meta property="og:image" content="/thumbnail.png" />

빌드

npm run build

12.19) 배포하기

  • 직접 서버를 구축할 때 해야할 일들
  • 클라우드 서비스는 위의 것들을 대신해준다.

  • 가장 추천하는 서비스는 Vercel
  • Next.js를 오픈소스로 개발하고 있는 회사이기도 함

Vercel 배포하기

  • vercel.com 회원가입
  • npm install -g vercel
  • vercel login
  • vercel
  • 강사님 화면
  • 내 화면
  • 하단의 production용 링크를 공유해야 오픈 그래프가 나타난다. Inspect나 Preview는 개발용인듯 하다.
profile
기록용 공부용 개발 블로그

0개의 댓글