<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Happhee</title>
    <link>https://happhee-dev.tistory.com/</link>
    <description>선한 영향을 주는 개발자, 햅히입니다</description>
    <language>ko</language>
    <pubDate>Fri, 12 Jun 2026 07:57:56 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Happhee.dev</managingEditor>
    <image>
      <title>Happhee</title>
      <url>https://tistory1.daumcdn.net/tistory/5444401/attach/a08ae35dee5d4104ad7d224e770fe4cb</url>
      <link>https://happhee-dev.tistory.com</link>
    </image>
    <item>
      <title>AI-KIT 패키지가 나에게 필요했던 이유</title>
      <link>https://happhee-dev.tistory.com/52</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;이거 왜 필요했는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기능을 사용하기 위해서는 외부sdk를 로드하고, 해당 sdk에서 제공하는 API를 연결하여 크레딧 정보 조회, 생성/취소 기능을 사용할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025년 7월 전까지 AI 기능을 쓰는 곳은 아래 사진처럼 3개의 구역에서만 사용하고 있었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;에디터 &amp;gt; 좌측탭 &amp;gt; AI도구&lt;/li&gt;
&lt;li&gt;에디터 &amp;gt; 좌측탭 &amp;gt; 사진 &amp;gt; 속성창 &amp;gt; 쉬운편집&lt;/li&gt;
&lt;li&gt;에디터 &amp;gt; 좌측탭 &amp;gt; 사진 &amp;gt; 쉬운편집&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Qkf3v/dJMcagK51YR/O9Kd9l7M5yn8GCfGZF94X0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Qkf3v/dJMcagK51YR/O9Kd9l7M5yn8GCfGZF94X0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Qkf3v/dJMcagK51YR/O9Kd9l7M5yn8GCfGZF94X0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQkf3v%2FdJMcagK51YR%2FO9Kd9l7M5yn8GCfGZF94X0%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;2048&quot; height=&quot;634&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가 2025년 7월 &lt;a title=&quot;미리클(miricle)&quot; href=&quot;https://www.miricanvas.com/ko/miricle&quot;&gt;미리클(miricle)&lt;/a&gt; 프로덕트를 개발하면서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 다시 3개의 지점에서 AI 생성 기능을 요구하게 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Miricle &amp;gt; AI도구 &amp;gt; 입력화면(생성,크레딧)&lt;/li&gt;
&lt;li&gt;Miricle &amp;gt; AI도구 &amp;gt; 결과화면(재생성)&lt;/li&gt;
&lt;li&gt;Miricle &amp;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-origin-width=&quot;1920&quot; data-origin-height=&quot;706&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kXXWl/dJMcabwekLz/asV4EhrXtWimgr4A2rtRpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kXXWl/dJMcabwekLz/asV4EhrXtWimgr4A2rtRpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kXXWl/dJMcabwekLz/asV4EhrXtWimgr4A2rtRpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkXXWl%2FdJMcabwekLz%2FasV4EhrXtWimgr4A2rtRpk%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;1920&quot; height=&quot;706&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;706&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;당시, 개발 일정이 약 2주 - 2주 반 정도 되는 시간이었기에 미리캔버스에서 사용하고 있는 AI기능을 import해서 쓰는 것보다는 비슷한 코드를 복사/붙여넣기해서 만드는 것이 가장 사이드 이펙트가 적고, 개발공수가 가장 적은 방향이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 정말 많은 코드들이 다른 폴더에 중복해서 생성되는 개발 부채는 늘어남과 동시에 코드량도 많아졌고, 기능 구현을 위해서 AI 기능 사용이라는 큰 덩어리를 옮기기 위해 찍어내는 커밋은 무궁무진하게 늘어났다.&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-origin-width=&quot;1280&quot; data-origin-height=&quot;611&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEz5T8/dJMcag5omGN/KxipO1VczLShwk3234rZt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEz5T8/dJMcag5omGN/KxipO1VczLShwk3234rZt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEz5T8/dJMcag5omGN/KxipO1VczLShwk3234rZt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEz5T8%2FdJMcag5omGN%2FKxipO1VczLShwk3234rZt1%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;391&quot; height=&quot;187&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;611&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;당시, 약 한 달동안 찍어낸 커밋 수는 약 550 - 600개 정도 되었다. 평일 근무로도 충분하지않아서 주말 근무는 당연히 하게 되었고, 코드 찍는 기계가 되었구나~라고 느꼈던 순간이기도 했다.&lt;/p&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;기능을 구현하면서도 물론 이전에 있던 AI도구의 개선점들을 최대한 반영할 수 있어서 좋았지만, 그럼에도 여전히 비슷한 코드들이 많아서 마음이 참 편하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 마음이 7월로 끝날 수 있었는가&amp;hellip;?&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-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cyRrd5/dJMcaiWoLNN/WGhDHuuQQrzJzRkMoHDHsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cyRrd5/dJMcaiWoLNN/WGhDHuuQQrzJzRkMoHDHsK/img.png&quot; data-alt=&quot;슬픈 에이드..ㅠㅠ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cyRrd5/dJMcaiWoLNN/WGhDHuuQQrzJzRkMoHDHsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcyRrd5%2FdJMcaiWoLNN%2FWGhDHuuQQrzJzRkMoHDHsK%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;317&quot; height=&quot;317&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&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;다시 미리캔버스로 돌아와서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 다시 AI 기능 사용을 위한 진입점이 한 군데 더 생성되었다.&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-origin-width=&quot;1920&quot; data-origin-height=&quot;1230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q9Hiv/dJMcagRRbA1/pGMLUlOFvCpABPcKVjvs11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q9Hiv/dJMcagRRbA1/pGMLUlOFvCpABPcKVjvs11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q9Hiv/dJMcagRRbA1/pGMLUlOFvCpABPcKVjvs11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ9Hiv%2FdJMcagRRbA1%2FpGMLUlOFvCpABPcKVjvs11%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;1920&quot; height=&quot;1230&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1230&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;이제 AI를 사용하기 위한 진입점이 총 7곳이 되어버린 상황에 직면해버렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 느꼈던 나의 슬픔 마음이 있었지만, 이번에도 스쿼드 상황을 바라봤을때, 주요 액션이 필요한 시기였기에 이번에도 일단 복사/붙여넣기를 진행하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 어땠을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말하지 않아도 다들 예상하실 수 있듯이&amp;hellip;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;368&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbB7Qz/dJMcafel3AH/HwMEp5g47EhaLZzDcpZMh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbB7Qz/dJMcafel3AH/HwMEp5g47EhaLZzDcpZMh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbB7Qz/dJMcafel3AH/HwMEp5g47EhaLZzDcpZMh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbB7Qz%2FdJMcafel3AH%2FHwMEp5g47EhaLZzDcpZMh0%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;599&quot; height=&quot;115&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;368&quot;/&gt;&lt;/span&gt;&lt;/figure&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;이 와중에 해당 시기에 여러 스쿼드에서 AI 기능에 대한 새로운 피쳐들을 개발하고자하는 시도들이 보였고,&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;lsquo;이 과정이 다른 스쿼드에서 시작된다?&amp;rsquo;&lt;/li&gt;
&lt;li&gt;&amp;lsquo;갑자기 어디선가 AI 기능 관련 코드가 변경되었는데 우리쪽에서 사이드이펙트가 터진다면..?&amp;rsquo;&lt;/li&gt;
&lt;li&gt;'진입점 개발을 당장 1주일안에 넣고 싶다고 한다면..?&lt;/li&gt;
&lt;li&gt;&amp;hellip;&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;그래서 이제는 결심하게 되었다.&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;style7&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;  AI-KIT 패키지를 만들자  &lt;/span&gt;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 16px; letter-spacing: 0px;&quot;&gt;우선, 이 패키지 개발을 시작할 수 있었던 배경은 아래와 같다.&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;스쿼드 업무의 효율 향상을 위해 필요한 기술 내용임을 PM님께 알려드렸다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;스쿼드의 목표 달성을 위해서 필요한 업무가 있다면 해당 업무를 우선으로 진행하고, 중간중간 틈이 나는 상황에서 AI-KIT 모듈화 이슈를 병렬적으로 진행하는 것으로 정렬하였다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI-KIT 의 개요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI-KIT은 React 애플리케이션에서 AI 생성 기능을 제공하는 라이브러리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 AI SDK를 추상화하여 React 환경에서 쉽게 사용할 수 있도록 만든 것이 목적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;1.-AI-기능-통합&quot; data-renderer-start-pos=&quot;1943&quot; data-local-id=&quot;d985ef01-d3f3-495e-8fd4-b1be1c0fcafa&quot; data-ke-size=&quot;size20&quot;&gt;1. AI 기능 통합&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-indent-level=&quot;1&quot; data-local-id=&quot;ee142efb-52cf-49ba-8ca8-cbc4af741a43&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 기능 사용을 위한 사용자 인증&lt;/li&gt;
&lt;li&gt;AI
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-local-id=&quot;031a37ae-443e-47c1-9860-7749e706ba16&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;text &amp;rarr; image&lt;/li&gt;
&lt;li&gt;text &amp;rarr; text&lt;/li&gt;
&lt;li&gt;text &amp;rarr; video&lt;/li&gt;
&lt;li&gt;text &amp;rarr; speech&lt;/li&gt;
&lt;li&gt;image &amp;rarr; image&lt;/li&gt;
&lt;li&gt;image &amp;rarr; video&lt;/li&gt;
&lt;li&gt;aiLogo , aiPoster, aiRedesign&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;쉬운편집
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-local-id=&quot;043f9e85-20bb-4e43-9885-63f7268e225c&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;영역 지우개&lt;/li&gt;
&lt;li&gt;화질개선&lt;/li&gt;
&lt;li&gt;부분생성&lt;/li&gt;
&lt;li&gt;이미지 확장&lt;/li&gt;
&lt;li&gt;배경제거&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;크레딧&lt;/li&gt;
&lt;li&gt;요청 취소&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2.-인증-토큰-자동-관리-및-갱신&quot; data-renderer-start-pos=&quot;2195&quot; data-local-id=&quot;b91ea6a8-217a-4e8f-9dbc-903972e8fff8&quot; data-ke-size=&quot;size20&quot;&gt;2. 인증 토큰 자동 관리 및 갱신&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-indent-level=&quot;1&quot; data-local-id=&quot;c02df307-8eee-4971-b797-483dd24d7f09&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI SDK를 사용하기 위해 미캔 인증정보와는 별도의 토큰을 발급&lt;/li&gt;
&lt;li&gt;인증 에러 시 토큰 갱신 후 자동 재시도&lt;/li&gt;
&lt;li&gt;이중 인증 시스템 (authV1/authV2) 통합 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3.-외부-AI-SDK의-React-통합&quot; data-renderer-start-pos=&quot;2320&quot; data-local-id=&quot;9c752b50-4a82-4d53-8905-bfd6d47d9b89&quot; data-ke-size=&quot;size20&quot;&gt;3. 외부 AI SDK의 React 통합&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-indent-level=&quot;1&quot; data-local-id=&quot;693cc974-3c56-412e-a0e1-8369d64a935f&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동적 스크립트 로딩 (window.__MIRID.aiImage)&lt;/li&gt;
&lt;li&gt;SDK를 React 훅으로 래핑&lt;/li&gt;
&lt;li&gt;AI SDK 사용을 위한 인증정보를 ai-kit에서 관리&lt;/li&gt;
&lt;li&gt;데이터 형식 자동 변환 (MIME 타입 기반 결과 타입 판별)&lt;/li&gt;
&lt;li&gt;에러 형식 통일&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4.-Task-기반-비동기-작업-관리&quot; data-renderer-start-pos=&quot;2493&quot; data-local-id=&quot;e3e66b90-bc04-4694-bfa0-d6c7fa3a5fdc&quot; data-ke-size=&quot;size20&quot;&gt;4. Task 기반 비동기 작업 관리&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-indent-level=&quot;1&quot; data-local-id=&quot;0727834d-da39-4aa8-8bc7-d6580a4867f1&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;div data-media-vc-wrapper=&quot;true&quot; data-renderer-start-pos=&quot;2576&quot; data-vc=&quot;media-single&quot; data-node-type=&quot;mediaSingle&quot; data-width-type=&quot;pixel&quot; data-width=&quot;736&quot; data-layout=&quot;center&quot;&gt;
&lt;div&gt;
&lt;div data-local-id=&quot;44939041-ee2c-4c14-8c65-aa252dbe4092&quot; data-renderer-start-pos=&quot;2577&quot; data-alt=&quot;화면 기록 2025-12-11 오전 9.43.21.mov&quot; data-file-mime-type=&quot;&quot; data-file-size=&quot;1&quot; data-file-name=&quot;file&quot; data-collection=&quot;contentId-1865580583&quot; data-id=&quot;d487aae4-2b15-4936-983d-efbbead36f89&quot; data-height=&quot;1934&quot; data-width=&quot;2846&quot; data-node-type=&quot;media&quot; data-type=&quot;file&quot; data-context-id=&quot;1865580583&quot;&gt;
&lt;div id=&quot;inlinePlayerWrapper&quot; data-media-vc-wrapper=&quot;true&quot; data-testid=&quot;media-card-inline-player&quot;&gt;
&lt;div data-testid=&quot;inactivity-detector-wrapper&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 기록 2025-12-11 오전 9.43.21 (2).gif&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u2gVv/dJMcai9UOKH/SDMYIKwBtDIz5cpP7eQUGk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u2gVv/dJMcai9UOKH/SDMYIKwBtDIz5cpP7eQUGk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u2gVv/dJMcai9UOKH/SDMYIKwBtDIz5cpP7eQUGk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/u2gVv/dJMcai9UOKH/SDMYIKwBtDIz5cpP7eQUGk/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;1000&quot; height=&quot;680&quot; data-filename=&quot;화면 기록 2025-12-11 오전 9.43.21 (2).gif&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;680&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1769387013010&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Task 상태
type Task = 
  | { state: 'PENDING', key: string }
  | { state: 'SUCCESS', key: string, data: {...} }
  | { state: 'ERROR', key: string };
// 콜백 시스템
onTaskSuccess: (task: Task.Success) =&amp;gt; void;
onTaskError: (task: Task.Error) =&amp;gt; void;
onTaskComplete: (task: Task) =&amp;gt; void;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;AI-KIT--데이터-흐름도&quot; data-renderer-start-pos=&quot;2867&quot; data-local-id=&quot;e71a345c-4d1d-4afb-80b0-0539010958b0&quot; data-ke-size=&quot;size23&quot;&gt;AI-KIT 데이터 흐름도&lt;/h3&gt;
&lt;h4 id=&quot;1.-컴포넌트에서의-데이터-흐름&quot; data-renderer-start-pos=&quot;2886&quot; data-local-id=&quot;60d972a9-7a9e-4d5b-b036-a788d05f15ee&quot; data-ke-size=&quot;size20&quot;&gt;1. 컴포넌트에서의 데이터 흐름&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;1728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xIdo3/dJMcaaqy22N/XhZwBmvvhh4mEJzmSWfK41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xIdo3/dJMcaaqy22N/XhZwBmvvhh4mEJzmSWfK41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xIdo3/dJMcaaqy22N/XhZwBmvvhh4mEJzmSWfK41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxIdo3%2FdJMcaaqy22N%2FXhZwBmvvhh4mEJzmSWfK41%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;554&quot; height=&quot;594&quot; data-origin-width=&quot;1612&quot; data-origin-height=&quot;1728&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-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;2b5e7cd2-ff43-4b73-a624-b61b4e5b9ba6&quot; data-ke-size=&quot;size18&quot;&gt;useAIKitLoaderQuery&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2347&quot; data-origin-height=&quot;1104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JlqfF/dJMcac9HkJZ/MuYrZnrJlNifXdzDo60jU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JlqfF/dJMcac9HkJZ/MuYrZnrJlNifXdzDo60jU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JlqfF/dJMcac9HkJZ/MuYrZnrJlNifXdzDo60jU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJlqfF%2FdJMcac9HkJZ%2FMuYrZnrJlNifXdzDo60jU0%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;2347&quot; height=&quot;1104&quot; data-origin-width=&quot;2347&quot; data-origin-height=&quot;1104&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-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;fc27f1e4-781a-41be-b632-ffb4b875a7fa&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span&gt;useAIKitAuthQuery&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;1448&quot; data-origin-height=&quot;914&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3pLP8/dJMcaaRDOUQ/I5kJkIURihDGVzkz7djQ2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3pLP8/dJMcaaRDOUQ/I5kJkIURihDGVzkz7djQ2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3pLP8/dJMcaaRDOUQ/I5kJkIURihDGVzkz7djQ2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3pLP8%2FdJMcaaRDOUQ%2FI5kJkIURihDGVzkz7djQ2K%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;732&quot; height=&quot;462&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;914&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;h4 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;d5cee42d-59b2-4c76-9038-036afc6162a8&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;2. 요청 단계&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1155&quot; data-origin-height=&quot;1666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KkqhX/dJMcahwsQyV/PdBeIUkZfK8pTSgv9MIaF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KkqhX/dJMcahwsQyV/PdBeIUkZfK8pTSgv9MIaF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KkqhX/dJMcahwsQyV/PdBeIUkZfK8pTSgv9MIaF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKkqhX%2FdJMcahwsQyV%2FPdBeIUkZfK8pTSgv9MIaF0%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;598&quot; height=&quot;863&quot; data-origin-width=&quot;1155&quot; data-origin-height=&quot;1666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;cf99de56-8c91-448c-8059-523178360644&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;3. 에러 처리 단계&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1562&quot; data-origin-height=&quot;1173&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/croo2W/dJMcadtZZMi/1uWpzAHBNsQkqU7e6tPdSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/croo2W/dJMcadtZZMi/1uWpzAHBNsQkqU7e6tPdSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/croo2W/dJMcadtZZMi/1uWpzAHBNsQkqU7e6tPdSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcroo2W%2FdJMcadtZZMi%2F1uWpzAHBNsQkqU7e6tPdSk%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;742&quot; height=&quot;557&quot; data-origin-width=&quot;1562&quot; data-origin-height=&quot;1173&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;477c7785-e890-45c9-bfff-b5b3f493c609&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;그래서 AI-KIT 어떻게 사용하는 건가요?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지 구조도의 핵심만 보여드리면 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1769387269564&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├── src/
│   ├── hooks/                     # AI Kit 기능을 사용하기 위한 React hooks
│   │
│   ├── providers/                 # Provider 및 관련 hooks
│   │                              # - AIKitQueryProvider: AIKitClient를 제공하는 Provider
│   │                              # - useAIKitQuery: Provider에서 AIKitClient를 가져오는 hook
│   │
│   ├── types/                     # 타입 정의
│   │   ├── client/                # 클라이언트에서 사용하는 타입 (AIKitTask, AIKitClient, UserStorage 등)
│   │   ├── sdk/                   # SDK 레벨의 타입 정의 (외부 SDK와의 인터페이스)
│   │   └── shared/                # SDK와 client의 공유 타입 정의
│   │
│   ├── converter/                 # 데이터 변환 함수
│   │
│   ├── queries/                   # 내부적으로만 사용되는 React Query 쿼리 (모듈 외부로 export되지 않음)
│   │                              # - useAIKitLoaderQuery: AI Kit SDK 로더 쿼리
│   │
│   ├── constants/                 # 상수 정의
│   │
│   └── __test__/                  # 테스트 파일
│
├── dist/                          # 빌드 결과물
├── node_modules/                  # 의존성 패키지
├── package.json
├── README.md
├── tsconfig.json
└── vite.config.ts&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-pm-slice=&quot;3 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;38655910-cd14-4b61-a95b-6f71042b1612&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;95b088b4-97d5-4fbc-ab81-82145e26ab39&quot;&gt;&lt;span&gt;types 하위에 있는 파일들&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;9fd46bb7-04fd-463c-b2a2-8201452ddf2c&quot;&gt;&lt;span&gt;converter&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;d54e1829-7ba7-4197-8416-48b7cb21ed8b&quot;&gt;&lt;span&gt;queries&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;5ec268dc-c1c6-44f1-a976-022babf5c081&quot;&gt;&lt;span&gt;constants&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;blockquote&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;e86ffe20-df28-47f0-bf3f-3d957e5a74ee&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;58574725-27dc-4163-b273-9d840c0edd19&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;위 4가지 함수가 기능별로 중복해서 이름만 다르게 작성되어있는 상태였다.&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;약&amp;hellip; 48개정도 * 3 = 97개 사라졌다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;b7f27b8b-d056-479e-be9c-ec4fde6688e4&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;ex) ai-drawing~ , ai-core~ , ai-workflow ~&lt;/span&gt;&lt;/p&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;style7&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;20e3d5bd-d95d-4f38-ace3-bd006e1d4481&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;  AI-KIT을 개발하면서 겪었던 트러블 슈팅&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-pm-slice=&quot;1 5 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;55520f27-4ce4-44ce-90ec-b2afb0112287&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;문제 상황&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;ab1e22b7-b600-4ce3-8baf-705455c1a511&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;6e82c6ec-ac9a-453c-9b00-744c0a8e7162&quot;&gt;&lt;span&gt;AIKitQueryProvider에서 sdk 스크립트 로드 &amp;amp; auth 발급하여 aiKitQueryClient 생성하는 로직 구현 되어있었다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;be86dea4-c855-41fa-9625-1f660347047b&quot;&gt;&lt;span&gt;app에서 감싸는 경우 sdk 스크립트 로드를 위해 csr 렌더링을 해야 하는 상황이 발생했다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;6397db7d-5553-4be0-abcc-3abf3d952334&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;be86dea4-c855-41fa-9625-1f660347047b&quot;&gt;&lt;span&gt;ssr : false 로 렌더링을 해야 하는 상황&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1155&quot; data-origin-height=&quot;1666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9vpKT/dJMcabbVTjx/Yxk6m81dOOUKBQhGlX7BE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9vpKT/dJMcabbVTjx/Yxk6m81dOOUKBQhGlX7BE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9vpKT/dJMcabbVTjx/Yxk6m81dOOUKBQhGlX7BE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9vpKT%2FdJMcabbVTjx%2FYxk6m81dOOUKBQhGlX7BE0%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;490&quot; height=&quot;707&quot; data-origin-width=&quot;1155&quot; data-origin-height=&quot;1666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;1 5 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;7c7e8062-be64-4e7a-aa69-10c51e5cadc9&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;해결 방안&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;eb79c173-78d3-4327-877d-01ae1d303e1e&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;946ccc95-8556-42b5-8e5e-68013d9d846e&quot;&gt;&lt;span&gt;설명했던 방식대로 useAIKitCreditQuery, useAIKitGenerateMutation &amp;hellip; 기능 사용을 위한 훅을 호출할 때, sdk로드 &amp;amp; auth 발급해서 사용하도록 구조 개편을 진행했다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;b878ab5d-51f4-469a-b113-db8628315ab4&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;946ccc95-8556-42b5-8e5e-68013d9d846e&quot;&gt;&lt;span&gt;sdk 로드 ,auth 발급은 쿼리 캐시 보고 데이터 가져오도록 작성&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;4573d2f6-4c25-484a-b3ec-078d91d03853&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;남은 작업들&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;48179fcc-7422-4f8a-8b00-b93c7ebe8a65&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;c64d34ad-a056-4aca-8226-0009e3beafee&quot;&gt;&lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;ai-kit 1차 모듈화한 것 &lt;b&gt;내부 미리클, 외부 미리클에 100% 적용하&lt;/b&gt;기&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;28f09e23-b627-4aa2-b89c-d0f4379f452c&quot;&gt;&lt;span&gt;AI-KIT 내부 &lt;b&gt;폴더 구조 개편&lt;/b&gt;하기&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;c530f640-e108-4e19-acd2-2685a8266019&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;3731357a-d0fc-4e95-a696-23844ac07a9e&quot;&gt;&lt;span&gt;사실 지금까지는 이 필요성을 느끼지 못했는데, 미리클 스쿼드의 내년 OKR의 주요 내용 중에서 '30초만에 사용성을 느낄 수 있는 UI/UX를 기능에 따라 동일하게 제공한다' 라는 맥락이 있었고, 이에 따르면 &lt;b&gt;layered architecture&lt;/b&gt; 로 가볼만한 이유가 된다고 생각했다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;b21005d1-c32b-4700-af2c-9afee6271ae8&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;그리고 모두가 생각했으면 하는 앞으로의 개발 자세&lt;/span&gt;&lt;/h2&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;0ef1dd23-b710-4cff-8c3c-fad8887792cb&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;스쿼드 업무를 하면서 느꼈지만, 프로덕트 관점에서는 제품 성장과 매출이 우선시 되어야 하기에 최대한 빠르게 검증하는 과정이 특정 시기마다 반복된다고 느꼈다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;0ef1dd23-b710-4cff-8c3c-fad8887792cb&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;4b066616-69f2-426e-86d5-87e763b1d863&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만, 그 과정에서 개발적으로 불필요한 내용, 아니면 이런 부분이 개선되면 프로덕트 관점에서 새로운 시도를 하기까지 오히려 더 시간이 단축될 수 있는 부분들을 개발자 스스로 발견하고, 피쳐 개발 이외의 시간을 투자하여 개선하는 것이 나뿐만 아니라 다른 개발자, 그리고 스쿼드 더 크게보면 미리디까지에도 영향을 줄 수 있는 부분이라고 생각했고 &lt;/span&gt;&lt;span&gt;그래서 개발자는 &amp;lsquo;코드 찍어내는 기계&amp;rsquo;가 맞지만 &lt;b&gt;그 코드를 얼마나 효율적으로 찍어내는 기계인지 스스로 점검&lt;/b&gt;하면서 임할 필요성을 크게 느꼈다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;4b066616-69f2-426e-86d5-87e763b1d863&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-local-id=&quot;06f3131b-0e8c-4804-bb04-5e143389778a&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;앞으로 여러분들도 개발을 하다가 이러한 비슷한 경험을 하고 있다면, 피쳐 개발이 몰려있다하더라도 틈틈히 내부 개발 부채들을 해결하는 노력을 시도해보았으면 좋을 것 같다. &lt;br /&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;</description>
      <category>WEB</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/52</guid>
      <comments>https://happhee-dev.tistory.com/52#entry52comment</comments>
      <pubDate>Mon, 26 Jan 2026 09:44:16 +0900</pubDate>
    </item>
    <item>
      <title>P.E.C 캠프 ) 데이터의 관점에서 FSD와 Next.js 결합해보기</title>
      <link>https://happhee-dev.tistory.com/51</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 P.E.C 캠프 6기로 참여하면서 FSD의 개념을 Next.js와 결합해보는 과정을 진행했었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a title=&quot;P.E.C 캠프&quot; href=&quot;https://slashpage.com/pec?lang=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;P.E.C 캠프&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1742661875607&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Product Engineer Camp - pec&quot; data-og-description=&quot;8주동안 진행하는 Camp 를 통해서, 내 주변에 실제 문제를 해결하며 진짜 성장을 경험하세요. 다양한 UX framework 를 활용하여 설계하고, 이를 토대로 AI 와 효율적으로 협업하는 방법을 학습합니다. &quot; data-og-host=&quot;slashpage.com&quot; data-og-source-url=&quot;https://slashpage.com/pec?lang=ko&quot; data-og-url=&quot;https://slashpage.com/pec?lang=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/IBpLN/hyYrTITic8/kPqCHSta3HR5hQzUXmIWVK/img.jpg?width=720&amp;amp;height=691&amp;amp;face=0_0_720_691,https://scrap.kakaocdn.net/dn/4sJZo/hyYvjF5GfH/S9rAARsRcspR9HjhAkK1lk/img.jpg?width=720&amp;amp;height=691&amp;amp;face=0_0_720_691,https://scrap.kakaocdn.net/dn/bCAogk/hyYvmpgjpk/1mjIRaWQWQRLJH1aKpv6W1/img.jpg?width=1500&amp;amp;height=855&amp;amp;face=0_0_1500_855&quot;&gt;&lt;a href=&quot;https://slashpage.com/pec?lang=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://slashpage.com/pec?lang=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/IBpLN/hyYrTITic8/kPqCHSta3HR5hQzUXmIWVK/img.jpg?width=720&amp;amp;height=691&amp;amp;face=0_0_720_691,https://scrap.kakaocdn.net/dn/4sJZo/hyYvjF5GfH/S9rAARsRcspR9HjhAkK1lk/img.jpg?width=720&amp;amp;height=691&amp;amp;face=0_0_720_691,https://scrap.kakaocdn.net/dn/bCAogk/hyYvmpgjpk/1mjIRaWQWQRLJH1aKpv6W1/img.jpg?width=1500&amp;amp;height=855&amp;amp;face=0_0_1500_855');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Product Engineer Camp - pec&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;8주동안 진행하는 Camp 를 통해서, 내 주변에 실제 문제를 해결하며 진짜 성장을 경험하세요. 다양한 UX framework 를 활용하여 설계하고, 이를 토대로 AI 와 효율적으로 협업하는 방법을 학습합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;slashpage.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;흔히 알고 있는 역할 중심의 설계를 벗어자세한 FSD의 개념은 하단 블로그에서 상세하게 소개시켜주고 있기에 여기서는 내가 정립했던 FSD와 Next.js의 개념에 대해서만 다뤄보고자 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://velog.io/@teo/fsd&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@teo/fsd&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1742661810931&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;FSD 관점으로 바라보는 코드 경계 찾기&quot; data-og-description=&quot;이번 글의 주제도 FSD(Feature-Slided Design)입니다! 현재 제가 가장 관심있는 관심사 2가지 중 하나가 바로 이 FSD 네요. 지금 하고 있는 일이 레거시 코드를 최신 기술로 고도화하는 작업입니다. 그러&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@teo/fsd&quot; data-og-url=&quot;https://velog.io/@teo/fsd&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/DhDgi/hyYurdL3HS/wXadMDokvI2HHK2CnywMAk/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/B3xX5/hyYvut4fKx/SmDqerx2xDWVRNzRkvbGWk/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/ulzF6/hyYrVUgI2S/IQBxMhNsGjWRIQfvvScHqK/img.png?width=4755&amp;amp;height=3211&amp;amp;face=0_0_4755_3211&quot;&gt;&lt;a href=&quot;https://velog.io/@teo/fsd&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@teo/fsd&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/DhDgi/hyYurdL3HS/wXadMDokvI2HHK2CnywMAk/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/B3xX5/hyYvut4fKx/SmDqerx2xDWVRNzRkvbGWk/img.png?width=700&amp;amp;height=350&amp;amp;face=0_0_700_350,https://scrap.kakaocdn.net/dn/ulzF6/hyYrVUgI2S/IQBxMhNsGjWRIQfvvScHqK/img.png?width=4755&amp;amp;height=3211&amp;amp;face=0_0_4755_3211');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;FSD 관점으로 바라보는 코드 경계 찾기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이번 글의 주제도 FSD(Feature-Slided Design)입니다! 현재 제가 가장 관심있는 관심사 2가지 중 하나가 바로 이 FSD 네요. 지금 하고 있는 일이 레거시 코드를 최신 기술로 고도화하는 작업입니다. 그러&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;h2 data-ke-size=&quot;size26&quot;&gt;우리가 정의한 FSD의 개념&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식문서에도 자세한 설명이 나와있지만, P.E.C 캠프에서 경찬님과 6기 회원분들이랑 정의한 FSD의 개념은 아래와 같았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;app&lt;/b&gt;: FSD와 동일&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pages&lt;/b&gt;: &lt;span style=&quot;text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;FSD와 동일&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;widgets&lt;/b&gt;: &lt;b&gt;features들&lt;/b&gt;의 모임&lt;/li&gt;
&lt;li&gt;&lt;b&gt;features&lt;/b&gt;: POST, PUT, DELETE와 같이 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/span&gt;의 상태가 변하는 요청들이 포함된 곳&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;entities&lt;/b&gt;: GET와 같이 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/span&gt;를 보여주는 요청들이 포함된 곳&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;shared&lt;/b&gt;: API 요청과는 관계없이 여러 도메인에서 사용하는 요소들이 포함된 곳&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;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 데이터 인가? 어떤 데이터 인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 클라이언트 개발자이다. &lt;br /&gt;그래서 이전까지만해도 클라이언트의 입장에서 레이어를 분리할 때 경계를 아래의 사진처럼 세로로 구현했었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;814&quot; data-origin-height=&quot;388&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dELfJi/btsMSSVO5zC/RGroqQqgJhU821WqXdLD60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dELfJi/btsMSSVO5zC/RGroqQqgJhU821WqXdLD60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dELfJi/btsMSSVO5zC/RGroqQqgJhU821WqXdLD60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdELfJi%2FbtsMSSVO5zC%2FRGroqQqgJhU821WqXdLD60%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;191&quot; data-origin-width=&quot;814&quot; data-origin-height=&quot;388&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;위 구조의 장/단점은 아래와 같다.&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;컴포넌트와 API의 스펙이 분리되어지기에 API 스펙 변동이 발생하면, 서버와 클라이언트 스펙 간의 변환지점만 수정해주면 된다. 즉, 컴포넌트 자체를 수정해야 하는 일은 거의 없다.   클라이언트와 서버 사이의 의존성을 끊어낼 수 있다.&amp;nbsp;&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;단순히 API 응답값을 화면에 보여주는 과정에서도 서버와 클라이언트 스펙 2가지를 모두 구현해야 하기에 중복으로 선언되는 타입들과 변환 과정을 구현하는 파일들이 불필요하게 생겨나게 된다.&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;의 관점을 클라이언트와 서버 2가지로 바라보고 있었기 때문에 만들어졌다고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 과거에 이 방향을 지향했던 이유이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 개발자의 입장에서 서버와 클라이언트 스펙 자체를 완전히 분리시키면 의존성을 끊어낸다면, API 스펙이 완성되기전까지 마냥 기다리는 클라이언트 개발자가 아니라, 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;다만, 앞서 말한 단점으로 인해 분리가 모호해지는 순간들과 똑같은 프로퍼티를 가지는 타입과 파일들이 점차 비대해지기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 바라보았을 때, 서버 API 스펙이 변경되면 클라이언트 스펙들의 수정범위도 점점 퍼져나가게 되는 상황이 오게 된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 데이터를 클라이언트, 서버 2개로 나눠서 보았을 때의 상황이다.&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;그렇다면 데이터를 1개로 바라본다면 어떤 상황에 마주할 수 있을까?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1079&quot; data-origin-height=&quot;471&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccMSEP/btsMSDj7Zdt/5kyYL97WGlKUVIakw3u0EK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccMSEP/btsMSDj7Zdt/5kyYL97WGlKUVIakw3u0EK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccMSEP/btsMSDj7Zdt/5kyYL97WGlKUVIakw3u0EK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccMSEP%2FbtsMSDj7Zdt%2F5kyYL97WGlKUVIakw3u0EK%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;500&quot; height=&quot;218&quot; data-origin-width=&quot;1079&quot; data-origin-height=&quot;471&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트, 서버 간의 경계가 사라지고, 하나로 묶어진 상황에서 계층이 좌우가 아닌 위/아래로 나뉘어지게 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 구조의 장/단점은 아래와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;데이터를 2개로 바라보았을때, 중복으로 선언되는 타입들과 변환 과정을 구현하는 파일들이 불필요하게 생겨나지 않는다. 앞서 말한 단점이 사라지게 되는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&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;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 여기서 궁금증이 생길 것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;1번과 2번을 나누는 기준은 무엇으로 잡아야 하는 것 일까?&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;이 질문이 앞서 정의한 FSD의 개념의 시발점이 되어주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다시 데이터 1개의 관점에서 FSD 개념을 살펴보자.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 언급한 FSD의 개념에서 features, entities의 정의를 다시 살펴보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;features&lt;/b&gt;: POST, PUT, DELETE와 같이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/span&gt;의 상태가 변하는 요청들이 포함된 곳&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #212529; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;변하는 데이터&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #212529; text-align: left;&quot;&gt;들이 모인 곳&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;entities&lt;/b&gt;: GET와 같이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;데이터&lt;/b&gt;&lt;/span&gt;를 보여주는 요청들이 포함된 곳&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #212529; text-align: left;&quot;&gt; &lt;/span&gt;&amp;nbsp;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;변하지 않는 데이터&lt;/b&gt;&lt;/span&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;blockquote data-ke-size=&quot;size26&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;변하는 데이터 vs 변하지 않는 데이터&lt;/span&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;변한다는 기준이 클라이언트의 입장으로만 바라보고 있는 것이 아니라 클라이언트와 서버의 2가지 관점을 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;한 번, segements까지 예시를 들어보면&lt;/p&gt;
&lt;pre id=&quot;code_1742678202542&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├──   feautres
│  ├──   도메인명
│  │ │   ├──   api		// ✅ POST,PUT,DELETE 요청
│  │ │   ├──   model
│  │ │   └──   ui
├──   entities
│  ├──   도메인명			// ✅ GET 요청
│  │ │   ├──   api
│  │ │   ├──   model
│  │ │   └──   ui&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;features의 하위 세그먼트 안의 api폴더에는 변하는 것들의 요청들이 들어가야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;사용자의 동작(클라이언트)으로 인해 DB의 값(서버)이 변하게 되는 요청&lt;/b&gt;인 POST, PUT, DELETE 등이 features의 api 파일에 위치할 수 있는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 entities의 개념은 자연스럽게 유추되지 않는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상한대로 &lt;b&gt;사용자의 동작(클라이언트)으로 인해 DB의 값(서버)이 변하지 않는 요청&lt;/b&gt;인 GET이 entities의 하위 api 파일에 위치할 수 있는 것이다.&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;그리고 widgets은 features의 상위 폴더이기에 features들의 모임이라면 widgets에 속할 수 있게 되는 개념으로 정의한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이제 Next.js와 결합해볼까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 위 개념을 요즘 많이 쓰는 Next.js에서 어떻게 적용해보아야 할 까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App 라우터를 쓰고 있다는 가정으로 폴더를 구성해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 App 라우터 폴더링은 다음과 같이 구성되어있다.&lt;/p&gt;
&lt;pre id=&quot;code_1742678796600&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├──   app
│  ├──   페이지명1
│  │ │   ├── layout.tsx
│  │ │   └── page.tsx
│  ├──   페이지명2
│  │ │   ├── layout.tsx
│  │ │   └── page.tsx

// ✅ 아래의 폴더들은 app폴더와 같은 위계에 있을수도 있고, 다른 위계에 있을 수도 있다.
...
├──   components
│  ├── 컴포넌트명.tsx
│  ├── 컴포넌트명.tsx
│  └── 컴포넌트명.tsx
...
├──   constant
├──   fonts
├──   lib
├──   hooks
├──   util
├──   styles
├──   types
...
├── .env
...
├── webpack.config.js
└── yarn.lock&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;app 하위에 페이지별로 폴더가 구성되어 있고, 각 폴더안 에는 layout.tsx, page.tsx가 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 각 페이지별로 필요한 components, constatns, hooks, utils,&amp;nbsp; styles, types 와 같은 폴더는 apps와 동일한 위계에 놓일 수도 있고, 도메인명 폴더링,페이지명 폴더링 하위와 같은 곳에 놓일 수도 있다.&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;이제부터 FSD와 Next.js를 결합해보면서 폴더 구조를 만들어 가려고 하는데, components, constatns, hooks, utils,&amp;nbsp; styles, type의 맥락에서 생성해야 하는 파일들을 도메인 하위로 놓는다는 전제로 글을 이어가려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 예시로 살펴보고자 상황은 블로그 테마를 목록으로 확인하고, 테마를 생성하고, 수정하는 기능이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣&amp;nbsp; App의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD에서 정의하고 있는 app의 개념과 next.js의 app폴더가 겹치게 된다는 것을 가장 먼저 발견할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각이 정의하고 있는 app의 역할이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center; height: 17px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center; height: 17px;&quot;&gt;&lt;b&gt;FSD&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;&lt;b&gt;Next.js&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;주요 역할&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;애플리케이션의 전역 설정 (&lt;b&gt;라우팅&lt;/b&gt;, 스토어 등)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;; text-align: start;&quot;&gt;새로운 파일 시스템 기반 &lt;b&gt;라우팅&lt;/b&gt;을 위한 디렉터리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;포함 요소&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;라우터 설정, 스토어, 글로벌 스타일, 에러 처리 등&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;레이아웃, 페이지, 서버 컴포넌트, 로딩 처리 등&lt;br /&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;개념적 의미&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;; text-align: left;&quot;&gt;애플리케이션 초기화의 중심&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;라우팅 중심 구조&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;사용 목적&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;아키텍처 관점에서의 책임 분리&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;프레임워크 관점에서의 디렉터리 규칙&lt;/span&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;Next.js의 app 디렉토리는 &lt;b&gt;파일 기반 라우팅을 위한 강제 구조&lt;/b&gt;이다. 여기서의 app은 _&quot;이 경로 안에서 페이지와 레이아웃을 정의하세요&quot;_라는 Next.js의 문법적 요구사항에 가깝다.&lt;/p&gt;
&lt;p data-end=&quot;943&quot; data-start=&quot;810&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;943&quot; data-start=&quot;810&quot; data-ke-size=&quot;size16&quot;&gt;반면, FSD에서 말하는 app은 애플리케이션 아키텍처 상에서 &lt;b&gt;전역 설정이 모이는 곳&lt;/b&gt;이다. 라우터, 전역 상태관리(store), 글로벌 스타일, 전역 에러 핸들링 등, 말 그대로 애플리케이션 전체의 스켈레톤을 구성하는 영역이다.&lt;/p&gt;
&lt;p data-end=&quot;943&quot; data-start=&quot;810&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;943&quot; data-start=&quot;810&quot; data-ke-size=&quot;size16&quot;&gt;두 개념 모두 &lt;b&gt;라우팅 기능을 갖고 있다&lt;/b&gt;는 점에서 혼란이 발생할 수 있다.&lt;br /&gt;하지만 목적은 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1115&quot; data-start=&quot;1029&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1065&quot; data-start=&quot;1029&quot;&gt;Next.js는 &lt;b&gt;파일 시스템 기반 라우팅만&lt;/b&gt;을 제공한다.&lt;/li&gt;
&lt;li data-end=&quot;1115&quot; data-start=&quot;1066&quot;&gt;FSD는 &lt;b&gt;라우터 라이브러리 자체 설정을 포함한 앱 전체 흐름 제어&lt;/b&gt;를 담당한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1172&quot; data-start=&quot;1117&quot; data-ke-size=&quot;size16&quot;&gt;즉, FSD는 Next.js의 라우팅 기능을 포함하거나 감싸는 &lt;b&gt;상위 개념&lt;/b&gt;이라고 볼 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1172&quot; data-start=&quot;1117&quot; data-ke-size=&quot;size16&quot;&gt;FSD와 Next.js 모두 &quot;앱&quot;이라는 용어를 사용하지만, &lt;b&gt;그들이 말하는 앱의 범위와 관점은 다르다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1307&quot; data-start=&quot;1260&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1280&quot; data-start=&quot;1260&quot;&gt;FSD는 아키텍처 설계 원칙이다.&lt;/li&gt;
&lt;li data-end=&quot;1307&quot; data-start=&quot;1281&quot;&gt;Next.js는 프레임워크의 구현 규칙이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1411&quot; data-start=&quot;1309&quot; data-ke-size=&quot;size16&quot;&gt;다만, Next.js가 제공하는 app 디렉토리를 FSD의 규칙 안에 포함시키는 방식으로 이해하면, 아래와 같이 폴더를 구성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1742681025661&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├──   app					
│  ├──   _providers	// ✅ 전역설정 
│  │    ├── ThemeProvider.tsx
│  │ 	└── QueryProvider.tsx
│  ├──   auth		// ✅ auth 체크 
│  ├──   themes		  // ✅ 라우팅 구현
│  │     ├──    [id]
│  │     │  ├── page.tsx
│  │     │	└── layout.tsx
│  │     ├──  page.tsx
│  │     └── layout.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;943&quot; data-start=&quot;810&quot; 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; text-align: start;&quot;&gt;두 개념이 충돌하기보다는&lt;span&gt;&amp;nbsp;&lt;/span&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2️⃣&amp;nbsp; model, lib, hooks는 어떻게 통합하지?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hooks라는 폴더를 사용했던 이유는 무엇일까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마, React에서 &quot;Custom Hook 만들기&quot;를 처음 배울 때, 자연스럽게 hooks 폴더를 만들기도 했고, &quot;이 훅, 여러 곳에서 쓰일 수 있으니까 공통 디렉토리에 빼야지.&quot; 라는 생각이 들면서 hooks라는 폴더링을 사용하지 않았을까 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hooks의 목적은 다음과 같이 간단하게 정의할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UI 로직 재사용 ex) useModal&lt;/li&gt;
&lt;li&gt;비즈니스 상태 관리 ex) useThemeCheckList&lt;/li&gt;
&lt;li&gt;API 호출 로직 ex) useGetThemeList, useThemeDetail&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 FSD에서 hooks를 제거하고 공식문서에서 정의하고 있는 방향인 model, 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;그 관점으로 폴더를 1차적으로 개선해보면, 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UI 로직 재사용   &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;model&lt;/b&gt;&lt;/span&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;비즈니스 상태 관리   &lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;model&lt;/span&gt;&amp;nbsp;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;API 호출 로직   &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;api&lt;/b&gt;&lt;/span&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;model에는 어쨋든 데이터(클라이언트,서버의 입장)와 관련된 로직들이 포함되어야 하고, api에는 말 그대로 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;그렇다면 lib은 어떻게 해야하는 거지?&lt;/p&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 공식 문서에서는 lib에 대한 명확한 가이드는 없다.&lt;br /&gt;하지만 실무를 하다 보면, model만으로는 책임이 너무 많아지고, 다음과 같은 고민이 생기게 된다:&lt;/p&gt;
&lt;blockquote data-end=&quot;1057&quot; data-start=&quot;993&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1057&quot; data-start=&quot;995&quot; data-ke-size=&quot;size16&quot;&gt;&quot;상태 관련 로직과 유틸성 로직이 뒤섞이면서 model이 점점 커지고 무거워지는데, 이걸 어떻게 나눠야 하지?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1113&quot; data-start=&quot;1059&quot; data-ke-size=&quot;size16&quot;&gt;그래서 나는 다음과 같은 기준으로 lib의 역할을 정의하고, model과 분리하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 내가 예상했을 때 lib이 존재하지 않으면 model이 엄청나게 비대해질 가능성이 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 lib을 도입하여 model의 역할을 분산시키고자 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수차례 언급하고 있는 데이터의 관점에서 다음과 같이 lib과 model을 분리하고자 했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-style=&quot;style2&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;model&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;lib&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 12.5581%; height: 19px; text-align: center;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;역할&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; height: 19px; text-align: center;&quot;&gt;&lt;span&gt;&lt;span&gt;클라이언트&amp;middot;서버 데이터를 포함한 &lt;b&gt;상태 주도 로직&lt;/b&gt;, &lt;br /&gt;API 요청, 상태 계산, 전역 상태 연동 등&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;&lt;span&gt;사용자 인터랙션에 따른 &lt;b&gt;순수한 연산&amp;middot;변환 로직&lt;/b&gt;,&lt;br /&gt;UI 상태 변경 유틸, 포맷터 등&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.5581%; text-align: center;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;예시&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4419%; text-align: center;&quot;&gt;&lt;span&gt;&lt;span&gt;useDeleteTheme&lt;br /&gt;useUpdateTheme&lt;br /&gt;useGetThemeDetails&lt;br /&gt;transformCreateThemeData&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;span&gt;useCheckedList&lt;br /&gt;calculateThemeCount&lt;/span&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;폴더구조로 이해해보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;features/theme&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1742682702976&quot; class=&quot;bash&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;├──   features					
│  ├──   themes
│  │    ├──   api	
│  │    │  ├── deleteThemes.ts
│  │    │  └── postTheme.ts
│  │    ├──   lib	
│  │    │  └── useThemeCheckedList.tsx
│  │    ├──   ui	
│  │    │  ├── DeleteThemeButton.tsx
│  │    │  └── CreateThemeButton.tsx
│  │ 	└──   model	
│  │    │  ├── transformCreateThemeData.ts
│  │    │  └── useDeleteThemes.ts&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 35px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;폴더&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center; height: 17px;&quot;&gt;api&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center; height: 17px;&quot;&gt;테마 생성, 삭제 등 외부와의 통신 역할을 담당하는 API 요청 함수들이 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;lib&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;클라이언트 상호작용에서 파생되는 UI 중심 로직 위치.&lt;br /&gt;&lt;br /&gt;useThemeCheckedList.tsx는 체크 상태를 관리하기 위한 훅이지만, &lt;br /&gt;상태 자체보다 UI 반응을 위한 로직에 가깝다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;ui&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;사용자 인터페이스 구성 요소. &lt;br /&gt;&lt;br /&gt;실제 버튼이나 리스트 등 뷰 레이어 컴포넌트가 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;model&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;상태 계산, 데이터 변환, 상태 관리 훅 등 비즈니스 로직에 가까운 부분&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;entites/themes&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1742683324645&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;				
├──   entities					
│  ├──   themes
│  │    ├──   api	
│  │    │  └── getThemeDetail.ts
│  │    ├──   lib	
│  │    │  └── adjustColor.tsx 
│  │    ├──   ui	
│  │    │  ├── ThemeThumbnailList.tsx
│  │    │  └── ThemeThumbnailCard.tsx
│  │ 	└──   model	
│  │    │  ├── useThemeDetail.tsx
│  │    │  └── useThemeList.tsx&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 35px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;폴더&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center; height: 17px;&quot;&gt;api&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center; height: 17px;&quot;&gt;테마 상세 조회와 같은 읽기 기반 API 호출 로직이 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;lib&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;UI 보조 기능 또는 순수 연산에 가까운 유틸 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;ui&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;사용자 인터페이스 구성 요소. &lt;br /&gt;&lt;br /&gt;실제 테마를 보여주는 카드, 리스트 UI 컴포넌트가 위치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 18.6047%; text-align: center;&quot;&gt;model&lt;/td&gt;
&lt;td style=&quot;width: 81.3953%; text-align: center;&quot;&gt;react-query 기반의 상태 훅 등, 외부 데이터와 내부 상태를 연결하는 비즈니스 로직&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;p data-ke-size=&quot;size16&quot;&gt;이와 같이 설계했을 때 얻을 수 있는 장점은 &lt;b&gt;hooks라는 구현 방식 중심의 폴더를 제거함으로써&lt;/b&gt;, 도메인 책임에 따라 로직이 명확하게 위치할 수 있으며, model은 상태&amp;middot;데이터 중심 로직, lib은 순수 함수나 UI 헬퍼 역할, api는 외부 통신이라는 명확한 기준을 가질 수 있다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 협업 시 &amp;ldquo;이 훅 어디 있지?&amp;rdquo;라는 질문 대신, &amp;ldquo;이 기능 어디 소속이지?&amp;rdquo;라는 질문이 되므로 &lt;b&gt;의도 파악이 쉬워진다.&amp;nbsp;&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;결론적으로 기능 중심이 아닌 도메인 중심으로 model, lib, hooks를 풀어가는 과정에서&amp;nbsp;hooks 폴더는&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 data-ke-size=&quot;size16&quot;&gt;또한, FSD 관점에서는 &lt;b&gt;UI, 상태, API 로직 모두 의미 있는 경계(도메인)&lt;/b&gt; 안에 배치해야 하기에 model은 상태와 데이터 흐름의 중심이 되고, lib은 그 흐름 안에서 쓰이는 보조 연산/가공 도구로써 기능을 분리하는 것이 이상적이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아직 함께 고민해봐야 할 FSD 정의들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FSD의 핵심은 &amp;lsquo;&lt;b&gt;의미 있는 책임 단위로 코드를 나누는 것&lt;/b&gt;&amp;rsquo;이다.&lt;br /&gt;하지만 실무에서는 명확히 떨어지지 않는 순간들이 많다. 아래는 개인적으로도 명확한 기준을 세우기 어려웠고, 함께 고민해보고 싶은 주제들이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번&amp;nbsp;글에서는&amp;nbsp;FSD와&amp;nbsp;Next.js를&amp;nbsp;결합하며&amp;nbsp;`hooks`&amp;nbsp;디렉토리를&amp;nbsp;제거하고,&amp;nbsp;`model`,&amp;nbsp;`lib`,&amp;nbsp;`api`로&amp;nbsp;로직을&amp;nbsp;재구성한&amp;nbsp;경험을&amp;nbsp;공유했다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;이&amp;nbsp;과정에서&amp;nbsp;&quot;데이터를&amp;nbsp;중심으로&amp;nbsp;경계를&amp;nbsp;나눈다&quot;는&amp;nbsp;사고방식이&amp;nbsp;설계를&amp;nbsp;어떻게&amp;nbsp;바꾸는지를&amp;nbsp;느낄&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;&lt;br /&gt;하지만&amp;nbsp;여전히&amp;nbsp;아래와&amp;nbsp;같은&amp;nbsp;질문은&amp;nbsp;명확한&amp;nbsp;정답이&amp;nbsp;없기에,&amp;nbsp;함께&amp;nbsp;고민해보고&amp;nbsp;싶다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 69px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;질문&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 18px;&quot;&gt;&lt;b&gt;해석&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;lib의 범위는 어디까지 허용해야 할까?&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;단순&amp;nbsp;유틸인지,&amp;nbsp;특정&amp;nbsp;도메인에&amp;nbsp;종속된&amp;nbsp;계산&amp;nbsp;로직인지에&amp;nbsp;따라&amp;nbsp;구분이&amp;nbsp;필요할까?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;공통 로직이 여러 도메인에서 반복될 경우, &lt;br /&gt;shared에 옮겨야 할 기준은 무엇일까?&amp;nbsp;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;의존성과&amp;nbsp;추상화&amp;nbsp;수준의&amp;nbsp;균형은&amp;nbsp;어디에서&amp;nbsp;잡아야&amp;nbsp;할까?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;서버 컴포넌트가 많아지는 Next.js 환경에서 &lt;br /&gt;상태를 model로 유지하는 기준은 어떻게 달라져야 할까?&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center; height: 17px;&quot;&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;widgets는 언제 도입해야 하는가?&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;widgets가 많아질수록 결국 또다른&quot;features 폴더&quot;가 되지는 않을까?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;Next.js에서 서버 컴포넌트와 FSD는 어떻게 공존할까?&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;서버 컴포넌트 내부에서 entities를 불러오는 게 자연스러운가?&lt;br /&gt;&lt;br /&gt;아니면&lt;br /&gt;&lt;br /&gt;그 자체도 entities로 봐야 하는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;정답보다는&amp;nbsp;상황에&amp;nbsp;따라&amp;nbsp;달라지는&amp;nbsp;선택의&amp;nbsp;연속일&amp;nbsp;수&amp;nbsp;있지만,&amp;nbsp;이런&amp;nbsp;구조적&amp;nbsp;고민을&amp;nbsp;계속&amp;nbsp;이어가는&amp;nbsp;것이&amp;nbsp;클린&amp;nbsp;아키텍처의&amp;nbsp;핵심이라고&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;그리고 FSD는 단순한 폴더 구조가 아니라 &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;middot;기술 스택&amp;middot;프로젝트 성격에 따라 유연하게 정의되어야 한다.&lt;/p&gt;
&lt;p data-end=&quot;2069&quot; data-start=&quot;1957&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이런 애매한 순간들이 오히려 팀과 함께 아키텍처 기준을 세우는 기회가 된다.&lt;/p&gt;
&lt;p data-end=&quot;2069&quot; data-start=&quot;1957&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2069&quot; data-start=&quot;1957&quot; data-ke-size=&quot;size16&quot;&gt;FSD를 도입하려는 사람, 도입했지만 의문을 느낀 사람들과 함께 &lt;b&gt;'우리만의 설계 원칙'을 만들어가고 싶다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 나의 바람은 곧 커뮤니티 오픈으로 함께 될 예정이다.&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;a href=&quot;https://www.productengineer.info/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;PEC Community &lt;/a&gt;에서 진행할 예정이다..!&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1742683898225&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;PEC Community&quot; data-og-description=&quot;최근 기술 컨텐츠(4월 오픈 예정) 기술의 등장 배경과 동작 원리에 대한 깊이 있는 컨텐츠를 제공합니다.&quot; data-og-host=&quot;www.productengineer.info&quot; data-og-source-url=&quot;https://www.productengineer.info/&quot; data-og-url=&quot;https://www.productengineer.info/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://www.productengineer.info/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.productengineer.info/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;PEC Community&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;최근 기술 컨텐츠(4월 오픈 예정) 기술의 등장 배경과 동작 원리에 대한 깊이 있는 컨텐츠를 제공합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.productengineer.info&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>WEB/Next.js</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/51</guid>
      <comments>https://happhee-dev.tistory.com/51#entry51comment</comments>
      <pubDate>Sun, 16 Mar 2025 09:58:24 +0900</pubDate>
    </item>
    <item>
      <title>TEOConf 2024 해피로 말하다</title>
      <link>https://happhee-dev.tistory.com/50</link>
      <description>&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;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/deXBf3/btsKVqG2j9J/KJnp3EKfmcCLgbkakzDkTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/deXBf3/btsKVqG2j9J/KJnp3EKfmcCLgbkakzDkTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/deXBf3/btsKVqG2j9J/KJnp3EKfmcCLgbkakzDkTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdeXBf3%2FbtsKVqG2j9J%2FKJnp3EKfmcCLgbkakzDkTk%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;1280&quot; height=&quot;640&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;640&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ &amp;nbsp;TEOConf2024 스피커로 참여하게 된 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;지난 feconf에서는 처음 사회에 나온 주니어 개발자들에게 어떤 마인드 셋으로 임해야 하는지, 이상적인 개발 환경이 아닌 곳에서 어려움을 헤쳐 나가고 있는 개발자들에게 자신감을 심어주기 위해 라이트닝 스피커로 참여한 경험이 있었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;이번에는 1년 동안 개발을 진행하면서, 프론트엔드 개발자가 어려워지는 순간은 프론트엔드가 서버에 의존적으로 되어서 수정작업을 빠르게 할 수 없는 것이 문제라고 생각했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;이를 해결하기 위해 &lt;b&gt;함께&lt;/b&gt; 고민하고, 도전해 봤던 기술적인 경험과 인사이트를 다른 개발자분들과 &lt;b&gt;공유&lt;/b&gt;하고 싶어, &lt;br /&gt;&lt;b&gt;해피&lt;/b&gt;라는 이름으로 '&lt;b&gt;어댑터 아키텍처를 통해 클라이언트 환경 개선하기&lt;/b&gt;'라는 주제를 가지고 TEOConf 2024의 스피커로 참여하게 되었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &amp;zwj;  테오와 함께&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테크와 관련된 주제를 청중들에게 발표하는 경험이 처음이었기에 살짝 걱정이 있었지만, 테오와 다른 스피커 분들을 믿고 &lt;b&gt;Adapter 아키텍처&lt;/b&gt;라는 테크로 주제를 잡게 되었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;9월 초에 진행되었던 온라인 피드백을 받기 전부터 고민했던 부분은 아래와 같다.&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;lsquo;만약 나라면, ~이렇게 했을 텐데&amp;rsquo;를 생각해 낼 수 있도록 만들기 위해서는 어디에 집중해야 하는 것일까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나와 배경이 다른 사람에게 기술적인 공감대를 끌어내는 것이 일상 속의 마인드 셋과 관련된 내용보다 어렵다고 느껴졌다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;발표자로 처음 어려웠던 순간을 해결해 준 분들은 테오와 함께하는 스피커 분들이었다.  &lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;우선, 처음 내가 전달하고 싶은 핵심 메시지, 메시지를 구성하는 서브 항목들, 핵심이 되는 항목과 인사이트 총 3가지를 정리하여 테오에게 전달했고, 이 내용과 앞서 블로그에 작성한 글을 기반으로 발표의 전개를 구상하기 시작했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;자기소개를 시작으로, 기획 요구사항의 수정으로 바뀌었던 API의 스펙으로 인해 비즈니스 로직을 컴포넌트 안에서 수정하게 되었던 내용, 이를 해결하기 위해 찾아보았던 Adapter 아키텍처와 Compound 패턴, 하지만 회사에 적용하기에는 검증이 필요하여 우선 사이드 프로젝트에 도입했던 내용 등을 대화로 나눠보면서 발표의 기승전결을 만들었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;여기서도 핵심은 내가 시도했던 방법인 서버 환경과 클라이언트 환경 사이에 Adapter라는 계층을 추가하는 그 과정의 흐름을 따라올 수 있도록 몰입을 이끌어내는 설명이었다.&lt;br /&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;기&amp;nbsp;&amp;nbsp;&lt;/b&gt;&lt;br /&gt;  &lt;b&gt;어떤 문제 상황&lt;/b&gt;이 발생했는가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;승&lt;/b&gt;&lt;br /&gt;  앞선 문제 상황을 &lt;b&gt;왜 해결&lt;/b&gt;해야 했는가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전&lt;/b&gt;&lt;br /&gt;  &lt;b&gt;왜 Adapter 아키텍쳐&lt;/b&gt;를 떠올렸는가?&lt;br /&gt;  Compound 패턴은 &lt;b&gt;어떻게 도입&lt;/b&gt;이 된 것인가?&lt;br /&gt;  &lt;b&gt;왜 Adapter 를 class 형식&lt;/b&gt;으로 만들게 되었는가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결&lt;/b&gt;&lt;br /&gt;  그래서 &lt;b&gt;결과는 어떻게&lt;/b&gt; 되었는가?&lt;br /&gt;  이 모든을 기반으로 청중에게 전하고 싶은 &lt;b&gt;마지막 메시지&lt;/b&gt;는 무엇인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테오와의 피드백을 통해 정리된 기승전결이다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그래도 나름 이전에 블로그에 기록해 두었던 내용들이 있어 테크임에도 불구하고 빠르게 완성될 수 있었다. &lt;br /&gt;여기서 또 한 번 기록의 중요성을 느꼈다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;다시 돌아와서, &lt;br /&gt;이제는 해당 내용을 기반으로 코드와 그림이 들어간 장표를 만들어내는 일을 진행하면 되었다.&lt;br /&gt;많은 방구석 컨퍼런스 경험으로 Youtube 재생목록에 저장되어 있던 다른 개발자분들의 장표를 하나씩 살펴보았고, 눈에 띄는 코드로 보이기 위해서 어떻게 이미지를 넣고, 강조를 하면 좋을지 정리했다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWLJmx/btsKWnP5hBx/O2kJXn5WZ9SMoAv4gYzAQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWLJmx/btsKWnP5hBx/O2kJXn5WZ9SMoAv4gYzAQK/img.png&quot; data-alt=&quot;위와 같이 컴포넌트의 영역과 코드 영역을 네모 박스로 매칭시켜 직관적인 이해를 돕고자 했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWLJmx/btsKWnP5hBx/O2kJXn5WZ9SMoAv4gYzAQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWLJmx%2FbtsKWnP5hBx%2FO2kJXn5WZ9SMoAv4gYzAQK%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&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;br /&gt;&lt;u&gt;&lt;b&gt;&quot;처음 보는 사람도 설득할 수 있는 문제 해결 과정&quot; &lt;/b&gt;&lt;/u&gt;을 녹여내기 위해서, &lt;br /&gt;&amp;nbsp;&lt;br /&gt;앞에 배경에서 나타난 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;문제상황을 마지막에는 어떤 결과가 되었는지 다시 상기&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #006dd7;&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;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u9e9w/btsKUmSqqzo/nXJJJQ33UNj0Qsp5hBUbgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u9e9w/btsKUmSqqzo/nXJJJQ33UNj0Qsp5hBUbgK/img.png&quot; data-alt=&quot;문제 상황과 결과를 처음과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u9e9w/btsKUmSqqzo/nXJJJQ33UNj0Qsp5hBUbgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu9e9w%2FbtsKUmSqqzo%2FnXJJJQ33UNj0Qsp5hBUbgK%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제 상황과 결과를 처음과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c22nja/btsKY3L39e0/ZN6WUwWy1aMXSScgoyNvtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c22nja/btsKY3L39e0/ZN6WUwWy1aMXSScgoyNvtk/img.png&quot; data-alt=&quot;끝에서 다시 강조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c22nja/btsKY3L39e0/ZN6WUwWy1aMXSScgoyNvtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc22nja%2FbtsKY3L39e0%2FZN6WUwWy1aMXSScgoyNvtk%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&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;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;span style=&quot;color: #006dd7;&quot;&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-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IEqts/btsKZCmQ2v6/FN5L0Vl84b54yi4rhRNoq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IEqts/btsKZCmQ2v6/FN5L0Vl84b54yi4rhRNoq1/img.png&quot; data-alt=&quot;문제를 해결하는 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IEqts/btsKZCmQ2v6/FN5L0Vl84b54yi4rhRNoq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIEqts%2FbtsKZCmQ2v6%2FFN5L0Vl84b54yi4rhRNoq1%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제를 해결하는 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCdsqc/btsK0jT1xI5/K1DQZxmkYDpn99rGEliH3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCdsqc/btsK0jT1xI5/K1DQZxmkYDpn99rGEliH3k/img.png&quot; data-alt=&quot;그 안에서의 고민했던 기술적인 비교군 설명&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCdsqc/btsK0jT1xI5/K1DQZxmkYDpn99rGEliH3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCdsqc%2FbtsK0jT1xI5%2FK1DQZxmkYDpn99rGEliH3k%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&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;br /&gt;위와 같은 장표의 구성으로 한 달 동안 만들다 보니 약 140개의 PPT로 만들어지게 되었다.&amp;nbsp;&lt;br /&gt;( 아마..이제 경험을 한번 해봤으니, 발표 준비 시간은 줄어들지 않을까 싶다..! )&lt;br /&gt;&amp;nbsp;&lt;br /&gt;오프라인으로 리허설하기 전에 장표에 대한 피드백을 받고자 따로 메일을 드렸고, 보완이 필요한 부분은 아래와 같았다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;전체적인 빌드업 과정에서 &lt;b&gt;국면 전환&lt;/b&gt;이 되어주는 슬라이드가 필요하다.&lt;/li&gt;
&lt;li&gt;다른 곳에서의 문제 인식을 통해 사이드 프로젝트에서 Adapter 아키텍쳐를 도입하고 싶었던 것인지? 아니면 사이드프로젝트에서 비슷한 문제를 다시 겪어서 Adapter 아키텍처를 떠올린 건지? &lt;b&gt;발표의 톤을 맞추는 과정&lt;/b&gt;이 필요하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;첫 번째 피드백이 나왔던 이유는 스피커인 나에게는 Adapter 아키텍처라는 주제가 너무 익숙하다보니, 당연한 내용을 설명하지 않고 넘어가는 개념과 배경이 누락되었기 때문이라고 느꼈다. 두 번째 피드백에 대해서는 문제 정의를 단순히 Adapter 아키텍처에만 집중하고 문제 해결 과정에 대한 흐름을 스스로 정의하지 않고 장표를 만들어, 배경과 개념이 나오는 순간 상황 자체가 섞여버리게 되었다고 생각했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;사실 두 번째 피드백을 통해서, 면접을 어떻게 준비해야 하는지 감을 잡을 수도 있었다.&amp;nbsp;&lt;br /&gt;'내가 경험한 내용이라 다 알고 있지 않을까?' 하고 넘어갔던 상황을 구체화하지 않으면, 꼬리 질문에 답을 하기 힘들다는 것이 느껴졌다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이렇게 두 가지의 피드백을 통해 리허설 전까지 장표를 보완했다.&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;  &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;스피커분들과 함께&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장표를 만들다 보니 벌써 10월이 되었고, 11/23-24에 있을 테오콘을 위해 스피커 분들이 모여 발표를 진행하고, 서로 좋았던 점과 부족한 점을 나누는 시간을 가졌다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;하이안, 동훈, 병스커, 김첨지, 토마토, 그리고 해피! 까지 총 6명의 스피커와 테오 그리고 솔싹과 함께 오전과 오후로 나눠 리허설을 시작했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;2019&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCdDyf/btsKXA8TTeB/e8IGGXbCC4PhkCr4F0gSeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCdDyf/btsKXA8TTeB/e8IGGXbCC4PhkCr4F0gSeK/img.png&quot; data-alt=&quot;리허설 시작!!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCdDyf/btsKXA8TTeB/e8IGGXbCC4PhkCr4F0gSeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCdDyf%2FbtsKXA8TTeB%2Fe8IGGXbCC4PhkCr4F0gSeK%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;2250&quot; height=&quot;2019&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;2019&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;br /&gt;&amp;nbsp;&lt;br /&gt;모든 주제가 흥미로운 내용이어서 집중해서 들었고, 각 이야기를 몰입도 있게 전달하는 방향을 서로 만들어내기 위해 정말 다양한 의견들을 주고받았다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;'목차가 서두에 제시되어서 발표의 흐름을 알고 들을 수 있어 좋았어요.'&lt;br /&gt;'진행기 안에서 실제 겪었던 트러블 슈팅 내용이 구체적으로 있었으면 좋겠어요.'&lt;br /&gt;'발표 중간에 청중들이 생각할 수 있게끔 질문을 던져주면 좋을 것 같아요.'&lt;br /&gt;'상황으로 설명하려고 하는 부분이 누락되어서 코드가 안 읽히는 것 같아요.'&amp;nbsp;&lt;br /&gt;'발표의 톤을 일화나 강의 둘 중 한 개로 맞춰서 말해주었으면 좋겠어요.'&lt;br /&gt;'리허설 장표를 기반으로 96-97에 상황 또는 개념에 대한 배경 설명에 대한 비약이 존재하는 것 같아요.'&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;회사에서도 다른 직군과 많이 소통하고 있기는 했지만, 사실 이러한 커뮤니케이션 스킬에 대한 피드백이 따로 오는 것이 아니라 일하다 보면서 자연스럽게 잘하는지, 못하는지 알게 되는 분야라고 생각하기에, 내가 가지고 있는 소통 방식으로 진행하는 발표에 대한 리뷰를 나눌 수 있는 리허설의 시간이 굉장히 흥미롭게 느껴졌다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그래서 테오가 녹음해 준 리허설 대화를 따로 저장하기도 했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;6시간의 리허설을 마치고, 함께 저녁을 먹으면서 어떻게 스피커/스태프를 하게 되었는지, 개발에 대한 각자의 생각들을 주고받으며 또 다른 추억을 만들기도 했다 :)&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이제 남은 시간 동안은 리허설 피드백을 기반으로 11/23-24 양일간 진행될 TEOConf 2024를 위해 마지막 장표 다듬기만 하면 되었다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  TEOConf 2024 당일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마음은 11/23(토), 11/24(일) 모두 스피커로 진행하고 싶었으나, YAPP의 팀빌딩 세션을 진행해야 하는 일정이 있어 11/24(일)에만 발표를 하기로 했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2214&quot; data-origin-height=&quot;1436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pAGqo/btsKWOfya2o/2m1ltvAU3reUwo60VFTKck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pAGqo/btsKWOfya2o/2m1ltvAU3reUwo60VFTKck/img.png&quot; data-alt=&quot;발표 순서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pAGqo/btsKWOfya2o/2m1ltvAU3reUwo60VFTKck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpAGqo%2FbtsKWOfya2o%2F2m1ltvAU3reUwo60VFTKck%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;800&quot; height=&quot;519&quot; data-origin-width=&quot;2214&quot; data-origin-height=&quot;1436&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;br /&gt;SOPT, YAPP, Feconf Lightning 과 같은 환경에서 여러 번 발표를 해보았기에, 발표에 대한 긴장감은 거의 없었지만 그래도! 공간에 대한 익숙함을 빠르게 받아들이기 위해서 한 시간 일찍 도착해서 발표 장소를 둘러보았다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;오히려 발표 공간과 타임라인이 작성되어 있는 팜플렛을 보고 나니까 긴장보다는 설렘으로 바뀌었고, 준비한 세션을 빨리 개발자분들께 전달하고 싶은 마음으로 들떠있었던 것 같다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1687&quot; data-origin-height=&quot;2383&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dv9ebZ/btsKWTIoaOq/YWOKKR8KTeLQR8SBBEn8J0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dv9ebZ/btsKWTIoaOq/YWOKKR8KTeLQR8SBBEn8J0/img.png&quot; data-alt=&quot;개인적으로 명함이 너무 귀여웠다..스태프분들 최고!!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dv9ebZ/btsKWTIoaOq/YWOKKR8KTeLQR8SBBEn8J0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdv9ebZ%2FbtsKWTIoaOq%2FYWOKKR8KTeLQR8SBBEn8J0%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;800&quot; height=&quot;1130&quot; data-origin-width=&quot;1687&quot; data-origin-height=&quot;2383&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;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  참가자로 함께&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 컨퍼런스와는 다르게 TEOConf은 스피커와 참가자의 경계 없이 같이 어우러져 개발 이야기를 공유하고 논의할 수 있는 부분이 행복했던 것 같다. 5시간 동안 앉아서 세션만 듣는 것이 아니라, 각자 다른 환경에서 프론트엔드 개발자로 임하고 있는 마음가짐과 앞으로의 목표 그리고 각자 가지고 있는 취미 활동을 나누며 네트워킹을 진행했다.&lt;br /&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/Bdony/btsKWUUQYoQ/iNAa5VFD359K6k66zKm611/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Bdony/btsKWUUQYoQ/iNAa5VFD359K6k66zKm611/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Bdony/btsKWUUQYoQ/iNAa5VFD359K6k66zKm611/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBdony%2FbtsKWUUQYoQ%2FiNAa5VFD359K6k66zKm611%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;3024&quot; height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddiofB/btsKXJZvqgD/42WNqdB9yu5C97xfEwlaCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddiofB/btsKXJZvqgD/42WNqdB9yu5C97xfEwlaCk/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot; style=&quot;width: 49.4186%;&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddiofB/btsKXJZvqgD/42WNqdB9yu5C97xfEwlaCk/img.png&quot; alt=&quot;&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddiofB%2FbtsKXJZvqgD%2F42WNqdB9yu5C97xfEwlaCk%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;3024&quot; height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/div&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;br /&gt;이 과정이 있어서 단순히 링크드인 공유가 아닌, 각 사람에 대한 애정을 더해서 또 다른 새로운 인연을 만들며 인사이트를 주고받을 수 있었던 것이 테오콘의 진짜 선물이 아닐까 싶었다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;수리, 데브희, 주디, 오원, 리우, 피터와 함께하면서 4개의 세션에 대해&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;서버 스펙이 바뀌는 상황에서 어떻게 프론트엔드 개발을 풀어나가는지 자신만의 노하우가 있나요? 아니면 아예 변경 사항에 대해서 미리 방지하는 편인가요?&amp;nbsp;&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;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  스피커로 함께&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 참가자로 진행하다가 나의 세션 순서가 되어 스피커로 TEOConf에 임하는 포지션으로 순식간에 위치가 바뀌었다.&lt;br /&gt;이것도 생각보다 너무 재밌었다. 유튜버로 비유하면, 영상 촬영과 제작을 같이 하는 프리랜서의 느낌으로 다가왔다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1081&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cP8Mtj/btsKV8GeGE9/aXgy0L3QyHeeoaJ96OsLW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cP8Mtj/btsKV8GeGE9/aXgy0L3QyHeeoaJ96OsLW0/img.png&quot; data-alt=&quot;세션 시작&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cP8Mtj/btsKV8GeGE9/aXgy0L3QyHeeoaJ96OsLW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcP8Mtj%2FbtsKV8GeGE9%2FaXgy0L3QyHeeoaJ96OsLW0%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;800&quot; height=&quot;601&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1081&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;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3672&quot; data-origin-height=&quot;2066&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/33cyW/btsKXy4UCPg/Zk8zz6NqVVABvZwvkdTEEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/33cyW/btsKXy4UCPg/Zk8zz6NqVVABvZwvkdTEEK/img.png&quot; data-alt=&quot;열심히 강연 중!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/33cyW/btsKXy4UCPg/Zk8zz6NqVVABvZwvkdTEEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F33cyW%2FbtsKXy4UCPg%2FZk8zz6NqVVABvZwvkdTEEK%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;800&quot; height=&quot;450&quot; data-origin-width=&quot;3672&quot; data-origin-height=&quot;2066&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;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkGwBD/btsKVCHMXmH/22tFKXx5Au65g7klDHKDR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkGwBD/btsKVCHMXmH/22tFKXx5Au65g7klDHKDR0/img.png&quot; data-alt=&quot;잊지 못할 순간!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkGwBD/btsKVCHMXmH/22tFKXx5Au65g7klDHKDR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkGwBD%2FbtsKVCHMXmH%2F22tFKXx5Au65g7klDHKDR0%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;800&quot; height=&quot;1067&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&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;배경 설명을 지나와서 Adapter 아키텍처와 Compound 패턴에 대한 테크 기술로 흐름이 시작되었을 때에는 위 사진처럼 열심히 듣고 계신 참가자분들을 바라보면서 발표 자료에는 잘 집중하고 계시는지, 이해가 끊겨서 갸우뚱하고 계신 분들은 없는지, 참가자분들이 스스로 생각하실 수 있는 중간 지점을 놓치지 않고 계신지를 확인하려고 노력했다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;물론 모든 부분을 다 파악할 수는 없었지만, 강연이 끝나고 난 뒤 다시 참가자로 돌아가 우리 팀 사람들이랑 함께 질의응답을 진행하는 과정에서 맥락이 끊기지 않았던 것으로 보아 내가 원하는 대로 강연을 잘 이끌었다는 것을 확인할 수 있었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;하지만, 발표 영상을 다시 보았을 때, 너무 해피해피하게 말해서 약간 톤을 낮춰야 하나..? 라는 생각도 조금은 들었다. ㅎㅎ&amp;nbsp;&lt;br /&gt;또 다른 발표 경험을 쌓다 보면, 노하우가 생기지 않을까 싶었고,&lt;br /&gt;&amp;nbsp;&lt;br /&gt;TEOConf의 스피커로 진행한 주제에 대해&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;나는 문제상황을 어댑터와 컴파운드 패턴으로 풀어갔는데, &lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;만약 다른 개발자라면 이 문제를 어떻게 풀어나갔을까?&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;라는 궁금증이 마음 속에 내재되어 있었는데, 이를 팀별로 얘기하면서 풀어내는 시간이 스피커로서 가장 큰 의미있는 순간이었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;' 어느 정도의 영역을 지정해서 변경될 수 있는 부분과 그렇지 않은 부분을 명확하게 기획자나 서버 개발자분들에게 전달해요'&lt;br /&gt;' 제가 만약에 해피처럼 어댑터를 썼다면, 코드양이 두 배로 증가한다는 단점이 생겨서 오히려 개발하기 힘들 것 같다는 생각도 들어요.'&lt;br /&gt;' 반대로 비즈니스 로직을 제외한 컴파운드 패턴으로 코드를 구성하면, 코드 관리가 한 곳에 집중되니까 전 편할 것 같아요.'&lt;br /&gt;&amp;nbsp;&lt;br /&gt;등등 서로 공감대를 잘 형성한 덕분에 소중한 의견들이 오갈 수 있었던 것 같다.&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;누군가와 함께 할 수 있다는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 TEOConf 2024를 해피로, 스피커로 준비하는 모든 시간을 혼자가 아닌 테오와 6명의 스피커, 스태프, 참가자분들과 함께 한다는 사실이 이 순간을 더욱 특별하게 만들었던 것 같다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;스피커로 발표를 준비하는 것도, 발표의 공간을 준비해주시는 분들과 참가자 분들이 계셔서 가치 있는 순간들로 만들어질수 있었고, 그분들이 계셔서 나의 공유로 또 다른 누군가와 함께 이어질 수 있는 연결고리가 만들어질 수 있다는 것을 다시 한번 느꼈다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;어떤 분이 내 강연을 듣고 나서 들었던 생각들을 링크드인에 올려주신 것을 보면서 &quot;노력&quot;이라는 감정을 느꼈다는 것을 알게 되었다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그 내용을 보고 나니&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;다른 누군가에게 또 다른 깨달음과 성장을 심어주기 위해&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;내가 배웠던 교훈들을 아낌없이 나누는 것이 &lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;개발자 생태계를 키워낼 수 있는 방법이구나.&lt;/span&gt;&lt;span style=&quot;font-family: Noto Serif KR;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이와 같은 깨달음을 얻게 되었고, 다가오는 2025년에도 내 경험을 열심히 만들어내면서 다른 곳에 공유할 기회가 있다면 놓치지 않고 참여하고 싶다는 목표를 가지게 되었다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;TEOConf24를 만들어주신 모든 분께 감사하고&lt;br /&gt;스피커로 당당하게 테크 강연을 할 수 있게 도와준 테오에게도 너무너무 감사했다..!&lt;br /&gt;&amp;nbsp;&lt;br /&gt;다음에 어디선가 다들 좋은 기회로 또 만나고 싶다!&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;모두 고생하셨습니다 :)&lt;/p&gt;
&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  세션 자료를 공유드립니다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.figma.com/proto/XdyD0rRq0UqeA9ySyVbpTJ/%EC%BB%A8%ED%8D%BC%EB%9F%B0%EC%8A%A4-%EB%B0%9C%ED%91%9C?page-id=90%3A8&amp;amp;node-id=460-2676&amp;amp;node-type=frame&amp;amp;viewport=216%2C-2569%2C0.16&amp;amp;t=joc118J4gqi9qROQ-1&amp;amp;scaling=contain&amp;amp;content-scaling=fixed&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;어댑터 아키텍쳐로 클라이언트 환경 개선하기&amp;nbsp;&lt;/span&gt;&lt;/a&gt;라는 주제로 세션을 진행하였습니다.&lt;/p&gt;</description>
      <category>WEB/Insight</category>
      <category>teoconf</category>
      <category>스피커</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/50</guid>
      <comments>https://happhee-dev.tistory.com/50#entry50comment</comments>
      <pubDate>Tue, 26 Nov 2024 17:16:19 +0900</pubDate>
    </item>
    <item>
      <title>Suspense 와 Errorboundary</title>
      <link>https://happhee-dev.tistory.com/49</link>
      <description>&lt;div&gt;
&lt;div data-message-model-slug=&quot;gpt-4o&quot; data-message-id=&quot;746fe4da-140e-42fe-baad-6090a6fd758b&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Suspense와 ErrorBoundary에 대해 알아보자&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서 비동기 로딩과 에러 핸들링을 위해 Suspense와 ErrorBoundary는 각각 중요한 역할을 하는 것은 모두가 알고 있는 내용이다.&lt;br /&gt;이 글에서는 두 컴포넌트의 개념을 직접 구현해보는 과정을 통해 내부 구조를 이해하며, 최종적으로 두 컴포넌트의 차이점과 활용 방안을 정리해보고자 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✨&amp;nbsp; 들어가기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Suspense&lt;/b&gt;와 &lt;b&gt;ErrorBoundary&lt;/b&gt;는 React에서 각각 비동기 작업의 로딩 상태와 컴포넌트 트리에서 발생하는 오류를 관리하는 데 사용되는 컴포넌트이다.&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;Suspense&lt;/b&gt;: 비동기 로딩 상태를 처리하기 위해 사용되며, 주로 React.lazy와 함께 활용한다. &lt;br /&gt;즉, Suspense는 컴포넌트를 렌더링하기 전에 로딩 중인 컴포넌트 대신 로딩 UI를 표시하도록 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ErrorBoundary&lt;/b&gt;: 자식 컴포넌트에서 발생하는 JavaScript 오류를 캐치하여 전체 앱이 중단되는 것을  방지한다. &lt;br /&gt;즉, ErrorBoundary는 트리의 특정 지점에서 오류를 포착하고, 대체 UI를 표시하는 역할을 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념으로만 알기에는 부족하니 직접 구현해보는 방향으로 살펴보자&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  내부 구조를 직접 구현하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ CustomSuspense 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Suspense의 기본 원리를 직접 구현해보며, 로딩 상태 관리와 Promise 기반의 비동기 작업을 처리하는 방식을 이해해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1730373707500&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { useState, useEffect, ReactNode } from 'react';

interface CustomSuspenseProps {
  children: () =&amp;gt; Promise&amp;lt;{ default: React.ComponentType&amp;lt;any&amp;gt; }&amp;gt;;
  fallback: ReactNode;
}

const CustomSuspense: React.FC&amp;lt;CustomSuspenseProps&amp;gt; = ({ children, fallback }) =&amp;gt; {
  const [Component, setComponent] = useState&amp;lt;React.ComponentType&amp;lt;any&amp;gt; | null&amp;gt;(null);
  const [error, setError] = useState&amp;lt;Error | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    let isMounted = true;

    children()
      .then((module) =&amp;gt; {
        if (isMounted) setComponent(() =&amp;gt; module.default);
      })
      .catch((err) =&amp;gt; {
        if (isMounted) setError(err);
      });

    return () =&amp;gt; {
      isMounted = false;
    };
  }, [children]);

  if (error) {
    return &amp;lt;div&amp;gt;Error loading component: {error.message}&amp;lt;/div&amp;gt;;
  }

  return Component ? &amp;lt;Component /&amp;gt; : &amp;lt;&amp;gt;{fallback}&amp;lt;/&amp;gt;;
};

export default CustomSuspense;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 Promise가 완료될 때까지 로딩 UI를 표시하며, 비동기 작업이 완료되면 동적으로 로드된 컴포넌트를 렌더링한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ CustomErrorBoundary 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로, ErrorBoundary의 작동 원리도 직접 구현해보며, 에러 핸들링의 구조를 이해해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1730373737148&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { Component, ReactNode } from 'react';

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

interface ErrorBoundaryState {
  hasError: boolean;
}

class CustomErrorBoundary extends Component&amp;lt;ErrorBoundaryProps, ErrorBoundaryState&amp;gt; {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

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

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error(&quot;Error caught by ErrorBoundary:&quot;, error, errorInfo);
  }

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

export default CustomErrorBoundary;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 componentDidCatch 메서드를 통해 자식 컴포넌트의 오류를 감지하고, 에러가 발생하면 hasError 상태를 업데이트하여 대체 UI를 표시하는 구조이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; 두 컴포넌트의 차이점 설명&lt;/h2&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;&lt;b&gt;Suspense : &lt;/b&gt;비동기 데이터를 로드하는 동안 로딩 상태를 관리하고 표시하는 데 주로 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ErrorBoundary :&lt;/b&gt; 런타임 에러가 발생할 때 이를 감지하여 대체 UI를 표시하고, 트리의 특정 지점에서 에러를 차단&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;&lt;b&gt;Suspense:&lt;/b&gt; 비동기 작업이 필수적인 경우, 특히 React.lazy로 컴포넌트를 동적으로 로드할 때 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ErrorBoundary : &lt;/b&gt;JavaScript 에러로 인한 애플리케이션 중단을 방지하고 에러를 특정 지점에서 관리할 필요가 있을 때 사용&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;&lt;b&gt;Suspense : &lt;/b&gt;Promise 객체를 활용하여 로딩 상태를 관리하고, 비동기 작업이 완료될 때까지 로딩 UI를 표시&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ErrorBoundary :&lt;/b&gt; 클래스 컴포넌트의 componentDidCatch 메서드를 통해 자식 컴포넌트에서 발생한 오류를 감지하고, 이를 위한 대체 UI를 제공하는 방식으로 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React의 &lt;b&gt;Suspense&lt;/b&gt;와 &lt;b&gt;ErrorBoundary&lt;/b&gt;는 각각 비동기 로딩과 에러 핸들링을 관리하는 데 필수적인 도구이고, 해당 과정을 직접 구현해보며 내부 동작 방식의 차이를 알 수 있었다. 비동기 작업 중 로딩 상태를 관리하고, 자바스크립트 오류로 인한 전체 애플리케이션 중단을 방지할 수 있는 이점이 있기에 적절한 곳에 배치하는 습관을 만들어, 단순히 기능 구현이 아닌 서비스의 성능을 향상시키는 노력을 지속적으로 진행하는 것이 중요할 것 같다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>WEB/React</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/49</guid>
      <comments>https://happhee-dev.tistory.com/49#entry49comment</comments>
      <pubDate>Thu, 31 Oct 2024 20:26:33 +0900</pubDate>
    </item>
    <item>
      <title>중앙화된 인증 처리 시스템 구축하기</title>
      <link>https://happhee-dev.tistory.com/48</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중고나라 셀러 지원 센터의 다양한 부분을 개발하면서, 특히 interceptor를 활용해 인증/인가 기능을 구현한 배경과 마주했던 문제와 느낀 점을 전달하고자 이 글을 쓰게 되었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ 셀러 지원 센터에서의 인증/인가 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러 지원 센터에서 인증/인가 구조는 셀러와 관리자가 분리되었다. 두 사용자 그룹에 따라 각각 다른 접근 방식이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;셀러 회원&lt;/h3&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-origin-width=&quot;1030&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d7bo4B/btsJNf5O3NL/eVkXKRB7RhK8oQmY7XjkPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d7bo4B/btsJNf5O3NL/eVkXKRB7RhK8oQmY7XjkPK/img.png&quot; data-alt=&quot;셀러의 회원가입 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d7bo4B/btsJNf5O3NL/eVkXKRB7RhK8oQmY7XjkPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd7bo4B%2FbtsJNf5O3NL%2FeVkXKRB7RhK8oQmY7XjkPK%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;700&quot; height=&quot;391&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;576&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;정상적으로 회원가입 화면에 진입했을 경우, 셀러는 아이디와 비밀번호를 입력하고 마케팅 수신 동의 항목을 거쳐 가입이 진행된다. 이 때, 중복되는 아이디는 사용 불가능하며 비밀번호를 8~16자의 영문과, 숫자, 특수문자를 조합하여야만 가입할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;관리자 회원&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자는 사내망 통합 인증 시스템을 통해 로그인을 진행해야 했다. 셀러와는 달리, 아이디와 비밀번호로 인증하는 방식이 아닌 joongna.com 도메인에서 공유되는 SSO 쿠키를 가지고 관리자용 로그인 API를 호출하여 토큰을 발급받는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨&amp;nbsp; interceptor로 인증/인가 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 Next.js를 사용하고 있었지만, 추후 SNS 로그인 방식이 도입될 가능성이 적었고, 셀러 지원 센터와 앱의 토큰을 번갈아 사용해야 하는 특성이 있었기에 ( 뒤에 설명 참고 ) next-auth 대신 직접 axios 라이브러리를 사용하여 인증/인가를 관리하기로 하였다.&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;우선, axios.create()로 Axios 인스턴스를 생성하여 여러 API 요청에 대해 공통으로 적용되도록 했다. 또한, 도메인별로 동일한 인증 로직을 유지할 수 있도록 interceptor 내부에서 코드를 구성하였다.&amp;nbsp;&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;h3 style=&quot;text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt; Request Interceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 전에 필요한 토큰을 헤더에 설정하거나 특정 상황에서 예외 처리하는 로직을 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 상황에 대한 예를 들면, 셀러 화면에서 order API 호출 시 앱 토큰 발행, 토큰 자체가 없으면 로그아웃 처리 , 엑세스 재발급 요청 시 리프레시 토큰을 헤더에 적용하는 것이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;   Order &lt;/span&gt;API 호출 시 거쳐야 하는 과정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 언급한 특정 상황 중에서 일반적이지 않은 과정은 셀러 화면에서 Order 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;셀러 화면에서 &lt;b&gt;Order API&lt;/b&gt;를 호출할 때는 &lt;b&gt;카페 게시 이용권 결제&lt;/b&gt;나 &lt;b&gt;구매 내역 조회&lt;/b&gt; 등의 &lt;b&gt;결제 기능&lt;/b&gt;과 관련된 요청에서 이루어졌다. 해당 상황에서는 앱에서 발급받은 &lt;b&gt;토큰을 이용&lt;/b&gt;해 호출하는 것이 필요했다. 다음은 Order API 호출 방식을 Diagram으로 표현한 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1114&quot; data-origin-height=&quot;776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mySbN/btsJMcoFtli/FloAu3iLBMzAFT3yBXslH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mySbN/btsJMcoFtli/FloAu3iLBMzAFT3yBXslH0/img.png&quot; data-alt=&quot;Order API 호출 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mySbN/btsJMcoFtli/FloAu3iLBMzAFT3yBXslH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmySbN%2FbtsJMcoFtli%2FFloAu3iLBMzAFT3yBXslH0%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;700&quot; height=&quot;488&quot; data-origin-width=&quot;1114&quot; data-origin-height=&quot;776&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Order API 호출 흐름&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;해당 로직을 request interceptor 내부에서 구현한 일부 코드이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1727237505325&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ✅ 앱 토큰 정보를 조회하여 헤더에 저장
const setJnTokenHeader = async (config: any) =&amp;gt; {
    try {
        const { data: jnToken } = await fetchJnTokenClient();
        const access_token = jnToken.data;
        config.headers['Authorization'] = `Bearer ${access_token}`;
        return config;
    } catch (error) {
        throw error; 
     }
};

instance.interceptors.request.use(
  async function (config) {
    const accessToken = Cookies.get(COOKIES.ACCESS_TOKEN);
    const isAdmin = Cookies.get(COOKIES.IS_ADMIN) === 'true';
    const checkUrl = config.url as string;

    const isOrderAPI = checkUrl?.includes(ORDER API 경로)

    if (isOrderAPI &amp;amp;&amp;amp; accessToken &amp;amp;&amp;amp; !isAdmin) {
      return await setJnTokenHeader(config);
    }
	...
    return config;
  },
  function (error) {
  ...
  },
);&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;setJnTokenHeader 함수는 셀러 지원 센터 토큰을 통해 앱 토큰을 발급받아 요청의 &lt;b&gt;Authorization 헤더&lt;/b&gt;에&lt;b&gt; Bearer ${access_token}&lt;/b&gt; 형식으로 설정하도록 작성하였다. 이후, request interceptor 내부에서는 &lt;b&gt;isOrderAPI&lt;/b&gt; 변수를 통해 결제 관련 API 호출 여부를 확인하고, access token과 셀러 회원의 여부를 확인한 후 setJnTokenHeader 함수를 호출하여 토큰을 재설정하는 방식으로 처리하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 셀러 화면에서 Order API를 호출할 때 토큰을 재설정해야 하는 문제를 해결할 수 있게 되었다.&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;  Response Interceptor&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;&lt;span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/b&gt;다음으로 response interceptor 내부에서는 &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;401 에러 발생으로 인한 새로운 액세스 토큰 발급 요청,&lt;/li&gt;
&lt;li&gt;새로운 엑세스 토큰 요청에 대한 실패 시 로그아웃 처리&lt;/li&gt;
&lt;li&gt;잘못된 형식으로 인한 403에러 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 내용을 구현한 코드 일부이다.&lt;/p&gt;
&lt;pre id=&quot;code_1727238405844&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ✅ 토큰 갱신 처리 함수
const handleTokenRefresh = async ({isAdmin, config, refreshToken}:{isAdmin: boolean, config: any, refreshToken: string}) =&amp;gt; {
  try {
    const { data } = isAdmin
      ? await fetchAdminTokenRefresh(refreshToken)
      : await fetchSellerTokenRefresh();

 	...
    config.headers['Authorization'] = `Bearer ${accessToken}`;
	
    return axios(config);
  } catch (error) 
    return Promise.reject(error);
  }
};

// ✅ 로그아웃 처리 함수
const handleLogout = ({ isAdmin }: {isAdmin: boolean}) =&amp;gt; {
  resetCookie([COOKIES.ONE_DAY_CLOSE]);
  window.location.href = isAdmin ? '/admin/logout' : '/seller/login';
};


instance.interceptors.response.use(
  async function (response) {
   ...
  },
  async function (error) {
    const { config, response } = error;
    const refreshToken = Cookies.get(COOKIES.REFRESH_TOKEN);
    const isAdmin = Cookies.get(COOKIES.IS_ADMIN) === 'true';

    // ✅ 셀러 및 관리자 토큰 처리
    if (response) {
      const isTokenRefreshUrl = config.url.includes(isAdmin ? ADMIN_TOKEN_REFRESH_URL : SELLER_TOKEN_REFRESH_URL);
      
      // ✅ 토큰 재발급 요청 실패 시 로그아웃 처리
      if (isTokenRefreshUrl &amp;amp;&amp;amp; response.status === 401) {
        handleLogout({isAdmin});
        return Promise.reject(error);
      }

      // ✅ 잘못된 토큰, 서명 문제 처리
      if (response.status === 403 || !refreshToken) {
        handleLogout({isAdmin});
        return Promise.reject(error);
      }

      // ✅ 토큰 만료 시, 토큰 재발급 처리
      if (response.status === 401 &amp;amp;&amp;amp; !isTokenRefreshUrl &amp;amp;&amp;amp; refreshToken) {
        return handleTokenRefresh({isAdmin, config, refreshToken});
      }
    }
	...
  }
);&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;handleTokenRefresh&lt;/b&gt; 함수를 통해 &lt;b&gt;셀러와 관리자 모두의 토큰 갱신&lt;/b&gt;을 처리하도록 구현했으며, &lt;b&gt;handleLogout&lt;/b&gt; 함수를 통해 로그아웃 로직을 처리하도록 진행하였다. 이러한 로직을 response interceptor 내부에서 사용하여, 셀러와 관리자의 인증/인가 과정을 효율적으로 관리할 수 있게 되었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Trouble Shooting&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 구현 과정에는 늘 문제가 발생하기 마련이다. 내가 겪었던 핵심 문제는 2가지였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; &amp;nbsp; 중복된 refresh token 발급 과정으로 인한 토큰 캐시 문제&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러가 구매내역 화면에 진입할 때 여러 번의 order API 호출되는 현상이 있었다. 해당 과정에서 앱 토큰을 발급받을 때, 셀러 지원 센터의 access token이 만료되면 refresh token으로 새로운 access token을 발급받아 다시 앱 토큰을 발급해야 했다. 하지만 각각의 order API 호출에 대해 &amp;nbsp;access token이 중복으로 발급되었다. 그 결과, 서버 내부적으로 한 번 토큰 캐싱이 어긋나는 현상이 발생하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국, 새로운 access token을 발급받아서 다시 order API를 호출해도 토큰 자체가 유효하지 않아 403 에러가 발생하여 로그아웃되는 현상이 발생했다.&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;  interceptor 내부에 방어 로직 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;서버 측에서 원인을 분석하는 동안, 클라이언트에서 401 에러가 발생할 때 refresh token 을 중복으로 요청하지 않도록 interceptor 내부에 방어 로직을 구현하는 것이 필요하다고 판단했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1727249306058&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let isTokenRefreshing = false;
const refreshSubscribers: any[] = [];

const onTokenRefreshed = (accessToken: string) =&amp;gt; {
  refreshSubscribers.map((callback) =&amp;gt; callback(accessToken));
};

const addRefreshSubscriber = (callback: any) =&amp;gt; {
  refreshSubscribers.push(callback);
};
const addRetryOriginalRequest = (config: any) =&amp;gt; {
  return new Promise((resolve) =&amp;gt; {
    addRefreshSubscriber(async (accessToken: string) =&amp;gt; {
      config.headers.Authorization = 'Bearer ' + accessToken;

      if (Order API 일 경우 ) {
       	// 앱토큰 발급받아서 헤더 토큰 재설정
        resolve(axios(config));
      }
      resolve(axios(config));
    });
  });
};

const refreshAccessToken = async (config: any) =&amp;gt; {
  try {
    const { data } = await 토큰재발급();
    const access_token = data?.access_token;
    const refresh_token = data?.refresh_token;

    if (access_token &amp;amp;&amp;amp; refresh_token) {
      // 새로운 토큰을 쿠키에 저장
      ...

      onTokenRefreshed(access_token);
      return axios(config);  // 요청 재시도
    }
    return config; // 토큰이 없으면 기본 config 반환
  } catch (error) {
    throw error;  
  } finally {
    isTokenRefreshing = false;  // 토큰 갱신 상태 초기화
  }
};


const createAxiosInstance = (baseURL: string): AxiosInstance =&amp;gt; {
  const instance = axios.create({
    baseURL,
  });

  instance.interceptors.request.use(
 	...
  );
  instance.interceptors.response.use(
    async function (config) {
      return config;
    },
    async function (error) {
      const { config, response } = error;

		...
        // NOTE : 401에러,  엑세스 토큰 만료 셀러 토큰 재발급 요청
        if (
          config.url !== SELLER_TOKEN_REFRESH_URL &amp;amp;&amp;amp;
          response.status === 401 &amp;amp;&amp;amp;
          isRefreshToken
        ) {
          if (!isTokenRefreshing) {
            isTokenRefreshing = true;
            return refreshAccessToken(config);  // 토큰 갱신 함수 호출
          } else {
            return addRetryOriginalRequest(config);  // 토큰 갱신 중 대기
          }
        }
...
    },
  );

  return instance;
};&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;위 코드로 살펴볼 수 있듯이 response interceptor에서 access token 만료를 401 코드로 파악하게 되는 시점에 방어 로직을 구성하였고, 핵심 요소는  아래와 같다.&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;isTokenRefreshing&lt;/b&gt; : 토큰 갱신이 진행 중인지 파악하기 위한 변수&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;false : 토큰 갱신 진행 중 X&lt;/li&gt;
&lt;li&gt;true : 토큰 갱신 진행 중 O&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;refreshSubscribers&lt;/b&gt; : 토근 갱신 중일 때, 발생한 다른 API 요청을 보류하고 토큰을 갱신하고 다시 요청을 보내도록 구현
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;토큰이 만료되고 갱신 중 일 때, 다른 API 요청들을 refreshSubscribers 배열에 콜백 함수로 저장&lt;/li&gt;
&lt;li&gt;토큰이 성공적으로 갱신하면, onTokenRefreshed 함수를 호출&lt;/li&gt;
&lt;li&gt;refreshSubscribers 배열에 저장된 모든 요청을 재시도&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 2가지 변수와 함수를 통해 토큰 갱신 관리와 중복 요청 방지 그리고 대기 중인 요청 처리를 수행할 수 있게 되어 클라이언트 측에서의 안정성이 높아졌다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  셀러 / 관리자의 토큰 교차 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러 지원 센터에서는 관리자와 셀러가 서로 다른 도메인에서 인증을 진행하고 있었다. 관리자는 도메인에서 통합 인증 시스템의 쿠키를 공유할 수 있어야 로그인이 가능했기에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;joongna.com&lt;/b&gt; 도메인, 셀러의 경우 &lt;b&gt;joonggonara.co.kr&lt;/b&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 style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;joonggonara.co.kr 으로만 진입하는 상황이 대부분이었다. &lt;b&gt;문제는 사내망에서 관리자와 셀러 기능을 모두 사용&lt;/b&gt;해서 문의에 대응하거나 테스트 하는 상황이 지속적으로 진행된다는 것이었다. token 관리를 쿠키로 진행하고 있었던 상황 때문에, 사내망에서 관리자 도메인을 가지고 셀러 페이지에 진입하게 되면, &lt;b&gt;관리자 토큰을 통해 셀러 API를 호출&lt;/b&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;  middleware로 path 방어 로직 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;셀러의 경우에는 /seller 하위 path를 사용하고 있었고, 관리자의 경우에는 /admin 하위 path를 사용하고 공통 페이지는 / 하위 path를 사용한다는 특징을 활용하여 middleware에서 path 방어로직을 구현하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1727250464546&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 도메인에 따른 경로 차단 설정
function getBlockedPathForHost({host} :{ host: string}) {
  return process.env.NEXT_PUBLIC_SELLER_WEB_SITE_DOMAIN.includes(host)
    ? '/admin'
    : '/seller';
}

// 사용자 유형에 따른 리다이렉트 처리 함수
function handleRedirect({url , isAdmin } : {url: URL, isAdmin: boolean}) {
  url.pathname = isAdmin ? '/admin' : '/seller';
  url.search = '';
  return NextResponse.redirect(url);
}

...

export default async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const url = req.nextUrl.clone();
  const isAdminCookie = req.cookies.get(COOKIES.IS_ADMIN);
  const isAdmin = isAdminCookie?.value === 'true';

  const host = req.headers.get('host') as string;

  const blockedForHost = getBlockedPathForHost({host});
  if (blockedForHost &amp;amp;&amp;amp; pathname.includes(blockedForHost)) {
  	  return handleRedirect({url, isAdmin});
  	}

  ...
}&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;blockedForHost&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;셀러 도메인을 가리키고 있다면 관리자로의 접근을 차단하기 위해 '/admin' 경로로 설정&lt;/li&gt;
&lt;li&gt;관리자 도메인이라면 셀러로의 접근을 차단하기 위해 '/seller' 경로로 값을 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pathname.&lt;span&gt;includes&lt;/span&gt;(blockedForHost)&amp;nbsp;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;관리자 쿠키가 있는 경우 , /admin 경로로 redirect&lt;/li&gt;
&lt;li&gt;관리자 쿠키가 없는 경우, /seller 경로로 redirect&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 host 정보를 기반으로 blockedForHost 변수를 통해 셀러와 관리자가&amp;nbsp; /admin과 /seller 경로에 접근하는 것을 방지하여 잘못된 토큰으로 인한 인증 문제를 해결하였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &amp;zwj;  이를 통해 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;/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;/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;/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;/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;div&gt;
&lt;div data-message-id=&quot;3548be88-e643-465f-988b-f0f475513614&quot; data-message-author-role=&quot;assistant&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;&lt;/b&gt;관리자(admin)와 셀러(seller)처럼 &lt;b&gt;구분된 사용자 그룹&lt;/b&gt;에 따라 &lt;b&gt;접근을 제어&lt;/b&gt;하는 것이 보안상 필수적이라는 것을 배울 수 있었다. 이를 통해 서비스가 &lt;b&gt;안정적으로 운영&lt;/b&gt;되고, 각 그룹의 사용자들이 적절한 권한을 가진 페이지에만 접근할 수 있도록 제어하는 것이 중요하다는 것을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 요청이 동시에 발생할 때 발생하는 &lt;b&gt;동시성 문제&lt;/b&gt;는 이전에 경험하지 못했던 과제였지만, 이를 인터셉터 내부에서 제어하는 과정은 매우 &lt;b&gt;흥미로운 기술적 도전&lt;/b&gt;이었다. 이러한 문제를 해결하면서 시스템의 안정성을 유지할 수 있었고, 이 과정에서 &lt;b&gt;중복된 로직을 함수로 분리하고 재사용 가능한 방식&lt;/b&gt;으로 구현하는 것이 시스템 확장에 매우 유리하다는 것을 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로, 이번 작업을 통해 &lt;b&gt;인증/인가&lt;/b&gt;와 관련된 문제를 해결할 때, &lt;b&gt;유연한 확장성&lt;/b&gt;을 위해 재사용 가능한 코드 구조를 유지하는 것이 매우 중요하다는 교훈을 얻을 수 있었습니다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>WEB/React</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/48</guid>
      <comments>https://happhee-dev.tistory.com/48#entry48comment</comments>
      <pubDate>Wed, 25 Sep 2024 11:59:39 +0900</pubDate>
    </item>
    <item>
      <title>개발자가 만들어가는 어드민 시스템의 UI/UX</title>
      <link>https://happhee-dev.tistory.com/47</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가기, 첫 사내프로젝트의 시작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;정규직으로 입사하자마자 2024년 상반기 중고나라의 핵심 프로젝트인 셀러 지원 센터가 시작되었다. 프로젝트 초기에는 기획자와 개발자가 함께 모여 이 시스템이 어떤 비즈니스 모델과 계획으로 발전할지 논의하는 시간을 가졌다. 수차례 회의를 통해 기획안이 하나씩 확정되기 시작했고, 우리 팀도 이에 맞춰 기술 스택을 선정하고 업무를 분담을 진행하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 순조롭게 진행되고 있는 줄 알았던 가운데,,, 알게된 사실이 하나 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 디자이너 없이 개발을 스스로 진행해야 한다는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디자이너 부재가 만들어낸 초기 셀러지원센터 화면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히, 디자인 시스템도 없었기에 폰트와 색상 모두 제 각각 이루어진 화면으로 Figma 기획안이 전달되었다. 아래와 같은 사진이었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;결제 내역 보기-수정전.png&quot; data-origin-width=&quot;1560&quot; data-origin-height=&quot;776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lk0hT/btsJJ95ZOS7/hxMAlEljYCbrI0B6tSJenk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lk0hT/btsJJ95ZOS7/hxMAlEljYCbrI0B6tSJenk/img.png&quot; data-alt=&quot;셀러 &amp;amp;gt; 카페 게시 이용권 구매 내역 화면 기획안&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lk0hT/btsJJ95ZOS7/hxMAlEljYCbrI0B6tSJenk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLk0hT%2FbtsJJ95ZOS7%2FhxMAlEljYCbrI0B6tSJenk%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;800&quot; height=&quot;398&quot; data-filename=&quot;결제 내역 보기-수정전.png&quot; data-origin-width=&quot;1560&quot; data-origin-height=&quot;776&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;셀러 &amp;gt; 카페 게시 이용권 구매 내역 화면 기획안&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;회원 정보-수정전.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBWjwp/btsJKydlsWq/SD4gaZndEvHU1NQWvCpB01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBWjwp/btsJKydlsWq/SD4gaZndEvHU1NQWvCpB01/img.png&quot; data-alt=&quot;셀러 &amp;amp;gt; 회원 정보 화면 기획안&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBWjwp/btsJKydlsWq/SD4gaZndEvHU1NQWvCpB01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBWjwp%2FbtsJKydlsWq%2FSD4gaZndEvHU1NQWvCpB01%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;800&quot; height=&quot;936&quot; data-filename=&quot;회원 정보-수정전.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;1498&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;셀러 &amp;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;색상은 흑백 계열로 구성되어 있고, 칼럼만 나열된 어색한 테이블, 정렬되지 않는 검색 필터, 토글 기능처럼 보이지만 실제로는 접히지 않는 사이드바 등 나를 당황하게 하는 요소들이 대부분이었다.&lt;/p&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;size16&quot;&gt;특히, 첫 번째 사진처럼 테이블을 보여주는 화면이 여러 곳에 있었는데, 각각의 화면 배치가 조화를 이루지 못해 어색함을 주는 현상이 발생했다. 분명 기획 화면 상에서는 같은 테이블임에도, 전체적인 UI/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;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;이렇게 기능 구현 열심히해서 배포하더라도,&lt;br /&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;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발자가 빠르게 바꾼 어드민 시스템 디자인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀은 향후 확장성을 고려하여 shadcn/ui를 채택하여 개발을 진행하고 있었고, 나는 빠르게 해당 라이브러리와 관련해서 참고할만한 템플릿이 있는지 검색했다. 그 중 선택한 repository에서 테이블, 사이드바, 비슷한 정보를 묶어서 보여줄 수 있는 Card 레이아웃 컴포넌트를 셀러 지원 센터에 적용하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  사용자에게 필요한 기능을 추가한 테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 shadcn/ui 로만 구성되었던 테이블을, react-table 라이브러리를 활용해 칼럼과 데이터를 관리하는 구조로 변경하였다. 더 나아가 사용자 편의성을 위해 아래의 3가지 기능을 추가하였다.&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;Row 수 선택 기능&lt;/b&gt;&amp;nbsp; &lt;br /&gt;테이블에서 보여주는 &lt;b&gt;row의 수를 10,20,30개로 선택&lt;/b&gt;할 수 있는 기능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;페이지 네비게이션 &lt;br /&gt;{ 현재 페이지 } / { 총 페이지 수 }&lt;/b&gt; 를 알려주며, 처음과 마지막 페이지 도달 시, &lt;b&gt;이전/다음 버튼 비활성화&lt;/b&gt; 기능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;좌우 스크롤 기능&lt;br /&gt;column의 수에 따른&lt;/b&gt; 테이블의 가로 길이 증가로 인해 텍스트 데이터가 압축되는 현상을 막기 위한 &lt;b&gt;테이블 좌/우 스크롤&lt;/b&gt; 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌/우 스크롤 기능 경우에는 shadcn/ui 에서 제공하는 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;color: #0070d1; text-align: left;&quot; href=&quot;https://ui.shadcn.com/docs/components/scroll-area&quot;&gt;Scroll-area&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/a&gt;컴포넌트를 활용하여 작업하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면-기록-2024-09-24-오전-11.25.48.gif&quot; data-origin-width=&quot;2050&quot; data-origin-height=&quot;894&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/A8Y1K/btsJIeOHD7a/Nmk1dKFRH4sFkTgb7JFSYK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/A8Y1K/btsJIeOHD7a/Nmk1dKFRH4sFkTgb7JFSYK/img.gif&quot; data-alt=&quot;수정된 테이블 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/A8Y1K/btsJIeOHD7a/Nmk1dKFRH4sFkTgb7JFSYK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/A8Y1K/btsJIeOHD7a/Nmk1dKFRH4sFkTgb7JFSYK/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;800&quot; height=&quot;349&quot; data-filename=&quot;화면-기록-2024-09-24-오전-11.25.48.gif&quot; data-origin-width=&quot;2050&quot; data-origin-height=&quot;894&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;이전보다 정렬된 ui로 테이블이 제공되었고, 테이블에 있는 데이터를 편하게 볼 수 있는 구조로 변경되었다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  shadcn/ui의 component를 활용한 사이드바&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음으로 진행한 작업은 사이드바 개선이었다. 기존에 구현되어 있던 사이드바는 useState로 토글 상태를 관리하고, &lt;a href=&quot;https://www.framer.com/motion/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;framer-motion&lt;/a&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;2024-09-2412.23.20-ezgif.com-video-to-gif-converter.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;873&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UKXvA/btsJImlI2Oh/BMVTpfKTfEWKcYqVVrcRM0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UKXvA/btsJImlI2Oh/BMVTpfKTfEWKcYqVVrcRM0/img.gif&quot; data-alt=&quot;기존 사이드 바&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UKXvA/btsJImlI2Oh/BMVTpfKTfEWKcYqVVrcRM0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/UKXvA/btsJImlI2Oh/BMVTpfKTfEWKcYqVVrcRM0/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;500&quot; height=&quot;728&quot; data-filename=&quot;2024-09-2412.23.20-ezgif.com-video-to-gif-converter.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;873&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;/p&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2024-09-2412.08.46-ezgif.com-video-to-gif-converter.gif&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;1412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TUbUa/btsJIgMxUHI/pWF79k1S8IprUg5aBzdWLk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TUbUa/btsJIgMxUHI/pWF79k1S8IprUg5aBzdWLk/img.gif&quot; data-alt=&quot;토글 기능 사용시에도 메뉴를 확인 할 수 있도록 개선된 사이드바&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TUbUa/btsJIgMxUHI/pWF79k1S8IprUg5aBzdWLk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/TUbUa/btsJIgMxUHI/pWF79k1S8IprUg5aBzdWLk/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;500&quot; height=&quot;1569&quot; data-filename=&quot;2024-09-2412.08.46-ezgif.com-video-to-gif-converter.gif&quot; data-origin-width=&quot;450&quot; data-origin-height=&quot;1412&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;지금은 메뉴가 많지 않아서 토글로 인한 메뉴 상태가 초기화되는 것이 그렇게 큰 불편함을 일으키지는 않을 것이라고 판단하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 메뉴가 많아지는 상황을 고려해보면, 사용자가 열어둔 메뉴가 토글로 인해 다시 닫히면, 사이드바를 열었을 때 해당 메뉴를 찾는 번거로움이 생길 수 있다고 느껴졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 대한 불편함을 해결하기 위해, 셀러 지원 센터의 사이드바는 사용자가 현재 위치한 화면에 맞춰 토글 버튼을 눌러도 해당 화면에 매칭되는 메뉴 리스트가 펼쳐진 상태로 유지되도록 재구성했다. 또한, 셀러 화면에는 서브 메뉴가 많지 않았기 때문에 드롭다운 기능을 제거하고, 관리자 화면에서만 드롭다운 기능이 활성화되도록 조건 분기를 추가하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1727180465113&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	// ✅ 최상단 메뉴의 하위 자식들인지 체크 
export default function useCheckActiveNav() {
  const { pathname } = useParsed();

  const checkActiveNav = (nav: string) =&amp;gt; {
    const currentPathname = pathname || '';
    const isSeller = currentPathname.includes('/seller');

    return isSeller ? currentPathname === nav : currentPathname.startsWith(nav);
  };

  return { checkActiveNav };
}&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;이를 구현하기 위해 useCheckActiveNav 라는 커스텀 훅을 만들어 드롭다운 Nav 컴포넌트에서 사용하였다.&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;현재 경로가 /seller/dashboard이고, checkActiveNav('/seller/dashboard')를 호출하면&lt;br /&gt;-&amp;gt; /seller 경로이므로 정확히 일치해야 활성화로 간주하여 true를 반환&lt;/li&gt;
&lt;li&gt;만약 현재 경로가 /about이고, checkActiveNav('/about')를 호출하면&lt;br /&gt;-&amp;gt; 경로가 /seller가 아니므로 /about으로 시작하는지를 확인하여 true를 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 useCheckActiveNav를 활용한 NavLinkDropdown 컴포넌트이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1727180563489&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function NavLinkDropdown({ title, icon, label, sub, closeNav }: NavLinkProps) {
  const { checkActiveNav } = useCheckActiveNav();
// ✅ 현재 URL 경로와 서브메뉴의 href를 비교
  const isChildActive = !!sub?.find((s) =&amp;gt; checkActiveNav(s.href));
  
// ✅ ADMIN 메뉴에 대해서만 드롭다운 기능 활성화
  const isToggle = label === 'ADMIN';

  const renderSubMenu = (
    &amp;lt;ul&amp;gt;
      {sub!.map((sublink) =&amp;gt; (
        &amp;lt;li key={sublink.title} className=&quot;my-1 ml-8&quot;&amp;gt;
          &amp;lt;NavLink {...sublink} subLink closeNav={closeNav} /&amp;gt;
        &amp;lt;/li&amp;gt;
      ))}
    &amp;lt;/ul&amp;gt;
  );

  return (
    &amp;lt;Collapsible defaultOpen={isChildActive}&amp;gt;
      &amp;lt;CollapsibleTrigger
        className={cn(
          'group h-12 w-full rounded-none px-6',
          'flex items-center',
          'text-sm',
          isToggle ? 'cursor-pointer' : 'cursor-default',
        )}
      &amp;gt;
        &amp;lt;div className=&quot;mr-2&quot;&amp;gt;{icon}&amp;lt;/div&amp;gt;
        {title}
        {isToggle &amp;amp;&amp;amp; ( // ✅ 아이콘 및 드롭다운 버튼 렌더링
          &amp;lt;span
            className={cn(
              'ml-auto transition-all group-data-[state=&quot;open&quot;]:-rotate-180',
            )}
          &amp;gt;
            &amp;lt;IconChevronDown stroke={1} /&amp;gt; 
          &amp;lt;/span&amp;gt;
        )}
      &amp;lt;/CollapsibleTrigger&amp;gt;
      {isToggle ? (  // ✅ 서브메뉴 렌더링
        &amp;lt;CollapsibleContent className=&quot;collapsibleDropdown&quot; asChild&amp;gt;
          {renderSubMenu}
        &amp;lt;/CollapsibleContent&amp;gt;
      ) : (
        renderSubMenu
      )}
    &amp;lt;/Collapsible&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;&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;checkActiveNav 사용&lt;br /&gt;&lt;/b&gt;useCheckActiveNav() 훅을 사용해, 현재 URL 경로와 서브메뉴의 href를 비교하여 서브메뉴가 활성화 상태 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isToggle 조건 설정&lt;br /&gt;&lt;/b&gt;메뉴의 label이 'ADMIN'일 경우, isToggle을 true로 설정하여 드롭다운 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Collapsible 컴포넌트 &lt;br /&gt;&lt;/b&gt;메뉴가 isChildActive일 경우 기본적으로 열려 있고, 'ADMIN' 메뉴에 대해서만 드롭다운 기능을 활성화하고, SELLER 메뉴는 기본적으로 드롭다운 기능 비활성화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;아이콘 및 드롭다운 버튼 렌더링&lt;/b&gt;&lt;br /&gt;'ADMIN' 메뉴에는 오른쪽에 IconChevronDown 아이콘을 추가해 드롭다운 표시 &amp;amp; 아이콘이 클릭 시 회전 애니메이션을 적용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서브메뉴 렌더링&lt;/b&gt;&lt;br /&gt;'ADMIN' 메뉴일 때는 드롭다운으로 서브메뉴가 렌더링되고, 그렇지 않으면 기본 리스트로 서브메뉴가 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 통해&amp;nbsp; 마침내 셀러 지원 센터에 필요한 사이드 바를 완성할 수 있었다.&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음으로는 앞서 살펴보았던 셀러 &amp;gt; 회원 정보 화면에서는 다양한 정보들이 구분선 없이 정렬되어 있어, 배치가 어색하게 느껴지는 현상이 발생했다. 이를 개선하기 위해, 각 정보의 성격에 맞게 세부 내용을 카드 형식으로 묶어서 보여줄 수 있는 CardLayout을 구현하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1727186004236&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { ReactElement } from 'react';

import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from '@shared/components/ui/card';
import { cn } from '@shared/utils/cn';

type Props = {
  title: string;
  icon?: ReactElement;
} &amp;amp; React.HTMLAttributes&amp;lt;HTMLDivElement&amp;gt;;
export default function CardLayout({
  title,
  icon,
  children,
  className,
  ...props
}: Props) {
  return (
    &amp;lt;div className={cn('grid gap-4', className)} {...props}&amp;gt;
      &amp;lt;Card&amp;gt;
        &amp;lt;CardHeader className=&quot;flex flex-row items-center justify-between space-y-0 pb-2&quot;&amp;gt;
          &amp;lt;CardTitle className=&quot;header-24 font-semibold&quot;&amp;gt;{title}&amp;lt;/CardTitle&amp;gt;
          {icon}
        &amp;lt;/CardHeader&amp;gt;
        &amp;lt;CardContent&amp;gt;{children}&amp;lt;/CardContent&amp;gt;
      &amp;lt;/Card&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;&amp;nbsp;CardLayout 컴포넌트는 제목과 선택적인 아이콘을 가진 카드를 생성하고, 내부에는 children으로 전달된 내용을 렌더링하는 구조를 가지고 있다. 이를 통해서 여러 화면에서 재사용할 수 있는 유연한 컴포넌트로 활용하면서 사용자에게 일관된 UI/UX를 제공할 수 있도록 개선되었다.&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;2024-09-24 Video to GIF.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;393&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dplWcM/btsJLkmbNou/GdKxZsa1p1IHOXJt9ok641/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dplWcM/btsJLkmbNou/GdKxZsa1p1IHOXJt9ok641/img.gif&quot; data-alt=&quot;정보의 특성에 맞게 카드 형식으로 묶어서 보여주도록 개선&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dplWcM/btsJLkmbNou/GdKxZsa1p1IHOXJt9ok641/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dplWcM/btsJLkmbNou/GdKxZsa1p1IHOXJt9ok641/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;800&quot; height=&quot;524&quot; data-filename=&quot;2024-09-24 Video to GIF.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;393&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;&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;CardLayout을 활용한 셀러 정보 화면의 결과이다. 기존 셀러 정보화면과 달리,&amp;nbsp;&lt;/span&gt;기본 정보, 셀러 지원 센터 가입 정보, 마케팅 수신동의 그리고 사업자 정보 (담당자, 대표자, 주소, 등록 서류 등)와 같이 분류된 정보를 각각 구분된 섹션으로 표현되고 있었다. 이렇게 개선된 레이아웃을 통해 정보를 보다 명확하고 깔끔하게 보여줌으로써 사용자 경험을 크게 향상시켰다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;빠른 UI/UX 수정을 통해 얻게 된 내부 피드백&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 기획안대로 만들어졌던 모든 화면의 디자인을 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;단 2일 만에 수정&lt;/b&gt;&lt;/span&gt;하였고, 해당 버전을 STG 환경에 배포하여 셀러 지원 센터 개발 팀장님께 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;먼저&lt;span&gt; 보고드렸다.&amp;nbsp;&lt;/span&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;수정완료슬랙피드백.png&quot; data-origin-width=&quot;507&quot; data-origin-height=&quot;609&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3NBcx/btsJIlUxewe/DvFA6dmLkS8PxvO46YoQek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3NBcx/btsJIlUxewe/DvFA6dmLkS8PxvO46YoQek/img.png&quot; data-alt=&quot;기존보다 나아진 UI/UX라는 피드백을 받았던 순간&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3NBcx/btsJIlUxewe/DvFA6dmLkS8PxvO46YoQek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3NBcx%2FbtsJIlUxewe%2FDvFA6dmLkS8PxvO46YoQek%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;507&quot; height=&quot;609&quot; data-filename=&quot;수정완료슬랙피드백.png&quot; data-origin-width=&quot;507&quot; data-origin-height=&quot;609&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기존보다 나아진 UI/UX라는 피드백을 받았던 순간&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 다행히도 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;긍정적인 피드백&lt;/b&gt;&lt;/span&gt;을 얻을 수 있었고, 해당 내용은 &lt;span style=&quot;color: #006dd7;&quot;&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-origin-width=&quot;1380&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8NdQn/btsJIUB8loj/5MHR2UkzMnmgzqQNzwD0Bk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8NdQn/btsJIUB8loj/5MHR2UkzMnmgzqQNzwD0Bk/img.png&quot; data-alt=&quot;변경된 UI/UX로 QA 진행&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8NdQn/btsJIUB8loj/5MHR2UkzMnmgzqQNzwD0Bk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8NdQn%2FbtsJIUB8loj%2F5MHR2UkzMnmgzqQNzwD0Bk%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;600&quot; height=&quot;223&quot; data-origin-width=&quot;1380&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;변경된 UI/UX로 QA 진행&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시, 1차 배포를 위한 QA가 진행 중이었으나, 셀러 회원에게 더 좋은 경험을 제공하기 위해서 부득이하게 QA 중간에 디자인이 변경되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼에도 끝까지 QA를 잘 마무리해주신 매니저님들께 감사했으며 1차 오픈을 개선된 UI/UX로 진행할 수 있다는 안도감이 밀려왔다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;짧지만 강한 임팩트를 만들어주었던 UI/UX 개선 작업&lt;/h2&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;또한, 기능 구현에만 집중했던 과거와 달리, 이번 프로젝트를 통해 사용자 경험의 중요성을 깨닫고, 사용자의 입장에서 생각하며 프로덕트를 개발하는 시각을 키울 수 있었다.&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;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>WEB/React</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/47</guid>
      <comments>https://happhee-dev.tistory.com/47#entry47comment</comments>
      <pubDate>Tue, 24 Sep 2024 13:54:37 +0900</pubDate>
    </item>
    <item>
      <title>Refine 이관 작업 ( feat. 중고나라 - 셀러지원센터 )</title>
      <link>https://happhee-dev.tistory.com/46</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  이관을 하게 된 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 셀러지원센터는 Next.js, react-query, shadcn/ui 의 환경에서 개발을 진행했었다. 짧은 시간 안에 빠른 개발, 빌드 시간 단축, 확장 할 수 있는 커스텀 조건이 위 기술의 채택 이유이기도 했다. (&lt;a href=&quot;https://happhee-dev.tistory.com/42#%EC%8B%A0%EC%9E%85%20%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98%20%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%A6%AC%EB%94%A9-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&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;1차,2차 배포까지만 해도 우리가 선택한 기술이 올바르다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3차 오픈을 준비하기 전에, 이전에 빠른 개발로 인해 미뤄두었던 코드 개선작업을 시작한 것이 이관의 시초점이다.&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;1️⃣ 개발자마다 다른 코드 스타일&lt;/h3&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;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ 직관적이지 않은 코드 베이스&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;하지만, API마다 데이터를 변환하는 방식, 페이지 수를 가져오는 방식이 달라 페이지 내부에 단순히 리스트 컴포넌트만 보여주는 것이 아닌 데이터를 클라이언트 환경에 맞게 변환하는 로직들이 섞여 있어 페이지 동작을 직관적으로 파악하기 어려웠다.&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;3️⃣ API마다 다른 스펙에 맞춰 변환하는 과정의 분산화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러지원센터에서 호출하는 API 도메인은 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;seller-api (셀러)&lt;/li&gt;
&lt;li&gt;partner-api (관리자)&lt;/li&gt;
&lt;li&gt;order-api (주문/결제 )&lt;/li&gt;
&lt;li&gt;main-api (중고나라 메인)&lt;/li&gt;
&lt;li&gt;edge-api (이미지)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 API를 개발한 팀들이 다르다 보니 데이터를 반환하는 형식이 다르게 설계되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 클라이언트 내부적으로 공통 컴포넌트를 만들어놓았지만, 만약 한 페이지에 여러 도메인의 API를 호출했을 경우에는 이를 클라이언트 형식에 맞게 변환하는 과정이 페이지 내부에 여러 유틸이 들어감으로써 가독성이 떨어지는 현상이 발생하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더불어 변환하는 과정을 모두 모듈로 관리하지 않았기 때문에, &lt;br /&gt;해당 방식 또한 개발자마다 스타일이 달라져 코드의 흐름을 파악하기 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 어드민 시스템의 특성상 테이블을 사용하는 페이지, 정보를 보여주는 페이지는 일관성을 가지도록 구현되는 것이 필요했고&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;이 3가지의 이유로 나는 어떤 특정 규격에 따른 코드 베이스를 가지면서 확장성을 고려한 기술의 도입이 필요하다고 판단했다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Refine의 간략한 특징&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 찾게 된 기술이 바로 &lt;a href=&quot;https://refine.dev/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Refine&lt;/a&gt;이다. 공식 문서에는 아래와 같이 설명되어 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;React 기반 내부 도구, 관리자 패널, 대시보드 및 엔터프라이즈급 비즈니스 애플리케이션을 구축하도록 설계된 오픈소스 프레임워크이다. CRUD, 상태 관리와 같은 일반적인 기능을 처리하기 위한 자동화 세트를 제공하여 개발 프로세스를 간소화한다.&lt;br /&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;기본적으로 해당 기술은 Ant Design, Material UI, Shadcn/ui와 같은 UI 프레임워크와 통합도 가능했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js, React Router, Remix도 지원하는 강력한 tool로 사용되고 있었다.&lt;/p&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;refine이 제시하고 있는 컨셉은 Headless,&amp;nbsp; Resource, Provider, Hook 총 4가지이며 자세한 내용은 문서를 통해 확인할 수 있기에 핵심 컨셉인 provider와 resource만 살펴보겠다.&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;1️⃣ &lt;a href=&quot;https://refine.dev/docs/guides-concepts/general-concepts/#provider-concept&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Provider&lt;/a&gt;의 개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;provider에도 data-provider, authentication-provider, access control-provider, notification-provider등 다양한 provider가 존재한다. 핵심은 data-provider이며 이는 백엔드 데이터와 get, post, path, delete와 같은 데이터 작업을 처리하는 provider이다.&lt;/p&gt;
&lt;pre id=&quot;code_1726384883989&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { DataProvider } from '@refinedev/core';

export const provider = ({
  httpClient,
}: {
  httpClient: AxiosInstance;
}): DataProvider =&amp;gt; ({
  getOne: async ({ resource, id, meta }) =&amp;gt; {
    const url = `${apiUrl}${resource ? `/${resource}` : ''}${id ? `/${id}` : ''}`;

    try {
      const { data } = await httpClient[requestMethod](url, { headers });
      return { data: data.data || data.meta };
    } catch (error) {
      return Promise.reject(getHttpError(error));
    }
  },

  update: async ({ resource, id, variables, meta }) =&amp;gt; {
    ...
  },

  create: async ({ resource, variables, meta }) =&amp;gt; {
   ...
  },

  deleteOne: async ({ resource, id, variables, meta }) =&amp;gt; {
   ...
  },

  getList: async ({ resource, pagination, sorters, filters, meta }) =&amp;gt; {
   ...
  },

  getApiUrl: () =&amp;gt; apiUrl || '',
});&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;이렇게 provider내부에는 getOne, update, create, deleteOne, getList, getApiUrl과 같은 메서드들이 정의되어 있으며&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드의 도메인에 따라 각 provider를 만들어 데이터를 관리하는 것이 가능한 구조이다.&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;2️⃣ &lt;a href=&quot;https://refine.dev/docs/guides-concepts/general-concepts/#providers&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Resource&lt;/a&gt;의 개념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스로 도메인별 entity를 정의하고, 해당 페이지에서 간단한 API 호출 방식으로 코드의 동작을 추상화하는 것이 가능해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 코드로 이해 해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1726383945710&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { Refine } from &quot;@refinedev/core&quot;;

export const App = () =&amp;gt; {
  return (
    &amp;lt;Refine
      resources={[
        {
          name: &quot;seller&quot;,
          list: &quot;/account&quot;,
          show: &quot;/account/:id&quot;,
          edit: &quot;/account/:id/edit&quot;,
        },
      ]}
    &amp;gt;
      {/* ... */}
    &amp;lt;/Refine&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;resource의 이름으로 seller를 정의해 두고, 셀러의 리스트를 불러오는 페이지의 경로를 list에 지정하고, 특정 셀러의 정보를 보여주는 페이지의 경로를 show에 지정하고, 특정 셀러의 정보를 편집하는 경로를 show에 지정하는 코드이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면, /account 페이지는 useList라는 훅을 호출하면 provider의 getList가 자동으로 호출되고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/account/:id 페이지에서는 useShow라는 훅을 호출하면 provider의 getOne이 자동으로 호출되고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/accoun/:id/edit 페이지에서는 useUpdate라는 훅을 호출하면 provider의 update이 자동으로 호출된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 내가 페이지에서 사용할 api의 자원을 미리 정의해 둠으로써 페이지 내부에서는 해당 과정을 구현할 필요 없이 응답 값에 대한 데이터 렌더링 및 사용자의 동작에만 집중할 수 있게 되는 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  우리가 검증한 내용들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;refine의 기술 자체는 너무나 매력적으로 다가왔으나, Next.js , react-query, shadcn/ui와 같은 기본적인 툴로도 이미 셀러지원센터의 기능을 만들어갈 수 있는 능력이 있는 상태에서 해당 기술을 이관하는 것으로 앞선 3가지의 문제를 해결해 줄 수 있을까..? 에 대한 검증 절차는 필요하다고 생각했다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ interceptor 로직의 사용 가능 여부 및 데이터 변환 과정의 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 셀러와 관리자의 인증/인가 로직은 Axios의 interceptor의 내부에서 관리되고 있었기에 해당 모듈을 그대로 적용할 수 있는지 파악해 보았다. 해당 모듈을 적용하는 것이 가능했고, app router에서 server component와 client component에 따라 다르게 실행될 수 있도록 분리하는 것도 가능하였다.&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;p data-ke-size=&quot;size16&quot;&gt;우선 data provider를 각 도메인으로 분리해 보았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;sellerProvider&lt;/li&gt;
&lt;li&gt;adminProvider&lt;/li&gt;
&lt;li&gt;orderProvider&lt;/li&gt;
&lt;li&gt;mainProvider&lt;/li&gt;
&lt;li&gt;edgeProvider&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 데이터를 반환하여 클라이언트에게 전달할 때는 동일한 규격을 가지도록 각 provider에 데이터 변환 과정을 도입하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1726385938990&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// sellerProvider
...
	getOne: async ({ resource, id, meta }) =&amp;gt; {
        const url = `${apiUrl}${resource ? `/${resource}` : ''}${id ? `/${id}` : ''}`;
        const { headers, method } = meta ?? {};
        const requestMethod = (method as MethodTypes) ?? 'get';

        try {
          const { data } = await httpClient[requestMethod](url, { headers });
          return { data: data.data || data.meta };
        } catch (error) {
          return Promise.reject(getHttpError(error));
        }
  },
...

// orderProvider

...
	getList: async ({ resource, pagination, sorters, filters, meta }) =&amp;gt; {
        const [countUrl] = resource.split('/list');
        const url = `${apiUrl}/${resource}`;

        const { headers: headersFromMeta, method, convertData } = meta ?? {};
       	
        ...
       
        try {
          const { data, headers } = await httpClient[requestMethod](
            `${url}?${queryString ? `${queryString}&amp;amp;` : ''}page=${pagination?.current}&amp;amp;size=${pagination?.pageSize}`,
            {
              headers: headersFromMeta,
            },
          );
          if (convertData) {
            const clientData = convertData({ data: data.data.results });
            return {
              data: clientData,
              total: data.data.page.totalCount,
            };
          }
          return {
            data: data.data.results || data.data,
            total: data.data.page.totalCount,
          };
        } catch (error) {
          return Promise.reject(getHttpError(error));
        }
      },
...&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;위의 코드처럼 셀러와 주문/결제 도메인에서 데이터를 가공하는 형식이 다르게 구현되어 있었지만, 데이터를 반환하는 구조를 객체 형식으로 통일시킴으로써 해당 데이터를 사용하는 곳에서는 data.{데이터타입} 으로 접근하여 사용하는 것이 가능해졌다.&lt;/p&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: #ee2323;&quot;&gt;&lt;b&gt;provider라는 객체 안에서 도메인별로 관리&lt;/b&gt;&lt;/span&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;✅ Data Table 호출 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 table을 호출하는 구조는 아래와 같았다.&lt;/p&gt;
&lt;pre id=&quot;code_1726386437968&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function AccountPage() {

  // 테이블 하단 페이지네이션 버튼 핸들러 커스텀 훅
  const { previousBtnInfo, handleNextBtn, handlePageSize } =
    useDataTableHandlers({ isNamePageSize: true });

  const filter = 테이블 필터 검색으로 API 호출 쿼리파라미터 받아오기;

  const { data: sellerList, error } = useQuery(셀러 리스트 불러오기);
  const { data: pageCount } = useQuery(리스트 관련 페이지 정보 불러오기);

  // 다음 페이지 네이션 버튼 이벤트 객체 
  const nextBtnInfo = {
    handleNextBtn,
    disabled: isNextBtnDisable({
      dataLength: sellerList?.length,
      pageCount,
      query,
    }),
  };

  return (
    &amp;lt;ContentLayout className=&quot;p-5&quot;&amp;gt;
      &amp;lt;Filter /&amp;gt;
      &amp;lt;DataTable
        columns={columns}
        data={sellerList || []}
        previousBtnInfo={previousBtnInfo}
        nextBtnInfo={nextBtnInfo}
        pageCount={pageCount}
        pageSize={Number(query.pageSize) || 10}
        handlePageSize={handlePageSize}
      /&amp;gt;
    &amp;lt;/ContentLayout&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;Data Table을 사용하기 위해서는 테이블에 주입할 리스트 정보, 테이블 하단 페이지 네이션 버튼 정보 및 핸들러, 리스트 테이블의 페이지 사이즈 및 페이지 수들을 페이지 단에서 만드는 과정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안상 모든 코드를 공개할 수 없어 압축하였지만, 해당 페이지 내부에서는 useQuery 호출 시 select를 사용하는 과정도 포함되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더불어 filter의 검색 버튼 이벤트로 만들어진 query를 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;refine에서는 React table을 활용한 &lt;a href=&quot;https://refine.dev/docs/data/hooks/use-table/#usage&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;useTable 훅&lt;/a&gt;을 제공하고 있었고, 해당 훅을 사용할 때, resource를 명시해 주면 해당 훅 내부에서 resource에 맞는 provider를 호출하여 반환한 데이터를 내부적으로 뿌려주도록 구현되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 적용한 코드이다.&lt;/p&gt;
&lt;pre id=&quot;code_1726386814407&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function AccountPage() {
  const { params } = useParsed();
  return (
    &amp;lt;DataTable
      columns={columns}
      refineCoreProps={{
        resource: 셀러리스트정보,
        meta: params?.filters,
        dataProviderName: 셀러제공자,
        queryOptions: { refetchOnMount: true },
      }}
      FilterComponent={Filter}
    /&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;/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;Data Table을 만들기 위해 refineCoreProps로 자원의 정보, 테이블 필터 정보를 params.filters로,&amp;nbsp; dataProvider의 이름을 명시적으로 작성하고, 원하는 queryOptions과 해당 페이지에서 필요한 Filter Component를 주입하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;어떤 자원&lt;/span&gt;으로, &lt;span style=&quot;color: #006dd7;&quot;&gt;어떤 API&lt;/span&gt;를 호출하고, &lt;span style=&quot;color: #006dd7;&quot;&gt;어떤 필터&lt;/span&gt;&lt;/b&gt;를 적용할 건지만 &lt;br /&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;선언적으로 전달&lt;/b&gt;&lt;/span&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;/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;✅ Filter Component 사용 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 사용하고 있던 Filter component의 구조이다.&lt;/p&gt;
&lt;pre id=&quot;code_1726387748115&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Filter() {
  const { query } = useRouter();
  const methods = useForm&amp;lt;FormValues&amp;gt;({
    defaultValues: { ...defaultValues, ...query },
  });
  const { getValues, setValue } = methods;

  // 필터를 query로 전달하여 page에서 해당 query를 파싱하여 호출 파라미터로 전달하기위한 push
  const onSubmit = () =&amp;gt; {
    push({ query: { ...query, ...getValues(), page: 1 } });
  };

 // api 마다 pageSize, size로 전달할지 파악하는 effect
  useEffect(
    function setPagesize() {
      if (query.pageSize) setValue('pageSize', Number(query.pageSize));
    },
    [query?.pageSize],
  );

  return (
    &amp;lt;Form {...methods}&amp;gt;
  	// ... 필터 요소들 렌더링
    &amp;lt;/Form&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;필터들에 대한 검색 버튼의 submit함수로 push 이벤트를 발생시키며, 이를 통해 page에서는 path의 query 변화를 감지하여 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;추가로 도메인마다 페이지의 사이즈를 호출하는 파라미터의 key가 pageSize 또는 size로 나뉘어 있어 이를 검증하여 setValue를 진행하는 effect도 구현되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 refine에서 제공하는 useTable과 data-provider를 사용하게 되면서 Filter component의 사용 방식도 바뀌게 되었다.&lt;/p&gt;
&lt;pre id=&quot;code_1726387568690&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Filter&amp;lt;TData&amp;gt;({ ids, getColumn }: FilterProps&amp;lt;TData&amp;gt;) {
  const methods = useForm&amp;lt;FormValues&amp;gt;({
    defaultValues: { ...defaultValues },
  });
  const { handleSubmit, reset, control, getValues } = methods;

  const onClickSearch = () =&amp;gt; {
    const filterValues = getFilterValues({ formValue: getValues() });
    setFilterValue({
      ids,
      filters: [...filterValues] as LogicalFilter[],
      getColumn,
    });
  };
  
  return (
     &amp;lt;Form {...methods}&amp;gt;
      ...
    &amp;lt;/Form&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;API 호출 시 pageSize인지, size인지 검증하는 내용은 provider 내부로 들어가게 되어 &lt;br /&gt;필터 컴포넌트에서는 필터 검색시 발생되는onClickSearch 이벤트에서 useTable에 검색 조건으로 주입할 filter의 값만 형식에 맞게 &lt;br /&gt;가공하여 setFilterValue의 과정만 거치게 되었다.&lt;/p&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: #ee2323;&quot;&gt;&lt;b&gt;UI에만 집중할 수 있는 구조&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;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러지원센터의 확장성을 고려했을 경우, 추후에 또 다른 기능들이 요구되었을 때 다른 기술들을 설치할 수 있는지 확인해 보는 과정도 진행했다. 확인 결과, refine에서 사용하고 있는 TanStack query의 버전을 5 이상으로 올리지만 않으면 다른 라이브러리는 수월하게 설치됨을 파악하였다.&lt;/p&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 셀러지원센터의 개발 환경을 refine으로 이관하는 것으로 결정하였다...!!!!!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  폴더구조도 개편하자..!&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 셀러지원센터 1차,2차 개발을 당시 폴더구조는 아래와 같았다.&lt;/p&gt;
&lt;pre id=&quot;code_1726388683554&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├──   src
│  ├──   api
│  ├──   components
│  ├──   constants         
│  ├──   hooks
│  ├──   lib
│  ├──   pages
│  ├──   styles
│  ├──  types              
...
└── tsconfig.json&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;하지만, 1차, 2차 개발과 오류 개선 작업을 진행하면서 해당 작업을 위한 개발자 경험의 비효율을 체감하게 되었다.&lt;/p&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;특정 페이지에서 오류 발생&lt;/li&gt;
&lt;li&gt;해당 페이지를 탐색하기 위해 src/pages/{페이지 이름} 접근&lt;/li&gt;
&lt;li&gt;오류 발생 컴포넌트 파악&lt;/li&gt;
&lt;li&gt;해당 컴포넌트 접근을 위해 components/{페이지 이름} 폴더로 이동&lt;/li&gt;
&lt;li&gt;내부적으로 api나 상수, 유틸 함수에서 오류가 발생했다면 다시 {api, constants, utils}/{페이지 이름} 폴더로 이동&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모두가 알겠지만, 프로젝트에서 1개 또는 2개의 페이지만 있는 것이 아니기 때문에 해당 과정을 진행하기 위해서는 vscode 좌측에서 스크롤을 위아래로 진행하는 과정이 길고 오래 발생하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버그를 수정하는 것보다 폴더를 찾는 과정으로 인해 피로도가 증가하는 것을 느꼈다.&lt;/p&gt;
&lt;pre id=&quot;code_1726389161427&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;├──   src
│  ├──   identifier               
│  ├──   resources                
│  ├──   shared                //셀러 &amp;amp; 관리자 도메인에서 공유되는 폴더
│  │    ├──   components             
│  │    ├──   hooks
│  │    ├──   api
...
│  ├──   admin
│  │    ├──   shared             // 관리자 도메인에서 공유되는 폴더
│  │    ├──   account
...
│  ├──   seller
│  │    ├──   shared             // 셀러 도메인에서 공유되는 폴더
│  │    ├──   info 
            ├──   components       // 컴포넌트   하위폴더명 (파스칼케이스.tsx)
            ├──   hooks            // 커스텀훅   하위폴더명 (카멜케이스.tsx)          
            ├──   utils            // 유틸함수   하위폴더명 (카멜케이스.tsx)
            ├──   types     
            │     └── index.ts     // types, constants, models, zods   하위폴더명 (index.ts)
            ├──   constants
            │     └── index.ts
            ├──   models
            │     └── index.ts   
            ├──   zods
            │     └── index.ts     
            └──    api               // api 통신 관련

...
│  ├──   app
│  │    ├──   admin
│  │    ├──   seller 
│  │    ├── _app.tsx     
│  │    ├── _document.tsx
│  │    └── index.tsx
...
└── tsconfig.json&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;그래서, refine으로 이관 작업을 진행하면서 폴더 구조를 위와 같이 관심사별로 개편하는 작업도 함께 진행하여 개발자 경험을 개선하고자 했다. 참고했던 내용은 &lt;a href=&quot;https://emewjin.github.io/feature-sliced-design/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FSD 아키텍쳐&lt;/a&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;최상위 폴더들을 페이지 기반으로 나누면서, 내부적으로 components, hooks, types, utils, constants, zods, 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;물론 페이지가 늘어날수록 폴더의 깊이와 수가 늘어나는 것이 단점이 될 수도 있다고 생각한다.&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;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그래서 이관 이후, 개발자 경험은 어떻게 달라졌을까..?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 refine이 제공하는 훅과 구조를 벗어나지 않게 코드를 작성해야 하는 것이 이전 버전에서는 존재하지 않았기에 불편함을 초래하기도 했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 오히려 refine이라는 특정 규격에 맞게 작성해야 하는 것이 일관성 있는 코드를 만들어주었기 때문에 개발자마다 다른 코드 스타일이 대폭 축소되어 내가 구현한 코드가 아니어도 수정하는 것이 수월해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더불어 refine이 정해준 틀이 있지만, 그 안에서 컴포넌트와 로직을 커스텀하는 과정도 가능하다는 장점이 있었기에&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;장기적으로 보았을 때, Refine 이관 작업으로 셀러지원센터의 확장과 유지/보수에 개발 생산성을 높일 수 있게 되었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &amp;zwj;  개발적으로 내가 느꼈던 내용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀러지원센터 웹 개발팀 리딩을 진행하며, 프로젝트를 개발하고 일정 관리를 하고 있는 경험도 새로운 도전의 차원에서 의미있는 경험이었다. 하지만, 프로젝트의 규모를 키워나가는 시점에서 좋은 DX를 제공하면서 빠르게 확장할 수 있는 방법을 코드베이스에서 고민할 수 있었다는 것이 개발자의 시야를 넓혀주게 된 시간이었던 것 같다.&lt;/p&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;div id=&quot;gtx-trans&quot; style=&quot;position: absolute; left: 231px; top: 12115.3px;&quot;&gt;
&lt;div class=&quot;gtx-trans-icon&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;</description>
      <category>WEB/Next.js</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/46</guid>
      <comments>https://happhee-dev.tistory.com/46#entry46comment</comments>
      <pubDate>Sun, 15 Sep 2024 13:58:05 +0900</pubDate>
    </item>
    <item>
      <title>FEConf 2024 - Lightning Speaker를 통해</title>
      <link>https://happhee-dev.tistory.com/45</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;630&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dVosXB/btsJmAD1cCe/9Birlvw9j3m0FneUumO8f1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dVosXB/btsJmAD1cCe/9Birlvw9j3m0FneUumO8f1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dVosXB/btsJmAD1cCe/9Birlvw9j3m0FneUumO8f1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdVosXB%2FbtsJmAD1cCe%2F9Birlvw9j3m0FneUumO8f1%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;700&quot; height=&quot;368&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;630&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ 내가 라이트닝 스피커를 신청하게 된 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 중고나라에서 웹 개발팀 매니저로 근무하게 된 지 0년 차, 약 6개월 된 주니어 개발자인 내가 라이트닝 스피커를 신청하게 된 이유는 2가지이다.&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;Feconf에는 다양한 환경에서 개발하시는 분들이 모이는 거대한 환경이었기 때문에&lt;/p&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;부수적으로는 앞으로 내가 만들어 갈 스피커 경험의 시작을 Feconf로 시작하고 싶었다.&lt;/p&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;IMG_9197.jpg&quot; data-origin-width=&quot;828&quot; data-origin-height=&quot;1033&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/T6Jr1/btsJooIXclP/cmrayxWFRYHXKKxkmLHUKk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/T6Jr1/btsJooIXclP/cmrayxWFRYHXKKxkmLHUKk/img.jpg&quot; data-alt=&quot;모교인 세종대학교에서 스피커로 첫 발걸음을 내딛을 수 있게 되었다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/T6Jr1/btsJooIXclP/cmrayxWFRYHXKKxkmLHUKk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FT6Jr1%2FbtsJooIXclP%2FcmrayxWFRYHXKKxkmLHUKk%2Fimg.jpg&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;500&quot; height=&quot;624&quot; data-filename=&quot;IMG_9197.jpg&quot; data-origin-width=&quot;828&quot; data-origin-height=&quot;1033&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;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &amp;nbsp; 2개월동안의 준비 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이트닝 스피커로 선정된 이후, 사전에 제출한 주제를 기반으로 총 6개의 그룹이 만들어지게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그룹 4로 편성되었으며 각자 준비하고 있는 발표 주제와 내용에 대한 피드백을 진행하기 위해 전체 OT 이전에 조별 오프라인 모임의 시간을 가지게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감사하게도 Feconf-Lightning Speaker를 만들어주신 동준님께서 우아한 형제들 오피스 장소를 제공해 주셔서 편하게 만날 수 있었다.&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;style1&quot;&gt;&amp;nbsp;만약, 슬라이드 한 장만 남긴다면 어떤 메시지를 작성하고 싶으신가요?&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피드백을 주고 받으면서 가장 인상깊었던 포인트 질문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 질문에 대한 나의 답은,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;몰입으로 안되는 건 없다.&lt;br /&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;/p&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;edited_04 어쩌다 시작된 신입 개발자의 어드민 프로젝트 리딩.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcfkqg/btsJmIWcsXO/e2a9dFTv1zuhkIkgI9HDrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcfkqg/btsJmIWcsXO/e2a9dFTv1zuhkIkgI9HDrK/img.png&quot; data-alt=&quot;몰티즈에 나를 비유하며 이야기를 풀어갔다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcfkqg/btsJmIWcsXO/e2a9dFTv1zuhkIkgI9HDrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbcfkqg%2FbtsJmIWcsXO%2Fe2a9dFTv1zuhkIkgI9HDrK%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;500&quot; height=&quot;281&quot; data-filename=&quot;edited_04 어쩌다 시작된 신입 개발자의 어드민 프로젝트 리딩.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&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;위 사진은 발표 슬라이드의 일부이다 ㅎㅎ&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;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  내가 얻어가게 된 내용들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소중한 경험을 통해 내가 얻어가게 된 내용은 총 3가지이다.&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나만 알고 있는 이야기를 처음 듣는 사람에게 전달하기 위해서는 기승전결의 구조가 중요하다는 것을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 기술적인 이야기가 아닌, 한 사람의 일상적인 경험을 전달하는 자리일수록 이 부분을 세심하게 신경 써야 한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면, 나의 이야기이기에 주관이 들어가게 되고 그 주관이 어떤 사람에게는 맞는 생각으로, 또 다른 사람에게는 틀린 생각으로 와 닿을 수 있기 때문이다.&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;'아~ 이 사람이 이렇게 일을 시작하게 되었구나.' , '이 사람이 그래서 어떤 일을 마주했을 때, 감당하기 어려웠겠구나.' , '그래서 이러한 선택을 했고, 결국엔 이러한 교훈을 얻었구나.'&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;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;h3 data-ke-size=&quot;size23&quot;&gt;  FAQ에 대한 준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;막상 발표에 대한 준비는 탄탄하게 진행했지만, FAQ에 대한 내용도 사전에 미리 준비했다면 조금 더 청중이랑 가까워질 수 있었을 것 같았다. 라이트닝 스피커라는 자리를 더 적극적으로 활용하지 못한 것 같아 아쉬웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 가장 좋은 방법은, 내가 준비하지 않아도 오로지 발표 내용을 기반으로 질의응답을 갖는 상황이지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발표자로서의 첫 도전이었고, 나만의 이야기에 관련된 주제였기에 사전 질문을 준비하는 것이 필요해 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에 도전할 컨퍼런스에서는 2~3개라도 준비를 해야겠다는 배움을 얻었다.&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 누구보다 뿌듯했던 것은 각자 다른 환경에서 모인 개발자로서의 고민을 나누면서, 또 오로지 이 컨퍼런스를 위해 서로 피드백을 주고받으면서 발표를 준비하면서, 최종적으로 Feconf의 라이트닝 스피커라는 행사를 완성할 수 있었다는 것이다.&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;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;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  글을 마무리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 가진 경험과 교훈들을 나눌 수 있다는 경험은 다른 어떤 성장의 경험보다 가치 있다는 것을 배울 수 있었고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 배움이 나를 기쁘게 만들며, 앞으로의 큰 원동력이 된다는 사실을 또다시 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_KakaoTalk_Photo_2024-09-01-17-24-21.jpeg&quot; data-origin-width=&quot;1163&quot; data-origin-height=&quot;1458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bplUwT/btsJmeBlrTa/KEBpiZ6lDIPrqCsWYB41E0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bplUwT/btsJmeBlrTa/KEBpiZ6lDIPrqCsWYB41E0/img.png&quot; data-alt=&quot;값진 첫 스피커의 시간..!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bplUwT/btsJmeBlrTa/KEBpiZ6lDIPrqCsWYB41E0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbplUwT%2FbtsJmeBlrTa%2FKEBpiZ6lDIPrqCsWYB41E0%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;500&quot; height=&quot;627&quot; data-filename=&quot;edited_edited_KakaoTalk_Photo_2024-09-01-17-24-21.jpeg&quot; data-origin-width=&quot;1163&quot; data-origin-height=&quot;1458&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;그래서 나는 또 도전하고 싶고, 그렇게 햅히만의 개발 이야기를 그려나가고 싶어졌다.&lt;/p&gt;</description>
      <category>WEB/Insight</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/45</guid>
      <comments>https://happhee-dev.tistory.com/45#entry45comment</comments>
      <pubDate>Sun, 1 Sep 2024 17:32:03 +0900</pubDate>
    </item>
    <item>
      <title>이미지 최적화로 성능 개선하기 ( feat. FEW )</title>
      <link>https://happhee-dev.tistory.com/44</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ 들어가기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글에 들어가기에 앞서, FEW 서비스를 아직 다운받지 않았다면..?&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;a href=&quot;https://www.fewletter.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FEW : WEB&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://apps.apple.com/kr/app/few-퀴즈로-뉴스레터-끝까지-읽기/id6467251877&quot;&gt;FEW : APP Download link&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1724380156414&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Few - Just a few minutes!&quot; data-og-description=&quot;퀴즈로 뉴스레터 끝까지 읽기&quot; data-og-host=&quot;www.fewletter.com&quot; data-og-source-url=&quot;https://www.fewletter.com/&quot; data-og-url=&quot;https://www.fewletter.com&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/TuSRU/hyWSadIhKo/2lG0kBBtpWgQnGSPE6JFnk/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877&quot;&gt;&lt;a href=&quot;https://www.fewletter.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.fewletter.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/TuSRU/hyWSadIhKo/2lG0kBBtpWgQnGSPE6JFnk/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Few - Just a few minutes!&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;퀴즈로 뉴스레터 끝까지 읽기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.fewletter.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1724335667772&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;&amp;lrm;FEW - 퀴즈로 뉴스레터 끝까지 읽기&quot; data-og-description=&quot;&amp;lrm;# 학습지 - 학습지를 구독하여, 매일 이메일로 뉴스레터를 받아보세요! # 아티클 읽기 - 퀄리티 높은 아티클 컨텐츠를 읽으세요! # 문제 풀기 - 내가 잘 읽었는지 문제를 풀며 문해력과 독해력을&quot; data-og-host=&quot;apps.apple.com&quot; data-og-source-url=&quot;https://apps.apple.com/kr/app/few-%ED%80%B4%EC%A6%88%EB%A1%9C-%EB%89%B4%EC%8A%A4%EB%A0%88%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EC%9D%BD%EA%B8%B0/id6467251877&quot; data-og-url=&quot;https://apps.apple.com/kr/app/few-%ED%80%B4%EC%A6%88%EB%A1%9C-%EB%89%B4%EC%8A%A4%EB%A0%88%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EC%9D%BD%EA%B8%B0/id6467251877&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eKPNni/hyWSmSClwS/U8amy8pWHoCYZICE2Zq9jk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/bdwUpK/hyWSgkAiKK/JcUYUnVfvqrdXa3SomaC9K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://apps.apple.com/kr/app/few-%ED%80%B4%EC%A6%88%EB%A1%9C-%EB%89%B4%EC%8A%A4%EB%A0%88%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EC%9D%BD%EA%B8%B0/id6467251877&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://apps.apple.com/kr/app/few-%ED%80%B4%EC%A6%88%EB%A1%9C-%EB%89%B4%EC%8A%A4%EB%A0%88%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%EC%9D%BD%EA%B8%B0/id6467251877&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eKPNni/hyWSmSClwS/U8amy8pWHoCYZICE2Zq9jk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/bdwUpK/hyWSgkAiKK/JcUYUnVfvqrdXa3SomaC9K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;&amp;lrm;FEW - 퀴즈로 뉴스레터 끝까지 읽기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;lrm;# 학습지 - 학습지를 구독하여, 매일 이메일로 뉴스레터를 받아보세요! # 아티클 읽기 - 퀄리티 높은 아티클 컨텐츠를 읽으세요! # 문제 풀기 - 내가 잘 읽었는지 문제를 풀며 문해력과 독해력을&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;apps.apple.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 서비스는 뉴스레터를 워크북 단위로 모아서 보여주고, 문제 풀이를 통해 지속적인 학습을 유도하는 데에 초점이 맞춰져 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 정보를 제공해 주는 서비스의 특징으로 인해 웹 사이트의 로딩 속도 및 로딩 중 사용자 경험 향상이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2가지 중에서 로딩 중 사용자 경험을 높이기 위해서 dynamic import를 통한 loading component 와 data fetching의 상태일 때 보여주는 loading component를 구현하여 &lt;a href=&quot;https://web.dev/articles/cls?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CLS 점수&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;하지만, 서버로부터 이미지를 가져와 사용자에게 보여주기까지의 시간이 길어져 loading component가 올바른 위치에 잘 렌더링 되고 있음에도 불구하고, 사용자에게 정보를 보여주는 체감 시간이 길어져 불편함이 야기되는 현상이 발생하였다.&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;이에 대한 원인을 분석하기 위해 chrome 개발자 도구에서 light house를 통해 웹 사이트 점수를 측정하였다.&amp;nbsp;&lt;br /&gt;참고로 퓨 사이트는 cloud flare로 배포 환경을 구성하였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  최초 메인 페이지 성능&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 점수는 앞서 언급한 loading component를 적절하게 배치시킨 작업의 최초 성능 측정 사진이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dzkpYN/btsJdsknx6J/jnkwJkcRQx6aStVx1VZ7jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dzkpYN/btsJdsknx6J/jnkwJkcRQx6aStVx1VZ7jk/img.png&quot; data-alt=&quot;처음 메인 페이지 속도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dzkpYN/btsJdsknx6J/jnkwJkcRQx6aStVx1VZ7jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdzkpYN%2FbtsJdsknx6J%2FjnkwJkcRQx6aStVx1VZ7jk%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;860&quot; height=&quot;973&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1638&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;확실히 CLS 점수에 공을 기울인 덕분에 0.006이라는 매우 좋은 점수를 얻을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, FCP 과 LCP 속도는 3.3s와 34.3s로 성능이 매우 안 좋았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 속도를 늦추는 원인으로는 image로 발견되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FCP 속도를 늦추게 하는 것은 png 파일로 이미지를 렌더링시키는 과정이었고, LCP 속도를 느리게 만드는 원인은 뷰포트에 보이는 사진의 크기보다 큰 이미지를 불러오는 과정이었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2284&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beHhnq/btsJbhrPV1i/t2cfk4bKoXTDtrq7m0Gay1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beHhnq/btsJbhrPV1i/t2cfk4bKoXTDtrq7m0Gay1/img.png&quot; data-alt=&quot;intrinsic size가 rendered size에 비해 약 3배가량 크다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beHhnq/btsJbhrPV1i/t2cfk4bKoXTDtrq7m0Gay1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeHhnq%2FbtsJbhrPV1i%2Ft2cfk4bKoXTDtrq7m0Gay1%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;2284&quot; height=&quot;1392&quot; data-origin-width=&quot;2284&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;intrinsic size가 rendered size에 비해 약 3배가량 크다.&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;메인 화면 내의 워크북 카드 대표 이미지 부분에서 사용자에게 보이는 크기는 269 x 172 px이지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 사진이 다운로드 되는 크기는 750 x 676 px로 약 3배가량 큰 사이즈로 불러와지고 있었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  FCP 개선하기 : png 에서 webp로 변환&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 FCP 개선하기 위해 차세대 이미지 형식인 WebP 포맷으로 이미지 파일을 변경하여 서버로부터 응답받을 수 있도록 변경하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_blob&quot; data-origin-width=&quot;1336&quot; data-origin-height=&quot;1616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cP8zOK/btsJdlTcAPf/CaqVnOZ0vu9PFIPcXAdtK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cP8zOK/btsJdlTcAPf/CaqVnOZ0vu9PFIPcXAdtK0/img.png&quot; data-alt=&quot;png에서 webp로 전환&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cP8zOK/btsJdlTcAPf/CaqVnOZ0vu9PFIPcXAdtK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcP8zOK%2FbtsJdlTcAPf%2FCaqVnOZ0vu9PFIPcXAdtK0%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;860&quot; height=&quot;1040&quot; data-filename=&quot;edited_blob&quot; data-origin-width=&quot;1336&quot; data-origin-height=&quot;1616&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;png에서 webp로 전환&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;그 결과, FCP 속도가 3.3s   2.9s 로 0.4s가 줄어든 것을 확인할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 LCP 속도도 34.3s   28.8s 로 5.5s가 줄어들었지만 여전히 LCP는 낮은 점수를 보이고 있었다.&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;당연히...원인을 분석하고 개선하기 이전이었기에.....! 그래도 5초가 줄어든 것에 희열을 느꼈다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  LCP 개선하기 : next/image의 기능을 활용하여 이미지 최적화&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 말이죠..?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스는 Next.js를 사용하고 있었으며 이미지를 보여주는 component도 next/image를 사용하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 왜..? next/image에서 제공해 주는 최적화기능들 ( ex. webp 변환, sizes ,cache 등)이 적용되지 않았을까..?&lt;/p&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;cloudflare로 배포했을 때, runtime을 edge로 사용하고 있었고 이는 가벼운 배포 환경이었기에 next/image가 제공하는 최적화 기능들을 사용하지 못하고 있었던 것이다..!&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;그래서 우리는 고민 없이 vercel로 배포 환경을 이관하는 작업을 진행하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 워크북 카드 내 이미지 컴포넌트에서 sizes와 priority 속성을 사용해 rendered size에 적합한 이미지를 불러오도록 조정했다.&lt;/p&gt;
&lt;pre id=&quot;code_1724336710420&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const MainImage = ({
  mainImageUrl,
  isPriorityImage,
}: Pick&amp;lt;WorkbookCardClientInfo, &quot;mainImageUrl&quot; | &quot;isPriorityImage&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;Image
    width={269}
    height={172}
    src={mainImageUrl}
    alt=&quot;main-image&quot;
    priority={isPriorityImage}
    quality={90}
    sizes=&quot;28vw&quot;
    className=&quot;h-[172px] w-[269px] rounded-t-lg object-cover&quot;
  /&amp;gt;
);

...

// ✅ workbook card model
const changeToClientData: WorkbookCardClientInfo = {
          id,
          badgeInfo: this.getBadeInfo({ cardType }),
          mainImageUrl: mainImageUrl,
          isPriorityImage: idx &amp;lt; 2, // ✅ 인덱스 0,1만 우선 노출
          title,
          writers: this.getWriterNameList({ writers }),
          metaComponent: this.getMetaComponent({
            category,
            currentDay,
            totalDay,
          }),
          personCourse: this.getPersonCourse({
            totalSubscriber,
            subscriberCount,
            status,
          }),
          buttonTitle: this.getButtonTitle({
            cardType,
            currentDay,
          }),
          cardType,
          articleId: this.getArticleId({ articleInfo }),
        };
        return changeToClientData;
      },&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;priority 속성은 사용자에게 처음으로 보이는 카드에만 true로 속성을 부여하였다.&lt;/p&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2213&quot; data-origin-height=&quot;1360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tm8UU/btsJcLrNCbf/pfhRYkj04x243FkG5awnkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tm8UU/btsJcLrNCbf/pfhRYkj04x243FkG5awnkk/img.png&quot; data-alt=&quot;intrinsic size가 rendered size에 대해 약 2배보다 작은 크기로 보여진다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tm8UU/btsJcLrNCbf/pfhRYkj04x243FkG5awnkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftm8UU%2FbtsJcLrNCbf%2FpfhRYkj04x243FkG5awnkk%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;2213&quot; height=&quot;1360&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2213&quot; data-origin-height=&quot;1360&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;intrinsic size가 rendered size에 대해 약 2배보다 작은 크기로 보여진다.&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;vercel 이관을 통해 next/image의 최적화 기능 적용과 sizes의 속성 사용을 통해 측정한 최종 lighthouse 점수 결과이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;1028&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qayWV/btsJb1uNkJx/qPVoFgm4S5UmDhKz6JTZvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qayWV/btsJb1uNkJx/qPVoFgm4S5UmDhKz6JTZvK/img.png&quot; data-alt=&quot;next/image 기능을 통한 최적화 진행&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qayWV/btsJb1uNkJx/qPVoFgm4S5UmDhKz6JTZvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqayWV%2FbtsJb1uNkJx%2FqPVoFgm4S5UmDhKz6JTZvK%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;860&quot; height=&quot;1142&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;1028&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;next/image 기능을 통한 최적화 진행&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;최종적으로&lt;br /&gt;FCP 속도는 3.3s   0.9s 로 2.4s가 줄어들면서 안정적인 초록 상태로 성능을 가지게 되었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LCP 속도도 34.3s   21.7s 로 12.6s가 줄어드는 결과를 얻게 되었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 정보를 제공하는 서비스라면 어떤 기능을 구현할 때, 단순히 기능 구현에만 초점이 맞춰서 개발하는 것이 아니라 이 기능을 사용할 사람들에게 좋은 경험을 제공하는 것을 지속해서 발전시키는 것이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히, 서버로부터 이미지나 글, 영상 등을 불러오는 과정에서 로딩 시간이 길어진다면 원인을 분석하고 개선하는 작업은 서비스가 고도화되어 갈수록 서비스의 품질을 높이는 데 기여할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;next환경에서 next/image가 제공하는 최적화 기능을 적절하게 사용할 줄 아는 것도 중요한 능력임을 깨닫게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FEW는 곧 2차 스프린트를 들어갈 예정이며 프론트엔드 개발자로서 이 부분을 놓치지 않고 서비스를 만들어가고자 한다.&lt;/p&gt;</description>
      <category>WEB/Next.js</category>
      <category>few</category>
      <category>image</category>
      <category>Next.js</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/44</guid>
      <comments>https://happhee-dev.tistory.com/44#entry44comment</comments>
      <pubDate>Fri, 23 Aug 2024 11:39:48 +0900</pubDate>
    </item>
    <item>
      <title>모델 계층으로 유연하게 컴포넌트 관리하기 ( feat.FEW )</title>
      <link>https://happhee-dev.tistory.com/43</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;✨ 들어가기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발을 할때 확정된 기획으로 개발에 들어가게 된다. 하지만, 시간이 지나면서 사용자 피드백을 통한 요구사항이 변하는 건 당연한 과정이기도 하다. 지난 시간동안 여러가지 프로젝트를 해보면서, 요구사항에 빠르게 대응하기 위해서는 &lt;span style=&quot;color: #409d00;&quot;&gt;&lt;b&gt;컴포넌트 그 자체의 역할을 할 수 있도록 만들고, 비즈니스 로직은 컴포넌트 외부에서 관리하는 것이 필요&lt;/b&gt;&lt;/span&gt;하다고 깨달았다. 이 부분이 뒷받침되는 설계로 프로젝트를 관리하면 뷰와 로직을 분리할 수 있게 될 거고, 개발 속도도 보다 빠르게 진행할 수 있을 것이라고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번에 새롭게 시작한 &lt;a href=&quot;https://www.fewletter.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Few 프로젝트&lt;/a&gt;에 Model계층을 기반으로 개발한 내용을 공유하고자 한다.&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;우선, Few는&amp;nbsp;매일 아침 8시에 뉴스레터를 이메일로 보내주고 &lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;매일 조금씩 읽는 습관 더 나아가 학습의 습관을 제공하는 서비스이다.&amp;nbsp; &lt;br /&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;a href=&quot;https://www.fewletter.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FEW : WEB&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://apps.apple.com/kr/app/few-퀴즈로-뉴스레터-끝까지-읽기/id6467251877&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FEW : APP Download link&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1723197692587&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Few - Just a few minutes!&quot; data-og-description=&quot;퀴즈로 뉴스레터 끝까지 읽기&quot; data-og-host=&quot;www.fewletter.com&quot; data-og-source-url=&quot;https://www.fewletter.com/&quot; data-og-url=&quot;https://www.fewletter.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/TNHX7/hyWKD1CS0D/3DQCWRi31cRfQyNSmRtSZk/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877,https://scrap.kakaocdn.net/dn/pWHPz/hyWKGYlRzn/EVomaJChnk56wjwAUAchzK/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877&quot;&gt;&lt;a href=&quot;https://www.fewletter.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.fewletter.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/TNHX7/hyWKD1CS0D/3DQCWRi31cRfQyNSmRtSZk/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877,https://scrap.kakaocdn.net/dn/pWHPz/hyWKGYlRzn/EVomaJChnk56wjwAUAchzK/img.png?width=2839&amp;amp;height=1877&amp;amp;face=0_0_2839_1877');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Few - Just a few minutes!&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;퀴즈로 뉴스레터 끝까지 읽기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.fewletter.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  MVP 기획안&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 검증을 위해 MVP는 로그인 없이 워크북별(작가들의 컨텐츠 모음집) 구독시, 입력하는 email로 사용자의 구독정보를 관리하고, 이메일 전송을 통해 &lt;u&gt;사용자가 얼마나 워크북 페이지 &amp;amp; 아티클 페이지 &amp;amp; 문제 풀이 페이지로 유입&lt;/u&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;메인 화면 X&lt;/li&gt;
&lt;li&gt;메인 url로 접속시, 노션 페이지로 이동
&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;워크북 선택시 , /workbook/{id} 로 진입&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 아래의 사진처럼 워크북을 구독하게 되면, 사용자의 email에 대해 여러 워크북들의 구독 데이터가 쌓이게 된다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;workbook_intro.png&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;6180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnf6rR/btsIZRGbVa9/SxUP2RBGWdje2hOeZDSYgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnf6rR/btsIZRGbVa9/SxUP2RBGWdje2hOeZDSYgK/img.png&quot; data-alt=&quot;MVP 워크북 상세 페이지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnf6rR/btsIZRGbVa9/SxUP2RBGWdje2hOeZDSYgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbnf6rR%2FbtsIZRGbVa9%2FSxUP2RBGWdje2hOeZDSYgK%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;340&quot; height=&quot;1401&quot; data-filename=&quot;workbook_intro.png&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;6180&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MVP 워크북 상세 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기에 API 스펙 논의를 진행할때도, &lt;b&gt;도메인 별로 API를 분리하는 방향&lt;/b&gt;으로 진행하였다. 위 사진에서는 워크북의 정보를 가져오는 API와 이메일을 통한 사용자의 구독 상태를 입력하고 조회하는 API로 방향을 정했다고 이해하면 된다.&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;즉, MVP의 워크북 상세 페이지에서 데이터를 보여주기 위해서는 워크북의 정보를 가져오는 &lt;span style=&quot;color: #1b711d;&quot;&gt;&lt;b&gt;API 1개만 호출해서 각 컴포넌트를 렌더링&lt;/b&gt;&lt;/span&gt; 해주면 되는 것이다. 사실 이 상황에서는 모델이 필요한가..? 라는 생각이 들 수도 있다. 왜냐하면 충분히 모델 없이도 API 스펙에 맞춰 컴포넌트의 Props를 명명하고 설계하면 구현가능하기 때문이다.&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;하지만, 프론트엔드에서 단일 API만 호출하는 경우도 있겠지만, 서비스 규모가 커지거나 도메인별로 분리된 API 환경에서는 한 페이지에서 보여주는 데이터들이 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;복수의 API의 응답값의 조합으로 구성&lt;/b&gt; &lt;/span&gt;될&amp;nbsp; 가능성이 높아지게 된다는 것은 당연하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;우리 프로젝트도 위 요구사항을 피할 수는 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  1차 요구 사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 MVP의 성공적인 론칭이후, 논의를 거쳐 만들어진 1차 요구사항은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;email을 통한 사용자 로그인/회원가입 기능&amp;nbsp;&lt;/li&gt;
&lt;li&gt;메인 화면 페이지&amp;nbsp;
&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVP 기획에서는 존재하지 않았던 메인 화면이 아래와 같이 설계되었다.&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;edited_main.png&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;1418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvQqaf/btsI0LZhtwb/QAdko41WY93vOK5BdwesY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvQqaf/btsI0LZhtwb/QAdko41WY93vOK5BdwesY0/img.png&quot; data-alt=&quot;1차 메인화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvQqaf/btsI0LZhtwb/QAdko41WY93vOK5BdwesY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvQqaf%2FbtsI0LZhtwb%2FQAdko41WY93vOK5BdwesY0%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;375&quot; height=&quot;1418&quot; data-filename=&quot;edited_main.png&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;1418&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;1차 메인화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;위 요구 사항만 보았을 때는, 기존에 있던 워크북 상세 조회 API &amp;amp; 아티클 상세 조회 API를 각 컴포넌트에서 따로 부르면 이또한 별문제는 없는 것으로 판단할 수 있다.&amp;nbsp;&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;text-align: start;&quot;&gt;그러나 세부 요구사항은 다음과 같았다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 170px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 40px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 40px; text-align: center;&quot;&gt;공통 전제 &lt;br /&gt;(메인화면 , &lt;br /&gt;워크북 카드 상세 정보)&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 40px; text-align: center;&quot;&gt;로그인 X ( Default ) /&lt;br /&gt;로그인 O + 구독이전 상태&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 40px; text-align: center;&quot;&gt;로그인 O + 구독중&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 40px; text-align: center;&quot;&gt;로그인 O + 학습완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 20px; text-align: center;&quot;&gt;이미지 썸네일&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 20px; text-align: center;&quot;&gt;워크북 썸네일&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 20px; text-align: center;&quot;&gt;워크북 썸네일&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 20px; text-align: center;&quot;&gt;워크북 썸네일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; text-align: center; height: 20px;&quot;&gt;좌측 상단 뱃지&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; text-align: center; height: 20px;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; text-align: center; height: 20px;&quot;&gt;현재 학습중&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; text-align: center; height: 20px;&quot;&gt;학습 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 18px; text-align: center;&quot;&gt;카테고리&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 18px; text-align: center;&quot;&gt;워크북 카테고리&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 18px; text-align: center;&quot;&gt;&lt;b&gt;현재 학습Day&lt;/b&gt;/총 Day&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 18px; text-align: center;&quot;&gt;현재 학습Day/총 Day&lt;br /&gt;(파란색 bold 글자)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 18px; text-align: center;&quot;&gt;워크북 제목&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 18px; text-align: center;&quot;&gt;제목&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 18px; text-align: center;&quot;&gt;제목&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 18px; text-align: center;&quot;&gt;제목&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 18px; text-align: center;&quot;&gt;워크북에 속한 작가이름 리스트&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 18px; text-align: center;&quot;&gt;작가이름 리스트&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 18px; text-align: center;&quot;&gt;작가이름 리스트&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 18px; text-align: center;&quot;&gt;작가이름 리스트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 18px; text-align: center;&quot;&gt;학습 중인 유저수&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 18px; text-align: center;&quot;&gt;학습 중인 유저수&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 18px; text-align: center;&quot;&gt;학습 중인 유저수&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 18px; text-align: center;&quot;&gt;총 학습 완료한 유저수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 14.6511%; height: 18px; text-align: center;&quot;&gt;하단 버튼&lt;/td&gt;
&lt;td style=&quot;width: 20.4652%; height: 18px; text-align: center;&quot;&gt;구독하기&lt;br /&gt;(클릭시, 구독 API 호출)&lt;/td&gt;
&lt;td style=&quot;width: 17.6744%; height: 18px; text-align: center;&quot;&gt;현재 Day 학습하기&lt;br /&gt;(클릭시, 해당 아티클로 이동)&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 18px; text-align: center;&quot;&gt;공유하기&lt;br /&gt;(클릭시, 해당 url 복사)&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;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-origin-width=&quot;1788&quot; data-origin-height=&quot;830&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpwvK3/btsI1nXXreD/HV6r0krnD4OZTOaBPOLmzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpwvK3/btsI1nXXreD/HV6r0krnD4OZTOaBPOLmzk/img.png&quot; data-alt=&quot;현재 구독중 / 구독이전 / 학습완료&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpwvK3/btsI1nXXreD/HV6r0krnD4OZTOaBPOLmzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpwvK3%2FbtsI1nXXreD%2FHV6r0krnD4OZTOaBPOLmzk%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;1788&quot; height=&quot;830&quot; data-origin-width=&quot;1788&quot; data-origin-height=&quot;830&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;그렇다면 여기서 생각해볼 수 있는 부분은 API의 설계가 도메인별로 나뉘어져 있다는 사실이다.&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;해당 페이지에서 로그인 여부에 따라 호출하는 API의 종류가 달라진다.&lt;/li&gt;
&lt;li&gt;로그인을 하지 않은 유저인 경우, 단일로 워크북 상세정보를 가지고 있는 워크북 리스트 API를 호출하여 데이터를 보여준다.&lt;/li&gt;
&lt;li&gt;하지만, 로그인을 한 유저라면 &lt;u&gt;워크북 리스트 API&lt;/u&gt;와 &lt;u&gt;자신이 구독하고 있는 워크북의 리스트 API&lt;/u&gt;를 호출한 &lt;u&gt;2개의 데이터 결과 값을 조합&lt;/u&gt;해서 사용자에게 보여준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이 부분을 클라이언트에서 처리하지 않고, 서버측에서 토큰 여부에 따라 로그인 한 유저 및 메인페이지 조회라는 조건 안에서 내부적으로 데이터를 조합해서 하나의 API로 데이터를 내려주는 것도 다른 방법이 될 수도 있다는 생각도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이렇게 된다면 화면에 종속된 API가 만들어진다는 단점과 서버측에서 여러 테이블을 오고가야 하는 불편한 과정이 추가될 가능성이 높아진다고 판단했다.&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;드디어 Model을 설계한 시간이 빛낼때가 온 것이다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-size: 1.62em; letter-spacing: -1px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;  WorkbookCard - Component&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 로그인 여부 및 구독상태와는 관계없이 워크북 카드 컴포넌트 자체만 바라보았을때, UI는 동일할 뿐 그 안에 들어가는 텍스트와 이벤트 핸들러의 동작이 다른 것이다. 따라서 워크북 카드 내 세부 컴포넌트를 우선 &lt;a href=&quot;https://patterns-dev-kr.github.io/design-patterns/compound-pattern/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Compound Component Pattern&lt;/a&gt;으로 구현하였다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1723200330611&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ✅ 이미지 썸네일
const MainImage = ({
  mainImageUrl,
}: Pick&amp;lt;WorkbookClientInfo, &quot;mainImageUrl&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;Image
    width={269}
    height={172}
    src={mainImageUrl}
    alt=&quot;main-image&quot;
    loading=&quot;lazy&quot;
    className=&quot;h-[172px] w-[269px] rounded-t-lg object-cover&quot;
  /&amp;gt;
);
// ✅ 이미지 썸네일 좌측 상단 뱃지
const CardBadge = ({ badgeInfo }: Pick&amp;lt;WorkbookClientInfo, &quot;badgeInfo&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;div
    className={cn(
      &quot;absolute left-[13px] top-[14px] w-fit&quot;,
      &quot;px-[6.3px] py-[3.4px]&quot;,
      &quot;rounded-[3.2px] text-[10px]/[15px] font-extrabold&quot;,
      badgeInfo.className,
    )}
  &amp;gt;
    {badgeInfo.title}
  &amp;lt;/div&amp;gt;
);
// ✅ 워크북 제목
const Title = ({ title }: Pick&amp;lt;WorkbookClientInfo, &quot;title&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;p className=&quot;body3-bold w-auto truncate py-[2px] text-white&quot;&amp;gt;{title}&amp;lt;/p&amp;gt;
);
// ✅ 작가 이름 리스트
const WriterList = ({ writers }: Pick&amp;lt;WorkbookClientInfo, &quot;writers&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;ul className=&quot;sub3-medium flex gap-1 pb-[10px] text-text-gray2&quot;&amp;gt;
    {writers.map((writer, idx) =&amp;gt; (
      &amp;lt;li key={`workbook-writer-${idx}`}&amp;gt;{writer}&amp;lt;/li&amp;gt;
    ))}
  &amp;lt;/ul&amp;gt;
);
// ✅ 유저의 수와 관련된 컴포넌트 
const PersonCourseWithFewLogo = ({
  personCourse,
}: Pick&amp;lt;WorkbookClientInfo, &quot;personCourse&quot;&amp;gt;) =&amp;gt; (
  &amp;lt;div className=&quot;flex justify-between pb-[26px] pt-[10px]&quot;&amp;gt;
    &amp;lt;span className=&quot;sub3-medium text-text-gray3&quot;&amp;gt;{personCourse}&amp;lt;/span&amp;gt;
    &amp;lt;FewLogo width={20} height={20} fill=&quot;#264932&quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
);
// ✅ 하단 버튼
const BottomButton = ({
  buttonTitle,
  handleClickBottomButton,
}: Pick&amp;lt;WorkbookClientInfo, &quot;buttonTitle&quot;&amp;gt; &amp;amp; {
  handleClickBottomButton: () =&amp;gt; void;
}) =&amp;gt; (
  &amp;lt;Button
    className={cn(
      &quot;sub3-semibold bg-white text-black&quot;,
      &quot;h-fit rounded py-[4.5px]&quot;,
      &quot;hover:bg-white&quot;,
      &quot;focus:bg-white&quot;,
    )}
    type=&quot;button&quot;
    onClick={(e) =&amp;gt; {
      e.stopPropagation();
      handleClickBottomButton();
    }}
  &amp;gt;
    {buttonTitle}
  &amp;lt;/Button&amp;gt;
);
const WorkbookCardDetail = {
  MainImage,
  Title,
  WriterList,
  PersonCourseWithFewLogo,
  BottomButton,
  CardBadge,
};

export default WorkbookCardDetail;&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;pre id=&quot;code_1723201075280&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function WorkbookCard({
  id,
  badgeInfo,
  mainImageUrl,
  metaComponent,
  title,
  writers,
  personCourse,
  buttonTitle,
  cardType,
  articleId,
}: Props)

...
&amp;lt;div&amp;gt;
      &amp;lt;WorkbookCardDetail.ImageWrapper&amp;gt;
        &amp;lt;WorkbookCardDetail.MainImage mainImageUrl={mainImageUrl} /&amp;gt; // ✅ 이미지 썸네일
        &amp;lt;WorkbookCardDetail.CardBadge badgeInfo={badgeInfo} /&amp;gt;		// ✅ 학습 뱃지
      &amp;lt;/WorkbookCardDetail.ImageWrapper&amp;gt;	
      &amp;lt;WorkbookCardDetail.WorkbookDetailInfoWrapper&amp;gt;
        {metaComponent}							// ✅ 카테고리 또는 학습 일자
        &amp;lt;WorkbookCardDetail.Title title={title} /&amp;gt;		// ✅ 워크북 제목
        &amp;lt;WorkbookCardDetail.WriterList writers={writers} /&amp;gt; // ✅ 작가 이름 리스트
        &amp;lt;WorkbookCardDetail.PersonCourseWithFewLogo
          personCourse={personCourse}					// ✅ 학습유저 수 관련
        /&amp;gt;
        &amp;lt;WorkbookCardDetail.BottomButton				// ✅ 하단 버튼
          buttonTitle={buttonTitle}
          handleClickBottomButton={handleButtonClick}
        /&amp;gt;
      &amp;lt;/WorkbookCardDetail.WorkbookDetailInfoWrapper&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;그렇다면 다음 액션은 어떻게 될까? 한번 생각해보면 좋을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 간단하다. 단순하게 각 세부 컴포넌트에 해당하는 props의 값을 상황에 맞는 데이터로 넣어주면 되는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 여기서 Model 계층이 없다면...?&lt;/p&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;amp; 구독 여부에 따른 상수를 기준으로 조건문을 만들어서 분기처리를 컴포넌트 내부에서 진행해야 할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 비즈니스 로직을 Model로 위임하고 컴포넌트는 오로지 View로만 사용할 수 있도록 하는 것이 컴포넌트의 유지/보수성을 높여준다는 것을 활용해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &amp;zwj;  Workbook Card Model&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1️⃣&lt;span&gt;&lt;span&gt;&amp;nbsp; Server와 Client 타입 분리하기&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델의 input은 서버 데이터 타입이지만, output은 WorkbookCard의 Props 데이터 타입이어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 우선, 워크북 리스트 조회 API 응답 데이터 타입과 사용자의 워크북 구독리스트 조회 API 응답 데이터 타입을 각각 정의해두어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1723201937760&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ✅ 워크북 구독 리스트 별 상세 데이터
export interface WorkbookSubscriptionInfo extends Pick&amp;lt;WorkbookInfo, &quot;id&quot;&amp;gt; {
  status: SubscriptionStatus;
  totalDay: number;
  currentDay: number;
  rank: number;
  totalSubscriber: number;
  articleInfo: string; 
}
// ✅ 워크북 리스트 별 상세 데이터
export type WorkbookServerInfo = {
  subscriberCount: number;
} &amp;amp; Omit&amp;lt;WorkbookInfo, &quot;articles&quot; | &quot;name&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;워크북 카드의 Props를 클라이언트 타입으로 정의하면 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1723202004644&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface WorkbookClientInfo {
  id: number;
  mainImageUrl: string;
  metaComponent: React.ReactElement;
  title: string;
  writers: string[];
  personCourse: string;
  buttonTitle: string;
  badgeInfo: HTMLAttributes&amp;lt;HTMLDivElement&amp;gt;;
  cardType: &quot;LEARN&quot; | &quot;SUBSCRIBE&quot; | &quot;SHARE&quot;;
  articleId: string | null;
}&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;그리고 만약 두 개의 API를 호출할 경우 workbook Id를 기준으로 데이터를 우선 병합하여 다시 리스트로 만드는 과정이 필요하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터는 Model 계층을 활용하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2️⃣ contructor 로 서버 데이터를 전달 후 Combine한 데이터로 변환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 정리하면, 워크북 리스트 조회 API 는 유저상태에 관계없이 필수적으로 호출해야 하지만, 워크북 구독 리스트 API 는 로그인한 유저에 한 해서만 호출하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1723202343469&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class WorkbookCardModel {
  constructor({
    initWorkbookSeverList,
    initWorkbookSubscriptionInfoList,
  }: {
    initWorkbookSeverList: WorkbookServerInfo[];
    initWorkbookSubscriptionInfoList?: WorkbookSubscriptionInfo[];
  }) {
    this.workbookList = initWorkbookSeverList;
    if (initWorkbookSubscriptionInfoList)
      this.workbookSubscriptionInfoList = initWorkbookSubscriptionInfoList;
    this.workbookCombineList = this.getWorkbookServerCombineData();
  }
  get workbookCombineListData() {
    return this.workbookCombineList;
  }
  getWorkbookServerCombineData(): WorkbookCombineInfo[] {
    ...
    return this.workbookList;
  }


  private workbookList: WorkbookServerInfo[];
  private workbookSubscriptionInfoList: WorkbookSubscriptionInfo[] | undefined;
  private workbookCombineList: WorkbookCombineInfo[];
}

type WorkbookCombineInfo = WorkbookServerInfo &amp;amp;
  Partial&amp;lt;WorkbookSubscriptionInfo&amp;gt;;
type WorkbookCombineInfoSet = {
  [key: number]:
    | Omit&amp;lt;WorkbookServerInfo, &quot;id&quot;&amp;gt;
    | Omit&amp;lt;Partial&amp;lt;WorkbookSubscriptionInfo&amp;gt;, &quot;id&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;위 처럼 workbookSubscriptionInfoList 는 optional 상태로 생성자를 만들면 되는 상황이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 개의 데이터를 빠르게 병합하기 위해서는 Array형식이 아닌 워크북의 id를 key로 가지는 set 객체를 만들면 이중 반복문을 사용하지 않고 데이터를 병합할 수 있다.&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;따라서 getWorkbookServerComnbineData( ) 함수 내부에서 workbookList, workbookSubscriptionInfoList를 &lt;b&gt;set의 형태&lt;/b&gt;로 변환시켜준다음 이를 다시 array로 만들어주면 컴포넌트에서 데이터를 주입할때 map을 사용할 수 있게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1723202570120&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;transformDataToSet({
    data,
  }: {
    data: WorkbookServerInfo[] | WorkbookSubscriptionInfo[];
  }) {
    return data.reduce&amp;lt;WorkbookCombineInfoSet&amp;gt;((acc, item) =&amp;gt; {
      const { id, ...rest } = item;
      acc[id] = {
        ...rest,
      };
      return acc;
    }, {});
  }&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;set의 형태로 데이터를 만들어주는 함수는 위와 같으며&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1723202617108&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; getWorkbookServerCombineData(): WorkbookCombineInfo[] {
    if (this.workbookSubscriptionInfoList) {
      const workbookCombineSet: WorkbookCombineInfoSet = {};

      const workbookSetList = this.transformDataToSet({
        data: this.workbookList,
      });
      const workbookSetSubscriptionInfoList = this.transformDataToSet({
        data: this.workbookSubscriptionInfoList,
      });

      for (const workbookKey in workbookSetList) {
        const isCommonKey =
          Object.prototype.hasOwnProperty.call(workbookSetList, workbookKey) &amp;amp;&amp;amp;
          Object.prototype.hasOwnProperty.call(
            workbookSetSubscriptionInfoList,
            workbookKey,
          );

        if (isCommonKey) {
          const subscriptionItem = workbookSetSubscriptionInfoList[workbookKey];
          const workbookItem = workbookSetList[workbookKey];

          workbookCombineSet[workbookKey] = {
            ...workbookItem,
            ...subscriptionItem,
          };
        } else {
          const workbookItem = workbookSetList[workbookKey];
          workbookCombineSet[workbookKey] = {
            ...workbookItem,
          };
        }
      }

      return Object.entries(workbookCombineSet).map(([key, value]) =&amp;gt; ({
        id: Number(key),
        ...value,
      })) as WorkbookCombineInfo[];
    }
    return this.workbookList;
  }&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; workbookSubscriptionInfoList가 있다면 set 변환 이후 데이터를 병합하여 Object를 만드는 과정을 거치고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;workbookSubscriptionInfoList가 없다면 단일 데이터인 workbookList를 반환해주면 된다.&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;3️⃣ Workbook Card 세부 컴포넌트 Data 만들고 주입하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;합쳐진 ServerCombine 배열을 기반으로 이제 client에 맞는 객체를 만들어 새로운 배열을 만들어주기만 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1723204045480&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export class WorkbookCardModel {
  constructor({
    initWorkbookSeverList,
    initWorkbookSubscriptionInfoList,
  }: {
    initWorkbookSeverList: WorkbookServerInfo[];
    initWorkbookSubscriptionInfoList?: WorkbookSubscriptionInfo[];
  }) {
    ..생략
  }
  
  getWorkbookServerCombineData(): WorkbookCombineInfo[] {
    ...생략
  }

  workbookCardList({
    workbookCombineList,
  }: {
    workbookCombineList: WorkbookCombineInfo[];
  }): WorkbookClientInfo[] { // ✅ WorkbookCard Props 형태로 반환 === 모델의 결과값
    return workbookCombineList.map(
      ({
        id,
        mainImageUrl,
        title,
        description,
        category,
        writers,
        subscriberCount,
        status,
        currentDay,
        totalDay,
        articleInfo,
      }) =&amp;gt; {
     
        const cardType = this.getWorkbookCardType({ status, currentDay });
        const changeToClientData: WorkbookClientInfo = {
          id,
          badgeInfo: this.getBadeInfo({ cardType }),
            ... 클라이언트 타입으로 가공
        };
        };
        return changeToClientData;
      },
    );
   getWorkbookCardType({
   	 status,
   	 currentDay,
 	 }: {
    	status: WorkbookSubscriptionInfo[&quot;status&quot;] | undefined;
    	currentDay: WorkbookSubscriptionInfo[&quot;currentDay&quot;] | undefined;
  	}): WorkbookClientInfo[&quot;cardType&quot;] {
    	if (status &amp;amp;&amp;amp; currentDay) {
    	  if (status === &quot;ACTIVE&quot;) return &quot;LEARN&quot;;
     	 else return &quot;SHARE&quot;;
    	}
    	return &quot;SUBSCRIBE&quot;;
  	}
    
   getBadeInfo({
    cardType,
  	}: Pick&amp;lt;WorkbookClientInfo, &quot;cardType&quot;&amp;gt;): WorkbookClientInfo[&quot;badgeInfo&quot;] {
    switch (cardType) {
      case &quot;LEARN&quot;:
        return {
          title: &quot;현재 학습중&quot;,
          className: &quot;text-text-gray1 bg-[#f5f5f5]&quot;,
        };
      case &quot;SHARE&quot;:
        return {
          title: &quot;학습완료&quot;,
          className: &quot;bg-success text-white text-[10px]&quot;,
        };
      default:
        return {};
    	}
  	}
  }
  

  private workbookList: WorkbookServerInfo[];
  private workbookSubscriptionInfoList: WorkbookSubscriptionInfo[] | undefined;
  private workbookCombineList: WorkbookCombineInfo[];
}&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;WorkbookCard props의 배열 형태로 workbookCardList를 사용하면,&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1723204334313&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function WorkbookCardList({
  code,
}: Partial&amp;lt;CategoryClientInfo&amp;gt;) {
  const isLogin = useIsLogin();

  const workbookCardList = useQueries({
    queries: [
      getWorkbooksWithCategoryQueryOptions({
        code: code !== undefined ? code : ENTIRE_CATEGORY,
      }),
      {
        ...getSubscriptionWorkbooksQueryOptions(),
        enabled: isLogin === true,   // ✅ enabled로 query 조건분기
      },
    ],
    combine: (result) =&amp;gt; {
    	  // 1. 워크북 리스트 		2. 워크북 구독 리스트 
      const [workbookServerList, workbookSubscriptionInfoList] = result;
      if (workbookServerList.data) {
        const workbookCardModel = new WorkbookCardModel({
          initWorkbookSeverList: workbookServerList.data,
          initWorkbookSubscriptionInfoList: workbookSubscriptionInfoList.data,
        });
        // ✅ 모델을 통한 client card List 반환 
        return workbookCardModel.workbookCardList({
          workbookCombineList: workbookCardModel.workbookCombineListData,
        });
      }
    },
  });

  if (!workbookCardList) return &amp;lt;WorkbookCardListSkeleton /&amp;gt;;
  
// ✨ map을 통한 워크북 카드 렌더링
  return (
    &amp;lt;section className=&quot;mr-[18px] flex gap-[8px] overflow-x-auto&quot;&amp;gt;
      {workbookCardList.map((data, idx) =&amp;gt; (
        &amp;lt;WorkbookCard key={`work-book-card-${idx}`} {...data} /&amp;gt;
      ))}
    &amp;lt;/section&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;위와 같이 Map으로 상태에 맞는 데이터를 뿌려줄 수 있게 된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  최종 layer 설계도&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설명을 위해서 workbookcard 에 대한 모델만 다루어보았지만, &lt;br /&gt;Few 프로젝트에서 사용하고 있는 layer방식은 아래의 계층 중 우측에 해당하는 구조를 가지고 있다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;808&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eM0CJa/btsI0dCgp8N/vMgzEKH8Q500yAwt0QpCwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eM0CJa/btsI0dCgp8N/vMgzEKH8Q500yAwt0QpCwK/img.png&quot; data-alt=&quot;좌 : api 결과값을 그대로 사용 / 우 : data model에서 로직 제어 가능&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eM0CJa/btsI0dCgp8N/vMgzEKH8Q500yAwt0QpCwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeM0CJa%2FbtsI0dCgp8N%2FvMgzEKH8Q500yAwt0QpCwK%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;1886&quot; height=&quot;808&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;808&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;좌 : api 결과값을 그대로 사용 / 우 : data model에서 로직 제어 가능&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌측처럼 layer를 2개의 계층으로 가진 경우에는 디자인이 변경되지 않더라도, API의 변경사항이 발생하면 component도 함께 수정해야 하는 과정이 생길 것이다.&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 style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;layer를 3개의 계층&lt;/b&gt;&lt;/span&gt;으로 만들게 되면 똑같은 상황에서 component를 &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;직접 수정하는 것이 아니라 model을 수정&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: #ef5369;&quot;&gt;&lt;b&gt;model 계층을 component 위에 만들어주게 되면, 컴포넌트는 정말 view의 역할로만 사용하는 결과를 만들어내는 설계 구조&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;style8&quot; /&gt;
&lt;h2 style=&quot;text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;  결론&lt;/h2&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트와 서버 계층 사이에 레이어를 두는 설계를 통해 API 스펙에 의존적이지 않고, 클라이언트 환경에서 컴포넌트를 쉽게 관리하는 방법을 사용하는 것( ex. 컴파운드 패턴 기반의 컴포넌트 ) 이 가능해졌다.&amp;nbsp;&lt;br /&gt;나중에 API 스펙이 변경되더라도 클라이언트 환경을 고치는 것이 아닌 그 중간 계층의 레이어를 수정하는 방향으로 작업을 진행하면 되는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 통해 프론트엔드 개발자에게도 초기 설계는 중요하며, 컴포넌트 안에 비즈니스 로직을 넣는 과정이 늘어날수록 버그가 발생했을 때 추적하기 어려울 것이며 발전하는 요구사항에 대응하는 것도 시간이 오래 걸린다는 것을 코드로 체감할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;중요한 사실은 API의 응답 계층이 컴포넌트에 직접 영향을 주는 설계를 피하고, &lt;span style=&quot;color: #ef5369;&quot;&gt;&lt;b&gt;서버와 컴포넌트 사이에 반드시 중간 계층&lt;/b&gt;&lt;/span&gt;(윗글에서는 model ) 을 두는 것이 유연한 컴포넌트로 개발하는 방향성이라는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;추가로 위 프로젝트에서 개선할 부분은 model을 tsx와 ts 확장자를 혼용해서 쓰고 있다는 부분이 있기에 온전히 class 형태의 ts 파일로 비즈니스 로직에 대한 결괏값을 반환하는 model로 통일성 있게 관리하는 것으로 구조를 잡아나가야 할 것 같다.&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>WEB/Insight</category>
      <author>Happhee.dev</author>
      <guid isPermaLink="true">https://happhee-dev.tistory.com/43</guid>
      <comments>https://happhee-dev.tistory.com/43#entry43comment</comments>
      <pubDate>Sun, 11 Aug 2024 22:00:25 +0900</pubDate>
    </item>
  </channel>
</rss>