<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>수달 코딩 공장</title>
    <link>https://jskim6335.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 18 Jun 2026 10:42:26 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>수달군</managingEditor>
    <image>
      <title>수달 코딩 공장</title>
      <url>https://tistory1.daumcdn.net/tistory/6844848/attach/712c111ec0124d7291edf4f44baf9a5b</url>
      <link>https://jskim6335.tistory.com</link>
    </image>
    <item>
      <title>10주간의 React 스터디를 마무리하며...!</title>
      <link>https://jskim6335.tistory.com/23</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;10주간의 리액트 중급 스터디를 마무리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트 중급 스터디가 어느덧 마지막 주차에 도달했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 스터디를 시작했을 때는 단순히 React와 Next.js를 조금 더 깊게 공부하고, 개인 프로젝트에 적용할 수 있는 실전적인 기술들을 익히는 것이 목표였다. 하지만 10주간의 과정을 지나오면서 느낀 것은, 프론트엔드 개발에서 중요한 것은 단순히 새로운 기술을 많이 아는 것이 아니라는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오히려 더 중요한 것은 &lt;b&gt;어떤 기준으로 코드를 작성할 것인지&lt;/b&gt;, &lt;b&gt;어떤 구조가 유지보수에 유리한지&lt;/b&gt;, 그리고 &lt;b&gt;사용자 경험과 개발자 경험을 어떻게 함께 고려할 것인지&lt;/b&gt;에 대한 고민이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 10주간의 스터디를 돌아보며 학습한 내용, 나의 설계 철학, 포트폴리오에 반영할 기술 블로그 방향, 그리고 스터디를 통해 얻은 점과 아쉬운 점을 정리해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10주 동안 무엇을 배웠나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디에서는 React와 Next.js를 중심으로 프론트엔드 개발에 필요한 여러 주제를 다루었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 프로젝트를 시작하기 위한 기본 구조를 잡는 것부터 시작했다. 단순히 페이지를 만들고 컴포넌트를 배치하는 것이 아니라, 프로젝트가 커졌을 때도 관리하기 쉬운 폴더 구조를 어떻게 가져갈지 고민했다. 이 과정에서 Feature-Sliced Design, Layered Architecture와 같은 아키텍처 개념을 학습했고, 기능 단위로 코드를 나누는 방식과 계층별 책임을 분리하는 방식에 대해 생각해볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 폴더 구조를 단순히 components, pages, hooks, utils처럼 나누는 정도로만 생각했다. 하지만 프로젝트 규모가 커질수록 이런 단순한 분류만으로는 코드의 책임을 명확하게 나누기 어렵다는 것을 느꼈다. 어떤 컴포넌트가 공통 컴포넌트인지, 어떤 컴포넌트가 특정 기능에 종속된 컴포넌트인지, API 요청 로직은 어디에 두어야 하는지 등을 기준 없이 작성하면 결국 유지보수가 어려운 구조가 될 수밖에 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중반에는 상태 관리, API 연동, 에러 핸들링, 스타일링 전략과 같은 실전적인 주제들을 다루었다. 특히 에러 핸들링을 학습하면서 프론트엔드에서 에러를 처리하는 방식이 생각보다 다양하다는 것을 알게 되었다. 단순히 try-catch로 에러를 잡는 것뿐만 아니라, 렌더링 영역에서는 Error Boundary를 사용하고, 서버 상태에서는 React Query의 isError, error 상태를 활용하며, 사용자 액션에서는 토스트 메시지나 모달을 통해 즉각적인 피드백을 제공할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후반에는 테스트 전략과 코드리뷰, 리팩토링에 대해 다루었다. Vitest와 React Testing Library를 활용해 컴포넌트 테스트와 통합 테스트의 차이를 정리했고, MSW를 활용해 API 요청을 Mocking하는 방법도 학습했다. 테스트는 아직 익숙하지 않은 영역이지만, 단순히 &amp;ldquo;잘 작동하는지 확인하는 코드&amp;rdquo;가 아니라 기능의 안정성을 보장하고 리팩토링에 대한 부담을 줄여주는 장치라는 점을 이해할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드리뷰와 리팩토링 실습도 의미 있었다. 같은 기능을 구현하더라도 컴포넌트 책임을 어떻게 나누는지, 중복 코드를 어떻게 줄이는지, CSS 구조를 어떻게 정리하는지에 따라 코드의 가독성과 유지보수성이 크게 달라진다는 것을 느꼈다. 결국 좋은 코드는 처음부터 완성되는 것이 아니라, 계속해서 리뷰하고 개선하는 과정 속에서 만들어진다는 생각이 들었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기능 구현에서 구조 설계로 관점이 바뀌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디를 하면서 가장 크게 달라진 점은 개발을 바라보는 관점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 기능이 정상적으로 동작하면 어느 정도 만족했다. 버튼을 누르면 모달이 열리고, API 요청을 보내면 데이터가 화면에 출력되고, 스타일이 디자인과 비슷하게 맞으면 구현이 끝났다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 스터디를 진행하면서 단순히 &amp;ldquo;동작하는 코드&amp;rdquo;와 &amp;ldquo;유지보수 가능한 코드&amp;rdquo;는 다르다는 것을 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작하는 코드는 지금 당장의 요구사항을 해결할 수 있다. 하지만 기능이 추가되고, 화면이 복잡해지고, API 명세가 바뀌는 순간 코드의 구조가 중요해진다. 컴포넌트가 너무 많은 책임을 가지고 있거나, 상태가 여러 곳에 흩어져 있거나, API 요청 로직과 UI 로직이 뒤섞여 있다면 작은 변경도 어렵게 느껴진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 스터디를 통해 &amp;ldquo;어떻게 구현할까?&amp;rdquo;보다 먼저 &amp;ldquo;어떤 구조로 나누어야 할까?&amp;rdquo;를 고민하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 하나의 페이지를 만들 때도 단순히 JSX를 길게 작성하는 것이 아니라, 페이지 컴포넌트, 섹션 컴포넌트, 카드 컴포넌트, 공통 UI 컴포넌트처럼 역할을 나눌 수 있다. API 요청도 컴포넌트 내부에서 직접 처리하기보다 커스텀 훅으로 분리하면 UI와 데이터 로직의 책임을 명확히 나눌 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조화는 당장은 시간이 더 걸릴 수 있다. 하지만 프로젝트가 커질수록 코드를 이해하고 수정하는 비용을 줄여준다. 결국 좋은 설계는 개발 속도를 늦추는 것이 아니라, 장기적으로 더 안정적인 개발을 가능하게 만드는 기반이라고 생각하게 되었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나의 설계 철학 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디를 통해 나만의 설계 철학도 조금씩 정리할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;사용자 흐름을 기준으로 기능을 설계하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발은 결국 사용자가 직접 마주하는 영역이다. 따라서 개발자의 구현 편의성보다 사용자가 어떤 흐름으로 서비스를 이용하는지가 중요하다. 사용자가 어떤 정보를 먼저 보고, 어떤 행동을 하며, 어떤 피드백을 받아야 하는지를 기준으로 화면과 기능을 설계해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 기능을 만든다면 단순히 검색창과 결과 목록을 만드는 것으로 끝나지 않는다. 사용자가 검색어를 입력하기 전에는 어떤 안내를 보여줄지, 검색 중에는 어떤 로딩 상태를 보여줄지, 결과가 없을 때는 어떤 메시지를 제공할지, 에러가 발생했을 때는 어떻게 다시 시도할 수 있게 할지까지 함께 고려해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;변경에 강한 구조를 만드는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 처음 설계한 그대로 유지되지 않는다. 기능은 계속 추가되고, 요구사항은 바뀌며, 디자인도 수정된다. 따라서 코드는 변경을 전제로 작성되어야 한다고 생각한다. 이를 위해 컴포넌트의 책임을 명확히 나누고, 반복되는 로직은 커스텀 훅이나 유틸 함수로 분리하며, 특정 페이지에만 필요한 코드와 여러 곳에서 재사용될 수 있는 코드를 구분하는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 &lt;b&gt;완벽한 코드보다 개선 가능한 코드를 지향하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 완벽한 구조를 만드는 것은 어렵다. 오히려 처음부터 너무 과하게 추상화하려고 하면 코드가 더 복잡해질 수도 있다. 그래서 현재 상황에서 가장 적절한 구조를 선택하되, 이후 문제가 발견되면 리팩토링을 통해 점진적으로 개선하는 태도가 중요하다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디에서 코드리뷰와 리팩토링을 경험하며, 좋은 코드는 한 번에 만들어지는 것이 아니라는 점을 다시 느꼈다. 코드를 작성한 뒤 다시 읽어보고, 다른 사람의 피드백을 받고, 더 나은 방식으로 고쳐보는 과정 자체가 개발 실력을 높이는 데 큰 도움이 되었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;앞으로 포트폴리오는 어떻게 채워나갈 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디에서 정리한 내용들은 단순한 학습 기록으로 끝내기보다 포트폴리오에 적극적으로 반영할 수 있다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트폴리오를 만들 때 흔히 프로젝트 결과물, 사용 기술, 주요 기능을 중심으로 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이것도 중요하지만 프론트엔드 개발자로서 더 잘 보여주어야 하는 것은 &amp;ldquo;왜 그렇게 만들었는가&amp;rdquo;라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 단순히 &amp;ldquo;React Query를 사용했다&amp;rdquo;고 적는 것보다, 서버 상태와 클라이언트 상태를 분리하기 위해 React Query를 사용했고, 캐싱과 로딩 상태, 에러 상태를 선언적으로 관리하기 위해 적용했다고 설명하는 것이 더 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &amp;ldquo;컴포넌트를 분리했다&amp;rdquo;고 적는 것보다, 페이지 컴포넌트가 너무 많은 책임을 가지지 않도록 섹션 단위와 공통 UI 단위로 컴포넌트를 나누었고, 이를 통해 재사용성과 가독성을 높였다고 정리하는 것이 더 설득력 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술 블로그도 마찬가지다. 단순히 공부한 개념을 정리하는 글도 의미 있지만, 실제 프로젝트에 적용하면서 어떤 문제를 만났고 어떻게 해결했는지를 함께 정리하면 더 좋은 포트폴리오 자료가 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디에서 작성하거나 정리할 수 있는 기술 블로그 주제는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Feature-Sliced Design과 Layered Architecture를 프로젝트에 적용하는 방법&lt;/li&gt;
&lt;li&gt;React Query를 활용한 서버 상태 관리 전략&lt;/li&gt;
&lt;li&gt;선언적 에러 처리와 명령적 에러 처리의 차이&lt;/li&gt;
&lt;li&gt;Error Boundary를 활용한 렌더링 에러 대응&lt;/li&gt;
&lt;li&gt;Vitest와 React Testing Library를 활용한 프론트엔드 테스트 전략&lt;/li&gt;
&lt;li&gt;MSW를 활용한 API Mocking&lt;/li&gt;
&lt;li&gt;실제 프로젝트 코드리뷰와 리팩토링 과정&lt;/li&gt;
&lt;li&gt;CSS 구조 개선과 공통 스타일 관리 방식&lt;/li&gt;
&lt;li&gt;포트폴리오 프로젝트에서 기술 선택 이유를 정리하는 방법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 글들은 단순히 &amp;ldquo;공부했다&amp;rdquo;는 기록이 아니라, 내가 어떤 기준으로 코드를 바라보고 개선하려고 했는지를 보여주는 자료가 될 수 있다. 앞으로 포트폴리오를 정리할 때도 프로젝트 결과 화면만 보여주는 것이 아니라, 문제 상황, 해결 과정, 개선 결과를 함께 담아내고 싶다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스터디를 통해 얻은 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스터디의 가장 큰 장점은 혼자 공부할 때보다 더 많은 관점을 얻을 수 있었다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자 공부하면 내가 이해한 방식이 맞는지 확인하기 어렵고, 특정 문제를 한 가지 방식으로만 바라보게 되는 경우가 많다. 하지만 스터디에서는 같은 주제에 대해서도 서로 다른 의견을 들을 수 있었고, 다른 사람이 작성한 코드나 정리한 내용을 보며 새로운 관점을 얻을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 코드리뷰 과정에서 많은 것을 느꼈다. 내가 작성한 코드는 익숙하기 때문에 문제점이 잘 보이지 않을 때가 있다. 하지만 다른 사람이 보면 컴포넌트 이름이 애매하거나, 로직이 너무 길거나, 스타일 코드가 반복되는 부분을 더 쉽게 발견할 수 있다. 반대로 다른 사람의 코드를 리뷰하면서 나 역시 좋은 코드와 아쉬운 코드의 차이를 더 구체적으로 생각해볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 스터디를 통해 꾸준히 글로 정리하는 습관을 만들 수 있었다. 개발을 하면서 배운 내용을 글로 정리하면 단순히 알고 있다고 생각했던 개념도 더 명확하게 이해할 수 있다. 설명하기 위해서는 개념을 구조화해야 하고, 예시를 들어야 하며, 내가 정확히 모르는 부분도 드러나기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아쉬웠던 점과 앞으로의 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 아쉬운 점도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10주 동안 다양한 주제를 다루다 보니, 일부 내용은 깊게 실습하기보다 개념을 이해하는 수준에서 넘어간 부분도 있었다. 특히 테스트 코드 작성, 성능 최적화, 접근성 개선과 같은 주제는 실제 프로젝트에 더 많이 적용해보면서 경험을 쌓아야겠다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트의 경우 개념은 이해했지만, 아직 프로젝트 전반에 자연스럽게 테스트를 작성하는 습관은 부족하다. 앞으로는 새로운 기능을 만들 때 핵심 로직이나 사용자 플로우를 기준으로 테스트를 함께 작성해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 최적화 역시 단순히 useMemo, useCallback을 사용하는 수준이 아니라, 실제 렌더링 병목을 분석하고 필요한 지점에 적절히 적용하는 경험이 필요하다고 생각한다. 접근성도 마찬가지로, 버튼이나 폼을 만들 때 키보드 접근성, 명확한 라벨, 스크린 리더 대응 등을 더 의식하면서 개발할 필요가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 이번 스터디에서 배운 내용을 개인 프로젝트와 포트폴리오 프로젝트에 직접 반영하려고 한다. 단순히 공부한 내용을 정리하는 것에서 끝나는 것이 아니라, 실제 코드에 적용하고 그 과정을 다시 기술 블로그로 기록하는 방식으로 이어가고 싶다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 10주간의 리액트 중급 스터디는 단순히 React 관련 기술을 학습한 시간이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 구현만을 목표로 하는 것이 아니라, 유지보수 가능한 구조를 고민하고, 사용자 흐름을 기준으로 화면을 설계하며, 에러 처리와 테스트, 리팩토링까지 함께 고려해야 한다는 것을 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 프론트엔드 개발자는 단순히 화면을 예쁘게 만드는 사람이 아니라, 사용자가 안정적으로 서비스를 이용할 수 있도록 구조를 설계하고 문제를 개선해나가는 사람이라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 이번 스터디에서 배운 내용을 바탕으로 더 나은 프로젝트를 만들고, 그 과정에서 생긴 고민과 해결 방법을 기술 블로그와 포트폴리오에 꾸준히 정리해나가고자 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 부족한 부분은 많지만, 적어도 이번 스터디를 통해 내가 어떤 방향으로 성장해야 하는지는 조금 더 분명해졌다. 이제 중요한 것은 배운 내용을 실제 프로젝트에 적용하고, 계속해서 개선해나가는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/23</guid>
      <comments>https://jskim6335.tistory.com/23#entry23comment</comments>
      <pubDate>Tue, 2 Jun 2026 21:46:05 +0900</pubDate>
    </item>
    <item>
      <title>개인 포트폴리오 페이지를 구현해보자!</title>
      <link>https://jskim6335.tistory.com/22</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js로 기술 블로그 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느덧 Next.js를 공부한지도 7주차가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 React에서 한 단계 더 나아가기 위해 Next.js를 공부하기 시작했지만, App Router, Server Component, 파일 기반 라우팅, SEO, 정적 생성 같은 기능들을 하나씩 배우다 보니 이 기술을 활용해서 직접 하나의 프로젝트를 만들어보고 싶다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러던 중 단순히 기능을 구현하는 프로젝트가 아니라, 나 자신을 보여줄 수 있는 사이트를 만들어보면 어떨까 하는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 공부한 내용, 진행했던 프로젝트, 문제를 해결했던 과정, 프론트엔드 개발자로 성장해가는 기록들을 한곳에 모아두면 좋겠다고 생각했다. 기존에는 Tistory에 내용을 정리했지만, Tistory에서는 디자인 수정이 쉽지 않기도 하고, 원하는 글 형식을 제공하지 않아서 아쉽다는 생각이 많이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번에는 직접 &lt;b&gt;블로그와 포트폴리오를 결합한 기술 블로그 사이트&lt;/b&gt;를 만들어보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 사이트는 단순히 글을 올리는 공간이 아니라, 내가 어떤 기술을 공부하고 있는지, 어떤 프로젝트를 만들어왔는지, 그리고 어떤 문제를 어떻게 해결했는지를 기록하는 공간이 될 예정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Next.js를 공부하면서 배운 개념들을 실제 프로젝트에 적용해보는 실습 공간이기도 하다. App Router 기반의 페이지 구조를 설계하고, Markdown 또는 MDX로 글을 관리하며, 프로젝트 소개 페이지와 기술 글 목록 페이지를 직접 구현해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트를 통해 단순히 &amp;ldquo;Next.js를 공부했다&amp;rdquo;에서 끝나는 것이 아니라, Next.js를 활용해 실제로 하나의 서비스를 기획하고 구현하는 경험을 쌓아보고자 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Next.js 기반의 기술 블로그 겸 포트폴리오 사이트&lt;/b&gt;를 제작하는 개인 프로젝트이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그에는 프론트엔드 개발을 공부하면서 정리한 기술 글을 작성하고,&lt;br /&gt;포트폴리오 영역에는 지금까지 진행했던 프로젝트들을 정리할 예정이다.&lt;br /&gt;또한 나에 대한 소개를 랜딩페이지에 소개하여 향후 다른 이들에게 개발자인 &quot;나&quot;를 소개할때 사용할 수 있도록 할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술 블로그에서는 다음과 같은 내용을 다룰 계획이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React와 TypeScript, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Next.js&lt;span&gt; 등의 학습 기록&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;프로젝트 개발 과정&lt;/li&gt;
&lt;li&gt;에러 해결 기록&lt;/li&gt;
&lt;li&gt;프론트엔드 테스트와 최적화 경험&lt;/li&gt;
&lt;li&gt;개인 프로젝트 회고&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트폴리오 영역에서는 단순히 프로젝트 결과물만 보여주는 것이 아니라, 프로젝트를 만들게 된 이유, 맡은 역할, 사용한 기술, 구현 과정, 문제 해결 경험, 아쉬운 점 등을 함께 정리하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이 사이트는 나의 학습 기록이자, 프로젝트 아카이브이며, 프론트엔드 개발자로서의 성장 과정을 보여주는 공간이 될 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 블로그와 포트폴리오를 함께 만들었는가&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발을 공부하다 보면 많은 기술을 접하게 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React, Next.js, TypeScript, Tailwind CSS, 상태 관리, API 연동, 성능 최적화, 테스트 코드 등 배워야 할 내용은 계속 늘어난다. 하지만 공부한 내용을 제대로 정리하지 않으면 시간이 지나면서 잊어버리기 쉽다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;특히 프로젝트를 진행하면서 겪었던 문제 해결 과정은 그 당시에는 치열하게 고민하지만, 기록하지 않으면 나중에 다시 떠올리기 어렵다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 내가 공부한 내용과 프로젝트 경험을 꾸준히 기록할 수 있는 공간이 필요하다고 느꼈다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;물론 Velog나 Notion 같은 좋은 플랫폼도 있지만, 직접 기술 블로그를 만들면 단순히 글을 쓰는 것 이상의 경험을 얻을 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;블로그 자체를 하나의 프론트엔드 프로젝트로 바라보고, 페이지 구조, 컴포넌트 설계, 콘텐츠 관리, SEO, 반응형 UI, 배포까지 직접 경험할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 기술 블로그만으로는 내가 어떤 프로젝트를 해왔고, 실제로 어떤 문제를 해결했는지 보여주기에는 조금 부족하다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 포트폴리오 사이트만 만들면 프로젝트 결과물은 보여줄 수 있지만, 그 과정에서 어떤 고민을 했는지, 어떤 기술을 학습했는지, 어떤 시행착오를 겪었는지는 충분히 담기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 블로그와 포트폴리오를 하나의 사이트 안에 함께 구성하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그에서는 학습한 기술과 문제 해결 과정을 기록하고, 포트폴리오에서는 실제 프로젝트 경험을 정리한다. 두 영역이 연결되면 단순한 자기소개 사이트보다 더 입체적으로 나를 보여줄 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 어떤 프로젝트에서 TanStack Query를 사용했다면, 포트폴리오 페이지에서는 해당 프로젝트에서 어떤 기능에 사용했는지를 설명하고, 블로그 글에서는 TanStack Query를 학습하며 정리한 개념을 더 자세히 다룰 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 프로젝트 경험과 기술 학습 기록이 서로 연결되는 구조를 만들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트를 통해 얻고 싶은 것은 단순히 예쁜 블로그 사이트 하나가 아니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;내가 배운 기술을 실제로 적용해보고, 그 과정을 다시 글로 정리하면서 기술을 더 깊게 이해하는 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;만들고자 하는 사이트의 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기술 블로그는 단순히 글을 나열하는 사이트가 아니라, 방문자가 나의 학습 흐름과 프로젝트 경험을 자연스럽게 볼 수 있는 구조로 만들고자 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 페이지에서는 내가 어떤 개발자인지 간단히 소개하고, 최근 작성한 글과 대표 프로젝트를 보여줄 예정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술 글 목록 페이지에서는 카테고리와 태그를 활용해 글을 쉽게 탐색할 수 있도록 구성하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 페이지에서는 지금까지 진행한 프로젝트들을 카드 형태로 보여주고, 상세 페이지에서는 프로젝트의 배경, 구현 과정, 문제 해결 경험을 정리할 계획이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적인 방향은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;학습 기록을 체계적으로 정리할 수 있는 블로그&lt;/li&gt;
&lt;li&gt;프로젝트 경험을 보여줄 수 있는 포트폴리오&lt;/li&gt;
&lt;li&gt;Next.js 학습 내용을 실제로 적용하는 실습 프로젝트&lt;/li&gt;
&lt;li&gt;꾸준히 확장 가능한 개인 웹사이트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;144&quot; data-start=&quot;130&quot; data-section-id=&quot;1xpay6l&quot; data-ke-size=&quot;size26&quot;&gt;현재 개발 중인 과정&lt;/h2&gt;
&lt;p data-end=&quot;207&quot; data-start=&quot;146&quot; data-ke-size=&quot;size16&quot;&gt;현재는 기술 블로그의 전체 방향성을 정하고, 실제 구현에 들어가기 전 필요한 구조를 하나씩 잡아가는 단계이다.&lt;/p&gt;
&lt;p data-end=&quot;367&quot; data-start=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;처음부터 완성된 블로그를 만들기보다는, 블로그를 구성하는 핵심 기능을 단계별로 나누어 구현하려고 한다.&lt;/p&gt;
&lt;p data-end=&quot;367&quot; data-start=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;367&quot; data-start=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;기술 블로그는 단순히 화면을 예쁘게 만드는 것보다, 글을 어떻게 관리할지, 사용자가 어떻게 글을 탐색할지, 프로젝트 경험을 어떻게 보여줄지에 대한 구조 설계가 중요하다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;401&quot; data-start=&quot;369&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;401&quot; data-start=&quot;369&quot; data-ke-size=&quot;size16&quot;&gt;그래서 현재는 다음과 같은 흐름으로 개발을 진행하고 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;421&quot; data-start=&quot;408&quot; data-section-id=&quot;1hweiv1&quot; data-ke-size=&quot;size26&quot;&gt;프로젝트 초기 세팅&lt;/h2&gt;
&lt;p data-end=&quot;466&quot; data-start=&quot;423&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 Next.js 프로젝트를 생성하고 기본 개발 환경을 구성하고 있다.&lt;/p&gt;
&lt;p data-end=&quot;591&quot; data-start=&quot;468&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 Next.js App Router 기반으로 진행할 예정이다. App Router는 폴더 구조를 기준으로 페이지를 구성할 수 있기 때문에, 블로그처럼 페이지 구조가 명확한 프로젝트에 잘 어울린다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;623&quot; data-start=&quot;593&quot; data-ke-size=&quot;size16&quot;&gt;기본적으로 다음과 같은 페이지 구조를 먼저 잡고 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;sas&quot;&gt;&lt;code&gt;src/
  app/
    page.tsx
    posts/
      page.tsx
      [slug]/
        page.tsx
    projects/
      page.tsx
      [slug]/
        page.tsx
    about/
      page.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;904&quot; data-start=&quot;805&quot; data-ke-size=&quot;size16&quot;&gt;이 구조에서 page.tsx는 메인 페이지, posts/page.tsx는 게시글 목록 페이지, posts/[slug]/page.tsx는 게시글 상세 페이지를 담당한다.&lt;/p&gt;
&lt;p data-end=&quot;1007&quot; data-start=&quot;906&quot; data-ke-size=&quot;size16&quot;&gt;projects 경로는 포트폴리오 역할을 하는 프로젝트 목록과 상세 페이지를 담당하고, about 페이지에서는 나에 대한 소개와 기술 스택, 활동 경험 등을 정리할 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;1102&quot; data-start=&quot;1009&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1102&quot; data-start=&quot;1009&quot; data-ke-size=&quot;size16&quot;&gt;처음부터 많은 페이지를 한 번에 구현하기보다는, 메인 페이지와 게시글 목록 페이지를 먼저 만들고 이후 프로젝트 페이지와 소개 페이지를 확장하는 방식으로 진행하고 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1120&quot; data-start=&quot;1109&quot; data-section-id=&quot;5z0q79&quot; data-ke-size=&quot;size26&quot;&gt;UI 구조 설계&lt;/h2&gt;
&lt;p data-end=&quot;1156&quot; data-start=&quot;1122&quot; data-ke-size=&quot;size16&quot;&gt;초기 세팅 이후에는 블로그의 전체 UI 구조를 설계하고 있다.&lt;/p&gt;
&lt;p data-end=&quot;1297&quot; data-start=&quot;1158&quot; data-ke-size=&quot;size16&quot;&gt;기술 블로그는 글을 읽는 경험이 중요하기 때문에 화려한 인터랙션보다는 가독성과 탐색 편의성을 우선으로 두고 있다. 방문자가 블로그에 들어왔을 때 내가 어떤 기술을 공부하고 있는지, 어떤 프로젝트를 진행했는지 빠르게 파악할 수 있어야 한다고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;1326&quot; data-start=&quot;1299&quot; data-ke-size=&quot;size16&quot;&gt;현재 생각하고 있는 기본 레이아웃은 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;공통 레이아웃
- Header
- Main Content
- Footer

메인 페이지
- Hero Section
- 최근 게시글
- 대표 프로젝트
- 관심 기술 스택

게시글 목록 페이지
- 게시글 카드 목록
- 카테고리 필터
- 태그 필터
- 검색 영역

게시글 상세 페이지
- 제목
- 작성일
- 태그
- 본문
- 코드 블록
- 목차&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1609&quot; data-start=&quot;1530&quot; data-ke-size=&quot;size16&quot;&gt;특히 게시글 상세 페이지는 기술 블로그의 핵심이기 때문에 본문 폭, 줄 간격, 제목 크기, 코드 블록 스타일 등을 신경 써서 구성하려고 한다.&lt;/p&gt;
&lt;p data-end=&quot;1676&quot; data-start=&quot;1611&quot; data-ke-size=&quot;size16&quot;&gt;기술 글은 일반 글보다 코드 예제와 설명이 많기 때문에, 작은 스타일 차이도 읽는 경험에 큰 영향을 준다고 생각했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1696&quot; data-start=&quot;1683&quot; data-section-id=&quot;tj8zjd&quot; data-ke-size=&quot;size26&quot;&gt;공통 컴포넌트 개발&lt;/h2&gt;
&lt;p data-end=&quot;1742&quot; data-start=&quot;1698&quot; data-ke-size=&quot;size16&quot;&gt;블로그에서 반복적으로 사용되는 UI는 공통 컴포넌트로 분리해서 관리할 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;1814&quot; data-start=&quot;1744&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 게시글 카드, 태그, 카테고리 배지, 버튼, 섹션 제목, 레이아웃 컴포넌트 등은 여러 페이지에서 반복해서 사용된다.&lt;/p&gt;
&lt;p data-end=&quot;1847&quot; data-start=&quot;1816&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1847&quot; data-start=&quot;1816&quot; data-ke-size=&quot;size16&quot;&gt;현재는 다음과 같은 컴포넌트를 우선적으로 만들려고 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;components/
  common/
    Button.tsx
    Badge.tsx
    SectionTitle.tsx

  layout/
    Header.tsx
    Footer.tsx
    Container.tsx

  post/
    PostCard.tsx
    PostList.tsx
    PostTag.tsx
    PostMeta.tsx

  project/
    ProjectCard.tsx
    ProjectList.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2191&quot; data-start=&quot;2120&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트를 나누는 기준은 단순히 파일을 많이 만드는 것이 아니라, 각 컴포넌트가 어떤 책임을 가지는지 명확하게 구분하는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;2290&quot; data-start=&quot;2193&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 PostCard는 게시글 하나를 카드 형태로 보여주는 역할만 담당하고, 게시글 목록을 순회하며 렌더링하는 책임은 PostList가 담당하도록 나눌 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2356&quot; data-start=&quot;2292&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 나중에 게시글 카드 디자인을 바꾸거나 목록 정렬 방식을 수정할 때도 영향을 받는 범위를 줄일 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2375&quot; data-start=&quot;2363&quot; data-section-id=&quot;6jbjgx&quot; data-ke-size=&quot;size26&quot;&gt;콘텐츠 구조 설계&lt;/h2&gt;
&lt;p data-end=&quot;2411&quot; data-start=&quot;2377&quot; data-ke-size=&quot;size16&quot;&gt;기술 블로그에서 가장 중요한 부분은 글을 어떻게 관리할지이다.&lt;/p&gt;
&lt;p data-end=&quot;2484&quot; data-start=&quot;2413&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2484&quot; data-start=&quot;2413&quot; data-ke-size=&quot;size16&quot;&gt;초기에는 Markdown 기반으로 글을 작성하고, 각 글의 상단에 frontmatter를 작성하여 메타데이터를 관리할 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;2517&quot; data-start=&quot;2486&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 게시글은 다음과 같은 구조로 작성할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
title: &quot;Next.js App Router 이해하기&quot;
description: &quot;Next.js App Router의 기본 구조와 동작 방식을 정리합니다.&quot;
category: &quot;Next.js&quot;
tags: [&quot;Next.js&quot;, &quot;App Router&quot;, &quot;React&quot;]
publishedAt: &quot;2026-05-24&quot;
published: true
---

본문 내용...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2844&quot; data-start=&quot;2740&quot; data-ke-size=&quot;size16&quot;&gt;여기서 title은 게시글 제목, description은 글 설명, category는 글의 큰 분류, tags는 세부 키워드, publishedAt은 작성일을 의미한다.&lt;/p&gt;
&lt;p data-end=&quot;2929&quot; data-start=&quot;2846&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 글 데이터를 구조화해두면 게시글 목록 페이지에서 카드 UI를 만들 때도 사용할 수 있고, 상세 페이지의 SEO 메타데이터에도 활용할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;3003&quot; data-start=&quot;2931&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3003&quot; data-start=&quot;2931&quot; data-ke-size=&quot;size16&quot;&gt;또한 태그와 카테고리를 기반으로 필터링 기능을 구현할 수 있기 때문에, 글이 많아져도 사용자가 원하는 내용을 쉽게 찾을 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;3029&quot; data-start=&quot;3010&quot; data-section-id=&quot;2hjvos&quot; data-ke-size=&quot;size26&quot;&gt;게시글 목록 페이지 구현 계획&lt;/h2&gt;
&lt;p data-end=&quot;3061&quot; data-start=&quot;3031&quot; data-ke-size=&quot;size16&quot;&gt;게시글 목록 페이지는 블로그의 중심이 되는 페이지이다.&lt;/p&gt;
&lt;p data-end=&quot;3136&quot; data-start=&quot;3063&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지에서는 작성한 글들을 최신순으로 보여주고, 사용자가 카테고리나 태그를 기준으로 원하는 글을 찾을 수 있도록 만들 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;3163&quot; data-start=&quot;3138&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3163&quot; data-start=&quot;3138&quot; data-ke-size=&quot;size16&quot;&gt;게시글 카드에는 다음 정보를 표시할 계획이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 게시글 제목
- 게시글 설명
- 작성일
- 카테고리
- 태그 목록
- 예상 읽기 시간&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3285&quot; data-start=&quot;3227&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 전체 게시글을 최신순으로 보여주는 기능부터 구현하고, 이후 검색과 필터 기능을 추가할 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;3352&quot; data-start=&quot;3287&quot; data-ke-size=&quot;size16&quot;&gt;검색 기능은 제목과 설명을 기준으로 동작하도록 만들고, 필터 기능은 카테고리와 태그를 기준으로 동작하게 할 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;3469&quot; data-start=&quot;3354&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3469&quot; data-start=&quot;3354&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 Next.js 카테고리를 선택하면 Next.js와 관련된 글만 보여주고, React, 성능 최적화, 테스트 같은 태그를 선택하면 해당 태그가 포함된 글만 보여주는 방식이다.&lt;/p&gt;
&lt;p data-end=&quot;3531&quot; data-start=&quot;3471&quot; data-ke-size=&quot;size16&quot;&gt;이 기능을 통해 단순한 글 목록이 아니라, 사용자가 원하는 글을 쉽게 찾을 수 있는 블로그를 만들고자 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;3557&quot; data-start=&quot;3538&quot; data-section-id=&quot;1k2e8ip&quot; data-ke-size=&quot;size26&quot;&gt;게시글 상세 페이지 구현 계획&lt;/h2&gt;
&lt;p data-end=&quot;3606&quot; data-start=&quot;3559&quot; data-ke-size=&quot;size16&quot;&gt;게시글 상세 페이지에서는 Markdown으로 작성한 본문을 실제 페이지로 렌더링한다.&lt;/p&gt;
&lt;p data-end=&quot;3668&quot; data-start=&quot;3608&quot; data-ke-size=&quot;size16&quot;&gt;상세 페이지는 단순히 글을 보여주는 화면이 아니라, 기술 글을 읽기 좋은 형태로 가공하는 역할을 해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;3697&quot; data-start=&quot;3670&quot; data-ke-size=&quot;size16&quot;&gt;따라서 다음과 같은 요소를 함께 구현할 계획이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 게시글 제목
- 작성일
- 카테고리
- 태그
- 본문 렌더링
- 코드 블록 스타일링
- 목차
- 이전 글 / 다음 글 이동
- 관련 글 추천&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3830&quot; data-start=&quot;3792&quot; data-ke-size=&quot;size16&quot;&gt;특히 코드 블록 스타일링은 기술 블로그에서 중요한 요소라고 생각한다.&lt;/p&gt;
&lt;p data-end=&quot;3948&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3948&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 기술 글에는 코드 예제가 자주 들어가기 때문에, 코드가 읽기 어렵다면 글 전체의 가독성이 떨어질 수 있다. 따라서 코드 하이라이팅을 적용하고, 필요하다면 파일명 표시나 복사 버튼도 추가할 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;4033&quot; data-start=&quot;3950&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4033&quot; data-start=&quot;3950&quot; data-ke-size=&quot;size16&quot;&gt;또한 글이 길어질 경우를 대비해 목차 기능도 고려하고 있다. 제목 구조를 기준으로 목차를 자동 생성하면 사용자가 글의 전체 흐름을 파악하기 쉬워진다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;4057&quot; data-start=&quot;4040&quot; data-section-id=&quot;owe3bp&quot; data-ke-size=&quot;size26&quot;&gt;프로젝트 페이지 구현 계획&lt;/h2&gt;
&lt;p data-end=&quot;4109&quot; data-start=&quot;4059&quot; data-ke-size=&quot;size16&quot;&gt;이 블로그는 단순한 기술 글 저장소가 아니라 포트폴리오 역할도 함께 하도록 만들 계획이다.&lt;/p&gt;
&lt;p data-end=&quot;4159&quot; data-start=&quot;4111&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4159&quot; data-start=&quot;4111&quot; data-ke-size=&quot;size16&quot;&gt;그래서 별도의 프로젝트 페이지를 만들고, 지금까지 진행한 프로젝트들을 정리하려고 한다.&lt;/p&gt;
&lt;p data-end=&quot;4196&quot; data-start=&quot;4161&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 목록 페이지에서는 각 프로젝트를 카드 형태로 보여준다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 프로젝트명
- 한 줄 소개
- 진행 기간
- 맡은 역할
- 사용 기술
- 주요 기능
- GitHub 링크
- 배포 링크&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4363&quot; data-start=&quot;4279&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 상세 페이지에서는 단순히 결과물을 보여주는 것이 아니라, 프로젝트를 만들게 된 이유와 구현 과정, 문제 해결 경험을 중심으로 작성할 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;4403&quot; data-start=&quot;4365&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4403&quot; data-start=&quot;4365&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 프로젝트 상세 글은 다음과 같은 흐름으로 구성할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 프로젝트 소개
- 프로젝트를 시작한 이유
- 맡은 역할
- 사용한 기술
- 주요 기능
- 구현 과정
- 문제 해결 경험
- 아쉬운 점
- 개선 방향&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4595&quot; data-start=&quot;4502&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 작성하면 단순히 &amp;ldquo;이런 프로젝트를 만들었다&amp;rdquo;에서 끝나는 것이 아니라, 프로젝트를 진행하면서 어떤 고민을 했고 어떤 방식으로 문제를 해결했는지를 보여줄 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;4617&quot; data-start=&quot;4602&quot; data-section-id=&quot;1ofp4ag&quot; data-ke-size=&quot;size26&quot;&gt;검색과 필터 기능 계획&lt;/h2&gt;
&lt;p data-end=&quot;4646&quot; data-start=&quot;4619&quot; data-ke-size=&quot;size16&quot;&gt;블로그에 글이 많아질수록 탐색 기능은 중요해진다.&lt;/p&gt;
&lt;p data-end=&quot;4728&quot; data-start=&quot;4648&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 글이 많지 않기 때문에 최신순 목록만으로도 충분할 수 있지만, 장기적으로 기술 글이 쌓이면 원하는 글을 빠르게 찾기 어려워질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;4764&quot; data-start=&quot;4730&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4764&quot; data-start=&quot;4730&quot; data-ke-size=&quot;size16&quot;&gt;그래서 검색과 필터 기능을 초기 설계 단계부터 고려하고 있다.&lt;/p&gt;
&lt;p data-end=&quot;4792&quot; data-start=&quot;4766&quot; data-ke-size=&quot;size16&quot;&gt;검색은 다음 기준으로 동작하도록 만들 계획이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;- 제목 검색
- 설명 검색
- 태그 검색
- 카테고리 검색&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4868&quot; data-start=&quot;4840&quot; data-ke-size=&quot;size16&quot;&gt;필터는 카테고리와 태그를 기준으로 구현할 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;5035&quot; data-start=&quot;4870&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5035&quot; data-start=&quot;4870&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 카테고리는 React, Next.js, TypeScript, CSS, 회고처럼 큰 주제로 나누고, 태그는 App Router, Server Component, Tailwind CSS, TanStack Query처럼 더 세부적인 키워드로 구성할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;5109&quot; data-start=&quot;5037&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5109&quot; data-start=&quot;5037&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 카테고리와 태그를 분리하면 글을 더 체계적으로 관리할 수 있고, 방문자도 관심 있는 주제의 글을 더 쉽게 탐색할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;5135&quot; data-start=&quot;5116&quot; data-section-id=&quot;1wh94pw&quot; data-ke-size=&quot;size26&quot;&gt;SEO와 메타데이터 설정 계획&lt;/h2&gt;
&lt;p data-end=&quot;5185&quot; data-start=&quot;5137&quot; data-ke-size=&quot;size16&quot;&gt;기술 블로그는 검색을 통해 유입되는 경우가 많기 때문에 SEO도 함께 고려할 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;5273&quot; data-start=&quot;5187&quot; data-ke-size=&quot;size16&quot;&gt;Next.js에서는 각 페이지마다 메타데이터를 설정할 수 있고, 게시글 상세 페이지에서는 글의 제목과 설명을 기반으로 동적인 메타데이터를 생성할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;5352&quot; data-start=&quot;5275&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;5352&quot; data-start=&quot;5275&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 게시글의 frontmatter에 작성한 title과 description을 활용해 페이지의 메타데이터를 구성할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export async function generateMetadata({ params }: Props) {
  const post = getPostBySlug(params.slug);

  return {
    title: post.title,
    description: post.description,
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;5589&quot; data-start=&quot;5545&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면 각 게시글마다 다른 제목과 설명을 검색 엔진에 전달할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;5679&quot; data-start=&quot;5591&quot; data-ke-size=&quot;size16&quot;&gt;또한 추후에는 Open Graph 이미지, sitemap, robots.txt도 함께 설정하여 검색과 공유 환경에서 더 자연스럽게 노출되도록 개선할 계획이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;5699&quot; data-start=&quot;5686&quot; data-section-id=&quot;18ggq4q&quot; data-ke-size=&quot;size26&quot;&gt;앞으로의 개발 계획&lt;/h2&gt;
&lt;p data-end=&quot;5760&quot; data-start=&quot;5701&quot; data-ke-size=&quot;size16&quot;&gt;현재는 블로그의 전체 구조와 핵심 기능을 설계하는 단계이며, 이후에는 다음 순서로 개발을 진행할 계획이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. Next.js 프로젝트 초기 세팅
2. 공통 레이아웃 구현
3. 메인 페이지 구현
4. Markdown 게시글 구조 설계
5. 게시글 목록 페이지 구현
6. 게시글 상세 페이지 구현
7. 코드 블록 스타일링 적용
8. 프로젝트 목록 페이지 구현
9. 프로젝트 상세 페이지 구현
10. 검색 및 필터 기능 구현
11. SEO 메타데이터 설정
12. 반응형 UI 개선
13. 배포&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;6041&quot; data-start=&quot;5988&quot; data-ke-size=&quot;size16&quot;&gt;우선은 블로그의 핵심 기능인 게시글 목록과 상세 페이지를 먼저 완성하는 것을 목표로 하고 있다.&lt;/p&gt;
&lt;p data-end=&quot;6101&quot; data-start=&quot;6043&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6101&quot; data-start=&quot;6043&quot; data-ke-size=&quot;size16&quot;&gt;그 이후 프로젝트 페이지와 소개 페이지를 추가하고, 검색과 필터 기능을 붙이면서 사용성을 높일 예정이다.&lt;/p&gt;
&lt;p data-end=&quot;6160&quot; data-start=&quot;6103&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 반응형 UI와 SEO를 정리하고 배포까지 진행하면 1차 버전의 기술 블로그가 완성될 것이다.&lt;/p&gt;
&lt;p data-end=&quot;6160&quot; data-start=&quot;6103&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6160&quot; data-start=&quot;6103&quot; data-ke-size=&quot;size16&quot;&gt;나만의 블로그를 완성하는 그날까지 화이팅!!&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/22</guid>
      <comments>https://jskim6335.tistory.com/22#entry22comment</comments>
      <pubDate>Sun, 24 May 2026 23:41:26 +0900</pubDate>
    </item>
    <item>
      <title>프론트엔드에서 테스트는 어떻게 진행할까?</title>
      <link>https://jskim6335.tistory.com/21</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;프론트엔드 테스트는 왜 필요할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼 하나의 조건을 바꿨는데 모달이 열리지 않을 수 있고, API 응답 구조가 조금 바뀌었는데 리스트가 렌더링되지 않을 수 있습니다. 또는 로그인 여부에 따라 보여야 할 페이지가 잘못 노출될 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 React 프로젝트에서는 컴포넌트가 많아질수록 상태, props, API, 라우팅, 전역 상태가 복잡하게 연결됩니다. 이때 테스트가 없으면 기능을 수정할 때마다 직접 브라우저를 열고 모든 경우를 손으로 확인해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드는 이런 과정을 자동화해 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 상황을 코드로 검증할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;사용자가 검색어를 입력한다.
검색 버튼을 클릭한다.
API 응답이 도착한다.
검색 결과 목록이 화면에 표시된다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 테스트로 작성되어 있다면, 이후 코드를 리팩토링하더라도 검색 기능이 깨졌는지 빠르게 확인할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프론트엔드 테스트의 기본 관점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트에서 중요한 기준은 &lt;b&gt;사용자 관점에서 테스트하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 React 컴포넌트 내부에 isOpen이라는 상태가 있다고 해보겠습니다. 모달이 열렸는지 테스트할 때 isOpen 값이 true인지 직접 확인하는 방식은 좋지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 사용자가 실제로 보는 화면을 기준으로 확인하는 것이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;expect(screen.getByText('로그인 정보를 입력해주세요')).toBeInTheDocument();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 &amp;ldquo;모달 상태가 true인가?&amp;rdquo;를 확인하는 것이 아니라, &amp;ldquo;사용자 화면에 로그인 안내 문구가 보이는가?&amp;rdquo;를 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이가 중요합니다. 내부 구현은 언제든 바뀔 수 있지만, 사용자에게 보여야 하는 결과는 유지되어야 하기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프론트엔드 테스트의 종류&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트는 보통 다음과 같이 나눌 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;단위 테스트&lt;/td&gt;
&lt;td&gt;함수, 유틸 로직&lt;/td&gt;
&lt;td&gt;날짜 포맷, 가격 계산, 배열 필터링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;컴포넌트 테스트&lt;/td&gt;
&lt;td&gt;개별 UI 컴포넌트&lt;/td&gt;
&lt;td&gt;Button, Modal, Input, Card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통합 테스트&lt;/td&gt;
&lt;td&gt;여러 컴포넌트와 상태의 연결&lt;/td&gt;
&lt;td&gt;검색 폼 + API + 결과 리스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2E 테스트&lt;/td&gt;
&lt;td&gt;실제 브라우저에서 전체 사용자 흐름&lt;/td&gt;
&lt;td&gt;로그인 &amp;rarr; 상품 검색 &amp;rarr; 결제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 테스트는 역할이 다릅니다. 모든 것을 하나의 방식으로 테스트하려고 하면 오히려 비효율적입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단위 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트는 가장 작은 로직을 검증하는 테스트입니다. 보통 UI보다는 순수 함수나 유틸 함수를 테스트할 때 많이 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 날짜를 화면에 보여주기 좋은 형식으로 바꾸는 함수가 있다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;export const formatDate = (date: string) =&amp;gt; {
  return date.replaceAll('-', '.');
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 다음과 같이 테스트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;import { describe, expect, it } from 'vitest';
import { formatDate } from './formatDate';

describe('formatDate', () =&amp;gt; {
  it('날짜 문자열의 하이픈을 점으로 변경한다', () =&amp;gt; {
    expect(formatDate('2026-05-19')).toBe('2026.05.19');
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트는 실행 속도가 빠르고, 실패했을 때 원인을 찾기 쉽습니다. 그래서 날짜 변환, 숫자 계산, 필터링, 정렬, 유효성 검사 같은 로직에 적합합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;컴포넌트 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트 테스트는 React 컴포넌트가 props나 사용자 행동에 따라 올바르게 렌더링되는지 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 버튼 컴포넌트가 있다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;type ButtonProps = {
  children: React.ReactNode;
  disabled?: boolean;
  onClick?: () =&amp;gt; void;
};

export function Button({ children, disabled, onClick }: ButtonProps) {
  return (
    &amp;lt;button disabled={disabled} onClick={onClick}&amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트는 다음과 같이 테스트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () =&amp;gt; {
  it('버튼 텍스트를 렌더링한다', () =&amp;gt; {
    render(&amp;lt;Button&amp;gt;저장&amp;lt;/Button&amp;gt;);

    expect(screen.getByRole('button', { name: '저장' })).toBeInTheDocument();
  });

  it('버튼을 클릭하면 onClick이 호출된다', async () =&amp;gt; {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(&amp;lt;Button onClick={handleClick}&amp;gt;저장&amp;lt;/Button&amp;gt;);

    await user.click(screen.getByRole('button', { name: '저장' }));

    expect(handleClick).toHaveBeenCalled();
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서도 핵심은 button 태그를 직접 찾는 것이 아니라, 사용자가 접근할 수 있는 역할인 button과 이름인 저장을 기준으로 찾는다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 접근성 측면에서도 좋습니다. 테스트하기 쉬운 컴포넌트는 대체로 사용자와 스크린 리더도 접근하기 쉬운 구조를 갖는 경우가 많습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;통합 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합 테스트는 여러 컴포넌트, 상태, API 요청이 함께 동작하는 흐름을 검증합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 애니메이션 검색 페이지가 있다고 해보겠습니다. 이 페이지는 검색어 입력 컴포넌트, 검색 버튼, API 요청, 결과 리스트 컴포넌트가 함께 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합 테스트는 이런 식으로 진행됩니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;it('검색어를 입력하고 검색하면 결과 목록이 표시된다', async () =&amp;gt; {
  const user = userEvent.setup();

  render(&amp;lt;AnimeSearchPage /&amp;gt;);

  await user.type(screen.getByPlaceholderText('애니메이션 검색'), 'naruto');
  await user.click(screen.getByRole('button', { name: '검색' }));

  expect(await screen.findByText('Naruto')).toBeInTheDocument();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트는 내부적으로 어떤 state를 사용하는지, API 함수 이름이 무엇인지에 관심을 두지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 사용자가 검색어를 입력하고 검색했을 때 결과가 보이는지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 실제로 가장 가치 있는 테스트는 이런 통합 테스트인 경우가 많습니다. 사용자가 경험하는 흐름을 직접 검증하기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API가 필요한 테스트는 어떻게 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트에서 API 요청을 실제 서버에 보내는 것은 보통 권장되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 상태나 네트워크 환경에 따라 테스트가 실패할 수 있고, 테스트 데이터가 바뀌면 결과도 흔들릴 수 있기 때문입니다. 그래서 API가 필요한 테스트에서는 보통 &lt;b&gt;Mocking&lt;/b&gt;을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 많이 사용하는 도구가 **MSW(Mock Service Worker)**입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSW는 프론트엔드 코드가 실제 API를 호출하는 것처럼 동작하게 두면서, 테스트 환경에서는 해당 요청을 가로채서 가짜 응답을 반환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 API를 다음과 같이 모킹할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/anime/search', () =&amp;gt; {
    return HttpResponse.json({
      results: [
        {
          id: 1,
          title: 'Naruto',
        },
      ],
    });
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 테스트에서는 실제 서버가 없어도 API 응답을 받은 것처럼 화면을 검증할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;it('API 응답으로 받은 검색 결과를 렌더링한다', async () =&amp;gt; {
  render(&amp;lt;AnimeSearchPage /&amp;gt;);

  expect(await screen.findByText('Naruto')).toBeInTheDocument();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSW를 사용하면 성공 응답뿐 아니라 실패 응답도 테스트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;http.get('/api/anime/search', () =&amp;gt; {
  return HttpResponse.json(
    { message: '검색 결과를 불러오지 못했습니다.' },
    { status: 500 }
  );
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 에러 응답을 만들고, 화면에 에러 메시지가 제대로 표시되는지 확인할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;expect(
  await screen.findByText('검색 결과를 불러오지 못했습니다.')
).toBeInTheDocument();
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로딩, 에러, 빈 상태도 테스트해야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트에서 자주 놓치는 부분이 있습니다. 바로 로딩 상태, 에러 상태, 빈 데이터 상태입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 서비스에서는 항상 성공 응답만 오지 않습니다. 데이터가 늦게 올 수도 있고, 실패할 수도 있고, 결과가 없을 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음과 같은 상태를 테스트하는 것이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;데이터를 불러오는 동안 로딩 UI가 보이는가?
API 요청이 실패했을 때 에러 메시지가 보이는가?
검색 결과가 없을 때 빈 상태 UI가 보이는가?
데이터가 정상적으로 도착하면 리스트가 보이는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 검색 결과가 없을 때는 다음과 같은 UI가 나올 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 테스트는 서비스의 안정성을 높여줍니다. 사용자는 성공 케이스만 경험하지 않기 때문에, 예외 상황까지 테스트하는 것이 중요합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 커버리지는 어떻게 봐야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 커버리지는 코드 중 어느 정도가 테스트에 의해 실행되었는지를 나타내는 지표입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 커버리지가 80%라면 전체 코드 중 80%가 테스트 실행 과정에서 지나갔다는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 커버리지 숫자가 높다고 해서 좋은 테스트라고 단정할 수는 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 &lt;b&gt;핵심 기능이 테스트되고 있는가&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 단순히 텍스트만 보여주는 컴포넌트를 많이 테스트해서 커버리지를 높이는 것보다, 로그인, 검색, 작성, 삭제, 권한 분기, API 에러 처리 같은 중요한 기능을 테스트하는 것이 더 가치 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에서는 보통 다음과 같은 우선순위로 테스트를 작성하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위 테스트 대상&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;로그인, 회원가입, 검색, 작성, 삭제 등 핵심 사용자 흐름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;API 성공, 실패, 빈 데이터 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;폼 유효성 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;권한에 따른 화면 분기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;공통 컴포넌트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;유틸 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;단순 정적 UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커버리지는 목표가 아니라 참고 지표로 보는 것이 좋습니다. 테스트의 목적은 숫자를 채우는 것이 아니라, 기능이 깨졌을 때 빠르게 알아차리는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프론트엔드 테스트 도구 조합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 프로젝트에서는 보통 다음 조합을 많이 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;Vitest: 테스트 실행 도구
React Testing Library: React 컴포넌트 테스트 도구
user-event: 사용자 입력과 클릭 시뮬레이션
MSW: API Mocking
Playwright 또는 Cypress: E2E 테스트
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vite 기반 프로젝트라면 Vitest가 잘 어울립니다. 설정이 간단하고 실행 속도가 빠르기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Testing Library는 컴포넌트를 실제 사용자 관점에서 테스트하는 데 적합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MSW는 API 응답을 안정적으로 제어할 수 있게 해주기 때문에, API 연동이 포함된 테스트에서 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Playwright나 Cypress는 실제 브라우저를 띄워서 로그인부터 페이지 이동까지 전체 흐름을 검증할 때 사용합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트는 어디까지 작성해야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 코드를 테스트하려고 하면 오히려 부담이 커집니다. 처음부터 완벽한 테스트 환경을 만들기보다는, 깨지면 치명적인 기능부터 테스트하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;인생 애니메이션 9선 선택 서비스&amp;rdquo;라면 다음 기능부터 테스트할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;애니메이션을 검색할 수 있는가?
검색 결과가 화면에 표시되는가?
작품을 선택할 수 있는가?
최대 9개까지만 선택되는가?
선택한 작품을 삭제할 수 있는가?
선택한 작품의 순서를 변경할 수 있는가?
결과 페이지에 9개 작품이 정상적으로 표시되는가?
API 요청 실패 시 에러 메시지가 표시되는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 테스트는 서비스의 핵심 흐름을 직접 보호합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 단순히 제목만 출력하는 섹션이나 스타일만 담당하는 컴포넌트는 우선순위를 낮게 잡아도 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트는 사용자가 실제로 서비스를 이용하는 흐름을 코드로 검증하는 과정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트는 유틸 함수나 작은 로직을 검증하고, 컴포넌트 테스트는 개별 UI가 올바르게 렌더링되는지 확인합니다. 통합 테스트는 여러 컴포넌트와 API 요청이 연결된 사용자 흐름을 검증합니다. 그리고 E2E 테스트는 실제 브라우저 환경에서 서비스 전체 흐름을 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 테스트에서 중요한 것은 내부 구현을 검증하는 것이 아니라, 사용자가 보는 화면과 행동 결과를 검증하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 좋은 프론트엔드 테스트는 다음 질문에 답할 수 있어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;사용자가 이 기능을 정상적으로 사용할 수 있는가?
데이터가 없거나 실패했을 때도 화면이 안정적으로 동작하는가?
리팩토링 이후에도 핵심 기능이 깨지지 않았는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 테스트는 코드를 더 복잡하게 만드는 작업이 아니라, 프로젝트를 더 안전하게 변경하기 위한 장치입니다. 특히 React 프로젝트처럼 컴포넌트와 상태가 많아지는 구조에서는 테스트가 있을수록 리팩토링과 기능 추가를 더 자신 있게 진행할 수 있습니다.&lt;/p&gt;</description>
      <category>React</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/21</guid>
      <comments>https://jskim6335.tistory.com/21#entry21comment</comments>
      <pubDate>Sun, 24 May 2026 22:06:31 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 스타일링 전략: Tailwind CSS, shadcn/ui, 그리고 CSS-in-JS</title>
      <link>https://jskim6335.tistory.com/20</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js 스타일링 전략: Tailwind CSS, shadcn/ui, 그리고 CSS-in-JS 이슈 직접 실험해보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router 환경에서는 스타일링 도구를 선택할 때 단순히 &amp;ldquo;디자인을 어떻게 입힐 것인가&amp;rdquo;만 보면 부족합니다. Server Components, Client Components, SSR, Streaming, 하이드레이션, 번들 크기, 스타일 삽입 시점까지 함께 고려해야 합니다. 특히 App Router에서는 기본적으로 Server Components를 적극적으로 활용할 수 있기 때문에, 스타일링 방식이 서버 렌더링 흐름과 잘 맞는지 확인하는 것이 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Tailwind CSS와 shadcn/ui를 활용해 UI 컴포넌트를 구현해보고, Styled-Components를 사용했을 때 SSR 환경에서 어떤 추가 설정이 필요한지 직접 실험해보겠습니다. 마지막으로 이 내용을 바탕으로 Next.js 프로젝트에서 어떤 스타일링 전략을 선택하면 좋을지 정리하겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js에서 Tailwind CSS가 자주 선택되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind CSS는 유틸리티 클래스를 조합해서 UI를 만드는 방식의 CSS 프레임워크입니다. 예를 들어 flex, px-4, text-sm, rounded-xl, bg-white 같은 클래스를 JSX의 className에 직접 작성해서 스타일을 적용합니다. 이 방식은 처음에는 HTML 안에 클래스가 많아 보여 어색할 수 있지만, 컴포넌트 단위로 UI를 작성하는 React와는 꽤 잘 맞는 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router에서는 컴포넌트가 기본적으로 Server Component로 동작합니다. Tailwind CSS는 런타임에 JavaScript로 스타일을 생성해서 주입하는 방식이 아니라, 빌드 과정에서 CSS를 생성하고 HTML에는 클래스 이름을 붙이는 방식에 가깝습니다. 그래서 Server Component에서도 특별한 클라이언트 런타임 없이 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래와 같은 컴포넌트는 별도의 &quot;use client&quot; 없이도 서버 컴포넌트로 작성할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/page.tsx

export default function HomePage() {
  return (
    &amp;lt;main className=&quot;min-h-screen bg-slate-50 px-6 py-10&quot;&amp;gt;
      &amp;lt;section className=&quot;mx-auto max-w-3xl rounded-2xl bg-white p-8 shadow-sm&quot;&amp;gt;
        &amp;lt;p className=&quot;text-sm font-medium text-blue-600&quot;&amp;gt;Next.js Styling&amp;lt;/p&amp;gt;

        &amp;lt;h1 className=&quot;mt-3 text-3xl font-bold tracking-tight text-slate-900&quot;&amp;gt;
          Tailwind CSS로 빠르게 UI를 구성하기
        &amp;lt;/h1&amp;gt;

        &amp;lt;p className=&quot;mt-4 leading-7 text-slate-600&quot;&amp;gt;
          Tailwind CSS는 유틸리티 클래스를 조합해 컴포넌트 단위의 UI를
          구성하기 좋은 방식입니다. Server Component에서도 className만
          사용하면 되기 때문에 Next.js App Router와 함께 사용하기 편합니다.
        &amp;lt;/p&amp;gt;

        &amp;lt;button className=&quot;mt-6 rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white hover:bg-slate-700&quot;&amp;gt;
          시작하기
        &amp;lt;/button&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 서버 컴포넌트로 렌더링될 수 있습니다. 버튼에 hover 스타일이 들어가 있지만, 이것만으로는 클라이언트 상태나 이벤트 핸들러가 필요하지 않습니다. 따라서 &quot;use client&quot;를 붙일 이유가 없습니다. 이 점은 Next.js에서 중요한 차이를 만듭니다. 스타일링을 위해 불필요하게 Client Component로 전환하지 않아도 되기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 공식 문서에서도 App Router에서 CSS-in-JS를 구성하려면 스타일 레지스트리, useServerInsertedHTML, Client Component wrapper 같은 별도 구성이 필요하다고 설명합니다. 반면 Tailwind CSS나 CSS Modules는 이러한 런타임 스타일 삽입 흐름을 직접 관리하지 않아도 되는 방식입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Tailwind CSS만 사용했을 때 생기는 아쉬움&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind CSS는 빠르게 UI를 만들기 좋지만, 프로젝트가 커지면 클래스 조합이 반복될 수 있습니다. 예를 들어 버튼을 여러 곳에서 사용한다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;&amp;lt;button className=&quot;rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white hover:bg-slate-700&quot;&amp;gt;
  저장하기
&amp;lt;/button&amp;gt;

&amp;lt;button className=&quot;rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white hover:bg-slate-700&quot;&amp;gt;
  수정하기
&amp;lt;/button&amp;gt;

&amp;lt;button className=&quot;rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white hover:bg-slate-700&quot;&amp;gt;
  삭제하기
&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 같은 스타일이 반복되면 유지보수가 어려워집니다. 나중에 버튼의 radius나 색상, padding을 바꾸려면 여러 파일을 찾아 수정해야 합니다. 그래서 실제 프로젝트에서는 Tailwind CSS를 그대로 쓰기보다는 공통 컴포넌트로 추상화해서 사용하는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 직접 Button 컴포넌트를 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// components/ui/button.tsx

import { ButtonHTMLAttributes } from &quot;react&quot;;

type ButtonVariant = &quot;primary&quot; | &quot;secondary&quot; | &quot;danger&quot;;

interface ButtonProps extends ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {
  variant?: ButtonVariant;
}

const variantClassName: Record&amp;lt;ButtonVariant, string&amp;gt; = {
  primary: &quot;bg-slate-900 text-white hover:bg-slate-700&quot;,
  secondary: &quot;bg-slate-100 text-slate-900 hover:bg-slate-200&quot;,
  danger: &quot;bg-red-600 text-white hover:bg-red-500&quot;,
};

export function Button({
  variant = &quot;primary&quot;,
  className = &quot;&quot;,
  children,
  ...props
}: ButtonProps) {
  return (
    &amp;lt;button
      className={[
        &quot;rounded-xl px-5 py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50&quot;,
        variantClassName[variant],
        className,
      ].join(&quot; &quot;)}
      {...props}
    &amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 페이지에서는 다음처럼 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/page.tsx

import { Button } from &quot;@/components/ui/button&quot;;

export default function HomePage() {
  return (
    &amp;lt;main className=&quot;p-10&quot;&amp;gt;
      &amp;lt;div className=&quot;flex gap-3&quot;&amp;gt;
        &amp;lt;Button&amp;gt;저장하기&amp;lt;/Button&amp;gt;
        &amp;lt;Button variant=&quot;secondary&quot;&amp;gt;취소하기&amp;lt;/Button&amp;gt;
        &amp;lt;Button variant=&quot;danger&quot;&amp;gt;삭제하기&amp;lt;/Button&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Tailwind의 장점은 유지하면서도, 반복되는 클래스는 컴포넌트 내부로 숨길 수 있습니다. 다만 직접 모든 컴포넌트를 만들기 시작하면 Button, Input, Modal, Dialog, Dropdown, Select 같은 UI를 계속 구현해야 합니다. 이 지점에서 shadcn/ui가 좋은 선택지가 될 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;shadcn/ui는 어떤 방식인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;shadcn/ui는 일반적인 UI 라이브러리와 조금 다릅니다. MUI나 Chakra UI처럼 패키지 내부에 있는 컴포넌트를 import해서 사용하는 방식이라기보다는, 필요한 컴포넌트의 코드를 내 프로젝트 안으로 가져와서 수정하며 사용하는 방식에 가깝습니다. shadcn/ui 공식 문서도 Next.js 프로젝트에서 CLI를 통해 설정하고 컴포넌트를 추가하는 방식을 안내하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 장점은 코드 소유권이 프로젝트에 있다는 점입니다. 예를 들어 Button 컴포넌트를 추가하면 components/ui/button.tsx 같은 파일이 실제 프로젝트 안에 생성됩니다. 이후에는 이 파일을 직접 수정해서 우리 서비스의 디자인 시스템에 맞게 바꿀 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;shadcn/ui는 보통 Tailwind CSS와 Radix UI를 함께 사용합니다. Tailwind CSS는 스타일을 담당하고, Radix UI는 Dialog, Dropdown, Select처럼 접근성 처리가 중요한 UI primitive를 제공합니다. shadcn/ui는 이 둘을 조합한 컴포넌트 예시를 프로젝트에 복사해주는 방식으로 볼 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;shadcn/ui 설치 예시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 프로젝트가 이미 있다고 가정하면, 먼저 shadcn/ui 초기 설정을 진행합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;npx shadcn@latest init
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 설정 후 필요한 컴포넌트를 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;npx shadcn@latest add button card input textarea
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트가 추가되면 보통 다음과 같은 구조가 생깁니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;components/
  ui/
    button.tsx
    card.tsx
    input.tsx
    textarea.tsx

lib/
  utils.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lib/utils.ts에는 보통 cn 함수가 들어갑니다. 이 함수는 조건부 클래스 조합을 정리할 때 사용됩니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// lib/utils.ts

import { clsx, type ClassValue } from &quot;clsx&quot;;
import { twMerge } from &quot;tailwind-merge&quot;;

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;clsx는 조건부 className을 조합하는 도구이고, tailwind-merge는 Tailwind 클래스가 충돌할 때 뒤에 온 값을 기준으로 정리해주는 도구입니다. 예를 들어 p-2 p-4가 동시에 들어왔을 때 최종적으로 p-4가 적용되도록 병합할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Tailwind + shadcn/ui로 UI 컴포넌트 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 shadcn/ui의 Button, Card, Input, Textarea를 활용해서 간단한 게시글 작성 카드를 만들어보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;// app/posts/new/page.tsx

import { Button } from &quot;@/components/ui/button&quot;;
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from &quot;@/components/ui/card&quot;;
import { Input } from &quot;@/components/ui/input&quot;;
import { Textarea } from &quot;@/components/ui/textarea&quot;;

export default function NewPostPage() {
  return (
    &amp;lt;main className=&quot;min-h-screen bg-slate-50 px-6 py-10&quot;&amp;gt;
      &amp;lt;section className=&quot;mx-auto max-w-2xl&quot;&amp;gt;
        &amp;lt;Card&amp;gt;
          &amp;lt;CardHeader&amp;gt;
            &amp;lt;CardTitle&amp;gt;새 글 작성&amp;lt;/CardTitle&amp;gt;
            &amp;lt;CardDescription&amp;gt;
              Next.js 스타일링 전략에 대한 글을 작성해보세요.
            &amp;lt;/CardDescription&amp;gt;
          &amp;lt;/CardHeader&amp;gt;

          &amp;lt;CardContent&amp;gt;
            &amp;lt;form className=&quot;space-y-5&quot;&amp;gt;
              &amp;lt;div className=&quot;space-y-2&quot;&amp;gt;
                &amp;lt;label
                  htmlFor=&quot;title&quot;
                  className=&quot;text-sm font-medium text-slate-700&quot;
                &amp;gt;
                  제목
                &amp;lt;/label&amp;gt;
                &amp;lt;Input
                  id=&quot;title&quot;
                  name=&quot;title&quot;
                  placeholder=&quot;글 제목을 입력하세요&quot;
                /&amp;gt;
              &amp;lt;/div&amp;gt;

              &amp;lt;div className=&quot;space-y-2&quot;&amp;gt;
                &amp;lt;label
                  htmlFor=&quot;content&quot;
                  className=&quot;text-sm font-medium text-slate-700&quot;
                &amp;gt;
                  내용
                &amp;lt;/label&amp;gt;
                &amp;lt;Textarea
                  id=&quot;content&quot;
                  name=&quot;content&quot;
                  placeholder=&quot;내용을 입력하세요&quot;
                  className=&quot;min-h-40 resize-none&quot;
                /&amp;gt;
              &amp;lt;/div&amp;gt;

              &amp;lt;div className=&quot;flex justify-end gap-3&quot;&amp;gt;
                &amp;lt;Button type=&quot;button&quot; variant=&quot;outline&quot;&amp;gt;
                  취소
                &amp;lt;/Button&amp;gt;
                &amp;lt;Button type=&quot;submit&quot;&amp;gt;작성하기&amp;lt;/Button&amp;gt;
              &amp;lt;/div&amp;gt;
            &amp;lt;/form&amp;gt;
          &amp;lt;/CardContent&amp;gt;
        &amp;lt;/Card&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예제에서 중요한 점은 UI를 구성하는 대부분의 코드가 Server Component로 작성될 수 있다는 것입니다. 현재 코드에는 useState, useEffect, onClick 같은 클라이언트 전용 로직이 없습니다. 따라서 페이지 전체에 &quot;use client&quot;를 붙일 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 입력값을 실시간으로 상태 관리하거나, 모달을 열고 닫거나, 클라이언트에서 즉시 유효성 검사를 해야 한다면 해당 부분은 Client Component로 분리할 수 있습니다. 핵심은 &amp;ldquo;상호작용이 필요한 부분만 Client Component로 분리하고, 나머지 정적 UI는 Server Component로 유지할 수 있다&amp;rdquo;는 점입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;variant가 많은 컴포넌트는 cva로 정리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;shadcn/ui의 Button 컴포넌트를 보면 class-variance-authority, 줄여서 cva를 사용하는 경우가 많습니다. cva는 variant와 size에 따라 className을 체계적으로 관리할 수 있게 도와줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 단순화한 Button 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// components/ui/custom-button.tsx

import * as React from &quot;react&quot;;
import { cva, type VariantProps } from &quot;class-variance-authority&quot;;

import { cn } from &quot;@/lib/utils&quot;;

const buttonVariants = cva(
  &quot;inline-flex items-center justify-center rounded-xl text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50&quot;,
  {
    variants: {
      variant: {
        default: &quot;bg-slate-900 text-white hover:bg-slate-700&quot;,
        outline: &quot;border border-slate-200 bg-white hover:bg-slate-100&quot;,
        ghost: &quot;hover:bg-slate-100&quot;,
        danger: &quot;bg-red-600 text-white hover:bg-red-500&quot;,
      },
      size: {
        sm: &quot;h-9 px-3&quot;,
        md: &quot;h-10 px-4&quot;,
        lg: &quot;h-12 px-6&quot;,
      },
    },
    defaultVariants: {
      variant: &quot;default&quot;,
      size: &quot;md&quot;,
    },
  }
);

interface CustomButtonProps
  extends React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt;,
    VariantProps&amp;lt;typeof buttonVariants&amp;gt; {}

export function CustomButton({
  className,
  variant,
  size,
  ...props
}: CustomButtonProps) {
  return (
    &amp;lt;button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 버튼 스타일을 문자열로 매번 직접 조합하지 않고, variant와 size라는 명확한 API로 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;CustomButton&amp;gt;기본 버튼&amp;lt;/CustomButton&amp;gt;
&amp;lt;CustomButton variant=&quot;outline&quot;&amp;gt;외곽선 버튼&amp;lt;/CustomButton&amp;gt;
&amp;lt;CustomButton variant=&quot;danger&quot; size=&quot;lg&quot;&amp;gt;
  삭제하기
&amp;lt;/CustomButton&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 디자인 시스템을 만들 때 특히 유용합니다. 버튼, 뱃지, 카드, 인풋처럼 반복적으로 사용되는 컴포넌트는 variant를 명확히 정의해두면 UI의 일관성을 유지하기 쉽습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;next/image와 next/font도 함께 고려해야 하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스타일링 전략을 이야기할 때 CSS만 보는 경우가 많지만, 실제 화면 품질에는 이미지와 폰트도 큰 영향을 줍니다. Next.js에서는 next/image와 next/font를 통해 이미지와 폰트를 최적화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;next/image의 &amp;lt;Image /&amp;gt; 컴포넌트는 HTML의 &amp;lt;img&amp;gt;를 확장한 컴포넌트이며, 디바이스에 맞는 크기의 이미지 제공, WebP 같은 최신 포맷 사용, lazy loading, layout shift 방지 등의 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/profile/page.tsx

import Image from &quot;next/image&quot;;

export default function ProfilePage() {
  return (
    &amp;lt;main className=&quot;mx-auto max-w-3xl px-6 py-10&quot;&amp;gt;
      &amp;lt;section className=&quot;flex items-center gap-5 rounded-2xl border bg-white p-6&quot;&amp;gt;
        &amp;lt;Image
          src=&quot;/images/profile.png&quot;
          alt=&quot;프로필 이미지&quot;
          width={96}
          height={96}
          className=&quot;rounded-full object-cover&quot;
        /&amp;gt;

        &amp;lt;div&amp;gt;
          &amp;lt;h1 className=&quot;text-2xl font-bold text-slate-900&quot;&amp;gt;김진성&amp;lt;/h1&amp;gt;
          &amp;lt;p className=&quot;mt-2 text-slate-600&quot;&amp;gt;
            Next.js와 React 기반의 프론트엔드 개발을 학습하고 있습니다.
          &amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폰트는 next/font를 사용할 수 있습니다. Next.js 공식 문서에 따르면 next/font는 폰트를 자동으로 최적화하고 외부 네트워크 요청을 제거하며, 자체 호스팅을 통해 layout shift 없이 웹 폰트를 로드할 수 있도록 돕습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx

import type { Metadata } from &quot;next&quot;;
import { Noto_Sans_KR } from &quot;next/font/google&quot;;
import &quot;./globals.css&quot;;

const notoSansKr = Noto_Sans_KR({
  subsets: [&quot;latin&quot;],
  weight: [&quot;400&quot;, &quot;500&quot;, &quot;700&quot;],
  variable: &quot;--font-noto-sans-kr&quot;,
});

export const metadata: Metadata = {
  title: &quot;Next.js Styling Strategy&quot;,
  description: &quot;Next.js 스타일링 전략 정리&quot;,
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;html lang=&quot;ko&quot; className={notoSansKr.variable}&amp;gt;
      &amp;lt;body className=&quot;font-sans&quot;&amp;gt;{children}&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind 설정에서 CSS 변수를 font family로 연결하면 프로젝트 전체에서 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;scss&quot;&gt;&lt;code&gt;/* app/globals.css */

@import &quot;tailwindcss&quot;;

@theme {
  --font-sans: var(--font-noto-sans-kr), sans-serif;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 UI 컴포넌트 스타일링뿐 아니라 이미지, 폰트까지 Next.js 방식에 맞게 정리할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS-in-JS가 왜 문제가 될 수 있는지 직접 실험해보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS-in-JS는 JavaScript 코드 안에서 CSS를 작성하는 방식입니다. 대표적으로 Styled-Components와 Emotion이 있습니다. 이 방식은 컴포넌트의 props에 따라 스타일을 바꾸기 쉽고, 스타일을 컴포넌트와 함께 관리할 수 있다는 장점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Styled-Components를 사용하면 다음처럼 작성할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import styled from &quot;styled-components&quot;;

const Button = styled.button&amp;lt;{ $variant?: &quot;primary&quot; | &quot;danger&quot; }&amp;gt;`
  border: none;
  border-radius: 12px;
  padding: 12px 20px;
  font-size: 14px;
  font-weight: 600;
  color: white;
  background-color: ${({ $variant }) =&amp;gt;
    $variant === &quot;danger&quot; ? &quot;#dc2626&quot; : &quot;#0f172a&quot;};

  &amp;amp;:hover {
    background-color: ${({ $variant }) =&amp;gt;
      $variant === &quot;danger&quot; ? &quot;#ef4444&quot; : &quot;#334155&quot;};
  }
`;

export function StyledButton() {
  return &amp;lt;Button $variant=&quot;primary&quot;&amp;gt;Styled Button&amp;lt;/Button&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드만 보면 깔끔합니다. props에 따라 스타일을 분기하기도 쉽습니다. 하지만 Next.js App Router 환경에서는 이 방식이 Tailwind CSS나 CSS Modules보다 고려할 점이 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 Styled-Components를 사용하는 컴포넌트는 보통 Client Component로 작성해야 합니다. 위 코드에서도 &quot;use client&quot;가 필요합니다. 스타일 생성과 삽입이 클라이언트 환경과 관련되기 때문입니다. 이 자체가 문제는 아니지만, 단순한 UI 컴포넌트까지 클라이언트 컴포넌트가 되면 클라이언트 번들에 포함되는 코드가 늘어날 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 SSR 환경에서 서버가 렌더링한 HTML에 스타일이 적절한 순서로 포함되지 않으면, 화면이 처음 나타나는 순간 스타일이 적용되지 않은 상태로 보이거나 하이드레이션 이후 스타일이 적용되는 현상이 생길 수 있습니다. 이를 흔히 FOUC, 즉 Flash of Unstyled Content라고 부릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 공식 문서에서는 App Router에서 CSS-in-JS를 사용하려면 렌더링 중 CSS 규칙을 수집하는 style registry, useServerInsertedHTML을 통한 스타일 삽입, 앱을 감싸는 Client Component 구성이 필요하다고 설명합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실험 준비: Styled-Components 설치하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Next.js 프로젝트에 Styled-Components를 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm install styled-components
npm install -D @types/styled-components
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 next.config.ts 또는 next.config.js에 Styled-Components compiler 옵션을 추가합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// next.config.ts

import type { NextConfig } from &quot;next&quot;;

const nextConfig: NextConfig = {
  compiler: {
    styledComponents: true,
  },
};

export default nextConfig;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 Styled-Components를 Next.js compiler가 처리할 수 있도록 도와줍니다. 하지만 App Router에서 SSR 스타일 삽입까지 제대로 처리하려면 이것만으로 충분하지 않을 수 있습니다. 그래서 style registry 설정이 필요합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실험 1: Registry 없이 Styled-Components 사용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 일부러 registry 설정 없이 Styled-Components를 사용해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// components/styled/hero-card.tsx

&quot;use client&quot;;

import styled from &quot;styled-components&quot;;

const Card = styled.section`
  max-width: 720px;
  margin: 80px auto;
  padding: 40px;
  border-radius: 24px;
  background: #0f172a;
  color: white;
  box-shadow: 0 20px 50px rgba(15, 23, 42, 0.25);
`;

const Label = styled.p`
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 700;
  color: #93c5fd;
`;

const Title = styled.h1`
  margin: 0;
  font-size: 40px;
  line-height: 1.2;
`;

const Description = styled.p`
  margin: 20px 0 0;
  color: #cbd5e1;
  line-height: 1.8;
`;

export function HeroCard() {
  return (
    &amp;lt;Card&amp;gt;
      &amp;lt;Label&amp;gt;CSS-in-JS Experiment&amp;lt;/Label&amp;gt;
      &amp;lt;Title&amp;gt;Styled-Components SSR 이슈 재현하기&amp;lt;/Title&amp;gt;
      &amp;lt;Description&amp;gt;
        이 컴포넌트는 Styled-Components로 작성되었습니다. App Router에서
        SSR 스타일 삽입 설정이 없을 때 초기 스타일 적용 시점을 확인할 수
        있습니다.
      &amp;lt;/Description&amp;gt;
    &amp;lt;/Card&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 페이지에서 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/styled-test/page.tsx

import { HeroCard } from &quot;@/components/styled/hero-card&quot;;

export default function StyledTestPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;HeroCard /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 개발 서버를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 브라우저에서 /styled-test 페이지를 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;&amp;lt;http://localhost:3000/styled-test&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 환경에서는 문제가 잘 보이지 않을 수 있습니다. 개발 서버는 HMR, 빠른 재컴파일, 개발용 런타임이 섞여 있기 때문입니다. SSR 스타일 문제는 프로덕션 빌드에서 더 확인하기 쉽습니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;npm run build
npm run start
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 브라우저에서 페이지를 새로고침하면서 다음 항목을 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;확인할 항목

- 페이지 첫 로딩 순간 스타일이 바로 적용되는가?
- 아주 짧은 순간 기본 HTML 스타일이 보였다가 styled-components 스타일이 적용되는가?
- 개발 환경과 프로덕션 환경의 차이가 있는가?
- View Source에서 styled-components 스타일 태그가 초기 HTML에 포함되어 있는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 실험에서 무조건 FOUC가 눈에 띄게 발생한다고 단정하기는 어렵습니다. 환경, Next.js 버전, Styled-Components 버전, 브라우저 상태, 네트워크 속도에 따라 다르게 보일 수 있습니다. 중요한 것은 Styled-Components를 App Router에서 안정적으로 사용하려면 서버 렌더링 중 생성된 스타일을 적절한 시점에 HTML에 삽입하는 구성이 필요하다는 점입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실험 2: Styled-Components Registry 추가하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 Next.js 공식 문서에서 안내하는 흐름에 맞춰 Styled-Components registry를 구성해보겠습니다. App Router에서 CSS-in-JS를 사용하려면 서버 렌더링 중 생성된 스타일을 수집하고, useServerInsertedHTML을 사용해 HTML에 삽입하는 과정이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 registry 파일을 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// lib/styled-components-registry.tsx

&quot;use client&quot;;

import React, { useState } from &quot;react&quot;;
import { useServerInsertedHTML } from &quot;next/navigation&quot;;
import {
  ServerStyleSheet,
  StyleSheetManager,
} from &quot;styled-components&quot;;

export function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() =&amp;gt; new ServerStyleSheet());

  useServerInsertedHTML(() =&amp;gt; {
    const styles = styledComponentsStyleSheet.getStyleElement();

    styledComponentsStyleSheet.instance.clearTag();

    return &amp;lt;&amp;gt;{styles}&amp;lt;/&amp;gt;;
  });

  if (typeof window !== &quot;undefined&quot;) {
    return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
  }

  return (
    &amp;lt;StyleSheetManager sheet={styledComponentsStyleSheet.instance}&amp;gt;
      {children}
    &amp;lt;/StyleSheetManager&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 app/layout.tsx에서 전체 앱을 감쌉니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/layout.tsx

import type { Metadata } from &quot;next&quot;;
import { StyledComponentsRegistry } from &quot;@/lib/styled-components-registry&quot;;
import &quot;./globals.css&quot;;

export const metadata: Metadata = {
  title: &quot;Styled Components SSR Test&quot;,
  description: &quot;Next.js App Router Styled-Components SSR 실험&quot;,
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;html lang=&quot;ko&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;StyledComponentsRegistry&amp;gt;
          {children}
        &amp;lt;/StyledComponentsRegistry&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다시 프로덕션 빌드를 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;npm run build
npm run start
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 /styled-test 페이지를 다시 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;확인할 항목

- 초기 HTML에 styled-components 스타일이 포함되는가?
- 새로고침 시 스타일이 더 안정적으로 적용되는가?
- 스타일이 늦게 적용되는 현상이 줄어드는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 실험을 통해 알 수 있는 점은 CSS-in-JS 자체가 항상 문제가 된다는 것이 아닙니다. 다만 Next.js App Router와 SSR 환경에서 사용하려면 Tailwind CSS나 CSS Modules보다 설정해야 할 부분이 많고, 스타일 삽입 시점을 신경 써야 한다는 점입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실험 3: 같은 UI를 Tailwind CSS로 작성해보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 같은 Hero UI를 Tailwind CSS로 작성해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// app/tailwind-test/page.tsx

export default function TailwindTestPage() {
  return (
    &amp;lt;main className=&quot;min-h-screen bg-white px-6&quot;&amp;gt;
      &amp;lt;section className=&quot;mx-auto mt-20 max-w-3xl rounded-3xl bg-slate-900 p-10 text-white shadow-2xl shadow-slate-300&quot;&amp;gt;
        &amp;lt;p className=&quot;mb-3 text-sm font-bold text-blue-300&quot;&amp;gt;
          Tailwind CSS Experiment
        &amp;lt;/p&amp;gt;

        &amp;lt;h1 className=&quot;text-4xl font-bold leading-tight&quot;&amp;gt;
          Tailwind CSS로 같은 UI 구현하기
        &amp;lt;/h1&amp;gt;

        &amp;lt;p className=&quot;mt-5 leading-8 text-slate-300&quot;&amp;gt;
          이 컴포넌트는 Tailwind CSS로 작성되었습니다. className 기반으로
          스타일을 적용하기 때문에 별도의 Client Component 전환이나
          CSS-in-JS registry 설정이 필요하지 않습니다.
        &amp;lt;/p&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 페이지는 기본적으로 Server Component입니다. &quot;use client&quot;도 없고, 스타일을 수집하기 위한 registry도 없습니다. HTML에는 className이 들어가고, CSS는 빌드된 스타일시트에서 처리됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 같은 UI를 만들 수 있다면 Tailwind CSS 방식이 Next.js App Router의 서버 중심 구조와 더 단순하게 맞아떨어지는 경우가 많습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실험 결과 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Styled-Components 방식은 컴포넌트 안에서 스타일을 함께 관리할 수 있고, props 기반 동적 스타일링이 자연스럽습니다. 하지만 Next.js App Router에서 SSR까지 고려하면 style registry, useServerInsertedHTML, Client Component wrapper 설정이 필요합니다. 또한 Styled-Components를 사용하는 컴포넌트는 클라이언트 컴포넌트가 되는 경우가 많기 때문에, 단순한 스타일링을 위해 클라이언트 번들 범위가 넓어지지 않는지 확인해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind CSS 방식은 클래스가 길어질 수 있다는 단점이 있지만, Server Component에서 바로 사용할 수 있고 별도의 런타임 스타일 삽입 설정이 필요하지 않습니다. 또한 shadcn/ui와 함께 사용하면 Button, Card, Dialog, Input 같은 UI 컴포넌트를 빠르게 구성하면서도 코드를 직접 수정할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS Modules는 Tailwind CSS와 CSS-in-JS 사이에 있는 안정적인 선택지로 볼 수 있습니다. CSS 파일을 따로 작성하면서도 클래스 이름 충돌을 막을 수 있고, Server Component와 함께 사용하기도 무난합니다. 다만 variant가 많은 디자인 시스템을 구성할 때는 Tailwind + cva 방식보다 반복이 늘어날 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CSS-in-JS가 무조건 나쁜 선택은 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 CSS-in-JS를 &amp;ldquo;사용하면 안 되는 기술&amp;rdquo;로 보면 안 된다는 것입니다. Styled-Components와 Emotion은 여전히 장점이 있습니다. 컴포넌트와 스타일을 한 파일에서 관리하기 좋고, props 기반 스타일 분기가 편하며, 기존 프로젝트에서 이미 디자인 시스템이 CSS-in-JS 기반으로 잘 구축되어 있다면 그대로 유지하는 편이 더 합리적일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Next.js App Router로 새 프로젝트를 시작한다면 상황이 조금 다릅니다. 서버 컴포넌트를 적극적으로 활용하고, 초기 렌더링 성능과 번들 크기를 관리하고 싶다면 Tailwind CSS, CSS Modules, shadcn/ui 같은 선택지가 더 단순한 구조를 제공할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, CSS-in-JS의 문제는 &amp;ldquo;스타일을 JS로 작성한다&amp;rdquo;는 사실 하나에만 있는 것이 아닙니다. 실제로는 SSR 스타일 추출, 하이드레이션 타이밍, Client Component 경계, 런타임 비용, 설정 복잡성이 함께 얽혀 있습니다. 이 지점을 이해하고 선택하는 것이 중요합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;블로그 예제 프로젝트 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 실험을 하나의 프로젝트 안에서 구성하면 다음과 같은 구조가 됩니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;app/
  layout.tsx
  globals.css

  page.tsx

  posts/
    new/
      page.tsx

  styled-test/
    page.tsx

  tailwind-test/
    page.tsx

components/
  styled/
    hero-card.tsx

  ui/
    button.tsx
    card.tsx
    input.tsx
    textarea.tsx
    custom-button.tsx

lib/
  styled-components-registry.tsx
  utils.ts

next.config.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;posts/new/page.tsx에서는 Tailwind + shadcn/ui 기반 컴포넌트를 확인하고, styled-test/page.tsx에서는 Styled-Components SSR 설정 여부에 따른 차이를 확인합니다. tailwind-test/page.tsx에서는 같은 UI를 Tailwind CSS만으로 구성해 비교할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 스타일링 전략을 선택할 때는 단순히 문법 취향만 기준으로 삼기보다는, App Router의 렌더링 구조와 함께 생각해야 합니다. Tailwind CSS는 className 기반으로 동작하기 때문에 Server Components와 함께 사용하기 편하고, 런타임 스타일 삽입 설정이 필요하지 않습니다. shadcn/ui는 Tailwind 기반 컴포넌트를 프로젝트 안으로 가져와 직접 수정하는 방식이라, 빠르게 UI를 만들면서도 코드 제어권을 유지할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Styled-Components나 Emotion 같은 CSS-in-JS는 동적 스타일링에는 장점이 있지만, Next.js App Router에서는 SSR 스타일 삽입과 Client Component 경계를 고려해야 합니다. 특히 Styled-Components를 사용할 때는 compiler.styledComponents 설정뿐 아니라, style registry와 useServerInsertedHTML을 활용한 구성이 필요할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 새 Next.js 프로젝트를 시작한다면 기본 스타일링은 Tailwind CSS 또는 CSS Modules로 가져가고, UI 컴포넌트는 shadcn/ui를 활용하는 방식이 관리하기 쉽습니다. CSS-in-JS는 기존 코드베이스, 강한 동적 스타일 요구사항, 이미 구축된 디자인 시스템이 있을 때 선택할 수 있지만, SSR과 App Router 환경에서 필요한 설정을 함께 이해하고 도입하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어주셔서 감사합니다!&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/20</guid>
      <comments>https://jskim6335.tistory.com/20#entry20comment</comments>
      <pubDate>Sun, 17 May 2026 23:41:18 +0900</pubDate>
    </item>
    <item>
      <title>폴더 구조와 아키텍처에 대해서 알아보자!</title>
      <link>https://jskim6335.tistory.com/19</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;폴더 구조 &amp;amp; 아키텍처&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 프로젝트가 작을 때는 보통 components, hooks, utils, api, pages 정도로 폴더를 나눠도 큰 문제가 없습니다. 하지만 기능이 많아지고, 로그인, 게시글, 댓글, 마이페이지, 관리자 기능, 실시간 알림, 검색, 필터링 같은 도메인이 늘어나면 문제가 생기기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 components 폴더 안에 UserCard, PostCard, CommentItem, AdminTable, LoginForm, ProfileEditModal 같은 파일이 계속 쌓이면, 이 컴포넌트가 어느 기능에 속하는지 알기 어려워집니다. hooks 폴더도 마찬가지입니다. useUser, usePost, useAuth, useModal, useDebounce, useInfiniteScroll 같은 훅이 한 곳에 모이면, 어떤 훅이 특정 기능 전용이고 어떤 훅이 공통 유틸인지 구분하기 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 규모가 있는 프론트엔드 프로젝트에서는 단순히 기술 종류별로 나누는 방식보다, &lt;b&gt;기능과 도메인을 기준으로 코드를 나누는 구조&lt;/b&gt;가 필요합니다. 이때 자주 사용되는 방식이 &lt;b&gt;FSD, Feature-Sliced Design&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 프론트엔드 애플리케이션을 layers, slices, segments라는 기준으로 나누는 구조입니다. 공식 문서에서도 FSD의 핵심 구조를 레이어, 슬라이스, 세그먼트로 설명하며, 레이어는 책임과 의존성의 크기에 따라 코드를 나누는 기준이고, 슬라이스는 비즈니스 의미 단위로 코드를 묶는 기준이며, 세그먼트는 기술적 역할에 따라 내부 코드를 나누는 기준입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD 아키텍처란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 &lt;b&gt;Feature-Sliced Design&lt;/b&gt;의 약자로, 프론트엔드 코드를 기능 단위로 분리하기 위한 아키텍처 방법론입니다. 이름 그대로 애플리케이션을 기능 단위로 잘라서 관리하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 보통 다음과 같이 폴더를 나눕니다.&lt;/p&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;src/
├── components/
├── hooks/
├── pages/
├── api/
├── utils/
├── stores/
└── types/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 초반에는 단순해서 좋습니다. 하지만 프로젝트가 커질수록 components 폴더가 너무 커지고, 특정 기능에서만 쓰이는 코드와 전역에서 재사용되는 코드가 섞이는 문제가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 FSD는 코드를 다음과 같이 나눕니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;src/
├── app/
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 폴더는 단순한 분류명이 아니라, &lt;b&gt;역할과 의존성 방향을 가진 계층&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- app은 앱 전체 설정을 담당합니다. 라우터, 전역 Provider, 전역 스타일, QueryClient 설정, Zustand Provider, Theme 설정 등이 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- pages는 실제 라우트 단위의 페이지를 담당합니다. Next.js App Router를 사용한다면 app/ 라우팅 폴더와 FSD의 pages 레이어를 어떻게 조합할지 팀 규칙을 정해야 합니다. 일반적으로 Next.js의 app 라우트 파일은 최대한 얇게 유지하고, 실제 페이지 UI는 FSD의 pages 또는 views 성격의 모듈로 분리하는 방식이 많이 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- widgets는 여러 기능과 엔티티를 조합해서 만든 큰 UI 블록입니다. 예를 들어 Header, Sidebar, PostList, UserProfileSection, CommentSection 같은 단위가 여기에 들어갈 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- features는 사용자의 행동 단위 기능입니다. 예를 들어 login, logout, create-post, edit-profile, add-comment, like-post, search-anime 같은 기능이 들어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- entities는 비즈니스 도메인의 핵심 개체입니다. 예를 들어 user, post, comment, anime, review, favorite 같은 것들이 엔티티가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- shared는 특정 비즈니스 도메인에 속하지 않는 공통 코드입니다. 예를 들어 공통 Button, Modal, Input, API 클라이언트, 날짜 포맷 함수, 공통 타입, 공통 상수 등이 들어갑니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Layered Architecture 적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 기본적으로 &lt;b&gt;Layered Architecture&lt;/b&gt;, 즉 계층형 아키텍처의 성격을 가집니다. 계층형 아키텍처의 핵심은 각 계층이 자신의 역할을 명확히 가지고, 의존성 방향을 제한하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 계층형 구조를 적용하지 않으면 이런 문제가 생깁니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 안 좋은 예시
import {LoginForm }from'@/components/LoginForm';
import {useUserStore }from'@/stores/userStore';
import {fetchUser }from'@/api/user';
import {formatDate }from'@/utils/date';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드 자체가 무조건 나쁜 것은 아닙니다. 하지만 프로젝트 전체가 이런 식으로 구성되면 어느 코드가 어느 기능에 속하는지 알기 어렵습니다. 로그인 기능에서만 쓰는 폼이 components에 있고, 유저 API는 api에 있고, 상태는 stores에 있고, 타입은 types에 흩어지게 됩니다. 결국 하나의 기능을 수정하려면 여러 폴더를 왔다 갔다 해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD에서는 같은 기능에 관련된 코드를 가까이 둡니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;src/
├── features/
│   └── login/
│       ├── ui/
│       │   └── LoginForm.tsx
│       ├── model/
│       │   └── useLoginForm.ts
│       ├── api/
│       │   └── loginApi.ts
│       └── index.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 로그인 기능을 수정할 때 features/login 폴더만 보면 됩니다. UI, 상태 로직, API 요청, 외부로 공개할 모듈이 한 기능 안에 모여 있기 때문에 응집도가 높아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계층형 구조에서 중요한 것은 의존성 방향입니다. 일반적으로 FSD에서는 상위 계층이 하위 계층을 가져다 쓸 수 있지만, 하위 계층이 상위 계층을 가져다 쓰면 안 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;app
 &amp;darr;
pages
 &amp;darr;
widgets
 &amp;darr;
features
 &amp;darr;
entities
 &amp;darr;
shared
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 pages는 widgets, features, entities, shared를 사용할 수 있습니다. 하지만 shared가 features를 import하면 안 됩니다. shared는 가장 낮은 계층이기 때문에 어떤 비즈니스 기능에도 의존하지 않아야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모듈 간 의존성 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD에서 가장 중요한 부분은 &lt;b&gt;모듈 간 의존성 관리&lt;/b&gt;입니다. 폴더를 아무리 잘 나눠도 import 규칙이 무너지면 구조는 금방 망가집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 코드는 문제가 될 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 좋지 않은 예시
import {UserAvatar }from'@/entities/user/ui/UserAvatar';
import {userApi }from'@/entities/user/api/userApi';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 entities/user 내부 구조를 외부에서 직접 알고 접근하고 있습니다. 처음에는 괜찮아 보이지만, 나중에 UserAvatar의 위치를 바꾸거나 userApi를 리팩토링하면 이 파일을 import한 모든 곳이 영향을 받습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD에서는 이를 막기 위해 &lt;b&gt;Public API&lt;/b&gt; 개념을 사용합니다. Public API는 특정 슬라이스가 외부에 공개할 코드만 index.ts에서 export하는 방식입니다. 공식 문서에서도 Public API를 모듈 간 계약으로 설명하며, 외부 코드는 내부 파일 구조가 아니라 Public API를 통해서만 접근하는 것이 권장됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 entities/user가 있다면 다음처럼 구성할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;src/
└── entities/
    └── user/
        ├── ui/
        │   └── UserAvatar.tsx
        ├── model/
        │   └── types.ts
        ├── api/
        │   └── userApi.ts
        └── index.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// entities/user/index.ts
export {UserAvatar }from'./ui/UserAvatar';
exporttype {User }from'./model/types';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서는 다음처럼 import합니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 좋은 예시
import {UserAvatar,typeUser }from'@/entities/user';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 entities/user 내부 구조가 바뀌어도 외부 코드는 영향을 덜 받습니다. 예를 들어 UserAvatar.tsx를 ui/avatar/UserAvatar.tsx로 옮기더라도 index.ts만 수정하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 다음과 같은 import는 피하는 것이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 피해야 하는 예시
import {UserAvatar }from'@/entities/user/ui/UserAvatar';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 내부 파일 경로에 직접 의존하기 때문에 리팩토링에 약합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD의 Layers, Slices, Segments&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 크게 세 가지 기준으로 코드를 나눕니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;Layer&lt;/b&gt;입니다. Layer는 코드의 책임 범위를 기준으로 나누는 최상위 계층입니다. 예를 들어 app, pages, widgets, features, entities, shared가 여기에 해당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;Slice&lt;/b&gt;입니다. Slice는 비즈니스 도메인이나 기능 의미를 기준으로 나누는 단위입니다. 공식 문서에서도 slice는 제품, 비즈니스, 애플리케이션의 의미에 따라 코드를 그룹화하는 단위라고 설명합니다. 예를 들어 user, post, comment, anime, favorite, search 같은 이름이 slice가 될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 &lt;b&gt;Segment&lt;/b&gt;입니다. Segment는 slice 내부에서 기술적 목적에 따라 코드를 나누는 단위입니다. 일반적으로 ui, model, api, lib, config 같은 이름을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 애니메이션 선택 서비스를 만든다고 하면 다음처럼 구성할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;src/
├── app/
│   ├── providers/
│   ├── styles/
│   └── router/
│
├── pages/
│   ├── home/
│   ├── anime-select/
│   └── result/
│
├── widgets/
│   ├── anime-search-panel/
│   ├── selected-anime-grid/
│   └── result-share-card/
│
├── features/
│   ├── search-anime/
│   ├── select-anime/
│   ├── remove-selected-anime/
│   ├── reorder-anime/
│   └── share-result/
│
├── entities/
│   ├── anime/
│   ├── user/
│   └── result/
│
└── shared/
    ├── ui/
    ├── api/
    ├── lib/
    ├── constants/
    └── types/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 보면 각 기능의 위치를 예측하기 쉬워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애니메이션 검색 API는 features/search-anime/api 또는 검색 결과가 애니메이션 도메인에 강하게 속한다면 entities/anime/api에 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애니메이션 카드 UI는 entities/anime/ui/AnimeCard.tsx에 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택된 애니메이션 9개를 보여주는 큰 영역은 여러 개의 AnimeCard와 선택/삭제 기능을 조합하므로 widgets/selected-anime-grid에 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택 버튼을 누르는 기능은 사용자 액션이므로 features/select-anime에 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 결과 페이지는 여러 위젯과 기능을 조합하므로 pages/result에 둘 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;실제 예시: 애니메이션 9선 선택 서비스&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 애니메이션을 검색하고, 9개를 선택하고, 순서를 바꾼 뒤, 결과 이미지를 공유하는 서비스를 만든다고 가정해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 다음처럼 단순하게 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;src/
├── components/
│   ├── AnimeCard.tsx
│   ├── SearchInput.tsx
│   ├── SelectedGrid.tsx
│   └── ShareButton.tsx
├── hooks/
│   ├── useAnimeSearch.ts
│   └── useSelectedAnime.ts
├── api/
│   └── animeApi.ts
├── pages/
│   ├── HomePage.tsx
│   └── ResultPage.tsx
└── stores/
    └── animeStore.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 프로젝트라면 이 구조도 충분합니다. 하지만 검색 필터, 정렬, 로그인, 결과 저장, 공유 이미지 생성, 댓글, 좋아요, 마이페이지까지 추가되면 components, hooks, stores가 금방 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD 방식으로 리팩토링하면 다음처럼 나눌 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;src/
├── app/
│   ├── providers/
│   │   ├── QueryProvider.tsx
│   │   └── ThemeProvider.tsx
│   └── styles/
│       └── globals.css
│
├── pages/
│   ├── home/
│   │   ├── ui/
│   │   │   └── HomePage.tsx
│   │   └── index.ts
│   ├── anime-select/
│   │   ├── ui/
│   │   │   └── AnimeSelectPage.tsx
│   │   └── index.ts
│   └── result/
│       ├── ui/
│       │   └── ResultPage.tsx
│       └── index.ts
│
├── widgets/
│   ├── anime-search-panel/
│   │   ├── ui/
│   │   │   └── AnimeSearchPanel.tsx
│   │   └── index.ts
│   ├── selected-anime-grid/
│   │   ├── ui/
│   │   │   └── SelectedAnimeGrid.tsx
│   │   └── index.ts
│   └── result-card/
│       ├── ui/
│       │   └── ResultCard.tsx
│       └── index.ts
│
├── features/
│   ├── search-anime/
│   │   ├── api/
│   │   │   └── searchAnime.ts
│   │   ├── model/
│   │   │   └── useAnimeSearch.ts
│   │   ├── ui/
│   │   │   └── SearchAnimeInput.tsx
│   │   └── index.ts
│   ├── select-anime/
│   │   ├── model/
│   │   │   └── selectedAnimeStore.ts
│   │   ├── ui/
│   │   │   └── SelectAnimeButton.tsx
│   │   └── index.ts
│   ├── reorder-anime/
│   │   ├── model/
│   │   │   └── useReorderAnime.ts
│   │   └── index.ts
│   └── share-result/
│       ├── lib/
│       │   └── createShareImage.ts
│       ├── ui/
│       │   └── ShareResultButton.tsx
│       └── index.ts
│
├── entities/
│   └── anime/
│       ├── api/
│       │   └── animeApi.ts
│       ├── model/
│       │   └── types.ts
│       ├── ui/
│       │   └── AnimeCard.tsx
│       └── index.ts
│
└── shared/
    ├── ui/
    │   ├── Button/
    │   ├── Input/
    │   └── Modal/
    ├── api/
    │   └── httpClient.ts
    ├── lib/
    │   ├── cn.ts
    │   └── formatDate.ts
    ├── constants/
    │   └── routes.ts
    └── types/
        └── api.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 각 코드의 위치가 더 명확해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimeCard는 애니메이션이라는 도메인 자체를 표현하는 UI이므로 entities/anime/ui에 둡니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SearchAnimeInput은 사용자가 애니메이션을 검색하는 액션과 연결되므로 features/search-anime/ui에 둡니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SelectedAnimeGrid는 선택된 애니메이션 목록, 삭제 버튼, 순서 변경 기능 등을 조합하는 큰 UI 블록이므로 widgets/selected-anime-grid에 둡니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimeSelectPage는 검색 패널과 선택 그리드를 조합하는 페이지 단위이므로 pages/anime-select에 둡니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예시 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 entities/anime는 애니메이션이라는 도메인 자체를 표현합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// entities/anime/model/types.ts
exportinterfaceAnime {
  id:number;
  title:string;
  posterUrl:string;
  releaseYear?:number;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// entities/anime/ui/AnimeCard.tsx
importtype {Anime }from'../model/types';

interfaceAnimeCardProps {
  anime:Anime;
  rightSlot?:React.ReactNode;
}

exportfunctionAnimeCard({ anime, rightSlot }:AnimeCardProps) {
return (
&amp;lt;articleclassName=&quot;rounded-xl border p-3&quot;&amp;gt;
&amp;lt;img
src={anime.posterUrl}
alt={anime.title}
className=&quot;aspect-[3/4] w-full rounded-lg object-cover&quot;
/&amp;gt;

&amp;lt;divclassName=&quot;mt-3 flex items-center justify-between gap-2&quot;&amp;gt;
&amp;lt;div&amp;gt;
&amp;lt;h3className=&quot;font-semibold&quot;&amp;gt;{anime.title}&amp;lt;/h3&amp;gt;
          {anime.releaseYear&amp;amp;&amp;amp; (
&amp;lt;pclassName=&quot;text-sm text-gray-500&quot;&amp;gt;{anime.releaseYear}&amp;lt;/p&amp;gt;
          )}
&amp;lt;/div&amp;gt;

        {rightSlot}
&amp;lt;/div&amp;gt;
&amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// entities/anime/index.ts
export {AnimeCard }from'./ui/AnimeCard';
exporttype {Anime }from'./model/types';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 외부에서는 내부 경로가 아니라 Public API를 통해 가져옵니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;import {AnimeCard,typeAnime }from'@/entities/anime';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 애니메이션 선택 기능입니다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;// features/select-anime/model/selectedAnimeStore.ts
import {create }from'zustand';
importtype {Anime }from'@/entities/anime';

interfaceSelectedAnimeState {
  selectedAnimeList:Anime[];
  selectAnime: (anime:Anime) =&amp;gt;void;
  removeAnime: (animeId:number) =&amp;gt;void;
}

exportconstuseSelectedAnimeStore=create&amp;lt;SelectedAnimeState&amp;gt;((set) =&amp;gt; ({
  selectedAnimeList: [],

  selectAnime: (anime) =&amp;gt;
set((state) =&amp;gt; {
constalreadySelected=state.selectedAnimeList.some(
        (item) =&amp;gt;item.id===anime.id,
      );

if (alreadySelected||state.selectedAnimeList.length&amp;gt;=9) {
returnstate;
      }

return {
        selectedAnimeList: [...state.selectedAnimeList,anime],
      };
    }),

  removeAnime: (animeId) =&amp;gt;
set((state) =&amp;gt; ({
      selectedAnimeList:state.selectedAnimeList.filter(
        (anime) =&amp;gt;anime.id!==animeId,
      ),
    })),
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// features/select-anime/ui/SelectAnimeButton.tsx
importtype {Anime }from'@/entities/anime';
import {useSelectedAnimeStore }from'../model/selectedAnimeStore';

interfaceSelectAnimeButtonProps {
  anime:Anime;
}

exportfunctionSelectAnimeButton({ anime }:SelectAnimeButtonProps) {
constselectAnime=useSelectedAnimeStore((state) =&amp;gt;state.selectAnime);

return (
&amp;lt;button
type=&quot;button&quot;
onClick={() =&amp;gt;selectAnime(anime)}
className=&quot;rounded-md bg-black px-3 py-2 text-sm text-white&quot;
&amp;gt;
      선택
&amp;lt;/button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// features/select-anime/index.ts
export {SelectAnimeButton }from'./ui/SelectAnimeButton';
export {useSelectedAnimeStore }from'./model/selectedAnimeStore';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 위젯에서는 엔티티와 기능을 조합합니다.&lt;/p&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;// widgets/anime-search-panel/ui/AnimeSearchPanel.tsx
import {AnimeCard }from'@/entities/anime';
import {SelectAnimeButton }from'@/features/select-anime';
import {useAnimeSearch }from'@/features/search-anime';

exportfunctionAnimeSearchPanel() {
const { keyword, setKeyword, animeList, isLoading }=useAnimeSearch();

return (
&amp;lt;section&amp;gt;
&amp;lt;input
value={keyword}
onChange={(event) =&amp;gt;setKeyword(event.target.value)}
placeholder=&quot;애니메이션 검색&quot;
className=&quot;w-full rounded-lg border px-4 py-3&quot;
/&amp;gt;

      {isLoading&amp;amp;&amp;amp;&amp;lt;p&amp;gt;검색 중입니다.&amp;lt;/p&amp;gt;}

&amp;lt;divclassName=&quot;mt-4 grid grid-cols-2 gap-4 md:grid-cols-4&quot;&amp;gt;
        {animeList.map((anime) =&amp;gt; (
&amp;lt;AnimeCard
key={anime.id}
anime={anime}
rightSlot={&amp;lt;SelectAnimeButtonanime={anime}/&amp;gt;}
/&amp;gt;
        ))}
&amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 AnimeSearchPanel은 entities/anime의 AnimeCard와 features/select-anime의 SelectAnimeButton을 조합합니다. 즉, 위젯은 여러 하위 계층을 조합하는 역할을 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 프로젝트 폴더 구조 리팩토링 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 프로젝트를 FSD로 리팩토링할 때는 한 번에 모든 폴더를 갈아엎으려고 하면 오히려 위험합니다. 가장 좋은 방식은 &lt;b&gt;기능 하나씩 이동하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 기존 구조가 다음과 같다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;src/
├── components/
│   ├── AnimeCard.tsx
│   ├── SearchInput.tsx
│   ├── SelectedGrid.tsx
│   └── ShareButton.tsx
├── hooks/
│   ├── useAnimeSearch.ts
│   └── useSelectedAnime.ts
├── api/
│   └── animeApi.ts
├── stores/
│   └── selectedAnimeStore.ts
└── pages/
    ├── HomePage.tsx
    └── ResultPage.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 먼저 도메인 중심으로 코드를 분류합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AnimeCard, animeApi, Anime 타입은 애니메이션 도메인에 해당하므로 entities/anime로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;selectedAnimeStore, SelectButton, useSelectedAnime는 사용자가 애니메이션을 선택하는 기능이므로 features/select-anime로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SearchInput, useAnimeSearch는 검색 기능이므로 features/search-anime로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SelectedGrid는 여러 선택된 애니메이션을 보여주는 UI 블록이므로 widgets/selected-anime-grid로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ShareButton과 공유 이미지 생성 로직은 결과 공유 기능이므로 features/share-result로 이동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩토링 후에는 다음처럼 정리됩니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;src/
├── features/
│   ├── search-anime/
│   ├── select-anime/
│   └── share-result/
├── entities/
│   └── anime/
├── widgets/
│   └── selected-anime-grid/
├── pages/
│   ├── home/
│   └── result/
└── shared/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 파일 이동 후 import 경로를 정리하는 것입니다. 특히 외부에서 내부 파일을 직접 import하지 않도록 index.ts를 만들어 Public API를 구성해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// features/search-anime/index.ts
export {SearchAnimeInput }from'./ui/SearchAnimeInput';
export {useAnimeSearch }from'./model/useAnimeSearch';
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// widgets/selected-anime-grid/index.ts
export {SelectedAnimeGrid }from'./ui/SelectedAnimeGrid';
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// pages/anime-select/ui/AnimeSelectPage.tsx
import {AnimeSearchPanel }from'@/widgets/anime-search-panel';
import {SelectedAnimeGrid }from'@/widgets/selected-anime-grid';

exportfunctionAnimeSelectPage() {
return (
&amp;lt;main&amp;gt;
&amp;lt;AnimeSearchPanel/&amp;gt;
&amp;lt;SelectedAnimeGrid/&amp;gt;
&amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js App Router와 함께 사용할 때&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router를 사용하면 이미 src/app 폴더가 라우팅 역할을 합니다. 이때 FSD의 app 레이어와 Next.js의 app 라우터가 이름이 겹칠 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 보통 두 가지 방식 중 하나를 선택합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 방식은 Next.js의 app 폴더를 라우팅 전용으로 두고, 실제 화면 구현은 FSD 구조 안으로 분리하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;src/
├── app/
│   ├── page.tsx
│   ├── result/
│   │   └── page.tsx
│   └── layout.tsx
├── pages/
│   ├── home/
│   └── result/
├── widgets/
├── features/
├── entities/
└── shared/
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/app/page.tsx
import {HomePage }from'@/pages/home';

exportdefaultfunctionPage() {
return&amp;lt;HomePage/&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/app/result/page.tsx
import {ResultPage }from'@/pages/result';

exportdefaultfunctionPage() {
return&amp;lt;ResultPage/&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 장점은 Next.js 라우팅 규칙과 FSD 화면 구조를 분리할 수 있다는 점입니다. src/app은 라우팅 진입점으로만 사용하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 UI와 비즈니스 로직은 pages, widgets, features, entities로 분리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 방식은 FSD의 pages 레이어를 사용하지 않고, Next.js의 app 폴더 안에서 페이지를 관리하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;src/
├── app/
│   ├── page.tsx
│   ├── result/
│   │   └── page.tsx
│   └── layout.tsx
├── widgets/
├── features/
├── entities/
└── shared/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 구조가 더 단순합니다. 다만 페이지 단위 UI가 커질 경우 app 폴더가 복잡해질 수 있으므로, app/page.tsx는 얇게 유지하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 프로젝트나 중간 규모 프로젝트에서는 첫 번째 방식이 더 관리하기 쉽습니다. 특히 포트폴리오나 팀 프로젝트에서는 app 라우팅 파일이 얇고, 실제 구현이 FSD 구조에 들어가 있으면 아키텍처 설명도 훨씬 명확해집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Shared 폴더를 조심해서 사용해야 하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD를 적용할 때 가장 흔한 실수는 shared를 새로운 utils, components 쓰레기통처럼 사용하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음처럼 모든 것을 shared에 넣으면 FSD를 적용한 의미가 줄어듭니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;shared/
├── components/
│   ├── AnimeCard.tsx
│   ├── UserProfile.tsx
│   ├── CommentItem.tsx
│   └── PostList.tsx
├── hooks/
├── api/
└── utils/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;shared에는 정말로 도메인과 무관한 코드만 들어가야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Button, Input, Modal, Dropdown, cn, formatDate, httpClient는 shared에 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 AnimeCard, UserProfile, PostList, CommentItem은 도메인 의미가 있으므로 shared에 두기보다는 각각 entities/anime, entities/user, entities/post, entities/comment로 보내는 것이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;shared/ui/Button
shared/ui/Input
shared/lib/cn
shared/lib/formatDate
shared/api/httpClient
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sqf&quot;&gt;&lt;code&gt;entities/anime/ui/AnimeCard
entities/user/ui/UserProfile
entities/comment/ui/CommentItem
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기준을 지키면 공통 코드와 도메인 코드가 섞이지 않습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD를 적용할 때의 판단 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트를 어디에 둬야 할지 헷갈릴 때는 다음 기준으로 판단하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 도메인 자체를 표현한다면 entities에 둡니다. 예를 들어 UserAvatar, AnimeCard, PostItem, CommentItem은 엔티티에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 행동을 처리한다면 features에 둡니다. 예를 들어 LoginForm, LikeButton, FollowButton, SearchAnimeInput, ShareResultButton은 기능에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 엔티티와 기능을 조합한 큰 UI 블록이라면 widgets에 둡니다. 예를 들어 Header, Sidebar, AnimeSearchPanel, SelectedAnimeGrid, CommentSection은 위젯에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우트 단위 화면이라면 pages에 둡니다. 예를 들어 HomePage, AnimeSelectPage, ResultPage, MyPage는 페이지에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스와 무관하게 어디서든 재사용된다면 shared에 둡니다. 예를 들어 Button, Input, Modal, useDebounce, cn, formatDate, httpClient는 shared에 둘 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD의 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD의 가장 큰 장점은 &lt;b&gt;코드 위치를 예측하기 쉬워진다&lt;/b&gt;는 점입니다. 기능이 많아져도 &amp;ldquo;검색 기능은 features/search-anime에 있겠구나&amp;rdquo;, &amp;ldquo;애니메이션 타입과 카드는 entities/anime에 있겠구나&amp;rdquo;, &amp;ldquo;선택된 애니메이션 목록 UI는 widgets/selected-anime-grid에 있겠구나&amp;rdquo;라고 예상할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 장점은 &lt;b&gt;기능 단위 리팩토링이 쉬워진다&lt;/b&gt;는 점입니다. 예를 들어 검색 기능을 React Query 기반에서 Server Action 기반으로 바꾸고 싶다면 features/search-anime 중심으로 수정하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째 장점은 &lt;b&gt;의존성 방향이 명확해진다&lt;/b&gt;는 점입니다. features가 widgets를 가져다 쓰거나, entities가 features를 가져다 쓰는 식의 역방향 의존성을 막으면 순환 참조와 스파게티 구조를 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 번째 장점은 &lt;b&gt;팀원 간 작업 충돌이 줄어든다&lt;/b&gt;는 점입니다. 기능별로 폴더가 분리되어 있으므로 검색 기능을 담당하는 사람, 결과 공유 기능을 담당하는 사람, 마이페이지를 담당하는 사람이 서로 다른 영역에서 작업할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯 번째 장점은 &lt;b&gt;포트폴리오에서 아키텍처 설명이 쉬워진다&lt;/b&gt;는 점입니다. 단순히 &amp;ldquo;폴더를 나눴다&amp;rdquo;가 아니라, &amp;ldquo;FSD 기반으로 레이어를 분리하고, Public API를 통해 모듈 간 의존성을 제한했으며, 도메인 단위로 응집도를 높였다&amp;rdquo;고 설명할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FSD의 단점과 주의할 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD는 무조건 좋은 구조는 아닙니다. 작은 프로젝트에서 처음부터 너무 엄격하게 적용하면 오히려 개발 속도가 느려질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 페이지가 2~3개뿐이고 기능도 단순한 프로젝트에서 features, entities, widgets, shared를 모두 세세하게 나누면 파일을 만들 때마다 고민이 많아집니다. 이 경우에는 처음에는 단순한 구조로 시작하고, 기능이 복잡해지는 시점에 FSD로 점진적으로 옮기는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 FSD를 적용할 때 모든 컴포넌트를 억지로 features나 entities에 넣으려고 하면 안 됩니다. 중요한 것은 폴더 이름이 아니라 &lt;b&gt;책임 분리&lt;/b&gt;입니다. 같은 기능을 수정할 때 관련 코드가 가까이 있고, 외부에서는 정해진 API를 통해서만 접근할 수 있으며, 의존성 방향이 깨지지 않는 것이 핵심입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD와 Layered Architecture는 프론트엔드 프로젝트가 커질수록 코드가 뒤엉키는 문제를 해결하기 위한 구조입니다. 핵심은 components, hooks, api처럼 기술 종류별로만 나누는 것이 아니라, user, anime, search, select, share처럼 &lt;b&gt;도메인과 기능 중심으로 코드를 배치하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 프로젝트에서는 app, pages, widgets, features, entities, shared 계층을 두고, 상위 계층이 하위 계층을 조합하는 방식으로 설계하는 것이 좋습니다. 또한 각 slice는 index.ts를 통해 Public API를 제공하고, 외부에서는 내부 파일 구조에 직접 접근하지 않도록 관리해야 합니다.&lt;/p&gt;</description>
      <category>React</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/19</guid>
      <comments>https://jskim6335.tistory.com/19#entry19comment</comments>
      <pubDate>Sun, 17 May 2026 11:09:50 +0900</pubDate>
    </item>
    <item>
      <title>Next.js에서의 인증 전략과 쿠키 vs 토큰</title>
      <link>https://jskim6335.tistory.com/18</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js에서 인증이 중요한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 인증이 조금 까다롭게 느껴지는 이유는 단순히 &amp;ldquo;로그인 여부를 확인하는 코드&amp;rdquo;만 작성하면 끝나는 구조가 아니기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 Server Components, Client Components, Route Handlers, Server Actions, Proxy 등 여러 실행 지점을 가지고 있고, 각각의 코드가 실행되는 위치가 다릅니다. 어떤 코드는 서버에서만 실행되고, 어떤 코드는 브라우저에서 실행되며, 어떤 코드는 사용자의 요청이 페이지에 도달하기 전에 먼저 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Next.js에서 인증을 설계할 때는 &amp;ldquo;로그인 상태를 어디에 저장할 것인가&amp;rdquo;, &amp;ldquo;서버 컴포넌트에서 세션을 어떻게 읽을 것인가&amp;rdquo;, &amp;ldquo;클라이언트 컴포넌트에서는 사용자 정보를 어떻게 사용할 것인가&amp;rdquo;, &amp;ldquo;보호된 페이지 접근은 어디서 막을 것인가&amp;rdquo;를 함께 고민해야 합니다. Next.js 공식 문서에서도 인증을 단순 로그인 구현이 아니라 인증, 세션 관리, 인가라는 흐름으로 나누어 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 React SPA에서는 로그인 후 accessToken을 localStorage나 메모리에 저장하고, API 요청 시 Authorization 헤더에 붙이는 방식이 자주 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Next.js에서는 서버에서 먼저 렌더링되는 영역이 많기 때문에 브라우저 저장소에만 의존하면 서버 컴포넌트에서 인증 정보를 바로 알기 어렵습니다. 예를 들어 localStorage는 브라우저에서만 접근할 수 있으므로 Server Component, Route Handler, Server Action에서는 직접 사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 Next.js에서는 쿠키 기반 세션 관리가 자주 사용됩니다. 쿠키는 브라우저가 요청을 보낼 때 자동으로 함께 전송되기 때문에 서버에서도 인증 상태를 확인하기 쉽습니다. 특히 httpOnly 쿠키를 사용하면 클라이언트 JavaScript에서 쿠키 값을 직접 읽을 수 없어서 XSS 공격으로 토큰이 탈취될 가능성을 줄일 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;쿠키 기반 인증과 JWT 토큰 기반 인증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키 기반 인증은 사용자의 인증 상태를 쿠키에 저장하고, 이후 요청마다 브라우저가 쿠키를 자동으로 서버에 전달하는 방식입니다. 보통 서버는 쿠키에 담긴 세션 ID나 세션 토큰을 확인한 뒤, 해당 사용자가 로그인한 사용자인지 판단합니다. 이때 쿠키에 httpOnly, Secure, SameSite 같은 옵션을 설정하는 것이 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;httpOnly&lt;/b&gt;는 브라우저의 JavaScript에서 쿠키에 접근하지 못하게 하는 옵션입니다. 이 옵션을 사용하면 document.cookie로 쿠키 값을 읽을 수 없기 때문에, XSS 취약점이 발생하더라도 토큰 자체가 직접 탈취될 가능성을 낮출 수 있습니다. Secure는 HTTPS 환경에서만 쿠키가 전송되도록 제한하는 옵션이고, SameSite는 다른 사이트에서 발생한 요청에 쿠키를 함께 보낼지 제어하는 옵션입니다. SameSite=Lax 또는 Strict를 사용하면 CSRF 공격 가능성을 줄이는 데 도움이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JWT 기반 인증&lt;/b&gt;은 로그인 성공 시 서버가 JWT를 발급하고, 클라이언트가 이 토큰을 보관한 뒤 API 요청마다 Authorization 헤더에 넣어 보내는 방식입니다. JWT는 자체적으로 사용자 식별 정보와 만료 시간 등을 포함할 수 있기 때문에 서버가 매 요청마다 세션 저장소를 조회하지 않아도 되는 장점이 있습니다. 다만 JWT를 어디에 저장하느냐가 중요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT를 localStorage에 저장하면 구현은 간단하지만 XSS에 취약해질 수 있습니다. 공격자가 클라이언트 JavaScript 실행 권한을 얻으면 localStorage에 저장된 토큰을 읽어갈 수 있기 때문입니다. 반대로 JWT를 httpOnly 쿠키에 저장하면 JavaScript에서 직접 읽을 수 없기 때문에 XSS로 인한 토큰 탈취 위험은 줄어듭니다. 다만 쿠키는 요청에 자동 포함되기 때문에 CSRF 방어를 함께 고려해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, JWT 자체가 위험하다기보다는 JWT를 저장하고 전송하는 방식에 따라 보안 특성이 달라집니다. localStorage 기반 JWT는 클라이언트 중심 SPA에서 구현하기 쉽지만 XSS에 더 민감하고, httpOnly 쿠키 기반 JWT 또는 세션 토큰 방식은 서버 중심 렌더링과 잘 맞지만 CSRF 방어와 쿠키 설정을 신경 써야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js에서 쿠키 기반 인증이 자주 권장되는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 서버에서 인증 정보를 읽어야 하는 상황이 많습니다. Server Component에서 사용자 정보를 기반으로 다른 UI를 보여줘야 할 수 있고, Server Action에서 사용자의 권한을 확인해야 할 수도 있으며, Route Handler에서 요청을 처리하기 전에 세션을 검증해야 할 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 쿠키 기반 인증은 Next.js의 구조와 잘 맞습니다. 브라우저가 요청을 보낼 때 쿠키를 자동으로 포함하므로 서버는 요청 객체에서 쿠키를 읽어 현재 사용자를 확인할 수 있습니다. 반면 localStorage에 저장된 토큰은 서버에서 직접 읽을 수 없기 때문에, 서버 렌더링 단계에서는 인증 상태를 알기 어렵습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Next.js App Router에서는 서버 컴포넌트를 통해 데이터 페칭과 렌더링을 서버에서 처리하는 경우가 많습니다. 이 구조에서는 인증 정보도 서버에서 안전하게 확인하는 편이 자연스럽습니다. 예를 들어 /dashboard 페이지에 접근했을 때 서버에서 세션을 확인하고, 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트하는 흐름을 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 쿠키 기반 인증이 항상 정답이라는 의미는 아닙니다. 서비스 구조, 백엔드 API 구조, 모바일 앱 연동 여부, 서브도메인 구성, 외부 API 호출 방식에 따라 JWT 헤더 기반 인증이 더 편한 경우도 있습니다. 중요한 것은 Next.js에서는 서버와 클라이언트가 함께 동작하므로, 클라이언트 저장소에만 인증 상태를 두는 방식은 제한이 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구분 쿠키 기반 인증 JWT 토큰 기반 인증&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;저장 위치&lt;/td&gt;
&lt;td&gt;주로 httpOnly 쿠키&lt;/td&gt;
&lt;td&gt;localStorage, sessionStorage, memory, cookie 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서버 컴포넌트 접근&lt;/td&gt;
&lt;td&gt;요청 쿠키를 통해 접근하기 쉬움&lt;/td&gt;
&lt;td&gt;localStorage 저장 시 서버에서 직접 접근 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS 대응&lt;/td&gt;
&lt;td&gt;httpOnly 설정 시 토큰 직접 탈취 위험 감소&lt;/td&gt;
&lt;td&gt;localStorage 저장 시 XSS에 취약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSRF 대응&lt;/td&gt;
&lt;td&gt;쿠키 자동 전송 때문에 SameSite, CSRF 토큰 고려 필요&lt;/td&gt;
&lt;td&gt;Authorization 헤더 방식이면 CSRF 위험이 상대적으로 낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구현 난이도&lt;/td&gt;
&lt;td&gt;쿠키 옵션과 서버 세션 처리 필요&lt;/td&gt;
&lt;td&gt;API 요청 헤더 처리 중심으로 구현 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js와의 궁합&lt;/td&gt;
&lt;td&gt;Server Component, Route Handler, Server Action과 잘 맞음&lt;/td&gt;
&lt;td&gt;클라이언트 중심 구조에서는 편하지만 서버 렌더링과 연결 시 추가 설계 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 경우&lt;/td&gt;
&lt;td&gt;SSR, 서버 중심 인증, 보안 중심 웹 서비스&lt;/td&gt;
&lt;td&gt;SPA, 외부 API 연동, 모바일 앱과 토큰 공유가 필요한 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Better Auth와 Auth.js v5&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Better Auth와 Auth.js는 둘 다 &lt;b&gt;웹 서비스에서 로그인, 회원가입, 세션 관리, 소셜 로그인, 권한 처리 등을 쉽게 구현하기 위한 인증 라이브러리&lt;/b&gt;입니다. 다만 지향점과 사용감이 조금 다릅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Better Auth&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Better Auth는 TypeScript 기반의 인증&amp;middot;인가 프레임워크입니다. 공식 문서에서는 특정 프레임워크에 묶이지 않는 &amp;ldquo;framework-agnostic&amp;rdquo; 인증 프레임워크라고 설명합니다. Next.js뿐 아니라 여러 JavaScript/TypeScript 환경에서 사용할 수 있고, 이메일/비밀번호 로그인, 세션 관리, 2FA, 패스키, 멀티 세션, 멀티 테넌시 같은 기능을 플러그인 중심으로 확장할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하면 Better Auth는 &amp;ldquo;인증 기능을 내 애플리케이션 안에 직접 구성하는 도구&amp;rdquo;에 가깝습니다. Clerk, Auth0처럼 외부 인증 서비스를 붙이는 느낌보다는, 내 프로젝트 안에서 인증 서버 로직과 클라이언트 사용 방식을 함께 구성하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Better Auth의 강점은 &lt;b&gt;기능 확장성&lt;/b&gt;입니다. 예를 들어 일반 로그인만 필요한 서비스라면 이메일/비밀번호 로그인과 세션 관리만 사용할 수 있고, 보안 요구사항이 커지면 2FA, 패스키, 조직 관리 같은 기능을 플러그인으로 추가할 수 있습니다. Better Auth의 2FA 플러그인은 OTP, TOTP, 백업 코드, 신뢰할 수 있는 기기 관리 등을 지원하고, 패스키 플러그인은 WebAuthn/FIDO2 기반의 비밀번호 없는 로그인을 지원합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나의 특징은 &lt;b&gt;타입 안정성&lt;/b&gt;입니다. TypeScript 프로젝트에서 인증 관련 API, 세션, 사용자 정보 등을 타입 기반으로 다룰 수 있도록 설계되어 있습니다. 그래서 인증 로직이 복잡해질수록 &amp;ldquo;어떤 데이터가 오고 가는지&amp;rdquo;를 코드 레벨에서 확인하기 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 프로젝트에서는 Better Auth를 사용해 서버 쪽 인증 설정을 만들고, 클라이언트에서는 로그인, 로그아웃, 세션 조회 같은 기능을 호출하는 구조로 사용할 수 있습니다. 인증 정보를 쿠키 기반 세션으로 관리하면 Server Component, Route Handler, Server Action에서도 현재 로그인 사용자를 확인하기 좋습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Auth.js&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Auth.js는 예전 이름으로 NextAuth.js라고 많이 알려진 인증 라이브러리입니다. Next.js에서 소셜 로그인이나 세션 관리를 구현할 때 오래 사용되어 온 선택지입니다. 현재 Auth.js 문서에서는 표준 Web API 기반의 런타임 독립 인증 라이브러리이며, Next.js, SvelteKit, Express, Qwik 등 여러 프레임워크와 통합할 수 있다고 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 사용할 때는 여전히 next-auth 패키지를 사용하며, v5부터는 auth.ts를 중심으로 설정하는 구조가 많이 사용됩니다. auth() 함수를 통해 Server Component, Route Handler, Proxy/Middleware 등에서 세션을 확인할 수 있습니다. Auth.js의 Next.js 문서에서는 Route Handler를 통해 OAuth, Email Provider, /api/auth/session 같은 인증 엔드포인트를 노출하는 구조를 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Auth.js의 가장 큰 장점은 소셜 로그인 Provider 생태계입니다. Google, GitHub, Discord, Apple 같은 OAuth Provider를 비교적 쉽게 붙일 수 있습니다. &amp;ldquo;빠르게 소셜 로그인을 붙이고, 세션을 관리하고, 보호된 페이지를 만들고 싶다&amp;rdquo;는 목적이라면 Auth.js가 여전히 좋은 선택지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 레퍼런스가 많습니다. NextAuth.js라는 이름으로 오랫동안 사용되어 왔기 때문에 블로그 글, 예제 코드, 오류 해결 사례가 많이 쌓여 있습니다. 팀원이 NextAuth 경험이 있다면 도입 장벽도 낮은 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Auth.js v5는 기존 NextAuth v4와 구조가 일부 달라졌습니다. 공식 마이그레이션 문서에서도 v5를 next-auth 패키지의 주요 재작성 버전으로 설명합니다. 따라서 기존 v4 자료를 그대로 따라 하면 맞지 않는 부분이 있을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;둘의 차이를 간단히 보자면&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 139px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&amp;nbsp;Better Auth&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Auth.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;성격&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;TypeScript 중심 인증&amp;middot;인가 프레임워크&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;NextAuth 계열 인증 라이브러리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;강점&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;타입 안정성, 플러그인 확장, 2FA/패스키/조직 기능&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;소셜 로그인 Provider, Next.js 레퍼런스, 익숙한 생태계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;사용감&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;인증 시스템을 직접 구성하는 느낌&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;OAuth 로그인과 세션 관리를 빠르게 붙이는 느낌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;적합한 경우&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;인증 요구사항이 복잡하거나 확장 가능성이 큰 서비스&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;소셜 로그인 중심의 일반적인 Next.js 서비스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;주의점&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;상대적으로 학습할 개념이 있음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;v4/v5 차이를 확인해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &lt;b&gt;Auth.js는 Next.js에서 검증된 소셜 로그인/세션 관리 도구&lt;/b&gt;에 가깝고, &lt;b&gt;Better Auth는 타입 안정성과 플러그인 확장성을 중심으로 인증 기능을 더 세밀하게 구성하는 도구&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 프로젝트나 블로그, 일반적인 서비스에서 Google/GitHub 로그인 정도가 필요하다면 Auth.js가 접근하기 쉽습니다. 반대로 회원가입, 이메일 인증, 2FA, 패스키, 조직/권한 관리처럼 인증 요구사항이 점점 커질 가능성이 있다면 Better Auth를 검토해볼 만합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;proxy.ts에서 인증 기반 접근 제어 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 16부터는 기존 middleware.ts라는 이름이 proxy.ts로 변경되었습니다. 기능 자체는 요청이 완료되기 전에 서버에서 코드를 실행하고, 요청을 리다이렉트하거나 rewrite하거나 헤더를 수정할 수 있다는 점에서 기존 Middleware와 같은 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에서도 Next.js 16부터 Middleware가 Proxy로 이름이 변경되었고, 기능은 동일하다고 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;proxy.ts는 인증 기반 접근 제어를 구현할 때 유용합니다. 예를 들어 로그인하지 않은 사용자가 /dashboard, /mypage, /admin 같은 보호된 경로에 접근하면 로그인 페이지로 이동시키고, 이미 로그인한 사용자가 /login에 접근하면 메인 페이지로 이동시키는 흐름을 만들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/proxy.ts
import {NextRequest,NextResponse }from'next/server';

constprotectedRoutes= ['/dashboard','/mypage','/admin'];
constauthRoutes= ['/login','/signup'];

exportfunctionproxy(request:NextRequest) {
const { pathname }=request.nextUrl;

constsessionToken=request.cookies.get('session')?.value;

constisLoggedIn=Boolean(sessionToken);
constisProtectedRoute=protectedRoutes.some((route) =&amp;gt;
pathname.startsWith(route)
  );
constisAuthRoute=authRoutes.some((route) =&amp;gt;
pathname.startsWith(route)
  );

if (!isLoggedIn&amp;amp;&amp;amp;isProtectedRoute) {
constloginUrl=newURL('/login',request.url);
loginUrl.searchParams.set('redirect',pathname);

returnNextResponse.redirect(loginUrl);
  }

if (isLoggedIn&amp;amp;&amp;amp;isAuthRoute) {
returnNextResponse.redirect(newURL('/',request.url));
  }

returnNextResponse.next();
}

exportconstconfig= {
  matcher: [
'/dashboard/:path*',
'/mypage/:path*',
'/admin/:path*',
'/login',
'/signup',
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 요청이 실제 페이지에 도달하기 전에 쿠키에 세션이 있는지 확인합니다. 세션이 없는데 보호된 페이지에 접근하려고 하면 /login으로 보냅니다. 반대로 이미 로그인한 사용자가 로그인 페이지에 접근하면 홈으로 이동시킵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 proxy.ts만으로 모든 보안 처리가 끝난다고 보면 안 됩니다. Proxy는 페이지 접근을 빠르게 제어하는 데 유용하지만, 실제 데이터 접근 권한은 Server Component, Route Handler, Server Action, 백엔드 API에서도 다시 확인해야 합니다. 사용자가 UI 경로 접근을 막았다고 해서 서버 액션이나 API 요청까지 자동으로 보호되는 것은 아니기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 실무에서는 보통 다음과 같이 나누어 처리합니다. proxy.ts에서는 페이지 접근을 1차로 제어하고, Server Component에서는 현재 사용자 정보를 읽어 화면을 분기하며, Route Handler나 Server Action에서는 실제 데이터 변경 전에 세션과 권한을 다시 검증합니다. 관리자 페이지라면 단순히 로그인 여부만 보는 것이 아니라 사용자의 role까지 확인해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 관리자 페이지는 다음처럼 role 기반으로 한 번 더 막을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// 개념 예시
if (pathname.startsWith('/admin')) {
constrole=request.cookies.get('role')?.value;

if (role!=='admin') {
returnNextResponse.redirect(newURL('/forbidden',request.url));
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 실제 서비스에서는 role 값을 그대로 클라이언트가 조작 가능한 쿠키에 저장하는 방식은 피하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;role은 세션 토큰을 통해 서버에서 검증하거나, 서명된 토큰 또는 세션 저장소를 통해 확인하는 방식이 더 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서 인증은 서버와 클라이언트 경계를 함께 고려해야 하기 때문에 일반적인 React SPA보다 설계할 요소가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Server Components, Route Handlers, Server Actions, Proxy처럼 서버에서 실행되는 지점이 많기 때문에, 인증 정보를 브라우저 저장소에만 두는 방식은 한계가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키 기반 인증은 요청과 함께 서버로 자연스럽게 전달되기 때문에 Next.js 구조와 잘 맞습니다. httpOnly, Secure, SameSite 설정을 적절히 사용하면 XSS와 CSRF 위험을 줄이는 데 도움이 됩니다. 반면 JWT 기반 인증은 API 중심 구조나 모바일 앱 연동에서는 편리할 수 있지만, 토큰 저장 위치에 따라 보안 특성이 크게 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리 선택에서는 Better Auth와 Auth.js v5를 함께 비교해볼 수 있습니다. Better Auth는 타입 세이프한 구조와 플러그인 기반 확장성이 강점이고, Auth.js v5는 Next.js 생태계에서 이어져 온 Provider 중심 인증 경험과 레퍼런스가 강점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 proxy.ts는 인증 기반 접근 제어의 첫 관문으로 사용할 수 있습니다. 하지만 실제 보안은 Proxy 하나에만 맡기지 않고, 서버 컴포넌트, 서버 액션, API 계층에서도 세션과 권한을 반복적으로 확인하는 구조로 설계하는 것이 안정적입니다.&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/18</guid>
      <comments>https://jskim6335.tistory.com/18#entry18comment</comments>
      <pubDate>Sun, 10 May 2026 14:51:55 +0900</pubDate>
    </item>
    <item>
      <title>에러 핸들링 &amp;amp; 안정성 - 프론트엔드 에러 처리</title>
      <link>https://jskim6335.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서도 에러처리가 필요하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스를 운영하기 위해 백엔드의 지원은 필수적이지만, 만일 백엔드 서버가 아프거나, 네트워크 상황이 좋지 않아 제대로 통신이 이루어지지 않았을 때, 에러처리가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만일 단순한 API를 요청이 실패하여 에러가 발생했을 경우, 그 에러로 인해 서비스 전체가 죽어버린다면 큰 손실이 발생할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 일이 발생하지 않도록 에러 핸들링과 안정성을 관리하는 방법을 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;발생할 수 있는 에러 상황에는 어떤 것이 있을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러처리를 하기에 앞서 어떤 상황에서 에러가 발생하는지에 대해서 먼저 알아보고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다양한 에러 상황 요약 :&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;- 데이터에서 시작되는 에러&lt;br /&gt;- 네트워크와 API에서 발생하는 에러&lt;br /&gt;- 사용자 행동에서 발생하는 에러&lt;br /&gt;- 비동기 처리에서 발생하는 에러&lt;br /&gt;- 렌더링 과정에서 발생하는 에러 &lt;br /&gt;- 전역 상태와 환경에서 발생하는 에러&lt;br /&gt;- 외부 라이브러리에서 발생하는 에러 &lt;br /&gt;- 브라우저와 환경 차이에서 발생하는 에러 &lt;br /&gt;- 성능 문제에서 시작되는 에러&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;294&quot; data-start=&quot;278&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;데이터가 아직 없을 때 발생하는 에&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;384&quot; data-start=&quot;296&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 가장 흔한 에러는 데이터와 관련되어 있다.&lt;/p&gt;
&lt;p data-end=&quot;384&quot; data-start=&quot;296&quot; data-ke-size=&quot;size16&quot;&gt;특히 API 기반으로 동작하는 서비스에서는 데이터가 항상 &amp;ldquo;정상적일 것&amp;rdquo;이라는 가정 자체가 위험하다.&lt;/p&gt;
&lt;p data-end=&quot;410&quot; data-start=&quot;386&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;410&quot; data-start=&quot;386&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 코드는 매우 흔하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;function UserProfile({ user }) {
  return &amp;lt;div&amp;gt;{user.name}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;547&quot; data-start=&quot;447&quot; data-ke-size=&quot;size16&quot;&gt;하지만 초기 렌더링 시점에 user가 아직 존재하지 않는다면, 이 코드는 즉시 에러를 발생시킨다.&lt;br /&gt;또한 API 응답 구조가 예상과 다르게 변경되는 경우에도 문제가 발생한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;data.items.map(...)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;643&quot; data-start=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;이때 실제 응답이 { products: [] }라면, items가 존재하지 않아 런타임 에러가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;643&quot; data-start=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;이러한 문제는 단순한 실수가 아니라, 프론트엔드가 &amp;ldquo;비동기 데이터&amp;rdquo; 위에서 동작한다는 특성에서 비롯된다.&lt;/p&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;따라서 데이터 접근은 항상 방어적으로 작성해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;안전한 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777833964715&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function UserProfile({ user }) {
  if (!user) return &amp;lt;p&amp;gt;로딩 중...&amp;lt;/p&amp;gt;;
  return &amp;lt;div&amp;gt;{user.name}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;762&quot; data-start=&quot;740&quot; data-ke-size=&quot;size16&quot;&gt;또는&lt;/p&gt;
&lt;pre id=&quot;code_1777833981602&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div&amp;gt;{user?.name ?? '이름 없음'}&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-end=&quot;762&quot; data-start=&quot;740&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;네트워크와 API에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;227&quot; data-start=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드는 항상 네트워크 위에서 동작한다.&lt;/p&gt;
&lt;p data-end=&quot;227&quot; data-start=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;즉, API 요청은 실패할 수 있는 것이 기본 전제다.&lt;/p&gt;
&lt;p data-end=&quot;227&quot; data-start=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;227&quot; data-start=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;요청 자체가 실패하는 경우도 있고, 서버가 정상적으로 응답하지 못하는 경우도 있다.&lt;/p&gt;
&lt;p data-end=&quot;260&quot; data-start=&quot;229&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 단순한 API 호출 코드가 있다고 가정해보자.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;const fetchUser = async () =&amp;gt; {
  const res = await fetch('/api/user');
  return res.json();
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;420&quot; data-start=&quot;369&quot; data-ke-size=&quot;size16&quot;&gt;이 코드는 겉보기에는 문제가 없어 보이지만, 실제로는 다음과 같은 상황에서 실패할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;475&quot; data-start=&quot;422&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;443&quot; data-start=&quot;422&quot;&gt;서버가 500 에러를 반환하는 경우&lt;/li&gt;
&lt;li data-end=&quot;457&quot; data-start=&quot;444&quot;&gt;네트워크가 끊긴 경우&lt;/li&gt;
&lt;li data-end=&quot;475&quot; data-start=&quot;458&quot;&gt;응답이 JSON이 아닌 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;513&quot; data-start=&quot;477&quot; data-ke-size=&quot;size16&quot;&gt;이때 아무런 처리를 하지 않으면 에러는 그대로 UI까지 전파된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetchUser(); // 실패해도 아무 처리 없음
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;612&quot; data-start=&quot;585&quot; data-ke-size=&quot;size16&quot;&gt;안전하게 처리하려면 반드시 실패를 고려해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const load = async () =&amp;gt; {
    try {
      const data = await fetchUser();
      setUser(data);
    } catch {
      setError(true);
    }
  };

  load();
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;834&quot; data-start=&quot;808&quot; data-ke-size=&quot;size16&quot;&gt;또 하나 중요한 문제는 요청이 겹치는 상황이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetch(`/api/user/${id}`).then(res =&amp;gt; res.json()).then(setUser);
}, [id]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1003&quot; data-start=&quot;942&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 빠르게 id를 변경하면 이전 요청이 늦게 도착하여 최신 데이터를 덮어쓰는 문제가 발생할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1042&quot; data-start=&quot;1005&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 요청 취소나 최신 요청만 반영하는 처리가 필요하다.&lt;/p&gt;
&lt;p data-end=&quot;1115&quot; data-start=&quot;1052&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1141&quot; data-start=&quot;1122&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;사용자 행동에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1130&quot; data-start=&quot;1070&quot; data-ke-size=&quot;size16&quot;&gt;사용자는 항상 개발자가 예상한 방식으로만 행동하지 않는다. 오히려 예상 밖의 행동이 훨씬 더 많이 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1159&quot; data-start=&quot;1132&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 버튼을 여러 번 클릭하는 상황을 보자.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function OrderButton() {
  const handleOrder = async () =&amp;gt; {
    await api.post('/order');
  };

  return &amp;lt;button onClick={handleOrder}&amp;gt;주문하기&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1363&quot; data-start=&quot;1326&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 사용자가 버튼을 여러 번 누르면 요청이 중복으로 발생한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1417&quot; data-start=&quot;1365&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1385&quot; data-start=&quot;1365&quot;&gt;서버에 동일 데이터 여러 번 생성&lt;/li&gt;
&lt;li data-end=&quot;1393&quot; data-start=&quot;1386&quot;&gt;상태 꼬임&lt;/li&gt;
&lt;li data-end=&quot;1417&quot; data-start=&quot;1394&quot;&gt;결제 중복 같은 심각한 문제 발생 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1443&quot; data-start=&quot;1419&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 로딩 상태로 제어해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function OrderButton() {
  const [loading, setLoading] = useState(false);

  const handleOrder = async () =&amp;gt; {
    if (loading) return;

    setLoading(true);
    try {
      await api.post('/order');
    } finally {
      setLoading(false);
    }
  };

  return (
    &amp;lt;button onClick={handleOrder} disabled={loading}&amp;gt;
      {loading ? '처리 중...' : '주문하기'}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1883&quot; data-start=&quot;1834&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 사용자 에러는 단순 코드 문제가 아니라 UX 설계 문제에서 발생하는 경우가 많다.&lt;/p&gt;
&lt;p data-end=&quot;1395&quot; data-start=&quot;1304&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 서버 데이터가 중복 생성되거나 상태가 꼬일 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1441&quot; data-start=&quot;1397&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1467&quot; data-start=&quot;1448&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비동기 처리에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1998&quot; data-start=&quot;1911&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript의 비동기 특성 역시 많은 문제를 만든다.&lt;/p&gt;
&lt;p data-end=&quot;1998&quot; data-start=&quot;1911&quot; data-ke-size=&quot;size16&quot;&gt;특히 Promise를 제대로 처리하지 않거나, 에러를 catch하지 않는 경우 문제가 발생한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetchData(); // 에러 처리 없음
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2112&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 내부에서 에러가 발생해도 UI는 아무 반응이 없고, 콘솔에만 에러가 찍힌다.&lt;/p&gt;
&lt;p data-end=&quot;2158&quot; data-start=&quot;2114&quot; data-ke-size=&quot;size16&quot;&gt;또한 컴포넌트가 unmount된 이후 상태를 업데이트하는 문제도 자주 발생한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetchData().then(data =&amp;gt; setState(data));
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2277&quot; data-start=&quot;2242&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 페이지를 빠르게 이동하면 다음과 같은 상황이 발생한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2333&quot; data-start=&quot;2279&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2293&quot; data-start=&quot;2279&quot;&gt;컴포넌트는 이미 사라짐&lt;/li&gt;
&lt;li data-end=&quot;2311&quot; data-start=&quot;2294&quot;&gt;하지만 비동기 요청은 완료됨&lt;/li&gt;
&lt;li data-end=&quot;2333&quot; data-start=&quot;2312&quot;&gt;setState 실행 &amp;rarr; 경고 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2360&quot; data-start=&quot;2335&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 마운트 상태를 체크해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  let isMounted = true;

  fetchData().then(data =&amp;gt; {
    if (isMounted) {
      setState(data);
    }
  });

  return () =&amp;gt; {
    isMounted = false;
  };
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2597&quot; data-start=&quot;2555&quot; data-ke-size=&quot;size16&quot;&gt;이러한 문제는 특정 타이밍에서만 발생하기 때문에 &amp;ldquo;숨은 에러&amp;rdquo;가 되기 쉽다.&lt;/p&gt;
&lt;p data-end=&quot;1728&quot; data-start=&quot;1667&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1754&quot; data-start=&quot;1735&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;렌더링 과정에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;2708&quot; data-start=&quot;2625&quot; data-ke-size=&quot;size16&quot;&gt;가장 치명적인 에러는 렌더링 중 발생하는 에러다.&lt;/p&gt;
&lt;p data-end=&quot;2708&quot; data-start=&quot;2625&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 React는 해당 컴포넌트 트리를 더 이상 렌더링하지 못하고, 전체 UI가 깨질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2708&quot; data-start=&quot;2625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2726&quot; data-start=&quot;2710&quot; data-ke-size=&quot;size16&quot;&gt;대표적인 예시는 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;{data.map(item =&amp;gt; (
  &amp;lt;div key={item.id}&amp;gt;{item.name}&amp;lt;/div&amp;gt;
))}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2839&quot; data-start=&quot;2803&quot; data-ke-size=&quot;size16&quot;&gt;여기서 data가 undefined라면 즉시 에러가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;2857&quot; data-start=&quot;2841&quot; data-ke-size=&quot;size16&quot;&gt;또는 더 위험한 경우도 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function Profile({ user }) {
  return &amp;lt;div&amp;gt;{user.name.toUpperCase()}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2986&quot; data-start=&quot;2949&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2962&quot; data-start=&quot;2949&quot;&gt;user = null&lt;/li&gt;
&lt;li data-end=&quot;2986&quot; data-start=&quot;2963&quot;&gt;user.name = undefined&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3004&quot; data-start=&quot;2988&quot; data-ke-size=&quot;size16&quot;&gt;렌더링 중 즉시 crash가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;3033&quot; data-start=&quot;3006&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 반드시 방어적으로 작성해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function Profile({ user }) {
  if (!user?.name) return &amp;lt;p&amp;gt;데이터 없음&amp;lt;/p&amp;gt;;

  return &amp;lt;div&amp;gt;{user.name.toUpperCase()}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3226&quot; data-start=&quot;3167&quot; data-ke-size=&quot;size16&quot;&gt;렌더링 에러는 Error Boundary가 없다면 전체 앱을 무너뜨릴 수 있기 때문에 가장 주의해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2053&quot; data-start=&quot;1998&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2082&quot; data-start=&quot;2060&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;전역 상태와 환경에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;3292&quot; data-start=&quot;3257&quot; data-ke-size=&quot;size16&quot;&gt;앱 전체를 감싸는 전역 영역에서 발생하는 에러는 특히 위험하다.&lt;/p&gt;
&lt;p data-end=&quot;3321&quot; data-start=&quot;3294&quot; data-ke-size=&quot;size16&quot;&gt;대표적인 예시는 localStorage 파싱이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;const user = JSON.parse(localStorage.getItem('user'));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3424&quot; data-start=&quot;3389&quot; data-ke-size=&quot;size16&quot;&gt;이 값이 깨진 JSON이라면 앱이 시작하자마자 에러가 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;3451&quot; data-start=&quot;3426&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 try-catch가 필요하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;function getUser() {
  try {
    const value = localStorage.getItem('user');
    return value ? JSON.parse(value) : null;
  } catch {
    return null;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3666&quot; data-start=&quot;3621&quot; data-ke-size=&quot;size16&quot;&gt;또한 Next.js에서는 hydration mismatch 문제도 자주 발생한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// 서버
&amp;lt;p&amp;gt;{new Date().toLocaleTimeString()}&amp;lt;/p&amp;gt;

// 클라이언트
&amp;lt;p&amp;gt;다른 시간&amp;lt;/p&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3784&quot; data-start=&quot;3750&quot; data-ke-size=&quot;size16&quot;&gt;서버와 클라이언트 결과 불일치 &amp;rarr; 경고 및 UI 문제 발생&lt;/p&gt;
&lt;p data-end=&quot;3820&quot; data-start=&quot;3786&quot; data-ke-size=&quot;size16&quot;&gt;이러한 에러는 특정 기능이 아니라 서비스 전체에 영향을 준다.&lt;/p&gt;
&lt;p data-end=&quot;2424&quot; data-start=&quot;2378&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2452&quot; data-start=&quot;2431&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;외부 라이브러리에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;3900&quot; data-start=&quot;3850&quot; data-ke-size=&quot;size16&quot;&gt;차트, 지도, 에디터 같은 외부 라이브러리는 내부 동작을 알기 어렵기 때문에 더 위험하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;&amp;lt;Chart data={data} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4010&quot; data-start=&quot;3936&quot; data-ke-size=&quot;size16&quot;&gt;data 형식이 조금만 달라도 내부에서 에러가 발생할 수 있고, 이 에러는 렌더링 단계에서 발생하기 때문에 전체 UI에 영향을 준다.&lt;/p&gt;
&lt;p data-end=&quot;4048&quot; data-start=&quot;4012&quot; data-ke-size=&quot;size16&quot;&gt;이를 방지하려면 반드시 Error Boundary로 감싸야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;ErrorBoundary fallback={&amp;lt;p&amp;gt;차트를 불러올 수 없습니다.&amp;lt;/p&amp;gt;}&amp;gt;
  &amp;lt;Chart data={data} /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2646&quot; data-start=&quot;2602&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2677&quot; data-start=&quot;2653&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;브라우저와 환경 차이에서 발생하는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;4233&quot; data-start=&quot;4184&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드는 다양한 환경에서 실행되기 때문에 동일한 코드라도 다른 결과가 나올 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;window.localStorage&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4308&quot; data-start=&quot;4266&quot; data-ke-size=&quot;size16&quot;&gt;이 코드는 브라우저에서는 정상 동작하지만, 서버 환경에서는 에러가 발생한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;if (typeof window !== 'undefined') {
  localStorage.getItem('user');
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4422&quot; data-start=&quot;4392&quot; data-ke-size=&quot;size16&quot;&gt;또한 특정 API는 일부 브라우저에서 지원되지 않는다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;navigator.clipboard.writeText('text');&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4494&quot; data-start=&quot;4474&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 일부 환경에서는 undefined&lt;/p&gt;
&lt;p data-end=&quot;4534&quot; data-start=&quot;4496&quot; data-ke-size=&quot;size16&quot;&gt;이러한 문제는 개발 환경에서는 잘 드러나지 않기 때문에 더 위험하다.&lt;/p&gt;
&lt;p data-end=&quot;2831&quot; data-start=&quot;2779&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2856&quot; data-start=&quot;2838&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 문제에서 시작되는 에러&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;4632&quot; data-start=&quot;4561&quot; data-ke-size=&quot;size16&quot;&gt;에러는 반드시 Exception 형태로만 나타나지 않는다. 성능 문제 역시 사용자 입장에서는 &amp;ldquo;서비스가 고장난 것처럼&amp;rdquo; 보인다.&lt;/p&gt;
&lt;p data-end=&quot;4663&quot; data-start=&quot;4634&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 대량 데이터를 한 번에 렌더링하는 경우다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;{
  items.map(item =&amp;gt; &amp;lt;Item key={item.id} {...item} /&amp;gt;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4763&quot; data-start=&quot;4736&quot; data-ke-size=&quot;size16&quot;&gt;items가 1000개라면 화면이 멈출 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;4784&quot; data-start=&quot;4765&quot; data-ke-size=&quot;size16&quot;&gt;또한 메모리 누수도 문제를 만든다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const id = setInterval(() =&amp;gt; {
    console.log('running');
  }, 1000);

  // cleanup 없음
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4932&quot; data-start=&quot;4914&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 컴포넌트가 사라져도 계속 실행&lt;/p&gt;
&lt;p data-end=&quot;4957&quot; data-start=&quot;4934&quot; data-ke-size=&quot;size16&quot;&gt;이를 해결하려면 cleanup이 필요하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const id = setInterval(() =&amp;gt; {
    console.log('running');
  }, 1000);

  return () =&amp;gt; clearInterval(id);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3053&quot; data-start=&quot;3012&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3053&quot; data-start=&quot;3012&quot; data-ke-size=&quot;size16&quot;&gt;이러한 문제는 코드가 틀리지 않았더라도 충분히 &amp;ldquo;에러 상황&amp;rdquo;으로 이어진다.&lt;/p&gt;
&lt;p data-end=&quot;3053&quot; data-start=&quot;3012&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3053&quot; data-start=&quot;3012&quot; data-ke-size=&quot;size16&quot;&gt;생각보다 많은 에러 상황이 존재한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Error Boundary - 에러를 막는 것이 아니라 &amp;lsquo;격리&amp;rsquo;하는 것&lt;/h2&gt;
&lt;p data-end=&quot;147&quot; data-start=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;단순한 코드 한 줄에서 에러가 발생했을 뿐인데, 화면 전체가 하얗게 변하면서 아무것도 보이지 않는 상황이다.&lt;/p&gt;
&lt;p data-end=&quot;283&quot; data-start=&quot;149&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;283&quot; data-start=&quot;149&quot; data-ke-size=&quot;size16&quot;&gt;React에서는 렌더링 중 에러가 발생하면 해당 컴포넌트 트리를 더 이상 렌더링할 수 없기 때문에, 별도의 처리를 하지 않으면 앱 전체가 무너질 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;283&quot; data-start=&quot;149&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;283&quot; data-start=&quot;149&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 등장한 개념이 바로 &lt;b&gt;Error Boundary&lt;/b&gt;다.&lt;/p&gt;
&lt;h4 data-end=&quot;313&quot; data-start=&quot;290&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Error Boundary란 무엇인가&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;394&quot; data-start=&quot;315&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary는 &lt;b&gt;하위 컴포넌트에서 발생한 렌더링 에러를 잡아서, 대체 UI(fallback UI)를 보여주는 컴포넌트&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;438&quot; data-start=&quot;396&quot; data-ke-size=&quot;size16&quot;&gt;쉽게 말하면, 에러가 발생했을 때 앱이 죽지 않도록 &amp;ldquo;보호막&amp;rdquo; 역할을 한다.&lt;/p&gt;
&lt;p data-end=&quot;467&quot; data-start=&quot;440&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 아래와 같은 코드가 있다고 가정해보자.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function Profile({ user }) {
  return &amp;lt;div&amp;gt;{user.name.toUpperCase()}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;661&quot; data-start=&quot;559&quot; data-ke-size=&quot;size16&quot;&gt;만약 user가 null이라면 user.name에서 에러가 발생한다.&lt;br /&gt;이때 Error Boundary가 없다면, 이 에러는 상위로 전파되면서 화면 전체가 깨질 수 있다.&lt;/p&gt;
&lt;h4 data-end=&quot;694&quot; data-start=&quot;668&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Error Boundary가 해결하는 문제&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;723&quot; data-start=&quot;696&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary의 핵심 역할은 하나다.&lt;/p&gt;
&lt;p data-end=&quot;760&quot; data-start=&quot;725&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;에러를 막는 것이 아니라, 에러의 영향을 제한하는 것&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;760&quot; data-start=&quot;725&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;803&quot; data-start=&quot;762&quot; data-ke-size=&quot;size16&quot;&gt;즉, 문제가 발생한 컴포넌트만 격리하고 나머지 UI는 정상적으로 유지한다.&lt;/p&gt;
&lt;p data-end=&quot;828&quot; data-start=&quot;805&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 대시보드 화면이 있다고 해보자.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;apache&quot;&gt;&lt;code&gt;&amp;lt;Dashboard&amp;gt;
  &amp;lt;Chart /&amp;gt;
  &amp;lt;UserInfo /&amp;gt;
  &amp;lt;Notifications /&amp;gt;
&amp;lt;/Dashboard&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1008&quot; data-start=&quot;914&quot; data-ke-size=&quot;size16&quot;&gt;여기서 Chart에서 에러가 발생했다고 해서 전체 페이지가 사라지는 것은 좋은 UX가 아니다.&lt;br /&gt;Error Boundary를 사용하면 다음과 같이 개선할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;Dashboard&amp;gt;
  &amp;lt;ErrorBoundary fallback={&amp;lt;p&amp;gt;차트를 불러올 수 없습니다.&amp;lt;/p&amp;gt;}&amp;gt;
    &amp;lt;Chart /&amp;gt;
  &amp;lt;/ErrorBoundary&amp;gt;

  &amp;lt;UserInfo /&amp;gt;
  &amp;lt;Notifications /&amp;gt;
&amp;lt;/Dashboard&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1201&quot; data-start=&quot;1168&quot; data-ke-size=&quot;size16&quot;&gt;이제 Chart가 깨지더라도 나머지 UI는 그대로 유지된다.&lt;/p&gt;
&lt;h4 data-end=&quot;1235&quot; data-start=&quot;1208&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Error Boundary는 언제 동작하는가&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1301&quot; data-start=&quot;1237&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary는 모든 에러를 잡아주는 것이 아니다.&lt;br /&gt;동작하는 범위를 정확히 이해하는 것이 중요하다.&lt;/p&gt;
&lt;p data-end=&quot;1317&quot; data-start=&quot;1303&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1317&quot; data-start=&quot;1303&quot; data-ke-size=&quot;size18&quot;&gt;잡을 수 있는 에러&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1373&quot; data-start=&quot;1318&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1335&quot; data-start=&quot;1318&quot;&gt;렌더링 과정에서 발생한 에러&lt;/li&gt;
&lt;li data-end=&quot;1354&quot; data-start=&quot;1336&quot;&gt;라이프사이클 메서드 내부 에러&lt;/li&gt;
&lt;li data-end=&quot;1373&quot; data-start=&quot;1355&quot;&gt;하위 컴포넌트에서 발생한 에러&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h3 data-end=&quot;221&quot; data-start=&quot;167&quot; data-ke-size=&quot;size23&quot;&gt;라이프사이클이란 무엇인가&lt;/h3&gt;
&lt;p data-end=&quot;221&quot; data-start=&quot;167&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;91&quot; data-start=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;React에서 &lt;b&gt;라이프사이클(Lifecycle)&lt;/b&gt;은 컴포넌트가 생성되고, 업데이트되고, 사라질 때까지의 전체 과정을 의미한다.&lt;/p&gt;
&lt;p data-end=&quot;114&quot; data-start=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;하나의 컴포넌트는 다음 흐름을 가진다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;생성(Mount) &amp;rarr; 업데이트(Update) &amp;rarr; 제거(Unmount)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;221&quot; data-start=&quot;167&quot; data-ke-size=&quot;size16&quot;&gt;이 과정 중 특정 시점마다 실행되는 함수들이 있는데, 이것을 &lt;b&gt;라이프사이클 메서드&lt;/b&gt;라고 한다.&lt;/p&gt;
&lt;p data-end=&quot;250&quot; data-start=&quot;228&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;250&quot; data-start=&quot;228&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;라이프사이클 흐름을 코드로 이해하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;279&quot; data-start=&quot;252&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Mount (처음 화면에 등장할 때)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log('컴포넌트가 처음 렌더링됨');
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;370&quot; data-start=&quot;351&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 컴포넌트가 처음 생성될 때 실행&lt;/p&gt;
&lt;hr data-end=&quot;375&quot; data-start=&quot;372&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;400&quot; data-start=&quot;377&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Update (값이 바뀔 때)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log('count가 변경됨');
}, [count]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;498&quot; data-start=&quot;474&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; state나 props가 변경될 때 실행&lt;/p&gt;
&lt;hr data-end=&quot;503&quot; data-start=&quot;500&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;533&quot; data-start=&quot;505&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Unmount (컴포넌트가 사라질 때)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  return () =&amp;gt; {
    console.log('컴포넌트가 제거됨');
  };
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;640&quot; data-start=&quot;625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 화면에서 사라질 때 실행&lt;/p&gt;
&lt;p data-end=&quot;675&quot; data-start=&quot;647&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;675&quot; data-start=&quot;647&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;라이프사이클 &amp;ldquo;메서드&amp;rdquo;라는 표현은 왜 나올까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;707&quot; data-start=&quot;677&quot; data-ke-size=&quot;size16&quot;&gt;이건 React의 &lt;b&gt;클래스 컴포넌트 시절 용어&lt;/b&gt;다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class MyComponent extends React.Component {
  componentDidMount() {
    console.log('마운트');
  }

  componentDidUpdate() {
    console.log('업데이트');
  }

  componentWillUnmount() {
    console.log('언마운트');
  }

  render() {
    return &amp;lt;div&amp;gt;Hello&amp;lt;/div&amp;gt;;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1004&quot; data-start=&quot;978&quot; data-ke-size=&quot;size16&quot;&gt;이 함수들이 바로 &lt;b&gt;라이프사이클 메서드&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;1051&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;지금은 함수형 컴포넌트 + useEffect로 대체되었지만, 개념은 동일하다.&lt;/p&gt;
&lt;hr data-end=&quot;1056&quot; data-start=&quot;1053&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;1090&quot; data-start=&quot;1058&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그럼 &amp;ldquo;라이프사이클 메서드 내부 에러&amp;rdquo;는 무슨 뜻인가&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1148&quot; data-start=&quot;1092&quot; data-ke-size=&quot;size16&quot;&gt;말 그대로 &lt;b&gt;이 lifecycle 흐름 중에 실행되는 코드에서 에러가 발생하는 경우&lt;/b&gt;를 의미한다.&lt;/p&gt;
&lt;p data-end=&quot;1164&quot; data-start=&quot;1150&quot; data-ke-size=&quot;size16&quot;&gt;예시로 보면 이해가 쉽다.&lt;/p&gt;
&lt;hr data-end=&quot;1169&quot; data-start=&quot;1166&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;1192&quot; data-start=&quot;1171&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 1: mount 시점 에러&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const data = JSON.parse(localStorage.getItem('user')); // 여기서 에러 가능
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1336&quot; data-start=&quot;1302&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 저장된 값이 깨져 있으면 JSON.parse에서 에러 발생&lt;/p&gt;
&lt;p data-end=&quot;1336&quot; data-start=&quot;1302&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1365&quot; data-start=&quot;1343&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 2: update 시점 에러&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  console.log(user.name.toUpperCase());
}, [user]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1469&quot; data-start=&quot;1449&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; user가 null이면 에러 발생&lt;/p&gt;
&lt;p data-end=&quot;1469&quot; data-start=&quot;1449&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1497&quot; data-start=&quot;1476&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 3: 클래스 컴포넌트 기준&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;class Test extends React.Component {
  componentDidMount() {
    throw new Error('에러 발생');
  }

  render() {
    return &amp;lt;div&amp;gt;Hello&amp;lt;/div&amp;gt;;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1685&quot; data-start=&quot;1655&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이 경우 Error Boundary가 잡을 수 있음&lt;/p&gt;
&lt;p data-end=&quot;1685&quot; data-start=&quot;1655&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1718&quot; data-start=&quot;1692&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 Error Boundary랑 연결되는가&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1747&quot; data-start=&quot;1720&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary는 다음 에러를 잡는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1780&quot; data-start=&quot;1749&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1762&quot; data-start=&quot;1749&quot;&gt;render 중 에러&lt;/li&gt;
&lt;li data-end=&quot;1780&quot; data-start=&quot;1763&quot;&gt;lifecycle 내부 에러&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1795&quot; data-start=&quot;1782&quot; data-ke-size=&quot;size16&quot;&gt;즉 이런 경우는 잡힌다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  throw new Error('에러');
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1875&quot; data-start=&quot;1860&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이런 건 못 잡는다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const handleClick = () =&amp;gt; {
  throw new Error('에러');
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1984&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이벤트 핸들러는 lifecycle이 아니라 &amp;ldquo;사용자 액션&amp;rdquo;이기 때문&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1388&quot; data-start=&quot;1375&quot; data-ke-size=&quot;size18&quot;&gt;잡지 못하는 에러&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1460&quot; data-start=&quot;1389&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1413&quot; data-start=&quot;1389&quot;&gt;이벤트 핸들러 에러 (onClick 등)&lt;/li&gt;
&lt;li data-end=&quot;1444&quot; data-start=&quot;1414&quot;&gt;비동기 코드 (setTimeout, Promise)&lt;/li&gt;
&lt;li data-end=&quot;1460&quot; data-start=&quot;1445&quot;&gt;서버 사이드 렌더링 에러&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1498&quot; data-start=&quot;1462&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 코드는 Error Boundary가 잡지 못한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const handleClick = () =&amp;gt; {
  throw new Error('에러 발생');
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1610&quot; data-start=&quot;1571&quot; data-ke-size=&quot;size16&quot;&gt;이 경우에는 try-catch 또는 별도의 에러 처리 로직이 필요하다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;1642&quot; data-start=&quot;1617&quot; data-ke-size=&quot;size23&quot;&gt;Error Boundary 직접 구현하기&lt;/h3&gt;
&lt;p data-end=&quot;1680&quot; data-start=&quot;1644&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary는 클래스 컴포넌트로만 구현할 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback: ReactNode;
}

interface State {
  hasError: boolean;
}

export class ErrorBoundary extends Component&amp;lt;Props, State&amp;gt; {
  state: State = {
    hasError: false,
  };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error('ErrorBoundary caught:', error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2287&quot; data-start=&quot;2246&quot; data-ke-size=&quot;size16&quot;&gt;이 컴포넌트는 하위에서 에러가 발생하면 fallback UI를 렌더링한다.&lt;/p&gt;
&lt;h4 data-end=&quot;2305&quot; data-start=&quot;2294&quot; data-ke-size=&quot;size20&quot;&gt;실제 사용 예시&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function App() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;대시보드&amp;lt;/h1&amp;gt;

      &amp;lt;ErrorBoundary fallback={&amp;lt;p&amp;gt;차트를 불러올 수 없습니다.&amp;lt;/p&amp;gt;}&amp;gt;
        &amp;lt;Chart /&amp;gt;
      &amp;lt;/ErrorBoundary&amp;gt;

      &amp;lt;ErrorBoundary fallback={&amp;lt;p&amp;gt;유저 정보를 불러올 수 없습니다.&amp;lt;/p&amp;gt;}&amp;gt;
        &amp;lt;UserInfo /&amp;gt;
      &amp;lt;/ErrorBoundary&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2657&quot; data-start=&quot;2597&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 기능 단위로 Error Boundary를 나누면, 특정 기능이 깨져도 전체 앱은 안정적으로 유지된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;2687&quot; data-start=&quot;2664&quot; data-ke-size=&quot;size23&quot;&gt;Error Boundary 설계 전략&lt;/h3&gt;
&lt;p data-end=&quot;2721&quot; data-start=&quot;2689&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary는 &amp;ldquo;어디에 두느냐&amp;rdquo;가 중요하다.&lt;/p&gt;
&lt;h4 data-end=&quot;2739&quot; data-start=&quot;2723&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-end=&quot;2739&quot; data-start=&quot;2723&quot; data-ke-size=&quot;size20&quot;&gt;너무 크게 감싸는 경우&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;apache&quot;&gt;&lt;code&gt;&amp;lt;ErrorBoundary&amp;gt;
  &amp;lt;App /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2826&quot; data-start=&quot;2796&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 하나의 에러로 전체 앱이 fallback으로 대체됨&lt;/p&gt;
&lt;h3 data-end=&quot;2844&quot; data-start=&quot;2828&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-end=&quot;2844&quot; data-start=&quot;2828&quot; data-ke-size=&quot;size20&quot;&gt;너무 작게 감싸는 경우&lt;/h4&gt;
&lt;p data-end=&quot;2868&quot; data-start=&quot;2846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 관리 포인트 증가, 코드 복잡도 증가&lt;/p&gt;
&lt;p data-end=&quot;2868&quot; data-start=&quot;2846&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;2884&quot; data-start=&quot;2875&quot; data-ke-size=&quot;size20&quot;&gt;추천 방식&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2938&quot; data-start=&quot;2886&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2901&quot; data-start=&quot;2886&quot;&gt;페이지 단위: 전체 보호&lt;/li&gt;
&lt;li data-end=&quot;2919&quot; data-start=&quot;2902&quot;&gt;기능 단위: 주요 UI 보호&lt;/li&gt;
&lt;li data-end=&quot;2938&quot; data-start=&quot;2920&quot;&gt;외부 라이브러리: 반드시 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;Page&amp;gt;
  &amp;lt;Header /&amp;gt;

  &amp;lt;ErrorBoundary fallback={&amp;lt;ErrorUI /&amp;gt;}&amp;gt;
    &amp;lt;Chart /&amp;gt;
  &amp;lt;/ErrorBoundary&amp;gt;

  &amp;lt;ErrorBoundary fallback={&amp;lt;ErrorUI /&amp;gt;}&amp;gt;
    &amp;lt;Map /&amp;gt;
  &amp;lt;/ErrorBoundary&amp;gt;
&amp;lt;/Page&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;762&quot; data-start=&quot;737&quot; data-ke-size=&quot;size23&quot;&gt;선언적 에러 처리 vs 명령적 에러 처리&lt;/h3&gt;
&lt;p data-end=&quot;168&quot; data-start=&quot;142&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선언적 에러 처리 (Declarative)&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;168&quot; data-start=&quot;142&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;243&quot; data-start=&quot;170&quot; data-ke-size=&quot;size16&quot;&gt;선언적 에러 처리는 &lt;b&gt;상태 기반으로 UI를 분기하는 방식&lt;/b&gt;이다.&lt;br /&gt;즉, &amp;ldquo;지금 상태가 어떤가&amp;rdquo;에 따라 보여줄 UI를 결정한다.&lt;/p&gt;
&lt;p data-end=&quot;272&quot; data-start=&quot;245&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 React Query를 사용한 코드다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function UserProfile() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });

  if (isLoading) {
    return &amp;lt;p&amp;gt;로딩 중...&amp;lt;/p&amp;gt;;
  }

  if (isError) {
    return (
      &amp;lt;div&amp;gt;
        &amp;lt;p&amp;gt;{error.message}&amp;lt;/p&amp;gt;
        &amp;lt;button onClick={refetch}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;
    );
  }

  return &amp;lt;div&amp;gt;{data.name}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;680&quot; data-start=&quot;665&quot; data-ke-size=&quot;size16&quot;&gt;이 코드의 특징은&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;748&quot; data-start=&quot;682&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;724&quot; data-start=&quot;682&quot;&gt;상태(isLoading, isError)에 따라 UI가 자동으로 결정된다&lt;/li&gt;
&lt;li data-end=&quot;737&quot; data-start=&quot;725&quot;&gt;흐름이 눈에 보인다&lt;/li&gt;
&lt;li data-end=&quot;748&quot; data-start=&quot;738&quot;&gt;유지보수가 쉽다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;750&quot; data-ke-size=&quot;size16&quot;&gt;이 방식은 특히 &lt;b&gt;데이터 조회 UI&lt;/b&gt;에서 강력하다.&lt;/p&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;750&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;750&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;명령적 에러 처리 (Imperative)&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;780&quot; data-start=&quot;750&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;856&quot; data-start=&quot;814&quot; data-ke-size=&quot;size16&quot;&gt;명령적 에러 처리는 &lt;b&gt;흐름을 직접 제어하면서 에러를 처리하는 방식&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;880&quot; data-start=&quot;858&quot; data-ke-size=&quot;size16&quot;&gt;대표적으로 try-catch가 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function LoginButton() {
  const handleLogin = async () =&amp;gt; {
    try {
      await api.post('/login', {
        email: 'test@test.com',
        password: '1234',
      });

      alert('로그인 성공');
    } catch (e) {
      alert('로그인 실패');
    }
  };

  return &amp;lt;button onClick={handleLogin}&amp;gt;로그인&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1218&quot; data-start=&quot;1198&quot; data-ke-size=&quot;size16&quot;&gt;이 방식은 다음과 같은 특징이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1265&quot; data-start=&quot;1220&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1236&quot; data-start=&quot;1220&quot;&gt;사용자의 행동에 즉각 반응&lt;/li&gt;
&lt;li data-end=&quot;1248&quot; data-start=&quot;1237&quot;&gt;흐름 제어가 명확&lt;/li&gt;
&lt;li data-end=&quot;1265&quot; data-start=&quot;1249&quot;&gt;사이드 이펙트 처리에 유리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1303&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;버튼 클릭, 폼 제출 같은 사용자 액션&lt;/b&gt;에 적합하다.&lt;/p&gt;
&lt;p data-end=&quot;1303&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1303&quot; data-start=&quot;1267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-end=&quot;1334&quot; data-start=&quot;1310&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;선언적 vs 명령적 - 같이 써야 한다&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;1361&quot; data-start=&quot;1336&quot; data-ke-size=&quot;size16&quot;&gt;실제 서비스에서는 둘 중 하나만 쓰지 않는다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;function Page() {
  const { data, isError } = useQuery(...);

  // 선언적 처리
  if (isError) return &amp;lt;ErrorUI /&amp;gt;;

  // 명령적 처리
  const handleSubmit = async () =&amp;gt; {
    try {
      await api.post('/submit');
    } catch {
      alert('실패');
    }
  };

  return &amp;lt;button onClick={handleSubmit}&amp;gt;제출&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1689&quot; data-start=&quot;1677&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;렌더링 &amp;rarr; 선언적 처리
사용자 액션 &amp;rarr; 명령적 처리&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;1219&quot; data-start=&quot;1194&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-end=&quot;1219&quot; data-start=&quot;1194&quot; data-ke-size=&quot;size20&quot;&gt;API 에러는 &amp;lsquo;분류&amp;rsquo;부터 시작해야 한다&lt;/h4&gt;
&lt;p data-end=&quot;1320&quot; data-start=&quot;1221&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1320&quot; data-start=&quot;1221&quot; data-ke-size=&quot;size16&quot;&gt;에러 처리가 어려운 이유는 대부분 &amp;ldquo;모든 에러를 동일하게 처리하려 하기 때문&amp;rdquo;이다.&lt;/p&gt;
&lt;p data-end=&quot;1320&quot; data-start=&quot;1221&quot; data-ke-size=&quot;size16&quot;&gt;안정적인 설계를 위해서는 먼저 에러를 예측 가능한 에러와 예측 불가능한 에러로 나누어야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1320&quot; data-start=&quot;1221&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1427&quot; data-start=&quot;1322&quot; data-ke-size=&quot;size16&quot;&gt;예측 가능한 에러는 사용자 입력이나 권한 문제 등 비즈니스 로직에서 발생하는 에러다.&lt;/p&gt;
&lt;p data-end=&quot;1427&quot; data-start=&quot;1322&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 인증 실패나 잘못된 요청은 사용자에게 안내 메시지를 보여주고, 필요한 행동을 유도해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1427&quot; data-start=&quot;1322&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1917&quot; data-start=&quot;1911&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시 코드:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;const login = async () =&amp;gt; {
  try {
    await api.post('/login');
  } catch (error) {
    if (error.status === 401) {
      alert('로그인이 필요합니다.');
    }
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2108&quot; data-start=&quot;2090&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 사용자에게 안내해야 하는 에러&lt;/p&gt;
&lt;p data-end=&quot;1427&quot; data-start=&quot;1322&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1540&quot; data-start=&quot;1429&quot; data-ke-size=&quot;size16&quot;&gt;반대로 예측 불가능한 에러는 서버 장애나 네트워크 문제처럼 시스템 레벨에서 발생한다.&lt;/p&gt;
&lt;p data-end=&quot;1540&quot; data-start=&quot;1429&quot; data-ke-size=&quot;size16&quot;&gt;이런 경우에는 Error Boundary를 통해 UI를 격리하고, fallback UI를 보여주는 것이 핵심이다.&lt;/p&gt;
&lt;p data-end=&quot;1540&quot; data-start=&quot;1429&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1612&quot; data-start=&quot;1542&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 에러를 분리하면 &amp;ldquo;어떤 에러는 사용자에게 안내하고, 어떤 에러는 시스템적으로 보호한다&amp;rdquo;는 명확한 전략을 세울 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1656&quot; data-start=&quot;1619&quot; data-ke-size=&quot;size26&quot;&gt;Fallback UI - 단순한 에러 화면이 아니라 UX 전략&lt;/h2&gt;
&lt;p data-end=&quot;1748&quot; data-start=&quot;1658&quot; data-ke-size=&quot;size16&quot;&gt;Fallback UI는 에러 상황에서 사용자에게 무엇을 보여줄지를 결정하는 요소다.&lt;/p&gt;
&lt;p data-end=&quot;1748&quot; data-start=&quot;1658&quot; data-ke-size=&quot;size16&quot;&gt;단순히 &amp;ldquo;에러가 발생했습니다&amp;rdquo;라는 메시지를 보여주는 것만으로는 충분하지 않다.&lt;/p&gt;
&lt;p data-end=&quot;1748&quot; data-start=&quot;1658&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1801&quot; data-start=&quot;1750&quot; data-ke-size=&quot;size16&quot;&gt;좋은 Fallback UI는 현재 상황을 이해할 수 있게 하고, 다음 행동을 유도해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1801&quot; data-start=&quot;1750&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1915&quot; data-start=&quot;1803&quot; data-ke-size=&quot;size16&quot;&gt;전체 페이지에서 문제가 발생한 경우에는 새로고침이나 홈으로 이동하는 선택지를 제공하는 것이 좋다.&lt;/p&gt;
&lt;p data-end=&quot;1915&quot; data-start=&quot;1803&quot; data-ke-size=&quot;size16&quot;&gt;반면 특정 기능에서만 문제가 발생했다면 해당 영역만 대체 UI로 바꾸고 나머지는 그대로 유지해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;1915&quot; data-start=&quot;1803&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2031&quot; data-start=&quot;1917&quot; data-ke-size=&quot;size16&quot;&gt;또한 데이터가 없는 상태는 에러가 아니라 정상 상태이기 때문에, &amp;ldquo;데이터 없음&amp;rdquo;에 대한 별도의 UI를 설계해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2031&quot; data-start=&quot;1917&quot; data-ke-size=&quot;size16&quot;&gt;여기에 재시도 버튼이나 자동 재요청 전략을 함께 고려하면 사용자 경험이 크게 개선된다.&lt;/p&gt;
&lt;p data-end=&quot;2031&quot; data-start=&quot;1917&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2091&quot; data-start=&quot;2033&quot; data-ke-size=&quot;size16&quot;&gt;결국 Fallback UI는 단순한 예외 처리가 아니라, 서비스의 완성도를 결정짓는 중요한 UX 요소다.&lt;/p&gt;
&lt;p data-end=&quot;2091&quot; data-start=&quot;2033&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2604&quot; data-start=&quot;2589&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 전체 페이지 에러&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;function GlobalError() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h2&amp;gt;문제가 발생했습니다&amp;lt;/h2&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; window.location.reload()}&amp;gt;
        다시 시도
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2813&quot; data-start=&quot;2799&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 부분 UI 에러&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;if (isError) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;차트를 불러올 수 없습니다.&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={refetch}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2968&quot; data-start=&quot;2957&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 특정 기능만 교체&lt;/p&gt;
&lt;p data-end=&quot;3000&quot; data-start=&quot;2975&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3000&quot; data-start=&quot;2975&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Empty State (에러 아님)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;if (data.length === 0) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;데이터가 없습니다&amp;lt;/p&amp;gt;
      &amp;lt;button&amp;gt;추가하기&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3136&quot; data-start=&quot;3129&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 정상 상태&lt;/p&gt;
&lt;p data-end=&quot;3136&quot; data-start=&quot;3129&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3155&quot; data-start=&quot;3143&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 재시도 전략&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;button onClick={refetch}&amp;gt;다시 시도&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3224&quot; data-start=&quot;3210&quot; data-ke-size=&quot;size16&quot;&gt;또는 React Query&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  retry: 2,
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2126&quot; data-start=&quot;2098&quot; data-ke-size=&quot;size26&quot;&gt;안정적인 에러 처리 구조는 어떻게 만들어지는가&lt;/h2&gt;
&lt;p data-end=&quot;2189&quot; data-start=&quot;2128&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 안정적인 에러 처리 구조는 한 가지 기술로 해결되지 않는다. 여러 레이어가 함께 작동해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2348&quot; data-start=&quot;2191&quot; data-ke-size=&quot;size16&quot;&gt;API 레벨에서는 에러를 구조화하고, 예측 가능한지 여부를 구분해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2348&quot; data-start=&quot;2191&quot; data-ke-size=&quot;size16&quot;&gt;로직 레벨에서는 try-catch를 통해 사용자 액션에 대한 피드백을 처리한다.&lt;/p&gt;
&lt;p data-end=&quot;2348&quot; data-start=&quot;2191&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2348&quot; data-start=&quot;2191&quot; data-ke-size=&quot;size16&quot;&gt;UI 레벨에서는 상태 기반으로 화면을 분기하고, 마지막으로 Error Boundary를 통해 전체 앱이 깨지는 것을 방지한다.&lt;/p&gt;
&lt;p data-end=&quot;2388&quot; data-start=&quot;2350&quot; data-ke-size=&quot;size16&quot;&gt;이 모든 구조 위에서 Fallback UI가 사용자 경험을 책임진다.&lt;/p&gt;
&lt;p data-end=&quot;2388&quot; data-start=&quot;2350&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3395&quot; data-start=&quot;3383&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. API 레벨&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;api.interceptors.response.use(
  res =&amp;gt; res,
  error =&amp;gt; {
    return Promise.reject({
      type: error.response?.status &amp;lt; 500 ? 'EXPECTED' : 'UNEXPECTED',
      message: error.message,
    });
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3625&quot; data-start=&quot;3614&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 로직 레벨&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;try {
  await api.post('/submit');
} catch {
  alert('실패');
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3717&quot; data-start=&quot;3706&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3717&quot; data-start=&quot;3706&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. UI 레벨&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (isError) return &amp;lt;ErrorUI /&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3781&quot; data-start=&quot;3769&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 렌더링 보호&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot;&gt;&lt;code&gt;&amp;lt;ErrorBoundary fallback={&amp;lt;ErrorPage /&amp;gt;}&amp;gt;
  &amp;lt;App /&amp;gt;
&amp;lt;/ErrorBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2401&quot; data-start=&quot;2395&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-end=&quot;2467&quot; data-start=&quot;2403&quot; data-ke-size=&quot;size16&quot;&gt;에러 핸들링은 단순히 &amp;ldquo;에러를 처리하는 코드&amp;rdquo;가 아니라, 서비스의 안정성과 사용자 경험을 동시에 설계하는 영역이다.&lt;/p&gt;
&lt;p data-end=&quot;2610&quot; data-start=&quot;2469&quot; data-ke-size=&quot;size16&quot;&gt;Error Boundary를 통해 시스템을 보호하고, 선언적&amp;middot;명령적 처리를 상황에 맞게 사용하며, API 에러를 명확하게 분류하고, Fallback UI를 전략적으로 설계하는 것. 이 네 가지가 결합될 때 비로소 &amp;ldquo;깨지지 않는 프론트엔드&amp;rdquo;가 만들어진다.&lt;/p&gt;
&lt;p data-end=&quot;2610&quot; data-start=&quot;2469&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2610&quot; data-start=&quot;2469&quot; data-ke-size=&quot;size16&quot;&gt;감사합니다!&lt;/p&gt;</description>
      <category>React</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/17</guid>
      <comments>https://jskim6335.tistory.com/17#entry17comment</comments>
      <pubDate>Mon, 4 May 2026 11:16:12 +0900</pubDate>
    </item>
    <item>
      <title>Profiler부터 memo까지 제대로 사용해보자!</title>
      <link>https://jskim6335.tistory.com/16</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/exuoz4/dJMcahxscBu/r6MVFpk30VIoQaEyCstfFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/exuoz4/dJMcahxscBu/r6MVFpk30VIoQaEyCstfFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exuoz4/dJMcahxscBu/r6MVFpk30VIoQaEyCstfFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fexuoz4%2FdJMcahxscBu%2Fr6MVFpk30VIoQaEyCstfFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div data-is-intersecting=&quot;true&quot; data-turn-id-container=&quot;request-WEB:feb81d2c-e09a-4dce-a561-241691092b48-9&quot;&gt;
&lt;div data-message-model-slug=&quot;gpt-5-3&quot; data-message-id=&quot;7be853c0-31bb-4dc7-bbb8-b16bac190191&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-section-id=&quot;fp0p7d&quot; data-start=&quot;103&quot; data-end=&quot;153&quot; data-ke-size=&quot;size26&quot;&gt;React 렌더링 최적화 실전 - Profiler부터 memo까지 제대로 사용해보자!&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;155&quot; data-end=&quot;334&quot; data-ke-size=&quot;size16&quot;&gt;React에서 성능 최적화를 이야기할 때 가장 많이 나오는 키워드는 useMemo, useCallback, React.memo입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;155&quot; data-end=&quot;334&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이들을 어떻게 올바르게 적용할 수 있을까요?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;155&quot; data-end=&quot;334&quot; data-ke-size=&quot;size16&quot;&gt;중요한 것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;어디가 느린지 먼저 확인하고, 그 원인을 분석한 뒤 필요한 부분에만 최적화를 적용하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;336&quot; data-end=&quot;416&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;336&quot; data-end=&quot;416&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 React DevTools Profiler와 memo를 사용하여 불필요한 렌더링을 찾아 최적화하는 방법에 대해서 알아보겠습니다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-section-id=&quot;40gbiw&quot; data-start=&quot;707&quot; data-end=&quot;722&quot; data-ke-size=&quot;size26&quot;&gt;React에서의 렌더링의 조건&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1e1f21; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jskim6335.tistory.com/9&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;React useState 완전 이해하기&lt;/a&gt;에서 state에 관련된 렌더링 조건에 대해서만 다뤘습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1e1f21; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;419&quot; data-start=&quot;387&quot; data-ke-size=&quot;size16&quot;&gt;React 컴포넌트는 다음 네 가지 상황에서 렌더링됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;487&quot; data-start=&quot;421&quot;&gt;state가 변경될 때&lt;/li&gt;
&lt;li data-end=&quot;487&quot; data-start=&quot;421&quot;&gt;props가 변경될 때&lt;/li&gt;
&lt;li data-end=&quot;487&quot; data-start=&quot;421&quot;&gt;부모 컴포넌트가 렌더링될 때&lt;/li&gt;
&lt;li data-end=&quot;487&quot; data-start=&quot;421&quot;&gt;context 값이 변경될 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;700&quot; data-start=&quot;591&quot; data-ke-size=&quot;size16&quot;&gt;또한 중요한 점은, 렌더링은 단순히 함수가 다시 실행되는 것이지 곧바로 DOM이 변경되는 것은 아니라는 점입니다.&lt;/p&gt;
&lt;p data-end=&quot;700&quot; data-start=&quot;591&quot; data-ke-size=&quot;size16&quot;&gt;React는 이전 결과와 비교하여 실제 변경이 필요한 부분만 DOM에 반영합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;722&quot; data-start=&quot;707&quot; data-section-id=&quot;40gbiw&quot; data-ke-size=&quot;size26&quot;&gt;렌더링이 문제되는 순간&lt;/h2&gt;
&lt;p data-end=&quot;771&quot; data-start=&quot;724&quot; data-ke-size=&quot;size16&quot;&gt;모든 렌더링이 문제되는 것은 아닙니다. 다음과 같은 경우에만 성능 문제가 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;858&quot; data-start=&quot;773&quot;&gt;렌더링 비용이 큰 컴포넌트가 포함된 경우&lt;/li&gt;
&lt;li data-end=&quot;858&quot; data-start=&quot;773&quot;&gt;리스트, 차트, 복잡한 UI가 함께 렌더링되는 경우&lt;/li&gt;
&lt;li data-end=&quot;858&quot; data-start=&quot;773&quot;&gt;불필요하게 넓은 범위의 state가 존재하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;936&quot; data-start=&quot;860&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 입력창 하나 때문에 페이지 전체가 다시 렌더링되면서 차트나 대형 리스트까지 같이 렌더링된다면 이는 비효율적인 구조가 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;973&quot; data-start=&quot;943&quot; data-section-id=&quot;1ykurup&quot; data-ke-size=&quot;size26&quot;&gt;React DevTools Profiler 활용법&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;455&quot; data-end=&quot;558&quot; data-ke-size=&quot;size16&quot;&gt;React DevTools의 Profiler는 렌더링 성능을 분석하기 위한 도구입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;455&quot; data-end=&quot;558&quot; data-ke-size=&quot;size16&quot;&gt;어떤 컴포넌트가 렌더링되었는지, 그리고 그 렌더링에 얼마나 시간이 걸렸는지를 확인할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;560&quot; data-end=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;560&quot; data-end=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;기본적인 사용 방법은 다음과 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;560&quot; data-end=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;560&quot; data-end=&quot;581&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;먼저 설치를 진행합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?pli=1&quot;&gt;설치링크&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;Profiler 탭에서 Record 버튼을 누릅니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 실제로 수행하는 인터랙션을 재현합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;583&quot; data-end=&quot;669&quot; data-ke-size=&quot;size16&quot;&gt;Record를 중지하고 결과를 확인합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;671&quot; data-end=&quot;707&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1123&quot; data-end=&quot;1153&quot; data-ke-size=&quot;size16&quot;&gt;Profiler에서는 다음 정보를 확인할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;1155&quot; data-end=&quot;1204&quot;&gt;어떤 컴포넌트가 렌더링되었는지&lt;/li&gt;
&lt;li data-start=&quot;1155&quot; data-end=&quot;1204&quot;&gt;각 컴포넌트의 렌더링 시간&lt;/li&gt;
&lt;li data-start=&quot;1155&quot; data-end=&quot;1204&quot;&gt;렌더링이 발생한 원인&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;671&quot; data-end=&quot;707&quot; data-ke-size=&quot;size16&quot;&gt;Profiler에서 가장 중요하게 봐야 할 부분은 두 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;709&quot; data-end=&quot;763&quot;&gt;첫째는 어떤 컴포넌트가 렌더링되었는지입니다.&lt;/li&gt;
&lt;li data-start=&quot;709&quot; data-end=&quot;763&quot;&gt;둘째는 그 렌더링이 얼마나 비용이 큰지입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;765&quot; data-end=&quot;899&quot; data-ke-size=&quot;size16&quot;&gt;Flamegraph에서는 컴포넌트의 렌더링 시간을 시각적으로 확인할 수 있으며, 넓고 진한 블록일수록 비용이 큰 컴포넌트입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;765&quot; data-end=&quot;899&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;765&quot; data-end=&quot;899&quot; data-ke-size=&quot;size16&quot;&gt;Ranked 탭에서는 렌더링 시간이 긴 컴포넌트를 순위로 확인할 수 있어 병목을 빠르게 찾는 데 유용합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;901&quot; data-end=&quot;1002&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;901&quot; data-end=&quot;1002&quot; data-ke-size=&quot;size16&quot;&gt;또한 특정 컴포넌트를 클릭하면 해당 컴포넌트가 왜 렌더링되었는지도 확인할 수 있습니다.&lt;br /&gt;props 변경인지, state 변경인지, 부모 렌더링 때문인지 파악하는 것이 핵심입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;실제로 Profiler를 사용해보자!&lt;/h2&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-26 162350.png&quot; data-origin-width=&quot;370&quot; data-origin-height=&quot;812&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KrKjE/dJMcah5hZnu/Y1ITYsBSrQkglG8tYpIo01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KrKjE/dJMcah5hZnu/Y1ITYsBSrQkglG8tYpIo01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KrKjE/dJMcah5hZnu/Y1ITYsBSrQkglG8tYpIo01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKrKjE%2FdJMcah5hZnu%2FY1ITYsBSrQkglG8tYpIo01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;370&quot; height=&quot;812&quot; data-filename=&quot;스크린샷 2026-04-26 162350.png&quot; data-origin-width=&quot;370&quot; data-origin-height=&quot;812&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제가 진행했던 '마음모음' 프로젝트의 2차 인증 비밀번호 입력화면입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음은 PIN.tsx 페이지의 코드 일부이며, 컴포넌트 상단에서 입력한 비밀번호에 대한 state를 관리하기 때문에,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;비밀번호가 입력되면 페이지 전체가 재렌더링 되는 상태였습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;const&amp;nbsp;[password,&amp;nbsp;setPassword]&amp;nbsp;=&amp;nbsp;useState(['',&amp;nbsp;'',&amp;nbsp;'',&amp;nbsp;'']);&lt;/blockquote&gt;
&lt;pre id=&quot;code_1777191370843&quot; class=&quot;xml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export default function PIN() {
  const [password, setPassword] = useState(['', '', '', '']);
  const [errorMessage, setErrorMessage] = useState&amp;lt;string | null&amp;gt;(null);
  
  ...

  const handleSubmit = () =&amp;gt; {
    verifyPassword({
      data: { secondaryPassword: password.join('') },
    });
  };

  return (
    &amp;lt;SubPageLayout title=&quot;2차 비밀번호 입력하기&quot; backTo={ROUTE.LOGIN}&amp;gt;
      &amp;lt;div className=&quot;flex w-full flex-col items-center&quot;&amp;gt;
        &amp;lt;div className=&quot;mt-6 flex h-auto flex-col items-center gap-4 px-6&quot;&amp;gt;
          &amp;lt;div className=&quot;h-51&quot;&amp;gt;
            &amp;lt;img src={lockGif} alt=&quot;&quot; className=&quot;h-auto w-51 object-cover&quot; /&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;h1 className=&quot;text-title-2-semibold mt-5 text-center whitespace-pre-line text-gray-800&quot;&amp;gt;
          소중한 마음 기록을{'\n'}보호하고 있어요
        &amp;lt;/h1&amp;gt;
        &amp;lt;h1 className=&quot;text-body-3-regular mt-1 mb-6 text-gray-400&quot;&amp;gt;현재 비밀번호를 입력해주세요&amp;lt;/h1&amp;gt;
        &amp;lt;InputOTP
          value={password}
          onChange={setPassword}
          isError={!!errorMessage}
          onErrorReset={() =&amp;gt; setErrorMessage(null)}
        /&amp;gt;
        {errorMessage &amp;amp;&amp;amp; &amp;lt;h1 className=&quot;text-caption-1-regular mt-3 text-red-500&quot;&amp;gt;{errorMessage}&amp;lt;/h1&amp;gt;}
      &amp;lt;/div&amp;gt;

      &amp;lt;Button
        className=&quot;fixed right-0 bottom-0 left-0 mx-6 mb-6 flex justify-center gap-3&quot;
        variant=&quot;primary&quot;
        appearance=&quot;filled&quot;
        size=&quot;lg&quot;
        onClick={handleSubmit}
        disabled={!password.every((v) =&amp;gt; v) || isPending}
        data-submit-btn
      &amp;gt;
        &amp;lt;p className=&quot;text-body-2-semibold&quot;&amp;gt;다음&amp;lt;/p&amp;gt;
      &amp;lt;/Button&amp;gt;
    &amp;lt;/SubPageLayout&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-26 162343.png&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;979&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNT4ff/dJMcaakLTAl/Z1Ja5c4rd0kQ1IPVzpx1b0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNT4ff/dJMcaakLTAl/Z1Ja5c4rd0kQ1IPVzpx1b0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNT4ff/dJMcaakLTAl/Z1Ja5c4rd0kQ1IPVzpx1b0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNT4ff%2FdJMcaakLTAl%2FZ1Ja5c4rd0kQ1IPVzpx1b0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;461&quot; height=&quot;481&quot; data-filename=&quot;스크린샷 2026-04-26 162343.png&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;979&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제로 PIN 내부에 있는 Input에 값을 입력할경우 페이지 전체가 리렌더링 되는 모습을 볼 수 있습니다.&lt;br /&gt;SubPageLayout 내부의 모든 컴포넌트가 렌더링 되는 모습입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1777191370852&quot; class=&quot;xml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export default function PIN() {
  
  ...

  const [formErrorMessage, setFormErrorMessage] = useState&amp;lt;string | null&amp;gt;(null);

  const handleSubmit = useCallback(
    (password: string[]) =&amp;gt; {
      verifyPassword({
        data: { secondaryPassword: password.join('') },
      });
    },
    [verifyPassword]
  );

  return (
    &amp;lt;SubPageLayout title=&quot;2차 비밀번호 입력하기&quot; backTo={ROUTE.LOGIN}&amp;gt;
      &amp;lt;div className=&quot;flex w-full flex-col items-center&quot;&amp;gt;
        &amp;lt;div className=&quot;mt-6 flex h-auto flex-col items-center gap-4 px-6&quot;&amp;gt;
          &amp;lt;div className=&quot;h-51&quot;&amp;gt;
            &amp;lt;img src={lockGif} alt=&quot;&quot; className=&quot;h-auto w-51 object-cover&quot; /&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;h1 className=&quot;text-title-2-semibold mt-5 text-center whitespace-pre-line text-gray-800&quot;&amp;gt;
          소중한 마음 기록을{'\n'}보호하고 있어요
        &amp;lt;/h1&amp;gt;
        &amp;lt;h1 className=&quot;text-body-3-regular mt-1 mb-6 text-gray-400&quot;&amp;gt;현재 비밀번호를 입력해주세요&amp;lt;/h1&amp;gt;
        &amp;lt;PinForm
          onSubmit={handleSubmit}
          errorMessage={formErrorMessage}
          onErrorReset={() =&amp;gt; setFormErrorMessage(null)}
          isPending={isPending}
        /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/SubPageLayout&amp;gt;
  );
}

type PinFormProps = {
  onSubmit: (password: string[]) =&amp;gt; void;
  errorMessage: string | null;
  onErrorReset: () =&amp;gt; void;
  isPending: boolean;
};

function PinForm({ onSubmit, errorMessage, onErrorReset, isPending }: PinFormProps) {
  const [password, setPassword] = useState(['', '', '', '']);
  const isPasswordComplete = password.every(Boolean);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;InputOTP value={password} onChange={setPassword} isError={!!errorMessage} onErrorReset={onErrorReset} /&amp;gt;

      {errorMessage &amp;amp;&amp;amp; &amp;lt;h1 className=&quot;text-caption-1-regular mt-3 text-red-500&quot;&amp;gt;{errorMessage}&amp;lt;/h1&amp;gt;}

      &amp;lt;Button
        className=&quot;fixed right-0 bottom-0 left-0 mx-6 mb-6 flex justify-center gap-3&quot;
        variant=&quot;primary&quot;
        appearance=&quot;filled&quot;
        size=&quot;lg&quot;
        onClick={() =&amp;gt; onSubmit(password)}
        disabled={!isPasswordComplete || isPending}
        data-submit-btn
      &amp;gt;
        &amp;lt;p className=&quot;text-body-2-semibold&quot;&amp;gt;다음&amp;lt;/p&amp;gt;
      &amp;lt;/Button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 PinForm 컴포넌트를 새로 만들고, 그 안에서 state를 관리하기로 하였다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-26 163035.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;976&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYTP4W/dJMcaakLTAI/ZMhnc8rkWjPkRlmiQJUDf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYTP4W/dJMcaakLTAI/ZMhnc8rkWjPkRlmiQJUDf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYTP4W/dJMcaakLTAI/ZMhnc8rkWjPkRlmiQJUDf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYTP4W%2FdJMcaakLTAI%2FZMhnc8rkWjPkRlmiQJUDf1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;486&quot; height=&quot;505&quot; data-filename=&quot;스크린샷 2026-04-26 163035.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;976&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이제 PIN을 입력하더라도 PinForm 내부만 다시 재렌더링이 발생하는 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;물론 기존 구조에서, &lt;b&gt;이미지나 텍스트도 함께 렌더링되지만 실제 비용은 거의 발생하지 않는다는 것( 4ms )&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React는 동일한 JSX 결과에 대해서는 DOM을 다시 생성하지 않으며, 이미지 역시 동일한 src를 유지하는 경우 재요청이 발생하지 않습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 극적인 최적화가 이뤄지지는 않을 것이며, 반드시 필요한 최적화라고도 말할 순 없습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1806&quot; data-end=&quot;1857&quot; data-ke-size=&quot;size16&quot;&gt;하지만 지금은 단순한 이미지와 텍스트지만, 여기에 다음과 같은 요소가 추가된다면 상황이 달라집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;1859&quot; data-end=&quot;1892&quot;&gt;대형 리스트&lt;/li&gt;
&lt;li data-start=&quot;1859&quot; data-end=&quot;1892&quot;&gt;차트&lt;/li&gt;
&lt;li data-start=&quot;1859&quot; data-end=&quot;1892&quot;&gt;애니메이션&lt;/li&gt;
&lt;li data-start=&quot;1859&quot; data-end=&quot;1892&quot;&gt;복잡한 계산 로직&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1894&quot; data-end=&quot;1940&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 입력값 하나 때문에 모든 요소가 다시 렌더링되는 구조는 비효율적이 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;1894&quot; data-end=&quot;1940&quot; data-ke-size=&quot;size16&quot;&gt;무거운 계산이 필요한 컴포넌트가 함께 있을 경우 이러한 최적화는 필요할 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Profiler를 어떻게 사용하는지 실제 코드에 적용하여 실습해볼 수 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot; data-section-id=&quot;q532rw&quot; data-start=&quot;2656&quot; data-end=&quot;2674&quot;&gt;이미지와 렌더링&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot; data-start=&quot;2676&quot; data-end=&quot;2747&quot;&gt;렌더링 최적화를 고민할 때 자주 나오는 질문 중 하나는 이미지나 GIF가 다시 렌더링되면 비용이 큰 것이 아닌가 하는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot; data-start=&quot;2749&quot; data-end=&quot;2872&quot;&gt;React에서 렌더링은 함수 실행을 의미하며, 동일한 JSX 결과가 생성되면 실제 DOM은 변경되지 않습니다.&lt;br /&gt;또한 이미지의 src가 동일하다면 브라우저 캐시를 활용하기 때문에 네트워크 요청이 다시 발생하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot; data-start=&quot;2874&quot; data-end=&quot;2976&quot;&gt;따라서 단순한 이미지나 텍스트는 렌더링 비용 측면에서 큰 문제가 되지 않는 경우가 많습니다.&lt;br /&gt;진짜로 신경 써야 할 대상은 리스트, 차트, 복잡한 계산과 같은 무거운 컴포넌트입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;div data-is-intersecting=&quot;true&quot; data-turn-id-container=&quot;request-WEB:feb81d2c-e09a-4dce-a561-241691092b48-10&quot;&gt;
&lt;div data-turn-start-message=&quot;true&quot; data-message-model-slug=&quot;gpt-5-3&quot; data-message-id=&quot;8f150b23-ad29-4317-aab3-c94926a351a0&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;h2 data-end=&quot;99&quot; data-start=&quot;41&quot; data-section-id=&quot;1znwg8&quot; data-ke-size=&quot;size26&quot;&gt;useMemo, useCallback, React.memo - 언제 쓰고, 왜 써야 하는가&lt;/h2&gt;
&lt;p data-end=&quot;307&quot; data-start=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;React에서 성능 최적화를 고민하다 보면 useMemo, useCallback, React.memo라는 세 가지 도구를 자주 접하게 됩니다. 하지만 이들을 단순히 &amp;ldquo;렌더링을 줄이는 기술&amp;rdquo;로 이해하고 사용하는 경우가 많습니다.&lt;/p&gt;
&lt;p data-end=&quot;307&quot; data-start=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;307&quot; data-start=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;실제로는 각각의 역할이 명확하게 다르며,&lt;/p&gt;
&lt;p data-end=&quot;307&quot; data-start=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;잘못 사용하면 오히려 코드 복잡도만 증가하고 성능 개선 효과는 거의 없는 경우도 많습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-end=&quot;379&quot; data-start=&quot;309&quot; data-ke-size=&quot;size16&quot;&gt;밑에서는 세 가지 도구가 각각 무엇을 하는지, 그리고 어떤 상황에서 사용하는 것이 적절한지를 실제 예시와 함께 설명합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;411&quot; data-start=&quot;386&quot; data-section-id=&quot;11pqs5g&quot; data-ke-size=&quot;size26&quot;&gt;useMemo - 값을 기억하는 Hook&lt;/h2&gt;
&lt;p data-end=&quot;508&quot; data-start=&quot;413&quot; data-ke-size=&quot;size16&quot;&gt;useMemo는 특정 계산 결과를 기억하고, 의존성이 변경되지 않는 한 이전 값을 재사용하는 Hook입니다.&lt;/p&gt;
&lt;p data-end=&quot;508&quot; data-start=&quot;413&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;508&quot; data-start=&quot;413&quot; data-ke-size=&quot;size16&quot;&gt;즉, 동일한 계산을 반복하지 않도록 만들어주는 역할을 합니다.&lt;/p&gt;
&lt;p data-end=&quot;650&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;650&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;React는 컴포넌트가 렌더링될 때마다 함수 내부 코드가 다시 실행됩니다.&lt;/p&gt;
&lt;p data-end=&quot;650&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;이때 데이터 가공이나 필터링과 같은 연산이 포함되어 있다면, 렌더링이 반복될수록 불필요한 계산이 계속 발생하게 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;650&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;650&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;useMemo는 이러한 문제를 해결하기 위해 존재합니다.&lt;/p&gt;
&lt;p data-end=&quot;686&quot; data-start=&quot;652&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 리스트를 필터링하는 코드가 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1777238032612&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const filteredList = items.filter(item =&amp;gt; item.name.includes(keyword));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;874&quot; data-start=&quot;772&quot; data-ke-size=&quot;size16&quot;&gt;이 코드는 렌더링이 발생할 때마다 실행됩니다. 만약 items의 크기가 크다면 이 연산은 성능에 영향을 줄 수 있습니다. 이를 useMemo로 감싸면 다음과 같이 개선할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1777238051105&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const filteredList = useMemo(() =&amp;gt; {
  return items.filter(item =&amp;gt; item.name.includes(keyword));
}, [items, keyword]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1076&quot; data-start=&quot;1007&quot; data-ke-size=&quot;size16&quot;&gt;이제 items나 keyword가 변경될 때만 필터링이 다시 실행되며, 그렇지 않은 경우에는 이전 결과를 그대로 사용합니다.&lt;/p&gt;
&lt;p data-end=&quot;1220&quot; data-start=&quot;1078&quot; data-ke-size=&quot;size16&quot;&gt;또한 useMemo는 단순히 계산 최적화뿐만 아니라 객체나 배열의 참조를 유지하는 용도로도 활용됩니다.&lt;/p&gt;
&lt;p data-end=&quot;1220&quot; data-start=&quot;1078&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1220&quot; data-start=&quot;1078&quot; data-ke-size=&quot;size16&quot;&gt;React에서는 객체와 배열이 매번 새로 생성되면 값이 같더라도 다른 것으로 판단되기 때문에, 이를 방지하기 위해 useMemo를 사용할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1257&quot; data-start=&quot;1227&quot; data-section-id=&quot;1vlfsv1&quot; data-ke-size=&quot;size26&quot;&gt;useCallback - 함수를 기억하는 Hook&lt;/h2&gt;
&lt;p data-end=&quot;1393&quot; data-start=&quot;1259&quot; data-ke-size=&quot;size16&quot;&gt;useCallback은 함수 자체를 기억하는 Hook입니다. React에서는 컴포넌트가 렌더링될 때마다 함수도 함께 새로 생성됩니다.&lt;/p&gt;
&lt;p data-end=&quot;1393&quot; data-start=&quot;1259&quot; data-ke-size=&quot;size16&quot;&gt;대부분의 경우 이는 문제가 되지 않지만, 해당 함수가 자식 컴포넌트로 전달되는 경우에는 상황이 달라집니다.&lt;/p&gt;
&lt;p data-end=&quot;1425&quot; data-start=&quot;1395&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 코드가 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1777238068135&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Child onClick={() =&amp;gt; doSomething()} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1614&quot; data-start=&quot;1479&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 렌더링이 발생할 때마다 새로운 함수가 생성되며, 자식 컴포넌트 입장에서는 props가 변경된 것으로 판단할 수 있습니다. 특히 자식 컴포넌트가 React.memo로 최적화되어 있다면, 이로 인해 불필요한 렌더링이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;1651&quot; data-start=&quot;1616&quot; data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 useCallback을 사용할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1777238149275&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = useCallback(() =&amp;gt; {
  doSomething();
}, []);

&amp;lt;Child onClick={handleClick} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1829&quot; data-start=&quot;1763&quot; data-ke-size=&quot;size16&quot;&gt;이제 handleClick 함수는 동일한 참조를 유지하게 되며, 자식 컴포넌트의 불필요한 렌더링을 방지할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;1944&quot; data-start=&quot;1831&quot; data-ke-size=&quot;size16&quot;&gt;다만 중요한 점은, useCallback은 모든 함수에 적용해야 하는 것이 아니라 &lt;b&gt;props로 전달되는 함수이며, 해당 컴포넌트가 memoization되어 있는 경우에만 의미가 있다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;1980&quot; data-start=&quot;1951&quot; data-section-id=&quot;6esl94&quot; data-ke-size=&quot;size26&quot;&gt;React.memo - 컴포넌트를 기억하는 기능&lt;/h2&gt;
&lt;p data-end=&quot;2077&quot; data-start=&quot;1982&quot; data-ke-size=&quot;size16&quot;&gt;React.memo는 컴포넌트를 감싸서, props가 변경되지 않은 경우 렌더링을 건너뛰도록 하는 기능입니다. 이는 함수형 컴포넌트에서 사용할 수 있는 최적화 방법입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1777238165386&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const Child = memo(function Child({ value }) {
  return &amp;lt;div&amp;gt;{value}&amp;lt;/div&amp;gt;;
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2223&quot; data-start=&quot;2171&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2223&quot; data-start=&quot;2171&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 작성하면 value가 변경되지 않는 한 Child 컴포넌트는 다시 렌더링되지 않습니다.&lt;/p&gt;
&lt;p data-end=&quot;2303&quot; data-start=&quot;2225&quot; data-ke-size=&quot;size16&quot;&gt;React.memo는 특히 리스트 아이템처럼 동일한 구조의 컴포넌트가 반복되는 경우나, 렌더링 비용이 큰 컴포넌트에 적용할 때 효과적입니다.&lt;/p&gt;
&lt;p data-end=&quot;2341&quot; data-start=&quot;2305&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 리스트가 다음과 같이 구성되어 있다고 가정해보겠습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1777238176591&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ListItem = memo(({ item }) =&amp;gt; {
  return &amp;lt;li&amp;gt;{item.name}&amp;lt;/li&amp;gt;;
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2492&quot; data-start=&quot;2428&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 부모 컴포넌트가 렌더링되더라도 item이 변경되지 않는 한 각 ListItem은 다시 렌더링되지 않습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;2763&quot; data-start=&quot;2749&quot; data-section-id=&quot;i2iau8&quot; data-ke-size=&quot;size26&quot;&gt;언제 사용해야 하는가&lt;/h2&gt;
&lt;p data-end=&quot;2811&quot; data-start=&quot;2765&quot; data-ke-size=&quot;size16&quot;&gt;세 가지 도구를 사용할 때 가장 중요한 기준은 &amp;ldquo;필요할 때만 사용한다&amp;rdquo;는 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1482&quot; data-end=&quot;1538&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1540&quot; data-end=&quot;1670&quot;&gt;useMemo는 계산 결과를 캐싱하기 위한 Hook입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1540&quot; data-end=&quot;1670&quot;&gt;filter, sort, map과 같이 비용이 큰 연산이 반복될 때 사용합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1540&quot; data-end=&quot;1670&quot;&gt;또한 객체나 배열을 props로 전달할 때 참조를 안정화하기 위해 사용할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1672&quot; data-end=&quot;1811&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1672&quot; data-end=&quot;1811&quot;&gt;useCallback은 함수 참조를 유지하기 위한 Hook입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1672&quot; data-end=&quot;1811&quot;&gt;함수를 props로 전달할 때, 특히 React.memo와 함께 사용할 때 의미가 있습니다.&lt;br /&gt;불필요하게 함수가 새로 생성되는 것을 방지하여 자식 컴포넌트의 리렌더링을 줄입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1813&quot; data-end=&quot;1923&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1813&quot; data-end=&quot;1923&quot;&gt;React.memo는 props가 변경되지 않았을 경우 렌더링을 건너뛰는 기능입니다.&lt;br /&gt;렌더링 비용이 큰 컴포넌트나, 리스트 아이템처럼 반복적으로 렌더링되는 컴포넌트에 적용하는 것이 효과적입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-start=&quot;1925&quot; data-end=&quot;2002&quot;&gt;중요한 점은, 이 세 가지 도구는 모두 &amp;ldquo;렌더링을 줄이기 위한 수단&amp;rdquo;이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이미 확인된 병목을 해결하기 위한 도구&lt;/b&gt;라는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;3045&quot; data-start=&quot;3039&quot; data-section-id=&quot;1h9nj85&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-end=&quot;3058&quot; data-start=&quot;2991&quot; data-ke-size=&quot;size16&quot;&gt;React 렌더링 최적화의 핵심은 단순히 렌더링을 줄이는 것이 아니라, &lt;b&gt;렌더링의 범위와 비용을 통제하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-end=&quot;3134&quot; data-start=&quot;3060&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3134&quot; data-start=&quot;3060&quot; data-ke-size=&quot;size16&quot;&gt;모든 컴포넌트에 memo를 적용하는 것이 아니라, Profiler를 통해 병목을 찾고 그 부분만 정확하게 최적화하는 것이 중요합니다.&lt;/p&gt;
&lt;p data-end=&quot;3165&quot; data-start=&quot;3136&quot; data-ke-size=&quot;size16&quot;&gt;결국 좋은 최적화는 다음과 같이 정리할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;3165&quot; data-start=&quot;3136&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;3223&quot; data-start=&quot;3167&quot; data-ke-size=&quot;size16&quot;&gt;렌더링을 막는 것이 아니라, 불필요한 렌더링과 비용이 큰 렌더링을 구분하고 제어하는 것이 중요합니다.&lt;/p&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;3223&quot; data-start=&quot;3167&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;3223&quot; data-start=&quot;3167&quot; data-ke-size=&quot;size16&quot;&gt;오늘도 읽어주셔서 감사합니다!&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>React</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/16</guid>
      <comments>https://jskim6335.tistory.com/16#entry16comment</comments>
      <pubDate>Mon, 27 Apr 2026 06:23:16 +0900</pubDate>
    </item>
    <item>
      <title>Next.js 데이터 페칭과 Server Actions</title>
      <link>https://jskim6335.tistory.com/15</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b730oa/dJMcagyiGz5/NbXkzAfPju3UssBWXcKVa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b730oa/dJMcagyiGz5/NbXkzAfPju3UssBWXcKVa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b730oa/dJMcagyiGz5/NbXkzAfPju3UssBWXcKVa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb730oa%2FdJMcagyiGz5%2FNbXkzAfPju3UssBWXcKVa0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1536&quot; height=&quot;1024&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js 데이터 페칭과 Server Actions&lt;/h2&gt;
&lt;p data-end=&quot;274&quot; data-start=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 Next.js에서 제공하는 데이터 페칭 방식과 Server Actions를 중심으로,&lt;br /&gt;실무에서 어떻게 활용해야 하는지까지 함께 정리해보겠습니다.&lt;/p&gt;
&lt;p data-end=&quot;274&quot; data-start=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;274&quot; data-start=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;React 개발을 처음 시작했을 때, 대부분의 데이터 페칭은 이런 형태였습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetch('/api/posts')
    .then(res =&amp;gt; res.json())
    .then(data =&amp;gt; setPosts(data));
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트가 마운트된 뒤에야 요청을 보내고, 로딩 상태를 따로 관리하고, 에러 처리도 별도로 해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴 자체가 나쁜 것은 아니지만, 한 가지 근본적인 한계가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 데이터 페칭이 클라이언트에 종속되어 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js App Router와 React Server Components는 이 전제를 바꿨습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 &quot;클라이언트가 받아서 가져오는&quot; 것이 아니라, &quot;서버가 그려서 전달하는&quot; 방식으로 전환한 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 그 변화의 핵심인 데이터 페칭 전략을 처음부터 차근히 살펴봅니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;React Server Components - 서버에서 바로 그린다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 말씀드렸듯이, 기존 React에서는 데이터를 가져오기 위해 useEffect를 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트가 먼저 렌더링되고, 브라우저에서 마운트된 이후에야 데이터 요청이 시작되는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 잠깐이라도 빈 화면이나 스켈레톤을 보게 되고, 클라이언트-서버 간 요청 왕복(round trip)이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Components에서는 이 방식이 필요하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트 자체를 async로 선언하고, 서버에서 직접 데이터를 가져올 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// app/posts/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    &amp;lt;ul&amp;gt;
      {posts.map(post =&amp;gt; (
        &amp;lt;li key={post.id}&amp;gt;{post.title}&amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 데이터 fetch &amp;rarr; HTML 생성 &amp;rarr; 클라이언트 전달&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 이미 완성된 UI를 받기 때문에 초기 렌더링 속도가 크게 개선됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Server Components는 브라우저에 자바스크립트 번들로 전송되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 접근 코드나 API 키 같은 민감한 로직을 클라이언트에 노출하지 않아도 된다는 점도 중요한 장점입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;fetch란 무엇인가 - Web API에서 Next.js 내장 기능으로&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch는 원래 브라우저가 제공하는 Web API입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청을 보내고 응답을 받는 표준 인터페이스로, XMLHttpRequest를 대체하기 위해 등장했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promise 기반으로 설계되어 async/await와 자연스럽게 연동됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const res = await fetch('https://api.example.com/posts');

if (!res.ok) {
  throw new Error(`HTTP error: ${res.status}`);
}

const data = await res.json();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch는 Response 객체를 반환합니다. 응답 본문을 읽으려면 .json(), .text(), .blob() 등의 메서드를 명시적으로 호출해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 중요한 점은, fetch는 네트워크 오류가 아닌 이상 HTTP 에러(4xx, 5xx)에서도 Promise를 reject하지 않는다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 res.ok 또는 res.status를 직접 확인해 에러를 처리하는 습관이 필요합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Next.js의 fetch - 캐싱 시스템과 함께 동작한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 기존 fetch Web API를 그대로 사용하되, 서버 환경에서 동작할 수 있도록 확장했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js 18 이전에는 서버 환경에서 fetch가 기본 제공되지 않아 node-fetch나 axios 같은 별도 라이브러리가 필요했지만, 이제는 클라이언트와 서버 모두에서 동일한 fetch API를 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 중요한 차이는 Next.js의 fetch가 내장 캐싱 시스템과 연결되어 있다는 점입니다. 단순히 데이터를 가져오는 것을 넘어, 그 데이터를 언제까지 보관하고 언제 다시 가져올지를 선언적으로 제어할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;awk&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 캐시 전략을 명시적으로 지정
const res = await fetch('https://api.example.com/posts', {
  cache: 'no-store',        // 항상 최신 데이터
  // cache: 'force-cache',  // 캐시 최대 활용
  // next: { revalidate: 60 } // 60초마다 갱신
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;요청 중복 제거 (Deduplication)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Components에서 여러 컴포넌트가 동일한 URL로 fetch를 호출하더라도, Next.js는 동일한 요청 주기 내에서 이를 자동으로 중복 제거합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 렌더링 트리 안에서 같은 URL을 여러 번 호출해도 실제 네트워크 요청은 한 번만 발생한다는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 각 컴포넌트가 필요한 데이터를 독립적으로 요청하도록 설계해도 불필요한 성능 낭비 없이 구조를 깔끔하게 유지할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;fetch API 캐싱 전략 - 데이터는 언제 새로 가져와야 하는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js의 fetch에서 가장 중요한 개념 중 하나는 캐싱 전략입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 URL이라도 어떤 옵션을 지정하느냐에 따라 전혀 다른 동작을 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;no-store - 항상 최신 데이터&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fetch(url, { cache: 'no-store' })&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 요청마다 서버에서 새 데이터를 가져옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시를 전혀 사용하지 않기 때문에 항상 최신 상태를 보장하지만, 그만큼 서버 부하와 응답 시간이 늘어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 대시보드, 주식 시세, 채팅 메시지처럼 데이터 변경이 잦은 상황에 적합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js 15부터는 이 옵션이 fetch의 기본값으로 변경되었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;force-cache - 최대한 캐시 사용&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fetch(url, { cache: 'force-cache' })&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번 가져온 데이터를 계속 재사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로그 글, 정적 문서처럼 데이터 변경이 드문 콘텐츠에 적합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 매번 새로 요청하지 않기 때문에 서버 부하를 줄이고 응답 속도를 높일 수 있지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 바뀌어도 캐시가 갱신되기 전까지는 오래된 내용이 표시될 수 있다는 점을 감안해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;revalidate - ISR 기반 갱신&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fetch(url, { next: { revalidate: 60 } })&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;60초마다 데이터를 재검증합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시를 활용하면서도 주기적으로 최신 데이터를 반영할 수 있어, 성능과 최신성 사이의 균형을 맞추는 전략입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 목록, 뉴스 피드처럼 실시간성이 완벽하게 필요하진 않지만 어느 정도 최신 상태를 유지해야 하는 데이터에 잘 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 Next.js의 ISR(Incremental Static Regeneration)을 fetch 단위로 적용하는 것과 동일합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;태그 기반 캐싱&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fetch(url, { next: { tags: ['posts'] } })&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 태그를 붙여두면, 나중에 그 태그를 기준으로 캐시를 무효화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간 기반 갱신이 아니라 특정 이벤트(글 작성, 수정 등)가 발생했을 때 정확히 해당 데이터만 다시 불러오고 싶을 때 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤에서 설명할 revalidateTag와 함께 사용하면 데이터 단위의 정밀한 캐시 제어가 가능합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Server Actions - 서버에서 직접 처리하는 로직&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Actions는 클라이언트에서 서버 함수를 직접 호출할 수 있는 기능입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 상단 또는 함수 내부에 'use server' 지시어를 선언하는 것만으로 해당 함수는 서버에서만 실행되며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서는 마치 일반 함수를 호출하듯 사용할 수 있습니다.&lt;/p&gt;
&lt;h3 data-end=&quot;427&quot; data-start=&quot;394&quot; data-ke-size=&quot;size23&quot;&gt;1. Next.js 내부에서 DB를 직접 사용하는 경우&lt;/h3&gt;
&lt;p data-end=&quot;469&quot; data-start=&quot;429&quot; data-ke-size=&quot;size16&quot;&gt;서버에서 직접 DB에 접근하는 구조입니다.&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot; data-start=&quot;1223&quot; data-end=&quot;1235&quot;&gt;게시물 작성 예시&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1775701027402&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// app/actions.ts
'use server';

import db from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;

  await db.post.create({
    data: { title },
  });

  // 데이터 변경 후 화면 갱신
  revalidatePath('/posts');
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;957&quot; data-start=&quot;948&quot; data-ke-size=&quot;size20&quot;&gt;동작 흐름&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1080&quot; data-start=&quot;959&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;970&quot; data-start=&quot;959&quot;&gt;사용자가 폼 제출&lt;/li&gt;
&lt;li data-end=&quot;995&quot; data-start=&quot;971&quot;&gt;Next.js가 자동으로 서버 요청 생성&lt;/li&gt;
&lt;li data-end=&quot;1018&quot; data-start=&quot;996&quot;&gt;서버에서 createPost 실행&lt;/li&gt;
&lt;li data-end=&quot;1031&quot; data-start=&quot;1019&quot;&gt;DB에 데이터 저장&lt;/li&gt;
&lt;li data-end=&quot;1058&quot; data-start=&quot;1032&quot;&gt;revalidatePath로 캐시 무효화&lt;/li&gt;
&lt;li data-end=&quot;1080&quot; data-start=&quot;1059&quot;&gt;페이지가 최신 데이터로 다시 렌더링&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-end=&quot;1112&quot; data-start=&quot;1087&quot; data-ke-size=&quot;size23&quot;&gt;2. 외부 백엔드 API를 사용하는 경우&lt;/h3&gt;
&lt;p data-end=&quot;1216&quot; data-start=&quot;1144&quot; data-ke-size=&quot;size16&quot;&gt;Server Actions는 DB뿐만 아니라 &lt;b&gt;외부 API를 감싸는 서버 레이어(BFF, BackEnd-For-FrontEnd)&lt;/b&gt; 로도 사용할 수 있습니다.&lt;/p&gt;
&lt;h4 data-end=&quot;1235&quot; data-start=&quot;1223&quot; data-ke-size=&quot;size20&quot;&gt;댓글 작성 예시&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1775701063901&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createComment(formData: FormData) {
  const postId = formData.get('postId');
  const content = formData.get('content');

  await fetch(`${process.env.API_URL}/comments`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      postId,
      content,
    }),
  });

  // 댓글 작성 후 해당 게시글 페이지 갱신
  revalidatePath(`/posts/${postId}`);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서는 공통적으로 다음과 같이 사용할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;&amp;lt;form action={createPost}&amp;gt;
  &amp;lt;input name=&quot;title&quot; /&amp;gt;
  &amp;lt;button type=&quot;submit&quot;&amp;gt;등록&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;useActionState - 폼 상태 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React 19에서는 useActionState를 통해 Server Action의 상태를 직접 관리할 수 있습니다. 기존에는 폼 제출 결과를 처리하기 위해 별도의 useState와 try/catch 로직을 작성해야 했지만, 이 훅을 사용하면 서버 응답 상태를 선언적으로 다룰 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

const initialState = { message: '', success: false };

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    &amp;lt;form action={formAction}&amp;gt;
      &amp;lt;input name=&quot;title&quot; /&amp;gt;
      {state.message &amp;amp;&amp;amp; (
        &amp;lt;p className={state.success ? 'text-green-600' : 'text-red-600'}&amp;gt;
          {state.message}
        &amp;lt;/p&amp;gt;
      )}
      &amp;lt;button disabled={isPending}&amp;gt;
        {isPending ? '등록 중...' : '등록'}
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 반환값 state는 Server Action이 반환한 최신 상태이고, 두 번째 formAction은 폼에 연결할 액션 함수입니다. 세 번째 isPending은 액션이 진행 중인지를 나타내며, 이를 통해 제출 버튼 비활성화나 로딩 UI를 별도의 상태 없이 간단히 구현할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;useFormStatus - 로딩 상태 제어&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useFormStatus는 가장 가까운 상위 &amp;lt;form&amp;gt;의 제출 상태를 읽어오는 훅입니다. useActionState의 isPending과 역할이 비슷해 보이지만, 용도가 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useFormStatus는 폼과 분리된 자식 컴포넌트에서 제출 상태를 읽어야 할 때 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼 컴포넌트를 별도로 분리하거나, 폼 내부의 여러 요소가 제출 상태에 반응해야 하는 상황에서 특히 깔끔한 해법이 됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    &amp;lt;button disabled={pending}&amp;gt;
      {pending ? '등록 중...' : '등록'}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SubmitButton은 폼의 내부 구조를 전혀 알 필요가 없습니다. 상위에 &amp;lt;form&amp;gt;이 있다면 그 폼의 제출 상태를 자동으로 구독하기 때문에, 폼 로직과 버튼 UI를 완전히 분리하면서도 중복 제출 방지와 사용자 피드백을 자연스럽게 처리할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;캐시 무효화 - 데이터를 언제 다시 그릴 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Server Actions로 데이터를 변경한 뒤에는 화면을 최신 상태로 갱신해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 이를 위한 캐시 무효화 API를 제공합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;revalidatePath&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { revalidatePath } from 'next/cache';

revalidatePath('/posts');&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 경로에 해당하는 페이지 캐시를 무효화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 요청 시 서버에서 해당 페이지를 다시 렌더링합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 새로 작성한 뒤 목록 페이지를 갱신하거나, 설정을 변경한 뒤 해당 페이지를 최신 상태로 보여줘야 할 때 사용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;revalidateTag&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { revalidateTag } from 'next/cache';

revalidateTag('posts');&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch 옵션에서 tags로 지정한 태그를 기준으로 캐시를 무효화합니다. 페이지 단위가 아니라 데이터 단위로 캐시를 제어할 수 있어, 여러 페이지에서 동일한 데이터를 공유하는 구조에서 특히 효과적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 글 목록과 글 상세 페이지가 모두 'posts' 태그를 사용하고 있다면, revalidateTag('posts') 한 번으로 두 페이지의 캐시를 동시에 무효화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;revalidatePath는 특정 URL 경로를 알고 있을 때, revalidateTag는 여러 경로에 걸쳐 있는 데이터를 한 번에 무효화하고 싶을 때 각각 사용하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 API를 조합하면 데이터 단위와 페이지 단위 모두에서 정밀하게 캐시를 제어할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Route Handlers vs Server Actions - 무엇을 선택해야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 방식은 역할이 다릅니다. 선택 기준은 &quot;누가 이 로직을 호출하는가&quot;로 단순하게 정리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Route Handlers&lt;/b&gt;는 app/api 디렉토리 아래에 위치하며, HTTP 엔드포인트를 만드는 방식입니다. 외부 시스템이나 서드파티 서비스가 호출해야 하는 API, REST 또는 GraphQL 구조가 필요한 경우, 웹훅 수신, 복잡한 인증 미들웨어 처리 등이 해당됩니다. 외부에 공개되는 인터페이스가 필요할 때 선택합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Server Actions&lt;/b&gt;는 앱 내부의 UI와 강하게 결합된 로직에 적합합니다. 폼 제출, 간단한 데이터 생성&amp;middot;수정&amp;middot;삭제, 같은 앱 안에서만 사용하는 비즈니스 로직이 이에 해당합니다. API 엔드포인트 없이 서버 함수를 직접 호출하기 때문에 코드가 간결해지고, 타입 안정성도 자연스럽게 유지됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 외부에 공개하는 API라면 Route Handler를, 내부 UI 로직이라면 Server Actions를 선택하는 것이 명확한 기준입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Streaming &amp;amp; Suspense - 느린 데이터도 빠르게 보여주기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 페칭에서 가장 흔한 UX 문제는 &quot;느린 데이터 때문에 전체 페이지가 지연되는 것&quot;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;댓글 목록처럼 무거운 데이터가 로딩될 때까지 페이지 전체가 블로킹되면,&lt;br /&gt;빠르게 렌더링될 수 있는 본문 영역까지 함께 지연됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 &lt;b&gt;Streaming&lt;/b&gt;을 통해 이 문제를 해결합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React의 &lt;b&gt;Suspense&lt;/b&gt;와 결합하면, 느린 데이터를 기다리는 동안 빠른 부분은 즉시 렌더링하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 부분은 나중에 스트리밍으로 채워 넣을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot; data-start=&quot;334&quot; data-end=&quot;350&quot;&gt;&lt;b&gt;Streaming이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;727&quot; data-start=&quot;585&quot; data-ke-size=&quot;size16&quot;&gt;Streaming은 페이지를 한 번에 완성해서 보내는 것이 아니라, 준비된 부분부터 먼저 클라이언트에 전달하는 방식입니다.&lt;br /&gt;이를 통해 서버는 빠르게 준비된 UI부터 먼저 전송하고, 느리게 도착하는 데이터는 나중에 이어서 전달할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;801&quot; data-start=&quot;729&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로 사용자는 페이지의 일부를 이미 확인할 수 있는 상태에서 나머지 콘텐츠가 점진적으로 채워지는 경험을 하게 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;350&quot; data-start=&quot;334&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;350&quot; data-start=&quot;334&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Suspense란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;371&quot; data-start=&quot;352&quot; data-ke-size=&quot;size16&quot;&gt;Suspense는 React에서&lt;b&gt; 아직 준비되지 않은 컴포넌트를 기다리는 동안 fallback UI를 보여주는 기능&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-end=&quot;371&quot; data-start=&quot;352&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;371&quot; data-start=&quot;352&quot; data-ke-size=&quot;size16&quot;&gt;다음과 같이 사용할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1775701940502&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Suspense } from 'react';

&amp;lt;Suspense fallback={&amp;lt;Loading /&amp;gt;}&amp;gt;
  &amp;lt;Component /&amp;gt;
&amp;lt;/Suspense&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense로 감싸진 컴포넌트가 데이터를 아직 가져오지 못한 상태라면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React는 해당 컴포넌트의 렌더링을 잠시 멈추고 fallback UI를 대신 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 데이터가 준비되는 순간, 해당 부분만 다시 렌더링하여 화면을 교체합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;334&quot; data-end=&quot;350&quot;&gt;Suspense와 Streaming을 함께 활용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Streaming과 Suspense를 함께 사용하는 예시를 살펴보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1775702320574&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function Comments() {
  const res = await fetch(&quot;https://api.example.com/comments&quot;);
  const comments = await res.json();

  return (
    &amp;lt;ul&amp;gt;
      {comments.map((c) =&amp;gt; (
        &amp;lt;li key={c.id}&amp;gt;{c.content}&amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;import { Suspense } from 'react';
import Comments from './Comments';

export default function PostPage() {
  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h1&amp;gt;게시글 제목&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;본문 내용...&amp;lt;/p&amp;gt;

      {/* 댓글은 느리게 로딩될 수 있으므로 Suspense로 감싼다 */}
      &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;댓글을 불러오는 중...&amp;lt;/div&amp;gt;}&amp;gt;
        &amp;lt;Comments /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2124&quot; data-start=&quot;1981&quot; data-ke-size=&quot;size16&quot;&gt;먼저 게시글 제목과 본문은 즉시 렌더링되어 사용자에게 전달됩니다.&lt;br /&gt;이와 동시에 Comments 컴포넌트는 데이터를 요청하게 되지만, 아직 응답이 도착하지 않았기 때문에&lt;br /&gt;Suspense가 이를 감지하고 fallback UI를 대신 렌더링합니다.&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2126&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2126&quot; data-ke-size=&quot;size16&quot;&gt;이 상태에서 사용자는 이미 본문을 읽고 있는 상황이 되고,&lt;br /&gt;댓글 영역에는 &lt;b&gt;&amp;ldquo;댓글을 불러오는 중...&amp;rdquo;&lt;/b&gt;이라는 메시지가 표시됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2276&quot; data-start=&quot;2203&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2276&quot; data-start=&quot;2203&quot; data-ke-size=&quot;size16&quot;&gt;이후 댓글 데이터가 도착하면, 해당 부분만 다시 렌더링되어&lt;br /&gt;fallback UI가 실제 댓글 목록으로 자연스럽게 교체됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2364&quot; data-start=&quot;2278&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2364&quot; data-start=&quot;2278&quot; data-ke-size=&quot;size16&quot;&gt;이 모든 과정은 페이지 전체를 다시 그리는 것이 아니라&lt;br /&gt;필요한 부분만 업데이트되며, 그 사이의 데이터 전달은 Streaming을 통해 이루어집니다.&lt;/p&gt;
&lt;p data-end=&quot;2364&quot; data-start=&quot;2278&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2404&quot; data-start=&quot;2371&quot; data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은 &lt;b&gt;&amp;ldquo;전체를 기다리지 않는다&amp;rdquo;&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;p data-end=&quot;2502&quot; data-start=&quot;2406&quot; data-ke-size=&quot;size16&quot;&gt;기존 방식에서는 모든 데이터가 준비될 때까지 화면이 지연되었지만,&lt;/p&gt;
&lt;p data-end=&quot;2502&quot; data-start=&quot;2406&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2502&quot; data-start=&quot;2406&quot; data-ke-size=&quot;size16&quot;&gt;Streaming과 Suspense를 사용하면 준비된 UI부터 먼저 사용자에게 보여줄 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;2543&quot; data-start=&quot;2504&quot; data-ke-size=&quot;size16&quot;&gt;그 결과 초기 렌더링 속도와 사용자 체감 속도가 동시에 개선됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;또한 이 구조는 Server Actions와도 자연스럽게 연결됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 댓글을 작성한 뒤 revalidatePath를 통해 페이지 캐시를 무효화하면,&lt;/p&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;페이지는 다시 렌더링되면서 댓글 영역은 다시 Suspense 상태로 들어가게 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2590&quot; data-start=&quot;2550&quot; data-ke-size=&quot;size16&quot;&gt;이때 fallback UI가 잠시 표시되고, 이후 최신 댓글 데이터가 Streaming을 통해 채워지게 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2766&quot; data-start=&quot;2762&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2811&quot; data-start=&quot;2768&quot; data-ke-size=&quot;size16&quot;&gt;데이터 변경 -&amp;gt; 캐시 무효화 -&amp;gt; 부분 렌더링 -&amp;gt; Streaming 업데이트&lt;/p&gt;
&lt;p data-end=&quot;2835&quot; data-start=&quot;2813&quot; data-ke-size=&quot;size16&quot;&gt;라는 흐름을 구현할 수 있게 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch를 어떤 옵션으로 사용하느냐에 따라 페이지가 정적이 될 수도, 동적이 될 수도, 주기적으로 갱신될 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 개념들을 바탕으로, 실제 프로젝트에서 &quot;어디서 데이터를 가져오고, 어디서 처리하고, 언제 갱신할지&quot;를 직접 고민해보시면 그 차이를 바로 체감하실 수 있을 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어주셔서 감사합니다!&lt;/p&gt;</description>
      <category>Next.js</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/15</guid>
      <comments>https://jskim6335.tistory.com/15#entry15comment</comments>
      <pubDate>Thu, 9 Apr 2026 11:45:58 +0900</pubDate>
    </item>
    <item>
      <title>TanStack Query 에 대해서 알아보기!</title>
      <link>https://jskim6335.tistory.com/14</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;682&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sIs9J/dJMcacWXFWM/ZS2OCkfRwbu42QkS9lIPB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sIs9J/dJMcacWXFWM/ZS2OCkfRwbu42QkS9lIPB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sIs9J/dJMcacWXFWM/ZS2OCkfRwbu42QkS9lIPB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsIs9J%2FdJMcacWXFWM%2FZS2OCkfRwbu42QkS9lIPB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;682&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;682&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 TanStack Query를 사용해야 할까요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;TanStack Query는 React 애플리케이션에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;서버 상태(Server State)&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 효율적으로 다루기 위한 라이브러리입니다.&lt;/span&gt;&lt;br /&gt;예전에는 React Query라는 이름으로 많이 알려져 있었고, &lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;v4부터 TanStack Query로 이름이 바뀌었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서 상태는 크게 두 가지로 나눌 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나는 사용자 입력, 모달 열림 여부, 탭 상태처럼 프론트엔드 내부에서 직접 관리하는 &lt;b&gt;클라이언트 상태&lt;/b&gt;이고,&lt;br /&gt;다른 하나는 서버에서 받아오고, 다시 동기화해야 하며, 시간이 지나면 낡을 수 있는 &lt;b&gt;서버 상태&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 상태는 다음과 같은 특징을 가집니다 :&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;300&quot; data-start=&quot;280&quot; data-section-id=&quot;1mn1m1z&quot;&gt;비동기다 (요청 &amp;rarr; 응답 기다림)&lt;/li&gt;
&lt;li data-end=&quot;311&quot; data-start=&quot;301&quot; data-section-id=&quot;1gwcagg&quot;&gt;실패할 수 있다&lt;/li&gt;
&lt;li data-end=&quot;328&quot; data-start=&quot;312&quot; data-section-id=&quot;h68o91&quot;&gt;여러 컴포넌트에서 공유된다&lt;/li&gt;
&lt;li data-end=&quot;350&quot; data-start=&quot;329&quot; data-section-id=&quot;e16pjr&quot;&gt;시간이 지나면 낡는다 (stale)&lt;/li&gt;
&lt;li data-end=&quot;372&quot; data-start=&quot;351&quot; data-section-id=&quot;wcnm7g&quot;&gt;다시 가져올 타이밍을 결정해야 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 요소들을 useState나 useEffect로 다루기엔 &lt;b&gt;로직이 계속 반복되고 점점 복잡해질 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tanstack Query (구 React Query)는 이러한 복잡한 문제를 손쉽게 다루기 위해 나온 라이브러리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;TanStack Query 없이 데이터를 가져오면 보통 이렇게 작성합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775245231153&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() =&amp;gt; {
  setIsLoading(true);
  fetch(&quot;/api/posts&quot;)
    .then((res) =&amp;gt; res.json())
    .then((data) =&amp;gt; {
      setData(data);
      setIsLoading(false);
    })
    .catch((err) =&amp;gt; {
      setError(err);
      setIsLoading(false);
    });
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;이 코드를 모든 API 호출마다 반복해야 합니다.&lt;br /&gt;&lt;br /&gt;그러나 TanStack Query를 쓰면 같은 작업이 이렇게 줄어듭니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1775245246526&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { data, isLoading, error } = useQuery({
  queryKey: [&quot;posts&quot;],
  queryFn: () =&amp;gt; fetch(&quot;/api/posts&quot;).then((res) =&amp;gt; res.json()),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #141413; text-align: start;&quot;&gt;코드가 줄어드는 것 이상으로, 캐싱 / 재요청 / 에러 처리 / 로딩 상태 관리까지 자동으로 챙겨줍니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 편리한 기능을 사용할 수 있는 TanStack Query에 대해서 본격적으로 알아봅시다!&lt;/p&gt;
&lt;h2 style=&quot;color: #141413; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;설치와 기본 세팅&lt;/h2&gt;
&lt;h3 style=&quot;color: #141413; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;설치&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;background-color: #f5f4ed; color: #141413; text-align: start;&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;npm install @tanstack/react-query&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #141413; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개발 중 쿼리 상태를 눈으로 확인하고 싶다면 DevTools도 함께 설치하는 것을 추천합니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;background-color: #f5f4ed; color: #141413; text-align: start;&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;npm install @tanstack/react-query-devtools&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;QueryClient와 QueryClientProvider 설정&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앱의 최상단에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;QueryClient를 생성하고&lt;span&gt;&amp;nbsp;&lt;/span&gt;QueryClientProvider로 감싸야 합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 설정이 있어야 앱 어디서든 TanStack Query 훅을 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// main.tsx 또는 App.tsx
import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;;
import { ReactQueryDevtools } from &quot;@tanstack/react-query-devtools&quot;;

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,  // 기본 staleTime: 1분
      retry: 1,              // 실패 시 1회 재시도
    },
  },
});

export default function App() {
  return (
    &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
      &amp;lt;MyApp /&amp;gt;
      {/* 개발 환경에서만 Devtools 표시 */}
      &amp;lt;ReactQueryDevtools initialIsOpen={false} /&amp;gt;
    &amp;lt;/QueryClientProvider&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;QueryClient는 앱 전체에서 공유하는 캐시 저장소입니다.&lt;br /&gt;defaultOptions로 모든 쿼리에 공통 설정을 적용할 수 있어, 각 훅마다 같은 옵션을 반복할 필요가 없습니다.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;useQuery - 데이터 읽기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;서버에서 데이터를 가져올 때 사용하는 기본 훅입니다. 반드시&lt;span&gt;&amp;nbsp;&lt;/span&gt;queryKey와&lt;span&gt;&amp;nbsp;&lt;/span&gt;queryFn&lt;span&gt;&amp;nbsp;&lt;/span&gt;두 가지를 전달해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;import { useQuery } from &quot;@tanstack/react-query&quot;;

async function fetchPosts() {
  const res = await fetch(&quot;/api/posts&quot;);
  if (!res.ok) throw new Error(&quot;데이터를 불러오지 못했습니다.&quot;);
  return res.json();
}

function PostList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: [&quot;posts&quot;],   // 이 데이터를 구분하는 고유 키
    queryFn: fetchPosts,   // 실제 데이터를 가져오는 함수
  });

  if (isLoading) return &amp;lt;div&amp;gt;로딩 중...&amp;lt;/div&amp;gt;;
  if (isError) return &amp;lt;div&amp;gt;에러: {error.message}&amp;lt;/div&amp;gt;;

  return (
    &amp;lt;ul&amp;gt;
      {data.map((post) =&amp;gt; (
        &amp;lt;li key={post.id}&amp;gt;{post.title}&amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;동적 데이터 - queryKey에 변수 넣기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;특정 ID에 해당하는 데이터를 가져올 때는&lt;span&gt;&amp;nbsp;&lt;/span&gt;queryKey에 해당 변수를 함께 넣어야 합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;키가 바뀌면 자동으로 새 요청을 보냅니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;function PostDetail({ postId }: { postId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: [&quot;posts&quot;, &quot;detail&quot;, postId],  // postId가 바뀌면 자동으로 재요청
    queryFn: () =&amp;gt;
      fetch(`/api/posts/${postId}`).then((r) =&amp;gt; r.json()),
  });

  if (isLoading) return &amp;lt;div&amp;gt;로딩 중...&amp;lt;/div&amp;gt;;
  return &amp;lt;h1&amp;gt;{data.title}&amp;lt;/h1&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;queryFn에서 에러가 발생하려면 반드시&amp;nbsp;throw를 해야 합니다.&lt;br /&gt;fetch는 404, 500 응답도 성공으로 처리하므로,&amp;nbsp;res.ok를 체크하고 직접 throw하는 습관이 중요합니다.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;자주 쓰는 옵션&lt;/h3&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;
&lt;div style=&quot;color: #444444;&quot;&gt;
&lt;table style=&quot;letter-spacing: 0px; border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;enabled&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;false이면 쿼리를 실행하지 않습니다. 특정 조건이 충족됐을 때만 요청할 때 씁니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;staleTime &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;데이터를 신선하게 볼 시간(ms). 이 시간 안에는 재요청하지 않습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;refetchOnWindowFocus &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;탭을 다시 활성화했을 때 자동으로 재요청할지 여부. 기본값은 true입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;retry &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;실패 시 재시도 횟수. 기본값은 3입니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;select &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;응답 데이터를 가공해서 반환합니다. 원본 캐시는 그대로 유지됩니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7209%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;placeholderData &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 76.2791%; height: 21px;&quot;&gt;데이터가 없을 때 보여줄 임시 데이터입니다. 스켈레톤 대신 쓸 수 있습니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;pre class=&quot;dts&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// enabled 예시 &amp;mdash; userId가 있을 때만 요청
const { data } = useQuery({
  queryKey: [&quot;user&quot;, userId],
  queryFn: () =&amp;gt; fetchUser(userId),
  enabled: !!userId,
});

// select 예시 &amp;mdash; 제목만 추출해서 반환
const { data: titles } = useQuery({
  queryKey: [&quot;posts&quot;],
  queryFn: fetchPosts,
  select: (data) =&amp;gt; data.map((post) =&amp;gt; post.title),
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;useMutation - 데이터 쓰기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;데이터를 생성/수정/삭제할 때 사용하는 훅입니다.&lt;span&gt;&lt;br /&gt;&lt;/span&gt;useQuery와 달리 자동으로 실행되지 않고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;mutate()를 직접 호출해야 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;import { useMutation, useQueryClient } from &quot;@tanstack/react-query&quot;;

async function createPost(newPost: { title: string; body: string }) {
  const res = await fetch(&quot;/api/posts&quot;, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify(newPost),
  });
  if (!res.ok) throw new Error(&quot;게시글 생성 실패&quot;);
  return res.json();
}

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

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () =&amp;gt; {
      // 성공하면 게시글 목록 캐시를 무효화 &amp;rarr; 다음 접근 시 재요청 발생
      queryClient.invalidateQueries({ queryKey: [&quot;posts&quot;] });
    },
    onError: (error) =&amp;gt; {
      alert(`에러: ${error.message}`);
    },
  });

  const handleSubmit = () =&amp;gt; {
    mutation.mutate({ title: &quot;새 게시글&quot;, body: &quot;내용입니다.&quot; });
  };

  return (
    &amp;lt;button onClick={handleSubmit} disabled={mutation.isPending}&amp;gt;
      {mutation.isPending ? &quot;저장 중...&quot; : &quot;게시글 작성&quot;}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;useMutation의 콜백 함수들&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 21.9768%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #1a1a1a; text-align: start;&quot;&gt;onSuccess&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 78.0232%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #555555; text-align: start;&quot;&gt;요청이 성공했을 때 실행됩니다. 캐시 무효화나 리다이렉트 처리를 주로 합니다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 21.9768%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #1a1a1a; text-align: start;&quot;&gt;onError&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 78.0232%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #555555; text-align: start;&quot;&gt;요청이 실패했을 때 실행됩니다. 에러 메시지 표시나 롤백 처리를 합니다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 21.9768%;&quot;&gt;
&lt;div&gt;onSettled&lt;/div&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 78.0232%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #555555; text-align: start;&quot;&gt;성공/실패 관계없이 항상 실행됩니다. 최종 동기화나 로딩 해제에 씁니다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 21.9768%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #1a1a1a; text-align: start;&quot;&gt;onMutate&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 78.0232%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #555555; text-align: start;&quot;&gt;요청 직전에 실행됩니다. Optimistic Update의 시작점입니다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;
&lt;div style=&quot;color: #555555;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt;&lt;/span&gt;useQueryClient - 캐시 직접 다루기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;useQueryClient는 캐시에 직접 접근할 수 있는 훅입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;주로 mutation 이후 캐시를 갱신하거나, 특정 쿼리를 강제로 다시 가져올 때 씁니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;const queryClient = useQueryClient();

// 특정 쿼리 캐시를 무효화 &amp;rarr; 다음 접근 시 재요청 발생
queryClient.invalidateQueries({ queryKey: [&quot;posts&quot;] });

// 캐시에 직접 데이터 쓰기 &amp;rarr; 재요청 없이 즉시 반영
queryClient.setQueryData([&quot;posts&quot;, &quot;detail&quot;, 1], updatedPost);

// 캐시에서 데이터 읽기
const cachedPost = queryClient.getQueryData([&quot;posts&quot;, &quot;detail&quot;, 1]);

// 미리 데이터 가져오기 (Prefetch)
await queryClient.prefetchQuery({
  queryKey: [&quot;posts&quot;, &quot;detail&quot;, 2],
  queryFn: () =&amp;gt; fetchPostDetail(2),
});&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;invalidateQueries는 캐시를 &quot;낡았다&quot;고 표시해 다음 접근 시 재요청을 유도합니다.&lt;br /&gt;setQueryData는 재요청 없이 캐시를 직접 덮어씁니다. 이 둘의 차이를 이해하면 mutation 후처리가 훨씬 명확해집니다.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;주요 상태값 한눈에 보기&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;useQuery와&lt;span&gt;&amp;nbsp;&lt;/span&gt;useMutation이 반환하는 상태값은 처음엔 헷갈릴 수 있습니다. 자주 쓰는 것들만 정리했습니다.&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;useQuery 상태&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 122px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isLoading&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;캐시가 없고 처음으로 데이터를 가져오는 중&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isFetching&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;백그라운드 포함, 요청이 진행 중인 모든 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isSuccess&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;데이터를 성공적으로 가져온 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isError&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;요청이 실패한 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isStale&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;staleTime이 지나 데이터가 낡은 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isPending&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;데이터가 아직 없는 상태 (v5 이상)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;
&lt;blockquote style=&quot;color: #666666;&quot; data-ke-style=&quot;style2&quot;&gt;isLoading은 캐시가 없어 처음 로딩 중일 때만 true입니다.&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;isFetching은 백그라운드에서 갱신 중일 때도 true입니다. 스켈레톤 UI에는&lt;/span&gt;&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;isLoading을, 상단 로딩 바에는&lt;/span&gt;&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #444444; background-color: #f0f6fd; letter-spacing: 0px;&quot;&gt;isFetching을 쓰는 것이 일반적입니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;useMutation 상태&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isPending&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;요청이 진행 중인 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isSuccess&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;요청이 성공한 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isError&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;요청이 실패한 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #333333; text-align: start;&quot;&gt;isIdle&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;background-color: #f8f8f6; color: #666666; text-align: start;&quot;&gt;아직 실행되지 않은 초기 상태&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #f8f8f6;&quot;&gt;
&lt;div style=&quot;color: #666666;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;실전 예제 -게시글 CRUD&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #2a2a2a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;지금까지 배운 것들을 하나로 묶어봅니다. 게시글 목록 조회, 상세 조회, 생성, 삭제를 모두 담은 예제입니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// api.ts &amp;mdash; API 함수 정의
export const api = {
  getPosts: () =&amp;gt;
    fetch(&quot;/api/posts&quot;).then((r) =&amp;gt; r.json()),

  getPost: (id: number) =&amp;gt;
    fetch(`/api/posts/${id}`).then((r) =&amp;gt; r.json()),

  createPost: (data: { title: string; body: string }) =&amp;gt;
    fetch(&quot;/api/posts&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify(data),
    }).then((r) =&amp;gt; r.json()),

  deletePost: (id: number) =&amp;gt;
    fetch(`/api/posts/${id}`, { method: &quot;DELETE&quot; }),
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// postKeys.ts &amp;mdash; Query Key 팩토리
export const postKeys = {
  all: [&quot;posts&quot;] as const,
  lists: () =&amp;gt; [...postKeys.all, &quot;list&quot;] as const,
  detail: (id: number) =&amp;gt; [...postKeys.all, &quot;detail&quot;, id] as const,
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// PostList.tsx &amp;mdash; 목록 조회 + 삭제
function PostList() {
  const queryClient = useQueryClient();

  const { data: posts, isLoading } = useQuery({
    queryKey: postKeys.lists(),
    queryFn: api.getPosts,
  });

  const deleteMutation = useMutation({
    mutationFn: api.deletePost,
    onSuccess: () =&amp;gt; {
      // 삭제 성공 시 목록 캐시 무효화
      queryClient.invalidateQueries({ queryKey: postKeys.lists() });
    },
  });

  if (isLoading) return &amp;lt;div&amp;gt;로딩 중...&amp;lt;/div&amp;gt;;

  return (
    &amp;lt;ul&amp;gt;
      {posts.map((post) =&amp;gt; (
        &amp;lt;li key={post.id}&amp;gt;
          {post.title}
          &amp;lt;button
            onClick={() =&amp;gt; deleteMutation.mutate(post.id)}
            disabled={deleteMutation.isPending}
          &amp;gt;
            삭제
          &amp;lt;/button&amp;gt;
        &amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f7f7f5; color: #1a1a1a; text-align: start;&quot;&gt;&lt;code&gt;// CreatePost.tsx &amp;mdash; 게시글 생성
function CreatePost() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState(&quot;&quot;);

  const createMutation = useMutation({
    mutationFn: api.createPost,
    onSuccess: (newPost) =&amp;gt; {
      // 목록 캐시 무효화 &amp;rarr; 새 글이 포함된 목록을 다시 가져옴
      queryClient.invalidateQueries({ queryKey: postKeys.lists() });

      // 생성된 글을 상세 캐시에 바로 저장 &amp;rarr; 상세 진입 시 재요청 없이 즉시 표시
      queryClient.setQueryData(postKeys.detail(newPost.id), newPost);
    },
  });

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;input
        value={title}
        onChange={(e) =&amp;gt; setTitle(e.target.value)}
        placeholder=&quot;게시글 제목&quot;
      /&amp;gt;
      &amp;lt;button
        onClick={() =&amp;gt; createMutation.mutate({ title, body: &quot;&quot; })}
        disabled={createMutation.isPending || !title}
      &amp;gt;
        {createMutation.isPending ? &quot;저장 중...&quot; : &quot;작성&quot;}
      &amp;lt;/button&amp;gt;
      {createMutation.isError &amp;amp;&amp;amp; (
        &amp;lt;p style={{ color: &quot;red&quot; }}&amp;gt;저장에 실패했습니다.&amp;lt;/p&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;생성 후&amp;nbsp;invalidateQueries와&amp;nbsp;setQueryData를 함께 쓰는 패턴은 실무에서 흔합니다. 목록은 서버에서 다시 받아오고, 상세는 응답 데이터를 바로 캐시에 넣어 다음 진입 시 즉시 보여줄 수 있습니다.&lt;/blockquote&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1a1a; text-align: start;&quot;&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기초 정리 체크리스트&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;QueryClientProvider로 앱 최상단을 감싼다&lt;/li&gt;
&lt;li&gt;데이터 읽기는&lt;span&gt;&amp;nbsp;&lt;/span&gt;useQuery, 데이터 쓰기는&lt;span&gt;&amp;nbsp;&lt;/span&gt;useMutation&lt;/li&gt;
&lt;li&gt;queryKey는 리소스 종류와 조회 조건을 모두 포함한다&lt;/li&gt;
&lt;li&gt;queryFn에서 에러가 나면 반드시&lt;span&gt;&amp;nbsp;&lt;/span&gt;throw를 한다&lt;/li&gt;
&lt;li&gt;mutation 성공 후에는 관련 캐시를&lt;span&gt;&amp;nbsp;&lt;/span&gt;invalidateQueries로 무효화한다&lt;/li&gt;
&lt;li&gt;isLoading과&lt;span&gt;&amp;nbsp;&lt;/span&gt;isFetching의 차이를 구분해서 UI에 적용한다&lt;/li&gt;
&lt;li&gt;개발 중에는 Devtools로 캐시 상태를 항상 확인한다&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Query Key 설계 전략과 캐시 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TanStack Query에서 Query Key는 캐시를 식별하는 기준입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터&amp;middot;정렬&amp;middot;페이지네이션이 붙기 시작하면 단순한 이름표로 다루기 어려워집니다. 조건이 다른 요청이 같은 캐시를 공유하거나, 의도치 않게 캐시가 재사용되는 문제가 생깁니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;좋은 Query Key의 조건&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;리소스 종류가 드러나야 합니다.&lt;/b&gt; 게시글 목록인지, 상세인지, 댓글인지 &amp;mdash; 키만 봐도 구분이 가야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;조회 조건이 키에 포함되어야 합니다.&lt;/b&gt; 페이지 번호, 정렬 기준, 검색어가 빠지면 서로 다른 조건의 요청이 같은 캐시를 바라보게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일관된 계층 구조를 가져야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;clojure&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[&quot;posts&quot;]
[&quot;posts&quot;, &quot;list&quot;, { page, sort, category }]
[&quot;posts&quot;, &quot;detail&quot;, postId]
[&quot;posts&quot;, &quot;comments&quot;, postId]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 유지하면 [&quot;posts&quot;]를 invalidate하는 것만으로 하위 캐시 전체를 무효화할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Query Key Factory 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 커질수록 Query Key를 하드코딩하는 방식은 유지보수가 어려워집니다. 이럴 때 쓰는 것이 Query Key Factory 패턴입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;export const postKeys = {
  all: [&quot;posts&quot;] as const,
  lists: () =&amp;gt; [...postKeys.all, &quot;list&quot;] as const,
  list: (params: { page: number; sort: string; category?: string }) =&amp;gt;
    [...postKeys.lists(), params] as const,
  details: () =&amp;gt; [...postKeys.all, &quot;detail&quot;] as const,
  detail: (id: number) =&amp;gt; [...postKeys.details(), id] as const,
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useQuery({
  queryKey: postKeys.list({ page: 1, sort: &quot;latest&quot;, category: &quot;react&quot; }),
  queryFn: () =&amp;gt; fetchPosts({ page: 1, sort: &quot;latest&quot;, category: &quot;react&quot; }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;오타를 줄이고, invalidateQueries나 setQueryData를 쓸 때 실수를 방지해 줍니다.&lt;br /&gt;부수적으로 캐시 구조를 코드로 문서화하는 역할도 합니다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;staleTime, gcTime&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Query Key 설계만큼 캐시 정책도 같이 잡아야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;staleTime&lt;/b&gt; 은 데이터를 신선하다고 볼 시간입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useQuery({
  queryKey: postKeys.detail(1),
  queryFn: () =&amp;gt; fetchPostDetail(1),
  staleTime: 1000 * 60,
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1분으로 설정하면 1분 안에 같은 쿼리가 다시 마운트되어도 재요청하지 않습니다.&lt;br /&gt;값이 너무 짧으면 요청이 과도해지고, 너무 길면 낡은 데이터를 오래 보여주게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자주 바뀌지 않는 데이터는 길게, 실시간성이 중요한 데이터는 짧게 가져가는 것이 일반적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;gcTime&lt;/b&gt; 은 사용하지 않는 캐시를 메모리에 얼마나 유지할지 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;staleTime이 신선도의 문제라면, gcTime은 보관 기간의 문제입니다. 둘을 혼동하지 않는 것이 중요합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;invalidateQueries vs setQueryData&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mutation 이후에 무조건 invalidateQueries만 쓰는 경우가 많은데, 상황에 따라 setQueryData가 더 적합할 때가 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 데이터를 다시 가져와야 할 때
queryClient.invalidateQueries({ queryKey: postKeys.lists() });

// 서버 응답을 이미 갖고 있을 때
queryClient.setQueryData(postKeys.detail(post.id), post);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 응답으로 확정된 데이터를 캐시에 바로 반영할 수 있다면 setQueryData가 효율적입니다. 영향 범위가 넓거나 최신 상태를 반드시 서버에서 받아와야 한다면 invalidateQueries가 안전합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Optimistic Update 패턴 심화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 응답을 기다리지 않고 UI를 먼저 바꾸는 방식입니다. 좋아요 수를 누르는 즉시 올려 보여주고, 실패하면 되돌리는 식입니다. UX를 크게 개선할 수 있지만, 잘못 쓰면 데이터 불일치나 롤백 복잡도가 높아집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 흐름&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const mutation = useMutation({
  mutationFn: toggleLike,
  onMutate: async (postId: number) =&amp;gt; {
    // 진행 중인 쿼리 취소 &amp;mdash; 덮어쓰기 충돌 방지
    await queryClient.cancelQueries({ queryKey: postKeys.detail(postId) });

    // 롤백을 위해 이전 데이터 저장
    const previousPost = queryClient.getQueryData(postKeys.detail(postId));

    // 캐시를 먼저 수정
    queryClient.setQueryData(postKeys.detail(postId), (old: any) =&amp;gt; {
      if (!old) return old;
      return {
        ...old,
        liked: !old.liked,
        likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
      };
    });

    return { previousPost };
  },
  onError: (error, postId, context) =&amp;gt; {
    // 실패 시 원래 상태로 복원
    if (context?.previousPost) {
      queryClient.setQueryData(postKeys.detail(postId), context.previousPost);
    }
  },
  onSettled: (_, __, postId) =&amp;gt; {
    // 성공&amp;middot;실패 무관하게 서버 기준으로 재동기화
    queryClient.invalidateQueries({ queryKey: postKeys.detail(postId) });
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 단순합니다. 미리 반영하고, 실패하면 되돌리고, 끝나면 서버와 맞춥니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;쓰면 안 되는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요, 북마크, 체크박스처럼 결과가 단순하고 예측 가능한 경우에 잘 맞습니다. 반면 아래 상황에서는 피하는 것이 낫습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 검증이 복잡한 경우&lt;/li&gt;
&lt;li&gt;실패 가능성이 높은 경우&lt;/li&gt;
&lt;li&gt;응답이 서버 계산 결과에 크게 의존하는 경우 (slug 생성, 작성자 정보 가공 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우에는 로딩 상태를 보여주고 성공 후 invalidate하는 것이 더 안정적입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목록과 상세 캐시를 함께 다뤄야 하는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 하나가 목록 캐시와 상세 캐시 양쪽에 존재하는 경우가 많습니다. 상세만 수정하고 목록을 그대로 두면 UI가 어긋납니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;queryClient.setQueriesData(
  { queryKey: postKeys.lists() },
  (oldData: any) =&amp;gt; {
    if (!oldData) return oldData;
    return {
      ...oldData,
      pages: oldData.pages?.map((page: any) =&amp;gt; ({
        ...page,
        items: page.items.map((post: any) =&amp;gt;
          post.id === postId
            ? {
                ...post,
                liked: !post.liked,
                likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
              }
            : post
        ),
      })),
    };
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Optimistic Update를 설계할 때는 해당 데이터가 어느 캐시에 중복으로 존재하는지를 반드시 함께 고려해야 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Prefetching 전략과 성능 최적화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 진입하기 전에 데이터를 미리 가져와 화면 전환 시 로딩을 줄이는 전략입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const handlePrefetch = (postId: number) =&amp;gt; {
  queryClient.prefetchQuery({
    queryKey: postKeys.detail(postId),
    queryFn: () =&amp;gt; fetchPostDetail(postId),
    staleTime: 1000 * 60,
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상세 페이지 진입 시 캐시가 이미 존재하면 로딩 없이 바로 표시되거나, 대기 시간이 크게 줄어듭니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;효과적인 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 페이지에 붙이는 것이 아니라, 사용자의 다음 행동이 예측 가능한 지점에 한해 적용해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;목록 &amp;rarr; 상세 이동이 잦은 구조&lt;/li&gt;
&lt;li&gt;탭 전환이 빈번한 구조&lt;/li&gt;
&lt;li&gt;hover, focus, viewport 진입 시점을 활용할 수 있는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예측 없이 모든 페이지를 prefetch하면 네트워크 낭비와 캐시 오염으로 이어집니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Infinite Query 최적화&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: [&quot;feed&quot;],
  queryFn: ({ pageParam = 1 }) =&amp;gt; fetchFeed(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) =&amp;gt; lastPage.nextCursor ?? undefined,
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;cursor 기반 페이지네이션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지 번호 방식은 중간에 데이터가 추가&amp;middot;삭제될 때 항목이 밀려 중복이나 누락이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cursor 기반은 &quot;마지막으로 본 항목의 다음부터&quot;가 기준이므로 목록이 변해도 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 API 설계 단계부터 cursor 방식을 고려하는 것이 좋습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 문제&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const allPosts = data?.pages.flatMap((page) =&amp;gt; page.items) ?? [];&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔하게 쓰는 패턴이지만 페이지가 많이 쌓이면 렌더링 비용이 올라갑니다. 이 시점부터는 리스트 가상화, memoization, item key 안정성까지 같이 고려해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중복 호출 방지&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;lisp&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  if (inView &amp;amp;&amp;amp; hasNextPage &amp;amp;&amp;amp; !isFetchingNextPage) {
    fetchNextPage();
  }
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hasNextPage와 isFetchingNextPage 조건이 없으면 sentinel이 보이는 동안 요청이 연속으로 나갑니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Error / Loading 바운더리 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컴포넌트마다 처리할 때의 문제&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;const { data, isLoading, isError, error } = useQuery(...);

if (isLoading) return &amp;lt;Loading /&amp;gt;;
if (isError) return &amp;lt;ErrorMessage error={error} /&amp;gt;;
return &amp;lt;Content data={data} /&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지가 적을 때는 괜찮습니다. 화면이 늘어나면 이 분기문이 모든 컴포넌트에 반복되고, 로딩 UI와 에러 처리 기준이 제각각이 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Suspense 기반 로딩 처리&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;useSuspenseQuery({
  queryKey: postKeys.detail(postId),
  queryFn: () =&amp;gt; fetchPostDetail(postId),
});&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;&amp;lt;Suspense fallback={&amp;lt;PostDetailSkeleton /&amp;gt;}&amp;gt;
  &amp;lt;PostDetail /&amp;gt;
&amp;lt;/Suspense&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트 내부에서 isLoading 분기를 작성할 필요 없이, 상위에서 fallback을 통일해 관리할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;QueryErrorResetBoundary&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;django&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;&amp;lt;QueryErrorResetBoundary&amp;gt;
  {({ reset }) =&amp;gt; (
    &amp;lt;ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) =&amp;gt; (
        &amp;lt;div&amp;gt;
          데이터를 불러오지 못했습니다.
          &amp;lt;button onClick={resetErrorBoundary}&amp;gt;다시 시도&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;gt;
      &amp;lt;Suspense fallback={&amp;lt;div&amp;gt;로딩 중...&amp;lt;/div&amp;gt;}&amp;gt;
        &amp;lt;PostDetail /&amp;gt;
      &amp;lt;/Suspense&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  )}
&amp;lt;/QueryErrorResetBoundary&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 실패 시 Error Boundary가 에러를 잡고, &quot;다시 시도&quot;를 누르면 reset을 통해 쿼리가 재실행됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 처리 계층 나누기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 에러를 Boundary로 올릴 필요는 없습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Boundary&lt;/b&gt;: 페이지 진입에 필수적인 데이터 조회 실패&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로컬 메시지&amp;middot;토스트&lt;/b&gt;: 댓글 등록, 좋아요처럼 페이지 자체는 유지되어야 하는 경우&lt;/li&gt;
&lt;li&gt;&lt;b&gt;섹션 수준 fallback&lt;/b&gt;: 특정 영역만 실패한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계층을 나눠두면 에러 처리 코드도 정리되고 UX도 자연스러워집니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 서버 상태 관리의 필수적이라고 봐도 무방한 TanStack Query 에 대해서 알아봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저도 처음에 Tanstack Query를 사용했을때는 단순히 data, isFetching과 error등의 서버 상태 관리 훅만 사용하였었는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깊이 공부하면 할 수록 신경써야할 부분이 많다는 것을 깨달았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TanStack Query를 깊이 이해한다는 건 결국 서버 상태를 어떤 정책으로 운영할지 설계할 수 있다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 프로젝트에서는 편리함이 먼저 보이지만, 규모가 커지면 설계력이 바로 드러나는 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분들도 TanStack Query를 정복하셔서 효과적인 서버 상태 관리를 이루어내셨으면 좋겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어주셔서 감사합니다!&lt;/p&gt;</description>
      <category>React</category>
      <author>수달군</author>
      <guid isPermaLink="true">https://jskim6335.tistory.com/14</guid>
      <comments>https://jskim6335.tistory.com/14#entry14comment</comments>
      <pubDate>Sat, 4 Apr 2026 04:45:31 +0900</pubDate>
    </item>
  </channel>
</rss>