<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>MemoJung</title>
    <link>https://memo-code.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 29 Sep 2026 05:48:29 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>JungCw</managingEditor>
    <item>
      <title>SSAFY 16기 합격 ~ 3개월차 Journey</title>
      <link>https://memo-code.tistory.com/87</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;현재 서울에서 자취하고 있고, 그동안의 바운더리에서 크게 벗어나고 싶지 않아서 서울캠퍼스로 지원하였다.&lt;br /&gt;지원을 하게 된 동기는 크지 않았다. 여느 지원자처럼 매달 100만원이라는 금액을 받으며 안정적으로 취준을 할 수 있다는 점, 그리고 규칙적인 생활과 교육 환경을 제공받을 수 있다는 점에 지금이 적기라고 생각되어 지원했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 지금 받고 있는 일명 싸월급과 멀티캠퍼스 내의 환경은 취준하는데 큰 도움이 된다고 생각한다.&lt;br /&gt;특히 같이 준비하는 친구들이 모두 취준을 목표로 하고 있기 때문에, 주고받는 정보도 딱 내게 필요한 경우가 많고 무엇보다 혼자 준비하는 것보단 멘탈 관리에 도움이 정말 많이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 그렇다고 한들 싸피를 다니는 것이 내 취업 성공으로 직결되는 것은 아니라고 생각하기에, 그동안 싸피를 다니면서도 개인적으로 열심히 살아왔다고 생각한다...는 나의 생각이기에 추석을 이용해 지난 싸피 생활을 돌아보고 앞으로의 계획을 재정비해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;에세이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상반기 취준을 아쉬운 마음으로 마무리하면서 배운 점은 결과 하나하나에 일희일비하면 안된다는 것이었다. 일년에 딱 한 번도 아니고 두 번, 그것도 한 시즌마다 수십개의 기업을 지원하면서 매번 좋아하고 힘들어하면 절대 취준 기간을 버텨낼 수가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기에 이번 싸피 지원도 같은 마음으로 임했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;싸피에 지원하기 위해선 에세이를 작성해야했는데, 특별한 내용을 담지는 않았다. 그냥 나는 어떤 사람인가를 보여주고자 하였고, 더하기빼기 없이 있는 그대로 작성하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;굳이 기억을 해보자면, 나는 어떤 사람인가에 지금의 나는 어떤 생각을 가지고 있는가?를 더한거 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금의 나는 '취업을 하기 위해 부족한 부분을 채우고 취업을 하기 위한 활동을 하고 궁극적으로 빠른 시일내에 원하는 기업에 취업을 하고 싶다.'라는 생각을 가지고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;코딩테스트&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 내가 주로 해왔던 활동을 생각하면, 코딩테스트는 자연스럽게 JS로 보는 것이 적절하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 싸피 코딩테스트 가능 언어에 JS는 포함되어 있지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별 수 없기에 코테 문제를 JS로 풀고 그걸 다시 Python으로 변환하는 연습을 1주일 정도 해본거 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거에 파이썬으로 코테 공부를 했던 기억은 있지만 몇 년 만에 해보려니 기억이 잘 나지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쩔 수 없이 되는대로 공부하고 테스트에 임했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;걱정과 달리 코테는 그리 어렵지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 전 찾아본 바로는 SWEA D3정도 풀 수 있으면 안정권이라고 했는데, 적어도 이번 16기에서는 그것보단 널널하게 준비해도 될 듯 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쨌든 2문제 중 2솔로 안정적으로 합격할 수 있었고, 파이썬 언어 가능이라는 결과를 남겼다. (근데 지금은 아니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;PT면접&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접 안내 메일이 거의 오자마자 참석 여부에 응답했던 이유인지 면접 기간 월~금 중 월요일에 배정되었고, 덕분에 약 1주 조금 더 되는 시간만 남아있었던거 같다. &lt;br /&gt;내 인생 처음으로 PT면접을 접했다. 준비해본 적이 없기에 난감했고, 그 주제마저 랜덤하다는 사실에 곤란했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 똑같은 면접이다 생각하고 준비하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 과거 면접 준비를 할 때 ai에게 내 이력서와 포폴을 먹이고 100개의 질문을 해달라고 해놓고 매일 혼자 대답을 하며 준비했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 사람이 하루종일 그거만 할 수는 없기에, 그냥 대책없이 누워서 ai랑 대화하기도 하고 식사 준비하면서 대답해보기도 하고 유튜브 보면서 대답해보기도 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PT면접도 크게 다르지 않게 준비했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 큰 단위로 템플릿을 준비했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람은 본인이 말하는 습관이라는게 존재한다고 생각한다. 그렇다는 것은 뭔지 알 수 없는 랜덤 주제에 대한 이야기를 제외하면 결국 평소 내가 말하는 방식대로 말할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면, &quot;~~ 아이디어에 대해서 발표하겠습니다. 이런 아이디어를 선택한 이유로는 ~~~가 있으며, 이 아이디어가 ~~~ 이유로 ~~~한 효과를 줄 수 있다고 생각해서~~&quot;라는 문장을 보면, ~~ 처리된 부분 빼고는 항상 같은 말을 할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 외우지 않고 매번 말하면 조금씩 달라지겠지만, 달라지는 이유가 말을 하면서 어떻게 말할까 고민해서이다. 그래서 나는 이 과정을 내 뇌에서 빼기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무슨 주제가 와도 고정되는 부분은 외워버렸다. 발표할 때 이걸 고민하는 시간에 다음 ~~ 부분에 대해서 고민하는 시간을 벌기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 외우고 나서는 앞서 말한 것처럼 그냥 대책없이 혼자 온갖 발표를 하며 생활을 했다. 유튜브를 보면서도 유튜브에 나온 주제에 대해서 발표도 해보고 하며 뇌빼고 할 수 있는 순간까지 해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주제는 그냥 편안하게 생각했다. 싸피가 관심있는 주제 정도만 준비해보고 나머지는 그냥 내가 관심있는 IT주제 위주로 준비했다. (준비..했다기엔 그냥 내가 가진 생각을 정리했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 스터디는 하지 않았다. 누군가와 소통하며 준비하면 집중을 못하는 스타일이었고, 너무 짧은 시간이었기에 혼자 집중해서 내 스타일을 고수하는 것이 좋아보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1045&quot; data-origin-height=&quot;1602&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bj3e53/dJMcabyYSkp/4KBNHBV0jMlVi0E0qejbk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bj3e53/dJMcabyYSkp/4KBNHBV0jMlVi0E0qejbk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bj3e53/dJMcabyYSkp/4KBNHBV0jMlVi0E0qejbk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbj3e53%2FdJMcabyYSkp%2F4KBNHBV0jMlVi0E0qejbk1%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;194&quot; height=&quot;1602&quot; data-origin-width=&quot;1045&quot; data-origin-height=&quot;1602&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히도 이번 내 선택은 운좋게도 나쁘지 않은 선택을 했던 듯하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 방식의 선택에 정답은 존재하지 않지만 이번 경험을 통해 이런 경우는 이런 선택지도 존재한다는 걸 얻을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;스타트캠프&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말하면 여느 OT같은 느낌이다. 근데 이제 2주라는 기간동안 하는..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엄청 바쁘거나 하지는 않지만 다양한 활동을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나름대로 틈틈히 할 일도 하고, 즐기기도 하면서 여유롭게 보냈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 반은 임시반이고 이후 치룰 자바 코딩테스트로 반이 결정된다는 사실이 은근히 부담으로 다가왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐면 이러면 나는 또 Java를 코테 언어로 공부해야하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쩌겠는가.. 결국 나는 Javascript &lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&amp;rarr; Python &lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&amp;rarr; Java 라는 변천사를 가지게 되었다.&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;지금은 Java가 편하긴하다.&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;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;본과정&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;7월 말 쯤 부터 본과정이 시작되었다.&lt;br /&gt;새로운 사람들과 시작하는 과정은 조금 부담되기도 했다..로 끝날 뻔 했으나, 2일 차 부터 내 옆자리가 나랑 동명이인임을 알게되었다. 내 이름이 흔한이름이 아닌데 이럴 수가 있나 싶었는데, 1일 차에 같은 옷을 입었고 같은 핸드폰 기종과 색상을 가진 사람이었다.&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;1. 서류에서 보여줄 무언가를 계속 만들기&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;2. 코테 합격할 실력 만들기&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;싸피의 첫 과정은 AI이고, 현재 내 관심사도 AI이기에 배운 내용을 활용해 프젝에 적용시켜보려고 하였다.&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;이론적인 내용은 딱 이해하는 정도로 공부하고, 이후 이뤄지는 로컬 AI모델과 RAG에 대해 배우고, 개인적으로 학습도 해보면서 프로젝트에 실제로 적용까지 시켜보았다.&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;그래도 나름 2개월간 꾸준히 풀어왔는데, 자소서를 쓰기 시작하니 시간이 아예 없다.&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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; 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: #202122; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202122; text-align: start;&quot;&gt;자기소개서는 포트폴리오가 아니라는 점을 간과했다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이미 지금까지 많은 자기소개서를 작성했지만, 지금이라도 깨달아서 다행이라고 생각한다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;그리고 여기서 사실 같은 반 친구들의 도움이 정말 컸다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&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;p&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;물론 코테를 놔줄 생각은 없다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;코테는 꾸준히 해야 그 의미가 있기에 앞으로 다시 하루에 최소 1~2문제 이상은 풀 생각이다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&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;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;앞으로의 계획&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;당장은 많은 걸 계획하고 할 수는 없어보인다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;지원서를 많이 쓰고 준비해야하는 시기이기도 하고, 합격 여부에 따라 코테나 인적성을 준비해야하기 때문이다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;그렇기에 이 시간을 뭔가 새로운걸 더 쌓아가는 시기로 생각하지 않고 그동안 내가 쌓아온걸 정리하고 재정비하는 시간으로 사용할 생각이다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이력서와 포트폴리오는 언제 봐도 아쉽고 부족하기에 꾸준히 업데이트하고 개선할 것이고, 자기소개서 작성에 필요한 문서나 나의 생각을 한 번 재정비할 것이다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&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;p&gt;&lt;span style=&quot;color: #202122;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>기타</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/87</guid>
      <comments>https://memo-code.tistory.com/87#entry87comment</comments>
      <pubDate>Thu, 24 Sep 2026 12:53:58 +0900</pubDate>
    </item>
    <item>
      <title>[AI] RAG를 만들어보자</title>
      <link>https://memo-code.tistory.com/86</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기획 배경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;19개월째 프로젝트를 이어오면서 팀 내 의사결정이나 회의 과정에서 지속적으로 발생하는 문제가 보이기 시작했다. 가장 대표적인 문제는 두 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는, 회의 중에 과거 회의 내용을 기억하지 못해 문제가 생기는 경우가 많아졌다는 것이다. 약 70회차가 넘는 회의록이 쌓이다 보니 팀원들 내부에서도 과거 의사 결정 내용을 기억하지 못하는 경우가 종종 발생한다. 이런 경우 다른 팀원들의 기억에 의존해야하다보니 회의 진행에 병목이 발생한다.&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;이러한 두 가지 문제를 해결하기 위해 우리 서비스의 지식을 배경으로 하는 LLM이 필요하다고 판단되었고, 이에 노션을 대상으로 RAG를 붙여 모꼬지 개발자 전용 LLM을 개발하게되었다. 마침 SSAFY에서 로컬 LLM의 사용과 RAG에 대해서 배우고 있던 참이라 이걸 팀 프로젝트에 적용시켜보기로 하였다.&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기초적으로 목표하는 상태는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;          사용자 질문
                     &amp;darr;
         관련 문서 검색
                     &amp;darr;
검색된 문서를 Context로 사용
                     &amp;darr;
     로컬 LLM이 답변 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면,&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Q. 카카오 auth로 최초 가입한 사용자의 초기 role은?

A. 최초 가입 사용자의 초기 role은 NORMAL입니다. + 첨부 문서&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;이런식으로 모꼬지라는 프로젝트만의 LLM을 구성해 자유롭게 사용할 수 있도록 구성하는 것이 목표이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구성을 위해서는 내부 문서를 Context로 찾아 붙여줄 RAG 시스템이 필요하고, 우선적으로 &amp;lsquo;동작이 가능한&amp;rsquo; RAG를 붙이는 것을 목표로 하였다.&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG를 사용한 로컬 LLM이라는 하나의 파이프라인을 구성하기 위해선 우선 몇 가지 개념을 알아야한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;RAG와 LLM은 별도의 구성이다.&lt;br /&gt;가장 처음으로 헷갈릴법한 부분이 둘의 관계인데, llm은 질문에 답변하기 위한 모델이고, RAG는 질문에 대한 대답을 준비하는데 참고할 자료를 찾기 위한 도구이다.&lt;/li&gt;
&lt;li&gt;RAG의 구성&lt;br /&gt;RAG의 파이프라인은 다음과 같다.&lt;br /&gt;대상 문서 파싱(읽을 수 있는 일관된 형태로 변환) &amp;rarr; 적절한 단위로 청킹 &amp;rarr; 청크 단위 임베딩 &amp;rarr; 임베딩된 문서 인덱싱 &amp;rarr; 질문 쿼리 임베딩 &amp;rarr; 유사도 기반 검색 &amp;rarr; LLM에 관련 문서 첨부&lt;/li&gt;
&lt;/ol&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;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;생성 모델&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 우선 심화적인 활용법을 고려하지 않고 기본적인 질문 사용을 위한 개발을 고려하였기 때문에 비용도 고려하기로 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 LLM 모델을 선택하기 전에 모델을 띄워둘 서버를 탐색하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;조건은 다음과 같았다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;24시간 상시로 띄워둘 수 있는 서버일 것.&lt;/li&gt;
&lt;li&gt;어느정도의 모델(7B)가 실행될 수 있는 환경일 것.&lt;/li&gt;
&lt;li&gt;학생 팀 프로젝트이기에 비용이 저렴하거나 무료일 것.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 조건에 부합하기 위해 찾아보다 Oracle 클라우드 컴퓨팅 서비스를 사용하기로 결정하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 작업이라도 그 내용의 정확도가 어느정도 보장되어야하고, 동시에 13B급 이상보다 메모리 사용량이 적기에 모델은 7~8B급을 사용할 것이고, 그 외에 임베딩 모델이나 FastAPI 등이 같이 올라가야 하기에 충분한 RAM 용량이 중요했다.&lt;br /&gt;때문에 Oracle Cloud에서 RAM을 많이 확보할 수 있는 무료자원인 ARM기반의 2 OCPU / 12GB RAM 서버를 선택하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스를 생성했으니 이제 해당 리눅스 서버에 Ollama를 설치해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서도 착각할 수 있는 것은, Ollama는 LLM모델이 아니다.&lt;br /&gt;Ollama는 모델을 다운로드하고 로컬에서 실행해주는 환경, 즉 런타임에 해당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# Ollama 설치
curl -fsSL https://ollama.com/install.sh | sh 

# 설치 버전 확인
ollama --version

# 생성용 모델 다운
ollama pull llama3.1:8b

# 로컬 모델 확인
ollama list&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;설치가 끝났다면 다음 명령어를 통해 LLM이 정상적으로 동작하는지 확인한다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 입력 후 아무거나 질문
ollama run llama3.1:8b&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;임베딩 모델&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 문서를 임베딩 해줄 모델을 설치한다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# nomic-embed-text 모델 사용
ollama pull nomic-embed-text&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임베딩 모델을 간략하게 설명하면, 자연어로 들어온 입력을 쉽게 의미를 파악하고 찾을 수 있도록 벡터로 바꾸는 모델이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;내일은 추석입니다. -&amp;gt; [0.124, -0.152, ...]&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;이 두 가지가 설정되었다면, 이제 RAG의 파이프라인을 구성하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4616&quot; data-origin-height=&quot;2216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb4wRV/dJMcafIi5Br/mplegKPTKfgaPgkZ8OkPwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb4wRV/dJMcafIi5Br/mplegKPTKfgaPgkZ8OkPwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb4wRV/dJMcafIi5Br/mplegKPTKfgaPgkZ8OkPwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb4wRV%2FdJMcafIi5Br%2FmplegKPTKfgaPgkZ8OkPwk%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;4616&quot; height=&quot;2216&quot; data-origin-width=&quot;4616&quot; data-origin-height=&quot;2216&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 보이듯 가진 문서의 처리와 질문의 처리, 두 개의 축으로 분리해 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;청킹&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 문서 처리를 보면 문서의 일부를 떼어오는 것을 알 수 있다.&lt;br /&gt;보통 RAG를 사용하는 이유 중 하나는 문서의 크기가 크기 때문에 이를 특정한 방법으로 처리하여 빠르게 검색하기 위함이다. 여기서 특정한 방법에 &amp;lsquo;청킹&amp;rsquo;이라는 개념이 존재한다.&lt;br /&gt;청킹, 그러니까 문서를 단위로 나누는 작업을 하는 이유를 설명하기 위해선 바로 뒤에 설명할 임베딩부터 이해해야하는데, 간단히 말하자면 주어진 문서의 의미를 벡터 값으로 치환하는 행위이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 여기서 발생하는데, 너무 큰 단위로 청킹을 하게 되면 그 의미가 포괄적으로 묶이는 문제가 있다. 그렇게 되면 A라는 내용을 검색했을 때, A이상의 정보까지 흘러들어오게 되면서 정확도가 떨어지는 상황이 발생하게 된다. 그렇다고 단위를 너무 작게 하면 충분한 컨텍스트가 잡히지 않는 문제가 발생한다. 그렇기에 이 문제를 개선하기 위해서 각 팀의 문서에 맞게 적절한 단위로 청킹하는 작업이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청킹은 여러 단위로 진행할 수 있는데, 여기서 우리는 노션의 페이지를 청킹하기에 블록 단위로 청킹하는 것을 선택했다. 또한, 청크 끝과 끝에서 문장이 잘리는 것을 보완하기 위해 약 50자의 패딩을 주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;임베딩&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG의 가장 중요한 개념이라고도 할 수 있는데, 앞서 말한 것처럼 임베딩은 문서의 자연어를 AI가 이해하고 쉽게 검색할 수 있게 벡터 값의 집합으로 치환하는 작업이다. 아무리 청킹을 잘해두어도 그 의미를 빠르게 파악할 수 없다면, 그저 조각조각 잘 분리된 일반 문서에 불과한다. 효율 좋은 작업을 위해선 AI가 읽을 수 있도록 의미를 미리 치환해두어야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 문서의 내용을 벡터로 치환한 것 만으로는 검색이 용이해지지는 않는다. 데이터베이스에서도 특정 데이터를 찾기 위해서 인덱싱 작업을 하듯이, 임베딩된 데이터들도 벡터 인덱스를 생성해주어야한다. 여기에서는 LlamaIndex를 이용해서 아래와 같은 형태로 사용한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;index = VectorStoreIndex.from_documents(documents)&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;사실 여기까지 하면 기본적인 RAG 문서는 준비가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;준비된 벡터 인덱스를 이용해 자료를 찾아낼 수만 있다면 내부 자료를 빠르게 가져다 사용할 수 있을 것이다.&lt;br /&gt;다만 이제 질문을 하게 된다면 이 질문과 준비된 자료의 인덱스를 어떻게 비교할 것인가가 문제로 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 질문도 벡터로 바꾸면 되지 않을까 싶은데, 이왕이면 정확한 검색을 위해서 임베딩 모델을 일치시키는 것이 좋다.&lt;br /&gt;여기에서는 &amp;lsquo;nomic-embed-text&amp;rsquo;를 사용했으니 질문 임베딩도 같은 모델을 사용하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 질문의 벡터 값과 문서의 벡터값이 완전히 일치할 수는 없다.&lt;br /&gt;어쩌면 당연한 이야기인데, 문서의 내용을 통틀어 하나의 인덱스로 치환한 벡터 값과 질문의 벡터 값이 일치하려면 자연어로 작성된 문서와 질문이 완전히 일치해야한다.&lt;br /&gt;이게 가능하다면 그건 더이상 검색이 아닐 것이다. 그럼 어떻게 해야하느냐? 답은 유사도 검색에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완전한 방법은 아니라고 생각하지만, &amp;lsquo;의미상 비교&amp;rsquo;라는 점을 고려하면 최선의 방법이라고도 생각한다.&lt;br /&gt;여기서는 코사인 유사도를 사용했는데,&lt;/p&gt;
&lt;pre class=&quot;fix&quot;&gt;&lt;code&gt;유사도 = 질문벡터 * 문서벡터 / ∣질문벡터∣&amp;times;∣문서벡터∣&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 식으로 계산된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 나온 결과 값이 1에 가까울 수록 유사하고, 0에 가까울 수록 유사하지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말그대로 &amp;lsquo;유사도&amp;rsquo;이기 때문에 어느 정도의 값이 적절한지에 대한 답이 정해져있지 않다. 그래서 우리는 팀 프로젝트의 여러 문서로 테스트 해보며 가장 적절하다고 판단되는 유사도를 선택하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;이상한 테스트 결과?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 진행하면 이제 LLM을 통해 질문하면 해당 RAG를 이용해 특정 유사도 이상인 문서를 첨부해줄 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론적으로는 문제가 없다고 생각해서 여러가지 테스트를 돌려보던 과정에서 이상한 점을 발견했다.&lt;br /&gt;분명히 정상적으로 동작하고 제대로 잘 대답하기도 했지만, 몇몇 질문에서는 엉뚱한 대답을 하거나 확신을 하지 못하는 모습을 보였다.&lt;br /&gt;원인을 찾고자 문서를 직접 찾아보았지만 명확하게 기록되어 있는 내용이었기에 자료에 문제는 아니라고 생각했다. 그럼 남은 원인은 모델이나 로직에 있다는 것이라 우선 모델을 더 좋을 것으로 바꿔보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 겪었던 질문 사례이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Q. 동아리장이 되려는 사용자가 카카오 OAuth로 최초 가입하면 처음 role은 뭐야?&lt;br /&gt;&amp;rarr; 이상적인 정답: NORMAL&lt;br /&gt;&amp;rarr; 잘못된 대답: NORMAL이나 MASTER가 될 수 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 바꾸어도 잘못된 대답을 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 우리는 로직에 문제가 있다고 판단하였고, 자세한 문제를 파악하기 시작했다.&lt;br /&gt;검색 결과를 확인하기 위해 debug_retrieval.py라는 디버깅용 파일을 만들었고, 검색 과정 중간에 결과를 출력하도록 작성했다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;original:
동아리장이 되려는 사용자가 카카오로 최초 가입하면 처음 role은 뭐야?

--- rank 1 ---
score: ...
file: ...
&amp;lt;실제 chunk 내용&amp;gt;

--- rank 2 ---
score: ...
file: ...
&amp;lt;실제 chunk 내용&amp;gt;

...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과 같이 출력되는 결과를 보고 검색된 문서 랭크에 내가 원하는 내용이 존재하는지 살펴보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;결과는 꽤 유의미했는데, 프로젝트 내에서 쓰이는 권한(role)이 개념은 같지만 여러 곳에서 쓰이기 때문에 해당 단어를 포함하는 여러 문서가 더 앞선 랭크로 잡히는 것이었다. 즉, 우리는 &amp;lsquo;답변 생성의 문제보다는 Retrival, 검색에 문제가 있다&amp;rsquo;라고 원인을 확정지었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;의미적 개선&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 우선 우리가 원하는 내용이 근사한 랭크에 들어와있는지부터 확인하였다.&lt;br /&gt;Top-K를 이용하는 방식이기에 Top-2로 설정된 값이 10까지 늘려보았다.&lt;br /&gt;역시나 이 안에 우리가 원하는 자료는 들어있지 않았다.&lt;br /&gt;그렇다면 적어도 이 문제는 &amp;ldquo;Top-K가 작아서 발생한 문제&amp;rdquo;는 아니라는 결론이 세워진다. 물론 Top-K를 늘려서 해결되는 문제여도 안되기는 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 남은건 입력한 질문의 의미가 우리가 찾던 자료의 의미와 임베딩된 값이 많이 멀다는 것이다.&lt;br /&gt;이 가정을 증명하기 위해 원래의 자연스러운 질문 대신, 원하는 문서에 매우 가까운 검색어를 넣어보았다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Q. 최초 로그인 role NOMAL 일반 유저 자동 회원가입&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;br /&gt;그러자 아까는 보이지 않았던 정답 청크가 rank 2까지 올라왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결과가 의미하는 바는 &amp;ldquo;질문 쿼리가 노이즈를 포함하고 있다&amp;rdquo;라고 보였다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;동아리장이 되려는 사용자가 카카오로 최초 가입하면 처음 role은 뭐야?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까 보았던 이 질문을 사람의 입장에서 중요한 의미만 뽑아보면&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;카카오, 최초 가입, 초기 role&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;하지만 벡터에서는 &amp;lsquo;동아리장&amp;rsquo;과 같은 값이 쿼리의 의미로 들어가 의도한 바와 다른 방향으로 흘러갈 수 있는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 보완하기 위해 LLM을 사용한 Query Normalizer을 적용해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념적으로는 단순히 모든 의미를 가진 쿼리를 생성하는 것이 아니라 LLM에세 검색에 사용할 핵심 키워드로 쿼리를 생성하는 것이다.&lt;br /&gt;단순 규칙으로 처리하려면 너무 많은 표현을 처리해야 하기에 LLM을 이용하면 다음과 같이 통일해볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;카카오, 최초 가입, 초기 role&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 자연어 표현의 다양성을 검색하기 좋은 형태로 정규화하는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론, Query Normalization을 적용한다고 반드시 좋아지는 것은 아니기 때문에 이후 더 많은 테스트를 통해 개선해 나가야할 것이다.&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적인 RAG LLM을 만들어보았다.&lt;br /&gt;확실히 프라이빗한 내용을 활용해 LLM을 사용하기 위해서 이거만한 방법은 없는거 같다.&lt;br /&gt;하지만 AI의 다양한 활용법이 만연한 지금 과연 RAG의 활용도가 기대만큼 좋을지는 잘 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 하반기인 지금, RAG라는 기술은 어쩌면 올드한 기술일 수도 있다고 생각한다.&lt;br /&gt;보안적인 부분을 제외한다면, 이번 세션에서 노션이라는 플랫폼에서 데이터를 가져오는건 굳이 API를 활용하지 않아도 된다. 요즘 핫한 MCP를 사용해서 가져다가 바로 써도 괜찮은데, 왜 이걸 따로 임베딩 해놓고 써야하느냐는 확실히 해결해야할 문제이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 기대하는 바를 말해보자면, 처리 속도를 기대해볼 수 있을 것 같다. 기존의 데이터를 미리 정제해서 빠르게 제공할 수 있다는 점이 첫번째 기대 효과이고, 두번째로는 업무와 관련된 자료는 한 곳에 통합해놓고 쓸 수 있다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술은 어떻게 연결하고 활용하는가에 따라 그 활용도가 천차만별이라고 생각하기에, 앞으로 어떤 아이디어를 가지고 접근할 것인지를 많이 고민해봐야 할 듯 하다. 그리고 그럼에도 여전히 &amp;ldquo;왜 RAG인가?&amp;rdquo;라는 근본적인 질문에는 답하기 어려운 것도 사실이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼에도 계속해서 새로운 기술이 올라오는 시대에 사용해보는 것과 아닌 것에는 차이가 있다고 생각하기에 유의미하다고 생각한다. 조만간 RAG를 활용에 대해 고민해보고 정리해보겠다.&lt;/p&gt;</description>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/86</guid>
      <comments>https://memo-code.tistory.com/86#entry86comment</comments>
      <pubDate>Wed, 23 Sep 2026 10:31:48 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 성능 개선기 (2) - 하이드레이션&amp;middot;서드파티 최적화로 TBT 개선하기</title>
      <link>https://memo-code.tistory.com/85</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1부에서 폰트 최적화로 대역폭 병목을 해소하고 나서 오히려 TBT가 630ms에서 700ms로 올라갔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxe8AG/dJMcagmcqV6/eaiFYWk9z8OmZK78ejKJFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxe8AG/dJMcagmcqV6/eaiFYWk9z8OmZK78ejKJFk/img.png&quot; data-alt=&quot;1차 최적화 전&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxe8AG/dJMcagmcqV6/eaiFYWk9z8OmZK78ejKJFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcxe8AG%2FdJMcagmcqV6%2FeaiFYWk9z8OmZK78ejKJFk%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;725&quot; height=&quot;299&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;1차 최적화 전&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;705&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dXeb8i/dJMcadCTJ6Y/Gnm2Z7FTZj153guHkyT220/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dXeb8i/dJMcadCTJ6Y/Gnm2Z7FTZj153guHkyT220/img.png&quot; data-alt=&quot;1차 최적화 후&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dXeb8i/dJMcadCTJ6Y/Gnm2Z7FTZj153guHkyT220/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdXeb8i%2FdJMcadCTJ6Y%2FGnm2Z7FTZj153guHkyT220%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;705&quot; height=&quot;268&quot; data-origin-width=&quot;705&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;1차 최적화 후&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음엔 뭔가 잘못된 줄 알았는데, 생각해보면 당연한 일이었다. 폰트 2MB가 사라지자 기존에 가려져 있던 JS 실행 비용이 더 좁은 구간에 몰려서 드러난 것이다. 결국 다음 타깃은 메인스레드를 잡아먹는 JS 자체였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 &lt;code&gt;'use client'&lt;/code&gt; 경계 관리와 서드파티 스크립트 정리, 두 가지 작업으로 Performance 78 &amp;rarr; 86, TBT 700ms &amp;rarr; 300ms를 달성한 과정을 정리해보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0697%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;242&quot; data-origin-height=&quot;215&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WNakB/dJMcabk0cpn/SLRXdGy3ni6gni7y2bMWn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WNakB/dJMcabk0cpn/SLRXdGy3ni6gni7y2bMWn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WNakB/dJMcabk0cpn/SLRXdGy3ni6gni7y2bMWn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWNakB%2FdJMcabk0cpn%2FSLRXdGy3ni6gni7y2bMWn1%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;242&quot; height=&quot;215&quot; data-origin-width=&quot;242&quot; data-origin-height=&quot;215&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 65.9303%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;285&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zEaDC/dJMcagT5SvS/IuKNTzfwyVt0DIb8dDKXXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zEaDC/dJMcagT5SvS/IuKNTzfwyVt0DIb8dDKXXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zEaDC/dJMcagT5SvS/IuKNTzfwyVt0DIb8dDKXXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzEaDC%2FdJMcagT5SvS%2FIuKNTzfwyVt0DIb8dDKXXK%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;710&quot; height=&quot;285&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;285&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목차&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;문제 파악 - 메인스레드가 왜 이렇게 바쁜가&lt;/li&gt;
&lt;li&gt;'use client' 경계 관리 - useParams 하나가 만든 나비효과&lt;/li&gt;
&lt;li&gt;서드파티 스크립트 정리&lt;/li&gt;
&lt;li&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;Lighthouse로 TBT를 뜯어보면 크게 두 덩어리가 눈에 띄었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;525&quot; data-origin-height=&quot;779&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zbi2M/dJMcacEd1sA/EZEAcls8TUAiQUlVt8k341/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zbi2M/dJMcacEd1sA/EZEAcls8TUAiQUlVt8k341/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zbi2M/dJMcacEd1sA/EZEAcls8TUAiQUlVt8k341/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzbi2M%2FdJMcacEd1sA%2FEZEAcls8TUAiQUlVt8k341%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;525&quot; height=&quot;779&quot; data-origin-width=&quot;525&quot; data-origin-height=&quot;779&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;&lt;b&gt;Script Evaluation&lt;/b&gt;: 클라이언트 컴포넌트 하이드레이션 비용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서드파티 스크립트&lt;/b&gt;: AdSense, Kakao AdFit, Microsoft Clarity, GA 합산 233ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폰트 최적화 이전에는 네트워크가 병목이라 이 비용들이 분산되어 보였다. 대역폭이 풀리고 나니 JS들이 한꺼번에 실행되면서 TBT가 눈에 띄게 올라간 것이다. &quot;악화&quot;가 아니라 &quot;노출&quot;이었던 셈이다.&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;&lt;b&gt;'use client' 경계 관리 - useParams 하나가 만든 나비효과&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원인 파악&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ClubItemClientList&lt;/code&gt;는 동아리 카드 목록을 렌더링하는 컴포넌트다. 원래 &lt;code&gt;useParams&lt;/code&gt;로 URL에서 &lt;code&gt;universityCode&lt;/code&gt;를 꺼내 쓰고 있었고, 그 때문에 파일 상단에 &lt;code&gt;'use client'&lt;/code&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;code&gt;'use client'&lt;/code&gt;가 단순히 &quot;이 컴포넌트만 클라이언트에서 실행&quot;을 의미하는 게 아니라는 점이다. 정확히는 &lt;b&gt;경계(boundary) 선언&lt;/b&gt;이다. 해당 파일을 import하는 그래프 전체가 클라이언트 번들로 끌려들어간다. 카드 15개와 그 안에 포함된 서브컴포넌트가 전부 하이드레이션 대상이 되고 있었던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결 - prop으로 대체&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 부모 서버 컴포넌트를 보니 이미 &lt;code&gt;universityCode&lt;/code&gt;를 props로 받고 있었다. 굳이 클라이언트 훅을 쓸 이유가 없었다. props로 내려주는 것으로 전환하고 &lt;code&gt;'use client'&lt;/code&gt;를 제거했다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// src/widgets/club/ui/club-item-client-list.tsx
'use client';

import useUniversityCode from '@/shared/hooks/useUniversityCode';

import Link from 'next/link';

import ClubItem from '@/entities/club/ui/club-item';
import { Club } from '../model/type';

interface ClubItemClientListProps {
  clubs: Club[];
}

function ClubItemClientList({ clubs }: ClubItemClientListProps) {
  const universityCode = useUniversityCode();
  const uniqueClubs = Array.from(
    new Map(clubs.map((club) =&amp;gt; [club.id, club])).values(),
  );

  return (
    &amp;lt;div className=&quot;grid w-full grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3&quot;&amp;gt;
      {uniqueClubs.map((item) =&amp;gt; (
        &amp;lt;Link
          key={item.id}
          href={`/${universityCode}/club/${item.id}`}
          className=&quot;block&quot;
        &amp;gt;
          &amp;lt;ClubItem {/* ...카드 props */} /&amp;gt;
        &amp;lt;/Link&amp;gt;
      ))}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;useParams&lt;/code&gt;, &lt;code&gt;useSearchParams&lt;/code&gt; 같은 훅이 필요해 보일 때, 서버 컴포넌트 단에서 이미 &lt;code&gt;params&lt;/code&gt;를 props로 받고 있다면 굳이 클라이언트 훅으로 전환할 필요가 없다. &lt;code&gt;'use client'&lt;/code&gt;를 붙이는 순간 그 컴포넌트의 import 그래프 전체가 클라이언트 번들로 묶인다는 점을 항상 염두에 두는 것이 좋다.&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;&lt;b&gt;서드파티 스크립트 정리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인스레드에서 남은 부분을 차지하는 서드파티를 233ms를 49ms까지 줄였다. 항목별로 어떻게 처리했는지 정리해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;AdSense 삭제&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AdSense는 221KB를 차지하면서 실제로는 미사용 상태였다. 판단은 간단했다. 쓰지 않는 스크립트를 유지할 이유가 없으므로 삭제했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Kakao AdFit - IntersectionObserver로 지연 로딩&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AdFit은 페이지 하단(below-the-fold)에 위치한 광고 스크립트다. 첫 로딩 시점에 바로 실행될 필요가 없다. IntersectionObserver로 해당 영역이 뷰포트에 가까워질 때만 스크립트를 동적으로 삽입하도록 전환했다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;'use client';

import { useEffect, useRef } from 'react';

export default function KakaoAdFit() {
  const containerRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

  useEffect(() =&amp;gt; {
    const container = containerRef.current;
    if (!container) return undefined;

    let script: HTMLScriptElement | null = null;

    const observer = new IntersectionObserver(
      (entries) =&amp;gt; {
        if (!entries.some((entry) =&amp;gt; entry.isIntersecting)) return;

        script = document.createElement('script');
        script.src = '//t1.daumcdn.net/kas/static/ba.min.js';
        script.async = true;
        document.body.appendChild(script);

        observer.disconnect();
      },
      { rootMargin: '200px' },
    );

    observer.observe(container);

    return () =&amp;gt; {
      observer.disconnect();
      if (script) {
        document.body.removeChild(script);
      }
    };
  }, []);

  return (
    &amp;lt;div ref={containerRef}&amp;gt;
      {/* 모바일 320&amp;times;100 / 데스크톱 728&amp;times;90 광고 영역 (ins 태그) */}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rootMargin: '200px'&lt;/code&gt;으로 뷰포트 200px 전방부터 감지하도록 했다. 스크립트가 삽입된 뒤에는 observer를 disconnect해서 중복 실행을 막고, 컴포넌트가 언마운트될 때는 스크립트도 함께 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Microsoft Clarity - lazyOnload로 변경&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Clarity는 Next.js의 &lt;code&gt;&amp;lt;Script&amp;gt;&lt;/code&gt; 컴포넌트로 삽입하고 있었는데, 전략이 &lt;code&gt;afterInteractive&lt;/code&gt;로 되어 있었다. 이를 &lt;code&gt;lazyOnload&lt;/code&gt;로 바꿨다. &lt;code&gt;lazyOnload&lt;/code&gt;는 페이지가 유휴 상태일 때 로드되므로 초기 인터랙션을 방해하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;GA - 55ms 유지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GA는 손대지 않았다. Web Vitals 리포팅이 &lt;code&gt;window.gtag&lt;/code&gt;에 의존하고 있어서 로딩을 미루면 측정 자체가 누락될 수 있기 때문이다. 55ms는 감수하기로 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 통해 불필요한 서드파티와 메인스레드를 잠아먹던 이슈를 최적화하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;706&quot; data-origin-height=&quot;555&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yBmK8/dJMb998slaC/ao8sPokl25y8iJUQgyY2t1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yBmK8/dJMb998slaC/ao8sPokl25y8iJUQgyY2t1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yBmK8/dJMb998slaC/ao8sPokl25y8iJUQgyY2t1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyBmK8%2FdJMb998slaC%2Fao8sPokl25y8iJUQgyY2t1%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;319&quot; data-origin-width=&quot;706&quot; data-origin-height=&quot;555&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;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;/p&gt;
&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;'use client' 사용 시 확인할 것&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;해당 컴포넌트가 정말 클라이언트 상태나 브라우저 API를 필요로 하는가&lt;/li&gt;
&lt;li&gt;부모 서버 컴포넌트에서 이미 동일한 값을 props로 내려줄 수 있지 않은가&lt;/li&gt;
&lt;li&gt;'use client'는 컴포넌트 하나를 지정하는 게 아니라 import 그래프 전체를 클라이언트로 만드는 경계 선언이다&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;b&gt;서드파티 스크립트 원칙&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;미사용은 삭제&lt;/li&gt;
&lt;li&gt;below-the-fold는 IntersectionObserver로 지연 로딩&lt;/li&gt;
&lt;li&gt;행동 분석 도구처럼 측정 정확도가 필요한 스크립트는 trade-off를 따져보고 결정&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;다시 정리해보자면, 이번 TBT 개선의 핵심은 두 가지였다. &lt;code&gt;'use client'&lt;/code&gt; 경계를 좁혀 불필요한 하이드레이션을 줄이는 것, 그리고 초기 로딩에 반드시 필요하지 않은 서드파티를 지연시키는 것. 둘 다 코드 구조나 로딩 전략을 조금 바꾼 것뿐인데 TBT가 700ms에서 300ms로 절반 이상 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 1편과 2편의 개선 내용을 통해 꽤 유의미한 성능 개선 결과를 얻어볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 전/후 성능에 대한 내용을 아래에 첨부하겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Performance&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmFScO/dJMcabSJqfG/1tqBp0CfpqNhgEYh9bqepK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmFScO/dJMcabSJqfG/1tqBp0CfpqNhgEYh9bqepK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmFScO/dJMcabSJqfG/1tqBp0CfpqNhgEYh9bqepK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmFScO%2FdJMcabSJqfG%2F1tqBp0CfpqNhgEYh9bqepK%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;270&quot; height=&quot;216&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;종합 개선 전 Performance&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;209&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p5adJ/dJMcabSJqf1/iydKkWbEtIlk1wpTUkXsy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p5adJ/dJMcabSJqf1/iydKkWbEtIlk1wpTUkXsy0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p5adJ/dJMcabSJqf1/iydKkWbEtIlk1wpTUkXsy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp5adJ%2FdJMcabSJqf1%2FiydKkWbEtIlk1wpTUkXsy0%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;240&quot; height=&quot;209&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;209&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;종합 개선 후 Performance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Metrix&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xZ03k/dJMcaiK3DHY/ErVqakdpEz5O6BVKeqE990/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xZ03k/dJMcaiK3DHY/ErVqakdpEz5O6BVKeqE990/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xZ03k/dJMcaiK3DHY/ErVqakdpEz5O6BVKeqE990/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxZ03k%2FdJMcaiK3DHY%2FErVqakdpEz5O6BVKeqE990%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;725&quot; height=&quot;299&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;종합 개선 전 Metrix&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bL5Qkg/dJMcaiK3DIc/VIEDM7R62hv6Hw6B2xXVPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bL5Qkg/dJMcaiK3DIc/VIEDM7R62hv6Hw6B2xXVPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bL5Qkg/dJMcaiK3DIc/VIEDM7R62hv6Hw6B2xXVPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbL5Qkg%2FdJMcaiK3DIc%2FVIEDM7R62hv6Hw6B2xXVPK%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;710&quot; height=&quot;286&quot; data-origin-width=&quot;710&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt; 종합 개선 전 Metrix &lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <category>LCP</category>
      <category>LIGHTHOUSE</category>
      <category>Next.js</category>
      <category>TBT</category>
      <category>Third-Party</category>
      <category>최적화</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/85</guid>
      <comments>https://memo-code.tistory.com/85#entry85comment</comments>
      <pubDate>Tue, 11 Aug 2026 17:37:12 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 성능 개선기 (1) - 웹폰트 동적 서브셋으로 LCP 개선하기</title>
      <link>https://memo-code.tistory.com/84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 즐겨찾기 페이지의 렌더링 전략 개선을 진행하면서 LightHouse를 재측정해보게 되었다. 오랜만에 확인해봐서 그런건지 점수가 생각보다 많이 낮았다.&lt;br /&gt;Largest Contentful Paint가 3.5초라고 표시되고 있었는데, 이건 사용자가 페이지에 들어왔을 때 가장 큰 콘텐츠가 3.5초 뒤에야 렌더링된다는 뜻이다.&lt;br /&gt;이건 사용자 경험과 직결되는 문제이기 때문에 무시하고 넘어갈 수가 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 과정에서 웹폰트가 LCP에 얼마나 큰 영향을 미치는지 알게 된 경험, 그리고 동적 서브셋을 통해 1st party 전송량을 2,128KB에서 119KB로 줄인 과정을 정리한 글이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선 전후 수치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 결과부터 간단히 요약하면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;252&quot; data-origin-height=&quot;216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWMUmV/dJMcacc2VJ6/YakwPMww8Ty85T9rwDJHi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWMUmV/dJMcacc2VJ6/YakwPMww8Ty85T9rwDJHi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWMUmV/dJMcacc2VJ6/YakwPMww8Ty85T9rwDJHi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWMUmV%2FdJMcacc2VJ6%2FYakwPMww8Ty85T9rwDJHi0%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;252&quot; height=&quot;216&quot; data-origin-width=&quot;252&quot; data-origin-height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;개선 전&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;performance_1.png&quot; data-origin-width=&quot;241&quot; data-origin-height=&quot;215&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4e6eh/dJMcag0KV03/r6YmDUv0qkMGXUMl8iaJY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4e6eh/dJMcag0KV03/r6YmDUv0qkMGXUMl8iaJY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4e6eh/dJMcag0KV03/r6YmDUv0qkMGXUMl8iaJY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4e6eh%2FdJMcag0KV03%2Fr6YmDUv0qkMGXUMl8iaJY0%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;241&quot; height=&quot;215&quot; data-filename=&quot;performance_1.png&quot; data-origin-width=&quot;241&quot; data-origin-height=&quot;215&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&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;table style=&quot;border-collapse: collapse; width: 100%; height: 582px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 302px;&quot;&gt;
&lt;td style=&quot;width: 100%; text-align: center; height: 302px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eKHu6T/dJMb99N5TkI/IkePAcfFTq0Qv1qRDcQsBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eKHu6T/dJMb99N5TkI/IkePAcfFTq0Qv1qRDcQsBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eKHu6T/dJMb99N5TkI/IkePAcfFTq0Qv1qRDcQsBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeKHu6T%2FdJMb99N5TkI%2FIkePAcfFTq0Qv1qRDcQsBk%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;725&quot; height=&quot;299&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt; 개선 전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 280px;&quot;&gt;
&lt;td style=&quot;width: 100%; text-align: center; height: 280px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;705&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sUoJu/dJMcadCTrG0/Bu4j6oPprY5SiKnleXQXb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sUoJu/dJMcadCTrG0/Bu4j6oPprY5SiKnleXQXb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sUoJu/dJMcadCTrG0/Bu4j6oPprY5SiKnleXQXb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsUoJu%2FdJMcadCTrG0%2FBu4j6oPprY5SiKnleXQXb0%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;729&quot; height=&quot;277&quot; data-origin-width=&quot;705&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 100%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;701&quot; data-origin-height=&quot;357&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zpt0U/dJMcaaM7itY/CnfVSmhHYZ1JjoO7d8GIKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zpt0U/dJMcaaM7itY/CnfVSmhHYZ1JjoO7d8GIKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zpt0U/dJMcaaM7itY/CnfVSmhHYZ1JjoO7d8GIKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzpt0U%2FdJMcaaM7itY%2FCnfVSmhHYZ1JjoO7d8GIKk%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;701&quot; height=&quot;357&quot; data-origin-width=&quot;701&quot; data-origin-height=&quot;357&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;개선 전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 100%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;607&quot; data-origin-height=&quot;95&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cm4X0f/dJMcaaM7iu1/AVZ6GPm3V9m9qK2ND8frk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cm4X0f/dJMcaaM7iu1/AVZ6GPm3V9m9qK2ND8frk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cm4X0f/dJMcaaM7iu1/AVZ6GPm3V9m9qK2ND8frk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcm4X0f%2FdJMcaaM7iu1%2FAVZ6GPm3V9m9qK2ND8frk0%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;607&quot; height=&quot;95&quot; data-origin-width=&quot;607&quot; data-origin-height=&quot;95&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전송량이 절반 가까이 줄었고, LCP와 Metrix도 눈에 띄게 개선되었다.&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;잘못된 가설, 그리고 LCP breakdown 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 Lighthouse 결과를 보고 메인스레드 문제라고 판단했다. TBT(Total Blocking Time)가 630ms로 높았기 때문이다. 그런데 LCP breakdown을 4단계로 쪼개서 보면서 생각이 달라졌다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;698&quot; data-origin-height=&quot;318&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d4qRdd/dJMcaij3Fzk/Ef7OXOqUgc9XcxuLBEKlU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d4qRdd/dJMcaij3Fzk/Ef7OXOqUgc9XcxuLBEKlU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d4qRdd/dJMcaij3Fzk/Ef7OXOqUgc9XcxuLBEKlU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd4qRdd%2FdJMcaij3Fzk%2FEf7OXOqUgc9XcxuLBEKlU1%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;318&quot; data-origin-width=&quot;698&quot; data-origin-height=&quot;318&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;p data-ke-size=&quot;size16&quot;&gt;LCP는 크게 네 단계로 구성된다.&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;TTFB&lt;/b&gt;: 서버 응답 시간&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Resource Load Delay&lt;/b&gt;: 리소스 로드 시작 지연&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Resource Load Duration&lt;/b&gt;: 리소스 실제 다운로드 시간&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Render Delay&lt;/b&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;여기서 Lighthouse가 보고하는 LCP 숫자가 두 개라는 점이 중요하다. 하나는 실제 측정값이고, 다른 하나는 Lantern 시뮬레이션 기반 값이다. 두 값 사이에 괴리가 크다면 대역폭 병목을 의심해야 한다. Lantern은 네트워크 조건을 시뮬레이션하기 때문에, 로컬에서 빠르게 로드되더라도 느린 네트워크 조건을 가정하면 전송량이 클수록 불이익을 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실측 render delay는 130ms였다. 메인스레드 문제였다면 이 숫자가 컸어야 한다. 범인은 따로 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;701&quot; data-origin-height=&quot;357&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ch8Dp/dJMcaasPpAL/JRjAiQgzeC1hLylK6FYZk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ch8Dp/dJMcaasPpAL/JRjAiQgzeC1hLylK6FYZk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ch8Dp/dJMcaasPpAL/JRjAiQgzeC1hLylK6FYZk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCh8Dp%2FdJMcaasPpAL%2FJRjAiQgzeC1hLylK6FYZk1%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;701&quot; height=&quot;357&quot; data-origin-width=&quot;701&quot; data-origin-height=&quot;357&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 탭에서 전송량 기준으로 정렬해보니 &lt;code&gt;PretendardVariable.woff2&lt;/code&gt; 하나가 2MB였다. 페이지 전체 전송량 4,087KiB의 약 50%를 폰트 파일 하나가 차지하고 있었던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 진단 흐름을 다시 정리해보자면:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;LCP 요소가 무엇인지 확인한다&lt;/li&gt;
&lt;li&gt;breakdown 4단계 중 어느 단계가 긴지 본다&lt;/li&gt;
&lt;li&gt;실측 LCP와 Lantern 시뮬레이션 LCP의 괴리를 확인한다&lt;/li&gt;
&lt;li&gt;네트워크 탭에서 전송량 기준으로 정렬해 범인을 찾는다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 동적 서브셋인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폰트 용량을 줄이는 방법은 크게 정적 서브셋과 동적 서브셋으로 나뉜다. 정적 서브셋은 미리 사용할 글자를 정해두고 그 글자만 포함한 폰트 파일을 만드는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 서비스를 동아리 서비스라는 점에서 생각해보면, 사용자가 직접 글을 쓰고 동아리를 등록하는 UGC(User Generated Content) 구조다. 어떤 글자가 등장할지 예측할 수 없다. 정적 서브셋으로 커버리지를 보장하려면 결국 대부분의 글자를 포함해야 하고, 그러면 용량 절감 효과가 거의 없어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 서브셋은 전체 폰트를 여러 조각으로 분할하고, 브라우저가 페이지에서 실제로 필요한 &lt;code&gt;unicode-range&lt;/code&gt;의 조각만 요청하는 방식이다. Pretendard 공식 배포본은 이 조각을 92개로 나눠 제공하는데, 단순히 균등 분할한 게 아니라 빈도 기반 그룹핑으로 최적화되어 있다. 자주 쓰이는 글자일수록 초반 조각에 몰려 있어, 첫 화면에서 실제로 내려받는 양이 줄어드는 구조다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;607&quot; data-origin-height=&quot;95&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dPbiOH/dJMcaa7sPnp/53SMclMy58hCla9h7ac5L1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dPbiOH/dJMcaa7sPnp/53SMclMy58hCla9h7ac5L1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dPbiOH/dJMcaa7sPnp/53SMclMy58hCla9h7ac5L1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdPbiOH%2FdJMcaa7sPnp%2F53SMclMy58hCla9h7ac5L1%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;607&quot; height=&quot;95&quot; data-origin-width=&quot;607&quot; data-origin-height=&quot;95&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 방식으로 첫 화면에서 실제 전송된 폰트 용량을 줄였다.&lt;/p&gt;
&lt;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;코드 변경 [Next.js]&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드는 &lt;code&gt;next/font/local&lt;/code&gt;로 단일 woff2 파일을 로드하는 방식이었다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// Before &amp;mdash; src/app/layout.tsx
import localFont from 'next/font/local';

const pretendard = localFont({
  src: [{ path: '../shared/fonts/PretendardVariable.woff2' }],
  variable: '--font-pretendard',
  display: 'swap',
});

export default function RootLayout({ children }) {
  return (
    &amp;lt;html lang=&quot;ko&quot;&amp;gt;
      &amp;lt;body className={`${pretendard.className} scrollbar-hide`}&amp;gt;
        {children}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 &lt;code&gt;next/font/local&lt;/code&gt; 없이 CSS의 &lt;code&gt;@font-face&lt;/code&gt;를 직접 작성하는 방식으로 교체했다. 92개의 &lt;code&gt;@font-face&lt;/code&gt; 블록을 CSS 파일에 직접 선언하고, 각각 &lt;code&gt;unicode-range&lt;/code&gt;를 지정해 브라우저가 필요한 조각만 내려받도록 했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;/* src/app/fonts.css &amp;mdash; @font-face 동적 서브셋 예시 */
@font-face {
  font-family: 'Pretendard Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 45 920;
  src: url(/fonts/pretendard/PretendardVariable.subset.91.woff2) format('woff2-variations');
  unicode-range: U+20-22, U+27-2a, U+2c-39, U+41-4e, U+61-7b, U+ac00, U+ace0, U+ae30, U+b2e4, U+b85c, U+c0ac, U+c2a4, U+c9c0, U+d558;
}

@font-face {
  font-family: 'Pretendard Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0.00%;
  size-adjust: 101.55%;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Pretendard Fallback&lt;/code&gt; 폰트 정의가 중요하다. &lt;code&gt;next/font/local&lt;/code&gt;이 자동으로 처리해주던 폴백 폰트 메트릭 오버라이드를 직접 이식한 것이다. &lt;code&gt;ascent-override&lt;/code&gt;, &lt;code&gt;descent-override&lt;/code&gt;, &lt;code&gt;size-adjust&lt;/code&gt; 같은 속성으로 폴백 폰트(Arial)의 크기와 높이를 Pretendard에 맞춰 보정해, 폰트가 교체될 때 레이아웃이 밀리지 않도록 한다. 덕분에 CLS가 0.001에서 0으로 개선되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tailwind v4 기반 프로젝트라 토큰으로 폰트를 등록하고, layout.tsx는 단순하게 유지했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;/* src/app/theme.css &amp;mdash; Tailwind v4 토큰 기반 */
@theme {
  --font-sans: 'Pretendard Variable', 'Pretendard Fallback', system-ui, sans-serif;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;/* src/app/layout.tsx &amp;mdash; After */
export default function RootLayout({ children }) {
  return (
    &amp;lt;html lang=&quot;ko&quot;&amp;gt;
      &amp;lt;body className=&quot;scrollbar-hide&quot;&amp;gt;
        {children}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 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;h2 data-ke-size=&quot;size26&quot;&gt;트레이드오프&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 점만 있었던 건 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;FCP 0.2초 증가.&lt;/b&gt; FCP가 1.2초에서 1.4초로 늘었다. 단일 파일을 한 번에 받던 것을 92개 조각으로 쪼개다 보니, HTTP/1.1 환경의 동시 연결 제한이 영향을 미친 것으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;레포 용량 증가.&lt;/b&gt; 92개 woff2 파일을 저장소에 직접 보관하게 되어 폰트 관련 용량이 2,009KB에서 2,888KB로 늘었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TBT 증가.&lt;/b&gt; TBT가 630ms에서 700ms로 늘었다. 대역폭 병목이 제거되면서 그동안 가려져 있던 메인스레드 비용이 측정에 드러난 것으로 보인다. 대역폭이 병목일 때는 다운로드를 기다리는 시간이 지배적이라 메인스레드 점유 시간이 상대적으로 묻혀버린다. TBT 증가는 다음 개선 대상이 메인스레드임을 알려주는 신호라고 볼 수 있다.&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;로컬 Lighthouse의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 측정은 모두 로컬 Lighthouse 기준이라는 점을 밝혀둔다. 프로덕션에서는 두 가지 문제가 추가로 존재하는데 이번 측정으로는 잡아내지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나는 캐싱 차단 문제다. 비로그인 사용자도 &lt;code&gt;cookies()&lt;/code&gt;를 호출하는 곳이 있어 Next.js가 해당 라우트를 dynamic으로 강제 처리하고, 결과적으로 캐싱이 막힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 하나는 함수 리전 문제다. 서울 엣지로 배포되어 있지만 실제 함수는 버지니아 리전에서 실행되고 있어 TTFB에 불필요한 레이턴시가 붙는다.&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 개선을 간단히 요약하면 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LCP 병목의 원인은 메인스레드가 아니라 2MB 폰트 파일이었다&lt;/li&gt;
&lt;li&gt;동적 서브셋 적용으로 첫 화면 폰트 전송량을 2MB에서 526KB로 줄였다&lt;/li&gt;
&lt;li&gt;UGC 서비스 특성상 정적 서브셋 대신 동적 서브셋을 선택했다&lt;/li&gt;
&lt;li&gt;next/font/local 제거 시 폴백 폰트 메트릭 오버라이드를 직접 이식해야 CLS를 지킬 수 있다&lt;/li&gt;
&lt;li&gt;TBT 증가는 회귀가 아니라 숨어 있던 메인스레드 비용이 드러난 것이다&lt;/li&gt;
&lt;li&gt;실측 render delay 130ms &amp;rarr; 140ms로 거의 변화 없음을 통해, localhost 환경에서 폰트 크기는 대역폭 시뮬레이션 단계에서만 영향을 미친다는 점을 확인할 수 있었다&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>React</category>
      <category>font</category>
      <category>LCP</category>
      <category>LIGHTHOUSE</category>
      <category>Next.js</category>
      <category>subset</category>
      <category>TBT</category>
      <category>최적화</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/84</guid>
      <comments>https://memo-code.tistory.com/84#entry84comment</comments>
      <pubDate>Tue, 11 Aug 2026 13:36:27 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] 즐겨찾기 페이지의 렌더링 전략 전환기: 하이드레이션 워터폴과 이중 요청을 React Query Prefetch로 해결하기</title>
      <link>https://memo-code.tistory.com/83</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;즐겨찾기 기능을 처음 구현할 때는 별 고민 없이 SSR로 처리했다. 어차피 서버에서 데이터를 내려주면 되는 거 아닌가 싶었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제로 운영하다 보니 즐겨찾기를 추가하거나 제거해도 페이지를 새로고침하면 예전 상태가 그대로 표시되는 버그가 발생했다. 이걸 고치려고 CSR로 바꿨더니 이번엔 하이드레이션 이후 데이터 요청 워터폴과, Http-Only 쿠키 처리를 위한 프록시 이중 요청이라는 새로운 문제가 기다리고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 두 번의 렌더링 전략 전환을 거쳐 React Query의 prefetch 기반 SSR로 정착하게 되었는데, 그 과정을 정리해보려 한다.&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;1단계: 정적 SSR의 즐겨찾기 갱신 버그&lt;/li&gt;
&lt;li&gt;2단계: CSR 전환과 새롭게 생긴 문제들&lt;/li&gt;
&lt;li&gt;하이드레이션 워터폴이란 무엇인가&lt;/li&gt;
&lt;li&gt;Http-Only 쿠키와 프록시 이중 요청&lt;/li&gt;
&lt;li&gt;3단계: React Query Prefetch 기반 SSR로 전환&lt;/li&gt;
&lt;li&gt;prefetch &amp;rarr; dehydrate &amp;rarr; hydrate &amp;rarr; HydrationBoundary 파이프라인&lt;/li&gt;
&lt;li&gt;설계의 핵심: queryHash 매칭과 안전장치&lt;/li&gt;
&lt;li&gt;최종 정리&lt;/li&gt;
&lt;/ol&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;1단계: 정적 SSR의 즐겨찾기 갱신 버그&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즐겨찾기 페이지는 처음엔 서버 컴포넌트가 직접 목록을 가져와 그대로 내려주는 구조였다. &lt;code&gt;FavoriteItemSection&lt;/code&gt;이 async 서버 컴포넌트로 &lt;code&gt;getFavoriteList&lt;/code&gt;를 호출하고, 받아온 배열을 클라이언트 컴포넌트인 &lt;code&gt;FavoriteItemList&lt;/code&gt;에 props로 넘기는 방식이었다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// widgets/favorite/ui/favorite-item-section.tsx (React Query 도입 전)
async function FavoriteItemSection() {
  const favoriteListResponse = await getFavoriteList({
    page: 1,
    size: MAX_FAVORITE_SIZE,
  });

  if (!favoriteListResponse.ok || !favoriteListResponse.data) {
    return ;
  }

  return ();
}&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;code&gt;FavoriteButton&lt;/code&gt;은 서버에서 내려받은 &lt;code&gt;isFavorite&lt;/code&gt;을 &lt;code&gt;useState&lt;/code&gt;의 초기값으로만 쓰고, 토글에 성공하면 로컬 상태만 뒤집었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// shared/ui/favorite-button.tsx (React Query 도입 전)
function FavoriteButton({ isFavorite, clubId }: FavoriteButtonProps) {
  const [favorite, setFavorite] = useState(isFavorite);

  const handleToggle = useMemo(
    () =&amp;gt;
      throttle(async () =&amp;gt; {
        const result = !favorite
          ? await postFavorite(Number(clubId))
          : await deleteFavorite(Number(clubId));

        if (!result.ok) {
          toast.error(result.message);
          return;
        }
        // 문제: 로컬 상태만 토글하고 부모 목록이나 서버 상태는 건드리지 않음
        setFavorite((prev) =&amp;gt; !prev);
      }, 300),
    [favorite, session, clubId],
  );
  // ...
}&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;code&gt;FavoriteItemList&lt;/code&gt;가 들고 있는 &lt;code&gt;clubs&lt;/code&gt; 배열 자체에서는 그 아이템이 빠지지 않았다. 목록에 여전히 남아있는 상태로, 다른 탭에서 즐겨찾기를 추가/삭제한 뒤 다시 즐겨찾기 페이지로 돌아와도 서버 컴포넌트가 매번 재실행된다는 보장이 없어 예전 목록이 그대로 보이는 경우도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 명확했다. &lt;b&gt;서버가 내려준 초기 데이터와, 버튼 하나하나가 들고 있는 로컬 상태가 완전히 분리되어 있었다.&lt;/b&gt; 버튼을 누르면 화면상 아이콘은 바뀌지만, 그 변경이 목록 배열에도, 서버 상태에도 반영되지 않으니 어느 시점에 무엇이 진실의 원천(source of truth)인지 알 수 없는 상태가 된 것이다.&lt;/p&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;2단계: CSR 전환과 새롭게 생긴 문제들&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;갱신 버그를 해결하기 위해 즐겨찾기 페이지를 CSR로 전환했다. React Query를 도입하고, 페이지에 진입할 때마다 클라이언트에서 데이터를 새로 fetching하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// widgets/favorite/ui/favorite-item-section.tsx (React Query 전환 후)
'use client';

function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return (

  );
}&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;즐겨찾기 갱신 버그는 해결되었다. 버튼 토글이 성공하면 mutation의 &lt;code&gt;onSuccess&lt;/code&gt;에서 &lt;code&gt;['favorites']&lt;/code&gt; 쿼리를 명시적으로 무효화하도록 했기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// src/widgets/favorite/ui/ClientFavoriteButton.tsx
const onSuccess = () =&amp;gt; {
  setFavorite((prev) =&amp;gt; !prev);
  queryClient.invalidateQueries({ queryKey: ['favorites'] });
};

const { mutate: addFavorite } = useMutation({ ...postFavoriteMutationOptions(), onSuccess });
const { mutate: removeFavorite } = useMutation({ ...deleteFavoriteMutationOptions(), onSuccess });&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;code&gt;['favorites']&lt;/code&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;그런데 CSR로 바꾸고 나서 두 가지 새로운 문제가 등장했다.&lt;/p&gt;
&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;b&gt;둘째, Http-Only 쿠키로 인한 프록시 이중 요청&lt;/b&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;CSR 방식에서 Next.js 페이지가 렌더링되는 흐름을 생각해보면 이런 식이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 서버: HTML 껍데기(빈 상태) 전송
2. 클라이언트: HTML 수신 및 파싱
3. 클라이언트: JavaScript 번들 다운로드
4. 클라이언트: 하이드레이션 (React가 DOM을 장악)
5. 클라이언트: useQuery 실행, API 요청 시작
6. 클라이언트: 데이터 수신 후 UI 렌더링
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 5번 단계다. &lt;code&gt;useQuery&lt;/code&gt;가 실행되려면 하이드레이션이 완료되어야 한다. 즉, 사용자는 페이지를 열었을 때 실제 콘텐츠를 보기까지 &lt;b&gt;&quot;HTML 수신 &amp;rarr; JS 다운로드 &amp;rarr; 하이드레이션 &amp;rarr; API 요청 &amp;rarr; 데이터 수신&quot;&lt;/b&gt; 이라는 긴 연쇄 과정을 기다려야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즐겨찾기 페이지가 복잡해지면서 이 지연이 눈에 띄게 느껴졌다. 목록 자체가 prefetch 없이 순수 클라이언트 쿼리였다 보니, &lt;code&gt;useSuspenseQuery&lt;/code&gt;가 실행되는 시점 자체가 하이드레이션 이후로 밀렸다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  // 하이드레이션이 끝나야 이 쿼리가 시작된다 &amp;rarr; 워터폴
  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return ;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Suspense&lt;/code&gt; 경계 덕분에 로딩 UI 자체는 자연스러웠지만, &quot;HTML 수신 &amp;rarr; JS 다운로드 &amp;rarr; 하이드레이션 &amp;rarr; 쿼리 시작 &amp;rarr; 데이터 수신&quot;이라는 체인이 그대로 노출되는 건 마찬가지였다.&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;Http-Only 쿠키와 프록시 이중 요청&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 문제는 인증 방식에서 비롯되었다. 보안을 위해 인증 토큰을 Http-Only 쿠키로 관리하고 있었는데, Http-Only 쿠키는 JavaScript에서 직접 읽을 수 없다. 그래서 클라이언트에서 외부 API를 직접 호출할 때 인증 헤더를 붙이는 게 불가능했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 Next.js API Routes를 프록시로 두는 방식을 채택했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트 &amp;rarr; Next.js API Route (프록시) &amp;rarr; 외부 API 서버
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// src/app/api/favorites/route.ts (프록시 역할)
import { NextRequest } from 'next/server';
import requestByUser from '@/shared/lib/user-request';

export async function GET(request: NextRequest) {
  return requestByUser('favorites', {
    searchParams: request.nextUrl.searchParams,
  });
}&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;이 구조 자체는 문제가 없었다. 그런데 CSR 방식에서 이렇게 되면 요청 흐름이 이중으로 이루어졌다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;[요청 흐름]
1. 클라이언트 &amp;rarr; /api/favorites (Next.js 프록시)  &amp;larr; 1번 요청
2. Next.js 프록시 &amp;rarr; 외부 API 서버               &amp;larr; 2번 요청

= 사용자 한 명의 즐겨찾기 조회에 네트워크 홉이 2회 발생
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 수가 많아질수록 이 이중 요청은 서버 부하와 응답 지연 양쪽에서 손해였다. 물론 SSR에서도 프록시를 쓸 수는 있지만, 서버 컴포넌트나 &lt;code&gt;getServerSideProps&lt;/code&gt;에서 직접 외부 API를 호출하면 프록시를 거치지 않고 서버-to-서버로 바로 통신할 수 있다. CSR에서는 이 옵션이 없었다.&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;3단계: React Query Prefetch 기반 SSR로 전환&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 문제를 동시에 해결하기 위해 최종적으로 React Query의 prefetch 기반 SSR을 도입하기로 했다. 핵심 아이디어는 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;서버에서&lt;/b&gt; React Query의 prefetchQuery를 실행해 데이터를 미리 가져온다&lt;/li&gt;
&lt;li&gt;그 상태를 dehydrate로 직렬화해 클라이언트에 전달한다&lt;/li&gt;
&lt;li&gt;클라이언트는 HydrationBoundary를 통해 hydrate하여 서버에서 가져온 데이터를 그대로 사용한다&lt;/li&gt;
&lt;li&gt;이후의 상태 관리(refetch, mutation 등)는 기존 React Query 방식대로 클라이언트에서 처리한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 하이드레이션 완료를 기다릴 필요 없이 첫 렌더링부터 데이터가 있는 상태로 그릴 수 있고, 서버에서 직접 외부 API를 호출하므로 프록시 이중 요청도 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;prefetch &amp;rarr; dehydrate &amp;rarr; hydrate &amp;rarr; HydrationBoundary 파이프라인 [Next.js + React Query]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전환 과정을 단계별로 살펴보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;서버에서 QueryClient 생성 및 prefetch&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/views/favorite/ui/favorite-page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import FavoriteDateSection from '@/widgets/favorite/ui/favorite-date-section';
import FavoriteItemSection from '@/widgets/favorite/ui/favorite-item-section';
import favoriteQueries from '@/widgets/favorite/api/queries';
import getServerFavoriteList from '@/widgets/favorite/api/getServerFavoriteList';
import { getSession } from '@/shared/lib/cookie-session';
import getServerQueryClient from '@/shared/lib/get-query-client';
import LoginRequired from '@/shared/ui/login-required';

interface FavoritePageProps {
  page: number;
  size: number;
}

async function FavoritePage({ page, size }: FavoritePageProps) {
  const session = await getSession();
  if (!session) return ;

  const queryClient = getServerQueryClient();
  await queryClient.prefetchQuery({
    queryKey: favoriteQueries.list({ page, size }).queryKey,
    queryFn: () =&amp;gt; getServerFavoriteList({ page, size }),
  });

  return (






  );
}

export default FavoritePage;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;page&lt;/code&gt;/&lt;code&gt;size&lt;/code&gt;는 상위 &lt;code&gt;app/[universityCode]/(main)/favorite/page.tsx&lt;/code&gt;가 &lt;code&gt;searchParams&lt;/code&gt;를 읽어서 props로 내려준다. 서버에서 &lt;code&gt;prefetchQuery&lt;/code&gt;를 호출하면 &lt;code&gt;queryClient&lt;/code&gt; 내부에 결과가 캐시되고, &lt;code&gt;dehydrate(queryClient)&lt;/code&gt;가 이 캐시 상태를 직렬화해 HTML에 실어 클라이언트로 넘긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;new QueryClient()&lt;/code&gt;를 직접 쓰지 않고 &lt;code&gt;getServerQueryClient()&lt;/code&gt;를 쓰는 이유는 뒤에 나오는 &quot;요청 단위 QueryClient 관리&quot; 절에서 다룬다.&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;서버 전용 fetch 함수&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/widgets/favorite/api/getServerFavoriteList.ts
import 'server-only';
import api from '@/shared/api/auth-api';
import type { FavoriteList } from '@/shared/model/type';

interface GetServerFavoriteListParams {
  page: number;
  size: number;
}

async function getServerFavoriteList({ page, size }: GetServerFavoriteListParams) {
  const json = await api
    .get('favorites', { searchParams: { page: String(page), size: String(size) } })
    .json();
  return json.data;
}

export default getServerFavoriteList;
&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;쿠키는 이 함수가 직접 읽는 게 아니라, 서버 전용 ky 인스턴스(&lt;code&gt;auth-api.ts&lt;/code&gt;)의 &lt;code&gt;beforeRequest&lt;/code&gt; 훅이 대신 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/shared/api/auth-api.ts
import 'server-only';
import ky from 'ky';
import { getSession } from '@/shared/lib/cookie-session';

const api = ky.create({
  prefixUrl: process.env.NEXT_PUBLIC_API_URL,
  hooks: {
    beforeRequest: [
      async (req) =&amp;gt; {
        if (req.headers.get('Authorization')) return;
        const session = await getSession();
        if (session?.accessToken) {
          req.headers.set('Authorization', `Bearer ${session.accessToken}`);
        }
      },
    ],
  },
});

export default api;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getSession()&lt;/code&gt;이 Http-Only 쿠키를 읽어 accessToken을 꺼내고, 매 요청마다 Authorization 헤더를 자동으로 붙여준다. 개별 fetch 함수(&lt;code&gt;getServerFavoriteList&lt;/code&gt; 등)는 인증을 신경 쓸 필요 없이 &lt;code&gt;api.get(...)&lt;/code&gt;만 호출하면 된다. 프록시를 거치지 않고 외부 API에 서버-to-서버로 바로 요청한다는 점은 동일하다.&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prefetch가 붙었다고 클라이언트 코드가 달라지지는 않는다. 2단계에서 만든 &lt;code&gt;FavoriteItemSection&lt;/code&gt;, &lt;code&gt;ClientFavoriteButton&lt;/code&gt;을 그대로 재사용한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// src/widgets/favorite/ui/favorite-item-section.tsx
'use client';

function FavoriteItemSection() {
  const searchParams = useSearchParams();
  const page = Number(searchParams.get('page')) || 1;
  const size = Number(searchParams.get('size')) || 6;

  const { data } = useSuspenseQuery(favoriteQueries.list({ page, size }));

  return (

  );
}
&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;code&gt;HydrationBoundary&lt;/code&gt;가 &lt;code&gt;favoriteQueries.list({ page, size })&lt;/code&gt;와 동일한 queryKey로 캐시를 미리 채워놓기 때문에, 이 &lt;code&gt;useSuspenseQuery&lt;/code&gt;는 첫 렌더링부터 캐시 히트로 즉시 데이터를 반환한다. Suspense fallback도, 클라이언트 API 요청도 없이 곧바로 목록이 그려진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;렌더링 흐름 비교&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[CSR 방식]
서버: HTML 껍데기 전송
클라이언트: JS 다운로드 &amp;rarr; 하이드레이션 &amp;rarr; useSuspenseQuery 실행
&amp;rarr; API 요청 (프록시 경유, 2회 홉)
&amp;rarr; 데이터 수신 &amp;rarr; 렌더링

[Prefetch SSR 방식]
서버: prefetchQuery 실행(외부 API 직접 호출) &amp;rarr; dehydrate &amp;rarr; HTML에 데이터 포함해 전송
클라이언트: JS 다운로드 &amp;rarr; HydrationBoundary에서 hydrate
&amp;rarr; useSuspenseQuery 호출 시 캐시에서 즉시 반환 (API 요청 없음)
&amp;rarr; 즉시 렌더링
&lt;/code&gt;&lt;/pre&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;설계의 핵심: queryHash 매칭과 안전장치&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &lt;code&gt;prefetchQuery&lt;/code&gt;와 &lt;code&gt;HydrationBoundary&lt;/code&gt;를 사용하는 것 이상으로, 내부 동작 방식을 이해하는 것이 중요하다. React Query의 prefetch 기반 SSR에는 몇 가지 중요한 안전장치가 내장되어 있다.&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;queryHash 기반 매칭&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 prefetch할 때와 클라이언트에서 &lt;code&gt;useQuery&lt;/code&gt;를 호출할 때 &lt;code&gt;queryKey&lt;/code&gt;가 정확히 일치해야 한다. React Query는 &lt;code&gt;queryKey&lt;/code&gt;를 해시한 &lt;code&gt;queryHash&lt;/code&gt;를 기준으로 캐시 항목을 매칭한다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// 서버 prefetch
await queryClient.prefetchQuery({
  queryKey: ['item', id], // ['item', '123']
  queryFn: () =&amp;gt; fetchItemDetailServer(id),
});

// 클라이언트 useQuery
const { data } = useQuery({
  queryKey: ['item', id], // ['item', '123'] - 반드시 동일해야 함
  queryFn: () =&amp;gt; fetchItemDetail(id),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;queryKey&lt;/code&gt;가 다르면 캐시 매칭이 실패하고 클라이언트에서 다시 API 요청이 발생한다. 이런 식으로 서버와 클라이언트가 동일한 queryKey 구조를 사용하도록 상수로 관리하는 것이 좋아 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// lib/queryKeys.ts - queryKey를 상수로 관리
export const favoriteKeys = {
  all: ['favorites'] as const,
  detail: (id: string) =&amp;gt; ['item', id] as const,
};

// 사용 예시 - 서버와 클라이언트 모두 같은 키 사용
queryClient.prefetchQuery({
  queryKey: favoriteKeys.detail(id),
  queryFn: () =&amp;gt; fetchItemDetailServer(id),
});
&lt;/code&gt;&lt;/pre&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;hydrate&lt;/code&gt; 함수는 단순히 서버 데이터로 클라이언트 캐시를 덮어쓰지 않는다. 내부적으로 데이터의 &lt;code&gt;dataUpdatedAt&lt;/code&gt; 타임스탬프를 비교하여 &lt;b&gt;더 최신 데이터만 캐시를 업데이트&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[hydrate 내부 동작]
for each query in dehydratedState:
  if (클라이언트 캐시에 같은 queryHash가 없음):
    &amp;rarr; 서버 데이터로 캐시 채움
  else if (서버 데이터의 dataUpdatedAt &amp;gt; 클라이언트 캐시의 dataUpdatedAt):
    &amp;rarr; 더 최신인 서버 데이터로 덮어씀
  else:
    &amp;rarr; 클라이언트 캐시 유지 (서버 데이터 무시)
&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;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 사용자가 즐겨찾기 페이지 방문 (서버 prefetch 데이터 hydrate됨)
2. 사용자가 아이템 즐겨찾기 추가 (클라이언트 캐시 업데이트됨)
3. 사용자가 뒤로 갔다가 다시 즐겨찾기 페이지로 (서버 prefetch 재실행)
4. hydrate 시 클라이언트 캐시가 더 최신이므로 덮어쓰지 않음
&amp;rarr; 사용자가 방금 추가한 즐겨찾기 상태 유지
&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;참고로 이건 우리가 따로 구현한 로직이 아니라 React Query의 &lt;code&gt;hydrate&lt;/code&gt; 내장 동작이다. 갱신 버그 자체는 이미 2단계에서 &lt;code&gt;invalidateQueries&lt;/code&gt;로 해결됐지만, prefetch SSR을 얹으면서 생길 수 있는 &quot;뒤로가기 시 서버 데이터가 최신 mutation 결과를 덮어쓰지 않을까&quot;라는 우려를 이 내장 동작이 자동으로 막아준다.&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;GlobalQueryClient 설정 주의점&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// src/shared/lib/get-query-client.ts
import 'server-only';
import { cache } from 'react';
import createQueryClient from './query-client';

const getServerQueryClient = cache(() =&amp;gt; createQueryClient());

export default getServerQueryClient;
&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;code&gt;new QueryClient()&lt;/code&gt;를 호출해도 되지만, 여러 서버 컴포넌트가 같은 QueryClient를 공유해야 할 가능성을 대비해 React의 &lt;code&gt;cache()&lt;/code&gt;로 감쌌다. &lt;code&gt;cache()&lt;/code&gt;는 하나의 요청(request) 생명주기 안에서만 결과를 재사용하고 요청이 끝나면 초기화되므로, 모듈 레벨 싱글톤처럼 사용자 간 데이터가 섞이는 일 없이 동일한 이점(중복 생성 방지)을 얻을 수 있다.&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;즐겨찾기 페이지의 세 번에 걸친 렌더링 전략 전환을 다시 정리해보자면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계 - 서버 컴포넌트 SSR&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;문제: 서버가 내려준 초기 데이터와 즐겨찾기 버튼의 로컬 state(useState)가 분리되어, 토글해도 목록 배열 자체는 갱신되지 않음&lt;/li&gt;
&lt;li&gt;async 서버 컴포넌트(FavoriteItemSection) + 버튼별 useState 구조에서 발생&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;b&gt;2단계 - CSR&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;문제 1: 하이드레이션 이후 워터폴 (하이드레이션 완료 후에야 API 요청 시작)&lt;/li&gt;
&lt;li&gt;문제 2: Http-Only 쿠키로 인한 프록시 이중 요청&lt;/li&gt;
&lt;li&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;b&gt;3단계 - React Query Prefetch 기반 SSR (최종)&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;서버에서 prefetchQuery &amp;rarr; dehydrate &amp;rarr; 클라이언트에서 HydrationBoundary &amp;rarr; hydrate&lt;/li&gt;
&lt;li&gt;초기 로드 성능 개선 (API 요청 없이 첫 렌더링부터 데이터 표시)&lt;/li&gt;
&lt;li&gt;프록시 이중 요청 제거 (서버-to-서버 직접 통신)&lt;/li&gt;
&lt;li&gt;클라이언트 상태 관리 반응성 유지 (mutation, invalidation 그대로 동작)&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;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;queryHash 기반 매칭&lt;/b&gt;: 서버와 클라이언트가 동일한 queryOptions().queryKey(favoriteQueries.list)를 써야 캐시 히트&lt;/li&gt;
&lt;li&gt;&lt;b&gt;더 최신 데이터만 덮어쓰는 안전장치&lt;/b&gt;: React Query hydrate의 내장 동작 &amp;mdash; dataUpdatedAt 비교로 클라이언트 상태 보호 (직접 구현한 로직 아님)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;요청 단위 QueryClient 관리&lt;/b&gt;: cache()로 감싸 요청 간 캐시가 섞이지 않도록 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이 전환이 공짜는 아니었다. 실제로 적용 전후를 비교해보니 세 가지가 눈에 띄게 달라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1) 하이드레이션 후 재요청이 사라짐&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; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;253&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/csWPlW/dJMcaiqExUA/oY81k6UqecVWpnsSztkfK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csWPlW/dJMcaiqExUA/oY81k6UqecVWpnsSztkfK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csWPlW/dJMcaiqExUA/oY81k6UqecVWpnsSztkfK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsWPlW%2FdJMcaiqExUA%2FoY81k6UqecVWpnsSztkfK0%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;332&quot; height=&quot;253&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;253&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;781&quot; data-origin-height=&quot;267&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxgiF3/dJMcahk5kvh/TMukNXUSzV0yN5T6aRR0tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxgiF3/dJMcahk5kvh/TMukNXUSzV0yN5T6aRR0tK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxgiF3/dJMcahk5kvh/TMukNXUSzV0yN5T6aRR0tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxgiF3%2FdJMcahk5kvh%2FTMukNXUSzV0yN5T6aRR0tK%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;368&quot; height=&quot;126&quot; data-origin-width=&quot;781&quot; data-origin-height=&quot;267&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;왼쪽(CSR)은 하이드레이션 이후 _&lt;code&gt;favorites?page=...&lt;/code&gt;&lt;/i&gt; 요청이 워터폴에 그대로 잡히지만, 오른쪽(SSR prefetch 적용 후)은 해당 요청이 사라졌다._&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 이상 클라이언트단에서 즐겨찾기 목록에 대한 요청을 하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2) Lighthouse Performance: 55 &amp;rarr; 57&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; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;br /&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;357&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/16v2c/dJMcajptrii/zTmhijq6HIWNVfatYTXSt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/16v2c/dJMcajptrii/zTmhijq6HIWNVfatYTXSt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/16v2c/dJMcajptrii/zTmhijq6HIWNVfatYTXSt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F16v2c%2FdJMcajptrii%2FzTmhijq6HIWNVfatYTXSt1%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;250&quot; height=&quot;165&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;357&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;522&quot; data-origin-height=&quot;345&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/s5XMB/dJMcaijVcQ3/4HgHJzKmTLcRqdlnkUr0Dk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/s5XMB/dJMcaijVcQ3/4HgHJzKmTLcRqdlnkUr0Dk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/s5XMB/dJMcaijVcQ3/4HgHJzKmTLcRqdlnkUr0Dk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fs5XMB%2FdJMcaijVcQ3%2F4HgHJzKmTLcRqdlnkUr0Dk%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;268&quot; height=&quot;177&quot; data-origin-width=&quot;522&quot; data-origin-height=&quot;345&quot;/&gt;&lt;/span&gt;&lt;/figure&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;br /&gt;&lt;i&gt;SI, FCP가 오르면서 전체 점수도 소폭 상승했다.&lt;/i&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;SI 개선&lt;/b&gt;: 이전엔 하이드레이션 이후 프록시 요청을 통해 데이터가 주입되는 구조였는데, 이 구조 자체가 바뀌면서 상승했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FCP 개선&lt;/b&gt;: 즐겨찾기 페이지는 목록이 가장 먼저 그려지는 UI인데, 이전엔 이 목록이 프록시 요청 이후에야 그려질 수 있었던 반면 이제는 초기 HTML에 바로 실려서 상승했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3) 반대로 TTFB는 늘었다&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; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1107&quot; data-origin-height=&quot;452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kAKU9/dJMcajiP5Wv/t7Iw3JCiMOZLDOmhkmj0d0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kAKU9/dJMcajiP5Wv/t7Iw3JCiMOZLDOmhkmj0d0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kAKU9/dJMcajiP5Wv/t7Iw3JCiMOZLDOmhkmj0d0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkAKU9%2FdJMcajiP5Wv%2Ft7Iw3JCiMOZLDOmhkmj0d0%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;421&quot; height=&quot;172&quot; data-origin-width=&quot;1107&quot; data-origin-height=&quot;452&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock undefined&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byVvCA/dJMcabryZtq/oYqkNIUfe3DjRLUEWEGyv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byVvCA/dJMcabryZtq/oYqkNIUfe3DjRLUEWEGyv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byVvCA/dJMcabryZtq/oYqkNIUfe3DjRLUEWEGyv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyVvCA%2FdJMcabryZtq%2FoYqkNIUfe3DjRLUEWEGyv1%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;416&quot; height=&quot;201&quot; data-origin-width=&quot;777&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;i&gt;서버가 prefetch 연산까지 마치고 응답해야 하니, 첫 바이트를 받기까지의 시간(TTFB)이 늘었다. Waiting for server 구간만 84ms 이상, 전체 TTFB는 약 120ms로 측정됐다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 빈 셸을 먼저 받고 나중에 데이터를 받던 구조에서, 서버가 데이터까지 다 채워서 내려주는 구조로 바뀌었으니 당연한 트레이드오프다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &quot;언제 데이터를 받느냐&quot;의 병목이 클라이언트(하이드레이션 이후 프록시 요청)에서 서버(TTFB)로 옮겨간 셈이다. 다만 이 TTFB 증가가 실제 체감 성능에 얼마나 영향을 주는지는 별도로 더 들여다봐야 했는데, Lighthouse로 LCP/TBT를 다시 뜯어본 결과 메인스레드 경합(무거운 서드파티 스크립트 + 목록 전체의 클라이언트 하이드레이션 비용)이 원인으로 잡혔다. 이 부분은 SSE/WebSocket 같은 실시간 동기화가 아니라, 서드파티 스크립트 로딩 전략 조정과 즐겨찾기 카드의 &quot;아일랜드화&quot;(정적인 부분은 서버 컴포넌트로 남기고 &lt;code&gt;ClientFavoriteButton&lt;/code&gt;만 client island로 분리)로 풀어야 할 다음 과제로 남겨뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/83</guid>
      <comments>https://memo-code.tistory.com/83#entry83comment</comments>
      <pubDate>Wed, 29 Jul 2026 13:08:45 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] Next.js 프로젝트를 위한 Docker 배포 가이드</title>
      <link>https://memo-code.tistory.com/82</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Docker&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 애플리케이션을 컨테이너라는 격리된 환경에 이미지로 패키징하여, 어떤 리눅스 환경에서든 동일하게 실행할 수 있도록 해주는 기술이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker 이미지 안에 어떤 운영체제를 사용하고, 어떤 런타임 환경을 사용하며 그 버전은 어떠한지 등을 담아두어 어디에서 실행하든 동일하게 코드가 동작할 수 있도록 한다.&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;Docker의 핵심 구성 요소&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker로 앱을 배포하기 위해선 세 가지 요소를 알아야한다.&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;Dockerfile&lt;br /&gt;&lt;/b&gt;이미지를 만들기 위한 설정 파일이다. 어떤 환경을 사용할지, 어떤 명령어를 실행할지 등을 텍스트의 형태로 담아둔 파일이다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker Image&lt;br /&gt;&lt;/b&gt;Dockerfile을 빌드한 결과물이다. 앱 실행에 필요한 모든 것이 담긴 패키지로, 이 이미지를 이용하면 동일한 환경에서 코드를 실행할 수 있다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Container&lt;br /&gt;&lt;/b&gt;이미지를 실행한 상태를 컨테이너라고 한다. EC2와 같은 서버에서 Docker엔진을 설치하고, 이 엔진 위에서 이미지를 실행하면 컨테이너가 생성된다.&lt;/li&gt;
&lt;/ol&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;코드로 보는 Docker&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dockerfile의 예시를 보며 어떻게 동작하는지 살펴보자.&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;전체 Dockerfile&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;FROM node:20-alpine AS base

RUN corepack enable &amp;amp;&amp;amp; corepack prepare pnpm@10.9.0 --activate

WORKDIR /app

FROM base AS deps

COPY package.json pnpm-lock.yaml ./

RUN pnpm install --frozen-lockfile --prod=false

FROM base AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

RUN pnpm run build

FROM base AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs &amp;amp;&amp;amp; \
    adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME=&quot;0.0.0.0&quot;

CMD [&quot;node&quot;, &quot;server.js&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stage1. 기반 환경 설치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;FROM node:20-alpine AS base

=&amp;gt; FROM = &quot;node버전 20의 alpine 배포판의 이미지를 기반으로 시작&quot;
=&amp;gt; AS = &quot;이 단계를 'base'라는 이름으로 설정&quot;

RUN corepack enable &amp;amp;&amp;amp; corepack prepare pnpm@10.9.0 --activate

=&amp;gt; RUN = &quot;명령어 실행&quot;
=&amp;gt; corepack(패키지 매니저)를 활성화하고, pnpm 10.9.0 버전을 설치 및 활성화

WORKDIR /app

=&amp;gt; WORKDIR = &quot;작업할 디렉토리 설정 (ex. /cd)&quot;
=&amp;gt; 컨테이너 안에서 /app 폴더를 기본 위치로 사용&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;Stage2. 의존성 설치&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;FROM base AS deps

=&amp;gt; 이 단계를 'deps'라는 이름으로 설정

COPY package.json pnpm-lock.yaml ./

=&amp;gt; COPY = (로컬 파일) -&amp;gt; (컨테이너 위치)
=&amp;gt; package.json과 pnpm-lock.yaml 파일을 './' 하위에 복사

RUN pnpm install --frozen-lockfile --prod=false

=&amp;gt; RUN = package.json에 명시된 라이브러리 설치
=&amp;gt; --frozen-lockfile = 의도치 않은 버전 설치 방지
=&amp;gt; --prod=false = devDependencies 설치&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;Q.&lt;/b&gt; 의존성 설치 스테이지가 있는 이유는 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A.&lt;/b&gt; Docker는 레이어 캐싱을 사용하는데, 파일이 변경되지 않으면 해당 레이어를 캐시에서 가져와서 사용하는 것을 의미한다. 만약 모든 파일을 한 번에 복사한다면, 코드의 일부만 수정되어도 &lt;code&gt;RUN pnpm install&lt;/code&gt; 작업이 다시 실행될 수 밖에 없다. 이걸 방지하기 위해 의존성을 설치하는 스테이지를 만들어 package.json만 따로 카피하는 과정을 가진다. 여기서는 package.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;&lt;b&gt;Stage3. 빌드&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;FROM base AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules

=&amp;gt; COPY --from=deps = 'deps' 단계에서 'node_modules'을 복사

COPY . .

=&amp;gt; 프로젝트 모든 파일 복사
=&amp;gt; 캐싱이 되는 점을 이용해 의존성이 설치된 폴더만 가져오는 의미

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

RUN pnpm run build

=&amp;gt; package.json의 '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;Stage4. 실행 환경&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;FROM base AS runner

=&amp;gt; base에서 시작하는 이유: 
=&amp;gt; 실행에 필요없는 파일(빌드된 파일, 정적 파일 외)을 제외하기 위해

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs &amp;amp;&amp;amp; \
    adduser --system --uid 1001 nextjs

=&amp;gt; 그룹/사용자 추가 + 그룹/사용자 ID + 그룹/사용자 이름
=&amp;gt; root 권한이 아닌 일반 사용자 권한으로 변경하기 위해 생성

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

=&amp;gt; builder 단계에서 필요한 파일(정적 파일, Next.js 서버, 의존성, 빌드된 파일) 복사
=&amp;gt; --chown=nextjs:nodejs = 복사한 파일의 소유자를 root에서 next.js(일반 사용자)로 넘겨줌

USER nextjs

=&amp;gt; 사용자 전황(root -&amp;gt; nextjs)

ENV PORT=3000
ENV HOSTNAME=&quot;0.0.0.0&quot;

=&amp;gt; 3000번 포트에서 실행 &amp;amp; 모든 네트워크 접근 허용

CMD [&quot;node&quot;, &quot;server.js&quot;]

=&amp;gt; 컨테이너가 시작되면 '$ node server.js' 명령어 실행&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;Q.&lt;/b&gt; 이렇게 여러 스테이지로 나눠서 하는 이유?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A.&lt;/b&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;Docker Compose&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dockerfile을 이용해 이미지를 만들었다면, 이제 이 이미지를 실행해서 컨테이너로 만들어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 사용되는 것이 Docker Compose이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너를 실행할 때는 포트 매핑, 환경변수, 네트워크 연결등의 다양한 설정이 필요한데, 이를 명령어로 해결하려면 복잡해진다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;docker run -d \
  --name my-app \
  -p 3000:3000 \
  -e NODE_ENV=production \
  -e NEXT_PUBLIC_API_URL=https://api.myapi.com \
  --network my-network \
  my-app:prod&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;Docker Compose는 이런 설정들을 YAML파일로 선언적으로 관리할 수 있게 해준다.&lt;/p&gt;
&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;docker-compose.yml&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;services:
  # Next.js 앱 컨테이너
  app:
    image: my-app:prod
    container_name: my-app
    ports:
      - &quot;3000:3000&quot;
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
    networks:
      - my-network
    healthcheck:
      test: [&quot;CMD&quot;, &quot;wget&quot;, &quot;--spider&quot;, &quot;http://localhost:3000/api/health&quot;]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # Nginx 컨테이너
  nginx:
    image: nginx:alpine
    container_name: my-nginx
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - my-network
    depends_on:
      - app
    restart: unless-stopped

networks:
  my-network:
    driver: bridge
&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;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;image: my-app:prod&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용할 Docker 이미지를 지정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;container_name: my-app&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성될 컨테이너의 이름이다. 다른 컨테이너에서 이 이름으로 접근할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;ports:
- &quot;3000:3000&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트 매핑 설정이다. 외부에서 호스트의 3000번 포트로 접속하면 컨테이너 내부의 3000번 포트로 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 포트 매핑을 이용해서 여러 개의 컨테이너를 만들고 외부에서 전달받는 포트의 번호를 다르게 설정해서 관리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 내부에서 사용할 환경변수 설정이다. 로컬의 &lt;code&gt;.env&lt;/code&gt; 파일과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;networks:
- my-network&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너가 연결될 네트워크를 지정한다. 같은 네트워크 안에 있어서 컨테이너 이름으로 서로 연결될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/82</guid>
      <comments>https://memo-code.tistory.com/82#entry82comment</comments>
      <pubDate>Wed, 28 Jan 2026 00:00:42 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] Next.js에서의 라우팅은 어떻게 다를까?</title>
      <link>https://memo-code.tistory.com/81</link>
      <description>&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;URL 경로와 그에 대응하는 처리 로직을 연결하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 경로에 따라 특정 UI나 컴포넌트는 반환해주는 것만이 아니라, 경로에 맞는 적절한 응답이나 뷰를 제공하는 맵핑작업이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 대표적인 라우팅 방식&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;명시적 라우팅 &lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768144940347&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    &amp;lt;BrowserRouter&amp;gt;
      &amp;lt;Routes&amp;gt;
        &amp;lt;Route path=&quot;/detail&quot; element={&amp;lt;Detail /&amp;gt;} /&amp;gt;
        &amp;lt;Route path=&quot;/detail/:id&quot; element={&amp;lt;DetailPage /&amp;gt;} /&amp;gt;
      &amp;lt;/Routes&amp;gt;
    &amp;lt;/BrowserRouter&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;명시적 라우팅&lt;/b&gt;은 코드로 직접 라우트를 선언하고 정의하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 &lt;b&gt;react-router-dom&lt;/b&gt; 이 있는데, 위처럼 코드로 직접적으로 'path' 에 각각 'component' 를 맵핑해 명시적으로 구현한 형태이다.&lt;/p&gt;
&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;1. 라우트 수집 및 평탄화&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768145181545&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function createRoutesFromChildren(
	children,      // &amp;lt;Route path=&quot;/detail/:id&quot; element={&amp;lt;DetailPage /&amp;gt;} /&amp;gt;
	parentPath = []
) {
  let routes = [];
  React.Children.forEach(children, (element, index) =&amp;gt; {
	  ...  // 검증 부분 생략
    let route = {
      id: element.props.id || treePath.join(&quot;-&quot;),  // &quot;0&quot;, &quot;1&quot;, &quot;1-1&quot;, ...
      element: element.props.element,              // &amp;lt;DetailPage /&amp;gt;
      path: element.props.path,                    // &quot;detail/:id&quot;
      ...
    };
    ...
    routes.push(route);
  });
  return routes;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'&amp;lt;Route&amp;gt;' 컴포넌트가 전달받은 정보들을 토대로 컴포넌트들을 JavaScript 객체로 생성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1768145245711&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;routes = [
  {
    id: &quot;0&quot;,
    path: &quot;/detail&quot;,
    element: &amp;lt;Detail /&amp;gt;,
  },
  {
    id: &quot;1&quot;,
    path: &quot;/detail/:id&quot;,
    element: &amp;lt;DetailPage /&amp;gt;,
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 맵핑된 라우트 객체가 생성되게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵핑된 객체를 가지고 평탄화 작업을 진행하게 되는데, 부모와 자식간의 레벨을 맞추기 위한 작업이다. 점수 기반의 정렬을 하기 위해 필요한 작업인데 여기서는 동등한 레벨이기에 점수 계산만 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768145311320&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;branches = [
  {
    path: &quot;/detail&quot;,
    score: 13,
    routesMeta: [{ relativePath: &quot;/detail&quot;, childrenIndex: 0, ... }]
  },
  {
    path: &quot;/detail/:id&quot;,
    score: 17,
    routesMeta: [{ relativePath: &quot;/detail/:id&quot;, childrenIndex: 1, ... }]
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반환된 결과에서 score의 의미는 해당 경로가 얼마나 구체적이고 좁은 범위를 의미하는가를 수치화한 것이다.&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;2. URL 매칭 (with 정규식 생성)&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;이제 평탄화된 라우트를 순회하며 현재 URL과 매칭을 시도한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 '/detail/123' 에 접속을 시도하면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 전 평탄화된 라우트 객체에서 score가 높은 순서대로 정규식을 이용한 매칭을 시도한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768145383612&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;'/detail/123'.match(/^\/detail\/([^\\/]+)$/) // 정규식 매칭

// 결과 반환
return {
  params,                     // { id: &quot;123&quot; }
  pathname: matchedPathname,  // &quot;/detail/123&quot;
  pathnameBase,               // &quot;/detail/123&quot;
  pattern                     // { path: &quot;detail/:id&quot;, ... }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 형태로 진행되고, params는 별도로 추출된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. context로 params 전달 및 렌더링 &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;마지막 과정으로 params는 context로 전달되고, 매칭된 Element를 조립하는 과정이 진행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 아는 'useParams()' 도 여기서 전달된 params를 가져오는 context 훅이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 react-router는 정규식 패턴 매칭을 이용해 라우팅을 처리한다고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt; 코드 기반 라우팅&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768145438681&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const express = require('express');
const app = express();

app.get('/detail', (req, res) =&amp;gt; {
  res.send('&amp;lt;h1&amp;gt;Detail Page&amp;lt;/h1&amp;gt;');
});

app.get('/detail/:id', (req, res) =&amp;gt; {
  res.send(`&amp;lt;h1&amp;gt;Detail ${req.params.id}&amp;lt;/h1&amp;gt;`);
});

app.listen(3000);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 기반 라우팅은 경로에 따라 필요한 코드를 전달해주는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로와 코드 및 컴포넌트를 연결하는 점에서 명시적 라우팅과 크게 다를게 무엇인가 싶을텐데, 과거의 웹을 구성하던 방식을 생각하면 이해하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재의 SPA 방식이 아닌 MPA 방식을 선택했다면 매 경로마다 일치하는 코드(HTML 문서)를 전달했을 것이다. 반면 SPA라면 초기에 하나의 HTML에 JS번들을 전부 다운로드받아 컴포넌트를 교체하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;명령형&lt;/b&gt;(MPA)과 &lt;b&gt;선언형&lt;/b&gt;(SPA)의 차이 정도로 이해하면 될 듯하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768145478480&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;app.get('/api/users', (req, res) =&amp;gt; {
  res.json([{ id: 1, name: 'John' }])
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 현재에는 Express(코드 기반 라우팅)은 HTML을 보내지 않고 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;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt; 데코레이터 기반 라우팅&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768145494901&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id, name: 'John' };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데코레이터 기반 라우팅은 클래스와 메서드 위에 '&lt;b&gt;@&lt;/b&gt;' 기호를 붙여서 라우팅 정보를 선언하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데코레이터는 &lt;b&gt;어떻게 동작해야하는지를 담고있는 설명서&lt;/b&gt;와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 데코레이터를 통해 'GET /users/:id' 라는 경로를 생성해두고, 'GET /users/123' 같은 요청이 들어오면 'findOne' 메서드를 실행하도록 하는 기능을 담고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;폴더구조 기반 라우팅&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768145556604&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Next.js 12이하
pages/
├── detail.js      -&amp;gt; localhost:3000/detail + html
└── detail/
    └── [id].js 

// Next.js 13이상
app/
└── detail/        -&amp;gt; localhost:3000/detail
    ├── page.js    -&amp;gt; html(detail)
    ├── layout.js
    ├── loading.js
    └── [id]/
        └── page.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js가 사용하고 있는 파일구조 기반의 라우팅에서는 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;파일/폴더&lt;/span&gt;&lt;/b&gt;를 기준으로 라우팅을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12/13 버전을 기준으로 라우팅 방식에 약간의 변화가 생겼는데, 간단히 설명하자면,&lt;/p&gt;
&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;12버전 이하&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12버전까지는 pages 폴더 하위에 &lt;span data-token-index=&quot;1&quot;&gt;&lt;b&gt;파일명 = URL&lt;/b&gt;&lt;/span&gt;인 라우팅 방식을 제공하였다. 파일명이 pathname이기 때문에 굉장히 직관적인 구조이며 단순하게 설정이 가능했다.&lt;/p&gt;
&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;13버전+&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;13버전부터는 app 폴더 하위에 원하는 url path으로 네이밍된 폴더가 위치하게 된다. 라우팅 경로는 생성되었지만, 응답해줄 페이지 컴포넌트가 존재하지 않기에 그 하위에 'page.tsx' 라는 파일을 생성해 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12버전까지를 Page Router라고 하고 13버전부터를 App Router라고 하는데, Next가 Page Router의 직관적인 구조를 포기하고 폴더명을 기준으로 라우팅을 하게된 이유는 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lsquo;detail&amp;rsquo;이라는(위의 경우) 폴더 아래에 &amp;lsquo;page&amp;rsquo;파일 외의 다른 역할을 해줄 파일이 필요했기 때문이다. 예를 들어 페이지별로 레이아웃이나 로딩, 에러 페이지 등은 굉장히 자주 쓰이는 컴포넌트들이다. 12버전에서는 각 페이지 컴포넌트 내에서 관리하거나, '_app.js' 에서 각 경로마다 분기처리를 해야했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768145679012&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function MyApp({ Component, pageProps, router }) {
  let Layout = DefaultLayout;

  if (router.pathname.startsWith('/detail')) {
    Layout = DetailLayout;
  }

  return (
    &amp;lt;Layout&amp;gt; 
      &amp;lt;Component {...pageProps} /&amp;gt; 
    &amp;lt;/Layout&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지가 많아지고 각각 다른 레이아웃을 필요로 한다면 그야말로 분기처리의 지옥이 될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;13버전의 App Router에서는 이를 폴더 구조를 이용해 분기처리를 해결하고, 좀더 손쉽게 사용할 수 있도록&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'layout.tsx', 'loading.tsx', 'error.tsx' 등으로 공용화(추상화) 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 layout.tsx, page.tsx 등은 Next.js에서의 &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;1141&quot; data-origin-height=&quot;867&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5aCa5/dJMcacaKOC1/IWIKxyt4XMXrXVfmepV6T0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5aCa5/dJMcacaKOC1/IWIKxyt4XMXrXVfmepV6T0/img.png&quot; data-alt=&quot;Next.js 공식문서 중&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5aCa5/dJMcacaKOC1/IWIKxyt4XMXrXVfmepV6T0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5aCa5%2FdJMcacaKOC1%2FIWIKxyt4XMXrXVfmepV6T0%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;1141&quot; height=&quot;867&quot; data-origin-width=&quot;1141&quot; data-origin-height=&quot;867&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Next.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;Next.js가 이러한 구조를 채택하는 이유는 개발자 경험(DX)을 핵심 설계 원칙으로 두고 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 개발자가 애플리케이션을 손쉽게 구축할 수 있도록 지원하고자 하기 때문에, 복잡한 라우팅 설정 등을 프레임워크 내부로 추상화하여 감추었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 돌아가서, Next.js가 폴더구조 기반의 라우팅, 이제 정확히는 파일 시스템 기반 라우팅을 채택한 이유도 개발자 경험을 개선하고자 하였기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js에서는 어떤 식으로 라우팅 되는지 이해하기 위해 간단하게 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;span data-token-index=&quot;0&quot;&gt;빌드 타임: 파일 스캔 및 라우트 맵핑&lt;/span&gt; &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js의 라우팅 관련 코드가 상대적으로 매우 복잡한 관계로 그 과정과 개념만 살려 표현하겠다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;271&quot; data-origin-height=&quot;281&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bN4V7w/dJMcagqGiEO/yP1XxVDA3sYpJKKq0aTMoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bN4V7w/dJMcagqGiEO/yP1XxVDA3sYpJKKq0aTMoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bN4V7w/dJMcagqGiEO/yP1XxVDA3sYpJKKq0aTMoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbN4V7w%2FdJMcagqGiEO%2FyP1XxVDA3sYpJKKq0aTMoK%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;271&quot; height=&quot;281&quot; data-origin-width=&quot;271&quot; data-origin-height=&quot;281&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우트로 변환할 대상은 app 하위의 파일들과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1768145913641&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function walk(dir, segments = []) {
	// 라우트 파싱
	...
	const newSegments = [
      ...segments,
      { name: folderName, type: segmentType, paramName }
  ]
        
  walk(path.join(dir, folderName), newSegments);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 시스템을 이용해 app 폴더 하위의 파일을 읽어와 라우트로 변환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 동적/정적 라우트 구분을 하여 type으로 반환하고, (main)과 같은 특수 폴더를 포함하지 않도록 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768145926990&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[
  {
    segments: [
      { name: 'detail', type: 'static', paramName: 'detail' },
      { name: '[id]', type: 'dynamic', paramName: 'id' }
    ],
    filePath: 'app/detail/[id]/page.tsx'
  }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과값은 앞서 보았던 react-router에서의 라우트 객체와 유사하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;span data-token-index=&quot;0&quot;&gt;정규식 변환 및 우선순위 정렬&lt;/span&gt; &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next에서도 react-router와 같이 정규식을 이용해 url과의 비교를 진행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 가장 큰 차이점은 정규식을 빌드 시점에 한 번만 생성한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, 점수 기반과 달리 사전에 구체적인 순서대로 정렬을 해둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;span data-token-index=&quot;0&quot;&gt;라우트 매니페스트 생성(routes-manifest.json)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;빌드 과정에서 생성해낸 모든 라우팅 정보를 JSON파일로 저장한다.&lt;/span&gt;&lt;span style=&quot;background-color: #000000; color: #eb5757;&quot; data-token-index=&quot;1&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;'./next/&lt;span data-token-index=&quot;0&quot;&gt;routes-manifest.json'&lt;/span&gt; 해당 경로에&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768146009677&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;version&quot;: 3,
  &quot;staticRoutes&quot;: [
    &quot;/about&quot;,
    &quot;/detail/new&quot;
  ],
  &quot;dynamicRoutes&quot;: [
    {
      &quot;page&quot;: &quot;/detail/[id]&quot;,
      &quot;regex&quot;: &quot;^\\/detail\\/([^\\/]+?)(?:\\/)?$&quot;,
      &quot;routeKeys&quot;: [&quot;id&quot;]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 형태의 라우팅 정보를 저장해두고 서버가 시작될 때 메모리에 로드되어 런타임 매칭에 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점이 React Router와의 가장 큰 차이인데, 런타임 환경에서 JSX 트리를 순회하여 라우트를 수집하는 방식과 달리 Next.js에서는 빌드 타임에 이미 모든 라우트를 계산하고 파일로 저장해둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 런타임: URL 매칭 및 컴포넌트 조립 &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;런타임 환경에서의 URL 매칭은 유사하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 '/detail/123' 에 접속을 시도하면,&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;staticRoutes&lt;/b&gt; 맵에서 검색&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dynamicRoutes&lt;/b&gt; 을 순회하며 정규식 매칭 &amp;rarr; 캡처 그룹에서 params 추출&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의 순서로 라우트 정보 매칭을 시도한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 컴포넌트를 찾았다면, 이제 조립을 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말했듯이 파일 컨벤션을 통해 모든 라우팅 구성이 이루어져있기 때문에, 컴포넌트 조립시에도 이 파일 컨벤션을 이용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768146077555&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function renderPage(matchedRoute) {
  const { page, params } = matchedRoute
  // page = '/detail/[id]', params = { id: '123' }
  const segments = page.split('/').filter(Boolean)
  // ['detail', '[id]']
  
  const layouts = []
  let currentPath = 'app'
  
  for (const segment of segments) {
    currentPath = path.join(currentPath, segment)
    const layoutPath = path.join(currentPath, 'layout.tsx')
    
    if (fs.existsSync(layoutPath)) {
      layouts.push(await import(layoutPath))
    }
  }
  
  // layouts = [
  //   RootLayout,    // app/layout.tsx
  //   DetailLayout   // app/detail/layout.tsx
  // ]
  
  const PageComponent = await import(path.join(currentPath, 'page.tsx'))
  let tree = &amp;lt;PageComponent params={params} /&amp;gt;
  
  for (let i = layouts.length - 1; i &amp;gt;= 0; i--) {
    const Layout = layouts[i]
    tree = &amp;lt;Layout params={params}&amp;gt;{tree}&amp;lt;/Layout&amp;gt;
  }
  
  return tree;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청된 path를 이용해 해당 폴더 위치를 찾아내고, 폴더 하위에 있는 'layout.tsx'등의 파일을 찾아 적용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768146103482&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;RootLayout&amp;gt;
  &amp;lt;DetailLayout&amp;gt;
    &amp;lt;PageComponent params={{ id: '123' }} /&amp;gt;
  &amp;lt;/DetailLayout&amp;gt;
&amp;lt;/RootLayout&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얻어진 컴포넌트 트리는 이러할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 Next.js는&amp;nbsp;&lt;b&gt;정규식 패턴 매칭&lt;/b&gt;을 사용한다는 점에서 React Router와 유사하지만, 모든 계산을&amp;nbsp;&lt;b&gt;빌드 타임에 수행&lt;/b&gt;하고 그 결과를&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>React</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/81</guid>
      <comments>https://memo-code.tistory.com/81#entry81comment</comments>
      <pubDate>Mon, 12 Jan 2026 00:42:06 +0900</pubDate>
    </item>
    <item>
      <title>VSCode 확장 프로그램 만들기 (3) - VSCode 코드 품질 측정 확장 프로그램(VSCode 익스텐션)</title>
      <link>https://memo-code.tistory.com/80</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Style Rank 프로젝트 소개&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 작성하다 보면 &quot;이 코드가 잘 작성된 건가?&quot; 하는 의문이 들 때가 있다. 주로 ESlint와 같은 도구를 활용하여 코드 컨벤션에 대한 검사를 하곤 하지만, 해당 코드가 얼마나 복잡하게 짜여있는지, 구조적으로 문제가 없어도 클린 코드로써 좋은 코드인지에 대한 검사에는 어려움이 있다. 리뷰를 해주는 입장에서 읽기에 가독성 등의 측면에서 문제가 없는지에 대한 의문이 든 경우가 많을 것이다.&lt;/p&gt;
&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: #fff59d;&quot;&gt;&lt;b&gt;&lt;code&gt;Style Rank&lt;/code&gt;&lt;/b&gt;&lt;/span&gt;는 이러한 의문에 답하기 위해 만든 VSCode 익스텐션이다. 파일을 저장하는 순간 코드 품질을 자동으로 분석하고, S/A/B/C/D/F 등급을 실시간으로 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 관련 깃허브 url과 vscode 익스텐션 마켓의 url이다.&lt;/p&gt;
&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;Github&lt;/b&gt;: &lt;a href=&quot;https://github.com/ChangwooJ/style-rank&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/ChangwooJ/style-rank&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1763990048214&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - ChangwooJ/style-rank&quot; data-og-description=&quot;Contribute to ChangwooJ/style-rank development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/ChangwooJ/style-rank&quot; data-og-url=&quot;https://github.com/ChangwooJ/style-rank&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0aNem/hyZOnUnpJc/GrDlzOKlUDZukeneKE8KAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cdXs4F/hyZOgvMbgX/Pt56vVaxVPyTRrIacZKzTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/ChangwooJ/style-rank&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/ChangwooJ/style-rank&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0aNem/hyZOnUnpJc/GrDlzOKlUDZukeneKE8KAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cdXs4F/hyZOgvMbgX/Pt56vVaxVPyTRrIacZKzTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - ChangwooJ/style-rank&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to ChangwooJ/style-rank development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;VSCode Marketplace(Style Rank)&lt;/b&gt;: &lt;a href=&quot;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1763990130298&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Style Rank - Visual Studio Marketplace&quot; data-og-description=&quot;Extension for Visual Studio Code - AST 기반 코드 품질 랭크 측정기&quot; data-og-host=&quot;marketplace.visualstudio.com&quot; data-og-source-url=&quot;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&quot; data-og-url=&quot;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cxSvPs/hyZOkQTda2/Eu92vlVcyw4bho3KyDayuk/img.png?width=727&amp;amp;height=641&amp;amp;face=0_0_727_641,https://scrap.kakaocdn.net/dn/6Aa4B/hyZN601a28/uwPfXKmFXWU4v1qgyP3RKK/img.png?width=727&amp;amp;height=641&amp;amp;face=0_0_727_641&quot;&gt;&lt;a href=&quot;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://marketplace.visualstudio.com/items?itemName=style-rank.style-rank&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cxSvPs/hyZOkQTda2/Eu92vlVcyw4bho3KyDayuk/img.png?width=727&amp;amp;height=641&amp;amp;face=0_0_727_641,https://scrap.kakaocdn.net/dn/6Aa4B/hyZN601a28/uwPfXKmFXWU4v1qgyP3RKK/img.png?width=727&amp;amp;height=641&amp;amp;face=0_0_727_641');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Style Rank - Visual Studio Marketplace&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Extension for Visual Studio Code - AST 기반 코드 품질 랭크 측정기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;marketplace.visualstudio.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;프로젝트 개요&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Style Rank는 AST 기반 코드 복잡도 분석 및 랭크 측정 도구다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 60.6977%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.6047%;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1933%;&quot;&gt;&lt;b&gt;내용&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.6047%;&quot;&gt;&lt;b&gt;목적&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1933%;&quot;&gt;TypeScript/JavaScript 코드의 복잡도와 클린코드 위반 자동 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.6047%;&quot;&gt;&lt;b&gt;핵심 기능&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1933%;&quot;&gt;파일 저장 시 자동 분석 + 실시간 랭크 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.6047%;&quot;&gt;&lt;b&gt;기술 스택&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1933%;&quot;&gt;Babel Parser, Babel Traverse, VSCode Extension API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.6047%;&quot;&gt;&lt;b&gt;지원 언어&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1933%;&quot;&gt;JavaScript, TypeScript, JSX, TSX&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;핵심 기능&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;자동 코드 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;src/extension.ts&lt;/code&gt;&lt;/b&gt;에서 파일 저장 이벤트를 감지하여 자동으로 분석을 실행한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const saveListener = vscode.workspace.onDidSaveTextDocument((document) =&amp;gt; {
  const supportedLanguages = ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'];

  if (!supportedLanguages.includes(document.languageId)) {
    console.log('지원하지 않는 언어입니다.');
    return;
  }

  try {
    const code = document.getText();
    const fileName = path.basename(document.fileName);
    const filePath = document.fileName;
    analyzeCode(code, fileName, filePath, true);
  } catch (e) {
    vscode.window.showErrorMessage(
      `코드 분석 중 오류가 발생했습니다: ${e instanceof Error ? e.message : String(e)}`
    );
  }
});&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;파일을 저장할 때마다 document.getText()로 전체 코드를 가져와 분석한다. 에러 발생 시 사용자에게 명확한 메시지를 표시한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;다차원 분석 파이프라인&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;src/extension.ts의 analyzeCode 함수는 여러 분석을 순차적으로 실행한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const analyzeCode = (code: string, fileName: string, filePath: string, showOutput: boolean = false) =&amp;gt; {
  try {
    // 1. AST 파싱
    const ast = parseCodeToAST(code);

    // 2. 복잡도 계산
    const complexityResult = calculateRefinedComplexityScore(ast);
    const { ccs, cognitiveComplexity, lengthPenalty, maxNestingDepth, longFunctions, complexityHotspots } = complexityResult;

    // 3. 클린 코드 규칙 검사
    const cleanCodeResult = checkCleanCodeRules(ast);
    const { violations, violationCount } = cleanCodeResult;

    // 4. 랭크 부여
    const rank = assignRefinedRank(ccs, violationCount);
    const rankDescription = getRankDescription(rank);

    // 5. 상태바 업데이트
    const detailedTooltip = [
      `종합 복잡도 점수: ${ccs.toFixed(1)}`,
      `인지 복잡도: ${cognitiveComplexity}`,
      `최대 중첩 깊이: ${maxNestingDepth}`,
      `클린 코드 위반: ${violationCount}건`,
      '',
      rankDescription,
    ].join('\\n');

    statusBarManager.updateRank(rank, Math.round(ccs), detailedTooltip);

    // 6. 분석 결과 저장
    lastAnalysisResult = {
      ccs,
      cognitiveComplexity,
      lengthPenalty,
      maxNestingDepth,
      violationCount,
      violations,
      rank,
      rankDescription,
      longFunctions,
      complexityHotspots,
      filePath,
    };

    return lastAnalysisResult;
  } catch (e) {
    console.error('Error analyzing code:', e);
    statusBarManager.hide();
    throw e;
  }
};&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;이 함수는 Style Rank의 두뇌 역할을 한다. AST 파싱부터 UI 업데이트까지 전체 분석 과정을 관장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;실시간 상태바 표시&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;&lt;code&gt;src/statusBar.ts&lt;/code&gt;&lt;/b&gt;의 StatusBarManager 클래스는 분석 결과를 시각화한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export class StatusBarManager {
  private statusBarItem: vscode.StatusBarItem;

  constructor(commandId: string) {
    this.statusBarItem = vscode.window.createStatusBarItem(
      vscode.StatusBarAlignment.Right,
      100
    );
    this.statusBarItem.command = commandId;
  }

  updateRank(rank: Rank, complexity: number, description: string): void {
    const icon = this.getRankIcon(rank);
    const color = this.getRankColor(rank);

    this.statusBarItem.text = `${icon} Rank: ${rank}`;
    this.statusBarItem.tooltip = `순환 복잡도: ${complexity}\\n${description}\\n\\n클릭하여 상세 보기`;
    this.statusBarItem.backgroundColor = color;
    this.statusBarItem.show();
  }

  private getRankIcon(rank: Rank): string {
    const icons: Record&amp;lt;Rank, string&amp;gt; = {
      S: '$(star-full)',
      A: '$(별)',
      B: '$(check)',
      C: '$(warning)',
      D: '$(alert)',
      F: '$(error)',
    };
    return icons[rank];
  }

  private getRankColor(rank: Rank): vscode.ThemeColor | undefined {
    if (rank === 'D' || rank === 'F') {
      return new vscode.ThemeColor('statusBarItem.warningBackground');
    }
    return undefined;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등급에 따라 다른 아이콘과 색상을 표시하여 직관적인 피드백을 제공한다. D와 F 등급은 경고 색상으로 강조된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;상세 분석 결과 제공&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;상태바를 클릭하면 QuickPick UI를 통해 상세 분석 결과를 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const showDetailedReport = () =&amp;gt; {
  if (!lastAnalysisResult) {
    vscode.window.showInformationMessage('분석 결과가 없습니다. 파일을 저장하여 분석을 시작하세요.');
    return;
  }

  quickPickManager.showAnalysisResult(lastAnalysisResult);
};

const showDetailsCommand = vscode.commands.registerCommand('style-rank.showDetails', showDetailedReport);&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;QuickPick은 복잡도 핫스팟, 긴 함수, 클린 코드 위반 항목을 보여주고, 선택하면 해당 코드 위치로 즉시 이동한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;분석 메트릭&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Style Rank는 다양한 메트릭을 종합하여 코드 품질을 평가한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;인지 복잡도 (Cognitive Complexity)&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;중첩 깊이를 고려한 복잡도 측정이다. Early Return 패턴을 감안하고, 깊은 중첩에 가중치를 부여한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 좋은 예: Early Return (복잡도 낮음)
function validateUser(user) {
  if (!user) {
    return false;
  }
  if (!user.email) {
    return false;
  }
  return true;
}

// 나쁜 예: 중첩된 조건문 (복잡도 높음)
function validateUser(user) {
  if (user) {
    if (user.email) {
      return true;
    }
  }
  return false;
}&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;size18&quot;&gt;&lt;b&gt;함수 길이 패널티&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;30줄을 초과하는 함수를 감지한다. 단, JSX를 포함한 React 컴포넌트는 제외한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// React 컴포넌트의 길이는 제외(추후 개선 예정)
function MyComponent() {
  return (
    &amp;lt;div&amp;gt;
      {/* 50줄의 JSX */}
    &amp;lt;/div&amp;gt;
  );
}

// 비즈니스 로직이 40줄이면 경고
function processData(data) {
  // 40줄의 복잡한 로직
  // 리팩토링 권장
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;클린 코드 규칙&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4가지 핵심 규칙을 검사한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;느슨한 동등 연산자&lt;/b&gt;: &lt;b&gt;&lt;code&gt;==&lt;/code&gt; &lt;/b&gt;대신 &lt;b&gt;&lt;code&gt;===&lt;/code&gt; &lt;/b&gt;사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;매직 넘버&lt;/b&gt;: 하드코딩된 숫자를 상수로 선언&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파라미터 플래그&lt;/b&gt;: boolean 플래그 대신 함수 분리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;과다한 파라미터&lt;/b&gt;: 5개 초과 시 객체로 그룹화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;등급 시스템&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 61.1628%; height: 135px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 21px;&quot;&gt;점수&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 21px; text-align: center;&quot;&gt;등급&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 21px; text-align: center;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 21px;&quot;&gt;&amp;le;5,&amp;nbsp;위반&amp;nbsp;0건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 21px; text-align: center;&quot;&gt;S&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 21px;&quot;&gt;완벽&amp;nbsp;-&amp;nbsp;클린하고&amp;nbsp;이해하기&amp;nbsp;쉬운&amp;nbsp;코드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 21px;&quot;&gt;&amp;le;10,&amp;nbsp;위반&amp;nbsp;&amp;le;1건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 21px; text-align: center;&quot;&gt;A&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 21px;&quot;&gt;우수&amp;nbsp;-&amp;nbsp;가독성과&amp;nbsp;유지보수성이&amp;nbsp;높은&amp;nbsp;코드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 21px;&quot;&gt;&amp;le;20,&amp;nbsp;위반&amp;nbsp;&amp;le;3건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 21px; text-align: center;&quot;&gt;B&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 21px;&quot;&gt;양호&amp;nbsp;-&amp;nbsp;약간의&amp;nbsp;개선&amp;nbsp;여지가&amp;nbsp;있는&amp;nbsp;코드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 17px;&quot;&gt;&amp;le;30,&amp;nbsp;위반&amp;nbsp;&amp;le;5건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 17px; text-align: center;&quot;&gt;C&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 17px;&quot;&gt;주의&amp;nbsp;-&amp;nbsp;복잡도&amp;nbsp;또는&amp;nbsp;코드&amp;nbsp;스타일&amp;nbsp;개선&amp;nbsp;필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 17px;&quot;&gt;&amp;le;40,&amp;nbsp;위반&amp;nbsp;&amp;le;8건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 17px; text-align: center;&quot;&gt;D&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 17px;&quot;&gt;나쁨&amp;nbsp;-&amp;nbsp;즉시&amp;nbsp;리팩토링&amp;nbsp;권장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.6821%; height: 17px;&quot;&gt;&amp;gt;40&amp;nbsp;또는&amp;nbsp;위반&amp;nbsp;&amp;gt;8건&lt;/td&gt;
&lt;td style=&quot;width: 7.63568%; height: 17px; text-align: center;&quot;&gt;F&lt;/td&gt;
&lt;td style=&quot;width: 34.8449%; height: 17px;&quot;&gt;위험&amp;nbsp;-&amp;nbsp;긴급&amp;nbsp;리팩토링&amp;nbsp;필수&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;&lt;b&gt;프로젝트&amp;nbsp;구조&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1763989611438&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;style-rank/
├── src/
│   ├── extension.ts           # 확장 진입점 및 이벤트 리스너
│   ├── analyzer.ts            # AST 파싱 엔진
│   ├── complexity.ts          # 복잡도 계산 로직
│   ├── ranking.ts             # 랭킹 시스템
│   ├── cleanCodeRules.ts      # 클린 코드 규칙 검사
│   ├── statusBar.ts           # 상태바 UI 관리
│   ├── quickPickManager.ts    # QuickPick UI 관리
│   └── suggestions.ts         # 타입 정의
├── package.json               # 확장 메타데이터
└── esbuild.js                # 빌드 설정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;사용 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. VSCode에서&amp;nbsp;JavaScript/TypeScript&amp;nbsp;파일&amp;nbsp;열기 &lt;br /&gt;2. 파일 저장 (Ctrl+S) &lt;br /&gt;3. 하단 우측 상태바에서 랭크 확인 &lt;br /&gt;4. 상태바 클릭 시 상세 분석 결과 보기 &lt;br /&gt;5. 개선 항목 선택 시 해당 코드 위치로 이동&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;향후 계획&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로젝트 전체 분석 및 리포트 생성&lt;/li&gt;
&lt;li&gt;클린 코드 규칙 사용자 설정&lt;/li&gt;
&lt;li&gt;커밋 기준 분석 및 깃허브 연동&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마치며&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;새로운&amp;nbsp;시도를&amp;nbsp;해보며&amp;nbsp;개발의&amp;nbsp;본질은&amp;nbsp;결국&amp;nbsp;문제를&amp;nbsp;정의하고&amp;nbsp;해결하는&amp;nbsp;과정이라는&amp;nbsp;점을&amp;nbsp;다시&amp;nbsp;한번&amp;nbsp;느꼈다.&amp;nbsp;AST라는&amp;nbsp;낯선&amp;nbsp;개념도&amp;nbsp;문서와&amp;nbsp;예제를&amp;nbsp;통해&amp;nbsp;학습하면&amp;nbsp;충분히&amp;nbsp;활용할&amp;nbsp;수&amp;nbsp;있었고,&amp;nbsp;VSCode&amp;nbsp;API도&amp;nbsp;타입&amp;nbsp;정의만&amp;nbsp;잘&amp;nbsp;따라가면&amp;nbsp;원하는&amp;nbsp;기능을&amp;nbsp;구현할&amp;nbsp;수&amp;nbsp;있었다.&amp;nbsp;처음엔&amp;nbsp;막연했던&amp;nbsp;&quot;코드&amp;nbsp;품질을&amp;nbsp;어떻게&amp;nbsp;측정할까?&quot;라는&amp;nbsp;질문이&amp;nbsp;구체적인&amp;nbsp;메트릭과&amp;nbsp;알고리즘으로&amp;nbsp;구현되는&amp;nbsp;과정을&amp;nbsp;지켜보는&amp;nbsp;것은&amp;nbsp;즐거운&amp;nbsp;경험이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히&amp;nbsp;실제로&amp;nbsp;내가&amp;nbsp;작성한&amp;nbsp;코드에&amp;nbsp;Style&amp;nbsp;Rank를&amp;nbsp;적용해보며&amp;nbsp;객관적인&amp;nbsp;피드백을&amp;nbsp;받는&amp;nbsp;경험은&amp;nbsp;신선했다.&amp;nbsp;평소에&amp;nbsp;&quot;이&amp;nbsp;함수가&amp;nbsp;좀&amp;nbsp;복잡한&amp;nbsp;것&amp;nbsp;같은데?&quot;라고&amp;nbsp;막연히&amp;nbsp;느끼던&amp;nbsp;것들이&amp;nbsp;구체적인&amp;nbsp;숫자와&amp;nbsp;등급으로&amp;nbsp;나타나니,&amp;nbsp;리팩토링의&amp;nbsp;우선순위를&amp;nbsp;정하기도&amp;nbsp;쉬워졌다.&amp;nbsp;개발&amp;nbsp;도구를&amp;nbsp;만드는&amp;nbsp;개발자로서,&amp;nbsp;내가&amp;nbsp;만든&amp;nbsp;도구가&amp;nbsp;실제로&amp;nbsp;도움이&amp;nbsp;된다는&amp;nbsp;것을&amp;nbsp;직접&amp;nbsp;체감할&amp;nbsp;수&amp;nbsp;있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로도&amp;nbsp;이런&amp;nbsp;작은&amp;nbsp;시도들을&amp;nbsp;계속&amp;nbsp;이어가며,&amp;nbsp;개발자로서의&amp;nbsp;도구&amp;nbsp;상자를&amp;nbsp;하나씩&amp;nbsp;채워나가고&amp;nbsp;싶다.&amp;nbsp;불편함을&amp;nbsp;느끼면&amp;nbsp;직접&amp;nbsp;해결하고,&amp;nbsp;필요한&amp;nbsp;도구가&amp;nbsp;없으면&amp;nbsp;만드는&amp;nbsp;것.&amp;nbsp;그것이&amp;nbsp;개발자가&amp;nbsp;가진&amp;nbsp;가장&amp;nbsp;큰&amp;nbsp;강점이&amp;nbsp;아닐까&amp;nbsp;싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>extension</category>
      <category>vscode</category>
      <category>vscode-extension</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/80</guid>
      <comments>https://memo-code.tistory.com/80#entry80comment</comments>
      <pubDate>Mon, 24 Nov 2025 22:19:18 +0900</pubDate>
    </item>
    <item>
      <title>VSCode 확장 프로그램 만들기 (2) - AST와 코드 복잡도 측정</title>
      <link>https://memo-code.tistory.com/79</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;코드 품질을 객관적으로 측정하려면 어떻게 해야 할까?&lt;br /&gt;이번 글에서는 AST(Abstract Syntax Tree)와 순환 복잡도, 인지 복잡도 등 이번 프로젝트에서 주요하게 다른 코드 분석 내용을 다룰 것이다.&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;AST(Abstract Syntax Tree)란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AST는 소스 코드의 구조를 트리 형태로 표현한 자료구조다. 코드의 문법적 구조를 추상화하여 컴파일러나 분석 도구가 이해할 수 있는 형태로 변환한다. 예를 들어,&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const add = (a, b) =&amp;gt; a + b;&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;이 코드는 AST로 다음과 같이 표현된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;VariableDeclaration&quot;,
  &quot;declarations&quot;: [
    {
      &quot;type&quot;: &quot;VariableDeclarator&quot;,
      &quot;id&quot;: { &quot;type&quot;: &quot;Identifier&quot;, &quot;name&quot;: &quot;add&quot; },
      &quot;init&quot;: {
        &quot;type&quot;: &quot;ArrowFunctionExpression&quot;,
        &quot;params&quot;: [
          { &quot;type&quot;: &quot;Identifier&quot;, &quot;name&quot;: &quot;a&quot; },
          { &quot;type&quot;: &quot;Identifier&quot;, &quot;name&quot;: &quot;b&quot; }
        ],
        &quot;body&quot;: {
          &quot;type&quot;: &quot;BinaryExpression&quot;,
          &quot;operator&quot;: &quot;+&quot;,
          &quot;left&quot;: { &quot;type&quot;: &quot;Identifier&quot;, &quot;name&quot;: &quot;a&quot; },
          &quot;right&quot;: { &quot;type&quot;: &quot;Identifier&quot;, &quot;name&quot;: &quot;b&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;Babel Parser를 사용한 AST 파싱을 통해 추상화된 내용을 이용해 분석 도구 등에 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript/TypeScript 생태계에서 가장 널리 사용되는 파서는 Babel Parser다. src/analyzer.ts에서 다음과 같이 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;import { parse } from '@babel/parser';
import type { File } from '@babel/types';

export function parseCodeToAST(code: string): File {
  try {
    const ast = parse(code, {
      sourceType: 'module',
      plugins: ['jsx', 'typescript'],
    });
    return ast;
  } catch (error) {
    console.error('AST Error:', error);
    throw new Error('코드를 AST 객체로 파싱하는 데에 실패하였습니다.');
  }
}&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;plugins: ['jsx', 'typescript'] 설정으로 React와 TypeScript 문법을 모두 지원한다. sourceType: 'module'은 ES 모듈 형식으로 파싱하겠다는 의미다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;해당 메서드를 통해 앞으로 모든 코드를 일관된 방법으로 파싱할 수 있게 되고, 파싱된 내용의 노드, 값 등을 분석해 복잡도 계산 등에 이용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;순환 복잡도(Cyclomatic Complexity)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순환 복잡도는 코드의 논리적 경로 수를 측정하는 지표다.&lt;br /&gt;Thomas McCabe가 1976년에 제안한 이론으로, 코드의 테스트 난이도와 유지보수성을 예측하는 데 사용된다.&lt;/p&gt;
&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;계산 공식: CC(순환 복잡도) = N(분기점의 개수) + 1&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분기점이 되는 요소:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;if, else if&lt;/li&gt;
&lt;li&gt;for, while, do-while&lt;/li&gt;
&lt;li&gt;switch-case&lt;/li&gt;
&lt;li&gt;논리 연산자 (&amp;amp;&amp;amp;, ||)&lt;/li&gt;
&lt;li&gt;삼항 연산자 (? :)&lt;/li&gt;
&lt;li&gt;catch 블록&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;src/complexity.ts&lt;/code&gt;에서 구현한 기본 순환 복잡도 계산 로직이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export function calculateComplexity(ast: File): number {
  let complexity = 1;

  traverse(ast, {
    IfStatement() {
      complexity++;
    },
    ConditionalExpression() {
      complexity++;
    },
    LogicalExpression(path: NodePath&amp;lt;LogicalExpression&amp;gt;) {
      const operator = path.node.operator;
      if (operator === '&amp;amp;&amp;amp;' || operator === '||') {
        complexity++;
      }
    },
    SwitchCase(path: NodePath&amp;lt;SwitchCase&amp;gt;) {
      if (path.node.test !== null) {
        complexity++;
      }
    },
    ForStatement() {
      complexity++;
    },
    WhileStatement() {
      complexity++;
    },
    CatchClause() {
      complexity++;
    },
  });

  return complexity;
}&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;@babel/traverse를 사용하면 AST의 특정 노드 타입을 방문하면서 복잡도를 카운팅할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;AST 상에서 if문은 type을 IfStatement 값으로 가지므로 위와 같이 각 분기 방문시마다 검증을 통해 복잡도를 카운팅하는 방식이다.&lt;br /&gt;AST를 통해 대체로 타입을 가려낼수 있으므로 각 조건에 대한 예외 등을 처리할 수도 있다. 지금처럼 정해진 패턴에서는 괜찮지만 구현자의 판단과 기준이 많이 들어갈수록 섬세한 예외처리가 필요하고 이때에 유용하게 사용될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;인지 복잡도(Cognitive Complexity)&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;br /&gt;이를 개선하기 위해 순환 복잡도에서 인지 복잡도로 방식을 변경하였는데 , SonarSource가 제안한 인지 복잡도는 코드를 읽는 사람의 관점에서 복잡도를 측정하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인지 복잡도의 핵심 원칙:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중첩 깊이에 가중치 부여: 중첩이 깊을수록 복잡도 증가&lt;br /&gt;Early Return 패턴 인정: Guard Clause는 복잡도를 낮춤&lt;br /&gt;선형적 구조 선호: 순차적인 조건문은 복잡도를 낮게 평가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;src/complexity.ts&lt;/code&gt;에서 구현한 인지 복잡도 계산 로직이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export function calculateCognitiveComplexity(ast: File): {
  complexity: number;
  hotspots: ComplexityHotspot[];
} {
  let cognitiveComplexity = 0;
  let nestingLevel = 0;
  const hotspots: ComplexityHotspot[] = [];

  traverse(ast, {
    IfStatement: {
      enter(path: NodePath&amp;lt;IfStatement&amp;gt;) {
        // Early Return 패턴 감지
        const isEarlyReturn =
          path.node.consequent.type === 'BlockStatement' &amp;amp;&amp;amp;
          path.node.consequent.body.length === 1 &amp;amp;&amp;amp;
          path.node.consequent.body[0].type === 'ReturnStatement' &amp;amp;&amp;amp;
          !path.node.alternate;

        if (isEarlyReturn) {
          cognitiveComplexity += 1;  // Early Return은 복잡도 낮게 평가
        } else {
          const complexity = 1 + nestingLevel;  // 중첩 깊이만큼 가중치
          cognitiveComplexity += complexity;

          // 중첩이 깊은 코드를 핫스팟으로 기록
          if (nestingLevel &amp;gt;= 2) {
            hotspots.push({
              type: 'IfStatement',
              line: path.node.loc?.start.line || 0,
              nestingLevel,
            });
          }

          nestingLevel++;
        }
      },
      exit(path: NodePath&amp;lt;IfStatement&amp;gt;) {
        const isEarlyReturn = /* ... */;
        if (!isEarlyReturn) {
          nestingLevel--;
        }
      },
    },
  });

  return { complexity: cognitiveComplexity, hotspots };
}&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;이 로직의 핵심은 enter와 exit 훅을 사용해 중첩 깊이를 추적하는 것이다. if 문에 진입할 때 nestingLevel을 증가시키고, 빠져나올 때 감소시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;인지 복잡도는 각 분기마다 이해를 방해하는 정도를 반영하기 위해 가중치를 부여해 계산하는 것이므로, 기본적으로 인지 복잡도에서의 가중치는 depth를 기준으로 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이를 이해하기 위해 예제 코드를 살펴보자.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;function foo(a, b, c) {
   if(a &amp;gt; b) {
      if(b &amp;gt; c) {
         ...
      }
   }
   if(a &amp;lt; b) {
      ...
   }
}&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;해당 파일의 인지 복잡도는 4이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;복잡도 증가분은 1 + N(중첩 레벨, 가중치) 인데, if(a &amp;gt; b) 에서 1+0, if(b &amp;gt; c) 에서 1+1 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인지 복잡도의 분기별 처리 기준:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;if문&amp;gt; 현재 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N&lt;/li&gt;
&lt;li&gt;삼항연산자&amp;gt; 현재 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N&lt;/li&gt;
&lt;li&gt;논리연산자&amp;gt; 중첩이 아니기 때문에 항상 기본 복잡도만 증가, (복잡도) = 1&lt;/li&gt;
&lt;li&gt;switch문&amp;gt; default 케이스는 제외하고 각 case마다 복잡도 증가(case마다 중첩), (복잡도) = 1 + N&lt;/li&gt;
&lt;li&gt;반목문&amp;gt; 반복문은 중첩을 만들기 때문에 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N&lt;/li&gt;
&lt;li&gt;catch문&amp;gt; 예외처리 역시 중첩 레벨만큼 가중치를 더해서 계산, (복잡도) = 1 + N&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;함수 길이 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴 함수는 가독성과 유지보수성을 해치는 주요 원인이다.&lt;br /&gt;다만, React 컴포넌트처럼 JSX를 반환하는 함수는 예외로 처리해야 한다. 현재로서는 JSX도 예외해두었지만, JSX 역시 길면 가독성에 영향을 주기 때문에 이를 어떻게 처리해야할지 고민중이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export function calculateLengthPenalty(ast: File): {
  penalty: number;
  longFunctions: LongFunction[];
} {
  const MAX_RECOMMENDED_LENGTH = 30;
  const longFunctions: LongFunction[] = [];

  traverse(ast, {
    'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
      const startLine = path.node.loc?.start.line || 0;
      const endLine = path.node.loc?.end.line || 0;
      const functionLength = endLine - startLine + 1;

      // JSX를 포함한 컴포넌트는 제외
      let hasJSX = false;
      path.traverse({
        JSXElement() { hasJSX = true; },
        JSXFragment() { hasJSX = true; },
      });

      if (hasJSX) {
        return;
      }

      if (functionLength &amp;gt; MAX_RECOMMENDED_LENGTH) {
        const functionName =
          path.node.type === 'FunctionDeclaration' &amp;amp;&amp;amp; path.node.id
            ? path.node.id.name
            : '익명 함수';

        longFunctions.push({
          name: functionName,
          startLine,
          endLine,
          length: functionLength,
        });
      }
    },
  });

  const maxPenalty = longFunctions.length &amp;gt; 0
    ? Math.max(...longFunctions.map(f =&amp;gt; Math.floor((f.length - MAX_RECOMMENDED_LENGTH) / 10)))
    : 0;

  return { penalty: maxPenalty, longFunctions };
}&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;path.traverse()를 사용하면 특정 노드 내부만 순회할 수 있다. 이를 통해 함수가 JSX를 포함하는지 확인한다.&lt;br /&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AST를 활용하면 코딩 컨벤션 위반도 감지할 수 있다.&lt;br /&gt;특정 값이나 기호 등을 타입을 통해 검사하고 원하는 규칙으로 사용하도록 설정할 수 있다. 앞서 언급한 것처럼 이는 주관이 개입하기 때문에 예외에 대한 처리가 확실하게 되어야한다.&lt;br /&gt;또한, 클린 코드에 대한 기준은 개인별로 다를 수 있기에 추후 개별적으로 설정할 수 있도록 하는 옵션도 추가할 예정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;src/cleanCodeRules.ts&lt;/code&gt;에서 다음 규칙들을 검사한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;느슨한 동등 연산자 (==, !=)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;traverse(ast, {
  BinaryExpression(path: NodePath&amp;lt;BinaryExpression&amp;gt;) {
    if (path.node.operator === '==' || path.node.operator === '!=') {
      violations.push({
        rule: 'no-loose-equality',
        message: `'${path.node.operator}' 대신 '${path.node.operator === '==' ? '===' : '!=='}'를 사용하세요`,
        line: path.node.loc?.start.line,
      });
    }
  },
});
&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;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;const ALLOWED_NUMBERS = new Set([0, 1, -1]);

traverse(ast, {
  NumericLiteral(path: NodePath&amp;lt;NumericLiteral&amp;gt;) {
    const value = path.node.value;

    if (ALLOWED_NUMBERS.has(value)) {
      return;
    }

    const parent = path.parent;
    if (
      parent.type === 'MemberExpression' ||
      parent.type === 'ObjectProperty' ||
      parent.type === 'ArrayExpression'
    ) {
      return;
    }

    violations.push({
      rule: 'no-magic-number',
      message: `매직 넘버 '${value}' 대신 상수를 사용하세요`,
      line: path.node.loc?.start.line,
    });
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&amp;lt;img src='/img' width={20} height={20} /&amp;gt;&lt;/code&gt; 과 같은 경우에 체크되는 숫자 값도 있으니 이를 제외하기 위해&lt;br /&gt;parent 노드를 확인하여 배열이나 객체 리터럴의 숫자는 제외하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;과다한 파라미터&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;const MAX_PARAMETERS = 5;

traverse(ast, {
  'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
    const paramCount = path.node.params.length;

    if (paramCount &amp;gt; MAX_PARAMETERS) {
      const functionName =
        path.node.type === 'FunctionDeclaration' &amp;amp;&amp;amp; path.node.id
          ? path.node.id.name
          : '익명 함수';

      violations.push({
        rule: 'max-parameters',
        message: `함수 '${functionName}'의 파라미터가 ${paramCount}개입니다 (최대 ${MAX_PARAMETERS}개)`,
        line: path.node.loc?.start.line,
      });
    }
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;종합 복잡도 점수(CCS_Refined)&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;여러 지표를 하나의 점수로 통합하기 위해 CCS(Comprehensive Complexity Score) 공식을 설계했다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;export function calculateRefinedComplexityScore(ast: File): {
  ccs: number;
  cognitiveComplexity: number;
  lengthPenalty: number;
  maxNestingDepth: number;
  longFunctions: LongFunction[];
  complexityHotspots: ComplexityHotspot[];
} {
  const cognitiveResult = calculateCognitiveComplexity(ast);
  const lengthPenaltyResult = calculateLengthPenalty(ast);
  const maxNestingDepth = calculateMaxNestingDepth(ast);

  const ccs = 1.0 * cognitiveResult.complexity + 0.5 * lengthPenaltyResult.penalty;

  return {
    ccs,
    cognitiveComplexity: cognitiveResult.complexity,
    lengthPenalty: lengthPenaltyResult.penalty,
    maxNestingDepth,
    longFunctions: lengthPenaltyResult.longFunctions,
    complexityHotspots: cognitiveResult.hotspots,
  };
}&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;CCS 계산식: 1.0 &amp;times; 인지 복잡도 + 0.5 &amp;times; 길이 페널티 + 5.0 &amp;times; 위반 건수 각 가중치는 실험적으로 조정한 값이다. 인지 복잡도가 가장 중요하고, 클린 코드 위반은 큰 페널티를 준다.&lt;br /&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;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 점수를 S/A/B/C/D/F 등급으로 변환한다.&lt;br /&gt;현 코드의 상태를 보다 쉽게 파악하고 재미 요소도 가미하기 위해 추가하였다.&lt;br /&gt;현재는 각 파일에 대해 액티브될 경우에만 표현되지만, 전체 파일에 대한 평가 혹은 깃허브를 기준으로 하는 커밋내역에 대한 평가도 구상중에 있다. (다만 확장 프로그램의 형태로는 구현이 불가해 다른 방향을 모색해야한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;src/ranking.ts&lt;/code&gt;에서 구현했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;export function assignRefinedRank(ccs: number, violationCount: number): Rank {
  const violationPenalty = violationCount * 5;
  const finalScore = ccs + violationPenalty;

  if (finalScore &amp;lt;= 5 &amp;amp;&amp;amp; violationCount === 0) {
    return 'S';
  } else if (finalScore &amp;lt;= 10 &amp;amp;&amp;amp; violationCount &amp;lt;= 1) {
    return 'A';
  } else if (finalScore &amp;lt;= 20 &amp;amp;&amp;amp; violationCount &amp;lt;= 3) {
    return 'B';
  } else if (finalScore &amp;lt;= 30 &amp;amp;&amp;amp; violationCount &amp;lt;= 5) {
    return 'C';
  } else if (finalScore &amp;lt;= 40 || violationCount &amp;lt;= 8) {
    return 'D';
  } else {
    return 'F';
  }
}
&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;S 등급을 받으려면 복잡도가 낮고 위반 사항이 전혀 없어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전반적으로 조건을 구성하며 완벽한 코드라는 것에 대한 기준이 모호하기에 사실상 A등급 정도만 되어도 충분히 깔끔하고 가독성 좋은 코드라고 보인다. 여기까지 설계를 마치며 드는 생각은 평가에 대한 엣지 케이스가 종종 보인다는 점이다. 단순히 예를 들어 파일의 길이가 짧으면 좋은 평가를 받기 쉬워진다. 그렇다고 파일의 길이가 길어야 좋은 것은 아니기에 &quot;적절함&quot;이라는 기준에 맞추기가 쉽지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>extension</category>
      <category>vscode</category>
      <category>vscode-extension</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/79</guid>
      <comments>https://memo-code.tistory.com/79#entry79comment</comments>
      <pubDate>Mon, 24 Nov 2025 21:50:10 +0900</pubDate>
    </item>
    <item>
      <title>VSCode 확장 프로그램 만들기 (1) - 확장 프로그램 시작하기</title>
      <link>https://memo-code.tistory.com/78</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;VSCode 확장 프로그램 개발 시작하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VSCode는 개발자들에게 가장 사랑받는 에디터 중 하나다. 그 이유 중 하나는 바로 확장 프로그램(Extension) 생태계 덕분이다. 이번 글에서는 VSCode 확장 프로그램을 만드는 방법과 개발 과정에서 겪은 경험을 공유한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개발 환경 구축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VSCode 확장 프로그램을 만들기 위해서는 먼저 개발 환경을 구축해야 한다. Yeoman과 generator-code를 사용하면 확장 프로그램의 기본 템플릿을 쉽게 생성할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;npm install -g yo generator-code

yo code&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;템플릿 생성 시 여러 옵션을 선택할 수 있는데, 나는 TypeScript와 esbuild를 선택했다. Webpack 대신 esbuild를 선택한 이유는 빌드 속도가 월등히 빠르기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 프로젝트의 핵심 파일 구조는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1763988154943&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;style-rank/
├── src/
│   └── extension.ts       # 확장 프로그램 진입점
├── package.json           # 확장 프로그램 설정 및 메타데이터
├── tsconfig.json          # TypeScript 설정
└── esbuild.js            # 빌드 설정&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;package.json 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;package.json은 확장 프로그램의 메타데이터를 정의하는 핵심 파일이다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;style-rank&quot;,
  &quot;displayName&quot;: &quot;Style Rank&quot;,
  &quot;description&quot;: &quot;AST 기반 코드 품질 랭크 측정기&quot;,
  &quot;version&quot;: &quot;0.0.4&quot;,
  &quot;icon&quot;: &quot;extension-icon.png&quot;,
  &quot;engines&quot;: {
    &quot;vscode&quot;: &quot;^1.105.0&quot;
  },
  &quot;activationEvents&quot;: [
    &quot;onLanguage:javascript&quot;,
    &quot;onLanguage:typescript&quot;,
    &quot;onLanguage:javascriptreact&quot;,
    &quot;onLanguage:typescriptreact&quot;
  ],
  &quot;main&quot;: &quot;./dist/extension.js&quot;,
  &quot;contributes&quot;: {
    &quot;commands&quot;: [
      {
        &quot;command&quot;: &quot;style-rank.helloWorld&quot;,
        &quot;title&quot;: &quot;Hello World&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;activationEvents는 확장 프로그램이 언제 활성화될지를 정의한다. 여기서는 JavaScript와 TypeScript 파일이 열릴 때 자동으로 활성화되도록 설정했다. icon은 추후 확장 프로그램으로 패키징 하였을 때 표시될 앱의 아이콘이다. 선택적으로 적용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;extension.ts - 진입점 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'src/extension.ts'는 확장 프로그램의 진입점이다. VSCode는 activate 함수를 호출하여 확장 프로그램을 실행한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  // 파일 저장 이벤트 리스너
  const saveListener = vscode.workspace.onDidSaveTextDocument((document) =&amp;gt; {
    const supportedLanguages = ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'];

    if (!supportedLanguages.includes(document.languageId)) {
      console.log('지원하지 않는 언어입니다.');
      return;
    }

    try {
      const code = document.getText();
      const fileName = path.basename(document.fileName);
      analyzeCode(code, fileName, document.fileName, true);
    } catch (e) {
      vscode.window.showErrorMessage(
        `코드 분석 중 오류가 발생했습니다: ${e instanceof Error ? e.message : String(e)}`
      );
    }
  });

  context.subscriptions.push(saveListener);
}

&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;이 코드는 파일이 저장될 때마다 onDidSaveTextDocument 이벤트를 감지하여 코드 분석을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;명령어(Command) 등록&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VSCode 확장 프로그램은 명령어를 통해 사용자와 상호작용할 수 있다. 명령어는 Command Palette(Ctrl+Shift+P 윈도우)에서 실행할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const testCommand = vscode.commands.registerCommand(
  'style-rank.helloWorld',
  async () =&amp;gt; {
    try {
      const sampleFilePath = path.join(
        context.extensionPath,
        'test-samples',
        'sample1.js'
      );

      const testCode = fs.readFileSync(sampleFilePath, 'utf-8');
      analyzeCode(testCode, 'sample1.js', sampleFilePath, true);
    } catch (e) {
      console.error('Error:', e);
      vscode.window.showErrorMessage(
        `분석 중 오류가 발생했습니다: ${e instanceof Error ? e.message : String(e)}`
      );
    }
  }
);

context.subscriptions.push(testCommand);

&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;상태바(Status Bar) UI 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VSCode 하단의 상태바는 사용자에게 정보를 표시하는 효과적인 방법이다. 이번 프로젝트에서 코드의 품질을 검사하고 평가된 점수를 보여주기 위해서 가장 효과적인 방법을 고민했는데, 윈도우의 작업표시줄, 맥의 메뉴바의 위치에 고양이가 나타나는 RunCat 앱이 떠올라 VSCode IDE 하단에 상태바로 제공하기로 결정하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;src/statusBar.ts에서 상태바를 관리하는 클래스를 구현했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import * as vscode from 'vscode';
import type { Rank } from './ranking';

export class StatusBarManager {
  private statusBarItem: vscode.StatusBarItem;

  constructor(commandId: string) {
    this.statusBarItem = vscode.window.createStatusBarItem(
      vscode.StatusBarAlignment.Right,
      100
    );
    this.statusBarItem.command = commandId;
  }

  updateRank(rank: Rank, complexity: number, description: string): void {
    const icon = this.getRankIcon(rank);
    const color = this.getRankColor(rank);

    this.statusBarItem.text = `${icon} Rank: ${rank}`;
    this.statusBarItem.tooltip = `순환 복잡도: ${complexity}\\\\n${description}\\\\n\\\\n클릭하여 상세 보기`;
    this.statusBarItem.backgroundColor = color;
    this.statusBarItem.show();
  }

  private getRankIcon(rank: Rank): string {
    const icons: Record&amp;lt;Rank, string&amp;gt; = {
      S: '$(star-full)',
      A: '$(별)',
      B: '$(check)',
      C: '$(warning)',
      D: '$(alert)',
      F: '$(error)',
    };
    return icons[rank];
  }
}

&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;VSCode는 `$(icon-name)` 형식으로 내장 아이콘을 제공한다. 이를 활용하면 시각적으로 풍부한 UI를 쉽게 구현할 수 있다. 초안은 이런식으로 상태바를 제공하고 호버 혹은 클릭 시 상세 정보를 보여주도록 구성하였다. 추후 이 방식은 QuickPick을 이용해 개선 파일 위치를 찾아주도록 개선된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;디버깅 환경 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확장 프로그램 개발에서 가장 중요한 것은 빠른 피드백 사이클이다. VSCode는 F5 키 하나로 확장 프로그램을 테스트할 수 있는 Extension Development Host를 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'.vscode/launch.json' 설정:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;version&quot;: &quot;0.0.1&quot;,
  &quot;configurations&quot;: [
    {
      &quot;name&quot;: &quot;Run Extension&quot;,
      &quot;type&quot;: &quot;extensionHost&quot;,
      &quot;request&quot;: &quot;launch&quot;,
      &quot;args&quot;: [
        &quot;--extensionDevelopmentPath=${workspaceFolder}&quot;
      ],
      &quot;outFiles&quot;: [
        &quot;${workspaceFolder}/dist/**/*.js&quot;
      ],
      &quot;preLaunchTask&quot;: &quot;${defaultBuildTask}&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;이 설정을 통해 F5를 누르면 자동으로 빌드가 실행되고, 새로운 VSCode 창에서 확장 프로그램이 로드된다. 해당 창에서 명령팔레트를 통해 테스트 파일을 실행해보거나, 혹은 원하는 파일을 열어 사전에 설정된 커맨드를 통해 테스트를 해볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;빌드 및 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발이 완료되면 확장 프로그램을 패키징하여 배포할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 개발 빌드
npm run compile

# 프로덕션 빌드
npm run package

# VSIX 파일 생성
vsce package&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;vsce package 명령어는 .vsix 파일을 생성하는데, 이를 VSCode Marketplace에 배포하거나 직접 설치할 수 있다. 직접 설치하여 이상이 없는지 테스트를 마치면 VSCode Marketplace에 배포할 준비가 된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Visual Studio Marketplace는 Microsoft의 Azure DevOps를 통해 관리된다. 마켓에 자신의 앱을 등록하기 위해서는 개인 액세스 토큰(PAT)을 발급 받아야한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;vsce login &amp;lt;Publisher Name&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;위 명령어를 통해 vsce에 로그인을 시도하면 PAT을 입력하라는 메시지가 노출된다. 여기에 발급받은 PAT 토큰을 사용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 배포하고자 하는 프로젝트 루트로 이동하여&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;vsce publish&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 실행하면 확장 프로그램의 배포가 완료된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;버전 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포시에 코드를 올리는 것과 함께 중요하게 여겨야하는 것이 바로 버전이다. 사용자들은 코드를 확인하는 것이 아니라 해당 프로그램에 어떠한 변화가 있었는지 업데이트마다 쉽게 파악할 수 있는 수단이 필요하다. 이 수단으로 사용되는 것이 바로 버전이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 사용되는 버전 관리 방식은 Semantic Versioning 규칙이다. 의미론적 버전 관리라는 의미인데, 이 관리 방식은 세 개의 숫자를 점(.)으로 구분하여 버전에 의미를 부여한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전 형식: MAJOR.MINOR.PATCH (예시: 1.3.9)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MAJOR&lt;/b&gt;: 주 버전, 이전 버전과 호환성이 완전히 깨지는 대규모 변경이 존재할 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MINOR&lt;/b&gt;: 부 버전, 이전 버전과 호환성이 깨지지 않고 새로운 기능이 추가되었을 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PATCH&lt;/b&gt;: 수정 버전, 이전 버전과 호환성이 깨지지 않고 버그 수정이나 작은 개선이 존재할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 관리되는 버전은 수동으로 그 의미에 맞게 숫자를 증가시켜 적용하기도 하지만, vsce를 사용할 때에는 간단한 명령어를 통해 해결가능하다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# package.json의 PATCH 버전을 자동으로 1 증가시키고 게시합니다.
vsce publish patch&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 원하는 버전 변경 방식을 선택하여 버전 관리와 배포를 동시에 처리할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>extension</category>
      <category>vscode</category>
      <category>vscode-extension</category>
      <author>JungCw</author>
      <guid isPermaLink="true">https://memo-code.tistory.com/78</guid>
      <comments>https://memo-code.tistory.com/78#entry78comment</comments>
      <pubDate>Mon, 24 Nov 2025 21:45:03 +0900</pubDate>
    </item>
  </channel>
</rss>