Dev_logplaist

React 상태 관리와 API 설계 고민 – Plaist #1

2025-01-02
ProjectReact

📌 Zustand 왜 사용할까?

이번 프로젝트에서 Zustand 를 사용했는데, 확실히 컴포넌트가 깊어질수록 가독성도 좋아지고 상태를 관리하는 데 편하다는 느낌을 받았다. 그래서 구체적으로 Zustand 를 사용하면 어떠한 이점이 있는지 정확하게 짚고 넘어가고 싶었다.

1. 전역 상태 관리

  • 특히 Zustand 는 전역 상태 관리를 간편하게 할 수 있는 도구이다. React 의 상태 관리 방식에서는 컴포넌트 간에 데이터를 공유하려면 props 를 통해 전달하거나, Context API 를 사용해야 하는데, 이는 상태가 깊게 전달되거나 여러 컴포넌트에서 상태를 동시에 다뤄야 할 경우 코드가 복잡해질 수 있다는 문제가 있다.
  • 그런 면에서 Zustand 는 전역 상태를 별도의 스토어로 관리하고, 이를 필요한 컴포넌트에서 가져다 사용할 수 있게 해준다. 이 방식은 상태를 전역적으로 관리할 수 있게 해줘서 여러 컴포넌트에서 같은 상태를 공유하고 수정하는 데 유리하다.

2. 간단하고 직관적인 API

  • Zustand 는 상태를 관리하는 데 필요한 기본적인 기능만 제공해서 이를 통해 복잡한 설정 없이 빠르게 상태를 관리할 수 있다.
  • create : 상태를 생성하는 함수, 상태와 상태를 업데이트 하는 함수를 반환한다.
  • set : 상태를 업데이트 하는 함수로, 이를 사용하여 상태를 변경할 수 있다.
  • get : 현재 상태를 가져오는 함수로, 이를 사용하여 상태에 접근할 수 있다.

3. 성능 최적화 & 컴포넌트와 상태 분리

  • 최소한의 리렌더링을 보장한다. 상태가 변경되었을 때, 상태를 사용하는 컴포넌트만 리렌더링되어서 불필요한 렌더링을 줄일 수 있다.
  • 이 부분이 가장 크게 와닿았던 부분으로, 상태를 컴포넌트와 분리하여 재사용성과 유지 보수성을 높일 수 있다. 상태를 외부 스토어에서 관리하면, 컴포넌트가 상태에 의존하지 않고 단일 책임 원칙(SRP) 을 따르게 된다.
  • 이렇게 별도의 파일로 분리하면, 컴포넌트는 UI 에 집중하고, 상태 관리 부분은 독립적으로 관리할 수 있다.
  • 그 결과 컴포넌트는 복잡해지지 않고, 나중에 상태 관리 방식이 변경되더라도 컴포넌트 코드에 영향을 주지 않는다.

4. 작고 가벼운 라이브러리

  • 다른 상태 관리 라이브러리들과 비교할 때, 설정이 적고 성능이 뛰어나며 코드 양도 적다. 따라서, 상태 관리의 복잡성을 줄이고 간단한 상태 관리 가 필요한 프로젝트에 매우 유용하다.

하지만 상태 의존성이 복잡하거나 성능 최적화가 중요한 대규모 프로젝트에서는 ReduxRecoil 같은 더 엄격한 상태 관리 라이브러리를 사용하는 것이 좋다고 한다.

5. 타입스크립트 지원

  • 또, ZustandTypeScript 와 잘 통합이 된다. 그래서 타입을 명확하게 정의하고 상태를 관리할 수 있기 때문에, 타입 안전성을 제공하고 코드 작성 시 오류를 미리 잡을 수 있다.

create<CommentState> 와 같이 상태의 타입을 지정해줘서 상태에 대한 타입 추론이 가능하고, 타입 에러를 방지할 수 있다.
💬 생각보다 타입 에러가 많이 발생했었는데, 에러 메세지를 꼭 잘 확인하고 어디서 타입 에러가 발생하는지 파악해서 디버깅 코드로 데이터 구조를 확인해보는 게 좋다.

6. 동시성 처리 및 비동기 상태 관리

  • 비동기 작업을 처리하는 데 좋다. 상태 업데이트가 비동기 작업에 의존할 때, 상태와 비동기 작업을 하나의 스토어에서 관리하면 코드가 더 깔끔하고 유지보수가 용이해진다.

예를 들어, 댓글을 화면에 보여주기 전에 서버에서 댓글 데이터를 먼저 받아와야 하는데, 이러한 상태 관리비동기 작업을 다른 곳에서 처리하면 상태를 업데이트 할 때마다 여러 곳을 변경해야 하고, 상태 관리가 분산되어서 코드가 복잡해질 수 있다.


🤥 왜 복잡해질까?
상태를 확인하려면 여러 곳을 돌아다니면서 확인해야 하니깐 헷갈리고 복잡해진다.


🤥 왜 비동기 작업을 처리할 때 좋을까?
한 곳에서 관리하기 때문에, 순서를 제대로 관리할 수 있다.


📌 초기화를 하지 않으면 오류가 발생한다?

Zustand에서 처음 생성할 때, 오류가 발생하고 렌더링이 안되는 경우가 발생했다. 왜 그럴까 찾아보니, 초기화를 안 해줘서였다.

  • 초기화는 변수나 상태에 기본값을 설정해주는 것인데, 아무 값도 없을 때 사용할 기본 상태 를 만들어주는 것이다.

🤥 왜 초기화가 필요할까?

  1. 코드가 오류를 일으킬 수 있다.

    js
    1
    const useCommentsStore = create(() => ({
      comments: undefined, // 초기화하지 않음
    }));
     
    // 댓글 목록을 추가
    const addComment = () => {
      const store = useCommentsStore();
      store.comments.push({ text: "새 댓글" }); // 오류 발생!
    };
    • commentsundefined 라서 .push() 를 호출하려고 하면 Cannot read properties of undefined 라는 오류가 발생한다.
  2. 초기값이 없으면, 상태가 어떤 값인지 알기 어렵다.

    • 우리는 상태가 항상 [] 이거나 {} 이라고 생각하고 짐작하지만, 초기화하지 않으면 아무 값도 없어서 코드가 이상하게 동작할 수 있다.
    • 그래서 초기화를 통해 개발자가 "이 변수는 어떤 역할을 할 것이다" 라는 의도를 명확히 표현하고, 의도한 대로 동작하게 할 수 있다. 이렇게 되면 코드를 읽는 사람도 상태의 기본 구조를 바로 이해할 수 있다.

📌 스프레드 연산자의 역할

상태 관리를 할 때, 특히 Zustand 에서 set 을 이용할 때, 기존 데이터는 유지한 채 새로운 데이터를 추가하거나 변경할 때 스프레드 연산자를 사용했는데 그 이유가 궁금해서, 정확한 동작 원리와 역할에 대해서 공부해보았다.

  • 기존 데이터를 복사하거나, 새로운 데이터를 추가할 때 사용
js
1
const bag = { book: "A", water: "B" };
const newBag = { ...bag }; // { book: "A", water: "B" }

🤥 왜 스프레드 연산자를 쓰는 걸까?

  1. 기존 데이터를 유지하기 위해

    • 기존 데이터를 지우지 않고 새 데이터를 추가하려면 기존 데이터를 복사해야 한다. 이때 스프레드 연산자가 이를 간단히 해준다.
    • 얕은 복사 : 겉만 복사하는 것으로, 객체 안에 있는 값이 원시 값이면 복사되고, 참조값이면 주소만 복사된다. 즉, 복사한 데이터가 원본 데이터와 일부를 공유하게 된다.
    • 깊은 복사 : 안에 있는 모든 값까지 새롭게 복사하는 것으로, 원본 데이터와 완전히 독립된 새로운 데이터를 만든다.
  2. 왜 얕은 복사를 해야 할까?

    • 원본 데이터는 변경되지 않고, 복사해서 새로운 데이터만을 추가할 수 있기 때문이다.
    • 또한, 기존 데이터를 유지하면서 일부를 변경해야 할 때가 많은데, 얕은 복사로 변경이 필요한 부분만 손쉽게 덮어쓸 수 있다.
    • 얕은 복사는 깊은 복사에 비해 빠르고 메모리 사용량이 적다.
      무엇보다 React 상태는 보통 얕은 구조를 가지거나, 상태 관리 라이브러리가 중첩된 데이터를 잘 관리할 수 있도록 설계되어 있어서(예: Zustand), 얕은 복사가 효율적이다.

📍 불변성 유지

React 상태 관리에서 불변성을 유지하는 것은 매우 중요하다.

  1. 쉬운 디버깅
    상태가 변경되기 전/후의 스냅샷을 비교하여 변경 내용을 쉽게 추적할 수 있다.
  2. 예상치 못한 부작용 방지
    원본 객체를 직접 수정하면, 다른 컴포넌트나 함수에서 해당 상태를 참조할 때 예상치 못한 결과가 발생할 수 있다.

🔗 깊은 복사가 필요한 상황

  • 얕은 복사는 상태 관리에 적합하지만, 중첩된 객체의 상태를 다룰 때는 깊은 복사가 필요할 수 있다.
  • 이를 해결하기 위해 lodashcloneDeep 이나 JSON 파싱 기법을 사용할 수 있다.

📌 불필요한 API 요청 줄이기

페이지에서 사용하는 상태는 API에서 데이터를 받아오는데, 다른 페이지를 갔다가 다시 기존의 페이지로 돌아왔을 때 또 다시 API에 요청해서 데이터를 받아오는 게 너무 불필요한 동작이라고 생각하게 되었다. 이걸 어떻게 하면 한 번만 처리해줄 수 있을까?

tsx
1
useEffect(() => {
  if (!isFetched) {
    fetchComments();
    setIsFetched(true);
  }
}, [isFetched, fetchComments]);
  • 이렇게 조건문을 이용해서 isFetched 상태로 확인해서 데이터를 한 번만 가져올 수 있게 제어할 수 있다.
  • isFetchedtrue 로 설정되어 있으면 데이터를 다시 받아올 필요가 없다.

🤥 그러면 데이터가 바뀐 경우엔?

  • 만약 데이터를 삭제하거나 추가해서 변경되었다면, 그럴 때만 해당 로직에서 fetchComments() 를 다시 호출해주면 된다.

🚨 새로고침을 해도 유지되는가?

페이지를 새로고침하면 isFetched 상태가 초기화되기 때문에 다시 데이터를 받아와야 한다. 왜냐하면 페이지 새로고침 시, React 컴포넌트상태가 초기화되기 때문이다.

  • 새로고침을 하면 브라우저가 페이지를 새로 로드하게 되고, 이때 모든 상태가 초기화된다. 즉, useStateuseEffect 로 관리하는 상태들은 새로고침 시 사라지게 된다.

🤥 새로고침 후 데이터를 다시 요청하지 않으려면?

  • 브라우저의 localStoragesessionStorage를 활용하여 데이터를 저장하고 불러오는 방법을 사용할 수 있다.
  • localStorage : 브라우저를 닫아도 데이터 유지
  • sessionStorage : 탭이 닫히면 데이터 삭제

또한, SWR 이나 React Query 같은 데이터 패칭 라이브러리를 활용하면 캐싱과 데이터 동기화를 손쉽게 구현할 수 있다.


📌 로컬 스토리지 (localStorage)

  • 영구적 저장 : 브라우저를 닫거나 컴퓨터를 꺼도 데이터가 계속 남아 있다.
  • 클라이언트 측 저장 : 브라우저에 데이터를 저장하므로, 사용자가 직접 데이터를 수정할 수 있다. (개발자 도구에서 수정/삭제 가능)
  • 민감한 정보 저장 금지 : 비밀번호, 인증 토큰, 카드 정보 등은 보안상 매우 위험하다.
  • HTTP vs HTTPS : HTTP 로 접속한 웹사이트에서는 암호화 없이 저장되기 때문에, 중간자 공격에 노출될 위험이 있다. 반드시 HTTPS 환경에서 사용하는 것이 좋다.

주로 로그인 상태 유지, 사용자 설정 저장, 장바구니 데이터 저장처럼 일정 시간 동안 유지되어야 하는 데이터를 저장할 때 사용된다.

js
1
// 로컬 스토리지에 데이터 저장
localStorage.setItem("username", "JohnDoe");
 
// 로컬 스토리지에서 데이터 불러오기
const username = localStorage.getItem("username");
console.log(username); // "JohnDoe"

✔ㅤ세션 스토리지 (sessionStorage)

  • 세션 종료 시 삭제 : 브라우저 “탭”을 닫으면 데이터가 사라진다.
  • 클라이언트 측 저장 : 로컬 스토리지와 마찬가지로 개발자 도구에서 수정/삭제 가능
  • 짧은 시간 동안만 유지 : 세션 동안만 필요한 데이터를 저장하는 데 적합하다.
  • HTTP vs HTTPS : 마찬가지로 HTTPS 를 사용해야 보안이 강화된다.

주로 현재 브라우저 세션 동안만 필요한 데이터를 저장할 때 사용한다.

js
1
// 세션 스토리지에 데이터 저장
sessionStorage.setItem("cartItem", "Apple");
 
// 세션 스토리지에서 데이터 불러오기
const cartItem = sessionStorage.getItem("cartItem");
console.log(cartItem); // "Apple"

✔ㅤ공통점

  • 도메인별 저장 : 같은 도메인에서만 데이터를 공유할 수 있다.
  • 용량 제한 : 대부분의 브라우저에서 약 5MB 내외.

🤥 왜 로컬/세션 스토리지를 사용할까?

웹 애플리케이션에서 데이터를 자주 불러오는 것은 서버에 부담을 줄 수 있고, 사용자에게도 불편을 줄 수 있다.
그래서 데이터를 브라우저에 저장해두면 빠르게 데이터를 불러올 수 있고, 새로고침이나 페이지 이동 시에도 불필요한 요청을 줄일 수 있다.

📍 보안 이슈

  • 데이터 암호화 : 민감한 정보를 저장해야 한다면 반드시 암호화 후 저장해야 한다.
  • 만료 시간 관리 : 로컬 스토리지는 영구적이라, 직접 만료 시간을 관리하거나 로그아웃 시 데이터를 삭제하는 로직이 필요하다.

📌 메모이제이션 사용

이번 프로젝트에서는 없는 API가 많아서 데이터를 받고 그 데이터를 가지고 또 다른 API를 호출하는 경우가 많았고, 반복적으로 계산해야 하는 값들이 많았다. 이 경우 메모이제이션을 사용하면 성능 최적화에 도움이 된다고 해서 사용해보았고, 이에 대해 정리해보고자 한다.

✔ㅤuseMemo

  • React의 Hook 으로, 특정 값이나 계산 결과를 메모이제이션(cache)하여 성능을 최적화하는 데 사용된다.
  • 간단히 말하면, 값을 계산하는 작업이 매번 반복되지 않도록 React가 이전 계산 결과를 기억해두는 역할을 한다.

🤥 언제 사용 할까?
“계산 비용이 높은 작업” (예: 배열 필터링, 복잡한 데이터 변환)이 컴포넌트가 리렌더링될 때마다 실행되는 것을 방지하고 싶을 때 사용한다.

🔗 useMemo vs useCallback

  • useMemo : “값” 자체를 메모이제이션
  • useCallback : “함수”를 메모이제이션

📌 오류 처리

멘토님께 오류 처리에 대한 코드 리뷰를 받으면서 관련 내용을 다시 한 번 찾아보고 수정을 진행했다.

API를 호출하는 함수, 특히 상태를 업데이트하는 post / put / patch / delete 같은 함수들은 try-catch 문을 작성할 때, catch 구문에서 에러를 단순히 로그만 남기고 넘기는 것보다 throw error 를 사용해 상위 호출 스택으로 오류를 전파하는 것이 중요하다.

이렇게 하지 않으면:

  • 디버깅이 어려워지고
  • 상위 함수가 오류를 제대로 인지하지 못하는 문제가 발생할 수 있다.

✔ㅤthrow error

  • 현재 실행 중인 코드 흐름을 중단하고 오류를 상위 호출 스택으로 전달해, 해당 오류가 전체적으로 관리될 수 있게 만든다.
  • 이를 통해 호출자가 해당 오류를 처리할 수 있도록 하며, 오류가 발생했음을 명확히 알리는 역할을 한다.
    예: API 호출 실패, 데이터 검증 실패 등.

📌 Axios

프로젝트에서 발생했던 치명적인 문제들의 원인이 바로 이 axios 설정 문제였다. 나중에 이 사실을 알고 코드를 수정했더니, 여러 곳에서 발생하던 문제들이 한꺼번에 해결되었다. 이를 계기로 이 부분을 확실히 정리해 두어야겠다고 다짐했다.

✔ㅤ수정 전 코드

ts
1
import axios from "axios";
import { getToken } from "../utills/Auth/getTokenWithCloser";
 
const token = getToken();
 
export const axiosInstance = axios.create({
  baseURL: "",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
});
  • getToken() 함수가 먼저 실행되어 토큰을 즉시 가져와 Authorization 헤더에 바로 설정된다.
  • 이 방식은 axiosInstance 가 생성될 때 “한 번만” 토큰을 설정한다.
  • 그래서 만약 토큰이 만료되거나 새로 발급된 경우, 업데이트된 토큰을 반영하지 못한다.

✔ㅤ수정 후 코드

ts
1
import axios from "axios";
import { getToken } from "../utills/Auth/getTokenWithCloser";
 
export const axiosInstance = axios.create({
  baseURL: "",
  headers: {
    "Content-Type": "application/json",
  },
});
 
axiosInstance.interceptors.request.use((config) => {
  const token = getToken();
 
  if (token) {
    config.headers["Authorization"] = `Bearer ${token}`;
  }
 
  return config;
});
  • axiosInstance 를 생성할 때 Authorization 헤더는 설정하지 않는다.
  • 대신 interceptors 를 사용하여 요청이 발생할 때마다 getToken() 을 호출하고, 최신 토큰을 Authorization 헤더에 추가한다.

📍 axios란?

  • HTTP 요청을 쉽게 보낼 수 있게 해주는 라이브러리.
  • 주로 서버와 데이터를 주고받을 때 사용된다.

📍 axios.create

  • 공통 설정이 포함된 axios 인스턴스를 생성하는 함수.
  • 매번 같은 설정을 반복하지 않고, 기본값을 설정해서 재사용할 수 있다.

📍 interceptors란?

요청(request)이나 응답(response)을 가로채서 추가 작업을 수행할 수 있는 axios 의 기능이다.

  • 요청을 보내기 전에 특정 데이터를 추가하거나, 응답을 받은 후 데이터를 변환할 때 주로 사용된다.

💬 request interceptors

  • 요청이 서버로 보내지기 전에 실행된다.
    예: 요청에 인증 토큰을 추가, 로그 남기기 등
ts
1
axiosInstance.interceptors.request.use((config) => {
  console.log("Request sent:", config);
  config.headers["Custom-Header"] = "Hello";
  return config; // 변경된 설정 반환
});

💬 response interceptors

  • 서버로부터 응답을 받은 후 실행된다.
    예: 응답 데이터 가공, 에러 공통 처리 등
ts
1
axiosInstance.interceptors.response.use(
  (response) => {
    console.log("Response received:", response);
    return response; // 수정한 응답 데이터 반환
  },
  (error) => {
    console.error("Error occurred:", error);
    return Promise.reject(error);
  }
);

🤥 headers란?

HTTP 요청에 대한 메타데이터를 설정하는 부분이다. 예를 들어 인증 토큰, 데이터 형식, 언어 정보 등을 설정한다.

🔗 주요 헤더 종류

  • Authorization : 인증 정보(토큰 등)를 전달
    Authorization: Bearer ${token}
  • Content-Type : 요청 데이터의 형식을 지정
    application/json, multipart/form-data
  • Accept : 서버에서 어떤 데이터 형식을 받을지 지정
    Accept: application/json

🤥 use (interceptors.use)

  • axios 의 인터셉터를 등록할 때 사용하는 메서드로, 두 개의 함수를 매개변수로 받는다.
    (정상 처리 콜백, 에러 처리 콜백)

🔗 config란?

  • 요청에 대한 모든 설정 정보를 담고 있는 객체.
  • interceptors 는 이 config 객체를 수정하거나 읽어서 원하는 작업을 수행한다.

예를 들어:

  • url : 요청할 서버의 주소
  • method : HTTP 요청 방식 (GET, POST, PUT, DELETE 등)
  • headers : 요청에 포함된 HTTP 헤더
  • data : 서버로 보내는 데이터 (POST, PUT 등)
  • params : URL에 붙는 쿼리 문자열
ts
1
const config = {
  url: "/users",
  method: "GET",
  params: { search: "Alice" }, // → /users?search=Alice
};