<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>youngeui_hong.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 10 Mar 2025 18:09:09 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>youngeui_hong.log</title>
            <url>https://velog.velcdn.com/images/youngeui_hong/profile/f1e168e8-d01f-447a-af66-51c60bf28f06/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. youngeui_hong.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/youngeui_hong" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Quill 에디터 커스텀 모듈 개발기]]></title>
            <link>https://velog.io/@youngeui_hong/Quill-%EC%97%90%EB%94%94%ED%84%B0-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%AA%A8%EB%93%88-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/Quill-%EC%97%90%EB%94%94%ED%84%B0-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%AA%A8%EB%93%88-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Mon, 10 Mar 2025 18:09:09 GMT</pubDate>
            <description><![CDATA[<h3 id="👋🏻-들어가며">👋🏻 들어가며</h3>
<p>최근 참여한 두 프로젝트에서 에디터를 구현할 일이 있었다.</p>
<p>첫 프로젝트에서 구현해야 했던 에디터는 간단한 텍스트 편집 정도 필요한 에디터여서 <code>contenteditable</code> 속성을 사용해서 에디터를 만들어야겠다 생각하고 진행했었다. 그런데 막상 개발을 진행하다보니 평소 에디터를 사용하며 당연하다 여겼던 요소들이 당연한 것이 아님을 깨달을 수 있었다. 커서 위치, 한글 조합, 편집 히스토리 관리 등 많은 요소들이 개발자가 관심을 기울여야 했던 요소였다.</p>
<p>에디터의 안정적인 동작을 위해선 굉장히 많은 작업이 필요함을 깨닫고, 다음에는 라이브러리를 활용하는 것이 좋겠다 생각했다. 그래서 두 번째 프로젝트에서는 에디터 라이브러리를 사용했는데, 나는 Quill을 사용해서 에디터를 구현했다. Quill은 에디터에 내가 원하는 기능이 없을 때는 내가 만든 모듈을 추가해서 커스텀할 수 있다는 게 가장 큰 장점이었다.</p>
<p>이번 프로젝트에서 구현해야 했던 주요 기능 중 하나가 각주 기능이었는데, 이 기능은 Quill에서 기본적으로 제공하는 기능이 아니어서 다른 개발자가 만든 모듈을 사용하거나 직접 개발해야 했다. 딱 우리 서비스에 맞는 각주 모듈을 하나 찾긴 했는데, 유료이고 소스코드가 공개되어 있지 않아서 사용하기가 어려웠다. 그래서 이번 프로젝트를 위해 각주 모듈을 개발했는데, 이 코드들을 정리해서 <code>quill-footnote</code>라는 이름의 npm 패키지로 배포했다. </p>
<p>이번 글에서는 이 경험에 대해서 정리해보고자 한다. 왜 Quill을 사용했는지, 그리고 커스텀 모듈을 사용하여 Quill에 내가 원하는 기능을 추가하는 방법 등에 대해 적어보았다.</p>
<h3 id="🤖-라이브러리-없이-에디터-개발하기">🤖 라이브러리 없이 에디터 개발하기</h3>
<p>첫 프로젝트의 경우 간단한 텍스트 편집만 필요했기 때문에 <code>contenteditable</code> 속성을 사용해서 에디터를 개발하기로 했다.
주로 <code>execCommand</code>, <code>queryCommandState</code>, <code>createElement</code>, <code>Selection</code> API, <code>Range</code> API를 사용해서 개발했다.</p>
<p>그런데 개발을 진행하면서 생각보다 고려해야 할 요소가 많다는 것을 깨달았다.
bold, italic 같은 간단한 포맷팅 작업은 쉽게 할 수 있지만, 요구사항이 복잡해질수록 구현이 까다로워졌다.
구현을 잘못하면 커서 위치가 갑자기 맨 앞으로 돌아가버리거나, IME(Input Method Editor) 조합을 방해해서 한글이 &quot;안녕&quot;이 &quot;ㅇㅏㄴㄴㅕㅇ&quot;과 같이 깨지는 등, 의외의 복병이 많았다.</p>
<p>더군다나 <code>document.execCommand</code> 같은 경우에는 크로스 브라우징 이슈가 있어서 deprecated된 상태였다. 
직접 DOM을 조작할 때와 달리 <code>document.execCommand</code>를 사용하면 undo buffer(= history)가 관리되기 때문에 간단하게 구현할 수 있는 점이 좋았지만, 아무래도 크로스 브라우징 이슈가 있다보니 자유롭게 사용하기는 어려웠다.</p>
<p>이런 경험을 하면서 에디터 라이브러리를 사용하는 이유를 너무나도 절실히 깨닫게 되었다.. 인력이 부족한 상황에서 라이브러리 없이 에디터를 개발하는 것은 너무나도 비효율적인 작업이었다.</p>
<h3 id="🤼-tinymce-vs-quill-👉🏻-quill-win">🤼 TinyMCE vs. Quill 👉🏻 Quill Win!</h3>
<p>다음 프로젝트에서는 각주나 테이블 편집 등 에디터 관련 요구사항이 좀 더 복잡해졌다. 지난 프로젝트에서 뼈저리게 느낀 바가 있는 만큼 이번에는 라이브러리를 사용해서 에디터를 개발하기로 했다.</p>
<p>npm trends를 살펴보면 ckeditor, draft-js, quill, tinymce가 많이 사용되는데, ckeditor와 draft-js 같은 경우에는 유지 보수가 되지 않은지 오래 돼서 TinyMCE와 Quill을 최종 후보로 간추렸다.</p>
<h4 id="tinymce">TinyMCE</h4>
<p>처음에는 TinyMCE가 제공하는 기본 기능이 훨씬 많아 유리해 보였다. 이미지 삽입과 테이블 편집 등 다양한 기능을 바로 사용할 수 있었다.
하지만 막상 실제 프로젝트에 사용할 수 있을지 확인해보니 제한사항이 많았다.
일단 기본적으로 오픈소스 라이브러리가 아니다보니까 커스텀할 수 있는 범위가 제한적이었다.
뿐만 아니라 에디터에 기본적으로 내장된 기능들이 많다보니 번들이 무겁고 로드하는데 꽤 많은 시간이 걸렸다. (Quill은 198.5kb, TinyMCE는 431.3kb)
처음에는 제공하는 기능이 많고 UI도 괜찮은데 왜 quill보다 npm trends가 낮은지 의아했는데, 이런 이유에서겠구나 싶었다.</p>
<h4 id="quill">Quill</h4>
<p>반면 Quill은 API Driven Design을 기반으로 개발된 오픈소스 라이브러리라 원하는 기능을 자유롭게 확장할 수 있어서 좋았다.
Quill은 TinyMCE와는 달리 HTML을 직접 편집하지 않고 JSON 형식으로 문서 정보를 관리하는 것이 특징적이었다.
Delta라는 JSON 포맷으로 변경사항을 관리하는데, 여기에는 텍스트 정보와 포맷팅 정보가 포함되어 있다.
HTML 자체를 편집하고 inline 스타일을 적용하는 방식이면 추후에 변경하기가 어려운데, Quill을 문서 정보를 JSON 기반으로 관리하기 때문에 요구사항의 변화에 유연하게 대처할 수 있는 점이 좋았다.
그래서 이번 프로젝트에서는 Quill을 사용하기로 결정했다.</p>
<h2 id="🪶-quill-커스텀하기">🪶 Quill 커스텀하기</h2>
<p>Quill은 내가 사용하고 싶은 기능이 기본 에디터에 없으면, 직접 커스텀 모듈을 만들어서 해당 기능을 추가할 수 있다. </p>
<h3 id="1️⃣-custom-blot-만들기">1️⃣ Custom Blot 만들기</h3>
<p>커스텀 모듈을 만들기 위해선 먼저 모듈에서 사용할 Blot을 만들어야 한다.</p>
<p>Blot은 Quill에서 DOM 노드를 표현하는 방식이라고 볼 수 있다.
Blot에는 Inline Blot, Block Blot, Embed Blot 등이 있는데, 이 중 하나를 상속해서 나만의 블롯을 만들면 된다.</p>
<p>Blot을 사용하지 않고 직접 DOM 노드를 조작하면 Quill이 관리하는 History에 제대로 반영되지 않기 때문에 Blot을 통해 편집해야 한다.</p>
<p>모든 블롯에는 반드시 <code>blotName</code>과 <code>tagName</code>을 작성해줘야 한다. Quill은 tagName을 바탕으로 DOM Node를 생성하고, 추후 DOM Node를 탐색할 때 blotName을 사용한다.</p>
<p>아래 코드는 각주 번호를 구현하기 위해 작성한 Blot 코드의 예시다.</p>
<pre><code class="language-js">import Embed from &quot;quill/blots/embed&quot;;

export interface FootnoteNumberValue {
  createdAt: string;
}

export class FootnoteNumber extends Embed {
  static blotName = &quot;footnote-number&quot;;
  static tagName = &quot;a&quot;;
  static className = &quot;footnote-number&quot;;

  static create(value: FootnoteNumberValue): HTMLElement {
    const node = super.create() as HTMLElement;
    const footnoteId = `footnote-${value.createdAt}`;
    node.setAttribute(&quot;id&quot;, footnoteId);
    node.setAttribute(&quot;class&quot;, &quot;footnote-number&quot;);
    node.setAttribute(&quot;data-index&quot;, &quot;0&quot;);
    node.setAttribute(&quot;data-createdAt&quot;, value.createdAt);
    node.setAttribute(&quot;contenteditable&quot;, &quot;false&quot;);
    node.textContent = `[0]`;
    return node;
  }

  static formats(node: HTMLElement) {
    return {
      id: node.getAttribute(&quot;id&quot;),
      index: node.getAttribute(&quot;data-index&quot;),
      createdAt: node.getAttribute(&quot;data-createdAt&quot;),
      footnote: true,
    };
  }

  static value(node: HTMLElement) {
    return {
      id: node.getAttribute(&quot;id&quot;),
      index: node.getAttribute(&quot;data-index&quot;),
      createdAt: node.getAttribute(&quot;data-createdAt&quot;),
    };
  }

  format(name: string, value: any): void {
    if (
      name === &quot;update-footnote-number-index&quot; &amp;&amp;
      value.id &amp;&amp;
      value.id === (this.domNode as HTMLElement).getAttribute(&quot;id&quot;)
    ) {
      (this.domNode as HTMLElement).setAttribute(&quot;data-index&quot;, value.index);
      this.domNode.textContent = `[${value.index}]`;
    }
  }
}
</code></pre>
<p>아래는 Blot을 정의하면서 자주 사용한 메서드들이다.</p>
<h4 id="◾️-static-createvalue-any-node">◾️ <code>static create(value?: any): Node</code></h4>
<p>블롯을 생성할 때 받아와야 할 인자들과 취해야 할 작업들을 정의할 수 있다.</p>
<h4 id="◾️-static-formatsdomnode-node">◾️ <code>static formats(domNode: Node)</code></h4>
<p>현재 DomNode의 포맷 정보를 반환하는 메서드다. 
특정 블롯에 대해 키보드 바인딩을 걸고 싶으면 여기에서 블롯을 식별할 수 있는 포맷 정보를 정의해서 반환하면 된다.</p>
<h4 id="◾️-formatformat-name-value-any">◾️ <code>format(format: name, value: any)</code></h4>
<p>블롯에 포맷을 적용할 때 사용하는 함수이다. 
format과 value는 필요에 따라 내가 자유롭게 정의해서 사용하면 된다.</p>
<h4 id="◾️-optimizecontext--key-string-any--void">◾️ <code>optimize(context: { [key: string]: any }): void</code></h4>
<p>optimize 메서드는 문서 업데이트가 완료된 다음에 Quill이 DOM 트리를 최적화하기 위해 호출하는 메서드다.
예를 들어 <code>&lt;p&gt;&lt;em&gt;기울&lt;/em&gt;&lt;em&gt;임체&lt;/em&gt;&lt;/p&gt;</code>라고 표현된 부분이 있으면 <code>&lt;p&gt;&lt;em&gt;기울임체&lt;/em&gt;&lt;/p&gt;</code>로 최적화한다.
때로는 Quill이 자동으로 실행하는 최적화가 내가 원하는 최적화가 아닐 수 있다.
그럴 때에는 블롯에서 optimize 메서드를 오버라이딩해서 수정해주면 된다.</p>
<h3 id="2️⃣-custom-module-만들기">2️⃣ Custom Module 만들기</h3>
<p>앞서 만든 Blot들을 바탕으로 실제로 편집하는 작업들은 Custom Module에 작성해주면 된다.
커스텀 모듈은 아래와 같이 <code>Module</code>을 상속한 클래스로 만들어주면 된다.</p>
<pre><code class="language-ts">class FootnoteModule extends Module {
  //...
}</code></pre>
<p>그리고 커스텀 블롯들은 <code>register()</code> 메서드에서 등록해줘야 사용할 수 있다.</p>
<pre><code class="language-ts">class FootnoteModule extends Module {
  static register(): void {
    Quill.register(FootnoteNumber);
    Quill.register(FootnoteDivider);
    //...
  }
}</code></pre>
<h4 id="undoredo-관련-history-관리">undo/redo 관련 History 관리</h4>
<p>커스텀 모듈을 개발할 때 유의해야 했던 부분은 undo/redo가 매끄럽게 진행될 수 있도록 히스토리 스택을 관리하는 것이었다.</p>
<p>예를 들어 각주를 추가할 때는 1) 본문에 각주번호를 추가하는 작업과 2) 본문 하단에 각주 내용을 추가하는 작업이 동시에 실행되어야 한다. 반대로 실행취소를 할 때도 각주 번호와 각주 내용은 <strong>동시에</strong> 삭제되어야 한다.
그런데 히스토리 스택에 이 두 작업이 별개의 작업으로 들어가면, cmd + z를 눌러 실행 취소를 했을 때 각주 번호만 삭제되고 각주 내용만 남아있는 이상한 상황을 목격하게 된다.</p>
<p>Quill은 <code>updateContents(delta: Delta, source: string = &#39;api&#39;): Delta</code>를 하나의 작업 단위로 인식하기 때문에, 히스토리 그룹핑이 필요한 경우에는 <code>updateContents</code> api를 여러 번 호출하지 않고 한 번만 호출하도록 했다.
이를 위해선 편집 내용을 하나의 Delta로 모으는 것이 필요한데, Delta의 compose 메서드를 사용하면 여러 Delta를 하나의 Delta로 병합할 수 있다.</p>
<p>그런데 구현 상 <code>updateContents</code>를 한 번만 실행하는 것이 어려운 경우가 있다. 이럴 때는 History 모듈의 <code>cutoff()</code> 메서드가 유용했다.</p>
<h3 id="3️⃣-키보드-바인딩-수정하기">3️⃣ 키보드 바인딩 수정하기</h3>
<p>때로 키보드 키의 효과를 수정하고 싶을 때가 있다. 예컨대 Backspace 키로 각주번호를 지우면 각주 내용도 함께 삭제되게 하거나, 테이블 셀은 Backspace 키로 삭제할 수 없게 막는 등으로 말이다. </p>
<p>이럴 때는 Quill의 키보드 바인딩을 수정해주면 된다.
키보드 바인딩 정보는 Quill 에디터를 생성할 때 <code>modules.keyboard.bindings</code> 옵션에 전달해주면 된다.</p>
<p>유의해야 할 점은 Backspace, Enter 키 등은 기본적으로 셋팅되어 있는 바인딩이 있기 때문에, 내가 새롭게 정의한 바인딩을 추가해도 우선순위 상에서 밀려서 적용이 안 될 수가 있다. </p>
<p>이럴 때는 키보드 바인딩을 적용할 포맷을 명시해주는 게 효과적이었다.
아래와 같이 format을 구체적으로 명시하면 기본 바인딩보다 내가 정의한 바인딩이 우선적으로 적용되었다.</p>
<pre><code class="language-ts">import Quill from &quot;quill&quot;;
import { FootnoteModule } from &quot;@src/module&quot;;

export const footnoteKeyboardBindings = {
  footnoteBackspace: {
    key: &quot;Backspace&quot;,
    format: [&quot;footnote&quot;],
    handler: function (this: { quill: Quill }, range: any): boolean {
      const [leaf] = this.quill.getLeaf(range.index);
      if (leaf?.statics?.blotName === &quot;footnote-number&quot;) {
        const footnoteModule = this.quill.getModule(
          &quot;footnote&quot;,
        ) as FootnoteModule;
        footnoteModule.deleteFootnote(leaf);
        return false;
      }
      return true;
    },
  },

  footnoteEnter: {
    key: &quot;Enter&quot;,
    format: [&quot;footnote-row&quot;],
    handler: function (this: { quill: Quill }, range: any): boolean {
      const [line] = this.quill.getLine(range.index);
      return line?.statics?.blotName !== &quot;footnote-row&quot;;
    },
  },
};
</code></pre>
<h3 id="💻-관련-코드">💻 관련 코드</h3>
<p>이번에 Quill에서 각주를 작성할 수 있도록 <code>quill-footnote</code> 라이브러리를 만들어보았다. Quill Custom Module과 관련해서 자세한 코드는 아래 GitHub에서 확인할 수 있다.</p>
<p><a href="https://github.com/YoungeuiHong/quill-footnote">[GitHub] quill-footnote</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[항해99] 세상을 구하는 AI 해커톤, 2024 항해커톤 후기]]></title>
            <link>https://velog.io/@youngeui_hong/2024-%ED%95%AD%ED%95%B4%EC%BB%A4%ED%86%A4-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/2024-%ED%95%AD%ED%95%B4%EC%BB%A4%ED%86%A4-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Sun, 09 Jun 2024 14:45:25 GMT</pubDate>
            <description><![CDATA[<h2 id="👋🏻-들어가며">👋🏻 들어가며</h2>
<p>2024 항해커톤의 주제는 <strong>오픈소스 AI를 활용해 사회의 문제를 해결하는 서비스 만들기</strong>였다.
이번 주제로 꼭 만들어보고 싶은 서비스가 있어서 친구들과 함께 항해99 해커톤에 참여하게 되었다. </p>
<h2 id="👀-기획의도">👀 기획의도</h2>
<p>이번 해커톤에서 우리가 해결하고자 한 사회 문제는 <strong>디지털 소외</strong> 문제였다.
디지털 소외를 단적으로 보여주는 사례가 바로 기차표 예매라고 생각한다.</p>
<p>요즘은 다들 온라인으로 기차표를 예매하기 때문에, 창구에선 표를 구하기 어려운 경우가 많다.
그래서 온라인 예매를 어려워 하는 어르신들은 표를 구하지 못해 입석으로 서서 가거나, 표가 나올 때까지 기차역에서 몇 시간을 기다리시기도 한다.</p>
<p>우리는 이러한 문제를 해결하기 위해 노인과 같은 디지털 약자들도 손쉽게 이용할 수 있는 KTX 예매 서비스를 개발하기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/f65e083c-ebec-4135-9831-54bc5a10f15a/image.png" alt=""></p>
<h2 id="🧐-어떻게-해결할-수-있을까">🧐 어떻게 해결할 수 있을까?</h2>
<blockquote>
<p><strong>어르신들은 온라인 예매를 왜 어려워하실까? 왜 창구에서 예매하는 걸 더 편하게 생각하실까?</strong></p>
</blockquote>
<p>현재 코레일 웹 사이트의 경우 기차 예매 뿐만 아니라 여행 상품 등 다양한 기능을 담고 있기 때문에 화면이 복잡한 편이다.
IT에 익숙한 우리들은 예매를 하려면 어디를 클릭해야 할지 당연하게 느껴지지만, IT에 익숙하지 않은 분들에게는 어려운 화면일 수 있다.
그래서 시간과 품이 들더라도 창구로 찾아가서 직원에게 물어보는 것을 더 편하게 느끼시는 것으로 보인다.</p>
<p>그렇다면 <strong>온라인을 통해 예매하더라도 마치 창구 직원과 대화하는 것처럼</strong> 기차표를 예매할 수 있다면 현재의 문제를 좀 더 개선할 수 있지 않을까?
우리는 이러한 아이디어를 바탕으로 <strong>대화형 인터페이스</strong>를 갖춘 서비스를 개발하기로 하였다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/0ed69a76-8195-49b2-8fec-a0fe728a8a5b/image.png" alt=""></p>
<h2 id="💬-대화형-인터페이스-구축하기">💬 대화형 인터페이스 구축하기</h2>
<p>대화형 인터페이스를 구축하기 위해 우리는 <strong>Chat GPT</strong>를 사용하기로 했다.</p>
<h3 id="👂🏻-speech-to-text">👂🏻 Speech to Text</h3>
<p>먼저, 사용자의 음성을 인식하기 위해 <a href="https://www.npmjs.com/package/react-speech-recognition"><code>react-speech-recognition</code></a> 라이브러리를 사용했다. 리액트에서 Web Speech API를 사용하기 좋도록 랩핑한 라이브러리이다. </p>
<h3 id="🎤-text-to-speech">🎤 Text to Speech</h3>
<p>Chat GPT의 응답을 음성으로 재생하는 데에는 OpenAI의 <a href="https://platform.openai.com/docs/guides/text-to-speech">Text to Speech</a> 기능을 사용했다. 처음에는 Web Speech API의 <code>SpeechSynthesisUtterance</code>를 사용해서 텍스트를 음성으로 변환했는데, 음성 변환이 너무 부자연스러워서 요금이 좀 들긴 하지만 OpenAI의 기능을 사용하기로 했다. </p>
<h3 id="📣-function-calling">📣 Function Calling</h3>
<p>Chat GPT를 통해 기차표를 조회하고 예매하려면 외부 함수를 호출해야 하기 때문에 Function Calling 기능을 사용했다.</p>
<p>Function Calling 기능을 사용하려면 <code>openai.chat.completions.create</code>로 Chat GPT에게 질문을 보낼 때, <code>tools</code> 프로퍼티에 호출해야 하는 함수의 내용 (함수명, 파라미터 등)을 담아서 보내면 된다. 그러면 Chat GPT가 함수의 호출이 필요하다고 판단되는 시점에  <code>tool_calls</code> 프로퍼티에 호출해야 하는 함수의 이름과 파라미터 정보를 담아서 응답을 보내주는데, 이 정보를 활용해서 함수를 호출하면 된다.</p>
<p>함수의 파라미터를 정의할 때에는 <code>description</code>에서 <code>출발 시간 (format: hhmmss)</code>와 같이 파라미터 내용과 형식을 Chat GPT에게 구체적으로 알려주는 것이 도움이 되었다.</p>
<pre><code class="language-typescript">async function askChatGpt() {
  // 인식된 음성을 대화내역에 추가
  messages.push({
    role: &#39;user&#39;,
    content: finalTranscript,
  });

  // Chat GPT API로 요청 보내기
  const response = await createChatCompletions(messages);

  // Chat GPT 응답 처리
  const responseMessage = response.choices[0].message;
  await handleResponse(responseMessage);
}

async function handleResponse(responseMessage: any) {
  // Chat GPT가 Function Calling을 요청하는 경우
  if (responseMessage.tool_calls) {
    messages.push(responseMessage);

    for (const toolCall of responseMessage.tool_calls) {
      // Function Call 파싱
      const { functionName, parameters } = parseFunctionCall(toolCall)?.[0];

      await handleToolCall(functionName, parameters, toolCall.id, toolCall.function.name);
    }
  }
}

async function handleToolCall(functionName: string, parameters: any, toolCallId: string, functionNameDisplay: string) {
  switch (functionName) {
    case &#39;saveTrainRoute&#39;:
      // 출발지와 도착지 정보 저장
      break;
    case &#39;saveDepartureTime&#39;:
      // 출발 시간 저장 후 예매 가능한 열차 조회
      break;
    case &#39;reserveTrain&#39;:
      // 열차 예매
      break;
    case &#39;goToPaymentPage&#39;:
      // 결제 페이지 이동
      break;
    default:
      break;
  }
}</code></pre>
<h3 id="🔨-fine-tuning">🔨 Fine Tuning</h3>
<p>처음에는 Fine Tuning 없이 <code>gpt-3.5-turbo-1106</code> 모델을 사용해서 개발을 했었다. 그런데 기본 모델을 사용하다보니 여러 제약사항을 경험하게 됐다.</p>
<p>KTX 예매 서비스에 맞는 일관적인 대화체를 유지해야 하는데 상대방의 말에 따라 대화체가 바뀌는 문제도 있었고, 대화형 인터페이스다보니 응답이 너무 길면 안 되는데 쓸데없는 말을 한참 동안 주절주절 하는 경우도 있었다. 그리고 Function Calling이 필요한 시점에 <code>tool_calls</code> 응답이 오지 않는 등의 문제도 있었다.</p>
<p>이러한 문제점을 해결하기 위해 Fine Tuning을 통해 우리의 서비스에 최적화된 모델을 만들기로 하였다. 아래와 같이 우리가 원하는 대화 시나리오를 json 형식으로 정리해서 jsonl 파일로 만들고, 이 파일을 바탕으로 학습시켜 Fine Tuning 모델을 만들었다.</p>
<p>Fine Tuning 모델을 사용한 이후부터 대화체가 확연히 정리되고, Function Calling 오류도 줄어들었다.</p>
<pre><code class="language-javascript">{
  &quot;messages&quot;: [
    {
      &quot;role&quot;: &quot;assistant&quot;,
      &quot;content&quot;: &quot;안녕하세요. 어디에서 어디로 가는 열차를 찾으시나요?&quot;
    },
    {
      &quot;role&quot;: &quot;user&quot;,
      &quot;content&quot;: &quot;부산에서 서울로 가요.&quot;
    },
    {
      &quot;role&quot;: &quot;assistant&quot;,
      &quot;function_call&quot;: {
        &quot;name&quot;: &quot;saveTrainRoute&quot;,
        &quot;arguments&quot;: &quot;{\&quot;departure\&quot;:\&quot;부산\&quot;,\&quot;destination\&quot;:\&quot;서울\&quot;}&quot;
      }
    },
    {
      &quot;role&quot;: &quot;function&quot;,
      &quot;name&quot;: &quot;saveTrainRoute&quot;,
      &quot;content&quot;: &quot;true&quot;
    },
    {
      &quot;role&quot;: &quot;assistant&quot;,
      &quot;content&quot;: &quot;출발 시간은 언제인가요?&quot;
    },
    //... 중략
  ],
  &quot;functions&quot;: [
    {
      &quot;name&quot;: &quot;saveTrainRoute&quot;,
      &quot;description&quot;: &quot;기차 출발지와 도착지 정보를 저장하는 함수&quot;,
      &quot;parameters&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;properties&quot;: {
          &quot;departure&quot;: {
            &quot;type&quot;: &quot;string&quot;,
            &quot;description&quot;: &quot;출발지 (Departure)&quot;
          },
          &quot;destination&quot;: {
            &quot;type&quot;: &quot;string&quot;,
            &quot;description&quot;: &quot;도착지 (Destination)&quot;
          }
        },
        &quot;required&quot;: [
          &quot;departure&quot;,
          &quot;destination&quot;
        ]
      }
    },
    //... 중략
  ]
}
</code></pre>
<h2 id="🚆-예매-및-결제-기능-구현하기">🚆 예매 및 결제 기능 구현하기</h2>
<h3 id="🎫-열차-조회-및-예매-기능">🎫 열차 조회 및 예매 기능</h3>
<p>열차 조회 및 예매 기능에는 carpedm20님의 <a href="https://github.com/carpedm20/korail2">Korail Python Wrapper</a>를 사용했다.
이 기능을 사용할 수 있도록 Fast API를 사용해서 백엔드 서버를 구축했다.</p>
<h3 id="💵-결제-기능">💵 결제 기능</h3>
<p>아쉽게도 carpedm20님의 Korail Python Wrapper에는 결제 기능이 없어서 결제 기능을 직접 구현해야 했다.
결제를 어떻게 구현할지 고민하다가 Selenium WebDriver를 사용해서 신용카드 결제 매크로를 만들었다.</p>
<h3 id="💳-신용카드-ocr">💳 신용카드 OCR</h3>
<p>어르신들이 가장 많이 어려움을 겪는 부분이 아무래도 결제 기능이 아닐까 싶었다.
어떻게 하면 최대한 쉽게 결제하실 수 있도록 구현할 수 있을까 고민하다가 신용카드 OCR 기능을 떠올리게 되었다.
그래서 신용카드 정보를 직접 입력하는 대신 카메라로 신용카드 뒷면 사진을 찍으면 카드 정보를 읽어올 수 있도록 구현했다.
이를 위해 Google Cloud의 Document AI로 신용카드 이미지 데이터를 학습시켜 OCR 기능을 구현했다.</p>
<h2 id="🎞️-시연-영상">🎞️ 시연 영상</h2>
<p>해커톤 현장에서는 시간이 부족해서 결제 기능 연동까지 보여드리지 못했다 🥹
대회가 끝나고 결제 기능까지 모두 연결하고, UI적으로 아쉬웠던 부분을 보완해서 아래와 같이 완성했다.</p>
<p><a href="https://youtu.be/_IbHw-469A4"><img src="https://velog.velcdn.com/images/youngeui_hong/post/64899375-8b6d-452e-864e-b65796b9ea2c/image.png" alt="항해커톤 시연영상"></a></p>
<p>이렇게 하고 코레일에 들어가보면 요렇게 열차표가 예매되어 있는 것을 확인할 수 있다!
(계속 테스트를 해보다가 서울에서 부산 가는 열차가 예매된 것을 깜빡해서 59000원을 날린 슬픈 일도 있었다,,,🥲)</p>
<img src="https://velog.velcdn.com/images/youngeui_hong/post/54ea21f5-c860-411b-ab93-bc5d2bc5d6f1/image.PNG" alt="Watch the video" width="300" margin="auto" />


<h2 id="💓-참여-소감">💓 참여 소감</h2>
<p>개발자가 된 이후로 해커톤에 꼭 한 번 참여해보고 싶었는데, 이번에 참여할 기회를 얻어서 너무 좋았다.</p>
<p>꼴딱 밤을 새는 것이 조금 힘들긴 했지만, 중간중간 럭키드로우 추첨도 있고, 포토부스에서 사진도 촬영할 수 있어서 즐겁게 참여할 수 있었다. 참가비도 없었는데 중간중간 맛있는 간식이랑 식사도 챙겨주셔서 감사했다 🥺</p>
<p>그리고 다른 팀의 발표를 들으면서 배울 수 있어서 좋았다. AI 기술을 접목해서 이렇게 다양한 서비스를 개발할 수 있구나 싶었다. 나도 앞으로 꾸준히 공부하면서 새로운 시도들을 많이 해봐야겠다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/37a917dd-26e8-424e-b14e-348ca2cea7e4/image.JPG" alt=""></p>
<p>인생 첫 해커톤, 너무 즐거운 경험이었어서 다음에 기회가 된다면 또 나가보고 싶다ㅎㅎ
우리 팀 정말 고생 많았구, 같이 할 수 있어서 넘 즐거웠구, 다음에 기회가 되면 또 같이 해커톤 나가봅시닷,,,💕</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/4c64a1d5-57bb-4f58-824a-0fcdda5ee526/image.JPG" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker + GitHub Actions + Google Cloud로 Next.js CI/CD 파이프라인 구축하기]]></title>
            <link>https://velog.io/@youngeui_hong/Docker-GitHub-Actions-Google-Cloud%EB%A1%9C-Next.js-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/Docker-GitHub-Actions-Google-Cloud%EB%A1%9C-Next.js-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 06 May 2024 08:37:05 GMT</pubDate>
            <description><![CDATA[<h2 id="1️⃣-nextjs-도커-이미지-빌드하기">1️⃣ Next.js 도커 이미지 빌드하기</h2>
<h3 id="nextconfigmjs"><code>next.config.mjs</code></h3>
<p>도커 이미지를 빌드하기에 앞서 next.js 빌드 결과물과 관련한 설정을 해줘야 한다.</p>
<p>나는 정적 export를 원하는 상황이 아니었으므로, <code>standalone</code> 옵션으로 설정해주었다.</p>
<pre><code class="language-js">const nextConfig = {
    output: &quot;standalone&quot;,
};

export default nextConfig;
</code></pre>
<h3 id="dockerfile"><code>Dockerfile</code></h3>
<p>다음으로 Next.js 애플리케이션을 도커 이미지로 빌드하기 위해 <code>Dockerfile</code>을 작성한다.</p>
<p>Next.js 공식 문서에서 제공하는 <a href="https://github.com/vercel/next.js/tree/canary/examples/with-docker">예제 코드</a>가 있어서 이를 참고해서 작성했다.</p>
<p><code>Dockerfile</code> 코드는 다음과 같이 크게 세 가지 스테이지로 이루어져 있다.</p>
<ul>
<li>1) 의존성 설치 스테이지</li>
<li>2) 빌드 스테이지</li>
<li>3) 프로덕션 이미지 실행 스테이지</li>
</ul>
<p>이처럼 <a href="https://docs.docker.com/build/building/multi-stage/">Multi-stage builds</a>를 사용하는 이유는 최종적으로 만들어지는 도커 이미지의 크기를 최소화하기 위함이다.</p>
<p>실제로 Multi-stage builds를 사용했을 때와 사용하지 않았을 때의 이미지 사이즈를 비교해보면 146.68MB와 2.51GB로 큰 차이가 존재함을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/c1ac55e0-d4ce-4658-bf7a-e27d591c55ce/image.png" alt=""></p>
<pre><code class="language-dockerfile"># 기본 이미지로 node:18-alpine 사용
FROM node:18-alpine AS base

# 1) 의존성 설치 스테이지
FROM base AS deps
# node:18-alpine은 musl libc를 사용하는데, musl libc와 glibc 간 호환성 문제가 있을 수 있기 때문에 libc6-compat 설치
RUN apk add --no-cache libc6-compat
# 작업 디렉토리를 /app으로 설정
WORKDIR /app

# 사용하는 패키지 매니저에 따라 의존성 설치
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm &amp;&amp; pnpm i --frozen-lockfile; \
  else echo &quot;Lockfile not found.&quot; &amp;&amp; exit 1; \
  fi


# 2) 빌드 스테이지
FROM base AS builder
# 작업 디렉토리를 /app으로 설정
WORKDIR /app
# node_modules 디렉토리 복사
COPY --from=deps /app/node_modules ./node_modules
# 소스코드 복사
COPY . .

# 소스코드 빌드
RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm &amp;&amp; pnpm run build; \
  else echo &quot;Lockfile not found.&quot; &amp;&amp; exit 1; \
  fi

# 3) 프로덕션 이미지 실행 스테이지
FROM base AS runner
# 작업 디렉토리를 /app으로 설정
WORKDIR /app

# NODE_ENV 환경 변수 설정
ENV NODE_ENV production

# 그룹 및 유저 생성
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 빌드된 자원 복사
COPY --from=builder /app/public ./public

# .next 디렉토리 권한 설정
RUN mkdir .next
RUN chown nextjs:nodejs .next

# 빌드 결과물 복사 및 파일 소유자 설정
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

# 사용자 설정
USER nextjs

# 3000번 포트를 외부로 노출
EXPOSE 3000

# 포트 환경 변수 설정
ENV PORT 3000

# 컨테이너가 시작될 떄 실행할 명령어 설정: next.js를 빌드한 결과물 중 하나인 server.js를 실행
CMD HOSTNAME=&quot;0.0.0.0&quot; node server.js</code></pre>
<h3 id="dockerignore"><code>.dockerignore</code></h3>
<p>도커 이미지에 필요하지 않은 파일들은 <code>.dockerignore</code>에 작성해준다.</p>
<pre><code>.github
.git
.idea
.next
.gitignore
node_modules
npm-debug.log
Dockerfile
README.md</code></pre><h2 id="2️⃣-google-cloud-설정하기">2️⃣ Google Cloud 설정하기</h2>
<p>앞서 도커 이미지 빌드를 위한 준비를 마쳤으니, 이제 빌드된 도커 이미지를 저장하고 실행할 환경을 마련해야 한다.</p>
<p>나는 Google Cloud의 Artifact Registry에 도커 이미지를 저장하고, Cloud Run을 통해 도커 이미지가 실행되도록 했다.</p>
<h3 id="artifiact-registry">Artifiact Registry</h3>
<p>먼저 도커 이미지를 관리하기 위해 Artifiact Registry에서 저장소를 생성해준다.</p>
<p>관리 리전으로는 <code>asia-northeast1</code>(도쿄)를 선택했다. </p>
<p><code>asia-northeast3</code>(서울) 리전도 있지만, 도쿄에 비해 비용이 비싸고, 도메인 매핑 기능을 사용할 수 없는 등 제약사항이 존재해서 <code>asia-northeast1</code>(도쿄)를 사용하기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/34d89b7a-721b-43cf-a967-ebde9af981e4/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/669eb5cd-c9be-4362-aa8c-2e598d2a6c82/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/c450e8e8-9b9e-495e-9794-4c7d45980985/image.png" alt=""></p>
<h3 id="iam">IAM</h3>
<p>GitHub Action에서 Google Cloud에 도커 이미지를 푸시하고 실행하려면 권한 설정이 필요하다. </p>
<p>이를 위해 <em>IAM 및 관리자 &gt; 서비스 계정</em> 페이지에서 서비스 계정을 생성한 뒤 JSON 타입의 비공개 키를 생성하고 다운 받았다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/3ca88ba4-f353-4829-8142-104bf0c89331/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/b930b42f-e5fa-4557-9eb0-6aa6f6d1ce1c/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/9c9f0547-e72a-4228-9a24-1b77601e85be/image.png" alt=""></p>
<h3 id="cloud-run">Cloud Run</h3>
<p>Cloud Run을 사용하면 Artifact Registry에 있는 도커 이미지를 가져와서 배포할 수 있고, 도메인 맵핑을 간편하게 구현할 수 있어서 좋았다.</p>
<p>우선 서비스를 하나 생성한 뒤, _커스텀 도메인 관리 버튼 &gt; 매핑 추가 버튼 &gt; Cloud Run 도메인 매핑 버튼_을 눌러 도메인을 매핑해준다. </p>
<p>매핑 추가 팝업에서 _Verify a new domain_을 선택하면 Google Search Console 사이트로 연결되는데, 여기에서 도메인 소유권 확인을 마친 다음, 다시 Google Cloud로 돌아와서 내가 사용하고자 하는 도메인을 선택해주면 된다.</p>
<p>이 절차를 마치면 DNS 레코드가 발급되는데, 나는 가비아에서 발급 받은 도메인을 사용하기 때문에 이 레코드들을 가비아 사이트에 작성해주었다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/80e9c556-815c-4f51-a426-dff6adc659ad/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/8b2f8c2f-3421-4b7b-86ea-1b0569631b6c/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/215c3e3f-3e69-4654-a141-01ecbb712af2/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/277141aa-bd1c-4a1c-acda-4f52c977a917/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/bbc45e04-48e3-40f5-915e-7e25799cec67/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/a159d061-d902-461a-a4d5-39c817e67382/image.png" alt=""></p>
<h2 id="3️⃣-github-actions-플로우-정의하기">3️⃣ GitHub Actions 플로우 정의하기</h2>
<p>이제 마지막으로 GitHub의 main 브랜치 코드가 변경되었을 때 자동으로 배포가 되도록 하는 설정만 추가해주면 된다.</p>
<h3 id="github-action-secret-설정">GitHub Action Secret 설정</h3>
<p>앞서 IAM에 발급 받은 JSON key나 region, registry 이름 등은 보안상 공개되지 않는 것이 좋기 때문에, YAML 파일에 직접 작성하지 않고 GitHub Action Secret에 있는 값을 불러와서 사용하도록 했다.</p>
<p>secret 값들은 <em>Settings &gt; Secrets and Variables &gt; Actions &gt; Repository Secrets &gt; New repository secret</em> 경로로 접속해서 등록해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/1d8585e4-0644-4513-9e0f-d91262f92b91/image.png" alt=""></p>
<h3 id="githubworkflowsgoogle-cloud-run-deployyml"><code>.github/workflows/google-cloud-run-deploy.yml</code></h3>
<p><code>.github/workflows</code>에 YAML 파일을 추가함으로써 GitHub Action에서 실행할 작업을 정의할 수 있다.</p>
<p>아래의 코드는 main 브랜치에 코드가 푸시되거나, 풀 리퀘스트가 머지될 때마다 도커 이미지를 빌드해서 Google Cloud의 Artifact Registry에 이미지를 푸시하고, Cloud Run에 배포되도록 하는 코드이다.</p>
<p>주의해야 할 점은 <code>{&#39;on&#39;: {pull_request: {branches: [main]}}}</code> 만 작성하면 아직 메인 브랜치에 풀 리퀘스트가 merge되지 않았는데도, Github Action이 실행되므로 <code>{&#39;on&#39;: {pull_request: {types: [closed]}}}</code>를 추가해주고 <code>{jobs: {dockerize-and-deploy: {if: github.event.pull_request.merged == true}}}</code> 조건을 걸어줘야 한다.</p>
<pre><code class="language-yml">name: Deploy to Google Cloud Run

env:
  SERVICE_NAME: giftogether

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
    types:
      - closed

jobs:
  dockerize-and-deploy:
    if: github.event.pull_request.merged == true

    runs-on: ubuntu-latest

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

      - name: Google Cloud Auth
        uses: &#39;google-github-actions/auth@v2&#39;
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          credentials_json: ${{ secrets.GCP_CREDENTIALS }}

      - name: Set up Cloud SDK
        uses: &#39;google-github-actions/setup-gcloud@v2&#39;

      - name: Configure Docker
        run: |
          gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev

      - name: Build and Push Docker Image
        run: |
          docker build -t &quot;${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}&quot; .
          docker push &quot;${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}&quot;

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy ${{ env.SERVICE_NAME }} \
            --image=${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }} \
            --region=${{ secrets.GCP_REGION }} \
            --platform=managed \
            --allow-unauthenticated</code></pre>
<h2 id="⚡️-도커-캐싱으로-빌드-속도-높이기">⚡️ 도커 캐싱으로 빌드 속도 높이기</h2>
<p>로컬에서 도커 이미지를 빌드해보면 두 번째 빌드부터는 캐싱된 내용이 있기 때문에 좀 더 빠른 속도로 이미지가 빌드됨을 확인할 수 있다.</p>
<p>하지만 GitHub Actions의 빌드는 매번 새로운 가상 환경에서 실행되기 때문에 도커 캐싱 기능을 사용하려면 별도의 설정을 추가해줘야 한다.</p>
<p>도커 캐싱 기능을 사용하려면, 먼저 <code>docker/setup-buildx-action@v3</code>를 사용하도록 <code>steps</code>에 아래 단계를 추가해준다. </p>
<pre><code class="language-yml">      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3</code></pre>
<p>그리고 캐싱된 내용을 사용할 수 있도록 <code>steps</code>에서 <code>name: Build and Push Docker Image</code> 단계를 아래와 같이 수정해준다. </p>
<pre><code class="language-yml">      - name: Build and Push Docker Image
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max</code></pre>
<p>그러면 빌드 속도가 얼마나 빨라지는지 확인해보자. </p>
<p>캐싱 기능을 사용하지 않을 때는 재배포를 해도 매번 새로 의존성을 설치하고 빌드하다보니 첫 배포와 비슷하게 <strong>4분 54초</strong>의 시간이 걸렸다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/81b77bc3-c4b5-4435-a2dd-a2068098e99e/image.png" alt=""></p>
<p>하지만 캐싱 기능을 추가하고 새로 배포를 해보면, <strong>2분 20초</strong>로 빌드 시간이 확연히 줄어든 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/ded0f71a-e850-48bf-9dd6-567dcab2d461/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/1ed20fa1-91ff-44f9-b9bc-63d8adfec131/image.png" alt=""></p>
<p>이와 관련해서 좀 더 자세한 내용은 <a href="https://fe-developers.kakaoent.com/2022/220414-docker-cache/#fn-1">GitHub Actions에서 도커 캐시를 적용해 이미지 빌드하기</a>에서 확인할 수 있다.</p>
<h2 id="🔐-런타임-환경-변수-설정하기">🔐 런타임 환경 변수 설정하기</h2>
<p>만약 <code>NEXT_PUBLIC_</code>으로 시작하는 런타임 환경변수를 사용하고 있다면 <code>Dockerfile</code>과 GitHub Action Flow에서 환경변수를 사용할 수 있도록 별도의 설정을 해줘야 한다.</p>
<p><code>.env</code>를 GitHub에 올리는 것은 보안상 좋지 않으므로 나는 <code>NEXT_PUBLIC_</code> 환경 변수를 GitHub Action Secret으로 등록해서 도커 이미지를 빌드할 때 가져다 쓸 수 있도록 했다.</p>
<p>우선 GitHub Action Flow의 도커 이미지 빌드를 정의하는 부분에서 아래와 같이 <code>build-args</code>를 추가해줘야 한다. 이는 도커 이미지를 빌드할 때 환경변수를 사용할 수 있도록 <code>Dockerfile</code>에 환경 변수를 전달해주는 코드이다.</p>
<p>🔻 <strong><code>google-cloud-run-deploy.yml</code></strong></p>
<pre><code class="language-yml">      - name: Build and Push Docker Image
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          build-args: |
            NEXT_PUBLIC_OPENAI_API_KEY=${{ secrets.NEXT_PUBLIC_OPENAI_API_KEY }}
          tags: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max</code></pre>
<p>다음으로 Dockerfile의 빌드 스테이지 부분 코드를 아래와 같이 수정해준다.
소스코드 빌드를 실행하기에 앞서 <code>build-args</code>를 통해 넘겨 받은 값을 환경 변수로 셋팅해주는 코드이다. </p>
<p>🔻 <strong><code>Dockerfile</code></strong></p>
<pre><code class="language-dockerfile"># 2) 빌드 스테이지
FROM base AS builder
# 작업 디렉토리를 /app으로 설정
WORKDIR /app
# node_modules 디렉토리 복사
COPY --from=deps /app/node_modules ./node_modules
# 소스코드 복사
COPY . .

ARG NEXT_PUBLIC_OPENAI_API_KEY
ENV NEXT_PUBLIC_OPENAI_API_KEY=$NEXT_PUBLIC_OPENAI_API_KEY

# 소스코드 빌드
RUN \
  if [ -f yarn.lock ]; then yarn run build; \
  elif [ -f package-lock.json ]; then npm run build; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm &amp;&amp; pnpm run build; \
  else echo &quot;Lockfile not found.&quot; &amp;&amp; exit 1; \
  fi
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[PWA와 Next.js를 사용해서 모바일 앱 만들기]]></title>
            <link>https://velog.io/@youngeui_hong/PWA%EC%99%80-Next.js%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/PWA%EC%99%80-Next.js%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Wed, 13 Mar 2024 12:06:06 GMT</pubDate>
            <description><![CDATA[<h2 id="👋🏻-들어가며">👋🏻 들어가며</h2>
<p>최근 사이드 프로젝트를 하나 하고 있는데, 이 프로젝트의 경우 모바일에서 접속할 가능성이 높기 때문에 PC보다는 모바일에 초점을 맞춰 개발하기로 했다.</p>
<p>PC와 모바일에서 모두 접속 가능하게 하면서도, 모바일에 최적화된 사이트를 어떻게 만들 수 있는 방법을 찾다가 프로그레시브 웹 앱(Progressive Web Apps, PWA)에 대해 알게 되었다.</p>
<p>PWA는 모바일 앱과 웹 사이트의 중간 형태로, 웹 개발을 통해 모바일 앱과 유사한 경험을 제공할 수 있다는 점이 매력적이었다. PWA를 사용하면 모바일 앱처럼 홈 화면에 아이콘을 추가하고, 푸시 알림을 받고, 오프라인 상태에서 작동하게 하는 것이 가능했다.</p>
<p>사이드 프로젝트에 적용하기에 앞서, 우선 간단한 TODO 앱을 만들면서 PWA 기능을 익혀보았다. 자세한 코드는 <a href="https://github.com/YoungeuiHong/react-deep-dive/tree/main/src/app/pwa-todo">GitHub</a>에서 확인할 수 있다.</p>
<h2 id="📱-manifest-앱-이름과-아이콘-설정하기">📱 manifest: 앱 이름과 아이콘 설정하기</h2>
<p>우선 web application manifest를 통해 앱 이름과 아이콘 등을 설정해줘야 PWA로 사용할 수 있다. web app manifest는 PWA가 기기에서 어떻게 동작해야 하는지 알려주는 JSON 파일이다.</p>
<p>manifest를 작성하지 않은 상태에서 Lighthouse 분석을 돌려보면 아래와 같이 PWA 설치가 불가능한 상태라고 뜬다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/c308da06-5cc1-4b77-b179-c631c980dbf1/image.png" alt=""></p>
<h3 id="manifestts"><code>manifest.ts</code></h3>
<pre><code class="language-typescript">import { MetadataRoute } from &quot;next&quot;;

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: &quot;PWA TODO&quot;,
    short_name: &quot;PWA TODO&quot;,
    theme_color: &quot;#f5f5f5&quot;,
    background_color: &quot;#f5f5f5&quot;,
    icons: [
      {
        src: &quot;app-icon/ios/192.png&quot;,
        sizes: &quot;192x192&quot;,
        type: &quot;image/png&quot;,
      },
      {
        src: &quot;app-icon/ios/512.png&quot;,
        sizes: &quot;512x512&quot;,
        type: &quot;image/png&quot;,
      },
      {
        src: &quot;app-icon/ios/192.png&quot;,
        sizes: &quot;192x192&quot;,
        purpose: &quot;maskable&quot;,
        type: &quot;image/png&quot;,
      },
      {
        src: &quot;app-icon/ios/512.png&quot;,
        sizes: &quot;512x512&quot;,
        purpose: &quot;maskable&quot;,
        type: &quot;image/png&quot;,
      },
    ],
    orientation: &quot;any&quot;,
    display: &quot;standalone&quot;,
    dir: &quot;auto&quot;,
    lang: &quot;ko-KR&quot;,
    start_url: &quot;/pwa-todo&quot;,
  };
}</code></pre>
<p>manifest 파일은 HTML의 <code>&lt;head&gt;</code> 안의 <code>&lt;link&gt;</code>에 파일의 경로를 작성해서 포함시킬 수 있는데, Next.js의 경우 <code>app</code> 디렉토리 안에 <code>manifest.ts</code> 파일을 두면 별도로 json 파일을 생성하고 <code>&lt;link&gt;</code>를 작성할 필요가 없었다.</p>
<p>위와 같이 <code>manifest.ts</code>를 작성했는데, PWA를 설정하기 위해 반드시 들어가야 하는 속성은 앱의 이름과 관련된 <code>name</code>과 아이콘과 관련된 <code>icons</code>이다.</p>
<p><strong>🔻 <code>icons</code></strong></p>
<p>앱 아이콘 이미지는 <a href="https://www.pwabuilder.com/imageGenerator">PWA Builder의 Image Genarator</a>를 사용하면 기기에 맞는 여러 사이즈로 만들 수 있어서 좋았다. 그리고 기기에서 아이콘이 어떻게 보일지 미리 보고 싶을 때는 <a href="https://maskable.app/">maskable</a> 사이트를 사용하니 편했다.</p>
<p><code>purpose</code> 필드에 아이콘이 <code>maskable</code>한지를 여부를 넣어주는데, 아이콘이 maskable하다는 것은 여러 모양에 적용 가능함을 의미한다. 예를 들어 안드로이드 기기에서 앱 아이콘이 원형인 경우 maskable하지 않은 경우 왼쪽과 같이 표시될 것이고, maskable한 경우 오른쪽과 같이 뜰 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/0492bf47-a840-4f45-8ed3-dd5b2b4528e4/image.png" alt=""></p>
<p><strong>🔻 <code>display</code></strong></p>
<p>다른 속성들도 살펴보면 <code>display</code> 의 경우 앱이 어떻게 표시될지를 나타내는 값인데, PWA의 경우 <code>standalone</code>으로 하는 것이 일반적이다. <code>fullscreen</code>으로 설정하면 아래 그림의 오른쪽과 같이 표시되는데, 안드로이드 기기에서는 적용 불가능하고, iOS 기기에서만 적용 가능하다. </p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/7de6a304-36fc-41c5-849b-3227044eab99/image.png" alt=""></p>
<p><strong>🔻 <code>theme_color</code></strong></p>
<p><code>standalone</code>으로 하면 위 그림의 왼쪽처럼 status bar 뒤가 비게 되는데 이 영역의 색상은 <code>theme_color</code> 속성을 통해 설정할 수 있다.</p>
<p>이렇게 manifest 설정을 마치고 Lighthouse 분석을 다시 돌려보면 아래와 같이 PWA 설치가 가능함을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/457c356d-d739-4fc4-a93a-0ec7c3b9b1b4/image.png" alt=""></p>
<h2 id="🔔-푸시-알림-기능-구현하기">🔔 푸시 알림 기능 구현하기</h2>
<h3 id="푸시-알림-개요">푸시 알림 개요</h3>
<p>푸시 알림 기능 구현에 필요한 작업은 크게 아래와 같이 나누어볼 수 있다.</p>
<ol>
<li>클라이언트로부터 푸시 알림 동의 받기</li>
<li>서버에 클라이언트의 구독 정보를 저장하기</li>
<li>서버로부터 푸시 알림이 오면 Notification을 띄우기
 3-1. Service Worker 등록하기
 3-2. push 이벤트 핸들러 등록하기
 3-3. 서버에서 클라이언트로 푸시 알림 보내기</li>
</ol>
<h3 id="푸시-알림-동의-받기">푸시 알림 동의 받기</h3>
<p>푸시 알림을 보내려면 먼저 클라이언트로부터 <code>Notification</code>에 대한 동의를 받아야 한다.</p>
<p>Service Worker, Notification, Push 기능을 지원하지 않는 브라우저도 있으므로 이를 확인한 후 동의를 받도록 한다.</p>
<p><strong>🔻 <code>Notification.requestPermission()</code></strong></p>
<pre><code class="language-tsx">// Notification 허용 버튼 클릭 시
async function onClickAlert() {
  if (
    &quot;serviceWorker&quot; in navigator &amp;&amp;
    &quot;Notification&quot; in window &amp;&amp;
    &quot;PushManager&quot; in window
  ) {
    Notification.requestPermission().then(async (result) =&gt; {
      if (result === &quot;granted&quot;) {
        const subscription = await getPushSubscription();
        await savePushSubscription(subscription);
        setAlertGranted(true);
      } else if (result === &quot;denied&quot;) {
        setAlertGranted(false);
      }
    });
  }
}</code></pre>
<h3 id="알림-구독-정보-저장하기">알림 구독 정보 저장하기</h3>
<p><code>Notification.requestPermission()</code>까지만 해도 앱이 켜져 있는 상태에서 클라이언트는 알림을 받을 수 있다. 하지만 여기까지만 하면 앱이 꺼져 있는 상태에서는 푸시 알림을 받을 수 없다. 앱이 꺼져 있는 상태에서도 알림을 받으려면 <code>Push</code> API 관련 작업이 필요하다.</p>
<p>웹 푸시는 아래와 같은 흐름으로 이루어지는데, 푸시 메세지가 유출되거나 변조되는 것을 막으려면 암호화 및 서버 인증 과정을 거쳐야 한다. 이 작업을 쉽게 할 수 있도록 <a href="https://www.npmjs.com/package/web-push"><code>web-push</code></a> 라이브러리를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/a97c5a1a-78ac-4605-9f67-0fe013c45eaa/image.png" alt=""></p>
<p>먼저 인증에 사용할 VAPID (Voluntary Application Server Identification) key를 발급 받아야 한다. <code>web-push</code> 라이브러리를 설치한 후 터미널창에 아래 명령어를 입력하면 VAPID key를 발급 받을 수 있는데, 나는 발급된 키를 환경변수에 담아두고 사용했다.</p>
<p><strong>🔻 VAPID key 발급</strong></p>
<pre><code class="language-shell">web-push generate-vapid-keys</code></pre>
<p> 다음으로는 <code>PushSubscription</code> 정보를 받아야 한다. <code>PushSubscription</code>은 아래와 같은 형식으로 구성되는데, 여기에는 알림을 받는 클라이언트의 endpoint 정보와 인증에 필요한 key 정보가 담겨있다.</p>
<p><strong>🔻 <code>PushSubscription</code></strong></p>
<pre><code class="language-json">{
  &quot;endpoint&quot;: &quot;https://random-push-service.com/some-kind-of-unique-id-1234/v2/&quot;,
  &quot;keys&quot;: {
    &quot;p256dh&quot;: &quot;BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=&quot;,
    &quot;auth&quot;: &quot;tBHItJI5svbpez7KI4CCXg==&quot;
  }
}</code></pre>
<p><code>PushSubscription</code> 을 받으려면 <code>registration.pushManager.subscribe()</code>를 호출하면 된다. </p>
<p>이 때 두 가지 옵션값을 설정할 수 있는데, 먼저 <code>userVisibleOnly</code>는 push 이벤트가 발생했을 때 사용자에게 알림을 보낼지 여부이다. 간혹 사용자에게 알리지 않고 백그라운드 작업이 필요한 경우 이 값을 false로 하면 된다.</p>
<p>다음으로 <code>applicationServerKey</code>는 푸시 알림을 보내는 애플리케이션을 식별하는 데에 사용되는 키다. 여기에는 앞서 발급받은 VAPID key의 public key를 담아주면 된다.</p>
<p><strong>🔻 <code>registration.pushManager.subscribe()</code></strong></p>
<pre><code class="language-tsx">// PushSubscription을 가져오는 함수
async function getPushSubscription(): Promise&lt;PushSubscription | null&gt; {
  try {
    const registration = await navigator.serviceWorker.getRegistration();

    if (!registration) {
      console.error(&quot;ServiceWorkerRegistration을 찾을 수 없습니다.&quot;);
      return null;
    }

    if (!process.env.VAPID_PUBLIC_KEY) {
      console.error(&quot;VAPID Puplic key가 존재하지 않습니다.&quot;);
      return null;
    }

    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: process.env.VAPID_PUBLIC_KEY,
    });

    return subscription;
  } catch (e) {
    console.error(
      &quot;PushSubscription을 가져오는 동안 오류가 발생했습니다: &quot;,
      e,
    );
    return null;
  }
}

// 서버에 PushSubscription을 저장하는 함수
async function savePushSubscription(subscription: PushSubscription | null) {
  if (!subscription) {
    console.error(&quot;PushSubscription이 존재하지 않습니다.&quot;);
    return;
  }

  axios
    .post(&quot;/api/subscribe&quot;, {
    subscription,
  })
    .catch((e) =&gt; console.error(e));
}
</code></pre>
<p><strong>🔻 서버에 Subscription 정보 저장하기 (<code>/app/api/subscribe/route.ts</code>)</strong></p>
<p><code>PushSubscription</code>을 받으면 추후에 알림을 보낼 때 사용할 수 있도록 서버에 저장을 해야 한다. 나는 Vercel의 Postgres를 사용해서 구축한 DB에 이 정보를 저장하도록 했다.    </p>
<pre><code class="language-ts">import { sql } from &quot;@vercel/postgres&quot;;
import { SubscriptionInfo } from &quot;@/app/pwa-todo/types&quot;;

export async function POST(req: Request) {
  const { subscription } = await req.json();
  const data = await sql`
        INSERT INTO pwa_subscription (subscription)
        VALUES (${subscription})
    `;
  return Response.json({ success: data.rowCount === 1 });
}
</code></pre>
<h3 id="serviceworker-등록하기">ServiceWorker 등록하기</h3>
<p>푸시 알림 기능을 구현하려면 앱이 켜져있지 않을 때에도 백그라운드에서 알림이 필요한 상황을 파악할 수 있어야 한다. 이러한 백그라운드 작업을 수행하려면 Service Worker를 등록해야 한다. </p>
<p>Service Worker는 자신이 커버하는 범위 내에 있는 요청이 들어오면 일종의 네트워크 프록시처럼 그 요청을 가로채서 Service Worker에 등록된 작업들을 수행한다. </p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/5128c9d8-a65f-4f88-947f-508474faae36/image.png" alt=""></p>
<p>Service Worker가 커버하는 범위는 javascript 파일의 위치에 따라 결정된다. 파일이 루트에 위치하면 <code>/</code> 범위로 등록돼서 모든 url을 커버하게 되고, <code>/sub-directory</code> 디렉토리 안에 위치하면 <code>/sub-directory</code>와 맵핑되는 url을 커버하게 된다. <a href="https://web.dev/learn/pwa/service-workers#scope">공식문서</a>에는 Service Worker를 가능한 한 루트에 가깝게 등록하는 것이 권장된다고 적혀있다. 실제로 다른 디렉토리에 넣어서 작업하다가 <code>undefined</code> 에러를 몇 번 마주했다. 그래서 <code>/public</code> 디렉토리에 <code>sw.js</code> 파일을 둬서 모든 범위를 커버할 수 있도록 했다. </p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (&quot;serviceWorker&quot; in navigator) {
    navigator.serviceWorker
      .register(&quot;/sw.js&quot;)
      .then((registration) =&gt; {
        console.log(
          &quot;Service Worker registration successful with scope: &quot;,
          registration.scope,
        );
      })
      .catch((err) =&gt;
         console.error(&quot;Service Worker registration failed: &quot;, err),
      );
  }
}, []);</code></pre>
<h3 id="push-이벤트-핸들러-등록하기">push 이벤트 핸들러 등록하기</h3>
<p>이제 Service Worker 등록을 마쳤으니, 서버로부터 푸시가 왔을 때 어떤 작업을 하면 될지 Service Worker에게 알려주면 된다. </p>
<p>웹 푸시가 이루어지는 과정은 아래 그림과 같은데, 서버로부터 온 메세지를 기기가 받으면 브라우저는 Service Worker를 깨우고, Push Event가 발생된다. 우리가 해야 할 일은 push 이벤트가 발생했을 때 알림을 띄울 수 있도록 하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/d9b707f6-8096-41ce-b0ec-7654cc0380cb/image.png" alt=""></p>
<p><code>/sw.js</code> 파일에 아래와 같이 <code>push</code> 이벤트 핸들러를 작성해주었다. 여기에서 <code>self</code>는 Service Worker를 의미한다. <code>push</code> 이벤트 핸들러는 <code>ServiceWorkerRegistration</code>의 <code>showNotification()</code>을 호출해서 알림을 띄울 수 있도록 한다.</p>
<pre><code class="language-javascript">self.addEventListener(&quot;push&quot;, function (event) {
  const { title, message } = event.data.json();
  const options = {
    body: message,
    icon: &quot;/app-icon/ios/192.png&quot;,
  };
  event.waitUntil(self.registration.showNotification(title, options));
});</code></pre>
<h3 id="푸시-알림-보내기">푸시 알림 보내기</h3>
<p>여기까지 오면 push 이벤트가 발생했을 때 알림을 띄울 수 있도록 하는 모든 작업이 마무리되었으니, 마지막으로 푸시 알림을 보내기만 하면 된다.</p>
<p>이를 위해 이전에 DB에 저장해두었던 클라이언트들의 구독 정보(<code>PushSubscription</code>)들을 조회해온다.</p>
<p><strong>🔻 Subscription 정보 조회 API (<code>/app/api/subscribe/route.ts</code>)</strong></p>
<pre><code class="language-ts">import { sql } from &quot;@vercel/postgres&quot;;
import { SubscriptionInfo } from &quot;@/app/pwa-todo/types&quot;;

export async function GET() {
  const data = await sql&lt;SubscriptionInfo&gt;`
    SELECT *
    FROM pwa_subscription
    ORDER BY id
  `;
  return Response.json(data.rows);
}
</code></pre>
<p>다음으로 <code>web-push</code> 라이브러리의 <code>sendNotification()</code>를 호출하여 클라이언트로 푸시 알림을 보낼 수 있는 API를 만든다. <code>sendNotification()</code>을 호출할 때는 알림을 받을 클라이언트의 <code>PushSubscription</code>과, 알림창의 제목과 내용, VAPID keys를 전달해주면 된다.</p>
<p><strong>🔻 푸시 알림 전송 API (<code>/app/api/send-message/route.ts</code>)</strong></p>
<pre><code class="language-tsx">import webPush, { PushSubscription } from &quot;web-push&quot;;

export async function POST(req: Request) {
  const { pushSubscription, title, message } = await req.json();
  const subscription = JSON.parse(pushSubscription) as PushSubscription;
  const payload = JSON.stringify({ title, message });

  if (
    !process.env.VAPID_SUBJECT ||
    !process.env.VAPID_PUBLIC_KEY ||
    !process.env.VAPID_PRIVATE_KEY
  ) {
    console.error(&quot;VAPID key 정보가 없습니다.&quot;);
    return Response.error();
  }

  const options = {
    vapidDetails: {
      subject: process.env.VAPID_SUBJECT,
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
    },
    TTL: 60,
  };

  try {
    const response = await webPush.sendNotification(
      subscription,
      payload,
      options,
    );
    return Response.json(response);
  } catch (error) {
    console.error(&quot;notification error&quot;, error);
    return Response.error();
  }
}
</code></pre>
<p><strong>🔻 클라이언트 푸시 알림 전송 코드</strong> </p>
<pre><code class="language-tsx">  // 구독하고 있는 클라이언트들에게 Push 알림을 보내는 함수
  async function pushNotification() {
    const subscriptions = await axios
      .get(&quot;/api/subscribe&quot;)
      .then((response) =&gt; response.data);

    let promiseChain = Promise.resolve();

    for (let i = 0; i &lt; subscriptions.length; i++) {
      const subscription = subscriptions[i];
      promiseChain = promiseChain.then(() =&gt; {
        return triggerPushMsg(
          subscription,
          &quot;🔔 TODO&quot;,
          &quot;오늘의 할 일 잊지 마세요!&quot;,
        );
      });
    }

    return promiseChain;
  }

  async function triggerPushMsg(
    pushSubscription: SubscriptionInfo,
    title: string,
    message: string,
  ) {
    await axios
      .post(&quot;/api/send-message&quot;, {
        pushSubscription: pushSubscription.subscription,
        title,
        message,
      })
      .catch((e) =&gt; console.error(e));
  }</code></pre>
<h2 id="👀-결과물">👀 결과물</h2>
<p>그러면 아래와 같이 홈 화면에 앱을 추가하고 알림을 받는 것이 가능하다. </p>
<p>PWA의 아쉬운 점은 설치 과정이 직관적이지 않은 점인 것 같다. 최소한 설치하기 버튼을 눌렀을 때 설치가 되게 할 수 있으면 좋을 것 같은데, iOS의 경우 반드시 사파리로 들어가서 공유하기 버튼을 누른 뒤, 홈 화면에 추가하기 버튼을 눌러야 설치를 할 수 있다. 아무래도 네이티브 언어로 개발한 앱에 비해서는 제약이 있는 것 같다.</p>
<p>그럼에도 불구하고 앱 스토어에 배포하는 과정 없이 모바일 앱과 유사한 경험을 제공할 수 있고, 데스크톱과 모바일에서 모두 사용 가능한 사이트를 만들 수 있다는 것은 큰 장점인 것 같다. </p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/87ca9678-1e08-40ee-944f-13f679ef7764/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 OpenVidu로 WebRTC 영상 통화 기능 구현하기 (Custom Hook 제작기)]]></title>
            <link>https://velog.io/@youngeui_hong/Next.js%EC%97%90%EC%84%9C-OpenVidu%EB%A1%9C-WebRTC-%EC%98%81%EC%83%81-%ED%86%B5%ED%99%94-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Custom-Hook-%EC%A0%9C%EC%9E%91%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/Next.js%EC%97%90%EC%84%9C-OpenVidu%EB%A1%9C-WebRTC-%EC%98%81%EC%83%81-%ED%86%B5%ED%99%94-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Custom-Hook-%EC%A0%9C%EC%9E%91%EA%B8%B0</guid>
            <pubDate>Sun, 25 Feb 2024 13:26:26 GMT</pubDate>
            <description><![CDATA[<h2 id="👋🏻-들어가며">👋🏻 들어가며</h2>
<p>지난 돌돌밋 프로젝트에서 OpenVidu를 사용해서 WebRTC 기능을 구현했었다.</p>
<p>그런데 이 때 아쉬웠던 점은 OpenVidu 관련 코드가 컴포넌트 내에서 너무 많은 양을 차지하다보니까 코드 가독성이 떨어졌던 점이었다.</p>
<p>그리고 WebRTC 기능을 사용하는 페이지마다 동일한 코드가 반복적으로 들어가는 것도 비효율적이었다.</p>
<p>이런 문제를 해결하기 위해 <code>useOpenVidu</code> 훅과 <code>useMediaDevice</code> 훅을 만들어보았다.</p>
<p>자세한 코드는 <a href="https://github.com/YoungeuiHong/react-deep-dive/tree/main/src/app/openvidu">GitHub</a>에서 확인할 수 있다.</p>
<h2 id="💚-useopenvidu">💚 <code>useOpenVidu</code></h2>
<p>OpenVidu로 영상통화 기능을 구현하려면 아래와 같은 작업들이 이루어져야 하는데, <code>useOpenVidu</code> 내에서 이 작업들을 수행하도록 했다.</p>
<ol>
<li><code>OpenVidu</code> 객체 생성</li>
<li><code>Session</code> 객체 생성</li>
<li><code>/openvidu/api/sessions</code> API로 요청을 보내서 세션 생성</li>
<li>세션에 <code>streamCreated</code>, <code>streamDestroyed</code> 이벤트 핸들러 등록</li>
<li><code>/openvidu/api/sessions/${sessionId}/connection</code> API로 요청을 보내서 토큰 발급</li>
<li>발급 받은 토큰을 기반으로 세션에 연결</li>
<li>접속한 기기의 비디오 및 오디오 장치를 기반으로 <code>Publisher</code> 객체 생성 </li>
<li>생성된 <code>Publisher</code> 객체를 세션에 배포</li>
</ol>
<h3 id="optionstype"><code>OptionsType</code></h3>
<p><code>useOpenVidu</code>의 파라미터로는 아래의 값들을 받을 수 있도록 했다.</p>
<ul>
<li><code>sessionId</code> : 세션 아이디</li>
<li><code>connect</code> : 영상통화 연결 여부 (true가 되는 순간 연결함)</li>
<li><code>clientData</code> : 세션 연결 시 전달할 클라이언트의 정보 (아이디, 이름 등)</li>
<li><code>eventHandlers</code> : 세션 이벤트 핸들러의 리스트</li>
<li><code>publisherProperties</code> : Publisher 설정 옵션 (거울 모드, frame rate 등)</li>
</ul>
<h3 id="returntype"><code>ReturnType</code></h3>
<p><code>useOpenVidu</code>는 아래의 객체들을 반환하도록 했다.</p>
<p>현재 영상통화의 설정을 변경할 때는 아래의 객체들에 접근하는 것이 필요하므로 이를 jotai의 <code>atom</code>으로 두어서 여러 컴포넌트에서 접근할 수 있도록 했다.</p>
<ul>
<li><code>ov</code> : <code>OpenVidu</code> 객체</li>
<li><code>session</code> : <code>Session</code> 객체</li>
<li><code>myStream</code> : 나의 미디어 스트림 (<code>Publisher</code> 객체)</li>
<li><code>subscribers</code>: 다른 사람들의 미디어 스트림 (<code>StreamManager</code>의 배열)</li>
</ul>
<h3 id="sessioneventhandler"><code>SessionEventHandler</code></h3>
<p>OpenVidu는 <code>Session</code> 객체의 <code>on</code> 메서드를 통해 특정 이벤트가 발생했을 때 이루어져야 할 작업을 정의할 수 있다. (예를 들어 <code>signal:alert</code> 이벤트가 발생하면 팝업이 열리게 하는 등)</p>
<p>지난 번 돌돌밋 프로젝트를 할 당시 OpenVidu 관련 로직을 쉽게 분리하지 못했던 이유는 이벤트 핸들러 등록 때문이었다. 왜냐하면 특정 이벤트가 발생했을 때 컴포넌트 내부의 state를 변경해야 하는 경우가 많았기 때문이다.</p>
<p>이러한 문제를 해결하기 위해 <code>SessionEventHandler</code> 타입을 정의했다. 그리고 state 변경 등 이벤트 발생 시 실행되어야 할 로직은 <code>handler</code> 프로퍼티에 담아서 <code>useOpenVidu</code>에 전달할 수 있도록 했다.</p>
<pre><code class="language-typescript">export type SessionEventHandler&lt;K extends keyof SessionEventMap&gt; = {
  type: K;
  handler: (event: SessionEventMap[K]) =&gt; void;
};</code></pre>
<h3 id="beforeunload"><code>beforeunload</code></h3>
<p><code>useOpenVidu</code> 페이지를 벗어나면 영상통화가 끊어지도록 해야 했다. </p>
<p>그렇지 않으면 영상통화 페이지를 벗어났는데도 카메라와 마이크를 사용하는 상태로 그대로 남아있었다.</p>
<p>이를 위해 <code>beforeunload</code> 이벤트가 발생하면 세션의 <code>disconnect</code> 메서드를 호출하여 연결을 해제하도록 했다.</p>
<h3 id="useopenvidu-전체-코드"><code>useOpenVidu</code> 전체 코드</h3>
<pre><code class="language-typescript">import { useEffect } from &quot;react&quot;;
import { useAtom } from &quot;jotai&quot;;
import {
  OpenVidu,
  Publisher,
  PublisherProperties,
  Session,
  StreamManager,
} from &quot;openvidu-browser&quot;;
import { SessionEventHandler } from &quot;@/app/openvidu/constants&quot;;
import { joinSession } from &quot;@/app/openvidu/api&quot;;
import {
  myStreamAtom,
  openViduAtom,
  sessionAtom,
  subscribersAtom,
} from &quot;@/app/openvidu/store&quot;;
import { registerDefaultEventHandler } from &quot;@/app/openvidu/utils&quot;;

interface OptionsType {
  sessionId: string;
  connect: boolean;
  clientData?: any;
  eventHandlers?: SessionEventHandler&lt;any&gt;[];
  publisherProperties?: PublisherProperties;
}

interface ReturnType {
  ov?: OpenVidu;
  session?: Session;
  myStream?: Publisher;
  subscribers?: StreamManager[];
}

function useOpenVidu({
  sessionId,
  connect,
  clientData,
  eventHandlers = [],
  publisherProperties,
}: OptionsType): ReturnType {
  const [ov, setOv] = useAtom&lt;OpenVidu | undefined&gt;(openViduAtom);
  const [session, setSession] = useAtom&lt;Session | undefined&gt;(sessionAtom);
  const [myStream, setMyStream] = useAtom&lt;Publisher | undefined&gt;(myStreamAtom);
  const [subscribers, setSubscribers] =
    useAtom&lt;StreamManager[]&gt;(subscribersAtom);

  // 세션 아이디가 변경될 때마다 세션에 다시 연결
  useEffect(() =&gt; {
    async function joinNewSession() {
      if (sessionId &amp;&amp; connect) {
        const ov = new OpenVidu();
        const session = ov.initSession();
        // 기본 이벤트 핸들러 등록 (Stream 추가 / 제거)
        registerDefaultEventHandler(eventHandlers, session, setSubscribers);
        const myStream = await joinSession({
          sessionId,
          ov,
          session,
          eventHandlers,
          clientData,
          publisherProperties,
        });

        // 상태 업데이트
        setOv(ov);
        setSession(session);
        setMyStream(myStream);
      }
    }

    joinNewSession();
  }, [sessionId, connect]);

  // 페이지를 벗어날 때 세션 연결 해제
  useEffect(() =&gt; {
    const beforeUnload = (event: BeforeUnloadEvent) =&gt; {
      // 세션 연결 해제
      session?.disconnect();
      // atom 초기화
      setOv(undefined);
      setSession(undefined);
      setMyStream(undefined);
      setSubscribers([]);
      event.returnValue = true;
    };

    window.addEventListener(&quot;beforeunload&quot;, beforeUnload);

    return () =&gt; {
      window.removeEventListener(&quot;beforeunload&quot;, beforeUnload);
    };
  }, []);

  return {
    ov,
    session,
    myStream,
    subscribers,
  };
}

export default useOpenVidu;</code></pre>
<h2 id="💛-usemediadevice">💛 <code>useMediaDevice</code></h2>
<p>사용할 수 있는 비디오 / 오디오 장치가 여러 가지 있는 경우 <code>useMediaDevice</code>를 통해 그 목록을 확인하고, 변경할 수 있도록 했다.</p>
<p><code>OpenVidu</code> 객체의 <code>getDevices()</code> 메서드를 통해 사용자의 미디어 디바이스 목록을 가져올 수 있기 때문에, <code>useOpenVidu</code>에서 생성한 <code>OpenVidu</code> 객체를 <code>useAtomValue</code>를 사용해서 가져왔다.</p>
<p>그리고 선택한 장치가 변경되면 이 옵션으로 새로운 <code>Publisher</code> 객체를 만들고 이를 세션에 다시 publish하도록 했다.</p>
<p><code>useMediaDevice</code> 기능을 테스트해보기 위해 아이폰으로 접속해보았는데, 아래와 같이 사용할 수 있는 디바이스 목록이 뜨고 선택한 옵션으로 변경 가능함을 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/b01c57f9-c500-4521-99c6-23070860bcf3/image.gif" alt=""></p>
<h3 id="usemediadevice-코드-전체"><code>useMediaDevice</code> 코드 전체</h3>
<pre><code class="language-typescript">import { useEffect, useState } from &quot;react&quot;;
import { useAtom, useAtomValue } from &quot;jotai&quot;;
import {
  Device,
  OpenVidu,
  Publisher,
  PublisherProperties,
  Session,
} from &quot;openvidu-browser&quot;;
import { myStreamAtom, openViduAtom, sessionAtom } from &quot;@/app/openvidu/store&quot;;
import { defaultPublisherProperties } from &quot;@/app/openvidu/constants&quot;;

interface OptionType {
  publisherProperties?: PublisherProperties;
}

interface ReturnType {
  audioInputs: Device[];
  videoInputs: Device[];
  selectedAudio: Device | undefined;
  selectedVideo: Device | undefined;
  changeMic: (deviceId: string) =&gt; void;
  changeCamera: (deviceId: string) =&gt; void;
}

export default function useMediaDevice({
  publisherProperties = defaultPublisherProperties,
}: OptionType = {}): ReturnType {
  // atom
  const ov = useAtomValue&lt;OpenVidu | undefined&gt;(openViduAtom);
  const session = useAtomValue&lt;Session | undefined&gt;(sessionAtom);
  const [myStream, setMyStream] = useAtom&lt;Publisher | undefined&gt;(myStreamAtom);
  // state
  const [audioInputs, setAudioInputs] = useState&lt;Device[]&gt;([]);
  const [videoInputs, setVideoInputs] = useState&lt;Device[]&gt;([]);
  const [selectedAudio, setSelectedAudio] = useState&lt;Device&gt;();
  const [selectedVideo, setSelectedVideo] = useState&lt;Device&gt;();

  useEffect(() =&gt; {
    async function init() {
      if (ov) {
        const devices = await ov.getDevices();
        const audioDevices = devices.filter(
          (device) =&gt; device.kind === &quot;audioinput&quot;,
        );
        const videoDevices = devices.filter(
          (device) =&gt; device.kind === &quot;videoinput&quot;,
        );
        setAudioInputs(audioDevices);
        setVideoInputs(videoDevices);
        setSelectedAudio(audioDevices[0]);
        setSelectedVideo(videoDevices[0]);
      }
    }

    init();
  }, [ov, session]);

  // 카메라 / 마이크 선택이 변경될 경우 변경된 Publisher 스트림을 세션에 배포
  useEffect(() =&gt; {
    async function changeDevice() {
      if (ov &amp;&amp; session) {
        const newPublisher = await ov.initPublisherAsync(undefined, {
          ...publisherProperties,
          videoSource: selectedVideo?.deviceId,
          audioSource: selectedAudio?.deviceId,
        });
        if (myStream) {
          await session.unpublish(myStream);
        }
        await session.publish(newPublisher);
        setMyStream(newPublisher);
      }
    }

    changeDevice();
  }, [selectedAudio, selectedVideo]);

  // 마이크 변경
  function changeMic(deviceId: string) {
    const selected = audioInputs.find((audio) =&gt; audio.deviceId === deviceId);

    setSelectedAudio(selected);
  }

  // 카메라 변경
  function changeCamera(deviceId: string) {
    const selected = videoInputs.find((video) =&gt; video.deviceId === deviceId);

    setSelectedVideo(selected);
  }

  return {
    audioInputs,
    videoInputs,
    selectedAudio,
    selectedVideo,
    changeMic,
    changeCamera,
  };
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[포트폴리오 사이트 제작기]]></title>
            <link>https://velog.io/@youngeui_hong/%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EC%A0%9C%EC%9E%91%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EC%A0%9C%EC%9E%91%EA%B8%B0</guid>
            <pubDate>Fri, 16 Feb 2024 17:09:40 GMT</pubDate>
            <description><![CDATA[<h2 id="👋🏻-들어가며">👋🏻 들어가며</h2>
<p>포트폴리오를 노션 링크로 제출하려니 경력사항과 프로젝트 목록이 한 눈에 들어오지 않는 점이 아쉬워서 웹사이트를 만들게 되었다.</p>
<h2 id="🛠-기술-스택">🛠 기술 스택</h2>
<ul>
<li>Next.js</li>
<li>TypeScript</li>
<li>Material UI</li>
<li>Vercel</li>
</ul>
<h2 id="🎨-ui-디자인">🎨 UI 디자인</h2>
<p>막상 포트폴리오 사이트를 만들려고 하니 디자인을 어떻게 해야 할지 막막했다.</p>
<p>그래서 &quot;프론트엔드 개발자 이력서&quot; 키워드로 검색해서 다른 분들의 디자인을 많이 참고했다.</p>
<p>그리고 Velog 사이트와 네이버, 카카오 등의 기업 소개 사이트들도 디자인을 구상할 때 많은 도움이 되었다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/153d9134-9b13-43ef-8e44-44115b64e245/image.gif" alt=""></p>
<h2 id="📝-마크다운-커스텀하기">📝 마크다운 커스텀하기</h2>
<p>회사 경력 사항, 프로젝트 상세 내용과 같은 부분은 들어갈 내용이 많고, 업데이트를 할 일이 잦다보니 아무래도 마크다운으로 관리하는 것이 편할 것 같았다.</p>
<p>그래서 아래 사진처럼 노란색 박스로 표시한 부분에 마크다운을 임베딩하는 방식으로 구현했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/41b76589-7ea0-4f16-a56b-24992c129773/image.png" alt="">
<img src="https://velog.velcdn.com/images/youngeui_hong/post/878d56d9-8f61-449c-95a1-a9e2393e02c9/image.png" alt=""></p>
<p>마크다운을 넣는 데에는 <code>react-markdown</code> 라이브러리를 사용했다.</p>
<p>그리고 마크다운 디자인을 커스텀하기 위해 Material UI의 <code>styled</code> 함수를 사용하여 코드 블록, 하이퍼링크 등의 CSS를 정의한 <code>StyledMarkdown</code> 컴포넌트를 만들었다.</p>
<pre><code class="language-typescript">import { styled } from &quot;@mui/material&quot;;

const StyledMarkdown = styled(&quot;div&quot;)(({ theme }) =&gt; {
  return {
    &quot;h1, h2, h3&quot;: {
      marginTop: 30,
    },

    // Code Block
    &quot;&amp; pre&quot;: {
      padding: &quot;0px 10px&quot;,
      borderRadius: 2,
    },
       // ... 중략
  };
});

export default StyledMarkdown;
</code></pre>
<p>그리고 아래와 같이 <code>ReactMarkdown</code> 컴포넌트를 <code>StyledMarkdown</code> 컴포넌트로 감싸주었다.</p>
<p>그리고 마크다운에 HTML 코드를 넣을 수 있도록 <code>rehypePlugins</code>에 <code>rehype-raw</code> 라이브러리를 추가해주었다.</p>
<pre><code class="language-typescript">export default function CustomMarkdown({ mdFilePath }: CustomMarkdownProps) {
  // ... 중략

  return (
    &lt;StyledMarkdown&gt;
      &lt;ReactMarkdown components={components} rehypePlugins={[rehypeRaw]}&gt;
        {markdown}
      &lt;/ReactMarkdown&gt;
    &lt;/StyledMarkdown&gt;
  );
}</code></pre>
<h2 id="🤹-carousel-구현하기">🤹 Carousel 구현하기</h2>
<p>노트북 화면을 기준으로 한 행에 그리드 아이템이 3개인 게 가장 보기가 좋았다. </p>
<p>교육 및 대외활동 섹션이나, 자격증 및 수상 섹션은 마침 딱 3개가 들어가서 딱이다 싶었는데, 문제는 프로젝트 섹션에 4개 항목이 들어가야 했다.</p>
<p>네 번째 항목만 덩그러니 다음 행으로 떨어져 있으면 보기가 안 좋았고, 그렇다고 &quot;더보기&quot; 버튼을 넣자니 새로운 페이지를 만들어야 해서 마땅찮았다.</p>
<p>그래서 고민하다가 Carousel을 넣는 것이 좋겠다고 결론을 내렸다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/0eb6dc36-14b2-478f-bf4f-bb17af8c6402/image.gif" alt=""></p>
<p>Carousel 라이브러리로는 <code>Swiper</code>, <code>Slick Slider</code>, <code>react-material-ui-carousel</code>를 설치해서 비교해봤는데, <code>Swiper</code>에 내가 원하는 기능이 모두 들어가 있어서 이 라이브러리로 구현했다.</p>
<p><code>Swiper</code>는 이전/다음 버튼을 커스텀할 수 있고, 반응형으로 슬라이드 개수를 지정할 수 있어서 좋았다.</p>
<pre><code class="language-typescript">&quot;use client&quot;;
import { Container, IconButton } from &quot;@mui/material&quot;;
import { Swiper } from &quot;swiper/react&quot;;
import { Navigation } from &quot;swiper/modules&quot;;
import NavigateBeforeIcon from &quot;@mui/icons-material/NavigateBefore&quot;;
import NavigateNextIcon from &quot;@mui/icons-material/NavigateNext&quot;;
import { ReactNode } from &quot;react&quot;;
import &quot;./Carousel.css&quot;;

interface Props {
  id: string;
  children: ReactNode;
}

export default function CarouselContainer({ id, children }: Props) {
  return (
    &lt;&gt;
      &lt;Container sx={{ maxWidth: &quot;80%&quot; }}&gt;
        &lt;Swiper
          cssMode={true}
          modules={[Navigation]}
          slidesPerView={3}
          spaceBetween={24}
          navigation={{
            prevEl: `#${id}-prev-slide-button`,
            nextEl: `#${id}-next-slide-button`,
            disabledClass: &quot;swiper-button-disabled&quot;,
          }}
          pagination={true}
          breakpoints={{
            0: {
              slidesPerView: 1,
            },
            920: {
              slidesPerView: 2,
            },
            1200: {
              slidesPerView: 3,
            },
          }}
          style={{
            width: &quot;100%&quot;,
            height: &quot;100%&quot;,
          }}
        &gt;
          {children}
        &lt;/Swiper&gt;
      &lt;/Container&gt;
      &lt;IconButton
        id={`${id}-prev-slide-button`}
        sx={{ position: &quot;absolute&quot;, top: &quot;46%&quot;, left: &quot;4vw&quot;, zIndex: 1000 }}
      &gt;
        &lt;NavigateBeforeIcon fontSize=&quot;large&quot; /&gt;
      &lt;/IconButton&gt;
      &lt;IconButton
        id={`${id}-next-slide-button`}
        sx={{ position: &quot;absolute&quot;, top: &quot;46%&quot;, right: &quot;4vw&quot;, zIndex: 1000 }}
      &gt;
        &lt;NavigateNextIcon fontSize=&quot;large&quot; /&gt;
      &lt;/IconButton&gt;
    &lt;/&gt;
  );
}
</code></pre>
<h2 id="📱💻-반응형-웹-디자인하기">📱💻 반응형 웹 디자인하기</h2>
<p>이번에 포트폴리오 사이트를 만들면서 신경 쓴 점은 어떤 디바이스, 플랫폼에서 접속하더라도 최대한 일관적인 디자인을 보여드릴 수 있도록 하는 것이었다.</p>
<h3 id="reset-css">reset css</h3>
<p>먼저 다양한 브라우저에서 일관된 모양이 나올 수 있도록 reset css를 적용하는 작업을 진행했다.</p>
<p>Material UI의 경우 <code>&lt;CssBaseline /&gt;</code> 컴포넌트를 통해 이 작업이 가능했다.</p>
<p>나는 앱 최상단 <code>layout.tsx</code>에 <code>&lt;CssBaseline /&gt;</code>를 추가해주었다.</p>
<h3 id="media-query">media query</h3>
<p>기본적으로 media query를 사용해서 뷰포트의 크기에 따라 화면이 다르게 보일 수 있도록 구현했다.</p>
<p>그런데 서버 사이드 렌더링 컴포넌트의 경우 렌더링 시점에 뷰포트의 크기를 알 수 없어서 문제가 발생했다.</p>
<p>모바일에서 접속하면 PC용 화면이 잠시 보였다가 렌더링이 완료된 이후에 모바일용 화면이 보이는 문제가 있었다.</p>
<p>이 문제는 Next.js의 Middleware를 사용해서 해결할 수 있었다.</p>
<p>아래 코드와 같이 Next.js의 <code>middleware.ts</code>에서 <code>User-Agent</code> 정보를 분석해서 렌더링이 이루어지기 전에 모바일 접속 여부를 파악할 수 있도록 했다.</p>
<p>그리고 이 정보를 response 헤더에 담아서 컴포넌트에 전달했다.</p>
<p>디바이스 타입 정보를 편하게 읽을 수 있도록 <code>useViewport</code> 훅을 만들어서 사용했다.</p>
<pre><code class="language-typescript">export function middleware(request: NextRequest) {
  const { device } = userAgent(request);
  const viewport = device.type === &quot;mobile&quot; ? &quot;mobile&quot; : &quot;desktop&quot;;
  const response = NextResponse.next();
  response.headers.set(&quot;viewport&quot;, viewport);
  return response;
}</code></pre>
<pre><code class="language-typescript">import { headers } from &quot;next/headers&quot;;

export const useViewport = () =&gt; {
  const viewport = headers().get(&quot;viewport&quot;);

  return {
    isMobile: viewport === &quot;mobile&quot;,
    isDesktop: viewport === &quot;desktop&quot;,
  };
};</code></pre>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/f2c0f527-8285-4f08-b66b-f7eb210cfc00/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Joyride로 프로덕트 투어 기능 구현하기]]></title>
            <link>https://velog.io/@youngeui_hong/React-Joyride%EB%A1%9C-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8-%ED%88%AC%EC%96%B4-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/React-Joyride%EB%A1%9C-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8-%ED%88%AC%EC%96%B4-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 Feb 2024 06:16:21 GMT</pubDate>
            <description><![CDATA[<h2 id="👋🏻-들어가며">👋🏻 들어가며</h2>
<p>최근 포트폴리오 사이트를 만들면서 고민이 됐던 지점은 &quot;어떻게 하면 프로젝트에서 구현한 기능들을 최대한 풍부하게 보여드릴 수 있을까?&quot;였다.</p>
<p>라이브 데모 링크를 첨부한다 하더라도, 우리 사이트를 처음 이용하는 분이라면 어디를 어떻게 눌러야 작동하는지 알기 어려울 것이기 때문이다.</p>
<p>특히 실제 서비스의 경우 정해진 조건들이 맞아야 제대로 기능이 동작하기 때문에, DB에 적절한 데이터가 없다면 사실상 메인 페이지밖에 볼 수 없는 상황이 되어버리는 것이다.</p>
<p>그래서 생각난 것이 바로 <strong>프로덕트 투어</strong> 기능이었다.</p>
<h2 id="🚗-react-joyride">🚗 React Joyride</h2>
<p>리액트에서 프로덕트 투어 기능을 구현할 때 많이 사용되는 라이브러리로는 React Joyride, intro.js, reacttour 등이 있었다.</p>
<p>우선 이번에는 가장 많이 사용되고, 제일 최근까지 업데이트가 이루어진 React Joyride로 프로덕트 투어 기능을 구현해보았다.</p>
<p>React Joyride를 사용해보니 불편한 점이 몇 가지 있었는데, 이 글을 쓰면서 다른 라이브러리들을 다시 살펴보다보니 다음에는 intro.js와 같은 다른 라이브러리를 사용해봐도 괜찮겠다는 생각이 든다.</p>
<p>React Joyride의 어떤 점이 좋았고, 아쉬웠는지는 아래에서 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/de67e62b-7055-450c-93d1-d2d0d4d21f7e/image.png" alt=""></p>
<h2 id="🛠-nextjs에서-react-joyride-사용하기">🛠 Next.js에서 React Joyride 사용하기</h2>
<p>React Joyride는 ssr이 지원되지 않아서 아래와 같이 dynamic import를 해서 사용했다.</p>
<p>그리고 페이지별로 <code>Joyride</code> 컴포넌트를 넣어야 하는데, 동일한 기본 옵션을 매 페이지마다 넣는 것은 번거로울 듯하여 아래와 같이 <code>ProductTour</code> 컴포넌트를 따로 만들었다.</p>
<pre><code class="language-tsx">import dynamic from &quot;next/dynamic&quot;;
import { Props } from &quot;react-joyride&quot;;
import TourTooltip from &quot;@/components/product-tour/TourTooltip&quot;;

const JoyRide = dynamic(() =&gt; import(&quot;react-joyride&quot;), { ssr: false });

export default function ProductTour(props: Props) {
  return (
    &lt;JoyRide
      showProgress={true}
      continuous={true}
      spotlightClicks={true}
      tooltipComponent={TourTooltip}
      styles={{
        options: {
          zIndex: 10000,
        },
      }}
      {...props}
    /&gt;
  );
}
</code></pre>
<h2 id="📝-step-작성하기">📝 <code>step</code> 작성하기</h2>
<p>프로덕트 투어 영역과 순서를 설정하는 방법은 꽤 간단했다. </p>
<p><code>steps</code> props에 내가 설명하고자 하는 컴포넌트의 css 선택자(<code>target</code>)와 툴팁에 넣을 내용(<code>content</code>)만 작성해주면 됐다. </p>
<p>필수 항목인 <code>target</code>과 <code>content</code> 외에도 <code>placement</code>를 사용해서 툴팁의 위치를 정하는 등 변경할 수 있는 옵션들이 꽤 있었다.</p>
<pre><code class="language-tsx">&lt;ProductTour
  run={true}
  callback={callback}
  steps={[
    {
      target: &quot;#fan-meeting-photo-carousel&quot;,
      content:
      &quot;함께 찍은 사진과 녹화된 팬미팅 영상을 다운로드 및 공유할 수 있어요.&quot;,
      disableBeacon: true,
      placement: &quot;left&quot;,
    },
    {
      target: &quot;#end-fanmeeting-page&quot;,
      content: &quot;자! 이제 돌돌밋을 즐기러 가볼까요? 🚀&quot;,
      disableBeacon: true,
      placement: &quot;center&quot;,
    },
  ]}
  /&gt;</code></pre>
<h2 id="🎨-tooltip-컴포넌트-커스텀하기">🎨 Tooltip 컴포넌트 커스텀하기</h2>
<p>다른 라이브러리들에 비해 React Joyride는 커스텀을 안 해도 기본 디자인이 깔끔하고 예쁜 편이었다.</p>
<p>하지만 마우스를 올렸을 때 버튼 테두리가 검정색으로 두껍게 표시되는 등 몇 가지 걸리는 부분들이 있어서 Tooltip 컴포넌트를 커스텀하기로 했다.</p>
<p>아래와 같이 <code>TooltipRenderProps</code>를 props로 받는 컴포넌트를 만들고, 이 컴포넌트롤 <code>JoyRide</code>의 <code>tooltipComponent</code> props로 전달해주었다.</p>
<pre><code class="language-tsx">import { Box, Button, Stack, Typography } from &quot;@mui/material&quot;;
import type { TooltipRenderProps } from &quot;react-joyride/src/types/components&quot;;

export default function TourTooltip({
  step,
  index,
  continuous,
  backProps,
  primaryProps,
  closeProps,
}: TooltipRenderProps) {
  return (
    &lt;Stack
      direction={&quot;column&quot;}
      sx={{ backgroundColor: &quot;#FFFFFF&quot;, borderRadius: 2, p: 2 }}
      spacing={1}
    &gt;
      &lt;Box sx={{ py: 3, px: 1 }}&gt;
        &lt;Typography sx={{ textAlign: &quot;center&quot;, fontSize: 15 }}&gt;
          {step.content}
        &lt;/Typography&gt;
      &lt;/Box&gt;
      &lt;Stack
        direction={&quot;row&quot;}
        justifyContent={index === 0 ? &quot;flex-end&quot; : &quot;space-between&quot;}
      &gt;
        {index &gt; 0 &amp;&amp; (
          &lt;Button {...backProps} variant={&quot;outlined&quot;}&gt;
            Back
          &lt;/Button&gt;
        )}
        {!step.hideFooter &amp;&amp; continuous &amp;&amp; (
          &lt;Button {...primaryProps} variant={&quot;contained&quot;}&gt;
            Next
          &lt;/Button&gt;
        )}
        {!step.hideFooter &amp;&amp; !continuous &amp;&amp; (
          &lt;Button {...closeProps}&gt;Close&lt;/Button&gt;
        )}
      &lt;/Stack&gt;
    &lt;/Stack&gt;
  );
}
</code></pre>
<h2 id="📣-callback-활용하기">📣 callback 활용하기</h2>
<p>프로덕트 투어 기능을 구현하면서 state를 변경하거나, url을 이동해야 하는 경우가 있었다. 이를 위해 <code>JoyRide</code> 컴포넌트의 <code>callback</code> props를 활용했다.</p>
<p>버튼을 누를 때마다 콜백 함수가 실행되는데, 콜백함수의 인자로 전달되는 <code>CallBackProps</code>에는 현재 버튼이 눌러진 스텝에 대한 정보(현재 툴팁이 보이는 상태인지, 종료된 상태인지, 몇 번째 단계인지 등등)가 담겨 있다.</p>
<p>이를 활용해서 내가 원하는 시점에 state를 변경해서 팝업을 띄우거나, url을 이동할 수 있었다.</p>
<p>intro.js의 경우 <code>onStart</code>, <code>onChange</code>, <code>onComplete</code> 등 콜백 함수가 세분화되어 있는 점이 상대적으로 좋아보였다. 콜백 함수를 다뤄야 할 일이 많다면 이 라이브러리를 써봐도 괜찮을 것 같았다.</p>
<pre><code class="language-tsx">  const productTourCallback = (data: CallBackProps) =&gt; {
    const { type, step, lifecycle } = data;

    // state 변경
    if (step.target === &quot;#subtitle-bar&quot; &amp;&amp; lifecycle === &quot;tooltip&quot;) {
      setEndSoon(true);
    }

    // url 이동
    if (type === &quot;tour:end&quot;) {
      window.location.href = &quot;/end-fanmeeting/user/3&quot;;
    }
  };</code></pre>
<h2 id="👏🏻-결과물">👏🏻 결과물</h2>
<p>그렇게 해서 완성된 최종 결과물은 아래와 같다.</p>
<p>실제 서비스의 경우 영상통화 상대가 접속해 있어야 영상통화방으로 넘어갈 수 있어서 사실상 메인 페이지 밖에 볼 수 없는 상태였는데, 프로덕트 투어 기능을 도입해서 팬미팅의 시작부터 종료까지 주요 기능을 전반적으로 훑어볼 수 있는 상태가 되어서 좋았다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/b4ae2c14-8c88-4d0d-9cd7-4eeefddd0e68/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@react-three/fiber로 Next.js에서 three.js 사용하기]]></title>
            <link>https://velog.io/@youngeui_hong/React-Three-Fiber%EB%A1%9C-Next.js%EC%97%90%EC%84%9C-three.js-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/React-Three-Fiber%EB%A1%9C-Next.js%EC%97%90%EC%84%9C-three.js-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 20 Jan 2024 09:36:30 GMT</pubDate>
            <description><![CDATA[<h2 id="🚀-시작하기">🚀 시작하기</h2>
<h3 id="-의존성-추가">+ 의존성 추가</h3>
<pre><code class="language-shell">yarn add three @react-three/fiber @react-spring/three @react-three/drei 
yarn add @types/three --dev</code></pre>
<h3 id="📝-nextconfigjs-수정">📝 <code>nextConfig.js</code> 수정</h3>
<p>그리고 <code>nextConfig.js</code>에서 <code>transpilePackages</code>를 수정해주었다.</p>
<p>기본적으로 Next.js는 외부 패키지를 트랜스파일하지 않는데, <code>transpilePackages</code>에 추가하면 해당 외부 패키지를 트랜스파일 대상에 포함시킬 수 있다.</p>
<p>three.js를 트랜스파일 대상에 포함시키는 것은 번들 사이즈를 줄이기 위해서이다.</p>
<p>트랜스파일되지 않은 코드는 번들링되어서 브라우저로 전송되는데, Three.js와 같이 큰 라이브러리의 경우 트랜스파일 대상에 포함시키는 것이 좋다.</p>
<pre><code class="language-js">/** @type {import(&#39;next&#39;).NextConfig} */
const nextConfig = {
  transpilePackages: [&quot;three&quot;],
};

module.exports = nextConfig;</code></pre>
<h2 id="💚-기본-요소">💚 기본 요소</h2>
<p><code>@react-three/fiber</code> (React Three Fiber)는 React 환경에서 three.js를 사용하기 쉽게 해주는 라이브러리이다.</p>
<h3 id="🔻-canvas">🔻 <code>Canvas</code></h3>
<p>만약 three.js만을 사용할 경우 아래와 같이 <code>Scene</code>과 <code>Camera</code>을 생성하고, <code>animate()</code>와 같이 render loop를 설정해줘야 한다.</p>
<p><strong>👉🏻 three.js만을 사용한 경우</strong></p>
<pre><code class="language-js">const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)

const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
document.querySelector(&#39;#canvas-container&#39;).appendChild(renderer.domElement)

function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
}

animate()</code></pre>
<p>하지만 <code>@react-three/fiber</code>의 <code>Canvas</code>를 사용하면 위의 코드는 아래와 같이 간단한 코드로 바꿀 수 있다.</p>
<p><strong>👉🏻 <code>@react-three/fiber</code>의 <code>Canvas</code>를 사용한 경우</strong></p>
<pre><code class="language-js">  return (
    &lt;div id=&quot;canvas-container&quot;&gt;
      &lt;Canvas /&gt;
    &lt;/div&gt;
  )</code></pre>
<h3 id="🔻-mesh">🔻 <code>Mesh</code></h3>
<p><code>&lt;mesh /&gt;</code> 태그는 <code>new THREE.Mesh()</code>와 동일하게 동작한다.</p>
<p><code>Mesh</code> 컴포넌트는 <code>geometry</code>와 <code>material</code>을 결합하여 3D 객체를 표현하는 데에 사용된다.</p>
<blockquote>
<p><strong>⭐️ geometry</strong>
three.js에서 geometry란 상자, 구, 원통 등과 같은 기하학적 모양을 의미한다.</p>
</blockquote>
<blockquote>
<p><strong>⭐️ material</strong>
three.js에서 material은 표면의 속성, 즉 재질을 의미한다. 색상, 빛 반사 등을 material을 통해 설정할 수 있다.</p>
</blockquote>
<hr>
<p>이제 React Three Fiber 공식문서의 예제를 따라 만들어보면서 기초를 익혀보자.</p>
<h2 id="🎨-배경-및-카메라-셋팅">🎨 배경 및 카메라 셋팅</h2>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/81382778-759f-4f57-928b-76066b70b175/image.gif" alt=""></p>
<pre><code class="language-ts">&quot;use client&quot;;
import { Canvas } from &quot;@react-three/fiber&quot;;
import {
  CameraControls,
  Environment,
  PerspectiveCamera,
} from &quot;@react-three/drei&quot;;
import { Box, Cactus, Camera, Level, Sudo } from &quot;@/components/Scene&quot;;

export default function Home() {
  return (
    &lt;Canvas flat&gt;
      &lt;CameraControls minPolarAngle={0} maxPolarAngle={Math.PI / 1.6} /&gt;
      &lt;ambientLight intensity={Math.PI / 2} /&gt;
      &lt;group scale={20} position={[0, -10, 0]}&gt;
        &lt;Level /&gt;
        &lt;Sudo /&gt;
        &lt;Camera /&gt;
        &lt;Cactus /&gt;
        &lt;Box position={[-0.8, 1.4, 0.4]} scale={0.15} /&gt;
      &lt;/group&gt;
      &lt;Environment preset=&quot;city&quot; background blur={1} /&gt;
      &lt;PerspectiveCamera makeDefault position={[80, 20, 80]} /&gt;
    &lt;/Canvas&gt;
  );
}</code></pre>
<p>위에서 <code>CameraControls</code>, <code>Environment</code>, <code>PerspectiveCamera</code>는 <code>@react-three/drei</code>의 컴포넌트이다.</p>
<blockquote>
<p>💡 <strong><code>@react-three/drei</code></strong>
<code>@react-three/drei</code>는 <code>@react-three/fiber</code>를 더 사용하기 쉽게끔 만들어주는 라이브러리라고 할 수 있다. 자세한 설명은 <a href="https://github.com/pmndrs/drei">GitHub</a>에서 읽을 수 있고, <a href="https://drei.pmnd.rs/?path=/docs/staging-accumulativeshadows--docs">Storybook</a>에서 값을 변경해가면서 각 컴포넌트를 테스트해볼 수 있다.</p>
</blockquote>
<h3 id="🔻-environment">🔻 <strong><code>Environment</code></strong></h3>
<p>우선 <code>Environment</code>는 three.js의 <code>scene.environment</code>과 동일한 컴포넌트이다.</p>
<p><code>preset</code>을 사용하면 내장된 배경을 사용할 수 있어서 편리했다.</p>
<p>숲, 도시, 방 등 꽤 다양한 옵션을 선택할 수 있었고, <code>blur</code>를 1까지 높이면 그라데이션 배경을 연출할 수 있어서 좋았다. </p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/2c1ecced-94db-4e94-9c1c-0450ddb6b7cb/image.gif" alt=""></p>
<h3 id="🔻-cameracontrols">🔻 <strong><code>CameraControls</code></strong></h3>
<p><code>CameraControls</code>는 <code>THREE.OrbitControls</code>의 기능 및 부가적인 기능을 제공하는 컴포넌트이다.</p>
<p><code>CameraControls</code>를 사용하면 타겟을 중심으로 한 카메라의 궤도를 설정할 수 있다. </p>
<h3 id="🔻-perspectivecamera">🔻 <strong><code>PerspectiveCamera</code></strong></h3>
<p>three.js에는 다양한 종류의 카메라가 있는데, 그 중 가장 일반적으로 사용되는것이 <code>PerspectiveCamera</code>인 것 같다.</p>
<p>이 외에도 2차원을 표시할 때 사용되는 <code>OrthographicCamera</code>, VR에 사용되는 <code>ArrayCamera</code>, <code>StereoCamera</code> 등이 있다.</p>
<h2 id="🧊-3d-모델-로드하기">🧊 3D 모델 로드하기</h2>
<h3 id="🔻-usegltf">🔻 <strong><code>useGLTF</code></strong></h3>
<p>3D 모델의 경우 <a href="https://polyhaven.com/models">PolyHaven</a>, <a href="https://sketchfab.com/feed">SketchFab</a>에서 무료 모델을 다운 받거나, <a href="https://www.blender.org/download/">Blender</a>에서 직접 만들 수도 있다.</p>
<p>이 모델을 로드하려면 <code>@react-three/drei</code>의 <code>useGLTF</code>를 사용하면 되는데, 이 훅은 <code>@react-three/fiber</code>의 <code>useLoader</code>에 <code>GLTFLoader</code>를 인자로 전달하는 것과 동일하게 동작해서, <code>.gltf</code> 또는 <code>.glb</code> 파일을 로드할 수 있도록 해준다.</p>
<blockquote>
<p>💡 <strong><code>.gltf</code> / <code>.glb</code> 파일이란?</strong>
<code>.gltf</code> (GL Transmission Format)는 3D 그래픽 콘텐츠를 전송하기 위한 오픈 표준 파일 포맷이다. <code>.gltf</code> 파일은 JSON 형식으로 작성된 메타 데이터와 바이너리 형식의 외부 자원 파일들로 구성된다.
반면 <code>.glb</code> 파일은 JSON 형식의 glTF 파일과 관련된 자원들을 하나의 바이너리 파일로 통합하여 표현한 파일이다. 이 타입을 사용하면 파일의 크기를 줄이고 로딩 속도를 향상시킬 수 있다.</p>
</blockquote>
<p><code>useGLTF</code>는 <code>(GLTF &amp; ObjectMap)[]</code>을 리턴하는데, <code>ObjectMap</code>에는 <code>nodes</code>와 <code>materials</code> 정보가 들어있다.</p>
<p><code>nodes</code>는 모델을 구성하는 노드들의 계층 구조를 보여주고, <code>materials</code>는 객체의 시각적인 정보가 담겨 있다.</p>
<p>아래와 같이 3D 모델이 구성되어 있는 상태에서, 예를 들어 선인장의 geometry 값은 <code>nodes.Cactus.geometry</code>와 같은 방식으로 가지고 올 수 있다.
<img src="https://velog.velcdn.com/images/youngeui_hong/post/96dcfe92-cbf1-4965-ac18-d7a25eeb1c22/image.png" alt=""></p>
<h2 id="🤹-애니메이션-효과-넣기">🤹 애니메이션 효과 넣기</h2>
<p>위의 GIF을 보면 큐브, 선인장, 카메라, 강아지의 고개가 움직이는 것을 확인할 수 있다. 이러한 애니메이션 효과를 어떻게 넣을 수 있는지 살펴보자.</p>
<h3 id="🔻-react-threedrei의-shaders-활용하기">🔻 <code>@react-three/drei</code>의 Shaders 활용하기</h3>
<p><code>@react-three/drei</code>의 Shaders를 활용하면 애니메이션, 그림자, 반사 효과 등을 줄 수 있다.</p>
<p>예를 들어 아래 코드와 같이 <code>MeshWobbleMaterial</code>을 사용하면 객체가 흔들리는 효과를 구현할 수 있다.</p>
<pre><code class="language-tsx">import { MeshWobbleMaterial, useGLTF } from &quot;@react-three/drei&quot;;

export function Cactus() {
  const { nodes, materials } = useGLTF(&quot;/glb/level-react-draco.glb&quot;);
  return (
    &lt;mesh
      geometry={nodes.Cactus.geometry}
      position={[-0.42, 0.51, -0.62]}
      rotation={[Math.PI / 2, 0, 0]}
    &gt;
      &lt;MeshWobbleMaterial factor={0.6} map={materials.Cactus.map} /&gt;
    &lt;/mesh&gt;
  );
}</code></pre>
<blockquote>
<p>🤔 <strong><code>Math.PI</code></strong>?
rotation 관련 코드에서 <strong><code>Math.PI</code></strong>가 많이 등장하는데, 이는  <strong><code>Math.PI</code></strong>가 180도를 의미하기 때문이다.</p>
</blockquote>
<h3 id="🔻-react-springthree-활용하기">🔻 <code>@react-spring/three</code> 활용하기</h3>
<p>또는 애니메이션 라이브러리인 <a href="https://www.react-spring.dev/"><code>@react-spring/three</code></a>를 활용하는 방법도 있다. </p>
<p>아래 코드는 Camera의 주기적으로 z축을 회전시키는 코드이다.</p>
<p>참고로 <code>&lt;a.group&gt;</code>에서 <code>a</code>는 &quot;animated&quot;의 약자이다.</p>
<pre><code class="language-tsx">import { a, useSpring } from &quot;@react-spring/three&quot;;

export function Camera() {
  const { nodes, materials } = useGLTF(&quot;/glb/level-react-draco.glb&quot;);
  const [spring, api] = useSpring(
    () =&gt; ({ &quot;rotation-z&quot;: 0, config: { friction: 40 } }),
    [],
  );

  useEffect(() =&gt; {
    let timeout;
    const wander = () =&gt; {
      api.start({ &quot;rotation-z&quot;: Math.random() });
      timeout = setTimeout(wander, (1 + Math.random() * 2) * 800);
    };
    wander();
    return () =&gt; clearTimeout(timeout);
  }, []);

  return (
    &lt;a.group
      position={[-0.58, 0.83, -0.03]}
      rotation={[Math.PI / 2, 0, 0.47]}
      {...spring}
    &gt;
      &lt;mesh geometry={nodes.Camera.geometry} material={nodes.Camera.material} /&gt;
      &lt;mesh geometry={nodes.Camera_1.geometry} material={materials.Lens} /&gt;
    &lt;/a.group&gt;
  );
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NextJS] SSR로 초기 화면 로딩 속도를 높여보자 🚀 (+ React Query)]]></title>
            <link>https://velog.io/@youngeui_hong/NextJS-SSR%EB%A1%9C-%EC%B4%88%EA%B8%B0-%ED%99%94%EB%A9%B4-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EB%A5%BC-%EB%86%92%EC%97%AC%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@youngeui_hong/NextJS-SSR%EB%A1%9C-%EC%B4%88%EA%B8%B0-%ED%99%94%EB%A9%B4-%EB%A1%9C%EB%94%A9-%EC%86%8D%EB%8F%84%EB%A5%BC-%EB%86%92%EC%97%AC%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 18 Jan 2024 07:27:21 GMT</pubDate>
            <description><![CDATA[<h1 id="👋-들어가며">👋 들어가며</h1>
<p>최근 돌아가며 만나는 나의 아이돌, 돌돌밋 프로젝트를 리팩토링하는 작업을 하고 있다.</p>
<p>프로젝트 마감이 임박해서 &quot;일단 돌아가면 넘어가!&quot;하며 외면했던 코드들을 다듬고 있다.</p>
<p>제일 처음 한 작업은 CSR 페이지를 SSR 페이지로 전환하는 것이다.</p>
<p>NextJS를 사용하면서 그 장점을 온전히 살리지 못한 것 같아 마음에 걸렸었기 때문이다.</p>
<p>CSR을 SSR로 전환하면서 둘 간의 차이에 대해 자세히 살펴보고 싶은 마음도 있었다.</p>
<h1 id="🔁-react-query와-ssr">🔁 React Query와 SSR</h1>
<h2 id="🥊-csr-vs-ssr">🥊 CSR vs. SSR</h2>
<p>CSR을 사용하는 경우 서버로부터 빈 HTML과 JavaScript 파일을 넘겨 받은 다음에 Query가 실행된다.</p>
<pre><code>1. |-&gt; Markup (without content)
2.   |-&gt; JS
3.     |-&gt; Query</code></pre><p>반면 SSR의 경우 서버에서 내용을 채워서 HTML을 보내줘야 하므로, Query는 Markup을 전달하기 전에 실행된다.</p>
<pre><code>1. |-&gt; Markup (with content AND initial data)
2.   |-&gt; JS</code></pre><h2 id="✌🏻-react-query에-ssr을-적용하는-두-가지-방법">✌🏻 React Query에 SSR을 적용하는 두 가지 방법</h2>
<p>SSR 페이지에서 React Query를 사용하는 방법은 <code>initialData</code>를 사용하는 방법과 Hydration API를 사용하는 방법으로 크게 두 가지가 있다.</p>
<h3 id="1-initialdata">1) <code>initialData</code></h3>
<p>첫 번째는 <code>getServerSideProps</code> 함수로부터 넘어온 props를 <code>useQuery</code>의 <code>initialData</code>로 넘겨주는 방법인데, 이는 아래와 같은 한계점이 있다.</p>
<ul>
<li>많은 컴포넌트를 타고 내려가야 하는 컴포넌트에서 <code>useQuery</code>를 사용하는 경우 prop drilling의 문제가 있다.</li>
<li>같은 query를 여러 곳에서 호출하는 경우 모든 query들에게 <code>initialData</code>를 넘겨줘야 하는 번거로움이 있다.</li>
<li>데이터가 조회된 시점에 대한 정보 (<code>dataUpdatedAt</code>)를 알 수 없다.</li>
<li>만약 캐시된 데이터가 있는 경우 <code>initialData</code>가 이 데이터를 덮어쓸 수 없다. (<code>initialData</code>가 더 최신의 데이터인 경우에도)</li>
</ul>
<h3 id="2-hydration-apis">2) Hydration APIs</h3>
<p><code>initialData</code>를 사용하는 경우 위와 같은 한계점이 있기 때문에 나는 Hydration API를 사용하여 React Query에 SSR을 적용했다. 구체적인 내용은 코드로 살펴보자.</p>
<p><strong>🔻 <code>_app.tsx</code></strong></p>
<p>먼저 <code>_app.tsx</code>에서 컴포넌트 트리를 <code>HydrationBoundary</code>로 감싸주고, <code>pageProps</code> 중 <code>dehydratedState</code>를 넘겨주도록 했다. </p>
<p>그리고 <code>QueryClient</code>를 생성할 때, 페이지가 렌더링되자마자 query가 다시 실행되는 상황을 방지하기 위해 <code>staleTime</code>을 1분으로 변경해주었다.</p>
<pre><code class="language-typescript">import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from &#39;@tanstack/react-query&#39;

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =&gt;
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      }),
  );

  return (
    &lt;QueryClientProvider client={queryClient}&gt;
      &lt;HydrationBoundary state={pageProps.dehydratedState}&gt;
        &lt;Component {...pageProps} /&gt;
      &lt;/HydrationBoundary&gt;
    &lt;/QueryClientProvider&gt;
  )
}</code></pre>
<p><strong>🔻 <code>getServerSideProps</code></strong></p>
<p><code>getServerSideProps</code>에서는 <code>QueryClient</code>를 생성한 다음 <code>prefetchQuery</code>를 실행했다.</p>
<p>그리고 <code>dehydrate</code>한 <code>QueryClient</code>를 <code>dehydratedState</code>라는 이름의 props로 리턴하였다.</p>
<pre><code class="language-typescript">export async function getServerSideProps() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: [&quot;fanMeetings&quot;, &quot;opened&quot;],
    queryFn: ({ queryKey }) =&gt; fetchFanMeetings(queryKey[1]),
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}</code></pre>
<h2 id="🧐-dehydrate이란">🧐 <code>dehydrate</code>이란?</h2>
<p>앞서 props로 리턴할 때 QueryClient를 <code>dehydrate</code>한다고 했는데, <code>dehydrate</code>란 무엇일까?</p>
<p>우선 <code>hydration</code>이란 정적인 HTML을 인터랙티브한 페이지로 만들기 위해 DOM에 event listener를 붙이는 과정을 의미한다. </p>
<p><code>dehydrate</code>은 <code>hydration</code>의 반대의 개념인데, React Query에서는 쿼리 결과를 서버에서 클라이언트로 전송할 수 있도록 쿼리 캐시를 직렬화하는 과정을 의미한다.</p>
<p>그래서 서버 사이드 렌더링 페이지에서 처음 반환 받는 HTML 파일을 살펴보면 아래와 같이 <code>dehydratedState</code>에 <code>mutations</code>와 <code>queries</code> 정보가 들어가 있는 것을 확인 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/833fb6fa-8178-4654-8fad-7e5bd5eb6de5/image.png" alt=""></p>
<h2 id="💫-hydrationboundary란">💫 <code>HydrationBoundary</code>란?</h2>
<p><code>HydrationBoundary</code> 내부 코드를 보면 <code>dehydratedState</code>로부터 <code>queries</code> 배열을 가지고 온 다음, <code>QueryClient</code>에 저장된 쿼리 캐시와 비교하는 부분이 있다.</p>
<p>여기에서 쿼리의 <code>dataUpdatedAt</code> 내용을 비교해서 <code>dehydratedState</code>가 좀 더 최근에 조회된 경우 캐시 내용을 변경한다.</p>
<p>앞서 <code>initialData</code>를 사용하는 방법은 데이터 조회 시점을 알 수 없다는 한계가 있다고 했는데, <code>HydrationBoundary</code>가 있기 때문에 최신의 데이터를 유지할 수 있는 것이다.</p>
<h1 id="👀-ssr로-변경하고-무엇이-달라졌나">👀 SSR로 변경하고 무엇이 달라졌나?</h1>
<p>CSR 방식에서 SSR 방식으로 변경한 다음 어떻게 달라졌는지 살펴보기 위해 Lighthouse 분석 결과를 비교해보았다. </p>
<p><strong>🔻 리팩토링 전 Lighthouse 분석 결과 (CSR)</strong></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/95727537-355d-4599-b17d-1376d28f7e80/image.png" alt=""></p>
<p>그런데 처음 SSR로 변경한 다음 Lighthouse 분석 결과가 갑자기 낮아졌었다.</p>
<p>원인은 SSR로 변경한 것에 있지는 않았고, <code>app</code> 디렉토리에 있던 파일을 <code>pages</code> 디렉토리에 옮기면서 변경된 사항들이 점수가 낮아지게 만든 것이었다. </p>
<p><code>app</code> 디렉토리와 달리 <code>pages</code> 디렉토리에서는 HTML의 메타 데이터를 <code>_document.tsx</code>에 넣어줘야 했다. <code>_document.tsx</code>를 생성하고 HTML의 <code>lang</code>과 <code>meta</code> 태그를 추가함으로써 SEO 점수를 높일 수 있었다.</p>
<p><strong>🔻 CSR</strong></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/196385a9-f588-41e1-b67b-d7661ec4fb86/image.png" alt=""></p>
<p><strong>🔻 SSR</strong></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/ae7a0294-6838-4393-9e57-4b76b23d75da/image.png" alt=""></p>
<p>위의 그림처럼 페이지가 렌더링되는 과정을 보니 확실히 CSR과 SSR의 차이를 확인할 수 있었다.</p>
<p>SSR은 초반에 흰 상태의 화면이 보이는 것과 달리, CSR은 비록 완성되지 않았지만 보이는 내용들이 있어 TTFB(Time-to-First-Byte)가 훨씬 빠름을 확인할 수 있었다.</p>
<p>한편 SSR의 경우 완성된 상태의 HTML을 보내주기 때문에 레이아웃이 변경되는 정도를 측정하는 Cumulative Layout Shift가 CSR에 비해 낮았다.</p>
<p>처음 DOM 컨텐츠를 보기까지 걸리는 시간인 FCP(First Contentful Paint)는 일반적으로 SSR이 더 짧다고 봤었는데, 돌돌밋 메인 페이지의 경우 똑같이 0.2s로 나와서 큰 차이가 없었다.</p>
<p><strong>🔻 리팩토링 후 Lighthouse 분석 결과 (SSR)</strong></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/68eee73f-a028-47d6-83be-a44c06397eeb/image.png" alt=""></p>
<h1 id="🖼-lcp-시간-줄이기">🖼 LCP 시간 줄이기</h1>
<p>이번 메인 페이지 리팩토링에서 CSR을 SSR로 변경하는 것은 성능 개선에 있어 큰 영향이 없었다.</p>
<p>대신 LCP(Largest Contentful Paint)를 줄인 것이 성능 개선에 미치는 영향이 컸다.</p>
<p>처음에 LCP가 1.6초로 나왔었는데, 상단의 배너에 들어가는 이미지를 로딩하는데 시간이 많이 걸리고 있었다.</p>
<p>결론을 먼저 말하자면 배너 이미지를 lazy loading하지 않도록 함으로써 LCP 시간을 줄일 수 있었다.</p>
<p>배너에는 이미지 최적화를 위해 <code>next/image</code>의 <code>Image</code> 컴포넌트를 사용하고 있는데, NextJS의 공식 문서를 보면 LCP가 높은 이미지는 <code>priority</code>를 <code>true</code>로 설정해서 우선순위를 높임으로써 LCP를 개선할 수 있다고 되어 있다.</p>
<p>실제로 <code>priority</code>를 설정하니 LCP가 1.6초에서 1.0초로 개선됨을 확인할 수 있었다.</p>
<p>그런데 <code>Image</code> 컴포넌트는 기본적으로 lazy loading이 적용되는데, 배너는 화면 최상단에 위치한 것이므로 굳이 lazy loading을 적용할 필요가 없겠다는 생각이 들었다.</p>
<p>그래서 <code>loading</code> 프로퍼티 값을 <code>eager</code>로 수정한 결과 LCP가 0.3초로 확연히 개선됨을 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SW사관학교 정글] 나만의 무기를 갖기 프로젝트 회고]]></title>
            <link>https://velog.io/@youngeui_hong/SW%EC%82%AC%EA%B4%80%ED%95%99%EA%B5%90-%EC%A0%95%EA%B8%80-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EB%A5%BC-%EA%B0%96%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@youngeui_hong/SW%EC%82%AC%EA%B4%80%ED%95%99%EA%B5%90-%EC%A0%95%EA%B8%80-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EB%A5%BC-%EA%B0%96%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Wed, 03 Jan 2024 14:52:13 GMT</pubDate>
            <description><![CDATA[<h2 id="🚀-들어가며">🚀 들어가며</h2>
<p>끝나지 않을 것만 같았던 나만무 프로젝트가 끝이 났다 🥹</p>
<p>5주 간 엄청 몰입해서 준비했다보니 크래프톤에서의 발표를 마친 후에는 끝났다는 게 실감이 안 났다.</p>
<p>이제 다음 단계로 넘어가기 전에 나만무 프로젝트를 차분히 돌아보고 정리하자!</p>
<h2 id="📸-데모-영상">📸 데모 영상</h2>
<p>볼 때마다 저항 없이 웃게 되는 우리 팀 시연 영상ㅠㅠㅋㅋㅋ</p>
<p>중간에 동환 오빠가 브이앱 방송 사고처럼 등장하는 것도 볼 때마다 웃긴다ㅋㅋㅋ</p>
<p>시연을 위해 고생해준 재화, 종호, 호영이에게 박수를 보낸다 👏🏻</p>
<p><a href="https://youtu.be/A6VFVRwBNBY"><img src="https://raw.githubusercontent.com/YoungeuiHong/image/main/jungle/%EB%8F%8C%EB%8F%8C%EB%B0%8B_%EB%8D%B0%EB%AA%A8%EC%98%81%EC%83%81_%EC%9C%A0%ED%8A%9C%EB%B8%8C_%EC%8D%B8%EB%84%A4%EC%9D%BC.png" alt=""></a></p>
<h2 id="🌐-배포하기">🌐 배포하기</h2>
<p>우리는 백엔드, 프론트엔드, OpenVidu 서버 이렇게 세 개의 서버를 배포해야 했는데, 배포 과정에서 이런저런 시행착오를 겪었다. </p>
<h3 id="💥-err_cert_authority_invalid">💥 ERR_CERT_AUTHORITY_INVALID</h3>
<p>OpenVidu 서버는 AWS EC2에서 도커 이미지를 실행하는 방식으로 배포했다. 그런데 Vercel로 배포한 프론트엔드에서 OpenVidu로 요청을 보내면 <code>ERR_CERT_AUTHORITY_INVALID</code> 에러가 발생했었다. Let&#39;s Encrypt로 인증서도 발급 받았는데 왜 인증서 관련 에러가 발생하나 했는데, OpenVidu 서버 주소를 도메인 주소가 아닌 IP 주소로 적은 것이 원인이었다. SSL/TLS 인증서는 도메인과 연결된 것이므로, https 통신에는 도메인 주소가 필수적임을 명심하자.</p>
<h2 id="💌-sse-server-sent-events">💌 SSE (Server-Sent Events)</h2>
<h3 id="🥲-이벤트-보냈는데-왜-받지를-못하니">🥲 이벤트 보냈는데... 왜 받지를 못하니?</h3>
<p>우리는 정해진 통화시간이 지나면 팬이 다음 영상통화방으로 이동하도록 알림을 보내는 기능이 필요했는데, 양방향 통신이 필요 없는 기능이었기 때문에 소켓 통신 대신 SSE를 사용하기로 했다. </p>
<p>그런데 클라이언트가 간헐적으로 서버가 보낸 이벤트를 받지 못하는 문제가 생겼다. 서버가 보낸 이벤트를 받지 못하면 다음 영상통화방으로 넘어가지 않기 때문에 이는 치명적인 문제였다. 서버 로그에는 분명히 이벤트를 보냈다는 기록이 남아있었다. </p>
<p>문제의 원인은 http 버전이었는데, HTTP/1.1에서 최대로 맺을 수 있는 SSE 연결의 개수는 6개이기 때문이었다. 서버에서 보낸 이벤트가 유실되는 문제는 버전을 HTTP/2로 변경함으로써 해결됐다. 그리고 연결 개수가 초과되는 문제를 미연에 방지하기 위해 페이지를 벗어날 때에는 <code>EventSource</code>를 잘 닫아주도록 했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/ef36d791-ebaa-45ec-8e3b-dc020cc8b665/image.png" alt=""></p>
<h2 id="🥸-넣지-못해-아쉬웠던-필터-기능">🥸 넣지 못해 아쉬웠던 필터 기능</h2>
<p>오프라인 팬미팅에서 아이돌에게 머리띠를 씌워주는 것처럼 필터로 머리띠를 씌우는 기능을 넣고 싶어서 OpenVidu의 <code>FaceOverlayFilter</code> 기능을 사용해서 필터 기능을 구현하려고 했다. </p>
<p>그런데 OpenVidu 공식 문서에 나와 있는 대로 코드를 작성했는데도 자꾸 <code>org.kurento.client.internal.server.KurentoServerException: &#39;offsetXPercent&#39; parameter should be a double (Code:40001, Type:null, Data: {&quot;type&quot;:&quot;MARSHALL_ERROR&quot;})</code>이라는 에러가 발생했다.</p>
<p>number 타입으로 전달했는데도 계속 타입 에러가 발생하는 것이 이상하다 싶어서 OpenVidu 쪽 코드를 살펴봤는데, json을 파싱하는 부분에서 잘못된 부분이 있음을 발견했다. Kurento 서버에 Double 타입으로 보내야 하는데 String 타입으로 파싱해서 보내고 있었던 것이었다.</p>
<p>그래서 <code>fromJsonObjectToProps</code>  부분 코드를 수정해서 도커 이미지를 만들고, OpenVidu에서 제공하는 도커 이미지 대신에 우리의 도커 이미지로 배포해보았다. 그랬더니 필터 기능이 드디어 정상 작동함을 확인할 수 있었다! </p>
<p>그런데 문제는 필터 기능이 되니 반대로 녹화 기능이 제대로 동작하지 않았다... <code>fromJsonObjectToProps</code>를 녹화 기능에서도 사용하다보니 발생하는 문제인 것 같았다. 프로젝트 마감까지 시간이 얼마 남지 않은 상황이어서 결국 필터 기능은 빼기로 결정했다 😢</p>
<p>필터 기능을 넣지 못해서 아쉽긴 했지만, 오픈소스 코드를 수정하는 경험을 해볼 수 있어서 좋았다. 중요한 일정이 끝나고 나면 다른 기능들도 제대로 동작할 수 있게 <code>fromJsonObjectToProps</code>를 수정해서 OpenVidu에 PR을 날려보고 싶다. </p>
<h2 id="👋-안녕-나만무">👋 안녕 나만무</h2>
<p>빡센 일정에 힘들긴 했지만 나는 나만무를 진행했던 기간이 정글에서 가장 즐거웠던 시간이었다. 그래서 나만무가 끝났을 때 조금 헛헛했던 것 같다ㅠ</p>
<p>나만무를 하면서 내가 만들고 싶은 서비스를 만드는 게 굉장히 즐거운 경험임을 느낄 수 있었다. 아이돌에 관심 많은 덕후들이 모여서 &quot;이런 기능 있으면 좋지 않을까?&quot; 얘기하면서 만드는 과정이 즐거웠고, 나도 모르게 퀄리티에 욕심이 생겨서 밤을 샜던 것 같다.</p>
<p>그리고 5주간 팀원들이랑 부대끼며 개발하면서, 팀원으로서의 나는 어떤 모습인가에 대해서도 많이 돌아보게 되었다. 이번 프로젝트를 하면서 배운 점은 &#39;내가 생각한 방법이 정답이 아닐 수 있음을 항상 명심하자&#39;였다. 고생해서 코드를 작성하다 보면 내가 작성한 코드에 이상한 애착(?)이 생겨서 더 나은 방법이 있음에도 그걸 모를 때가 있었다. 근거 없는 똥고집을 부리지 말도록 하자.</p>
<p>마지막으로 상언이, 가람오빠, 재화, 호영이, 종호 모두 고생 많았고, 좋은 추억 남길 수 있게 해줘서 진심으로 고맙습니닷 🫶 모두 승승장구하는 2024년이 되길!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SW사관학교 정글] Week14. 나만의 무기를 갖기 1주차 개발 일지 ]]></title>
            <link>https://velog.io/@youngeui_hong/SW%EC%82%AC%EA%B4%80%ED%95%99%EA%B5%90-%EC%A0%95%EA%B8%80-Week14.-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EB%A5%BC-%EA%B0%96%EA%B8%B0-1%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@youngeui_hong/SW%EC%82%AC%EA%B4%80%ED%95%99%EA%B5%90-%EC%A0%95%EA%B8%80-Week14.-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EB%A5%BC-%EA%B0%96%EA%B8%B0-1%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Thu, 16 Nov 2023 18:08:01 GMT</pubDate>
            <description><![CDATA[<h2 id="👀-주제-선정">👀 주제 선정</h2>
<p>이번 주차에는 초안 발표가 있었다. 내가 평소 관심 있는 주제, 해보고 싶은 기술을 중심으로 어떤 주제를 하는 게 좋을지 고민해보았다. </p>
<p>내가 평소 관심 있는 주제는 아이돌이고, 구현해보고 싶은 기술은 영상 채팅 / 실시간 채팅이었다.</p>
<p>이렇게 고민하다가 생각난 것이 바로 비대면 팬미팅이었다.</p>
<p>비대면 팬미팅을 할 때 보통 카톡 페이스톡으로 많이 하는데, 소속사에서 일일이 시간을 재면서 걸고 끊고 하는 게 번거롭겠다는 생각이 들었다. </p>
<p>그러면서 &quot;영상 통화를 걸고 끊는 것을 우리가 자동으로 해주면 어떨까?&quot; 라는 생각으로 이어졌다.</p>
<p>마침 우리 조에 아이돌을 좋아하는 친구들도 많았고, 아이돌 라이브를 주제로 프로젝트를 하고 싶어했던 친구도 있었다. 서로의 생각이 모이다보니 재밌는 아이디어들이 엄청 많이 나왔다.</p>
<p>그래서 비대면 팬미팅을 주제로 초안 발표를 했는데, 운영진분들께서 재밌는 주제라는 피드백을 주셔서 이 주제 그대로 진행하게 되었다.</p>
<h2 id="🚀-기술스택-선정">🚀 기술스택 선정</h2>
<ul>
<li>NextJS</li>
<li>TypeScript</li>
<li>React Query</li>
<li>Jotai</li>
<li>WebRTC 👉🏻 OpenVidu</li>
<li>Vercel</li>
</ul>
<p>기술 스택은 위와 같이 선정했다.</p>
<p>지난 주에 NextJS를 사용해봤는데, 14 버전으로 업데이트된지가 얼마되지 않아서, 다른 라이브러리와 호환이 안 되는 문제 등이 있었다. 그래서 안전하게 13 버전으로 사용하기로 했다.</p>
<p>그리고 React Query를 사용하기 때문에 상태 관리 라이브러리는 가벼운 것을 사용해도 되겠다 싶었다. 그래서 recoil과 jotai 중 무엇을 사용할까 고민하다가 사용 방법이 좀 더 간단한 jotai를 선택했다.</p>
<p>WebRTC를 구현할 때 어떤 라이브러리를 많이 사용하는지 찾아봤는데, Janus와 OpenVidu를 많이 사용하는 것 같았다. 그런데 Janus의 경우 공식문서 내용이 자세하지 않고, React와 함께 사용한 케이스가 많지 않아서 위험 부담이 크겠다 싶었다. 그래서 WebRTC 라이브러리는 구현 예시가 많은 OpenVidu로 선정했다.</p>
<h2 id="💜-돌돌밋">💜 돌돌밋</h2>
<p>프로젝트 제목은 &quot;돌돌밋&quot;으로 결정했다.</p>
<p>마치 컨테이너 벨트 위를 움직이는 것처럼 돌돌 돌며 나의 아이돌을 만난다는 뜻이다.</p>
<h2 id="🎥-openvidu">🎥 OpenVidu</h2>
<h3 id="리액트-최신-버전에-맞게-수정하기">리액트 최신 버전에 맞게 수정하기</h3>
<p>먼저 openvidu-tutorials에 있는 코드들은 리액트 옛날 버전의 코드들이어서 요즘의 버전에 맞게 수정하는 작업을 했다.</p>
<p>클래스 기반 컴포넌트의 라이프 사이클 메서드인 <code>componentDidMount</code>, <code>componentDidUpdate</code>, <code>componentWillUnmount</code>들은 <code>useEffect</code>로 교체해주었다.</p>
<h3 id="정해진-시간이-지나면-화상-채팅이-종료되게-하기">정해진 시간이 지나면 화상 채팅이 종료되게 하기</h3>
<p>우리가 생각했을 때 우리의 핵심 기능은 정해진 시간이 지나면 자동으로 전화를 끊고 다음 아이돌 멤버에게 전화를 걸어주는 것이었다. (카리나와의 2분 통화가 끝나면 자동으로 끊고, 다음 멤버인 윈터에게 자동으로 영상 통화가 연결되는 식으로)</p>
<p>그래서 중간 발표 때까지 이 기능은 꼭 구현하기로 했다.</p>
<p><code>setInterval</code> 또는 <code>setTimeout</code>을 사용해서 타이머를 구현하면 되겠지 싶었는데 생각보다 간단하지가 않았다. 그리고 타이머를 구현하는 방식에 따라 시간의 정확도도 달라진다는 사실을 발견했다. 아래 블로그를 참고해서 최대한 정확히 시간을 측정하려고 했다.</p>
<p><a href="https://medium.com/@bsalwiczek/building-timer-in-react-its-not-as-simple-as-you-may-think-80e5f2648f9b">Building timer in React? It’s not as simple as you may think!</a></p>
<p>그리고 <code>setInterval</code>과 <code>setTimeout</code>은 비동기 함수가 완료될 때까지 기다려주지 않으므로 유의하자.</p>
<h3 id="다음-팬을-영상통화로-연결하기">다음 팬을 영상통화로 연결하기</h3>
<p>OpenVidu 튜토리얼을 보면 사용자가 Join 버튼을 눌렀을 때 영상통화가 연결되게 하는데, 우리가 원하는 기능은 이 방식으로 구현할 수 없었다.</p>
<p>즉, 팬을 대기열에서 기다리고 있게 하다가 앞 사람의 통화가 종료되면 그 때 영상통화가 연결되게 해야 하는데 이걸 어떻게 구현할지 방법을 생각해내는 게 이번 주 중 제일 어려운 부분이었다.</p>
<p>그러다가 OpenVidu API 중 세션에 시그널을 보내는 기능이 있다는 것을 알게 됐다. 즉, 특정 이벤트가 발생하면 해당 세션에 참여하고 있는 커넥션들에게 시그널을 보내는 기능이었다. 이 기능을 사용하면 통화가 종료됐을 때 시그널을 보내서 다음 팬이 참여할 수 있게 할 수 있겠다 싶었다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/c2f6c652-5ec1-432a-bf64-2aeb90df39fb/image.png" alt=""></p>
<p>그래서 대기열에 들어온 팬은 토큰만 먼저 발급 받아놨다가, 시그널이 왔을 때 그 토큰을 가지고 세션에 connect하도록 기능을 구현했다. </p>
<p>아직 엉성하긴 하지만 아래와 같이 팬들이 차례대로 들어올 수 있는 발판은 마련했다. 어서 속도를 내서 나머지 기능들도 구현해보자!</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/460415a0-b0e2-4028-9d8f-0075975978bf/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나만의 무기 준비하기] 5-6일차 개발일지]]></title>
            <link>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-5%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-5%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Mon, 06 Nov 2023 17:22:56 GMT</pubDate>
            <description><![CDATA[<h2 id="🔥-firebase-storage에-이미지-업로드하기">🔥 Firebase Storage에 이미지 업로드하기</h2>
<h3 id="🤯-cors-에러">🤯 CORS 에러</h3>
<blockquote>
<p>Access to XMLHttpRequest at &#39;<a href="https://firebasestorage.googleapis.com/v0/b/http%3A%2F%2Fjungle-react-realtime-chat.appspot.com/o?name=memoji2.png&#39;">https://firebasestorage.googleapis.com/v0/b/http%3A%2F%2Fjungle-react-realtime-chat.appspot.com/o?name=memoji2.png&#39;</a> from origin &#39;<a href="http://localhost:3000&#39;">http://localhost:3000&#39;</a> has been blocked by CORS policy: Response to preflight request doesn&#39;t pass access control check: It does not have HTTP ok status.</p>
</blockquote>
<p><a href="https://firebase.google.com/docs/storage/web/download-files?hl=ko">Firebase 공식문서</a>를 보니 아래와 같이 CORS 구성을 변경하라고 되어 있어서 수정했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/5dca0e63-4070-46aa-b8f9-1a450348ed03/image.png" alt=""></p>
<p>그런데도 계속 동일한 에러가 발생해서 한참을 고생했는데, 보다보니 요청을 보내는 주소가 좀 이상해보였다. </p>
<p>URL 중간에 http가 들어가 있는 것에 아무래도 이상해서 <code>firebaseConfig</code>의 <code>storageBucket</code>에서 <code>http://</code>를 삭제해봤다. 그랬더니 이게 문제가 맞았다...❗️ Firebase 프로젝트를 생성하면 제공해주는 <code>firebaseConfig</code>를 그대로 복사해온 거여서 이게 잘못됐을 거라 생각을 못했는데, 이게 문제가 맞았다.</p>
<pre><code class="language-ts">const firebaseConfig = {
  // ❌ Wrong: 잘못된 주소
  storageBucket: &quot;http://jungle-react-realtime-chat.appspot.com&quot;,

  // 🆗 정상 작동하는 주소
  storageBucket: &quot;jungle-react-realtime-chat.appspot.com&quot;,

};</code></pre>
<h2 id="✍️-게시글-수정하기">✍️ 게시글 수정하기</h2>
<h3 id="🧐-getstaticpaths-에러">🧐 <code>getStaticPaths</code> 에러</h3>
<blockquote>
<p>🚨 Error: Invalid <code>paths</code> value returned from getStaticPaths in /posts/[id].
<code>paths</code> must be an array of strings or objects of shape { params: [key: string]: string }</p>
</blockquote>
<p>분명히 알맞은 포맷으로 <code>paths</code> 변수를 만드는 것 같은데, 자꾸 아래와 같은 에러가 발생했다. 포맷이 잘못됐나 싶어서 <code>console.log()</code>를 찍어보려고 했는데, 콘솔에 찍히지도 않았다. </p>
<p>문제는 <code>async</code> 함수를 호출하면서 비동기 작업이 완료되기를 기다려주지 않은 것이었다. <code>getAllPostIds()</code> 함수 앞에 <code>await</code> 키워드를 붙여서 에러를 해결했다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/13bddd99-9531-4189-b1d9-6073067d3a99/image.png" alt=""></p>
<h3 id="🙉-그런데-게시글-수정-사항이-반영되지-않는다">🙉 그런데 게시글 수정 사항이 반영되지 않는다...!</h3>
<p>분명히 DB에는 수정사항이 반영돼서 게시글 목록에서는 수정된 내용으로 보이는데, 게시글 상세 내용 조회화면에 들어가면 수정사항이 반영되지 않고 이전 내용이 보였다.</p>
<p>어떻게 보면 당연한 내용이었다. 게시글 상세 내용 조회 화면을 정적 페이지로 구현했기 때문에 수정사항이 반영되지 않는 것이었다. </p>
<p>Static Site Generation은 빌드 타임에 서버에서 페이지를 미리 생성해서, 나중에 빌드된 페이지를 그대로 제공하는 방식이다. 정적 페이지의 경우 런타임에 페이지의 내용이 변경되지 않기 때문에 수정 기능이 있는 페이지에서 사용하는 것은 적절하지 않다.</p>
<p>시간은 조금 낭비했지만 🥹 덕분에 Static Site Generation에 대해 잘 이해할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나만의 무기 준비하기] 3-4일차 개발 일지]]></title>
            <link>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-3-4%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-3-4%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Sat, 04 Nov 2023 15:25:00 GMT</pubDate>
            <description><![CDATA[<h2 id="🔐-nextauthjs">🔐 NextAuth.JS</h2>
<p>처음에는 Firebase의 인증 시스템을 사용해보려고 했는데, 살펴보다보니 <code>NextAuth.js</code>로 훨씬 더 간단하게 인증/인가 기능을 구현할 수 있어서 <code>NextAuth.js</code>를 사용하기로 했다.</p>
<p>OAuth 기능을 사용해보고 싶었어서 이번에는 이메일/비밀번호 대신 OAuth를 통해 로그인/로그아웃하는 기능을 구현하기로 했다.</p>
<h3 id="🥹-간단하게-구현될-줄-알았는데">🥹 간단하게 구현될 줄 알았는데</h3>
<p>공식 문서에 나와있는 대로 구현을 했더니 아래와 같은 에러가 계속 발생했다.</p>
<pre><code> ○ Compiling /api/auth/[...nextauth]/route ...
 ✓ Compiled /api/auth/[...nextauth]/route in 606ms (1120 modules)
 ⨯ TypeError: r is not a function</code></pre><h3 id="💡-버전의-문제였다">💡 버전의 문제였다..</h3>
<p>Next 14 버전으로 업데이트된 지가 얼마 안 돼서 혹시나 하고 Next 13 버전으로 변경해보았는데, 그랬더니 문제 없이 돌아갔다. Next 14 버전에서 가능한 방법을 찾으려면 시간이 너무 오래 걸릴 듯 하여 결국 아래와 같이 버전을 낮췄다</p>
<pre><code>    &quot;next&quot;: &quot;^13&quot;,
    &quot;next-auth&quot;: &quot;^4.24.4&quot;,</code></pre><h2 id="👀-oauth">👀 OAuth</h2>
<p>Google OAuth 기능을 사용하려면 우선 구글 클라이언트 아이디와 비밀번호가 필요한데, 이거는 Google Developer Console에서 발급 받을 수 있었다.</p>
<p>발급 받을 때 redirection url을 작성해줘야 하는데, NextAuth.js의 경우 <code>/api/auth/callback/google</code>로 작성해주면 된다.</p>
<p>그리고 <code>src/app/api/auth/[...nextauth]/route.ts</code> 경로에 아래와 같이 작성해주면 OAuth 관련 설정은 간단히 끝난다.</p>
<pre><code class="language-ts">import NextAuth from &quot;next-auth&quot;;
import GoogleProvider from &quot;next-auth/providers/google&quot;;
import { AuthOptions } from &quot;next-auth&quot;;

export const authOptions: AuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID ?? &quot;&quot;,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? &quot;&quot;,
    }),
  ],
};

export const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };</code></pre>
<h2 id="👋-로그인-기능">👋 로그인 기능</h2>
<p>모든 routes에 대해서 로그인이 되어 있지 않은 상태면 로그인 화면으로 이동하는 기능을 구현하기로 했다.</p>
<p>처음에 생각한 방법은 jotai로 loginAtom이라는 state를 만들고, 이 값이 false이면 로그인 화면으로 이동하게 하려고 했다. 그런데 NextJS는 기본적으로 서버 컴포넌트를 가정하고 있고, 서버 컴포넌트에서는 state값을 사용하지 않기 때문에 이 방법은 적절하지 않았다.</p>
<p>다음으로 고려한 방법은 <code>middleware.ts</code> 파일에서 아래와 같이 <code>getServerSession</code> 함수를 사용해서 로그인 상태 값을 받아오고, 로그인이 되어 있지 않은 경우 로그인 화면으로 리다이렉션 하는 방법이었다. 하지만 아래의 코드로 하면 오류가 발생했는데 <code>middleware.ts</code>는 Vercel 플랫폼의 Edge 상태에서 실행돼서 <code>getServerSession</code> 함수가 정상적으로 작동할 수 없기 때문이었다.</p>
<pre><code class="language-ts">import { NextResponse } from &quot;next/server&quot;;
import type { NextRequest } from &quot;next/server&quot;;
import { getServerSession } from &quot;next-auth&quot;;

export async function middleware(request: NextRequest) {
  const session = await getServerSession();

  if (session) {
    return NextResponse.next();
  }

  return NextResponse.redirect(new URL(&quot;/api/auth/login&quot;, request.url));</code></pre>
<p>결국 성공한 방법은 <code>layout.tsx</code>에서 로그인 여부를 체크하는 방법이었다. 아래와 같이 <code>getServerSession()</code> 함수로 로그인 상태를 받아오고 로그인이 되어 있지 않으면 <code>redirect()</code> 함수를 사용해서 로그인 화면으로 이동하도록 했다.</p>
<pre><code class="language-tsx">import { Layout } from &quot;@/app/components/layout&quot;;
import ThemeClient from &quot;@/app/components/themes/ThemeClient&quot;;
import { getServerSession } from &quot;next-auth&quot;;
import SessionProvider from &quot;@/app/components/auth/SessionProvider&quot;;
import { redirect } from &quot;next/navigation&quot;;

/** 중략 */

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession();

  if (!session || !session.user) {
    redirect(&quot;api/auth/signin&quot;);
  }

  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body className={inter.className}&gt;
        &lt;SessionProvider session={session}&gt;
          &lt;ThemeClient&gt;
            &lt;Layout&gt;{children}&lt;/Layout&gt;
          &lt;/ThemeClient&gt;
        &lt;/SessionProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p>위 코드를 보면 <code>SessionProvider</code>를 <code>next-auth</code>가 아닌 프로젝트 내부 컴포넌트로 import하는 것을 발견할 수 있다. 이렇게 한 이유는 <code>SessionProvider</code>는 클라이언트 컴포넌트여야 하기 때문에 아래와 같이 별도의 컴포넌트를 만들었다.</p>
<pre><code class="language-ts">&quot;use client&quot;;
import { SessionProvider } from &quot;next-auth/react&quot;;
export default SessionProvider;</code></pre>
<h2 id="👋-로그아웃-기능">👋 로그아웃 기능</h2>
<p>로그아웃 기능은 <code>signOut()</code> 함수를 사용하면 간단하게 구현할 수 있었다.</p>
<pre><code class="language-tsx">import { signOut } from &quot;next-auth/react&quot;;

const Header = () =&gt; {

  return (
    {/* 중략 */}
        &lt;IconButton
          size=&quot;large&quot;
          color=&quot;inherit&quot;
          sx={{
            width: 35,
            height: 35,
            mt: 1,
            ...{
              color: &quot;grey.400&quot;,
            },
          }}
          onClick={() =&gt; signOut()}
        &gt;
          &lt;LogoutIcon /&gt;
        &lt;/IconButton&gt;

  );
};

export default Header;
</code></pre>
<h2 id="💻-결과물">💻 결과물</h2>
<p><code>NextAuth.js</code>를 사용해서 아래와 같이 OAuth 로그인 기능을 구현할 수 있었다. 시간이 된다면 <code>NextAuth.js</code>에서 제공하는 기본 UI 말고 커스텀 UI를 사용하도록 수정해봐야겠다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/46f8407c-cf92-4770-943b-a7c303c4df54/image.gif" alt=""></p>
<h2 id="🔥-firebase">🔥 Firebase</h2>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/e73e3850-716b-41ec-b767-8c27bd396535/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나만의 무기 준비하기] 2일차 개발 일지]]></title>
            <link>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-2%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-2%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Thu, 02 Nov 2023 14:33:41 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-서버-사이드-렌더링">🤔 서버 사이드 렌더링</h2>
<p>Next JS를 사용하면서 가장 익숙하지 않은 점은 서버 사이드 렌더링이다.</p>
<p>아래는 클라이언트 사이드 렌더링과 서버 사이드 렌더링을 비교해서 살펴본 내용이다.</p>
<p>&quot;애플리케이션의 로직과 렌더링을 어디에 구현할 것인가&quot;와 관련해서 고민이 필요한 것 같다.</p>
<h3 id="💻-서버-사이드-렌더링">💻 서버 사이드 렌더링</h3>
<ul>
<li>SSR은 서버에서 사용자에게 보여줄 페이지를 모두 구성해서 사용자에게 페이지를 보여주는 방식. JSP/Servlet의 아키텍처에서 이 방식을 사용했음</li>
<li>즉, 외부 데이터를 fetching하고 React Component를 HTML로 변환하는 과정이 클라이언트로 보내지기 전에 서버에서 이루어지는 것</li>
<li>SSR을 사용하면 모든 데이터가 매핑된 서비스 페이지를 클라이언트(브라우저)에게 바로 보여줄 수 있음.</li>
<li>서버를 이용해서 페이지를 구성하기 때문에 클러이언트에서 구성하는 CSR(Client-Side Rendering)보다 페이지를 구성하는 속도는 늦다</li>
<li>하지만 사용자에게 보여주는 컨텐츠 구성이 완료되는 시점은 더 빨라지는 장점이 있다.</li>
<li>SEO(Search Engine Optimization) 또한 쉽게 구성할 수 있다.</li>
<li>SSR의 경우 화면이 렌더링되는 동안 사용자가 화면을 볼 수 있지만, interactable한 상태는 아니다. 사용자가 취한 액션은 React 실행이 완료되기 전까지는 처리되지 않는다.</li>
<li>SSR은 서버가 HTML 파일을 만들기까지 시간이 걸리기 때문에 TTFB(Time To First Byte)는 CSR에 비해 느리다.</li>
</ul>
<ul>
<li>서버 사이드 렌더링은 <strong>Hydration</strong> 과정을 거쳐서 Interactive UI가 된다.</li>
</ul>
<blockquote>
<p>🤔 <strong>Hydration이란?</strong></p>
</blockquote>
<ul>
<li>서버에서 생성한 HTML 마크업이 클라이언트로 전달될 때 이 HTML에는 React 컴포넌트의 초기 상태 및 이벤트 핸들러가 포함되어 있지만 아직 활성화되지 않은 상태이다.</li>
<li>HTML이 로드되면 클라이언트 측 JavaScript 코드가 React 컴포넌트를 다시 만들어서 초기 상태를 적용하고 이벤트 핸들러를 등록합니다. 이 프로세스를 &quot;hydration&quot;이라고 한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/1ae4bff2-5160-476e-8fff-774ea182d008/image.png" alt=""></p>
<h3 id="👩🏻💻-클라이언트-사이드-렌더링">👩🏻‍💻 클라이언트 사이드 렌더링</h3>
<ul>
<li>CSR은 서버로부터 빈 HTML 문서를 서버로부터 반환 받는데, JS와 React의 다운로드, React의 실행이 완료된 후에 사용자가 화면을 볼 수 있다.</li>
<li>CSR은 SSR보다 초기 전송되는 페이지의 속도는 빠르지만 서비스에서 필요한 데이터를 클라이언트(브라우저)에서 추가로 요청하여 재구성해야 하기 때문에 전체적인 페이지 완료 시점은 SSR보다 느려진다.</li>
<li>클라이언트 사이드 렌더링은 렌더링이 완료되기 전까지 빈 화면을 보게 된다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/616a5195-cbec-4b48-b5b1-6fbaba642c36/image.png" alt=""></p>
<blockquote>
<p>🤔 <strong>클라이언트 사이드 렌더링이 왜 SEO에 취약한가?</strong>
SEO(Search Engine Organization)을 위해서는 HTML 페이지 전체가 필요한데, SPA 페이지의 HTML 파일들은 아래와 같이 빈 껍데기이기 때문</p>
</blockquote>
<pre><code>&lt;html&gt;
&lt;head&gt;
  &lt;title&gt;리액트 프로젝트&lt;/title&gt;
  &lt;link rel=&quot;stylesheet&quot; href=&quot;app.css&quot; type=&quot;text/css&quot;&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div id=&quot;app&quot;&gt;&lt;/div&gt;
  &lt;script src=&quot;app.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><hr>
<p>아래는 서버 사이드 렌더링과 클라이언트 사이드 렌더링을 살펴보다 보면 나오는 용어들의 정의다.</p>
<blockquote>
<p><strong>Rendering 관련 용어</strong> </p>
</blockquote>
<ul>
<li><strong>SSR</strong>: Sever-Side Rendering. client-side 또는 범용 앱을 서버의 HTML로 렌더링함</li>
<li><strong>CSR</strong>: Client-Side Rendering. 일반적으로 DOM을 사용하여 브라우저에서 앱을 렌더링함.</li>
<li><strong>Rehydration</strong>: 서버에서 렌더링된 HTML DOM 트리와 데이터를 재사용하도록 클라이언트에서 JavaScript View를 부팅하는 것</li>
<li><strong>Prerendering</strong>: 빌드할 때 정적 HTML의 초기 상태를 캡처하기 위해 client-side 애플리케이션을 실행하는 것</li>
</ul>
<blockquote>
<p><strong>Performance 관련 용어</strong></p>
</blockquote>
<ul>
<li><strong>TTFB</strong>: Time to First Byte. 링크를 클릭한 후 컨텐츠의 첫 번째 bit가 들어오기까지 걸린 시간</li>
<li><strong>FP</strong>: First Paint. 사용자에게 처음으로 픽셀이 보인 시간</li>
<li><strong>FCP</strong>: First Contentful Paint. 요청한 내용이 보인 시간</li>
<li><strong>TTI</strong>: Time To Interactive. 페이지가 상호작용이 가능해진 시간</li>
</ul>
<hr>
<h2 id="🤓-nextjs의-렌더링-방식">🤓 NextJS의 렌더링 방식</h2>
<p>다시 돌아와서 NextJS의 렌더링 방식을 살펴보자. NextJS에서는 세 가지 렌더링 방식이 가능하다.</p>
<ol>
<li>Server-Side Rendering</li>
<li>Static Site Generation</li>
<li>Client-Side Rendering</li>
</ol>
<p>Next.js에서는 <code>getServerSideProps</code>를 사용하여 서버 사이드 렌더링을 사용할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[나만의 무기 준비하기] 1일차 개발 일지]]></title>
            <link>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-1%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@youngeui_hong/%EB%82%98%EB%A7%8C%EC%9D%98-%EB%AC%B4%EA%B8%B0-%EC%A4%80%EB%B9%84%ED%95%98%EA%B8%B0-1%EC%9D%BC%EC%B0%A8-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Thu, 02 Nov 2023 03:34:26 GMT</pubDate>
            <description><![CDATA[<h2 id="🧐-nextjs">🧐 NextJS</h2>
<p>이전 회사에서도 리액트를 사용하기는 했지만 NextJS를 사용해보지는 못했었다.</p>
<p>이번 주차가 NextJS를 공부해볼 수 있는 좋은 기회다 싶어서 NextJS를 사용해서 개발해보기로 했다.</p>
<p>오늘은 NextJS 작동 방식에 대해 전반적으로 살펴보고 레이아웃을 잡았다.</p>
<h3 id="라우팅">라우팅</h3>
<p>NextJS는 기본적으로 프로젝트의 폴더 구조를 기반으로 라우팅 기능을 제공한다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/f79756d7-3c57-4d76-b97b-8d5abfbbaef7/image.png" alt=""></p>
<p>이전에는 컴포넌트를 분리해서 관리할 때 별도의 폴더로 분리해서 사용했어서, 이러한 폴더들이 모두 라우팅 대상에 포함되면 어떡하지? 싶었는데, 다행히 아래 그림과 같이 <code>page.js</code> 파일과 <code>route.js</code> 파일만 라우팅 대상에 포함된다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/08cdcd48-fa90-4875-a6e2-d84e2f2a4f28/image.png" alt=""></p>
<h3 id="레이아웃">레이아웃</h3>
<p>NextJS에서는 <code>layout.tsx</code> 파일을 사용하면 여러 페이지에서 공통적으로 레이아웃을 공유할 수 있다. 이 기능을 사용하면 라우팅이 변경될 때 레이아웃 부분을 제외한 나머지 부분만 렌더링 되는 partial rendering이 적용된다. </p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/4060cf28-f2b5-4273-906c-2a279f05c119/image.png" alt=""></p>
<h3 id="서버-사이드-렌더링과-클라이언트-사이드-렌더링">서버 사이드 렌더링과 클라이언트 사이드 렌더링</h3>
<p>NextJS에서는 기본적으로 서버 사이드 렌더링을 가정한다. 따라서 클라이언트 사이드 렌더링 컴포넌트인 경우에는 파일의 가장 상단에 <code>&#39;use client&#39;</code>라고 작성해서 표시해줘야 한다. 아니면 아래와 같은 오류를 마주할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/fc59d247-27e7-4730-bd2a-8838633745fa/image.png" alt=""></p>
<p>아래는 NextJS에서 설명하는 서버 컴포넌트를 사용했을 때의 장점이다.</p>
<ul>
<li>Server Components execute on the server, so you can keep expensive data fetches and logic on the server and only send the result to the client.</li>
<li>Server Components support promises, providing a simpler solution for asynchronous tasks like data fetching. You can use async/await syntax without reaching out for useEffect, useState or data fetching libraries.</li>
<li>Since Server Components execute on the server, you can query the database directly without an additional API layer.</li>
</ul>
<h3 id="folder-structure">Folder Structure</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/47e3afb8-4ed1-4c4b-8a6b-4516a3afff3d/image.png" alt=""></p>
<h3 id="css-modules">CSS Modules</h3>
<p>기회가 되면 아래 방법을 써보자!</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/19337e2e-0a55-476f-bbb4-6c22bfdc9503/image.png" alt=""></p>
<h3 id="clsx"><code>clsx</code></h3>
<p>조건에 따라 CSS를 다르게 적용해야 할 때 유용하게 사용될 것 같다. 기억해놨다가 써보자.
<img src="https://velog.velcdn.com/images/youngeui_hong/post/2b1035fe-c371-419a-ad50-16c73441c90d/image.png" alt=""></p>
<h3 id="폰트-적용하기">폰트 적용하기</h3>
<p><a href="https://nextjs.org/learn/dashboard-app/optimizing-fonts-images">https://nextjs.org/learn/dashboard-app/optimizing-fonts-images</a></p>
<h3 id="image"><code>&lt;Image&gt;</code></h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/a8983d74-548f-441c-b721-6b14532f5e45/image.png" alt=""></p>
<h1 id="🔐-oauth-20">🔐 OAuth 2.0</h1>
<p>NextJS에서는 아래 라이브러리를 사용해서 OAuth 기능을 구현하는 것 같다. 추후 사용해보자.</p>
<p><a href="https://www.npmjs.com/package/next-auth">https://www.npmjs.com/package/next-auth</a></p>
<p><a href="https://next-auth.js.org/providers/google">https://next-auth.js.org/providers/google</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩 테스트] 다이나믹 프로그래밍 (동적 계획법, DP) 문제 총 정리 📚]]></title>
            <link>https://velog.io/@youngeui_hong/%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8B%A4%EC%9D%B4%EB%82%98%EB%AF%B9-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EB%8F%99%EC%A0%81-%EA%B3%84%ED%9A%8D%EB%B2%95-DP-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@youngeui_hong/%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8B%A4%EC%9D%B4%EB%82%98%EB%AF%B9-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EB%8F%99%EC%A0%81-%EA%B3%84%ED%9A%8D%EB%B2%95-DP-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 30 Oct 2023 13:58:22 GMT</pubDate>
            <description><![CDATA[<h2 id="🧐-다이나믹-프로그래밍이란">🧐 다이나믹 프로그래밍이란?</h2>
<p>다이나믹 프로그래밍이란 문제를 효율적으로 풀기 위해 복잡한 큰 문제를 더 작은 하위 문제로 나누어 푸는 방법이다.</p>
<p>이 방법은 동일한 하위 문제를 반복해서 풀지 않고, 중복되는 계산을 제거하여 효율적으로 문제를 풀 수 있다.</p>
<p>다이나믹 프로그래밍 구현 방법은 크게 top-down 방식과 bottom-up 방식으로 나누어 살펴볼 수 있다.</p>
<h3 id="⬇️-top-down-방식">⬇️ top-down 방식</h3>
<p>top-down 방식은 큰 문제를 작은 하위 문제로 나누고, 각 하위 문제의 해결을 위해 재귀 함수를 호출한다.</p>
<p>이 때 중복 계산을 피하기 위해 메모이제이션 기법을 사용하여 계산한 결과를 메모리 공간에 메모해놓고, 같은 문제가 다시 호출되면 메모했던 결과를 그대로 가져온다.</p>
<h3 id="⬆️-bottom-up-방식">⬆️ bottom-up 방식</h3>
<p>bottom-up 방식은 작은 하위 문제부터 시작해서 큰 문제까지 순차적으로 해결해나가는 방법인데, 계산한 값을 dp 테이블에 저장해나가는 방식을 사용한다.</p>
<p>일반적으로 재귀보다 반복문이 속도가 더 빠르므로, top-down 방식을 bottom-up 방식으로 바꾸려고 시도하는 경우가 많다.</p>
<hr>
<h2 id="백준-2748번-피보나치-수-2"><a href="https://www.acmicpc.net/problem/2748">백준 2748번 피보나치 수 2</a></h2>
<h3 id="📄-문제">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/c4b4d1d2-9a50-42a8-89a1-43ac8aaf3b72/image.png" alt=""></p>
<h3 id="📝-답안">📝 답안</h3>
<p><strong>1) 시간복잡도: <code>O(2^n)</code></strong></p>
<pre><code class="language-python">import sys

n = int(sys.stdin.readline().strip())

result = [0] * (n + 1)

def fibonacci(n):
    if n == 1 or n == 2:
        result[n] = 1
        return 1

    # 메모이제이션
    if result[n]:
        return result[n]

    result[n] = fibonacci(n - 1) + fibonacci(n - 2)

    return result[n]

print(fibonacci(n))</code></pre>
<p><strong>2) 시간복잡도: <code>O(n)</code></strong></p>
<pre><code class="language-python">import sys

n = int(sys.stdin.readline().strip())

result = [0] * (n + 1)

def fibonacci(n):
    if n &lt;= 1:
        return n

    fib = [0] * (n + 1)
    fib[1] = 1

    for i in range(2, n + 1):
        fib[i] = fib[i - 1] + fib[i - 2]

    return fib[n]

print(fibonacci(n))</code></pre>
<hr>
<h2 id="백준-2624번-동전-바꿔주기"><a href="https://www.acmicpc.net/problem/2624">백준 2624번 동전 바꿔주기</a></h2>
<h3 id="📄-문제-1">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/3cb47269-a517-48e5-838c-495653aa911c/image.png" alt=""></p>
<h3 id="📝-답안-1">📝 답안</h3>
<pre><code class="language-python">import sys

input = sys.stdin.readline

# 지폐의 금액
T = int(input().strip())

# 동전의 가지 수
k = int(input().strip())

# 동전 정보
coins = []
for _ in range(k):
    p, n = map(int, input().split())
    coins.append((p, n))

# 금액 n에 대한 동전 교환 방법의 가지 수
dp = [0] * (T + 1)

# 0원인 경우 교환 방법은 한 가지이므로 1로 초기화
dp[0] = 1

# 동적 프로그래밍
for coin, cnt in coins:
    for money in range(T, 0, -1): # 목표금액에서 1원까지 내려가며 진행
        for i in range(1, cnt + 1): # 현재 동전 coin의 개수 cnt만큼 반복문 진행
            if money - coin * i &gt;= 0:
                # coin을 i개 사용했을 때 금액 money를 만들 수 있는 경우의 수는 
                # 나머지 금액 money - coin * i를 만들 수 있는 경우의 수와 같음
                dp[money] += dp[money - coin * i]

print(dp[T])</code></pre>
<hr>
<h2 id="백준-9084번-동전"><a href="https://www.acmicpc.net/problem/9084">백준 9084번 동전</a></h2>
<h3 id="📄-문제-2">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/64f93015-e076-4b58-8ded-795958db5594/image.png" alt=""></p>
<h3 id="📝-답안-2">📝 답안</h3>
<p>아래 그림과 같이 동전을 사용한 경우와 사용하지 않은 경우를 나누어서 재귀 함수를 활용해서 푼 방법이다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/4c28c17b-fafa-46eb-b49f-3e22ce141d6e/image.png" alt=""></p>
<pre><code class="language-python">import sys

input = sys.stdin.readline

def solution(T, coins):
    # 메모이제이션
    memo = [[-1] * (T + 1) for _ in range(len(coins))]

    # 가능한 경우의 수 세기
    def number_of_ways(i, amount): # i: 현재 동전의 인덱스 / amount: 남은 금액
        if amount == 0: # 목표한 금액을 만들면 1 리턴
            return 1
        if i == len(coins): # 목표한 금액을 만들지 못한 상태로 동전 배열의 끝에 도달하면 0 리턴
            return 0
        if memo[i][amount] != -1: # 이미 가능한 경우의 수를 셌다면 중복 계산 없이 해당 값 리턴
            return memo[i][amount]

        if coins[i] &gt; amount: # 만약 남은 금액보다 현재 동전의 금액이 크다면 다음 동전으로 넘어가기
            memo[i][amount] = number_of_ways(i + 1, amount)
            return memo[i][amount]
        else:
            # 현재 동전을 포함했을 때와 포함하지 않았을 때의 경우의 수를 합함
            memo[i][amount] = number_of_ways(i, amount - coins[i]) + number_of_ways(i + 1, amount)
            return memo[i][amount]

    return number_of_ways(0, amount)

case = int(input().strip())

for _ in range(case):
    coin_n = int(input().strip())
    coins = sorted(list(map(int, input().strip().split())), reverse=True)
    amount = int(input().strip())
    print(solution(amount, coins))</code></pre>
<hr>
<h2 id="백준-2294번-동전-2"><a href="https://www.acmicpc.net/problem/2294">백준 2294번 동전 2</a></h2>
<h3 id="📄-문제-3">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/86c28b17-2e21-4d03-902d-04f1482a83ae/image.png" alt=""></p>
<h3 id="📝-답안-3">📝 답안</h3>
<pre><code class="language-python">import sys

input = sys.stdin.readline

# n: 동전 종류의 개수, k: 만들려는 금액
n, k = map(int, input().strip().split())

# 동전 종류별 금액
coins = sorted([int(input().strip()) for _ in range(n)])
coins = set(coins)

# 동전 개수 테이블
nums = [10001] * (k + 1)
nums[0] = 0

for coin in coins:
    for i in range(coin, k + 1):
        nums[i] = min(nums[i], nums[i-coin] + 1)

if nums[k] == 10001:
    print(-1)
else:
    print(nums[k])</code></pre>
<hr>
<h2 id="백준-9251번-lcs"><a href="https://www.acmicpc.net/problem/9251">백준 9251번 LCS</a></h2>
<h3 id="📄-문제-4">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/6629cf0d-1d33-47d9-97b4-1affae772b25/image.png" alt=""></p>
<h3 id="📝-답안-4">📝 답안</h3>
<p>자세한 풀이는 <a href="https://velog.io/@youngeui_hong/%ED%8C%8C%EC%9D%B4%EC%8D%AC-%EB%B0%B1%EC%A4%80-9251%EB%B2%88-LCS">이 포스팅</a>에 작성함</p>
<pre><code class="language-python">import sys

first = sys.stdin.readline().strip()
second = sys.stdin.readline().strip()

def longest_common_subsequence(text1: str, text2: str) -&gt; int:
    dp_grid = [[0] * (len(text2) + 1) for _ in range(len(text1) + 1)]
    for col in reversed(range(len(text2))):
        for row in reversed(range(len(text1))):
            if text2[col] == text1[row]:
                dp_grid[row][col] = 1 + dp_grid[row + 1][col + 1]
            else:
                dp_grid[row][col] = max(dp_grid[row + 1][col], dp_grid[row][col + 1])
    return dp_grid[0][0]

print(longest_common_subsequence(first, second))</code></pre>
<hr>
<h2 id="leetcode-70-climbing-stairs"><a href="https://leetcode.com/problems/climbing-stairs/">LeetCode 70. Climbing Stairs</a></h2>
<h3 id="📄-문제-5">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/12066c5b-68d9-44b4-90a9-efbae362a99b/image.png" alt=""></p>
<h3 id="📝-답안-5">📝 답안</h3>
<pre><code class="language-python">class Solution(object):
    def climbStairs(self, n):
        &quot;&quot;&quot;
        :type n: int
        :rtype: int
        &quot;&quot;&quot;
        def dfs(rest, memo):
            if rest == 0:
                return 1
            if rest &lt; 0:
                return 0
            if rest in memo:
                return memo[rest]

            memo[rest] = dfs(rest - 1, memo) + dfs(rest - 2, memo)
            return memo[rest]

        memo = {}
        return dfs(n, memo)</code></pre>
<hr>
<h2 id="300-longest-increasing-subsequence"><a href="https://leetcode.com/problems/lo%F0%9F%93%9Dngest-increasing-subsequence/description/">300. Longest Increasing Subsequence</a></h2>
<h3 id="📄-문제-6">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/b19948a3-e600-43e4-801e-e6dfa499d28e/image.png" alt=""></p>
<h3 id="📝-답안-6">📝 답안</h3>
<pre><code class="language-python">class Solution(object):
    def lengthOfLIS(self, nums):
        &quot;&quot;&quot;
        :type nums: List[int]
        :rtype: int
        &quot;&quot;&quot;
        n = len(nums)
        dp = [1] * n

        for i in range(1, n):
            for j in range(i):
                if nums[i] &gt; nums[j]:
                    dp[i] = max(dp[i], dp[j] + 1)

        return max(dp)</code></pre>
<hr>
<h2 id="백준-10942번-팰린드롬"><a href="https://www.acmicpc.net/problem/10942">백준 10942번 팰린드롬?</a></h2>
<h3 id="📄-문제-7">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/91070cfa-2f1f-40d5-9de3-30aac1484e6e/image.png" alt=""></p>
<h3 id="📝-답안-7">📝 답안</h3>
<pre><code class="language-python">import sys
sys.setrecursionlimit(10 ** 9)

input = sys.stdin.readline

N = int(input().strip())

nums = list(map(int, input().strip().split()))

M = int(input().strip())

visited = [[0] * N for _ in range(N)]

def is_palindrome(s, e):
    while s &lt;= e:
        if nums[s] == nums[e]:
            if not visited[s][e]:
                result = is_palindrome(s + 1, e - 1)
                visited[s][e] = result
            return visited[s][e]
        else:
            return 0
    return 1

for _ in range(M):
    s, e = map(int, input().strip().split())
    print(is_palindrome(s - 1, e - 1))</code></pre>
<hr>
<h2 id="5-longest-palindromic-substring"><a href="https://leetcode.com/problems/longest-palindromic-substring/">5. Longest Palindromic Substring</a></h2>
<h3 id="📄-문제-8">📄 문제</h3>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/e1b1f60b-d8ff-4e38-9f89-c72ef7f43c84/image.png" alt=""></p>
<h3 id="📝-답안-8">📝 답안</h3>
<pre><code class="language-python">class Solution(object):
    def longestPalindrome(self, s):
        &quot;&quot;&quot;
        :type s: str
        :rtype: str
        &quot;&quot;&quot;
        n = len(s)
        dp = [[None] * n for _ in range(n)]

        for i in range(n):
            dp[i][i] = True

        longest_start, max_len = 0, 1

        for end in range(n):
            for start in reversed(range(end)):
                if s[start] == s[end]:
                    if end - start == 1 or dp[start + 1][end - 1]:
                        dp[start][end] = True
                        if max_len &lt; end - start + 1:
                            max_len = end - start + 1
                            longest_start = start

        return s[longest_start:longest_start + max_len]
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩 테스트] 위상정렬 문제 총 정리 ✍️]]></title>
            <link>https://velog.io/@youngeui_hong/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%9C%84%EC%83%81%EC%A0%95%EB%A0%AC-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@youngeui_hong/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%9C%84%EC%83%81%EC%A0%95%EB%A0%AC-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sun, 29 Oct 2023 14:23:28 GMT</pubDate>
            <description><![CDATA[<h2 id="👀-위상정렬-topological-sort">👀 위상정렬 (Topological Sort)</h2>
<p>위상정렬은 싸이클이 없는 방향 그래프(Directed Acyclic Graph)의 노드들을 선형적으로 정렬하는 방법이다.</p>
<p>이 알고리즘은 &quot;선수과목을 고려한 학습 순서 설정&quot;과 같이 스케줄링, 의존성 관리, 작업 순서 결정 등에 많이 사용된다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/367ae3ec-cffa-4662-8e2e-b95afbc6039d/image.png" alt=""></p>
<h3 id="🤔-진입차수indegree란">🤔 진입차수(Indegree)란?</h3>
<p>진입차수란 특정 노드로 들어오는 간선의 개수를 의미한다.
<img src="https://velog.velcdn.com/images/youngeui_hong/post/e356bc6c-3a9f-44f2-8809-5f2ef5e92ce9/image.png" alt=""></p>
<h3 id="👩🏻💻-위상정렬-풀이-방법">👩🏻‍💻 위상정렬 풀이 방법</h3>
<p>1) 진입차수가 0인 모든 노드를 큐에 넣는다.</p>
<p>2) 큐가 빌 때까지 다음의 과정을 반복한다.</p>
<ul>
<li>큐에서 원소를 꺼내 해당 노드에서 나가는 간선을 그래프에서 제거한다.</li>
<li>새롭게 진입 차수가 0이 된 노드를 큐에 넣는다.</li>
</ul>
<p>👉🏻 결과적으로 큐에 노드가 들어온 순서가 위상정렬을 수행한 결과와 같다.</p>
<h3 id="📝-위상정렬-cheat-sheet">📝 위상정렬 Cheat Sheet</h3>
<pre><code class="language-python">from collections import deque

# 노드의 개수와 간선의 개수를 입력 받기
v, e = map(int, input().split())
# 모든 노드에 대한 진입차수는 0으로 초기화
indegree = [0] * (v + 1)
# 각 노드에 연결된 간선 정보를 담기 위한 연결 리스트 초기화
graph = [[] for i in range(v + 1)]

# 방향 그래프의 모든 간선 정보를 입력 받기
for _ in range(e):
    a, b = map(int, input().split())
    graph[a].append(b) # 정점 A에서 B로 이동 가능
    # 진입 차수를 1 증가
    indegree[b] += 1

# 위상 정렬 함수
def topology_sort():
    result = [] # 알고리즘 수행 결과를 담을 리스트
    q = deque() # 큐 기능을 위한 deque 라이브러리 사용

    # 처음 시작할 때는 진입차수가 0인 노드를 큐에 삽입
    for i in range(1, v + 1):
        if indegree[i] == 0:
            q.append(i)

    # 큐가 빌 때까지 반복
    while q:
        # 큐에서 원소 꺼내기
        now = q.popleft()
        result.append(now)
        # 해당 원소와 연결된 노드들의 진입차수에서 1 빼기
        for i in graph[now]:
            indegree[i] -= 1
            # 새롭게 진입차수가 0이 되는 노드를 큐에 삽입
            if indegree[i] == 0:
                q.append(i)

    # 위상 정렬을 수행한 결과 출력
    for i in result:
        print(i, end=&#39; &#39;)

topology_sort()</code></pre>
<hr>
<h2 id="leetcode-207-course-schedule">LeetCode 207. Course Schedule</h2>
<h3 id="🔗-문제">🔗 문제</h3>
<p><a href="https://leetcode.com/problems/course-schedule/description/">207. Course Schedule
</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/d14a38d3-8396-41b7-919f-3ff5d1fc6f0f/image.png" alt=""></p>
<h3 id="📝-답안">📝 답안</h3>
<p>위상정렬을 한 결과를 <code>result</code> 배열에 담는데, 만약 이 <code>result</code> 배열에 모든 노드(= 과목)가 들어가지 못하면 모든 코스를 완료할 수 없음을 의미한다.</p>
<pre><code class="language-python">from collections import deque


class Solution(object):
    def canFinish(self, numCourses, prerequisites):
        &quot;&quot;&quot;
        :type numCourses: int
        :type prerequisites: List[List[int]]
        :rtype: bool
        &quot;&quot;&quot;

        # 모든 과목에 대한 진입 차수는 0으로 초기화
        indegree = [0] * numCourses

        # 각 과목에 연결된 간선 정보를 담기 위한 그래프 초기화
        graph = [[] for _ in range(numCourses)]

        # 선수 과목 관계를 그래프에 반영하기
        for edge in prerequisites:
            u, v = edge # 수업 u를 듣기 위해선 v를 먼저 들어야 함
            graph[v].append(u) # v -&gt; u

            indegree[u] += 1 # u의 진입 차수 1 증가

        result = [] # 수강할 수 있는 과목 목록
        q = deque()

        # 진입 차수가 0인 과목들을 큐에 삽입
        for i in range(numCourses):
            if indegree[i] == 0:
                q.append(i)

        # 큐가 빌 때까지 반복
        while q:
            now = q.popleft()
            result.append(now)

            for i in graph[now ]:
                # now 과목과 연결된 노드들의 진입 차수에서 1 빼기
                indegree[i] -= 1
                # 새롭게 진입 차수가 0이 되는 과목들을 큐에 삽입
                if indegree[i] == 0:
                    q.append(i)

        # 들을 수 있는 과목 수가 numCourses보다 적으면 False 리턴
        return len(result) == numCourses</code></pre>
<hr>
<h2 id="백준-2252번-줄-세우기">백준 2252번 줄 세우기</h2>
<h3 id="🔗-문제-1">🔗 문제</h3>
<p><a href="https://www.acmicpc.net/problem/2252">2252. 줄 세우기</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/e0775441-ef06-42d0-b1dd-934ae43ab5e6/image.png" alt=""></p>
<h3 id="📝-답안-1">📝 답안</h3>
<pre><code class="language-python">import sys
from collections import deque

input = sys.stdin.readline

n, m = map(int, input().split())

# 정점 연결 정보
graph = [[] for i in range(n + 1)]

# 모든 노드에 대한 진입 차수는 0으로 초기화
indegree = [0] * (n + 1)

# 비교(간선) 정보 입력 받기
for _ in range(m):
    a, b = map(int, input().split())
    # a 보다 큰 노드로 b 추가
    graph[a].append(b)
    # b의 진입차수 1 증가
    indegree[b] += 1

# 위상 정렬 함수
def topology_sort():
    result = [] # 키 순서대로 정렬
    q = deque()

    # 처음 시작할 때는 진입 차수가 0인 노드를 큐에 삽입. 즉 가장 작은 학생
    for i in range(1, n + 1):
        if indegree[i] == 0:
            q.append(i)

    # 큐가 빌 때까지 반복
    while q:
        now = q.popleft()
        result.append(now)
        # 해당 원소와 연결된 노드들의 진입 차수에서 1 빼기
        for i in graph[now]:
            indegree[i] -= 1
            # 새롭게 진입 차수가 0이 되는 노드를 큐에 삽입
            if indegree[i] == 0:
                q.append(i)

    for i in result:
        print(i, end=&quot; &quot;)


topology_sort()</code></pre>
<hr>
<h2 id="백준-2637번-장난감-조립">백준 2637번 장난감 조립</h2>
<h3 id="🔗-문제-2">🔗 문제</h3>
<p><a href="https://www.acmicpc.net/problem/2637">2637. 장난감 조립</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/e79ffa1f-5b8f-40e6-a8e7-521ed7e58918/image.png" alt=""></p>
<h3 id="📝-답안-2">📝 답안</h3>
<pre><code class="language-python">import sys
from collections import deque

input = sys.stdin.readline

n = int(input().strip())
connect = [[] for _ in range(n + 1)]  # 연결 정보
needs = [[0] * (n + 1) for _ in range(n + 1)]  # 각 제품을 만들때 필요한 부품
q = deque()  # 위상 정렬
degree = [0] * (n + 1)  # 진입 차수
for _ in range(int(input())):
    a, b, c = map(int, input().split()) # a를 만드는데 b가 c개 필요
    connect[b].append((a, c))
    degree[a] += 1

for i in range(1, n + 1):
    # 진입 차수가 0인걸 넣어준다.
    if degree[i] == 0:
        q.append(i)

# 위상 정렬 시작
while q:
    now = q.popleft()
    # 현 제품의 다음 단계 번호, 현 제품이 얼마나 필요한지
    for next, next_need in connect[now]:
        # 만약 현 제품이 기본 부품이면
        if needs[now].count(0) == n + 1:
            needs[next][now] += next_need
        # 현 제품이 중간 부품이면
        else:
            for i in range(1, n + 1):
                needs[next][i] += needs[now][i] * next_need
        # 차수 -1
        degree[next] -= 1
        if degree[next] == 0:
            # 차수 0이면 큐에 넣음
            q.append(next)
for x in enumerate(needs[n]):
    if x[1] &gt; 0:
        print(*x)</code></pre>
<hr>
<h2 id="백준-1432번-그래프-수정">백준 1432번 그래프 수정</h2>
<h3 id="🔗-문제-3">🔗 문제</h3>
<p><a href="https://www.acmicpc.net/problem/1432">1432. 그래프 수정</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/f5c5b72f-f27c-4f42-b3ca-a70bcc849afb/image.png" alt=""></p>
<h3 id="📝-답안-3">📝 답안</h3>
<pre><code class="language-python">import sys
import heapq

input = sys.stdin.readline

n = int(input().strip())

# 각 노드에 연결된 간선 정보를 담기 위한 연결 리스트 초기화
graph = [[] for _ in range(n + 1)]

# 모든 노드에 대한 진출차수는 0으로 초기화
outdegree = [0] * (n + 1)

# 결과 저장 배열
result = [0] * (n + 1)

for i in range(1, n + 1):
    connection = list(map(int, input().strip()))

    for idx, val in enumerate(connection, start=1):
        if val == 1:
            graph[idx].append(i)
            outdegree[i] += 1

# 위상 정렬
def topology_sort(n):
    heap = []

    for i in range(1, n + 1):
        if outdegree[i] == 0:
            heapq.heappush(heap, -i)

    while heap:
        now = -heapq.heappop(heap)
        result[now] = n

        for connected_node in graph[now]:
            outdegree[connected_node] -= 1
            if outdegree[connected_node] == 0:
                heapq.heappush(heap, -connected_node)
        n -= 1

topology_sort(n)

if result.count(0) &gt; 2:
    print(-1)
else:
    print(&#39; &#39;.join(map(str, result[1:])))</code></pre>
<hr>
<h2 id="백준-1948번-임계경로">백준 1948번 임계경로</h2>
<h3 id="🔗-문제-4">🔗 문제</h3>
<p><a href="https://www.acmicpc.net/problem/1948">1948. 임계경로</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/e673c0de-6bdc-4021-8d4d-3e9f51e6254c/image.png" alt=""></p>
<h3 id="📝-답안-4">📝 답안</h3>
<pre><code class="language-python">import sys
from collections import deque

n=int(input()) #노드, 도시 개수
m=int(input()) #도로의 개수

graph = [[] * (n + 1) for _ in range(n + 1)]
back_graph = [[] * (n +1) for _ in range(n  + 1)]
indegree = [0] * (n + 1)
result = [0] * (n + 1)
check = [0] * (n + 1)

q = deque()

for _ in range(m):
    a, b ,t = map(int,input().split())
    graph[a].append((b,t))
    back_graph[b].append((a,t))
    indegree[b]+=1

start,end=map(int,input().split())

q.append(start)

def topology():
    while q:
        cur = q.popleft()
        for i, t in graph[cur]:
            indegree[i] -= 1
            result[i] = max(result[i], result[cur] + t)
            if indegree[i] == 0:
                q.append(i)

    # 백트래킹
    cnt = 0 # 임계 경로에 속한 모든 정점의 개수
    q.append(end)
    while q:
        cur = q.popleft()
        for i, t in back_graph[cur]:
            if result[cur] - result[i] == t:
                cnt += 1
                if check[i] == 0:
                    q.append(i)
                    check[i] = 1

    print(result[end])
    print(cnt)

topology()</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[파이썬] LeetCode 399. Evaluate Division 👉🏻 수들 간의 수학적 관계를 그래프로 표현하기
]]></title>
            <link>https://velog.io/@youngeui_hong/%ED%8C%8C%EC%9D%B4%EC%8D%AC-LeetCode-399.-Evaluate-Division-%EC%88%98%EB%93%A4-%EA%B0%84%EC%9D%98-%EC%88%98%ED%95%99%EC%A0%81-%EA%B4%80%EA%B3%84%EB%A5%BC-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%ED%91%9C%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@youngeui_hong/%ED%8C%8C%EC%9D%B4%EC%8D%AC-LeetCode-399.-Evaluate-Division-%EC%88%98%EB%93%A4-%EA%B0%84%EC%9D%98-%EC%88%98%ED%95%99%EC%A0%81-%EA%B4%80%EA%B3%84%EB%A5%BC-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%ED%91%9C%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 29 Oct 2023 08:22:59 GMT</pubDate>
            <description><![CDATA[<h2 id="🔗-문제">🔗 문제</h2>
<p><a href="https://leetcode.com/problems/evaluate-division/description/">LeetCode 399. Evaluate Division
</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/ec1983c1-909a-4630-a50a-38bc3a9ecccc/image.png" alt=""></p>
<h2 id="👀-접근-방법">👀 접근 방법</h2>
<p>수들 간의 수학적 관계는 그래프로 표현할 수 있다. </p>
<p>예를 들어 <code>a / b = 2</code>라 한다면 <code>a</code>를 2로 나누면 <code>b</code>가 되고, <code>b</code>를 1/2로 나누면 <code>a</code>가 된다. </p>
<p>이러한 관계는 아래와 같이 그래프로 표현할 수 있다. 즉 어떤 수가 다른 수가 되기 위해 <strong>나눠야 하는 수</strong>를 간선의 가중치로 삼는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/2e014a8a-3e1c-495e-a255-ce0e9ab33800/image.png" alt=""></p>
<p>만약 query가 <code>[&quot;a&quot;, &quot;c&quot;]</code>로 주어진다면 우리는 <code>a / c = ?</code>에서 <code>?</code>의 값을 구해야 한다. </p>
<p>이 수식은 <code>a / ? = c</code>로 다시 표현할 수 있는데, 즉 <code>a</code>를 어떤 수로 나눠야 <code>c</code>가 될 수 있는지 구하는 것과 동일한 문제이다.</p>
<p>따라서 그래프 상에서 노드 <code>a</code>에서 <code>c</code>까지 가면서 거치는 간선의 가중치를 구하면 될 것이다.</p>
<h2 id="📝-답안">📝 답안</h2>
<pre><code class="language-python">from collections import defaultdict


class Solution:
    def calcEquation(self, equations, values, queries):
        graph = self.buildGraph(equations, values)
        results = [self.calculatePathWeight(start, end, set(), graph) for start, end in queries]
        return results

    # equations과 values를 바탕으로 그래프 생성
    def buildGraph(self, equations, values):
        # 딕셔너리의 딕셔너리 형태로 그래프 생성
        graph = defaultdict(dict)
        # a / b = 2라면 a-&gt; 2 -&gt; b 그리고 b -&gt; 1/2 -&gt; a
        for (u, v), weight in zip(equations, values):
            graph[u][v] = weight
            graph[v][u] = 1 / weight
        return graph

    # DFS 방식으로 start에서 end까지 가는 경로의 가중치 계산
    def calculatePathWeight(self, start, end, visited, graph):
        if start not in graph:
            return -1.0

        if end in graph[start]:
            return graph[start][end]

        visited.add(start)

        for neighbor, weight in graph[start].items():
            if neighbor not in visited:
                cumulativeWeight = self.calculatePathWeight(neighbor, end, visited, graph)
                if cumulativeWeight != -1.0:
                    return weight * cumulativeWeight

        return -1.0
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[코딩 테스트] Union-Find 관련 문제 총 정리 🧐]]></title>
            <link>https://velog.io/@youngeui_hong/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-Union-Find-%EA%B4%80%EB%A0%A8-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@youngeui_hong/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-Union-Find-%EA%B4%80%EB%A0%A8-%EB%AC%B8%EC%A0%9C-%EC%B4%9D-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sat, 28 Oct 2023 11:20:17 GMT</pubDate>
            <description><![CDATA[<h2 id="👀-union-find란">👀 Union-Find란?</h2>
<p>union-find 알고리즘은 서로소 집합(disjoint-set) 알고리즘이라고도 불리는데, 두 노드가 같은 집합에 포함되는지 여부를 파악해야 할 때 사용하기 좋은 알고리즘이다.</p>
<h3 id="📌-cheat-sheet-python">📌 Cheat sheet (Python)</h3>
<p>출처: <a href="https://github.com/ndb796/python-for-coding-test/blob/master/10/3.py">이것이 취업을 위한 코딩 테스트다 with Python</a></p>
<pre><code class="language-python"># 특정 원소가 속한 집합을 찾기
def find_parent(parent, x):
    # 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
    if parent[x] != x:
        # path compression: 경로 상의 모든 노드를 루트 노드에 연결
        # =&gt; 트리의 높이가 낮아지고, Find 연산의 성능이 향상됨
        parent[x] = find_parent(parent, parent[x])
    return parent[x]

# 두 원소가 속한 집합을 합치기 (일반적으로 값이 작은 것을 부모로 만들어줌)
def union_parent(parent, a, b):
    a = find_parent(parent, a)
    b = find_parent(parent, b)
    if a &lt; b:
        parent[b] = a
    else:
        parent[a] = b

# 노드의 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1) # 부모 테이블 초기화하기

# 부모 테이블상에서, 부모를 자기 자신으로 초기화
for i in range(1, v + 1):
    parent[i] = i

# Union 연산을 각각 수행
for i in range(e):
    a, b = map(int, input().split())
    union_parent(parent, a, b)

# 각 원소가 속한 집합 출력하기
print(&#39;각 원소가 속한 집합: &#39;, end=&#39;&#39;)
for i in range(1, v + 1):
    print(find_parent(parent, i), end=&#39; &#39;)

print()

# 부모 테이블 내용 출력하기
print(&#39;부모 테이블: &#39;, end=&#39;&#39;)
for i in range(1, v + 1):
    print(parent[i], end=&#39; &#39;)</code></pre>
<h3 id="📌-cheat-sheet-javascript">📌 Cheat Sheet (JavaScript)</h3>
<pre><code class="language-javascript">function find_parent(parent, x) {
    if (parent[x] !== x) {
        parent[x] = find_parent(parent, parent[x]);
    }

    return parent[x];
}

function union_parent(parent, a, b) {
    a = find_parent(parent, a);
    b = find_parent(parent, b);

    parent[Math.max(a, b)] = Math.min(a, b);
}

function solution(n, computers) {  
    const parent = Array.from({length: n}, (_, idx) =&gt; idx);

    for (let i = 0; i &lt; n; i++) {
        for (let j = 0; j &lt; n; j++) {
            if (computers[i][j] === 1) {
                union_parent(parent, i, j);
            }
        }
    }

    for (let i = 0; i &lt; n; i++) {
        find_parent(parent, i);
    }

    return new Set(parent).size;
}</code></pre>
<hr>
<h2 id="백준-2606번-바이러스">백준 2606번 바이러스</h2>
<h3 id="📝-문제">📝 문제</h3>
<p><a href="https://www.acmicpc.net/problem/2606">백준 2606번 바이러스</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/be7294c7-8ce8-44c1-bad6-38cced14a09f/image.png" alt=""></p>
<h3 id="👩🏻💻-답안">👩🏻‍💻 답안</h3>
<pre><code class="language-python">import sys

def find_parent(parents, x):
    if parents[x] != x:
        parents[x] = find_parent(parents, parents[x])
    return parents[x]

def union_parent(parents, a, b):
    a = find_parent(parents, a)
    b = find_parent(parents, b)
    if a &lt; b:
        parents[b] = a
    else:
        parents[a] = b

# 컴퓨터의 수
n = int(sys.stdin.readline())

# 부모 테이블 초기화
parents = [0] * (n + 1)
for i in range(1, n + 1):
    parents[i] = i

# 연결된 컴퓨터 쌍의 수
c = int(sys.stdin.readline())

# 입력값을 받고 부모 합치기 연산 수행
for _ in range(c):
    a, b = map(int, sys.stdin.readline().strip().split())
    union_parent(parents, a, b)

# 부모 테이블이 업데이트되지 않은 경우를 위해 각각의 노드에 대해 부모 찾기 연산 수행
for i in range(1, n + 1):
    find_parent(parents, i)

# 1번 컴퓨터와 부모가 같은 컴퓨터의 개수 세기
print(parents.count(parents[1]) - 1)
</code></pre>
<hr>
<h2 id="백준-11724번-연결-요소의-개수">백준 11724번 연결 요소의 개수</h2>
<h3 id="📝-문제-1">📝 문제</h3>
<p><a href="https://www.acmicpc.net/problem/11724">백준 11724번 연결 요소의 개수</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/2787a94e-50e2-469f-bc0f-4095bae7aa5c/image.png" alt=""></p>
<h3 id="👩🏻💻-답안-1">👩🏻‍💻 답안</h3>
<pre><code class="language-python">import sys

def find_parent(parents, x):
    if parents[x] != x:
        parents[x] = find_parent(parents, parents[x])
    return parents[x]

def union_parent(parents, a, b):
    a = find_parent(parents, a)
    b = find_parent(parents, b)
    if a &lt; b:
        parents[b] = a
    else:
        parents[a] = b

# 정점의 개수, 간선의 개수
n, m = map(int, sys.stdin.readline().strip().split())

# 부모 배열 초기화
parents = [0] * (n + 1)
for i in range(1, n + 1):
    parents[i] = i

# 간선의 양 끝점 u와 v
for _ in range(m):
    u, v = map(int, sys.stdin.readline().strip().split())
    union_parent(parents, u, v)

for i in range(1, n+1):
    find_parent(parents, i)

parents_set = set(parents)
print(len(parents_set) - 1)</code></pre>
<hr>
<h2 id="백준-1197번-최소-스패닝-트리">백준 1197번 최소 스패닝 트리</h2>
<h3 id="📝-문제-2">📝 문제</h3>
<p><a href="https://www.acmicpc.net/problem/1197">백준 1197번 최소 스패닝 트리</a></p>
<blockquote>
<p>💡 <strong>Spanning Tree란?</strong>
Spanning Tree는 그래프의 모든 노드를 포함하면서 사이클이 존재하지 않는 최소 연결 부분 그래프를 의미한다.</p>
</blockquote>
<blockquote>
<p>💡 <strong>최소 스패닝 트리((Minimum Spanning Tree)란?</strong>
그래프의 모든 정점들을 연결하는 부분 그래프 중에서 가중치의 합이 최소인 트리를 의미한다. 도로 네트워크에서 최소 비용으로 모든 도시를 연결하는 문제 등에 활용된다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/ca3ad090-777c-4c93-a1e6-749f48a84151/image.png" alt=""></p>
<h3 id="👩🏻💻-답안-2">👩🏻‍💻 답안</h3>
<p>최소 스패닝 트리와 관련해서 대표적인 알고리즘은 <strong>크루스칼 알고리즘(Kruskal Algorithm)</strong>인데, 아래와 같은 방법으로 구현할 수 있다.</p>
<ol>
<li>그래프의 모든 간선을 가중치 순으로 정렬한다.</li>
<li>가중치가 낮은 간선부터 스패닝 트리에 추가할지 여부를 살펴본다. 👉🏻 여기에서 <strong>union-find</strong>가 사용된다.
 2-1. find 연산을 했는데 부모 노드가 같다면 싸이클이 발생하는 경우이므로 스패닝 트리에 포함시키지 않는다.
 2-2. find 연산 결과 부모 노드가 다르다면 union 연산을 통해 스패닝 트리에 포함한다.</li>
</ol>
<pre><code class="language-python">import sys

# 특정 원소가 속한 집합을 찾기
def find_parent(parent, x):
    if parent[x] != x:
        parent[x] = find_parent(parent, parent[x])
    return parent[x]

# 두 원소가 속한 집합을 합치기
def union_parent(parent, a, b):
    a_parent = find_parent(parent, a)
    b_parent = find_parent(parent, b)
    # 값이 작은 것을 부모로 만들기
    if a_parent &gt; b_parent:
        parent[a_parent] = b_parent
    else:
        parent[b_parent] = a_parent

# 노드의 개수와 간선의 개수 입력 받기
v, e = map(int, sys.stdin.readline().strip().split())

# 부모 테이블 초기화하기
parent = [0] * (v + 1)
for i in range(1, v + 1):
    parent[i] = i

# 모든 간선을 담을 리스트와 최종 비용을 담을 변수
edges = []
min_cost = 0

# 모든 간선의 정보 입력 받기
for i in range(e):
    a, b, cost = map(int, sys.stdin.readline().strip().split())
    edges.append((cost, a, b))


# 간선을 비용순으로 정렬
edges.sort()

# 간선을 하나씩 확인하며 사이클이 발생하지 않는 경우에만 집합에 포함
for edge in edges:
    cost, a, b = edge

    if find_parent(parent, a) != find_parent(parent, b):
        union_parent(parent, a, b)
        min_cost += cost

print(min_cost)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[파이썬] LeetCode 209. Minimum Size Subarray Sum 👉🏻 Sliding Window / Two Pointers]]></title>
            <link>https://velog.io/@youngeui_hong/%ED%8C%8C%EC%9D%B4%EC%8D%AC-LeetCode-209.-Minimum-Size-Subarray-Sum-Sliding-Window-Two-Pointers</link>
            <guid>https://velog.io/@youngeui_hong/%ED%8C%8C%EC%9D%B4%EC%8D%AC-LeetCode-209.-Minimum-Size-Subarray-Sum-Sliding-Window-Two-Pointers</guid>
            <pubDate>Sat, 28 Oct 2023 07:38:13 GMT</pubDate>
            <description><![CDATA[<h2 id="🔗-문제">🔗 문제</h2>
<p><a href="https://leetcode.com/problems/minimum-size-subarray-sum/description/">209. Minimum Size Subarray Sum
</a></p>
<p><img src="https://velog.velcdn.com/images/youngeui_hong/post/acc7b196-b8ad-4286-8691-f8113b3126f4/image.png" alt=""></p>
<h2 id="📝-답안">📝 답안</h2>
<p>아래 코드는 Sliding Window의 사이즈를 늘렸다가 줄였다가를 반복하면서 최소값을 찾는 코드이다.</p>
<pre><code class="language-python">class Solution(object):
    def minSubArrayLen(self, target, nums):
        # 초기화
        left, right, current_sum, min_len = 0, 0, 0, float(&#39;inf&#39;)

        while right &lt; len(nums):
            # 현재 부분 배열의 합을 업데이트
            current_sum += nums[right]
            right += 1

            # 합이 목표 값보다 크거나 같아질 때까지 왼쪽 포인터를 이동
            while current_sum &gt;= target:
                # 최소 길이 업데이트
                min_len = min(min_len, right - left)
                # 현재 부분 배열에서 왼쪽 요소를 빼고 왼쪽 포인터 이동
                current_sum -= nums[left]
                left += 1

        # 최소 길이가 초기값인 무한대면 0을 반환, 그렇지 않으면 최소 길이 반환
        return 0 if min_len == float(&#39;inf&#39;) else min_len
</code></pre>
]]></description>
        </item>
    </channel>
</rss>