<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ssooo_kk_77.log</title>
        <link>https://velog.io/</link>
        <description>기록을 습관처럼</description>
        <lastBuildDate>Sat, 27 Jun 2026 09:58:29 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ssooo_kk_77.log</title>
            <url>https://velog.velcdn.com/images/ssooo_kk_77/profile/d9eb7240-3bbe-4779-8c84-32e5a73bfc54/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ssooo_kk_77.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ssooo_kk_77" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[AI 도구 링크 모음]]></title>
            <link>https://velog.io/@ssooo_kk_77/AI-%EB%8F%84%EA%B5%AC-%EB%A7%81%ED%81%AC-%EB%AA%A8%EC%9D%8C</link>
            <guid>https://velog.io/@ssooo_kk_77/AI-%EB%8F%84%EA%B5%AC-%EB%A7%81%ED%81%AC-%EB%AA%A8%EC%9D%8C</guid>
            <pubDate>Sat, 27 Jun 2026 09:58:29 GMT</pubDate>
            <description><![CDATA[<p>최근 AI 도구들을 찾아보면서 업무나 사이드 프로젝트에 활용해볼 만한 사이트들을 정리해두고 싶었다.
각 도구를 깊게 사용해본 것은 아니지만, 어떤 기능을 제공하는지와 실제 업무에서 어떻게 연결해볼 수 있을지 간단히 메모해본다.</p>
<h2 id="1-ai-에이전트--업무-자동화">1. AI 에이전트 / 업무 자동화</h2>
<h3 id="hermes-agent">Hermes Agent</h3>
<ul>
<li>링크: <a href="https://www.elancer.co.kr/blog/detail/1086">https://www.elancer.co.kr/blog/detail/1086</a></li>
<li>키워드: AI 에이전트, 기억, 반복 업무 자동화, 개인 비서</li>
</ul>
<p>Hermes Agent는 서버나 PC에 상주하면서 사용자의 선호도, 반복 업무, 자주 하는 지시사항을 기억하고 학습하는 AI 에이전트 개념으로 소개되어 있다.</p>
<p>단순히 한 번 답변을 생성하는 챗봇이라기보다, 사용자의 업무 패턴을 기억하고 반복되는 작업을 스킬처럼 저장해두는 방식에 가깝다.</p>
<p>업무에 연결해본다면 반복 보고서 작성, 일정 요약, 자주 쓰는 문서 양식 생성, Slack이나 Telegram과 연동한 개인 업무 비서처럼 활용할 수 있을 것 같다.</p>
<h3 id="ennoia">Ennoia</h3>
<ul>
<li>링크: <a href="https://ennoia.so/">https://ennoia.so/</a></li>
<li>키워드: AI 에이전트 플랫폼, 싱글 에이전트, 멀티 에이전트, 앱 제작</li>
</ul>
<p>Ennoia는 원티드에서 제작한 기업용 AI 에이전트 플랫폼이다. 싱글 에이전트, 멀티 에이전트, 앱 제작 기능을 제공하는 것으로 보인다.</p>
<p>개인용 AI 도구보다는 조직이나 팀 단위에서 반복 업무를 자동화하고, 여러 에이전트가 역할을 나눠 일하는 흐름을 만들 때 활용할 수 있을 것 같다.</p>
<p>요구사항 정리, 문서 요약, 고객 문의 분류, 운영 리포트 생성 같은 반복 업무를 에이전트 단위로 나눠 자동화하는 방식이 가능해 보인다.</p>
<h3 id="google-opal">Google Opal</h3>
<ul>
<li>링크: <a href="https://opal.google/landing/">https://opal.google/landing/</a></li>
<li>키워드: AI 워크플로우, 노코드 AI 앱, 흐름 제어</li>
</ul>
<p>Google Opal은 LLM의 동작을 순서에 맞게 구성하고, 하나의 흐름으로 제어할 수 있는 서비스로 보인다.</p>
<p>단순히 프롬프트를 한 번 입력하는 것이 아니라, 입력 → 분석 → 요약 → 결과 생성처럼 여러 단계를 연결해서 사용할 수 있다는 점이 흥미로웠다.</p>
<p>블로그 초안 작성, 경쟁사 분석, 회의록 요약, 기획서 초안 생성처럼 반복되는 AI 작업 흐름을 하나의 미니 앱처럼 만들어둘 수 있을 것 같다.</p>
<br />

<h2 id="2-ai-개발-방식--하네스-엔지니어링">2. AI 개발 방식 / 하네스 엔지니어링</h2>
<h3 id="하네스-엔지니어링">하네스 엔지니어링</h3>
<ul>
<li>링크: <a href="https://madplay.github.io/post/harness-engineering#google_vignette">https://madplay.github.io/post/harness-engineering#google_vignette</a></li>
<li>키워드: AI 개발 자동화, 에이전트 작업 환경, 테스트, 피드백 루프</li>
</ul>
<p>하네스 엔지니어링은 AI 에이전트가 일을 잘할 수 있도록 작업 환경을 설계하는 개념에 가깝다.</p>
<p>처음에는 프롬프트를 잘 쓰는 것만 중요하다고 생각했는데, AI가 프로젝트를 이해하고 안정적으로 작업하려면 폴더 구조, 규칙 문서, 테스트 기준, 리뷰 흐름 같은 환경도 함께 정리되어 있어야 한다는 점이 인상적이었다.</p>
<p>프로젝트에 <code>rules.md</code>, API 명세, 컴포넌트 작성 규칙, 테스트 기준 등을 정리해두고 AI 도구가 그 규칙을 참고해서 작업하도록 만드는 방식으로 활용할 수 있을 것 같다.</p>
<br />

<h2 id="3-프롬프트--디자인-참고-도구">3. 프롬프트 / 디자인 참고 도구</h2>
<h3 id="claude-prompts-kr">Claude Prompts KR</h3>
<ul>
<li>링크: <a href="https://claude-prompts-kr.vercel.app/">https://claude-prompts-kr.vercel.app/</a></li>
<li>키워드: 프롬프트 모음, 업무 템플릿, 분석 프롬프트</li>
</ul>
<p>Claude Prompts KR은 다양한 상황에서 사용할 수 있는 프롬프트 예시를 모아둔 사이트다.</p>
<p>예를 들어 경쟁사 분석을 하고 싶을 때 아래와 같은 프롬프트를 가져와서 사용할 수 있다.</p>
<pre><code class="language-text">Analyze [COMPANY/PRODUCT] against its top 5 competitors.
Compare on [DIMENSIONS].
Identify where it leads, where it lags, and where the biggest opportunities are.</code></pre>
<p>프롬프트가 길지 않아도 목적, 비교 대상, 기준이 명확하면 좋은 결과를 얻을 수 있다는 점이 좋았다.</p>
<p>경쟁사 분석, 기획서 초안, 사용자 리서치 정리, 마케팅 문구 작성, 회고 작성 등에 활용할 수 있을 것 같다.</p>
<h3 id="getdesignmd">getDesign.md</h3>
<ul>
<li>링크: <a href="https://getdesign.md/">https://getdesign.md/</a></li>
<li>키워드: 디자인 프롬프트, UI 참고, Markdown 기반 디자인 설명</li>
</ul>
<p>getDesign.md는 마음에 드는 디자인을 고르고, 해당 디자인을 설명하는 Markdown 프롬프트를 복사해 사용할 수 있는 사이트다.</p>
<p>PPT나 웹사이트, 랜딩 페이지, 포트폴리오 페이지를 만들 때 원하는 디자인 방향을 LLM에게 설명하기가 어려운데, 이런 프롬프트를 활용하면 스타일을 더 구체적으로 전달할 수 있을 것 같다.</p>
<p>업무에 연결해본다면 관리자 페이지 UI 톤 정리, 랜딩 페이지 초안, PPT 디자인 방향 설정, 사이드 프로젝트 화면 구성에 활용할 수 있을 것 같다.</p>
<br />

<h2 id="4-ai-기반-웹사이트-생성-도구">4. AI 기반 웹사이트 생성 도구</h2>
<h3 id="readdy">Readdy</h3>
<ul>
<li>링크: <a href="https://readdy.ai/project">https://readdy.ai/project</a></li>
<li>키워드: AI 웹사이트 생성, 노코드, 퍼블리싱</li>
</ul>
<p>Readdy는 AI 기반 노코드 웹사이트 생성 및 퍼블리싱 플랫폼이다.
웹사이트 초안을 만들고, 수정 후 배포까지 이어갈 수 있는 도구이다.</p>
<p>랜딩 페이지 초안 제작, 서비스 소개 페이지, 포트폴리오 페이지, 이벤트 페이지처럼 빠르게 형태를 확인해야 하는 작업에 활용할 수 있을 것 같다.</p>
<p>개발자 입장에서는 완성된 서비스를 모두 대체한다기보다, 초기 화면 구조나 디자인 방향을 빠르게 잡는 용도로 참고하면 좋을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GraphRAG, Knowledge Graph, Ontology의 차이점]]></title>
            <link>https://velog.io/@ssooo_kk_77/GraphRAG-Knowledge-Graph-Ontology%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@ssooo_kk_77/GraphRAG-Knowledge-Graph-Ontology%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Tue, 16 Jun 2026 07:13:16 GMT</pubDate>
            <description><![CDATA[<h1 id="graphrag-knowledge-graph-ontology는-뭐가-다를까">GraphRAG, Knowledge Graph, Ontology는 뭐가 다를까?</h1>
<p>최근 GraphRAG를 공부하면서 가장 헷갈렸던 부분이 Ontology, Knowledge Graph, GraphRAG의 차이였다.</p>
<p>처음에는 세 개가 거의 같은 개념인 줄 알았다. 자료를 보다 보면 &quot;온톨로지를 설계한다&quot;, &quot;지식 그래프를 구축한다&quot;, &quot;GraphRAG를 적용한다&quot;는 말이 한꺼번에 등장하는 경우가 많아서 더 헷갈렸다.</p>
<p>이번에 관련 자료들을 찾아보면서 나름대로 이해한 내용을 정리해 보려고 한다.</p>
<p>결론부터 말하면 세 개는 서로 다른 역할을 가지고 있다.</p>
<pre><code class="language-text">Ontology
↓
Knowledge Graph
↓
GraphRAG</code></pre>
<p>생각보다 관계가 단순했다.</p>
<hr>
<h2 id="ontology는-설계도에-가깝다">Ontology는 설계도에 가깝다</h2>
<p>Ontology는 쉽게 말해서 데이터의 구조와 관계를 정의하는 설계도다.</p>
<p>예를 들어 호텔 고객 경험(CX) 플랫폼을 만든다고 가정해 보자.</p>
<p>우리 시스템에는 다음과 같은 데이터가 존재할 수 있다.</p>
<ul>
<li>고객(Customer)</li>
<li>예약(Reservation)</li>
<li>객실(Room)</li>
<li>체크인(Check-In)</li>
<li>설문(Survey)</li>
<li>VOC</li>
</ul>
<p>이때 단순히 데이터가 존재하는 것만으로는 부족하다.</p>
<p>서로 어떤 관계를 가지는지도 정의해야 한다.</p>
<p>예를 들면</p>
<pre><code class="language-text">고객 → 예약한다 → 예약

고객 → 작성한다 → 설문

고객 → 등록한다 → VOC

예약 → 포함한다 → 객실

설문 → 평가한다 → 객실</code></pre>
<p>같은 관계들이다.</p>
<p>Ontology는 이런 관계를 정의하는 단계라고 보면 된다.</p>
<p>중요한 점은 아직 실제 데이터는 없다는 것이다.</p>
<p>홍길동 고객이 예약을 했는지, 어떤 설문을 작성했는지는 중요하지 않다.</p>
<p>단지 &quot;고객은 예약을 할 수 있다&quot;, &quot;고객은 설문을 작성할 수 있다&quot; 같은 규칙을 정의하는 것이다.</p>
<hr>
<h2 id="knowledge-graph는-실제-데이터가-들어간-결과물">Knowledge Graph는 실제 데이터가 들어간 결과물</h2>
<p>Ontology가 설계도라면 Knowledge Graph는 실제 데이터가 채워진 상태다.</p>
<p>예를 들어 Ontology에서</p>
<pre><code class="language-text">고객 → 설문 작성</code></pre>
<p>이라는 관계를 정의했다면,</p>
<p>Knowledge Graph에서는</p>
<pre><code class="language-text">홍길동 → 설문#123 작성

김철수 → 설문#456 작성

이영희 → 설문#789 작성</code></pre>
<p>처럼 실제 데이터가 연결된다.</p>
<p>개인적으로는 객체지향의 Class와 Instance 관계로 이해하니까 가장 쉬웠다.</p>
<pre><code class="language-text">Ontology = Class

Knowledge Graph = Instance</code></pre>
<p>즉,</p>
<pre><code class="language-text">Ontology + 실제 데이터
= Knowledge Graph</code></pre>
<p>라고 생각하면 이해하기 편하다.</p>
<hr>
<h2 id="지하철-노선도로-생각하면-이해가-쉽다">지하철 노선도로 생각하면 이해가 쉽다</h2>
<p>개인적으로는 지하철 노선도로 비유하니까 가장 이해가 잘 됐다.</p>
<h3 id="ontology">Ontology</h3>
<p>Ontology는 지하철 노선도를 설계하는 단계다.</p>
<pre><code class="language-text">강남역 ↔ 역삼역 ↔ 선릉역</code></pre>
<p>어떤 역이 존재하고 어떤 역끼리 연결되는지를 정의한다.</p>
<p>아직 실제 열차가 운행되는 것은 아니다.</p>
<p>그냥 구조만 설계한 상태다.</p>
<hr>
<h3 id="knowledge-graph">Knowledge Graph</h3>
<p>Knowledge Graph는 실제 운행 데이터가 들어간 상태다.</p>
<pre><code class="language-text">강남역
↓
2호선

역삼역
↓
2호선

선릉역
↓
2호선</code></pre>
<p>여기에</p>
<pre><code class="language-text">현재 승객 수

혼잡도

첫차/막차 시간

환승 정보</code></pre>
<p>같은 실제 데이터가 추가된다.</p>
<p>즉 단순 노선도가 아니라 실제 운영 정보를 담고 있는 지하철 시스템이 되는 것이다.</p>
<hr>
<h3 id="graphrag">GraphRAG</h3>
<p>GraphRAG는 이 노선도를 이용해서 최적 경로를 찾아주는 앱이라고 생각하면 된다.</p>
<p>예를 들어 사용자가</p>
<blockquote>
<p>서울역에서 잠실역까지 가장 빠르게 가는 방법은?</p>
</blockquote>
<p>이라고 물어보면,</p>
<p>앱은 노선도를 따라가면서</p>
<pre><code class="language-text">서울역
↓
시청역
↓
을지로입구
↓
잠실역</code></pre>
<p>같은 경로를 탐색한다.</p>
<p>GraphRAG도 비슷하다.</p>
<p>질문이 들어오면 데이터 간의 관계를 따라가면서 필요한 정보를 수집하고, 그 결과를 LLM에게 전달한다.</p>
<hr>
<h2 id="graphrag는-그래프를-활용한-rag">GraphRAG는 그래프를 활용한 RAG</h2>
<p>그럼 GraphRAG는 정확히 무엇일까?</p>
<p>이름 그대로 RAG(Retrieval Augmented Generation)에 그래프를 결합한 방식이다.</p>
<p>기존 RAG는 보통 다음과 같이 동작한다.</p>
<pre><code class="language-text">질문
↓
벡터 검색
↓
관련 문서 추출
↓
LLM 응답 생성</code></pre>
<p>반면 GraphRAG는</p>
<pre><code class="language-text">질문
↓
그래프 탐색
↓
관련 데이터 수집
↓
LLM 응답 생성</code></pre>
<p>방식으로 동작한다.</p>
<p>예를 들어 사용자가</p>
<blockquote>
<p>최근 체크인 만족도가 낮은 이유는 무엇인가요?</p>
</blockquote>
<p>라고 질문했다고 가정해 보자.</p>
<p>기존 RAG는 체크인 관련 문서 몇 개를 찾아서 답변할 가능성이 높다.</p>
<p>하지만 GraphRAG는 관계를 따라가면서 정보를 수집할 수 있다.</p>
<pre><code class="language-text">체크인 만족도
↓
체크인 대기시간
↓
프런트 직원 수
↓
주말 방문객 증가
↓
VOC 불만 내용</code></pre>
<p>이런 식으로 관련 데이터를 탐색하고 연결한다.</p>
<p>그래서 단순히 체크인 관련 문서만 찾는 것이 아니라 체크인 만족도에 영향을 줄 수 있는 다양한 정보들을 함께 고려할 수 있다.</p>
<hr>
<h2 id="실제-프로젝트에서는">실제 프로젝트에서는?</h2>
<p>요즘 설문조사 플랫폼이나 VOC 분석 플랫폼은 단순히 설문 데이터만 분석하지 않는다.</p>
<p>고객 정보, 예약 정보, 결제 정보, VOC, CRM 데이터까지 함께 활용한다.</p>
<p>예를 들어 호텔 서비스에서</p>
<pre><code class="language-text">고객
예약
체크인
객실
설문
VOC</code></pre>
<p>를 모두 연결해 두었다고 가정해 보자.</p>
<p>그러면 AI는 단순히</p>
<blockquote>
<p>객실 만족도가 낮습니다.</p>
</blockquote>
<p>수준의 답변이 아니라</p>
<blockquote>
<p>객실 만족도가 낮은 고객의 73%는 체크인 대기시간도 길었으며, VIP 고객군에서 해당 현상이 더 자주 발생했습니다.</p>
</blockquote>
<p>같은 형태의 분석도 가능해진다.</p>
<p>이런 관점에서 보면 GraphRAG의 핵심은 LLM 자체보다 데이터 간의 관계를 얼마나 잘 설계했는지에 있는 것 같다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음에는 GraphRAG만 공부하면 될 줄 알았다.</p>
<p>그런데 공부할수록 GraphRAG보다 먼저 Ontology 설계가 중요하다는 생각이 들었다.</p>
<p>관계가 제대로 정의되지 않으면 아무리 좋은 LLM을 붙여도 의미 있는 결과를 얻기 어렵기 때문이다.</p>
<p>내가 이해한 기준으로 정리하면 다음과 같다.</p>
<ul>
<li>Ontology : 개념과 관계를 정의하는 설계도</li>
<li>Knowledge Graph : 실제 데이터가 연결된 그래프</li>
<li>GraphRAG : 그래프를 활용해 정보를 탐색하고 검색하는 기술</li>
</ul>
<p>그리고 지하철 노선도로 비유하면</p>
<pre><code class="language-text">Ontology = 노선도 설계

Knowledge Graph = 실제 운행 정보가 포함된 노선도

GraphRAG = 노선도를 활용해 최적 경로를 찾아주는 앱</code></pre>
<p>정도로 이해할 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 PWA로 오프라인 입력 데이터를 저장하고 재전송하기]]></title>
            <link>https://velog.io/@ssooo_kk_77/Next.js%EC%97%90%EC%84%9C-PWA%EB%A1%9C-%EC%98%A4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%9E%85%EB%A0%A5-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B3%A0-%EC%9E%AC%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ssooo_kk_77/Next.js%EC%97%90%EC%84%9C-PWA%EB%A1%9C-%EC%98%A4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%9E%85%EB%A0%A5-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B3%A0-%EC%9E%AC%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 21 Jan 2026 23:50:39 GMT</pubDate>
            <description><![CDATA[<p>현재 진행 중인 프로젝트에서 네트워크가 불안정한 환경에서도 사용자가 입력한 자료가 사라지지 않도록 개선하는 작업을 진행했다.</p>
<p>단순히 오프라인에서도 화면이 보이게 하는 것뿐 아니라, 사용자가 작성한 폼 데이터와 등록/수정 요청을 브라우저에 저장해두고 온라인 상태로 돌아왔을 때 다시 전송하는 흐름이 필요했다. 이 과정에서 PWA, Service Worker, Cache Storage, IndexedDB를 함께 적용하게 되었고, 적용하면서 알게 된 내용을 정리해보려고 한다.</p>
<h2 id="pwaprogressive-web-app">PWA(Progressive Web App)</h2>
<blockquote>
<p>PWA는 Progressive Web App의 약자로, 웹을 앱처럼 동작하게 만드는 여러 기능을 포함한다. 홈 화면 설치, 전체 화면 실행, 푸시 알림 같은 기능도 PWA의 일부지만, 이번 프로젝트에서 중요했던 부분은 오프라인 대응이었다.</p>
</blockquote>
<p>PWA를 적용하면 네트워크가 불안정하거나 끊긴 상황에서도 캐시된 리소스를 활용해 화면을 유지할 수 있다. 또한 자주 사용하는 파일을 캐시해두면 재방문 시 로딩 속도도 개선할 수 있다.</p>
<p>이번 프로젝트에서 기대했던 효과는 크게 두 가지였다.</p>
<p>1️⃣ 오프라인 또는 느린 네트워크에서도 최소한의 화면을 유지하는 것
2️⃣ 오프라인 상태에서 입력한 데이터를 저장해두고, 온라인 전환 시 다시 전송하는 것</p>
<p>즉, 단순히 앱처럼 설치되는 웹을 만드는 것이 아니라, <code>네트워크 상태가 좋지 않아도 사용자가 하던 작업을 이어갈 수 있도록 만드는 것</code>이 핵심이었다.</p>
<br />

<h3 id="🧩-pwa를-구성하는-기본-요소">🧩 PWA를 구성하는 기본 요소</h3>
<p>PWA를 적용할 때 기본적으로 봐야 하는 요소는 크게 세 가지다.</p>
<p><strong>1️⃣ HTTPS</strong>
Service Worker는 보안 연결 환경에서 동작하기 때문에 운영 환경에서는 HTTPS가 필요하다.</p>
<p>*<em>2️⃣ Manifest *</em>
앱 이름, 아이콘, 시작 URL, 테마 색상처럼 브라우저가 이 웹앱을 설치 가능한 앱으로 인식하기 위한 정보를 담는다.</p>
<p><strong>3️⃣ Service Worker</strong>
Service Worker는 브라우저와 서버 사이에서 네트워크 요청을 가로채고, 캐시 전략이나 오프라인 동작을 처리하는 역할을 한다.</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/292265e7-1272-4b26-9dfc-1217232846b3/image.png" alt="">
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/e4ff2a0b-02cb-425c-a983-436a8a642675/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/6844d4b7-552f-4068-b4e8-6240e3b26c84/image.png" alt=""></p>
<p>처음에는 이 세 가지를 각각 따로 봤지만, 실제로 구현해보니 역할을 나누어 이해하는 것이 더 중요했다. Manifest는 설치 정보를 담당하고, Service Worker는 요청 처리와 캐싱을 담당한다. 그리고 실제 데이터 저장은 Cache Storage와 IndexedDB를 상황에 맞게 나누어 사용해야 했다.</p>
<br />

<hr>
<br />

<h3 id="🗂️-cache-storage와-indexeddb의-역할-나누기">🗂️ Cache Storage와 IndexedDB의 역할 나누기</h3>
<p>오프라인 대응을 하면서 가장 먼저 정리해야 했던 부분은 어떤 데이터를 어디에 저장할 것인가였다.</p>
<p>정적 리소스와 사용자가 입력한 데이터는 성격이 다르기 때문에 별도의 저장소를 필요로 할 것 같다는 생각이 들었다.</p>
<p><strong>Cache Storage</strong></p>
<blockquote>
<p>Service Worker가 관리하는 응답 캐시 저장소로 JS, CSS, 이미지, 페이지 문서, API 응답처럼 네트워크 응답 형태로 다루기 좋은 데이터에 적합하다.</p>
</blockquote>
<p><strong>IndexedDB</strong></p>
<blockquote>
<p>브라우저 내장 로컬 DB에 가깝다. 리스트, 셀렉트 옵션, 트리 데이터처럼 구조화된 조회 데이터를 저장하거나, 오프라인 상태에서 발생한 등록/수정 요청을 큐 형태로 쌓아두기 좋다.</p>
</blockquote>
<p>이번 프로젝트에서는 역할을 아래처럼 나누었다.</p>
<p><strong>Cache Storage</strong></p>
<ul>
<li>앱 실행에 필요한 정적 리소스</li>
<li>Next.js 번들</li>
<li>이미지</li>
<li>페이지 문서 캐시</li>
</ul>
<p><strong>IndexedDB</strong></p>
<ul>
<li>온라인 상태에서 조회한 셀렉트 목록</li>
<li>폼 작성에 필요한 기준 데이터</li>
<li>오프라인 상태에서 작성한 등록/수정 요청</li>
<li>온라인 복귀 시 재전송할 요청 큐</li>
</ul>
<p>이렇게 나누니 구조가 더 명확해졌다. Cache Storage는 앱을 다시 켜기 위한 껍데기 역할을 하고, IndexedDB는 화면 안에 채워 넣을 데이터와 사용자가 입력한 요청을 보관하는 역할을 하게 되었다.</p>
<br />

<hr>
<br />

<h3 id="⚙️-nextjs에-pwa-적용하기">⚙️ Next.js에 PWA 적용하기</h3>
<p>Next.js에서 PWA를 적용하기 위해 next-pwa를 사용하여 Workbox 기반으로 서비스 워커와 캐시 전략을 빌드 단계에서 생성하는 방식을 선택했다.</p>
<p>기본 설정은 아래와 같이 구성했다.</p>
<pre><code>const withPWA = require(&quot;next-pwa&quot;)({
  dest: &quot;public&quot;,
  register: true,
  skipWaiting: true,
  clientsClaim: true,
  cacheOnFrontEndNav: true,
  cacheStartUrl: true,
  disable: process.env.NODE_ENV === &quot;development&quot;,
  fallbacks: {
    document: &quot;/&quot;,
  },
  runtimeCaching: [
    // cache rules
  ],
});</code></pre><p>각 옵션의 역할을 정리하면 다음과 같다.</p>
<p><strong>dest</strong> : &quot;public&quot;은 생성된 sw.js와 Workbox 관련 파일을 public 폴더에 생성하기 위한 설정이다. Service Worker는 브라우저에서 정적 파일처럼 접근할 수 있어야 하므로 일반적으로 public에 생성되도록 둔다.</p>
<p><strong>register</strong>: true는 앱에서 Service Worker를 자동으로 등록하도록 한다.</p>
<p><strong>skipWaiting</strong>: true는 새 Service Worker가 설치되었을 때 대기 상태에 머무르지 않고 바로 활성화되도록 하는 옵션이다.</p>
<p><strong>clientsClaim</strong>: true는 활성화된 Service Worker가 현재 열려 있는 탭도 바로 제어하도록 한다. skipWaiting과 함께 사용하면 업데이트된 Service Worker를 비교적 빠르게 반영할 수 있다.</p>
<p><strong>disable</strong>: process.env.NODE_ENV === &quot;development&quot;는 개발 환경에서는 PWA를 비활성화하기 위한 설정이다. 개발 중에는 캐시가 오히려 디버깅을 방해할 수 있기 때문에 운영 환경에서만 활성화되도록 했다.</p>
<br />

<hr>
<br />

<h3 id="🧱-app-shell-방식으로-오프라인-화면-유지하기">🧱 App Shell 방식으로 오프라인 화면 유지하기</h3>
<p>PWA를 적용하면서 가장 먼저 마주한 문제는 오프라인 상태에서 새로고침을 했을 때 앱이 아예 뜨지 않는 상황이었다.</p>
<p>사용자가 이미 화면을 보고 있다가 네트워크가 끊기는 것은 어느 정도 대응할 수 있었지만, 오프라인 상태에서 새로고침을 하거나 특정 URL로 바로 접근하면 문서 요청이 실패하면서 앱 자체가 뜨지 않았다.</p>
<p>이 문제를 해결하기 위해 <code>App Shell</code> 방식을 사용했다.</p>
<h4 id="app-shell">App Shell</h4>
<blockquote>
<p>앱을 실행하는 데 필요한 최소한의 껍데기를 캐시해두고, 오프라인에서도 우선 앱의 기본 화면을 띄우는 방식이다. 이후 화면에 필요한 실제 데이터는 IndexedDB에서 복원한다.</p>
</blockquote>
<p>이번 설정에서는 아래 옵션들이 이 역할을 했다.</p>
<pre><code>cacheStartUrl: true,
cacheOnFrontEndNav: true,
fallbacks: {
  document: &quot;/&quot;,
},</code></pre><p><strong>cacheStartUrl</strong>: true는 앱의 시작 URL을 캐시에 저장한다. 오프라인에서 /로 폴백할 계획이라면 시작 URL 자체가 캐시에 있어야 안전하다.</p>
<p><strong>cacheOnFrontEndNav</strong>: true는 Next.js의 클라이언트 라우팅 상황에서도 캐시가 쌓이도록 도와준다.</p>
<p><strong>fallbacks</strong>: { document: &quot;/&quot; }는 문서 요청이 실패했을 때 /로 폴백하도록 한다. 즉, 오프라인에서 어떤 경로를 새로고침하더라도 우선 앱의 진입점을 띄우고, 이후 필요한 데이터는 별도로 복원하는 방향이다.</p>
<p>정리하면 아래와 같다.</p>
<p>오프라인에서 문서 요청 실패
  ↓
캐시된 &quot;/&quot; 문서로 폴백
  ↓
앱 Shell 실행
  ↓
IndexedDB에서 화면 데이터 복원</p>
<br />

<hr>
<br />

<h3 id="🧠-runtimecaching-설정하기">🧠 runtimeCaching 설정하기</h3>
<p>다음으로는 어떤 요청을 어떤 전략으로 캐싱할지 정리했다.</p>
<p>모든 요청을 같은 방식으로 캐싱하면 문제가 생길 수 있다. JS/CSS 번들처럼 변경이 적고 해시가 붙는 파일은 캐시에 오래 두어도 괜찮지만, 페이지 데이터나 API 응답처럼 최신성이 중요한 데이터는 네트워크를 먼저 확인하는 편이 낫다.</p>
<p>그래서 요청 성격에 따라 캐시 전략을 나누었다.</p>
<p>1️⃣. Next 데이터 JSON은 <code>Network First</code></p>
<pre><code>{
  urlPattern: /^\/_next\/data\/.*\.json$/i,
  handler: &quot;NetworkFirst&quot;,
  options: {
    cacheName: &quot;next-data&quot;,
    networkTimeoutSeconds: 3,
  },
}</code></pre><p>/_next/data/*.json은 페이지 전환이나 프리패치에서 사용되는 데이터다. 최신 데이터가 중요하기 때문에 NetworkFirst 전략을 사용했다.</p>
<p>다만 네트워크가 애매하게 느릴 때 계속 기다리면 화면이 멈춘 것처럼 보일 수 있다. 그래서 networkTimeoutSeconds: 3을 두어 3초 안에 응답이 오지 않으면 캐시로 폴백하도록 했다.</p>
<p>2️⃣. Next 정적 번들은 <code>Cache First</code><br>{
  urlPattern: /^https?.<em>/_next/static/.</em>/i,
  handler: &quot;CacheFirst&quot;,
  options: {
    cacheName: &quot;next-static&quot;,
  },
}</p>
<p>/_next/static 하위의 JS/CSS 번들은 파일명에 해시가 붙기 때문에 캐시와 잘 맞는다. 한 번 받은 파일은 빠르게 재사용하는 것이 더 중요하다고 판단해 CacheFirst 전략을 사용했다.</p>
<p>3️⃣. 이미지는 <code>Stale While Revalidate</code></p>
<pre><code>{
  urlPattern: /^https?.*\/_next\/image\?url=.*/i,
  handler: &quot;StaleWhileRevalidate&quot;,
  options: {
    cacheName: &quot;next-image&quot;,
    expiration: {
      maxEntries: 200,
      maxAgeSeconds: 60 * 60 * 24 * 30,
    },
  },
}</code></pre><p>이미지는 매번 최신을 받기보다 일단 빠르게 보여주는 것이 사용자 경험에 더 좋을 때가 많다. 그래서 캐시에 이미지가 있으면 먼저 보여주고, 뒤에서 최신 이미지를 받아 캐시를 갱신하는 StaleWhileRevalidate 전략을 사용했다.</p>
<p>이미지 캐시는 용량이 커질 수 있기 때문에 최대 개수와 보관 기간도 함께 제한했다.</p>
<p>4️⃣. 문서 요청은 <code>Network First</code></p>
<pre><code>{
  urlPattern: ({ request }) =&gt; request.mode === &quot;navigate&quot;,
  handler: &quot;NetworkFirst&quot;,
  options: {
    cacheName: &quot;pages&quot;,
    networkTimeoutSeconds: 3,
    expiration: {
      maxEntries: 50,
      maxAgeSeconds: 60 * 60 * 24 * 7,
    },
  },
}</code></pre><p>문서 요청은 오프라인 새로고침 안정성과 관련이 있다. 온라인 상태에서는 최신 문서를 받는 것이 좋지만, 오프라인이거나 네트워크 응답이 늦으면 캐시된 문서를 보여줘야 한다.</p>
<p>그래서 문서 요청은 NetworkFirst로 두고, 네트워크 응답이 3초 이상 지연되면 캐시로 폴백하도록 했다.</p>
<br />

<hr>
<br />

<h3 id="📝-manifest-설정하기">📝 Manifest 설정하기</h3>
<p>PWA로 설치 가능한 앱처럼 동작하려면 Manifest 설정도 필요하다.</p>
<p>Next.js App Router에서는 app/manifest.ts로 Manifest를 제공할 수 있다.</p>
<pre><code>import type { MetadataRoute } from &quot;next&quot;;

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: &quot;My Next PWA&quot;,
    short_name: &quot;MyPWA&quot;,
    description: &quot;Next.js로 만든 PWA&quot;,
    start_url: &quot;/&quot;,
    display: &quot;standalone&quot;,
    background_color: &quot;#ffffff&quot;,
    theme_color: &quot;#000000&quot;,
    icons: [
      {
        src: &quot;/icon-192x192.png&quot;,
        sizes: &quot;192x192&quot;,
        type: &quot;image/png&quot;,
      },
      {
        src: &quot;/icon-512x512.png&quot;,
        sizes: &quot;512x512&quot;,
        type: &quot;image/png&quot;,
      },
    ],
  };
}</code></pre><p><strong>start_url</strong>: 설치된 앱을 실행했을 때 처음 열리는 경로다.</p>
<p><strong>display</strong>: &quot;standalone&quot;은 주소창이 보이지 않는 독립 앱처럼 실행되도록 한다.</p>
<p><strong>icons</strong>: 홈 화면에 추가했을 때 사용할 아이콘이다. 보통 192px, 512px 아이콘은 기본적으로 준비하는 것이 좋다.</p>
<p>Pages Router를 사용하거나 public/manifest.json으로 직접 제공하는 경우에는 _app.tsx 또는 문서 설정에 아래처럼 manifest 링크를 추가할 수 있다.</p>
<pre><code>&lt;link rel=&quot;manifest&quot; href=&quot;/manifest.json&quot; /&gt;</code></pre><br />

<hr>
<link rel="manifest" href="/manifest.json" />

<br />

<h3 id="🔍-빌드-후-service-worker-확인하기">🔍 빌드 후 Service Worker 확인하기</h3>
<p>설정을 마친 뒤 npm run build와 npm run start로 운영 빌드 환경에서 실행하면 public 폴더에 sw.js와 Workbox 관련 파일이 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/9e9dc84d-54cb-4a7b-a65c-67533b466c81/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/37aa836c-cb4b-4f82-a9f6-cff9414df19d/image.png" alt=""></p>
<p>브라우저에서는 DevTools의 Application 탭에서 Service Worker가 정상적으로 등록되었는지 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/dbe3d831-39ae-486a-b604-ca1312092e6e/image.png" alt=""></p>
<p>여기서 Service Worker 상태가 활성화되어 있고 실행 중으로 표시되면 기본 등록은 정상적으로 된 것이다.</p>
<br />

<hr>
<br />

<h3 id="💾-오프라인-입력-데이터-저장하기">💾 오프라인 입력 데이터 저장하기</h3>
<p>PWA 설정만으로는 오프라인에서 입력한 데이터를 저장하고 재전송할 수 없다. Service Worker와 Cache Storage는 앱을 띄우고 리소스를 캐싱하는 데 도움을 주지만, 사용자가 작성한 폼 데이터나 등록 요청은 별도로 저장해야 한다.</p>
<p>이번 프로젝트에서는 온라인 상태일 때 미리 조회한 셀렉트 목록과 폼 입력 데이터를 IndexedDB에 저장했다.</p>
<p>오프라인 상태에서 사용자가 등록 또는 수정 요청을 하면, 바로 API를 호출하는 대신 요청 정보를 IndexedDB에 큐 형태로 저장했다. 이후 온라인 상태로 돌아오면 큐에 쌓인 요청을 하나씩 꺼내 서버로 전송하도록 구성했다.</p>
<p>흐름을 정리하면 아래와 같다.</p>
<p>온라인 상태
  ↓
셀렉트 목록, 기준 데이터 조회
  ↓
IndexedDB에 저장</p>
<p>오프라인 상태
  ↓
사용자가 폼 작성 후 등록/수정
  ↓
API 요청 대신 IndexedDB Queue에 저장</p>
<p>온라인 전환
  ↓
Queue에서 요청을 하나씩 꺼냄
  ↓
서버로 재전송
  ↓
성공한 요청은 Queue에서 제거</p>
<p>이 방식의 장점은 사용자가 네트워크 상태를 크게 신경 쓰지 않고 작업을 이어갈 수 있다는 점이다. 오프라인 상태에서 작성한 데이터가 사라지지 않고, 온라인으로 돌아왔을 때 자동으로 전송되기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/ec27c23c-6f82-46e0-9a29-9fd95da60b7d/image.png" alt=""></p>
<hr>
<br />

<h3 id="✅-적용하면서-느낀-점">✅ 적용하면서 느낀 점</h3>
<p>이번 작업을 하면서 PWA는 단순히 Manifest를 추가하고 Service Worker를 등록하는 것으로 끝나는 기능이 아니라는 것을 느꼈다.</p>
<p>오프라인 화면을 유지하려면 Cache Storage가 필요하고, 사용자가 입력한 데이터를 보관하려면 IndexedDB가 필요했다. 또한 온라인으로 돌아왔을 때 요청을 다시 보내려면 큐 구조와 재전송 흐름도 함께 설계해야 했다.</p>
<p>특히 Cache Storage와 IndexedDB의 역할을 나누는 것이 중요했다.</p>
<p>Cache Storage는 앱을 다시 실행할 수 있게 해주는 정적 리소스와 응답 캐시에 적합했고, IndexedDB는 화면에 뿌릴 구조화된 데이터와 오프라인 요청을 저장하는 데 더 적합했다.</p>
<p>처음에는 오프라인 대응을 “캐시를 잘 하면 되는 문제”라고 생각했지만, 실제로는 화면 유지, 데이터 복원, 사용자 입력 저장, 온라인 복귀 후 동기화까지 함께 고려해야 하는 작업이었다.</p>
<p>앞으로 비슷한 기능을 구현하게 된다면 먼저 아래 질문부터 정리해볼 것 같다.</p>
<p>오프라인에서도 꼭 보여줘야 하는 화면은 어디까지인가?
어떤 데이터는 캐시하고, 어떤 데이터는 IndexedDB에 저장할 것인가?
사용자가 오프라인에서 작성한 요청은 어떤 형식으로 저장할 것인가?
온라인으로 돌아왔을 때 요청을 어떤 순서로 재전송할 것인가?
재전송 실패 시 사용자에게 어떻게 보여줄 것인가?</p>
<p>이번 경험을 통해 PWA는 단순히 웹을 앱처럼 보이게 만드는 기술이 아니라, 네트워크가 불안정한 상황에서도 사용자의 작업 흐름을 최대한 유지하기 위한 구조라는 것을 알게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[OpenLayers Overlay와 iframe 레이어 문제]]></title>
            <link>https://velog.io/@ssooo_kk_77/OpenLayers-Overlay%EC%99%80-iframe-%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@ssooo_kk_77/OpenLayers-Overlay%EC%99%80-iframe-%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sun, 11 Jan 2026 23:41:21 GMT</pubDate>
            <description><![CDATA[<p>대시보드 화면에서 지도를 렌더링하고, 그 위에 여러 위젯을 iframe 형태로 배치하는 구조를 작업한 적이 있다. 지도 위에는 OpenLayers Overlay를 사용해 마커 클릭 시 팝업을 띄우고 있었다.</p>
<p>처음에는 단순한 레이어 문제라고 생각했다. 팝업이 위젯 아래로 가려졌기 때문에 <code>z-index</code>를 조정하면 해결될 것이라고 생각했지만 실제 구조를 확인해보니 그 문제가 아닌걸 알 수 있었다.</p>
<h3 id="📌-문제-상황">📌 문제 상황</h3>
<p>대시보드 상위 페이지에 지도를 렌더링하고, 그 위에 iframe 형태의 위젯 UI가 함께 배치된 구조였다.</p>
<p>지도 위에는 OpenLayers Overlay를 사용해 마커 클릭 시 팝업을 띄우고 있었다. 그런데 위젯을 접거나 펼칠 때 iframe 상단 타이틀 영역이 레이아웃을 차지하면서, 지도 Overlay 팝업이 iframe 아래로 가려지는 현상이 발생했다.</p>
<pre><code class="language-text">대시보드 상위 페이지
├─ 지도 영역
│  └─ OpenLayers Overlay 팝업
└─ iframe 위젯 영역
   └─ 위젯 UI</code></pre>
<p>지도 자체는 정상적으로 렌더링되고 있었지만, 팝업 일부가 보이지 않거나 클릭할 수 없는 상태가 되었다.</p>
<br />

<h3 id="❗-핵심-원인">❗ 핵심 원인</h3>
<p>구조를 뜯어보면 지도와 Overlay는 대시보드 상위 문서의 DOM 안에서 OpenLayers 기준으로 렌더링되고 있었다. 반면 위젯은 지도 위에 겹쳐진 iframe 레이어이다.</p>
<pre><code class="language-text">지도 + Overlay
→ 대시보드 DOM 안에서 OpenLayers 기준으로 위치 계산

iframe 위젯
→ 지도 위에 겹쳐진 별도의 레이어</code></pre>
<p>즉, 지도 Overlay는 지도 좌표 기준으로는 정상 위치에 있었다. 하지만 iframe은 화면 상단 기준으로 레이아웃을 차지하고 있었고, 이 둘의 표시 기준이 달라 Overlay가 iframe 아래로 가려지는 문제가 발생했다.</p>
<br />

<h3 id="기존-overlay-구현-코드">기존 Overlay 구현 코드</h3>
<p>기존에는 마커를 클릭하면 팝업 DOM을 생성하고, Vue 컴포넌트를 마운트한 뒤 OpenLayers Overlay로 지도에 추가하는 방식이었다.</p>
<pre><code class="language-ts">/**
 * 클릭된 장비 마커에 팝업을 표시합니다.
 * - 동일 장비(eqpmSn)에 대해 중복 팝업을 방지합니다.
 * @param {IEquipment} item - 클릭된 장비 정보
 * @param {[number, number]} coordinate - 팝업 표시 좌표
 */
function showClickPopup(item: IEquipment, coordinate: [number, number]) {
  // 이미 열린 팝업이면 중복 생성 방지
  if (clickPopupOverlays.has(item.eqpmtSn)) return;

  const map = imgMapRef.value?.getImgMapInstance();
  if (!map) return;

  const el = document.createElement(&#39;div&#39;);
  el.className = &#39;marker-popup-container click-popup&#39;;

  const app = createApp(PopupContent, {
    data: item,
    onClose: () =&gt; clearClickPopup(item.eqpmtSn)
  });

  app.mount(el);

  const overlay = new Overlay({
    element: el,
    positioning: &#39;center-center&#39;,
    stopEvent: true,
    offset: [0, 0],
    position: coordinate
  });

  map.addOverlay(overlay);
  makeOverlayDraggable(overlay, map);

  clickPopupOverlays.set(item.eqpmtSn, overlay);
  clickPopupApps.set(item.eqpmtSn, app);
}</code></pre>
<p>이 구조에서 팝업 DOM은 Vue로 생성했지만, 최종적으로는 OpenLayers Overlay로 지도에 추가된다.
즉, Overlay는 항상 OpenLayers가 생성한 overlay container 안에서 렌더링된다.</p>
<br />

<h3 id="🔄-시도했던-방법--teleport">🔄 시도했던 방법 — Teleport</h3>
<p>처음에는 이 문제를 레이어 문제로 판단했다. 그래서 Overlay UI를 지도 컨테이너 밖으로 빼면 iframe 레이아웃의 영향을 덜 받지 않을까 생각했다.</p>
<p>이때 검토했던 방법이 Vue의 Teleport였다.</p>
<p>Overlay 내부의 Vue 컴포넌트를 <code>body</code> 같은 다른 DOM 위치로 이동시키면, iframe 아래로 가려지는 문제를 피할 수 있지 않을까 생각했다.</p>
<br />

<h3 id="teleport란">Teleport란?</h3>
<blockquote>
<p>Vue의 Teleport는 컴포넌트를 현재 컴포넌트 트리 위치와 상관없이 지정한 다른 DOM 위치로 렌더링할 수 있게 해주는 기능이다.</p>
</blockquote>
<p>대표적인 사용 예시는 다음과 같다.</p>
<ul>
<li>모달</li>
<li>툴팁</li>
<li>글로벌 팝업</li>
</ul>
<p>부모 컴포넌트의 <code>overflow</code>, <code>z-index</code>, stacking context 영향을 피하고 싶을 때 자주 사용한다.</p>
<pre><code class="language-vue">&lt;Teleport to=&quot;body&quot;&gt;
  &lt;Modal /&gt;
&lt;/Teleport&gt;</code></pre>
<br />

<h3 id="👉-teleport의-한계">👉 Teleport의 한계</h3>
<p>Teleport는 Vue 컴포넌트의 DOM 위치만 이동시킬 뿐, 브라우저 렌더링 컨텍스트나 외부 라이브러리의 좌표 계산 방식까지 바꾸지는 않는다.</p>
<p>OpenLayers Overlay는 지도 컨테이너를 기준으로 좌표를 계산하고 렌더링한다. 반면 Teleport는 Vue 컴포넌트가 어느 DOM 위치에 그려질지만 바꾼다.</p>
<pre><code class="language-text">Teleport
→ Vue 컴포넌트 DOM 위치 이동

OpenLayers Overlay
→ 지도 좌표와 Overlay container 기준으로 위치 계산</code></pre>
<p>즉, 두 기능은 역할과 책임 레벨이 다르다.</p>
<p>Teleport를 사용하면 DOM 위치는 이동시킬 수 있지만, OpenLayers가 Overlay를 지도 좌표 기준으로 관리한다는 사실은 변하지 않는다. 결과적으로 Teleport를 사용해도 Overlay가 가지는 근본적인 구조는 바뀌지 않았다.</p>
<p>그래서 이 방식은 문제를 해결하기보다는 화면에 보이는 DOM만 우회해서 옮기려는 시도에 가깝다고 판단했다.</p>
<br />

<h3 id="✅-해결-방법">✅ 해결 방법</h3>
<p>Overlay를 지도 컨테이너 밖으로 억지로 옮기기보다 Overlay가 표시되는 기준을 조정하는 방향으로 접근을 바꿨다.</p>
<p>Overlay가 iframe 상단 영역과 겹치는 위치에 표시되면서 가려지는 것이었기 때문에 팝업을 띄울 때 현재 위젯이 접혀 있는지, 펼쳐져 있는지에 따라 iframe이 차지하는 영역을 확인하고 Overlay가 해당 영역과 겹치지 않도록 표시 위치를 조정했다.</p>
<p>기존에는 마커 좌표를 그대로 Overlay 위치로 사용했다.</p>
<pre><code>const overlay = new Overlay({
  element: el,
  positioning: &#39;center-center&#39;,
  stopEvent: true,
  offset: [0, 0],
  position: coordinate
});</code></pre><p>하지만 iframe 타이틀 영역이나 위젯 영역이 지도 위를 덮는 경우에는 팝업이 그 영역 안으로 들어가지 않도록 offset 또는 표시 기준을 조정해야 했다.</p>
<p>예를 들어 팝업이 위젯 상단 영역과 겹칠 수 있는 경우에는 Overlay의 위치를 그대로 사용하지 않고, 위젯이 차지하는 높이만큼 화면 안쪽으로 내려주거나, 팝업이 보이는 방향으로 offset을 조정하는 식으로 처리했다.</p>
<pre><code>const overlay = new Overlay({
  element: el,
  positioning: &#39;bottom-center&#39;,
  stopEvent: true,
  offset: [0, adjustedOffsetY],
  position: coordinate
});</code></pre><br />

<h3 id="✅-정리">✅ 정리</h3>
<ul>
<li><code>OpenLayers Overlay</code>는 지도 좌표와 지도 컨테이너를 기준으로 동작</li>
<li><code>iframe</code>은 별도의 문서와 레이어 컨텍스트를 가짐 </li>
<li><code>Teleport</code>는 Vue 컴포넌트의 DOM 위치를 이동시키는 도구</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[HLS와 WebRTC]]></title>
            <link>https://velog.io/@ssooo_kk_77/hls%EC%99%80-WebRTC</link>
            <guid>https://velog.io/@ssooo_kk_77/hls%EC%99%80-WebRTC</guid>
            <pubDate>Sun, 11 Jan 2026 23:29:54 GMT</pubDate>
            <description><![CDATA[<h3 id="hls와-webrtc-실시간-cctv-위젯에서는-무엇을-선택해야-할까">HLS와 WebRTC, 실시간 CCTV 위젯에서는 무엇을 선택해야 할까?</h3>
<p>대시보드에 실시간 CCTV 영상 위젯을 만드는 기능을 개발하면서 HLS를 사용한 적이 있다. 처음에는 CCTV 영상을 화면에 보여주는 작업이라 단순히 영상 URL을 받아 <code>video</code> 태그에 연결하면 될 것이라고 생각했다. 하지만 실제로 구현해보니 생각보다 고려할 부분이 많았다.</p>
<p>브라우저에서 HLS를 바로 재생할 수 있는지, CCTV URL이 바뀌었을 때 기존 플레이어를 어떻게 정리할지, 단일 영상과 여러 영상을 보여주는 화면을 어떻게 나눌지, 라이브 영상의 지연 시간과 안정성 사이에서 어떤 값을 선택할지 등을 함께 봐야 했다.</p>
<p>이 과정에서 자연스럽게 HLS와 WebRTC의 차이도 궁금해졌다. 둘 다 웹에서 영상을 다룰 때 자주 언급되는 기술이지만, 실제로는 목적이 다르다. HLS는 안정적으로 영상을 전달하는 데 강점이 있고, WebRTC는 지연 시간을 줄여 실시간으로 주고받는 데 강점이 있다.</p>
<p>이번 글에서는 HLS와 WebRTC가 각각 어떤 방식으로 동작하는지, 그리고 대시보드 CCTV 위젯 같은 상황에서는 어떤 기준으로 선택하면 좋을지 정리해보려고 한다.</p>
<h2 id="hls란">HLS란?</h2>
<p>HLS는 HTTP Live Streaming의 약자다. 이름 그대로 HTTP 기반으로 영상을 전달하는 스트리밍 방식이다.</p>
<p>HLS는 하나의 긴 영상을 그대로 보내는 것이 아니라, 영상을 작은 조각으로 나눈 뒤 클라이언트가 그 조각들을 순서대로 받아 재생한다. 이때 어떤 조각을 어떤 순서로 받아야 하는지 알려주는 파일이 <code>.m3u8</code> 플레이리스트다.</p>
<p>흐름을 단순하게 보면 아래와 같다.</p>
<pre><code class="language-text">원본 영상
  ↓
작은 세그먼트로 분할
  ↓
.m3u8 플레이리스트 생성
  ↓
브라우저에서 세그먼트를 순서대로 받아 재생</code></pre>
<p>웹에서 HLS를 사용할 때는 보통 <code>hls.js</code> 같은 라이브러리를 사용한다. 내가 구현한 CCTV 위젯에서도 브라우저에서 HLS를 재생하기 위해 <code>hls.js</code>를 사용했다.</p>
<pre><code class="language-ts">import Hls from &#39;hls.js&#39;;

const videoRef = ref&lt;HTMLVideoElement | null&gt;(null);
let hls: Hls | null = null;

const initPlayer = (url: string) =&gt; {
  if (!url) return;

  const video = videoRef.value as HTMLVideoElement;

  if (Hls.isSupported()) {
    if (hls) {
      hls.destroy();
      hls = null;
    }

    hls = new Hls({
      liveSyncDuration: 30,
      maxBufferLength: 60,
      maxMaxBufferLength: 120,
    });

    hls.attachMedia(video);
    hls.loadSource(url);

    hls.on(Hls.Events.MANIFEST_PARSED, () =&gt; {
      video.play();
    });
  } else if (video.canPlayType(&#39;application/vnd.apple.mpegurl&#39;)) {
    video.src = url;
    video.addEventListener(&#39;loadedmetadata&#39;, () =&gt; {
      video.play();
    });
  }
};</code></pre>
<p>코드만 보면 단순히 HLS 소스를 로드하고 재생하는 것처럼 보이지만, 실제로는 몇 가지를 신경 써야 했다.</p>
<p>첫 번째는 브라우저 지원 여부였다. 모든 브라우저가 HLS를 같은 방식으로 지원하지 않기 때문에, 먼저 <code>Hls.isSupported()</code>로 <code>hls.js</code> 사용 가능 여부를 확인했다. 지원되는 환경에서는 <code>hls.js</code>를 통해 <code>.m3u8</code> 스트림을 <code>video</code> 태그에 연결했고, 네이티브 HLS를 지원하는 환경에서는 <code>video.canPlayType(&#39;application/vnd.apple.mpegurl&#39;)</code> 조건으로 직접 재생할 수 있도록 분기했다.</p>
<p>두 번째는 CCTV URL이 바뀌는 상황이었다. 대시보드 위젯에서는 사용자가 다른 CCTV를 선택할 수 있기 때문에, 처음 한 번 연결한 스트림만 계속 재생하는 구조로는 부족했다. 그래서 <code>props.cctvurl</code> 변경을 감지하고, URL이 바뀔 때마다 플레이어를 다시 초기화하도록 구성했다.</p>
<pre><code class="language-ts">watch(
  () =&gt; props.cctvurl,
  async (newUrl) =&gt; {
    await nextTick();
    if (videoRef.value) initPlayer(newUrl);
  }
);</code></pre>
<p>여기서 중요한 점은 기존 HLS 인스턴스를 정리하는 것이었다. 새로운 CCTV 스트림을 연결하기 전에 기존 <code>hls</code> 인스턴스가 남아 있으면 이전 스트림 연결이 유지되거나 불필요한 리소스를 계속 사용할 수 있다. 그래서 새 인스턴스를 만들기 전에 기존 인스턴스가 있다면 <code>destroy()</code>로 정리한 뒤 다시 생성하도록 했다.</p>
<p>세 번째는 라이브 버퍼 설정이었다.</p>
<pre><code class="language-ts">hls = new Hls({
  liveSyncDuration: 30,
  maxBufferLength: 60,
  maxMaxBufferLength: 120,
});</code></pre>
<p>실시간 CCTV라고 해서 무조건 지연 시간을 최소화하는 것만 중요한 것은 아니었다. 대시보드 관제 화면에서는 영상이 너무 자주 끊기지 않고 안정적으로 재생되는 것도 중요했다. 그래서 라이브 버퍼 목표 시간과 최대 버퍼 길이를 설정해, 실시간성과 재생 안정성 사이에서 균형을 맞추려고 했다.</p>
<br />

<hr>
<br />

<h2 id="webrtc란">WebRTC란?</h2>
<p>HLS를 사용하면서 같이 비교해보게 된 기술이 WebRTC였다. 직접 프로젝트에 적용해본 것은 아니지만, 영상 스트리밍 방식을 찾아보다 보면 HLS와 함께 자주 언급되는 기술이라 차이를 정리해보고 싶었다. <code>WebRT</code>는 Web Real-Time Communication의 약자다. 브라우저에서 별도 플러그인 없이 오디오, 비디오, 데이터를 실시간으로 주고받기 위한 기술이다. HLS처럼 서버에 있는 영상 조각을 받아 재생하는 방식이라기보다, 연결을 맺은 뒤 미디어 스트림을 실시간으로 주고받는 방식에 가깝다.</p>
<p>그래서 WebRTC는 <code>화상회의, 음성 통화, 원격 제어처럼 지연 시간이 짧아야 하는 서비스에서 많이 사용된다.</code> 예를 들어 사용자의 카메라 영상을 가져오는 기본 코드는 아래처럼 작성할 수 있다.</p>
<pre><code class="language-ts">const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

const video = document.querySelector(&#39;video&#39;) as HTMLVideoElement;
video.srcObject = stream;</code></pre>
<p>다만 실제 서비스에서 WebRTC를 사용하려면 이 코드만으로 끝나지는 않는다. 서로 연결 정보를 주고받는 signaling 과정이 필요하고, 네트워크 환경에 따라 STUN, TURN 서버를 사용해야 할 수도 있다. 여러 사용자가 동시에 연결되는 구조라면 SFU 같은 미디어 서버도 고려해야 한다.</p>
<p>정리하면 WebRTC는 지연 시간을 줄이는 데 강점이 있지만, 그만큼 연결 관리나 서버 구성 측면에서 고려할 부분도 많아 보였다. 그래서 단순히 실시간 영상이면 WebRTC가 더 좋다라고 보기보다는, 서비스에서 어느 정도의 실시간성이 필요한지를 먼저 봐야 한다고 느꼈다.</p>
<br />

<hr>
<br />

<h2 id="hls와-webrtc-비교">HLS와 WebRTC 비교</h2>
<p>두 기술을 비교할 때 가장 먼저 봐야 할 기준은 지연 시간이 얼마나 중요한지라고 생각했다.</p>
<p>HLS는 HTTP 기반으로 동작하고, 영상을 작은 조각으로 나누어 받아 재생하는 방식이다. 그래서 기존 웹 서버나 CDN과 잘 맞고, 여러 사용자가 안정적으로 영상을 보는 상황에 적합하다. 대신 구조적으로 어느 정도의 지연 시간은 발생할 수 있다.</p>
<p>반대로 WebRTC는 연결을 맺은 뒤 미디어 스트림을 실시간으로 주고받는 방식이라 지연 시간을 줄이는 데 강점이 있다. 다만 signaling, STUN/TURN, 미디어 서버 같은 요소를 함께 고려해야 해서 HLS보다 구현과 운영이 복잡할 수 있다.</p>
<p>두 방식의 차이를 간단히 정리하면 아래처럼 볼 수 있다.</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>HLS</th>
<th>WebRTC</th>
</tr>
</thead>
<tbody><tr>
<td>목적</td>
<td>안정적인 영상 전달</td>
<td>지연 시간을 줄인 실시간 통신</td>
</tr>
<tr>
<td>방식</td>
<td>영상을 조각으로 나누어 HTTP로 재생</td>
<td>연결을 맺고 미디어 스트림을 실시간 전송</td>
</tr>
<tr>
<td>지연 시간</td>
<td>상대적으로 있음</td>
<td>낮음</td>
</tr>
<tr>
<td>구현 난이도</td>
<td>비교적 단순</td>
<td>상대적으로 복잡</td>
</tr>
<tr>
<td>확장성</td>
<td>CDN 활용이 쉬움</td>
<td>대규모 확장 시 별도 미디어 서버 고려 필요</td>
</tr>
<tr>
<td>적합한 사례</td>
<td>라이브 방송, VOD, CCTV 모니터링</td>
<td>화상회의, 원격 제어, 양방향 영상 통신</td>
</tr>
</tbody></table>
<br />

<hr>
<br />

<h2 id="cctv-위젯에서는-왜-hls를-사용했을까">CCTV 위젯에서는 왜 HLS를 사용했을까?</h2>
<p>내가 개발했던 CCTV 위젯은 사용자가 대시보드에서 영상을 확인하는 용도에 가까웠다. 영상을 보면서 카메라와 양방향으로 통신하거나, 화면에서 즉시 장비를 제어하는 구조는 아니었다. 여러 위젯이 함께 있는 대시보드 안에서 CCTV 영상을 안정적으로 보여주는 것이 더 중요한 상황이었다.</p>
<p>이 기준으로 보면 HLS가 더 잘 맞는 방식이라고 생각했다. 지연 시간을 최대한 줄이는 것보다, 브라우저에서 안정적으로 재생되고 기존 웹 구조 안에서 비교적 단순하게 다룰 수 있는 점이 더 중요했기 때문이다.</p>
<p>실제 구현에서도 단순히 영상 URL을 연결하는 것에서 끝나지는 않았다. CCTV URL이 바뀌면 새로운 스트림을 다시 연결해야 했고, 단일 화면과 멀티 화면도 나누어 보여줘야 했다. 영상이 없는 경우에는 빈 상태도 필요했다. 이런 흐름을 처리할 때 <code>hls.js</code>를 사용하면 스트림 연결, 재초기화, 브라우저 지원 분기를 비교적 일관된 방식으로 가져갈 수 있었다.</p>
<p>물론 CCTV라고 해서 항상 HLS가 맞다고 생각하지는 않는다. 만약 영상을 보면서 장비를 바로 제어해야 하거나, 현장 상황이 거의 즉시 반영되어야 하는 서비스라면 WebRTC를 검토해야 할 것 같다. 몇 초의 지연도 문제가 되는 상황이라면 안정적인 재생보다 낮은 지연 시간이 더 중요한 기준이 될 수 있기 때문이다.</p>
<p>결론적으로 단순히 현재 상황을 모니터링하는 정도라면 HLS로도 충분할 수 있고, 즉각적인 상호작용이 필요하다면 WebRTC가 더 적합해 보인다고 생각했다.</p>
<br />

<hr>
<br />

<h2 id="실무에서-선택할-때-보는-기준">실무에서 선택할 때 보는 기준</h2>
<p>이번에 HLS를 사용하고 WebRTC와 비교해보면서, 영상 스트리밍 방식을 선택할 때는 몇 가지 기준을 먼저 봐야겠다고 생각했다.</p>
<h4 id="1️⃣-지연시간이-얼마나-중요한가">1️⃣ 지연시간이 얼마나 중요한가?</h4>
<p>몇 초 정도의 지연이 허용되는 화면이다? <code>HLS</code>
사용자의 행동이나 현장 상황이 거의 바로 반영되어야 한다? <code>WebRTC</code></p>
<h4 id="2️⃣-양방향-통신이-필요한가">2️⃣ 양방향 통신이 필요한가?</h4>
<p>사용자가 영상을 보기만 하는 구조이다? <code>HLS</code>
서로 영상을 주고받거나, 사용자의 입력이 영상 흐름에 바로 영향을 주는 구조이다? <code>WebRTC</code></p>
<h4 id="3️⃣-같은-영상을-얼마나-많은-사용자가-보는가">3️⃣ 같은 영상을 얼마나 많은 사용자가 보는가?</h4>
<p>여러 사용자가 같은 영상을 안정적으로 봐야 한다? <code>HLS</code>
왜냐하면 HTTP 기반으로 동작하고 CDN을 활용하기 쉬워 확장성 측면에서 유리하기 때문이다. 반면 WebRTC는 낮은 지연 시간을 얻을 수 있지만, 사용자가 많아질수록 미디어 서버나 연결 구조를 더 신경 써야 할 것 같다.</p>
<h4 id="4️⃣-운영-복잡도">4️⃣ 운영 복잡도</h4>
<p><code>HLS</code>는 상대적으로 단순하게 구성 가능. 
<code>WebRTC</code>는 signaling, STUN/TURN, 연결 상태 관리 같은 요소를 함께 봐야 하기 때문에, 초저지연이 꼭 필요한 서비스인지 먼저 판단하는 것이 중요하다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비전공자 개발자의 CS 공부기록(2)]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-CS-%EA%B3%B5%EB%B6%80%EA%B8%B0%EB%A1%9D2</link>
            <guid>https://velog.io/@ssooo_kk_77/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-CS-%EA%B3%B5%EB%B6%80%EA%B8%B0%EB%A1%9D2</guid>
            <pubDate>Sat, 19 Apr 2025 07:38:49 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-웹-애플리케이션-아키텍처의-구조와-역할">📌 웹 애플리케이션 아키텍처의 구조와 역할</h3>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/3da8adae-2d13-461c-ace6-cf71d5c3488e/image.png" alt=""></p>
<p><code>웹 클라이언트</code> </p>
<ul>
<li>사용자가 웹에 접근할 때 사용하는 프로그램</li>
<li>크롬, 사파리, 엣지 등의 웹 브라우저</li>
</ul>
<p><code>웹 서버</code></p>
<ul>
<li>웹 클라이언트로부터 HTTP 요청을 받아 데이터를 전달하는 프로그램</li>
<li>아파치 웹 서버, IIS(Internet Information Services), 엔진엑스(Nginx) 등</li>
</ul>
<p><code>WAS(Web Application Server)</code></p>
<ul>
<li>웹 서버로부터 주로 동적 콘텐츠를 받아 이를 처리한 뒤, 그 결과를 다시 웹 서버에 전달하는 중간자 역할</li>
<li>웹 서버가 해야 하는 일을 나눠 가지며 서버의 부담을 줄이고 전체 웹 통신의 효율을 높이는 조력자 역할</li>
<li>톰캣, 제우스, IBM 웹스피어 등</li>
</ul>
<p><code>데이터베이스(DB)</code></p>
<ul>
<li>웹 서비스에 필요한 다양한 데이터를 체계적으로 저장하는 곳이며 WAS에서 동적 콘텐츠를 제공하기 위해 접근하는 장소</li>
<li>MySQL, 오라클, MongoDB 등</li>
</ul>
<br />
<br />


<h3 id="📌-http란">📌 HTTP란?</h3>
<blockquote>
<p>HTTP(Hypertext Transfer Protocol) 란 웹 브라우저와 웹 서버 간에 데이터를 주고 받기 위해 사용하는 클라이언트/서버 모델을 따르는 프로토콜이다. </p>
</blockquote>
<h4 id="특징">특징</h4>
<ul>
<li><p>한 번 통신을 주고받으면 연결을 끊는 비연결성 프로토콜이다.</p>
</li>
<li><p>헤더
 · &#39;헤더명: 헤더값&#39;의 형식으로 작성하며 대소문자를 구분하지 않는다.
 · 헤더 종류</p>
<pre><code> ㄴ 공통 헤더 : Date / Cache-control
 ㄴ 요청 헤더 : Host / User-Agent
 ㄴ 응답 헤더 : Server / Location
 ㄴ 엔티티 헤더(메시지 본문에 대한 정보를 포함하는 헤더) : Content-Length / Content-Type
 ![](https://velog.velcdn.com/images/ssooo_kk_77/post/486e0b01-ddfd-488c-9d35-2b82f7aa828a/image.png)</code></pre></li>
<li><p>한계
  ㄴ 정보를 암호화하지 않은 상태로 보내기 때문에 정보 유출의 위험에 노출될 가능성이 높다.
  ㄴ 통신 상대를 확인하지 않기 때문에 누가 요청을 보냈는지, 통신 중인 상대가 허가된 상대인지 파악할 방법이 없다.</p>
</li>
<li><p>상태 코드
1️⃣XX : 웹 서버가 현재 요청을 받았으며 작업을 진행하고 있다.
<code>100(Continue)</code> : 서버가 요청을 받았으며 추가 요청을 기다리고 있음.</p>
<p>2️⃣XX : 클라이언트가 요청한 작업을 서버가 성공적으로 처리했다.
<code>200(Ok)</code> : 요청이 성공적으로 되었습니다.
<code>201(Created)</code> : 요청이 성공적이었으며 그 결과로 새로운 리소스가 생성되었습니다.
<code>204(No Content)</code> : 요청은 성공적으로 처리되었지만 응답 본문이 없습니다. </p>
<p>3️⃣XX : 리다이렉션 완료 응답으로 요청을 완료하기 위해 재전송이 필요하다.
<code>301(Moved Permanently)</code> : 추가로 요청한 페이지로 영구 이동되었습니다.
<code>302(Found)</code> : 추가로 요청한 페이지로 일시적으로 이동되었습니다.
<code>304(Not Modified)</code> : 리소스가 수정되지 않았으므로 캐시된 데이터를 사용해도 됩니다.</p>
<p>4️⃣XX : 클라이언트측에 오류가 있다.
<code>400(Bad Request)</code> : 클라이언트의 요청 내용에 문제가 있습니다.
<code>401(Unauthorized)</code> : 인증되지 않은 사용자입니다.
<code>404(Not Found)</code> : 요청에는 문제가 없으나 요청한 데이터가 없습니다.</p>
<p>5️⃣XX : 서버가 요청을 수행하지 못했음.
<code>500(Internal Server Error)</code> : 서버 내부적으로 오류가 발생해 응답에 실패했습니다.
<code>502(Bad Gateway)</code> : 서버가 다른 서버로부터 잘못된 응답을 받는 등 서버간의 네트워크에 문제가 생겨 통신이 제대로 되지 않습니다.
<code>503(Service Unavailable)</code> : 서버가 일시적으로 과부하 상태이거나 유지보수 중입니다.
<code>504(Gateway Timeout)</code> : 게이트웨이 또는 프록시 서버가 응답을 기다리다 시간이 초과되었습니다.</p>
</li>
</ul>
<br />
<br />

<h3 id="📌-https란">📌 HTTPS란?</h3>
<blockquote>
<p>HTTP 계층 아래 SSL이라는 보안 계층이 추가된 프로토콜이다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/286d95b7-22cb-4a38-877c-79cc32528ea1/image.png" alt=""></p>
</blockquote>
<p>🔆 SSL의 동작 과정
SSL은 공개키 기법과 대칭키 기법이라는 두 암호화 기법을 함께 사용한다.
(<a href="https://velog.io/@ssooo_kk_77/AES-%EC%95%94%ED%98%B8%ED%99%94%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%9D%B5%EB%AA%85-%ED%88%AC%ED%91%9C">관련 포스팅 보기</a>)
SSL은 크게 핸드셰이크, 세션, 세션 종료의 세 단계로 이루어진다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/7488c036-302b-4a68-ba5e-c414b3337619/image.png" alt=""></p>
<p><code>1단계</code> 
: 클라이언트가 랜덤한 데이터와 함께 현재 지원할 수 있는 암호화 방식을 서버에 전달한다.
<code>2단계</code>
: 서버가 클라이언트에게 랜덤 데이터와 지원 가능한 암호화 방식, 인증서(서버가 공식적으로 인증된 기관인 CA에서 발급받은 문서)를 전달한다.
<code>3단계</code>
: 인증서를 발급받은 클라이언트는 이 인증서가 제대로 된 문서인지 검증하기 위해 CA가 발급한 인증서 목록 중에서 서버가 전달한 인증서가 있는지 확인한다. 인증서가 목록에 있다면 한 번 더 확인하기 위해 CA에서 공유하는 공개키를 가지고 인증서를 복호화한다. 복호화에 성공한다면 이 인증서는 서버가 자신의 비밀키로 암호화했다는 것이 검증되니 서버를 신뢰할 수 있게 된다.
<code>4단계</code>
: 본격적으로 키를 주고받기 위해 클라이언트는 실제 데이터 통신에서 사용할 대칭키를 임시로 만든다.(이 때 대칭키는 클라이언트와 서버가 서로 주고받은 랜덤한 데이터를 조합한 임시키이다.)임시키는 대칭키이기 때문에 앞서 갖고 있던 공개키로 암호화해 서버에 전달한다.
<code>5단계</code>
: 키를 받은 서버는 자신이 갖고 있던 비밀키로 암호를 해독해 임시 키를 전달받는다.
<code>6단계</code>
: 클라이언트와 서버의 임시 키는 일련의 과정을 거쳐 세션 키로 바뀌고 이 세션키를 이용해 본격적으로 클라이언트와 서버가 통신을 할 수 있게 된다.</p>
<br />
<br />
<br />

<p>[출처] - 그림으로 쉽게 이해하는 웹/HTTP/네트워크</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DHTMX 간트차트 구현기]]></title>
            <link>https://velog.io/@ssooo_kk_77/DHTMX-%EA%B0%84%ED%8A%B8%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</link>
            <guid>https://velog.io/@ssooo_kk_77/DHTMX-%EA%B0%84%ED%8A%B8%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</guid>
            <pubDate>Sat, 12 Apr 2025 05:24:56 GMT</pubDate>
            <description><![CDATA[<p>진행하는 프로젝트에서 간트차트로 일정관리하는 기능 개발을 담당하게 되어 여러 라이브러리를 비교하던 중 </p>
<ol>
<li>UI가 깔끔하고</li>
<li>React를 지원하며</li>
<li>문서화가 잘 되어있는</li>
</ol>
<p>DHTMLX(<a href="https://dhtmlx.com/docs/products/dhtmlxGantt/">공식문서</a>) 라이브러리를 사용하기로 했다.</p>
<br />

<p>주요 기능들과 커스텀한 내용을 소개해보자면</p>
<h3 id="1️⃣-커스텀-스크롤">1️⃣ 커스텀 스크롤</h3>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/c1cd2468-488f-4ebb-8966-47fed06f22d7/image.gif" alt=""></p>
<p>원래 기본 스크롤은 왼쪽 그리드 영역은 고정이고 오른쪽 차트 영역을 스크롤하는 것이었다. 하지만 위와같이 그리드 영역이 좁아질 경우 데이터를 제대로 보여주지 못하는 문제가 있어 그리드 영역과 차트 영역 각각에 스크롤을 설정해 컨트롤할 수 있게 개선이 필요했다.</p>
<pre><code>          gantt.config.layout = {
                css: &quot;gantt_container&quot;,
                cols: [
                    {
                        width: 400,
                        min_width: 400,
                        rows: [
                            {view: &quot;grid&quot;, scrollable: true, scrollX: &quot;gridScrollHor&quot;, scrollY: &quot;scrollVer&quot;},
                            {view: &quot;scrollbar&quot;, id: &quot;gridScrollHor&quot;, height: 20},
                        ],
                    },
                    {
                        rows: [
                            {view: &quot;timeline&quot;, scrollable: true, scrollX: &quot;timelineScrollHor&quot;, scrollY: &quot;scrollVer&quot;, width: 1},
                            {view: &quot;scrollbar&quot;, scroll: &quot;x&quot;, id: &quot;timelineScrollHor&quot;, height: 20},
                        ],
                    },
                    {view: &quot;scrollbar&quot;, id: &quot;scrollVer&quot;},
                ],
            };</code></pre><p>왼쪽 그리드 영역은 width를 400px로 고정하고 그 외의 영역은 timeline이 차지하도록 width : 1을 설정했다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/be0dc7c2-4a6c-43a3-8f6c-abe77246a3f3/image.png" alt=""></p>
<br />

<h3 id="2️⃣-locale-ko로-수정">2️⃣ locale ko로 수정</h3>
<pre><code>gantt.locale = {
                date: {
                    month_full: [&quot;1월&quot;, &quot;2월&quot;, &quot;3월&quot;, &quot;4월&quot;, &quot;5월&quot;, &quot;6월&quot;, &quot;7월&quot;, &quot;8월&quot;, &quot;9월&quot;, &quot;10월&quot;, &quot;11월&quot;, &quot;12월&quot;],
                    month_short: [&quot;1월&quot;, &quot;2월&quot;, &quot;3월&quot;, &quot;4월&quot;, &quot;5월&quot;, &quot;6월&quot;, &quot;7월&quot;, &quot;8월&quot;, &quot;9월&quot;, &quot;10월&quot;, &quot;11월&quot;, &quot;12월&quot;],
                    day_full: [&quot;일요일&quot;, &quot;월요일&quot;, &quot;화요일&quot;, &quot;수요일&quot;, &quot;목요일&quot;, &quot;금요일&quot;, &quot;토요일&quot;],
                    day_short: [&quot;일&quot;, &quot;월&quot;, &quot;화&quot;, &quot;수&quot;, &quot;목&quot;, &quot;금&quot;, &quot;토&quot;],
                },
                labels: {
                    new_task: &quot;새일정&quot;,
                    dhx_cal_today_button: &quot;오늘&quot;,
                    day_tab: &quot;일&quot;,
                    week_tab: &quot;주&quot;,
                    month_tab: &quot;월&quot;,
 ...</code></pre><p> 한국어로 date 표시 및 ui 요소(버튼명, 폼 필드명 등)에 표시되는 라벨명 수정</p>
<p>++ 라벨 추가는  </p>
<pre><code>gantt.locale.labels.section_modification_reason = &quot;일정 변경 사유&quot;;</code></pre> <br />
 <br />


<h3 id="3️⃣-date-순서-변경">3️⃣ date 순서 변경</h3>
<p>기본 date format은 dd-mm-yy 순</p>
<p> <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/b57d6706-f647-4b38-8f66-873a2c310fd8/image.png" alt=""></p>
<p>✔️ yy-mm-dd순으로 변경</p>
<pre><code> gantt.config.date_format = &quot;%Y-%m-%d %H:%i&quot;;</code></pre><br />
<br />

<h3 id="4️⃣-onbeforelightbox">4️⃣ onBeforeLightbox</h3>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/df6fd035-dac0-4554-a44c-219a06d6041e/image.png" alt=""></p>
<p>일정 등록/수정/삭제를 위한 모달을 lightbox라고 하는데 이 모달이 열리기전에 하고자 하는 작업을 설정할때 사용하는 함수이다.</p>
<pre><code>gantt.attachEvent(&quot;onBeforeLightbox&quot;, function(id) {
    const task = gantt.getTask(id);
    task.my_template = `&lt;span id=&#39;title1&#39;&gt;Holders: &lt;/span&gt;${task.users}
    &lt;span id=&#39;title2&#39;&gt;Progress: &lt;/span&gt;${task.progress*100}%`;
    return true;
});</code></pre><p>나는 여기에 따로 외부에서 만든 셀렉트 박스 컴포넌트를 주입하기 위해 </p>
<pre><code>gantt.config.buttons_right = [&quot;dhx_delete_btn&quot;];
gantt.config.lightbox.sections = [
                                {
                                    name: &quot;food&quot;, 
                                    height: 35,
                                    map_to: &quot;food&quot;,
                                    type: &quot;textarea&quot;,
                                },
                                {
                                    name: &quot;drink&quot;, 
                                    height: 20,
                                    map_to: &quot;drink&quot;,
                                    type: &quot;custom&quot;,
                                },</code></pre><p>lightbox.sections의 type을 custom으로 설정하고 </p>
<pre><code> const addMuntoContainer: LightboxControl = {
                render: function (sns: any) {
                    return `&lt;div class=&#39;custom_muntno_container&#39; style=&quot;width: 100%;&quot;&gt;&lt;/div&gt;`;
                },

                set_value: function (node: HTMLElement, value: string, task: any, section: any) {
                    ...
                },

                get_value: function (node: HTMLElement, task: any, section: any) {
                    return task.muntno || &quot;&quot;;
                },
            };

  gantt.form_blocks[&quot;custom&quot;] = addMuntoContainer;</code></pre><p>form_blocks의 type이 custom인 영역에 div태그를 추가해주었다.</p>
<br />

<h3 id="5️⃣-onlightbox">5️⃣ onLightbox</h3>
<p>모달이 열렸을 때 실행하는 함수로 onBeforeLightbox에서 생성한 div 태그를 찾아 외부에서 만든 컴포넌트를 렌더링 해주었다.</p>
<pre><code>            gantt.attachEvent(&quot;onLightbox&quot;, function (id: number) {
                const task = gantt.getTask(id);

                const lightbox = document.querySelector(&quot;.custom_muntno_container&quot;);

                if (lightbox) {
                    if (rootInstance) {
                        rootInstance.unmount();
                    }

                    rootInstance = createRoot(lightbox);
                    rootInstance.render(
                        &lt;CutomComponents
                        /&gt;,
                    );
                }
            });</code></pre><br />
<br />

<h3 id="6️⃣-onlightboxsave">6️⃣ onLightboxSave</h3>
<p>모달에서 save 버튼 클릭 시 실행되는 함수로 api 연동이나 validation check 로직을 정의했다.</p>
<pre><code>gantt.attachEvent(&quot;onLightboxSave&quot;, function(id, task, is_new){
    //any custom logic here
    return true;
})</code></pre><br/>
<br />

<h3 id="7️⃣-lightbox-header-name-설정">7️⃣ lightbox header name 설정</h3>
<pre><code> gantt.templates.lightbox_header = function (start: Date, end: Date, task: GanttTask) {
                let headerName;
                if (currentLevelRef.current === 1) {
                    if (task.text !== &quot;새 일감&quot;) {
                        headerName = &quot;일감 수정&quot;;
                    } else {
                        headerName = &quot;일감 등록&quot;;
                    }
                }
                return headerName;
            };</code></pre><br />
<br />

<h3 id="8️⃣-timeline-영역-헤더-커스텀">8️⃣ timeline 영역 헤더 커스텀</h3>
<pre><code>  // timeline 헤더 설정
            gantt.config.scales = [
                {
                    unit: &quot;day&quot;,
                    step: 1,
                    format: function (date: Date) {
                        const monthNames = [&quot;01&quot;, &quot;02&quot;, &quot;03&quot;, &quot;04&quot;, &quot;05&quot;, &quot;06&quot;, &quot;07&quot;, &quot;08&quot;, &quot;09&quot;, &quot;10&quot;, &quot;11&quot;, &quot;12&quot;];
                        const day = date.getDate();
                        const month = date.getMonth();
                        const dayString = day &lt; 10 ? `0${day}` : day;
                        return `${monthNames[month]}.${dayString}`;
                    },
                },
            ];</code></pre><p>기본 timeline 헤더 
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/916e4b01-a876-4d52-9148-feacebdad7d2/image.png" alt="">
커스텀한 timeline 헤더
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/4f233084-42a1-478f-a62f-084992373b7a/image.png" alt=""></p>
<br />
<br />

<h3 id="9️⃣-레벨에-따라-일감을-스타일링하고-싶을-때">9️⃣ 레벨에 따라 일감을 스타일링하고 싶을 때</h3>
<pre><code> gantt.templates.task_class = function (start: Date, end: Date, task: GanttTask) {
                // 최상위 일감
                if (!task.parent) {
                    return &quot;highlight-parent-task&quot;;
                }

                // 하위 일감
                const parentTask = gantt.getTask(task.parent);
                if (parentTask) {
                    return &quot;highlight-child-task&quot;;
                }
                return &quot;&quot;;
            };</code></pre><p>클래스를 다르게 주어 </p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/fdb2fd5d-a0d0-43ce-b9e6-d1af552617c8/image.png" alt="">
부모일감과 하위일감의 색을 커스텀할 수 있다.</p>
<br />
<br />

<h3 id="🔟-onlightboxdelete">🔟 onLightboxDelete</h3>
<p>lightbox에서 삭제 버튼을 클릭했을때 실행되는 함수이다. true를 return하면 정상정으로 삭제를 진행, false를 return하면 삭제가 진행되지 않는다.</p>
<pre><code>gantt.attachEvent(&quot;onLightboxDelete&quot;, function(id){
    const task = gantt.getTask(id);
    if (task.duration &gt; 60){
        alert(&quot;The duration is too long. Please, try again&quot;);
        return false;
    }
    return true;
})</code></pre><br />
<br />

<h3 id="1️⃣1️⃣-onaftertaskupdate">1️⃣1️⃣ onAfterTaskUpdate</h3>
<p>일감이 수정된 후 실행되는 함수</p>
<pre><code>gantt.attachEvent(&quot;onAfterTaskUpdate&quot;, function(id,task){
    //any custom logic here
});</code></pre><br />
<br />

<h3 id="1️⃣2️⃣-드래그-관련-함수">1️⃣2️⃣ 드래그 관련 함수</h3>
<ul>
<li><p>onTaskDrag
: 드래그할 때 실행되는 함수</p>
<pre><code>gantt.attachEvent(&quot;onTaskDrag&quot;, function(id, mode, task, original){
  //any custom logic here
});</code></pre></li>
<li><p>onAfterTaskDrag
: 드래그 완료 후 실행되는 함수</p>
<pre><code>gantt.attachEvent(&quot;onAfterTaskDrag&quot;, function(id, mode, e){
  //any custom logic here
});</code></pre></li>
<li><p>onBeforeTaskDrag
: autoScehduling을 직접 구현하다보니 최상위 일감의 드래그를 막는 로직이 추가로 필요했다.(최상위 일감은 하위 일감들의 일정에 따라 계산되는 일정)
일정을 드래그하여 조정하기 전 최상위 일감인지 먼저 판단 후 최상위면 false를 return하여 드래그를 방지했다.</p>
</li>
</ul>
<pre><code>    gantt.attachEvent(&quot;onBeforeTaskDrag&quot;, function (id: string, mode: string, e: MouseEvent) {
                const task = gantt.getTask(id);

                // 부모 Task 드래그 방지
                if (task.type === &quot;project&quot; || gantt.hasChild(id)) {
                    return false;
                }

                return true;
            });</code></pre><br />
<br />

<p>➕ 이 외에도 다양한 커스텀 가능한 이벤트들이 정의되어 있고 설명과 예시도 문서화로 잘 구성되어 있어서 다음에도 간트 차트를 구현한다면 dhtmlx 라이브러리를 사용할 것 같다.
🧷 <a href="https://docs.dhtmlx.com/gantt/api__refs__gantt_events.html">이벤트 문서</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AES 암호화를 활용한 익명 투표]]></title>
            <link>https://velog.io/@ssooo_kk_77/AES-%EC%95%94%ED%98%B8%ED%99%94%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%9D%B5%EB%AA%85-%ED%88%AC%ED%91%9C</link>
            <guid>https://velog.io/@ssooo_kk_77/AES-%EC%95%94%ED%98%B8%ED%99%94%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%9D%B5%EB%AA%85-%ED%88%AC%ED%91%9C</guid>
            <pubDate>Sat, 22 Mar 2025 06:54:43 GMT</pubDate>
            <description><![CDATA[<p>프로젝트의 기능 중 하나인 투표를 담당하면서 db에서 추적되지 않는 <code>익명</code>투표를 위해 암호화에 대해 공부하며 기록을 남기게 되었다. </p>
<p>전체적인 프로세스는 다음과 같다.
1️⃣ 투표를 익명으로 생성 시 db에서 추적이 불가능하게 하기 위해 먼저 사용자 id를 해시하고 
2️⃣ 이 값의 일부로 고유한 AES키를 생성해 서버에 전달한다. 
3️⃣ 이를 해당 투표의 고유 id로 정의하고 
4️⃣ 사용자가 투표 답변, 수정, 삭제 시 동일한 방법으로 서버에 고유한 키를 보내면 
5️⃣ 서버에 저장되어 있던 암호화된 투표 고유 id와 비교해 자신이 생성한 투표인지 아닌지를 판단할 수 있게 하였다.</p>
<p>따라서 사용자의 원래 id를 서버에 전달하지 않고 db에는 사용자를 식별할 수 있는 정보가 없으므로 익명성이 보장된다.</p>
<p>코드를 살펴보자면</p>
<pre><code>import forge from &quot;node-forge&quot;;

function getSignature(publicKey: string, content: string): string {
  const md = forge.md.sha256.create();
  md.update(publicKey);
  const key = md.digest().getBytes(16); // AES-128 사용</code></pre><p>먼저 사용자 ID를 해시하여 AES 키를 생성하고</p>
<pre><code>  const iv = &quot;0000000000000000&quot;;</code></pre><p>동일키에 동일 암호문이 나와야 하기 때문에 IV (초기화 벡터)를 고정시킨다.</p>
<pre><code>const cipher = forge.cipher.createCipher(&quot;AES-CBC&quot;, key);
cipher.start({ iv: iv });
cipher.update(forge.util.createBuffer(content));
cipher.finish();

const encrypted = cipher.output.getBytes();</code></pre><p>forge 라이브러리를 사용하여 AES-CBC 암호화 객체를 생성하고 위에서 만든 AES 키를 설정한다. 이 후 암호화된 데이터를 바이트 배열로 변환하여 저장한다.</p>
<pre><code> return forge.util.encode64(encrypted);</code></pre><p>최종으로 IV와 암호화된 데이터를 Base64로 인코딩하여 반환하는 함수를 만들었다.</p>
<p>이후 테스트를 진행하였는데 url로 이 키를 전달할 시 기호(?*^% 등)가 빈 문자열로 들어가는 이슈로 인해 투표를 생성한 사람과 동일함에도 매칭이 안되는 문제가 생겼다. </p>
<p>url-safe 문자열로 변환하기 위해 encodURIComponent로 한번 감싸서 보내 해결할 수 있었다. </p>
<pre><code> return encodeURIComponent(forge.util.encode64(encrypted));</code></pre><br />
<br />

<p>➕ 추가로 알게 된 것 ➕</p>
<p>AES는 대칭 키 암호화 방식으로 고정된 키와 초기화 벡터(IV)를 사용해 데이터를 암호화한다. </p>
<p>여기서 대칭키 암호 알고리즘과 비대칭키 암호 알고리즘을 비교하며 좀 더 알아보자면 다음과 같은 특징이 있다.</p>
<p>✔️ <strong>대칭키 암호 알고리즘</strong>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/1298f6bd-4bd0-46b2-b8bc-6858983a38c9/image.png" alt=""></p>
<ul>
<li>하나의 키로 암호화/복호화를 모두 수행하는 알고리즘</li>
<li>암호화하는 암호키와 복호화하는 해독키가 같다.</li>
<li>따라서 이 키는 절대 외부에 노출되면 안되고 해당 키를 Secret Key라고 부른다.</li>
<li>프론트엔드와 백엔드 간의 동일한 키를 공유해야하기 때문에 키 관리에 대한 어려움이 있고, 잦은 키 변경이 있는 경우에는 불편함을 초래할 가능성이 있다.</li>
<li>비대칭키 암호 알고리즘보다 속도가 빠르다.</li>
<li>키가 노출되면 암호화된 데이터를 복호화할 수 있다는 취약성이 있다.</li>
</ul>
<p> 
<br /></p>
<p>✔️ <strong>비대칭키 암호 알고리즘</strong>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/83dea08a-2a7c-4282-8eeb-fbd5de7a8e96/image.png" alt=""></p>
<p>- 암호화/복호화 시의 키가 서로 다른 키를 의미한다.</p>
<ul>
<li>공개키와 개인키를 사용한다.</li>
<li>한 쌍의 키가 존재하며 하나는 특정 사람만이 가지는 개인키이며 하나는 누구나 가질 수 있는 공개키이다.</li>
<li>예를들어 공개키로 암호화한 데이터는 개인키로만 복호화할 수 있고 반대로 개인키로 암호화한 데이터는 공개키로만 복호화할 수 있다.</li>
<li>누군가 공개키를 가로채더라도 개인키를 모르기 때문에 데이터를 온전히 복호화할 수 없다.</li>
<li>주로 키 교환이나 인증에 사용한다.</li>
<li>대칭키 암호 알고리즘에 비해 느리다.</li>
</ul>
<br />
<br />

<p><a href="https://choijying21.tistory.com/entry/CryptoJS%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%EC%95%94%ED%98%B8%ED%99%94%EB%B3%B5%ED%98%B8%ED%99%94%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8">참고</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[간트차트 빌드 에러 ]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EA%B0%84%ED%8A%B8%EC%B0%A8%ED%8A%B8%EB%A5%BC-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%EB%8F%84%EC%A4%91-%EB%A7%8C%EB%82%9C-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@ssooo_kk_77/%EA%B0%84%ED%8A%B8%EC%B0%A8%ED%8A%B8%EB%A5%BC-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%EB%8F%84%EC%A4%91-%EB%A7%8C%EB%82%9C-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Mon, 17 Mar 2025 23:23:58 GMT</pubDate>
            <description><![CDATA[<h3 id="💥-에러-발생">💥 에러 발생</h3>
<p>처음 간트차트를 구현할 때 다른 라이브러리들 처럼 상단에 import를 해서 useEffect 내에서 초기화 후 렌더링하는 방식으로  로직을 짰다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/255eae3e-79c6-4c90-9831-0fd00c5de29d/image.png" alt=""></p>
<pre><code>const GanttChart: React.FC = () =&gt; {
    const ganttContainer = useRef&lt;HTMLDivElement&gt;(null);

 useEffect(() =&gt; {
        // Gantt 초기화
        if (ganttContainer.current) {

        ...

        gantt.init(ganttContainer.current);

          return () =&gt; {
          gantt.clearAll();
       };
    }
}, []);

return (
        &lt;div&gt;
            &lt;div ref={ganttContainer} style={{width: &quot;100%&quot;, height: &quot;400px&quot;}} /&gt;</code></pre><p>로컬에서는 문제가 없었는데 빌드를 하니 다음과 같은 에러가 발생하였다.</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/b779b4e8-b73a-409d-8941-2096cf4b8698/image.png" alt=""></p>
<br />

<h3 id="❓-에러-원인">❓ 에러 원인</h3>
<p>해당 프로젝트는 <code>Next</code> 프레임워크를 사용하고 있다.</p>
<p>dhtmlx-gantt는 DOM을 직접 조작하는 라이브러리이고 Next는 서버사이드 렌더링(SSR)을 수행하므로 DOM이 없는 서버에서 dhtmlx-gantt를 실행하려고 했던 것이 원인이었다.</p>
<br />

<h3 id="❗에러-해결">❗에러 해결</h3>
<p>1️⃣ <code>useEffect</code>와 <code>typeof window</code>를 사용하여 클라이언트 환경에서만 실행되도록 한다.
2️⃣ Next는 import 구문을 SSR 단계에서 처리하므로 클라이언트 환경에서 동적으로 로드하기 위해 <code>required</code>를 사용한다.</p>
<p>최종 코드</p>
<pre><code>import React, { useEffect, useRef } from &quot;react&quot;;
import &quot;dhtmlx-gantt/codebase/dhtmlxgantt.css&quot;;

const GanttChart: React.FC = () =&gt; {
    const ganttContainer = useRef&lt;HTMLDivElement&gt;(null);

    useEffect(() =&gt; {
        if (typeof window !== &quot;undefined&quot; &amp;&amp; ganttContainer.current) {
            const gantt = require(&quot;dhtmlx-gantt&quot;);
            if (ganttContainer.current) {
                gantt.init(ganttContainer.current);
                gantt.parse({
                    data: [
                        { id: 1, text: &quot;Task #1&quot;, start_date: &quot;01-04-2023&quot;, duration: 5, progress: 0.6 },
                        { id: 2, text: &quot;Task #2&quot;, start_date: &quot;06-04-2023&quot;, duration: 3, progress: 0.4, parent: 1 },
                    ],
                });
            }
        }
    }, []);

    return &lt;div ref={ganttContainer} style={{ width: &quot;100%&quot;, height: &quot;400px&quot; }} /&gt;;
};

export default GanttChart;</code></pre><br />

<p>수정 후 다시 시도하니 빌드가 자알 된다!😊</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비전공자 개발자의 CS 공부기록(1)]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-CS-%EA%B3%B5%EB%B6%80%EA%B8%B0%EB%A1%9D1</link>
            <guid>https://velog.io/@ssooo_kk_77/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9D%98-CS-%EA%B3%B5%EB%B6%80%EA%B8%B0%EB%A1%9D1</guid>
            <pubDate>Mon, 17 Feb 2025 01:10:31 GMT</pubDate>
            <description><![CDATA[<h2 id="클라이언트-기반-데이터-저장-방식">클라이언트 기반 데이터 저장 방식</h2>
<h3 id="📌-쿠키">📌 쿠키</h3>
<ul>
<li><p>사용자의 브라우저에 저장되는 작은 데이터 조각으로 필요할 때 쉽게 데이터를 받아 사용 가능하다.</p>
</li>
<li><p>만료 기간이 정해져 있지 않은 쿠키인 <code>세션 쿠키</code>(Session cookie)와 만료 기간이 존재하는 <code>영구 쿠키</code>(persistent cookie)가 있다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/7abe2f6b-0941-4c5c-b640-ee78ffb448a5/image.png" alt="">
(위 사진에서 theme 세션이 세션 쿠키, refresh_token이 영구 쿠키에 해당한다.)</p>
</li>
<li><p>세션 쿠키는 브라우저가 종료될 때 함께 사라지는 반면, 영구 쿠키는 만료기간이 끝나면 삭제된다.</p>
</li>
<li><p>동작
  1️⃣ 클라이언트가 서버에 웹 페이지를 요청하면 서버는 요청에 대한 응답과 함께 쿠키정보를 전송한다.
  2️⃣ 응답을 받은 클라이언트는 메시지 안에 있던 쿠키 정보를 브라우저 공간에 저장한다.
  3️⃣ 이후부터 클라이언트에서 서버로 보내는 모든 요청에 쿠키 정보를 담아 전달한다.
  4️⃣ 서버는 이러한 요청 메시지의 쿠키 정보를 확인해 사용자를 식별하거나 적절한 데이터를 보내준다.</p>
</li>
<li><p>단점</p>
<ul>
<li>누구나 쉽게 접근이 가능하고 조작할 수 있기 때문에 탈취되거나 변조될 위험이 크다.</li>
<li>요청마다 메시지에 쿠키 정보가 함께 전송되기 때문에 오가는 데이터의 양도 더 커져 웹 통신의 성능이 떨어지는 원인이 될 수 있다.</li>
<li>사용자가 방문한 웹 사이트에서 직접 발행하는 <code>퍼스트 파티 쿠키</code>와 제삼자가 발행한 <code>서드 파티 쿠키</code>로 구분할 수 있다.</li>
<li><code>서드파티 쿠키</code>는 사용자로부터 충분한 동의를 받지 않고 웹 브라우징 활동을 추적해 표적 광고에 이용하기 때문에 개인 정보 침해의 소지가 있다.</li>
<li>개인 정보와 같은 민감한 데이터보다는 팝업 다시 보지 않기, 다크 모드 여부 등의 용도로 쿠키를 사용할 수 있다.</li>
</ul>
</li>
</ul>
<br />

<h3 id="📌-로컬-스토리지-vs-세션-스토리지">📌 로컬 스토리지 vs 세션 스토리지</h3>
<ul>
<li><p><strong>로컬 스토리지</strong></p>
<ul>
<li>한 번 브라우저에 저장되면 의도적으로 삭제하지 않는 이상 데이터가 유지된다.</li>
<li>자동 로그인 등의 용도로 사용할 수 있다.</li>
</ul>
</li>
<li><p><strong>세션 스토리지</strong></p>
<ul>
<li>브라우저 탭이 닫힐 때 데이터도 함께 삭제된다.</li>
<li>일회성 정보의 데이터 저장 용도로 활용할 수 있다.</li>
</ul>
</li>
</ul>
<br />

<h3 id="📌-쿠키와-웹-스토리지-비교">📌 쿠키와 웹 스토리지 비교</h3>
<table>

  <thead>
    <th>
    쿠키
    </th>
    <th>웹 스토리지</th>
  </thead>
  <tbody>
    <tr>
      <td>
        4KB까지 저장이 가능하다.
      </td>
      <td>
        5MB ~ 10MB까지 저장이 가능하다.
      </td>
    </tr>
       <tr>
      <td>
        클라이언트와 서버 양쪽 모두 값에 접근 가능하다.
      </td>
      <td>
        클라이언트 단에서만 접근 가능하다.
      </td>
    </tr>
      <tr>
      <td>
        HTTP 헤더에 담겨 서버로 전송된다.
      </td>
      <td>
        따로 서버 전송을 하지 않기 때문에 네트워크 통신량이 줄어든다.
      </td>
    </tr>
  </tbody></table>

<br />

<h3 id="📌-index-db">📌 Index DB</h3>
<ul>
<li>데이터의 형태가 문자열로 한정된 쿠키나 웹 스토리지에 반해, Key Value 한 쌍을 저장하며 , File/Blob 포함 다양한 자바스크립트 데이터 타입 지원한다. </li>
<li>용량 제한이 특별히 없다.</li>
<li>복잡한 구조의 데이터나 큰 규모의 데이터를 괸리해야할 때 유용하게 사용할 수 있다.</li>
<li>same origin policy 를 따르며 온라인/오프라인 환경 모두에서 쿼리 지원한다.</li>
</ul>
<br />
<br />

<h2 id="서버-기반-데이터-저장-방식">서버 기반 데이터 저장 방식</h2>
<h3 id="📌-세션">📌 세션</h3>
<ul>
<li>정보를 서버에서 관리하기 때문에 보안이 우수하다.</li>
<li>클라이언트별로 고유 ID를 부여해 관리하기 때문에 각 클라이언트의 요구에 맞는 맞춤형 서비스를 제공할 수 있다.</li>
<li>사용자가 많아지거나 관리해야 하는 데이터의 양이 많아지면 서버의 부담이 커진다는 단점이 있다.</li>
<li>브라우저에서 값을 보여주는 것보다 상대적으로 데이터 제공 속도가 느릴 수 있다.</li>
<li>동작
  1️⃣ 로그인한 사용자의 정보를 서버에 전달한다.
  2️⃣ 서버는 요청을 보낸 클라이언트가 이전에 접근한 적이 있는지 확인하고 없다면 고유한 값인 세션 ID를 발급해 자신의 메모리에 저장하고 요청에 대한 응답과 함께 세션 ID를 클라이언트에 전달한다.
  3️⃣ 클라이언트는 전달받은 세션 ID를 쿠키에 저장한다.
  <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/a6b9d6c8-8389-41e5-bcfa-81a4ef24d02b/image.jpg" alt="">
  4️⃣ 가령 사용자가 장바구니 api를 요청하면 클라이언트는 요청과 함께 쿠키 안의 세션ID를 함께 전달한다.
  5️⃣ 요청을 받은 서버는 메모리에서 자신이 받은 세션ID에 해당하는 클라이언트가 있는지 확인하고 있다면 장바구니 정보를 조회해 응답 데이터로 전송한다.
  <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/cd1615e2-1d7f-4f2a-a29d-d8257bf8d10d/image.jpg" alt=""></li>
</ul>
<br />
<br />

<p><a href="https://product.kyobobook.co.kr/detail/S000201603021">이미지 출처</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[엑셀 파일 업로드를 구현해보자]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EC%97%91%EC%85%80-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@ssooo_kk_77/%EC%97%91%EC%85%80-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 02 Jun 2024 07:23:29 GMT</pubDate>
            <description><![CDATA[<p>이번에 구현하게 된 기능은 엑셀 파일 업로드이다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/68c2a098-774c-47c9-8157-9066f065595d/image.gif" alt=""></p>
<br />
<br />


<p>먼저 input을 정의해주고 onChange 함수 작성</p>
<pre><code>&lt;input type=&quot;file&quot; accept=&quot;.xlsx, .xls&quot; onChange={handleFileChange} /&gt;</code></pre><pre><code>const uploadedFileRefPut = useRef&lt;File | null&gt;(null);</code></pre><pre><code>const handleFileChange = useCallback(async (event, fileRef) =&gt; {
    const selectedFile = event.target.files?.[0];

    if (selectedFile) {
       fileRef.current = selectedFile;
       const reader = new FileReader();
       reader.onload = async (e: ProgressEvent&lt;FileReader&gt;) =&gt; {
           if (e.target &amp;&amp; e.target.result) {
              const data = new Uint8Array(e.target.result as ArrayBuffer);
              const workbook = XLSX.read(data, { type: &quot;array&quot;, bookVBA: true });
              const worksheet = workbook.Sheets[workbook.SheetNames[0]];
              const jsonData: ExcelData[] = XLSX.utils.sheet_to_json(worksheet);
          }
      };

        reader.readAsArrayBuffer(selectedFile);
   }
}, []);</code></pre><br />

<p>버튼 클릭 시 업로드를 수행 할 함수 작성</p>
<pre><code>// 엑셀 파일로 일괄 수정
    const handlePutExcel = (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
        e.preventDefault();

        if (!uploadedFileRefPut.current) {
            toast.error(&quot;엑셀 파일을 업로드 해주세요.&quot;);
            return;
        }

        const formData = new FormData();

        formData.append(&quot;file&quot;, uploadedFileRefPut.current);
        uploadUserExcelPut(formData);
    };</code></pre><br />
<br />

<hr>
<br />

<p>최종 구현된 코드는 위와 같지만 초기에 upload할 파일을 useState로 상태를 관리하고 있었다.</p>
<pre><code>const [uploadedFile, setUploadedFile] = useState&lt;File | null&gt;(null);</code></pre><pre><code>  const jsonData: ExcelData[] = XLSX.utils.sheet_to_json(worksheet);
  setUploadedFile(jsonData);
}</code></pre><p>이렇게 onChange 됐을 때 setUploadedFile로 상태를 업데이트 해 주었음에도 업로드 버튼을 클릭하면 계속 엑셀 파일이 없다는 toast 에러가 뜨는 문제가 발생했다.</p>
<p>디버깅을 해보니 uploadedFile이 null로 찍히고 있었다...
왜 null이지? 나는 분명 setUploadedFile로 업데이트를 해주었는데...</p>
<br />

<p>한참 헤맨 끝에 찾은 원인은 uploadedFile이 <code>클로저</code>이기 때문이었다.</p>
<p>❔ <strong>원인</strong>
엑셀 파일 업로드라는 버튼을 클릭하면 input이 있는 모달이 뜨는데 모달이 열린 시점에는 uploadedFile 상태를 클로저로 기억하고 있어서 상태가 업데이트된 이후에도 null 값을 유지하고 있었다.</p>
<br />

<p>두 가지의 해결 방법이 있다.</p>
<br />

<p>❕<strong>방법 1</strong> </p>
<p>uploadedFile 상태가 변경될 때마다 handleUpload 함수를 새로 정의하기 위해 useCallback으로 감싸고 의존성 배열에 uploadedFile을 추가하기</p>
<pre><code>    const handleUpload = useCallback(async () =&gt; {
        if (!uploadedFile) {
            toast.error(&quot;엑셀 파일을 업로드 해주세요.&quot;);
            return;
        }

        const formData = new FormData();
        formData.append(&quot;file&quot;, uploadedFile);

        await uploadUserExcel(formData);
    }, [uploadedFile]);</code></pre><br />

<p>❕<strong>방법 2</strong> </p>
<p>useRef를 사용하여 파일 상태를 저장하고 이를 참조하여 항상 최신 상태를 유지하는 방법</p>
<pre><code>위의 최종 코드와 동일</code></pre><p>두 방법 모두 상태가 변경될 때마다 handleUpload 함수가 최신 상태를 참조하게 되어 uploadedFile 상태의 동기화를 유지할 수 있게 된다!</p>
<p>나는 상태변경 이후 추가적인 UI의 변경이 없고 리렌더링이 필요하지 않았기 때문에 두 번째 방법으로 구현하기로 했다!</p>
<p>자바스크립트의 기본기가 중요하다는 것을 다시금 느끼게 된 과정이었다.</p>
<br />
<br />

<p>🧷 <a href="https://poiemaweb.com/js-closure">참고</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sortablejs를 이용한 Drag and Drop 구현]]></title>
            <link>https://velog.io/@ssooo_kk_77/Sortablejs%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Drag-and-Drop-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@ssooo_kk_77/Sortablejs%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Drag-and-Drop-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 02 Jun 2024 06:21:46 GMT</pubDate>
            <description><![CDATA[<br />

<p>처음 구현하는 기능은 언제나 떨리고 조금 설렌다🤓</p>
<p>프론트엔드에서 흔하게 접할 수 있는 <strong>Drag and Drop</strong> 기능과 새로 정렬된 리스트의 순서를 서버에 바로 반영해 새로고침해도 정렬이 유지되는 기능 구현해보자!</p>
<p>내가 사용한 라이브러리는 <strong>react-sortablejs</strong>이다.</p>
<br />

<h3 id="1️⃣-설치">1️⃣ 설치</h3>
<pre><code>npm i react-sortablejs</code></pre><br />

<h3 id="2️⃣-drag-and-drop-구현">2️⃣ Drag and Drop 구현</h3>
<p>깃헙 공식 문서에 나와있는 기본형은 아래와 같다.</p>
<pre><code>import React, { FC, useState } from &quot;react&quot;;
import { ReactSortable } from &quot;react-sortablejs&quot;;

interface ItemType {
  id: number;
  name: string;
}

export const BasicFunction: FC = (props) =&gt; {
  const [state, setState] = useState&lt;ItemType[]&gt;([
    { id: 1, name: &quot;shrek&quot; },
    { id: 2, name: &quot;fiona&quot; },
  ]);

  return (
    &lt;ReactSortable list={state} setList={setState}&gt;
      {state.map((item) =&gt; (
        &lt;div key={item.id}&gt;{item.name}&lt;/div&gt;
      ))}
    &lt;/ReactSortable&gt;
  );
};</code></pre><p>오 생각보다 단순하다..!</p>
<pre><code>import { ReactSortable } from &quot;react-sortablejs&quot;;</code></pre><pre><code>const [list, setList] = useState(lists);

&lt;ReactSortable tag={&quot;ul&quot;} animation={100} list={list.map((item) =&gt; ({ ...item, id: item.tid }))} setList={setList} className=&quot;flex list-none flex-col gap-[20px]&quot; onEnd={handleDragEnd}&gt;
            {list?.map((item, index) =&gt; (
                &lt;TopicList
                    index={index}
                /&gt;
            ))}
&lt;/ReactSortable&gt;</code></pre><br />

<p>전체 리스트 목록을 ReactSortable 컴포넌트로 감싸고 필요한 속성 값을 지정해주면 끝!</p>
<p>다양한 옵션을 제공하는데 그 중 사용한 것에 대해 간략히 정리를 하자면</p>
<ul>
<li><p><strong>tag</strong>
ReactSortable 컴포넌트가 어떤 HTML 태그나 컴포넌트로 렌더링될지를 지정하는 속성이다. 나는 TopicList가 li태그이기 때문에 ul로 지정해주었다.</p>
</li>
<li><p><strong>animation</strong>
항목을 정렬할 때 이동하는 애니메이션의 속도를 밀리초(ms) 단위로 설정.
만약 animation={0}으로 설정하면 애니메이션 없이 즉시 이동된다.</p>
</li>
<li><p><strong>list</strong>
ReactSortable에서 사용할 항목 배열로 필수로 지정해주어야 하는 속성이다.</p>
</li>
<li><p><strong>setList</strong>
항목 목록의 상태를 설정하는 함수로 정렬된 후의 새로운 상태를 받아 컴포넌트의 상태를 업데이트해준다. list와 마찬가지로 필수로 지정해주어야 한다.</p>
</li>
<li><p><strong>onEnd</strong>
<code>onEnd?: ((evt: Sortable.SortableEvent, sortable: Sortable | null, store: Store) =&gt; void)</code></p>
<p>ReactSortable 컴포넌트에서 드래그 앤 드롭 작업이 끝났을 때 호출되는 콜백 함수를 지정하고자 할 때 사용하는 속성이다. 이 콜백 함수는 SortableEvent 객체를 매개변수로 받아 드래그된 항목과 그 위치 변경에 대한 정보를 제공한다.</p>
<p>나는 드래그가 끝나고 변경된 위치를 서버에 반영해 새로고침해도 정렬이 유지되도록 구현하고 싶었기 때문에 handleDragEnd라는 콜백 함수를 지정해주었다. </p>
</li>
</ul>
<br />
<br />

<h3 id="3️⃣-정렬-구현">3️⃣ 정렬 구현</h3>
<pre><code>const handleDragEnd = (event) =&gt; {
    const updatedList = [...list];
    const movedItem = updatedList.splice(event.oldIndex, 1);
    updatedList.splice(event.newIndex, 0, movedItem);
    setList(updatedList);
};</code></pre><p>SortableEvent 객체의 주요 속성은 다음과 같다.</p>
<ul>
<li><p>item : 드래그된 DOM 요소</p>
</li>
<li><p>from : 드래그 시작 위치의 DOM 요소</p>
</li>
<li><p>to : 드롭된 위치의 DOM 요소</p>
</li>
<li><p>oldIndex : 드래그 시작 전의 요소 인덱스</p>
</li>
<li><p>newIndex : 드래그 후의 요소 인덱스</p>
<ol>
<li>먼저 리스트 배열의 복사본을 만들고 </li>
<li>복사본을 splice메소드를 사용해 oldIndex 속성으로 드래그 한 요소를 제거한다.</li>
<li>복사본을 다시 splice 메소드로 드래그한 후 인덱스 위치에 제거한 요소를 삽입한 후 setList로 상태를 업데이트 해준다. </li>
</ol>
</li>
</ul>
<br />
<br />

<h3 id="4️⃣-최종">4️⃣ 최종</h3>
<p>새로 정렬된 리스트를 서버에 반영하면 새로고침해도 정렬된 리스트가 유지된다!</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/a01c311d-712a-47e7-a8de-b90a18b38852/image.gif" alt=""></p>
<br />
<br />


<h4 id="🧷-참고">🧷 참고</h4>
<ul>
<li><a href="https://github.com/SortableJS/react-sortablejs">react-sortablejs</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[브라우저 렌더링 과정에 대해 알아보자]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EA%B3%BC%EC%A0%95%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@ssooo_kk_77/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EA%B3%BC%EC%A0%95%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 07 Mar 2024 04:03:17 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/74d6787c-66a5-45cf-9f59-4d14337b01e2/image.png" alt=""></p>
<h3 id="1-dom-트리-빌드">1. DOM 트리 빌드</h3>
<p>이전 단계에서 통신을 통해 받아온 html 파일들은 바이트 형태로 전달된다.
바이트 &gt; 문자 &gt; 토큰 &gt; 노드 &gt; 객체 모델로 전환하는 작업이 수행되고 최종 트리 형태의 DOM이 출력된다.
    + 만약 html 파싱 중 link 태그나 style 태그를 만날 경우 블로킹되어 CSSOM 생성으로 넘어간다.
    <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/3294a624-4236-4ffa-be37-8f9d9eaba8c4/image.png" alt=""></p>
<h3 id="2-cssom-트리-빌드">2. CSSOM 트리 빌드</h3>
<p>css 파일도 html 파일과 마찬가지로 트리 형태의 객체 모델로 전환하는 과정을 거친다. css 파싱이 완료되면 html 파싱이 중단된 시점으로 돌아가 다시 html을 파싱한다.
    <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/5d67e6c5-8679-4c63-92d4-a18c716aa538/image.png" alt=""></p>
<h3 id="3-render-tree-생성">3. Render Tree 생성</h3>
<p>기존에 제작된 DOM과 CSSOM을 결합하여 Render Tree를 생성한다.
    + 화면에 렌더링되는 노드만으로 구성된다. (display : none이나 meta 태그 등은 렌더트리를 구성하지 않음)
    <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/4b12c002-2bb2-4dad-b110-d0cd203a3a6e/image.png" alt=""></p>
<h3 id="4-js-파싱-후-ast-생성">4. JS 파싱 후 AST 생성</h3>
<p>html 파싱 중 js 즉, script 태그를 만나게 되면 blocking되어 제어권을 JS엔진에게 넘겨주어 JS 파싱으로 넘어간다. 
소스코드를 토큰으로 분해하고 파싱해 AST라는 추상적 구문 트리로 생성한 뒤 인터프리터가 읽을 수 있도록 바이트코드를 생성하여 실행한다. </p>
<p>이후 js 파싱과 실행이 종료되면 렌더링 엔진으로 다시 제어권이 넘어가 html 파싱이 중단된 시점부터 다시 시작하여 dom 생성을 재개한다.
    <img src="https://velog.velcdn.com/images/ssooo_kk_77/post/82bbcd36-5896-4ccb-92f3-6adb486eb061/image.png" alt=""></p>
<h3 id="5-layout">5. Layout</h3>
<p>Render Tree의 노드들의 위치와 크기를 계산하는 단계</p>
<h3 id="6-paint">6. Paint</h3>
<p>계산된 값들을 기반으로 화면에 필요한 요소들을 실제로 그리는 작업을 실행한다.</p>
<h3 id="7-reflow--repaint">7. Reflow &amp; Repaint</h3>
<p>아래 3가지의 경우와 같이 html 요소의 크기나 위치를 변경해야 하는 경우가 발생하면 렌더링 트리의 요소의 크기와 위치를 다시 계산하는 reflow 과정을 거쳐 다시 페인팅하는 repaint 가 발생한다. 
    + 리렌더링 조건
    1. 자바스크립트에 의한 노드 추가 또는 삭제
    2. 브라우저 창 리사이징에 의한 뷰포트의 크기 변경
    3. html 요소의 레이아웃에 변경을 발생시키는 width, height 등의 스타일 변경</p>
<h4 id="-composition">++ Composition</h4>
<p>레아아웃과 페인트를 수행하지 않고 레이어의 합성만 실행시키는 단계
Transform, opacity와 같은 요소들을 의미한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹 브라우저에 해당 도메인 주소를 입력했을 때 데이터를 받아오는 과정]]></title>
            <link>https://velog.io/@ssooo_kk_77/%EC%9B%B9-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90-%ED%95%B4%EB%8B%B9-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EC%86%8C%EB%A5%BC-%EC%9E%85%EB%A0%A5%ED%96%88%EC%9D%84-%EB%95%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%B0%9B%EC%95%84%EC%98%A4%EB%8A%94-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@ssooo_kk_77/%EC%9B%B9-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90-%ED%95%B4%EB%8B%B9-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EC%86%8C%EB%A5%BC-%EC%9E%85%EB%A0%A5%ED%96%88%EC%9D%84-%EB%95%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%B0%9B%EC%95%84%EC%98%A4%EB%8A%94-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Wed, 06 Mar 2024 10:33:19 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/12abf2a9-9bd8-44b2-b8f8-e8dfbd15daa3/image.png" alt=""></p>
<ol>
<li><p>주소창에 url을 입력하면 DNS서버(domain name system의 약자로 도메인 이름과 IP 주소를 매핑해주는 서버)에 요청하기 전에 먼저 CDN에 캐싱된 dns 기록들을 확인한다.</p>
<ul>
<li>CDN : <code>콘텐츠 전송 네트워크</code>로 여러 개의 서버를 이용해 웹 콘텐츠를 사용자와 가까운 서버에서 전송함으로써 전송 속도를 높인다는 장점을 가진다. AWS나 SK브로드밴드, KT 등의 예가 있다.</li>
</ul>
</li>
<li><p>만약 캐싱된 기록이 있으면 요청을 보내지 않고 바로 ip 주소를 반환하고 없으면 dns 서버 요청으로 넘어간다.</p>
</li>
<li><p>가장 가까운 dns 서버에서 도메인 이름에 해당하는 IP 주소를 찾기 위해 dns query를 전달한다. </p>
</li>
<li><p>dns query는 현재 dns 서버에서 원하는 ip 주소가 존재하지 않으면 다른 dns 서버를 방문하는 과정을 원하는 IP 주소를 찾을 때까지 반복한다.</p>
</li>
<li><p>찾던 IP 주소를 전달 받으면 이 IP 주소를 이용하여 웹 브라우저는 웹 서버에게 해당 웹 사이트에 맞는 html 문서를 요청한다.</p>
</li>
<li><p>해당 HTTP 요청 메세지는 TCP/IP 프로토콜을 사용하여 서버로 전송된다.</p>
</li>
<li><p>전송 제어 프로토콜인 TCP는 데이터의 전송을 제어하고 데이터를 어떻게 보낼지 어떻게 맞출지? 정한다.</p>
</li>
<li><p>IP의 특징인 비신뢰성과 비연결성으로 인해 IP 프로토콜 만으로는 통신을 할 수 없기 때문에 신뢰성과 연결성을 책임지는 TCP를 활용하여 통신한다.</p>
</li>
<li><p>TCP는 <code>3-웨이 핸드셰이크</code>라는 작업을 진행하면서 연결 및 데이터를 수신받는다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/d6cd1d34-18ed-4ddf-828a-a44f41750852/image.jpg" alt=""></p>
<ol>
<li>SYN단계 : 클라이언트는 서버에 클라이언트의 ISN을 담아 SYN을 보낸다. </li>
<li>SYN + ACK 단계 : 서버는 클라이언트의 SYN을 수신하고 서버의 ISN을 보내며 승인번호로 클라이언트의 ISN + 1을 보낸다.</li>
<li>ACK 단계 : 클라이언트는 서버의 ISN + 1한 값인 승인번호를 담아 ACK를 서버에 보낸다.
이렇게 3-웨이 핸드셰이킹 과정 이후 신뢰성이 구축되고 데이터 전송을 시작한다.</li>
</ol>
<blockquote>
<ul>
<li>SYN : SYNchronization의 약자, 연결 요청 플래그<ul>
<li>ACK : Acknowledgement의 약자, 응답 플래그</li>
<li>ISN : Initial Sequence Numbers의 약자로 초기 네트워크 연결을 할 때 할당된 32비트의 고유 시퀀스 번호</li>
</ul>
</li>
</ul>
</blockquote>
</li>
<li><p><code>4-웨이 핸드셰이크</code>라는 과정을 통해 연결을 종료한다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/73798016-e437-4b31-8d80-fd2f53a8acbf/image.jpg" alt=""></p>
<ol>
<li>클라이언트가 연결을 닫으려고 할 때 FIN으로 설정된 세그먼트를 보낸다.</li>
<li>서버는 클라이언트로 ACK라는 승인 세그먼트를 보내고 자신의 통신이 끝날 때까지 기다린다.</li>
<li>서버는 일정 시간 이후 클라이언트에 FIN이라는 세그먼트를 보낸다.</li>
<li>클라이언트는 확인했다는 메세지인 ACK를 보내고 서버는 CLOSED 상태가 된다. 이후 클라이언트는 어느 정도의 시간을 대기한 후 연결이 닫히고 클라이언트와 서버의 모든 자원의 연결이 해제된다.</li>
</ol>
</li>
<li><p>특정 데이터 요청을 브라우저로부터 받게되면 웹 서버는 페이지의 로직이나 데이터베이스 연동을 위해 WAS에게 이들의 처리를 요청한다. 웹 서버의 과부하 방지를 위한 WAS(사용자의 컴퓨터나 장치에 웹 어플리케이션을 수행해주는 미들웨어)는 해당 요청을 통해 동적인 페이지 처리를 담당하고 DB에서 필요한 데이터 정보를 받아 그에 맞는 파일을 생성한다.</p>
<blockquote>
<p>웹 서버 : 정적인 파일(HTML, CSS, 이미지 파일)을 처리
WAS : 동적인 파일(JS, TS)을 처리</p>
</blockquote>
</li>
<li><p>WAS에서의 작업 처리 결과들을 웹 서버로 전송하고, 웹 서버는 웹 브라우저에게 html 문서 결과를 전달한다.
전달 과정에서 status code를 통해 서버 요청에 따른 결과 및 상태를 전달한다.
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/9bb6ef05-87de-402f-8377-0ec6e22c72c9/image.webp" alt=""></p>
</li>
</ol>
<ul>
<li>1xx : 정보가 담긴 메세지</li>
<li>2xx : response 성공</li>
<li>3xx : 클라이언트를 다른 URL로 리다이렉트</li>
<li>4xx : 클라이언트 측에서 에러 발생</li>
<li>5xx : 서버 측에서 에러 발생</li>
</ul>
<br />

<p>💡</p>
<ul>
<li><a href="https://velog.io/@tnehd1998/%EC%A3%BC%EC%86%8C%EC%B0%BD%EC%97%90-www.google.com%EC%9D%84-%EC%9E%85%EB%A0%A5%ED%96%88%EC%9D%84-%EB%95%8C-%EC%9D%BC%EC%96%B4%EB%82%98%EB%8A%94-%EA%B3%BC%EC%A0%95">참고</a></li>
<li><a href="https://thebook.io/080326/0079/">참고</a></li>
<li><a href="https://brunch.co.kr/@danni/5">참고</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[requestAnimationFrame을 사용해 ProgressBar 만들기]]></title>
            <link>https://velog.io/@ssooo_kk_77/requestAnimationFrame%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4-ProgressBar-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@ssooo_kk_77/requestAnimationFrame%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4-ProgressBar-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 18 Jan 2024 14:31:25 GMT</pubDate>
            <description><![CDATA[<h2 id="⚒️-setinterval과-requestanimationframe">⚒️ setInterval과 requestAnimationFrame</h2>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/bd8d7445-19e5-4fca-a18f-045e1f842264/image.gif" alt=""></p>
<p>첫 번째, 두 번째 막대는 각각 setInterval과 requestAnimationFrame을 이용해서 너비값을 증가시키는 애니메이션이다.</p>
<p>setInterval을 이용한 막대 그래프는 requestAnimationFrame을 이용한 막대 그래프에 비해 일정한 간격으로 실행이 되지 않고 프레임이 유연하지 못한 것을 확인할 수 있다.</p>
<ul>
<li><p>setInterval을 사용한 애니메이션</p>
<pre><code>const startInterval = () =&gt; {
  const interval = document.querySelector(&#39;#interval&#39;) as HTMLDivElement;

  const id = setInterval(() =&gt; {
    interval.style.width = `${intervalWidth}px`;
    intervalWidth += 1;

    if (intervalWidth &gt; window.innerWidth) clearInterval(id);
  }, 1000 / 60);
};</code></pre></li>
<li><p>requestAnimationFrame을 사용한 애니메이션</p>
<pre><code>const startAnimationFrame = () =&gt; {
  const animationFrame = document.querySelector(
    &#39;#animationFrame&#39;
  ) as HTMLDivElement;

  animationFrame.style.width = `${requestWidth}px`;
  requestWidth += 1;
  const id = requestAnimationFrame(startAnimationFrame);
  if (requestWidth &gt; window.innerWidth) cancelAnimationFrame(id);
};</code></pre></li>
</ul>
<p>이렇게 다르게 동작하는 이유는 setInterval로 width를 자주 변경하게 되면 자바스크립트가 실행되는 동안(브라우저에서 다른 작업이 처리되는 동안) 레이아웃 변경이 지정된 간격보다 더 늦게 또는 더 빠르게 실행될 수 있다. 즉 간격이 일정하지 못하게 되고 실제 화면에 렌더링 되는 프레임의 수가 줄어드는 frame drop 현상으로 이어지게 된다. 이로인해 버벅이는 듯한 애니메이션으로 이어지게 되는 것이다.</p>
<p>반면 <code>requestAnimationFrame은 브라우저의 렌더링 주기와 동기화 돼서 실행</code>이된다. requestAnimationFrame 콜백에 DOM을 변경하는 함수를 넣어주면 해당 변경은 브라우저의 렌더링 주기에 최적화된 방식으로 반영이 되기 때문에 setInterval보다 requestAnimation을 사용할 때 더 유연한 애니메이션을 제공할 수 있게된다.</p>
<br />
<br/ >

<h2 id="✔️-requsetanimationframe으로-scroll에-따른-progessbar-구현하기">✔️ requsetAnimationFrame으로 scroll에 따른 progessBar 구현하기</h2>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/fe0849ae-1861-4a6c-8b00-d3cb1ff8a68a/image.gif" alt=""></p>
<p>비용이 많이 드는 레이아웃 변경 대신 transform의 scaleX로 progressBar의 너비값이 변하도록 적용했다. </p>
<ul>
<li><p>전체 코드</p>
<pre><code>function ScrollProgressBar() {
  const [progess, setProgress] = useState(0);
  const rafRef = useRef&lt;number | null&gt;(null);

  useEffect(() =&gt; {
      const scroll = () =&gt; { 
          const scrollTop = document.documentElement.scrollTop
          // 문서 전체 높이 - 뷰포트 높이 값
          const height = document.documentElement.scrollHeight - document.documentElement.clientHeight

          // 중복된 작업이 반복적으로 일어나지 않도록 방지코드
          if (rafRef.current) {
              cancelAnimationFrame(rafRef.current)    
          }

          rafRef.current = requestAnimationFrame(() =&gt; {
              setProgress(scrollTop / height)
          })
      }

      window.addEventListener(&#39;scroll&#39;, scroll)

      return () =&gt; {
          if (rafRef.current) {
              cancelAnimationFrame(rafRef.current)    
          }

          window.removeEventListener(&#39;scroll&#39;, scroll)
      }
  },[])
  return (
      &lt;div style={{ transform : `scaleX(${progess})`, transformOrigin : &#39;left&#39;, 
      backgroundColor:&#39;&#39;, height : 8, position :&#39;sticky&#39;, 
      top : 62,  zIndex:999, background: &#39;rgb(255 196 35)&#39;}}&gt;

      &lt;/div&gt;
  )
}</code></pre><p>💡</p>
</li>
<li><p><a href="https://stackblitz.com/edit/stackblitz-starters-ht4nty?description=React%20%20%20TypeScript%20starter%20project&amp;file=src%2FApp.tsx&amp;title=React%20Starter">참고</a></p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프로젝트를 최적화하는 다양한 방법 - lazy loading]]></title>
            <link>https://velog.io/@ssooo_kk_77/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EB%8A%94-%EB%8B%A4%EC%96%91%ED%95%9C-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@ssooo_kk_77/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EB%8A%94-%EB%8B%A4%EC%96%91%ED%95%9C-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 17 Jan 2024 08:54:00 GMT</pubDate>
            <description><![CDATA[<h2 id="✔️-코드-스플리팅code-splitting">✔️ 코드 스플리팅(Code-Splitting)</h2>
<p>: 커다란 번들을 작은 여러개의 청크로 나누는 과정으로 코드 스플리팅을 통해 유저가 필요하지 않은 코드들을 다운 받지 않도록 할 수 있다. 
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/4cec549f-a3ee-4e98-b44f-e84840682bfc/image.webp" width="600" /></p>
<br />

<h3 id="➕-코드-스플리팅-적용-시-이점">➕ 코드 스플리팅 적용 시 이점</h3>
<p>: 어플리케이션 규모가 커질수록 번들 사이즈는 증가하게 되고 초기 로딩 속도가 증가할수 밖에 없다. 이때 코드 스플리팅을 적용하면 <code>현재 필요한 코드만 다운 받을 수 있게 되어 첫 페이지 랜딩 속도를 더 빠르게 향상</code>시켜 사용자 경험을 향상시킬 수 있다.</p>
<blockquote>
<p>&lt;공식 문서&gt;
앱의 코드 양을 줄이지 않고도 사용자가 필요하지 않은 코드를 불러오지 않게 하며 앱의 초기화 로딩에 필요한 비용을 줄여줍니다.</p>
</blockquote>
<br />

<h2 id="➕-reactlazy-적용하기">➕ React.lazy 적용하기</h2>
<h4 id="1️⃣-import-하기">1️⃣ import 하기</h4>
<pre><code>import {lazy, Suspense} from &quot;react&quot;</code></pre><h4 id="2️⃣-const-컴포넌트명변수명--lazy--importcomponenetsauthprivateroute">2️⃣ const 컴포넌트명(변수명) = lazy(() =&gt; import(&#39;@componenets/auth/PrivateRoute&#39;))</h4>
<pre><code>const OtherComponent = React.lazy(() =&gt; import(&#39;./OtherComponent&#39;));</code></pre><h4 id="3️⃣-suspense로-컴포넌트들-감싸주기">3️⃣ Suspense로 컴포넌트들 감싸주기</h4>
<p>: <code>lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링</code>되어야 한다. </p>
<pre><code>     &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
        &lt;section&gt;
          &lt;OtherComponent /&gt;
          &lt;AnotherComponent /&gt;
        &lt;/section&gt;
      &lt;/Suspense&gt;</code></pre><h4 id="4️⃣-fallback-옵션-지정하기">4️⃣ fallback 옵션 지정하기</h4>
<p>:  Suspense는 컴포넌트가 lazy loading되는 동안 보여줄 ui를 만드는 fallback props를 제공한다.</p>
<pre><code>import React, { Suspense } from &#39;react&#39;;

const OtherComponent = React.lazy(() =&gt; import(&#39;./OtherComponent&#39;));

function MyComponent() {
  return (
    &lt;div&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
        &lt;OtherComponent /&gt;
      &lt;/Suspense&gt;
    &lt;/div&gt;
  );
}</code></pre><h4 id="5️⃣-프로젝트에-적용해보기">5️⃣ 프로젝트에 적용해보기</h4>
<p><code>lazy 적용 전</code> 유저가 첫 페이지를 만나기까지 걸리는 시간 &gt; 1.27s</p>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/3cafc3af-3657-4375-aaba-3410465ef72a/image.png" alt="">
<code>lazy 적용 후</code> 유저가 첫 페이지를 만나기까지 걸리는 시간 &gt; 407.17ms
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/dc9ea020-aa6f-4548-93d3-46a2097c9208/image.png" alt=""></p>
<p>대략 <code>0.87s</code>가 더 빨라진 것을 확인할 수 있다!!</p>
<br />
<br />

<p>💡</p>
<ul>
<li><a href="https://bhanu.io/easy-code-splitting-using-react-lazy-and-suspense-ec5295b8082e">참고</a></li>
<li><a href="https://ko.legacy.reactjs.org/docs/code-splitting.html">참고</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next를 Next 답게 사용하는 방법]]></title>
            <link>https://velog.io/@ssooo_kk_77/Next%EB%A5%BC-Next-%EB%8B%B5%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@ssooo_kk_77/Next%EB%A5%BC-Next-%EB%8B%B5%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Wed, 10 Jan 2024 11:28:41 GMT</pubDate>
            <description><![CDATA[<h2 id="✔️-image-컴포넌트-사용">✔️ Image 컴포넌트 사용</h2>
<ul>
<li><p><strong>lazy loading</strong> : <code>이미지 로드하는 시점을 지연시키는 기능</code>을 제공한다. 따라서 스크린 밖에 있는 이미지들은 로딩을 지연시키고 스크린 안에 있는 이미지만을 로드해  불필요한 대역폭 사용을 줄이고 필요한 이미지만 빠르게 로드할 수 있도록 최적화 할 수 있다. (우선적으로 로딩 시키고 싶다면 priority 속성을 추가하면 된다!)</p>
</li>
<li><p>*<em>이미지 사이즈 최적화 *</em></p>
<ul>
<li>사용자의 디바이스에 맞는 이미지 사이즈를 만들어 다운로드 할 수 있게 제공 </li>
<li>webp와 같은 용량이 작은 포맷으로 변환하여 제공(이미지에 대한 최초 요청 시에 Next.js 서버에서 진행)</li>
<li>이후 요청에는 캐시가 만료될 때까지 캐시 된 이미지가 제공되기 때문에 첫 번째 요청보다 훨씬 빠르게 이미지를 보여줄 수 있게 된다. </li>
</ul>
</li>
<li><p>*<em>placeholder 제공 *</em>: 레이아웃이 흔들리는 현상을 방지하기 위해 placeholder를 제공 해 <code>이미지가 로드되기 전에도 이미지 높이만큼 영역을 표시</code>해서 이미지가 로드된 후에 레이아웃이 흔들리지 않도록 할 수 있다.</p>
</li>
</ul>
<pre><code>import logoImg from &#39;@/assets/logo.png&#39;

export default function Header() {
    return(
        &lt;header className={classes.header}&gt;
           &lt;img src={logoImg.src} alt=&quot;logoImg&quot; /&gt;
</code></pre><pre><code>                ▽ ▽ ▽ ▽ ▽ ▽ ▽</code></pre><pre><code>import logoImg from &#39;@/assets/logo.png&#39;
import Image from &quot;next/image&quot;;

export default function Header() {
    return(
        &lt;header className={classes.header}&gt;
            &lt;Image src={logoImg} alt=&quot;logo image&quot; width={80} height={100} priority /&gt;</code></pre><p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/5b5ed662-bd4b-450c-b8db-d0c6ffca3aea/image.png" alt="">
<code>Network/Headers/Content-Type에서 확인 가능</code>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/6c566e81-ac84-4312-a22e-bc6de25d0b3d/image.png" alt=""></p>
<br />
<br />

<p>💡</p>
<ul>
<li><a href="https://fe-developers.kakaoent.com/2022/220714-next-image/">참고</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[전역 상태 관리 라이브러리인 Recoil에 대해 알아보자]]></title>
            <link>https://velog.io/@ssooo_kk_77/recoil</link>
            <guid>https://velog.io/@ssooo_kk_77/recoil</guid>
            <pubDate>Wed, 03 Jan 2024 05:07:20 GMT</pubDate>
            <description><![CDATA[<h2 id="❓recoil-이란">❓Recoil 이란?</h2>
<ul>
<li><p>React 애플리케이션 <code>상태 관리를 위한 라이브러리</code></p>
</li>
<li><p>Facebook에서 개발한 상태 관리 라이브러리로 <code>atom 이라는 단위로 상태를 정의</code>하고 이를 이용해 컴포넌트 사이에서 데이터를 공유하며 상태를 업데이트한다.</p>
</li>
</ul>
<br />

<h2 id="❗recoil-사용시-장점">❗Recoil 사용시 장점</h2>
<ul>
<li><p><strong>간편한 상태관리</strong>: 간편하게 상태를 정의하고 관리할 수 있다.</p>
</li>
<li><p><strong>최적화된 리렌더링</strong>: 내부적으로 최적화되어 있기 때문에 필요한 경우에만 리렌더링된다.</p>
<blockquote>
<p>서로 다른 selector가 같은 atom을 참조하고 setter의 결과로 원본 atom에 변화가 있더라도, 또다른 selector가 참조하고 있는 필드가 변경되지 않았다면 recoil이 이를 판단해서 영리하게 리렌더링을 일으키지 않는다.</p>
</blockquote>
</li>
<li><p><strong>복잡한 애플리케이션에 적합</strong>: 복잡한 상태 관리를 효과적으로 다루는데 적합하다.</p>
</li>
</ul>
<br />

<h2 id="📌-atom과-selector">📌 Atom과 Selector</h2>
<ul>
<li><h3 id="atom"><strong>Atom</strong></h3>
<p>: 앱의 상태를 정의하는 단위로 atom 함수를 통해 생성한다. atom의 값을 읽는 컴포넌트들은 암묵적으로 atom을 구독하기 때문에 atom에 어떤 변화가 있으면 그 atom을 구독하는 모든 컴포넌트가 재 렌더링 되는 결과가 발생한다.</p>
<blockquote>
<p><strong>기본형</strong>  </p>
</blockquote>
<pre><code>function atom&lt;T&gt;({
    key: string, // 내부적으로 atom을 식별하는데 사용되는 고유한 문자열
    default: T | Promise&lt;T&gt; | RecoilValue&lt;T&gt;, // atom의 초깃값 또는 Promise 또는 동일한 타입의 값을 나타내는 다른 atom이나 selector
}): RecoilState&lt;T&gt;</code></pre><p>ex)</p>
<pre><code>import { atom } from &quot;recoil&quot;;

const userState = atom({
    key : &#39;userState&#39;,
    default : null,
})</code></pre></li>
<li><h3 id="selector"><strong>Selector</strong></h3>
<p>: 상태 변화를 만들어 내는 함수로 다른 atom이나 selector로부터 값을 계산한다.</p>
<blockquote>
<p><strong>기본형</strong>  </p>
</blockquote>
<pre><code>  function selector&lt;T&gt;({
    key: string, // 내부적으로 atom을 식별하는데 사용되는 고유한 문자열
    get: ({
      get: GetRecoilValue // selector는 읽기만 가능한 상태를 반환한다.
    }) =&gt; T | Promise&lt;T&gt; | RecoilValue&lt;T&gt;,
    set?: ( // selector는 쓰기 가능한 상태를 반환한다.
      {
        get: GetRecoilValue,
        set: SetRecoilState,
        reset: ResetRecoilState,
      },
      newValue: T | DefaultValue,
    ) =&gt; void,
  })</code></pre><p>ex)</p>
<pre><code>import { selector } from &quot;recoil&quot;;

const userNameState = selector({
    key : &#39;userNameState&#39;,
    get : ({get}) =&gt; {
        const user = get(userState);
        return user ? user.name : &#39;Guest&#39;;
    }
})</code></pre></li>
</ul>
<br />

<h2 id="📌-recoil에서-자주-사용하는-hook">📌 Recoil에서 자주 사용하는 Hook</h2>
<ul>
<li><h3 id="userecoilstatestate">useRecoilState(state)</h3>
<ul>
<li><code>Atom 값을 읽고 업데이트하는데 사용</code>되는 훅으로 useState과 비슷한 구조이지만 전역 상태를 다룬다는 점에서 차이가 있다. 상태가 업데이트 되었을 때 리렌더링을 하도록 컴포넌트를 구독한다.<pre><code>import {atom, useRecoilState, RecoilRoot} from &quot;recoil&quot;;
</code></pre></li>
</ul>
<p>const countState = atom({</p>
<pre><code>key : &#39;countState&#39;,
default : 0,</code></pre><p>});</p>
<p>function Counter() {</p>
<pre><code>const [count, setCount] = useRecoilState(countState);

return(
    &lt;div&gt;
        &lt;p&gt;Count : {count} &lt;/p&gt;
        &lt;button onClick={() =&gt; setCount(count + 1)}&gt;Increment&lt;/button&gt;
        &lt;button onClick={() =&gt; setCount(count - 1)}&gt;Decrement&lt;/button&gt;
    &lt;/div&gt; 
);</code></pre><p>}</p>
<pre><code></code></pre></li>
</ul>
<br />

<ul>
<li><h3 id="userecoilvaluestate">useRecoilValue(state)</h3>
<ul>
<li><code>단순히 Atom 값을 읽는데에 사용</code>되며 상태 업데이트를 트리거하지 않는다. <pre><code>import {selector, useRecoilValue, RecoilRoot} from &#39;recoil&#39;;
</code></pre></li>
</ul>
<p>const userNameState = selector({</p>
<pre><code>key : &#39;userNameState&#39;,
get : ({get}) =&gt; {
    const user = get(userState);
    return user ? user.name : &#39;Guest&#39;;
},</code></pre><p>});</p>
<p>function UserInfo() {</p>
<pre><code>const userName = useRecoilValue(userNameState);

return &lt;p&gt;Welcome, {userName}!&lt;/p&gt;</code></pre><p>}</p>
<pre><code></code></pre></li>
</ul>
<br />

<ul>
<li><h3 id="usesetrecoilstatestate">useSetRecoilState(state)</h3>
<ul>
<li><code>Atom 값을 업데이트</code>하는 setter 함수를 반환한다.</li>
</ul>
<pre><code>import {atom, useSetRecoilState, RecoilRoot} from &#39;recoil&#39;;

const countState = atom({
    key : &#39;countState&#39;,
    default : 0,
});

function Counter() {
    const setCount = useSetRecoilState(countState);

    return(
        &lt;div&gt;
            &lt;button onClick={() =&gt; setCount((prevCount) =&gt; prevCount + 1)}&gt;Increment&lt;/button&gt;
        &lt;/div&gt;
    );
}</code></pre></li>
</ul>
<br />

<ul>
<li><h3 id="useresetrecoilstate">useResetRecoilState</h3>
<ul>
<li><code>Atom 값을 초기값으로 리셋</code>하는 함수를 반환한다.<pre><code>import {atom, useResetRecoilState, RecoilRoot} from &#39;recoil&#39;;
</code></pre></li>
</ul>
<p>const countState = atom({</p>
<pre><code>key : &#39;countState&#39;,
default : 0,</code></pre><p>});</p>
<p>function ResetButton() {</p>
<pre><code>const resetCount = useResetRecoilState(countState);

return(
    &lt;div&gt;
        &lt;button onClick={resetCount}&gt;Reset Count&lt;/button&gt;
    &lt;/div&gt;
);</code></pre><p>}</p>
<pre><code></code></pre></li>
</ul>
<br />

<hr>
<br />

<h2 id="📌-그렇다면-프로젝트에-적용해보자">📌 그렇다면 프로젝트에 적용해보자!</h2>
<h3 id="1️⃣-설치">1️⃣ 설치</h3>
<pre><code>npm install recoil
yarn add recoil</code></pre><h3 id="2️⃣-recoilroot로-컴포넌트-감싸기">2️⃣ RecoilRoot로 컴포넌트 감싸기</h3>
<pre><code>export const NextProvider = ({ children }: Props) =&gt; {
  return (
    &lt;RecoilRoot&gt;
      &lt;QueryClientProvider client={queryClient}&gt;
        ...
      &lt;/QueryClientProvider&gt;
    &lt;/RecoilRoot&gt;
  );
};</code></pre><h3 id="3️⃣-지도-전역-상태-설계">3️⃣ 지도 전역 상태 설계</h3>
<p>: 기존 Map, Marker에 전달하던 상태 및 상태 관리 함수는 모두 삭제 후 전역으로 상태 관리하도록 적용</p>
<blockquote>
<p>• mapState : 지도의 기본 상태를 저장
• currentStoreState : 현재 선택한 맛집 상태를 저장
• locationState : 현재 위치 및 zoom 상태를 저장</p>
</blockquote>
<ul>
<li><h4 id="atom-값-설계">Atom 값 설계</h4>
<pre><code>// atom/index
</code></pre></li>
</ul>
<p>export const mapState = atom<any>({
  key: &quot;map&quot;,
  default: null,
  dangerouslyAllowMutability: true,
});</p>
<p>export const currentStoreState = atom&lt;StoreType | null&gt;({
  key: &quot;store&quot;,
  default: null,
});</p>
<p>export const locationState = atom<LocationType>({
  key: &quot;location&quot;,
  default: {
    lat: DEFAULT_LAT,
    lng: DEFAULT_LNG,
    zoom: DEFAULT_ZOOM,
  },
});</p>
<pre><code>
- #### Map 컴포넌트에 적용
</code></pre><p>// components/map
export default function Map({ lat, lng, zoom }: MapProps) {
  const setMap = useSetRecoilState(mapState);
  const location = useRecoilValue(locationState);</p>
<pre><code>- #### Marker 컴포넌트에 적용</code></pre><p>export default function Marker({ store }: MarkerProps) {
  const map = useRecoilValue(mapState);</p>
<pre><code>
- #### Markers 컴포넌트에 적용</code></pre><p>// components/markers
export default function Markers({ stores }: MarkerProps) {
  const map = useRecoilValue(mapState);
  const setCurrentStore = useSetRecoilState(currentStoreState);
  const [location, setLocation] = useRecoilState(locationState);</p>
<pre><code>
&lt;br /&gt;

### 4️⃣ 검색 기능 전역 상태 설계
: 기존 SearchFilter에 전달하던 상태 및 상태 관리 함수는 모두 삭제 후 전역으로 상태 관리하도록 적용

&gt; searchState : 검색 상태를 저장

- ####  Atom 값 설계</code></pre><p>// atom/index</p>
<p>export const searchState = atom&lt;SearchType | null&gt;({
  key: &quot;search&quot;,
  default: null,
});</p>
<pre><code>
- ####  SearchFilter 컴포넌트에 적용</code></pre><p>export default function SearchFilter() {
  const [search, setSearch] = useRecoilState(searchState);</p>
<p>  return (
    ...
        &lt;input
          type=&quot;search&quot;
          onChange={(e) =&gt; setSearch({ ...search, q: e.target.value })}
          ...
        /&gt;
      </div>
      &lt;select
        onChange={(e) =&gt; setSearch({ ...search, district: e.target.value })}
        ...
      &gt;</p>
<p>````</p>
<br />
<br />

<p>💡 </p>
<ul>
<li><a href="https://recoiljs.org/ko/docs/introduction/getting-started">참고</a></li>
<li><a href="https://velog.io/@2ast/React-Recoil-selector%EB%A1%9C-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%B5%9C%EC%A0%81%ED%99%94%EC%97%90-%EA%B8%B0%EC%97%AC%ED%95%98%EA%B8%B0">참고</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[pages 라우터에서 app 라우터로 마이그레이션]]></title>
            <link>https://velog.io/@ssooo_kk_77/pages-%EB%9D%BC%EC%9A%B0%ED%84%B0%EC%97%90%EC%84%9C-app-%EB%9D%BC%EC%9A%B0%ED%84%B0%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@ssooo_kk_77/pages-%EB%9D%BC%EC%9A%B0%ED%84%B0%EC%97%90%EC%84%9C-app-%EB%9D%BC%EC%9A%B0%ED%84%B0%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</guid>
            <pubDate>Sun, 24 Dec 2023 03:02:46 GMT</pubDate>
            <description><![CDATA[<h3 id="❔-app-router로-변환하는-방법">❔ App router로 변환하는 방법</h3>
<ul>
<li>app 디렉토리 안에 <code>page.js 파일</code>을 만들어서 공개적으로 접근 가능한 URL 경로를 만든다.
(app router는 page router보다 우선시 되며 디렉토리 간에 라우팅이 겹칠 경우 충돌을 방지하기 위해 빌드 시 에러를 발생시킨다.) </li>
<li>기본적으로 app 디렉토리 내부의 컴포넌트는 리액트의 <code>서버 컴포넌트로 동작</code>한다.(성능 최적화 적용 가능) <ul>
<li>클라이언트 컴포넌트로 사용하고 싶다면 최상단에 &quot;<code>use client</code>&quot;로 정의하면 된다.</li>
</ul>
</li>
</ul>
<br />

<h3 id="📝-파일-규칙">📝 파일 규칙</h3>
<table class="table">
        <thead>
<tr>
<th>파일명(.js, jsx, .tsx)</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td>layout</td>
<td>세그먼트와 그 자식들에 대한 공유하는 UI. 레이아웃 파일</td>
</tr>
  <tr>
<td>page</td>
<td>라우트의 고유한 UI(페이지)를 만들고 공개적으로 접근 가능하게 만드는 파일</td>
</tr>
  <tr>
<td>loading</td>
<td>세그먼트와 그 자식들에 대한 로딩 UI</td>
</tr>
  <tr>
<td>not-found</td>
<td>세그먼트와 그 자식들에 대한 404 UI</td>
</tr>
  <tr>
<td>error/global-error</td>
<td>세그먼트와 그 자식들에 대한 에러 UI. 글로벌 에러 UI</td>
</tr>
  <tr>
<td>route</td>
<td>서버 측 API 엔드포인트(기존 pages의 api 폴더 역할)</td>
</tr>
    <tr>
<td>template</td>
<td>커스텀 된(리렌더링) 레이아웃 UI(상태유지X)</td>
</tr>
</tbody>
</table>

<br />
<br />

<h3 id="페이지와-레이아웃">페이지와 레이아웃</h3>
<ul>
<li><strong>Page.js</strong> : Route의 유일하게 UI를 보여줄 수 있는 페이지<ul>
<li>기본적으로 서버 컴포넌트로 구성되며 따로 클라이언트 컴포넌트 설정이 가능하다.</li>
</ul>
</li>
<li><strong>Layouts</strong> : 여러 pages 간에 공유되는 UI<ul>
<li>상태 보존, 인터렉티브 유지, 리렌더링 되지 않는다.</li>
<li>모든 페이지에 공유되는 Root Layout는 필수로 생성해야 한다.</li>
<li>child layout이나 child page가 있는 경우에는 항상 children props를 설정해주어야 한다.</li>
</ul>
</li>
<li><strong>Templates</strong> : 템플릿은 레이아웃과 비슷하게 layout, page를 감싸지만 상태 유지는 안됨.(새로운 인스턴스 생성)<ul>
<li>특별한 상황이 아닌 경우 , Layout 사용이 권장된다. </li>
</ul>
</li>
<li>layout.js와 page.js 파일은 같은 폴더에 정의 가능하며, layout.js는 항상 page.js를 감싸는 구조<pre><code>export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
  &lt;html lang=&quot;en&quot;&gt;
    &lt;body&gt;{children}&lt;/body&gt;
  &lt;/html&gt;
)
}</code></pre></li>
</ul>
<br />
<br />

<h3 id="라우트-그룹">라우트 그룹</h3>
<ul>
<li>사이트의 목적별로 또는 기능별로 경로를 구성하고 싶은데 URL 경로에는 포함되지 않도록 하고자 할 때 폴더를 Route 그룹으로 표시하여 사용한다.</li>
<li>레이아웃만 다르게 적용하고 url은 변경하고 싶지 않은 경우에 사용한다.</li>
<li>컨벤션 : 폴더 이름을 괄호로 묶음으로써 생성(name)<ul>
<li>각각의 Route Group 마다 같은 URL 계층을 가져도 다른 layout을 적용</li>
</ul>
</li>
<li>(marketing), (shop)은 app 하단의 최상위 루트지만 Route Group을 이용해서 별개의 레이아웃 구성
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/4a611bb7-a0dd-492a-a160-2c9726b8ab42/image.avif" alt=""></li>
</ul>
<br />
<br />

<hr>
<br />
<br />

<h3 id="프로젝트에-적용하기">프로젝트에 적용하기</h3>
<p>그렇다면 이제 제 프로젝트를 app router로 마이그레이션 해보겠습니다!</p>
<h4 id="1-nodejs와-nextjs-최신-버전으로-업그레이드하기">1. node.js와 next.js 최신 버전으로 업그레이드하기</h4>
<pre><code>    &gt; yarn add next@latest</code></pre><h4 id="2-src-폴더-하위에-app-디렉토리-생성">2. src 폴더 하위에 app 디렉토리 생성</h4>
<br />

<h4 id="3-app-디렉토리-하위에-root-layouttsx-파일-생성">3. app 디렉토리 하위에 Root Layout.tsx 파일 생성</h4>
<pre><code>  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      &lt;html lang=&quot;en&quot;&gt;
        &lt;body&gt;
          &lt;GoogleAnalytics GA_TRACKING_ID={process.env.NEXT_PUBLIC_GA_ID} /&gt;
          &lt;NextProvider&gt;
            &lt;NextLayout&gt;{children}&lt;/NextLayout&gt;
          &lt;/NextProvider&gt;
        &lt;/body&gt;
      &lt;/html&gt;
    );
  }</code></pre><h4 id="4-_documenttsx-파일을-마이그레이션-해주기-위해-metadata-정의">4. _document.tsx 파일을 마이그레이션 해주기 위해 Metadata 정의</h4>
<pre><code>  import { Metadata } from &quot;next&quot;;

   export const metadata: Metadata = {
     title: &quot;Hole in the wall&quot;,
     description: &quot;Next.js 13 로컬 맛집 앱&quot;,
   }; </code></pre><h4 id="5-기존-_apptsx-파일과-_docuemnttsx-파일-삭제">5. 기존 _app.tsx 파일과 _docuemnt.tsx 파일 삭제</h4>
<br />

<h4 id="6-기존-index-파일을-app의-pagetsx-파일로-변경">6. 기존 index 파일을 app의 page.tsx 파일로 변경</h4>
<br />

<h4 id="7-데이터-패칭-변경">7. 데이터 패칭 변경</h4>
<pre><code>    // 기존 getServersideProps로 하던 패칭 방법
    export async function getServerSideProps() {
    const stores = await axios(`${process.env.NEXT_PUBLIC_API_URL}/api/stores`);

    return {
      props: { stores: stores.data },
    };
  }</code></pre><p>  ⬇⬇⬇  ⬇⬇⬇  ⬇⬇⬇  ⬇⬇⬇  ⬇⬇⬇</p>
<pre><code>  // fetch api를 사용한 패칭 방법
  // Home 내부에서 호출
  const stores: StoreType[] = await getData();

  async function getData() {
    try {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/stores`, {
        cache: &quot;no-store&quot;,
      });

      if (!res.ok) {
        throw new Error(&quot;Failed to fetch data&quot;);
      }

      return res.json();
    } catch (e) {
      console.log(e);
    }
  }</code></pre><br />

<h4 id="8-서버-컴포넌트로-작동하는-app-폴더-내에서-usestate나-useeffect와-같은-훅을-사용하는-컴포넌트를-작동시키기-위해-클라이언트-사이드에서-use-client-명시">8. 서버 컴포넌트로 작동하는 app 폴더 내에서 useState나 useEffect와 같은 훅을 사용하는 컴포넌트를 작동시키기 위해 클라이언트 사이드에서 &quot;use client&quot; 명시</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/7ba1c7c4-ca70-4a03-ab68-8e02fac44cd2/image.JPG" width="700" />


<pre><code>// Markers.tsx

&quot;use client&quot;;
import { currentStoreState, locationState, mapState } from &quot;@/atom&quot;;
import { StoreType } from &quot;@/interface&quot;;
import { useCallback, useEffect } from &quot;react&quot;;
import { useSetRecoilState, useRecoilValue, useRecoilState } from &quot;recoil&quot;;</code></pre><br />

<h4 id="9-userouter-usepathname-usesearchparams-변경">9. <code>useRouter()</code>, <code>usePathname()</code>, <code>useSearchParams()</code> 변경</h4>
<ul>
<li>기존에 next/router에서 import하던 useRouter 훅을 next/navigation으로 변경</li>
<li>새로운 useRouter는 더이상 문자열 pathname을 반환하지 않기 때문에 usePathname 훅으로 변경</li>
<li>새로운 useRouter는 쿼리를 반환하지 않기 때문에 useSearchParams 훅으로 변경</li>
</ul>
<pre><code>&#39;use client&#39;

import { useRouter, usePathname, useSearchParams } from &#39;next/navigation&#39;

export default function ExampleClientComponent() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  // ...
}</code></pre><pre><code>  // 기존의 useRouter를 사용해 url로 쿼리를 받음.
  const router = useRouter();
  const { page = &quot;1&quot; }: any = router.query;

  // 새로운 useSearchParams 훅으로 parameters를 받음.
  const searchParams = useSearchParams();
  const page: any = searchParams?.get(&quot;page&quot;) || &quot;1&quot;;</code></pre><br />

<h4 id="10-url-경로에-따라-폴더-생성하고-pagetsx-파일로-변환">10. URL 경로에 따라 폴더 생성하고 page.tsx 파일로 변환</h4>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/b3a1870a-e700-4a3b-8be6-be464f24ace5/image.JPG" alt=""></p>
<br />

<h4 id="11-global-error-파일-생성">11. global-error 파일 생성</h4>
<pre><code>  &quot;use client&quot;;

  export default function GlobalError({
    reset,
  }: {
    error: Error &amp; { digest?: string };
    reset: () =&gt; void;
  }) {
    return (
      &lt;html&gt;
        &lt;body&gt;
          &lt;div&gt;
            다시 시도해주세요.
            &lt;button
              onClick={() =&gt; reset()}&gt;
              Try again
            &lt;/button&gt;
          &lt;/div&gt;
        &lt;/body&gt;
      &lt;/html&gt;
    );
  }</code></pre><br />

<h4 id="12-app-디렉토리-내에-apiauthnextauthroutets-파일-생성">12. app 디렉토리 내에 api/auth/[...nextauth]/route.ts 파일 생성</h4>
<p>/app/api/auth/[...nextauth]/route.ts</p>
<pre><code>import NextAuth from &quot;next-auth&quot;

const handler = NextAuth({
  ...
})

export { handler as GET, handler as POST }</code></pre><p>💥 해당 과정 중 생긴 오류 </p>
<blockquote>
<p><a href="https://velog.io/@ssooo_kk_77/build-error">오류 해결 포스팅</a></p>
</blockquote>
<br />

<h4 id="13-기존-pages-폴더-안의-nextauthroutets-삭제">13. 기존 pages 폴더 안의 [...nextauth]/route.ts 삭제</h4>
<br />

<h4 id="14-stores의-route-http-메서드에-따라-함수화">14. stores의 route HTTP 메서드에 따라 함수화</h4>
<p>// 기존의 POST 메서드</p>
<pre><code>import type { NextApiRequest, NextApiResponse } from &quot;next&quot;;
import { StoreApiResponse, StoreType } from &quot;@/interface&quot;;
import prisma from &quot;@/db&quot;;
import axios from &quot;axios&quot;;

import { getServerSession } from &quot;next-auth&quot;;
import { authOptions } from &quot;./auth/[...nextauth]&quot;;

interface Responsetype {
  page?: string;
  limit?: string;
  q?: string;
  district?: string;
  id?: string;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse&lt;StoreApiResponse | StoreType[] | StoreType | null&gt;
) {
  const { page = &quot;&quot;, limit = &quot;&quot;, q, district, id }: Responsetype = req.query;
  const session = await getServerSession(req, res, authOptions);

  if (req.method === &quot;POST&quot;) {
    const formData = req.body;
    const headers = {
      Authorization: `KakaoAK ${process.env.KAKAO_CLIENT_ID}`,
    };

    ...


    return res.status(200).json(result);
</code></pre><p>⬇⬇⬇ ⬇⬇⬇ ⬇⬇⬇ ⬇⬇⬇ ⬇⬇⬇</p>
<p>// NextResponse로 변경한 POST 메서드</p>
<pre><code>import { NextResponse } from &quot;next/server&quot;;
import prisma from &quot;@/db&quot;;
import axios from &quot;axios&quot;;

import { getServerSession } from &quot;next-auth&quot;;
import { authOptions } from &quot;@/app/utils/authOptions&quot;;

export async function POST(req: Request) {
  // 데이터 생성을 처리한다.
  const formData = await req.json();

  const headers = {
    Authorization: `KakaoAK ${process.env.KAKAO_CLIENT_ID}`,
  };

 ...


  return NextResponse.json(result, { status: 200 });
}</code></pre><br />
<br />
<br />

<p>💡</p>
<ul>
<li><a href="https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration#migrating-_documentjs-and-_appjs">공식 문서 참고</a></li>
<li><a href="https://next-auth.js.org/configuration/initialization">next auth route handler</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프로젝트에 Google Analytics 적용하기]]></title>
            <link>https://velog.io/@ssooo_kk_77/Google-Analytics-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@ssooo_kk_77/Google-Analytics-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Sat, 23 Dec 2023 08:16:06 GMT</pubDate>
            <description><![CDATA[<h2 id="❔-google-analytics란">❔ Google Analytics란?</h2>
<blockquote>
<p>웹 사이트 및 앱의 트래픽, 사용자의 행동을 분석하는 데 사용되는 google의 무료 웹 분석 도구(<a href="https://analytics.google.com/">https://analytics.google.com/</a>)</p>
</blockquote>
<br />

<h2 id="❕-google-analytics를-사용했을-때-장점">❕ Google analytics를 사용했을 때 장점</h2>
<ul>
<li>트래픽 소스, 사용자의 경로, 전환률 등을 상세하게 분석할 수 있어 <code>웹 사이트나 앱의 성능 향상</code>에 도움을 준다.</li>
<li>사용자가 웹 사이트나 앱에서 어떻게 상호 작용하는지 이해할 수 있고 이를 바탕으로 <code>사용자 경험을 개선</code>할 수 있다.</li>
<li><code>트래픽 분석</code> : 웹 사이트나 앱에 어떤 트래픽 소스에서 사용자가 유입되는지 추적할 수 있다,</li>
<li><code>페이지 분석</code> : 어떤 페이지나 화면이 사용자에게 가장 많이 방문되는지 어떤 컨텐츠가 인기 있는지 파악할 수 있다.</li>
<li><code>전환률 분석</code> : 원하는 동작(가입, 다운로드 등)을 완료한 사용자의 비율을 계산하고 개선 방법을 고려할 수 있다. </li>
</ul>
<br />

<h2 id="❕-세팅">❕ 세팅</h2>
<h4 id="1-관리--만들기--계정">1. 관리 &gt; 만들기 &gt; 계정</h4>
<p><a href="https://analytics.google.com/analytics">https://analytics.google.com/analytics</a></p>
<h4 id="2-계정-이름-설정">2. 계정 이름 설정</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/facbabc0-2ca7-43fb-91fd-353b43ebf1f6/image.JPG" width="600"/>

<br />

<h4 id="3-속성-이름-보고-시간대-통화-설정">3. 속성 이름, 보고 시간대, 통화 설정</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/780d0011-5d3d-4b71-a6e1-1ba3dc420cbd/image.JPG" width="600"/>

<br />

<h4 id="4--업종-카테고리-비지니스-규모-선택">4.  업종 카테고리, 비지니스 규모 선택</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/f1521f6b-e58f-4868-9782-84fc6f25870e/image.JPG" width="300"/>

<br />

<h4 id="5-비지니스-목표-선택">5. 비지니스 목표 선택</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/94af9895-7c1b-4a66-9822-f7b263f65aaa/image.JPG" width="500"/>

<br />

<h4 id="6-약관-동의-후-플랫폼-선택">6. 약관 동의 후 플랫폼 선택</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/350f4985-d843-44c4-93c6-b460f1f81017/image.JPG" width="800"/>

<br />

<h4 id="7-배포한-url과-스트림-이름-설정">7. 배포한 URL과 스트림 이름 설정</h4>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/98b742c1-623b-493b-b39a-e8a3085250ca/image.JPG" width="700"/>

<br />

<ol start="8">
<li>측정 ID를 .env 파일에 NEXT_PUBLIC_GA_ID로 저장<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/ef566c50-641d-4ecf-89d9-786bb21ac916/image.JPG" width="700"/>

</li>
</ol>
<br />

<hr>
<br />

<h2 id="❕-적용">❕ 적용</h2>
<h4 id="1-설치">1. 설치</h4>
<pre><code>yarn add -D @types/gtag.js</code></pre><h4 id="2-tsconfigjson에-gtag-추가해-타입-선언-파일-지정">2. tsconfig.json에 gtag 추가해 타입 선언 파일 지정</h4>
<pre><code>  &quot;types&quot;: [&quot;@types/gtag.js&quot;],</code></pre><h4 id="3-이전-페이지에서-저장한-측정-id를-env-파일에-저장">3. 이전 페이지에서 저장한 측정 ID를 .env 파일에 저장</h4>
<h4 id="4-srclibgtagts-파일-생성-후-구글-애널리틱스-관련-타입-및-함수-정의">4. /src/lib/gtag.ts 파일 생성 후 구글 애널리틱스 관련 타입 및 함수 정의</h4>
<ul>
<li><p>pageview 함수</p>
<ul>
<li>page path를 넘겨받아 사용자가 페이지를 이동할 때마다 해당 path를 저장</li>
</ul>
</li>
<li><p>event 함수</p>
<ul>
<li>사용자가 찜 버튼을 누를 때마다 해당 이벤트가 감지되도록 정의<pre><code>export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_ID;
</code></pre></li>
</ul>
</li>
</ul>
<p>export const pageview = (url: URL | string) =&gt; {
  if (process.env.NODE_ENV !== &quot;development&quot;) {
    window.gtag(&quot;config&quot;, GA_TRACKING_ID as string, {
      page_path: url,
    });
  }
};</p>
<p>type gtagEvent = {
  action: string;
  category: string;
  label: string;
  value: number;
};</p>
<p>export const event = ({ action, category, label, value }: gtagEvent) =&gt; {
  if (process.env.NODE_ENV !== &quot;development&quot;) {
    window.gtag(&quot;event&quot;, action, {
      event_category: category,
      event_label: label,
      value: value,
    });
  }
};</p>
<pre><code>
#### 5. src/app/googleAnalytics.tsx 파일 생성 후 구글 스크립트 로드
- pathname이 변경될 때 마다 pageview 함수 호출
</code></pre><p>import { pageview } from &quot;@/lib/gtag&quot;;
import Script from &quot;next/script&quot;;
import { useEffect } from &quot;react&quot;;
import { usePathname } from &quot;next/navigation&quot;;</p>
<p>function GoogleAnalytics({ GA_TRACKING_ID }: { GA_TRACKING_ID?: string }) {
  const pathname = usePathname();</p>
<p>  useEffect(() =&gt; {
    if (pathname) {
      pageview(pathname);
    }
  }, [pathname]);</p>
<p>  if (process.env.NODE_ENV !== &quot;production&quot;) {
    return null;
  }
  return (
    <div className="container">
      &lt;Script
        strategy=&quot;afterInteractive&quot;
        src={<code>https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}</code>}
      /&gt;
      <Script id="google-analytics" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());</p>
<pre><code>      gtag(&#39;config&#39;, &#39;${GA_TRACKING_ID}&#39;);
    `}
  &lt;/Script&gt;
&lt;/div&gt;</code></pre><p>  );
}</p>
<p>export default GoogleAnalytics;</p>
<pre><code>
#### 6. /src/app/layout.tsx에서 앞서 만든 googleAnalytics 컴포넌트 로드</code></pre><p>export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <GoogleAnalytics GA_TRACKING_ID={process.env.NEXT_PUBLIC_GA_ID} />
        <NextProvider>
          <NextLayout>{children}</NextLayout>
        </NextProvider>
      </body>
    </html>
  );
}</p>
<pre><code>
#### 7. 찜 하기 버튼을 클릭했을 때 event 함수 호출</code></pre><p>const toggleLike = async () => {
    // 찜하기 / 찜 취소
    if (session?.user && store) {
      try {
        ...생략</p>
<pre><code>    event({
      action: &quot;click_like&quot;,
      category: &quot;like&quot;,
      label: like.status === 201 ? &quot;create_like&quot; : &quot;delete_like&quot;,
      value: storeId,
    });

    refetch();
  } catch (e) {
    console.log(e);
  }
} else if (status === &quot;unauthenticated&quot;) {
  toast.warn(&quot;로그인 후 이용해주세요.&quot;);
  event({
    action: &quot;click_like&quot;,
    category: &quot;like&quot;,
    label: &quot;need_login_like&quot;,
    value: storeId,
  });
}</code></pre><p>  };</p>
<p>```</p>
<h4 id="8-vercel의-환경-변수-추가-후-재배포">8. Vercel의 환경 변수 추가 후 재배포</h4>
<p><img src="https://velog.velcdn.com/images/ssooo_kk_77/post/2f55565a-7fd6-4566-b635-40d79a550ca8/image.JPG" alt=""></p>
<br />
<br />

<p>여기까지 설정은 끝이났고 적용이 잘 되었는지 확인해봅시다!</p>
<p>먼저 event를 적용한 찜 하기 기능을 테스트 하기 위해 가게 하나를 찜하고 찜을 삭제한 후</p>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/efafae37-b470-4abb-8896-036bf689c578/image.JPG" width="600"/>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/523d86e9-3c47-4921-b11f-2f80d8cf3a17/image.JPG" width="600"/>

<br />

<p>Google 애널리틱스의 프로젝트 보고서를 확인하니 사용자 수가 측정된 것을 확인할 수 있었습니다.</p>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/c9d3641c-5e68-4db1-965a-8e395368c7dc/image.JPG" width="600"/>

<br />

<p>찜하기와 찜삭제 기능이 몇 번 발생했는지 측정되었습니다! </p>
<img src="https://velog.velcdn.com/images/ssooo_kk_77/post/02a508d5-d0f8-4d7a-8c8f-e1576f81bf3f/image.JPG" width="500"/>



<br />
<br />

<p>💡
<a href="https://nextjs.org/docs/messages/next-script-for-ga">공식 문서 참고</a>
<a href="https://developers.google.com/analytics/devguides/collection/gtagjs/pages">페이지 조회수 측정 참고</a>
<a href="https://developers.google.com/analytics/devguides/collection/gtagjs/events">Google 애널리틱스 이벤트 측정 참고</a></p>
]]></description>
        </item>
    </channel>
</rss>