<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>mimizae 님의 블로그</title>
    <link>https://mimizae.tistory.com/</link>
    <description>mimizae 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Thu, 23 Jul 2026 05:41:31 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>mimizae</managingEditor>
    <image>
      <title>mimizae 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8174379/attach/eb7f7150e4414601ad8f97016763e630</url>
      <link>https://mimizae.tistory.com</link>
    </image>
    <item>
      <title>인증/인가 전략</title>
      <link>https://mimizae.tistory.com/40</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA에서 &lt;a href=&quot;https://mimizae.tistory.com/39&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;보안에 대해 공부&lt;/a&gt;하며 인증/인가 관련 전략을 구성했던 전반적인 과정을 담은 글입니다!  &lt;/span&gt;&lt;span data-phocus=&quot;https://blog.kakaocdn.net/dna/dmtKBF/dJMcagLoHkH/AAAAAAAAAAAAAAAAAAAAAPvei03A8jPHMU19Q2Z_d6KO2GHODcTvle_iXchwvb7R/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1774969199&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=27KzS4E6eHQHP00QNMgYv9xt3ZY%3D&quot; data-url=&quot;https://blog.kakaocdn.net/dna/dmtKBF/dJMcagLoHkH/AAAAAAAAAAAAAAAAAAAAAPvei03A8jPHMU19Q2Z_d6KO2GHODcTvle_iXchwvb7R/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1774969199&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=27KzS4E6eHQHP00QNMgYv9xt3ZY%3D&quot;&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3DQ4G/dJMcaaYGJRt/jkZYOBWTM5JNLWNyGFsWfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3DQ4G/dJMcaaYGJRt/jkZYOBWTM5JNLWNyGFsWfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3DQ4G/dJMcaaYGJRt/jkZYOBWTM5JNLWNyGFsWfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3DQ4G%2FdJMcaaYGJRt%2FjkZYOBWTM5JNLWNyGFsWfk%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;463&quot; height=&quot;260&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&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;CareNA는 &lt;b&gt;사용자의 건강검진 데이터&lt;/b&gt;를 입력&amp;middot;분석&amp;middot;시각화하는 서비스로, 일반적인 서비스와 달리 &lt;b&gt;개인에게 매우 민감한 의료&amp;middot;건강 정보&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;이기 때문에, 기능 구현 이전에 &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&gt;OAuth 기반 로그인 및 리다이렉트 흐름&lt;/li&gt;
&lt;li&gt;accessToken / refreshToken 분리 관리와 저장 전략&lt;/li&gt;
&lt;li&gt;OCR을 통한 건강검진 문서 텍스트 입력&lt;/li&gt;
&lt;li&gt;인증 상태에 따라 민감 페이지 접근을 제어해야 하는 라우팅 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 &lt;b&gt;보안과 밀접한 요소들이 프론트엔드 전반에 걸쳐 얽혀 있었기 때문에&lt;/b&gt;, 단순히 API를 연결하는 수준이 아니라 &lt;b&gt;인증&amp;middot;인가 구조 전체를 어떻게 설계할 것인가&lt;/b&gt;를 고민하는 과정 자체가 이번 프로젝트에서 가장 도전적인 요소라고 생각했다&amp;hellip;!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  어떤 이유로 도전하게 되었는지&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안은 흔히 서버의 영역으로 인식되지만, CareNA 프로젝트를 진행하며 &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;  특히 다음과 같은 질문들이 문제의 출발점이었다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;accessToken과 refreshToken은 어떤 역할 차이로 &lt;b&gt;분리&lt;/b&gt;되어 있는가?&lt;/li&gt;
&lt;li&gt;토큰은 프론트엔드에서 어디에 저장하는 것이 &lt;b&gt;가장 안전&lt;/b&gt;한가?&lt;/li&gt;
&lt;li&gt;localStorage, sessionStorage, cookie, memory는 각각 &lt;b&gt;어떤 보안적 특성&lt;/b&gt;을 가지는가?&lt;/li&gt;
&lt;li&gt;React 환경에서 &lt;b&gt;XSS, CSRF&lt;/b&gt;는 실제로 어떤 경로로 발생할 수 있는가?&lt;/li&gt;
&lt;li&gt;새로고침, 리다이렉트, OAuth 인증 흐름 속에서 &lt;b&gt;인증 상태는 어떻게 유지&lt;/b&gt;되어야 하는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 라이브러리나 관례에 따라 인증 방식을 적용해왔지만, CareNA에서는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;인증 구조의 선택 이유를 스스로 설명할 수 있는 상태&lt;/b&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;즉, 단순히 기능을 구현하는 데 그치지 않고, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;프론트엔드 관점에서 발생 가능한 보안 위협을 이해한 뒤 책임질 수 있는 영역을 명확히 정의하고 그에 맞는 인증/인가 전략을 직접 설계하는 경험&lt;/b&gt;&lt;/span&gt;을 하고자 했다!!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt; 접근 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1️⃣ 프론트엔드 관점에서 발생 가능한 위협을 먼저 정리&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;를 정리하는 것이었다.&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;CareNA는 사용자가 &lt;b&gt;건강검진 데이터를 입력&lt;/b&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;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&gt;사용자 입력 및 OCR 결과를 통한 &lt;b&gt;XSS 공격 가능성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;쿠키 기반 인증 구조에서 발생할 수 있는 &lt;b&gt;CSRF 공격&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;React 환경에서의 DOM 조작, state 관리로 인한 &lt;b&gt;의도치 않은 민감 정보 노출&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;새로고침, 리다이렉트, OAuth 흐름 속에서 &lt;b&gt;인증 상태가 흔들리는 구간&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2️⃣ 프론트엔드 단독으로 책임질 수 있는 것과, 서버의 도움이 필요한 것을 분리&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;프론트엔드에서 기본적으로 반드시 지켜야 하는 것&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;XSS 방어 (입력&amp;middot;출력 처리, DOM 조작 방식)&lt;/li&gt;
&lt;li&gt;CSRF 기본 방어 인식&lt;/li&gt;
&lt;li&gt;민감 데이터 렌더링 및 state 관리 전략&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버의 도움이 반드시 필요한 영역&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;refreshToken을 HttpOnly 쿠키로 관리&lt;/li&gt;
&lt;li&gt;토큰 탈취 방지를 위한 쿠키 옵션(Secure, SameSite)&lt;/li&gt;
&lt;li&gt;토큰 재발급 흐름의 서버 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프레임워크 특성과 연결되는 보안 고려 사항&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React의 기본 escape 동작&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;dangerouslySetInnerHTML&lt;/span&gt; 사용 시의 위험성&lt;/li&gt;
&lt;li&gt;리렌더링으로 인한 민감 정보 재노출 가능성&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&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;1차 인증/인가 전략&lt;/b&gt;을 구성했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;a href=&quot;https://discovered-muskox-349.notion.site/2eae36bc38f48061a558d68046d5af57&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;인증/인가 전략을 구성하기 위해 서버와 논의하며 정한 사용자 인증 흐름, 토큰 저장 방식과 구현까지의 과정을 자세히 담은 아티클&lt;/a&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;위의 아티클을 요약하자면,&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;토큰 탈취(XSS)&lt;/b&gt; 와 &lt;b&gt;쿠키 기반 인증에서의 위조 요청(CSRF)&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;이러한 위험을 최소화하기 위해, &lt;b&gt;accessToken은 JavaScript로 접근 가능한 저장소를 배제하고 메모리에서만 관리&lt;/b&gt;하고, &lt;b&gt;refreshToken은 서버의 책임 영역으로 두어 HttpOnly 쿠키로 관리&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;또한 메모리에 저장된 accessToken이 새로고침 시 사라지는 특성을 고려해, 앱 시작 시 refreshToken을 기준으로 인증 상태를 다시 판단하도록 하고, 이 과정이 끝나기 전까지는 인증 여부를 단정하지 않도록 &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;되고, 각 페이지와 라우트는 그 결과를 안전하게 소비하는 구조를 만들 수 있었다!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;  &lt;/span&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차로 구성한 인증/인가 전략을 팀원과 공유한 뒤 논의의 시간을 가졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;(이 논의의 시간이 없었다면 아마 지금까지 못했을지도 모르겠다&amp;hellip; ☠️)&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;이 과정에서 드러난 문제는, &lt;b&gt;인증 로직과 라우팅 로직이 마운트 타이밍에서 서로 충돌할 수 있다&lt;/b&gt;는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 인증 전략 자체는 논리적으로 맞더라도, &lt;b&gt;React 앱이 다시 마운트되는 순간&lt;/b&gt;과 &lt;b&gt;ProtectedRoute가 인증 여부를 판단하는 시점&lt;/b&gt;을 함께 고려하지 않으면 의도한 로그인 흐름이 성립하지 않을 수 있었다&amp;hellip;!!!!&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;이러한 논의를 바탕으로 1차 리팩토링을 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1️⃣ 회원가입 페이지 라우팅 구조 변경&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;span style=&quot;color: #ef5369;&quot;&gt;ProtectedRoute&lt;/span&gt; 하위에 위치해 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 회원가입 페이지로 진입하더라도 앱이 최초 마운트되면서 AuthInitializerProvider가 실행되고, 아직 토큰이 발급되지 않은 상태이기 때문에 즉시 로그인 페이지로 강제 라우팅되는 문제가 발생했다.&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: #ef5369;&quot;&gt;PublicRoute&lt;/span&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;size18&quot;&gt;&lt;b&gt;2️⃣ 토큰 교환 로직을 HomePage &amp;rarr; OAuth Callback 페이지로 이동&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;초기에는 회원가입 성공 후 HomePage로 리다이렉트하고, 그 시점에 카카오 인가 코드 기반 토큰 교환 API를 호출하여 accessToken과 refreshToken을 발급받는 흐름을 구상했다.&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;앱이 HomePage에 도착하는 순간 앱이 새로 마운트&lt;/li&gt;
&lt;li&gt;마운트 시점에 AuthInitializerProvider가 실행&lt;/li&gt;
&lt;li&gt;AuthInitializerProvider는 accessToken 재발급 요청을 시도&lt;/li&gt;
&lt;li&gt;이 시점에는 아직 토큰 교환이 이루어지지 않았으므로 accessToken, refreshToken 모두 없는 상태&lt;/li&gt;
&lt;li&gt;그 결과 ProtectedRoute 하위에 있는 HomePage는 토큰이 없다고 판단되어 즉시 로그인 페이지로 리다이렉트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, HomePage에 진입하자마자 토큰 교환 로직이 실행되기 전에 라우팅이 끊겨 의도했던 로그인 흐름 자체가 성립할 수 없는 구조였다... ☠️&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/callback 페이지를 새로 생성했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회원가입 성공 이후의 리다이렉트 경로를 auth/callback으로 변경&lt;/li&gt;
&lt;li&gt;해당 페이지는 PublicRoute 하위에 위치&lt;/li&gt;
&lt;li&gt;토큰이 없는 상태에서도 접근 가능&lt;/li&gt;
&lt;li&gt;이 페이지에서 인가 코드 기반 토큰 교환을 먼저 수행&lt;/li&gt;
&lt;li&gt;토큰 발급이 완료된 이후, 클라이언트에서 HomePage로 라우팅&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;그러나&amp;hellip; ㅠㅠ 실제 플로우를 따라 테스트를 진행하면서 또 하나의 구조적인 문제가 드러났다.&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;/ul&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;회원으로 등록되어 accessToken이 발급된 이후에만&lt;/b&gt; 건강검진 정보를 저장할 수 있었다&amp;hellip;!!!&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기본 정보 입력 &amp;rarr; 회원가입 완료 (회원 생성)&lt;/li&gt;
&lt;li&gt;accessToken 발급 이후 &amp;rarr; 건강검진 정보 입력&lt;/li&gt;
&lt;/ol&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;  &lt;/span&gt;회고&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 경험을 통해 단순히 &lt;b&gt;새로고침 시 토큰이 유지되는가?&lt;/b&gt; 같은 표면적인 흐름만 보는 것이 아니라, &lt;b&gt;React 앱이 언제 마운트되고, 그 시점에 어떤 인증 로직과 라우팅 로직이 실행되는지&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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;앱의 전체 생명주기와 실행 순서를 먼저 그려보고, 각 단계에서 어떤 상태와 토큰이 존재하는지&lt;/b&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;또한 이번 작업은 혼자였다면 끝까지 밀어붙이기 어려웠을 것이라는 생각이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차로 구성한 전략을 팀원들과 공유하고, 의도하지 않은 충돌 가능성을 함께 논의하는 과정에서 문제를 더 빠르게 인지할 수 있었고, 결과적으로 인증 흐름을 한 단계 더 안정적으로 다듬을 수 있었다!!&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;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/40</guid>
      <comments>https://mimizae.tistory.com/40#entry40comment</comments>
      <pubDate>Sun, 1 Mar 2026 16:19:20 +0900</pubDate>
    </item>
    <item>
      <title>프론트엔드의 보안</title>
      <link>https://mimizae.tistory.com/39</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA를 본격적으로 시작하기 전, 보안에 대해 공부한 과정을 담은 글입니다!  &lt;/span&gt;&lt;span data-phocus=&quot;https://blog.kakaocdn.net/dna/FEAHe/dJMcacWlLSk/AAAAAAAAAAAAAAAAAAAAAC6jRf5qB24VPFOKWm1olrcB6funna3bp0klwn4q3S4w/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1774969199&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=gpBtqjypTnvxqCgLi3CqpEswm9Y%3D&quot; data-url=&quot;https://blog.kakaocdn.net/dna/FEAHe/dJMcacWlLSk/AAAAAAAAAAAAAAAAAAAAAC6jRf5qB24VPFOKWm1olrcB6funna3bp0klwn4q3S4w/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1774969199&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=gpBtqjypTnvxqCgLi3CqpEswm9Y%3D&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmtKBF/dJMcagLoHkH/kb91RjKSocJjTQ9nGSHxrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmtKBF/dJMcagLoHkH/kb91RjKSocJjTQ9nGSHxrk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmtKBF/dJMcagLoHkH/kb91RjKSocJjTQ9nGSHxrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmtKBF%2FdJMcagLoHkH%2Fkb91RjKSocJjTQ9nGSHxrk%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;463&quot; height=&quot;260&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스 CareNA는 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;사용자의&amp;nbsp;건강검진 데이터 해설&lt;/span&gt;&lt;/b&gt;을 통해 본인의 건강에 대해 쉽게 이해할 수 있도록 도와주며 실제&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;3&quot;&gt;&lt;b&gt; 건강관리를 위한&lt;/b&gt; &lt;b&gt;운동과 식단같은 행동 정보도 쉽게 탐색&lt;/b&gt;&lt;/span&gt;할 수 있는 건강관리 서비스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czAoLw/dJMb996yXX6/cS2ET6NUeVTZLfuVAs8B3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czAoLw/dJMb996yXX6/cS2ET6NUeVTZLfuVAs8B3K/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;1324&quot; data-filename=&quot;blob&quot; width=&quot;298&quot; height=&quot;843&quot; data-widthpercent=&quot;26.01&quot; style=&quot;width: 25.7067%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czAoLw/dJMb996yXX6/cS2ET6NUeVTZLfuVAs8B3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FczAoLw%2FdJMb996yXX6%2FcS2ET6NUeVTZLfuVAs8B3K%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;468&quot; height=&quot;1324&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oTxbC/dJMcabJ6ixe/pV0zblgdQUzsuuEi9u5Lhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oTxbC/dJMcabJ6ixe/pV0zblgdQUzsuuEi9u5Lhk/img.png&quot; data-origin-width=&quot;1446&quot; data-origin-height=&quot;1438&quot; data-is-animation=&quot;false&quot; data-filename=&quot;blob&quot; style=&quot;width: 73.1305%;&quot; data-widthpercent=&quot;73.99&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oTxbC/dJMcabJ6ixe/pV0zblgdQUzsuuEi9u5Lhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoTxbC%2FdJMcabJ6ixe%2FpV0zblgdQUzsuuEi9u5Lhk%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;1446&quot; height=&quot;1438&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&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;br /&gt;즉!!!&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;민감정보(건강 검진 데이터)를 다뤄야 한다...&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;아직 와이어 프레임 단계이지만!!!!! 기본적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;건강 검진 데이터 입력&lt;/span&gt;&lt;/b&gt;을 받아야 하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;3&quot;&gt;건강 검진 데이터 분석 결과를 렌더링&lt;/span&gt;&lt;/b&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;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;민감 정보 입력&lt;/b&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;민감 정보 렌더링&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;두 측면에서 보안을 신경 써야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 보안으로는 완전한 방어선을 구축할 수는 없지만&amp;hellip;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;최소한으로는 챙겨야 할 요소&lt;/b&gt;&lt;/span&gt;들에는 무엇이 있을까?&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;0. XSS, CSRF 방어&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;XSS란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 악성 스크립트를 &lt;b&gt;신뢰되는 웹 사이트의 응답&lt;/b&gt;에 섞어 보내서 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;사용자의 브라우저가 그 악성 스크립트를 실행하게 만드는&lt;/b&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;&lt;b&gt;흐름&lt;/b&gt;으로 정리해 보자면&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자가 어떤 URL을 클릭하거나 사이트에 접속을 한다.&lt;/li&gt;
&lt;li&gt;브라우저가 그 사이트에 요청을 보낸다.&lt;/li&gt;
&lt;li&gt;서버가 응답을 보내는데, 해당 사이트가 XSS에 취약한 웹 사이트라서 그 응답 안에 악성 스크립트가 섞여 있다면?&lt;/li&gt;
&lt;li&gt;브라우저는 이를 인지하지 못하고 정상 페이지의 일부로만 인식하고 사이트를 실행하며 그 악성 스크립트 자체를 함께 실행하게 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 인지하지 못하냐면, 브라우저 입장에서는 웹 사이트의 응답 출처가 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;신뢰된 도메인&lt;/b&gt;&lt;/span&gt;에서 온 것이기 때문. 의심할 여지가 없다&amp;hellip;&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;b&gt; &amp;nbsp;XSS 공격의 종류&lt;/b&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;b&gt;Stored XSS &amp;rarr;&lt;/b&gt; XSS에 사용되는 악성 스크립트가 서버에 저장되고 서버의 응답에 담겨오는 XSS&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 게시글 / 댓글 / 프로필 / OCR 텍스트 등 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;입력 가능한 곳에 악성 스크립트를 섞어서 저장할 때&lt;/span&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;이제 피해자가 그 게시글 페이지에 접속하게 되면 서버가 저장된 내용을 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;HTML 응답에 그대로 포함하게 되고&lt;/span&gt;,&lt;/b&gt; 브라우저는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;해당 악성 스크립트를 실행&lt;/b&gt;&lt;span style=&quot;color: #333333;&quot;&gt; 시킨다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772343527724&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(1) 공격자
    게시글/댓글/프로필/OCR 이미지 업로드
    &amp;rarr; &amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt; 포함

(2) 서버
    입력 검증 없이 DB에 저장

(3) 피해자
    해당 페이지 방문

(4) 서버
    DB 내용 &amp;rarr; HTML 응답에 포함

(5) 브라우저
    script 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;Reflected XSS &amp;rarr;&lt;/b&gt; XSS에 사용되는 악성 스크립트가 URL에 삽입되고 서버의 응답에 담겨오는 XSS&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;악성 스크립트가 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;사용자의 요청(Request)에 포함&lt;/b&gt;&lt;/span&gt;되고, 서버가 그 값을 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;응답(Response)에 그대로 반사(reflect)&lt;/b&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;경우 1)&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;pre id=&quot;code_1772343559874&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;1. 사용자가 사이트에 접속
2. 검색창에 입력 (또는 자동 완성 / 추천 / 복붙)
   &amp;rarr; &amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt; 가 포함됨
3. 브라우저가 서버에 요청 전송
4. 서버가 검색어를 그대로 HTML 응답에 포함
5. 브라우저가 script 실행&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경우 2)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 공격자가 악성 스크립트 문자열이 포함된 URL을 만들어 사용자가 이를 클릭하도록 유도하면, 사용자의 브라우저가 해당 요청을 서버에 보내고 서버가 그 값을 응답에 반사(reflect)하면서 XSS가 발생한다. &lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;rarr; 이상한 메일이나 메세지의 url을 함부로 클릭하면 안 되는 이유&amp;hellip;!!!&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772343583573&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(1) 공격자
    https://site.com/search?q=&amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt; -&amp;gt; 링크 생성

(2) 피해자 -&amp;gt; 링크 클릭
    
(3) 서버 -&amp;gt; 요청으로 받은 q 값을 그대로 HTML 응답에 포함

(4) 브라우저 -&amp;gt; script 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;DOM-based XSS &amp;rarr;&lt;/b&gt; 서버 응답은 정상인데, 클라이언트가 DOM을 조작하는 과정에서 악성 스크립트가 실행 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 문제가 없고 프론트엔드 코드의 문제라고 생각하면 됨&amp;hellip;!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772343606441&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div id=&quot;output&quot;&amp;gt;&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
  const data = location.hash.substring(1);
  document.getElementById(&quot;output&quot;).innerHTML = data;
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 만드는 URL &amp;rarr; &lt;a href=&quot;https://site.com/#&quot;&gt;https://site.com/#&lt;/a&gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;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;사용자가 악성 스크립트 파라미터로 이루어진 URL 접속하면 서버는 &lt;b&gt;완전 정상 HTML&lt;/b&gt; 반환하지만 브라우저가 JS을 실행할 때 &lt;span style=&quot;color: #ef5369;&quot;&gt;location.hash&lt;/span&gt; 을 읽고&lt;span style=&quot;color: #ef5369;&quot;&gt; innerHTML&lt;/span&gt;에 그대로 삽입해 &lt;span style=&quot;color: #ef5369;&quot;&gt;&amp;lt;script&amp;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;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;XSS 방지하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XSS는 구조적으로 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;완벽하게 차단하기 어려운 취약점&lt;/b&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;사용자 입력이 서버를 거쳐 다시 브라우저에서 실행되는 구조 자체가 문제의 근원이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 보통 XSS 대응은 한 가지 방법이 아니라, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;여러 단계에서의 방어를 조합&lt;/b&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;크게 보면 반드시 지켜야 할 원칙은 두 가지다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; 입력 시에는 형식 검증 (validation) &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;출력 시에는 컨텍스트 기반 escape (encoding)&lt;/span&gt; &lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;입력 시: 형식 검증 (validation)&lt;/b&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;다만, 입력 단계에서 XSS를 완전히 제거하려고 시도하는 것은 현실적으로 어렵다&amp;hellip;!!!!&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: #ef5369;&quot;&gt;&amp;lt;script&amp;gt;&lt;/span&gt; 같은 &lt;b&gt;특정 문자열을 차단하는 방식&lt;/b&gt;은 &lt;b&gt;우회 방법이 너무 많고&lt;/b&gt;, HTML 태그, 이벤트 핸들러, 인코딩된 스크립트 등 다양한 변형을 모두 고려하기도 어렵다.&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: #9a6bb4;&quot;&gt;&lt;b&gt;악성 스크립트 제거보다는 형식 검증에 초점을 둔다.&lt;/b&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;예를 들어,&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;길이가 비정상적으로 긴 값은 아닌지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와 같은 검증을 통해 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;비정상적인 입력을 1차적으로 걸러내는 역할&lt;/b&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;즉, 입력 검증은 XSS 방지의 보조 수단이지, 결정적인 해결책은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;출력 시: 컨텍스트 기반 escape (encoding)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XSS 방지에서 가장 중요한 단계는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;출력 시점&lt;/b&gt;&lt;/span&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 저장된 데이터가 안전하다고 가정해서는 안 되고, &lt;b&gt;브라우저에 전달되는 순간 반드시 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;escape&lt;/span&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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;데이터가 출력되는 위치(컨텍스트)에 따라 escape 방식이 달라진다&lt;/b&gt;&lt;/span&gt;는 것이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTML 본문에 출력될 때&lt;/li&gt;
&lt;li&gt;HTML 속성 값으로 들어갈 때&lt;/li&gt;
&lt;li&gt;JavaScript 코드 안에 포함될 때&lt;/li&gt;
&lt;li&gt;URL 파라미터로 사용될 때&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;그래서 단순히 한 번 escape 해두는 방식이 아니라, &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;어디에 출력되는지를 기준으로 처리&lt;/span&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;대부분의 서버 사이드 템플릿 엔진이나 프론트엔드 프레임워크는 HTML 출력에 대해 자동 escape 기능을 제공한다. 이 기능을 끄지 않고 사용하는 것만으로도 많은 XSS를 예방할 수 있다.&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: #ef6f53;&quot;&gt;innerHTML&lt;/span&gt;처럼 HTML을 직접 삽입하는 방식은 XSS로 이어질 가능성이 매우 높기 때문에 사용에 각별한 주의가 필요하드아!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;CSRF란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSRF(Cross-Site Request Forgery)는 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;사용자가 의도하지 않은 요청을, 사용자의 권한으로 서버에 보내게 만드는 공격&lt;/span&gt;&lt;/b&gt;이다&amp;hellip;!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용자가&lt;b&gt; 인증된 상태&lt;/b&gt;에서 악의적인 사이트를 방문했을 때, 사용자의 의지와는 상관없이 공격자가 의도한 요청을 해당 웹 애플리케이션에 보내게 만드는 공격 기법!&lt;/span&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;&lt;b&gt;CSRF 공격 흐름&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자가 A 사이트에 로그인한다. &amp;rarr; (쿠키 / 세션이 브라우저에 저장됨)&lt;/li&gt;
&lt;li&gt;사용자는 로그인 상태를 유지한 채 다른 사이트(B 사이트)에 접속한다.&lt;/li&gt;
&lt;li&gt;B 사이트에는 &lt;b&gt;A 사이트로 요청을 보내는 악성 코드&lt;/b&gt;가 숨어 있다.&lt;/li&gt;
&lt;li&gt;사용자의 브라우저가 그 요청을 자동으로 A 사이트에 전송한다.&lt;/li&gt;
&lt;li&gt;브라우저는 쿠키를 &lt;b&gt;아무 생각 없이 함께 보낸다.&lt;/b&gt; 악성 코드에 의해 요청이 보내지지만 이 요청을 보내게 되는 주체는 인증된 브라우저니까!&lt;/li&gt;
&lt;li&gt;서버는 로그인된 사용자의 요청이라고 판단해 해당 요청을 처리한다&amp;hellip;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만 약 에&amp;hellip; 사용자가 은행 웹사이트에 로그인한 상태에서 악성 웹사이트를 방문하게 된 경우&amp;hellip;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자는 사용자의 은행 계좌에서 &lt;b&gt;돈을 이체하는 요청을 자동으로 생성&lt;/b&gt;할 수 있다&amp;hellip;!!!!!!!!&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt; &lt;/span&gt; CSRF 방지하기&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) CSRF Token 사용 &amp;rarr;&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버가 &lt;b&gt;예측 불가능한 랜덤 토큰&lt;/b&gt;을 발급&lt;/li&gt;
&lt;li&gt;이 토큰은 &lt;b&gt;정상 페이지에서만&lt;/b&gt; 얻을 수 있음&lt;/li&gt;
&lt;li&gt;요청 시 토큰이 없거나 틀리면 &lt;b&gt;요청 거부&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CSRF Token 동작 흐름!!&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버가 랜덤한 토큰을 생성한다.&lt;/li&gt;
&lt;li&gt;이 토큰을 HTML 폼이나 헤더에 포함한다.&lt;/li&gt;
&lt;li&gt;요청이 들어오면 서버는 토큰을 검증한다.&lt;/li&gt;
&lt;/ol&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;b&gt;2) SameSite Cookie 설정&lt;/b&gt; &amp;rarr; &lt;/span&gt;쿠키 자체에 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;2&quot;&gt;제약&lt;/span&gt;&lt;/b&gt;을 거는 방식이다!!&lt;/p&gt;
&lt;pre id=&quot;code_1772343902773&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Set-Cookie: session=abcd; SameSite=Lax; Secure; HttpOnly&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;Strict &lt;/span&gt;/ &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;2&quot;&gt;Lax &lt;/span&gt;옵션을 사용하면 &lt;b&gt;&lt;span data-token-index=&quot;4&quot;&gt;다른 사이트에서 오는 요청에는 쿠키가 자동으로 포함되지 않는다.&lt;/span&gt;&lt;/b&gt;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 51.0464%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style7&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 11.965%; text-align: center;&quot;&gt;&lt;b&gt;옵션&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 35.4945%;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 11.965%; text-align: center;&quot;&gt;Strict&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 35.4945%;&quot;&gt;완전 차단 (다른 사이트 요청에 쿠키 안 감)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 11.965%; text-align: center;&quot;&gt;Lax&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 35.4945%;&quot;&gt;대부분의 CSRF 차단 (GET 이동 정도만 허용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 11.965%; text-align: center;&quot;&gt;None&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 35.4945%;&quot;&gt;크로스 사이트 허용 (&lt;b&gt;위험, 반드시 Secure 필요)&lt;/b&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;XSS와 CSRF, 그리고 왜 XSS를 더 먼저 챙겨야 하는지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSRF와 XSS는 둘 다 중요하지만, 실무에서 보안 우선순위를 정할 때는 &lt;b&gt;항상 XSS가 앞에 온다&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;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;1. XSS는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;공격자가 우리 서비스 안에 들어온 상태다&lt;/span&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;CSRF는 어디까지나 &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;반대로 XSS는 다르다&amp;hellip;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XSS가 발생하면 공격자는&lt;span style=&quot;color: #9a6bb4;&quot;&gt; &lt;b&gt;우리 서비스 도메인에서 JavaScript를 실행하는 권한&lt;/b&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;이 순간부터 공격자는 사용자가 누르는 버튼을 감시하고 사용자가 입력하는 값을 읽고 서버 API를 자유롭게 호출하고 응답 데이터까지 전부 확인한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;요청 위조가 아니라 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;권한 탈취 상태&lt;/span&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;2. XSS 하나면 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;CSRF 방어는 무력화&lt;/span&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;CSRF 방어는 &lt;b&gt;공격자는 정상 페이지를 볼 수 없다&lt;/b&gt;는 전제를 깔고 있다. 그래서 CSRF 토큰을 숨겨두는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 XSS가 있으면 이 전제가 깨진다. 공격자는 정상 페이지를 보고 있고 DOM에 접근할 수 있고 토큰이 어디 있는지 다 안다&amp;hellip;&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;CSRF Token &amp;rarr; 그대로 읽어서 사용&lt;/li&gt;
&lt;li&gt;SameSite Cookie &amp;rarr; 동일 사이트 요청이라 의미 없음&lt;/li&gt;
&lt;li&gt;Origin 검사 &amp;rarr; 정상 origin&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;b&gt;XSS는 CSRF 방어를 전부 우회하는 상위 공격&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;3. CSRF는 설정으로 막히는 경우가 많다 (위험하긴 하지만!!)&lt;/b&gt;&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;SameSite=Lax 기본 적용&lt;/li&gt;
&lt;li&gt;JWT + Authorization Header&lt;/li&gt;
&lt;li&gt;CORS 정책 강화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 이유로 &lt;b&gt;의도하지 않아도 CSRF가 막혀 있는 경우가 많다.&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. OCR을 통해 건강검진 통보서 스캔 (텍스트 보안, 스크립트 삽입 방지)&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;OCR 기술이란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR(Optical Character Recognition, 광학 문자 인식)은 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;이미지 안에 있는 글자를 사람이 아니라 컴퓨터가 읽을 수 있는 텍스트로 변환하는 기술&lt;/b&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;조금 더 풀어서 말하면 스캔한 문서, 사진, 캡처 이미지처럼 &lt;b&gt;픽셀로 이루어진 이미지&lt;/b&gt;를 입력으로 받아서 그 안에 포함된 &lt;b&gt;문자(숫자&amp;middot;한글&amp;middot;영문 등)를 인식해 문자열 데이터로 바꾸는 과정&lt;/b&gt;이 OCR이다.&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;이미지에서 텍스트를 추출해 내는 OCR 기술을 구현할 때는 &lt;b&gt;Tesseract.js 라이브러리나 Google Vision API&lt;/b&gt;를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;둘 중에는 Google Vision API가 성능이 더 좋은 것 같다!&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;우리 서비스는 민감 정보인 건강 검진 정보를 사용자의 수기로 입력하게 할 수도, OCR 기술을 통해서 이미지를 스캔해 텍스트를 추출해 낼 수도 있다.&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: #9a6bb4;&quot;&gt;&lt;b&gt;이미지에서 추출해 낸 텍스트&lt;/b&gt;&lt;/span&gt;에 대한 보안을 챙겨야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR 결과는 사용자가 직접 입력한 것도 아니고 외부 라이브러리/모델이 만든 결과이기 때문에 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;OCR 결과 텍스트&lt;/b&gt;를 &lt;b&gt;신뢰할 수 없는 사용자 입력&lt;/b&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;만약&amp;hellip; OCR로 추출한 텍스트를 검증 없이 그대로 저장하고, 이를 다시 HTML 응답에 포함해 렌더링(출력)한다면&amp;hellip; &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;Stored XSS의 입력 경로가 될 수 있다&amp;hellip;!!!!&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR은 &lt;b&gt;입력 수단이 이미지일 뿐&lt;/b&gt;, 보안 관점에서는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;일반 사용자 입력과 동일하게 검증/escape&lt;/b&gt; &lt;/span&gt;해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;프론트엔드 렌더링 시 주의할 점&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR 결과 텍스트는 사용자 편의를 위해 페이지 하단의 input 필드나 화면에 자동으로 렌더링된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 단순히 DOM에 텍스트로 노출되는 것 자체는 문제가 아닐 수 있지만&amp;hellip; &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;HTML로 해석될 여지가 있는 방식의 렌더링은 위험&lt;/b&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;특히 다음과 같은 방식은 피해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;dangerouslySetInnerHTML&lt;/span&gt; 사용&lt;/li&gt;
&lt;li&gt;OCR 결과를 그대로 HTML로 렌더링&lt;/li&gt;
&lt;li&gt;innerHTML 기반 DOM 조작&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR 결과가 &lt;span style=&quot;color: #ef5369;&quot;&gt;&amp;lt;script&amp;gt;&lt;/span&gt; 태그나 이벤트 핸들러 형태로 해석될 수 있는 구조라면, 브라우저는 이를 정상적인 페이지의 일부로 인식하고 실행할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;대응 전략 정리!!&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OCR 결과를 안전하게 처리하기 위해 다음이 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OCR 결과 텍스트를 &lt;b&gt;일반 사용자 입력과 동일하게 취급&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;서버 저장 전 &lt;b&gt;입력값 유효성 검사 및 새니타이징&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;HTML 출력 시 &lt;b&gt;컨텍스트 기반 escape 적용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;프론트엔드에서 HTML 직접 삽입 방식 사용 금지&lt;/li&gt;
&lt;li&gt;third-party script 통제 및 CSP(Content Security Policy) 적용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 OCR 보안의 핵심은 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;OCR 결과 텍스트를 신뢰하지 않는 것&lt;/b&gt;&lt;/span&gt;이다.&lt;/p&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. React와 연관 지어 보자&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;React는 XSS에 자유롭다?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React는 기본적으로 XSS에 강한 프레임워크다. JSX에서 렌더링되는 값은 HTML 문자열로 그대로 삽입되지 않고, 모두 escape된 텍스트 노드로 처리된다.&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;그래서 사용자 입력이나 서버 응답을 JSX에 그대로 출력하더라도, 브라우저에서 스크립트로 실행되는 일은 거의 없다. 이 때문에 React를 쓰면 &lt;b&gt;XSS는 신경 안 써도 된다&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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;React가 의도한 방식으로 렌더링할 때만&lt;/b&gt;&lt;/span&gt; 유지된다&amp;hellip;!!!!&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: #ef5369;&quot;&gt;dangerouslySetInnerHTML&lt;/span&gt;을 사용하거나, &lt;span style=&quot;color: #ef5369;&quot;&gt;ref&lt;/span&gt;를 통해 DOM에 직접 접근해 &lt;span style=&quot;color: #ef5369;&quot;&gt;innerHTML&lt;/span&gt;을 조작하는 순간, React의 XSS 방어는 더 이상 개입하지 않는다. 이 시점부터 React는 &lt;b&gt;이 HTML이 안전한지&lt;/b&gt;에 대해 아무것도 보장하지 않으며, 외부에서 들어온 값이 그대로 DOM에 삽입될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 OCR 결과, 서버에서 내려온 HTML, 사용자 입력을 HTML로 렌더링해야 하는 경우에는 반드시!!!! &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;DOMPurify나 sanitize-html를 이용해 정제 과정을 거쳐야 한다.&lt;/b&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;React의 state는 그냥 평문 저장소이다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 단계 더 나아가 보면, 문제는 XSS뿐만이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React의 &lt;span style=&quot;color: #ef5369;&quot;&gt;state&lt;/span&gt; 자체가 민감 정보 관점에서는 꽤 취약한 위치에 있다. React의 state는 암호화된 저장소도 아니고, 보호된 영역도 아니다. 단순히 메모리에 올라간 평문 데이터일 뿐이다&amp;hellip;!!!!&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;예를 들어 OCR 라이브러리로 신분증이나 건강 정보에서 텍스트를 추출한 뒤, 그 결과를 state에 저장했다면, 그 순간부터 해당 정보는 메모리에 남아 있고 렌더링 대상이 된다.&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;민감 정보(OCR 결과 등)는 필요한 순간에만 &lt;b&gt;로컬 변수 또는 최소한의 state&lt;/b&gt;로 잠깐 다루고, 사용자 확인이 끝나면 &lt;b&gt;즉시 서버로 전송한 뒤 state를 &lt;span style=&quot;color: #ef5369;&quot;&gt;null&lt;/span&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;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;범위와 시간을 최소화하기&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1. 전역 상태나 상위 컴포넌트에는 두지 않는다 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2. 정말 필요한 컴포넌트 내부에서만 관리한다 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3. 사용이 끝나는 즉시 null로 초기화한다&lt;/span&gt; &lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; &amp;nbsp;Re-render는 민감 정보 재노출 이벤트이다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 React의 리렌더링 특성은 민감 정보 관점에서 주의가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서는 부모 컴포넌트가 리렌더되면, props가 바뀌지 않았더라도 자식 컴포넌트가 다시 계산된다.&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;민감 정보를 표시하는 컴포넌트가 있다면, 리렌더링은 곧 그 정보가 DOM에 다시 그려진다는 의미다. 겉보기에는 화면 변화가 없더라도, DOM이 재생성되는 과정 자체가 노출 기회를 늘린다.&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: #9a6bb4;&quot;&gt;&lt;b&gt;한 번 더 접근할 수 있는 순간&lt;/b&gt;&lt;/span&gt;이 되는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 민감 정보를 다룰 때는 성능 최적화 관점이 아니라 &lt;b&gt;노출 최소화 관점&lt;/b&gt;에서 React를 바라볼 필요가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;해당 컴포넌트를 &lt;span style=&quot;color: #ef5369;&quot;&gt;React.memo&lt;/span&gt;로 감싸 props가 실제로 바뀌지 않는 한 다시 렌더링되지 않도록 한다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;필요하다면&lt;span style=&quot;color: #ef5369;&quot;&gt; useMemo&lt;/span&gt;를 사용해 민감 데이터를 기반으로 한 계산 결과도 고정한다!!&lt;/span&gt;&lt;/blockquote&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;ETC&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://alwaysbeen.tistory.com/57&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://alwaysbeen.tistory.com/57&lt;/a&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;span&gt;&lt;/span&gt;&lt;a href=&quot;https://velog.io/@imphj3/JavaScript-tesseract.js를-통해-이미지-한글-텍스트-추출하기&quot;&gt;https://velog.io/@imphj3/JavaScript-tesseract.js를-통해-이미지-한글-텍스트-추출하기&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://bbin-guuuu.tistory.com/88&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://bbin-guuuu.tistory.com/88&lt;/a&gt;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/39</guid>
      <comments>https://mimizae.tistory.com/39#entry39comment</comments>
      <pubDate>Sun, 1 Mar 2026 15:05:17 +0900</pubDate>
    </item>
    <item>
      <title>drop-down 공통 컴포넌트 구현</title>
      <link>https://mimizae.tistory.com/38</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다!  &lt;/span&gt;&lt;span data-phocus=&quot;https://blog.kakaocdn.net/dna/bplc6h/dJMcaioKnVh/AAAAAAAAAAAAAAAAAAAAAGTym3VEv1OQcudZfMbqJW8O2bR1K3ji22SbfFauJpa9/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1772290799&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=bmUpeQFoth88C73ZZdHY5qQmpfo%3D&quot; data-url=&quot;https://blog.kakaocdn.net/dna/bplc6h/dJMcaioKnVh/AAAAAAAAAAAAAAAAAAAAAGTym3VEv1OQcudZfMbqJW8O2bR1K3ji22SbfFauJpa9/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1772290799&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=bmUpeQFoth88C73ZZdHY5qQmpfo%3D&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FEAHe/dJMcacWlLSk/N72eF4cmkKCh0ZV1cYyEVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FEAHe/dJMcacWlLSk/N72eF4cmkKCh0ZV1cYyEVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FEAHe/dJMcacWlLSk/N72eF4cmkKCh0ZV1cYyEVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFEAHe%2FdJMcacWlLSk%2FN72eF4cmkKCh0ZV1cYyEVK%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;463&quot; height=&quot;260&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;shadcn/ui를 사용한 이유&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1771390058868&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm dlx shadcn@latest add select&lt;/code&gt;&lt;/pre&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;shadcn/ui&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;컴포넌트&lt;/b&gt;을 활용해서 drop-down 공통 컴포넌트를 제작했다.&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;shadcn/ui&lt;/span&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;shadcn/ui는 Tailwind CSS를 전제로 설계된 컴포넌트 시스템이라 스타일 충돌이나 설정 이슈가 거의 없고, 컴포넌트가 npm 패키지로 감춰져 있는 구조가 아니라 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;실제 코드가 그대로 프로젝트 안에 생성&lt;/span&gt;&lt;/b&gt;된다는 점이 특히 매력적이었다!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 디자인 요구사항이나 UX 변화가 생겨도 컴포넌트를 뜯어서 바로 수정할 수 있고, 팀 스타일에 맞게 자유롭게 커스터마이징할 수 있어 유지보수와 확장이 훨씬 수월해진다.&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;복잡한 UI 로직을 팀 차원에서 표준화할 수 있다는 점&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;특히 Dropdown, Select, Modal 같은 컴포넌트는 접근성, 키보드 인터랙션, 포커스 관리까지 직접 구현하려면 생각보다 많은 로직과 테스트가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 부분을 shadcn/ui가 이미 잘 설계된 구조로 제공해 주기 때문에, 팀원 &lt;b&gt;모두가 &lt;span data-token-index=&quot;1&quot;&gt;신뢰할 수 있는 기본 구현!! &lt;/span&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;select.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  select.tsx 코드 전문  &lt;/b&gt;&lt;/p&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;pre id=&quot;code_1771390183215&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import * as SelectPrimitive from &quot;@radix-ui/react-select&quot;;
import type * as React from &quot;react&quot;;
import { ChevronSDown } from &quot;../../assets/svg&quot;;
import { cn } from &quot;../../libs/cn&quot;;

const focusReset = `
  outline-none
  focus:outline-none
  focus-visible:outline-none
  focus:ring-0
  focus-visible:ring-0
  focus-visible:shadow-none
`;

function Select({
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Root&amp;gt;) {
  return &amp;lt;SelectPrimitive.Root data-slot=&quot;select&quot; {...props} /&amp;gt;;
}

function SelectGroup({
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Group&amp;gt;) {
  return &amp;lt;SelectPrimitive.Group data-slot=&quot;select-group&quot; {...props} /&amp;gt;;
}

function SelectValue({
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Value&amp;gt;) {
  return &amp;lt;SelectPrimitive.Value data-slot=&quot;select-value&quot; {...props} /&amp;gt;;
}

function SelectTrigger({
  className,
  children,
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Trigger&amp;gt;) {
  return (
    &amp;lt;SelectPrimitive.Trigger
      data-slot=&quot;select-trigger&quot;
      className={cn(
        `group head03-sb-16 data-[disabled]:head03-sb-16 flex h-[4.7rem] w-full min-w-[33.5rem] cursor-pointer items-center rounded-[12px] border border-gray-500 bg-white px-[2rem] py-[1.2rem] data-[state=open]:border-primary-500`,
        focusReset,
        className,
      )}
        {...props}
    &amp;gt;
      {children}
      &amp;lt;SelectPrimitive.Icon className=&quot;ml-[1.2rem] transition-transform duration-500 ease-in-out group-data-[disabled]:hidden group-data-[state=open]:rotate-180&quot;&amp;gt;
        &amp;lt;ChevronSDown /&amp;gt;
      &amp;lt;/SelectPrimitive.Icon&amp;gt;
    &amp;lt;/SelectPrimitive.Trigger&amp;gt;
  );
}

function SelectContent({
  className,
  children,
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Content&amp;gt;) {
  return (
    &amp;lt;SelectPrimitive.Portal&amp;gt;
      &amp;lt;SelectPrimitive.Content
        position=&quot;popper&quot;
        side=&quot;bottom&quot;
        sideOffset={4}
        className={cn(
          `z-50 flex max-h-[18.4rem] w-[var(--radix-select-trigger-width)] flex-col rounded-[8px] bg-white p-[0.8rem] data-[state=open]:animate-[select-slide-down_200ms_ease-out]`,
          className,
        )}
          {...props}
      &amp;gt;
        &amp;lt;SelectPrimitive.Viewport&amp;gt;{children}&amp;lt;/SelectPrimitive.Viewport&amp;gt;
      &amp;lt;/SelectPrimitive.Content&amp;gt;
    &amp;lt;/SelectPrimitive.Portal&amp;gt;
  );
}

function SelectItem({
  className,
  children,
  ...props
}: React.ComponentProps&amp;lt;typeof SelectPrimitive.Item&amp;gt;) {
  return (
    &amp;lt;SelectPrimitive.Item
      data-slot=&quot;select-item&quot;
      className={cn(
        `body03-r-16 flex w-full cursor-pointer items-center gap-[1rem] rounded-[8px] bg-white px-[0.8rem] py-[1rem] transition-colors hover:bg-gray-100 active:bg-gray-100`,
        focusReset,
        className,
      )}
        {...props}
    &amp;gt;
      {children}
    &amp;lt;/SelectPrimitive.Item&amp;gt;
  );
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Select 컴포넌트는 위의 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;pnpm dlx shadcn@latest add select &lt;/span&gt;명령어로 생성된&lt;b&gt; &lt;span data-token-index=&quot;3&quot;&gt;shadcn/ui의 기본 Select&lt;/span&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;shadcn/ui의 Select는 본질적으로 이렇게 구성된다.&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;Radix UI Select&lt;/b&gt; &amp;rarr; 접근성, 포커스 관리, 키보드 제어, open/close 상태&lt;/li&gt;
&lt;li&gt;&lt;b&gt;shadcn/ui 래퍼&lt;/b&gt; &amp;rarr; Tailwind 기반 스타일 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;각 Radix primitive를 얇게 감싼 컴포넌트 모음&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실제 로직은 Radix가 담당하고, shadcn은 이걸 Tailwind로 쓰기 좋게 감싸 놓은 UI 레이어에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 이 기본 생성물에서 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;우리 서비스에 쓰지 않는 서브 컴포넌트는 전부 제거&lt;/span&gt;&lt;/b&gt;하고, 남은 &lt;b&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;Trigger&lt;/span&gt;, &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;5&quot;&gt;Content&lt;/span&gt;, &lt;/b&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;7&quot;&gt;&lt;b&gt;Item&lt;/b&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;shadcn/ui + Radix 조합의 핵심은 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;Radix가 노출해 주는 data attribute를 Tailwind에서 바로 쓸 수 있다는 점&lt;/span&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;pre id=&quot;code_1771390627663&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;SelectPrimitive.Trigger
  className={cn(
    `
    group
    flex w-[33.5rem] h-[4.7rem]
    px-[2rem] py-[1.2rem]
    items-center
    rounded-[1.2rem]
    head03-sb-16 border border-gray-500
    bg-white cursor-pointer

    data-[state=open]:border-primary-500
    data-[disabled]:head03-sb-16
    `,
    focusReset,
    className,
  )}
&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Radix Select는 내부 상태에 따라 자동으로 이런 attribute를 붙인다.&lt;/p&gt;
&lt;pre id=&quot;code_1771390648529&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data-state=&quot;open&quot;
data-disabled&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Tailwind는 이를 그대로 선택자로 쓸 수 있다!!&lt;/p&gt;
&lt;pre id=&quot;code_1771390665177&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data-[state=open]:border-primary-500
data-[disabled]:head03-sb-16&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 덕분에 JS 없이도 열렸을 때 테두리 강조, 비활성화 상태 스타일 같은 UI 규칙을 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;완전히 선언적으로 표현&lt;/span&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;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;group &lt;/span&gt;+ &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;group-data-[state=open] &lt;/span&gt;&lt;/b&gt;패턴을 사용해&lt;/p&gt;
&lt;pre id=&quot;code_1771390703778&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;SelectPrimitive.Icon
  className=&quot;
    transition-transform duration-450
    group-data-[state=open]:rotate-180
    group-data-[disabled]:hidden
  &quot;
&amp;gt;&lt;/code&gt;&lt;/pre&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;그런데&amp;hellip; ☠️☠️☠️ 문제 발생&amp;hellip;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하고 싶었던 트랜지션이 있었는데, select의 content의 opacity + translateY에 transition을 걸어서 열릴 때는 fade in, 닫힐 때는 fade out 되도록 하고 싶었다&amp;hellip;&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;이건 구현 실수나 Tailwind 한계가 아니라, &lt;b&gt;Radix Select의 구조 때문에 원천적으로 불가능한 방식&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;문제를 추적하며 Radix Select의 동작 방식을 살펴보았고, Radix SelectContent는 &lt;span style=&quot;color: #ef5369;&quot;&gt;state=closed&lt;/span&gt;가 되는 순간 DOM에서 &lt;b&gt;즉시 unmount&lt;/b&gt;된다는 사실을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 상태 변화의 흐름이 이렇다. &lt;b&gt;open &amp;rarr; closed &amp;rarr; 바로 DOM 제거&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;span style=&quot;color: #ef5369;&quot;&gt;data-[state=closed]&lt;/span&gt;에 transition 기반 스타일을 적용하더라도 애니메이션이 실행될 시간 자체가 존재하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;(transition은 DOM이 유지된 상태에서만 동작하기 때문에, unmount가 즉시 발생하는 구조에서는 적용 자체가 불가능했다.)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771391187367&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data-[state=closed]:opacity-0
data-[state=closed]:-translate-y-2&lt;/code&gt;&lt;/pre&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; keyframes + animate 방식&lt;/b&gt;으로 해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Radix 공식 문서를 확인한 결과, Select와 같은 컴포넌트에서는 transition 대신 &lt;span data-token-index=&quot;1&quot;&gt;keyframes 기반의 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;animate-in / animate-out &lt;/b&gt;&lt;/span&gt;방식&lt;/span&gt;을 사용하는 것이 권장된다는 점을 알게 되었다.&lt;/p&gt;
&lt;pre id=&quot;code_1771391216000&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data-[state=open]:animate-xxx
data-[state=closed]:animate-yyy&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 방식은 unmount 전에 애니메이션 한 프레임을 보장&lt;/b&gt;해 주기 때문에 닫힘 애니메이션을 구현할 수 있었다!!!&lt;/p&gt;
&lt;pre id=&quot;code_1771391241576&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@layer utilities {
  @keyframes select-slide-down {
    from {
      opacity:0;
      transform:translateY(-8px);
    }
    to {
      opacity:1;
      transform:translateY(0);
    }
  }
  
  @keyframes select-slide-up {
    from {
      opacity:1;
      transform:translateY(0);
    }
    to {
      opacity:0;
      transform:translateY(-8px);
    }
  }
}&lt;/code&gt;&lt;/pre&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: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;global.css &lt;/span&gt;에 keyframes를 정의했다.&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;그리고 SelectContent에 이렇게 적용했다.&lt;/p&gt;
&lt;pre id=&quot;code_1771391324958&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;SelectPrimitive.Content
  className={cn(
    `
    flex flex-col
    w-[var(--radix-select-trigger-width)]
    h-[18.4rem]
    p-[0.8rem]
    rounded-[0.8rem]
    bg-white
    z-50

    data-[state=open]:animate-[select-slide-down_200ms_ease-out]
    data-[state=closed]:animate-[select-slide-up_200ms_ease-in]
    `,
    className,
  )}
&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 Radix의 생명주기와 충돌하지 않으면서도 디자인 명세에 맞는 부드러운 드롭다운 애니메이션을 구현할 수 있었다!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  휴&amp;hellip;!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;drop-down.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트의 역할은 UI 전용 공통 컴포넌트이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 컴포넌트가 이미 &lt;b&gt;정렬 및 포맷까지 끝낸 옵션&lt;/b&gt;을 이 형태로 넘겨주면, DropDown은 그걸 &lt;b&gt;그대로 UI에만 렌더링&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre id=&quot;code_1771390930107&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;interface DropDownOption {
  value:string; // ex) &quot;2025-11-02&quot;
  label:string; // ex) &quot;2025년 11월 02일&quot;
  subLabel:string; // ex) 병원명
}&lt;/code&gt;&lt;/pre&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;날짜 포맷 (2025-11-02 &amp;rarr; 2025년 11월 02일)&lt;/li&gt;
&lt;li&gt;날짜 최신순 정렬&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;날짜 포맷이나 최신순 정렬 같은 로직이 &lt;b&gt;UI 컴포넌트가 감당해야 할 책임이 아니라고 생각했기 때문이다.&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;&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;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;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;그리고&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771391624254&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;SelectPrimitive.Trigger className=&quot;flex items-center ...&quot;&amp;gt;
  {children}
  &amp;lt;SelectPrimitive.Icon className=&quot;ml-[1.2rem] ...&quot; /&amp;gt;
&amp;lt;/SelectPrimitive.Trigger&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SelectTrigger 내부는 이렇게 구성되어 있어서 SelectTrigger가 감싸는 두 label들, span들은 하나의 children으로 여겨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1771391676968&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;SelectTrigger disabled={options.length &amp;lt;= 1 }&amp;gt;
  &amp;lt;span&amp;gt;{selectedOption?.label}&amp;lt;/span&amp;gt;
  &amp;lt;span className=&quot;flex-1 text-left ml-[2rem] body04-r-14&quot;&amp;gt;
    {selectedOption?.subLabel}
  &amp;lt;/span&amp;gt;
&amp;lt;/SelectTrigger&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Trigger는&lt;span&gt;&amp;nbsp;&lt;/span&gt;children과&lt;span&gt;&amp;nbsp;&lt;/span&gt;Icon&lt;span&gt;&amp;nbsp;&lt;/span&gt;사이만 레이아웃을 책임지기에 여기에&lt;span&gt;&amp;nbsp;&lt;/span&gt;gap을 주면&lt;span&gt;&amp;nbsp;&lt;/span&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 style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만... 실제 디자인 요구사항은, 날짜와 병원명 사이 간격 병원명과 아이콘 사이 간격이 각각 서로 달라 트리거 내부의 두 라벨은 Trigger가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;DropDown 쪽에서 직접 간격을 제어&lt;/b&gt;하도록 했다!!&lt;/p&gt;
&lt;pre id=&quot;code_1771391658801&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;span&amp;gt;{option.label}&amp;lt;/span&amp;gt;
&amp;lt;span className=&quot;ml-[1rem] body05-r-12&quot;&amp;gt;{option.subLabel}&amp;lt;/span&amp;gt;&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  최종 구현 예시&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;item이 한 개일 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.20.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VH7Av/dJMcacvk10C/XL3GCUCWLwf8uSieXxhuKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VH7Av/dJMcacvk10C/XL3GCUCWLwf8uSieXxhuKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VH7Av/dJMcacvk10C/XL3GCUCWLwf8uSieXxhuKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVH7Av%2FdJMcacvk10C%2FXL3GCUCWLwf8uSieXxhuKK%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;400&quot; height=&quot;65&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.20.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;130&quot;/&gt;&lt;/span&gt;&lt;/figure&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;gt; 선택한 값을 리스트에 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.32.png&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;502&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDlHpY/dJMcahKa2CC/EqCF1WZ6d7BIilie4iMSJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDlHpY/dJMcahKa2CC/EqCF1WZ6d7BIilie4iMSJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDlHpY/dJMcahKa2CC/EqCF1WZ6d7BIilie4iMSJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDlHpY%2FdJMcahKa2CC%2FEqCF1WZ6d7BIilie4iMSJ1%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;381&quot; height=&quot;265&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.32.png&quot; data-origin-width=&quot;722&quot; data-origin-height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;호버 or 터치 했을 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.51.png&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/olkqy/dJMcacIRPnK/GbEF5ugf2MfguACNLVaKu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/olkqy/dJMcacIRPnK/GbEF5ugf2MfguACNLVaKu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/olkqy/dJMcacIRPnK/GbEF5ugf2MfguACNLVaKu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Folkqy%2FdJMcacIRPnK%2FGbEF5ugf2MfguACNLVaKu1%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;364&quot; height=&quot;282&quot; data-filename=&quot;스크린샷 2026-02-18 오후 2.11.51.png&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt; ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/45&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/38</guid>
      <comments>https://mimizae.tistory.com/38#entry38comment</comments>
      <pubDate>Wed, 18 Feb 2026 14:22:05 +0900</pubDate>
    </item>
    <item>
      <title>button 공통 컴포넌트 구현</title>
      <link>https://mimizae.tistory.com/37</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bplc6h/dJMcaioKnVh/LLTMKGIaKCex8qzW69uwC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bplc6h/dJMcaioKnVh/LLTMKGIaKCex8qzW69uwC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bplc6h/dJMcaioKnVh/LLTMKGIaKCex8qzW69uwC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbplc6h%2FdJMcaioKnVh%2FLLTMKGIaKCex8qzW69uwC0%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;463&quot; height=&quot;260&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;내가 구현을 맡은 UI&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;946&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bF6zmi/dJMcac25Cpc/kran6dWXKYkAgD5HjjcD11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bF6zmi/dJMcac25Cpc/kran6dWXKYkAgD5HjjcD11/img.png&quot; data-alt=&quot;피크마 캡쳐본&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bF6zmi/dJMcac25Cpc/kran6dWXKYkAgD5HjjcD11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbF6zmi%2FdJMcac25Cpc%2Fkran6dWXKYkAgD5HjjcD11%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;449&quot; height=&quot;364&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;946&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;피크마 캡쳐본&lt;/figcaption&gt;
&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;폴더 구조 선정 이유&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1771072916008&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;shared
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp; │&amp;nbsp;
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp; └── ui
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     ├── buttons
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     │&amp;nbsp;&amp;nbsp; ├── add-button.tsx
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     │&amp;nbsp;&amp;nbsp; ├── button.tsx
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     │&amp;nbsp;&amp;nbsp; └── ocr-button.tsx
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     ├── check-box
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     │&amp;nbsp;&amp;nbsp; └── check-box-button.tsx
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;     └── radio-button
│&amp;nbsp;&amp;nbsp; │&amp;nbsp;&amp;nbsp;         └── radio-button.tsx&lt;/code&gt;&lt;/pre&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 기준에 맞춰 공통으로 사용되는 UI 컴포넌트들을 관리하기 위해 &lt;span style=&quot;color: #ef5369;&quot;&gt;shared/ui&lt;/span&gt; 하위에 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼, 체크박스, 라디오 버튼 모두 특정 도메인에 종속되지 않고 여러 화면에서 재사용되는 컴포넌트이기 때문에, 공통 레이어인 &lt;b&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;shared&lt;/span&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;buttons 폴더 내부에서 &lt;span style=&quot;color: #ef5369;&quot;&gt;button.tsx&lt;/span&gt;와 &lt;span style=&quot;color: #ef5369;&quot;&gt;add-button.tsx&lt;/span&gt;, &lt;span style=&quot;color: #ef5369;&quot;&gt;ocr-button.tsx&lt;/span&gt;를 분리한 이유는 UI의 성격 차이 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;button.tsx&lt;/span&gt;는 서비스 전반에서 사용되는 &lt;b&gt;기본 직사각형 버튼이고&lt;/b&gt;, 사이즈나 컬러 등 다양한 variant가 있어서 이를 내부에서 분기 처리하도록 했다.&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: #ef5369;&quot;&gt;add-button.tsx&lt;/span&gt;와 &lt;span style=&quot;color: #ef5369;&quot;&gt;ocr-button.tsx&lt;/span&gt;는 기본 버튼의 변형이라기보다는, 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;b&gt;input&lt;/b&gt;이다. 그래서 버튼 컴포넌트들과는 분리해 &lt;span style=&quot;color: #ef5369;&quot;&gt;check-box&lt;/span&gt;, &lt;span style=&quot;color: #ef5369;&quot;&gt;radio-button&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;여기에서!! &lt;b&gt;이 컴포넌트들을 input을 담당한 팀원이 가져가지 않은 이유는&lt;/b&gt;, 우리 서비스에서의 input 컴포넌트가 단순 선택이 아니라 텍스트 입력과 값 검증, 에러 처리 등 복잡한 입력 로직을 포함하고 있었기 때문이다.&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;체크박스와 라디오 버튼은 input 요소이긴 하지만, 값 입력보다는 &lt;b&gt;선택&lt;/b&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;b&gt; &amp;nbsp;button.tsx&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  button.tsx 코드 전문  &lt;/b&gt;&lt;/p&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;pre id=&quot;code_1771126614699&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { cva, type VariantProps } from &quot;class-variance-authority&quot;;
import * as React from &quot;react&quot;;
import { cn } from &quot;@/shared/libs/cn&quot;;

const buttonVariants = cva(
  `flex w-full items-center justify-center
  bg-primary-400 text-white active:bg-primary-600 transition-default
  disabled:bg-gray-300 disabled:text-gray-600 disabled:cursor-not-allowed`,
  {
    variants: {
      size: {
        sm: &quot;label05-r-14 h-[3.2rem] min-w-[6rem] rounded-[4px] px-[1rem]&quot;,
        lg: &quot;label04-r-16 h-[5.2rem] min-w-[8.4rem] rounded-[8px] px-[2rem]&quot;,
      },
    },
    defaultVariants: {
      size: &quot;lg&quot;,
    },
  },
);

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

export const Button = React.forwardRef&amp;lt;HTMLButtonElement, ButtonProps&amp;gt;(
  ({ className, size, type = &quot;button&quot;, ...props }, ref) =&amp;gt; {
    return (
      &amp;lt;button
        ref={ref}
        type={type}
        className={cn(buttonVariants({ size }), className)}
        {...props}
      /&amp;gt;
    );
  },
);

Button.displayName = &quot;Button&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UI를 일관되게 관리하기 위해&lt;/b&gt; &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;class-variance-authority(cva)&lt;/b&gt;&lt;/span&gt;를 사용해 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼의 사이즈나 상태에 따라 클래스가 달라지는 구조이기 때문에, 조건 분기 로직을 JSX 안에서 처리하기보다는 스타일 변형을 선언적으로 관리하고자 했다!!&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: #ef5369;&quot;&gt;cva&lt;/span&gt;는 &lt;b&gt;variant 기반으로 className을 조합해주는 유틸리티&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;span style=&quot;color: #ef5369;&quot;&gt;buttonVariants&lt;/span&gt;에서는 버튼의 공통 스타일을 기본 클래스로 정의하고, &lt;span style=&quot;color: #ef5369;&quot;&gt;size&lt;/span&gt;라는 variant를 통해 버튼 크기에 따른 스타일을 분기했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;sm&lt;/span&gt;, &lt;span style=&quot;color: #ef5369;&quot;&gt;lg&lt;/span&gt; 사이즈에 따라 높이, 패딩, 폰트 스타일, border-radius가 달라지도록 설정했고, &lt;span style=&quot;color: #ef5369;&quot;&gt;defaultVariants&lt;/span&gt;를 통해 별도 지정이 없을 경우 기본값으로 &lt;span style=&quot;color: #ef5369;&quot;&gt;lg&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;이렇게 함으로써 버튼 사이즈가 추가되더라도 JSX를 수정하지 않고 variant만 확장하면 되도록 구조를 만들었다!!&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771073055123&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const buttonVariants = cva( baseClass, {
  variants: {size: { ... } },
  defaultVariants: {size: &quot;lg&quot; },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ButtonProps 타입은 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt;&lt;/b&gt;&lt;/span&gt;와 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;VariantProps&amp;lt;typeof buttonVariants&amp;gt;&lt;/b&gt;&lt;/span&gt;를 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;상속&lt;/b&gt;&lt;/span&gt;받아 정의했다. &lt;span style=&quot;color: #9d9d9d;&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;&lt;span style=&quot;color: #ef5369;&quot;&gt;ButtonHTMLAttributes&lt;/span&gt;를 사용한 이유는, 이 컴포넌트가 실제로는 &lt;span style=&quot;color: #ef5369;&quot;&gt;&amp;lt;button&amp;gt;&lt;/span&gt; 태그를 래핑한 컴포넌트이기 때문에 o&lt;span style=&quot;color: #ef5369;&quot;&gt;nClick, disabled, type&lt;/span&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;또한 &lt;span style=&quot;color: #ef5369;&quot;&gt;VariantProps&amp;lt;typeof buttonVariants&amp;gt;&lt;/span&gt;를 함께 사용해, size 같은 cva에서 정의한 variant가 자동으로 props 타입에 포함되도록 했다!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 덕분에 size=&quot;sm&quot;처럼 사용할 때 타입 안정성을 유지할 수 있고, 존재하지 않는 variant 값을 전달하는 실수를 방지할 수 있다.&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;span style=&quot;color: #ef5369;&quot;&gt; React.forwardRef&lt;/span&gt;&lt;/b&gt;를 사용해 구현했다. 이는 외부에서 버튼 DOM에 직접 접근해야 하는 경우(포커스 제어, 스크롤 이동 등)를 대비한 것으로, 공통 컴포넌트로서 확장성을 고려한 선택이다.&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;마지막으로 cn 유틸리티를 사용해 buttonVariants에서 생성된 클래스와 외부에서 전달된 className을 &lt;b&gt;병합&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;ocr-button.tsx와 add-button.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  ocr-button.tsx와 add-button.tsx 코드 전문 보기  &lt;/b&gt;&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;add-button.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771126552818&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type * as React from &quot;react&quot;;
import { Plus } from &quot;@/shared/assets/svg&quot;;
import { cn } from &quot;@/shared/libs/cn&quot;;

interface AddButtonProps
  extends React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt; {}

export const AddButton = ({ className, ...props }: AddButtonProps) =&amp;gt; {
  return (
    &amp;lt;button
      type=&quot;button&quot;
      {...props}
      className={cn(
        &quot;flex w-fit items-center justify-center gap-[0.4rem] rounded-[4px] bg-white px-[0.8rem] py-[0.4rem] transition-default active:bg-gray-200&quot;,
        className,
      )}
    &amp;gt;
      &amp;lt;span className=&quot;label06-r-12 text-gray-900&quot;&amp;gt;검진결과추가&amp;lt;/span&amp;gt;
      &amp;lt;Plus aria-hidden=&quot;true&quot; /&amp;gt;
    &amp;lt;/button&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&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;ocr-button.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771126570763&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type * as React from &quot;react&quot;;
import { Scan } from &quot;@/shared/assets/svg&quot;;
import { cn } from &quot;@/shared/libs/cn&quot;;

type OcrButtonProps = React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt;;

export const OcrButton = ({ className, ...props }: OcrButtonProps) =&amp;gt; {
  return (
    &amp;lt;button
      type=&quot;button&quot;
      {...props}
      className={cn(
        &quot;mx-auto flex w-[23.6rem] items-center justify-center gap-[1.2rem] whitespace-nowrap rounded-[8px] border border-primary-600 bg-primary-50 px-[1.6rem] py-[1.2rem] transition-default active:bg-primary-100&quot;,
        className,
      )}
    &amp;gt;
      &amp;lt;Scan aria-hidden=&quot;true&quot; /&amp;gt;
      &amp;lt;span className=&quot;label03-m-12 text-primary-600&quot;&amp;gt;
        검진 결과지 스캔하고 자동 입력받기
      &amp;lt;/span&amp;gt;
    &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;&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;AddButton과 OcrButton는 공통 버튼 컴포넌트(Button)와 역할이 겹쳐 보일 수 있으나!! &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;스타일과 사용 목적이 명확하게 고정된 버튼&lt;/b&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;두 컴포넌트 모두 특정 화면과 흐름에서만 사용되며, 디자인상 아이콘과 텍스트 배치, 색상, 크기가 고정되어 있어 size나 variant로 일반화하기보다는 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;의미 중심의 버튼 컴포넌트&lt;/b&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;구현 자체는 &lt;b&gt;기본적인 버튼 컴포넌트와 크게 다르지 않다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &amp;lt;button&amp;gt; 태그를 그대로 사용하고, React.ButtonHTMLAttributes&amp;lt;HTMLButtonElement&amp;gt;를 상속해 onClick, disabled 등 네이티브 버튼 속성을 그대로 전달받을 수 있도록 했다!!&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;스타일은 Tailwind 클래스 문자열로 고정되어 있으며, cn 유틸리티를 사용해 내부 기본 클래스와 외부에서 전달받은 className을 병합했다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;check-box.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  check-box.tsx 코드 전문  &lt;/b&gt;&lt;/p&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;pre id=&quot;code_1771132833945&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type * as React from &quot;react&quot;;
import { CheckW } from &quot;@/shared/assets/svg&quot;;
import { cn } from &quot;@/shared/libs/cn&quot;;

interface CheckBoxProps
  extends Omit&amp;lt;
    React.InputHTMLAttributes&amp;lt;HTMLInputElement&amp;gt;,
    &quot;type&quot; | &quot;onChange&quot;
  &amp;gt; 
  {
    checked: boolean;
    onChange: (checked: boolean) =&amp;gt; void;
    disabled?: boolean;
}

export const CheckBox = ({ checked, onChange, disabled }: CheckBoxProps) =&amp;gt; {
  return (
    &amp;lt;label
      className={cn(
        &quot;relative inline-flex items-center&quot;,
        disabled ? &quot;cursor-not-allowed&quot; : &quot;cursor-pointer&quot;,
      )}
    &amp;gt;
      &amp;lt;input
        type=&quot;checkbox&quot;
        checked={checked}
        onChange={(e) =&amp;gt; onChange(e.target.checked)}
        disabled={disabled}
        className=&quot;peer sr-only&quot;
      /&amp;gt;
      &amp;lt;span
        className={cn(
          &quot;flex h-[2rem] w-[2rem] items-center justify-center rounded-[4px] border transition-default [&amp;amp;&amp;gt;svg]:opacity-0 peer-checked:[&amp;amp;&amp;gt;svg]:opacity-100&quot;,
          disabled ? &quot;border-gray-500 bg-gray-200&quot; : &quot;border-gray-900 peer-checked:bg-primary-500&quot;,
        )}
      &amp;gt;
        &amp;lt;CheckW /&amp;gt;
      &amp;lt;/span&amp;gt;
    &amp;lt;/label&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&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;기본 HTML checkbox의 스타일 제약을 피하기 위해, &lt;b&gt;실제 input은 숨기고(label + peer)를 활용해 커스텀 UI를 구현한 방식으로 접근했다!!!&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;span style=&quot;color: #ef5369;&quot;&gt;&amp;lt;label&amp;gt;&lt;/span&gt; 안에 &lt;span style=&quot;color: #ef5369;&quot;&gt;input[type=&quot;checkbox&quot;]&lt;/span&gt;와 시각적인 역할을 하는 &lt;span style=&quot;color: #ef5369;&quot;&gt;span&lt;/span&gt;을 함께 배치했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 label을 클릭했을 때 &lt;b&gt;input이 함께 토글&lt;/b&gt;되기 때문에, 별도의 클릭 핸들링 없이도 자연스러운 사용자 인터랙션을 구현할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 checkbox input은 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;sr-only &lt;/span&gt;클래스를 사용해 &lt;span data-token-index=&quot;3&quot;&gt;화면에서는 숨기되,&lt;/span&gt; 포커스나 checked 상태 같은 기본 동작은 그대로 유지하도록 했다.&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;Tailwind의 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;peer &lt;/span&gt;클래스를 사용한 것이 핵심이다!!! 이번에 peer에 대해서 새로 알게 되었는데&amp;hellip;&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;peer&lt;/span&gt;는 &lt;b&gt;&lt;span data-token-index=&quot;2&quot;&gt;특정 요소의 상태를 기준으로, 그 형제 요소의 스타일을 제어할 수 있게 해주는 Tailwind 유틸리티&lt;/span&gt;다.&lt;/b&gt;&lt;/div&gt;
&lt;div&gt;&lt;b&gt;&lt;/b&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컴포넌트에서는 실제 checkbox 역할을 하는 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;input&lt;/span&gt;에 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;peer &lt;/span&gt;클래스를 적용하고, 그 다음 형제 요소인 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;5&quot;&gt;span&lt;/span&gt;에서 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;7&quot;&gt;peer-checked&lt;/span&gt;를 사용해 체크 상태에 따른 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;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;React state&lt;/span&gt;&lt;/b&gt;로 조건 분기해 className을 제어하는 방식이 익숙했지만&amp;hellip;&lt;/p&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1771132650090&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이런 느낌... 으으 복잡~~
const [checked, setChecked] = useState(false);

&amp;lt;input
  type=&quot;checkbox&quot;
  checked={checked}
  onChange={(e) =&amp;gt; setChecked(e.target.checked)}
/&amp;gt;

&amp;lt;span
  className={cn(
    &quot;w-8 h-8 border rounded&quot;,
    checked &amp;amp;&amp;amp; &quot;bg-primary-500&quot;
  )}
&amp;gt;
  &amp;lt;CheckIcon className={checked ? &quot;opacity-100&quot; : &quot;opacity-0&quot;} /&amp;gt;
&amp;lt;/span&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;peer&lt;/span&gt;를 사용하면 &lt;b&gt;&lt;span data-token-index=&quot;2&quot;&gt;input의 상태 자체를 스타일의 기준으로 삼을 수 있어&lt;/span&gt;&lt;/b&gt; 로직과 스타일을 더 깔끔하게 분리할 수 있었다&amp;hellip;&lt;/div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 checkbox나 radio처럼 상태가 단순한 컴포넌트에서는, 이런 방식이 오히려 코드 가독성과 유지보수성 면에서 더 효과적이라고 느꼈다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 label과 함께 사용함으로써 클릭 영역을 자연스럽게 확장할 수 있었고, input을 &lt;span style=&quot;color: #ef5369;&quot;&gt;sr-only&lt;/span&gt;로 숨기면서도 접근성과 기본 동작은 그대로 유지할 수 있었다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;span&lt;/span&gt;에 적용된 스타일은 &lt;b&gt;&lt;span data-token-index=&quot;2&quot;&gt;커스텀 체크박스의 외형을 정의&lt;/span&gt;&lt;/b&gt;한다.&lt;/div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;span style=&quot;color: #ef5369;&quot;&gt;[&amp;amp;&amp;gt;svg]:opacity-0&lt;/span&gt;는 Tailwind의 &lt;b&gt;arbitrary selector&lt;/b&gt; 문법으로, 해당 &lt;span style=&quot;color: #ef5369;&quot;&gt;span&lt;/span&gt;의 &lt;b&gt;직계 자식인 svg 요소에만 스타일을 적용&lt;/b&gt;한다는 의미다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 기본 상태에서는 체크 아이콘(&lt;span style=&quot;color: #ef5369;&quot;&gt;CheckW&lt;/span&gt;)이 렌더링되어 있지만 보이지 않도록 opacity를 0으로 설정한 것이다!!!&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: #ef5369;&quot;&gt;peer-checked:[&amp;amp;&amp;gt;svg]:opacity-100&lt;/span&gt;을 통해 체크 상태일 때만 아이콘이 나타나도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 사용함으로써 조건부 렌더링 없이도, CSS만으로 아이콘의 노출 여부를 제어할 수 있었다 ㅎ.ㅎ&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;radio-button.tsx&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &lt;/b&gt;&lt;b&gt;radio-button&lt;/b&gt;&lt;b&gt;.tsx 코드 전문&lt;span&gt;&amp;nbsp;&lt;/span&gt; &lt;/b&gt;&lt;/p&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;pre id=&quot;code_1771132984043&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { cn } from &quot;@/shared/libs/cn&quot;;

interface RadioButtonProps {
  value: string;
  text?: string;
  checked: boolean;
  onChange: (value: string) =&amp;gt; void;
  name: string;
  disabled?: boolean;
}

export const RadioButton = ({
  value,
  text,
  checked,
  onChange,
  name,
  disabled,
}: RadioButtonProps) =&amp;gt; {
  return (
    &amp;lt;label
      className={cn(
        &quot;flex w-fit cursor-pointer items-center&quot;,
        text &amp;amp;&amp;amp; &quot;gap-[1.2rem] py-[1.2rem] pl-[1.2rem]&quot;,
        disabled &amp;amp;&amp;amp; &quot;cursor-not-allowed&quot;,
      )}
    &amp;gt;
      &amp;lt;input
        type=&quot;radio&quot;
        name={name}
        checked={checked}
        disabled={disabled}
        value={value}
        onChange={() =&amp;gt; onChange(value)}
        className=&quot;peer sr-only&quot;
      /&amp;gt;
        
      &amp;lt;span className=&quot;flex h-[2rem] w-[2rem] flex-shrink-0 items-center justify-center rounded-full border border-gray-700 transition-default peer-checked:border-primary-500 peer-disabled:cursor-not-allowed peer-disabled:border-gray-500 peer-disabled:bg-gray-200 peer-checked:peer-disabled:border-gray-500 peer-checked:peer-disabled:bg-gray-100 [&amp;amp;&amp;gt;span]:opacity-0 peer-checked:[&amp;amp;&amp;gt;span]:opacity-100 peer-disabled:[&amp;amp;&amp;gt;span]:bg-gray-500&quot;&amp;gt;
        &amp;lt;span className=&quot;h-[1rem] w-[1rem] rounded-full bg-primary-500&quot; /&amp;gt;
      &amp;lt;/span&amp;gt;
        
      {text &amp;amp;&amp;amp; &amp;lt;span className=&quot;label04-r-16 text-black&quot;&amp;gt;{text}&amp;lt;/span&amp;gt;}
    
    &amp;lt;/label&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1072&quot; data-origin-height=&quot;544&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tFnEE/dJMb99SRdM9/HrPkZ5V4MjtPx2fKMyrow1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tFnEE/dJMb99SRdM9/HrPkZ5V4MjtPx2fKMyrow1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tFnEE/dJMb99SRdM9/HrPkZ5V4MjtPx2fKMyrow1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtFnEE%2FdJMb99SRdM9%2FHrPkZ5V4MjtPx2fKMyrow1%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;529&quot; height=&quot;268&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1072&quot; data-origin-height=&quot;544&quot;/&gt;&lt;/span&gt;&lt;/figure&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;바로 check가 되었지만 disabled인 상태이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;건강 검진 기록이 이미 등록이 되어있는 상태에서 추가하려고 할 때, 기본 정보 등은 사전에 등록된 건강 검진 기록에 따라 미리 선택된 채로 페이지에 진입하게 된다. 이런 경우에!! 이미 check가 되었지만 disabled인 상태를 사용한다.&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: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;checked + disabled &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;일반적인 disabled 상태와 구분하기 위해, 단순히 회색 처리만 하는 것이 아니라 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;peer-checked:peer-disabled:* &lt;/span&gt;형태의 조합 셀렉터를 사용해 &lt;b&gt;&lt;span data-token-index=&quot;3&quot;&gt;선택은 되어 있지만 비활성화된 상태임을 시각적으로 드러내도록&lt;/span&gt;&lt;/b&gt; 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1771133186470&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;peer-checked:border-primary-500
peer-disabled:border-gray-500
peer-checked:peer-disabled:border-gray-500
peer-checked:peer-disabled:bg-gray-100&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 내부에 있는 원형 indicator 역시 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;[&amp;amp;&amp;gt;span] &lt;/span&gt;arbitrary selector를 사용해 제어했다!!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;data-disabled &lt;/span&gt;같은 커스텀 속성을 사용해 상태를 구분하는 방식도 고려했지만, 최종적으로는 &lt;b&gt;&lt;span data-token-index=&quot;3&quot;&gt;Tailwind에서 제공하는 &lt;/span&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;4&quot;&gt;peer-disabled &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot; data-token-index=&quot;5&quot;&gt;셀렉터를 최대한 활용하는 쪽으로 수정&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이유는 상태 판단 기준을 HTML input의 실제 상태에 맞추는 것이 더 직관적이고, checkbox 컴포넌트와도 동일한 패턴을 유지할 수 있었기 때문이다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  최종 구현 예시&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;534754046-adbe6768-2ea1-4619-a27d-a2e104a656bc.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;549&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxdBR4/dJMcai3hjgs/gEYGlzc3ZivSnaHmIHtdk0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxdBR4/dJMcai3hjgs/gEYGlzc3ZivSnaHmIHtdk0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxdBR4/dJMcai3hjgs/gEYGlzc3ZivSnaHmIHtdk0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bxdBR4/dJMcai3hjgs/gEYGlzc3ZivSnaHmIHtdk0/img.gif&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;400&quot; height=&quot;549&quot; data-filename=&quot;534754046-adbe6768-2ea1-4619-a27d-a2e104a656bc.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;549&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt; ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/42&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/37</guid>
      <comments>https://mimizae.tistory.com/37#entry37comment</comments>
      <pubDate>Mon, 16 Feb 2026 11:23:12 +0900</pubDate>
    </item>
    <item>
      <title>마이페이지 min-height 문제</title>
      <link>https://mimizae.tistory.com/36</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 CareNA 프로젝트에서의 트러블 슈팅 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkAHco/dJMcabwkwLy/XdVt6qQveqZinkKUhkN1sK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkAHco/dJMcabwkwLy/XdVt6qQveqZinkKUhkN1sK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkAHco/dJMcabwkwLy/XdVt6qQveqZinkKUhkN1sK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkAHco%2FdJMcabwkwLy%2FXdVt6qQveqZinkKUhkN1sK%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;518&quot; height=&quot;291&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;문제점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CareNA의 마이페이지를 구현하는 과정에서, 페이지의 콘텐츠 양과 상관없이 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;항상 화면 전체 높이를 채우도록&lt;/b&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;하지만 실제로 페이지를 렌더링해보니, 화면 전체 높이를 차지하기는커녕 페이지 내부 콘텐츠의 높이에 따라 화면이 줄어드는 문제가 발생했다. 특히 콘텐츠가 적은 페이지에서는 하단에 배경이 비어 보이는 현상이 눈에 띄게 나타났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;1428&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cd4shm/dJMcahpNyDi/pgeav48BIIVO36H0SHzBmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cd4shm/dJMcahpNyDi/pgeav48BIIVO36H0SHzBmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cd4shm/dJMcahpNyDi/pgeav48BIIVO36H0SHzBmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcd4shm%2FdJMcahpNyDi%2Fpgeav48BIIVO36H0SHzBmk%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;347&quot; height=&quot;432&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1146&quot; data-origin-height=&quot;1428&quot;/&gt;&lt;/span&gt;&lt;/figure&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;그래서 global.css를 확인해 보니 이미 이렇게&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;body&lt;/span&gt;와 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;#root &lt;/span&gt;에 다음과 같이 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;5&quot;&gt;min-height &lt;/span&gt;를 설정해두고 있었다&amp;hellip; 왜지!! 왜 전체 높이가 안 먹힐까!&lt;/p&gt;
&lt;pre id=&quot;code_1770701561851&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;body {
  min-height: 100vh;
  min-height: 100dvh;
}

#root {
  min-height: 100vh;
  min-height: 100dvh;
}&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;문제 상황의 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 라우팅 구조는 최상위에서 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;Layout &lt;/span&gt;컴포넌트가 공통 레이아웃을 담당하고, 각 페이지는 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;3&quot;&gt;Outlet &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;&lt;b&gt;  Router.tsx 코드 전문&lt;span&gt;&amp;nbsp;&lt;/span&gt; &amp;nbsp;&lt;/b&gt;&lt;/p&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;pre id=&quot;code_1770702175596&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface RouteHandle {
  header?: HeaderVariant;
  title?: string;
}

export const router = createBrowserRouter([
  {
    path: ROUTE_PATH.LOGIN,
    element: &amp;lt;LoginPage /&amp;gt;,
  },
  {
    element: &amp;lt;ProtectedRoute /&amp;gt;,
    children: [
      {
        path: ROUTE_PATH.LAYOUT,
        element: &amp;lt;Layout /&amp;gt;,
        children: [
          {
            index: true,
            element: &amp;lt;Navigate to={ROUTE_PATH.HOME} replace /&amp;gt;,
          },
          {
            path: ROUTE_PATH.HOME,
            element: &amp;lt;HomePage /&amp;gt;,
            handle: { header: &quot;main&quot; } satisfies RouteHandle,
          },
          {
            path: ROUTE_PATH.MY_PAGE,
            element: &amp;lt;MyPage /&amp;gt;,
            handle: {
              header: &quot;back&quot;,
              title: &quot;마이페이지&quot;,
            } satisfies RouteHandle,
           },
           {
             path: ROUTE_PATH.HEALTH_TIP,
             element: &amp;lt;HealthTipPage /&amp;gt;,
             handle: {
               header: &quot;back&quot;,
               title: &quot;생활 속 건강 팁&quot;,
             } satisfies RouteHandle,
           },
           {
             path: ROUTE_PATH.HEALTH_TIP_DETAIL,
             element: &amp;lt;HealthTipDetailPage /&amp;gt;,
             handle: {
               header: &quot;back&quot;,
               title: &quot;건강 팁 상세&quot;,
             } satisfies RouteHandle,
           },
           {
             path: &quot;*&quot;,
             element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;, // TODO: 추후 NotFound 컴포넌트 추가
           },
         ],
       },
     ],
   },
]);&lt;/code&gt;&lt;/pre&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;body
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;#root
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Layout
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Header&lt;/li&gt;
&lt;li&gt;main
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Outlet (각 페이지 컴포넌트)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;실제 페이지 컴포넌트들은 Layout의 자식 요소&lt;/b&gt;&lt;/span&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 중요한 점은&amp;hellip;!!!! &lt;span style=&quot;color: #ef5369;&quot;&gt;body&lt;/span&gt;와 &lt;span style=&quot;color: #ef5369;&quot;&gt;#root&lt;/span&gt;에 설정한 &lt;span style=&quot;color: #ef5369;&quot;&gt;min-height&lt;/span&gt;가 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;자동으로 모든 하위 컴포넌트에 적용되지 않는다는 사실&lt;/b&gt;&lt;/span&gt;이었다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;원인&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 height와 min-height가 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;상속되는 속성이 아니라는 점&lt;/b&gt;&lt;/span&gt;에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&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;&lt;b&gt;1️⃣ height는 상속되지 않는다&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;CSS에서 height는 기본적으로 상속되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자식 요소의 높이는 부모의 높이를 자동으로 따라가지 않으며, 부모의 높이를 기준으로 계산되기 위해서는 부모 요소에 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;명시적인 height 값이 존재해야 한다&lt;/b&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 부모가 &lt;span style=&quot;color: #ef5369;&quot;&gt;height: auto&lt;/span&gt;인 상태라면 자식이 &lt;span style=&quot;color: #ef5369;&quot;&gt;height: 100%&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;&lt;b&gt;2️⃣ min-height 역시 전파 되지 않는다!&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;min-height 또한 상속되는 속성이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부모 요소에&lt;span style=&quot;color: #ef5369;&quot;&gt; min-height: 100vh&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;이 경우 브라우저는 다음과 같이 동작한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;body와 #root는 최소 화면 높이를 보장&lt;/li&gt;
&lt;li&gt;그 안의 Layout, main, 페이지 컴포넌트는 콘텐츠 크기만큼만 높이를 차지&lt;/li&gt;
&lt;li&gt;결과적으로 페이지 전체 높이가 아닌 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;내용 기반 높이&lt;/b&gt;&lt;/span&gt;로 레이아웃이 결정됨&amp;hellip;!!!!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &amp;nbsp;왜 width에서는 이런 문제가 덜 느껴질까?&lt;/span&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 width와 비교하면 더 이해하기 쉽다.&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;width: 100%는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;부모의 가로 폭을 기준으로 계산되는데,&lt;/b&gt;&lt;/span&gt; 대부분의 경우 부모 요소는 이미 화면 너비를 차지하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 별다른 설정 없이도 가로 길이는 자연스럽게 맞아떨어진다&amp;hellip; ☠️☠️&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;해결&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 한번 상기해 보자면, 문제의 원인은 &lt;span style=&quot;color: #ef5369;&quot;&gt;body&lt;/span&gt;와 &lt;span style=&quot;color: #ef5369;&quot;&gt;#root&lt;/span&gt;에는 화면 높이에 대한 기준이 있었지만 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;실제로 화면을 채워야 하는 페이지 컴포넌트에는 그 기준이 전달되지 않았다는 점&lt;/b&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;height나 min-height는 &lt;b&gt;상속되는 속성이 아니기 때문에&lt;/b&gt;, 부모 요소가 아무리 min-height: 100vh를 가지고 있어도 자식 요소는 기본적으로 &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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;전체 높이가 필요한 페이지 루트 요소에 직접 높이를 명시하는 것&lt;/b&gt;&lt;/span&gt;이었다.&lt;/p&gt;
&lt;pre id=&quot;code_1770701677203&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;return (
  &amp;lt;div className=&quot;min-h-dvh bg-white px-[2rem]&quot;&amp;gt;
    ...
  &amp;lt;/div&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 각 페이지의 최상위 div에 min-h-dvh를 지정해주면,&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;뷰포트 높이를 기준으로 레이아웃을 계산&lt;/b&gt;하게 되고&lt;/li&gt;
&lt;li&gt;내부 콘텐츠가 적더라도 화면을 꽉 채우며&lt;/li&gt;
&lt;li&gt;하위 요소들의 flex, sticky, absolute 레이아웃도 안정적으로 동작한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이 문제는 &lt;b&gt;왜 body에 높이를 줬는데 적용되지 않지?&lt;/b&gt;가 아니라, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;어떤 요소가 실제로 화면 전체를 책임져야 하는가?를 명확히 하지 않았던 것&lt;/b&gt;&lt;/span&gt;에 가까웠다&amp;hellip;!!!!&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/36</guid>
      <comments>https://mimizae.tistory.com/36#entry36comment</comments>
      <pubDate>Tue, 10 Feb 2026 14:45:20 +0900</pubDate>
    </item>
    <item>
      <title>ticker 구현기</title>
      <link>https://mimizae.tistory.com/35</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wvsUl/dJMcafZMWGB/kKZgDoLZxLYVH6d2B9vV81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wvsUl/dJMcafZMWGB/kKZgDoLZxLYVH6d2B9vV81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wvsUl/dJMcafZMWGB/kKZgDoLZxLYVH6d2B9vV81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwvsUl%2FdJMcafZMWGB%2FkKZgDoLZxLYVH6d2B9vV81%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;388&quot; height=&quot;218&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;내가 구현해야 할 UI&lt;/b&gt;&lt;/h3&gt;
&lt;p 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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;ticker&lt;/b&gt;&lt;/span&gt;는 사용자의 건강검진 상태에 맞게 맞춤 건강 팁들을 홈에서 세로로 자동 스크롤 되며 보여주는 기능을 갖고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1242&quot; data-origin-height=&quot;990&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FFXIz/dJMcaflcwA9/KiCXPZmHUJkXIA7Dur9CnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FFXIz/dJMcaflcwA9/KiCXPZmHUJkXIA7Dur9CnK/img.png&quot; data-alt=&quot;피그마 캡쳐본&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FFXIz/dJMcaflcwA9/KiCXPZmHUJkXIA7Dur9CnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFFXIz%2FdJMcaflcwA9%2FKiCXPZmHUJkXIA7Dur9CnK%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;308&quot; height=&quot;246&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1242&quot; data-origin-height=&quot;990&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;피그마 캡쳐본&lt;/figcaption&gt;
&lt;/figure&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;이 컴포넌트를 처음 보고 vetical로 자동으로 올라가는 걸 어떻게 구현할지 고민하다가 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;자동으로 올라간다는 것&lt;/span&gt;&lt;/b&gt;과 방향만 다를 뿐&amp;nbsp;&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;3&quot;&gt;이게 가로였다면&lt;/span&gt;&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt; 기능 구현&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 ticker의 핵심 원리는 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;위치 이동 + 상태 관리&lt;/span&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&gt;여러 개의 텍스트를 세로로 쭉 나열해 두고&lt;/li&gt;
&lt;li&gt;지금 보여줘야 할 요소의&lt;span style=&quot;color: #9a6bb4;&quot;&gt; &lt;b&gt;index를 state&lt;/b&gt;&lt;/span&gt;로 관리&lt;/li&gt;
&lt;li&gt;translateY를 이용해 컨테이너 자체를 위로 이동시켜 마치 한 줄씩 위로 올라가는 것처럼 보이게 하자.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;1️⃣&lt;/span&gt;&amp;nbsp;&lt;span data-token-index=&quot;1&quot;&gt;팁 배열 확장 (무한 루프를 위한 준비)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770618181847&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const tips = [&quot;팁1&quot;, &quot;팁2&quot;, &quot;팁3&quot;];
const extendedTips = [&quot;팁1&quot;, &quot;팁2&quot;, &quot;팁3&quot;, &quot;팁1&quot;]; // 0번째 index를 확장한 버전&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #9a6bb4;&quot;&gt;&amp;nbsp;&lt;b&gt;자연스럽게 한 칸 더 내려가는 애니메이션&lt;/b&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;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt;2️⃣&lt;/span&gt; 자동으로 index를 증가시키는 로직&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770618501401&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const autoScrollInterval = setInterval(() =&amp;gt; {
    setIsTransitionEnabled(true);
    setVisibleIndex((prev) =&amp;gt; prev + 1);
  }, 3000);
  
  return () =&amp;gt; clearInterval(autoScrollInterval);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3초마다 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;visibleIndex&lt;/b&gt;&lt;/span&gt;를 1씩 증가시킨다. 이때는 항상 transition을 켜둔 상태로 이동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 translateY(-&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;visibleIndex&lt;/b&gt;&lt;/span&gt; * ITEM_HEIGHT) 값이 바뀌면서 한 줄씩 위로 스르륵 이동한다!!&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;span data-token-index=&quot;0&quot;&gt;&lt;span&gt;3️⃣&lt;/span&gt; 마지막에 도달했을 때 순간이동 처리&lt;/span&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;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;Ticker의 핵심 포인트&lt;/b&gt;&lt;/span&gt;이다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ef5369;&quot;&gt;visibleIndex === tips.length&lt;/span&gt;가 되는 순간, 즉 팁 배열의 마지막 아이템까지 보여준 순간은 복제된 팁 1이 화면에 보인 상태이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서 transition을 끄고 index만을 강제로 0으로 되돌린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, transition이 꺼져 있고, index만 수정하는 것이기 때문에 사용자는 이 위치 변경을 전혀 인식하지 못하며!!!!! 실제로 화면에서는 다음과 같이 보일 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770618581294&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;0px
&amp;darr;
-35px (한 줄 아래로 -&amp;gt; 팁 2로)
&amp;darr;
-70px (두 줄 아래로 -&amp;gt; 팁 3으로)
&amp;darr;
-105px (복제 팁1으로 )
&amp;darr;
(순간이동...)
0px (강제로, index만 변경!! 화면은 여전히 복제된 팁 1이 보인다)
&amp;darr;
-35px (한 줄 아래로 -&amp;gt; 팁 2로)&lt;/code&gt;&lt;/pre&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;복제된 팁 1 &amp;rarr; 진짜 팁 1&lt;/b&gt;로 바뀌는 순간 화면에는 동일한 내용이 보이기 때문에 사용자는 위로 튕겨 다시 올라갔다는 느낌 없이 계속 아래로 흐르는 것처럼 인식하게 된다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;(index가 교체될 뿐, 애니메이션은 없는 상태)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 interval에서 index가 다시 증가하면서 자연스럽게 팁 2 &amp;rarr; 팁 3 &amp;rarr; &amp;hellip; 순서로 이어진다!!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;UI&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1770618759510&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div
  className={cn(
    &quot;will-change-transform&quot;,
    isTransitionEnabled &amp;amp;&amp;amp; &quot;transition-transform duration-400 ease-in-out&quot;,
  )}
  style={{ transform: `translateY(-${visibleIndex * ITEM_HEIGHT}px)` }}&amp;gt;
    {extendedTips.map((tip) =&amp;gt; (
      &amp;lt;div
        key={tip.id}
        className=&quot;h-[3.5rem] overflow-hidden text-ellipsis whitespace-nowrap&quot;&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  모든 문자열을 세로로 쭉 늘어놓고, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;컨테이너를 통째로 위로 밀어 올리면서 input 창 안에 보이는 한 줄만 바꿔&lt;/span&gt; 보여주는 구조&lt;/b&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;&lt;b&gt;1️⃣ 위치 이동 방식 (translateY)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770618890030&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;transform: translateY(-${visibleIndex * ITEM_HEIGHT}px)&lt;/code&gt;&lt;/pre&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&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;고정된 높이(ITEM_HEIGHT)&lt;/b&gt;&lt;/span&gt;를 가지고 세로로 나열되어 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재 보여줄 팁의 i&lt;b&gt;ndex(visibleIndex)&lt;/b&gt;에 따라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;translateY(-index * height)&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&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;실제 DOM 구조는 그대로 두고, 위치만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;transform&lt;/b&gt;으로 이동하기 때문에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;리플로우&lt;/b&gt;나&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;리페인트&lt;/b&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-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt;2️⃣&lt;/span&gt; inline style을 사용한 이유&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770618919625&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;style={{ transform: `translateY(-${visibleIndex * ITEM_HEIGHT}px)` }}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 의도적으로 inline style을 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;translateY 값은 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;visibleIndex * ITEM_HEIGHT&lt;/b&gt;&lt;/span&gt;처럼 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;런타임 계산&lt;/b&gt;&lt;/span&gt;이 필요한 값인데, Tailwind 유틸 클래스는 정적인 값만 처리 가능하기 때문에 이 경우 순수 유틸로 표현하기가 어려웠다.&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;따라서 위치 이동처럼 상태에 따라 계속 바뀌는 값은 inline style로 처리하는 것이 가장 명확하고 안전한 방식이라고 판단했다!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;구현 결과&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1770618960224&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Ticker } from &quot;@/shared/ui/ticker/ticker&quot;;

const tips = [
  {
    id: 1,
    title: '건강 팁 한 줄 쪼로록!'
  },
  {
    id: 2,
    title: '건강 팁이 한 줄이 넘어갈 경우에는 말 줄임표를 쓸래 말래 쓸래말래쓸래말래' 
  },
  {
    id: 3,
    title: '단 거 적당히 드세요 ㅋㅋ'
  },
]

export const HomePage = () =&amp;gt; {
  return (
    &amp;lt;div className=&quot;p-[2rem] bg-primary-50&quot;&amp;gt;
      &amp;lt;h1&amp;gt;CareNA&amp;lt;/h1&amp;gt;
      &amp;lt;Ticker tips={tips}/&amp;gt;
     &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 기록 2026-02-09 오후 3.37.44.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/da7Y0L/dJMcajgMwV1/MTERq0gvKjaF7BAo2kmuFk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/da7Y0L/dJMcajgMwV1/MTERq0gvKjaF7BAo2kmuFk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/da7Y0L/dJMcajgMwV1/MTERq0gvKjaF7BAo2kmuFk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/da7Y0L/dJMcajgMwV1/MTERq0gvKjaF7BAo2kmuFk/img.gif&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;584&quot; height=&quot;105&quot; data-filename=&quot;화면 기록 2026-02-09 오후 3.37.44.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/55&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/35</guid>
      <comments>https://mimizae.tistory.com/35#entry35comment</comments>
      <pubDate>Mon, 9 Feb 2026 15:43:59 +0900</pubDate>
    </item>
    <item>
      <title>RouteHandle은 뭐고, 왜 라우트 단에서 헤더를 관리할 수 있을까?</title>
      <link>https://mimizae.tistory.com/34</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트 초기 세팅 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  해당 구조는 웹 파트 리드님의 아이디어로, 스스로 떠올린 아이디어는 아니지만! 처음 접해본 패턴이라 동작 원리와 설계 의도를 이해해보고자 했다. 단순히 사용하는 데 그치지 않고 왜 이런 방식으로 설계했는지, 어떤 문제를 해결하려는 구조인지를 이해하기 위해 관련 문서와 코드를 참고하며 혼자 정리해보는 시간을 가졌다.&lt;br /&gt;그 과정에서 &lt;b&gt;라우트를 단순한 경로 정의가 아니라 페이지 메타데이터를 관리하는 단위로 확장해볼 수 있다는 관점&lt;/b&gt;을 얻었다&amp;hellip;!!!&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpIZbA/dJMcaajRet0/6dhXA0JqcxRmOuXWqmxS0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpIZbA/dJMcaajRet0/6dhXA0JqcxRmOuXWqmxS0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpIZbA/dJMcaajRet0/6dhXA0JqcxRmOuXWqmxS0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpIZbA%2FdJMcaajRet0%2F6dhXA0JqcxRmOuXWqmxS0k%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;394&quot; height=&quot;221&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;문제점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지마다 헤더가 다르다&amp;hellip;!!&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;로그인: 헤더 없음&lt;/li&gt;
&lt;li&gt;etc...&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Router 초기 세팅을 진행하면서 페이지마다 layout을 분리하는 게 어떤지? 하는 제안을 했었는데, 그 원인 또한 &lt;b&gt;페이지마다 헤더가 달랐기 때문이었다...!!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;(Router 초기 세팅을 하며 현재 mvp 단에서는 페이지 수가 많지 않아 큰 문제는 아니지만, 스프린트를 거듭할 수록 유지보수에 어떤 구조가 좋을지 고민했었다.)&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;이걸 구현하려고 하면 보통 다음과 같은 선택지들을 고민하게 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이지 컴포넌트 안에서 &amp;lt;Header /&amp;gt;를 직접 렌더링&lt;/li&gt;
&lt;li&gt;전역 상태로 현재 페이지 타입을 관리&lt;/li&gt;
&lt;li&gt;URL을 기준으로 if/else 분기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이러한 접근 방식들은 공통적으로 &lt;b&gt;페이지의 UI 설정이 라우트 구조와 분리&lt;/b&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;라는 정보를 코드를 통해 직관적으로 파악하기 어렵다는 점이 가장 큰 아쉬움이었다&amp;hellip;!!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt; React Router의 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;handle&lt;/span&gt;이란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Router에서 각 라우트 객체에는&lt;b&gt; &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;handle&lt;/span&gt;&lt;span data-token-index=&quot;2&quot;&gt;이라는 임의의 데이터를 담을 수 있는 필드&lt;/span&gt;&lt;/b&gt;가 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770103763334&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  path: &quot;/home&quot;,
  element: &amp;lt;HomePage /&amp;gt;,
  handle: { header: &quot;main&quot; }
}&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #eb5757;&quot;&gt;handle&lt;/span&gt;&lt;/b&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;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;span style=&quot;color: #eb5757;&quot;&gt;handle&lt;/span&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;이를 통해 페이지 UI에 대한 설정을 라우트 구조 안으로 자연스럽게 끌어올릴 수 있었다!!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770103839270&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface RouteHandle {
  header?: HeaderVariant;
  title?: string;
}&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #eb5757;&quot;&gt;RouteHandle&lt;/span&gt;&lt;/b&gt; 인터페이스는 &lt;b&gt;이 handle에 어떤 정보가 들어갈 수 있는지&lt;/b&gt;를 명확히 정의하기 위한 타입이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;헤더의 형태나 타이틀&lt;/b&gt;과 같이 레이아웃에서 공통으로 참조해야 하는 값들을 타입으로 제한함으로써, 라우트 설정 단계에서의 실수를 줄이고 &lt;b&gt;&lt;span style=&quot;color: #eb5757;&quot;&gt;Layout 컴포넌트&lt;/span&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;음? 근데 이 정보는 어떻게 Layout까지 전달되는 거지?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 등장하는 게 &lt;b&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;useMatches()&lt;/span&gt;&lt;/b&gt;다!!&lt;/p&gt;
&lt;pre id=&quot;code_1770103939704&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const matches = useMatches();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layout 컴포넌트에서는 &lt;b&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;useMatches()&lt;/span&gt;&lt;/b&gt; 훅을 사용해 &lt;b&gt;현재&amp;nbsp; URL과 매칭된 모든 라우트 정보&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;예를 들어, 아래와 같은 라우트 구조를 가지고 있는 &lt;b&gt;/my-page&lt;/b&gt;에 접속한다면&lt;/p&gt;
&lt;pre id=&quot;code_1770104157640&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; &amp;lt;App&amp;gt;
    &amp;lt;ErrorBoundary&amp;gt;
      &amp;lt;AppProvider&amp;gt;
        &amp;lt;AuthInitializerProvider&amp;gt;
          ...
                &amp;lt;ProtectedRoute&amp;gt;
                  &amp;lt;Layout&amp;gt;
                    &amp;lt;MyPage /&amp;gt;
                  &amp;lt;/Layout&amp;gt;
                &amp;lt;/ProtectedRoute&amp;gt;
              ...
        &amp;lt;/AuthInitializerProvider&amp;gt;
      &amp;lt;/AppProvider&amp;gt;
    &amp;lt;/ErrorBoundary&amp;gt;
  &amp;lt;/App&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1770104172440&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[
  { handle: undefined },          // ErrorBoundary
	...
  { handle: undefined },          // ProtectedRoute
  { handle: undefined },          // Layout
  { handle: { header: &quot;back&quot;, title: &quot;마이페이지&quot; } } // MyPage
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ErrorBoundary &amp;rarr; &amp;hellip; &amp;rarr; ProtectedRoute &amp;rarr; Layout &amp;rarr; MyPage 순서로 &lt;b&gt;라우트가 매칭&lt;/b&gt;되며, 각 라우트에 정의된 &lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;&lt;b&gt;handle&lt;/b&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;그래서 Layout에서는 이 배열의 가장 마지막 요소를 현재 페이지로 판단하고,&lt;/p&gt;
&lt;pre id=&quot;code_1770104284389&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const currentMatch = matches[matches.length - 1]; // MyPage!&lt;/code&gt;&lt;/pre&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: #eb5757;&quot;&gt;&lt;b&gt;현재 페이지에 해당하는 라우트의 handle&lt;/b&gt;&lt;/span&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;이 방식 덕분에 Layout은 현재 URL이나 페이지 컴포넌트의 내부 구현을 알 필요 없이!!! 라우트 정의에 선언된 메타 정보만을 기준으로 공통 UI를 제어할 수 있게 된다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;이 패턴의 핵심 장점&lt;/b&gt;&lt;/h3&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;하고, 라우트는 &lt;b&gt;페이지의 성격과 메타 정보를 설명&lt;/b&gt;하며, Layout은 이 정보를 해석해 &lt;b&gt;공통 UI를 제어&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre id=&quot;code_1770104369927&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
	path:ROUTE_PATH.MY_PAGE,
	element:&amp;lt;MyPage /&amp;gt;,
	handle: {
		header:&quot;back&quot;,
		title:&quot;마이페이지&quot;,
	},
}&lt;/code&gt;&lt;/pre&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;b&gt;서브 페이지&lt;/b&gt;라는 사실을 &lt;b&gt;코드 레벨에서 바로 이해할 수 있을 것이다.&lt;/b&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-ke-size=&quot;size26&quot;&gt;&lt;b&gt; &amp;nbsp;전체 코드&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드를 살펴보며 흐름을 더 확실히 이해해 보자&lt;/p&gt;
&lt;pre id=&quot;code_1770104440225&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 헤더 변형 타입
 * - &quot;main&quot;: 메인 페이지용 (로고 + 마이페이지 아이콘)
 * - &quot;back&quot;: 서브 페이지용 (뒤로가기 + 타이틀)
 * - &quot;none&quot;: 헤더 없음
 */
 
export type HeaderVariant = &quot;main&quot; | &quot;back&quot; | &quot;none&quot;;

/**
 * 라우트 핸들 인터페이스
 * - useMatches()로 현재 라우트의 handle을 가져와서 Layout에서 헤더를 동적으로 렌더링
 */

export interface RouteHandle {
  header?: HeaderVariant;
  title?: string;
}

export const router = createBrowserRouter([
  {
    path: ROUTE_PATH.LOGIN,
    element: &amp;lt;LoginPage /&amp;gt;,
  },
  {
    element: &amp;lt;ProtectedRoute /&amp;gt;,
    children: [
      {
        path: ROUTE_PATH.LAYOUT,
        element: &amp;lt;Layout /&amp;gt;,
        children: [
          {
            index: true,
            element: &amp;lt;Navigate to={ROUTE_PATH.HOME} replace /&amp;gt;,
           },
           {
             path: ROUTE_PATH.HOME,
             element: &amp;lt;HomePage /&amp;gt;,
             handle: { header: &quot;main&quot; } satisfies RouteHandle,
            },
            {
              path: ROUTE_PATH.MY_PAGE,
              element: &amp;lt;MyPage /&amp;gt;,
              handle: {
                header: &quot;back&quot;,
                title: &quot;마이페이지&quot;,
              } satisfies RouteHandle,
            },
            {
              path: &quot;*&quot;,
              element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;,
             },
           ],
         },
       ],
     },
   ]
 );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770104739077&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet, useMatches } from &quot;react-router&quot;;
import { Header } from &quot;@/shared/ui/navigations/header&quot;;
import type { RouteHandle } from &quot;./router&quot;;

export const Layout = () =&amp;gt; {
	/**
	 * useMatches(): 현재 URL과 매칭된 모든 라우트 정보를 배열로 반환
	 * 예: /home 접속 시 &amp;rarr; [{ path: &quot;/&quot; }, { path: &quot;home&quot;, handle: { header: &quot;main&quot; } }]
	 *
	 * matches[matches.length - 1]: 가장 마지막(가장 깊은) 라우트 = 현재 페이지
	 */
	const matches = useMatches();
	const currentMatch = matches[matches.length - 1];
	const headerConfig = currentMatch?.handle as RouteHandle | undefined;
	const showHeader = headerConfig?.header !== &quot;none&quot;;

	return (
		&amp;lt;&amp;gt;
			&amp;lt;Header variant={headerConfig?.header} title={headerConfig?.title} /&amp;gt;
			&amp;lt;main className={showHeader ? &quot;pt-[5.6rem]&quot; : &quot;&quot;}&amp;gt;
				&amp;lt;Outlet /&amp;gt;
			&amp;lt;/main&amp;gt;
		&amp;lt;/&amp;gt;
	);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 Layout 컴포넌트에서 &lt;b&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;useMatches() &lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;1&quot;&gt;훅으로 현재 url에 대한 라우트 구조 정보를 끌어와 어떤 페이지 컴포넌트를 렌더링하고 있는지 알아내어서, currentMatch에 저장한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770105467119&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const headerConfig = currentMatch?.handle as RouteHandle | undefined;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 currentMatch(현재 컴포넌트)에 포함된 handle 값을 가져와, 이 프로젝트에서 미리 정의해 둔 RouteHandle 인터페이스 형태로 해석한다. 그리고 현재 페이지에서 사용할 헤더 설정(variant, title)을 추출한다!!&lt;/p&gt;
&lt;pre id=&quot;code_1770105114153&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface RouteHandle {
  header?: HeaderVariant;
  title?: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1770105137586&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Header variant={headerConfig?.header} title={headerConfig?.title} /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 추출한 설정 값을 Header 컴포넌트에 전달하여, 페이지마다 서로 다른 헤더 variant와 title을 렌더링한다.&lt;/p&gt;
&lt;pre id=&quot;code_1770105520304&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const showHeader = headerConfig?.header !== &quot;none&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, showHeader 값을 기준으로 헤더가 있을 때만 padding-top 적용시켜 페이지 컴포넌트들은 레이아웃 신경 안 써도 되도록 했다!&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;nbsp;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/34</guid>
      <comments>https://mimizae.tistory.com/34#entry34comment</comments>
      <pubDate>Tue, 3 Feb 2026 17:03:18 +0900</pubDate>
    </item>
    <item>
      <title>React Route 초기 세팅</title>
      <link>https://mimizae.tistory.com/33</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트의 초기세팅 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2o7SJ/dJMcabiMlNf/kZQtbqsm54ywsgtquK9Wo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2o7SJ/dJMcabiMlNf/kZQtbqsm54ywsgtquK9Wo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2o7SJ/dJMcabiMlNf/kZQtbqsm54ywsgtquK9Wo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2o7SJ%2FdJMcabiMlNf%2FkZQtbqsm54ywsgtquK9Wo0%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;390&quot; height=&quot;219&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;719&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;⚙️&lt;/span&gt;&amp;nbsp;react-router 설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt;❓&lt;/span&gt;&amp;nbsp;왜 react-router-dom이 아니라 react-router?&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;React Router는 &lt;b&gt;v7부터 패키지 구조가 변경&lt;/b&gt;되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 웹 환경에서 react-router-dom을 설치하는 것이 일반적이었지만, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;v7부터는 패키지가 단일화되어 새 프로젝트 기준으로 react-router만 사용하는 것이 권장된다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 package.json을 확인해 보면, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;메인 버전이 7 이상&lt;/b&gt;&lt;/span&gt;으로 시작하는 경우가 대부분이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3lykm/dJMcafFsFrk/nKUB7tjIXDpHLLRv7o2rQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3lykm/dJMcafFsFrk/nKUB7tjIXDpHLLRv7o2rQ0/img.png&quot; data-alt=&quot;실제 설치한 버전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3lykm/dJMcafFsFrk/nKUB7tjIXDpHLLRv7o2rQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3lykm%2FdJMcafFsFrk%2FnKUB7tjIXDpHLLRv7o2rQ0%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;475&quot; height=&quot;204&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;486&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실제 설치한 버전&lt;/figcaption&gt;
&lt;/figure&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;React Router는 &lt;b&gt;v5 &amp;rarr; v6&lt;/b&gt;으로 넘어오면서 라우팅 방식, 훅, API 구조가 크게 변경되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 라우터 관련 검색 시&lt;span style=&quot;color: #ee2323;&quot;&gt; &lt;b&gt;v5 이하 문서나 예제는 현재(v6/v7)에서 동작하지 않을 수 있으니 주의&lt;/b&gt;&lt;/span&gt;가 필요하다&amp;hellip;!!!!!!!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;v6과 v7은 구조와 사용 방식이 매우 유사&lt;/b&gt;&lt;/span&gt;해 v6 기반 예제들도 대부분 참고 가능하다.  &amp;zwj; &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;⚠️ 데이터 라우터 도입 (v6.4+)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Router &lt;b&gt;v6.4부터&lt;/b&gt; 데이터 로딩과 에러 핸들링이 내장된 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;데이터 라우터 방식&lt;/b&gt;&lt;/span&gt;이 도입되었다.&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;0&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;createBrowserRouter &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;+ &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;2&quot;&gt;RouterProvider&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/div&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;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;라우터를 구성&lt;/b&gt;하는데 가장 자주 쓰이는 2가지 모듈.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;createBrowserRouter
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;createBrowserRouter는 브라우저 환경에서 사용할 수 있는 라우터를 생성.&lt;/li&gt;
&lt;li&gt;브라우저의 히스토리 API를 사용하여 페이지 이동을 관리.&lt;/li&gt;
&lt;li&gt;라우트 경로와 그에 대응하는 컴포넌트를 설정하는 데 사용.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;RouterProvider
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React 앱에 라우터 인스턴스를 공급.&lt;/li&gt;
&lt;li&gt;RouterProvider에 라우터를 전달하면, 해당 라우터에 정의된 경로와 컴포넌트 설정을 앱 전체에서 사용할 수 있음.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&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;span data-token-index=&quot;1&quot;&gt;권장되는 라우팅 방식&lt;/span&gt;이다.&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;BrowserRouter&lt;/li&gt;
&lt;li&gt;Routes&lt;/li&gt;
&lt;li&gt;Route&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로 구성하는 기존의 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;선언적 라우팅 방식도 여전히 공식 지원&lt;/b&gt;&lt;/span&gt;되며, v7 문서에서도 별도의 시작 가이드를 제공하고 있다!!!!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;프로젝트의 복잡도와 요구사항에 따라 적절한 방식을 선택하면 된다.&lt;/b&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;&lt;b&gt;⚠️&amp;nbsp;v7에서 react-router-dom은?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v7부터는 react-router-dom이 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;호환용 패키지&lt;/b&gt;&lt;/span&gt;가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 &lt;b&gt;v6에서 v7로 마이그레이션하는 프로젝트를 위한 용도&lt;/b&gt;이며, &lt;b&gt;새 프로젝트 기준으로는 react-router만 사용하는 것이 권장된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;신규 프로젝트 &amp;rarr; react-router &lt;br /&gt;기존 v6 프로젝트 &amp;rarr; 필요 시 react-router-dom 유지&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;라는 기준으로 이해하면 된다!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;⚙️&lt;/span&gt;&amp;nbsp;라우터 구조의 분리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Router를 사용할 때 컴포넌트 내부에서 Route를 직접 나열하는 방식은 소규모 프로젝트에서는 크게 문제가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 프로젝트 규모가 커지고 페이지 수와 라우팅 조건이 늘어나기 시작하면, 라우터 코드가 점점 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;관리하기 어려운 영역&lt;/b&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;특히 다음과 같은 문제가 자주 발생한다.&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;라우팅 구조가 분산되어 있으면 다른 사람이 만든 흐름을 파악하는 데 시간이 오래 걸리고, 의도치 않게 중복되거나 잘못된 경로를 추가할 가능성도 커진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 줄이기 위해 본 프로젝트에서는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;라우터 관련 역할을 명확히 분리한 &lt;b&gt;구조&lt;/b&gt;를 선택했다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYGWbW/dJMcabiMmAt/YDAikk7V85Mpd0ovdq9LBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYGWbW/dJMcabiMmAt/YDAikk7V85Mpd0ovdq9LBK/img.png&quot; data-alt=&quot;실제 파일 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYGWbW/dJMcabiMmAt/YDAikk7V85Mpd0ovdq9LBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYGWbW%2FdJMcabiMmAt%2FYDAikk7V85Mpd0ovdq9LBK%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;359&quot; height=&quot;189&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;228&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실제 파일 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;paths.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770037577242&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const routePath = {
  LAYOUT: '/',
  HOME: '/home',
  MY_PAGE: '/my-page',
  // 페이지 작업 시 추가
} as const;

export type Routes = (typeof routePath)[keyof typeof routePath];&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;paths.ts는 애플리케이션에서 사용되는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;모든 라우트 경로를 한 곳에서 관리&lt;/b&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;라우트 경로를 문자열로 직접 작성하는 대신 상수로 정의함으로써,&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;라우팅 구조를 한눈에 파악할 수 있다!!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 as const와 타입 추출을 통해 라우트 경로를 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;타입 레벨에서도 안전하게 관리&lt;/b&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;size18&quot;&gt;&lt;b&gt;router.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770037632644&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createBrowserRouter, Navigate } from &quot;react-router&quot;;
import { Layout } from &quot;./layout&quot;;
import { routePath } from &quot;./paths&quot;;

export const router = createBrowserRouter([
	{
		path: routePath.LAYOUT,
		element: &amp;lt;Layout /&amp;gt;,
		children: [
			{
				index: true,
				element: &amp;lt;Navigate to={routePath.HOME} replace /&amp;gt;,
			},
			{
				path: routePath.HOME,
				element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;,
			},
			{
				path: routePath.MY_PAGE,
				element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;,
			},
		],
	},
]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;router.tsx는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;애플리케이션 전체의 라우팅 구조를 정의하는 중심 파일&lt;/b&gt;&lt;/span&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;기본 진입 경로(/)에서 어디로 이동할지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와 같은 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;라우팅 흐름을 한눈에 확인&lt;/b&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;특히 Layout을 기준으로 하위 라우트를 구성함으로써, 공통 UI를 사용하는 페이지들을 자연스럽게 그룹화할 수 있다!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;layout.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770037681188&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet } from &quot;react-router&quot;;

export const Layout = () =&amp;gt; {
	return (
		&amp;lt;div&amp;gt;
			{/* Header 위치 */}
			&amp;lt;Outlet /&amp;gt;
		&amp;lt;/div&amp;gt;
	);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;layout.tsx 는 여러 페이지에서 공통으로 사용되는 UI 구조를 담당하는 레이아웃 컴포넌트이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outlet은 현재 라우트에 매칭된 &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;이 있다고 한다. 이렇게 어떤 페이지에는 헤더가 없고, 다른 페이지에는 헤더가 있는 이런 상황은 어떻게 처리해야 할까?  &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;1️⃣ 레이아웃을 분리하는 방법&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;DefaultLayout.tsx &amp;rarr; 헤더 있는 페이지&lt;/p&gt;
&lt;pre id=&quot;code_1770037808254&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet } from &quot;react-router&quot;;
import Header from &quot;@/components/Header&quot;;

export const DefaultLayout = () =&amp;gt; {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;Header /&amp;gt;
      &amp;lt;Outlet /&amp;gt;
    &amp;lt;/&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;0&quot;&gt;BlankLayout.tsx&amp;nbsp; &lt;/span&gt;&amp;rarr; 헤더 없는 페이지&lt;/div&gt;
&lt;pre id=&quot;code_1770037818919&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet } from &quot;react-router&quot;;

export const BlankLayout = () =&amp;gt; &amp;lt;Outlet /&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;이렇게 layout을 나눈다면 router.tsx에서 나눈 layout들을 아래처럼 적용할 수 있을 것이다.&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1770037829754&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createBrowserRouter, Navigate } from &quot;react-router&quot;;
import { Layout } from &quot;./layout&quot;;
import { routePath } from &quot;./paths&quot;;

export const router = createBrowserRouter([
  // 헤더 있는 일반 페이지
  {
    element: &amp;lt;DefaultLayout /&amp;gt;,
    children: [
      { index: true, element: &amp;lt;Navigate to={routePath.HOME} replace /&amp;gt; },
      { path: routePath.HOME, element: &amp;lt;Home /&amp;gt; },
      { path: routePath.MY_PAGE, element: &amp;lt;MyPage /&amp;gt; },
      // 다른 페이지도 여기 추가!!
    ],
  },

  // 헤더 없는 페이지 (로그인 등)
  {
    element: &amp;lt;BlankLayout /&amp;gt;,
    children: [
      { path: routePath.LOGIN, element: &amp;lt;Login /&amp;gt; },
      // 헤더 없는 페이지가 생기면 이곳에!
    ],
  },
]);&lt;/code&gt;&lt;/pre&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt;2️⃣&lt;/span&gt; Layout 안에서 조건부 렌더링&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770037859153&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Outlet, useLocation } from &quot;react-router&quot;;
import Header from &quot;@/components/Header&quot;;

export const Layout = () =&amp;gt; {
  const location = useLocation();
  const hideHeader = location.pathname === &quot;/login&quot;; // 로그인 페이지만 헤더 숨김

  return (
    &amp;lt;div&amp;gt;
      {!hideHeader &amp;amp;&amp;amp; &amp;lt;Header /&amp;gt;}
      &amp;lt;Outlet /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에도, 다양한 방식이 있겠지만! 우리 프로젝트에서 선택한 방식은&lt;b&gt; React Route의 handle&lt;/b&gt;을 이용하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 글은 &lt;a href=&quot;https://mimizae.tistory.com/34&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이곳&lt;/a&gt;을 참고해 주길 바란다!&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt; 인증 라우트? ProtectedRoute?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 &lt;b&gt;건강 검진 기록&lt;/b&gt;이라는 민감한 정보를 다루는 서비스이기 때문에, &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;인증되지 않은 사용자가 내부 페이지에 접근하는 것은 반드시 제한할 필요&lt;/b&gt;&lt;/span&gt;가 있다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 페이지 컴포넌트 내부에서 매번 인증 여부를 확인하는 방식보다는, &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;라우터 레벨에서 접근 자체를 제어하는 방식&lt;/b&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;이러한 요구사항을 해결하기 위한 패턴이 &lt;b&gt;PrivateRoute&lt;/b&gt; 또는 &lt;b&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;3&quot;&gt;ProtectedRoute &lt;/span&gt;&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ProtectedRoute는 사용자의 인증 상태를 확인한 뒤, 조건을 만족하지 못할 경우 특정 페이지로 리다이렉트시키고 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;조건을 만족하는 경우에만 하위 라우트를 렌더링&lt;/span&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&gt;인증 로직을 한 곳에서 관리할 수 있고&lt;/li&gt;
&lt;li&gt;각 페이지 컴포넌트는 인증 여부를 신경 쓰지 않아도 되며&lt;/li&gt;
&lt;li&gt;&amp;ldquo;어떤 페이지가 보호 대상인지&amp;rdquo;를 라우터 구조만 보고도 파악할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;0&quot;&gt;&lt;b&gt;ProtectedRoute.tsx&lt;/b&gt; &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;예시&lt;/span&gt;&lt;/b&gt;&lt;/div&gt;
&lt;pre id=&quot;code_1770037969067&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ProtectedRoute = () =&amp;gt; {
    const { isLoggedIn } = useAuth(); // 사용자가 인증되었는지 확인할 수 있는 훅 예시

    if (!isLoggedIn) {
        // 유저 정보가 없다면 홈으로, 혹은 로그인 페이지로 가게 할 수 있음
        return &amp;lt;Navigate to=&quot;/&quot; replace={true} /&amp;gt;;
    }

    // 유저 정보가 있다면 자식 컴포넌트를 보여줌
    return &amp;lt;Outlet /&amp;gt;;
};

export default ProtectedRoute;&lt;/code&gt;&lt;/pre&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: #333333;&quot; data-token-index=&quot;1&quot;&gt;router.tsx&lt;/span&gt;는 아래와 같이 수정될 것이다!!&lt;/p&gt;
&lt;pre id=&quot;code_1770037986574&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createBrowserRouter, Navigate } from &quot;react-router&quot;;
import { Layout } from &quot;./layout&quot;;
import { ProtectedRoute } from &quot;./ProtectedRoute&quot;;
import { routePath } from &quot;./paths&quot;;

export const router = createBrowserRouter([
  // 공개 라우트 (로그인 / 회원가입 등)
  {
    path: routePath.LOGIN,
    element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;,
  },

  // 보호된 라우트
  {
    element: &amp;lt;ProtectedRoute /&amp;gt;, // Layout은 children에서 처리
    children: [
      {
        path: routePath.LAYOUT,
        element: &amp;lt;Layout /&amp;gt;,
        children: [
           {
            index: true,
            element: &amp;lt;Navigate to={routePath.HOME} replace /&amp;gt;,
          },
          {
            path: routePath.MY_PAGE,
            element: &amp;lt;&amp;gt;&amp;lt;/&amp;gt;,
          },
        ],
      },
    ],
  },
]);&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 라우트를 사용한다면 다음과 같은 흐름을 가질 것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;URL 접근&lt;br /&gt;&amp;rarr; &quot;/&quot; 매칭 &lt;br /&gt;&amp;rarr; ProtectedRoute 실행 &lt;br /&gt;&amp;rarr; 인증 OK &amp;rarr; Layout &amp;rarr; 실제 페이지 &lt;br /&gt;&amp;rarr; 인증 NO &amp;rarr; /login redirect!!&lt;/blockquote&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/21&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/33</guid>
      <comments>https://mimizae.tistory.com/33#entry33comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:44:30 +0900</pubDate>
    </item>
    <item>
      <title>Tanstack Query 초기 세팅</title>
      <link>https://mimizae.tistory.com/32</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트의 초기세팅 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1687&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btRXnJ/dJMcaaRFUCl/LFkRUTfuJvAOYK0IyU5dVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btRXnJ/dJMcaaRFUCl/LFkRUTfuJvAOYK0IyU5dVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btRXnJ/dJMcaaRFUCl/LFkRUTfuJvAOYK0IyU5dVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtRXnJ%2FdJMcaaRFUCl%2FLFkRUTfuJvAOYK0IyU5dVK%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;386&quot; height=&quot;217&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;1687&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;Tanstack Query 사용 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;건강 검진 기록&lt;/b&gt;&lt;/span&gt;이라는 상대적으로 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;민감한 정보를 다루는 서비스&lt;/b&gt;&lt;/span&gt;다. 그래서 처음에는 자연스럽게 이런 고민이 들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  &amp;ldquo;음&amp;hellip; 이거 쿼리 써도 되나? 민감한 정보를 캐시해도 괜찮은가? 캐시라는 행위 자체가 보안적으로 불리한 거 아닌가&amp;hellip;?&amp;rdquo;&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;특히 TanStack Query는 &lt;span style=&quot;color: #9a6bb4;&quot;&gt;&lt;b&gt;캐싱 라이브러리&lt;/b&gt;&lt;/span&gt;라는 인식이 강하다 보니, 민감한 데이터를 &lt;span data-token-index=&quot;3&quot;&gt;오래 클라이언트에 들고 있는 구조&lt;/span&gt;가 보안 측면에서 적절하지 않다고 느껴졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 내가 TanStack Query를 고민했던 이유는 &lt;span style=&quot;color: #2c2c2b;&quot; data-token-index=&quot;1&quot;&gt;캐시를 한다 / 안 한다의 문제&lt;/span&gt;가 아니라, &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;3&quot;&gt;캐시에 너무 집중한 나머지 다른 장점들을 충분히 보지 못했던 것&lt;/span&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는 단순히 데이터를 캐시해 두는 도구라기보다는 서버 상태를 안정적으로 관리하고, 요청 중복을 방지하며, 데이터를 언제 다시 가져올지에 대한 타이밍을 제어할 수 있는 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;서버 상태 관리 도구&lt;/span&gt;&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 캐시는 무조건 오래 유지해야 하는 개념이 아니라, &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;서비스 특성에 맞게 조절할 수 있는 여러 옵션 중 하나&lt;/span&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의 성능과 UX 측면의 장점은 적극적으로 활용하되, 모든 데이터를 동일한 기준으로 다루기보다는 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;데이터의 민감도에 따라 캐시 전략을 분리하는 방식&lt;/span&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의 캐싱 이점을 활용해 불필요한 네트워크 요청을 줄이고 부드러운 사용자 경험을 제공하되, &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;민감한 정보가 포함된 데이터에 한해서는&lt;/span&gt;&lt;/b&gt;&lt;span data-token-index=&quot;2&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;즉!!!!!&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 query는 적절한 staleTime을 설정해 캐시를 활용하고&lt;/li&gt;
&lt;li&gt;건강 검진 기록과 같이 민감한 데이터가 포함된 query는 staleTime을 매우 짧게 설정해 캐시된 데이터를 &lt;b&gt;즉시 stale 상태&lt;/b&gt;로 판단하며&lt;/li&gt;
&lt;li&gt;화면 진입, 포커스 변경, 네트워크 복구 시점마다 자동으로 refetch가 일어나도록 개별 설정한다.&lt;/li&gt;
&lt;li&gt;또한 민감한 데이터의 경우 gcTime을 짧게 설정해 사용이 종료된 캐시가 클라이언트 메모리에 오래 남지 않도록 한다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;⚙️&lt;/span&gt;&amp;nbsp;Tanstack Query 및 devtools 설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt; &lt;/span&gt; 패키지 매니저는 pnpm을 사용한다! 이유는 &lt;a href=&quot;https://mimizae.tistory.com/30&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 글&lt;/a&gt;을 참고해 주길 바란다.&lt;/p&gt;
&lt;pre id=&quot;code_1769650174049&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pnpm add @tanstack/react-query @tanstack/react-query-devtools -D&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt;❓&lt;/span&gt;@tanstack/react-query-devtools 사용 이유&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TanStack Query Devtools는 애플리케이션에서 사용 중인 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;query와 mutation의 상태를 실시간으로 확인하기 위한 개발 도구&lt;/span&gt;&lt;/b&gt;다!!&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 어떤 query가 존재하는지&lt;/li&gt;
&lt;li&gt;각 query의 상태 (idle, loading, success, error)&lt;/li&gt;
&lt;li&gt;stale 여부 및 refetch 여부&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;특히 서버 상태를 다루는 로직은 코드만으로 흐름을 파악하기 어려운 경우가 많기 때문에, Devtools를 통해 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;데이터 흐름과 상태 변화를 시각적으로 확인&lt;/span&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;QueryClient 생성&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;  QueryProvider.tsx 코드&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769650456170&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { QueryClientProvider } from &quot;@tanstack/react-query&quot;;
import { ReactQueryDevtools } from &quot;@tanstack/react-query-devtools&quot;;
import type { ReactNode } from &quot;react&quot;;
import { useState } from &quot;react&quot;;
import { createQueryClient } from &quot;@/shared/apis/query-client&quot;;

export function QueryProvider({ children }: { children: ReactNode }) {
	const [queryClient] = useState(() =&amp;gt; createQueryClient());

	return (
		&amp;lt;QueryClientProvider client={queryClient}&amp;gt;
			{children}
			{import.meta.env.DEV ? (
				&amp;lt;ReactQueryDevtools initialIsOpen={false} /&amp;gt;
			) : null}
		&amp;lt;/QueryClientProvider&amp;gt;
	);
}&lt;/code&gt;&lt;/pre&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;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;QueryClient란&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;0&quot;&gt;TanStack Query 라이브러리에서&amp;nbsp;전역적으로 서버 상태를 관리하고 상호작용하는 핵심 객체&lt;/span&gt;&lt;/b&gt;로, 데이터를 가져오고(fetch), 캐싱하며(cache), 동기화(sync)하는 모든 작업을 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;2&quot;&gt;제어하는 중심 역할&lt;/span&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의 전역 상태 관리자이자 캐시 저장소인 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;QueryClient &lt;/span&gt;내부에는 다음과 같은 정보와 로직이 포함되어 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 query의 &lt;b&gt;캐시 데이터&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;query의 상태 (loading, error, success)&lt;/li&gt;
&lt;li&gt;데이터의 &lt;b&gt;stale / fresh 여부 판단 로직&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;자동 refetch 및 retry 정책&lt;/li&gt;
&lt;li&gt;mutation의 상태 및 결과&lt;/li&gt;
&lt;li&gt;query 무효화(invalidate) 및 재요청(refetch) 제어&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 기능을 통해 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;서버 상태를 일관되게 관리&lt;/span&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;  createQueryClient.ts 코드 전문&amp;nbsp;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&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;pre id=&quot;code_1769651499397&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { MutationCache, QueryCache, QueryClient } from &quot;@tanstack/react-query&quot;;

export const createQueryClient = () =&amp;gt; {
	return new QueryClient({
		queryCache: new QueryCache({
			onError: () =&amp;gt; {
				// TODO: 추후 query 요청 실패 시 공통 에러 처리 로직 추가 예정
				// ex) 에러 코드별 메시지 매핑, 전역 토스트 노출 등
			},
		}),
		mutationCache: new MutationCache({
			onError: () =&amp;gt; {
				// TODO: mutation 실패 시 공통 에러 처리 로직 추가 예정
			},
		}),
		defaultOptions: {
			queries: {
				staleTime: 60 * 1000,
				retry: 1,
				refetchOnWindowFocus: false,
				throwOnError: true,
			},
			mutations: {
				retry: 0,
				throwOnError: true,
			},
		},
	});
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span&gt;❓&lt;/span&gt;createQueryClient 함수 분리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;QueryClient &lt;/span&gt;내부에 캐시 데이터, 상태, 옵저버 등 다양한 정보를 함께 관리한다고 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;상대적으로 &lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;2&quot;&gt;무거운&lt;/span&gt;&lt;span data-token-index=&quot;3&quot;&gt; 객체&lt;/span&gt;&lt;/b&gt;이며 한 번 생성되면 &lt;b&gt;&lt;span data-token-index=&quot;5&quot;&gt;애플리케이션 전반에서 계속 &lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;6&quot;&gt;재사용&lt;/span&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;span style=&quot;color: #000000; text-align: start;&quot;&gt;QueryClient&lt;/span&gt;&lt;span style=&quot;background-color: #000000; color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;&lt;/span&gt;를 아래와 같이 &lt;b&gt;&lt;span data-token-index=&quot;3&quot;&gt;컴포넌트 내부에서 바로 생성하는 방식&lt;/span&gt;&lt;/b&gt;은 적절하지 않다.&lt;/p&gt;
&lt;pre id=&quot;code_1769651020565&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const queryClient = new QueryClient(); // ❌&lt;/code&gt;&lt;/pre&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;컴포넌트가 리렌더링될 때마다 QueryClient가 &lt;b&gt;새로 생성&lt;/b&gt;될 수 있고&lt;/li&gt;
&lt;li&gt;캐시가 초기화되고, 기존 query 상태가 모두 사라지며&lt;/li&gt;
&lt;li&gt;React StrictMode 환경에서는 의도치 않게 여러 번 생성될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 아래와 같은 패턴을 사용했다!&lt;/p&gt;
&lt;pre id=&quot;code_1769651043697&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [queryClient] = useState(() =&amp;gt; createQueryClient());&lt;/code&gt;&lt;/pre&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;span data-token-index=&quot;1&quot;&gt;React의 lazy initializer&lt;/span&gt;&lt;/b&gt;를 활용한 것으로, 최초 렌더링 시에만 QueryClient를 한 번 생성하고 이후 리렌더링에서는 동일한 인스턴스를 계속 재사용한다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해, QueryClient&lt;span data-token-index=&quot;2&quot;&gt;가 &lt;b&gt;불필요하게 재생성되는 것을 방지&lt;/b&gt;&lt;/span&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;또한 createQueryClient라는 &lt;b&gt;&lt;span data-token-index=&quot;3&quot;&gt;생성 함수로 분리&lt;/span&gt;&lt;/b&gt;함으로써, 아래와&lt;span data-token-index=&quot;5&quot;&gt; &lt;/span&gt;같은 이점을 얻을 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;QueryClient 생성 책임을 명확히 분리&lt;/li&gt;
&lt;li&gt;설정 로직을 한 곳에서 관리 가능&lt;/li&gt;
&lt;li&gt;추후 옵션 확장이나 환경별 분기에도 유연하게 대응 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #2c2c2b;&quot; data-token-index=&quot;0&quot;&gt;&lt;span&gt;❓&lt;/span&gt;&amp;nbsp;defaultOptions&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전반에서&lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt; 동일한 데이터 요청 정책과 캐싱 전략을 유지&lt;/span&gt;&lt;/b&gt;하기 위해 QueryClient를 생성할 때 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;5&quot;&gt;기본 옵션(defaultOptions)&lt;/span&gt;&lt;/b&gt;을 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 모든 query가 공통된 기준으로 동작하고, 개별 query마다 중복 설정을 줄이며, 팀 단위로 일관된 데이터 패칭 전략을 유지할 수 있다!! 우리 서비스에서는 아래와 같은 옵션들을 추가했다.&lt;/p&gt;
&lt;pre id=&quot;code_1769651586757&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;defaultOptions: {
	queries: {
		staleTime: 60 * 1000,
		retry: 1,
		refetchOnWindowFocus: false,
		throwOnError: true,
	},
	mutations: {
		retry: 0,
		throwOnError: true,
	},
},&lt;/code&gt;&lt;/pre&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;queries 옵션&lt;/b&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;span style=&quot;color: #eb5757;&quot;&gt;staleTime: 60 * 1000&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;데이터를 &lt;b&gt;1분 동안 fresh 상태로 유지&lt;/b&gt;한다. 해당 시간 동안은 같은 query가 다시 실행되더라도 &lt;b&gt;네트워크 요청 없이 캐시된 데이터를 재사용&lt;/b&gt;한다. 불필요한 API 호출 최소화하고 페이지 이동이 잦은 환경에서도 부드러운 UX를 제공하기 위함이다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #eb5757;&quot;&gt;retry: 1&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;query 요청이 실패했을 경우 &lt;b&gt;1회 재시도&lt;/b&gt;한다. 일시적인 네트워크 오류를 고려한 설정이며 사용자에게 즉시 에러를 노출하지 않고 일시적인 실패에 대한 복원력을 확보하기 위함이다!!&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #eb5757; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;refetchOnWindowFocus: false&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;브라우저 탭이 다시 포커스될 때 &lt;b&gt;자동으로 refetch 되는 동작을 비활성화&lt;/b&gt;한다! 사용자가 다른 탭을 잠깐 다녀올 때마다 데이터가 다시 로드되는 현상 방지해 예기치 않은 &lt;b&gt;UI 변경 및 깜빡임 최소화&lt;/b&gt; 하기 위함이다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;0&quot;&gt;mutations &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;옵션&lt;/span&gt;&lt;/b&gt;&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #eb5757;&quot;&gt;retry: 0&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;mutation 요청은 &lt;b&gt;자동 재시도를 하지 않는다.&lt;/b&gt; mutation은 &lt;b&gt;서버 상태&lt;/b&gt;를 &lt;b&gt;변경하는 요청&lt;/b&gt;이기 때문에 &lt;b&gt;재시도 시 중복 요청 / 중복 데이터 생성 위험이 있기 때문&lt;/b&gt;이다. 따라서, mutation 실패 시 즉시 에러를 처리하고 필요한 경우 사용자가 명시적으로 다시 시도하도록 한다.&lt;/span&gt;&lt;br /&gt;&lt;/span&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;QueryProvider로 감싸기&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;0&quot;&gt;QueryClient&lt;/span&gt;를 생성했다면, 다음은 해당 &lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;2&quot;&gt;QueryClient&lt;/span&gt;를 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;4&quot;&gt;애플리케이션 전반에서 사용할 수 있도록 주입&lt;/span&gt;&lt;/b&gt;&lt;span data-token-index=&quot;5&quot;&gt;해야 한다!&lt;/span&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;이를 위해 TanStack Query에서는 &lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;1&quot;&gt;QueryClientProvider&lt;/span&gt;를 제공하며, 이 Provider를 통해 QueryClient 인스턴스를 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;5&quot;&gt;React Context로 공유&lt;/span&gt;&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre id=&quot;code_1769652877404&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const AppProvider = () =&amp;gt; {
	return (
		&amp;lt;AuthInitializerProvider&amp;gt;
			&amp;lt;QueryProvider&amp;gt;
				&amp;lt;MainToaster /&amp;gt;
				&amp;lt;OverlayProvider&amp;gt;
					&amp;lt;RouterProvider /&amp;gt;
				&amp;lt;/OverlayProvider&amp;gt;
			&amp;lt;/QueryProvider&amp;gt;
		&amp;lt;/AuthInitializerProvider&amp;gt;
	);
};

export const App = () =&amp;gt; {
	return (
		&amp;lt;ErrorBoundary FallbackComponent={GlobalErrorFallback}&amp;gt;
			&amp;lt;AppProvider /&amp;gt;
		&amp;lt;/ErrorBoundary&amp;gt;
	);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;0&quot;&gt;QueryClientProvider&lt;/span&gt;를 감싼 &lt;span style=&quot;color: #333333;&quot; data-token-index=&quot;2&quot;&gt;QueryProvider &lt;/span&gt;컴포넌트를 생성하고, 애플리케이션의 &lt;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;4&quot;&gt;최상단&lt;/span&gt;&lt;/b&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;b&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;1&quot;&gt;동일한 &lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;2&quot;&gt;QueryClient &lt;/span&gt;&lt;span style=&quot;color: #9a6bb4;&quot; data-token-index=&quot;3&quot;&gt;인스턴스&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;를 공유할 수 있고 모든 하위 컴포넌트에서 &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;useQuery, useMutation, &lt;/span&gt;&lt;span style=&quot;color: #eb5757;&quot; data-token-index=&quot;9&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;useQueryClient&lt;/span&gt; &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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/20&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&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;span data-token-index=&quot;0&quot;&gt;  NextJS에서의 Tanstack Query 초기 세팅?!&lt;/span&gt;&lt;/b&gt;&lt;/p&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;pre id=&quot;code_1769653495091&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
        refetchOnMount: true,
        refetchOnReconnect: true,
        retry: 1,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (isServer) {
    return makeQueryClient();
  } else {
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

export function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  return (
    &amp;lt;QueryClientProvider client={queryClient}&amp;gt;
      {children}
      &amp;lt;ReactQueryDevtools initialIsOpen={false} /&amp;gt;
    &amp;lt;/QueryClientProvider&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;잘 보면 React의 TQ 초기세팅과 다를 바 없어보이지만 NextJS의 초기세팅에는 getQueryClient라는 함수가 하나 추가되어있는 걸 볼 수 있다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;왜 Next.js에서는 QueryClient를 이렇게 관리해야 할까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;413&quot; data-start=&quot;374&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;413&quot; data-start=&quot;374&quot; data-ke-size=&quot;size16&quot;&gt;Next.js는 Server + Client가 섞여 있다.&lt;/p&gt;
&lt;p data-end=&quot;432&quot; data-start=&quot;415&quot; data-ke-size=&quot;size16&quot;&gt;즉, Next.js에서는 같은 코드가&amp;nbsp;&lt;b&gt;서버에서 실행될 수도 있고&amp;nbsp;&lt;/b&gt;&lt;b&gt;브라우저에서 실행될 수도 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;432&quot; data-start=&quot;415&quot; data-ke-size=&quot;size16&quot;&gt;그래서 TanStack Query에서도 지금 이 코드가 서버에서 도는지, 클라이언트에서 도는지를 구분해야만 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1769654064418&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { isServer } from '@tanstack/react-query';&lt;/code&gt;&lt;/pre&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;서버에서는 요청마다 QueryClient가 새로 필요하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;672&quot; data-start=&quot;656&quot; data-ke-size=&quot;size16&quot;&gt;서버에서는 다음과 같은 특징을 갖는데,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;746&quot; data-start=&quot;674&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;702&quot; data-start=&quot;674&quot;&gt;요청 A, 요청 B는 &lt;b&gt;완전히 다른 사용자&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;746&quot; data-start=&quot;703&quot;&gt;QueryClient를 공유하면 &lt;b&gt;데이터가 섞이는 치명적인 문제&lt;/b&gt; 발생... ☠️&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;788&quot; data-start=&quot;748&quot; data-ke-size=&quot;size16&quot;&gt;그래서 서버에서는 &lt;b&gt;항상 새 QueryClient를 생성&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1769654113315&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (isServer) {
  return makeQueryClient();
}&lt;/code&gt;&lt;/pre&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;브라우저에서는 QueryClient를 하나만 유지해야 한다.&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;nbsp;&lt;b&gt;싱글톤처럼 동작하도록 만든 것이다!&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769654176567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let browserQueryClient: QueryClient | undefined = undefined;

if (!browserQueryClient) {
  browserQueryClient = makeQueryClient();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;결론!!! (정리)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 같은 코드가 서버와 클라이언트에서 모두 실행되기 때문에, 서버에서는 &lt;b&gt;요청 간 캐시 오염을 막기 위해&lt;/b&gt; QueryClient를 매번 새로 생성하고, 클라이언트에서는 캐시 유지를 위해 단 하나의 QueryClient를 재사용해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1769654206232&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function getQueryClient() {
  if (isServer) {
    // 서버: 요청마다 새로 생성
    return makeQueryClient();
  } else {
    // 클라이언트: 한 번만 생성해서 재사용
    if (!browserQueryClient) {
      browserQueryClient = makeQueryClient();
    }
    return browserQueryClient;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/32</guid>
      <comments>https://mimizae.tistory.com/32#entry32comment</comments>
      <pubDate>Thu, 29 Jan 2026 11:42:05 +0900</pubDate>
    </item>
    <item>
      <title>swagger-typescript-api 적용기</title>
      <link>https://mimizae.tistory.com/31</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d; text-align: start;&quot;&gt;37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트의 초기세팅 과정을 담은 글입니다!  &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1324&quot; data-origin-height=&quot;556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZxmuS/dJMcahwtQue/iNenWFfXYNSxx7cgrrPaKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZxmuS/dJMcahwtQue/iNenWFfXYNSxx7cgrrPaKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZxmuS/dJMcahwtQue/iNenWFfXYNSxx7cgrrPaKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZxmuS%2FdJMcahwtQue%2FiNenWFfXYNSxx7cgrrPaKK%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;398&quot; height=&quot;167&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1324&quot; data-origin-height=&quot;556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; &amp;nbsp;swagger-typescript-api란?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger 문서를 이용한 자동 타입 생성기이다!!&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;swagger-typescript-api는 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;OpenAPI(Swagger) 스펙으로부터 TypeScript 코드&lt;/b&gt;&lt;/span&gt;를 자동으로 생성해주는 도구다. 이 도구는 OpenAPI 3.0 및 2.0, JSON/YAML 형식의 스펙을 받아서 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;fetch나 axios 기반의 API 클라이언트 코드와 TypeScript 타입 정의를 생성&lt;/b&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;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;타입만 생성해주는 줄 알았는데 클라이언트 코드까지 자동 생성해준다&amp;hellip; 덜덜  &lt;/span&gt;&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;swagger-typescript-api를 사용하게 된 계기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAPI(Swagger) 스펙을 기반으로 프론트엔드에서 사용할 API 타입과 호출 코드를 효율적으로 관리할 방법을 고민하던 중, swagger-typescript-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;이 도구는 Swagger 스펙으로부터 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;TypeScript 타입뿐만&lt;/b&gt;&lt;/span&gt; 아니라 Axios 기반의 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;API 클라이언트 코드까지 함께 생성해준다는 점&lt;/b&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;기존에도 OpenAPI Generator, orval, openapi-typescript 등 다양한 대안이 존재하긴 했다. 각각의 대안을 판단하기에, &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;OpenAPI Generator&lt;/b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;는&lt;/span&gt; &lt;b&gt;생성 코드가 비교적 복잡&lt;/b&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;&lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;orval&lt;/b&gt;&lt;/span&gt;은 Tanstack Query 기반의 훅 생성에 강점이 있는 도구였지만, 본 프로젝트에서는 Tanstack Query 사용 여부와 관계없이 공통으로 활용 가능한 API SDK를 구성하는 것이 더 적합하다고 판단했다!!!!&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: #8a3db6;&quot;&gt;&lt;b&gt;openapi-typescript&lt;/b&gt;&lt;/span&gt;의 경우 타입 정의만 제공해 주기 때문에 API 호출 로직을 직접 구현해야 했다. 물론 구현은 가능했지만, OpenAPI 도구를 사용하는 만큼 API 호출 코드까지 함께 자동화할 수 있다면 개발 과정이 더 수월해질 것이라고 생각했다&amp;hellip;!!!!!&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;이러한 점들을 고려했을 때 swagger-typescript-api는 프론트엔드에서 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;바로 사용할 수 있는 API SDK 형태의 코드를 생성&lt;/b&gt;&lt;/span&gt;하면서도, 생성 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;결과가 비교적 단순하고 가독성이 좋아 유지보수가 용이하다는 장점&lt;/b&gt;&lt;/span&gt;이 있었기 때문에 swagger-typescript-api 을 선택했다.&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;ky에서 axios로 변경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 swagger-typescript-api를 설치하고 적용하려고 했는데, 하나 &lt;b&gt;문제점&lt;/b&gt;이 있었다&amp;hellip;  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;swagger-typescript-api가 공식적으로 ky를 지원 안 한다는 사실&amp;hellip;!!!!!!&lt;/b&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;우리 서비스는 api 인터셉터로 자주 쓰던 axios 대신&lt;span style=&quot;color: #8a3db6;&quot;&gt; &lt;b&gt;ky&lt;/b&gt;&lt;/span&gt;를 선택했다. 그 이유는 ky가 axios와 세팅, 사용 방법이 거의 비슷해 적응하는 데 (axios를 알고 있는 상태라면) &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;러닝 커브가 굉장히 완만&lt;/b&gt;&lt;/span&gt;하고, ky가 axios보다 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;의존성이 가볍고 번들 사이즈가 작아지기&lt;/b&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;하지만!! axios를 사용하더라도 번들 사이즈 증가로 인해 체감할 만한 초기 로딩 속도 차이는 크지 않다고 판단했다. 또한 swagger-typescript-api가 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;axios 기반의 API 클라이언트를 기본적으로 제공&lt;/b&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;&lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;따라서 최종적으로 axios 사용 결정!!&lt;/b&gt;&lt;/span&gt;&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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;⚙️&lt;/span&gt;&amp;nbsp;swagger-typescript-api 사용법&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 설치&lt;/b&gt;&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;pnpm add &lt;b&gt;swagger-typescript-api&lt;/b&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. package.json에 실행 커맨드 추가&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1769566028479&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  &quot;scripts&quot;: {
    ...
    &quot;api:generate&quot;: &quot;swagger-typescript-api generate -p {Swagger 문서 Path} -o src/shared/apis/generated --no-client --modular -d --extract-request-body --extract-response-body --extract-response-error&quot;
  },&lt;/code&gt;&lt;/pre&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;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;p data-end=&quot;454&quot; data-start=&quot;440&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;generate&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;527&quot; data-start=&quot;455&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;496&quot; data-start=&quot;455&quot;&gt;swagger-typescript-api의 &lt;b&gt;코드 생성 서브커맨드&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;527&quot; data-start=&quot;497&quot;&gt;Swagger 스펙을 읽어서 TS 코드를 만들어 줌&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;p data-end=&quot;560&quot; data-start=&quot;534&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;-p {Swagger 문서 Path}&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;727&quot; data-start=&quot;572&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;595&quot; data-start=&quot;572&quot;&gt;Swagger/OpenAPI 문서 위치, 이 &lt;b&gt;문서를 기준으로 모든 타입이 만들어진다.&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;727&quot; data-start=&quot;603&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;654&quot; data-start=&quot;603&quot;&gt;로컬 파일&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;background-color: #fafafa; color: #333333; text-align: start;&quot;&gt;-p ./swagger.json&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;654&quot; data-start=&quot;603&quot;&gt;URL&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;span&gt;-p &lt;a href=&quot;https://api.example.com/v3/api-docs&quot;&gt;https://api.example.com/v3/api-docs&lt;/a&gt; &lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&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;p data-end=&quot;795&quot; data-start=&quot;761&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;-o src/shared/apis/generated&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;898&quot; data-start=&quot;809&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;826&quot; data-start=&quot;809&quot;&gt;생성 결과가 들어갈 디렉토리&lt;/li&gt;
&lt;li data-end=&quot;898&quot; data-start=&quot;827&quot;&gt;보통, generated 폴더는 &lt;b&gt;직접 수정 안 하는 영역&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;898&quot; data-start=&quot;827&quot;&gt;git에 포함시키거나, CI에서 매번 생성&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;h4 data-end=&quot;922&quot; data-start=&quot;905&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;--no-client&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1028&quot; data-start=&quot;936&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;975&quot; data-start=&quot;936&quot;&gt;&lt;b&gt;axios / fetch 기반 API 호출 함수 생성 안 함&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;975&quot; data-start=&quot;936&quot;&gt;대신 &lt;b&gt;&lt;b&gt;타입만 생성!!&lt;/b&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;1049&quot; data-end=&quot;1129&quot;&gt;
&lt;li data-start=&quot;1049&quot; data-end=&quot;1063&quot;&gt;Request body&lt;/li&gt;
&lt;li data-start=&quot;1066&quot; data-end=&quot;1081&quot;&gt;Response body&lt;/li&gt;
&lt;li data-start=&quot;1084&quot; data-end=&quot;1100&quot;&gt;Error response&lt;/li&gt;
&lt;li data-start=&quot;1103&quot; data-end=&quot;1129&quot;&gt;Params, Query, Path 변수 등&lt;/li&gt;
&lt;/ul&gt;
&lt;b&gt;&lt;/b&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;-modular&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 코드를 &lt;b&gt;Swagger의 tag(컨트롤러) 단위로 분리하여 생성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;하나의 거대한 파일이 아닌, 도메인별 파일 구조를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769567939655&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;generated/
 ├─ auth.ts
 ├─ user.ts
 ├─ product.ts&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;d (-default-response)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swagger 문서에 여러 response(200, 400, 500 등)가 정의되어 있을 경우 &lt;b&gt;기본 response 타입을 자동으로 선택&lt;/b&gt;하도록 하는 옵션&lt;/li&gt;
&lt;li&gt;일반적으로 200 또는 default response를 기준으로 삼는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; Swagger 문서의 응답 정의가 불완전하거나 일관되지 않은 경우 &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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;-extract-request-body&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;request body를 &lt;b&gt;별도의 TypeScript 타입으로 분리하여 생성&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769568015028&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface CreateUserRequest {
	name:string;
	age:number;
}&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;-extract-response-body&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;response body를 &lt;b&gt;별도의 TypeScript 타입으로 분리하여 생성&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769568054293&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface CreateUserResponse {
	id:number;
}&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;-extract-response-error&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;에러 응답(4xx, 5xx)을 &lt;b&gt;에러 전용 타입으로 분리하여 생성&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1769568083327&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface ErrorResponse {
	message:string;
	code:string;
}&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;-axios&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API 호출 코드를 &lt;b&gt;axios 기반&lt;/b&gt;으로 생성&lt;/li&gt;
&lt;li&gt;fetch가 아닌 axios를 사용하는 프로젝트에 적합&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;n {name} (name)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;생성되는 타입 / 클래스 / 함수에 사용될 &lt;b&gt;기본 이름(prefix 또는 root name)&lt;/b&gt;을 지정한다.&lt;/li&gt;
&lt;li&gt;팀 내 컨벤션에 맞게 수정 가능&lt;/li&gt;
&lt;/ul&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;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;API 인터셉터 설정, 요청 래핑 함수, 응답 코드 및 에러 처리 로직&lt;/b&gt;&lt;/span&gt;을 프론트엔드에서 별도로 관리하고 있다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;swagger-typescript-api&lt;/span&gt;&lt;/b&gt;는 API 호출 로직을 생성하지 않고, &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;타입 정의만 생성하는 용도로 사용한다.&lt;/b&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;위의 실행 커맨드를 설정하고 실행한다면, 실제로 &lt;b&gt;만들어지는 파일 예시이다!&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769566162459&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;generated/
 ├─ User.ts        &amp;larr; tag: User
 ├─ Auth.ts        &amp;larr; tag: Auth
 ├─ Product.ts    &amp;larr; tag: Product
 ├─ data-contracts.ts  &amp;larr; 전역 공통 (1개)
 └─ index.ts           &amp;larr; 전역 barrel (1개)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span&gt;❓&lt;/span&gt;data-contracts.ts&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;data-contracts.ts는 Swagger(OpenAPI) 문서의 components.schemas를 기반으로 생성되는 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;전역 공통 타입 파일&lt;/b&gt;&lt;/span&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 API에서 공통으로 사용될 수 있는 DTO, enum, 에러 타입, 공용 응답 모델 등이 이 파일에 정의되며, 도메인(tag)별로 분리되지 않고 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;한 번만 생성된다&lt;/b&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 생성 파일이므로 직접 수정하지 않아야 하며, 필요 시 각 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;도메인 타입 파일에서 참조&lt;/b&gt;&lt;/span&gt;되는 용도로만 사용한다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769566504846&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// data-contracts.ts
export interface BaseResponse&amp;lt;T&amp;gt; {
  data: T;
  message: string;
  status: number;
}

export interface ErrorResponse {
  message: string;
  code: string;
}&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;❓index.ts&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;index.ts는 generated 디렉토리의 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;barrel 파일(entry point)&lt;/b&gt;&lt;/span&gt; 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 도메인(tag)별로 생성된 타입 파일과 공통 타입 파일을 한 곳에서 re-export하여, &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;외부에서는 단일 경로로 타입을 import 할 수 있도록&lt;/b&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;프로젝트 정책에 따라 data-contracts.ts를 export 대상에서 제외하는 것도 가능하다.&lt;/p&gt;
&lt;pre id=&quot;code_1769566555163&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// index.ts
export * from './User';
export * from './Auth';
export * from './data-contracts';

// 사용 예시
import {
  LoginRequest,
  LoginResponse,
} from '@/shared/apis/generated'; // 단일 경로로 타입 import!!&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;⬇️ Barrel 방식이란? ⬇️&lt;/span&gt;&lt;/b&gt;&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;Barrel 방식은 여러 파일에서 export되는 모듈들을 하나의 파일(index.ts)로 모아 다시 export하는 구조를 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 외부에서는 개별 파일 경로를 직접 참조하지 않고, &lt;b&gt;단일 진입점(entry point)&lt;/b&gt;을 통해 필요한 모듈을 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;&lt;b&gt;Barrel 방식을 사용하면 import 경로가 단순해지고, 파일 구조 변경 시 영향 범위를 최소화할 수 있다!!!&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;span data-token-index=&quot;0&quot;&gt;Barrel 파일(index.ts)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769566578801&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// generated/index.ts
export * from './User';
export * from './Auth';
export * from './data-contracts';&lt;/code&gt;&lt;/pre&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;span data-token-index=&quot;0&quot;&gt;사용 측 코드&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769566608611&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { LoginRequest, LoginResponse} from '@/shared/apis/generated';&lt;/code&gt;&lt;/pre&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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;(비교) Barrel 방식 미사용 시&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769566623247&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { LoginRequest } from '@/shared/apis/generated/Auth';
import { LoginResponse } from '@/shared/apis/generated/Auth';&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt; &lt;/span&gt;&amp;nbsp;실제 적용&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cBpdqT/dJMcacooIxn/n30f4CVTdo7UkxajyKezIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cBpdqT/dJMcacooIxn/n30f4CVTdo7UkxajyKezIk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1270&quot; data-filename=&quot;blob&quot; width=&quot;427&quot; height=&quot;322&quot; style=&quot;width: 47.9589%; margin-right: 10px;&quot; data-widthpercent=&quot;48.52&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cBpdqT/dJMcacooIxn/n30f4CVTdo7UkxajyKezIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcBpdqT%2FdJMcacooIxn%2Fn30f4CVTdo7UkxajyKezIk%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;1684&quot; height=&quot;1270&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pbEDq/dJMcacu94dk/Sv1grN2WXgUKDyVc2KFKGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pbEDq/dJMcacu94dk/Sv1grN2WXgUKDyVc2KFKGk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;1254&quot; data-filename=&quot;blob&quot; width=&quot;480&quot; height=&quot;341&quot; data-widthpercent=&quot;51.48&quot; style=&quot;width: 50.8783%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pbEDq/dJMcacu94dk/Sv1grN2WXgUKDyVc2KFKGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpbEDq%2FdJMcacu94dk%2FSv1grN2WXgUKDyVc2KFKGk%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;1764&quot; height=&quot;1254&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&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;현재 모든 타입이 data-contracts.ts 하나의 파일로만 생성되고 있다. . . ☠️&lt;br /&gt;--modular 옵션을 사용하고 있음에도 파일이 분리되지 않고, index.ts 역시 생성되지 않았다.&lt;/p&gt;
&lt;p data-end=&quot;480&quot; data-start=&quot;255&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;480&quot; data-start=&quot;255&quot; data-ke-size=&quot;size16&quot;&gt;원인을 확인해보니 Swagger 문서에서 각 API operation(GET, POST 등)에 tags가 지정되어 있지 않았다.&lt;br /&gt;swagger-typescript-api의 --modular 옵션은 operation에 선언된 tags를 기준으로 파일을 분리하기 때문에, tag가 없는 경우 모든 타입을 공통 계약 파일인 data-contracts.ts로만 생성한다. . .!!!!&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;548&quot; data-start=&quot;482&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;609&quot; data-start=&quot;550&quot; data-ke-size=&quot;size16&quot;&gt;각 API operation에 도메인에 맞는 tags를 추가하도록 백엔드 측에 요청해야겠다.  &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-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  ETC&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/team-carena/carena-client/pull/22&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;작업 PR 링크&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;</description>
      <category>FE</category>
      <author>mimizae</author>
      <guid isPermaLink="true">https://mimizae.tistory.com/31</guid>
      <comments>https://mimizae.tistory.com/31#entry31comment</comments>
      <pubDate>Wed, 28 Jan 2026 11:42:48 +0900</pubDate>
    </item>
  </channel>
</rss>