<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hisy4429_sun.log</title>
        <link>https://velog.io/</link>
        <description>기록은 기억이 된다</description>
        <lastBuildDate>Wed, 04 Dec 2024 07:29:41 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hisy4429_sun.log</title>
            <url>https://velog.velcdn.com/images/hisy4429_sun/profile/86ad7e94-ef8c-4692-b0d6-14200e1b6bc5/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hisy4429_sun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hisy4429_sun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[<오늘 점심은 먹대리가> 회고]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%98%A4%EB%8A%98-%EC%A0%90%EC%8B%AC%EC%9D%80-%EB%A8%B9%EB%8C%80%EB%A6%AC%EA%B0%80</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%98%A4%EB%8A%98-%EC%A0%90%EC%8B%AC%EC%9D%80-%EB%A8%B9%EB%8C%80%EB%A6%AC%EA%B0%80</guid>
            <pubDate>Wed, 04 Dec 2024 07:29:41 GMT</pubDate>
            <description><![CDATA[<p><a href="https://today-lunch-smoky.vercel.app">오늘 점심 메뉴가 고민된다면, 먹대리에게 추천 받아보세요!</a></p>
<h2 id="만들게-된-계기">만들게 된 계기</h2>
<p>점심 고민하다가 검색 해본 점심 추천 서비스들을 검색해보니 밋밋하고 손이 안가는 사이트들이 많았다. 내 마음에 들게 만들어서, 주변 사람들도 쓰게 하고 싶다는 생각에 내 입맛대로 만든 점심 추천 서비스를 만들어보고 싶었다. 일단 시작은 아주 간단하게 하나의 페이지, 랜덤으로 메뉴 추천이었다.</p>
<p>나는 디자이너의 재능은 하나도 없고 도구도 다룰 줄 몰라서 적당히 내가 원하는 이미지를 잘라서 붙여넣으며 그림판으로 1차 시안을 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/95d5c23c-0472-448c-b97b-6d3add3261ec/image.png" alt=""></p>
<p>추천받기를 누르면 3개의 랜덤 메뉴를 보여주는게 핵심이었다. 다른 서비스에서 1개 선택해줘도 계속 다시 추천받는걸 누르게 되는 내 심리를 반영한 결과였다.</p>
<p>이걸 들고 같이 팀 프로젝트를 했던 UIUX 디자이너 2명에게 같이 만들어 보자고 제안했더니 모두 흔쾌히 수락하셨다. 두 분이 없었다면 결과물은 그림판과 같았을지도 모른다. <del>그럼 결국 안예뻐서 나도 안썼을듯</del></p>
<p>결과 먼저 소개하자면 아주 귀여운 캐릭터 &quot;먹대리&quot;와 서비스가 탄생했다. 로딩 과정과 도장이 찍히는 애니메이션까지 너무 귀엽게 만드셨다!</p>
<h3 id="결과-이미지">결과 이미지</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/5674763d-126b-416b-976b-60d5621518df/image.png" alt="">
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/1d876fff-bd34-46d3-8a7d-4c5fe4eb5d57/image.png" alt="">
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/5ebc5946-3803-473b-aba1-d453ae90d75f/image.png" alt=""></p>
<p>맨 처음 단순히 기획했던 랜덤 추천에서 추가적으로 날씨에 맞춰서 추천하는 페이지도 추가되었다.</p>
<h2 id="디자인-과정">디자인 과정</h2>
<p>어떻게 만들지 디자이너 두 분의 주도하에 레퍼런스를 확인하고 함께 결정했다. 제안서의 모양이 나름 개성있고 대상도 뚜렷히 잡는 느낌이라 직장인 대상, 도구리와 같은 캐릭터를 만들기로 진행이 되었다.</p>
<p>래퍼런스 중 마음에 들었던 제안서 타입!
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/7eb11485-a6b3-4dd2-ba41-3ce253491b9c/image.png" alt=""></p>
<p>먹대리의 탄생까지
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/54a3144b-bec5-4729-bdd7-8c2813db1f24/image.png" alt=""></p>
<p>디자이너분들의 고민을 뒤늦게 살펴보았다. 먹대리야 이런 과정을 거쳐서 나왔구나!!! 게다가 시작 페이지에 손이 들어가는데, 실제 라쿤의 손이 제법 사람같다는 사실을 이때 깨달았다. 이런 점까지 고려하신 디테일 미쳤다!</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/cc277265-87b6-4992-a65c-77dc53df865f/image.png" alt=""></p>
<p>이런 디테일함으로 도장도 결국 먹대리의 얼굴로 되었다. 만화 고기 도장도 제안받아서 전달했더니, 비건이거나 음식 메뉴가 고기가 아닐 경우 어색할 수 있다는 말에 동의했다.</p>
<h2 id="회고를-마치며">회고를 마치며</h2>
<p>셋이서 피그마에서 보여서 간략히 후기를 남기며 회고의 시간을 가졌다. 수민님과 수진님 모두 수고하셨고 다시 한번 감사의 말씀을~~!</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/934fc945-c825-4c2f-a850-8ca624113568/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 구글 애널리틱스 연동 - 2 (API 연결하기)]]></title>
            <link>https://velog.io/@hisy4429_sun/Next.js-%EA%B5%AC%EA%B8%80-%EC%95%A0%EB%84%90%EB%A6%AC%ED%8B%B1%EC%8A%A4-%EC%97%B0%EB%8F%99-2-API-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/Next.js-%EA%B5%AC%EA%B8%80-%EC%95%A0%EB%84%90%EB%A6%AC%ED%8B%B1%EC%8A%A4-%EC%97%B0%EB%8F%99-2-API-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 Nov 2024 04:17:18 GMT</pubDate>
            <description><![CDATA[<p>구글 애널리틱스 API 연동을 위한 방법이 다양해서 오히려 헷갈리더라구요.</p>
<p>제가 연동한 방법을 소개합니다!</p>
<p>먼저 프로젝트에서 구글 애널리틱스 API로 데이터를 받아보기 위해서는 3가지 선행 작업이 필요합니다.</p>
<blockquote>
<p>GA API를 사용하기 위해서는 GCP 콘솔에서 서비스 계정을 추가하고, API를 호출할 때 필요한 키를 JSON 형태로 다운로드 받아야 합니다.</p>
</blockquote>
<h2 id="사전-준비">사전 준비</h2>
<h3 id="1구글-클라우드-콘솔에서-서비스-계정을-추가">1.구글 클라우드 콘솔에서 서비스 계정을 추가</h3>
<p><a href="https://console.cloud.google.com/">https://console.cloud.google.com</a></p>
<ul>
<li>구글 클라우드 콘솔에 접속하여 해당 프로젝트 내용으로 새로 생성하기</li>
<li>대시보드를 눌러서 프로젝트 설정으로 이동</li>
<li>좌측 메뉴에서 <strong>서비스 계정</strong>을 선택하고 상단에 <strong>서비스 계정 만들기</strong> 클릭</li>
<li>작업 … 를 눌러 키관리 선택 후 새 키 만들기</li>
<li>다운받은 JSON 파일을 열어 client_email라는 키의 값을 복사합니다.</li>
<li>이후 구글 애널리틱스 사이트로 이동합니다.</li>
</ul>
<p>(사진 추가 예정)</p>
<h3 id="2-구글-애널리틱스-사이트에서-계정-엑세스-관리로-사용자-추가">2. 구글 애널리틱스 사이트에서 계정 엑세스 관리로 사용자 추가</h3>
<p>구글 애널리틱스 사이트에서 1번의 서비스 계정을 추가해서 얻은 JSON 파일의 이메일 값을 써야한다.</p>
<p><a href="https://analytics.google.com">https://analytics.google.com</a></p>
<ul>
<li>홈의 <strong>속성 설정 계속 진행하기</strong> 의 설정 <strong>어시스턴트로 이동하기</strong> 클릭</li>
<li>왼쪽 메뉴 탭의 톱니바퀴 모양의 설정에 들어가서 계정 → <strong>계정 액세스 관리 클릭</strong>도 가능</li>
<li>계정 액세스 관리에서 <code>+</code> 버튼을 눌러 <strong>사용자 추가</strong> 클릭</li>
<li>이메일 주소에 아까 다운로드 받은 JSON의 client_email 입력하고, 표준 역할은 <strong>뷰어</strong>로 선택</li>
<li>client_email는 <code>AAAA예시@AAAAAAA예시.iam.gserviceaccount.com</code> 형태이다.</li>
</ul>
<h3 id="3-구글-클라우드-콘솔에서-api-및-서비스에서-ga-api-사용하기">3. 구글 클라우드 콘솔에서 API 및 서비스에서 GA API 사용하기</h3>
<ul>
<li>API 및 서비스 → 라이브러리</li>
<li>Google Analytics 를 검색 후  <strong>Google Analytics Data API</strong>를 선택합니다.</li>
<li>사용하기를 누릅니다.</li>
</ul>
<blockquote>
<p>라이브러리 검색 시 Google Analytics의 API가 3종류가 있으며 간략한 소개를 덧붙입니다.</p>
</blockquote>
<h4 id="1-google-analytics-api-universal-analytics">1) Google Analytics API (Universal Analytics)</h4>
<ul>
<li>Google Analytics의 구버전(UA) 데이터를 추출하는 데 사용됩니다.</li>
<li>Universal Analytics 계정을 사용하고 있다면 이 API를 사용해야 합니다.</li>
</ul>
<p>2023년 7월 1일 이후, 새로운 Universal Analytics 프로퍼티의 데이터 처리가 중단되므로, 기존 UA 프로퍼티를 사용 중이 아니라면 이 API를 사용하는 것은 추천되지 않습니다. (gpt의 설명으로 부정확할 수 있습니다.)</p>
<h4 id="2-google-analytics-reporting-api-v4">2) Google Analytics Reporting API v4</h4>
<p>Google Analytics의 Universal Analytics와 GA4 데이터를 보고하는 데 사용됩니다.</p>
<h4 id="3-google-analytics-data-api-ga4---이번에-사용하는-라이브러리">3) Google Analytics Data API (GA4) - 이번에 사용하는 라이브러리</h4>
<p>Google Analytics 4(GA4) 데이터를 추출하는 데 사용됩니다.</p>
<hr>
<h2 id="프로젝트에-api-연결하기">프로젝트에 API 연결하기</h2>
<p>사전 작업을 마치고 프로젝트에서 구글 API를 호출하는 방법입니다.</p>
<h3 id="1-google-api-client-설정">1. Google API Client 설정</h3>
<p>Google API Client 라이브러리 설치가 필요합니다. <strong>Node.js 환경에서 호출하는 경우</strong>, 먼저 <code>googleapis</code> 라이브러리를 설치해야 합니다.</p>
<p>속성ID(숫자)</p>
<pre><code>npm install googleapis</code></pre><h3 id="2-서비스-계정-키-파일-준비">2. 서비스 계정 키 파일 준비</h3>
<p>서비스 계정 키는 이미 JSON 형식으로 받았으므로, 해당 파일을 프로젝트에 안전하게 보관합니다.</p>
<p>저는 루트 디렉토리 secrets 폴더에 해당 JSON 파일을 넣었습니다.
<code>secrets/service-account-key.json</code></p>
<p>그리고 <code>.env.local</code> 에서 해당 파일경로를 환경변수로 등록했습니다.</p>
<pre><code>GOOGLE_SERVICE_KEY_PATH=secrets/service-account-key.json</code></pre><p>잊지말고 <code>.gitlgnore</code>에 해당 파일경로를 등록해주세요!</p>
<pre><code># 구글 애널리틱스 서비스 키
/secrets/service-account-key.json</code></pre><h3 id="3-google-analytics-data-api-클라이언트-설정">3. Google Analytics Data API 클라이언트 설정</h3>
<p>API 호출을 위한 코드입니다. getAnalyticsData 함수의 requestBody에서 dateRanges와 metrics는 필요한 데이터에 따라 변경하시면 됩니다.</p>
<p>저는 누적 방문객을 얻고싶어서 totalUsers으로 했습니다.</p>
<p>reponse의 property: &#39;properties/속성ID(숫자)&#39;에서 속성 ID는 구글 애널리틱스 설정 -&gt; 속성 설정 -&gt; 속성 -&gt; 속성 세부정보에서 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/e308f5e2-263e-4431-b368-07b0022f73f7/image.png" alt=""></p>
<p><code>lib/getAnalyticsData.ts</code></p>
<pre><code class="language-jsx">import { google } from &#39;googleapis&#39;
import path from &#39;path&#39;

// 서비스 계정 키 파일 경로 설정
const keyFilePath = path.join(process.cwd(), process.env.GOOGLE_SERVICE_KEY_PATH!)

// 인증 클라이언트 생성
const auth = new google.auth.GoogleAuth({
  keyFile: keyFilePath,
  scopes: [&#39;https://www.googleapis.com/auth/analytics.readonly&#39;],
})

const analyticsData = google.analyticsdata(&#39;v1beta&#39;)

export async function getAnalyticsData() {
  try {
    const authClient = await auth.getClient()

    const response = await analyticsData.properties.runReport({
      auth: authClient,
      properties/YOUR_PROPERTY_ID, 
      // 속성 ID를 직접 파라미터로 전달
      requestBody: {
        dateRanges: [
          {
            startDate: &#39;2024-11-04&#39;,
            endDate: &#39;today&#39;,
          },
        ],
        metrics: [
          {
            name: &#39;totalUsers&#39;,
          },
        ],
      },
    })

    return response.data
  } catch (error) {
    console.error(&#39;Error fetching Analytics Data:&#39;, error)
    throw error
  }
}

// app/page.tsx
export default async function Start() {
  try {
    const data = await getAnalyticsData()
    console.log(&#39;Analytics Data:&#39;, data)

    return (
      &lt;div&gt;
        {/* data를 사용하여 UI 렌더링 */}
        &lt;pre&gt;{JSON.stringify(data, null, 2)}&lt;/pre&gt;
      &lt;/div&gt;
    )
  } catch (error) {
    console.error(&#39;Error in page:&#39;, error)
    return &lt;div&gt;Error loading analytics data&lt;/div&gt;
  }
}</code></pre>
<p>호출한 결과를 보면 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/81321cba-8752-49fe-a9bb-21aacddfee2b/image.png" alt=""></p>
<h3 id="4-api-호출-시-필요한-주요-값">4. API 호출 시 필요한 주요 값</h3>
<ul>
<li><strong><code>YOUR_PROPERTY_ID</code></strong>: GA4의 속성 ID. GA4 계정에서 찾을 수 있습니다. 보통 <code>properties/XXXXXX</code> 형태입니다.</li>
<li><strong><code>activeUsers</code></strong>: 요청하려는 데이터의 메트릭입니다. 다른 메트릭도 사용 가능합니다. 예: <code>sessions</code>, <code>users</code> 등.</li>
<li><strong><code>dateRanges</code></strong>: 데이터를 요청할 날짜 범위입니다. 예: 최근 7일(<code>&#39;7daysAgo&#39;</code>)부터 오늘까지(<code>&#39;today&#39;</code>).</li>
</ul>
<h4 id="ga4의-메트릭-설명">GA4의 메트릭 설명</h4>
<ul>
<li>activeUsers: 현재 활성 사용자 수 (지정된 기간 동안 실제로 사이트와 상호작용한 사용자 수)</li>
<li>totalUsers: 지정된 기간 동안의 총 방문자 수 (중복 제외)</li>
</ul>
<h4 id="주요-메트릭-활용">주요 메트릭 활용</h4>
<ul>
<li>totalUsers와 activeUsers를 비교하여 사이트의 전반적인 활성도 파악</li>
<li>sessions와 totalUsers를 비교하여 재방문율 추정</li>
<li>averageSessionDuration으로 사용자 참여도 측정</li>
<li>screenPageViews로 전체적인 사이트 트래픽 파악</li>
</ul>
<h3 id="여러-메트릭-동시-조회와-사용-예시">여러 메트릭 동시 조회와 사용 예시</h3>
<pre><code class="language-jsx">export async function getAnalyticsData() {
  try {
    const authClient = await auth.getClient()

    const response = await analyticsData.properties.runReport({
      auth: authClient,
      property: &#39;properties/466314451&#39;,
      requestBody: {
        dateRanges: [
          {
            startDate: &#39;2024-01-01&#39;,
            endDate: &#39;today&#39;,
          },
        ],
        metrics: [
          { name: &#39;totalUsers&#39; },
          { name: &#39;activeUsers&#39; },
          // 추가로 유용한 메트릭들
          { name: &#39;sessions&#39; },           // 세션 수
          { name: &#39;averageSessionDuration&#39; },  // 평균 세션 지속 시간
          { name: &#39;screenPageViews&#39; }     // 총 페이지뷰 수
        ],
      },
    })

    // 응답 데이터 처리 예시
    const metrics = response.data.rows[0].metricValues
    const analyticsData = {
      totalUsers: metrics[0].value,
      activeUsers: metrics[1].value,
      sessions: metrics[2].value,
      avgSessionDuration: metrics[3].value,
      pageViews: metrics[4].value,
    }

    return analyticsData
  } catch (error) {
    console.error(&#39;Error fetching Analytics Data:&#39;, error)
    throw error
  }
}</code></pre>
<pre><code class="language-jsx">// 서버 컴포넌트
export default async function Start() {
  const data = await getAnalyticsData()
  return &lt;Analytics analyticsData={data} /&gt;
}

// 클라이언트 컴포넌트
&#39;use client&#39;

type AnalyticsData = {
  totalUsers: string
  activeUsers: string
  sessions: string
  avgSessionDuration: string
  pageViews: string
}

export function Analytics({ analyticsData }: { analyticsData: AnalyticsData }) {
  return (
    &lt;div className=&quot;space-y-4&quot;&gt;
      &lt;div&gt;총 방문자 수: {analyticsData.totalUsers}명&lt;/div&gt;
      &lt;div&gt;현재 활성 사용자: {analyticsData.activeUsers}명&lt;/div&gt;
      &lt;div&gt;총 세션 수: {analyticsData.sessions}&lt;/div&gt;
      &lt;div&gt;평균 세션 시간: {Math.round(Number(analyticsData.avgSessionDuration) / 60)}분&lt;/div&gt;
      &lt;div&gt;총 페이지뷰: {analyticsData.pageViews}&lt;/div&gt;
    &lt;/div&gt;
  )
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 구글 애널리틱스 연동 - 1]]></title>
            <link>https://velog.io/@hisy4429_sun/Next.js-%EA%B5%AC%EA%B8%80-%EC%95%A0%EB%84%90%EB%A6%AC%ED%8B%B1%EC%8A%A4-%EC%97%B0%EB%8F%99-1</link>
            <guid>https://velog.io/@hisy4429_sun/Next.js-%EA%B5%AC%EA%B8%80-%EC%95%A0%EB%84%90%EB%A6%AC%ED%8B%B1%EC%8A%A4-%EC%97%B0%EB%8F%99-1</guid>
            <pubDate>Fri, 08 Nov 2024 03:32:46 GMT</pubDate>
            <description><![CDATA[<p>점심 메뉴 추천 서비스의 사이드 프로젝트를 기획 &amp; 개발을 시작했습니다. 다양한 사용자들이 범용적으로 사용할 수 있는 주제여서, 검색에도 노출되게 하고 주변 지인들에게도 소개해서 사용을 유도하려고 계획했습니다. 그렇기에 실제 사용자들의 결과를 살펴보는 것이 유의미할 것으로 기대되어 구글 애널리틱스 연동을 고려하게 되었습니다.</p>
<p>먼저 프로젝트를 배포한 주소를 이용해서 구글 애널리틱스 사이트에 배포한 웹사이트를 등록해야 합니다.</p>
<h2 id="설정-과정">설정 과정</h2>
<h3 id="1-google-analytics-가입">1. Google Analytics 가입</h3>
<p><a href="https://analytics.google.com">https://analytics.google.com</a> 사이트에서 가입할 수 있습니다.
제 경우는 계정 생성의 4번째 단계인 비즈니스 목표를 트래픽과 사용자 참여 및 유지를 선택했습니다.</p>
<ul>
<li><strong>트래픽</strong>: 사용자가 어떤 경로로 사이트에 유입되는지(예: 검색, 직접 방문, 외부 링크 등)를 파악할 수 있습니다. SEO를 통해 검색 유입을 늘리려는 목표가 있으므로 유입 경로와 관련된 데이터를 활용하는 것이 중요합니다.</li>
<li><strong>사용자 참여 및 유지</strong>: 사용자가 사이트에서 어떤 방식으로 메뉴를 탐색하는지, 추천 메뉴에 대한 관심도(예: 클릭한 메뉴, 머문 시간 등)를 확인할 수 있습니다. 이러한 데이터는 사용자들이 어떤 메뉴에 관심을 가지는지 분석하고 개선하는 데 도움이 됩니다.</li>
</ul>
<p>계정 생성을 마치면 G-로 시작하는 측정 ID를 받습니다</p>
<h3 id="2-프로젝트에-적용">2. 프로젝트에 적용</h3>
<p>Google 태그 설정 기초 안내는 다음과 같습니다.</p>
<pre><code>&lt;!-- Google tag (gtag.js) --&gt;
&lt;script async src=&quot;https://www.googletagmanager.com/gtag/js?id=G-ABCDEABCDE&quot;&gt;&lt;/script&gt;
&lt;script&gt;
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag(&#39;js&#39;, new Date());

  gtag(&#39;config&#39;, &#39;G-ZH51XEQZ0F&#39;);
&lt;/script&gt;</code></pre><p>현재 프로젝트가 Next.js 이므로 <code>next/script</code> 에서 제공하는 Script 컴포넌트를 사용할 수 있습니다.</p>
<pre><code>src/lib

import Script from &#39;next/script&#39;

const GoogleAnalytics = ({ gaId }: { gaId: string }) =&gt; {
  return (
    &lt;&gt;
      &lt;Script async src=&#39;https://www.googletagmanager.com/gtag/js?id=G-ABCDEABCDE&#39; /&gt;
      &lt;Script
        id=&#39;google-analytics&#39;
        dangerouslySetInnerHTML={{
          __html: `
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag(&#39;js&#39;, new Date());
          gtag(&#39;config&#39;, &#39;${gaId}&#39;);`,
        }}
      /&gt;
    &lt;/&gt;
  )
}

export default GoogleAnalytics
</code></pre><ul>
<li>G-ABCDEABCDE는 예시입니다.</li>
</ul>
<p>루트 레이아웃 <code>layout.tsx</code> 에 GoogleAnalytics 컴포넌트 적용합니다.</p>
<pre><code>export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode
}&gt;) {
  return (
    &lt;html lang=&#39;ko&#39;&gt;
      &lt;body className={`${pretendard.className} ${paperlogy.variable} antialiased`}&gt;
        {process.env.NEXT_PUBLIC_GA_ID 
        &amp;&amp; &lt;GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} /&gt;}

        &lt;main&gt;{children}&lt;/main&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre><h3 id="3-확인하기">3. 확인하기</h3>
<p>브라우저에 console.log(dataLayer)를 입력하면 아래와 같이 출력되면 적용된 것입니다.</p>
<p>(이미지 추가 예정)</p>
<p>그 다음 게시물은 구글 애널리틱스 API를 연결하고 사용하는 방법입니다.</p>
<hr>
<p>참고한 게시물
<a href="https://velog.io/@newjin46/Next.js-13.4-Google-Analytics%EB%A5%BC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%B6%94%EA%B0%80%ED%95%98%EA%B3%A0-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%8C%A8%ED%84%B4-%EB%B6%84%EC%84%9D%ED%95%98%EA%B8%B0">Google Analytics를 프로젝트에 추가하고...</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Gitlab에 ssh key 추가하기]]></title>
            <link>https://velog.io/@hisy4429_sun/Gitlab%EC%97%90-ssh-key-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/Gitlab%EC%97%90-ssh-key-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 05 Aug 2024 11:54:52 GMT</pubDate>
            <description><![CDATA[<h2 id="ssh-키-생성-및-사용">SSH 키 생성 및 사용</h2>
<h3 id="ssh란-무엇인가">SSH란 무엇인가?</h3>
<p>SSH(Secure Shell)는 네트워크 상에서 안전하게 데이터를 교환하기 위한 프로토콜입니다. 특히 원격 서버에 로그인하거나 데이터를 전송할 때 보안을 강화합니다.</p>
<p>SSH 키는 비밀번호 대신 사용될 수 있는 인증 방식입니다. 이는 두 개의 키로 구성된 공개 키 암호화를 사용합니다.</p>
<h3 id="ssh-키-쌍이란">SSH 키 쌍이란?</h3>
<ul>
<li><p>공개 키 (Public Key): 서버에 저장됩니다. 이 키는 누구나 볼 수 있으며, 서버는 이를 사용해 클라이언트의 신원을 확인합니다.</p>
</li>
<li><p>개인 키 (Private Key): 로컬 컴퓨터에 안전하게 저장됩니다. 이 키는 비밀로 유지되며, 클라이언트가 자신임을 증명하는 데 사용됩니다.</p>
</li>
</ul>
<h2 id="데스크탑-윈도우에서-gitlab-ssh-key-만들기">데스크탑 윈도우에서 Gitlab SSH key 만들기</h2>
<p>PowerShell를 관리자로 실행하기로 띄워서 아래의 명령어를 입력</p>
<h3 id="ssh-키-생성하기">SSH 키 생성하기</h3>
<pre><code>ssh-keygen -t rsa -b 4096 -C &quot;your_email@example.com&quot;</code></pre><ul>
<li><code>your_email@example.com</code>은 GitLab 계정에 등록된 이메일 주소</li>
<li>이 명령어는 ~/.ssh/ 디렉토리에 id_rsa (개인 키)와 id_rsa.pub (공개 키) 파일을 생성함</li>
<li>키 생성 과정에서 저장 경로를 필요에 따라 설정할 수 있으며, 그냥 엔터키를 계속 누르면 기본 폴더에 만들어짐</li>
</ul>
<h4 id="4096은-무슨-뜻인가">4096은 무슨 뜻인가?</h4>
<ul>
<li>4096이나 2048은 생성할 키의 비트 길이를 지정하는 것</li>
<li>2048-bit는 키 생성과 인증 과정이 4096-bit 키 보다 빠르며 일반적으로 많이 쓰임</li>
</ul>
<h3 id="공개-키-확인하기">공개 키 확인하기</h3>
<pre><code>cat $env:USERPROFILE\.ssh\id_rsa.pub</code></pre><ul>
<li>ssh-rsa로 시작하는 key 값이 나옴</li>
<li>git lab의 ssh키에 추가하기</li>
</ul>
<h3 id="gitlab에-ssh-키-추가">GitLab에 SSH 키 추가</h3>
<ul>
<li>User settings의 SSH Keys로 이동하여 add new key를 선택</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/12b83b37-d280-4dcf-858e-1faad8da0d88/image.png" alt=""></p>
<h2 id="ssh-키-에이전트에-추가">SSH 키 에이전트에 추가</h2>
<p>SSH 키 에이전트는 SSH 연결 세션 동안 개인 키를 메모리에 저장하여 여러 번의 인증을 요구하지 않게 해주는 프로그램입니다. 이를 통해 한 번의 인증으로 여러 번의 SSH 연결을 처리할 수 있습니다. SSH 키를 에이전트에 추가하면, SSH 명령어를 사용할 때마다 비밀번호를 입력하지 않고도 인증할 수 있습니다.</p>
<h3 id="ssh-키-에이전트에-개인-키-추가-방법">SSH 키 에이전트에 개인 키 추가 방법</h3>
<h4 id="1-ssh-에어전트-시작">1. SSH 에어전트 시작</h4>
<ul>
<li>Windows PowerShell에서 SSH 에이전트를 시작하는 명령어<pre><code>Start-Service ssh-agent</code></pre></li>
</ul>
<h4 id="2-ssh-키-에이전트에-개인-키-추가">2. SSH 키 에이전트에 개인 키 추가</h4>
<p>개인 키를 SSH 에이전트에 추가하는 명령어</p>
<pre><code>ssh-add $env:USERPROFILE\.ssh\id_rsa</code></pre><p>이렇게 하면 개인 키가 SSH 에이전트에 추가되어, SSH 연결을 할 때마다 자동으로 인증됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/8575163b-11f2-4a97-be4f-e15b8aacba20/image.png" alt=""></p>
<h2 id="ssh-에어전트-명령어-오류">SSH 에어전트 명령어 오류</h2>
<p>윈도우에서 ssh-agent 서비스가 설정이 안되어 있으면 아래와 같은 오류가 발생할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/5a160a51-8f5e-47b0-9ce0-05db0e0c8873/image.png" alt=""></p>
<h3 id="해결하기">해결하기</h3>
<h4 id="1-openssh-client-기능-활성화">1. OpenSSH Client 기능 활성화</h4>
<p>먼저 Windows 기능에서 OpenSSH Client와 OpenSSH Server가 활성화되어 있는지 확인해야 합니다.</p>
<ol>
<li>Windows 설정을 엽니다.</li>
<li>앱을 선택합니다.</li>
<li>선택적 기능을 클릭합니다.</li>
<li>OpenSSH Client와 OpenSSH Server가 설치되어 있는지 확인합니다. 설치되어 있지 않다면, 기능 추가를 클릭하고 해당 기능을 추가합니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/5983371e-d4ee-4f24-8083-128a2b74d806/image.png" alt=""></p>
<h4 id="2-ssh-agent-서비스-수동으로-시작">2. ssh-agent 서비스 수동으로 시작</h4>
<ul>
<li>윈도우 + R 버튼을 누르고 <code>services.msc</code>를 입력 후 확인을 눌러서 서비스 관리 도구를 엽니다.</li>
<li>OpenSSH Authentication Agent 서비스를 찾아 시작합니다.</li>
<li>목록에서 OpenSSH Authentication Agent를 찾아 선택한 후, 오른쪽 클릭하고 시작을 선택합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/3fa77f7d-2295-4ffd-a0c5-1c2adb0cb8fd/image.png" alt=""></p>
<p>관련 문서: <a href="https://learn.microsoft.com/ko-kr/windows-server/administration/openssh/openssh_install_firstuse?tabs=gui">Windows용 OpenSSH 시작</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Firebase와 Next.js를 활용한 비동기 작업 오류 로깅]]></title>
            <link>https://velog.io/@hisy4429_sun/Firebase%EC%99%80-Next.js%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85-%EC%98%A4%EB%A5%98-%EB%A1%9C%EA%B9%85</link>
            <guid>https://velog.io/@hisy4429_sun/Firebase%EC%99%80-Next.js%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85-%EC%98%A4%EB%A5%98-%EB%A1%9C%EA%B9%85</guid>
            <pubDate>Tue, 16 Jul 2024 16:08:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Firebase Realtime Database를 사용하여 알림을 생성하고, 비동기 처리 시 발생할 수 있는 에러를 Firestore DB에 기록하였습니다. 이 작업으로 비동기 작업에서 발생할 수 있는 에러를 기록하고 디버깅에 활용하는 방법을 구현해보았습니다.</p>
</blockquote>
<h2 id="프로젝트-환경-설명">프로젝트 환경 설명</h2>
<ul>
<li><p>프로젝트 환경: Next.js 14 App Router 사용</p>
</li>
<li><p>알림 생성 트리거: 게시물이 업로드될 때 Firebase Realtime Database에 알림이 생성됩니다.</p>
</li>
<li><p>알림 생성 로직: 알림 생성 로직은 Next.js API 엔드포인트에 작성되어 있습니다. (create-notification)</p>
</li>
<li><p>에러 로깅 목적: 알림 생성 비동기 작업에 오류가 발생하면 Firestore의 error_logs 컬렉션에 오류 내용을 저장하기 위함입니다.</p>
</li>
</ul>
<h2 id="에러-로깅을-추가-하게-된-이유">에러 로깅을 추가 하게 된 이유</h2>
<p><strong>forEach -&gt; Promise.allSettled를 사용한 비동기 처리</strong></p>
<p>프로젝트에서 유저들에게 알림을 생성하는 기능을 구현했습니다.</p>
<p>처음에는 단순히 유저들의 정보를 가져와 forEach로 처리했으나, 비동기 작업의 안정성을 고려하여 Promise.allSettled를 사용하도록 변경했습니다. </p>
<p>또한, 알림 생성 중에 발생할 수 있는 실패 케이스를 대비하여 에러를 기록하는 기능을 추가하게 되었습니다.</p>
<h3 id="기존의-알림-생성-코드">기존의 알림 생성 코드</h3>
<p><code>src/app/api/create-notification/route.ts</code></p>
<pre><code class="language-jsx">// 중략
export const POST = async (request: Request) =&gt; {
  try {
    const { title, body } = await request.json()

    const usersSnapshot = await getDocs(collection(firestore, &#39;users&#39;))
    if (!usersSnapshot.empty) {
      const timestamp = Date.now()

      usersSnapshot.forEach(async (doc) =&gt; {
        const uid = doc.id // user의 uid
        const newNotificationRef = ref(database, `notifications/${uid}/${timestamp}`)
        await set(newNotificationRef, {
          title,
          body,
          timestamp,
          read: false,
        })
      })</code></pre>
<h3 id="promiseallsettled-적용">Promise.allSettled 적용</h3>
<pre><code class="language-jsx">export const POST = async (request: Request) =&gt; {
  try {
    const { title, body } = await request.json()

    // 오류 테스트를 위해 추가한 유효성 검사
    if (!title || !body || typeof title !== &#39;string&#39; || typeof body !== &#39;string&#39;) {
      const error = new Error(&#39;Invalid data: title and body are required and must be strings&#39;)
      throw error // 유효성 검사 실패 에러 (1)
    }

    const usersSnapshot = await getDocs(collection(firestore, &#39;users&#39;))
    if (!usersSnapshot.empty) {
      const timestamp = Date.now()

      const notificationPromises = usersSnapshot.docs.map((doc) =&gt; {
        const uid = doc.id
        const newNotificationRef = ref(database, `notifications/${uid}/${timestamp}`)
        return set(newNotificationRef, {
          title,
          body,
          timestamp,
          read: false,
        }).catch(async (error) =&gt; {
          throw error // 에러 (2)
        })
      })

      await Promise.allSettled(notificationPromises)

      return NextResponse.json({ message: &#39;Notifications created successfully&#39; })
    } else {
      return NextResponse.json({ error: &#39;No users found&#39; }, { status: 404 })
    }
  } catch // 생략</code></pre>
<h3 id="에러-로그-기록-추가">에러 로그 기록 추가</h3>
<p>알림 생성 중에 발생할 수 있는 에러를 기록하기 위해 Firestore에 error_logs 컬렉션을 추가했습니다.</p>
<p>에러가 발생하면 해당 정보를 /api/log 엔드포인트로 전송하여 기록합니다.</p>
<pre><code class="language-jsx">// Promise.allSettled 적용 부분 코드의 뒷부분

catch (error: unknown) {
    // 타입 가드 사용과 throw error 처리
    if (error instanceof Error) {
      // 에러 시 로그 남기기
      const apiUrl = process.env.NEXT_PUBLIC_API_URL
      await fetch(`${apiUrl}/api/log`, {
        method: &#39;POST&#39;,
        headers: {
          &#39;Content-Type&#39;: &#39;application/json&#39;,
        },
        body: JSON.stringify({
          error: error.message,
          stack: error.stack,
        }),
      }).catch((logError) =&gt; {
        console.error(&#39;Error logging notification error:&#39;, logError)
      })

      console.error(&#39;Error creating notifications:&#39;, error)
      return NextResponse.json({ error: &#39;Error creating notifications&#39; }, { status: 500 })
    } else {
      // Error 객체가 아닌 경우
      console.error(&#39;Unknown error&#39;, error)
      return NextResponse.json({ error: &#39;Unknown error&#39; }, { status: 500 })
    }
  }</code></pre>
<h3 id="fetch-경로-문제">fetch 경로 문제</h3>
<p>처음에는 fetch 요청에서 상대 경로를 사용했으나, 로컬 환경에서는 절대 경로를 사용해야 한다는 문제를 발견했습니다. 이에 따라 fetch 요청을 수정하여 절대 경로를 사용하도록 하였습니다.</p>
<p><strong>에러 메세지</strong></p>
<pre><code>Error logging notification error: TypeError: Failed to parse URL from /api/log
</code></pre><p><strong>문제 해결</strong>
next api의 엔드포인트 fetch 요청에서 절대 경로를 사용하도록 수정하였습니다.
<code>src/app/api/create-notification/route.ts</code></p>
<pre><code class="language-jsx">await fetch(&#39;http://localhost:3000/api/log&#39;, { // 절대 경로 사용
  method: &#39;POST&#39;,
  headers: {
    &#39;Content-Type&#39;: &#39;application/json&#39;,
  },
  body: JSON.stringify({
    uid: &#39;unknown&#39;,
    error: error.message,
    stack: error.stack,
  }),
});</code></pre>
<hr>
<h2 id="결과-확인하기">결과 확인하기</h2>
<p>테스트 API 작업은 포스트맨을 이용했습니다. 유효성에 걸리게 body의 값을 비우고 POST 요청을 보냅니다.
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/a10c7faf-86e8-4477-a156-23739f8ec015/image.png" alt=""></p>
<p>Firestore의 error_logs 컬렉션에 오류 로그가 추가된 것이 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/4c428579-5c39-4f3e-8c86-cf5beed98f80/image.png" alt=""></p>
<p>해당 에러 시 서버 콘솔입니다.
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/706ea47c-18dc-48c9-a343-7849ac979b7b/image.png" alt="">
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/ba682465-11e3-445f-af20-46de4b95e553/image.png" alt=""></p>
<h3 id="스택-트레이스를-통한-오류-분석">스택 트레이스를 통한 오류 분석</h3>
<p>에러 로그에 stack 필드를 추가하여 에러 발생 위치와 경로를 추적할 수 있게 되었습니다. 예를 들어, POST 요청에서 body 필드를 아예 빼고 전송했을 때, 다음과 같은 스택 트레이스를 통해 문제를 분석할 수 있습니다.</p>
<pre><code>SyntaxError: Unexpected token } in JSON at position 25
    at JSON.parse (&lt;anonymous&gt;)
    at parseJSONFromBytes (node:internal/deps/undici/undici:5584:19)
    at successSteps (node:internal/deps/undici/undici:5555:27)
    at fullyReadBody (node:internal/deps/undici/undici:1665:9)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async POST (webpack-internal:///(rsc)/./src/app/api/create-notification/route.ts:16:33)
    ...</code></pre><ul>
<li>마지막 줄을 보면 오류 발생 위치를 알 수 있습니다.</li>
<li>유효성 검사 부분에서 body가 없어서 발생한 에러입니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions 워크플로우에 Cypress 테스트 추가하기]]></title>
            <link>https://velog.io/@hisy4429_sun/GitHub-Actions-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0%EC%97%90-Cypress-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/GitHub-Actions-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0%EC%97%90-Cypress-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 15 Jul 2024 17:09:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번에 E2E 테스트 코드를 처음 적용해보면서, Cypress를 사용하여 자동화된 테스트를 GitHub Actions 워크플로우에 추가해봤습니다. 
이를 통해 테스트 코드의 완성도 있는 과정을 체험하고, CI/CD 파이프라인에서의 자동화된 테스트 실행을 경험했습니다.</p>
</blockquote>
<p>이전 게시물에서 Next.js 프로젝트에서 Cypress를 사용하여 업로드 페이지에 E2E 테스트를 적용했습니다.</p>
<p>이를 GitHub Actions 워크플로우에 통합하는 과정을 소개합니다.</p>
<h2 id="파일-변경-사항">파일 변경 사항</h2>
<h3 id="스크립트-추가하기">스크립트 추가하기</h3>
<p><code>package.json</code> 파일에 스크립트를 추가합니다.</p>
<p><strong>test:ci 스크립트 추가 이유:</strong></p>
<p><code>cypress open</code>은 GUI 모드로 Cypress 테스트를 실행하지만, CI/CD 환경에서는 CLI 모드로 테스트를 실행하는 것이 더 적합합니다.
<code>cypress run</code>을 사용하는 <code>test:ci</code> 스크립트를 추가하여, 자동화된 환경에서 테스트를 실행할 수 있도록 했습니다.</p>
<pre><code class="language-jsx">{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;cypress open&quot;,
    &quot;test:ci&quot;: &quot;cypress run&quot;
  }
}</code></pre>
<h3 id="github-actions-워크플로우-파일-작성">GitHub Actions 워크플로우 파일 작성</h3>
<p><code>.github/workflows/cypress.yml</code> 파일을 생성합니다. 다음은 최종적으로 적용된 코드 입니다.</p>
<pre><code class="language-jsx">name: Cypress Tests

on: [pull_request]

jobs:
  cypress-run:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: &#39;18&#39;

      - name: Install dependencies
        run: npm install

      - name: Build application
        run: npm run build
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}

      - name: Start application
        run: |
          npm run start &amp;&gt; server.log &amp;
          sleep 10  # 10초 대기 후
          cat server.log  # 로그 출력
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}

      - name: Wait for application to be ready
        run: npx wait-on http://localhost:3000 --timeout 300000

      - name: Check application status
        run: curl -I http://localhost:3000

      - name: Run Cypress tests
        run: npm run test:ci
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}
</code></pre>
<h2 id="에러-사항">에러 사항</h2>
<h3 id="nodejs-버전-문제">Node.js 버전 문제</h3>
<p>처음에는 노드 버전을 14로 했어서 워크플로우 중 버전 에러가 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/384232ad-4f56-45ec-a280-4978fde76996/image.png" alt=""></p>
<pre><code class="language-jsx">// 중략
- name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: &#39;18&#39;</code></pre>
<ul>
<li>node-version을 18 버전으로 수정</li>
</ul>
<h3 id="production-build-누락">Production Build 누락</h3>
<p>next start 명령어가 production build를 필요로 했지만, next build가 실행되지 않았습니다. </p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/a468ea89-21cd-4fc8-ab1f-a275f53e1a24/image.png" alt=""></p>
<pre><code class="language-jsx">// 중략
- name: Install dependencies
        run: npm install

- name: Build application
        run: npm run build</code></pre>
<ul>
<li>npm install 하단에 npm run build 명령어 추가</li>
</ul>
<h3 id="환경-변수-api-키-문제">환경 변수 API 키 문제</h3>
<p>CI/CD 환경에서 Firebase API 키가 설정되지 않아 auth/invalid-api-key 오류가 발생했습니다.</p>
<p>레포지토리 설정에서 GitHub Secrets에 환경 변수를 설정하여도 제대로 주입되지 않는 이슈가 있었습니다.</p>
<p>각 스텝에 env 설정을 추가하여 환경 변수를 명시적으로 설정했습니다.</p>
<pre><code class="language-jsx">// 일부 생략
- name: Build application
        run: npm run build
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}

      - name: Start application
        run: |
          npm run start &amp;&gt; server.log &amp;
          sleep 10  # 10초 대기 후
          cat server.log  # 로그 출력
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}

- name: Run Cypress tests
        run: npm run test:ci
        env:
          NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
          NEXT_PUBLIC_KAKAO_MAP_CLIENT: ${{ secrets.NEXT_PUBLIC_KAKAO_MAP_CLIENT }}
</code></pre>
<p>다음 명령어에 env 설정 추가</p>
<ul>
<li>npm run build</li>
<li>npm run start</li>
<li>npm run test:ci</li>
</ul>
<h2 id="적용-결과">적용 결과</h2>
<ul>
<li>GitHub Actions에서 Cypress 테스트가 자동으로 실행되며, 모든 테스트가 성공적으로 통과되었습니다.</li>
<li>빌드 및 테스트 과정에서 발생한 문제들을 해결하여 CI/CD 파이프라인의 신뢰성을 높였습니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/60982f35-1c75-4262-8c4e-83ce5f12019a/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[업로드 페이지에서 Cypress를 사용한 E2E 테스트]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%97%85%EB%A1%9C%EB%93%9C-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90%EC%84%9C-Cypress%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%97%85%EB%A1%9C%EB%93%9C-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90%EC%84%9C-Cypress%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 15 Jul 2024 12:43:47 GMT</pubDate>
            <description><![CDATA[<h2 id="cypress로-업로드-페이지-테스트하기">Cypress로 업로드 페이지 테스트하기</h2>
<p>Next.js 환경에서 개발한 업로드 페이지에 Cypress를 이용해 E2E(End-to-End) 테스트를 구현했습니다.</p>
<p>이 테스트의 주요 목표는 업로드 페이지의 기본 렌더링부터 사진 등록, 장소 검색, 설명 입력, 그리고 최종적으로 등록 버튼이 활성화되는지 확인하는 전체 작업 흐름을 검증하는 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/92e86b5c-dc43-4eae-a8ec-2d600932cca9/image.png" alt=""></p>
<p>테스트 코드를 적용하기 전 유저의 플로우를 예상해보고 정리했습니다.</p>
<h2 id="테스트코드-적용-미리보기">테스트코드 적용 미리보기</h2>
<h3 id="1-업로드-페이지의-모든-필드-입력과-버튼-활성화-확인하기">1. 업로드 페이지의 모든 필드 입력과 버튼 활성화 확인하기</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/cc956831-bef0-4aac-9d25-275a530e5250/image.gif" alt=""></p>
<ul>
<li>업로드 페이지에서 모든 필드를 올바르게 입력하면, 등록 버튼이 활성화되는지를 확인합니다.</li>
<li>Cypress 테스트 환경에서는 실제로 이미지를 업로드하는 대신, img 태그가 생성되어 미리보기가 올바르게 표시되는지만 확인합니다.</li>
<li>이 테스트는 사용자가 모든 필드를 입력했을 때만 폼이 제출 가능하도록 보장합니다.</li>
</ul>
<pre><code class="language-jsx">describe(&#39;업로드 페이지의 기본 렌더링 및 입력과 버튼 검증 확인하기&#39;, () =&gt; {
  it(&#39;사진 등록, 검색 모달창 입력, 설명 입력하면 등록 버튼이 활성화 된다.&#39;, () =&gt; {
    cy.visit(&#39;/place/upload&#39;)

    // 제목과 Form 렌더링 확인하기
    cy.get(&#39;[data-cy=&quot;upload-title&quot;]&#39;)
    cy.get(&#39;form&#39;).should(&#39;be.visible&#39;)

    // 사진 등록 시 미리보기가 보인다.
    const imagePath = &#39;testimage.PNG&#39; // cypress/fixtures에 정적 이미지 추가
    cy.get(&#39;input[type=&quot;file&quot;]&#39;).attachFile(imagePath)
    cy.get(&#39;img&#39;).should(&#39;be.visible&#39;)

    // 검색 모달창에서 입력하고 그 결과를 선택해서 업로드 폼에 다시 돌아온다.
    cy.get(&#39;[data-cy=&quot;right-arrow-icon&quot;]&#39;).click()
    cy.get(&#39;[data-cy=&quot;search-modal&quot;]&#39;).should(&#39;be.visible&#39;)
    cy.get(&#39;[data-cy=&quot;search-modal-input&quot;]&#39;).type(&#39;카페베네&#39;)
    cy.get(&#39;[data-cy=&quot;search-modal-item&quot;]&#39;).first().click()
    cy.get(&#39;[data-cy=&quot;upload-form-input&quot;]&#39;).should(&#39;have.value&#39;, &#39;카페베네&#39;)

    // 꿀플 노트 입력
    cy.get(&#39;[data-cy=&quot;upload-form-textarea&quot;]&#39;).type(
      &#39;정말 멋진 카페였습니다. 분위기도 좋고 커피도 맛있어요.&#39;
    )

    // fetch 요청 모킹
    cy.intercept(&#39;POST&#39;, &#39;/api/create-notification&#39;, {
      statusCode: 200,
      body: { success: true },
    }).as(&#39;createNotification&#39;)

    // 등록 버튼이 활성화된다.
    cy.get(&#39;[data-cy=&quot;upload-btn&quot;]&#39;).should(&#39;not.be.disabled&#39;)
  })
})</code></pre>
<h3 id="2-다녀온-플레이스-이후의-페이지-이동-확인하기">2. 다녀온 플레이스 이후의 페이지 이동 확인하기</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/840bfb03-851a-4438-a27c-922e21357908/image.gif" alt=""></p>
<p>사용자가 다녀온 플레이스를 입력하기 위해 검색 모달창을 열고, 새로운 플레이스를 추가하는 과정을 테스트합니다.</p>
<ul>
<li><p>다녀온 플레이스를 입력하려면 검색 모달창이 뜹니다.</p>
</li>
<li><p>그 이후 새로운 플레이스를 추가하면 <code>newplace</code>와 <code>map</code> 페이지를 거쳐서 <code>upload</code> 페이지로 돌아옵니다.</p>
</li>
<li><p>이 테스트는 새로운 플레이스를 추가하는 복잡한 과정을 검증하기 위해 깊이가 3번 이상 되는 단계를 포함합니다.</p>
</li>
<li><p>단계별로 페이지가 이동하는지, 입력한 값이 올바르게 반영되는지를 확인하여 기능의 정상 동작을 보장합니다.</p>
</li>
</ul>
<pre><code class="language-jsx">// describe 생략
it(&#39;모달창에서 새로운 꿀플레이스 추가하기를 누른 후 단계별 페이지 이동을 거쳐서 업로드 페이지로 돌아온다.&#39;, () =&gt; {
    cy.visit(&#39;/place/upload&#39;)

    // 모달창 띄우기
    cy.get(&#39;[data-cy=&quot;right-arrow-icon&quot;]&#39;).click()
    cy.get(&#39;[data-cy=&quot;search-modal&quot;]&#39;).should(&#39;be.visible&#39;)
    cy.get(&#39;[data-cy=&quot;search-modal-input&quot;]&#39;).type(&#39;새로운 가게&#39;)
    cy.get(&#39;[data-cy=&quot;add-new-place-btn&quot;]&#39;).click()

    // 새로운 꿀플레이스 추가 페이지로 이동 확인
    cy.url().should(&#39;include&#39;, &#39;/place/newplace&#39;)
    cy.get(&#39;[data-cy=&quot;newplace-name-input&quot;]&#39;).should(&#39;have.value&#39;, &#39;새로운 가게&#39;)

    // 지도 페이지로 이동
    cy.get(&#39;[data-cy=&quot;map-page-link&quot;]&#39;).click()
    cy.url().should(&#39;include&#39;, &#39;/place/map&#39;)

    // 지도 페이지에서 기본 주소 확인 및 위치 등록 (초기 주소값)
    cy.get(&#39;p&#39;).contains(&#39;서울특별시 중구 세종대로 110&#39;).should(&#39;be.visible&#39;)
    cy.get(&#39;[data-cy=&quot;map-btn&quot;]&#39;).click()

    // 새로운 꿀플레이스 추가 페이지로 돌아옴
    cy.url().should(&#39;include&#39;, &#39;/place/newplace&#39;)
    cy.get(&#39;[data-cy=&quot;newplace-name-input&quot;]&#39;).should(&#39;have.value&#39;, &#39;새로운 가게&#39;)
    cy.get(&#39;[data-cy=&quot;newplace-address-input&quot;]&#39;).should(
      &#39;have.value&#39;,
      &#39;서울특별시 중구 세종대로 110&#39;
    )

    // 업로드 페이지로 이동
    cy.get(&#39;[data-cy=&quot;upload-page-link&quot;]&#39;).click()
    cy.url().should(&#39;include&#39;, &#39;/place/upload&#39;)

    // 업로드 폼에서 입력된 장소 확인
    cy.get(&#39;[data-cy=&quot;upload-form-input&quot;]&#39;).should(&#39;have.value&#39;, &#39;새로운 가게&#39;)
  })</code></pre>
<hr>
<h3 id="주요-테스트-시나리오">주요 테스트 시나리오</h3>
<h4 id="첫번째-테스트">첫번째 테스트</h4>
<p>기본 렌더링 확인</p>
<ul>
<li>업로드 페이지가 정상적으로 로드되고, 제목과 폼이 올바르게 렌더링되는지 확인합니다.</li>
</ul>
<p>사진 등록 및 미리보기 확인</p>
<ul>
<li>사용자가 사진을 업로드하면 미리보기가 정상적으로 표시되는지 확인합니다.</li>
</ul>
<p>장소 검색 및 선택</p>
<ul>
<li>검색 모달창에서 장소를 검색하고 선택한 결과가 업로드 폼에 반영되는지 확인합니다.</li>
</ul>
<p>설명 입력 및 등록 버튼 활성화</p>
<ul>
<li>설명을 입력한 후, 모든 필드가 올바르게 입력되면 등록 버튼이 활성화되는지 확인합니다.</li>
</ul>
<h4 id="두번째-테스트">두번째 테스트</h4>
<ul>
<li>검색 모달창에서 새로운 플레이스 등록 과정 확인</li>
<li>newplace 페이지 이동</li>
<li>map 페이지 이동 후 다시 newplace 이동</li>
<li>확인하기 누르면 업로드 페이지로 이동</li>
</ul>
<h4 id="참고사항">참고사항</h4>
<p>보통 Cypress 테스트를 작성할 때는 각 기능을 개별적으로 테스트하기 위해 여러 개의 <code>it</code> 블록으로 나눕니다.</p>
<p><strong>예시코드</strong></p>
<pre><code class="language-jsx">describe(&#39;카운터 앱&#39;, () =&gt; {
    // 첫 번째 테스트 시나리오
    it(&#39;페이지에 진입하면 카운터 앱이 정상적으로 실행된다(0이 표시된다)&#39;, () =&gt; {
        cy.visit(&#39;http://localhost:3000&#39;);
        cy.get(&#39;[data-cy=counter]&#39;).contains(0);
    });

    // 두 번째 테스트 시나리오
    it(&#39;플러스 버튼을 누르면 카운터가 1이 증가한다&#39;, () =&gt; {
        cy.visit(&#39;http://localhost:3000&#39;);
        cy.get(&#39;[data-cy=add-button]&#39;).click();
        cy.get(&#39;[data-cy=counter]&#39;).contains(1);
    });

    // 세 번째 테스트 시나리오
    it(&#39;마이너스 버튼을 누르면 카운터가 1이 감소한다&#39;, () =&gt; {
        cy.visit(&#39;http://localhost:3000&#39;);
        cy.get(&#39;[data-cy=minus-button]&#39;).click();
        cy.get(&#39;[data-cy=counter]&#39;).contains(-1);
    });
});</code></pre>
<p>본 업로드 페이지 테스트 코드는 전체 플로우를 검증하기 위해서 입력 상태가 유지되어야 하므로 하나의 <code>it</code> 블록에 통합되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/4dcb3160-5831-4e08-8408-fa643126f609/image.png" alt=""></p>
<h3 id="1-cypress-설치하기">1. Cypress 설치하기</h3>
<h4 id="cypress-패키지-다운로드">Cypress 패키지 다운로드</h4>
<pre><code>npm install --save-dev cypress
</code></pre><h4 id="packagejson에-스크립트-추가">package.json에 스크립트 추가</h4>
<pre><code class="language-jsx">{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;cypress open&quot;
  }
}</code></pre>
<p>터미널에서 명령어를 작성합니다.</p>
<pre><code>npm test 또는
npm t</code></pre><p>프로그램이 켜지면 E2E Testing을 선택하고 Chrome을 선택합니다.</p>
<p>cypress.config.ts와 cypress 폴더가 생성됩니다.</p>
<h4 id="cypress-설정-파일-수정">Cypress 설정 파일 수정</h4>
<pre><code class="language-jsx">import { defineConfig } from &#39;cypress&#39;;

export default defineConfig({
  e2e: {
    baseUrl: &#39;http://localhost:3000&#39;,
  },
});
</code></pre>
<ul>
<li><code>cypress.config.ts</code>에서 
<code>baseUrl: &#39;http://localhost:3000&#39;</code>를 추가하여 기본 URL을 설정합니다.</li>
</ul>
<h3 id="2-cypress-테스트-파일-설정">2. Cypress 테스트 파일 설정</h3>
<h4 id="cypress-폴더-구조-설정">Cypress 폴더 구조 설정</h4>
<ul>
<li>cypress/e2e 폴더를 생성하고, <code>upload.cy.ts</code> 파일을 생성합니다.</li>
</ul>
<h4 id="테스트-시나리오와-코드-작성">테스트 시나리오와 코드 작성</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/97f8e2fc-c80b-4360-a149-625e3db2c648/image.gif" alt=""></p>
<pre><code class="language-jsx">import &#39;cypress-file-upload&#39;

describe(&#39;업로드 페이지의 기본 렌더링 및 입력과 버튼 검증 확인하기&#39;, () =&gt; {
  it(&#39;사진 등록, 검색 모달창 입력, 설명 입력 후 등록 버튼을 누르면 홈으로 이동한다.&#39;, () =&gt; {
    cy.visit(&#39;/place/upload&#39;)

    // 제목과 Form 렌더링 확인하기
    cy.get(&#39;[data-cy=&quot;upload-title&quot;]&#39;)
    cy.get(&#39;form&#39;).should(&#39;be.visible&#39;)

    // 사진 등록 시 미리보기가 보인다.
    const imagePath = &#39;testimage.PNG&#39; // cypress/fixtures에 정적 이미지 추가
    cy.get(&#39;input[type=&quot;file&quot;]&#39;).attachFile(imagePath)
    cy.get(&#39;img&#39;).should(&#39;be.visible&#39;)

    // 검색 모달창에서 입력하고 그 결과를 선택해서 업로드 폼에 다시 돌아온다.
    cy.get(&#39;[data-cy=&quot;right-arrow-icon&quot;]&#39;).click()
    cy.get(&#39;[data-cy=&quot;search-modal&quot;]&#39;).should(&#39;be.visible&#39;)
    cy.get(&#39;[data-cy=&quot;search-modal-input&quot;]&#39;).type(&#39;카페베네&#39;)
    cy.get(&#39;[data-cy=&quot;search-modal-item&quot;]&#39;).first().click()
    cy.get(&#39;[data-cy=&quot;upload-form-input&quot;]&#39;).should(&#39;have.value&#39;, &#39;카페베네&#39;)

    // 꿀플 노트 입력
    cy.get(&#39;[data-cy=&quot;upload-form-textarea&quot;]&#39;).type(
      &#39;정말 멋진 카페였습니다. 분위기도 좋고 커피도 맛있어요.&#39;
    )

    // fetch 요청 모킹
    cy.intercept(&#39;POST&#39;, &#39;/api/create-notification&#39;, {
      statusCode: 200,
      body: { success: true },
    }).as(&#39;createNotification&#39;)

    // 등록 버튼이 활성화된다.
    cy.get(&#39;[data-cy=&quot;upload-btn&quot;]&#39;).should(&#39;not.be.disabled&#39;)
  })
})</code></pre>
<ul>
<li>가져오려는 태그 요소에 이름을 지정합니다.</li>
<li><code>&lt;h2 data-cy=&#39;upload-title&#39;&gt;</code></li>
<li><code>cy.get(&#39;[data-cy=&quot;upload-title&quot;]&#39;)</code>으로 요소를 가져옵니다.</li>
</ul>
<h4 id="이미지-등록-시-미리보기-확인">이미지 등록 시 미리보기 확인</h4>
<blockquote>
<p>Cypress를 사용하여 파일 업로드를 테스트할 때 attachFile 메서드를 사용할 수 있습니다. 이 메서드는 cypress-file-upload 패키지를 통해 제공되며, 사용자가 파일을 업로드하는 시나리오를 자동화할 수 있게 해줍니다.</p>
</blockquote>
<pre><code>npm install --save-dev cypress-file-upload</code></pre><p>패키지를 다운로드 합니다.</p>
<pre><code class="language-jsx">import &#39;cypress-file-upload&#39;;</code></pre>
<p>테스트 코드 작성 상단에서 import 합니다.</p>
<pre><code class="language-jsx">const imagePath = &#39;testimage.PNG&#39;
// cypress/fixtures 폴더에 위치한 파일
    cy.get(&#39;input[type=&quot;file&quot;]&#39;).attachFile(imagePath)
 cy.get(&#39;img&#39;).should(&#39;be.visible&#39;)</code></pre>
<p>파일 업로드를 테스트하기 위해 attachFile 메서드를 사용합니다. 이 메서드는 파일 인풋 요소에 파일을 첨부하는 기능을 제공합니다.</p>
<h4 id="attachfile-메서드-설명"><code>attachFile</code> 메서드 설명</h4>
<ul>
<li><p>테스트용 정적 이미지 등록: <code>cypress/fixtures/testimage.PNG</code> 파일을 등록하고 attachFile의 인수로 사용합니다.</p>
</li>
<li><p>메서드 사용: cy.get(&#39;input[type=&quot;file&quot;]&#39;).attachFile(imagePath);는 파일 인풋 요소에 지정된 파일을 첨부합니다.</p>
</li>
<li><p>미리보기 확인: 파일이 첨부된 후, cy.get(&#39;img&#39;).should(&#39;be.visible&#39;);을 통해 업로드된 이미지의 미리보기가 정상적으로 표시되는지 확인합니다.</p>
</li>
</ul>
<h2 id="firebase-api-모킹">Firebase API 모킹</h2>
<p>Firebase API 함수에 환경 변수를 사용하여 테스트 환경에서 실제 DB 호출을 피하는 방법입니다.
제 프로젝트의 업로드 페이지는 등록 버튼 시 로그인을 확인하기에 실제로 등록 버튼을 누르는 테스트는 생략하고, 모든 필드가 입력되었을 때 등록 버튼이 활성화 되는 상태만 확인했습니다.</p>
<h3 id="firebase-api-모킹을-위해-추가했던-부분">Firebase API 모킹을 위해 추가했던 부분</h3>
<h4 id="1-cross-env-설치">1. cross-env 설치:</h4>
<pre><code>npm install --save-dev cross-env</code></pre><h4 id="2-packagejson-수정">2. package.json 수정:</h4>
<pre><code class="language-jsx">{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;cross-env NODE_ENV=test cypress open&quot;
  }
}</code></pre>
<ul>
<li>환경 변수를 설정합니다.</li>
</ul>
<h4 id="3-firebase-업로드-함수-수정">3. Firebase 업로드 함수 수정</h4>
<pre><code class="language-jsx">// firebase strage에 데이터를 등록하는 addHoneyPlace 함수
import { addDoc, collection } from &#39;firebase/firestore&#39;
import { db } from &#39;@root/firebase&#39;
import { uploadNewPlace } from &#39;@/interfaces/IPlace&#39;

const isTestEnv = process.env.NODE_ENV === &#39;test&#39;

export const addHoneyPlace = async (newPlace: uploadNewPlace) =&gt; {
  if (isTestEnv) {
    return 
    // 테스트 환경에서는 Firebase 호출 X
  }

  const placeDocRef = collection(db, &#39;honey_place&#39;)
  return await addDoc(placeDocRef, newPlace)
}

------------------------------------
// 업로드 페이지의 등록 버튼 함수
const onSubmit: SubmitHandler&lt;FieldValues&gt; = async (formData) =&gt; {
    const newPlace = {
        name,
        description,
        address,
        images: uploadedImageFiles,
        createdAt: new Date(),
    }

    await addHoneyPlace(newPlace)
}
</code></pre>
<h2 id="에러사항">에러사항</h2>
<p>버튼을 못찾는 상황
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/2aea1565-b289-4958-b4ff-3ee5668f9b3f/image.png" alt=""></p>
<p>button 태그가 아니라 만든 Button 컴포넌트여서.</p>
<p>Button 컴포넌트 수정:</p>
<p>Button 컴포넌트가 data-cy 속성을 받아 내부의 실제 HTML button 요소에 전달하도록 수정합니다.</p>
<pre><code class="language-jsx">// Button.tsx
interface ButtonProps {
  type: &#39;button&#39; | &#39;submit&#39; | &#39;reset&#39;;
  label: string;
  disabled: boolean;
  &#39;data-cy&#39;?: string; // data-cy 속성을 받을 수 있도록 추가
}

const Button: React.FC&lt;ButtonProps&gt; = ({ type, label, disabled, &#39;data-cy&#39;: dataCy }) =&gt; (
  &lt;button type={type} disabled={disabled} data-cy={dataCy}&gt;
    {label}
  &lt;/button&gt;
);

export default Button;
</code></pre>
<p>UploadForm 컴포넌트에서 Button 컴포넌트 사용:</p>
<p>UploadForm 컴포넌트에서 Button 컴포넌트를 사용할 때 data-cy 속성을 전달합니다.
tsx
코드 복사</p>
<pre><code>&lt;Button
  data-cy=&#39;upload-btn&#39;
  type=&#39;submit&#39;
  label=&#39;꿀플레이스 로그 등록&#39;
  disabled={!isValid}
/&gt;</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[React 가상화 리스트 적용하기]]></title>
            <link>https://velog.io/@hisy4429_sun/React-%EA%B0%80%EC%83%81%ED%99%94-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/React-%EA%B0%80%EC%83%81%ED%99%94-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 06 Jul 2024 14:18:43 GMT</pubDate>
            <description><![CDATA[<h1 id="가상화virtualization-windowing">가상화(Virtualization), Windowing</h1>
<blockquote>
<p>대량의 데이터(예: 수천 개의 목록 항목)를 한 번에 렌더링하면 성능 문제가 발생할 수 있습니다. 가상화는 이 문제를 해결하기 위해 현재 화면에 보이는 항목들만 렌더링하고, 스크롤할 때 필요한 항목들을 동적으로 추가 및 제거하는 기술입니다.</p>
</blockquote>
<p>무한 스크롤을 적용했던 다른 프로젝트에서 가상화의 개념을 알게 되어 새로운 프로젝트에 적용해보았습니다. 일반적으로 페이지네이션이 없는 페이지나, 무한스크롤이 적용되어서 스크롤을 내릴수록 DOM 요소가 쌓일 때 가상화 기법으로 렌더링 최적화를 할 수 있습니다.</p>
<h2 id="라이브러리-소개">라이브러리 소개</h2>
<p>프로젝트 환경은 Next14 App router 입니다.</p>
<p><code>react-window</code>와 <code>react-virtuoso</code> 2개의 라이브러리를 사용해봤습니다. <code>react-virtualized</code> 라이브러리도 많이 사용됩니다.</p>
<p>첫번째로 <code>react-window</code> 를 선택한 이유는 가벼운 패키지이며, 게시물 리스트의 높이가 고정되어 있어서 크기를 동적으로 계산할 필요가 없었습니다.</p>
<p>리스트의 모양이 한 열에 하나의 UI가 보이는 리스트면 구현이 어렵지 않습니다. 저는 한 줄에 2개의 게시물이 보이는 UI 구성이어서 커스텀이 필요했습니다. </p>
<p><code>react-window</code>를 먼저 사용해보고 불편함을 느껴서 <code>react-virtuoso</code>를 적용했습니다.</p>
<pre><code class="language-css">display: &#39;grid&#39;,
gridTemplateColumns: &#39;repeat(2, 1fr)&#39;</code></pre>
<p>크기가 고정적이라면 가벼운 라이브러리인 <code>react-window</code>를 사용하고, 동적인 크기라면 <code>react-virtualized</code>와 <code>react-virtuoso</code>를 이용하는게 더 편리합니다.</p>
<h2 id="react-window-적용예시">react-window 적용예시</h2>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/59e492ac-48cb-42be-b1f3-0e80bb8baabb/image.png" alt=""></p>
<pre><code class="language-jsx">import { FixedSizeList as List } from &#39;react-window&#39;;

const Row = ({ index, style }) =&gt; (
  &lt;div style={style}&gt;Row {index}&lt;/div&gt;
);

const Example = () =&gt; (
  &lt;List
    height={150}
    itemCount={1000}
    itemSize={35}
    width={300}
  &gt;
    {Row}
  &lt;/List&gt;
);</code></pre>
<p>react-window의 예시를 확인할 수 있는 사이트입니다.
<a href="https://react-window.vercel.app/#/examples/grid/fixed-size">https://react-window.vercel.app/#/examples/grid/fixed-size</a></p>
<h3 id="fixedsizegrid-사용과-문제점">FixedSizeGrid 사용과 문제점</h3>
<p>단일 게시물을 일렬로 표시할 때는 문제가 없지만, grid를 이용해서 여러 게시물을 한줄에 보여줘야 할때는 css 문제가 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/3b369020-de59-4253-b474-1d3ea07e8233/image.png" alt=""></p>
<pre><code class="language-jsx">&lt;div className=&quot;flex flex-col items-center bg-red-300&quot;&gt;
&lt;p&gt;test &lt;/p&gt;
  &lt;div&gt;테스트&lt;/div&gt;
  &lt;Grid 속성생략&gt;
&lt;/div&gt;</code></pre>
<p>코드를 간략히 보면 flex와 items-center의 영향으로 p 태그와 div 태그는 가운데 정렬이 되었지만 Grid 태그는 적용이 안되는 문제가 있습니다.</p>
<pre><code class="language-jsx">import faker from &quot;https://cdn.skypack.dev/faker@5.5.3&quot;;
import { FixedSizeGrid as Grid } from &quot;https://cdn.skypack.dev/react-window@1.8.6&quot;;
faker.seed(2);

const COLUMNS = 4;
const ROWS = 10;

const data = Array.from({ length: ROWS }, () =&gt;
  Array.from({ length: COLUMNS }, faker.internet.avatar)
);

function App() {
  return (
    &lt;main className=&quot;grid place-items-center min-h-screen&quot;&gt;
      &lt;div className=&quot;flex flex-col items-center bg-red-300&quot;&gt;
        &lt;p&gt;test &lt;/p&gt;
        &lt;div&gt;테스트&lt;/div&gt;
      &lt;Grid
        columnCount={2}
        rowCount={6}
        columnWidth={150}
        rowHeight={300}
        height={500}
        width={400}
        className=&quot;flex items-center&quot;
      &gt;
        {({ rowIndex, columnIndex, style }) =&gt; {
          return (
            &lt;div style={style} className=&quot;border-8 border-white bg-gray-100&quot;&gt;
              &lt;img
                className=&quot;object-cover w-full h-full rounded-md shadow&quot;
                src={data[rowIndex][columnIndex]}
                alt=&quot;&quot;
              /&gt;
            &lt;/div&gt;
          );
        }}
      &lt;/Grid&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}

ReactDOM.render(&lt;App /&gt;, document.getElementById(&quot;app&quot;));
</code></pre>
<p>위과 같은 react-window를 사용해볼 수 있는 사이트입니다.
<a href="https://codepen.io/smhmd/pen/MWmbPeX">https://codepen.io/smhmd/pen/MWmbPeX</a></p>
<h3 id="react-window-스크롤-분리-문제">react-window 스크롤 분리 문제</h3>
<p>react-window의 경우는 리스트 내부에 스크롤이 생겨서 메인 스크롤과 분리되는 문제가 있으며 이를 해결한 글을 참고로 올립니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/6ddc3af5-19eb-42df-ac43-2817c44e0e36/image.png" alt=""></p>
<ul>
<li>리스트 내부 스크롤과 윈도우 스크롤로 나뉘게 됨</li>
</ul>
<p><a href="https://velog.io/@gn753/React-window-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0">React-window 스크롤 브라우저 연동하기</a></p>
<h3 id="react-virtuoso의-window-scrolling">react-virtuoso의 Window Scrolling</h3>
<p>동일한 문제의 경우 <code>react-virtuoso</code>는 제공하는 속성 <code>useWindowScroll</code>를 이용하면 해결할 수 있습니다.</p>
<pre><code class="language-jsx">function App() {
  return (
    &lt;Virtuoso
      useWindowScroll
      totalCount={200}
      itemContent={(index) =&gt; ( &lt;div style={{ padding: &#39;1rem 0.5rem&#39; }}&gt;Item {index}&lt;/div&gt;)}
    /&gt;
  )
}</code></pre>
<p>위의 예시 확인하기
<a href="https://virtuoso.dev/window-scrolling/">https://virtuoso.dev/window-scrolling/</a></p>
<h2 id="react-virtuoso로-grid-리스트-가상화-하기">react-virtuoso로 Grid 리스트 가상화 하기</h2>
<p>예시를 확인할 수 있는 사이트입니다.
<a href="https://virtuoso.dev/grid-responsive-columns/">https://virtuoso.dev/grid-responsive-columns/</a></p>
<h4 id="적용-전">적용 전</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/d9630bc1-ea1f-43d1-82c0-8dc95317be28/image.png" alt=""></p>
<ul>
<li>페이지네이션이 없는 상태</li>
<li>div 요소가 많고 전부 렌더링되어 우측 스크롤이 길다</li>
</ul>
<h4 id="적용-후">적용 후</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/82015045-98de-4cc7-9cdb-5ff66f432567/image.png" alt=""></p>
<ul>
<li>grid 적용이 안됨</li>
</ul>
<p>단순하게 리스트가 1열로 표시되는 UI라면 아래처럼 간단히 적용할 수 있습니다.
grid UI를 사용하려면 <code>&lt;VirtuosoGrid /&gt;</code> 를 사용해야 합니다.</p>
<pre><code class="language-jsx">&#39;use client&#39;
import { Virtuoso } from &#39;react-virtuoso&#39;

const HoneyPlaceListClinet = ({ initialPlaces }: { initialPlaces: HoneyPlace[] }) =&gt; {
  const [places, setPlaces] = useState(initialPlaces)

  return (
    &lt;div className=&#39;flex justify-center mb-14&#39;&gt;
      &lt;section className=&#39;mt-16 grid grid-cols-2 gap-3 items-center&#39;&gt;
        &lt;Virtuoso
          useWindowScroll
          increaseViewportBy={0} // 시작점
          data={places}
          itemContent={(index, place) =&gt; {
            return &lt;HoneyPlaceCard key={place.id} place={place} /&gt;
          }}
        /&gt;
        &lt;PlaceFloatingButton /&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  )
}</code></pre>
<ul>
<li>useWindowScroll로 윈도우 스크롤 사용</li>
<li>increaseViewportBy 시작 지점 설정</li>
<li>data에는 기존에 places.map()으로 사용하던 데이터인 places를 넣을 수 있습니다.</li>
<li>itemContent는 첫번째 props로는 인덱스, 두번째는 데이터를 받습니다.</li>
</ul>
<h2 id="프로젝트-코드에-적용하기">프로젝트 코드에 적용하기</h2>
<h4 id="적용-전-기존-코드">적용 전 기존 코드</h4>
<pre><code class="language-jsx">// firebase db 데이터를 가져오는 함수를 서버컴포넌트에서 실행
const HoneyPlaceListServer = async () =&gt; {
  const places = await getHoneyPlaces()
  return &lt;HoneyPlaceListClinet initialPlaces={places} /&gt;
}

// props로 받은 데이터로 렌더링
const HoneyPlaceListClinet = ({ initialPlaces }: { initialPlaces: HoneyPlace[] }) =&gt; {
  const [places, setPlaces] = useState(initialPlaces)

  return (
    &lt;div className=&#39;flex justify-center&#39;&gt;
      &lt;section className=&#39;grid grid-cols-2 gap-3 items-center&#39;&gt;
        {places.map((place) =&gt; (
          &lt;HoneyPlaceCard key={place.id} place={place} /&gt;
        ))}
      &lt;/section&gt;
    &lt;/div&gt;
  )
}</code></pre>
<h3 id="react-window">react-window</h3>
<pre><code class="language-jsx">import { FixedSizeGrid as Grid } from &#39;react-window&#39;

const HoneyPlaceListClinet = ({ initialPlaces }: { initialPlaces: HoneyPlace[] }) =&gt; {
  const [places, setPlaces] = useState(initialPlaces)

  const columnCount = 2
  const rowCount = Math.ceil(places.length / columnCount)
  const columnWidth = 154 + 10 // 카드 너비 + 간격

  return (
    &lt;div className=&#39;flex justify-center mb-14&#39;&gt;
      &lt;section className=&#39;mt-16 w-full flex justify-center&#39;&gt;
        &lt;Grid
          columnCount={columnCount}
          columnWidth={columnWidth} // 카드 너비 + 간격
          height={800} // 그리드의 높이
          rowCount={rowCount}
          rowHeight={300} // 카드 높이 + 간격
          width={columnCount * columnWidth + 10} // 그리드의 너비
          itemData={places}
        &gt;
          {({ columnIndex, rowIndex, style, data }) =&gt; {
            const index = rowIndex * columnCount + columnIndex
            if (index &gt;= data.length) return null
            const place = data[index]
            return (
              &lt;article style={style}&gt;
                &lt;HoneyPlaceCard key={place.id} place={place} /&gt;
                &lt;/article&gt;
            )
          }}
        &lt;/Grid&gt;
      &lt;/section&gt;
    &lt;/div&gt;</code></pre>
<h3 id="react-virtuoso의-virtuosogrid">react-virtuoso의 VirtuosoGrid</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/e119bf14-8f22-4ed5-bb84-674f34aced77/image.png" alt=""></p>
<pre><code class="language-jsx">import { forwardRef, useState } from &#39;react&#39;
import { VirtuosoGrid } from &#39;react-virtuoso&#39;

const gridComponents = {
  List: forwardRef(({ style, children, ...props }: any, ref) =&gt; (
    &lt;div
      ref={ref}
      {...props}
      style={{
        display: &#39;grid&#39;,
        gridTemplateColumns: &#39;repeat(2, 1fr)&#39;,
        gap: &#39;1rem&#39;,
        ...style,
      }}
      className=&#39;mt-16 items-center&#39;
    &gt;
      {children}
    &lt;/div&gt;
  )),

  Item: ({ children, ...props }: any) =&gt; (
    &lt;div className=&#39;p-3&#39; {...props}&gt;
      {children}
    &lt;/div&gt;
  ),
}


const HoneyPlaceListClinet = ({ initialPlaces }: { initialPlaces: HoneyPlace[] }) =&gt; {
  const [places, setPlaces] = useState(initialPlaces)

   return (
    &lt;div className=&#39;flex justify-center mb-14&#39;&gt;
      &lt;section className=&#39;w-full&#39;&gt;
        &lt;VirtuosoGrid
          useWindowScroll
          components={gridComponents}
          itemContent={(index) =&gt; &lt;HoneyPlaceCard key={places[index].id} place={places[index]} /&gt;}
          data={places}
        /&gt;
        &lt;PlaceFloatingButton /&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  )</code></pre>
<ul>
<li>useWindowScroll는 스크롤 분리를 없애줍니다.</li>
<li>components는 가상화 리스트로 보여줄 컴포넌트의 바깥 속성을 지정할 수 있습니다.</li>
<li>data에는 map으로 넘기던 데이터를 설정합니다.</li>
<li>itemContent에는 렌더링 할 리스트 요소를 담습니다.</li>
</ul>
<h2 id="최종-적용-코드">최종 적용 코드</h2>
<h3 id="수직-정렬">수직 정렬</h3>
<p>배경색을 임시로 지정해서 수평 정렬을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/db3a472b-8b12-4f2d-aa72-f1504e4cb78f/image.png" alt=""></p>
<p>gridComponents 함수의 className으로 적용하는 css가 일부는 적용되고 일부는 적용이 안되는 문제가 있습니다.
style에 직접 작성하니 가운데로 정렬되는 css가 적용 됩니다!</p>
<pre><code class="language-jsx">const gridComponents: any = {
  List: forwardRef(({ style, children, ...props }: any, ref) =&gt; (
    &lt;div
      ref={ref}
      {...props}
      style={{
        display: &#39;grid&#39;,
        gridTemplateColumns: &#39;repeat(2, 1fr)&#39;,
        gap: &#39;0.25rem&#39;, // 1rem = 4 * 0.25rem
        paddingBottom: &#39;30px&#39;,
        justifyItems: &#39;center&#39;, // 수평 정렬
        alignItems: &#39;center&#39;, // 수직 정렬
        ...style,
      }}
      className=&#39;mt-16 ml-5 bg-red-200&#39;
    &gt;
      {children}
    &lt;/div&gt;
  )),

  Item: ({ children, ...props }: any) =&gt; (
    &lt;div className=&#39;&#39; {...props}&gt;
      {children}
    &lt;/div&gt;
  ),
}</code></pre>
<h3 id="footer에-가려지는-문제">Footer에 가려지는 문제</h3>
<p>VirtuosoGrid의 components={gridComponents}로 작성한 
className으로 적용한 mt는 반영이 되었지만 mb과 pb는 적용이 되지 않는 문제가 있었습니다.</p>
<pre><code class="language-jsx">&lt;div
  ref={ref}
  {...props}
  style={{...style}}
  className=&#39;mt-16 bg-red-100 grid grid-cols-2 mb-16 items-center justify-center&#39;
  &gt;
  {children}
&lt;/div&gt;</code></pre>
<ul>
<li>mt-16, bg-red-100, grid, grid-cols-2는 적용이 됨</li>
<li>그 외에는 적용이 안됨</li>
</ul>
<p>return부의 VirtuosoGrid의 부모 태그인 section에 인라인으로 css를 적용하니 해결되었습니다.</p>
<p>정확한 원인은 찾지 못했지만 Tailwind CSS보다 style 속성에 명시된 인라인 스타일이 더 높은 우선순위를 가집니다.
VirtuosoGrid는 가상화 라이브러리임으로 DOM 조작과 스타일 적용 과정이 일반 태그와 다를 수 있어서 tailwind css 클래스 일부가 제대로 적용되지 않은 것 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/fec6ae1e-2e7d-4fde-96e5-ca7a615c2b4b/image.png" alt=""></p>
<h4 id="기존-코드">기존 코드</h4>
<pre><code class="language-jsx">const gridComponents: any = {
  List: forwardRef(({ style, children, ...props }: any, ref) =&gt; (
    &lt;div
      ref={ref}
      {...props}
      style={{
        display: &#39;grid&#39;,
        gridTemplateColumns: &#39;repeat(2, 1fr)&#39;,
        gap: &#39;0.25rem&#39;,
        justifyItems: &#39;center&#39;, // 수평 정렬
        alignItems: &#39;center&#39;, // 수직 정렬
        ...style,
      }}
      className=&#39;mt-16 bg-red-100 mb-16&#39; 
      // mb-16 적용 X
    &gt;
      {children}
    &lt;/div&gt;
  )),

  Item: ({ children, ...props }: any) =&gt; (
    &lt;div className=&#39;p-3&#39; {...props}&gt;
      {children}
    &lt;/div&gt;
  ),
}</code></pre>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/1db0e5a5-80a8-4891-8282-ebf050309c37/image.png" alt=""></p>
<h4 id="변경-코드">변경 코드</h4>
<pre><code class="language-jsx">return (
    &lt;div className=&#39;flex justify-center mb-20&#39;&gt;
      &lt;section 
        className=&#39;w-full&#39; 
        style={{ paddingBottom: &#39;20px&#39; }}&gt; 
        // 추가한 부분
        &lt;VirtuosoGrid
          useWindowScroll
          components={gridComponents}
          itemContent={(index) =&gt; &lt;HoneyPlaceCard key={places[index].id} place={places[index]} /&gt;}
          data={places}
        /&gt;
        &lt;PlaceFloatingButton /&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  )</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 게시물 등록 시 상태관리로 데이터 업데이트]]></title>
            <link>https://velog.io/@hisy4429_sun/Next.js-%EA%B2%8C%EC%8B%9C%EB%AC%BC-%EB%93%B1%EB%A1%9D-%EC%8B%9C-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</link>
            <guid>https://velog.io/@hisy4429_sun/Next.js-%EA%B2%8C%EC%8B%9C%EB%AC%BC-%EB%93%B1%EB%A1%9D-%EC%8B%9C-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8</guid>
            <pubDate>Fri, 05 Jul 2024 16:42:43 GMT</pubDate>
            <description><![CDATA[<p>리액트 쿼리 없이, 데이터를 갱신하려면?</p>
<p>Next.js의 App Router와 Firebase를 백엔드로 사용하여 서버 컴포넌트 환경에서 게시물을 등록한 후, 새로운 게시물이 실시간으로 홈 페이지에 반영되도록 하는 방법입니다.</p>
<h4 id="서버-컴포넌트에서-데이터-가져오기">서버 컴포넌트에서 데이터 가져오기</h4>
<pre><code class="language-jsx">import { db } from &#39;@root/firebase&#39;
import { collection, getDocs, query, orderBy } from &#39;firebase/firestore&#39;
import { HoneyPlace } from &#39;@/interfaces/IPlace&#39;

export const getHoneyPlaces = async () =&gt; {
  const placesCol = collection(db, &#39;honey_place&#39;)
  const q = query(placesCol, orderBy(&#39;createdAt&#39;, &#39;desc&#39;))
  const querySnapshot = await getDocs(q)

  const places = querySnapshot.docs.map((doc) =&gt; {
    const data = doc.data()
    return {
      ...data,
      createdAt: data.createdAt.toDate().toISOString(), // Timestamp를 ISO 문자열로 변환
      id: doc.id,
    }
  }) as HoneyPlace[]

  return places
}</code></pre>
<h4 id="서버-컴포넌트에서-데이터-사용">서버 컴포넌트에서 데이터 사용</h4>
<p>서버 컴포넌트에서 위에서 정의한 데이터 가져오기 함수를 사용하여 데이터를 클라이언트 컴포넌트에 전달합니다.</p>
<pre><code class="language-jsx">export default function Home() {
  return (
    &lt;main&gt;
      &lt;Header /&gt;
      &lt;HoneyPlaceListServer /&gt;
      &lt;Footer /&gt;
    &lt;/main&gt;
  );
}

const HoneyPlaceListServer = async () =&gt; {
  const places = await getHoneyPlaces();
  return &lt;HoneyPlaceListClient initialPlaces={places} /&gt;;
};
</code></pre>
<h4 id="클라이언트-컴포넌트에서-데이터-사용-및-업데이트">클라이언트 컴포넌트에서 데이터 사용 및 업데이트</h4>
<p>클라이언트 컴포넌트에서 서버 컴포넌트로부터 전달받은 데이터를 상태로 관리하고, 라우터가 변경될 때마다 데이터를 다시 불러옵니다.</p>
<pre><code class="language-jsx">&#39;use client&#39;;

const HoneyPlaceListClient = ({ initialPlaces }: { initialPlaces: HoneyPlace[] }) =&gt; {
  const [places, setPlaces] = useState(initialPlaces);
  const router = useRouter();

  useEffect(() =&gt; {
    const fetchPlaces = async () =&gt; {
      const updatedPlaces = await getHoneyPlaces();
      setPlaces(updatedPlaces);
    };

    fetchPlaces();
  }, [router]);

  return (
    &lt;section&gt;
      {places.map((place) =&gt; (
        &lt;HoneyPlaceCard key={place.id} place={place} /&gt;
      ))}
    &lt;/section&gt;
  );
};</code></pre>
<ul>
<li><code>initialPlaces</code>를 상태로 저장</li>
<li><code>useEffect</code> 훅을 사용하여 라우터 변경 감지</li>
<li><code>getHoneyPlaces</code> 함수를 호출하여 새로운 데이터로 상태를 업데이트</li>
</ul>
<p>리액트 쿼리를 당장 안써도 되는 상황이라면, 이런 방법을 사용할 수 있습니다.
업로드 페이지에서 게시물을 등록해서 router.push(&#39;/)로 홈으로 돌아왔을 때 새 게시물을 확인할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CI/CD - vercel 배포 & CD 워크플로우 커스텀 설정]]></title>
            <link>https://velog.io/@hisy4429_sun/CICD-vercel-%EB%B0%B0%ED%8F%AC-CD-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@hisy4429_sun/CICD-vercel-%EB%B0%B0%ED%8F%AC-CD-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Tue, 25 Jun 2024 13:09:03 GMT</pubDate>
            <description><![CDATA[<h2 id="github-actions이란">GitHub Actions이란?</h2>
<blockquote>
<p>GitHub에서 제공하는 CI/CD(Continuous Integration and Continuous Deployment) 플랫폼입니다. 이를 통해 개발자들은 자동화된 워크플로우를 생성하여, 코드 변경 시 자동으로 빌드, 테스트, 배포 등의 작업을 수행할 수 있습니다.
GitHub Actions는 단순한 DevOps를 넘어 리포지토리에서 다른 이벤트가 발생할 때 워크플로를 실행할 수 있도록 합니다. </p>
</blockquote>
<h3 id="주요-기능">주요 기능</h3>
<h4 id="자동화된-워크플로우">자동화된 워크플로우:</h4>
<p>코드 저장소의 이벤트(예: 푸시, PR 생성, 이슈 생성 등)를 트리거로 하여 다양한 작업을 자동으로 실행할 수 있습니다.</p>
<h4 id="cicd-파이프라인-구축">CI/CD 파이프라인 구축:</h4>
<p>코드를 푸시하거나 PR을 생성할 때, 자동으로 빌드, 테스트, 린트, 배포 등의 작업을 수행할 수 있습니다.</p>
<hr>
<h3 id="문제상황">문제상황</h3>
<ul>
<li>깃허브 액션과 워크플로우의 ci.xml 파일을 작성하여 CI를 추가했습니다. </li>
<li><del>CI 과정의 Lint가 실패했는데도 버셀에서는 배포가 되는 문제가 있었습니다.</del><blockquote>
<p>버셀의 배포는 Production과 Preview가 있습니다. 프로덕션(Production) 배포는 main, master 브랜치와 같은 기본 브랜치의 변경 사항이 배포됩니다.
프리뷰(Preview) 배포는 기능 브랜치의 push, PR, merge 등의 상황에 생성되며 임시 도메인을 가집니다.</p>
</blockquote>
</li>
</ul>
<h4 id="버셀-프로젝트-페이지">버셀 프로젝트 페이지</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/c0f4220c-11ac-426d-87ea-1a45b9648e04/image.png" alt=""></p>
<h4 id="pr의-버셀">PR의 버셀</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/e3aafad8-b7ee-44b3-b27d-34f28ee9c899/image.png" alt=""></p>
<h4 id="pr의-ci">PR의 CI</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/d9e4c952-8bf9-4476-b9b6-f905eb9b1f6f/image.png" alt=""></p>
<h2 id="접근하기">접근하기</h2>
<p>main 브랜치로 PR이나 merge가 있을 때만 배포되는 줄 알았는데, 그렇지 않았습니다. PR이 생성되면 Vercel이 PR 브랜치를 배포합니다. 그래서 CI가 실패했는데도 Vercel에서 PR 브랜치가 Preview 배포가 이루어진 것입니다.</p>
<p>일단 버셀의 자동배포를 끄면 되지 않을까 싶었지만 그런 설정은 버셀에 없었습니다.
github actions ci/cd Vercel 로 검색해보니 버셀의 문서가 있습니다.</p>
<p><a href="https://vercel.com/guides/how-can-i-use-github-actions-with-vercel">https://vercel.com/guides/how-can-i-use-github-actions-with-vercel</a></p>
<p>위 문서를 따라 CD 워크플로우를 추가해보겠습니다.</p>
<h2 id="vercel-배포에서-github-actions를-사용하여-cd-워크플로우-커스텀-설정하기">Vercel 배포에서 GitHub Actions를 사용하여 CD 워크플로우 커스텀 설정하기</h2>
<h3 id="cdyml-파일-작성">cd.yml 파일 작성</h3>
<ul>
<li>문서의 &lt;Vercel 프로덕션 배포를 생성하는 GitHub 작업&gt; 으로 안내된 코드 사용<pre><code class="language-yml">name: Vercel Production Deployment
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
push:
  branches:
    - main
jobs:
Deploy-Production:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v2
    - name: Install Vercel CLI
      run: npm install --global vercel@latest
    - name: Pull Vercel Environment Information
      run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
    - name: Build Project Artifacts
      run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
    - name: Deploy Project Artifacts to Vercel
      run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}</code></pre>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/d9921aa4-8ad1-409f-bd1d-24111574ffb3/image.png" alt=""></p>
<h3 id="vercel-cli-설치와-진행">Vercel CLI 설치와 진행</h3>
<h4 id="맥북-터미널이나-윈도우-파워쉘에서">맥북 터미널이나 윈도우 파워쉘에서</h4>
<pre><code>npm i -g vercel

vercel login</code></pre><h4 id="프로젝트-내의-터미널에서">프로젝트 내의 터미널에서</h4>
<pre><code>vercel link</code></pre><p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/0b879ac3-efae-4ec9-8cdb-48e012e9f279/image.png" alt=""></p>
<ul>
<li>프로젝트 루트 폴더에 <code>.vercel/project.json</code> 폴더와 파일이 생깁니다.</li>
<li>이곳에 적힌 projectId와 orgId을 깃허브 레포지토리 설정의 환경변수로 등록합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/cc2eacd4-a601-4387-b28b-2d053c497335/image.png" alt=""></p>
<h4 id="버셀-토큰-발급하기">버셀 토큰 발급하기</h4>
<ul>
<li>버셀의 계정 설정 (Account Settings) 페이지</li>
<li>Tokens에서 Create Token</li>
</ul>
<h4 id="깃허브-레포-secrets-환경변수-등록">깃허브 레포 Secrets 환경변수 등록</h4>
<blockquote>
<p>Github Repository의 Secrets은 환경 변수를 암호화해서 저장할 수 있습니다. 해당 기능은 Actions를 트리거할 때, 환경 변수를 가져오기 위해 사용할 수 있으며 로컬의 .env 파일과 같은 기능입니다.`</p>
</blockquote>
<ul>
<li><code>Settings -&gt; Secrets and variables -&gt; Actions</code> 에서 Secret 등록하기</li>
<li>vercel link로 생긴 파일의 값으로 <code>VERCEL_ORG_ID</code> &amp; <code>VERCEL_PROJECT_ID</code> 등록하기</li>
<li>버셀 토큰 발급한 것도 <code>VERCEL_TOKEN</code>으로 등록하기</li>
</ul>
<p>이제 CI 과정을 거쳐서 CD가 이뤄지고, main에 push가 발생하면 자동으로 배포를 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CI 설정 후 Your main branch isn't protected 메세지]]></title>
            <link>https://velog.io/@hisy4429_sun/CI-%EC%84%A4%EC%A0%95-%ED%9B%84-Your-main-branch-isnt-protected-%EB%A9%94%EC%84%B8%EC%A7%80</link>
            <guid>https://velog.io/@hisy4429_sun/CI-%EC%84%A4%EC%A0%95-%ED%9B%84-Your-main-branch-isnt-protected-%EB%A9%94%EC%84%B8%EC%A7%80</guid>
            <pubDate>Tue, 25 Jun 2024 08:20:27 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/3f295069-7013-4ac4-9bef-5234facb0f32/image.png" alt=""></p>
<p>CI에 Lint와 Build를 추가하고 PR를 했더니 Lint에서 오류가 나서 PR이 실패했습니다. 그러자 위와 같은 안내가 레포지토리에 뜨기 시작했습니다.</p>
<h2 id="브랜치-보호-설정하기">브랜치 보호 설정하기</h2>
<blockquote>
<p>main 브랜치를 보호하여 실수로 강제 푸시되거나 삭제되지 않도록 설정할 수 있습니다.
세부 규칙 설정을 키고 꺼서 pr를 강제하거나, 스쿼시나 리베이스를 통한 머지만 허용하거나 하는 등의 설정을 할 수 있습니다.</p>
</blockquote>
<p>GitHub에서 브랜치를 보호하는 방법은 다음과 같습니다.</p>
<p><strong>GitHub 리포지토리로 이동:</strong>
리포지토리의 설정(Settings) 페이지로 이동합니다.</p>
<p><strong>Branches 메뉴 선택:</strong>
왼쪽 사이드바에서 &quot;Branches&quot; 메뉴를 선택합니다.</p>
<p><strong>Branch protection rules 설정:</strong>
&quot;Branch protection rules&quot; 섹션에서 &quot;Add branch ruleset&quot; 버튼을 클릭합니다.</p>
<h4 id="rule-설정">Rule 설정:</h4>
<ul>
<li>Ruleset Name 설정</li>
<li>Enforcement status: Active</li>
<li>Target branches: Add target: Include default branch를 선택하여 기본 브랜치(main 브랜치)가 포함되도록 설정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/14e994b5-672c-4731-ac2f-20889ba57f5b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/7dd503a0-b1dc-431a-be6a-0ba12b2c17ea/image.png" alt=""></p>
<p>Rules
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/a7ed3b98-8959-4b2c-9252-311d4978b51d/image.png" alt="">
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/bad8c3ee-2f80-48f9-9e5d-e261fbd0d487/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CI/CD 워크플로우 -  CI로 ESLint 검사하기]]></title>
            <link>https://velog.io/@hisy4429_sun/CICD-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-CI%EB%A1%9C-ESLint-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/CICD-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-CI%EB%A1%9C-ESLint-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 25 Jun 2024 07:27:38 GMT</pubDate>
            <description><![CDATA[<h2 id="cicd란">CI/CD란?</h2>
<h3 id="cicontinuous-integration">CI(Continuous Integration)</h3>
<p>지속적인 통합은 개발자들이 변경 사항을 자주, 최소 하루에 한 번씩 코드 베이스에 병합하는 방법론입니다. 주요 목표는 코드를 자주 통합함으로써 통합 문제를 조기에 발견하고 해결하는 것입니다.</p>
<h4 id="주요-요소">주요 요소:</h4>
<ul>
<li>자동화된 빌드: 코드를 병합할 때마다 자동으로 빌드가 실행됩니다.</li>
<li>자동화된 테스트: 빌드 과정에서 자동으로 테스트가 실행되어 코드 변경으로 인한 버그를 조기에 발견합니다.</li>
</ul>
<h3 id="cdcontinuous-deployment">CD(Continuous Deployment)</h3>
<p>지속적인 배포는 변경 사항을 자동으로 프로덕션 환경에 배포하는 방법론입니다. 이는 지속적인 전달(Continuous Delivery)의 확장으로, 모든 코드 변경이 자동으로 릴리스 준비가 되며, 수동 개입 없이 프로덕션에 배포됩니다.</p>
<h4 id="주요-요소-1">주요 요소:</h4>
<ul>
<li><p>자동화된 배포: CI 파이프라인을 통과한 변경 사항이 자동으로 프로덕션 환경에 배포됩니다.</p>
</li>
<li><p>배포 파이프라인: 빌드, 테스트, 배포 과정을 정의한 파이프라인을 통해 변경 사항이 배포됩니다.</p>
</li>
</ul>
<h2 id="ci-설정하기">CI 설정하기</h2>
<p>CI 과정에서 Lint와 Build를 설정했습니다. 버셀로 배포한 프로젝트여서 CI만 추가하였습니다.
빌드 또한 버셀이 진행하지만 CI에서 빌드하면 미리 오류를 잡을 수 있는 장점이 있습니다.</p>
<p>ESLint 설정은 아래에 따로 있습니다.</p>
<h4 id="파일-생성-위치">파일 생성 위치:</h4>
<ul>
<li>루트 다이렉트의 <code>.github/workflows</code> 폴더를 생성합니다. 이는 규칙으로 정해진 네이밍입니다.</li>
<li>하위의<code>(파일명).yml</code> 파일을 생성해 ci 로직을 작성합니다. </li>
<li>yml과 yaml은 같은 파일 형식입니다.</li>
</ul>
<h3 id="ci---lint-설정하기">CI - Lint 설정하기</h3>
<p><code>.github/workflows/ci.yml</code></p>
<pre><code class="language-yml">lint:
  name: Lint
  runs-on: ubuntu-latest

  steps:
    - name: Checkout code
      uses: actions/checkout@v2

    - name: Set up Node.js
      uses: actions/setup-node@v2
      with:
        node-version: &#39;14&#39;

    - name: Install dependencies
      run: npm install

    - name: Run ESLint
      run: npm run lint
</code></pre>
<p>steps 하위의 name을 지정하지 않고도 간략하게 사용할 수 있습니다.</p>
<pre><code class="language-yml">lint:
  name: Lint
  runs-on: ubuntu-latest

  steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v2
      with:
        node-version: &#39;18&#39;
    - run: npm install
    - run: npm run lint</code></pre>
<p>코드를 설명하면 다음과 같습니다.</p>
<h4 id="trigger-설정">Trigger 설정:</h4>
<p><code>on: [pull_request]</code>: 모든 PR 이벤트가 발생할 때 이 워크플로우가 실행됩니다.</p>
<h4 id="lint-작업">Lint 작업:</h4>
<p><code>runs-on: ubuntu-latest</code>: Lint 작업을 실행할 환경을 지정합니다.
steps:
<code>actions/checkout@v2</code>: 코드를 체크아웃합니다.
<code>actions/setup-node@v2</code>: Node.js 환경을 설정합니다.
<code>npm install</code>: 프로젝트 의존성을 설치합니다.
<code>npm run lint</code>: ESLint를 실행합니다.</p>
<h3 id="build-설정하기">Build 설정하기</h3>
<p>빌드도 추가한 최종 코드입니다.</p>
<pre><code class="language-yml">name: CI

on: [pull_request]

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v2
        with:
          node-version: &#39;18&#39;
      - run: npm install
      - run: npm run lint
  build:
    name: Build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v2
        with:
          node-version: &#39;18&#39;
      - run: npm install
      - run: CI=&#39;false&#39; npm run build</code></pre>
<p><code>run: CI=&#39;false&#39; npm run build</code></p>
<ul>
<li>빌드중에 발생하는 경고를 오류로 취급해서 빌드가 실패하는 경우 방지하기 위함</li>
</ul>
<hr>
<h3 id="lint-오류">Lint 오류</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/29ee915e-7fcb-4130-89c6-29b8d75becb8/image.png" alt=""></p>
<p>package.json 파일의 type이 module로 설정되어 있어, ESLint가 .eslintrc.js 파일을 ES Module로 인식하고 CommonJS로 처리하려 할 때 발생하는 문제입니다.</p>
<p>.eslintrc.js 파일을 <code>.eslintrc.cjs</code>로 변경하여 해결</p>
<hr>
<h3 id="eslint-설정하는-방법">ESLint 설정하는 방법</h3>
<p>.eslintrc.cjs 파일만 만들면 ESLint가 제대로 동작하지 않습니다. </p>
<p>ESLint 규칙 알아보기 - <a href="https://eslint.org/docs/latest/rules/">eslint.org</a></p>
<h4 id="라이브러리-설치하기">라이브러리 설치하기</h4>
<p><code>npm install eslint --save-dev</code>
이 명령어는 ESLint를 프로젝트의 개발 의존성에 추가하여 프로젝트에서 ESLint를 사용할 수 있게 됩니다.</p>
<p>이후에는 <code>.eslintrc.cjs</code> 파일 직접 작성하기 또는 <code>npx eslint --init</code> 명령어로 파일을 생성할 수 있습니다.</p>
<p>이 두가지의 차이점은 파일 형식과 구성 방식, 설정 내용입니다.</p>
<h3 id="eslintrc-파일-직접-작성하는-경우">eslintrc 파일 직접 작성하는 경우:</h3>
<h4 id="eslintrccjs">.eslintrc.cjs</h4>
<ul>
<li>기존 설정 방식으로, ESLint 설정을 CommonJS 형식으로 작성합니다.</li>
<li>다양한 플러그인과 규칙을 쉽게 추가할 수 있습니다.</li>
</ul>
<p>예시 코드:</p>
<pre><code class="language-javascript">// .eslintrc.js

module.exports = {
  root: true,
  env: { browser: true, es2021: true },
  extends: [
    &#39;eslint:recommended&#39;, // 기본 ESLint 규칙
    &#39;plugin:react/recommended&#39;, // React 권장 규칙
    &#39;plugin:@typescript-eslint/recommended&#39;, // TypeScript 권장 규칙
    &#39;plugin:react-hooks/recommended&#39;, // React Hooks 권장 규칙
  ],
  ignorePatterns: [&#39;dist&#39;, &#39;.eslintrc.js&#39;],
  parser: &#39;@typescript-eslint/parser&#39;,
  plugins: [&#39;react-refresh&#39;, &#39;react&#39;],
  rules: {
    &#39;react-refresh/only-export-components&#39;: [
      &#39;warn&#39;,
      { allowConstantExport: true },
    ],
    &#39;no-console&#39;: &#39;warn&#39;, 
    // console.log 사용 시 경고
  },
}
</code></pre>
<h3 id="npx-eslint---init으로-파일-생성하는-경우">npx eslint --init으로 파일 생성하는 경우:</h3>
<p><code>npx eslint --init</code> :
ESLint 초기 설정을 도와줍니다. 실행하면 몇 가지 질문을 통해 프로젝트에 적합한 ESLint 설정 파일(.eslintrc)을 생성합니다.</p>
<h4 id="eslintconfigjs">.eslint.config.js</h4>
<ul>
<li>ESLint v8.21.0에서 도입된 새로운 구성 방식으로, ES 모듈 형식을 사용합니다.</li>
<li>최신 구성 방식으로, 플러그인과 설정을 가져오고 이를 확장하는 새로운 방식입니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/51b744f2-edd6-48ed-9d0b-9d8c13a2d1b5/image.png" alt=""></p>
<p>초기 설정 단계를 거치면 eslint.config.js 파일이 생깁니다.</p>
<h3 id="prettier-충돌-방지하기">prettier 충돌 방지하기</h3>
<p>eslint와 prettier를 같이 사용할 때, 규칙이 다르다면 충돌이 발생할 수 있습니다.</p>
<h4 id="라이브러리-설치">라이브러리 설치</h4>
<pre><code>npm install --save-dev eslint-config-prettier eslint-plugin-prettier
</code></pre><h4 id="eslintrcjs-파일-설정">.eslintrc.js 파일 설정</h4>
<p>기존 설정에 eslint-config-prettier와 eslint-plugin-prettier를 추가합니다</p>
<h4 id="예시코드">예시코드</h4>
<pre><code class="language-javascript">module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
  },
  extends: [
    &#39;eslint:recommended&#39;, // 기본 ESLint 규칙
    &#39;plugin:react/recommended&#39;, // React 권장 규칙
    &#39;plugin:@typescript-eslint/recommended&#39;, // TypeScript 권장 규칙
    &#39;plugin:react-hooks/recommended&#39;, // React Hooks 권장 규칙
    &#39;prettier&#39;, // Prettier와 충돌을 방지하기 위해 추가
    &#39;plugin:prettier/recommended&#39;, // Prettier 권장 규칙 및 eslint-plugin-prettier 활성화
  ],
  ignorePatterns: [&#39;dist&#39;, &#39;.eslintrc.js&#39;],
  parser: &#39;@typescript-eslint/parser&#39;,
  plugins: [
    &#39;react-refresh&#39;, 
    &#39;react&#39;,
    &#39;prettier&#39; // Prettier 플러그인 추가
  ],
  rules: {
    &#39;react-refresh/only-export-components&#39;: [
      &#39;warn&#39;,
      { allowConstantExport: true },
    ],
    &#39;no-console&#39;: &#39;warn&#39;, // console.log 사용 시 경고
    &#39;prettier/prettier&#39;: &#39;error&#39;, // Prettier 규칙을 ESLint 규칙으로 추가
  },
};
</code></pre>
<ul>
<li><code>eslint-config-prettier</code>는 Prettier와 충돌할 수 있는 ESLint 규칙을 비활성화합니다.</li>
<li><code>eslint-plugin-prettier</code>는 Prettier의 규칙을 ESLint 규칙으로 적용해줍니다.</li>
<li><code>plugin:prettier/recommended</code> 설정은 Prettier 규칙을 기본 ESLint 규칙으로 추가하고, Prettier 플러그인을 활성화합니다.</li>
</ul>
<p>참고 - <a href="https://cottonpup.vercel.app/blog/%ED%98%91%EC%97%85%EC%9D%84-%EC%9C%84%ED%95%9C-ESLint-%EC%99%80-Prettier-%ED%99%98%EA%B2%BD%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0">협업을 위한 ESLint와 Prettier 환경설정하기</a></p>
<h3 id="lint-에러-수정하기">Lint 에러 수정하기</h3>
<h4 id="react를-스코프에-포함하지-않은-오류"><code>React</code>를 스코프에 포함하지 않은 오류</h4>
<pre><code>&#39;React&#39; must be in scope when using JSX  react/react-in-jsx-scope</code></pre><ul>
<li>프로젝트 내 react의 버전은 18이상으로 import React from &#39;react&#39;;를 명시적으로 포함할 필요가 없음</li>
<li>react/react-in-jsx-scope의 규칙을 비활성화로 변경<pre><code class="language-js">&#39;.eslintrc.cjs&#39;
module.exports = {
// 다른 코드 생략
rules: {
  &#39;react-refresh/only-export-components&#39;: [
    &#39;warn&#39;,
    { allowConstantExport: true },
  ],
  &#39;react/react-in-jsx-scope&#39;: &#39;off&#39;, // import React from &#39;react&#39; 필요 없음
},
}</code></pre>
<h4 id="react-hook-조건부-호출-오류">React Hook 조건부 호출 오류</h4>
</li>
<li>React Hook은 조건부로 호출해서는 안됨</li>
<li>모든 Hook은 컴포넌트가 렌더링될 때마다 동일한 순서로 호출되어야 함</li>
<li>조건부 로직 바깥에서 Hook을 호출할 것</li>
</ul>
<h4 id="proptypes-오류">PropTypes 오류</h4>
<pre><code>error  &#39;className&#39; is missing in props validation  react/prop-types
</code></pre><ul>
<li>PropTypes를 사용하여 컴포넌트의 props를 정의하지 않은 경우 발생하는 오류</li>
<li>PropTypes 대신 TypeScript 인터페이스를 사용하여 props를 정의할 것</li>
</ul>
<h4 id="no-explicit-any-오류">no-explicit-any 오류</h4>
<pre><code>6:24  error    Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
22:52  error    Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any</code></pre><ul>
<li>TypeScript에서 any 타입을 사용하는 것은 권장되지 않음</li>
<li>외부 라이브러리와 같이 any 타입을 사용해야 하는 경우  ESLint와 TypeScript에서 제공하는 주석 사용</li>
<li><code>// eslint-disable-next-line @typescript-eslint/no-explicit-any</code></li>
<li>여러 줄의 경우는 아래 처럼 사용 가능<pre><code class="language-js">/* eslint-disable @typescript-eslint/no-explicit-any */
function handleExternalLibrary(data: any) {
// ...
}
/* eslint-enable @typescript-eslint/no-explicit-any */
</code></pre>
</li>
</ul>
<p>&#39;특정 파일에서 규칙 비활성화하기&#39;
/* eslint-disable @typescript-eslint/no-explicit-any */</p>
<p>```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React에서 SEO 최적화하기]]></title>
            <link>https://velog.io/@hisy4429_sun/React%EC%97%90%EC%84%9C-SEO-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/React%EC%97%90%EC%84%9C-SEO-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Jun 2024 14:19:27 GMT</pubDate>
            <description><![CDATA[<h1 id="웹-바이탈-체크-및-seo-최적화-시도">웹 바이탈 체크 및 SEO 최적화 시도</h1>
<p>웹 바이탈을 라이트하우스로 체크하면서 접근성과 검색엔진 최적화 점수가 각각 85점과 73점으로 낮았습니다. 이왕이면 좋은 점수를 받고 싶어서 개선을 위해 SEO의 궁금한 점을 간략히 소개하고 검색엔진 최적화를 시도한 방법들을 정리합니다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/de60d3d8-d2f9-4f03-941c-aa0044928024/image.png" alt="">
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/ef92390a-0215-4ac7-bc32-c922faf9e574/image.png" alt=""></p>
<p>1차적으로 index.html의 메타태그 추가와 React-Helmet-asnyc 라이브러리로 추가 적용으로 라이트하우스 점수는 91점이 되었습니다.</p>
<h2 id="html에-메타-태그-설정">HTML에 메타 태그 설정</h2>
<p>메타 태그로 사이트의 기본 정보를 설정합니다. 특히 Title과 Description은 필수적입니다.</p>
<ul>
<li>모든 페이지에 범용적인 제목을 설정합니다.</li>
<li>개별 페이지마다 설정이 필요하면 react-helmet-async 라이브러리를 사용할 수 있습니다.</li>
</ul>
<h3 id="indexhtml의-head-태그">index.html의 head 태그</h3>
<ul>
<li>title 태그 설정하기 (70자 미만 권장)</li>
<li>meta 태그 추가하기</li>
<li>description은 160자 미만 권장</li>
<li>OG 태그는 소셜 미디어의 링크 미리보기 정보 설정<pre><code class="language-javascript">&lt;head&gt;
  &lt;meta charset=&quot;UTF-8&quot; /&gt;
  &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/vite.svg&quot; /&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
  &lt;title&gt;Sun Coffee&lt;/title&gt;
  &lt;meta name=&quot;description&quot; content=&quot;하루의 시작은 커피로!&quot; /&gt;
  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot; /&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;Sun Coffee&quot; /&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;하루의 시작은 커피로!&quot; /&gt;
  &lt;meta property=&quot;og:locale&quot; content=&quot;ko_KR&quot; /&gt;
  &lt;!-- 이미지의 경우 크기 지정 가능 --&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;https://www.image.com&quot; /&gt;
  &lt;meta property=&quot;og:image:width&quot; content=&quot;200&quot; /&gt;
  &lt;meta property=&quot;og:image:height&quot; content=&quot;200&quot; /&gt;
&lt;/head&gt;</code></pre>
</li>
</ul>
<h2 id="react-helmet-asnyc">React Helmet asnyc</h2>
<p>HTML 문서의 head 태그를 관리합니다. 페이지 별로 제목, 설명, 키워드, og태그 등을 설정할 수 있습니다.</p>
<ul>
<li>og 태그: 소셜미디어 미리보기 정보를 설정합니다.</li>
</ul>
<h2 id="sitemap">sitemap</h2>
<p>웹 사이트 내의 링크들의 우선 순위를 알려주고 사이트의 구조를 생성해 크롤러에게 알려줍니다.
sitemap.xml으로 유용한 메타데이터를 추가하여 사용할 수 있습니다.</p>
<p>자세한 설명을 볼 수 있는 링크
<a href="https://www.sitemaps.org/ko/protocol.html">https://www.sitemaps.org/ko/protocol.html</a></p>
<h3 id="사이트맵-설정-방법들">사이트맵 설정 방법들</h3>
<blockquote>
<p>동적 라우트를 사용하는 프로젝트에는 라이브러리를 이용해 자동으로 사이트맵 생성하기</p>
</blockquote>
<h4 id="1-react-router-sitemap-라이브러리">1. React Router Sitemap 라이브러리</h4>
<ul>
<li>라이브러리를 설치하여 동적 사이트맵 생성</li>
<li>사이트맵 생성기 파일 작성과 스크립트 실행의 방식</li>
<li>동적 라우트를 사용해 사이트맵을 자동으로 생성할 때 유용한 방법<pre><code>npm install react-router-sitemap</code></pre></li>
</ul>
<h4 id="2-nextjs-프레임워크">2. Next.js 프레임워크</h4>
<ul>
<li>정적 사이트맵을 생성하는 데 유용한 내장 기능과 플러그인을 제공</li>
<li>next-sitemap 설정 파일 작성과 스크립트 실행의 방식<pre><code>npm install next-sitemap</code></pre></li>
</ul>
<h4 id="3-직접-사이트맵-작성-or-사이트맵-생성-도구-사용">3. 직접 사이트맵 작성 or 사이트맵 생성 도구 사용</h4>
<ul>
<li>직접 루트 디렉토리에 <code>sitemap.xml</code> 파일 작성</li>
<li>정적인 사이트에 유용</li>
<li><a href="https://www.xml-sitemaps.com">사이트맵 생성 사이트</a></li>
</ul>
<p>예시:</p>
<pre><code>&lt;!-- public/sitemap.xml --&gt;
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;https://yourdomain.com/&lt;/loc&gt;
    &lt;lastmod&gt;2023-01-01&lt;/lastmod&gt;
    &lt;changefreq&gt;monthly&lt;/changefreq&gt;
    &lt;priority&gt;1.0&lt;/priority&gt;
  &lt;/url&gt;
  &lt;url&gt;
    &lt;loc&gt;https://yourdomain.com/category&lt;/loc&gt;
    &lt;lastmod&gt;2023-01-01&lt;/lastmod&gt;
    &lt;changefreq&gt;monthly&lt;/changefreq&gt;
    &lt;priority&gt;0.8&lt;/priority&gt;
  &lt;/url&gt;
  &lt;!-- 다른 URL 추가 --&gt;
&lt;/urlset&gt;</code></pre><p><a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap?hl=ko">구글 문서: 사이트맵 제작 및 제출하기</a></p>
<p><a href="https://3d-yeju.tistory.com/70">블로그 - 라이브러리 없이 js로 사이트맵 생성하기</a></p>
<h2 id="robotstxt">Robots.txt</h2>
<p>웹 크롤러 접근을 제어하기 위한 방법입니다. 중요하지 않은 리소스의 방문과 색인 생성을 방지할 수 있습니다.
또한 중요한 정보를 숨기기 보다는 크롤링을 제하거나 개인정보가 들어있는 사이트들을 검색엔진에 노출하고 싶지 않을 경우 사용합니다.</p>
<blockquote>
<p>특히 로그인이 필요한 페이지를 차단할 필요가 있음
이탈률이 높은 페이지는 구글의 랭킹에 악영향을 줍니다.</p>
</blockquote>
<ul>
<li>위치: 사이트의 루트 디렉터리</li>
<li>사용 용도: 사이트 전체 또는 특정 경로에 대한 크롤링 제어</li>
<li>예시:<pre><code>User-agent: *
Disallow: /admin/
Disallow: /private/
Allow: /private/public-info.html</code></pre>이 설정은 모든 크롤러에게 /admin/ 및 /private/ 경로를 크롤링하지 않도록 지시합니다.
그러나 /private/ 경로 아래의 public-info.html 페이지를 크롤링할 수 있도록 허용합니다.</li>
</ul>
<h2 id="로봇-메타-태그">로봇 메타 태그</h2>
<p>meta 태그를 이용하여 해당 페이지는 색인되지 않도록 설정할 수 있습니다.</p>
<ul>
<li>위치: HTML 문서의 <code>&lt;head&gt;</code> 섹션</li>
<li>사용 용도: 특정 페이지에 대한 크롤링 및 인덱싱 제어</li>
<li>예시:<pre><code>&lt;meta name=&quot;robots&quot; content=&quot;index,nofollow&quot; /&gt; 
// 색인 대상O, 페이지 내 링크 수집X
&lt;meta name=&quot;robots&quot; content=&quot;noindex,follow&quot; /&gt; 
// 색인 대상X, 페이지 내 링크 수집O
&lt;meta name=&quot;robots&quot; content=&quot;noindex,nofollow&quot; /&gt; 
// 색인 대상X, 페이지 내 링크 수집X
&lt;meta name=&quot;googlebot&quot; content=&quot;noindex, nofollow&quot; /&gt; 
// 구글에서만 사이트 노출X</code></pre></li>
</ul>
<h3 id="robotstxt과-로봇-메타태그의-차이점">Robots.txt과 로봇 메타태그의 차이점</h3>
<h4 id="적용-범위">적용 범위:</h4>
<ul>
<li>robots.txt: 사이트 전체 또는 특정 디렉터리에 대한 접근을 제어합니다.</li>
<li>메타 로봇 태그: 개별 페이지에 대한 접근을 제어합니다.</li>
</ul>
<h4 id="기능">기능:</h4>
<ul>
<li>robots.txt: 크롤러가 특정 경로를 크롤링하지 않도록 지시합니다. 그러나 검색 엔진이 이미 알고 있는 URL에 대해 인덱싱을 막을 수는 없습니다.</li>
<li>메타 로봇 태그: 페이지를 인덱싱하지 않고, 페이지 내의 링크를 따라가지 않도록 설정할 수 있습니다.</li>
</ul>
<p>그래서 robots.txt를 설정할 때 개별 페이지의 내용을 설정하기보다는 광범위한 패턴에 중점을 두라는 안내가 Chrome 문서도구에 적혀있습니다.</p>
<blockquote>
<p>robots.txt을 500KiB 미만으로 유지
파일이 500KiB보다 크면 검색엔진이 robots.txt 처리를 도중에 중지할 수 있습니다. 이렇게 하면 검색엔진에 혼란을 주어 사이트가 잘못 크롤링될 수 있습니다.</p>
</blockquote>
<blockquote>
<p>robots.txt를 작게 유지하려면 개별적으로 제외된 페이지에 덜 집중하고 더 광범위한 패턴에 더 중점을 둡니다. 예를 들어 PDF 파일의 크롤링을 차단해야 하는 경우 각 개별 파일을 금지하지 마세요. 대신 disallow: /*.pdf를 사용하여 .pdf가 포함된 모든 URL을 허용하지 않습니다.
 출처: <a href="https://developer.chrome.com/docs/lighthouse/seo/invalid-robots-txt?utm_source=lighthouse&amp;utm_medium=devtools&amp;hl=ko">Chrome for Developers</a></p>
</blockquote>
<h2 id="추가-사항">추가 사항</h2>
<h3 id="구조화된-마크업">구조화된 마크업</h3>
<p><a href="https://www.google.com/webmasters/markup-helper/u/0/">https://www.google.com/webmasters/markup-helper/u/0/</a> 나 react-helmet 라이브러리에서 동적으로 생성할 수 있습니다.
출처:<a href="https://velog.io/@rnrn99/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-SEO-%EA%B8%B0%EC%B4%88">리액트 개발자를 위한 SEO 기초</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[lazy loading과 React.memo로 최적화 하기]]></title>
            <link>https://velog.io/@hisy4429_sun/lazy-loading%EA%B3%BC-React.memo%EB%A1%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/lazy-loading%EA%B3%BC-React.memo%EB%A1%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Jun 2024 13:03:52 GMT</pubDate>
            <description><![CDATA[<h3 id="lazy-로딩-적용-전">lazy 로딩 적용 전</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/a4124249-343c-4e8d-8d4a-b4d363f4b6d0/image.png" alt=""></p>
<h3 id="lazy-로딩-적용-후reactmemo-포함">lazy 로딩 적용 후(React.memo 포함)</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/4d46f697-a40e-461c-b2b7-15b494cb9f56/image.png" alt=""></p>
<h3 id="렌더링-시간-차이">렌더링 시간 차이:</h3>
<p>첫 번째 이미지의 렌더링 시간은 17.5ms이고, 두 번째 이미지의 렌더링 시간은 3.6ms입니다. 이는 최적화 작업 후 렌더링 성능이 크게 개선되었음 보여줍니다.</p>
<h3 id="컴포넌트-구조-및-리렌더링">컴포넌트 구조 및 리렌더링:</h3>
<p>첫 번째 이미지에서는 많은 컴포넌트들이 한 번에 렌더링되고 있는 반면, 두 번째 이미지에서는 렌더링되는 컴포넌트의 수가 크게 줄어들어 있는 것을 볼 수 있습니다.
특히, 첫 번째 이미지에서는 Nav, SearchBar, ProductList 등이 렌더링되고 있지만, 두 번째 이미지에서는 이러한 컴포넌트들이 렌더링되지 않거나 최소한의 렌더링만 이루어지고 있습니다.</p>
<h3 id="suspense와-lazy-loading">Suspense와 Lazy Loading:</h3>
<p>두 번째 이미지에서 Suspense와 Loading 컴포넌트가 강조되어 있습니다. 이는 초기 렌더링 시 필요한 일부 컴포넌트들이 lazy loading 되어 실제 렌더링 시간이 줄어든 것을 의미합니다.</p>
<p>최적화 작업 후, 특히 React.memo와 useCallback을 적용한 결과, 초기 렌더링 시 불필요한 컴포넌트의 리렌더링이 줄어들고 lazy loading이 적용되면서 전체적인 렌더링 시간이 크게 감소하고 애플리케이션의 초기 로딩 속도가 빨라졌습니다.</p>
<ul>
<li>React.memo와 useCallback을 적용하여 렌더링 성능 최적화</li>
<li>Nav와 MenuItem 컴포넌트에 React.memo 적용</li>
<li>Profiler 결과, 렌더링 시간이 17.5ms에서 3.6ms로 감소</li>
<li>초기 렌더링 성능이 개선</li>
</ul>
<h3 id="리액트-메모">리액트 메모</h3>
<p><strong>접근 방법</strong>:
리렌더링을 최적화하기 위해 다음의 접근 방법을 시도했습니다:</p>
<ol>
<li>카트 상태를 변경하는 함수에 <code>useCallback</code>을 적용하여 불필요한 함수 재생성을 방지했습니다.</li>
<li>전체 레이아웃을 담당하는 <code>PageLayout</code> 컴포넌트에 <code>React.memo</code>를 적용하여 상태 변경 시 하위 컴포넌트의 불필요한 리렌더링을 최소화했습니다.</li>
<li>화면 왼쪽에 고정된 <code>Nav</code> 컴포넌트와 그 내부의 <code>MenuItem</code> 컴포넌트에도 <code>React.memo</code>를 적용하여 성능을 최적화했습니다.</li>
</ol>
<p><strong>결과</strong>:
이러한 최적화 작업을 통해 렌더링 시간이 7ms에서 5ms로, 그리고 <code>Nav</code> 컴포넌트 최적화 후 3ms로 감소하는 성과를 얻었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[폰트 최적화하기]]></title>
            <link>https://velog.io/@hisy4429_sun/%ED%8F%B0%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/%ED%8F%B0%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 20 Jun 2024 06:44:25 GMT</pubDate>
            <description><![CDATA[<p>폰트를 적용해보니, 폰트에도 최적화가 필요함을 느꼈다. 적용한 폰트가 다운로드 될 때 까지 보이지 않거나, 기본 폰트에서 추가 적용한 폰트로 변화하는게 보이는 건 유저의 경험을 떨어트린다.</p>
<h2 id="브라우저-별-폰트-포맷">브라우저 별 폰트 포맷</h2>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/8251e1b8-a29e-43c0-a138-5189bea46ca0/image.png" alt=""></p>
<p>용량은 TTF/OTF &gt; WOFF &gt; WOFF2 순으로 WOFF2가 가장 작다.</p>
<h2 id="폰트를-적용하는-방식">폰트를 적용하는 방식</h2>
<ol>
<li><p>웹 폰트 서비스로 link 태그에 적용</p>
<pre><code>&lt;link href=&quot;font_url&quot; /&gt;</code></pre></li>
<li><p>폰트 다운로드 후 css에 적용</p>
<pre><code>@font-face { font-family: &quot;font_name&quot;, src: url(&quot;font_url&quot;) }</code></pre></li>
</ol>
<h2 id="선택한-폰트">선택한 폰트</h2>
<p>Pretenard를 선택했으며 아래 링크에서 다운로드 받을 수 있다.
<a href="https://github.com/orioncactus/pretendard/releases/tag/v1.3.9">Pretenard released note</a></p>
<p>이 외의 네이버 폰트 서비스에서도 선택 가능!
네이버에서 다운로드한 폰트의 경우는 TTF 형식이다. 아래 사이트에서 WOFF로 파일 형식을 변환할 수 있음
<a href="https://cloudconvert.com/ttf-converter">cloudconvert</a></p>
<h2 id="폰트-적용하기">폰트 적용하기</h2>
<h4 id="srcassetsfonts-폴더에-다운로드한-폰트-파일-넣기">src/assets/fonts 폴더에 다운로드한 폰트 파일 넣기</h4>
<p>src 내부에 넣으면 번들링에 포함되어서 폰트 파일을 모듈처럼 관리하고 빌드 프로세스를 통해 최적화를 할 수 있다.</p>
<pre><code class="language-css">/* src/index.css */

@font-face {
  font-family: &#39;Pretendard&#39;;
  src: 
  url(&#39;./assets/fonts/subset-PretendardVariable-Regular.woff2&#39;) format(&#39;woff2&#39;),
  url(&#39;./assets/fonts/subset-PretendardVariable-Regular.woff&#39;) format(&#39;woff&#39;),
  url(&#39;./assets/fonts/subset-PretendardVariable-Regular.ttf&#39;) format(&#39;truetype&#39;);

  font-weight: 100 900;
  font-display: swap;
}

body {
  font-family: &#39;Pretendard&#39;, &#39;sans-serif&#39;;
}</code></pre>
<h2 id="최적화-포인트-3가지">최적화 포인트 3가지</h2>
<h3 id="1-font-display로-폰트-적용-시점-설정하기">1. font-display로 폰트 적용 시점 설정하기</h3>
<p>폰트의 적용 방식은 FOIT와 FOUT 방식으로 구분된다. 속성은 5가지로 auto, block, swap, fallback, optional가 있으며 각각 속하는 방식은 아래와 같다.</p>
<h4 id="fout-flash-of-unstyled-text">FOUT (Flash of unstyled text)</h4>
<p>폰트가 다운로드 되기 전에는 기본 폰트를 노출한다.</p>
<ul>
<li>swap: 폰트가 다운로드 되기 전에는 기본 폰트, 다운로드 완료 후 폰트 교체</li>
</ul>
<h4 id="foit-flash-of-invisible-text">FOIT (Flash of invisible text)</h4>
<p>폰트를 다운로드 하기 전에 텍스트를 노출하지 않는다.</p>
<ul>
<li>block: 3초내에 폰트를 다운받지 못하면 기본 폰트를 노출함</li>
<li>fallback: 0.1초 block이 발생하고 3초 후에도 불러오지 못하면 기본 폰트를 유지하고 다운로드된 폰트는 캐시됨. 이후 사용자에게 폰트가 바로 적용될 수 있음.</li>
<li>optional: 네트워크 상태에 따라 기본 폰트 또는 웹폰트 적용을 결정하고 캐시한다.</li>
</ul>
<h3 id="2-폰트-용량-줄이기">2. 폰트 용량 줄이기</h3>
<h4 id="자주-사용하는-한글-목록으로-subset-폰트-만들기">자주 사용하는 한글 목록으로 Subset 폰트 만들기</h4>
<p>아래 링크에서 2번 목록에 해당하는 글자들을 복사하고 <a href="https://transfonter.org/">transfonter</a> 에서 Characters에 붙여넣기 하여 Subset을 만들 수 있다.
필요한 글자만 폰트로 만들기 위함이다. (갌 같은 글자는 일반적으로 안쓰기에 그런 조합을 제외한 목록)
참고로 한글 목록을 복사 붙여넣기하고 추가로 자주쓰는 특수문자와 영대소문자와 숫자를 추가한다.</p>
<pre><code>ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789,./;&#39;[]\_+=-!@#$%^&amp;*(){}|\~</code></pre><p><a href="https://namu.wiki/w/%EC%99%84%EC%84%B1%ED%98%95/%ED%95%9C%EA%B8%80%20%EB%AA%A9%EB%A1%9D/KS%20X%201001">완성형 한글 목록 나무위키</a></p>
<h4 id="trnasfonter에서-formats과-characters-지정해서-변환하기">trnasfonter에서 Formats과 Characters 지정해서 변환하기.</h4>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/bc033a0e-879b-4f5b-a9c6-a8cf086a8365/image.png" alt=""></p>
<p>그렇게 커스텀한 Subset 파일의 용량을 비교하면 차이가 많이 난다. 브라우저 호환을 위해 TTF도 추가하기~
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/9b5004c1-02f8-413e-99f8-272c2f98e421/image.png" alt=""></p>
<p>네트워크에서 다운로드 시간을 보면 300밀리초가 3밀리초로 줄었다.</p>
<p>그래도 새로고침시 주의깊게 보면 기본 폰트에서 변화하는게 보여서</p>
<pre><code class="language-css">font-display: fallback;</code></pre>
<p>font-display 설정을 바꾸니 워낙 파일의 크기가 작아서 fallback 속성이 적절해 보인다.</p>
<h3 id="3-preload로-폰트-먼저-로딩하기">3. Preload로 폰트 먼저 로딩하기</h3>
<p>현재 폰트가 로딩되는 시점을 살펴보자. 개발자도구의 성능 탭에서 기록 버튼을 누르고 새로고침 후 정지하고 네트워크 탭을 살피면 폰트의 다운로드는 445밀리초 쯤에 이뤄진다. 앞서서 스크립트들이 다운로드 되고 난 후에 폰트가 다운로드 되고 있다.</p>
<p>445초밀리초의 작은 초록색 박스가 폰트 부분이다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/a819f029-0ef6-4d0e-bcb2-4c4071b87c78/image.png" alt=""></p>
<h4 id="link-태그로-프리로드-하기"><code>&lt;link&gt;</code> 태그로 프리로드 하기</h4>
<p>index.html</p>
<pre><code class="language-javascript">&lt;link
  rel=&quot;preload&quot;
  href=&quot;/src/assets/fonts/subset-PretendardVariable-Regular.woff2&quot;
  as=&quot;font&quot;
  type=&quot;font/woff2&quot;
  crossorigin=&quot;anonymous&quot;
/&gt;</code></pre>
<p>프리로드를 적용하기 위한 방법은 index.html에 <code>&lt;link&gt;</code> 태그를 추가하는 방법과 플러그인을 이용해서 번들링 과정에 프리로드 태그를 생성하게 하는 방법이 있다.</p>
<p>vite 환경이기에 그냥 index.html에 적용했다. 적용하고 나서 다시 성능을 녹화해서 살펴보면 폰트 로드가 앞서서 처리된다. 445밀리초에서 40밀리초에 처리되는 변화를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/5c479a80-3498-4a20-b3d5-6ea6a2701b66/image.png" alt=""></p>
<h4 id="웹팩으로-프리로드">웹팩으로 프리로드</h4>
<p>웹팩을 쓴다면 아래와 같은 플러그인을 이용하면 폰트가 변경되거나 추가될 때마다 HTML을 수정할 필요없이 처리할 수 있다.</p>
<pre><code class="language-javascript">npm i -D webpack-font-preload-plugin

config.ts

const FontPreloadPlugin = require(&#39;webpack-font-preload-plugin&#39;)

module.exports = {
  webpack: {
    plugins: {
      add: [
        new FontPreloadPlugin({
          extensions: [&#39;woff2], // 우선순위 설정
        })
        ]
    }
  }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[항해99 취업 리부트코스 후기와 추천(FE) 할인 받는 방법]]></title>
            <link>https://velog.io/@hisy4429_sun/%ED%95%AD%ED%95%B499-%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8%EC%BD%94%EC%8A%A4-%ED%9B%84%EA%B8%B0%EC%99%80-%EC%B6%94%EC%B2%9CFE-%ED%95%A0%EC%9D%B8-%EB%B0%9B%EB%8A%94-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@hisy4429_sun/%ED%95%AD%ED%95%B499-%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8%EC%BD%94%EC%8A%A4-%ED%9B%84%EA%B8%B0%EC%99%80-%EC%B6%94%EC%B2%9CFE-%ED%95%A0%EC%9D%B8-%EB%B0%9B%EB%8A%94-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Sat, 15 Jun 2024 14:23:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>10주 과정의 항해99 취업리부트 코스를 수료하고 작성하는 후기입니다!
(할인 코드는 맨 아래 있습니다)</p>
</blockquote>
<h3 id="서류라도-합격하고-싶은데">서류라도 합격하고 싶은데...</h3>
<p>비전공자로 1년전에 부트캠프를 수료하고서 혼자 강의를 보며 공부하고, 외부 스터디에도 참여하면서 공부를 하다가, 작년 말부터 취업을 본격적으로 준비했습니다.</p>
<p>일단 이력서에 사용할 프로젝트가 마땅치 않아서인지 서류 합격률 0%였습니다.</p>
<p>기본적으로 리액트와 넥스트, 타입스크립트를 사용하면서 강의를 보고 혼자 연습하면서 기술을 익혀두고 있었기에 프로젝트를 새로 갈아엎어서 이력서의 내용 자체를 바꿔야겠다고 생각하고 있었습니다.</p>
<h3 id="항해-리부트-코스-참여">항해 리부트 코스 참여</h3>
<p>혼자서 어떤 주제로 무엇을 목표로 프로젝트를 만들지 정하는 것도 쉽지 않아서 취업과 관련된 프로그램을 찾다가 항해 99의 취업리부트 코스를 알게 되었습니다.</p>
<p>저는 개인 프로젝트를 보다 완성도 있게 만들 수 있다는 점이 마음에 들었습니다. 혼자서 어떤 주제로 무엇을 개발할지도 고민이었는데, 항해99 취업 코스의 가이드를 기준으로 만들 수 있겠다는 생각에 참여하였습니다! </p>
<h3 id="시작하기-전과-현재는">시작하기 전과 현재는?</h3>
<p>큰 고민이었던 점은 취업이었고, 취업리부트 코스를 하고 나서도 취업이 안되면 개발직 구인을 그만두고 원래 하던 직무로 돌아가야하나 그런 고민이 계속 있었습니다. 결론부터 말하면 서류가 붙기 시작해서 면접을 몇 차례 보았습니다. 다만 서류 합격률이 높은 것은 아니고, 공고에 적힌 자격요건과 우대사항들을 보면서 어떤 점을 보완하면 좋을지가 더 눈에 들어오는 것 같습니다.</p>
<h3 id="항해-리부트-코스-과정">항해 리부트 코스 과정</h3>
<p>요약하면 이렇습니다.
현직 개발자의 이력서 코칭, 자료구조&amp;알고리즘 학습과 코딩테스트 준비, 프로젝트 구현 사항을 가이드 해주는 문서를 보며 개인 프로젝트 개발, 실전 면접 대비 훈련.
주차별로 진행이 정해져있으니 열심히 따라가기만 하면 됩니다!!</p>
<h3 id="이력서-코칭">이력서 코칭</h3>
<p>처음에 합류하면 현재의 이력서를 다듬게 합니다. 프로젝트의 구성이 맞춰져있지 않은 채로 이력서를 정비하는게 큰 도움이 될까 싶었지만, 이력서를 다듬는 기술을 미리 배우며 기초를 수정해놔야 추후에 프로젝트 내용을 추가할 때 도움되는 것 같습니다. 그때가서 새로 내용을 추가하면서 이력서는 계속 수정하고 손봐야하기 때문에 나중에 한번에 끝낸다는 생각보다는 미리 기초 작업을 해두는게 맞는 것 같습니다!</p>
<h3 id="자료구조와-알고리즘-학습">자료구조와 알고리즘 학습</h3>
<p>그 다음은 알고리즘 공부와 코딩테스트 문제를 푸는 시간을 한달정도 가집니다. 이건 본격적인 코딩테스트 준비라기 보다는, 앞으로를 위한 작업이라고 볼 수 있습니다.</p>
<p>저는 프로젝트를 새로 만들어서 이력서에 추가하는게 제일 원하던 것이었기에 이 과정이 굉장히 지루하고 힘들었습니다. 그치만 지원공고를 보면 기본적인 코딩테스트를 진행하는 곳도 있기에 이 과정은 어쩔수 없이 해야만 한다고 생각 합니다. (서류가 붙어서 코딩테스트를 보기도 했습니다)</p>
<h3 id="대기업-시나리오-프로젝트">대기업 시나리오 프로젝트</h3>
<p>그 다음 과정으로 본격적인 프로젝트를 만드는 작업을 4주에 걸쳐서 진행하게 됩니다. 2주차때부터는 집중도 잘 안되고 힘들었지만 같은 주제로 프로젝트를 만드는 동료들과 의견도 나누고 질의응답을 할 수 있는 것이 좋았습니다. 면접 대비를 위한 연습도 같이 진행하는 것도 장점입니다.</p>
<h3 id="그외">그외</h3>
<p>이력서 수정과 면접 연습, 면접 코칭을 지원해주는 것도 도움이 됩니다. 지금은 서류 합격률을 높이기 위해서 만들었던 개인 프로젝트를 조금 보완하고 이력서를 수정하려고 계획 중입니다.</p>
<p>어느정도 기본적인 학습은 마쳤고, 개인 프로젝트도 해봤으며 이력서를 제출하는데 서류 합격이 안되서 어떻게 할지 모르겠거나 의욕이 떨어지신 분들에게 큰 도움이 될 코스라고 생각합니다!</p>
<p>지인 추천코드를 이용하면 10만원을 할인받을 수 있습니다! <del>저는 할인 못받고 함</del></p>
<h3 id="추천인-코드">추천인 코드</h3>
<h4 id="추천왕-2기-김선은">추천왕 2기 김선은</h4>
<p>만들었던 개인 프로젝트 화면입니다. 저는 디자인 부분은 핀터레스트에서 찾은 UI, UX 디자인 이미지들을 보고 따라 만들었었습니다. 레퍼런스를 참고하거나 실제 있는 서비스의 디자인을 따라해서 기획, 페이지 구성이나 디자인 부분에는 힘을 많이 안쓰고 개발에 집중하는게 좋은것같습니다~!
<img src="https://velog.velcdn.com/images/hisy4429_sun/post/33960891-4533-4fc2-bf66-36ef5365469f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[카카오 지도 API로 지도 가운데 마커 표시하기]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%A7%80%EB%8F%84-API%EB%A1%9C-%EC%A7%80%EB%8F%84-%EA%B0%80%EC%9A%B4%EB%8D%B0-%EB%A7%88%EC%BB%A4-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%B9%B4%EC%B9%B4%EC%98%A4-%EC%A7%80%EB%8F%84-API%EB%A1%9C-%EC%A7%80%EB%8F%84-%EA%B0%80%EC%9A%B4%EB%8D%B0-%EB%A7%88%EC%BB%A4-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 05 Jun 2024 15:36:19 GMT</pubDate>
            <description><![CDATA[<p><a href="https://apis.map.kakao.com/web/sample/addr2coord/">https://apis.map.kakao.com/web/sample/addr2coord/</a>
<a href="https://apis.map.kakao.com/web/documentation/">https://apis.map.kakao.com/web/documentation/</a></p>
<p>getCenter 메서드와 같은 설명은 2번째 링크 documentation를 참고할 수 있습니다.
카카오 개발자 페이지에서 appkey를 발급받아서 사용해야합니다.</p>
<p>원하는 동작은 다음과 같습니다.</p>
<ul>
<li>지도가 처음 뜨는 위치를 서울시청으로 할 것</li>
<li>가운데 마커가 고정으로 있을 것</li>
<li>클릭이 아니라, 지도를 움직이는 방식으로 이용하기</li>
<li>지도가 움직이면 바뀐 위치의 가운데 마커에 해당되는 주소 얻기</li>
</ul>
<p>환경은 Next14의 App 라우터입니다.</p>
<p>카카오 지도의 스크립트를 사용할 컴포넌트는 윈도우를 사용해야해서 클라이언트 컴포넌트로 작성해야 합니다.</p>
<h3 id="주요-기능별-설명">주요 기능별 설명</h3>
<h4 id="스크립트-비동기-로드">스크립트 비동기 로드</h4>
<pre><code class="language-jsx">import Script from &#39;next/script&#39;</code></pre>
<p>next/script를 사용하여 카카오 지도 API 스크립트를 비동기적으로 로드합니다. 이는 페이지 로딩 속도를 향상시킵니다.</p>
<h4 id="상태-변수-정의">상태 변수 정의:</h4>
<pre><code class="language-jsx">const [address, setAddress] = useState(&#39;&#39;)</code></pre>
<p>address 상태 변수를 정의하여 지도 중심의 주소를 저장합니다.</p>
<h4 id="카카오-지도-로드-함수">카카오 지도 로드 함수:</h4>
<pre><code class="language-jsx">const loadKaKaoMap = () =&gt; {
  // 카카오 지도 API가 로드된 후 실행될 콜백 함수
  window.kakao.maps.load(() =&gt; {
    // 지도를 표시할 HTML 요소를 가져옵니다.
    const mapContainer = document.getElementById(&#39;map&#39;);

    // 지도의 초기 설정 옵션을 지정합니다.
    const mapOption = {
      // 지도의 중심 좌표를 설정합니다. (서울시청 기준)
      center: new window.kakao.maps.LatLng(37.5667, 126.9782),
      // 지도의 확대 레벨을 설정합니다.
      level: 1,
    };

    // 지도를 생성합니다.
    const map = new window.kakao.maps.Map(mapContainer, mapOption);

    // 마커를 생성하고 지도의 중심에 위치시킵니다.
    const marker = new window.kakao.maps.Marker({
      position: map.getCenter(),
      map: map,
    });

    // 주소를 변환할 Geocoder 객체를 생성합니다.
    const geocoder = new window.kakao.maps.services.Geocoder();

    // 좌표를 주소로 변환하는 함수입니다.
    const updateAddress = (coords) =&gt; {
      geocoder.coord2Address(
        coords.getLng(),
        coords.getLat(),
        (result, status) =&gt; {
          if (status === window.kakao.maps.services.Status.OK) {
            // 변환된 주소를 가져옵니다.
            const detailAddr = result[0].road_address
              ? result[0].road_address.address_name
              : result[0].address.address_name;
            // 변환된 주소를 상태 변수에 저장합니다.
            setAddress(detailAddr);
          }
        }
      );
    };

    // 초기 지도 중심의 주소를 변환합니다.
    updateAddress(map.getCenter());

    // 지도의 중심이 변경될 때마다 실행될 이벤트 리스너를 추가합니다.
    window.kakao.maps.event.addListener(map, &#39;idle&#39;, function () {
      // 지도의 새로운 중심 좌표를 가져옵니다.
      const center = map.getCenter();
      // 마커를 새로운 중심 좌표로 이동시킵니다.
      marker.setPosition(center);
      // 새로운 중심 좌표의 주소를 변환하여 업데이트합니다.
      updateAddress(center);
    });
  });
};
</code></pre>
<p>지도 초기화: 지도와 마커를 초기화하고 지도 중심을 설정합니다.
주소 변환 함수: geocoder.coord2Address를 사용하여 좌표를 주소로 변환합니다.
중심 좌표 변경 이벤트 리스너: idle 이벤트를 사용하여 지도의 중심이 변경될 때마다 마커 위치를 지도 중심으로 업데이트하고, 주소를 변환하여 상태 변수 address에 저장합니다.</p>
<h4 id="useeffect로-주소-로그-출력">useEffect로 주소 로그 출력:</h4>
<pre><code class="language-jsx">useEffect(() =&gt; {
  console.log(address)
}, [address])</code></pre>
<p>address 상태가 변경될 때마다 콘솔에 현재 주소를 출력합니다.
동작을 확인할 수 있습니다.</p>
<h4 id="컴포넌트-렌더링">컴포넌트 렌더링:</h4>
<pre><code class="language-jsx">return (
  &lt;&gt;
    &lt;Script
      strategy=&quot;afterInteractive&quot;
      src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_CLIENT}&amp;libraries=services,clusterer,drawing&amp;autoload=false`}
      onReady={loadKaKaoMap}
    /&gt;
    &lt;div id=&quot;map&quot; style={{ width: &#39;100%&#39;, height: &#39;650px&#39; }}&gt;&lt;/div&gt;
    &lt;div&gt;현재 주소: {address}&lt;/div&gt;
  &lt;/&gt;
)</code></pre>
<h4 id="next-script">Next Script</h4>
<p>Next Script 컴포넌트를 사용하여 카카오 지도 API 스크립트를 로드하고, 로드가 완료되면 loadKaKaoMap 함수를 실행합니다. 이때 스크립트 src 파라미터에 autoload=false를 꼭 추가해야 합니다. useEffect로 동작 순서를 관리하지 않아도됩니다.</p>
<h4 id="스크립트-src-파라미터">스크립트 src 파라미터</h4>
<p>appkey: 카카오 개발자 사이트에서 발급받은 앱 키를 지정합니다.
autoload=false: 스크립트 로드 후 자동으로 API를 초기화하지 않도록 설정합니다. 이렇게 하면 스크립트를 로드한 후 직접 초기화할 수 있습니다.
libraries=services,clusterer,drawing: 추가 라이브러리(서비스, 클러스터러, 드로잉)를 로드하여 지도 API의 기능을 확장합니다.
이 설명을 참고하여 코드를 이해하고, 필요에 맞게 사용할 수 있습니다.</p>
<p>최종 코드</p>
<pre><code class="language-javascript">&#39;use client&#39;

import Script from &#39;next/script&#39;
import { useEffect, useState } from &#39;react&#39;

declare global {
  interface Window {
    kakao: any
  }
}

const SearchMap = () =&gt; {
  const [address, setAddress] = useState(&#39;&#39;)

  const loadKaKaoMap = () =&gt; {
    window.kakao.maps.load(() =&gt; {
      const mapContainer = document.getElementById(&#39;map&#39;)
      const mapOption = {
        center: new window.kakao.maps.LatLng(37.5667, 126.9782),
        level: 1,
      }

      const map = new window.kakao.maps.Map(mapContainer, mapOption)
      const marker = new window.kakao.maps.Marker({
        position: map.getCenter(),
        map: map,
      })

      const geocoder = new window.kakao.maps.services.Geocoder()

      const updateAddress = (coords: any) =&gt; {
        geocoder.coord2Address(
          coords.getLng(),
          coords.getLat(),
          (result: any, status: any) =&gt; {
            if (status === window.kakao.maps.services.Status.OK) {
              const detailAddr = result[0].road_address
                ? result[0].road_address.address_name
                : result[0].address.address_name
              setAddress(detailAddr)
            }
          }
        )
      }

      updateAddress(map.getCenter())

      window.kakao.maps.event.addListener(map, &#39;idle&#39;, function () {
        const center = map.getCenter()
        marker.setPosition(center)
        updateAddress(center)
      })
    })
  }

  useEffect(() =&gt; {
    console.log(address)
  }, [address])

  return (
    &lt;&gt;
      &lt;Script
        strategy=&quot;afterInteractive&quot;
        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_CLIENT}&amp;libraries=services,clusterer,drawing&amp;autoload=false`}
        onReady={loadKaKaoMap}
      /&gt;
      &lt;div id=&quot;map&quot; style={{ width: &#39;100%&#39;, height: &#39;650px&#39; }}&gt;&lt;/div&gt;
    &lt;/&gt;
  )
}

export default SearchMap</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[취업 리부트 코스 7주차 WIL]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-7%EC%A3%BC%EC%B0%A8-WIL</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-7%EC%A3%BC%EC%B0%A8-WIL</guid>
            <pubDate>Tue, 07 May 2024 12:42:41 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 주 항해 취업 리부트코스에서 내가 구현한 기능은 무엇인가요? </p>
</blockquote>
<ul>
<li>장바구니에 담은 상품으로 가상 결제를 구현하기.</li>
</ul>
<blockquote>
<p>해당 기능을 구현하기 위해, 어떤 기술적 의사결정을 거쳤나요?</p>
</blockquote>
<ul>
<li>장바구니와 결제와 관련되어 전역으로 상태를 참조하고 업데이트를 하는 경우가 많으니 Context API를 이용했다.</li>
<li>Provider 함수 내부에 상태와 관련된 로직을 작성하여 page 부분의 컴포넌트에서는 주로 Provider 함수 내에서 작성된 함수를 사용하고 상태를 참조하는 구조로 진행했다.</li>
<li>상태가 복잡하지 않기에 context API를 사용해도 괜찮다고 생각했으며 최적화 부분에서 단점이 있기에 화면에서 보여지는 비율이 큰 상품리스트 컴포넌트에 React.memo를 적용했다.</li>
</ul>
<h2 id="기능-구현사항">기능 구현사항</h2>
<p>3주차 기능 구현 요구 사항을 간략히 소개하면 다음과 같다.</p>
<ul>
<li>상품 구매 기능</li>
<li>결제 SDK 연동</li>
<li>판매자 주문 상태 변경 가능</li>
</ul>
<p>구매자가 장바구니에 담은 상품으로 구매를 진행해야해서 결제 SDK를 연동해 가상 결제를 진행하고, 결제가 완료되면 주문 상태를 DB에 새로운 ORDER로 생성하는게 주요 목표였다.</p>
<p>고민한 사항들과 구현한 방법, 결제 프로세스를 정리해보자.</p>
<h3 id="주문-처리-순서">주문 처리 순서</h3>
<ol>
<li><strong>장바구니에서 주문 진행</strong>: 사용자가 장바구니에서 &quot;주문하기&quot;를 선택하면, 주문 정보(선택한 상품, 수량, Dine in/Take out, 총 금액)를 임시로 저장, 결제 모달 띄우기(결제 정보를 입력받는 모달)</li>
<li><strong>결제 처리</strong>: 사용자가 필요한 정보를 입력하고 결제를 진행. 아임포트 결제 게이트웨이를 사용하여 처리. 결제가 성공적으로 완료되면,</li>
<li><strong>DB에 주문 저장</strong>: 결제 완료 후, 주문 정보와 함께 주문 상태를 &quot;주문 완료&quot;로 설정하여 데이터베이스에 저장.</li>
</ol>
<h3 id="firebase-firestore-데이터-스키마">Firebase Firestore 데이터 스키마</h3>
<ul>
<li><strong>Orders 컬렉션</strong><ul>
<li><strong>Document ID</strong>: 자동 생성된 ID</li>
<li><strong>products</strong>: Array of Objects<ul>
<li><strong>product_id</strong>: 상품 ID</li>
<li><strong>name</strong>: 상품 이름</li>
<li><strong>quantity</strong>: 수량</li>
</ul>
</li>
<li><strong>total_amount</strong>: 총 금액</li>
<li><strong>order_status</strong>: 주문 상태 (예: 주문 완료, 제조 대기, 제조 완료, 주문 취소)</li>
<li><strong>order_type</strong>: Dine in/Take out</li>
<li><strong>timestamp</strong>: 주문 시각</li>
<li><strong>customer_name</strong>: 고객 NAME (선택적)</li>
</ul>
</li>
</ul>
<p>장바구니와 결제와 관련된 데이터를 참조하거나 상태를 변경하는 함수는 ContextAPI를 활용해서 Provider 내부에 함수를 선언하여 사용하는 방식을 이용했다.</p>
<ul>
<li>CartContext와 PaymentContext로 나누어 구성</li>
</ul>
<p>장바구니의 데이터와 결제 모달창에서 구매자의 정보를 입력받고 결제 모듈 창을 띄우기까지의 과정은 다음과 같다.</p>
<ul>
<li><strong>Cart 컴포넌트</strong>에서 DB에 저장할 형태의 데이터인 <strong><code>orderData</code></strong>를 생성하고, 이를 <strong><code>PaymentProvider</code></strong>로 전송.</li>
<li><strong>PaymentModal</strong>에서는 React Hook Form을 사용하여 폼 데이터를 수집하고, 이 데이터를 <strong><code>PaymentProvider</code></strong>로 전송.</li>
<li><strong>PaymentProvider</strong>는 <strong><code>Cart</code></strong>와 <strong><code>PaymentModal</code></strong>에서 전달받은 데이터를 통합하여 <strong><code>paymentData</code></strong>를 관리하고, 이 데이터를 <strong><code>payment.ts</code></strong>의 <strong><code>startPayment</code></strong> 함수로 넘겨 결제를 진행합니다.</li>
<li><strong><code>startPayment</code></strong>는 결제 데이터를 받아서 PG사의 결제 모듈창을 띄우고 성공과 실패시의 로직을 작성할 수 있습니다.</li>
</ul>
<p>흐름을 더 자세히 살펴보면 다음과 같다.</p>
<h3 id="파일-구성과-결제-프로세스-확인하기">파일 구성과 결제 프로세스 확인하기</h3>
<p>결제 프로세스는 주로 <strong><code>PaymentContext</code></strong>, <strong><code>PaymentModal</code></strong>, 그리고 <strong><code>payment.ts</code></strong> 파일에서 관리중입니다.</p>
<ol>
<li><strong>결제 정보 수집 및 처리</strong><ul>
<li><strong>결제 정보 입력</strong>: 사용자는 결제 모달창에서 결제 정보(예: 이름, 전화번호 등)를 입력합니다.<ul>
<li><strong><code>onSubmit</code></strong> 함수가 실행되면, 입력된 데이터는 <strong><code>PaymentContext</code></strong>의 <strong><code>updateOrderUserData</code></strong> 함수를 통해 상태에 저장되고, 폼은 리셋됩니다.</li>
</ul>
</li>
<li><strong>결제 데이터 생성</strong>: 사용자 데이터가 입력되면 <strong><code>handlePayment</code></strong> 함수가 호출되어 결제 데이터를 생성하고 <strong><code>startPayment</code></strong> 함수를 호출합니다.<ul>
<li><strong><code>useEffect</code></strong> 훅으로 <strong><code>orderUserData</code></strong> 상태에 변화가 있을 때 <strong><code>handlePayment</code></strong> 함수를 호출합니다.</li>
</ul>
</li>
</ul>
</li>
<li><strong>결제 처리 및 확인 (<code>handlePayment</code>)</strong>:<ul>
<li><strong><code>PaymentContext</code></strong>에서 createPaymentData 함수는 orderUserData와 orderData로 결제 모듈에 보낼 결제 데이터를 만들어 리턴합니다.</li>
<li><strong><code>PaymentContext</code></strong>에서 <strong><code>handlePayment</code></strong> 함수는 <strong><code>startPayment</code></strong> 함수에 ****결제 데이터를 보내고 KG 이니시스 결제 모듈을 통해 결제를 요청합니다. 성공적인 결제 후, 주문 정보는 데이터베이스에 저장됩니다.</li>
<li><strong><code>startPayment</code></strong> 함수에는 결제 완료 후 실행할 콜백 함수가 인자로 전달됩니다. 이 콜백은 <code>**handlePayment**</code> 내부에서 정의됩니다.</li>
</ul>
</li>
<li><strong>결제 요청 (<code>startPayment</code>)</strong>:<ul>
<li><strong><code>payment.ts</code></strong> 파일에서 <strong><code>startPayment</code></strong> 함수는 실제 결제를 처리합니다. 이 함수는 <strong><code>IMP.request_pay</code></strong>를 호출하여 결제를 시도하고, 성공 여부에 따라 적절한 조치를 취합니다.</li>
<li>결제 성공 시, 데이터베이스에 주문 정보를 저장하고, 전달받은 콜백 함수를 실행합니다.</li>
</ul>
</li>
<li><strong>결제 성공 콜백</strong>:<ul>
<li>성공 콜백 내에서 결제 모달을 닫고, orderData의 상태를 비우고, 결제 모달창을 닫고 장바구니를 비우는 등의 후속 조치가 이루어집니다.</li>
</ul>
</li>
</ol>
<h3 id="결제가-완료-후-처리-로직-개선하기">결제가 완료 후 처리 로직 개선하기</h3>
<h4 id="주요-개선-사항"><strong>주요 개선 사항</strong></h4>
<h4 id="1-결제-모달-리셋-및-닫기"><strong>1. 결제 모달 리셋 및 닫기</strong></h4>
<ul>
<li><strong>모달 리셋</strong>: React Hook Form의 <strong><code>reset()</code></strong> 함수를 사용하여 결제 폼의 입력 필드를 초기화합니다.</li>
<li><strong>모달 닫기</strong>: <strong><code>handlePayment</code></strong> 함수 내 <strong><code>startPayment</code></strong> 호출 시 콜백으로 <strong><code>closeModal()</code></strong> 함수를 전달하여 결제 성공 후 모달을 자동으로 닫습니다.</li>
</ul>
<h4 id="2-장바구니-관리"><strong>2. 장바구니 관리</strong></h4>
<ul>
<li><strong>장바구니 비우기</strong>: 결제가 성공적으로 완료되면 <strong><code>clearCart()</code></strong> 함수를 호출하여 장바구니를 비웁니다. 이는 사용자가 새로운 쇼핑 세션을 깔끔하게 시작할 수 있도록 돕습니다.</li>
<li><strong>장바구니 모달 닫기</strong>: 결제 완료와 동시에 장바구니 모달도 닫히며, 사용자는 주문 완료 페이지나 주문 완료 모달로 이동할 수 있습니다.</li>
</ul>
<h4 id="3-결제-처리-로직-개선"><strong>3. 결제 처리 로직 개선</strong></h4>
<ul>
<li><strong>결제 중복 호출 문제 해결</strong>: <strong><code>useEffect</code></strong>의 의존성 배열에서 <strong><code>handlePayment</code></strong>를 제거하여 결제가 중복으로 발생하는 문제를 해결했습니다. 이로 인해 결제 모달창이 한 번만 뜨고 적절히 처리됩니다.</li>
</ul>
<h4 id="4-결제-데이터-초기화"><strong>4. 결제 데이터 초기화</strong></h4>
<ul>
<li><strong>결제 데이터 지속 문제</strong>: 한 번 결제를 완료한 후 <strong><code>orderUserData</code></strong> 상태가 유지되어, 장바구니에서 &#39;주문하기&#39; 버튼을 다시 누를 때 정보 입력 없이 바로 결제창이 뜨는 문제가 있었습니다.</li>
<li><strong>모달창 데이터 초기화</strong>: 결제 모달창에서 폼 제출 시 <strong><code>useEffect</code></strong>로 <strong><code>handlePayment</code></strong>를 호출하고, <strong><code>handlePayment</code></strong>는 <strong><code>startPayment</code></strong> 함수를 사용하여 결제를 진행합니다. 결제가 완료된 후, <strong><code>startPayment</code></strong>의 콜백 함수에서 모든 관련 상태(<strong><code>orderUserData</code></strong>, <strong><code>orderData</code></strong>)를 초기화하여 이 문제를 해결합니다.</li>
</ul>
<h4 id="작성한-코드-살펴보기">작성한 코드 살펴보기</h4>
<pre><code class="language-javascript">📂 PaymentContext.tsx

const PaymentContext = createContext&lt;PaymentContextProps | null&gt;(null)

export const usePayment = () =&gt; useContext(PaymentContext)

// 카트 컴포넌트에서 DB에 저장할 데이터 받아오기
  const updateOrderData = (data: TypeOrderData) =&gt; {
    setOrderData(data)
  }

  // 결제 모달창에서 받은 유저 정보 받아오기
  const updateOrderUserData = (data: TypeOrderUserData) =&gt; {
    setOrderUserData(data)
  }

  // PG사에 보낼 데이터만들기
  const createPaymentData = () =&gt; {
    if (!orderData || !orderUserData) {
      console.error(&#39;주문 데이터 또는 사용자 데이터가 누락되었습니다.&#39;)
      return
    }

    const firstProductName = orderData.products[0]?.name
    const additionalProductCount = orderData.products.length - 1
    const paymentName =
      additionalProductCount &gt; 0
        ? `${firstProductName} 외 ${additionalProductCount}개`
        : firstProductName

    const paymentData: paymentDataProps = {
      ...orderUserData,
      buyer_email: orderUserData.buyer_email || &#39;&#39;,
      pg: &#39;html5_inicis&#39;, // KG 이니시스
      pay_method: &#39;card&#39;,
      merchant_uid: `merchant_${new Date().getTime()}`, // 고유 주문번호
      name: paymentName, // 구매 상품명
      amount: orderData.total_amount, // 총 결제 금액
    }
    return paymentData
  }
  // PG사에 결제 요청 보내기
  const handlePayment = () =&gt; {
    const paymentData = createPaymentData()
    if (paymentData &amp;&amp; orderData) {
      startPayment(paymentData, orderData, () =&gt; {
        setOrderData(null)
        setOrderData(null)
        closeModal()
        clearCart()
        closeCart()
      })
    } else {
      console.error(&#39;결제 데이터 또는 주문 데이터가 누락되었습니다&#39;)
    }
  }

return (
    &lt;PaymentContext.Provider
      value={{
        isOpen,
        openModal,
        closeModal,
        updateOrderData,
        updateOrderUserData,
        handlePayment,
        orderUserData,
      }}
    &gt;
      {children}
    &lt;/PaymentContext.Provider&gt;
  )

}</code></pre>
<p>결제모달창</p>
<pre><code class="language-javascript">📂 PaymentModal.tsx

const PaymentModal = () =&gt; {
  const paymentContext = usePayment()
  if (!paymentContext) {
    return
  }
  const { closeModal, updateOrderUserData, handlePayment, orderUserData } =
    paymentContext

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm&lt;TypeOrderUserData&gt;()

  // 입력값을 PG사에 보낼 data로 사용하기 위해 PaymentProvider의 updateOrderUserData로 전달
  const onSubmit = (data: TypeOrderUserData) =&gt; {
    updateOrderUserData(data)
    reset()
  }

  useEffect(() =&gt; {
    if (orderUserData) {
      handlePayment()
    }
  }, [orderUserData])
  // 이하 생략
}  </code></pre>
<h3 id="고민한-부분">고민한 부분</h3>
<p>장바구니에 상품을 담고서 결제를 진행하는 과정을 어떻게, 어디서 코드를 분리하고 실행할 지 구조적인 부분에서 고민을 많이했다. 최대한 Provider에는 state로 관리중인 상태와 그 상태를 업데이트하는 함수들로 구성했다.</p>
<p>그렇기에 PG사의 결제를 요청하고 결제가 성공하면 DB에 ORDER 데이터를 업데이트하는 <strong><code>startPayment</code></strong> 함수를 provider에 두지 않고 따로 작성했다.</p>
<p><strong><code>startPayment</code></strong> 는 결제 요청시 보낼 데이터와, 성공했을때 실행할 콜백함수를 받는 구조이다. 이 함수에 데이터와 결제 요청이 성공했을 때 실행할 함수들은 <strong><code>paymentProvider의</code></strong> <strong><code>handlePayment</code></strong> 함수가 담당하고 있다.</p>
<pre><code class="language-javascript">📂 PaymentContext.tsx
// PG사에 결제 요청 보내기
  const handlePayment = () =&gt; {
    const paymentData = createPaymentData()
    if (paymentData &amp;&amp; orderData) {
      startPayment(paymentData, orderData, () =&gt; {
        setOrderData(null)
        setOrderData(null)
        closeModal()
        clearCart()
        closeCart()
      })
    } else {
      console.error(&#39;결제 데이터 또는 주문 데이터가 누락되었습니다&#39;)
    }
  }</code></pre>
<pre><code class="language-javascript">📂 payment.ts

import { collection, doc, setDoc } from &#39;firebase/firestore&#39;
import { db } from &#39;@/firebase&#39;
import { paymentDataProps, TypeOrderData } from &#39;@/types/common&#39;

let initialized = false
const IMP = (window as any).IMP
const initialzeIMP = () =&gt; {
  if (!initialized) {
    IMP.init(&#39;imp30282078&#39;)
    initialized = true
  }
}

// PG사 KG 이니시스에 결제 요청
const startPayment = (
  paymentData: paymentDataProps,
  orderData: TypeOrderData,
  onSuccess: () =&gt; void
) =&gt; {
  initialzeIMP()

  IMP.request_pay(paymentData, function (response: any) {
    if (response.success) {
      alert(&#39;결제가 완료되었습니다.&#39;)
      onSuccess()

      const orderDataDB = async () =&gt; {
        try {
          const orderCollection = collection(db, &#39;orders&#39;)
          const docRef = doc(orderCollection) // 새 문서 참조 생성
          await setDoc(docRef, {
            ...orderData,
            order_id: docRef.id,
          })
        } catch (error) {
          alert(&#39;결제가 실패하였습니다.&#39;)
          console.error(&#39;주문 실패&#39;, error)
        }
      }
      orderDataDB()
    } else {
      console.error(&#39;결제 실패&#39;, response.error)
    }
  })
}

export default startPayment</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[취업 리부트 코스 6주차 WIL]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-6%EC%A3%BC%EC%B0%A8-WIL</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-6%EC%A3%BC%EC%B0%A8-WIL</guid>
            <pubDate>Tue, 30 Apr 2024 14:56:13 GMT</pubDate>
            <description><![CDATA[<p>이번 주 항해 취업 리부트코스에서 내가 구현한 기능은 무엇인가요?</p>
<ul>
<li>카테고리 페이지에서 무한 스크롤 적용과 최신순, 가격순 정렬</li>
<li>상품 상세 모달창</li>
<li>장바구니 구현</li>
</ul>
<p>해당 기능을 구현하기 위해, 어떤 기술적 의사결정을 거쳤나요?</p>
<ul>
<li>이전에는 장바구니를 전역 상태로 사용하기 위해 recoil를 사용한 적이 있었다. 이번 구현 요구 사항에서는 Context API를 요구했기에 Context API와 provider함수, useState로 장바구니 상태 관리에 사용했다. 상태가 복잡하지 않기에 context API를 사용해도 괜찮다고 생각했다. 최적화 부분에서 단점이 있기에 memo와 같은 함수를 추후에 적용해보려고 한다.</li>
</ul>
<h4 id="모든-상품을-하나의-컬렉션에-저장할지">모든 상품을 하나의 컬렉션에 저장할지?</h4>
<p>잠깐 고민했던 부분은 firebase에 상품을 저장할때 카테고리 별로 나눌지, 통으로 저장할지였다. 데이터가 많지 않기에 오히려 관리하기 편하도록 통으로 저장하는 쪽을 택했다.</p>
<p>상품 데이터를 파이어스토어에 저장할 때 카테고리별로 별도의 컬렉션을 만들지, 아니면 한 컬렉션(<strong><code>products</code></strong>)에 모두 저장하고 <strong><code>category</code></strong> 필드로 필터링할지 결정하는 것은 데이터 관리와 조회 성능에 영향을 미친다.</p>
<h4 id="한-곳에-관리할-때의-장점">한 곳에 관리할 때의 장점</h4>
<ol>
<li><strong>단순한 데이터 구조</strong>: 모든 상품 데이터가 하나의 컬렉션에 있기 때문에 데이터 구조가 단순, 관리가 용이.</li>
<li><strong>효율적인 쿼리 수행</strong>: 특정 카테고리에 대한 쿼리를 <strong><code>category</code></strong> 필드를 기준으로 필터링하여 수행할 수 있으므로, 복잡한 쿼리 로직 없이도 원하는 데이터를 효율적으로 조회.</li>
<li><strong>무한 스크롤 구현 용이</strong>: 리액트 쿼리와 같은 데이터 페칭 라이브러리를 사용할 때, 한 컬렉션에서 모든 데이터를 불러온 후 프론트엔드에서 필요에 따라 무한 스크롤로 데이터를 로드 가능.</li>
</ol>
<hr>
<h2 id="트러블-슈팅-문제와-해결">트러블 슈팅? 문제와 해결</h2>
<h3 id="구조-변경에-따라-무한-스크롤-적용-대상-변경">구조 변경에 따라 무한 스크롤 적용 대상 변경</h3>
<p>홈 화면에서는 카테고리별로 4개의 상품만 보여주고, 더보기 버튼을 통해 카테고리 페이지로 이동해서 무한스크롤을 적용해야 했다.</p>
<p>첫 화면에서 사용할 useQuery와 카테고리 페이지에서 사용할 useInfiniteQuery를 구분해서 따로 만들었다.</p>
<p>그리고 무한스크롤에서 중요한 점은 기준점을 어디로 삼고 코드상의 위치를 어디에 두는지 중요한것같다. 카테고리 페이지로 이동하자마자 스크롤을 내린것처럼 네트워크 요청이 한번에 전달되거나, 스크롤을 한번 내렸는데 계속 내린것처럼 모든 상품을 한번에 가져오는 문제가 있었다.</p>
<p><strong><code>ref</code></strong> 요소가 어떻게 배치되고, 뷰포트에 어떻게 드러나는지에 따라 네트워크 요청의 동작이 달라지기에 중요하다.</p>
<p>추가로 ref props의 div가 data를 map 메서드로 보여주고 있는 태그 내부에 있어야 작동한다.</p>
<p>작동 안되는 예시</p>
<pre><code class="language-jsx">&lt;div&gt;
    &lt;div&gt;
      {data?.pages &amp;&amp;
        data?.pages.flatMap((page) =&gt;
          page.products.map((product) =&gt; (
            &lt;ProductCard data={product} key={product.id} /&gt;
          ))
        )}
    &lt;/div&gt;
    &lt;div ref={hasNextPage ? ref : undefined} /&gt;
&lt;/div&gt;</code></pre>
<p><code>&#39;react-intersection-observer&#39;</code> 를 이용해서 ref 요소를 지정했는데 잘못됐던 코드와 수정한 코드이다.</p>
<ul>
<li>스크롤 한번에 나머지 데이터 전부 로드</li>
</ul>
<pre><code class="language-jsx">import { useInView } from &#39;react-intersection-observer&#39;

&lt;div&gt;
    {data?.pages &amp;&amp;
      data?.pages.flatMap((page) =&gt;
        page.products.map((product) =&gt; (
          &lt;ProductCard data={product} key={product.id} /&gt;
        ))
      )}
    {isFetchingNextPage ? (
      &lt;p&gt;Loading more...&lt;/p&gt;
    ) : (
      hasNextPage &amp;&amp; &lt;div ref={ref} /&gt;
    )}
&lt;/div&gt;</code></pre>
<p><strong><code>ref</code></strong>가 항상 활성화되어 있기 때문. <strong><code>ref</code></strong> 요소가 항상 뷰포트에 보이게 배치되어 있다면, 스크롤이 이 요소를 지날 때마다 계속해서 데이터 로드된다. <strong><code>inView</code></strong> 상태가 <strong><code>true</code></strong>가 되고, 이 상태를 활용하여 추가 데이터를 요청하기 때문이다.</p>
<p>수정한 코드</p>
<pre><code class="language-jsx">&lt;div&gt;
    {data?.pages &amp;&amp;
      data?.pages.flatMap((page) =&gt;
        page.products.map((product) =&gt; (
          &lt;ProductCard data={product} key={product.id} /&gt;
        ))
      )}
    &lt;div ref={hasNextPage ? ref : undefined} /&gt;
    {isFetchingNextPage &amp;&amp; &lt;p&gt;Loading more...&lt;/p&gt;}
  &lt;/div&gt;
&lt;/div&gt;</code></pre>
<h3 id="중복-네트워크-요청-에러">중복 네트워크 요청 에러</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/df910f8b-728e-4283-b0cb-107b9ff2d2dc/image.png" alt=""></p>
<p>노란 부분을 보면 Last visibel이 없고 pages: Array(17)은 마지막에 해당하는 데이터인데, 화면을 누르거나 하는 동작을 하면 처음부터 다시 요청을 하고 있음</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/5548fa19-5a4c-48f8-8b9d-41f0bdedf44e/image.png" alt=""></p>
<p>요청이 계속 쌓인걸 볼수있다.</p>
<p>왜그럴까? 이유를 찾아보니 페이지 내에서 동작을 할때는 중복으로 요청이 안되는데 탭을 옮겼다가 다시 누르거나, 개발자 도구에서 콘솔이나 네트워크 탭을 누르면 처음부터 데이터를 불러오길래 코드상 문제가 아니라 다른 문제인 것 같아서 찾아보니 리액트 쿼리의 기본 기능이었다. 데이터를 새로 요청할 필요가 없어서 <code>refetchOnWindowFocus: false</code>를 추가해서 방지했다.</p>
<pre><code class="language-jsx">export const useQueryProducts = () =&gt; {
  return useInfiniteQuery({
      // ... 중략
    refetchOnWindowFocus: false,
  })
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[취업 리부트 코스 5주차 WIL]]></title>
            <link>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-5%EC%A3%BC%EC%B0%A8-WIL</link>
            <guid>https://velog.io/@hisy4429_sun/%EC%B7%A8%EC%97%85-%EB%A6%AC%EB%B6%80%ED%8A%B8-%EC%BD%94%EC%8A%A4-5%EC%A3%BC%EC%B0%A8-WIL</guid>
            <pubDate>Tue, 23 Apr 2024 15:23:10 GMT</pubDate>
            <description><![CDATA[<p>개인 프로젝트 주차가 시작되었다!</p>
<p>프론트엔드의 주제로는 SNS, 커뮤니티, 커머스가 있었다. 커머스가 할 것도 많고 난이도가 높아서 커머스를 선택했다! 추후에 주차별 기능 구현 과제에 결제 SDK 연결도 있어서 난이도가 있는 것 같다. 참고로 프로젝트의 기본 셋업 구성을 간략히 소개하면 React와 Firebase이다.</p>
<p>항해의 취업리부트코스에서 개인프로젝트 1주차의 개요를 보면 다음과 같다.</p>
<blockquote>
<p>안녕하세요, 1주차 과제에 참여하신 여러분을 환영합니다! 
앞으로 각 주차 별 과제들을 수행하며 자신만의 e-커머스 플랫폼을 개발할 예정입니다.
첫 과제로는 프론트엔드 개발을 위한 UI/UX 와이어프레임 작업과 프로젝트 개발을 위한 환경 셋팅, 모든 서비스에 기본이 되는 인증 시스템 구현을 개발하는 것이 목표입니다. </p>
</blockquote>
<p>프로젝트 시작 전 작업으로 본인이 만들 프로젝트의 와이어 프레임 작성과 유저 플로우 문서화가 있었다.</p>
<h3 id="와이어-프레임">와이어 프레임</h3>
<p>디자인 툴을 다룰 줄 모르고 피그마로도 세세하게 만들어서 사용 할 자신도 시간도 없었고 나만 볼 개인 프로젝트이기에 레퍼런스를 참고해서 이미지를 붙이는 형식으로 만들었다. 핀터레스트에 이미지가 많아서 마음에 드는걸로 pick!</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/6e4a0fbc-e14e-4b41-bf65-9b2a62b223dd/image.png" alt=""></p>
<h3 id="유저-플로우">유저 플로우</h3>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/1ab91f78-6cea-4326-b0cc-fb6c634d13e5/image.png" alt=""></p>
<h3 id="ui-컴포넌트-라이브러리">UI 컴포넌트 라이브러리</h3>
<p>shadcn/ui라는 컴포넌트 라이브러리를 참고해서 UI 컴포넌트에 필요한 경우 사용했다.
<a href="https://ui.shadcn.com/docs/components/accordion">shadcn/ui</a>
UI 라이브러리를 별로 사용해본적이 없어서 익숙하게 못쓰는 것 같다. shadcn/ui의 메인 화면에서 예시로 보여주는 화면을 나중에 한번 만들어보면서 UI 라이브러리로 개발 효율성을 올리는 연습을 해보면 좋을듯!</p>
<h3 id="구현-요구사항">구현 요구사항</h3>
<p>구현 요구사항이 자세히 제시된다. 베이직 기능 구현과 Advanced 구현 내용으로 나뉘는데 1주차에서는 베이직 기능 구현만으로 시간을 다 썼다. 추후에  Advanced 내용을 구현하는 것이 목표이다!</p>
<table>
<thead>
<tr>
<th>프로젝트 환경 셋팅</th>
<th>환경 세팅</th>
<th>4/17</th>
<th>체크</th>
</tr>
</thead>
<tbody><tr>
<td>파이어 베이스 구축 준비</td>
<td>환경 세팅</td>
<td>4/17</td>
<td>✅</td>
</tr>
<tr>
<td>와이어 프레임 작업</td>
<td>환경 세팅</td>
<td>4/18</td>
<td>✅</td>
</tr>
<tr>
<td>유저 플로우 문서화</td>
<td>환경 세팅</td>
<td>4/18</td>
<td>✅</td>
</tr>
<tr>
<td>페이지 라우팅 설계</td>
<td>Basic</td>
<td>4/19</td>
<td>✅</td>
</tr>
<tr>
<td>로그인 / 회원가입</td>
<td>Basic</td>
<td>4/20</td>
<td>✅</td>
</tr>
<tr>
<td>상품 CRUD</td>
<td>Basic</td>
<td>4/22</td>
<td>✅</td>
</tr>
<tr>
<td>로그인 / 회원가입 -&gt; 소셜 로그인</td>
<td>Advanced</td>
<td>4/21</td>
<td></td>
</tr>
<tr>
<td>컴포넌트 및 라우트의 지연 로딩(Lazy Loading)</td>
<td>Advanced</td>
<td>4/23</td>
<td></td>
</tr>
</tbody></table>
<p>추가로 5분 기록 보드를 통해서 프로젝트를 하다가 메모할 사항이 생기면 사용했더니 1주차에 10개가 넘게 생겼다.</p>
<p><img src="https://velog.velcdn.com/images/hisy4429_sun/post/ac43f92e-a620-4bd0-af31-99681a9643fe/image.png" alt=""></p>
<p>조만간 firebase를 이용해서 CRUD를 한 내용으로 게시물을 추가해야겠다~</p>
<h3 id="추가할-사항">추가할 사항</h3>
<p>WIL 작성 가이드를 뒤늦게 봤다. 시간이 늦어서 추후에 내용을 추가하기로!!!</p>
<ul>
<li><p>이번 주 항해 취업 리부트코스에서 내가 구현한 기능은 무엇인가요?</p>
</li>
<li><p>해당 기능을 구현하기 위해, 어떤 기술적 의사결정을 거쳤나요?</p>
<ul>
<li><p>고민한 기술의 종류들에는 무엇이 있나요?</p>
<p>  ex ) A, B, C 중 B 선택</p>
</li>
<li><p>위 기술들별로 각각의 장단점이 있다면 무엇인가요?</p>
</li>
</ul>
</li>
<li><p>이번 주 겪은 트러블 슈팅이 있다면 무엇인가요?</p>
<ul>
<li>문제와 원인은 무엇이었나요?</li>
<li>해당 문제를 해결하기 위해 어떤 고민과 시도가 있었나요?</li>
<li>어떤 방법으로 트러블 슈팅을 해결했나요?</li>
</ul>
</li>
<li><p>이번 주 진행된 개인 프로젝트에서 얻은 인사이트는 무엇인가요?</p>
</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>