돈이 없는게 문제가 아니다. 비전이 없는게 문제다.

 

들어가는 첫 문장부터 나에게 꼭 필요한 문장이다. 

지금 나의 비전은 무엇인가? 돈을 버는것? 직업적으로 성장? 결혼? 막연하게 바라고 있지만 명확하게 설정하고 나아가고 있지 않았다. 책은 백만장자에 들에 대한 내용이지만, 읽으며 비전에 대한 인사이트를 얻었다.

 

 

백만장자들을 소득, 직업, 지출, 마음가짐 등 다방면으로 분석하고 그들의 공통점과 어떻게 백만장자가 되었는지에 대한 고찰이 주를 이룬다. 세이노의 가르침 중 '반드시 읽어라' 항목에 있는 도서에 포함되어 읽게되었다. 여기서 백만장자는 1996년대의 백만장자로 순자산 10억원 정도를 보유한 이들로 지금과는 차이가 있다. 하지만 그들의 마음가짐이나 삶은 지금의 백만장자의 본질은 크게 다르지 않다고 생각한다.

 

백만장자는 자신의 능력에 자신감을 가지고 있다. 그들은 소위 1세대 백만장자로 부모세대로 부터 물려받은 것이 아닌 스스로의 능력으로 부를 이루었다. 대부분 사업가인 그들은 많은 풍파와 위험을 이겨내고 올라왔기 때문에 그 능력에 자신감이 있다.

자신은 반드시 문제를 해결할 것이라 믿으며 '용기'를 가지고있다. (여기서 용기는 두려움을 불러내어 맞서려는 태도이다)

 

그들은 목표를 세운다. 일, 주, 월, 1년 단위로 목표를 세우고 이를 실천한다. 이 부분에 있어서는 목표를 세우지 말고 시스템을 만들라는 스콧 에덤스의 'The System' 에서 내가 조금 오해했던 부분을 정정해주었다. 스콧에덤스도 목표를 놓고 월단위, 일단위 계획을 세워 목표를 위해 매일매일 실행하는 시스템을 만들라고 했는데, 목표를 너무 명확하게 잡으면 목표를 이루지 못한 모든 날들을 '실패'로 여겨 자신감과 자기효능감이 계속 떨어진다고 한다. 따라서 목표를 조금 모호하게 잡는 것. 목표 보다는 매일매일 실행하는 시스템에 대한 중요성을 이야기하는데 거기서 나는 목표를 세우지 말라는 뉘앙스가 강해 의도적으로 목표를 세우지 않았었다. (책을 1권만 읽었을 때가 가장 무섭다.. 이웃집 백만장자가 정정해 준 덕에 26년도 플래너를 작성하는데 더 박차를 가할 수 있게되었다)

 

그들은 재정적인 계획을 세우는데 2배의 시간 이상을 사용하고 재정적인 걱정을 절반보다 적게 한다.

소득대비 지출 계획을 세우고, 세금을 절약할 방법을 찾고, 가계부를 작성하고 꼭 필요한 물건을 사는데(새차 구입, 가구 구입 등) 훨씬 시간을 많이쓴다. 대신에 재정적인 걱정, 미래에 대한 걱정, 만약 본인의 건강상태가 나빠져 가족을 부양할 수 없게되는 걱정, 자녀들이 나쁜일을 당하지 않을까 하는 걱정을 훨씬 적게한다. 백만장자 중에서도 PAW, UAW를 나누는데 PAW는 수익대비 축적한 자산이 많은 군, UAW는 수익대비 축적한 자신이 적은 군이다. 위에서의 비교는 항상 PAW와 UAW의 비교이다. 

 

돈이되는 분야를 좇으라는 이야기를 한다. 부자들을 상대하는 직업들이 부자가 될 확률과 통계적으로 당시 부자들의 수와 앞으로 10년내 부자들의 수를 비교하고 부자들의 관심사가 늘어나는 분야를 소개한다. 매우 통계적이고 구체적으로 부자가 될 확률을 높이는 접근법에서 느끼는 바가 있었다. 부자가 되고 싶다는 막연한 생각만 했지 구체적인 실행방법을 엿볼 수 있었고 이전에 세무사 1차 시험을 붙기위해 비슷한 방법을 썼던 기억이 났다. 1차 시험에서 많이 나오는 단원을 집중적으로 공부해서 나머지는 찍어서 과락을 넘기는 방법으로 10개년도를 분석하여 많이나오는 단원(재고자산4, 유형자산7 문항) 40문제 중 11문제 이상을 확실히 맞출수 있다면 나머지를 찍어도 과락(40점)을 넘기는 방법이었다. 이론상으론 재고자산, 유형자산 단원만 확실하게 다 맞춘다면 회계 과목에서 과락을 맞지 않는다. (당시 이 방법으로 1차를 금방 붙었으나 2차에서 탈락의 고배를 마시고 그만두었다)

 

사업상의 어려움을 소개한다. 백만장자의 자녀에게 '그들도 사업을 하길 원하는가' 의 질문에서 대부분이 NO를 말한다. 그보단 전문직을 선택하길 원했다. (의사, 변호사, 회계사 등등) 여기서 저자도 책 대부분의 내용인 백만장자 중 사업가가 많고 어떤 유형의 사업가가 백만장자 비율이 높고 (평범하고 지루하지만 꾸준히 성장하는 분야- ex. 인력사무소, 세탁업, 자영업 등 고객을 꾸준히 늘리는 업) 그들은 어떤 마음가짐을 가졌고 등등.. 그들을 낱낱이 분석하면서 독자들이 너도나도 사업으로 뛰어들까봐 마지막에 이런 항목을 넣은 듯 하다. (실제로 저자는 언론 기사들이 사업중 망하는 사례는 쏙 빼놓고 일부 성공 사례만 보도 하는게 싫다고 이야기한다)

나도 상당히 혹하긴 했다. 그래도 지금은 사업하기 정말 좋은 때라고 생각하는게 AI 를 사용해서 소프트웨어 사업을 하면 창업비용을 거의 들이지 않고 로우리스크로 회사와 병행하며 할 수 있다고 생각한다. 개발자인 나의 특성을 고려해서 접목해보자. 먼저 지금 만들고 있는 에어비앤비 개발에 박차를 가해보자.

 

이 책을 읽고 생긴 액션 포인트

- 매일 7시 러닝 (건강뿐 아니라 매일 마음가짐을 다 잡고 성취감까지 준다는 면에서 정말 최고라고 생각한다)

- 26년도 목표 (목표를 향한 월단위, 주단위, 일단위 계획)

- 사이드 프로젝트 (결국 나의 직업에서 레밸업을 해야한다)

 

할 수 있다! 화이팅!

자주 사용하고 중요하지만 종종 잊어버리는 것들을 정리해봤습니다. React 개발자라면 복습겸 슥 읽어보셔도 좋을것 같습니다😊

 

useEffect 의 의존성 배열

의존성 배열은 얕은 비교를 해서 객체를 의존성 배열에 두면 예상치 못한 사이드 이팩트가 발생할 수 있음.

const [value, setValue] = useState({ id: 0, name: "" })
const {id, name} = value;
useEffect(() => {
  console.log(value); // value 가 변하면 console 실행
}, [value]);

useEffect(() => {
  console.log(value); // value 중 name 만 변하면 console 실행 x
}, [id]); // 객체보다 프로퍼티를 사용해 내부 로직을 더 잘 관리할 수 있음!

 

더이상 forwardRef 는 사용하지 말자.

(React에서도 공식적으로 19버전부터 필요하지 않고 향후 릴리스에 중단 발표 https://ko.react.dev/reference/react/forwardRef)

 

ref를 props 로 넘길 때 별도로 type 지정 해줘야됨

처음엔 forwardRef  안써도 된다길래 ...props 에 낑겨서 넘어갈줄 알았는데 안되더라

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
    children: React.ReactNode;
    onClick: () => void;
    ref?: Ref<HTMLButtonElement>; 
}

const Button = ({
    children,
    onClick,
    ref = null;
    ...props // 여기에 ref 는 안낑겨 들어간다 type 지정을 별도로 해주자
}: ButtonProps) => {
  return (
    <button
      onClick={onClick}
      ref={ref}
      {...props}
    >
      {children}
    </button>
  );
}

 

useReducer 사용

리팩토링 할 시간 많이 없는거 알지만.. 한번씩 이런거 써서 정리하면 너무 좋다..?

먼저 효용감을 체감해보자. useState로 이름과 나이를 변경하는 간단한 함수를 구현했다. 

type State = {
  name: string;
  age: number;
};

const Test = () => {
  const [state, setState] = useState<State>({
    name: "jack",
    age: 20,
  });

  const onPlusAge = () => {
    setState((prev) => ({
      ...prev,
      age: prev.age + 1,
    }));
  };

  const onChangeName = () => {
    setState((prev) => ({
      ...prev,
      name: "park",
    }));
  };

  return (
    <div>
      <button onClick={onPlusAge}>onplus age</button>
      <button onClick={onChangeName}>onchange name</button>
    </div>
  );
};

export default Test;

 

이걸 useReducer로 변경하면 로직이 감추어져서(reducer 부분) Test 컴포넌트는 보다 간결해지고 추상화되어 가독성이 좋아졌다.

import { useReducer } from "react";
type Action = { type: "plus_age" } | { type: "change_name"; newName: string };

type State = {
  name: string;
  age: number;
};

const reducer: React.Reducer<State, Action> = (state, action) => {
  switch (action.type) {
    case "plus_age": {
      return {
        ...state,
        age: state.age + 1,
      };
    }
    case "change_name": {
      return {
        ...state,
        name: action.newName,
      };
    }
  }
};

const Test = () => {

  const [state, dispatch] = useReducer(reducer, {
    name: "jack",
    age: 20,
  });

  const onPlusAge = () => {
    dispatch({ type: "plus_age" });
  };

  const onChangeName = () => {
    dispatch({ type: "change_name", newName: "park" });
  };

  return (
    <div>
      <button onClick={onPlusAge}>onplus age</button>
      <button onClick={onChangeName}>onchange name</button>
    </div>
  );
};

export default Test;

 

다른 예시를 하나 더 보도록하자.

평범한 투두리스트이다.

function TodoReducer() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [inputValue, setInputValue] = useState("");

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  const handleAddTodo = () => {
    if (inputValue.trim() !== "") {
      setTodos([
        ...todos,
        { id: Date.now(), text: inputValue, completed: false },
      ]);
      setInputValue("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const handleDeleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const handleClearCompleted = () => {
    setTodos(todos.filter((todo) => !todo.completed));
  };

  return (
    <div>
      <h1>Todo App</h1>
      <input
        type="text"
        value={inputValue}
        onChange={handleInputChange}
        placeholder="Add a new todo"
      />
      ..생략

 

이것도 useReducer 로 변경해보자

input 부분을 AddTodo 로 리팩토링하는 기믹을 부리긴 했지만 로직이 감추어져서 컴포넌트의 가독성이 훨씬 좋아졌다

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

type Action =
  | { type: "ADD_TODO"; value: string }
  | { type: "TOGGLE_TODO"; id: number }
  | { type: "DELETE_TODO"; id: number }
  | { type: "CLEAR_COMPLETED" };

const reducer: React.Reducer<Todo[], Action> = (state, action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [
        ...state,
        { id: state.length + 1, text: action.value, completed: false },
      ];
    case "TOGGLE_TODO":
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case "DELETE_TODO":
      return state.filter((todo) => todo.id !== action.id);
    case "CLEAR_COMPLETED":
      return state.filter((todo) => !todo.completed);
    default:
      return state;
  }
};

function TodoReducer() {
  const [state, dispatch] = useReducer(reducer, []);

  const handleAddTodo = (text: string) => {
    dispatch({ type: "ADD_TODO", value: text });
  };

  const handleToggleTodo = (id: number) => {
    dispatch({ type: "TOGGLE_TODO", id });
  };

  const handleDeleteTodo = (id: number) => {
    dispatch({ type: "DELETE_TODO", id });
  };

  const handleClearCompleted = () => {
    dispatch({ type: "CLEAR_COMPLETED" });
  };

  return (
    <div>
      <h1>Todo App</h1>
      <AddTodo addTodo={handleAddTodo} /> // 리팩토링
      .. 생략

 

아래는 AddTodo 컴포넌트

import { useState } from "react";

interface AddTodoProps {
  addTodo: (text: string) => void;
}

const AddTodo = ({ addTodo }: AddTodoProps) => {
  const [inputValue, setInputValue] = useState("");

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  const handleAddTodo = () => {
    if (inputValue.trim() !== "") {
      addTodo(inputValue);
      setInputValue("");
    }
  };

  return (
    <>
      <input
        type="text"
        value={inputValue}
        onChange={handleInputChange}
        placeholder="Add a new todo"
      />
      <button onClick={handleAddTodo}>Add Todo</button>
    </>
  );
};

export default AddTodo;

 

확실히 기능 쳐낼때보단 리팩토링하고 설계하면서 개발자적인 무언가(?)가 성장하는 것 같다.

GPT 딸깍도 좋지만.. 책보면서 공부하는거 놓치지 말자구 화이팅~🙌

'React' 카테고리의 다른 글

상태관리와 랜더링 최적화 -1  (0) 2024.08.19

새로운 회사에 입사하기 전 여러 토이 프로젝트로 워밍업을 하고 독서도 다시 시작했다. 그중에 읽어야지 하면서 장바구니에 담아 두었던 '함께 자라기'라는 책을 집어 들었다. 개인과 팀이 성장하기 위한 구체적인 방법을 제시해주는 정말 좋은 책이다.

 

경력, 그 견딜 수 없는 무거움

'강한놈이 오래 가는것이 아니라, 오래 가는 놈이 강한 거더라. -장필호 <짝패>에서

 

막연하게 걱정했던 부분이었다. 경력 2년차인데 프론트엔드 사수없이, 동료 프론트엔드 개발자 없이 쌓인 경력이 점점 늘어나면 도태되는거 아닌가. 그보다 이것저것 공부는 하고있는데 성장하는게 맞나? 싶은 부분들.

작가도 개발 경력이 실력에 연관관계가 크게 있지 않다고 말하고있다. 처음 1년 정도의 경력은 연관관계가 크지만 2년 그 이상부터는 오히려 경력이 많은게 실력과 연관성이 없다고까지 얘기한다. 그동안 고연차의 개발자들의 무용담을 들어오며 '아 나도 저렇게 성장할 수 있을까' 싶었던 막연한 불안감이 어째 사라지는 듯 했다. 물론 물경력의 개발자가 많으니까 나도 그래도 되겠지 싶은 건 아니고, 그 많은 물경력 개발자들 사이에서 진짜 실력있는 개발자가 되는 방법을 이 책을 통해 알게 되어서 인것 같다.

 

1만시간의법칙? 그동안 잘못 알고 있었다. 1시간을 그냥 시간만 들인다고 되는게 아니라 성장을 위해 자신의 약젖을 개선하려고 애쓰는 시간이 1만시간이 쌓여야 진짜 1만 시간의 법칙이었다. 50년 걸었다고 걷는 달인이 되지 않는 것처럼

 

자개계발은 복리로 돌아온다.

회고의 중요성을 얘기한다. 어떤 일이 끝났을 때, 연말에 일년을 돌아보며 그리고 회고에는 자기계발에 대한 내용이 있어 얼마나 성장했는지 알 수 있다. 하루 1% 이자가 복리로 붙으면 70일만에 원금의 2배가되고 1년이면 38배가 되는 성장을 말하고 있다.

조직에서도 통하는 이야기인데,

 

조직이 A결과를 내고 다음 프로젝트에 같은 조직이 B결과를 낸다. (일반적인 기업)

하지만 여기에 기업 차원의 성장은 적다.

 

조직이 A결과를 내고 다음 프로젝트에 같은 조직이 A결과를 활용해 B결과를 낸다. 이것이 쌓여 기업과 조직의 복리 성장이 가능해진다.

 

포인트는 어떻게 이율을 올릴지, 이자 주기를 짧게 할 수 있을지이다.

방법은 다음과 같다.

1. 이미 가지고 있는 것을 활용하라 (서로 연결하고 서로 넘나들기 수월해지도록 하라)

2. 주기적으로 외부 물질(자극)을 받고 재빨리 내것으로 만들어라 

3. 자신을 개선하는 프로세스에 대해 생각해봐라 (회고를 주기적으로 할 수 있도록 시스템을 만들어라)

4. 피드백을 자주 받아라.

 

학습프레임과 실행프레임

아이들 2그룹으로 나눠 그림을 그리게하고 A 그룹에는 평가하고 점수를 측정하겠다 말하고, B 그룹에는 평소에 그리지 않던 것을 자유롭게 그리라고 한다.

결과는 B그룹 참여율이 압도적을 높았고 이를 점수를 얻기 위한 실행 프레임이 아닌 평소에 그리지 않던 그림(배우는) 학습 프레임의 장점이라고 설명한다. 회사에서 적용하게되면 '일정 안에 해야되는 업무로 바쁜데 어떻게 하냐'고 생각할수 있지만 실제 직원들 인터뷰를 통해 실행하는 직원들이 분명 있었고 몇년 후 그들의 성장 정도는 분명 다를 것이다. 마인드의 차이는 정말 중요하다고 생각한다.

 

'달인'이 되는 비결

동기가 필요하고 적절한 시기의 구체적인 피드백이 필요하다

그리고 내 업무에 '몰입'이 된다면 계속해서 성장할 수 있다.

 

몰입은 적절한 난이도를 통해 발생한다.

이 중에서 실력에 비해 난이도가 높으면 '불안함' 상태가 되고

실력에 비해 난이도가 낮음연 '지루함' 상태가 된다 

즉, 몰입은 잘 일어나지 않게된다.

 

b1. 난이도를 낮춰 몰입으로 갈 수 있다.

자신이 맡은 일의 간단하면서 핵심적인 결과물 (아기버전 0.0.1)을 첫번째 목표로 삼는다. 프로토 타입을 개발하고 실제 작업을 하게되는 것 만으로도 학습효과, 적절한 난이도를 통한 동기강화, 스트레스 감소, 자기효능감 증가의 장점이 있다. 

 

b2. 실력을 높여서 몰입으로 갈 수 있다.

전문가의 도움을 받거나 (페어 프로그래밍, 튜터리얼 문서 활용 등), 도움이 되는 툴, 라이브러리를 사용하거나,

비슷한 일을 했던 경험을 머릿속에서 되살려 문제를 해결해보는 것은 자기효용감까지 증가시켜준다. 

 

a1, a2는 모래주머니를 달아 내 실력을 떨어뜨리거나 업무에 기한을 줄이는 것으로 난이도를 조절해 몰입으로 들어간다.

 

몰입으로 들어가는 핵심은 지속적으로 난이도와 본인의 위치를 파악해 계속해서 조정하는데 있다.

회사에서 난이도가 어려운 업무를 맡았는데 팀장이 계속해서 닥달하고 핀잔을 준다면 나의 위치를 파악하고 몰입으로 가기 더욱 힘들어진다. 그리고 지루함 상태인데 회사에서 근로의욕 증진을 위해 교육이나 컨퍼런스 참여등을 더욱 시키다면 실력과 난이도가 더욱 차이나게 되는 현상이 발생하므로 현재 상황과 메타인지가 정말 중요한 부분인것 같다.

 

b1은 공감되는게 전에 개발하던 것 중에 난이도가 높아 진행이 더디게 되던 적이 있었는데 프로토타입을 만들고 실제 작업에 들어가니 훨씬 수월했던 경험이 있다. 그리고 b2에서 중요한건 실력을 높여 난이도가 높은 문제를 해결했을 때의 상황과 방법을 잘 기록해두는게 중요한 것 같다. 이건 작가가 말하는 성장의 내용과도 일치하는 부분이라고 생각한다.

 

실수는 예방하는 것이 아니라 관리하는 것이다.

전문가도 1시간에 평균 3~5개의 실수를 저지른다고 하는데 뉴비인 내가 실수가 무서워 예방하려고만 하지는 않았나? 

실수를 예방하는 것보다 관리하는 경우에 기업의 혁신 정도가 높고 수익성이 높은 연구 결과가 있다.

그 이유는 실수를 통해서 회사와 조직원이 학습하기 때문이다.  

 

전문가에 대한 미신

전문가를 생각하면 고독한 천재 이미지를 가지고 있지는 않나? 나도 혼자서 척척 해내는 사람을 곧 잘 생각하곤 한다.

하지만 전문가일수록 어떤 문제에 부딪혔을 때 '타인'에게 도움을 받는 시간이 적게든다고 한다. 

뛰어난 소프트웨어 개발자일수록 타인과 인터렉션에 더 많은 시간을 쓴다. 이들 전문가들은 보다 사회적 자본(인맥) 이 뛰어난 경우가 많다. 사회적 자본은 신뢰와 영향이 깊다. 사회적 기술을 통해 타인과의 신뢰관계를 쌓는 것이 중요하다.

일상적인 동료들과의 인터렉션을 통해서 얼마든지 사회적 기술을 훈련할 수 있다.

인사 주고받기, 지나가는 대화, 물어보기 등 일상적이고 소소한 것들인데 이것을 기록하고 복기하는 것으로 충분히 훈련이 가능하다.

 

좋은 내용은 많지만 이정도면 핵심은 정리한것 같다.

<몰입 가운데서 업무를 하고 적절한 내용과 시기의 피드백을 통한 성장> 이 사이클을 가능한 짧게 반복하는 것.

 

오늘도 1% 성장했다. 다들 화이팅🙌

코딩은 개발 근육을 키운다는 말이 종종 체감되는게

추상화한 코드만 사용하다보면 내부 로직들이 기억이 희미해져서 에러 핸들링 하는 부분만 처음부터 작성해보기로 한다

 

먼저 공통으로 사용되는 호출 api를 만든다

export const apiRequester = axios.create({
  baseURL: "https://fakestoreapi.com",
  timeout: 5000,
});

 

try catch 를 통해 에러 핸들링을 해준다.

const apiLogin = async (
  payload: loginData
): Promise<{ token: string }> => {
  try {
    const url = "/auth/logins";
    const response = await apiRequester.post(url, payload);
    return response.data;
  } catch (err) {
    throw new Error("일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
  }
};

 

Axios 라이브러리는 에러에 대해 isAxiosError 라는 타입 가드를 제공하고 있다 타입 가드를 통해 error 응답 값을 핸들링해보자

// 서버에서 error 응답 값을 이렇게 내려준다고 가정해보자

interface ErrorRespnse {
  message: string;
  status: number;
  response?: {
    data: string; // 400번대 에러는 data에 에러 메시지가 들어있지만 500번대 에러에는 없는걸 가정
  };
}
// catch 부분만 수정
catch (e) {
    const axiosError = isAxiosErr(e);
    if (axiosError && e?.status) {
      const errorMessage = onActionByErrorStatus(e); // 상태 코드에 따라 에러메시지 return 함수
      throw new Error(errorMessage);
    }

    throw new Error("일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
  }
  

export function isAxiosErr(error: unknown): error is AxiosError<ErrorRespnse> {
  return axios.isAxiosError(error);
}

 

axios error 일 경우 응답 스키마에 status를 포함하고 있어 status 에 따라 적절히 에러메시징을 return 해준다 

export function onActionByErrorStatus(error: AxiosError<ErrorRespnse>): string {
  switch (error?.status) {
    case 401:
      return "인증이 필요합니다. 다시 로그인 해주세요.";
    case 404:
      return error?.message || "요청하신 리소스를 찾을 수 없습니다.";
    default:
      return (
        error?.message ||
        "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
      );
  }
}

 

상태 코드에 맞게 에러 핸들링을 해주었다.

다만, api 요청할 때마다 catch문에 위 작업을 반복해줘야해서 번거로운것 같다.

 

Axios 인터셉터를 적용해 간단하게 만들어보자 

apiRequester.interceptors.response.use(
  (response) => response, // 정상 응답인 경우 정상 응답 반환
  (error: AxiosError<ErrorRespnse>) => { // error 인 경우 status code에 따라 에러 핸들링
    let errorMessage =
      "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";

    if (error?.status) {
      const status = error.status;
      switch (status) {
    	case 401:
      	  errorMessage = "인증이 필요합니다. 다시 로그인 해주세요.";
          break;
        case 404:
          errorMessage =
            error?.message || "요청하신 리소스를 찾을 수 없습니다.";
          break;
        default:
          errorMessage =
            error?.message ||
            "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
      }
      return Promise.reject(new Error(errorMessage));
    }

    return Promise.reject(new Error(errorMessage));
  }
);

 

이렇게 해주면 error 핸들링을 인터셉터에서 해주기 때문에 api 호출부분도 이렇게 간결해진다. (try catch 아웃!)

export const apiLogin = async (
  payload: loginData
): Promise<{ token: string }> => {
  const url = "/auth/login";
  const response = await apiRequester.post(url, payload);
  return response.data;
};

 

인터셉터에서 로그인 인증 관련 (401) 처리를 해주면 아주 유용하다.

case 401:
  errorMessage = "인증이 필요합니다. 다시 로그인 해주세요.";
  localStorage.removeItem("token");
  const result = confirm(errorMessage);

  if (result) window.location.href = "/login";
  break;

너무 좋아하는 것을 '이거 좋으니까 한번 해보세요 잡숴보세요!' 자랑하는 아이처럼 

영화 평론가 이동진 작가는 이 책을 통해 독서를 권장하고 있다.

덕분에 간만에 책을 들었는데도 그동안 잊고 있었던 독서의 효용과 근본적인 즐거움을 깨닫는 좋은 시간이었다.

 

그럼에도 불구하고 책

먼저 목적에 의한 책의 효용을 알기 쉽게 전달해준다. 그리고 장점들을 이야기하며 막연하게 있는 독자들의 진입장벽을 낮추도록 도와준다. 조금만 읽어도 독서에 흥미가 생겨 다른 읽고 싶은 책들을 찾게된다. 문체가 짧아서인지 묘하게 몰입감이 좋다.

 

1) 정보전달

작년까지 모르는건 구글링하고 올해부턴 GPT를 활용해온 나로서도 모르는 정보는 인터넷을 통해 얻는게 습관화되어있었다. 정보를 찾기 위해 뒤적이던 작년과는 다르게 즉각적으로 필요한 부분만 알려주는 GTP의 사용을 거부하기는 어려운것 같다. 그럼에도 어떤 정보의 맥락이나 너무나 즉각적인 정보에 소화할 시간을 미처 두지 못하고 다음 정보를 검색하고 수동적으로 받아들이는 답답함? 같은게 있었다. 이전에 구글링을 할 때는 그나마 파편화된 정보들을 모으고 나의 정보로 소화시키는 시간이 필요했는데 GPT를 통한 정보의 습득은 또 다른 느낌이다.

 

이동진 작가는 이부분을 이야기 하고있다. 인터넷의 파편화되고 전문적이지 못한 정보의 바다에서 책이 주는 정제된 정보들을 전체 맥락과 함께 이해하는 효용에 대해서 말이다. 상당부분 동의한다.

 

2) 있어빌리티

책의 효용성은 그야.. 읽고 있으면 아니 들고만 있어도 있어보이지않나? 있어보인다 라는 것은 근본적으로 나에게 없는 것이라는 걸 알고 그 지적으로 비어있는 부분을 이해하고 채우려는 노력이 좋다고 생각한다. 비록 보여지기 위한 목적 때문이라도 그게 독서를 하는 동력이 된다면 꽤나 지지한다고 작가는 이야기한다. 얼마전 뇌과학 관련 영상을 봤는데 뇌는 보상이 있는걸 좋아한다는 내용이었다. SNS나 블로그를 통해 멋진 내가 독서 하는걸 자랑하며 얻는 도파민 보상으로 계속해서 독서를 하게되는 선순환으로 사용해도 좋아보인다.

 

3) 재밌으니까

영화 평론가 이동진 작가는 '하루에 8시간 아니 12시간씩 매일해도 질리지 않는다 할수 있다' 라고 이야기한다. 하루에 8시간이나 할 수 있는건 일이랑 독서밖에 없다고하며 본인이 영화를 좋아해도 하루 3편이나 보면 힘들다고 한다. 그만큼 책이 재밌어서 읽는데 보통 재미로 독서하는 매우 긍정적인 취미를 가진 사람이 내가 아니다보니.. 엄청 와닿지는 않는다. 하지만 나도 매년 8권 정도는 읽어와서 무슨 느낌인지는 알거같다. 얘기가 나와서 말인데 올해 2월즈음부터 약 7개월동안 책을 1권도 읽지 않았다. 못했다? 아니 읽지 않는것을 선택했다. 4월부터 9월 중순까지 취업준비를 하면서 당장에 성과가 보이는 것들만 해왔다. 나름대로 열심히 해왔는데 매 순간의 최선의 선택이 전체적으로는 최선이 아닌 아니 지금와서보면 차악 정도의 선택이 되지 않았나 싶다. 혼자서 몰입했던 취업준비는 사고를 좁게하고 뇌가 상상하지 못하게 만들었다. 좋아하는 사람을 만나 이야기하고 책을 읽으며 사고를 확장하는 경험을 6개월을 쉬었더니 뇌가 노화되어감을 느꼈다. 그걸 겨우 50p 남짓 읽은 책이 깨줬다. 다시 활력을 불어줬다.

 

다시 돌아와서, 독서에는 진입장벽이 있다. '책 읽어도 도움되는지 모르겠어요' 하는 말에도 어느정도 이해는 간다. 하지만 처음엔 물에 설탕을 넣으면 설탕이 녹아 버리지만, 설탕을 계속 넣어 과포화용액이 되면 넣은 설탕들이 고스란히 보인다. 처음에는 도움이 안되는 것 같이 느껴지겠지만 어느순간 눈에 보일 정도로 내 삶에 많은 변화를 주는 것이 독서고 눈에 보이지 않을 때에도 나에게 영향을 계속 주고있다는 것을 잊지 않으면 조금은 독서에 더 의미를 부여할 수 있지 않을까?

 

4) 완독하지 않아도 돼요

나도 처음에는 완독의 저주에 걸렸었다. 뭔가 다 읽어야만 책임을 다한 것 같고, 그 지식이 나에게 전달 된 것 같고 뭐 그런느낌으로.

이동진 작가는 그런 사람들에게 완독에 대한 부담감을 내려놓으라 얘기한다. 책에게 책임감을 느끼지 말라고한다.

대표적으로 오랜시간 서울대 필독서였던 총,균,쇠 예시를 들며 읽기 싫은 책을 읽어야지 읽어야지 하며 꾸역꾸역 읽기보단 재밌어 보이는 위대한 게츠비를 읽고 그것도 읽다가 재미 없으면 다른책을 읽으라고 한다.

 

나도 언제부턴가 읽고 싶은 책을 2~5권 한꺼번에 사고 그 중에 재미없는 책은 거르고 재밌는 것만 읽게 되었다. 그렇게 하니까 독서의 흥미를 계속 유지할 수 있던것 같다.

그리고 개인적으로 느끼는 독서의 장점으론 지적 호기심이 생긴다. 그 호기심을 또 독서로 채울수가 있다. 일전에 김영하 작가의 검은 꽃을 읽다가 한국 근대사에 관심이 생겨 파친코를 읽고 벌거벗은 세계사, 벌거벗은 한국사를 읽으며 내가 알고 있는 세계가 확장되는 경험을 했다. 또 어떤 책에서 약간의 철학을 다루는 것을 보고 소크라테스의 변명, 정의란 무엇인가 를 읽으며 약간의 철학을 맛보는 경험을 했다. 이런 독서로 느낀 도파민은 유튜브, 미디어의 도파민과는 달라서 다시 찾으러 가야하는 매력이있다

 

그래서 좋은 독서란 무엇일까

 

글을 쓴다는건 작가의 지적인 세계를 투영하고 글을 읽는다는 건 '나'라는 세계가 작가의 세계와 부딪혀 교감을 하게된다. 책을 통해 정보를 얻으려는 목적 너머에, 작가와 나 사이의 연결 그 연결을 통해 내게 어떠한 영향을 주는 것 그게 독서라고 한다면 좋은 독서라는 명확한 답지는 없고 나에게 좋은 영향을 주는 세게를 찾는 여정을 떠나야 되지 않을까? 수많은 작가와 수많은 독자들의 연결 속에서 어떤 일이 벌어질지는 모두 다를테니까.

 

이 책은 2부작으로 1부에 대한 내용만 읽고 생각을 적어봤다. 2부에 대한 내용은 인터뷰형식으로 진행되어 읽기는 좋았는데 내게는 1부가 좋은 독서가 되어주었다.

간만에 정말 좋은 독서의 시간이었고 앞으로 내게는 영화 평론가 이동진보다 작가 이동진으로 더 기억될 것 같다.

'잡담' 카테고리의 다른 글

이웃집 백만장자  (0) 2026.01.02
함께 자라기  (0) 2025.09.19
스터디카페 추천 헤헤  (3) 2022.11.21
React 시작시 기본적으로 설치해야되는거!  (0) 2022.08.13
4. 나만의 프로젝트  (0) 2022.07.28

 

🤔당시 고민했던 것들 

카페 24 보다 차별성이 있는가?

 

카페 24를 활용하면 제공해주는 템플릿을 통해 빠른 플랫폼 구축이 가능합니다.

직접 디자인과 개발없이도 결제, 회원가입, 장바구니 등의 핵심 기능이 내장되어 있어 회사는 '비즈니스' 에 몰입할 수 있습니다.

 

백오피스를 개발하지 않아도 관리자 페이지에서 재고, 주문, CS 관리를 할 수있게 제공해주는데,

가격은 기본적인 PG사 가입 비용 등을 제외하고 호스팅, 쇼핑몰 솔루션에 대한 초기 비용은 10만원이 안됩니다..

 

그래서 카페24 장점이 이렇게 많은데 왜 직접 개발했나요?

 

우리 제품은 B2B에 특화되었고 IoT와 로봇 부품이라는 특수한 도메인이었습니다.

부품별로 특화된 실시간 호환성을 체크 해주는게 기능이 필요하고

ex:  아두이노 우노 선택 시 호환 가능한 센서/ 모터만 자동 필터링

 

프로젝트별로 맞춤 제작 & 부품 리스트를 어필하는 것이 포인트였습니다.

ex: "스마트 화분" 선택 시

필수 부품 : 아두이노, LED, 온습도 센서, 토양습도 센서, 펌프 등

 

특히 타깃 고객은 연구진이나 개발진인데 이들에게는 기술적인 지원이 필요했습니다

제품, 부품 구매 후 코드 제공 서비스라던지 

 -> 구매한 프로젝트에 맞는 아두이노/ 라즈베리파이 코드 제공

 -> GitHub 연동으로 지속적 업데이트

 -> 버그 리포트 시 실제 엔지니어 지원

 

실시간 기술 지원이라던지

-> 1:1 문의 게시판 및 실시간 채팅으로 지원

 

따라서 차별화된 기능과 워크플로우가 필요했기 때문에 직접 개발을 선택하게되었습니다. 

 

 

UI/UX 설계

1. 주문 취소/환불 프로세스 설계

주문 취소와 환불은 매우 복잡한 비즈니스 로직이었습니다. 결제 방식별로 다른 처리 과정, 부분 취소, 환불 지원 그리고 법적 요구사항까지 고려해야 해서 많은 고민이 필요했습니다. 고객 신뢰와 직결되는 중요한 부분으로 처음에는 단순하게 생각 했지만,

구현 과정에서 복잡성을 깨달았습니다.

 

1) 용어 정리 

주문 취소: 아직 배송이 시작되지 않은 상태에서 주문을 철회

주문 환불: 이미 배송된 상품을 반품하고 돈을 돌려받음

 

주문 취소는 배송도 결제도 일어나지 않아 재고만 복구해주면 되었지만,

환불은 실물 반품부터 받은 상품 상태를 확인해야하고 배송비 등의 문제도 있었습니다.

 

2) 주문 상태별 취소/ 환불 정책

주문 상태 고객 취소 가능 관리자 취소 가능 환불 가능 비고
주문접수 ✅ 즉시 ✅ 즉시 -
결제완료 ✅ 자동 ✅ 수동 결제 취소 API 호출 필요
배송중 ✅ 배송 완료 후 택배사 연동 필요
배송완료 ✅ 7일 이내 전자상거래법 준수

 

3) 프론트엔드로서 고민한 부분

주문 상태에 따라 UI 와 버튼 노출이 달라져야합니다.

취소 가능 여부, 환불 가능 여부, 관리자 승인 필요 여부를 고려해 조건부 렌더링이 필요한 부분이었습니다.

 

가능한 진행 상태를 명확히 보여주려고 했습니다. 사용자가 쉽게 실수하지 않도록 Confirm modal을 적극 활용하고

취소 요청 -> "승인 대기 중",  환불 요청 -> "환불 처리 중" OR "완료" 등 단계별로 텍스트 상태를 구분해주었습니다.

 

에러 및 예외 처리도 가능한 명확하게 안내하려고 했습니다.

export type ApiError = { status: number; code?: string; message?: string; };

const ERROR_MSG: Record<string, string> = {
  PAYMENT_CANCEL_FAILED: "결제 취소에 실패했어요. 고객센터로 문의해주세요.",
  ALREADY_SHIPPED: "이미 배송이 시작되어 취소가 불가해요. 반품/환불을 진행해주세요.",
  PARTIAL_ONLY: "일부 품목만 환불 가능합니다.",
  TIMEOUT: "네트워크 지연으로 실패했어요. 잠시 후 다시 시도해주세요.",
  UNKNOWN: "요청 처리 중 문제가 발생했어요. 잠시 후 다시 시도해주세요.",
};

export function toUserMessage(err: unknown): string {
  const e = err as ApiError;

  // 1. HTTP Status 기반 처리
  if (e.status && STATUS_MSG[e.status]) {
    return STATUS_MSG[e.status];
  }
  
  // 2. 비즈니스 에러 코드
  if (e.code && ERROR_MSG[e.code]) {
    return ERROR_MSG[e.code];
  }
  
  // 3. 서버 기본 메시지 or UNKNOWN
  return e.message || ERROR_MSG.UNKNOWN;
}

 

개발 시 고려했던 점

 

1) 좋아요 상태관리

Tanstack Query 무한스크롤로 가져온 상품을 좋아요하는 기능이 있습니다. 좋아요는 서버 상태를 변경하는 이벤트니 좋아요 or 좋아요 취소 시 쿼리 키를 무효화하거나 queryQlient.setQueryData 를 통해 캐시 데이터를 업데이트 해주어야 합니다.

 

그런데 무한스크롤은 이미 많은 일을 하고 있습니다.

queryKey는 제품의 필터링, 검색 , 정렬등의 기능을 함께 가지고 있어 무한스크롤의 데이터를 건드리는데는 사이드 이팩트가 발생할 확률이 높습니다. 

 

좋아요의 상태를 즉시 UI에 반영하되 사이드 이팩트는 줄이기 위해 상태를 전역으로 관리해 책임을 분리했습니다.

// useProductStore.ts

likedProducts: new Set<string>(), 

// Actions
toggleLike: (productId: string) => {
    set((state) => {
      const newLikedProducts = new Set(state.likedProducts);
      if (newLikedProducts.has(productId)) {
        newLikedProducts.delete(productId);
      } else {
        newLikedProducts.add(productId);
      }
      return { likedProducts: newLikedProducts };
    });
},

 

좋아요의 서버 상태와 전역 상태 동기화를 위해 상위 컴포넌트에서 setter 함수로 좋아요 한 상품의 id를 연동시켰습니다.

// useProductStore.ts
setterLikedProduct: (productIds: string[]) => {
	set({ likedProducts: new Set(productIds) });
},


// App.tsx
const { data: likedProducts, isSuccess } = useLikedProducts();
  const setterLikedProduct = useProductStore(
    (state) => state.setterLikedProduct
  );
  const isSynced = useRef(false);

  useEffect(() => {
    if (isSuccess && likedProducts && !isSynced.current) {
      const likedProductIds = likedProducts.map((product) => product.id);
      setterLikedProduct(likedProductIds);
      isSynced.current = true; // 한 번만 동기화
    }
  }, [isSuccess, likedProducts, setterLikedProduct]);

 

이제 무한스크롤과 독립적으로 좋아요를 관리 할 수 있게되었고 productCard 컴포넌트에서 각각 좋아요 상태를 체크해주면 됩니다. 무한스크롤로 가져온 data는 좋아요 상태와 별도로 움직이기 때문에 productCard 에서 체크해주지 않으면 좋아요 상태가 누락하게됩니다.

 

// productCard.tsx
const isLiked = useProductStore((state) => state.isLiked);
const liked = isLiked(product.id);


// useProductStore.ts
isLiked: (productId: string) => {
	return get().likedProducts.has(productId);
},

 

 

2) 조건부 관리자 패널

커스텀훅을 활용해 권한에 따라 UI를 조건부 렌더링했습니다.

export const useAuth = () => {
  const { user } = useAuthStore();
  
  const isAdmin = user?.role === 'admin';
  const isUser = user?.role === 'user';
  
  const hasPermission = (requiredRole) => {
    if (requiredRole === 'admin') return isAdmin;
    return isAdmin || isUser; // admin은 모든 권한 포함
  };
  
  return { user, isAdmin, isUser, hasPermission };
};


// components/PermissionGate.jsx
import { useAuth } from '../hooks/useAuth';

const PermissionGate = ({ 
  requiredRole, 
  children, 
  fallback = null 
}) => {
  const { hasPermission } = useAuth();
  
  return hasPermission(requiredRole) ? children : fallback;
};

 

 

HOC (Higher-Order Component) 방식으로 관리자 패널을 관리해주었습니다.

// 사용 예시

<PermissionGate requiredRole="admin">
  <AdminPanel />
</PermissionGate>

 

프로젝트를 시작할 때 주변에서 이런 질문을 했습니다. "카페 24쓰면 되는데 왜 굳이 직접 개발해?"

처음엔 저조차도 드는 질문이었습니다. 하지만 카페24는 훌륭한 솔루션이었지만, 우리 고객에게 필요한 기능이 없었습니다.

 

이커머스 도메인을 개발한 시간동안 정말 많은 걸 배웠고 그 때문에 추가로 유사한 사이드 프로젝트를 하면서 부족했던 부분들을 채워나간 좋은 경험이었습니다. (UI와 애니메이션, 결제 등)

 

이 프로젝트의 기술적 선택과 고민의 중심에는 항상 사용자가 있었습니다. 

어려운 워크플로우를 클릭 몇번으로 사용자가 원하는 목표를 달성하도록 하는 고민이 중요하다는걸 깨달았습니다. 

여러분은 어떤 기준으로 UI를 설계하시나요? 댓글로 경험을 공유해 주세요! 🙌

 

ps.나중에 참고하려고 내용 정리

 

const routes = [
	{ to: '/login', label: '로그인', auth: false },
	{ to: '/product', label: '제품', auth: false },
	{ to: '/product/:id', label: '상세페이지', auth: false },
	{ to: '/user/cart', label: '카트', auth: true },
	{ to: '/history', label: '주문목록', auth: true },
]

 

// paymentModel
{
	-- 토스페이먼츠 필수 식별자
	payment_key // 토스페이면츠 결제 고유키
    order_id // 상점 주문번호
    customer_key // 구매자 고유 식별자
    
    -- 주문 정보
    order_name // 상품명
    amount // 결제 금액
    currency // 통화 (ex: KRW)
    
    -- 구매자 정보
    customer_name
    customer_email
    customer_phone
    
    status // 결제 상태 ( READY, PROGRESS, DONE, CANCELED(환불) )
}

// userModel

{
    name,
    password,
    email,
    role: number (관리자, 일반 사용자)
    cart: { (product)id, quantatiy, date }[],
	history: { (product)name, quantatiy, price, dateOfPurchase, paymentId(*중요) }
}

// productModel

{
	write: (user)id,
    title,
    description,
    price,
    images: string[],
    sold: number, (얼마나 팔렸는지)
    views: number,
    createdAt,
    updatedAt
}

신규 프로젝트를 시작하면 react를 install 하듯이 TanStack Query를 install 하게 되는것 같습니다

이번 포스팅은 TanStack Query 사용법과 실무에서 만났던 트러블슈팅을 정리해보고자 합니다.

 

1. TanStack Query 왜 써야 하나요?

 

TanStack Query 없이 data를 fetching 해보겠습니다.

const PostList = () => {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchPosts = async () => {
      setLoading(true);
      try {
        const response = await fetch('/api/posts');
        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []);
  
  return <div>hello, tanstack query! </div>
};

 

 

딱히 문제는 없는 코드입니다. 하지만 TanStack Query 와 비교하니 다음의 문제점이 보이네요 

1. 캐싱이 없습니다 - 다른 페이지를 갔다오면 api를 재호출합니다.

2. 중복 요청 - 같은 데이터를 여러 컴포넌트에서 사용하려면 중복 요청하거나 setter 함수로 전역 상태로 만들어 사용해야합니다.

3. 에러 발생 시 fetch 재시도 요청이 없습니다 (재시도 하려면 직접 구현해주어야 합니다)

 

이번엔 강력한 TanStack Query를 사용해보겠습니다.

const PostList = () => {
  const { data: posts, isLoading, error } = useQuery({
  	queryKey: ['posts'],
    queryFn: () => fetch('api/posts').then(res => res.json()),
    staleTime: 5 * 60 * 1000 // 5분간 fresh
    cacheTime: 10 * 60 * 1000 // 10분간 캐시 유지
    retry: 3 // api 응답 실패 시 3번 재시도
  });

 

힘의 차이가 느껴지시나요?

1. 자동 캐싱 & 중복 요청 방지: 같은 데이터를 여러 컴포넌트에서 사용해도 한 번만 요청하고 나머지는 캐싱한 데이터를 활용합니다 (이 기능 덕에 전역 상태로 만들지 않고 필요한 컴포넌트에서 각각 호출하기만 하면 됩니다)

2. 자동 재시도: api 응답 에러 시 재호출합니다.

그리고 useEffect를 사용하지 않고 로딩과 에러를 인터페이스로 제공해주는 등 코드의 양이 엄청나게 줄었습니다. 

 

2. 실무 사용 예시 

 

1) 무한스크롤

useInfiniteQuery를 이용해서 무한스크롤을 구현해보겠습니다

백엔드에서 응답은 이렇게 내려준다고 하네요

{
  "data": [
    { "id": 1, "title": "첫 번째 포스트" },
    { "id": 2, "title": "두 번째 포스트" },
    // ... 20개 데이터
  ],
  "page": 1,           // 현재 페이지 (1부터 시작)
  "size": 20,	      // 내려줄 데이터 개수
  "totalPages": 50,    // 전체 페이지 수
  "totalElements": 1000 // 전체 데이터 개수
}

 

 

요렇게 커스텀 훅으로 작성하는 것이 재사용하기 좋습니다.

const fetchPosts = async ({ pageParam = 1 }) => {
  const params = new URLSearchParams({
    page: pageParam.toString(),
    size: '20'
  });
  const response = await fetch(`/api/posts?${params}`);
  return response.json();
};

// hooks/usePosts.js
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/posts';

export const useInfinitePosts = () => {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => fetchPosts({ 
      pageParam
    }),
    getNextPageParam: (lastPage) => {
      return lastPage.page < lastPage.totalPages  // 마지막 페이지인지 체크
        ? lastPage.page + 1 
        : undefined;
    },
    initialPageParam: 1,
  });
};

 

useQuery와 동일하게 여러 컴포넌트에서 같은 데이터를 공유합니다.

useQuery처럼 여러 인터페이스를 제공해줘서 코드 수가 확 줄었습니다🎉

( useState로 fetchNextPage, hasNextPage, isFetchingNextPage를 관리한다고 생각하면 고개가 가로저어지네요🙂‍↔️ )

 

// components/PostList.js
import { useInfinitePosts } from '../hooks/usePosts';

const PostList = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts();

  const allPosts = data?.pages.flatMap(page => page.data) ?? [];

  return (
    <div>
      {allPosts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
};

 

 

2) 낙관적인 UI 업데이트

서버 응답을 기다리지 않고 UI를 바로 업데이트 해줘 보다 좋은 사용자 경험을 제공합니다.

그후, 서버 응답이 성공하면 서버 데이터로 업데이트 하고 서버 응답에 실패하면 이전 상태로 롤백해주는 방식으로 동작합니다. 

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

// 장바구니 조회 API
const fetchCart = async () => {
  const response = await fetch('/api/cart');
  return response.json();
};

// 장바구니 추가 API
const addToCart = async (product) => {
  const response = await fetch('/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(product)
  });
  return response.json();
};

function Cart() {
  const queryClient = useQueryClient();

  // 장바구니 데이터 조회
  const { data: cart = [], isLoading } = useQuery({
    queryKey: ['cart'],
    queryFn: fetchCart
  });

  // 장바구니 추가 mutation (낙관적 업데이트)
  const addToCartMutation = useMutation({
    mutationFn: addToCart,
    
    // 낙관적 업데이트 - API 호출 전에 UI 즉시 업데이트
    onMutate: async (newProduct) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries(['cart']);
      
      // 현재 데이터 백업 (롤백용)
      const previousCart = queryClient.getQueryData(['cart']);
      
      // 낙관적으로 UI 업데이트
      queryClient.setQueryData(['cart'], (old = []) => [
        ...old,
        { ...newProduct, id: Date.now() } // 임시 ID
      ]);
      
      return { previousCart };
    },
    
    // 성공 시 실제 서버 데이터로 업데이트
    onSuccess: (data) => {
      queryClient.setQueryData(['cart'], data);
    },
    
    // 실패 시 이전 상태로 롤백
    onError: (err, newProduct, context) => {
      queryClient.setQueryData(['cart'], context.previousCart);
    }
  });

  const handleAddToCart = (product) => {
    addToCartMutation.mutate(product);
  };

 

낙관적인 UI 업데이트를 직접 구현할 수도 있지만,

useMutation 을 사용하면 이전 요청 취소, 연속으로 인터렉션 발생 시 순서 보장, 중복 방지를 자동으로 처리해줍니다..b

또한, 서버 응답 결과에 따라 모든 케이스를 수동으로 처리를 해주어야됩니다🙂‍↔️ 

 

 

3) select 활용

가져온 데이터에서 일부 필드만 사용하고 싶다면

const PostTitles = () => {
  const { data: titles } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    select: (data) => 
      data.posts
        .map(post => post.title), // 제목만 추출
  });
  
  return (
    <ul>
      {titles?.map((title, index) => (
        <li key={index}>{title}</li>
      ))}
    </ul>
  );
};

 

select는 렌더링 최적화와도 관계가 있습니다.

API 응답이 { title: "hello", content: "world" } 라면 content의 서버 상태가 바뀐다면 postTitles 컴포넌트는 title 만 구독하고 있기 때문에 useQuery 내부에서 렌더링 사이클을 태우지 않습니다.

 

하지만 select를 사용하지 않았다면 posts 객체를 모두 구독하기 때문에 postTitles 컴포넌트는 리랜더링이 되고맙니다😣

 

3. 트러블 슈팅

 

서버 상태 (Tanstack Query), 지역 상태(useState), 전역 상태(Zustand) 상태 관리는 중요합니다

설계를 잘 하지 않으면 서버 상태와 클라이언트 상태를 혼합해서 사용하다 동기화 이슈가 일어나기도 합니다

 

 1) 게스트 장바구니 (로컬 상태) + 상품 정보 (서버 상태)

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useGuestCartStore = create(
  persist(
    (set, get) => ({
      guestCartItems: [], // 로컬스토리지에 저장됨

      addToGuestCart: (productId, quantity) => {}, // 생략
    {
      name: 'guest-cart', // 로컬스토리지 key 이름
      getStorage: () => localStorage, // 기본값이 localStorage라 생략 가능
    }
  )
);

export default useGuestCartStore;

 

 

로컬스토리지에 담은 장바구니 data는 금방 상하기(stale) 때문에 제품 id로 서버 상태를 재조회 합니다.

const GuestCartSummary = () => {
  const { guestCartItems } = useGuestCartStore();
  
  // 장바구니에 있는 productId들만 추출
  const cartProductIds = guestCartItems.map(item => item.productId);
  
  // 해당 상품들만 서버에서 가져오기
  const { data: cartProducts } = useQuery({
    queryKey: ['products', 'byIds', cartProductIds],
    queryFn: () => fetchProductsByIds(cartProductIds),
    enabled: cartProductIds.length > 0,
  });
  
  const totalPrice = cartProducts.reduce((sum, item) => sum + item.totalPrice, 0);
  
  return (
    <div>
      {cartProducts.map(item => (
        <div key={item.productId}>
          {item.product?.name} - {item.quantity}개 - {item.totalPrice}원
        </div>
      ))}
      <div>총액: {totalPrice}원</div>
    </div>
  );
};

 

 

처음엔 로컬스토리지에 상품 데이터를 그대로 넣어주었는데 데이터는 금방 상하기 때문에.. 상품 재고가 적용이 안되는 이슈가 있었습니다

 

개인적으로 TanStack Query는 프론트엔드 개발에서 선택이 아닌 필수가 되었다고 생각합니다. 처음에는 단순히 API 호출에 장점이 있다고 생각했는데, 프론트엔드의 서버 상태 관리 패러다임을 바꿔준다고 생각합니다.

 

내용에 오류가 있거나 TanStack Query 관련한 좋은 트러블슈팅이 있었다면 댓글로 공유해주세요!

읽어주셔서 감사합니다✨

2년전쯤 three.js 존재를 알게되었습니다. 3d 라이브러리에 흥미는 있었지만 러닝커브가 높다는 이야기가 많아 외면하고 있었습니다. 근데 2년이 지난 지금도 막연하게 어렵겠거니 피하고 있는 나 자신을 발견..

공식문서에서 예제 몇개 돌려보니 새로운 장난감을 발견한것 처럼 재밌더군요, 그런 의미에서 오늘부터 며칠간 three.js를 파헤쳐보려고 합니다:)

 

three.js 기본개념 훑어보기

react three fiber는 three.js를 react 에서 쉽게 사용하게 해주는 라이브러리입니다. 기본적으로 three.js를 사용하려면 장면, 카메라, 렌더러, 객체, 조명 등 알아야하는 기본 개념들이 많습니다. 러닝커브가 높을거 같아 react three fiber 먼저 기웃거렸는데 공식문서에서 three.js 모르면 먼저 갔다오라고 하네요 좋은 튜토리얼도 소개시켜주길래 three.js 부터 훑어보겠습니다.

 

https://discoverthreejs.com/book/introduction/

 

| Discover three.js

 

discoverthreejs.com

 

튜토리얼을 사용하는 방법, three.js를 위해 필요한 내용, 관련 커뮤니티, github 등등의 긴 서론을 지나 목차 1.2부터 코드가 나옵니다. 스택블리츠처럼 코드를 작성하면 바로 렌더링해줘서 튜토리얼하기 좋지만 자동완성이 안되는 점은 아쉽네요

 

(*대부분의 코드부터 이미지의 출처는 위 튜토리얼 내용입니다)

3D 앱을 구성하기 위해서는 아래 구성 요소가 필요하다고 합니다. 

 

핵심 요소는 장면, 카메라, 렌더러입니다. 여기에 메시와 같은 가시적인 객체를 등장시키고 우리가 결과를 볼수 있게 HTML의 canvas 요소에 그려주게됩니다.

 

scene, camera, renderer

위에 코드에서 사용한 scene, camera, renderer 에 대해 좀더 알아보겠습니다.

scene(장면) 은 3차원입니다. 선형대수학에서 배우던 (x, y, z) 좌표계와 동일합니다. 

새 객체가 장면에 추가되면 원점(0,0,0) 에 배치되고, 객체가 이동할 때마다 이 좌표계 내에서 이동하게됩니다.

 

import { Scene } from 'three';
const scene = new Scene();

 

camera(이중 PerspectiveCamera) 우리 눈이 보는것과 유사하게 동작합니다. 브라우저에 3d scene(장면)을 만들어 카메라를 통해 본다고 생각해도 좋을것 같네요.

import { PerspectiveCamera } from 'three';

const fov = 35; // 시야각 (카메라의 시야가 얼마나 넓은지를 각도로 나타낸 값)
const aspect = container.clientWidth / container.clientHeight; // 종횡비 (장면의 너비와 높이 비율)
const near = 0.1; // the near clipping plane // 평면 근처 (이볻바 카메라에 가까운 것은 안보임)
const far = 100; // the far clipping plane // 원거리 (이보다 카메라에서 먼 것은 안보임)

const camera = new PerspectiveCamera(fov, aspect, near, far);

 

renderer는 camera(눈)을 통해 본 scene(장면)을 빠른 속도로 그려줍니다.

하지만 장면, 카메라, 렌더러는 three.js 의 뼈대이지만 어느 것도 실제로 우리에게 보여지지 않습니다.

import { WebGLRenderer } from 'three';
const renderer = new WebGLRenderer();

 

첫 번째 보이는 객체: 메시

메시는 3d 컴퓨터 그래픽에서 가장 일반적으로 사용되는 보이는 객체입니다.

import { Mesh } from 'three';
const mesh = new Mesh(geometry, material);

 

지오메트리와 머티리얼이라는 2개의 매개변수를 받아 생성합니다.

지오메트리는 메시의 모양(상자, 자동차, 고양이 )을 정의하고 머티리얼은 메시 표면의 모양(질감, 색갈 등)을 정의합니다.

import { BoxBufferGeometry } from 'three';

const length = 2;
const width = 2;
const depth = 2;

const geometry = new BoxBufferGeometry(length, width, depth);
import { MeshBasicMaterial } from 'three';
const material = new MeshBasicMaterial();

 

이제 위에 내용을 합쳐 브라우저에 렌더링 해보겠습니다.

import {
 BoxBufferGeometry,
 Color,
 Mesh,
 MeshBasicMaterial,
 PerspectiveCamera,
 Scene,
 WebGLRenderer,
} from 'three';

const container = document.querySelector('#scene-container');
const scene = new Scene();
scene.background = new Color('skyblue');

const fov = 35;
const aspect = container.clientWidth / container.clientHeight;
const near = 0.1;
const far = 100;
const camera = new PerspectiveCamera(fov, aspect, near, far);

camera.position.set(0, 0, 10);

const geometry = new BoxBufferGeometry(2, 2, 2);
const material = new MeshBasicMaterial();
const cube = new Mesh(geometry, material);
scene.add(cube);

const renderer = new WebGLRenderer();
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio); // * 렌더러 기기 화면에 픽셀 비율을 알려줘야합니다

container.append(renderer.domElement);
renderer.render(scene, camera);

 

위 과정을 거쳐 큐브가 렌더링 되었습니다🎉 (아직 화려하진 않지만 개념을 배우기 좋은 튜토리얼 이었네요:) )

 

+ Recent posts