<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>moyang</title>
    <link>https://marklee1117.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 14 Apr 2026 19:51:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>moyanglee</managingEditor>
    <image>
      <title>moyang</title>
      <url>https://tistory1.daumcdn.net/tistory/5319515/attach/563e0a3c610d43fbb875a08dfb97a0b6</url>
      <link>https://marklee1117.tistory.com</link>
    </image>
    <item>
      <title>대시보드 대용량 데이터 처리(feat.웹 압축 기술)</title>
      <link>https://marklee1117.tistory.com/185</link>
      <description>&lt;p data-end=&quot;357&quot; data-start=&quot;295&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;357&quot; data-start=&quot;295&quot; data-ke-size=&quot;size16&quot;&gt;회사에서 서비스 현황을 한눈에 볼 수 있는 대시보드를 만들게 되었다.&lt;/p&gt;
&lt;p data-end=&quot;357&quot; data-start=&quot;295&quot; data-ke-size=&quot;size16&quot;&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 data-end=&quot;357&quot; data-start=&quot;295&quot;&gt;DB 쿼리 레벨의 집계와 설계&lt;/li&gt;
&lt;li data-end=&quot;357&quot; data-start=&quot;295&quot;&gt;YouTube 대시보드 요청 쿼리 구조&lt;/li&gt;
&lt;li data-end=&quot;357&quot; data-start=&quot;295&quot;&gt;백엔드&amp;ndash;프론트엔드 간 효율적인 네트워크 통신&lt;/li&gt;
&lt;li data-end=&quot;357&quot; data-start=&quot;295&quot;&gt;실제 사용자 관점에서의 UX 개선&lt;/li&gt;
&lt;/ol&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-end=&quot;357&quot; data-start=&quot;295&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;대시보드 요구 사항&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;357&quot; data-start=&quot;295&quot; data-ke-size=&quot;size16&quot;&gt;처음 요구사항은 비교적 단순해 보였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;474&quot; data-start=&quot;359&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-start=&quot;383&quot; data-end=&quot;402&quot;&gt;달력을 통한 자유로운 기간 선택&lt;/li&gt;
&lt;li data-start=&quot;403&quot; data-end=&quot;448&quot;&gt;집계 단위 선택 (Hour / Day / Week / Month / Year)&lt;/li&gt;
&lt;li data-end=&quot;382&quot; data-start=&quot;359&quot;&gt;멀티 리전/여러 고객사(프로젝트) 지원&lt;/li&gt;
&lt;li data-end=&quot;474&quot; data-start=&quot;449&quot;&gt;각 집계 단위마다 표출할 수 있는 데이터 최대 수량을 지정 할 수 있어야 한다.(e.g. 시간 단위 선택시 최근 24시간 표출)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;그런데 꼭 기간 마다 표출할 수 있는 데이터의 최대 수량을 지정하는게 필요할까 라는 생각과, 만약 그렇게 표출하는 데이터의 최대 수량을 지정하게 되면 1번 요구사항과 상충된다고 생각이 들었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 내가 &lt;b&gt;기간은 3년 + 집계 단위를 시간으로 설정&lt;/b&gt;하면, 실제로 보여주는 건 최근 24시간만 보여준다. 이상하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;더군다나 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;대시보드 상단에 선택한 기간에 대한 Total Count가 표시되는 부분이 있는데, 차트는 최근 24시간, 상단 카드에는 선택한 기간 그대로 표시해야 하나?&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;2250&quot; data-origin-height=&quot;1176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhHdMT/dJMcac9oGbk/QmqM6yFwnHwa7d0od6AEg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhHdMT/dJMcac9oGbk/QmqM6yFwnHwa7d0od6AEg1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhHdMT/dJMcac9oGbk/QmqM6yFwnHwa7d0od6AEg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhHdMT%2FdJMcac9oGbk%2FQmqM6yFwnHwa7d0od6AEg1%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;542&quot; height=&quot;283&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;1176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;그럼 그냥 데이터 전부를 보여주면 어떨까? 3년 선택하고 Day로 집계를 하게 되면 아래처럼 X축 라벨이 엉망으로 나오게 될 텐데...?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1796&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEsFM9%2FdJMcaiPjfAC%2FV5Ihr0TkjePgG8huFRmTKk%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;532&quot; height=&quot;302&quot; data-origin-width=&quot;1796&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h2 data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;YouTube 대시보드 분석 - UX&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;우선 가장 먼저 떠오르는 고도화된 대시보드인 YouTube의 대시보드를 보기로 했다.&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;2524&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/86WnH/dJMcaaKygmY/SnNoKbGBiFwfxKgA6eK7SK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/86WnH/dJMcaaKygmY/SnNoKbGBiFwfxKgA6eK7SK/img.png&quot; data-alt=&quot;YouTube 컨텐츠 조회 고급 분석 대시보드 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/86WnH/dJMcaaKygmY/SnNoKbGBiFwfxKgA6eK7SK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F86WnH%2FdJMcaaKygmY%2FSnNoKbGBiFwfxKgA6eK7SK%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;691&quot; height=&quot;175&quot; data-origin-width=&quot;2524&quot; data-origin-height=&quot;640&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube 컨텐츠 조회 고급 분석 대시보드 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;우선 먼저 문제 하나는 해결됐다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;&quot;데이터를 다 보여주되, X축에는 가시성이 좋을 정도로만 Label을 표시하자&quot;&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;따라서 요구사항 4번 부분을 빼기로 하고 &lt;b&gt;총 3개의 요구사항&lt;/b&gt;을 만족시키는 대시보드를 구현해보기로 했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;476&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;문득, 이 생각이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;10년치의 데이터를 일별 집계로 들고 오면 어마어마한 데이터일텐데 이걸 YouTube는 어떻게 처리할까?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; 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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-start=&quot;476&quot; data-end=&quot;619&quot;&gt;&lt;b&gt;YouTube 대시보드 분석 - Request/Response 구조&lt;/b&gt;&lt;/h2&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;개발자 모드에서 Request와 Response를 까봤다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;아래 사진은 유튜브가 대시보드 화면을 구성하기 위해 보낸 요청의 Payload 구조이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;내가 생각한 것과 너무 달랐다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;우선, 왜 nodes 배열로 관리를 할까? 왜 Object 형태가 가독성, 접근성 모두 좋을텐데? 라는 생각이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;그러나 아래 connectors를 보는 순간 &lt;b&gt;그래프인가!?&lt;/b&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;1048&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIcpSw/dJMcahQrBYm/JeNHEgcbKXbJSgHLbEMEA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIcpSw/dJMcahQrBYm/JeNHEgcbKXbJSgHLbEMEA0/img.png&quot; data-alt=&quot;YouTube Request Payload 전체구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIcpSw/dJMcahQrBYm/JeNHEgcbKXbJSgHLbEMEA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIcpSw%2FdJMcahQrBYm%2FJeNHEgcbKXbJSgHLbEMEA0%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;428&quot; height=&quot;577&quot; data-origin-width=&quot;1048&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube Request Payload 전체구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;connectors 부분만 잠깐 설명을 하자면,&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;extractorParams&lt;/b&gt;는 &lt;u&gt;&lt;b&gt;어떤 쿼리 노드의 결과에서 뭘 뽑아올지 정의하는 부분&lt;/b&gt;&lt;/u&gt;이고,&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;fillerParams&lt;/b&gt;은 &lt;u&gt;&lt;b&gt;뽑아온 값으로 어떤 노드의 쿼리/ID를 채울지 정의하는 부분&lt;/b&gt;&lt;/u&gt; 이다.&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, extractorParams의&amp;nbsp;&lt;b&gt;2__TOP_ENTITIES_TABLE_QUERY_KEY&lt;/b&gt; 라는 쿼리 노드는 영상의 조회수, 조회 시간 등 각종 데이터를 테이블 형태로 나오는 쿼리이다. 그래서 이 테이블 결과에서 VIDEO 디멘션 값들(VIDEO ID) 추출해라. 라는 뜻이고,&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;fillerParams는 뽑아낸 VIDEO ID들을 &lt;b&gt;2__TOP_ENTITIES_CHARTS_QUERY_KEY&lt;/b&gt; 조회할 때 채워라. &lt;br /&gt;거기에도 { &quot;dimension&quot;: { &quot;type&quot;: &quot;VIDEO&quot; } }라고 되어 있으니까, 이 video ID들을 filller로 넣어라. 라는 뜻이다.&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;그랬다. YouTube는 &lt;b&gt;한번의 요청으로 대시보드 화면을 구성하기 위해, 위와 같이 그래프 형태의 페이로드&lt;/b&gt;&amp;nbsp;구조로 요청을 보내고 있었다.&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;재밌다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;이제 응답 구조를 한번 보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;4718&quot; data-start=&quot;4625&quot; data-ke-size=&quot;size16&quot;&gt;앞서 요청 페이로드에 있었던 nodes 배열과 흡사하게 results 값 역시 배열로 구성되어 있다.&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;1304&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6YZZX/dJMb99ZaZS7/CUGKCFGcq4RyDUMIvmtzhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6YZZX/dJMb99ZaZS7/CUGKCFGcq4RyDUMIvmtzhK/img.png&quot; data-alt=&quot;YouTube Response 전체구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6YZZX/dJMb99ZaZS7/CUGKCFGcq4RyDUMIvmtzhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6YZZX%2FdJMb99ZaZS7%2FCUGKCFGcq4RyDUMIvmtzhK%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;502&quot; height=&quot;280&quot; data-origin-width=&quot;1304&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube Response 전체구조&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;우리가 집중할 부분은 Chart 부분이니 차트 관련 쿼리만 자세히 보도록 한다.&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;1332&quot; data-origin-height=&quot;1882&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XCFYu/dJMcahiBHXj/PZQahagLswErYooiAF1MuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XCFYu/dJMcahiBHXj/PZQahagLswErYooiAF1MuK/img.png&quot; data-alt=&quot;YouTube Response - Chart 쿼리 응답 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XCFYu/dJMcahiBHXj/PZQahagLswErYooiAF1MuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXCFYu%2FdJMcahiBHXj%2FPZQahagLswErYooiAF1MuK%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;406&quot; height=&quot;574&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;1882&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube Response - Chart 쿼리 응답 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 쿼리의 응답 구조는 크게 3부분으로 나누어져 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;dimensionColumns의 dateIds&lt;/b&gt;, &lt;b&gt;dimensionColumns의 strings.values&lt;/b&gt;, &lt;b&gt;metricColumns의 counts.values&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;dimensionColumns&lt;/b&gt;는 내가 조회한 기간을 의미한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dimensionColumns의 strings.values&lt;/b&gt;는 영상의 고유한 아이디 이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;metricColumns의 counts.values&lt;/b&gt;&lt;b&gt;는 실제 조회수 이다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 각각의 0번째 Index 값들을 조회하면 20251128년도 Er... 영상의 조회수는 0이었다. 라는 것을 알 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저 데이터 구조를 보니 &lt;b&gt;차트에 바로 넣기 최적화된 구조&lt;/b&gt;라는 것을 알 수 있었다.&lt;br /&gt;아! &lt;u&gt;&lt;b&gt;프론트엔드 부하를 줄이기 위해 서버쪽에서 차트에 넣기 최적화된 구조로 파싱해서 내려주고 있구나. 라고 생각&lt;/b&gt;&lt;/u&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;b&gt;YouTube의 대용량 데이터 처리 방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 문제점은 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;중복된 데이터들&lt;/b&gt;&lt;/span&gt; 이었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 나는 조회기간을 매우 짧게 영상도 1개에 대해 조회한 것이지만 만약 영상 100개, 3년간의 일자별 데이터를 조회하면?&lt;br /&gt;그럼 데이터 칼럼 부분은 100개 * 3년 * 365 개의 어마어마한 중복된 데이터를 보내게 된다.&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;결론적으로 &lt;b&gt;YouTube는 Brotli라는 압축 기술을 쓰고 있다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 사진에서 Content-Encoding: br 이라는 부분이 해당 응답은 Brotli로 압축해서 내려온 데이터다 라는 것을 보여주는 항목이다.&lt;br /&gt;특히, Brotli는 &lt;b&gt;더 큰 윈도우 사이즈를 활용해 중복된 데이터를 매우 효율적으로 압축할 수 있다는 점에서 강점을 가진 압축 알고리즘이다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;900&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cezYbO/dJMcaiaKuX2/onBh0sah51vvA7S8Uk9nx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cezYbO/dJMcaiaKuX2/onBh0sah51vvA7S8Uk9nx0/img.png&quot; data-alt=&quot;YouTube 요청/응답 헤더&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cezYbO/dJMcaiaKuX2/onBh0sah51vvA7S8Uk9nx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcezYbO%2FdJMcaiaKuX2%2FonBh0sah51vvA7S8Uk9nx0%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;470&quot; height=&quot;297&quot; data-origin-width=&quot;1424&quot; data-origin-height=&quot;900&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube 요청/응답 헤더&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;사실 부끄럽지만, Brotli는 처음 들어봤다. &lt;br /&gt;하지만 당연히 gzip은 들어봤기에 두개의 압축기술을 비교하며 정리해봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2524&quot; data-origin-height=&quot;554&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3iJdo/dJMcacuP2VO/GYcX0ynrPIY21M0OKTI6U0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3iJdo/dJMcacuP2VO/GYcX0ynrPIY21M0OKTI6U0/img.png&quot; data-alt=&quot;Brotli vs Gzip&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3iJdo/dJMcacuP2VO/GYcX0ynrPIY21M0OKTI6U0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3iJdo%2FdJMcacuP2VO%2FGYcX0ynrPIY21M0OKTI6U0%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;683&quot; height=&quot;150&quot; data-origin-width=&quot;2524&quot; data-origin-height=&quot;554&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Brotli vs Gzip&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 Brotli가 Gzip보다 압축효율이 15~20%정도 좋다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히나 중복된 데이터를 검사하는 윈도우 사이즈가 gzip보다 훨씬 크기 때문에 훨씬 효율적으로 압축을 진행할 수 있다.&amp;nbsp;&lt;br /&gt;사실 거의 모든 면에서 Brotli가 좋아보이지만, &lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;IE를 지원하지 않는다&lt;/span&gt;는 단점이 존재&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;b&gt;우리 제품 특성상&lt;/b&gt;&lt;span&gt;&lt;b&gt; gzip을 사용하기로 했다&lt;/b&gt;. &lt;br /&gt;&lt;b&gt;KeyLink에서 2~3년간의 일자별 데이터를 조회 했을 때, 데이터의 크기가 일반적으로 gzip의 윈도우 사이즈(32kb)보다 작은 것을 확인&lt;/b&gt;했고, 크게 문제되지 않을 것이라 판단했다.&amp;nbsp;&lt;/span&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 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;YouTube 분석 결과 및 적용 포인트&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;그래서 결론적으로 YouTube 대시보드를 분석/KeyLink에 적용한한 결과는 아래와 같았다.&lt;/span&gt;&lt;/span&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;b&gt;프론트 엔드 부하 최소화&amp;nbsp;&lt;/b&gt;&lt;br /&gt;프론트에서는 최소한의 작업으로 차트 렌더링 할 수 있도록 서버가 가공된 데이터를 전달 해주고 있었다.&amp;nbsp;&lt;br /&gt;그래프 구조의 쿼리가 멋있어 보였지만, 현재 우리 제품의 레벨에서는 오버엔지니어링이란 판단을 내렸고,&lt;br /&gt;우선, &lt;u&gt;프론트에서 바로 차트에 데이터를 넣어 렌더링 할수 있도록 아래처럼 구조를 설계&lt;/u&gt;하였다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Brotli 와 같은 웹 압축기술 사용&lt;/b&gt;&lt;br /&gt;앞서 언급한 것처럼 제품이 납품되는 사이트 특성상 &lt;u&gt;gzip을 적용&lt;/u&gt;하기로 결정했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI/UX 포인트&amp;nbsp;&lt;/b&gt;&lt;br /&gt;&lt;u&gt;선택된 기간의 데이터를 전부 보여주되, 사용자가 보기 좋게 X축 라벨을 일부 생략하여 보여주기로 결정했다.&lt;/u&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;3368&quot; data-origin-height=&quot;1282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTHS7e/dJMcagjHh80/TqtxQRPLfz2MrkMGKp0Fn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTHS7e/dJMcagjHh80/TqtxQRPLfz2MrkMGKp0Fn1/img.png&quot; data-alt=&quot;YouTube 분석 결과 및 응답 구조 재설계&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTHS7e/dJMcagjHh80/TqtxQRPLfz2MrkMGKp0Fn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTHS7e%2FdJMcagjHh80%2FTqtxQRPLfz2MrkMGKp0Fn1%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;579&quot; height=&quot;220&quot; data-origin-width=&quot;3368&quot; data-origin-height=&quot;1282&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;YouTube 분석 결과 및 응답 구조 재설계&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;&lt;b&gt;gzip 적용 및 성능 테스트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 gzip을 Spring서버에 application.yml에서 아래처럼 간단히 세팅했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nDN4Q/dJMcadf9QkI/xY7EFPkPRB3QX2cqoYUBE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nDN4Q/dJMcadf9QkI/xY7EFPkPRB3QX2cqoYUBE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nDN4Q/dJMcadf9QkI/xY7EFPkPRB3QX2cqoYUBE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnDN4Q%2FdJMcadf9QkI%2FxY7EFPkPRB3QX2cqoYUBE0%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;613&quot; height=&quot;67&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;mime-types: 압축할 대상 타입을 지정&lt;/li&gt;
&lt;li&gt;min-request-size: 압축할 응답 최소 사이즈 지정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 응답에 대해 압축을 진행하게 되면 오버헤드가 더 커질 것이므로, min-request-size를 통해 1kb이상의 응답에 대해서만 압축을 진행하도록 세팅하였다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1946&quot; data-origin-height=&quot;1136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q4JKE/dJMcaiaKu46/pPIZnpbVBaEVSbhPd6OgGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q4JKE/dJMcaiaKu46/pPIZnpbVBaEVSbhPd6OgGK/img.png&quot; data-alt=&quot;gzip 성능 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q4JKE/dJMcaiaKu46/pPIZnpbVBaEVSbhPd6OgGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq4JKE%2FdJMcaiaKu46%2FpPIZnpbVBaEVSbhPd6OgGK%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;565&quot; height=&quot;330&quot; data-origin-width=&quot;1946&quot; data-origin-height=&quot;1136&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;gzip 성능 테스트&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;네트워크 전송 용량은 96%가 획기적으로 감소&lt;/b&gt;했다. 하지만 &lt;b&gt;응답속도의 변화는 미미&lt;/b&gt;했다.&amp;nbsp;&lt;br /&gt;용량이 저렇게 획기적으로 줄었다는 것도 충분히 획기적인 성능 개선이지만, UX를 개선하고 싶었던 내 입장에선 조금 아쉬웠고 이유를 파고 들었다.&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;color: #333333; text-align: start;&quot;&gt;원인은 테스트 환경에 있었다. 테스트 진행시 로컬에 띄운 서버에서 테스트를 진행했고, 로컬에서 진행하다 보니 전체 소요시간에서 네트워크 전송시간이 차지하는 비율이 매우 작았다. 따라서 줄어든 네트워크 전송시간이 압축/해제 시간에 의해 상쇄됐던 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;1068&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJcyLi/dJMcafd1Lm4/5kdxeGsopoYqk8DBsxHO71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJcyLi/dJMcafd1Lm4/5kdxeGsopoYqk8DBsxHO71/img.png&quot; data-alt=&quot;gzip 적용 후, 전송시간 변화 원인 분석&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJcyLi/dJMcafd1Lm4/5kdxeGsopoYqk8DBsxHO71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJcyLi%2FdJMcafd1Lm4%2F5kdxeGsopoYqk8DBsxHO71%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;487&quot; height=&quot;288&quot; data-origin-width=&quot;1808&quot; data-origin-height=&quot;1068&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;gzip 적용 후, 전송시간 변화 원인 분석&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;하지만, 일반적인 네트워크환경이나 조금만 안좋은 네트워크 환경에서는 그 시간이 획기적으로 줄어들 것으로 생각되며, 사실 네트워크 전송 데이터량을 96%나 줄이는 것만으로도 웹 압축기술은 반드시 도입해야할 기술이라고 생각이 된다.&lt;/span&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 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;집계 테이블 리팩토링 및 쿼리 최적화&lt;/b&gt;&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;기존 집계 테이블 구조엔 yy, mm, dd, hh, week와 같이 집계 시점을 저장하는 필드들이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 설계하던 당시엔 Index만 제대로 걸어 놓는다면 어떤 시점의 데이터를 들고오기 좋은 구조라고 생각했다.&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;620&quot; data-origin-height=&quot;294&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEC9UM/dJMcahQrCld/XWWucKKGIKswW4B3kx1dpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEC9UM/dJMcahQrCld/XWWucKKGIKswW4B3kx1dpK/img.png&quot; data-alt=&quot;DB 스키마 변경(전)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEC9UM/dJMcahQrCld/XWWucKKGIKswW4B3kx1dpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEC9UM%2FdJMcahQrCld%2FXWWucKKGIKswW4B3kx1dpK%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;272&quot; height=&quot;129&quot; data-origin-width=&quot;620&quot; data-origin-height=&quot;294&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DB 스키마 변경(전)&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;저렇게 각 필드로 저장된 값을 조합해서 기간으로 검색하려고 하니 각 필드를 CONCAT해서 String을 비교 연산자를 통해 비교하게 되었다.&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;집계 기준시간을 event_time 이라는 필드에 저장하기로 했다.&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;1264&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czr7Zf/dJMcai9BGID/7O1VqQxo3y3MPrFRJKUB00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czr7Zf/dJMcai9BGID/7O1VqQxo3y3MPrFRJKUB00/img.png&quot; data-alt=&quot;DB 스키마 변경(후)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czr7Zf/dJMcai9BGID/7O1VqQxo3y3MPrFRJKUB00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fczr7Zf%2FdJMcai9BGID%2F7O1VqQxo3y3MPrFRJKUB00%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;338&quot; height=&quot;101&quot; data-origin-width=&quot;1264&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DB 스키마 변경(후)&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;참고로 해당 필드를 String으로 저장할지 datetime으로 저장할지 고민하다가&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DATETIME 타입이 저장용량도 적을 뿐더러 비교할 때에도 String은 한글자씩 비교하지만, DATETIME은 숫자로 저장되기에 비교 속도마저 빠르기 때문에, 훨씬 적합한 필드 타입이라 생각이 되어 DATETIME 타입으로 결정했다.&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;b&gt;UX 개선(feat. chart.js 커스텀 함수)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 마지막으로 UX 개선을 하기로 했다.&amp;nbsp;&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-origin-width=&quot;1796&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot; data-alt=&quot;수많은 라벨이 X축에 쌓여있는 차트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EsFM9/dJMcaiPjfAC/V5Ihr0TkjePgG8huFRmTKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEsFM9%2FdJMcaiPjfAC%2FV5Ihr0TkjePgG8huFRmTKk%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;532&quot; height=&quot;302&quot; data-origin-width=&quot;1796&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수많은 라벨이 X축에 쌓여있는 차트&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;Canvas에 기반한 Chart.js 라이브러리를 사용하고 있었는데, &lt;br /&gt;다행히 해당 라이브러리에서 AutoSkip이라는 옵션을 제공해주고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, &lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;항상 그렇듯 문제가 있었다. 마지막 라벨이 출력이 안된다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Cw7MK/dJMcafkNbGK/hC0sDph94Pml8myWvMVY3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Cw7MK/dJMcafkNbGK/hC0sDph94Pml8myWvMVY3K/img.png&quot; data-alt=&quot;chart.js - 마지막 라벨 출력 문제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Cw7MK/dJMcafkNbGK/hC0sDph94Pml8myWvMVY3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCw7MK%2FdJMcafkNbGK%2FhC0sDph94Pml8myWvMVY3K%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;501&quot; height=&quot;249&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;chart.js - 마지막 라벨 출력 문제&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;찾아보니 라벨을 auto skip할 때 앞에서 하나씩 채워 나가기 때문에, 이런 문제가 생기며 chart.js의 고질적인 문제였다.&amp;nbsp;&lt;br /&gt;다행히 label을 렌더링 하는 callback 함수를 세팅하는 옵션을 제공해 주었기에 직접 구현하기로 했다.&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;X축에서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;최대 8개 정도의&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;1470&quot; data-origin-height=&quot;1390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dXIVXb/dJMcaaX5Dci/4gPmOUiXPRP51evB16ioRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dXIVXb/dJMcaaX5Dci/4gPmOUiXPRP51evB16ioRk/img.png&quot; data-alt=&quot;chart.js ticks callback 구현&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dXIVXb/dJMcaaX5Dci/4gPmOUiXPRP51evB16ioRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdXIVXb%2FdJMcaaX5Dci%2F4gPmOUiXPRP51evB16ioRk%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;474&quot; height=&quot;448&quot; data-origin-width=&quot;1470&quot; data-origin-height=&quot;1390&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;chart.js ticks callback 구현&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;&lt;b&gt;최종 회고&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 대시보드를 구현하는 과정은 정말 즐거운 경험이었다.&lt;/p&gt;
&lt;p data-end=&quot;131&quot; data-start=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;특히 YouTube가 대용량 데이터를 어떻게 처리하는지, 왜 그런 복잡한 쿼리 구조와 응답 포맷을 선택했는지 하나씩 뜯어보는 과정이 무척 흥미로웠다. 이 프로젝트를 통해&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;241&quot; data-start=&quot;132&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;153&quot; data-start=&quot;132&quot;&gt;DB 쿼리 레벨의 집계와 설계,&lt;/li&gt;
&lt;li data-end=&quot;181&quot; data-start=&quot;154&quot;&gt;백엔드&amp;ndash;프론트엔드 간 네트워크 통신 구조,&lt;/li&gt;
&lt;li data-end=&quot;241&quot; data-start=&quot;182&quot;&gt;실제 사용자 관점에서의 UX 개선&lt;br /&gt;까지 한 번에 고민해볼 수 있었던 점이 개인적으로 큰 배움이었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;403&quot; data-start=&quot;243&quot; data-ke-size=&quot;size16&quot;&gt;무엇보다도 이 경험을 나만의 경험으로 끝내지 않고, 사내 동료들과 세미나 형식으로 공유한 것이 뜻깊었다. 발표를 통해 내가 설계한 방향이 타당한지 검증받을 수 있었고, 동시에 동료들이 제안해준 다른 관점과 아이디어를 접하면서 내가 미처 보지 못했던 부분도 새롭게 생각해볼 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;479&quot; data-start=&quot;405&quot; data-ke-size=&quot;size16&quot;&gt;이 대시보드 프로젝트는 단순한 기능 개발을 넘어, &amp;ldquo;좋은 구조란 무엇인가&amp;rdquo;를 함께 고민하고 성장할 수 있었던 소중한 경험으로 남았다.&lt;/p&gt;</description>
      <category>java,springboot</category>
      <category>brotli</category>
      <category>gzip</category>
      <category>YouTube대시보드</category>
      <category>개발회고</category>
      <category>대시보드</category>
      <category>대용량</category>
      <category>사내세미나</category>
      <category>웹 압축</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/185</guid>
      <comments>https://marklee1117.tistory.com/185#entry185comment</comments>
      <pubDate>Sat, 6 Dec 2025 09:53:49 +0900</pubDate>
    </item>
    <item>
      <title>[R2DBC] MariaDB Bulk Insert 성능 23배 개선: Driver의 한계를 넘자</title>
      <link>https://marklee1117.tistory.com/184</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring WebFlux + R2DBC + MariaDB 조합으로 서비스를 개발하다 보면, &lt;b&gt;대량 INSERT&lt;/b&gt;가 필요한 구간에서 성능 이슈를 마주하게 된다. 특히 &amp;ldquo;키 생성&amp;rdquo;, &amp;ldquo;로그 적재&amp;rdquo;, &amp;ldquo;배치성 데이터 적재&amp;rdquo;처럼 한 번에 수천~수만 건을 넣어야 하는 상황에서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;484&quot; data-start=&quot;411&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;445&quot; data-start=&quot;411&quot;&gt;Reactive니까 알아서 빠르겠지 라는 기대와 달리,&lt;/li&gt;
&lt;li data-end=&quot;484&quot; data-start=&quot;446&quot;&gt;실제로는 &lt;b&gt;몇 분 단위로 시간이 걸리는&lt;/b&gt; 경우를 겪게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;490&quot; data-start=&quot;486&quot; data-ke-size=&quot;size16&quot;&gt;이 글은&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;660&quot; data-start=&quot;492&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;535&quot; data-start=&quot;492&quot;&gt;&lt;b&gt;Spring WebFlux + R2DBC + MariaDB 환경&lt;/b&gt;에서&lt;/li&gt;
&lt;li data-end=&quot;571&quot; data-start=&quot;536&quot;&gt;대량 INSERT 성능이 잘 나오지 않았던 원인을 분석하고,&lt;/li&gt;
&lt;li data-end=&quot;621&quot; data-start=&quot;572&quot;&gt;&lt;b&gt;다중 VALUES 기반 Bulk Insert 유틸리티&lt;/b&gt;로 성능을 개선한 사례와,&lt;/li&gt;
&lt;li data-end=&quot;660&quot; data-start=&quot;622&quot;&gt;그 과정에서 고려해야 할 &lt;b&gt;보안&amp;middot;유지보수 관점의 트레이드오프&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;675&quot; data-start=&quot;662&quot; data-ke-size=&quot;size16&quot;&gt;까지 정리한 기록이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-end=&quot;675&quot; data-start=&quot;662&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;675&quot; data-start=&quot;662&quot; data-ke-size=&quot;size26&quot;&gt;2. 문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;개발하고 있는 차량 키 관리 솔루션에서 대칭 키를 대량으로 유도/생성/암호화하여 DB에 저장하는 기능이 있었고,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이에 대한 요구사항은 명확했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666;&quot; data-start=&quot;1063&quot; data-end=&quot;1124&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-start=&quot;1065&quot; data-end=&quot;1124&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;10,000개 키 생성&amp;middot;저장을&lt;br /&gt;**현실적인 시간 내(1분 이내)**에 끝낼 수 있게 만들 것.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;R2DBC에서 제공하는 API(&lt;b&gt;repository.saveAll(Flux&amp;lt;...&amp;gt;)&lt;/b&gt;)를 사용하였으나 10분이상 소요되는 문제가 발생하였고, &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;심지어 10,000건이 넘어가는 경우 DB Connection이 초과되는 에러가 발생했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. R2DBC + MariaDB 조합에서의 제약&lt;/h2&gt;
&lt;h3 data-end=&quot;1170&quot; data-start=&quot;1143&quot; data-ke-size=&quot;size23&quot;&gt;3-1. 숫자부터 확인: 어디가 느린가?&lt;/h3&gt;
&lt;p data-end=&quot;1256&quot; data-start=&quot;1172&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 &amp;ldquo;Reactive + 비동기니까 어느 정도는 빨라야 하지 않을까?&amp;rdquo;라는 막연한 기대가 있었다.&lt;br /&gt;그래서 먼저, 다음 정도만 빠르게 계측했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1316&quot; data-start=&quot;1258&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1277&quot; data-start=&quot;1258&quot;&gt;키 생성 자체에 걸리는 시간&lt;/li&gt;
&lt;li data-end=&quot;1302&quot; data-start=&quot;1278&quot;&gt;DB INSERT 구간에 걸리는 시간&lt;/li&gt;
&lt;li data-end=&quot;1316&quot; data-start=&quot;1303&quot;&gt;전체 배치 처리 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1324&quot; data-start=&quot;1318&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1391&quot; data-start=&quot;1326&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1351&quot; data-start=&quot;1326&quot;&gt;키 생성 로직은 상대적으로 빠른 편이었고,&lt;/li&gt;
&lt;li data-end=&quot;1391&quot; data-start=&quot;1352&quot;&gt;&lt;b&gt;INSERT 쿼리가 나가는 구간에서 지연이 집중&lt;/b&gt;되어 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1445&quot; data-start=&quot;1393&quot; data-ke-size=&quot;size16&quot;&gt;즉, 이 문제는 &lt;b&gt;알고리즘이나 비즈니스 로직이 아니라, DB 쓰기 패턴&lt;/b&gt;과 관련이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-end=&quot;1497&quot; data-start=&quot;1452&quot; data-ke-size=&quot;size20&quot;&gt;3-2. &amp;ldquo;내 코드 탓인가?&amp;rdquo; &amp;ndash; saveAll, 설정, 인덱스까지 의심&lt;/h4&gt;
&lt;p data-end=&quot;1530&quot; data-start=&quot;1499&quot; data-ke-size=&quot;size16&quot;&gt;다음으로 의심한 건 순수하게 &amp;ldquo;내 코드/설정 문제&amp;rdquo;였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1662&quot; data-start=&quot;1532&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1591&quot; data-start=&quot;1532&quot;&gt;Spring Data R2DBC의 saveAll/리액티브 리포지토리 패턴을 제대로 쓰고 있는지,&lt;/li&gt;
&lt;li data-end=&quot;1631&quot; data-start=&quot;1592&quot;&gt;커넥션 풀 크기, 타임아웃 등 R2DBC 관련 설정이 적절한지,&lt;/li&gt;
&lt;li data-end=&quot;1662&quot; data-start=&quot;1632&quot;&gt;인덱스 설계/실행 계획에서 쓸데없는 풀스캔이 있는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1674&quot; data-start=&quot;1664&quot; data-ke-size=&quot;size16&quot;&gt;간단히 했던 일들:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1802&quot; data-start=&quot;1676&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1712&quot; data-start=&quot;1676&quot;&gt;EXPLAIN으로 INSERT 대상 테이블 인덱스 확인&lt;/li&gt;
&lt;li data-end=&quot;1735&quot; data-start=&quot;1713&quot;&gt;불필요한 인덱스/트리거 여부 점검&lt;/li&gt;
&lt;li data-end=&quot;1760&quot; data-start=&quot;1736&quot;&gt;R2DBC 설정 조정 후 성능 재측정&lt;/li&gt;
&lt;li data-end=&quot;1802&quot; data-start=&quot;1761&quot;&gt;saveAll / DatabaseClient 여러 조합으로 실험&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1896&quot; data-start=&quot;1804&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이런 조정을 통해서는 &lt;b&gt;근본적인 성능 향상은 나오지 않았다.&lt;/b&gt;&lt;br /&gt;체감상 조금 나아지는 정도에 그쳤고, &amp;ldquo;10분 &amp;rarr; 1분&amp;rdquo; 수준의 개선과는 거리가 멀었다.&lt;/p&gt;
&lt;p data-end=&quot;1896&quot; data-start=&quot;1804&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-end=&quot;1943&quot; data-start=&quot;1903&quot; data-ke-size=&quot;size20&quot;&gt;3-3. &amp;ldquo;그럼 R2DBC 쪽 한계일 수도?&amp;rdquo; &amp;ndash; 레퍼런스 탐색&lt;/h4&gt;
&lt;p data-end=&quot;1959&quot; data-start=&quot;1945&quot; data-ke-size=&quot;size16&quot;&gt;여기부터는 가설을 바꿨다.&lt;/p&gt;
&lt;blockquote data-end=&quot;2059&quot; data-start=&quot;1961&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2059&quot; data-start=&quot;1963&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;혹시 내가 API를 잘못 쓰고 있는 게 아니라,&lt;br /&gt;아예 이 조합(R2DBC + MariaDB)이 &lt;b&gt;Batch Insert를 제대로 지원하지 않는 건 아닐까?&lt;/b&gt;&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2082&quot; data-start=&quot;2061&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2082&quot; data-start=&quot;2061&quot; data-ke-size=&quot;size16&quot;&gt;그래서 다시 열심히 구글링을 시작했다.&lt;/p&gt;
&lt;p data-end=&quot;2248&quot; data-start=&quot;2203&quot; data-ke-size=&quot;size16&quot;&gt;그 과정에서 아래 GitHub 이슈를 포함한 몇 가지 레퍼런스를 확인하게 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2368&quot; data-start=&quot;2250&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2368&quot; data-start=&quot;2250&quot;&gt;spring-projects/spring-data-r2dbc의 &lt;b&gt;Add support for batch insert (#259)&lt;/b&gt; &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-data-r2dbc/issues/259&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;GitHub&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2378&quot; data-start=&quot;2370&quot; data-ke-size=&quot;size16&quot;&gt;이 이슈에서는,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2685&quot; data-start=&quot;2380&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2439&quot; data-start=&quot;2380&quot;&gt;DatabaseClient를 이용해 &lt;b&gt;Batch Insert를 하고 싶다는 요청&lt;/b&gt;이 있었고,&lt;/li&gt;
&lt;li data-end=&quot;2555&quot; data-start=&quot;2440&quot;&gt;작성자가 &amp;ldquo;워크어라운드로 Statement를 직접 써보긴 했지만, Connection을 직접 관리해야 해서 불편하다&amp;rdquo;고 적고 있다. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-data-r2dbc/issues/259&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;GitHub&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li data-end=&quot;2685&quot; data-start=&quot;2556&quot;&gt;라벨이 status: pending-design-work로 달려 있으며,&lt;br /&gt;&amp;ldquo;아직 정식으로 설계&amp;middot;지원되지 않은 기능&amp;rdquo;이라는 상태로 유지되고 있었다. &lt;span data-state=&quot;closed&quot;&gt;&lt;span data-testid=&quot;webpage-citation-pill&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-data-r2dbc/issues/259&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;GitHub&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2706&quot; data-start=&quot;2687&quot; data-ke-size=&quot;size16&quot;&gt;이걸 보고 정리한 결론은 단순했다.&lt;/p&gt;
&lt;blockquote data-end=&quot;2814&quot; data-start=&quot;2708&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2814&quot; data-start=&quot;2710&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;내가 API를 &amp;lsquo;틀리게&amp;rsquo; 쓰고 있어서가 아니라,&lt;br /&gt;&lt;b&gt;Spring Data R2DBC 자체가 아직 원하는 형태의 Batch Insert를 공식 지원하지 않는 상황&lt;/b&gt;에 가깝다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2883&quot; data-start=&quot;2816&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2883&quot; data-start=&quot;2816&quot; data-ke-size=&quot;size16&quot;&gt;즉, 프레임워크에 &amp;ldquo;알아서 Batch 처리해달라&amp;rdquo;고 맡기는 전략은 이 조합에서는 한계가 있다는 걸 받아들이게 되었다.&lt;/p&gt;
&lt;p data-end=&quot;2883&quot; data-start=&quot;2816&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-end=&quot;2936&quot; data-start=&quot;2890&quot; data-ke-size=&quot;size20&quot;&gt;3-4. 방향 전환: 프레임워크에 맡기지 말고, 쿼리 패턴을 내가 설계하자&lt;/h4&gt;
&lt;p data-end=&quot;2953&quot; data-start=&quot;2938&quot; data-ke-size=&quot;size16&quot;&gt;여기서부터 접근이 바뀌었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3085&quot; data-start=&quot;2955&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3006&quot; data-start=&quot;2955&quot;&gt;&amp;ldquo;고수준 API를 더 파보자&amp;rdquo; &amp;rarr; &amp;ldquo;&lt;b&gt;실제 DB로 나가는 쿼리 패턴 자체를 바꾸자&lt;/b&gt;&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;3085&quot; data-start=&quot;3007&quot;&gt;드라이버&amp;middot;프레임워크에 기대지 않고,&lt;br /&gt;&lt;b&gt;내가 원하는 형태의 INSERT 쿼리를 만들어서 한 번에 보내는 쪽&lt;/b&gt;으로 방향을 틀었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3145&quot; data-start=&quot;3087&quot; data-ke-size=&quot;size16&quot;&gt;이때 떠올린 전통적인 RDB 패턴이 바로 다음과 같은 아주 기본적인&amp;nbsp;&lt;b&gt;다중 VALUES Bulk Insert&lt;/b&gt;였다.&lt;/p&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1764119415891&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INSERT INTO key_table (col1, col2, col3) VALUES 
('a1', 1, '2025-11-26'), 
('a2', 2, '2025-11-27'),
... ,
('aN', N, '2025-12-01');&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;3342&quot; data-start=&quot;3299&quot; data-ke-size=&quot;size26&quot;&gt;4. 해결 방향: 다중 VALUES 기반 Bulk Insert 유틸리티&lt;/h2&gt;
&lt;p data-end=&quot;3355&quot; data-start=&quot;3344&quot; data-ke-size=&quot;size16&quot;&gt;위 패턴을 활용하면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3446&quot; data-start=&quot;3357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3389&quot; data-start=&quot;3357&quot;&gt;&lt;b&gt;쿼리 호출 횟수 감소&lt;/b&gt; &amp;rarr; 네트워크 왕복 감소&lt;/li&gt;
&lt;li data-end=&quot;3418&quot; data-start=&quot;3390&quot;&gt;&lt;b&gt;쿼리 파싱/실행 계획 수립 횟수 감소&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3446&quot; data-start=&quot;3419&quot;&gt;한 번의 트랜잭션 안에서 더 많은 row 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3458&quot; data-start=&quot;3448&quot; data-ke-size=&quot;size16&quot;&gt;라는 장점이 있다.&lt;/p&gt;
&lt;p data-end=&quot;3545&quot; data-start=&quot;3460&quot; data-ke-size=&quot;size16&quot;&gt;그래서 R2DBC 환경에서도 이 형태를 쓰기 위해, 그리고 동료도 편히 쓸 수 있도록&lt;br /&gt;&lt;b&gt;다중 VALUES 기반 Bulk Insert SQL을 만들어주는 유틸리티&lt;/b&gt;를 직접 설계했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;3578&quot; data-start=&quot;3552&quot; data-ke-size=&quot;size26&quot;&gt;5. Bulk Insert 유틸리티 설계&lt;/h2&gt;
&lt;p data-end=&quot;3656&quot; data-start=&quot;3580&quot; data-ke-size=&quot;size16&quot;&gt;코드를 블로그에 그대로 공개하는 것은 문제가 있어, 아주 단순화한 유틸리티를 활용한 예시는 다음과 같다.&lt;/p&gt;
&lt;h4 data-end=&quot;3681&quot; data-start=&quot;3658&quot; data-ke-size=&quot;size20&quot;&gt;5-1. BatchQuery 생성 예제&lt;/h4&gt;
&lt;pre id=&quot;code_1764119525639&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;StringBuilder sqlSb = queryGenerator.getInitSql(ProjectUsers.class, true);
sqlSb.append(queryGenerator.generateBatchQuery(users, createId, ProjectUsers.class));

return databaseClient.sql(sqlSb.toString())
        .fetch()
        .all()
        .map(row -&amp;gt; {
            log.info(&quot;row is {}&quot;, row);
            return ProjectUsers.builder().build();
        });&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;⚠️ Security Note: SQL Injection 방어&lt;/b&gt; 이 방식은 Raw SQL을 생성하므로 SQL Injection에 취약할 수 있다. 따라서 이 유틸리티를 범용적으로 공개하지 않고, 엄격한 Type Check가 선행된 내부 로직에만 제한적으로 사용하도록 통제했다. 외부 사용자 입력을 직접 바인딩해야 한다면 ESAPI나 엄격한 정규식 검증을 거치도록 설계해야 하므로 주의가 필요하다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-end=&quot;4838&quot; data-start=&quot;4802&quot; data-ke-size=&quot;size26&quot;&gt;6. 실제 적용 결과: 10,000건 기준 13분 &amp;rarr; 34초&lt;/h2&gt;
&lt;p data-end=&quot;4901&quot; data-start=&quot;4840&quot; data-ke-size=&quot;size16&quot;&gt;이 Bulk Insert 유틸리티는 실제로 &lt;b&gt;차량 키 관리 솔루션의 대칭 키 생성 배치 작업&lt;/b&gt;에 적용했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최초&amp;nbsp;구현에서는&amp;nbsp;&lt;b&gt;대칭&amp;nbsp;키&amp;nbsp;10,000개를&amp;nbsp;생성&amp;middot;저장하는&amp;nbsp;데&amp;nbsp;약&amp;nbsp;13분(780초)&lt;/b&gt;이&amp;nbsp;걸렸지만,&lt;br /&gt;Bulk Insert 유틸리티 적용 후에는&amp;nbsp;&lt;b&gt;10,000개 기준으로 환산하면 약 32초가 &lt;/b&gt;소요됐으며,&amp;nbsp;&lt;br /&gt;대략 &lt;b&gt;23배 수준의 성능을 확보&lt;/b&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-origin-width=&quot;2860&quot; data-origin-height=&quot;120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjx0Ih/dJMcajm4YhM/wdwP4IvRxqYhkvbWDaQUy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjx0Ih/dJMcajm4YhM/wdwP4IvRxqYhkvbWDaQUy0/img.png&quot; data-alt=&quot;10,000개 키 유도/암호화/저장&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjx0Ih/dJMcajm4YhM/wdwP4IvRxqYhkvbWDaQUy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbjx0Ih%2FdJMcajm4YhM%2FwdwP4IvRxqYhkvbWDaQUy0%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;2860&quot; height=&quot;120&quot; data-origin-width=&quot;2860&quot; data-origin-height=&quot;120&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10,000개 키 유도/암호화/저장&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 개발 편의성과 성능관점에서의 트레이드오프&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 구현한 Bulk Insert 유틸리티는 Java Reflection을 사용하여 쿼리를 동적으로 생성했다.&lt;br /&gt;이는 매번 개발자가 직접 Query를 작성하지 않고, 유지보수성을 높이기 위함이었다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;물론, 일반적으로 Reflection은 정적 코드보다 호출 비용이 높다고 알려져 있다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;8,2&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 기능의 핵심 병목(Bottleneck)은 CPU 연산이 아니라, DB와의 네트워크 통신(I/O)에 있었다.&lt;br /&gt;실제로 10만 건의 데이터를 처리할 때 DB I/O가 수 초(Sec) 단위로 발생하는 반면, Reflection을 통한 문자열 생성은 밀리초(ms) 단위에 불과했다. &lt;br /&gt;&lt;br /&gt;따라서 코드의 복잡도를 높이는 캐싱 로직 추가보다는, &lt;b&gt;유지보수성과 범용성을 챙기는 방향&lt;/b&gt;으로 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>java,springboot</category>
      <category>bulk insert</category>
      <category>MariaDB</category>
      <category>r2dbc</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/184</guid>
      <comments>https://marklee1117.tistory.com/184#entry184comment</comments>
      <pubDate>Wed, 26 Nov 2025 10:49:45 +0900</pubDate>
    </item>
    <item>
      <title>WebFlux 환경에서 레거시 Logging 시스템을 AOP 기반으로 리팩토링한 기록</title>
      <link>https://marklee1117.tistory.com/183</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;컨트롤러에 로직이 묻히고, 로그 한 줄 남기기 위해 수십 줄의 코드가 반복되던 레거시 로깅 시스템. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;결국 주말을 반납하고 고군분투 끝에 AOP 기반으로 리팩토링했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 포스트에서는 이 리팩토링 과정을 정리 해보려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;✨ 리팩토링 배경&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;1. 기존 Logging 기능&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존 레거시 로그코드는 아래와 같은 기능과 모습을 가지고 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;6하원칙 기반 로그 메시지&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 누가, 언제, 어디서, 무엇을 했는지를 기반으로 성공/실패 여부와 메시지를 남긴다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;다국어 지원&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 한국어 및 영어 메시지를 국제화 키로 관리&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;WebFlux 대응&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 비동기 환경에서도 동작 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;2. 문제점&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;문제 항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;상세 설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;⚠️ 관심사 혼재&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;컨트롤러에 비즈니스 로직과 로깅 로직이 섞여 있어 가독성 저하(가장 문제가 되는 부분이었다.)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;⚠️ 반복 코드&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;API마다 유사한 로깅 코드가 반복됨&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;⚠️ 메시지 분산&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;다국어 메시지 키가 코드 곳곳에 분산되어 유지보수 어려움&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;⚠️ WebFlux 특성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;RequestBody는 Flux&lt;/span&gt;&lt;span&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;기존 레거시코드는 아래와 같은 구조를 가지고 있었으며 위와 같은 문제점을 가지고 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1744526707428&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Operation(summary = &quot;Insert Secure Data&quot;, description = &quot;Legacy Insert API with Logging&quot;)
@PostMapping(&quot;/insert&quot;)
public Mono&amp;lt;SecureDTO.Response&amp;gt; insert(
        @Parameter(description = &quot;SecureDTO.Request&quot;) @RequestBody SecureDTO.Request reqDto,
        ServerHttpRequest request) {

    return getCurrentUserId(request).switchIfEmpty(Mono.just(&quot;&quot;)).flatMap(userId -&amp;gt; {
        String userIp = extractClientIp(request);
        String uri = request.getPath().toString();

        // Set common service metadata
        // ...

        AtomicReference&amp;lt;List&amp;lt;Map&amp;lt;String, Object&amp;gt;&amp;gt;&amp;gt; logs = new AtomicReference&amp;lt;&amp;gt;();

        Mono&amp;lt;SecureEntity&amp;gt; insertFlow = secureService.insertData(reqDto)
        .doOnSuccess(result -&amp;gt; {
            // Success log 세팅
            // ...
        })
        .onErrorResume(e -&amp;gt; {
            // Error log 세팅
            // ...
        });

        return logUtil.logWithData(insertFlow, logs);
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 3 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;  리팩토링 목표 및 성과&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;내가 지향하는 이번 리팩토링의 목표는 아래와 같았다.&lt;/span&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;u&gt;&lt;b&gt;6하원칙에 의거한 자세한 로깅 Content 제공&lt;/b&gt;&lt;/u&gt;&lt;br /&gt;기존에 비해 심플한 로깅 메시지를 남기되, RequestBody를 함께 저장함으로써 추적성을 올렸다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;성공/실패에 대한 로그 및 에러코드 제공&lt;/b&gt;&lt;/u&gt; &lt;br /&gt;기존과 동일하게 해당 요청의 성공/실패 여부를 남기고, 실패 했다면 실패 원인과 에러코들르 함께 남긴다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;multipart/form-data Content-Type 지원&lt;/b&gt;&lt;/u&gt;&lt;br /&gt;제품 특성상 자주 쓰이는 multipart/form-data Content-Type을 지원해야 했다. 단, 이때 용량이 크고 보안상 문제가 될 수 있는 fileData는 filename만 로그에 남기는 식으로 처리하였다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;다국어 기능 제공&lt;/b&gt;&lt;/u&gt; &lt;br /&gt;기존과 동일하게 2개국어 지원이 되도록 한다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;보안 데이터 마스킹&lt;/b&gt;&lt;/u&gt; &lt;br /&gt;비밀번호와 같은 보안데이터는 로그데이터에 남기지 않도록 마스킹처리 하였다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;로그 메시지 유지보수성 제고&lt;/b&gt;&lt;/u&gt; &lt;br /&gt;기존에 분산화 되어 있던 로그메시지를 하나의 파일에서 직접 관리할 수 있도록 개선하였다.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;WebFlux환경에서 Reactive하게 동작&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번에 리팩토링한 결과 아래처럼 간단히 @LoggingAPI만 붙여주면 로그가 모두 남게 되었다.&lt;br /&gt;아래 실제 구현 코드를 보면서 어떻게 위 조건들을 충족시킬 수 있었는지 정리해보도록하자.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@LoggingAPI
@PostMapping(&quot;/insert&quot;)
public Mono&amp;lt;SecureDTO.Response&amp;gt; insert(@RequestBody SecureDTO.Request reqDto, ServerHttpRequest request) {
    return getCurrentUserId(request).flatMap(userId -&amp;gt; {
        //... data settings...
        String userIp = extractClientIp(request);
        return secureService.insertData(reqDto);
    });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;  구현 구조&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 리팩토링에서는 세 가지 핵심 컴포넌트를 새롭게 설계하여 전체 로깅 시스템의 구조를 개선했다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;각 컴포넌트는 기존의 문제를 해결하기 위한 목적을 가지고 있으며, 어떻게 역할을 나누었는지 아래에 자세히 설명한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;1. LogMessages - 로그 메시지 집중 관리&lt;/span&gt;&lt;/b&gt;&lt;b&gt;&lt;span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존에는 로그 메시지에 사용되는 다국어 키들이 코드 전반에 흩어져 있었기 때문에, 하나의 메시지를 변경하려 해도 어디서 사용하는지 찾는 데 시간이 걸렸다. 이를 해결하기 위해 메시지 키를 Enum 클래스로 집중 관리하도록 설계했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Getter
@RequiredArgsConstructor
public enum LogMessages {
    LOGIN(&quot;/login&quot;, &quot;System&quot;, &quot;log.audit.login.success&quot;, &quot;log.audit.login.fail&quot;),
    ...
    UNKNOWN(&quot;&quot;, &quot;Unknown&quot;, &quot;log.audit.etc.success&quot;, &quot;log.audit.etc.fail&quot;);

    public static LogMessages fromPath(String path) {
        return Arrays.stream(values())
            .filter(e -&amp;gt; path.equalsIgnoreCase(e.path))
            .findFirst().orElse(UNKNOWN);
    }
    
    public String getSuccessMessage(List&amp;lt;String&amp;gt; params) {
        return MessageUtil.getMessageService().getMessage(successMsgKey, params);
    }

    public String getFailMessage(List&amp;lt;String&amp;gt; params) {
        return MessageUtil.getMessageService().getMessage(failMsgKey, params);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 Enum을 사용하면 경로 기반으로 자동으로 메시지를 매핑할 수 있어서 가독성과 유지보수성을 크게 개선할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;2. CachingRBFilter - RequestBody 캐싱&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;WebFlux의 비동기 환경에서는 RequestBody가 스트리밍 방식으로 전달되기 때문에, 한번 읽고 나면 더 이상 데이터를 꺼내 쓸 수 없다. 이 문제를 해결하기 위해 RequestBody를 메모리에 저장해두고, 여러 번 재사용할 수 있도록 필터(WebFilter)를 도입했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
//...
@Slf4j
public class CachingRequestBodyFilter implements WebFilter {

    @Override
    public Mono&amp;lt;Void&amp;gt; filter(ServerWebExchange exchange, WebFilterChain chain) {
        if (!isCachingRequired(request)) {
            log.debug(&quot;no need to cache request body&quot;);
            return chain.filter(exchange);
        }

        return DataBufferUtils.join(request.getBody())
                .defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0]))
                .flatMap(dataBuffer -&amp;gt; {
                    byte[] bytes = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(bytes);
                    DataBufferUtils.release(dataBuffer);

                    String cachedBody = new String(bytes, StandardCharsets.UTF_8);

                    // 저장
                    exchange.getAttributes().put(&quot;cachedRequestBody&quot;, cachedBody);

                    // 복제해서 body 제공
                    ServerHttpRequestDecorator decoratedRequest = new ServerHttpRequestDecorator(request) {
                        @Override
                        public HttpHeaders getHeaders() {
                            return super.getHeaders();
                        }

                        @Override
                        public Flux&amp;lt;DataBuffer&amp;gt; getBody() {
                            return Flux.defer(() -&amp;gt; {
                                DataBufferFactory factory = exchange.getResponse().bufferFactory();
                                return Flux.just(factory.wrap(bytes));
                            });
                        }
                    };

                    return chain.filter(exchange.mutate().request(decoratedRequest).build());
                });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특히&amp;nbsp;multipart/form-data 형식은 애초에 재사용이 가능할 뿐아니라 대용량 파일이 들어올 수 있기 때문에, &lt;br /&gt;이런 요청은 필터에서 캐싱 대상에서 제외(isCachingRequired)해 성능에 주는 영향을 최소화 하였다.&lt;/span&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;b&gt;&lt;span&gt;3. LoggingAspect - 핵심 로깅 로직&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;로깅 처리는 &lt;/span&gt;&lt;span&gt;LoggingAspect&lt;/span&gt;&lt;span&gt;라는 클래스에서 수행되며, 핵심 흐름은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span&gt;@LoggingAPI&lt;/span&gt;&lt;span&gt; 어노테이션이 붙은 API를 감지하여 AOP가 동작한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;사용자 ID, IP, 요청 URI, Body 등을 추출한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;요청 결과가 성공인지 실패인지에 따라 적절한 메시지를 설정한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;이를 바탕으로 로그를 생성하고 DB에 저장한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;아래는 그 예시코드이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Aspect
@Component
@Slf4j
@Order(3)
@RequiredArgsConstructor
public class LoggingAspect {

    private final ConfigProperties configProperties;
    private final LoginService loginService;
    private final ApiKeyService apiKeyService;
    private final AuditLogRepository auditLogRepository;
    private final int UNEXPECTED_ERROR_CODE = 9999;

    @Around(&quot;@annotation([package-path].LoggingAPI)&quot;)
    public Object logRequestResponse(ProceedingJoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        ServerHttpRequest request = (ServerHttpRequest) args[args.length - 1];

        return getUserId(request)
                .flatMap(userId -&amp;gt; processLoging(joinPoint, request, args, userId))
                .cast(Object.class);
    }

    /**
     * 사용자 ID를 조회하는 메서드
     * 1. 쿠키에 토큰이 있는 경우에는 해당 토큰으로 사용자 ID를 조회
     * 2. 쿠키에 토큰이 없는 경우에는 API Key로 사용자 ID를 조회
     * 3. fallback 처리
     *
     * @param request
     * @return
     */
    private Mono&amp;lt;String&amp;gt; getUserId(ServerHttpRequest request) {
        //...
    }

    private Mono&amp;lt;?&amp;gt; processLoging(ProceedingJoinPoint joinPoint, ServerHttpRequest request, Object[] args, String userId) {
        LocalDateTime requestTime = DateUtil.getUTCLocalDateTime();
        String userIp = NetworkUtil.getClientIP(request);
        String requestPath = request.getPath().toString();
        String requestMethod = request.getMethod().toString();

        String requestBody = extractRequestBody(request, args);

        LogMessages lm = LogMessages.fromPath(requestPath);
        String successMessage = lm.getSuccessMessage(null);
        String failMessage = lm.getFailMessage(null);

        AuditLogBuilder alb = AuditLog.builder()
                //... 생략
                .userId(userId)
                .userIp(userIp)
                .requestPath(requestPath)
                .requestMethod(requestMethod)
                .requestBody(requestBody)
                .requestTime(requestTime);
        try {
            Object result = joinPoint.proceed();
            return handleResult(result, alb, successMessage, failMessage)
                    .onErrorResume(e -&amp;gt; {
                        // some error 처리
                        return Mono.error(e);
                    });
        } catch (Throwable e) {
            return handleException(e, alb, failMessage);
        }
    }

    /**
     * Request Body를 추출하는 메서드
     * content-type이 multipart/form-data인 경우에는
     * FilePart를 포함한 Map&amp;lt;String, Object&amp;gt; 형태로 변환하여 데이터를 정리하여 json.stringify해서 리턴
     * 그 이외의 경우에는 String 형태로 변환하여 리턴
     *
     * @param request
     * @param args
     * @return
     */
    private String extractRequestBody(ServerHttpRequest request, Object[] args) {
        // ... 생략
        // 그 이외의 경우
        Object cachedBody = request.getAttributes().get(&quot;cachedRequestBody&quot;);
        return (cachedBody instanceof String) ? (String) cachedBody : ObjectUtil.convertObjectToJsonString(cachedBody);
    }

    /**
     * multipart/form-data인 경우에 Request Body를 Map&amp;lt;String, Object&amp;gt; 형태로 변환하는 메서드
     * FilePart인 경우에는 filename을 key로 하여 Map에 저장
     * FilePart[]인 경우에는 filename을 ArrayList에 저장하여 Map에 저장
     * jsonString인 경우에는 data를 키로 하여 그대로 Map에 저장
     *
     * @param args
     * @return
     */
    private Map&amp;lt;String, Object&amp;gt; getMultipartDataMap(Object[] args) {
        Map&amp;lt;String, Object&amp;gt; result = new HashMap&amp;lt;&amp;gt;();
        for (Object arg : args) {
            if (arg instanceof FilePart) {
                FilePart file = (FilePart) arg;
                result.put(&quot;file&quot;, file.filename());
            } else if (arg instanceof FilePart[]) {
                FilePart[] files = (FilePart[]) arg;
                ArrayList&amp;lt;String&amp;gt; filenames = new ArrayList&amp;lt;&amp;gt;();
                for (FilePart file : files) {
                    filenames.add(file.filename());
                }
                result.put(&quot;files&quot;, filenames);
            } else if (arg instanceof String) {
                result.put(&quot;datas&quot;, arg);
            }
        }

        return result;
    }

    /**
     * 응답을 처리하는 메서드
     */
    private Mono&amp;lt;?&amp;gt; handleResult(Object result, AuditLogBuilder alb, String successMessage, String failMessage) {
        if (result instanceof Mono) {
            return ((Mono&amp;lt;?&amp;gt;) result).flatMap(response -&amp;gt; {
                AuditLog auditLog = alb.logMessage(successMessage)
                        .errorCode(0)
                        .build();

                return saveAuditLog(auditLog).thenReturn(response);
            })
                    .onErrorResume(e -&amp;gt; {
                        String errorDesc = e.getMessage();
                        int errorCode = UNEXPECTED_ERROR_CODE;

                        if (e instanceof CustomException) {
                            errorDesc = ((CustomException) e).getErrorMsg();
                            errorCode = ((CustomException) e).getErrorCode().getCode();
                        }
                        AuditLog auditLog = alb.logMessage(failMessage)
                                .errorCode(errorCode)
                                .errorDesc(errorDesc)
                                .build();

                        return saveAuditLog(auditLog).then(Mono.error(e));
                    });
        } else {
            // Mono가 아닌 응답 처리
            // ...
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;  삽질 &amp;amp; 극복기&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;처음 이 리팩토링을 시작했을 때에는 그렇게 어려운 작업이 아닐거라고 생각했지만,&amp;nbsp;&lt;br /&gt;여느 다른 일들과 마찬가지로 막상 시작하고나니 여러가지 문제들이 나를 기다리고 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;1. WebFlux와 RequestBody&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;WebFlux에서는 RequestBody가 &lt;/span&gt;&lt;span&gt;Flux&amp;lt;DataBuffer&amp;gt;&lt;/span&gt;&lt;span&gt; 형태로 들어오는데, 이게 한 번 읽고 나면 다시는 사용할 수 없다는 제한이 있다. 처음엔 단순히 로그를 남기려다 전체 API 동작이 꼬이는 문제가 생겼다. 그래서 이 문제를 해결하기 위해 &lt;/span&gt;&lt;span&gt;CachingRBFilter&lt;/span&gt;&lt;span&gt;를 만들었고, Request를 복제해서 다시 사용할 수 있도록 &lt;/span&gt;&lt;span&gt;ServerHttpRequestDecorator&lt;/span&gt;&lt;span&gt;를 적용해 해결할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;2. 성능 고려&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;RequestBody를 캐싱하면 메모리를 추가로 사용하게 되는데, 우리 시스템처럼 대용량 파일도 다뤄야 하는 상황에서는 큰 부담이 될 수 있었다. 그래서 &lt;/span&gt;&lt;span&gt;multipart/form-data&lt;/span&gt;&lt;span&gt; 요청은 캐싱 대상에서 제외했고, 나머지 요청에 대해서만 필요한 경우에 한해 캐싱이 동작하도록 설계했다. 이후 JMeter로 현재 제품 사용량을 고려한 실제 부하 테스트를 돌려본 결과, 성능상 문제가 없는 것을 확인했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;3. 레거시 코드 제거&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;AOP 기반 로깅 시스템을 도입하면서 기존의 모든 API에서 레거시 로깅 코드를 걷어내야 했다. 단순히 삭제만 하면 되는 줄 알았지만, 실제로는 과거 코드 안에 숨어 있던 버그 3개를 발견하게 되었고, 이 기회에 함께 수정할 수 있었다. 이 작업이 정말 시간이 오래 걸렸다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;4. 동료 설득&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;가장 고민스러웠던 부분 중 하나는 기존 로그 시스템이 동료 선임분이 꽤 공들여 만들어 놓은 구조였다는 점이다. 자칫하면 기존 코드를 부정하는 모양새가 될 수 있었기에, 변경 전후 비교 예시와 개선 효과를 실제 코드와 함께 최대한 정리해서 설명드렸다. 실제 동작하는 모습까지 보여드리며 리뷰를 요청했고, 다행히 긍정적으로 검토해 주시고 피드백도 많이 주셔서 성공적으로 마칠 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span&gt;  마무리하며&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;단순히 코드를 리팩토링한 것이 아니라, 시스템의 유지보수성과 확장성을 높이는 경험이었다. 반복되는 코드, 분산된 메시지, WebFlux의 제약을 극복하면서 더욱 견고하고 재사용 가능한 로깅 시스템을 만들 수 있었다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;앞으로도 기술적 부채를 외면하지 않고, 직접 개선하는 개발자가 되고 싶다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>java,springboot</category>
      <category>aop</category>
      <category>aspect</category>
      <category>log</category>
      <category>logging</category>
      <category>Reactive</category>
      <category>springboot</category>
      <category>webflux</category>
      <category>기술 부채</category>
      <category>로그</category>
      <category>리팩토링</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/183</guid>
      <comments>https://marklee1117.tistory.com/183#entry183comment</comments>
      <pubDate>Sun, 13 Apr 2025 16:06:45 +0900</pubDate>
    </item>
    <item>
      <title>[Java] Java에서 JSON 다루기(mapper, converter)</title>
      <link>https://marklee1117.tistory.com/181</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;이번 포스트에서는 Java에서 JSON을 다루는 방법에 대해 정리해보고자 합니다.&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; 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;Object to JsonString&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POJO를 JsonString으로 변환하는 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다양한 방법이 있지만 만약 아래처럼 field명과 jsonProperty가 달라야 한다면 ObjectMapper를 사용하는 것이 편리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 저는 ObjectMapper를 사용하는 방법을 중심으로 설명하도록 하겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1712103662473&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class SampleClass{
    @JsonProperty(&quot;Test Custom Field&quot;)
    private String testCustomField;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1712103903180&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.fasterxml.jackson.databind.ObjectMapper;


public static String convertObjectToJsonString(Object o) {
    ObjectMapper om = new ObjectMapper();
    //Object에 비어있지 않은 값만 JSONString에 포함
    om.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);

    String result = null;
    try {
        result = om.writeValueAsString(o);
    } catch (JsonProcessingException e) {
        System.out.println(&quot;Error&quot;);
    }
    return result;
}&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;JsonString to Object (특정 클래스로 변환)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 변환한 JSONString을 특정 클래스로 되돌리는 함수입니다. &lt;br /&gt;강제 캐스팅 없이 범용적으로 사용하기 위해 Generic으로 만들었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1712104014242&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public static &amp;lt;T&amp;gt; T convertJsonStringToObject(String jsonString, Class&amp;lt;T&amp;gt; c) {
    ObjectMapper om = new ObjectMapper();
    T result = null;
    try {
        result = om.readValue(jsonStr, c);
    } catch (JsonProcessingException e) {
        System.out.println(&quot;Error&quot;);
    }
    return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Object to JSONObject&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POJO를 JSONObject로 변환하는 방법입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 JSONOBject의 import 경로에 주의해주세요.(simple json이 아닙니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;convertObjectToJsonString 함수는 앞서 구현한 Object To JsonString을 참고해주세요.&lt;/p&gt;
&lt;pre id=&quot;code_1712103235076&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import org.json.JSONObject;

public static JSONObject convertObjectToJSONObject(Object obj) {
    String stringifiedJSON = convertObjectToJsonString(obj);
    JSONObject jsonObject = new JSONObject(stringifiedJSON);

    return jsonObject;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. Gson 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google에서 나온 Gson을 이용해서도 아래처럼 간단히 구현할 수 있지만, 앞서 설명한 것 처럼 커스턴 프로퍼티네이밍을 처리하기가 불편합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1712103517952&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//build.gradle.kts
implementation(&quot;com.google.code.gson:gson:2.10.1&quot;)


//Some.java
public static JSONObject convertObjectToJSONObject(Object obj) {
    String gsonStringJson = new Gson().toJson(obj);
    JSONObject jsonObject = new JSONObject(gsonStringJson);

    return jsonObject;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;JSONObject to Object(특정 클래스로 변환)&lt;/h2&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;JSONObject를 원래 Object로 변환하는 방법입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;역시 강제 캐스팅 없이 범용적으로 사용하기 위해 Generic으로 만들었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;참고로 JSONObject.toString 메소드를 이용하면 간단히 JSONString으로 변환할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1712104479045&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public static &amp;lt;T&amp;gt; T convertJSONObjectToObject(JSONObject jsonObj, Class&amp;lt;T&amp;gt; c) {
    return convertJsonStringToObject(jsonObj.toString(), c);
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>java,springboot</category>
      <category>convert</category>
      <category>generic</category>
      <category>Gson</category>
      <category>json to object</category>
      <category>JSONObject</category>
      <category>JSONObject to Object</category>
      <category>JSONString</category>
      <category>ObjectMapper</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/181</guid>
      <comments>https://marklee1117.tistory.com/181#entry181comment</comments>
      <pubDate>Thu, 4 Apr 2024 09:00:18 +0900</pubDate>
    </item>
    <item>
      <title>[OpenSSL]self-signed 인증서 체인 만들기(2depth, intermediateCA)</title>
      <link>https://marklee1117.tistory.com/180</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;이번 포스트에서는 &lt;b&gt;openssl&lt;/b&gt;을 이용하여 &lt;b&gt;인증서 체인&lt;/b&gt;을 만드는 방법에 대해 정리해보고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;최종 모습은 &lt;b&gt;RootCA - IntermediateCA -  Leaf&lt;/b&gt; 구조(2depth)가 될 것입니다.&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이렇게 발급된 인증서가 정상적으로 발급된 것인지 openssl을 활용한&amp;nbsp;&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;openssl이 설치되어 있어야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;인증서 발급 순서&lt;/span&gt;&lt;/b&gt;&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;&quot;&gt;openssl을 이용하여 인증서를 발급받는 일반적인 순서는 아래와 같을 것입니다.&lt;br /&gt;하지만 RootCA, IntermediateCA, Leaf 마다 발급 시 옵션이 조금씩 상이하니 아래 글을 따라 진행해주시면 되겠습니다.&lt;br /&gt;(** CA = Certificate Authority)&lt;/span&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;b&gt;privateKey 생성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CSR 생성&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Certificate생성&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;RootCA 인증서 발급&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RootCA인증서 발급방법에 대해 바로 들어가기 전에 먼저 CSR이 무엇인지 간단히 집고 넘어가도록 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 이미 RootCA는 CSR을 생성할 필요가 없는 이유에 대해 알고 계시다면 아래 CSR부분은 넘겨도 무방합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. CSR(Certificate Signing Request)?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSR은 &lt;b&gt;인증서 발급기관(Certificate Authority)&lt;/b&gt;에게 제출하는 일종의 &lt;b&gt;인증서 발급 요청서&lt;/b&gt;라고 할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSR이 일반적으로 어떤 내용을 포함하고 있는지 openssl을 이용해 확인해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1710893407173&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl req -in serverCA.csr -text -noout

# CSR 내용
Certificate Request:
    Data:
        Version: 1 (0x0)
        Subject: C=KR, ST=Seoul, L=Yeouido, O=ServerCA, OU=ServerCA, CN=localhost
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:fc:80:2b:1c:af:...
                Exponent: 65537 (0x10001)
        Attributes:
            (none)
            Requested Extensions:
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        7e:bc:c7:79:09:...&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;Subject&lt;/b&gt;는 인증서에 포함될 Entity에 대한 정보를 가지고 있으며 보통 아래 내용이 포함됩니다.&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;C&lt;/b&gt;(country): 국가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ST&lt;/b&gt;(State or Province Name): 주, 또는 지방&lt;/li&gt;
&lt;li&gt;&lt;b&gt;L&lt;/b&gt;(Locality Name): 도시 또는 지역&lt;/li&gt;
&lt;li&gt;&lt;b&gt;O&lt;/b&gt;(Organization Name): 주체의 조직 또는 회사명&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OU&lt;/b&gt;(Organization Unit Name): 주체의 부서나 단위&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CN&lt;/b&gt;(Common Name): &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: left;&quot;&gt;주로 서버의 도메인 이름이나 개체의 이름입니다. 인증서가 특정 도메인이나 개체와 연결되어야 할 때 사용됩니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Subject Public Key Info&lt;/b&gt;는 인증서에 포함된 PublicKey에 대한 정보를 보여주며, 보통 아래 내용을 포함합니다.&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;Public Key Algorithm&lt;/b&gt;: 공개키 생성 알고리즘&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Public-Key&lt;/b&gt;: 공개키 크기(e.g. 2048 bit)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Modulus, Exponent&lt;/b&gt;: RSA알고리즘 디테일정보&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Signature Value&lt;/b&gt;: CSR에 포함된 publicKey와 쌍이 되는 privateKey를 이용해 CSR에 사인한 값. (CA가 검증하기 위해 사용)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 CSR은 CA에게 이런이런 내용의 인증서를 발급해달라는 요청서이기 때문에, 상위 CA가 없는 RootCA는 CSR을 생성할 필요가 없기 때문입니다. (RootCA는 Self-Signed한 인증서를 가지게 됩니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 저는 아래에서 진행할 RootCA 인증서 발급에서 CSR을 생성하지 않고 바로 Self-Signed 인증서를 만들 것입니다.&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. RootCA 인증서 발급&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1710894659514&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#serverRootCA 개인키 생성
openssl genrsa -aes256 -out rootCA.key 2048
#serverRootCA 인증서 발급(self-sign)
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1825 -out rootCA.pem&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;위 command가 가지는 의미는 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;genrsa: rsa알고리즘을 이용하여 개인키 생성&lt;/li&gt;
&lt;li&gt;-aes256: 개인키 생성시 &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;AES 256 비트 암호화 사용&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;-out: &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;출력 파일의 이름 지정&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;2048: 개인키 길이&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;req -x509 -new: &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;x.509형식의&amp;nbsp;&lt;/span&gt;새로운 인증서를 생성&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;-key: 인증서에 서명할 키를 지정(이때 RootCA의 개인키를 그대로 사용 -&amp;gt; selfSigned)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;-sha256: 인증서 서명을 sha256알고리즘 이용해 암호화&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;-days: 인증서 유효기간&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;-out: 출력파일 이름지정&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 발급 시 입력 값 예시 및 결과&lt;/b&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;652&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SSkri/btsFU0eRlLK/lOxm71hhXL1AOkOxGJTOwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SSkri/btsFU0eRlLK/lOxm71hhXL1AOkOxGJTOwk/img.png&quot; data-alt=&quot;RootCA 인증서 발급&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SSkri/btsFU0eRlLK/lOxm71hhXL1AOkOxGJTOwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSSkri%2FbtsFU0eRlLK%2FlOxm71hhXL1AOkOxGJTOwk%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;713&quot; height=&quot;398&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;652&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RootCA 인증서 발급&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;그 결과로 아래처럼 rootCA.key와 rootCA.pem이 생성된 것을 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCRUZp/btsFWshnESj/mKo8OnYS1WkwoeCVy4ZruK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCRUZp/btsFWshnESj/mKo8OnYS1WkwoeCVy4ZruK/img.png&quot; data-alt=&quot;RootCA 인증서 발급 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCRUZp/btsFWshnESj/mKo8OnYS1WkwoeCVy4ZruK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdCRUZp%2FbtsFWshnESj%2FmKo8OnYS1WkwoeCVy4ZruK%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;698&quot; height=&quot;118&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RootCA 인증서 발급 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;IntermediateCA 인증서 발급&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 위에서 만든 RootCA인증서로 사인한 IntermediateCA용 인증서를 발급 받아보도록 하겠습니다.&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;b&gt;1. IntermediateCA 인증서 발급&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RootCA인증서 발급과 달라 주의할 부분은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;발급절차에 CSR 생성 추가&lt;/li&gt;
&lt;li&gt;인증서 발급시 &lt;b&gt;CA관련 정보 입력&lt;/b&gt; 및 &lt;b&gt;basicConstraints 추가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1710896364888&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# intermediateCA 개인키 생성
openssl genrsa -aes256 -out intermediateCA.key 2048
# intermediateCA CSR 생성
openssl req -new -sha256 -key intermediateCA.key -out intermediateCA.csr
# intermediateCA 인증서 발급
openssl x509 \
  -req \
  -in intermediateCA.csr \
  -CA rootCA.pem \
  -CAkey rootCA.key \
  -CAcreateserial \
  -out intermediateCA.pem \
  -days 365 \
  -sha256 \
  -extfile &amp;lt;(echo &quot;basicConstraints=critical,CA:TRUE&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 커맨드에서 새롭게 추가되어 주의할 부분은 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;-CAcreateserial: &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;자동으로 시리얼(serial) 파일을 생성하여 서명된 인증서에 일련 번호(serial number)를 할당. 이 옵션이 있으면, 따로 시리얼 넘버를 지정할 필요가 없어서 편리.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;b&gt;-extfile&lt;/b&gt;: &lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;사용자 지정 확장(extension) 구성 파일을 지정하는 데 사용. 주로 인증서의 확장 필드를 설정하는 데 활용.&lt;br /&gt;여기에서는 Leaf인증서가 아닌, CA인증서로 발급하기 위해 사용(자세한 내용은 하단 &lt;b&gt;TroubleShooting&lt;/b&gt; 참고)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 발급 시 입력 값 예시 및 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EmailAddress와&amp;nbsp; optional한 입력값들은 그냥 enter키를 입력하여 넘겼습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1256&quot; data-origin-height=&quot;1260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bht6Xj/btsFSbPbiMB/9Wjoe5SPyOdrm7LS14mIF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bht6Xj/btsFSbPbiMB/9Wjoe5SPyOdrm7LS14mIF1/img.png&quot; data-alt=&quot;Intermeidate 인증서 발급절차&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bht6Xj/btsFSbPbiMB/9Wjoe5SPyOdrm7LS14mIF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbht6Xj%2FbtsFSbPbiMB%2F9Wjoe5SPyOdrm7LS14mIF1%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;695&quot; height=&quot;697&quot; data-origin-width=&quot;1256&quot; data-origin-height=&quot;1260&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Intermeidate 인증서 발급절차&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;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;이제 &lt;b&gt;intermediateCA.key, intermediateCA.csr, intermediateCA.pem&lt;/b&gt;이&amp;nbsp;생성된 것을 볼 수 있을 것입니다.&lt;/span&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;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;아래 명령어를 통해 인증서가 제대로 생성됐는지 살펴보고 가겠습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1710897632889&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# intermediateCA 인증서 출력
openssl x509 -in intermediateCA.pem -text -noout&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1298&quot; data-origin-height=&quot;1262&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKlMX6/btsFUH7tjki/JqW4Isyso3NS0ipc9EK82K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKlMX6/btsFUH7tjki/JqW4Isyso3NS0ipc9EK82K/img.png&quot; data-alt=&quot;IntermediateCA 인증서 상세내역&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKlMX6/btsFUH7tjki/JqW4Isyso3NS0ipc9EK82K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKlMX6%2FbtsFUH7tjki%2FJqW4Isyso3NS0ipc9EK82K%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;688&quot; height=&quot;669&quot; data-origin-width=&quot;1298&quot; data-origin-height=&quot;1262&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;IntermediateCA 인증서 상세내역&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;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;여기에서 주의해서 볼 점은 &lt;b&gt;Issuer&lt;/b&gt;부분과 &lt;b&gt;X509v3 extensions&lt;/b&gt; 부분입니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;b&gt;Issuer &lt;/b&gt;부분에서 RootCA 인증서를 발급할 때 입력한 값들이 제대로 들어 있는지 확인합니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;X509v3 extensions &lt;/b&gt;부분에서 X509v3 Basic Constraints가 critical, CA:TRUE로 세팅되어 있는지 확인합니다.&lt;br /&gt;이 값이 존재해야&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;u&gt;&lt;b&gt; Leaf인증서가 아닌, CA인증서로 제대로 발급된 것&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 검증&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;intermediateCA 인증서가 제대로 발급되었는지, 간단히 검증해보도록 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령어로 검증 해볼 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1710897935679&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# openssl 인증서 검증
openssl verify -CAfile rootCA.pem intermediateCA.pem&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;아래처럼 OK가 나오면 rootCA로 제대로 사인되었고, 검증이 가능하다는 뜻입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1246&quot; data-origin-height=&quot;56&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k7RvA/btsFVTzz5qe/MTAXWR8yaJ0Y75aU7EpFLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k7RvA/btsFVTzz5qe/MTAXWR8yaJ0Y75aU7EpFLk/img.png&quot; data-alt=&quot;IntermediateCA 인증서 검증 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k7RvA/btsFVTzz5qe/MTAXWR8yaJ0Y75aU7EpFLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk7RvA%2FbtsFVTzz5qe%2FMTAXWR8yaJ0Y75aU7EpFLk%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;639&quot; height=&quot;29&quot; data-origin-width=&quot;1246&quot; data-origin-height=&quot;56&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;IntermediateCA 인증서 검증 결과&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;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; Leaf 인증서 발급&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 intermediate인증서를 이용하여 &lt;b&gt;Leaf 인증서&lt;/b&gt;를 발급해보도록 하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Leaf 인증서는 해당 인증서로 다른 인증서를 발급하지 못하는 인증서&lt;/b&gt;를 말합니다. 즉, &lt;u&gt;&lt;b&gt;CA 인증서가 아니라는 뜻&lt;/b&gt;&lt;/u&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;&lt;b&gt;1. Leaf 인증서 발급&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;intermediateCA 인증서를 발급했던 것과 거의 흡사합니다. 단, CA인증서 확장필드값을 설정해주는 부분이 사라졌습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1710898375968&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl genrsa -aes256 -out leaf.key 2048
openssl req -new -sha256 -key leaf.key -out leaf.csr
openssl x509 \
	-req \
	-in leaf.csr \
	-CA intermediateCA.pem \
	-CAkey intermediateCA.key \
	-CAcreateserial \
	-out leaf.pem \
	-days 365 \
	-sha256&lt;/code&gt;&lt;/pre&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;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;b&gt;2. 발급 시 입력 값 예시 및 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;intermediateCA 인증서를 발급과 같은 절차입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;922&quot; data-origin-height=&quot;1008&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBqjg4/btsFWByp3EG/2ApKVSkVkWcb61tsmqDgW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBqjg4/btsFWByp3EG/2ApKVSkVkWcb61tsmqDgW1/img.png&quot; data-alt=&quot; Leaf 인증서 발급&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBqjg4/btsFWByp3EG/2ApKVSkVkWcb61tsmqDgW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBqjg4%2FbtsFWByp3EG%2F2ApKVSkVkWcb61tsmqDgW1%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;671&quot; height=&quot;734&quot; data-origin-width=&quot;922&quot; data-origin-height=&quot;1008&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt; Leaf 인증서 발급&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;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;이제 &lt;b&gt;leaf.key, leaf.csr, leaf.pem&lt;/b&gt; 파일이 생성된 것을 볼 수 있을 것입니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;아래처럼 정상적으로 발급된 것을 확인 할 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;extensions에 CA관련 부분이 없다는 것을 확인 할 수 있습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1710898588327&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl x509 -in leaf.pem -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            15:01:be:40:d1:b4:5f:72:6d:b1:d2:df:2b:18:1f:d7:14:d6:a5:2e
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=KR, ST=Seoul, L=Yeouido, O=IntermediateCA, OU=IntermediateCA, CN=localhost
        Validity
            Not Before: Mar 20 01:32:41 2024 GMT
            Not After : Mar 20 01:32:41 2025 GMT
        Subject: C=KR, ST=Seoul, L=Yeouido, O=Leaf, OU=Leaf, CN=localhost
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:c6:26:c9:b3:...
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                47:9C:51:5B:9E:12:C8:C8:1C:A6:BB:FE:28:63:18:0A:C3:89:9C:D7
            X509v3 Authority Key Identifier:
                85:29:C1:4C:3D:2A:87:CC:A4:88:DA:FD:89:8B:BE:71:BE:BD:9D:83
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        58:7c:1e:57:c1:3d:...&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style8&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 검증&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 RootCA, IntermediateCA 인증서를 이용해 Leaf 인증서가 제대로 발급되었는지 확인해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 RootCA 인증서와 IntermediateCA 인증서를 이어붙여 &lt;b&gt;trustChain&lt;/b&gt;을 만들겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1710898851440&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cat rootCA.pem intermediateCA.pem &amp;gt; trustChain.pem&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;openssl은 검증할 때, 인증서 체인을 따라 올라가며 끝까지 검증을 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;따라서 위처럼 trustChain을 만들어 해주는 것이 편리합니다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;  이 trustChain으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;RootCA부터 Leaf 인증서까지 모두 정상적으로 검증되는 것을 아래 사진을 통해 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;170&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eDLCd2/btsFUeEGTQW/totkcF2XJh9pKxovQcvSYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eDLCd2/btsFUeEGTQW/totkcF2XJh9pKxovQcvSYK/img.png&quot; data-alt=&quot;TrustChain 인증서 검증&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eDLCd2/btsFUeEGTQW/totkcF2XJh9pKxovQcvSYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeDLCd2%2FbtsFUeEGTQW%2FtotkcF2XJh9pKxovQcvSYK%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;695&quot; height=&quot;114&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;170&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;TrustChain 인증서 검증&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 위처럼 trustChain을 만들지 않고 아래처럼 바로 IntermediateCA 인증서로 Leaf 인증서를 검증하려고 한다면,&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;1232&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmp5xN/btsFW9uSb8K/zNUE8kOsPqyVnp2Bh5VoeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmp5xN/btsFW9uSb8K/zNUE8kOsPqyVnp2Bh5VoeK/img.png&quot; data-alt=&quot;Leaf 인증서 - 검증실패&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmp5xN/btsFW9uSb8K/zNUE8kOsPqyVnp2Bh5VoeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbmp5xN%2FbtsFW9uSb8K%2FzNUE8kOsPqyVnp2Bh5VoeK%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;692&quot; height=&quot;66&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Leaf 인증서 - 검증실패&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;이는 IntermediateCA 인증서의 Issuer가 있다고 하는데, -CAfile에서 해당 인증서(RootCA 인증서)를 찾지 못했기 때문에 발생하는 에러입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;b&gt;&lt;span&gt;TroubleShootings&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;1. TrustChain 구성하지 않음&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;처음에는 openssl이 검증할 때 최상단 RootCA 인증서까지 검증한다는 사실을 모르고, IntermediateCA 인증서로 Leaf인증서를 검증하려고 하여 아래와 같은 에러를 계속 맞이하였습니다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1710899815860&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl verify -CAfile wrongCA.pem leaf2.pem
C=KR, ST=Seoul, L=Yeouido, O=WrongCA, OU=WrongCA, CN=localhost
error 2 at 1 depth lookup: unable to get issuer certificate
error leaf2.pem: verification failed
80052A0301000000:error:80000002:system library:file_open:No such file or directory:providers/implementations/storemgmt/file_store.c:263:calling stat(/opt/homebrew/etc/openssl@3/certs)&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;이 부분은 앞서 설명한 것처럼 RootCA 인증서까지 포함한 trustChain을 만듦으로써 해결할 수 있었습니다.&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;2. CA 인증서 발급, not Leaf 인증서&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;trustChain을 구성했음에도 검증에 실패하며 아래와 같은 에러가 발생하였습니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1710899944611&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;openssl verify -CAfile wrongTrustChain.pem leaf2.pem
C=KR, ST=Seoul, L=Yeouido, O=WrongCA, OU=WrongCA, CN=localhost
error 79 at 1 depth lookup: invalid CA certificate
error leaf2.pem: verification failed&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;스택오버플로우에서&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;openssl에서 확장필드값을 설정하지 않고 인증서를 발급하게 되면, default로 &lt;b&gt;CA인증서&lt;/b&gt;가 아니라 &lt;b&gt;Leaf인증서&lt;/b&gt;를 발급한다는 사실을 알게 되었습니다. (&lt;a href=&quot;https://stackoverflow.com/questions/67883761/openssl-verify-fails-if-done-with-multiple-issuer-certificates&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고링크&lt;/a&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;이에 앞서 IntermediateCA 인증서를 발급하는 부분에서 언급한 것처럼 CA 인증서로 지정하는 extension 부분을 설정해줌으로써 정상적으로 검증할 수 있었습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>보안</category>
      <category>basicConstraints</category>
      <category>DEPTH</category>
      <category>intermediate</category>
      <category>invalid CA certificate</category>
      <category>openssl</category>
      <category>rootCA</category>
      <category>unable to get issuer certificate</category>
      <category>인증서체인</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/180</guid>
      <comments>https://marklee1117.tistory.com/180#entry180comment</comments>
      <pubDate>Wed, 20 Mar 2024 11:01:49 +0900</pubDate>
    </item>
    <item>
      <title>[Springboot] Reactive Redis 총 정리(config, generic, test)</title>
      <link>https://marklee1117.tistory.com/178</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;사내에서 Springboot를 사용하면서 Reactive Redis를 쓰고 있습니다.&lt;br /&gt;이번 포스트에서는 config는 어떻게 설정하는지, &lt;span style=&quot;background-color: #f8f9fa; color: #212529; text-align: start;&quot;&gt;테스트 코드 작성방법, &lt;/span&gt;Generic하게 클래스에 맵핑해서 꺼낼 수 있는방법에 대해 정리해보고자 합니다.&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;b&gt;Dependency&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;springboot 3.2.2, java17을 사용하고 있으며, dependency는 아래와 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707193500606&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	compileOnly 'org.projectlombok:lombok:1.18.24'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	testImplementation &quot;org.junit.jupiter:junit-jupiter:5.8.1&quot;

	//test-container
	testImplementation 'org.testcontainers:junit-jupiter'
	testImplementation 'org.testcontainers:testcontainers:1.19.4'
}&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;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Configuration&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에 연결하고 관련 서비스를 만들기 위해서, redis.core의 ReativeRedisOperation이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 Config는 해당 Bean을 등록해주는 과정이라고 생각해주시면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707194772501&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@EnableAutoConfiguration(exclude={RedisAutoConfiguration.class, RedisReactiveAutoConfiguration.class})
public class RedisConfig {
    @Value(&quot;${spring.data.redis.host}&quot;)
    private String host;

    @Value(&quot;${spring.data.redis.port}&quot;)
    private int port;

    @Bean
    public ReactiveRedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public ReactiveRedisOperations&amp;lt;String, Object&amp;gt; redisTemplate() {
        ReactiveRedisConnectionFactory rrcf = redisConnectionFactory();

        Jackson2JsonRedisSerializer&amp;lt;Object&amp;gt; serializer = new Jackson2JsonRedisSerializer&amp;lt;&amp;gt;(Object.class);

        RedisSerializationContext.RedisSerializationContextBuilder&amp;lt;String, Object&amp;gt; builder = RedisSerializationContext
                .newSerializationContext(new StringRedisSerializer());

        RedisSerializationContext&amp;lt;String, Object&amp;gt; context = builder.value(serializer).hashValue(serializer)
                .hashKey(serializer).build();

        return new ReactiveRedisTemplate&amp;lt;&amp;gt;(rrcf, context);
    }
}&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;@EnableAutoConfiguration(exclude={...})&lt;/b&gt;&lt;br /&gt;내가 지정한 host, port, serializer를 등록하기 위한 부분입니다. 이 부분이 없으면 아래와 같은 에러를 보게 될 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;The bean 'redisConnectionFactory', defined in class path resource [org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [com/example/demo/RedisConfig.class] and overriding is disabled.&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Jackson2JsonRedisSerializer&amp;lt;Object&amp;gt; serializer&lt;br /&gt;&lt;/b&gt;&lt;span style=&quot;color: #374151; text-align: left;&quot;&gt;JSON 형식의 데이터를 Redis에 저장하기 위한 직렬화 및 역직렬화를 담당하는 Jackson2JsonRedisSerializer를 생성합니다. 이 때, &lt;/span&gt;Object.class&lt;span style=&quot;color: #374151; text-align: left;&quot;&gt;를 전달하여 어떠한 객체 타입이라도 처리할 수 있도록 합니다. 이후 데이터를 가져올 때, 알맞은 Dto와 맵핑하는 부분은 아래 RedisService에서 구현하겠습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&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: #374151; text-align: left;&quot;&gt;그리고 application.yml에 아래처럼 세팅해주었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1707196681610&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  data:
    redis:
      host: localhost
      password:
      port: 6379&lt;/code&gt;&lt;/pre&gt;
&lt;h1 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;b&gt;redis test - TestContainer&lt;/b&gt;&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 테스트 컨테이너 생성&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레디스를 테스트하는 것에는 embeded redis 혹은 testContainer를 이용하는 2가지 방법이 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 아래와 같은 이유로 &lt;b&gt;testContainer&lt;/b&gt;를 이용하기로 결정하였습니다. 아래코드는 &lt;b&gt;&lt;a href=&quot;https://java.testcontainers.org/quickstart/spock_quickstart/#2-get-testcontainers-to-run-a-redis-container-during-our-tests&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;테스트컨테이너 홈페이지&lt;/a&gt;&lt;/b&gt;를 참조했습니다.&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git clone만 받으면 따로 local에 redis설치 없이도 테스트를 돌릴 수 있다.&lt;/li&gt;
&lt;li&gt;테스트의 독립성을 보장할 수 있다.&lt;/li&gt;
&lt;li&gt;포트 충돌을 고려할 필요 없다.(&lt;b&gt;&lt;a href=&quot;https://jojoldu.tistory.com/297&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고링크&lt;/a&gt;&lt;/b&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선, 아래와 같이 Redis Test Container를 생성해줍니다. 아래 클래스는 &lt;b&gt;BeforeAllCallback&lt;/b&gt;의 구현 클래스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드 내에서 @BeforeAll안에서 사용하지 않고 이렇게 빼는 이유는 아래에서 설명하겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707196157284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.demo;

import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

public class RedisContainer implements BeforeAllCallback{

    private static final String REDIS_DOCKER_IMAGE = &quot;redis:5.0.3-alpine&quot;;

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        GenericContainer&amp;lt;?&amp;gt; REDIS_CONTAINER = new GenericContainer&amp;lt;&amp;gt;(DockerImageName.parse(REDIS_DOCKER_IMAGE))
            .withExposedPorts(6379).withReuse(true);

        REDIS_CONTAINER.start();

        System.setProperty(&quot;spring.data.redis.host&quot;, REDIS_CONTAINER.getHost());
        System.setProperty(&quot;spring.data.redis.port&quot;, REDIS_CONTAINER.getMappedPort(6379).toString());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;위 코드가 의미하는 부분은 간단합니다.&amp;nbsp;&lt;br /&gt;&lt;/b&gt;&lt;span style=&quot;color: #1c7d4d; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;가장 먼저&lt;/span&gt; &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #f5f5f5; color: #1c7d4d; text-align: start;&quot;&gt;&quot;redis:5.0.3-alpine&quot;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; 라는 이미지를 사용하여 REDIS_CONTAINER 내부에서 redis default port인 6379를 expose하도록 세팅합니다.&lt;/span&gt;&lt;b&gt;&lt;br /&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;그리고 실제로 해당 컨테이너를 띄운 후, 해당 컨테이너의 호스트와 컨테이너 내부 포트 6379와 매핑되는 외부포트를 받아와 SystemProperty에 세팅해줍니다.&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;b&gt;2. 테스트코드 작성&lt;/b&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;여기에서 주의하여 볼 점은 @BeforeAll 메소드를 사용하는 것이 아니라, RedisContainer를 따로 뺀 뒤, ExtendWith를 통해 CoffeeTest클래스 생성전에 레디스 컨테이너연결을 하도록 했다는 점입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707196738028&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.demo;

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.testcontainers.junit.jupiter.Testcontainers;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;


@ExtendWith(RedisContainer.class)
@Testcontainers
@Import(RedisService.class)
@SpringBootTest
public class CoffeeTest {

    @Autowired
    private RedisService redisService;

    @Test
    void testCoffee() {
        String TESTKEY = &quot;TEST_KEY&quot;;
        String TESTVALUE = &quot;TEST_VALUE&quot;;

        redisService.setValue(TESTKEY, TESTVALUE).subscribe();
        Mono&amp;lt;String&amp;gt; storedValue = redisService.getCacheValueGeneric(TESTKEY, String.class);

        StepVerifier.create(storedValue)
                .assertNext(value -&amp;gt; assertThat(value).isEqualTo(TESTVALUE))
                .verifyComplete();
    }
}&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;위 테스트를 실행시키면, 아래처럼 testContainer가 실제로 뜨는 것을 확인 할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1795&quot; data-origin-height=&quot;428&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVApyN/btsEttCMfsY/GqGSNz25L9nCEf0SxOfOiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVApyN/btsEttCMfsY/GqGSNz25L9nCEf0SxOfOiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVApyN/btsEttCMfsY/GqGSNz25L9nCEf0SxOfOiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVApyN%2FbtsEttCMfsY%2FGqGSNz25L9nCEf0SxOfOiK%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;1795&quot; height=&quot;428&quot; data-origin-width=&quot;1795&quot; data-origin-height=&quot;428&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;&amp;nbsp;&lt;/p&gt;
&lt;h1 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;&lt;b&gt;redis 서비스 - Generic redis service&lt;/b&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 redis service를 만들어 보겠습니다. 처음 말씀드린 것처럼 Object로 직렬화 시켜 저장시킨 데이터를 다시 역직렬화 시키면서 원하는 DTO에 매핑하는 부분을 ObjectMapper와 Generic 함수를 이용해 &lt;b&gt;getCacheValueGeneric&lt;/b&gt;를 구현해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707197148629&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.data.redis.core.ReactiveRedisOperations;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import reactor.core.publisher.Mono;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class RedisService {
  private final ReactiveRedisOperations&amp;lt;String, Object&amp;gt; redisOps;
  private final ObjectMapper objectMapper;

  public Mono&amp;lt;String&amp;gt; getValue(String key) {
    return redisOps.opsForValue().get(key).map(String::valueOf);
  }

  public Mono&amp;lt;Boolean&amp;gt; setValue(String key, Object value) {
    return redisOps.opsForValue().set(key, value);
  }

  public &amp;lt;T&amp;gt; Mono&amp;lt;T&amp;gt; getCacheValueGeneric(String key, Class&amp;lt;T&amp;gt; clazz) {
      try {
          return redisOps.opsForValue().get(key)
              .switchIfEmpty(Mono.error(new RuntimeException(&quot;No Datas for key: &quot; + key)))
              .flatMap(value -&amp;gt; Mono.just(objectMapper.convertValue(value, clazz)));
      } catch (Exception e) {
          e.getStackTrace();
          return Mono.error(new RuntimeException(&quot;error occured!&quot; + e.getMessage()));
      }
  }
}&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실제코드와 연동&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약, 위 서비스 코드를 test-container가 아닌, 실제 redis와 연결해 이용하고 싶다면 redis를 설치 후 redis-server를띄운 뒤, 해당 서비스와 매핑되는 controller 코드를 구현하여 실행시키면 될 것입니다.&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. redis 설치&lt;/h2&gt;
&lt;pre id=&quot;code_1707197445794&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;brew install redis&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. redis-server 실행&lt;/h2&gt;
&lt;pre id=&quot;code_1707197474733&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;redis-server&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. Controller 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 만든 controller코드는 아래와 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1707197392409&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;


@RestController
@RequiredArgsConstructor
public class CoffeeController {
  private final RedisService redisService;

  @GetMapping(&quot;/coffees&quot;)
  public Mono&amp;lt;Coffee&amp;gt; getCoffee(@RequestParam(&quot;id&quot;) String coffeeId) {
    return redisService.getCacheValueGeneric(coffeeId, Coffee.class);
  }

  @PostMapping(&quot;/coffee&quot;)
  public Mono&amp;lt;Boolean&amp;gt; postMethodName(@RequestBody Coffee coffee) {

      return redisService.setValue(coffee.getId(), coffee);
  }
  
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>java,springboot</category>
      <category>redis</category>
      <category>springboot</category>
      <category>TestCode</category>
      <category>TestContainer</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/178</guid>
      <comments>https://marklee1117.tistory.com/178#entry178comment</comments>
      <pubDate>Tue, 6 Feb 2024 14:31:58 +0900</pubDate>
    </item>
    <item>
      <title>Preview 있는 File Input 만들기(Next.js, TS)</title>
      <link>https://marklee1117.tistory.com/177</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://morph-dev.com/posts/file-input&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;현재 만들고 있는 기술블로그(링크)&lt;/a&gt;&lt;/b&gt;에서 포스팅의 편리성을 위해 이미지 업로드를 할 수 있는 기능이 필요했습니다. 이를 위해 사진 이미지를 미리 보여주는 file input 컴포넌트를 만들었고, 이번 포스트에서는 어떻게 구현했는지 정리해 보고자 합니다. CSS에 대한 포스트가 아님으로 CSS 부분은 최소화하고, 구현 로직에 대해 코드로 설명하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;&lt;b&gt;컴포넌트 설계&lt;/b&gt;&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;file-input-sample.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;608&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCGa0q/btsEaPLKIxg/HBmVhLu1AvoVmdGSah3KR1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCGa0q/btsEaPLKIxg/HBmVhLu1AvoVmdGSah3KR1/img.gif&quot; data-alt=&quot;File Input with Preview 작동모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCGa0q/btsEaPLKIxg/HBmVhLu1AvoVmdGSah3KR1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bCGa0q/btsEaPLKIxg/HBmVhLu1AvoVmdGSah3KR1/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;1080&quot; height=&quot;608&quot; data-filename=&quot;file-input-sample.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;608&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;File Input with Preview 작동모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇을 만들기 전에, 내가 만들고자 하는 것의 핵심 기능은 무엇인지, 어떻게 사용할 것인지 정리하는 부분은 정말 중요한 것 같습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;핵심기능&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 구현 하고자 하는 &lt;b&gt;File Input&lt;/b&gt;의 핵심기능은 아래와 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;복수의 이미지 파일들을 버튼을 통해 업로드 할 수 있다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복수의 이미지 파일들을 preview 쪽에 Drag&amp;amp;Drop으로 업로드 할 수 있다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현재 업로드한 파일의 preview를 보여준다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;preview 우측상단의 삭제 버튼을 통해 업로드한 파일을 삭제 할 수 있다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;사용방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 해당 컴포넌트를 아래와 같은 방식으로 사용하고 싶습니다.&lt;br /&gt;File 리스트 타입을 가진 useState를 선언하고, files와 setFiles 함수만 간단히 전달하면 나머지 preview, 삭제 버튼 등의 기능은 내부적으로 처리가 되었으면 좋겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 선택된 파일들을 서버에 보낼 때에는 files를 사용하면 될 것 입니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;//...
const [files, setFiles] = useState&amp;lt;File[]&amp;gt;([]);
//...
&amp;lt;FileInput files={files} setFiles={setFiles} /&amp;gt;
//...&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;&lt;b&gt;기능 구현&lt;/b&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Input태그를 사용하는 방법자체는 매우 간단합니다. 아래처럼 type을 file로, 그리고 multiple 속성을 주면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;//accept는 Input이 허용할 파일 유형을 나타냅니다.
&amp;lt;input type=&quot;file&quot; multiple accept=&quot;image/*&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 태그를 그대로 사용해도 되지만, CSS를 커스텀해주기 위해서는 아래의 추가 작업이 필요합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;input에 display:none; 속성을 줘서 보이지 않게 한다.&lt;/li&gt;
&lt;li&gt;useRef를 활용하여, 커스텀 버튼이 클릭되면 Input태그가 클릭되게 한다.&lt;/li&gt;
&lt;li&gt;팝업된 파일선택창에서 파일은 선택하면, trigger되는 이벤트인 change에 이벤트 리스너를 단다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JSX 구조 잡기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 JSX구조를 먼저 잡아 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;jsx-structure.png&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oZWJ3/btsD7onu5Gk/SZjL0aPdSzHpj8WKStGsqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oZWJ3/btsD7onu5Gk/SZjL0aPdSzHpj8WKStGsqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oZWJ3/btsD7onu5Gk/SZjL0aPdSzHpj8WKStGsqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoZWJ3%2FbtsD7onu5Gk%2FSZjL0aPdSzHpj8WKStGsqK%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;750&quot; height=&quot;482&quot; data-filename=&quot;jsx-structure.png&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;482&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크게 &lt;b&gt;타이틀&lt;/b&gt;, &lt;b&gt;Preview&lt;/b&gt;, &lt;b&gt;CustomButton&lt;/b&gt; 으로 구성되어 있습니다.&lt;br /&gt;Input태그에는 &lt;b&gt;display:none;&lt;/b&gt; 속성을 부여했고, &lt;b&gt;버튼을 클릭할 경우, useRef를 활용하여 Input태그를 클릭하게 했습니다.&lt;/b&gt;&lt;br /&gt;Preview지역에 Drop이벤트가 일어나거나, File을 선택하는 이벤트가 발생하면, &lt;b&gt;selectFile&lt;/b&gt;가 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;&amp;lt;Top05&amp;gt;사진첨부&amp;lt;/Top05&amp;gt;
&amp;lt;FileThumbnails
    files={files}
    deleteFileHandler={deleteFile}
    addFileHandler={() =&amp;gt; inputRef.current?.click()}
    onDrop={selectFile}
/&amp;gt;
&amp;lt;Spacing size={15} /&amp;gt;
&amp;lt;Button
    onClick={() =&amp;gt; inputRef.current?.click()}
    style=&quot;outline&quot;
    fontSize=&quot;0.75rem&quot;
    btnType=&quot;button&quot;
&amp;gt;
    사진을 추가해주세요
&amp;lt;/Button&amp;gt;
&amp;lt;input
    id=&quot;file&quot;
    ref={inputRef}
    type=&quot;file&quot;
    multiple
    accept=&quot;image/*&quot;
    style={{ display: 'none' }}
    onChange={selectFile}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;selectFile 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 선택할 때 실행되는 selectFile 함수를 구현해보도록 하겠습니다.&lt;br /&gt;&lt;b&gt;drop이벤트&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;기존에 추가된 파일이 있는 경우, 덮어 쓰는 것이 아니기 때문에, spread문법을 이용해 파일을 추가해주었습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function FileInput({ files, setFiles }: FileInputProps) {
    //...
    function selectFile(
        e: React.DragEvent | React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;
    ) {
        e.preventDefault();
        let selectedFiles = [] as File[];
        if (e.type === 'drop') {
            const event = e as React.DragEvent;
            //Drop인 경우, dataTransfer 속성안에서 files를 찾을 수 있다.
            selectedFiles = Array.from(event.dataTransfer.files);
        } else if (e.type === 'change') {
            const inputEl = e.target as HTMLInputElement;
            //Change인 경우, event.target.files에서 files를 찾을 수 있다.
            selectedFiles = inputEl.files ? Array.from(inputEl.files) : [];
        }

        setFiles((prev) =&amp;gt; [...prev, ...selectedFiles]);
    }
    //...
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Preview - URL.createObjectURL&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;selectFile을 통해 추가된 파일들을 미리 보여 주는 기능을 URL.createObjectURL()을 이용하여 구현했습니다.&lt;/p&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;URL.createObjectURL&lt;/b&gt;는 File, Blob, MediaSource객체를 인자로 받고, 전달받은 객체를 가리키는 URL을 DOMString으로 변환하여 돌려줍니다.&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/URL/createObjectURL_static&quot;&gt;(API 세부 내역링크)&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;앞서 저는 선택한 파일들의 리스트(File[])를 FileThumbnails에 files props로 내려줬습니다. FileThumbnails에서 아래처럼 map을 이용해 순회하면서 렌더 해주었습니다.&lt;br /&gt;Image는 next/image를 사용했고, src 부분에 집중해 주시면 되겠습니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;//FileThumbnails.tsx
type FileThumbnailsProps = {
    files: File[];
    deleteFileHandler?: Function;
    addFileHandler?: MouseEventHandler&amp;lt;HTMLDivElement&amp;gt;;
    onDrop?: Function;
};

export default function FileThumbnails({
    files,
    deleteFileHandler,
    addFileHandler,
    onDrop
}: FileThumbnailsProps) {
//...
    {files.map((file) =&amp;gt; (
        &amp;lt;div className={classes.image} key={file.name}&amp;gt;
            &amp;lt;Image
                src={URL.createObjectURL(file)}
                width={80}
                height={80}
                sizes=&quot;80px&quot;
                alt={file.name}
                placeholder=&quot;blur&quot;
                blurDataURL={URL.createObjectURL(file)}
            /&amp;gt;
            &amp;lt;div
                className={classes.delete__btn}
                onClick={() =&amp;gt; clickDeleteBtn(file.name)}
            &amp;gt;
                &amp;lt;CloseIcon size=&quot;0.8rem&quot; /&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    ))}
}
//...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 추가한 파일들의 preview를 볼 수 있습니다.&lt;br /&gt;참고로, 여기에서 preview 우측 상단에 delete 버튼을 추가하고, 클릭 시 clickDeleteBtn 함수를 호출하게 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Drop이벤트 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 FileThumbnails에 onDrop Props로 selectFile을 넘겨줬습니다. 이 부분을 어떻게 마무리해줘야 하는지 간단히 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Thumbnails 컴포넌트 최상단 요소에 아래와 같이 이벤트 리스너를 설정해 주어야 합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 우리가 파일을 드래그 &amp;amp; 드롭하게 되면 생기는 이벤트들 중 drop 이벤트가 일어났을 때에만 원하는 함수를 실행시키기 위함입니다.&lt;br /&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/HTML_Drag_and_Drop_API&quot;&gt;(드래그 &amp;amp; 드랍시 발생하는 이벤트 리스트 참고)&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;&amp;lt;div className={classes.image__wrapper}
    onDragOver={(e) =&amp;gt; {
        e.preventDefault();
        e.stopPropagation();
    }}
    onDragEnter={(e) =&amp;gt; {
        e.preventDefault();
        e.stopPropagation();
    }}
    onDrop={(e) =&amp;gt; {
        e.preventDefault();
        e.stopPropagation();
        onDrop &amp;amp;&amp;amp; onDrop(e)
    }
    }
&amp;gt;
    //...
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 우리가 파일을 드래그 &amp;amp; 드롭 했을 때 정상적으로 selectFile이 실행되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추가 파일 제거 기능&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가된 파일을 목록에서 제거 하는 방법은 아래 처럼 fileName을 이용해 줬습니다.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;//FileInput.tsx
//...
function deleteFile(fileName: string) {
    setFiles((prev) =&amp;gt; prev.filter((file) =&amp;gt; file.name !== fileName));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자! 이제 Preview를 보여주는 커스텀 File Input 컴포넌트가 모두 완성되었습니다.&lt;br /&gt;확실히 각 기능을 담당하는 컴포넌트들을 미리 만들어 놓은 상태에서 위에서 기술한 &lt;b&gt;JSX 구조 잡기&lt;/b&gt;에서처럼 코드를 작성하다 보니, 가독성도 좋고 개발 시간도 줄일 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 필요한 기능이 있으면 바로바로 기능들을 하나씩 추가할 수 있다는 부분이 참 재미있고, 자신만의 기술 블로그를 가지는 매력인 듯합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시 부족한 부분이 있거나 궁금한점이 있으면 언제든지 댓글로 문의 부탁드립니다!&lt;/p&gt;</description>
      <category>React</category>
      <category>File Input</category>
      <category>next</category>
      <category>preview</category>
      <category>react</category>
      <category>Thumbnail</category>
      <category>ts</category>
      <category>typescript</category>
      <category>썸네일</category>
      <category>파일 업로드</category>
      <category>파일 이미지</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/177</guid>
      <comments>https://marklee1117.tistory.com/177#entry177comment</comments>
      <pubDate>Mon, 29 Jan 2024 13:25:00 +0900</pubDate>
    </item>
    <item>
      <title>[ToC] 목차 컴포넌트 (feat. React) 구현</title>
      <link>https://marklee1117.tistory.com/175</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;요즘 너무 바쁘지만, 주말을 맞아 NextJS로 만들고 있던 Blog에 ToC 컴포넌트 만들어 봤습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; 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;구현한 최종 모습은 아래와 같을 것입니다. (영상을 찍으면서 box-shadow효과가 뭉개져서 나오는데 이 부분은 무시해주세요..ㅎ)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;toc.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;608&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GduLZ/btsDKaJYDdf/pJKizzp2JRllck26yGIyM0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GduLZ/btsDKaJYDdf/pJKizzp2JRllck26yGIyM0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GduLZ/btsDKaJYDdf/pJKizzp2JRllck26yGIyM0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/GduLZ/btsDKaJYDdf/pJKizzp2JRllck26yGIyM0/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;1080&quot; height=&quot;608&quot; data-filename=&quot;toc.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;608&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;주요 기능사항&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;포스트의 HTag를 기반으로 목차를 만든다(범위 : h1 ~ h3)&lt;/li&gt;
&lt;li&gt;목차를 열고 닫을 수 있어야 한다.
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;ToC 컴포넌트가 메인컴포넌트가 겹치지 않는다면 열려 있는 것이 default이어야 한다.&lt;/li&gt;
&lt;li&gt;겹친다면, 닫혀 있는 것이 default이어야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;현재 읽고 있는 부분이 ToC 컴포넌트에 색상으로 표시되어야 한다.&lt;/li&gt;
&lt;li&gt;ToC컴포넌트에서 클릭 시, 해당 컨텐츠로 이동해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;구현 로직&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;post 본문을 감싸는 태그에 유니크한 아이디를 부여한다.(e.g. #content__entry__point)&lt;br /&gt;(물론, 본문을 string으로 넘겨 처리하는 방법도 존재하지만, tistory와 같은 곳에서도 사용할 수 있도록 이러한 방법을 선택했습니다.)&lt;/li&gt;
&lt;li&gt;본문에 있는 h1~h3 태그들을 모두 찾는다.&lt;/li&gt;
&lt;li&gt;찾은 hTag들을 기반으로 목차에 넣을 li 요소를 만들어준다.&lt;br /&gt;(이때 만들면서 클릭하면, 해당요소의 위치로 이동하는 eventListner도 같이 설정 해 줍니다.)&lt;/li&gt;
&lt;li&gt;현재 focused Element를 찾아 표시한다.&lt;/li&gt;
&lt;li&gt;스크롤 이벤트가 일어날 때 마다 focused Element를 찾아 표시해준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 하단에 전체 구현코드를 첨부하도록 하고, 바로 TroubleShooting으로 넘어가도록 하겠습니다.&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;style3&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;TroubleShootings&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[&lt;b&gt;메인 컨텐츠 위치 찾기 - &lt;/b&gt;Problem] 잘못된 방법(IntersectionObserver)&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 로직을 구현하는 부분 자체는 크게 어렵지 않았습니다. 다만, 현재 읽고 있는 부분을 어떻게 정의하고 이를 찾는 방법을 구현하는 것에 가장 많은 시간이 소모되었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 처음 생각 했던 방법은 &lt;b&gt;IntersectionObeserver&lt;/b&gt; 였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 화면에 보이고 있는 hTag를 찾고, 상단 20%정도안에 위치한 hTag중 가장 처음에 있는 태그를 메인 컨텐츠로 설정하면 어떨까 했습니다. 하지만, 이 방법은 아래 사진에서 볼 수 있듯이 컨텐츠양이 커질 경우 문제가 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1796&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjs2gF/btsDJUHnOQx/GVZoW7J0W01SXzzL4k99J1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjs2gF/btsDJUHnOQx/GVZoW7J0W01SXzzL4k99J1/img.png&quot; data-alt=&quot;ToC가 메인컨텐츠를 보여주지 못하고 있는 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjs2gF/btsDJUHnOQx/GVZoW7J0W01SXzzL4k99J1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcjs2gF%2FbtsDJUHnOQx%2FGVZoW7J0W01SXzzL4k99J1%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;589&quot; height=&quot;384&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1796&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ToC가 메인컨텐츠를 보여주지 못하고 있는 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사진을 보면 &lt;b&gt;현재 메인 컨텐츠는 올드타운하우스 부분&lt;/b&gt;이지만, &lt;b&gt;ToC에서는 그 하단에 있는 도르트문트 U-타워&lt;/b&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;이 문제는 메인 컨텐츠가 길어져서 해당 제목(HTag)이 화면에 더 이상 보이지 않는 경우 발생&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[&lt;b&gt;메인 컨텐츠 위치 찾기 -&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;Solve] 직접 메인 컨텐츠 판단하는 함수 개발&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 원하는 방식으로 메인컨텐츠를 찾아내기 위해 아래처럼 직접 함수를 만들기로 했습니다.&lt;br /&gt;최대한 자세히 주석을 달도록 하였지만, 궁금하시거나 잘못된 부분이 있다면 댓글로 알려주세요.&lt;/p&gt;
&lt;pre id=&quot;code_1705743555600&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 진짜 내가 보고 있는 컨텐츠의 헤드를 찾아주는 함수
 * 단순히 ObserverIntersection을 이용하면, 컨텐츠가 길어지는 경우, HTag가 보이지 않으면
 * 내가 보고 있는 컨텐츠가 아니라, 하단에 아직 읽지 않고 있는 hTag에 포커스가 가는 것을 방지하기 위함
 * @param mainContentHeight
 * @returns
 */
function getMainElementAtMainContentHeight(mainContentHeight: number) {
    // 메인 컨텐츠의 높이(mainContentHeight)
    // 이전 태그의 TOP값(PT)
    // 이후 태그의 TOP값(NT)
    // MainTag(MT)
    // 조건: 항상 PT &amp;lt; NT
    // 경우의 수
    // 1. PT &amp;lt;= mainContentHeight &amp;lt; NT ==&amp;gt; MT = PT
    // 2. mainContentHeight &amp;lt; PT ==&amp;gt; MT = PT
    // 3. PT &amp;lt; mainContentHeight &amp;amp;&amp;amp; NT &amp;lt;= mainContentHeight ==&amp;gt;  MT = NT
    let MT = hTags[0];
    for (let i = 1; i &amp;lt; hTags.length; i++) {
        const PT = hTags[i];
        if (
            mainContentHeight &amp;lt; MT.offsetTop &amp;amp;&amp;amp;
            mainContentHeight &amp;lt; PT.offsetTop
        ) {
            return MT;
        } else if (
            MT.offsetTop &amp;lt; mainContentHeight &amp;amp;&amp;amp;
            mainContentHeight &amp;lt;= PT.offsetTop
        ) {
            return MT;
        } else {
            MT = PT;
        }
    }
    return MT;
}&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;이 함수를 이용하여 아래처럼 컨텐츠가 길어져서 현재 보이는 HTag는 This is the Second H1 Tag 밖에 없더라도 &lt;br /&gt;메인 컨텐츠는 상단의 andSomeLongPart 태그라고 판단 할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;818&quot; data-origin-height=&quot;1222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TFqqb/btsDJT2L3Ye/qnhMXmdTq7ApDnQ1fnbnD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TFqqb/btsDJT2L3Ye/qnhMXmdTq7ApDnQ1fnbnD0/img.png&quot; data-alt=&quot;ToC 메인 컨텐츠 판단 함수 활용 예시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TFqqb/btsDJT2L3Ye/qnhMXmdTq7ApDnQ1fnbnD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTFqqb%2FbtsDJT2L3Ye%2FqnhMXmdTq7ApDnQ1fnbnD0%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;441&quot; height=&quot;659&quot; data-origin-width=&quot;818&quot; data-origin-height=&quot;1222&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ToC 메인 컨텐츠 판단 함수 활용 예시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;b&gt;[현재 보고 있는 위치 찾기 - Problem] 잘못된 방법: window.innerHeight&lt;/b&gt;&lt;/b&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;window.innerHeight로 판단하면 될 것 같았는데, 이 방식으로는 스크롤에 따라 변하는 내가 현재 보고 있는 위치를 알 수 없었습니다.&amp;nbsp;&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;b&gt;&lt;b&gt;[현재 보고 있는 위치 찾기 - Solve] innerHeight + scrollY + Offset&lt;/b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 실시간으로 내가 보고 있는 위치를 찾는 방법은 어렵지 않았습니다. innerHeight와 scrollY를 통해 현재 위치를 찾을 수 있었고, 따로 offset을 추가 하면서 정확히 화면상단 n%의 높이를 찾을 수 있었습니다.&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;window.innerHeight / 2 : 화면의 전체 뷰포트 높이를 픽셀로 나타낸 수의 절반&lt;/li&gt;
&lt;li&gt;window.scrollY : 원점으로부터 문서를 수직방향으로 스크롤한 픽셀 수&lt;/li&gt;
&lt;li&gt;mainContentOffset : 기준점을 화면 상단으로부터 30%인 지점으로 잡기 위한 offset&lt;br /&gt;(e.g. 화면 상단으로부터 20%를 기준점으로 잡으려면 mainContentOffset을 0.3으로 조정)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1705746241948&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function setFocusedElement() {
    const mainContentOffset = 0.2;
    const mainContentHeight =
        window.innerHeight * (0.5 - mainContentOffset) + window.scrollY;

    const mainTag =
        getMainElementAtMainContentHeight(mainContentHeight);
    const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);

    focusedTag.current?.classList.remove(classes.focused);
    focusedTocTag?.classList.add(classes.focused);
    focusedTag.current = focusedTocTag ?? null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[&lt;b&gt;ToC요소 클래스명 부여 - Problem&lt;/b&gt;] 잘못된 방법: focuse변할 때마다 리렌더링(feat. useState)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 개발한 함수를 통해 메인 컨텐츠를 찾는 부분은 해결했지만, 여기에서 찾은 HTag는 본문의 태그이기에 ToC컴포넌트의 Element에 제가 원하는 클래스(&quot;.focused&quot;)를 부여해주는 작업이 필요했습니다. 처음에는 그냥 ToC순회 하면서 li Element안의 innerHTML이 내가 찾은 HTag의 innerHTML과 같은 경우에 &quot;.focused&quot;클래스를 부여해주려고 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이 경우에 focused가 바뀌었을 때, 제거 하는 작업을 하기위해 또 순회해야 하는 문제가 생겼습니다.&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;focusedTag를 state로 관리&lt;/b&gt;를 하려고 하였지만, 이렇게 되면 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;focusedTag가 변할 때 마다 ToC컴포넌트가 리렌더링되는 문제&lt;/b&gt;&lt;/span&gt;가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[&lt;b&gt;&lt;b&gt;ToC요소 클래스명 부여&lt;/b&gt;&lt;/b&gt;&lt;b&gt;&amp;nbsp;- Solve&lt;/b&gt;] Map으로 관리(feat. useRef)&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 해결하기 위해 ToC의&lt;b&gt; li Element를 Map으로 관리&lt;/b&gt;하고, &lt;b&gt;focused Element는 useRef로 관리&lt;/b&gt;하기로 했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;hTag를 순회하며 ToC의 li Element를 생성해주는 과정에서 &lt;b&gt;실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가지는 Map을 생성&lt;/b&gt;해주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1705744711591&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//...
const hTagToTocElMapper = new Map&amp;lt;String, HTMLElement&amp;gt;();
//...
//HTag를 돌면서, Toc에 넣을 Element를 생성해서 넣어준다.
hTags?.forEach((hTag) =&amp;gt; {
    const liEl = document.createElement('li');
    const linkEl = document.createElement('a');

    //...
    //liElement를 생성하면서 바로 Map에 넣어준다. 
    //실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가진다.
    hTagToTocElMapper.set(hTag.innerHTML, liEl);
    listContainer?.appendChild(liEl);
});&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;그리고 하단처럼 focusedElement를 세팅해주는 함수에서 아래처럼 기존에 있는 focusedTag의 클래스를 삭제하고, 현재 focusedTag에 클래스를 부여하고 useRef에 세팅해주는 방식으로 해결하였습니다. 이를 통해 리렌더 되지 않고도 focusedTag를 적절히 관리 할 수 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1705744933413&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//...
const focusedTag = useRef&amp;lt;HTMLElement | null&amp;gt;(null);
//...
function setFocusedElement() {
	//...
    const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);

    focusedTag.current?.classList.remove(classes.focused);
    focusedTocTag?.classList.add(classes.focused);
    focusedTag.current = focusedTocTag ?? 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;이를 통해 앞서 보여드린 결과물 처럼 메인 컨텐츠에 따라 focused 된 부분을 표현 할 수 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;사용방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용방법은 아래와 같습니다.&lt;br /&gt;여기에서 주의할 점은 본문이 있는 CustomMarkdown 태그를 content__entry__point id를 가진 div로 감싸줬다는 점입니다.&lt;br /&gt;그리고, 하단에 ToC컴포넌트를 호출해주면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1705745558325&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div className={classes.content__wrapper}&amp;gt;
    &amp;lt;FadeIn from=&quot;left&quot;&amp;gt;
            &amp;lt;div id=&quot;content__entry__point&quot;&amp;gt;
                &amp;lt;CustomMarkdown components={post} /&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/article&amp;gt;
    &amp;lt;/FadeIn&amp;gt;
    &amp;lt;Toc title={post.title}&amp;gt;&amp;lt;/Toc&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;구현코드&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 구현코드는 아래와 같습니다. (Next.js를 사용하고 있기에 use clinet로 client 컴포넌트임을 명시했습니다.)&lt;br /&gt;내부 구현 로직에 대해서는 최대한 자세히 주석을 달았습니다.&lt;br /&gt;잘못된 점이 있거나 궁금한 점이 있다면 댓글로 남겨주시면 감사하겠습니다!&lt;/p&gt;
&lt;pre id=&quot;code_1705745642567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'use client';

import classes from './toc.module.scss';
import ListIcon from './icons/list-icon';
import CloseIcon from './icons/close-icon';
import { useEffect, useRef, useState } from 'react';

type TocProps = {
	title?: string;
};
const levelMap: { [key: string]: string } = {
	H1: 'lv1',
	H2: 'lv2',
	H3: 'lv3',
};

export default function Toc({ title }: TocProps) {
	const [tocOpened, setTocOpened] = useState(false);
	const [hTags, setHTags] = useState&amp;lt;[] | NodeListOf&amp;lt;HTMLElement&amp;gt;&amp;gt;([]);
	const focusedTag = useRef&amp;lt;HTMLElement | null&amp;gt;(null);

	console.log('hmm');
	//처음 렌더 후, hTag 모두 찾기.
	useEffect(() =&amp;gt; {
		const entryPoint = document.querySelector('#content__entry__point');
		const hTagEls = entryPoint?.querySelectorAll(
			'h1, h2, h3'
		) as NodeListOf&amp;lt;HTMLElement&amp;gt;;
		setHTags(hTagEls);

		//현재 창 넓이가 1350 이하면 ToC와 메인 컴포넌트가 겹친다.
		//그 이상이라면, ToC를 처음에 열어준다.
		const width = window.innerWidth;
		if (width &amp;gt;= 1350) {
			setTocOpened(true);
		}
	}, []);

	//hTag를 다 찾으면, 아래 로직 실행
	useEffect(() =&amp;gt; {
		//실제 HTag에 해당하는 TocElement를 맵핑해주는 Map
		//실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가진다.
		const hTagToTocElMapper = new Map&amp;lt;String, HTMLElement&amp;gt;();
		const listContainer = document?.querySelector('#toc__list');

		//HTag를 돌면서, Toc에 넣을 Element를 생성해서 넣어준다.
		hTags?.forEach((hTag) =&amp;gt; {
			const liEl = document.createElement('li');
			const linkEl = document.createElement('a');

			linkEl.innerHTML = hTag.innerHTML;
			//linkEl를 클릭하면, 해당 Tag를 가진 곳으로 스크롤 해 준다.
			linkEl.addEventListener('click', () =&amp;gt; {
				window.scrollTo({
					top: hTag.offsetTop - 20,
					behavior: 'smooth',
				});
			});
			liEl.appendChild(linkEl);
			const levelClass = levelMap[hTag.tagName];
			liEl.classList.add(classes[levelClass]);

			hTagToTocElMapper.set(hTag.innerHTML, liEl);

			listContainer?.appendChild(liEl);
		});

		//현재 focused Element를 찾아 세팅해준다.
		setFocusedElement();

		//scroll Event가 발생할 때마다 focused Element를 찾아 세팅해준다.
		const findCurTagEvent = (window.onscroll = () =&amp;gt; setFocusedElement());

		function setFocusedElement() {
			// window.innerHeight / 2 : 화면의 전체 뷰포트 높이를 픽셀로 나타낸 수의 절반
			// window.scrollY : 원점으로부터 문서를 수직방향으로 스크롤한 픽셀 수
			// mainContentOffset : 기준점을 화면 상단으로부터 30%인 지점으로 잡기 위한 offset
			// e.g. 화면 상단으로부터 20%를 기준점으로 잡으려면 mainContentOffset을 0.3으로 조정
			const mainContentOffset = 0.2;
			const mainContentHeight =
				window.innerHeight * (0.5 - mainContentOffset) + window.scrollY;

			const mainTag =
				getMainElementAtMainContentHeight(mainContentHeight);
			const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);

			focusedTag.current?.classList.remove(classes.focused);
			focusedTocTag?.classList.add(classes.focused);
			focusedTag.current = focusedTocTag ?? null;
		}

		/**
		 * 진짜 내가 보고 있는 컨텐츠의 헤드를 찾아주는 함수
		 * 단순히 ObserverIntersection을 이용하면, 컨텐츠가 길어지는 경우, HTag가 보이지 않으면
		 * 내가 보고 있는 컨텐츠가 아니라, 하단에 아직 읽지 않고 있는 hTag에 포커스가 가는 것을 방지하기 위함
		 * @param mainContentHeight
		 * @returns
		 */
		function getMainElementAtMainContentHeight(mainContentHeight: number) {
			// 메인 컨텐츠의 높이(mainContentHeight)
			// 이전 태그의 TOP값(PT)
			// 이후 태그의 TOP값(NT)
			// MainTag(MT)
			// 조건: 항상 PT &amp;lt; NT
			// 경우의 수
			// 1. PT &amp;lt;= mainContentHeight &amp;lt; NT ==&amp;gt; MT = PT
			// 2. mainContentHeight &amp;lt; PT ==&amp;gt; MT = PT
			// 3. PT &amp;lt; mainContentHeight &amp;amp;&amp;amp; NT &amp;lt;= mainContentHeight ==&amp;gt;  MT = NT
			let MT = hTags[0];
			for (let i = 1; i &amp;lt; hTags.length; i++) {
				const PT = hTags[i];
				if (
					mainContentHeight &amp;lt; MT.offsetTop &amp;amp;&amp;amp;
					mainContentHeight &amp;lt; PT.offsetTop
				) {
					return MT;
				} else if (
					MT.offsetTop &amp;lt; mainContentHeight &amp;amp;&amp;amp;
					mainContentHeight &amp;lt;= PT.offsetTop
				) {
					return MT;
				} else {
					MT = PT;
				}
			}
			return MT;
		}

		//걸어줬던 스크롤 이벤트를 clean-up 해준다.
		return findCurTagEvent;
	}, [hTags]);

	return (
		&amp;lt;&amp;gt;
			&amp;lt;div
				className={`${classes.icon} ${tocOpened &amp;amp;&amp;amp; classes.opened}`}
				onClick={() =&amp;gt; setTocOpened((prev) =&amp;gt; !prev)}
			&amp;gt;
				&amp;lt;ListIcon size=&quot;25px&quot; /&amp;gt;
			&amp;lt;/div&amp;gt;
			{hTags.length &amp;gt; 0 &amp;amp;&amp;amp; (
				&amp;lt;div
					className={`${classes.toc} ${tocOpened &amp;amp;&amp;amp; classes.opened}`}
				&amp;gt;
					&amp;lt;nav&amp;gt;
						&amp;lt;header className={`${classes.title}`}&amp;gt;
							&amp;lt;div
								className={classes.close__icon}
								onClick={() =&amp;gt; setTocOpened(false)}
							&amp;gt;
								&amp;lt;CloseIcon size=&quot;12.5px&quot; /&amp;gt;
							&amp;lt;/div&amp;gt;
							&amp;lt;p&amp;gt;{title ? title : 'Table Of Contents'}&amp;lt;/p&amp;gt;
						&amp;lt;/header&amp;gt;
						&amp;lt;div className={`${classes.toc__body}`}&amp;gt;
							&amp;lt;ul
								className={classes.toc__list}
								id=&quot;toc__list&quot;
							&amp;gt;&amp;lt;/ul&amp;gt;
						&amp;lt;/div&amp;gt;
					&amp;lt;/nav&amp;gt;
				&amp;lt;/div&amp;gt;
			)}
		&amp;lt;/&amp;gt;
	);
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>React</category>
      <category>innerHeight</category>
      <category>intersection</category>
      <category>nextjs</category>
      <category>Observer</category>
      <category>react</category>
      <category>scrollY</category>
      <category>Table of Contents</category>
      <category>TOC</category>
      <category>useRef</category>
      <category>목차</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/175</guid>
      <comments>https://marklee1117.tistory.com/175#entry175comment</comments>
      <pubDate>Sat, 20 Jan 2024 21:00:53 +0900</pubDate>
    </item>
    <item>
      <title>Content-Type vs Accept vs responseType(feat. XHR, ajax)</title>
      <link>https://marklee1117.tistory.com/174</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;HTTP 통신에서 사용하는 headers 중 Content-Type과 Accept, 그리고 XHR 프로퍼티중 하나인 responseType에 대해 비교하고 정리해보고자 합니다.&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;800&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dZT6Sx/btsC53Tk8RC/hkCXKDmaNPoObZG3ep3gB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dZT6Sx/btsC53Tk8RC/hkCXKDmaNPoObZG3ep3gB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dZT6Sx/btsC53Tk8RC/hkCXKDmaNPoObZG3ep3gB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdZT6Sx%2FbtsC53Tk8RC%2FhkCXKDmaNPoObZG3ep3gB0%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;224&quot; height=&quot;224&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Content-Type&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Content-Type은 Client가 서버로 보내는 HTTP요청의 Message Body가 가지고 있는 리소스 타입에 대해 알려주는 역할을 합니다.&lt;/b&gt; 예를 들어, 아래처럼 간단한 HTTP POST요청을 한다고 하였을 때, body에 들어가 있는 타입을 알려주는 역할을 한다는 것입니다. 여기에서는 json 형식이니, &quot;application/json&quot;을 넣어 주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1704673731853&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fetch('some/url', {
	method: 'POST',
	headers: {
		'Content-Type': 'application/json'
	},
	body: JSON.stringify({ key: 'value' })
})&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;formData&lt;/b&gt;를 보낸다고 한다면 &lt;b&gt;&quot;Content-Type&quot;: &quot;multipart/form-data&quot;&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;만약에 POST요청을 &quot;Content-Type&quot;을 설정하지 않고 보낸다면, default는 &quot;application/x-www-form-urlencoded&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0c0d0e;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&quot;이게 되고,&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #0c0d0e;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;FormData를 보내는 경우에는 &quot;multipart/form-data&quot;가 default로 설정되게 됩니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Accept&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 자신이 원하는 미디어 타입 및 우선순위를 알려주기 위해 사용됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 서버한테 나한테 이런 종류의 데이터를 응답으로 보내줘 라고 알려주는 역할이라고 생각하면 이해하기 쉽습니다.&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;style3&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;b&gt;responseType&lt;/b&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;responseType은 서버의 응답 데이터를 XHR Object가 어떤 포맷으로 Parse할 지 결정하는 속성입니다. 서버가 보내는 응답 데이터의 타입에 대해 정의한다는 점은 Accept와 같지만, 사용되는 level에서 차이가 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞서 설명 한 것처럼, Accept header는 Client가 서버에 요청할 때 자신이 받고 싶은 데이터의 타입을 설정하는 부분입니다. 이 header를 확인한 서버는 Accept에 맞도록 응답을 보내려고 할 것입니다.&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;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;반면 XHR 객체의 responseType은 서버가 이미 보낸 응답을 브라우저가 js를 이용해서 어떤 포맷으로 parse할 지 결정할 때 사용되는 속성입니다.&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;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;responseType에 사용될 수 있는 속성은 아래와 같습니다.(&lt;b&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType#document&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고링크&lt;/a&gt;&lt;/b&gt;)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;&quot;&lt;/b&gt;: default 값으로 &quot;text&quot;와 똑같이 처리 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;arraybuffer&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;blob&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;docuemnt&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;json&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;text&quot;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;파일 다운로드시 헤더 example&lt;/b&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;파일 다운로드를 하기 위해&amp;nbsp;&lt;/span&gt;POST요청으로 body에 fileId를 실어 보내고, 서버는 클라이언트에게 byte[]로 응답을 해준다고 가정해봅시다. 이때, 파일다운로드 요청을 보낼 때 어떻게 보내면 좋을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704674289797&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fetch('some/url', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/octet-stream',
    },
    responseType: 'blob' //혹은 'arraybuffer'도 좋습니다.
    body: JSON.stringify({ fId: 'someId' })
})&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Content-Type: 'application/json'&lt;/b&gt; - POST요청의 body에 심은 fileId가 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;json형식이라는 뜻&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Accept: 'application/octet-stream'&lt;/b&gt; - 파일 데이터를 바이너리데이터 형식으로 받고 싶다 라는 뜻&lt;/li&gt;
&lt;li&gt;&lt;b&gt;responseType: 'blob'&lt;/b&gt; - 응답 데이터를 브라우저에서 blob 포맷으로 파싱하겠다는 뜻&lt;/li&gt;
&lt;/ul&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>JavaScript</category>
      <category>accept</category>
      <category>application/octet-stream</category>
      <category>content-Type</category>
      <category>header</category>
      <category>http</category>
      <category>responseType</category>
      <category>xhr</category>
      <category>파일 다운로드</category>
      <category>파일 업로드</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/174</guid>
      <comments>https://marklee1117.tistory.com/174#entry174comment</comments>
      <pubDate>Tue, 9 Jan 2024 10:00:15 +0900</pubDate>
    </item>
    <item>
      <title>[React] Custom useForm 직접 만들기 (feat. zod)</title>
      <link>https://marklee1117.tistory.com/173</link>
      <description>&lt;p style=&quot;margin: 2rem 0px; border-left: 4px solid #20c997; border-top-right-radius: 4px; border-bottom-right-radius: 4px; background: #f8f9fa; padding: 1rem 1rem 1rem 2rem; color: #212529;&quot; data-ke-size=&quot;size16&quot;&gt;최근에 Next.js를 이용해서 내 블로그를 직접 만들고 있습니다. 작업하는 와중에 form 유효성 검사를 좀 간단히 할 수 있는 hook을 만들고 싶어져 zod를 활용하여 한번 만들어 보게 되었습니다. useForm이라는 라이브러리가 있는 것도 알고 있지만, 간단한 것은 직접 만드는게 좋아 직접 만들어 보게 되었고, 공유하고자 글을 정리해봅니다. 어떻게 구현했는지는 코드를 보면 알 수 있으니, &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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;936&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSGpET/btsC1oqgLqg/gLQZZ0kwX1jmh1tcpu4Ff0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSGpET/btsC1oqgLqg/gLQZZ0kwX1jmh1tcpu4Ff0/img.png&quot; data-alt=&quot;useForm&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSGpET/btsC1oqgLqg/gLQZZ0kwX1jmh1tcpu4Ff0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSGpET%2FbtsC1oqgLqg%2FgLQZZ0kwX1jmh1tcpu4Ff0%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;330&quot; height=&quot;193&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;936&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;useForm&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Features&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;우선, 완성된 form은 아래처럼 작동 하게 될 것입니다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;kakaotv&quot; data-video-url=&quot;https://tv.kakao.com/v/443726009&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/rpY4t/hyU2ndHhMn/CJykvlTru3JZSni6xdcFpK/img.jpg?width=1920&amp;amp;height=617&amp;amp;face=0_0_1920_617,https://scrap.kakaocdn.net/dn/bmHfxu/hyUXNkQ9M4/vKYLcqGVLyXtqqqIGHDEw0/img.jpg?width=1920&amp;amp;height=617&amp;amp;face=0_0_1920_617&quot; data-video-width=&quot;860&quot; data-video-height=&quot;276&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;276&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-play-service=&quot;daum_tistory&quot; data-original-url=&quot;&quot; data-video-title=&quot;&quot;&gt;&lt;iframe src=&quot;https://play-tv.kakao.com/embed/player/cliplink/443726009?service=daum_tistory&quot; width=&quot;860&quot; height=&quot;276&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption&gt;useForm 작동 영상&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;useForm&lt;/b&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;각 input을 유효성 체크하는 함수와 에러 메시지를 &lt;b&gt;z.object(using zod)&lt;/b&gt;를 이용해 인자로 전달 할 수 있다.&lt;/li&gt;
&lt;li&gt;submit 버튼을 클릭하기 전에는 유효성 검사를 진행하지 않는다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;submit 버튼을 클릭하면 유효성 검사를 진행하고, 실패한 인풋들은 빨간색 테두리를 부여한다.&lt;/li&gt;
&lt;li&gt;submit 버튼 클릭 이후에는 input에 입력할 때마다, 유효성 체크를 진행한다.&lt;/li&gt;
&lt;li&gt;유효성 검사 실패 시, z.object에 설정한 에러 메시지가 아래에 표시된다.&lt;/li&gt;
&lt;li&gt;모든 인풋이 유효성 검사에 통과 했는지 여부를 알 수 있는 상태 값을 리턴 받는다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;form태그의 onSubmit에 전달된 함수의 로직은 유효성검사가 끝난 이후에 실행된다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&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;b&gt;사용 방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;useForm hook은 아래처럼 사용 할 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;form 태그 안에 label과 input을 생성하고, label의 htmlFor과 input의 name과 id를 같은 값으로 입력해준다.&lt;/li&gt;
&lt;li&gt;form태그에 ref를 걸어준다.&lt;/li&gt;
&lt;li&gt;각 인풋들을 유효성 체크로직을 &lt;b&gt;zod의 z.object를 활용하여 입력&lt;/b&gt;해준다.(이때, 에러 메시지는 { message } 형태로 넣어준다.)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;useForm의 인자&lt;/b&gt;로, &lt;b&gt;formRef와 z.object(schema)&lt;/b&gt;를 넣어 준다.&lt;/li&gt;
&lt;li&gt;useForm은 &lt;b&gt;form 전체가 유효성 검사에 통과했는지 여부를 리턴&lt;/b&gt;한다.&lt;/li&gt;
&lt;li&gt;useForm의 리턴값으로, submitHandler에서 이후 로직을 진행할지 여부를 결정한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1704526689761&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//JS
const schema = z.object({
    email: z.string().email({ message: 'email 형식에 맞지 않습니다.' }),
    name: z.string().min(1, { message: '필수 입력 값 입니다.' }),
});
const formRef = useRef&amp;lt;HTMLFormElement&amp;gt;(null);
const formValid = useFrom(formRef, schema);

function submitHandler(e: FormEvent) {
    e.preventDefault();

    if (!formValid) return;
    console.log('submitted!', formValid);
}

//JSX
&amp;lt;form
    onSubmit={submitHandler}
    ref={formRef}
    noValidate
&amp;gt;
    &amp;lt;div className={classes.control}&amp;gt;
        &amp;lt;label htmlFor=&quot;email&quot; className=&quot;required&quot;&amp;gt;
            Your Email
        &amp;lt;/label&amp;gt;
        &amp;lt;input
            type=&quot;email&quot;
            id=&quot;email&quot;
            name=&quot;email&quot;
            autoComplete=&quot;off&quot;
            required
        /&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div className={classes.control}&amp;gt;
        &amp;lt;label htmlFor=&quot;name&quot; className=&quot;required&quot;&amp;gt;
            Your Name
        &amp;lt;/label&amp;gt;
        &amp;lt;input
            type=&quot;text&quot;
            id=&quot;name&quot;
            name=&quot;name&quot;
            autoComplete=&quot;off&quot;
        /&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div className={classes.actions}&amp;gt;
	    &amp;lt;button type=&quot;submit&quot;&amp;gt;Send Message&amp;lt;/button&amp;gt;
    &amp;lt;/div&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;이렇게 해주면, 위 영상에서처럼 작동하게 됩니다.&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;b&gt;구현 코드&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 구현 코드는 아래와 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704527313575&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { z } from 'zod';
import { RefObject, useEffect, useState } from 'react';

export default function useFrom(
	formRef: RefObject&amp;lt;HTMLFormElement | null&amp;gt;,
	schema: z.ZodObject&amp;lt;any&amp;gt;
) {
	const ERROR_MSG_ID_PREFIX = 'error__msg__';
	const [formStatus, setFormStatus] = useState&amp;lt;{ [key: string]: boolean }&amp;gt;(
		{}
	);
	const [isFirst, setIsFirst] = useState(true);
	const [formValid, setFormValid] = useState(false);
	const [formData, setFormData] = useState&amp;lt;{ [key: string]: any }&amp;gt;({});

	useEffect(() =&amp;gt; {
		const formElement = formRef.current;
		let removeSubmitEventListener: null | Function = null;

		function checkFormValidHandler() {
			const values = Object.values(formStatus);
			return (
				values.length &amp;gt; 0 &amp;amp;&amp;amp;
				Object.values(formStatus).every((valid) =&amp;gt; valid === true)
			);
		}

		function addSubmitEventListenerHandler(formElement: HTMLFormElement) {
			// submit 버튼 클릭 할 때, 실행 되는 함수.
			function handleSubmit(event: SubmitEvent) {
				const target = event.target as HTMLFormElement;
				if (target) {
					setIsFirst(false);
					if (isFirst) {
						addValidateELHandler(target, schema);
					}

					if (checkFormValidHandler()) {
						setFormValid(true);
					} else {
						setFormValid(false);
					}
				}
			}

			formElement.addEventListener('submit', handleSubmit);

			return () =&amp;gt; {
				formElement.removeEventListener('submit', handleSubmit);
			};
		}

		function addValidateELHandler(
			formElement: HTMLFormElement,
			schema: z.ZodObject&amp;lt;any&amp;gt;
		) {
			const inputs = formElement.querySelectorAll('input');
			const textareas = formElement.querySelectorAll('textarea');

			for (const input of inputs) {
				//처음 submit 버튼 클릭했을 때, validate.
				validateInputHandler(input, schema);
				setEventHandler(input, schema);
			}

			for (const textarea of textareas) {
				validateInputHandler(textarea, schema);
				//이후 input 이벤트 마다 validate 하도록 eventListenr 추가.
				setEventHandler(textarea, schema);
			}
		}

		function setEventHandler(
			inputEl: HTMLInputElement | HTMLTextAreaElement,
			schema: z.ZodObject&amp;lt;any&amp;gt;
		) {
			//validate eventHandler
			inputEl.addEventListener('input', () =&amp;gt;
				validateAndSetFormDataHandler(inputEl, schema)
			);
		}

		function validateAndSetFormDataHandler(
			input: HTMLInputElement | HTMLTextAreaElement,
			schema: z.ZodObject&amp;lt;any&amp;gt;
		) {
			setFormDataHandler(input);
			validateInputHandler(input, schema);
		}

		function setFormDataHandler(
			input: HTMLInputElement | HTMLTextAreaElement
		) {
			const targetId = input.id;
			setFormData((prev) =&amp;gt; {
				return {
					...prev,
					[targetId]: input.value,
				};
			});
		}

		function validateInputHandler(
			input: HTMLInputElement | HTMLTextAreaElement,
			schema: z.ZodObject&amp;lt;any&amp;gt;
		) {
			const targetId = input.id;

			const response = schema
				.pick({ [targetId]: true })
				.safeParse({ [targetId]: input.value });

			if (!response.success) {
				setFormStatus((prev) =&amp;gt; {
					return {
						...prev,
						[targetId]: false,
					};
				});
				const { errors } = response.error;
				input.classList.add('invalid');
				addErrorMsgElement(input, targetId, errors);
			} else {
				setFormStatus((prev) =&amp;gt; {
					return {
						...prev,
						[targetId]: true,
					};
				});
				input.classList.remove('invalid');
				input.parentElement
					?.querySelector(`#${ERROR_MSG_ID_PREFIX}${targetId}`)
					?.remove();
			}
		}

		if (formElement) {
			removeSubmitEventListener =
				addSubmitEventListenerHandler(formElement);
			//required 별표 표시는 label에 global css를 적용
			//제출 버튼을 눌렀을 때 부터 input 요소들에게 맞는 validation 해야 한다.
		}

		return () =&amp;gt; {
			if (removeSubmitEventListener) {
				removeSubmitEventListener();
			}
		};
	}, [formRef, schema, isFirst, formStatus]);

	function addErrorMsgElement(
		inputEl: HTMLInputElement | HTMLTextAreaElement,
		targetId: String,
		errors: z.ZodIssue[]
	) {
		const errorMsgEl = inputEl.parentElement?.querySelector(
			`#${ERROR_MSG_ID_PREFIX}${targetId}`
		);

		if (errorMsgEl) {
			errorMsgEl.innerHTML = errors[0].message;
		} else {
			const errorMsg = document.createElement('span');
			errorMsg.id = `${ERROR_MSG_ID_PREFIX}${targetId}`;
			errorMsg.className = 'error__msg';
			errorMsg.innerHTML = errors[0].message;
			inputEl.parentElement?.insertAdjacentElement('beforeend', errorMsg);
		}
	}

	return { valid: formValid, formData: formData };
}&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;아, 참고로 아래처럼 global css를 적용해줘야 위 영상처럼 작동 할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1704527428067&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;input:focus {
    outline: none;
}

label.required {
    &amp;amp;::after {
        content: ' *';
        color: rgb(249, 77, 77);
        font-size: 1rem;
    }
}

.invalid {
    border: 1px solid red !important;
}
.error__msg {
    color: red;
    font-size: 1rem;
}&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;주의점&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실, 위 코드를 보시면 아시겠지만, 일반적인 input과 textarea들에 대해서만 validation 체크가 진행되고, 나머지 케이스에는 작동하지 않습니다. 이 부분은 추후 개발할 예정입니다.&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;style3&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;TroubleShootings&lt;/b&gt;&lt;/h2&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;b&gt;1. useRef 값 customHook에 넘기기(feat. lifecycle, useEffect)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useForm에 인자로 &lt;b&gt;formElement(formRef.current)&lt;/b&gt;를 넘겨줬는데, 처음에 null로 나왔습니다.&amp;nbsp;&lt;br /&gt;이때 제일 처음 든 생각은 아래처럼&amp;nbsp; &lt;b&gt;useEffect의 dependency에 formElement를 등록해주면 되겠네?&lt;/b&gt; 였습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1704603160535&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function useForm(formElement: HTMLFormElement | null, schema: z.ZodObject&amp;lt;any&amp;gt;){
//...
    useEffect(() =&amp;gt; {
    if(!formElement) return;
    //formElement가 정상적으로 등록 된 경우 아래 로직 진행

    }, [formElement])
}

function TestComponent(){
	//...
	const formRef = useRef(null);
	useForm(formRef.current, shema);
    
    return(
    	&amp;lt;form ref={formRef}&amp;gt;
        //...
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, &lt;b&gt;&lt;a href=&quot;https://react.dev/learn/escape-hatches#removing-effect-dependencies&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;리액트 공식문서&lt;/a&gt;&lt;/b&gt;에도 나와 있다싶이, 리액트는 useRef.current의 변경은 알지 못합니다.&lt;br /&gt;애초에 값의 변화가 있을 때, 리렌더가 일어나지 않기위해 만든 hook이니까요. 대신, 그 값의 변화는 useRef.current에 저장되어 있으니,&amp;nbsp; 변화된 값을 언제든지 사용할 수 있습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 우리는&lt;b&gt; formRef.current 처럼 값 자체가 아닌,&lt;span style=&quot;color: #ee2323;&quot;&gt; formRef 객체 자체&lt;/span&gt;를 넘겨줘야 나중에 변화된 값을 사용할 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 &lt;b&gt;실행 시점&lt;/b&gt;이 중요 해 보입니다. 사실 useForm을 호출하는 시점 자체는 TestComponent가 mount하기 이전 시점이니까요.&amp;nbsp;&lt;br /&gt;공식문서에 따르면, &lt;b&gt;useEffect 훅은 ComponentDidMount()가 일어난 이후에 실행&lt;/b&gt;되게 됩니다. &lt;br /&gt;(물론, dependency를 빈 배열로 줬을때 입니다!, 위 코드처럼 인자를 넘긴다면, ComponentDidUpdate()이후에도 실행되게 됩니다.)&lt;/p&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;formRef를 useRef를 이용해 생성한다.(formRef.current는 여전히 null인 상태)&lt;/li&gt;
&lt;li&gt;useForm을 호출한다.&lt;br /&gt;이때,&lt;b&gt; useForm(formRef, shema)&lt;/b&gt;처럼 객체를 통으로 넘겨줘야 합니다. 이 시점에도 &lt;u&gt;여전히 formRef.current는 null&lt;/u&gt; 입니다.&lt;/li&gt;
&lt;li&gt;return 문을 통해 해당 컴포넌트가 렌더된다.(&lt;b&gt;ComponentDidMount() &amp;rarr; &lt;span style=&quot;color: #006dd7;&quot;&gt;formRef.current에 DOM요소&lt;/span&gt;&lt;/b&gt;가 들어가게 됩니다.)&lt;/li&gt;
&lt;li&gt;useForm안에 등록되어 있던 useEffect가 호출된다. (formRef.current를 통해 DOM 요소에 접근 가능하다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 최종적으로 코드는 아래처럼 구현하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1704611901183&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function useForm(formRef: RefObject&amp;lt;HTMLFormElement | null&amp;gt;, schema: z.ZodObject&amp;lt;any&amp;gt;){
//...
    useEffect(() =&amp;gt; {
    const formElement = formRef.current
    if(!formElement) return;
    //formElement가 정상적으로 등록 된 경우 아래 로직 진행

    }, [formRef])
}

function TestComponent(){
	//...
	const formRef = useRef(null);
	useForm(formRef, shema);
    
    return(
    	&amp;lt;form ref={formRef}&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;근데, 이 로직대로라면, useEffect안에 formRef 의존성을 추가 안해줘도 되는 거 아닌가? 라는 생각이 드실 수 있습니다.&lt;br /&gt;하지만, 그렇게 하면, lint에서 경고를 주기 때문에, 일단 추가를 해주었습니다. 이렇게 해도 사실상 formRef가 변경되는 일은 없기 때문에, 사이드이펙트는 없다고 판단하였습니다.&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;b&gt;2. submit시점 처리 (다수의 이벤트 핸들러 순서 처리)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 처음 생각했을 때, 아래처럼 form태그의 onSubmit 을 통해서 사이드이펙트를 처리하고자 했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고, 처음부터 validation을 체크하는 것이 아니라 버튼이 클릭된 이후부터 유효성검사를 실시하고자 했죠.&lt;/p&gt;
&lt;pre id=&quot;code_1704612342265&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const formValid = useFrom(formRef, schema);

&amp;lt;form
    className={classes.form}
    onSubmit={submitHandler}
    ref={formRef}
    noValidate
&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때의 문제점은, &lt;b&gt;validation과 submitHandler 둘 다 submit이벤트를 통해 실행&lt;/b&gt;되지만, &lt;br /&gt;&lt;b&gt;submitHandler가&lt;span style=&quot;color: #ee2323;&quot;&gt; validation이 모두 마친 뒤&lt;/span&gt;에 실행되어야 한다는 점&lt;/b&gt;이었습니다. 그래야, validation의 결과인 formValid를 통해서 form을 제출할 지 여부를 결정할 수 있으니까요.&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;우선, 제가 알고있기로 eventListner는 등록된 순서대(FIFO)로 호출되고 있었습니다. 하지만 React는 브라우저 기본 이벤트가 아닌, &lt;b&gt;&lt;a href=&quot;https://react.dev/reference/react-dom/components/common#react-event-object&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ReactEventObject(synthetic event)&lt;/a&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 브라우저에서 이벤트 리스너를 확인해본 모습입니다. (dev모드라, 2번씩 등록되어 있습니다.)&lt;br /&gt;button에 등록된 2개의 이벤트(33, 36 line)는 제가 addEventListner로 등록한 이벤트핸들러이고, document에 등록된 이벤트는 onClick으로 React 이벤트를 등록한 이벤트 핸들러입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eIbVCX/btsC8VUb9X7/pHo6Mev7n7h0JZOG9zdWq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eIbVCX/btsC8VUb9X7/pHo6Mev7n7h0JZOG9zdWq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eIbVCX/btsC8VUb9X7/pHo6Mev7n7h0JZOG9zdWq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeIbVCX%2FbtsC8VUb9X7%2FpHo6Mev7n7h0JZOG9zdWq0%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;586&quot; height=&quot;182&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;268&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;이처럼 React는 브라우저 기본 이벤트처럼 각 Dom요소에 이벤트리스너를 추가하는 것이 아니라, root(docuemnt)요소에 모두 등록을 합니다. 이렇게 되면 간단해 졌습니다. 먼저 자기에게 걸린 이벤트 핸들러를 먼저 실행시키고, 부모에 걸려있는 이벤트핸들러를 실행시키기 때문입니다.&lt;/p&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_1704614290687&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function clickHandler() {
    console.log('react Event handler!');
}

useEffect(() =&amp;gt; {
    console.log('useEffect');
    const testbtn = document.querySelector('#test');
    console.log('testbtn is', testbtn);
    testbtn?.addEventListener('click', () =&amp;gt; {
        console.log('native first function');
    });
    testbtn?.addEventListener('click', () =&amp;gt; {
        console.log('native last function');
    });
}, []);
    
//jsx
&amp;lt;button id=&quot;test&quot; onClick={clickHandler}&amp;gt;
    click
&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 아래와 같습니다.(역시나 dev모드라 두번씩 실행되었습니다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1596&quot; data-origin-height=&quot;234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NAKPa/btsC5ZQEknv/LwrV7cVEk63UjcEpKwecj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NAKPa/btsC5ZQEknv/LwrV7cVEk63UjcEpKwecj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NAKPa/btsC5ZQEknv/LwrV7cVEk63UjcEpKwecj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNAKPa%2FbtsC5ZQEknv%2FLwrV7cVEk63UjcEpKwecj0%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;1596&quot; height=&quot;234&quot; data-origin-width=&quot;1596&quot; data-origin-height=&quot;234&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼이 클릭되게 되면, 자신에게 가장 먼저 걸려있는 이벤트 리스너부터 실행 시키고, 그 다음에 자신에게 add된 이벤트 리스너, 그 다음에 리액트 이벤트핸들러로 등록된 이벤트가 실행되게 됩니다.&lt;/p&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;form에 onSubmit 이벤트 등록(validation이 모두 끝난 이후에 실행된다.)&lt;/li&gt;
&lt;li&gt;useForm 내부에서 form요소를 찾아 addEventListener(&quot;submit&quot;, validateForm) 해준다.(1번보다 먼저 실행)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js를 시작한지 지금 몇 일 되지 않아, 부족하거나 잘못된 점이 있을 수 있습니다.&amp;nbsp;&lt;br /&gt;잘못된 점이나 궁금한점에 대해서 댓글로 알려주신다면 감사합니다!&lt;/p&gt;</description>
      <category>React</category>
      <category>custom hook</category>
      <category>dependency</category>
      <category>eventhandler</category>
      <category>form</category>
      <category>nextjs</category>
      <category>react</category>
      <category>useEffect</category>
      <category>useForm</category>
      <category>useRef</category>
      <category>zod</category>
      <author>moyanglee</author>
      <guid isPermaLink="true">https://marklee1117.tistory.com/173</guid>
      <comments>https://marklee1117.tistory.com/173#entry173comment</comments>
      <pubDate>Mon, 8 Jan 2024 11:00:23 +0900</pubDate>
    </item>
  </channel>
</rss>