<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sumi-0011.log</title>
        <link>https://velog.io/</link>
        <description>안녕하세요 😚 썸네일을 쉽게 만들 수 있는 서비스를 운영중입니다. 많은 관심 부탁드립니다. https://thumbnail.ssumi.space/</description>
        <lastBuildDate>Sat, 28 Feb 2026 04:29:21 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sumi-0011.log</title>
            <url>https://velog.velcdn.com/images/sumi-0011/profile/4d9a9e50-dce4-4019-81bb-fd44e2666c6d/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sumi-0011.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sumi-0011" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[런타임에 변하는 한글 - i18next에서 조사를 자동으로 선택하기]]></title>
            <link>https://velog.io/@sumi-0011/i18n-post-korea</link>
            <guid>https://velog.io/@sumi-0011/i18n-post-korea</guid>
            <pubDate>Sat, 28 Feb 2026 04:29:21 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>i18next 기반 프로젝트에서 국제화 작업을 진행하고 있었습니다. 한국어 텍스트를 하나씩 번역 키로 옮기다 보니, 이런 게 눈에 들어왔습니다.</p>
<pre><code class="language-json">&quot;deleteModalDescription&quot;: &quot;{{name}}을/를 삭제하시겠습니까?&quot;</code></pre>
<p><code>{{name}}</code>에 들어올 단어에 따라 <code>을</code>이 맞을 수도, <code>를</code>이 맞을 수도 있습니다. 그러면 이걸 쓰는 곳마다 사용부에서 직접 분기해야 하나? 싶었습니다.</p>
<p>모든 페이지를 국제화해야 하는 상황에서 이렇게 DX가 안 좋을 리 없다고 생각했고, 이 문제를 좀 파보게 되었습니다. 한국어의 <strong>조사</strong> 문제였습니다.</p>
<hr>
<h2 id="왜-이게-문제가-되는가">왜 이게 문제가 되는가</h2>
<p>한국어는 앞 글자의 받침(종성) 유무에 따라 조사가 달라집니다.</p>
<table>
<thead>
<tr>
<th align="center">받침 있음</th>
<th align="center">받침 없음</th>
<th align="left">예시</th>
</tr>
</thead>
<tbody><tr>
<td align="center">을</td>
<td align="center">를</td>
<td align="left">모델<strong>을</strong> / 도구<strong>를</strong></td>
</tr>
<tr>
<td align="center">은</td>
<td align="center">는</td>
<td align="left">모델<strong>은</strong> / 도구<strong>는</strong></td>
</tr>
<tr>
<td align="center">이</td>
<td align="center">가</td>
<td align="left">모델<strong>이</strong> / 도구<strong>가</strong></td>
</tr>
<tr>
<td align="center">과</td>
<td align="center">와</td>
<td align="left">모델<strong>과</strong> / 도구<strong>와</strong></td>
</tr>
<tr>
<td align="center">으로</td>
<td align="center">로</td>
<td align="left">부산<strong>으로</strong> / 서울<strong>로</strong></td>
</tr>
</tbody></table>
<p>보통은 문제가 되지 않습니다. 글을 쓰는 시점에 앞 단어를 알고 있으니까요.</p>
<p>그런데 i18n에서는 상황이 다릅니다. <code>{{name}}</code>에 &quot;모델&quot;이 올지 &quot;도구&quot;가 올지는 런타임에 결정됩니다. 번역 키를 작성하는 시점에는 어떤 조사가 맞는지 알 수가 없습니다.</p>
<p>영어에는 이 문제 자체가 없습니다. &quot;Delete {{name}}&quot;은 name에 뭐가 오든 문법이 동일하거든요. 한국어-영어 다국어 프로젝트에서 한국어 쪽만 이 짐을 안게 되는 셈이죠.</p>
<p>그렇다면 프로젝트에서는 이 문제를 어떻게 다루고 있었을까요?</p>
<hr>
<h2 id="프로젝트에서-발견한-세-가지-패턴">프로젝트에서 발견한 세 가지 패턴</h2>
<p><code>ko.json</code>을 더 살펴보니, 이 문제를 나름대로 회피하려는 세 가지 패턴이 섞여 있었습니다.</p>
<p><strong>패턴 1. 슬래시 표기: &quot;을/를&quot;을 그대로 노출</strong></p>
<pre><code class="language-json">&quot;deleteModalDescription&quot;: &quot;{{agentName}}을/를 삭제하시겠습니까?&quot;</code></pre>
<p>가장 흔한 패턴이었습니다. 양쪽 다 써놓으면 틀릴 일은 없으니까요. 하지만 &quot;을/를&quot;이 화면에 그대로 나옵니다. UI가 비전문적으로 느껴지는 게 가장 큰 문제였습니다.</p>
<p><strong>패턴 2. 하드코딩된 단일 조사: 특정 단어에서만 맞음</strong></p>
<pre><code class="language-json">&quot;fieldPlaceholder&quot;: &quot;{{name}}를 입력하세요&quot;</code></pre>
<p>&quot;를&quot;로 고정해뒀는데, <code>name</code>에 &quot;설명&quot;이 들어오면 &quot;설명를&quot;이 됩니다. 받침 있는 단어에서 무조건 틀리죠. 아마 처음 작성할 때 테스트한 단어가 받침 없는 단어였을 겁니다.</p>
<p><strong>패턴 3. 키 2개로 분리: 유지보수 부담</strong></p>
<pre><code class="language-json">&quot;selectFieldPlaceholder&quot;: &quot;{{name}}을 선택하세요&quot;,
&quot;selectFieldPlaceholder2&quot;: &quot;{{name}}를 선택하세요&quot;</code></pre>
<p>가장 정직한 접근이지만, 호출하는 쪽에서 받침 판단을 직접 해야 합니다. 컴포넌트마다 &quot;이 단어는 받침이 있으니 1번 키, 저 단어는 없으니 2번 키&quot;라는 분기 코드가 들어가고, 조사가 필요한 키가 늘수록 키도, 판단 로직도 같이 불어납니다.</p>
<p>세 패턴 모두 근본적인 해결은 아니었습니다. 제가 원한 건 <strong>번역 키 하나로, 런타임에 올바른 조사를 자동 선택</strong>하는 것이었습니다. 거기에 영어 번역에는 아무 영향을 주지 않아야 했고요.</p>
<hr>
<h2 id="세-가지-접근법을-비교하다">세 가지 접근법을 비교하다</h2>
<p>i18next 생태계에서 이 문제를 해결하는 방법은 크게 세 가지가 있었습니다.</p>
<h3 id="접근법-1-formatter-커스텀">접근법 1. Formatter 커스텀</h3>
<p>i18next의 내장 format 함수를 오버라이드하는 방식입니다.</p>
<pre><code class="language-json">&quot;selectPlaceholder&quot;: &quot;{{name, 을}} 선택하세요&quot;</code></pre>
<p>보간 구문 안에 조사 힌트를 넣고, format 함수에서 받침을 판별해 올바른 조사를 반환합니다. 조사를 하나만 쓰면 되니 구문은 간결합니다. 다만 i18next의 기존 formatter와 충돌할 수 있고, 날짜 포맷 같은 다른 format 기능과 조사 처리가 한 함수에 얽히게 되는 점이 걸렸습니다.</p>
<h3 id="접근법-2-post-processor-직접-구현">접근법 2. Post-Processor 직접 구현</h3>
<p>i18next가 보간을 완료한 뒤, 후처리 단계에서 조사를 교체하는 방식입니다.</p>
<pre><code class="language-json">&quot;selectPlaceholder&quot;: &quot;{{name}}(을/를) 선택하세요&quot;</code></pre>
<p>보간이 끝난 문자열을 정규식으로 순회하면서, <code>(을/를)</code> 앞 글자의 받침을 보고 올바른 쪽을 고릅니다. 보간 시스템과 완전히 분리되니 충돌 걱정은 없습니다. 대신 양쪽 조사를 다 써야 하고, 받침 판별 로직과 엣지 케이스(숫자, 영어, 괄호 등)를 직접 챙겨야 합니다.</p>
<h3 id="접근법-3-검증된-라이브러리-도입">접근법 3. 검증된 라이브러리 도입</h3>
<p>찾아보니 <code>i18next-korean-postposition-processor</code>라는 npm 패키지가 이미 있었습니다. Post-Processor 방식인데, 직접 구현 대신 검증된 코드를 가져다 쓰는 겁니다.</p>
<pre><code class="language-json">&quot;selectPlaceholder&quot;: &quot;{{name}}[[를]] 선택하세요&quot;</code></pre>
<p>조사를 하나만 쓰면 됩니다. <code>[[를]]</code>이라고 쓰면 앞 글자의 받침에 따라 &quot;을&quot; 또는 &quot;를&quot;로 자동 변환됩니다. 숫자나 괄호로 감싸진 텍스트 같은 엣지 케이스도 이미 처리되어 있었습니다. 이 라이브러리가 내부적으로 받침을 어떻게 판별하는지는 뒤에서 따로 살펴봅니다.</p>
<h3 id="어떤-걸-선택했는가">어떤 걸 선택했는가</h3>
<table>
<thead>
<tr>
<th align="center">비교 기준</th>
<th align="center">Formatter</th>
<th align="center">직접 구현</th>
<th align="center">라이브러리</th>
</tr>
</thead>
<tbody><tr>
<td align="center">구문 간결함</td>
<td align="center"><code>{{name, 을}}</code></td>
<td align="center"><code>(을/를)</code> 양쪽</td>
<td align="center"><code>[[를]]</code> 하나</td>
</tr>
<tr>
<td align="center">구현 비용</td>
<td align="center">중간</td>
<td align="center">높음</td>
<td align="center">거의 없음</td>
</tr>
<tr>
<td align="center">테스트 신뢰도</td>
<td align="center">직접 작성</td>
<td align="center">직접 작성</td>
<td align="center">이미 검증됨</td>
</tr>
<tr>
<td align="center">엣지 케이스</td>
<td align="center">직접 처리</td>
<td align="center">직접 처리</td>
<td align="center">내장 (숫자, 괄호 등)</td>
</tr>
<tr>
<td align="center">유지보수</td>
<td align="center">직접</td>
<td align="center">직접</td>
<td align="center">커뮤니티</td>
</tr>
</tbody></table>
<p>최종적으로 <strong>라이브러리를 도입하기로 했습니다.</strong> 받침 판별이라는 문제는 이미 잘 정의되어 있고, 검증된 구현체가 있는데 굳이 직접 만들 이유가 없었습니다.</p>
<p>Formatter 방식도 구문이 간결해서 끌렸지만, 결정적으로 <strong>처리 시점</strong>이 달랐습니다. Formatter는 보간 값 자체를 변환하는 단계에서 동작합니다. <code>{{name, 을}}</code>에서 <code>name</code> 값을 받아 조사를 붙인 결과를 돌려주는 방식인데, 이러면 format 함수 하나가 날짜 포맷, 숫자 포맷, 조사 처리를 전부 분기해야 합니다.</p>
<p>반면 Post-Processor는 보간이 끝난 완성된 문자열을 받습니다. <code>&quot;모델[[를]] 선택하세요&quot;</code>처럼 이미 보간이 끝난 상태에서, <code>[[를]]</code> 앞 글자만 보고 조사를 결정하면 됩니다. 기존 format 로직에 영향을 주지 않고, 조사 처리는 어차피 &quot;앞 글자가 확정된 뒤&quot;에야 할 수 있는 작업이니까 Post-Processor가 더 자연스러운 시점이라고 판단했습니다.</p>
<hr>
<h2 id="구현-과정">구현 과정</h2>
<p>바꿔야 할 파일은 4개(<code>package.json</code>, <code>pnpm-lock.yaml</code>, <code>i18n.ts</code>, <code>ko.json</code>), 세 단계로 끝났습니다.</p>
<h3 id="1단계-설치">1단계. 설치</h3>
<pre><code class="language-bash">pnpm add i18next-korean-postposition-processor</code></pre>
<h3 id="2단계-플러그인-등록">2단계. 플러그인 등록</h3>
<p>기존 i18n 설정에 두 줄만 추가하면 됩니다.</p>
<pre><code class="language-typescript">// src/lib/i18n/i18n.ts
import koreanPostpositionProcessor from &#39;i18next-korean-postposition-processor&#39;;

i18n
  .use(initReactI18next)
  .use(koreanPostpositionProcessor) // 추가
  .init({
    // ... 기존 설정 유지
    postProcess: [&#39;korean-postposition&#39;], // 추가
  });</code></pre>
<p>여기서 한 가지 주의할 게 있습니다. <code>postProcess</code> 배열에 넣는 이름 <code>&#39;korean-postposition&#39;</code>은 라이브러리 소스의 <code>get name()</code>에서 정의된 값입니다. 이 이름이 틀리면 아무 동작도 하지 않으니, 라이브러리의 실제 name 속성을 꼭 확인해야 합니다.</p>
<h3 id="3단계-번역-키-마이그레이션">3단계. 번역 키 마이그레이션</h3>
<p><code>ko.json</code>에서 기존 조사 관련 키들을 <code>[[조사]]</code> 구문으로 바꿨습니다. 규칙은 단순합니다. 기존 조사 자리를 <code>[[조사]]</code>로 감싸면 됩니다.</p>
<pre><code class="language-json">// 하드코딩 조사 → [[조사]]
&quot;{{name}}를 입력하세요&quot;       →  &quot;{{name}}[[를]] 입력하세요&quot;
&quot;새로운 {{item}}가 생성되었습니다.&quot;  →  &quot;새로운 {{item}}[[가]] 생성되었습니다.&quot;

// 슬래시 표기 → [[조사]]
&quot;{{agentName}}을/를 삭제하시겠습니까?&quot;  →  &quot;{{agentName}}[[를]] 삭제하시겠습니까?&quot;</code></pre>
<p><code>[[를]]</code>이든 <code>[[을]]</code>이든 상관없습니다. 앞 글자의 받침에 따라 알아서 올바른 형태로 바뀝니다.</p>
<p>이 작업을 하면서 하나 더 정리한 게 있습니다. 기존에는 &quot;<del>를 입력하세요&quot;, &quot;</del>을 선택하세요&quot; 같은 placeholder가 컴포넌트마다 따로 있었는데, 공통 키 두 개로 통합했습니다.</p>
<pre><code class="language-json">&quot;selectPlaceholder&quot;: &quot;{{name}}[[을]] 선택하세요&quot;,
&quot;inputPlaceholder&quot;: &quot;{{name}}[[을]] 입력하세요&quot;</code></pre>
<p>사용하는 쪽에서는 이렇게 쓰면 됩니다.</p>
<pre><code class="language-tsx">commonT(&#39;selectPlaceholder&#39;, { name: t(&#39;model&#39;) })
// → &quot;모델을 선택하세요&quot;</code></pre>
<p><code>en.json</code>은 손대지 않았습니다. <code>[[]]</code> 패턴이 없는 문자열은 post-processor가 그냥 통과시키거든요.</p>
<hr>
<h2 id="라이브러리는-어떻게-동작하는가">라이브러리는 어떻게 동작하는가</h2>
<p>앞에서 &quot;엣지 케이스가 이미 처리되어 있다&quot;고 했는데, 실제로 어떤 과정인지 궁금해서 라이브러리 내부를 들여다봤습니다.</p>
<ol>
<li>i18next가 <code>{{name}}</code>을 보간해서 <code>&quot;모델[[를]] 선택하세요&quot;</code>라는 문자열을 만듭니다.</li>
<li>post-processor가 정규식으로 <code>[[를]]</code>을 찾습니다.</li>
<li><code>[[를]]</code> 바로 앞 글자 <code>&#39;델&#39;</code>의 유니코드를 분석합니다.</li>
</ol>
<p>한글 유니코드에서 받침을 판별하는 공식은 <code>(charCode - 0xAC00) % 28</code>입니다. 결과가 0이면 받침 없음, 0이 아니면 받침 있음. 생각보다 단순한 산술 연산 하나로 끝나는 게 인상적이었습니다.</p>
<p><code>&#39;델&#39;</code>의 코드포인트는 <code>0xB378</code>이고, <code>(0xB378 - 0xAC00) % 28 = 8</code>이니까 받침이 있다고 판별됩니다. 받침이 있으니 <code>를</code> 대신 <code>을</code>이 선택되고, 최종 출력은 <code>&quot;모델을 선택하세요&quot;</code>가 됩니다.</p>
<p>숫자는 한국어 발음 규칙을 따릅니다. &quot;3&quot;은 &quot;삼&quot;으로 읽히니 받침이 있고, &quot;2&quot;는 &quot;이&quot;로 읽히니 받침이 없는 식이죠. <code>&quot;모델&quot;</code>처럼 따옴표나 괄호로 감싸진 텍스트는 괄호 안의 마지막 문자를 기준으로 판별합니다. 직접 구현했다면 이런 케이스를 하나씩 테스트하고 처리해야 했을 텐데, 라이브러리를 선택한 이유가 여기에 있었습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>처음에 <code>ko.json</code>에서 발견한 &quot;을/를&quot;이 화면에 그대로 찍히던 문제(슬래시 표기, 하드코딩 조사, 키 분리)는 결국 같은 원인이었습니다. 런타임에 결정되는 보간 값의 받침을 번역 키 작성 시점에 알 수 없다는 것이죠.</p>
<p>세 가지 접근법을 비교하고, 구현 비용과 검증도를 기준으로 라이브러리를 도입했습니다. 하드코딩 텍스트를 i18n으로 옮기는 작업과 함께 조사 처리까지 한 PR로 정리할 수 있었습니다. &quot;사용부마다 조사를 직접 분기해야 하나?&quot;라는 처음의 DX 고민은 번역 키에 <code>[[조사]]</code>를 쓰는 것만으로 해소되었고, 이제 &quot;을/를&quot;이 화면에 나오는 일도 없어졌습니다.</p>
<p>앞으로 새 번역 키를 작성할 때는 이렇게 쓰면 됩니다.</p>
<pre><code class="language-json">// ko.json
&quot;message&quot;: &quot;{{name}}[[를]] 삭제하시겠습니까?&quot;

// en.json - 변경 없음
&quot;message&quot;: &quot;Do you want to delete {{name}}?&quot;</code></pre>
<p>지원되는 조사는 <code>[[을]]</code> <code>[[를]]</code> <code>[[은]]</code> <code>[[는]]</code> <code>[[이]]</code> <code>[[가]]</code> <code>[[과]]</code> <code>[[와]]</code> <code>[[으로]]</code> <code>[[로]]</code> <code>[[이랑]]</code> <code>[[랑]]</code>이고, 어느 쪽을 써도 알아서 올바른 형태로 변환됩니다.</p>
<p>다만 이 방식은 i18next의 post-processor로 동작하기 때문에 i18next 없이는 쓸 수 없습니다. 순수 유틸리티 함수가 필요하다면 Toss의 <a href="https://www.slash.page/libraries/common/hangul/src/josa.i18n">slash josa</a>나 <a href="https://www.npmjs.com/package/hangul-postposition">hangul-postposition</a> 같은 독립 라이브러리도 있습니다.</p>
<p>그리고 <code>[[]]</code> 구문은 번역자에게 익숙하지 않을 수 있습니다. 팀 내에서 번역 키 작성 규칙으로 공유하고, 이 구문의 의미를 문서화해두는 게 좋겠다고 느꼈습니다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://github.com/Perlmint/i18next-korean-postposition-processor">i18next-korean-postposition-processor (GitHub)</a></li>
<li><a href="https://www.slash.page/libraries/common/hangul/src/josa.i18n">Toss slash josa 유틸리티</a></li>
<li><a href="https://www.npmjs.com/package/hangul-postposition">hangul-postposition (npm)</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[TypeScript는 왜 내 코드를 의심할까]]></title>
            <link>https://velog.io/@sumi-0011/typescript-narrowing-2</link>
            <guid>https://velog.io/@sumi-0011/typescript-narrowing-2</guid>
            <pubDate>Wed, 28 Jan 2026 07:34:49 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<pre><code class="language-typescript">interface State {
  user?: { name: string };
}

function showGreeting(state: State) {
  if (state.user) {
    setTimeout(() =&gt; {
      console.log(`안녕하세요, ${state.user.name}님!`);
      // ❌ &#39;state.user&#39; is possibly &#39;undefined&#39;
    }, 100);
  }
}</code></pre>
<blockquote>
<p>&quot;분명 if문에서 체크했는데, 왜 아직도 undefined인가?&quot;</p>
</blockquote>
<p>TypeScript를 사용하다 보면 이런 상황을 만나게 됩니다. 존재 여부를 확인했는데, 바로 다음 줄에서 &quot;undefined일 수도 있다&quot;고 말하는 TypeScript. 처음에는 버그인 줄 알았습니다.</p>
<p>그런데 지역 변수에 담으면 에러가 사라집니다.</p>
<pre><code class="language-typescript">function showGreeting(state: State) {
  const user = state.user;
  if (user) {
    setTimeout(() =&gt; {
      console.log(`안녕하세요, ${user.name}님!`); // ✅ 정상 동작
    }, 100);
  }
}</code></pre>
<p>같은 값인데, 왜 결과가 다를까요?</p>
<p>이 글에서는 TypeScript가 왜 우리 코드를 &quot;의심&quot;하는지, 그 뒤에 숨은 설계 철학을 살펴보겠습니다.</p>
<hr>
<h2 id="type-narrowing-복습">Type Narrowing 복습</h2>
<blockquote>
<p>Type Narrowing이 처음이라면 <a href="https://velog.io/@sumi-0011/type-narrowing">이전 글: if문 하나로 TypeScript가 똑똑해지는 이유</a>를 먼저 읽어보시기 바랍니다.</p>
</blockquote>
<p>TypeScript는 코드의 흐름을 분석해서 타입을 좁혀나갑니다. 이를 Control Flow Analysis라고 부릅니다.</p>
<pre><code class="language-typescript">function greet(value: string | number) {
  if (typeof value === &#39;string&#39;) {
    console.log(value.toUpperCase()); // string으로 좁혀짐
  } else {
    console.log(value.toFixed(2)); // number로 좁혀짐
  }
}</code></pre>
<p>if문을 통과하면 TypeScript가 타입을 좁혀줍니다. 덕분에 타입 단언 없이도 안전하게 코드를 작성할 수 있습니다.</p>
<p>그런데 이 기능이 어떤 상황에서는 왜 제대로 동작하지 않는 걸까요?</p>
<hr>
<h2 id="typescript의-의심-그-합리적인-이유">TypeScript의 의심, 그 합리적인 이유</h2>
<p>결론부터 말하면, TypeScript는 <strong>&quot;지금 체크한 값이 나중에 쓸 때도 같을까?&quot;</strong>를 의심합니다.</p>
<p>&quot;체크하고 바로 쓰는데 뭐가 바뀌는가?&quot;라고 생각할 수 있습니다. React 개발에서 자주 겪는 상황들을 살펴보겠습니다.</p>
<h3 id="상황-1-props로-받은-객체의-프로퍼티">상황 1: props로 받은 객체의 프로퍼티</h3>
<pre><code class="language-typescript">interface ChatState {
  connection?: WebSocket;
  messages: Message[];
}

function ChatRoom({ state }: { state: ChatState }) {
  const sendMessage = (text: string) =&gt; {
    if (state.connection) {
      // 이 시점: connection이 있음

      setTimeout(() =&gt; {
        // ❌ &#39;state.connection&#39; is possibly &#39;undefined&#39;
        state.connection.send(text);
      }, 100);
    }
  };
}</code></pre>
<p><code>state.connection</code>을 체크했지만, <code>setTimeout</code> 콜백 안에서 다시 접근하면 에러가 발생합니다. 부모 컴포넌트에서 <code>state</code>를 업데이트하면 <code>connection</code>이 <code>undefined</code>가 될 수 있기 때문입니다.</p>
<h3 id="상황-2-외부-store나-전역-객체">상황 2: 외부 store나 전역 객체</h3>
<pre><code class="language-typescript">// Zustand나 전역 상태를 직접 참조하는 경우
const authStore = {
  user: null as User | null,
  logout() { this.user = null; }
};

function UserGreeting() {
  const handleClick = () =&gt; {
    if (authStore.user) {
      // 이 시점: user가 있음

      setTimeout(() =&gt; {
        // ❌ &#39;authStore.user&#39; is possibly &#39;null&#39;
        console.log(`안녕하세요, ${authStore.user.name}님!`);
      }, 100);
    }
  };
}</code></pre>
<p><code>authStore.user</code>를 체크했지만, <code>setTimeout</code>이 실행되기 전에 다른 곳에서 <code>logout()</code>이 호출될 수 있습니다. TypeScript는 이 가능성을 인식하고 에러를 냅니다.</p>
<h3 id="상황-3-refcurrent는-언제든-바뀔-수-있다">상황 3: ref.current는 언제든 바뀔 수 있다</h3>
<pre><code class="language-typescript">function VideoPlayer() {
  const videoRef = useRef&lt;HTMLVideoElement | null&gt;(null);

  const handlePlay = () =&gt; {
    if (videoRef.current) {
      // 이 시점: video 엘리먼트가 있음

      someAsyncOperation().then(() =&gt; {
        // 🤔 이 사이에 조건부 렌더링으로 video가 사라졌다면?
        videoRef.current.play();
      });
    }
  };
  // 조건에 따라 video가 렌더링되거나 안 될 수 있음
  return showVideo ? &lt;video ref={videoRef} /&gt; : &lt;div&gt;영상 없음&lt;/div&gt;;
}</code></pre>
<p><code>ref.current</code>는 DOM이 업데이트되면 언제든 바뀔 수 있습니다. <code>if</code>로 체크한 시점과 실제 사용 시점이 다르면 <code>null</code>일 수 있습니다.</p>
<h3 id="왜-typescript는-이렇게-설계됐을까">왜 TypeScript는 이렇게 설계됐을까?</h3>
<p>TypeScript 팀의 입장은 다음과 같습니다:</p>
<blockquote>
<p>&quot;각 프로퍼티 접근이 다른 값을 반환할 수 있다고 가정하는 것이 유일하게 안전한 방법입니다.&quot;</p>
</blockquote>
<p>React의 상태는 언제든 바뀔 수 있고, ref는 DOM과 함께 변하고, 비동기 작업은 &quot;나중에&quot; 실행됩니다. TypeScript는 이 모든 가능성을 고려해서 <strong>런타임 에러보다는 컴파일 타임의 불편함</strong>을 선택한 것입니다.</p>
<p>보수적으로 느껴질 수 있지만, 이것은 버그가 아니라 의도적인 설계 결정입니다.</p>
<hr>
<h2 id="언제-typescript가-의심할까">언제 TypeScript가 의심할까?</h2>
<p>모든 상황에서 의심하는 것은 아닙니다. 패턴을 알아두면 도움이 됩니다.</p>
<h3 id="의심하는-경우-나중에-실행되는-코드">의심하는 경우: 나중에 실행되는 코드</h3>
<pre><code class="language-typescript">if (state.user) {
  // ❌ setTimeout 콜백
  setTimeout(() =&gt; {
    console.log(state.user.name); // 에러
  }, 100);

  // ❌ 배열 메서드 콜백
  items.forEach(() =&gt; {
    console.log(state.user.name); // 에러
  });

  // ❌ 이벤트 핸들러
  button.addEventListener(&#39;click&#39;, () =&gt; {
    console.log(state.user.name); // 에러
  });

  // ❌ Promise 콜백
  fetchData().then(() =&gt; {
    console.log(state.user.name); // 에러
  });
}</code></pre>
<p>이 코드들의 공통점은 모두 <strong>&quot;나중에 실행되는&quot;</strong> 콜백 함수라는 점입니다.</p>
<h3 id="의심하지-않는-경우-바로-실행되는-코드">의심하지 않는 경우: 바로 실행되는 코드</h3>
<pre><code class="language-typescript">if (state.user) {
  // ✅ 바로 다음 줄
  console.log(state.user.name);

  // ✅ 함수 호출 후에도
  doSomething();
  console.log(state.user.name);

  // ✅ await 후에도 (같은 함수 본문이므로)
  await fetchData();
  console.log(state.user.name);
}</code></pre>
<p>동기적으로 실행되는 코드에서는 TypeScript가 narrowing을 유지합니다.</p>
<hr>
<h2 id="지역-변수는-왜-안전한가">지역 변수는 왜 안전한가?</h2>
<pre><code class="language-typescript">const user = state.user; // 이 시점의 값을 &quot;스냅샷&quot;으로 저장

if (user) {
  setTimeout(() =&gt; {
    console.log(user.name); // ✅ 안전
  }, 100);
}</code></pre>
<p>지역 변수(특히 <code>const</code>)의 특성:</p>
<ul>
<li>재할당이 불가능함</li>
<li>외부에서 값을 바꿀 방법이 없음</li>
<li>TypeScript가 완전히 추적 가능</li>
</ul>
<p><code>state.user</code>가 나중에 바뀌더라도, <code>user</code> 변수에 담긴 값은 그대로입니다. TypeScript는 이를 알고 있어서 안심하고 narrowing을 유지합니다.</p>
<hr>
<h2 id="실무에서-자주-만나는-상황">실무에서 자주 만나는 상황</h2>
<h3 id="react에서-이벤트-핸들러">React에서 이벤트 핸들러</h3>
<pre><code class="language-typescript">function UserProfile() {
  const { data } = useQuery([&#39;user&#39;], fetchUser);

  // ❌ 콜백에서 직접 접근
  const handleSave = () =&gt; {
    if (data?.user) {
      saveUser(data.user).then(() =&gt; {
        toast(`${data.user.name}님 저장 완료`); // 에러
      });
    }
  };

  // ✅ 지역 변수로 해결
  const handleSave = () =&gt; {
    const user = data?.user;
    if (user) {
      saveUser(user).then(() =&gt; {
        toast(`${user.name}님 저장 완료`); // OK
      });
    }
  };
}</code></pre>
<h3 id="배열-순회에서">배열 순회에서</h3>
<pre><code class="language-typescript">function processUsers(state: State) {
  // ❌ forEach 콜백
  if (state.user) {
    items.forEach(item =&gt; {
      sendNotification(item, state.user.email); // 에러
    });
  }

  // ✅ 지역 변수로 해결
  const user = state.user;
  if (user) {
    items.forEach(item =&gt; {
      sendNotification(item, user.email); // OK
    });
  }
}</code></pre>
<h3 id="클래스-메서드에서">클래스 메서드에서</h3>
<pre><code class="language-typescript">class NotificationService {
  private user?: User;

  // ❌ this 프로퍼티 + 콜백
  notify() {
    if (this.user) {
      setTimeout(() =&gt; {
        this.send(this.user.email); // 에러
      }, 1000);
    }
  }

  // ✅ 지역 변수로 해결
  notify() {
    const user = this.user;
    if (user) {
      setTimeout(() =&gt; {
        this.send(user.email); // OK
      }, 1000);
    }
  }
}</code></pre>
<hr>
<h2 id="더-나은-타입-설계">더 나은 타입 설계</h2>
<p>지역 변수 외에도 TypeScript가 잘 이해하는 패턴이 있습니다.</p>
<h3 id="discriminated-union">Discriminated Union</h3>
<pre><code class="language-typescript">type ApiResult =
  | { status: &#39;success&#39;; data: User }
  | { status: &#39;error&#39;; message: string };

function handle(result: ApiResult) {
  if (result.status === &#39;success&#39;) {
    setTimeout(() =&gt; {
      console.log(result.data.name); // ✅ 콜백에서도 OK
    }, 100);
  }
}</code></pre>
<p><code>status</code> 프로퍼티가 전체 타입을 결정하는 &quot;판별자&quot; 역할을 합니다. TypeScript가 가장 잘 이해하는 패턴입니다. API 응답이나 상태 관리에서 이런 형태로 타입을 설계하면 좋습니다.</p>
<hr>
<h2 id="해결-방법-정리">해결 방법 정리</h2>
<table>
<thead>
<tr>
<th>방법</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>지역 변수에 담기 (권장)</strong></td>
<td><code>const user = state.user;</code></td>
</tr>
<tr>
<td><strong>구조 분해 할당</strong></td>
<td><code>const { user } = state;</code></td>
</tr>
<tr>
<td><strong>Early Return 패턴</strong></td>
<td><code>if (!user) return;</code></td>
</tr>
<tr>
<td><strong>Discriminated Union</strong></td>
<td><code>{ status: &#39;loaded&#39;; user: User }</code></td>
</tr>
</tbody></table>
<h3 id="1-지역-변수에-담기">1. 지역 변수에 담기</h3>
<pre><code class="language-typescript">const user = state.user;
if (user) {
  setTimeout(() =&gt; console.log(user.name), 100); // ✅
}</code></pre>
<h3 id="2-구조-분해-할당">2. 구조 분해 할당</h3>
<pre><code class="language-typescript">const { user } = state;
if (user) {
  items.forEach(() =&gt; console.log(user.name)); // ✅
}</code></pre>
<h3 id="3-early-return-패턴">3. Early Return 패턴</h3>
<pre><code class="language-typescript">function process(state: State) {
  const user = state.user;
  if (!user) return;

  // 이 아래 전체가 user가 있는 스코프
  setTimeout(() =&gt; console.log(user.name), 100); // ✅
}</code></pre>
<h3 id="4-discriminated-union으로-타입-설계">4. Discriminated Union으로 타입 설계</h3>
<pre><code class="language-typescript">type State =
  | { status: &#39;idle&#39; }
  | { status: &#39;loaded&#39;; user: User }
  | { status: &#39;error&#39;; message: string };</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>정리하면 다음과 같습니다.</p>
<ul>
<li>TypeScript가 &quot;의심&quot;하는 것은 <strong>버그가 아니라 의도적 설계</strong>입니다</li>
<li><strong>&quot;나중에 실행되는 코드&quot;</strong>에서 값이 바뀔 가능성을 고려하는 것입니다</li>
<li><strong>지역 변수에 담으면</strong> TypeScript가 안전하게 추적할 수 있습니다</li>
<li><strong>Discriminated Union</strong>을 활용하면 더 나은 타입 추론을 받을 수 있습니다</li>
</ul>
<p>처음에는 TypeScript가 지나치게 의심이 많다고 느꼈습니다. &quot;분명히 체크했는데&quot;라고 생각했습니다.</p>
<p>하지만 생각해보면 맞는 말입니다. <code>setTimeout</code> 콜백이 실행되는 100ms 동안 상태가 바뀌고, 컴포넌트가 언마운트되고, 사용자가 로그아웃할 수도 있습니다.</p>
<p>지역 변수 하나를 더 만드는 작은 습관으로 잠재적인 런타임 에러를 방지할 수 있습니다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">TypeScript Handbook - Narrowing</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[if문 하나로 TypeScript가 똑똑해지는 이유]]></title>
            <link>https://velog.io/@sumi-0011/type-narrowing</link>
            <guid>https://velog.io/@sumi-0011/type-narrowing</guid>
            <pubDate>Wed, 28 Jan 2026 07:32:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<pre><code class="language-typescript">function process(value: string | number) {
  console.log(value.toUpperCase());
  // ❌ Property &#39;toUpperCase&#39; does not exist on type &#39;string | number&#39;.
}</code></pre>
<p>TypeScript를 처음 사용할 때 이 에러를 자주 만났습니다.</p>
<p><code>value</code>가 <code>string</code>일 수도 있으니 <code>toUpperCase()</code>를 쓸 수 있어야 하는 것 아닌가? 하지만 TypeScript 입장에서는 <code>value</code>가 <code>number</code>일 가능성도 있습니다. <code>number</code>에는 <code>toUpperCase()</code>가 없으니 에러를 내는 거죠.</p>
<p>그런데 if문 하나를 추가하면 에러가 사라집니다.</p>
<pre><code class="language-typescript">function process(value: string | number) {
  if (typeof value === &#39;string&#39;) {
    console.log(value.toUpperCase()); // ✅ OK!
  }
}</code></pre>
<p>TypeScript가 if문 안에서는 <code>value</code>가 <code>string</code>이라는 걸 인식합니다. 별도의 타입 단언(<code>as string</code>) 없이도요.</p>
<p>이게 바로 <strong>Type Narrowing</strong>입니다. 이 개념을 이해하고 나면 TypeScript 코드 작성이 훨씬 수월해집니다.</p>
<hr>
<h2 id="type-narrowing이란">Type Narrowing이란?</h2>
<p>Type Narrowing은 <strong>넓은 타입을 좁은 타입으로 좁혀가는 과정</strong>입니다.</p>
<p><code>string | number</code> → <code>string</code>처럼, 가능한 타입의 범위를 줄여나가는 것이죠.</p>
<h3 id="왜-이게-중요할까요">왜 이게 중요할까요?</h3>
<p>TypeScript의 핵심 가치는 <strong>컴파일 타임에 에러를 잡는 것</strong>입니다. 그런데 Union 타입을 사용하면 &quot;이 값이 정확히 어떤 타입인지 모른다&quot;는 상황이 생깁니다.</p>
<pre><code class="language-typescript">function formatValue(value: string | number) {
  // value가 string인지 number인지 알 수 없으므로
  // 어떤 메서드도 안전하게 호출할 수 없습니다
}</code></pre>
<p>Type Narrowing은 이 문제를 해결합니다. &quot;이 시점에서 이 값은 확실히 string이다&quot;라고 TypeScript에게 알려주는 것이죠.</p>
<h3 id="typescript는-어떻게-타입을-추론할까요">TypeScript는 어떻게 타입을 추론할까요?</h3>
<p>TypeScript는 코드의 흐름을 분석해서 타입을 자동으로 좁혀줍니다. 이 분석을 <strong>Control Flow Analysis(제어 흐름 분석)</strong>라고 부릅니다. (TypeScript 2.0부터 도입된 기능입니다.)</p>
<pre><code class="language-typescript">function greet(value: string | number) {
  // 여기서 value는 string | number

  if (typeof value === &#39;string&#39;) {
    // 이 블록에서 value는 string
    console.log(value.toUpperCase());
  } else {
    // 이 블록에서 value는 number
    console.log(value.toFixed(2));
  }
}</code></pre>
<p>그리고 이런 분석을 가능하게 하는 조건문들을 <strong>Type Guard</strong>라고 합니다.</p>
<p>그럼 이제부터는 Type Guard를 실제 상황에서 어떻게 사용할 수 있을지 정리해보겠습니다.</p>
<hr>
<h2 id="type-guard-총정리">Type Guard 총정리</h2>
<h3 id="1-typeof">1. typeof</h3>
<p>원시 타입을 구분하는 가장 기본적인 방법입니다. 개인적으로 가장 자주 쓰는 Type Guard이기도 합니다.</p>
<pre><code class="language-typescript">function formatValue(value: string | number | boolean) {
  if (typeof value === &#39;string&#39;) {
    return value.trim(); // string
  }
  if (typeof value === &#39;number&#39;) {
    return value.toFixed(2); // number
  }
  return value ? &#39;Yes&#39; : &#39;No&#39;; // boolean
}</code></pre>
<p>TypeScript는 <code>typeof</code> 연산자의 결과를 신뢰합니다. JavaScript 런타임에서 <code>typeof</code>가 반환하는 값은 확실하기 때문입니다. <code>typeof value === &#39;string&#39;</code>이 <code>true</code>라면, 그 시점에서 <code>value</code>는 100% <code>string</code>입니다.</p>
<p><strong>typeof가 반환하는 값들</strong>: <code>&quot;string&quot;</code>, <code>&quot;number&quot;</code>, <code>&quot;boolean&quot;</code>, <code>&quot;undefined&quot;</code>, <code>&quot;object&quot;</code>, <code>&quot;function&quot;</code>, <code>&quot;symbol&quot;</code>, <code>&quot;bigint&quot;</code></p>
<p>API 응답을 처리할 때 특히 유용합니다. 서버에서 숫자가 문자열로 올 때도 있고, 숫자 그대로 올 때도 있거든요.</p>
<pre><code class="language-typescript">function parseAmount(value: string | number): number {
  if (typeof value === &#39;string&#39;) {
    return parseFloat(value);
  }
  return value;
}</code></pre>
<p><strong>주의할 점</strong>: <code>typeof null</code>은 <code>&quot;object&quot;</code>를 반환합니다. JavaScript의 오래된 버그입니다. 따라서 <code>null</code> 체크는 <code>typeof</code>로 하면 안 됩니다.</p>
<h3 id="2-truthiness-체크">2. Truthiness 체크</h3>
<p><code>null</code>이나 <code>undefined</code>를 걸러내는 가장 간단한 방법입니다. 코드도 짧고 직관적이라 자주 쓰게 됩니다.</p>
<pre><code class="language-typescript">function greet(name: string | null | undefined) {
  if (name) {
    console.log(`Hello, ${name}!`); // string
  }
}</code></pre>
<p>JavaScript에서 <code>null</code>과 <code>undefined</code>는 falsy 값입니다. 따라서 <code>if (value)</code>를 통과했다면, <code>value</code>는 <code>null</code>도 <code>undefined</code>도 아닌 것이 확실합니다. TypeScript는 이 JavaScript 동작을 알고 있어서 타입을 자동으로 좁혀줍니다.</p>
<p>아래는 React에서 Optional props를 처리할 때 자주 사용하는 패턴입니다.</p>
<pre><code class="language-typescript">function UserCard({ user }: { user?: User }) {
  if (user) {
    return &lt;div&gt;{user.name}&lt;/div&gt;;
  }
  return &lt;div&gt;Loading...&lt;/div&gt;;
}</code></pre>
<p>배열에도 동일하게 적용됩니다.</p>
<pre><code class="language-typescript">function getLength(arr?: string[]) {
  if (arr) {
    return arr.length; // string[]
  }
  return 0;
}</code></pre>
<p>그런데 여기서 함정이 하나 있습니다. <code>0</code>, <code>&quot;&quot;</code>, <code>NaN</code>도 falsy 값이라서 의도치 않게 걸러질 수 있습니다.</p>
<pre><code class="language-typescript">function goToPage(page: number | null) {
  if (page) {
    // ⚠️ page가 0이면 이 블록에 진입하지 않습니다
    navigate(`/list?page=${page}`);
  }

  // 명시적인 null 체크가 더 안전합니다
  if (page !== null) {
    // page가 0이어도 진입합니다
    navigate(`/list?page=${page}`);
  }
}</code></pre>
<p>실제로 페이지네이션에서 첫 페이지(0)로 이동이 안 되는 버그를 만난 적이 있습니다. <code>if (page)</code> 조건 때문이었습니다. 그 이후로 숫자를 다룰 때는 <code>!== null</code>을 명시적으로 사용합니다.</p>
<h3 id="3-동등-비교--">3. 동등 비교 (===, !==)</h3>
<p>Truthiness 체크는 &quot;falsy가 아닌 모든 것&quot;을 통과시킵니다. 반면 동등 비교는 <strong>정확히 그 값인지</strong> 확인합니다. <code>null</code>만 걸러내고 싶을 때, <code>0</code>이나 <code>&quot;&quot;</code>는 살리고 싶을 때 동등 비교가 필요합니다.</p>
<pre><code class="language-typescript">function handle(value: string | null) {
  if (value === null) {
    return &#39;No value&#39;;
  }
  return value.toUpperCase(); // string
}</code></pre>
<p>리터럴 타입과 함께 사용하면 더 강력해집니다.</p>
<pre><code class="language-typescript">type Status = &#39;loading&#39; | &#39;success&#39; | &#39;error&#39;;

function getMessage(status: Status) {
  if (status === &#39;loading&#39;) {
    return &#39;Loading...&#39;; // &#39;loading&#39;
  }
  if (status === &#39;success&#39;) {
    return &#39;Done!&#39;; // &#39;success&#39;
  }
  return &#39;Something went wrong&#39;; // &#39;error&#39;
}</code></pre>
<p>API 호출 상태를 관리할 때 유용한 패턴입니다.</p>
<h3 id="4-in-연산자">4. in 연산자</h3>
<p>객체에 특정 프로퍼티가 있는지로 타입을 구분합니다.</p>
<pre><code class="language-typescript">interface AdminUser {
  role: &#39;admin&#39;;
  permissions: string[];
}

interface GuestUser {
  role: &#39;guest&#39;;
  expiresAt: Date;
}

function getUserInfo(user: AdminUser | GuestUser) {
  if (&#39;permissions&#39; in user) {
    console.log(`권한: ${user.permissions.join(&#39;, &#39;)}`); // AdminUser
  } else {
    console.log(`만료일: ${user.expiresAt}`); // GuestUser
  }
}</code></pre>
<p>API 응답 처리에서 성공/실패 응답의 구조가 다를 때 유용합니다.</p>
<pre><code class="language-typescript">interface SuccessResponse { data: User; }
interface ErrorResponse { error: string; }

function handleResponse(res: SuccessResponse | ErrorResponse) {
  if (&#39;error&#39; in res) {
    console.error(res.error); // ErrorResponse
    return;
  }
  console.log(res.data); // SuccessResponse
}</code></pre>
<p><code>res.error</code>로 바로 접근하면 안 되는 이유가 있습니다. <code>SuccessResponse</code>에는 <code>error</code> 프로퍼티가 정의되어 있지 않기 때문에 접근 자체가 타입 에러가 됩니다. <code>in</code> 연산자는 런타임에 프로퍼티 존재 여부를 체크하면서 TypeScript에게 타입 정보도 전달합니다.</p>
<h3 id="5-instanceof">5. instanceof</h3>
<p>클래스로 만든 객체를 구분할 때 씁니다.</p>
<pre><code class="language-typescript">class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

function handleError(error: Error) {
  if (error instanceof ApiError) {
    console.log(`Status: ${error.statusCode}`); // ApiError
  } else {
    console.log(error.message); // Error
  }
}</code></pre>
<p><code>instanceof</code>는 객체의 프로토타입 체인을 확인합니다. <code>error instanceof ApiError</code>가 <code>true</code>라면, <code>error</code>는 <code>ApiError</code> 클래스(또는 그 하위 클래스)의 인스턴스입니다. TypeScript는 이를 통해 해당 클래스의 프로퍼티와 메서드에 안전하게 접근할 수 있다고 판단합니다.</p>
<p>커스텀 에러 클래스를 만들어서 에러 종류별로 다르게 처리할 수 있습니다.</p>
<pre><code class="language-typescript">class ValidationError extends Error {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}

class NetworkError extends Error {
  retryable: boolean;
  constructor(message: string, retryable: boolean) {
    super(message);
    this.retryable = retryable;
  }
}

function handleError(error: Error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message);
  } else if (error instanceof NetworkError &amp;&amp; error.retryable) {
    showRetryButton();
  } else {
    showGenericError(error.message);
  }
}</code></pre>
<h3 id="6-arrayisarray">6. Array.isArray</h3>
<p><code>typeof []</code>는 <code>&quot;object&quot;</code>를 반환합니다. 그래서 배열인지 확인하려면 <code>Array.isArray</code>를 써야 합니다.</p>
<pre><code class="language-typescript">function process(input: string | string[]) {
  if (Array.isArray(input)) {
    return input.join(&#39;, &#39;); // string[]
  }
  return input; // string
}</code></pre>
<p>API 응답이 단일 객체일 수도 있고 배열일 수도 있을 때 유용합니다.</p>
<pre><code class="language-typescript">function normalizeResponse(data: User | User[]) {
  if (Array.isArray(data)) {
    return data; // User[]
  }
  return [data]; // User -&gt; User[]
}</code></pre>
<p>이렇게 정규화하면 이후 코드에서 항상 배열로 일관되게 처리할 수 있습니다.</p>
<h3 id="7-discriminated-union">7. Discriminated Union</h3>
<p>복잡한 상태를 다룰 때 가장 권장하는 패턴입니다. 공통된 리터럴 프로퍼티(discriminant)로 타입을 구분합니다.</p>
<pre><code class="language-typescript">type ApiState =
  | { status: &#39;idle&#39; }
  | { status: &#39;loading&#39; }
  | { status: &#39;success&#39;; data: User }
  | { status: &#39;error&#39;; error: string };

function render(state: ApiState) {
  switch (state.status) {
    case &#39;idle&#39;:
      return &lt;div&gt;Ready&lt;/div&gt;;
    case &#39;loading&#39;:
      return &lt;Spinner /&gt;;
    case &#39;success&#39;:
      return &lt;UserCard user={state.data} /&gt;; // data 접근 가능
    case &#39;error&#39;:
      return &lt;ErrorMessage message={state.error} /&gt;; // error 접근 가능
  }
}</code></pre>
<p>이 패턴의 핵심 장점은 <strong>불가능한 상태 조합을 타입 레벨에서 방지한다</strong>는 것입니다.</p>
<p>아래와 같은 설계를 비교해보면 차이가 명확합니다.</p>
<pre><code class="language-typescript">// ❌ 문제가 있는 설계: 불가능한 상태 조합이 허용됨
interface State {
  isLoading: boolean;
  data: User | null;
  error: string | null;
}

// isLoading: true이면서 data와 error가 모두 존재하는 상태가 가능
// 이런 상태는 논리적으로 말이 안 됩니다</code></pre>
<p>Discriminated Union을 사용하면 이런 논리적 오류를 컴파일 타임에 방지할 수 있습니다.</p>
<p>Redux나 Zustand의 액션 타입에서도 이 패턴이 널리 사용됩니다.</p>
<pre><code class="language-typescript">type Action =
  | { type: &#39;INCREMENT&#39; }
  | { type: &#39;DECREMENT&#39; }
  | { type: &#39;SET&#39;; payload: number };

function reducer(state: number, action: Action) {
  switch (action.type) {
    case &#39;INCREMENT&#39;: return state + 1;
    case &#39;DECREMENT&#39;: return state - 1;
    case &#39;SET&#39;: return action.payload; // payload 접근 가능
  }
}</code></pre>
<hr>
<h2 id="사용자-정의-type-guard-is-키워드">사용자 정의 Type Guard (is 키워드)</h2>
<p>기본 Type Guard들로 해결이 안 되는 경우가 있습니다. 복잡한 조건이 필요하거나, 조건을 재사용하고 싶을 때입니다.</p>
<p>이럴 때 <code>is</code> 키워드로 직접 Type Guard를 정의할 수 있습니다.</p>
<h3 id="기본-문법">기본 문법</h3>
<pre><code class="language-typescript">function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return &#39;permissions&#39; in user;
}</code></pre>
<p>반환 타입이 <code>user is AdminUser</code>인 것이 핵심입니다. &quot;이 함수가 <code>true</code>를 반환하면 <code>user</code>는 <code>AdminUser</code> 타입이다&quot;라고 TypeScript에게 알려주는 것이죠.</p>
<p>단순히 <code>boolean</code>을 반환하는 것과의 차이를 살펴보겠습니다.</p>
<pre><code class="language-typescript">// ❌ boolean 반환: 타입이 좁혀지지 않음
function isAdminBoolean(user: AdminUser | GuestUser): boolean {
  return &#39;permissions&#39; in user;
}

if (isAdminBoolean(user)) {
  console.log(user.permissions); // 에러: user는 여전히 AdminUser | GuestUser
}

// ✅ is 키워드 사용: 타입이 좁혀짐
function isAdmin(user: AdminUser | GuestUser): user is AdminUser {
  return &#39;permissions&#39; in user;
}

if (isAdmin(user)) {
  console.log(user.permissions); // OK: user는 AdminUser
}</code></pre>
<h3 id="실무-예시-api-응답-체크">실무 예시: API 응답 체크</h3>
<pre><code class="language-typescript">interface SuccessResponse {
  success: true;
  data: User;
}

interface ErrorResponse {
  success: false;
  error: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

// 사용자 정의 Type Guard
function isSuccess(res: ApiResponse): res is SuccessResponse {
  return res.success === true;
}

// 사용
function handleResponse(res: ApiResponse) {
  if (isSuccess(res)) {
    console.log(res.data); // SuccessResponse
  } else {
    console.error(res.error); // ErrorResponse
  }
}</code></pre>
<p>이렇게 정의하면 여러 곳에서 <code>isSuccess</code> 함수를 재사용할 수 있습니다.</p>
<h3 id="배열-필터링에서의-활용">배열 필터링에서의 활용</h3>
<p>Type Guard는 <code>filter</code>와 함께 사용할 때 특히 유용합니다.</p>
<pre><code class="language-typescript">const items: (string | null)[] = [&#39;a&#39;, null, &#39;b&#39;, null, &#39;c&#39;];

// ❌ 일반 filter: 타입이 (string | null)[]로 유지됨
const filtered1 = items.filter(item =&gt; item !== null);
// filtered1의 타입: (string | null)[]

// ✅ Type Guard 사용: 타입이 string[]로 좁혀짐
function isNotNull&lt;T&gt;(value: T | null): value is T {
  return value !== null;
}
const filtered2 = items.filter(isNotNull);
// filtered2의 타입: string[]</code></pre>
<p>이 차이가 중요한 이유는, <code>filtered1</code>을 사용할 때마다 <code>null</code> 체크를 다시 해야 하기 때문입니다. <code>filtered2</code>는 그럴 필요가 없습니다.</p>
<p>실무에서는 이런 식으로 활용합니다.</p>
<pre><code class="language-typescript">// 비어있지 않은 문자열만 필터링
function isNonEmptyString(value: string | null | undefined): value is string {
  return value !== null &amp;&amp; value !== undefined &amp;&amp; value.length &gt; 0;
}

const tags = [&#39;react&#39;, &#39;&#39;, null, &#39;typescript&#39;, undefined];
const validTags = tags.filter(isNonEmptyString); // string[]</code></pre>
<hr>
<h2 id="어떤-type-guard를-선택할까">어떤 Type Guard를 선택할까?</h2>
<p>상황별로 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>Type Guard</th>
</tr>
</thead>
<tbody><tr>
<td>원시 타입(string, number 등) 구분</td>
<td><code>typeof</code></td>
</tr>
<tr>
<td>null/undefined 체크</td>
<td>Truthiness 또는 <code>=== null</code></td>
</tr>
<tr>
<td>숫자/문자열의 null 체크</td>
<td><code>!== null</code> (0, &quot;&quot;을 살리려면)</td>
</tr>
<tr>
<td>특정 값 체크</td>
<td><code>===</code> 동등 비교</td>
</tr>
<tr>
<td>객체의 프로퍼티로 구분</td>
<td><code>in</code> 연산자</td>
</tr>
<tr>
<td>클래스 인스턴스 구분</td>
<td><code>instanceof</code></td>
</tr>
<tr>
<td>배열 여부 확인</td>
<td><code>Array.isArray</code></td>
</tr>
<tr>
<td>복잡한 상태 관리</td>
<td>Discriminated Union</td>
</tr>
<tr>
<td>재사용 가능한 조건</td>
<td>사용자 정의 Type Guard (<code>is</code>)</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>정리하면 다음과 같습니다.</p>
<ul>
<li><strong>Type Narrowing</strong>은 넓은 타입을 좁은 타입으로 좁혀가는 과정입니다</li>
<li>TypeScript는 <strong>Control Flow Analysis</strong>로 이를 자동으로 수행합니다</li>
<li><strong>Type Guard</strong>는 이 분석을 가능하게 하는 조건문입니다</li>
<li>상황에 맞는 Type Guard를 선택하면 타입 단언(<code>as</code>) 없이도 안전한 코드를 작성할 수 있습니다</li>
</ul>
<p>Type Narrowing을 제대로 활용하면서 <code>as</code> 키워드 사용이 크게 줄었습니다. 이전에는 &quot;TypeScript가 왜 이걸 모르지?&quot;라며 <code>as</code>를 자주 사용했는데, 이제는 &quot;내가 TypeScript에게 충분한 정보를 제공하지 않았구나&quot;라고 생각하게 되었습니다.</p>
<p>그런데 한 가지 의문이 남습니다.</p>
<pre><code class="language-typescript">if (state.user) {
  setTimeout(() =&gt; {
    console.log(state.user.name); // ❌ 에러?!
  }, 100);
}</code></pre>
<p>분명 if문에서 체크했는데, 왜 콜백 안에서는 에러가 발생할까요?</p>
<p>이 질문에 대한 답은 다음 글에서 다루겠습니다.</p>
<p><strong>다음 글</strong>: <a href="https://velog.io/@sumi-0011/typescript-narrowing-2">TypeScript는 왜 내 코드를 의심할까</a></p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">TypeScript Handbook - Narrowing</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Fast Refresh에서 DOM 직접 조작 시 블록이 사라지는 문제 해결하기]]></title>
            <link>https://velog.io/@sumi-0011/React-Fast-Refresh%EC%97%90%EC%84%9C-DOM-%EC%A7%81%EC%A0%91-%EC%A1%B0%EC%9E%91-%EC%8B%9C-%EB%B8%94%EB%A1%9D%EC%9D%B4-%EC%82%AC%EB%9D%BC%EC%A7%80%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sumi-0011/React-Fast-Refresh%EC%97%90%EC%84%9C-DOM-%EC%A7%81%EC%A0%91-%EC%A1%B0%EC%9E%91-%EC%8B%9C-%EB%B8%94%EB%A1%9D%EC%9D%B4-%EC%82%AC%EB%9D%BC%EC%A7%80%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 30 Mar 2025 13:25:02 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황-👿">문제 상황 👿</h2>
<p>이메일 빌더 애플리케이션을 개발하는 중 이상한 문제가 발생했습니다. Fast Refresh가 실행된 후 (코드를 수정하고 저장할 때마다 발생) 이메일 블록들이 화면에서 완전히 사라지는 현상이 일어났습니다.</p>
<p>콘솔에는 &quot;Fast Refresh done in 228ms&quot;라는 메시지만 표시될 뿐, 이메일 콘텐츠 영역은 텅 비어 있었습니다. 저장할 때마다 작업 내용이 모두 사라지니 개발 효율이 크게 떨어지는 상황이었습니다.</p>
<p>솔직히 말하면, React를 꽤 오래 사용했음에도 불구하고 이런 문제가 발생할 거라고는 전혀 예상하지 못했습니다. 처음에는 단순히 구현 방식의 문제인 줄 알았는데, 알고 보니 React의 핵심 원칙을 위반한 결과였습니다.</p>
<h2 id="문제-분석-🔍">문제 분석 🔍</h2>
<p>문제의 본질을 파악하기 위해 코드를 면밀히 분석했습니다. 원래 코드는 다음과 같은 방식으로 작동했습니다.</p>
<ol>
<li>템플릿 HTML을 렌더링하는 함수가 있었습니다.</li>
<li>숨겨진 div에 블록 컴포넌트들을 렌더링했습니다.</li>
<li><code>useEffect</code> 훅 내에서 <code>innerHTML</code>과 <code>appendChild</code>를 사용해 DOM을 직접 조작했습니다.</li>
</ol>
<pre><code class="language-ts">// 이전 코드 (문제가 있던 부분)
useEffect(() =&gt; {
  const contentElement = document.getElementById(&#39;email-content&#39;);
  if (contentElement &amp;&amp; content) {
    contentElement.innerHTML = &#39;&#39;;
    contentElement.appendChild(content);
  }
}, [template, blocks, blockOrder, content]);</code></pre>
<p>이 부분이 문제의 핵심이었습니다. React 내에서 DOM을 직접 조작하고 있었고, 이것이 Fast Refresh와 충돌을 일으키고 있었습니다. 생각해보니 React의 핵심 철학인 &quot;선언적 UI 업데이트&quot;와 정면으로 충돌하는 접근 방식이었습니다.</p>
<h2 id="원인-파악-🤔">원인 파악 🤔</h2>
<p>문제의 원인이 무엇일지에 대해 다음과 같은 가설을 세웠습니다.</p>
<ol>
<li><strong>React의 가상 DOM과 충돌</strong>: React는 가상 DOM을 통해 UI를 업데이트합니다. 직접적인 DOM 조작은 이 메커니즘을 우회해 React가 DOM의 현재 상태를 알 수 없게 만듭니다. 그 과정에서 충돌이 있었던 걸까..?</li>
<li><strong>Fast Refresh와의 충돌</strong>: Fast Refresh는 React 컴포넌트의 상태를 보존하면서 변경된 코드만 새로 로드합니다. DOM을 직접 조작해 React가 관리하지 않는 DOM 요소가 생겨 Fast Refresh 후 이 요소들이 사라지고 생성이 되지 않은걸까</li>
<li><strong>컴포넌트 언마운트 시 DOM 참조 유실</strong>: Fast Refresh 과정에서 컴포넌트가 일시적으로 언마운트되면 DOM 참조가 유실, 없는 참고에 접근을 해서 생기지 않는걸까..</li>
</ol>
<p>이런 분석을 통해 문제의 근본 원인이 명확해졌지만, 솔직히 처음부터 React의 방식을 따르지 않은 제 실수였습니다. &quot;빠르게 구현하려다&quot; 오히려 더 큰 문제를 만든 셈이죠.</p>
<h2 id="해결-방법-💡">해결 방법 💡</h2>
<p>이 문제를 해결하기 위해 코드를 완전히 리팩토링했습니다.</p>
<h3 id="1-dom-직접-조작-제거">1. DOM 직접 조작 제거</h3>
<p>가장 중요한 변경은 <code>innerHTML</code>과 <code>appendChild</code>를 사용한 직접적인 DOM 조작을 완전히 제거했습니다. React의 선언적 렌더링 방식을 활용하는 것이 핵심입니다.</p>
<h3 id="2-템플릿-처리-방식-개선">2. 템플릿 처리 방식 개선</h3>
<p>템플릿을 헤더와 푸터 부분으로 나누어 처리하고, <code>{{content}}</code> 부분을 분기점으로 사용했습니다:</p>
<pre><code class="language-ts">// 개선된 코드
// 템플릿 HTML의 헤더 부분
const templateHeader = template
  ? template.content
      .split(&#39;{{content}}&#39;)[0]
      .replace(&#39;{{meta-title}}&#39;, &#39;이메일 제목&#39;)
      .replace(&#39;{{title}}&#39;, &#39;이메일 제목&#39;)
  : &#39;&#39;;

// 템플릿 HTML의 푸터 부분
const templateFooter = template ? template.content.split(&#39;{{content}}&#39;)[1] : &#39;&#39;;</code></pre>
<p>이렇게 하면 템플릿의 앞뒤 부분을 구분해서 처리할 수 있고, 콘텐츠는 React 컴포넌트로 직접 렌더링할 수 있습니다.</p>
<h3 id="3-usestate에서-useref로-변경">3. useState에서 useRef로 변경</h3>
<p>상태 관리를 단순화하기 위해 <code>useState</code>와 hidden div 접근법 대신 <code>useRef</code>를 사용했습니다</p>
<pre><code class="language-ts">const contentRef = useRef&lt;HTMLDivElement&gt;(null);</code></pre>
<p>이를 통해 DOM 요소를 참조하되 React의 렌더링 사이클에 영향을 주지 않도록 했습니다.</p>
<h3 id="4-직접적인-콘텐츠-렌더링">4. 직접적인 콘텐츠 렌더링</h3>
<p>이전에는 숨겨진 div에 렌더링한 후 DOM 조작으로 이동시켰지만, 이제는 직접 콘텐츠 영역에 블록들을 렌더링합니다</p>
<pre><code class="language-ts">&lt;div
  id=&quot;email-content&quot;
  ref={contentRef}
&gt;
  {blockOrder.map((blockId, index) =&gt; (
    &lt;BlockItem
      key={blockId}
      isFirst={index === 0}
      isLast={index === blockOrder.length - 1}
      {...blocks[blockId]}
    /&gt;
  ))}
&lt;/div&gt;</code></pre>
<p>이 방식은 React의 선언적 패러다임을 따르므로 Fast Refresh와 완벽하게 호환됩니다.</p>
<p>지금 생각해보면 처음부터 이렇게 구현했어야 했는데, 어떻게든 빨리 동작하는 코드를 만들고 싶다는 욕심이 불필요한 복잡성을 가져왔습니다. </p>
<h2 id="문제-해결-결과-✅">문제 해결 결과 ✅</h2>
<p>위 변경사항을 적용한 후, Fast Refresh가 작동할 때도 이메일 블록들이 정상적으로 화면에 유지되었습니다. 코드를 수정하고 저장해도 더 이상 콘텐츠가 사라지지 않았고, 개발 효율이 크게 향상되었습니다.</p>
<p>가장 기뻤던 건, 이제 코드를 저장할 때마다 모든 내용이 사라져서 다시 상태를 만들어야 하는 스트레스에서 벗어났다는 점입니다. 이런 경험을 통해 &quot;빠른 구현&quot;과 &quot;올바른 구현&quot; 사이에서 후자를 선택하는 것이 결국 더 효율적이라는 점을 다시 한번 깨달았습니다.</p>
<h2 id="알게-된-점-📚">알게 된 점 📚</h2>
<p>이 문제를 해결하며 React 개발에서 몇 가지 중요한 점을 알게 되었습니다</p>
<ol>
<li><strong>React 내에서 DOM 직접 조작은 피해야 합니다</strong>. React는 가상 DOM을 통해 UI를 관리하는데, 직접 DOM을 조작하면 이 메커니즘이 무너집니다.</li>
<li><strong>Fast Refresh는 React의 선언적 렌더링에 의존합니다</strong>. DOM을 직접 조작하면 Fast Refresh 후 React가 UI 상태를 복원할 수 없습니다.</li>
<li><strong>React의 선언적 렌더링 방식을 따르는 것이 중요합니다</strong>. 상태 변화에 따라 UI가 자동으로 업데이트되는 React의 방식이 더 예측 가능한 결과를 가져옵니다.</li>
<li><strong>useEffect의 의존성 배열은 신중하게 설계해야 합니다</strong>. 특히 DOM 조작이 포함된 경우 의존성 관리가 복잡해질 수 있습니다.</li>
</ol>
<p>솔직히 이런 기본적인 React 원칙을 어기면서 코드를 작성했다는 게 아쉬웠습니다. &quot;빠르게 동작하는 코드&quot;를 만들려다가 오히려 개발 과정을 더 복잡하게 만든것 같네요. React의 선언적 방식을 믿고 따랐다면 이런 문제로 시간을 낭비하지 않았을 텐데, 이번 경험을 통해 다시 한번 프레임워크의 철학을 따르는 것의 중요성을 깨달았습니다.</p>
<p>결론적으로, 이 경험은 빠른 해결책보다 올바른 해결책을 찾는 것의 중요성을 다시 일깨워주었습니다. 아마 대부분의 개발자들이 비슷한 실수를 한 번쯤은 해봤을 거라 생각합니다. 하지만 중요한 건 실수를 인정하고, 더 나은 방향으로 코드를 개선해 나가는 과정이 아닐까 싶습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚀 PWA 도입 경험기 - 앱 스토어 없이 앱 만들기]]></title>
            <link>https://velog.io/@sumi-0011/pwa-1</link>
            <guid>https://velog.io/@sumi-0011/pwa-1</guid>
            <pubDate>Sun, 16 Mar 2025 12:10:01 GMT</pubDate>
            <description><![CDATA[<h2 id="고민했던-배경">고민했던 배경</h2>
<p><a href="https://www.gitanimals.org/">GitAnimals</a> 프로젝트를 진행하면서 한 가지 고민이 있었습니다. 이 서비스는 깃허브 커밋을 통해 포인트를 얻고 그 포인트로 펫을 뽑아 키우는 서비스인데요, 일회용성 접근이 아닌 사용자 참여가 지속적으로 이루어지도록 하고싶었어요. 더 나은 사용자 경험과 접근성을 위해 앱 형태로 제공하는 것을 고려하게 되었습니다.</p>
<p>처음에는 RN을 이용한 웹 앱 개발을 고려했지만, 몇 가지 현실적인 제약이 있었습니다. 🤔</p>
<ol>
<li>GitHub 로그인과 계정 연동이 필수적인데, iOS 앱의 경우 소셜 로그인을 구현하려면 Apple 로그인 기능도 필수적으로 요구됩니다.</li>
<li>복잡한 인증 플로우로 인해 개발 일정이 지연될 가능성이 높았습니다.</li>
<li>앱 스토어 심사 과정과 배포 시간도 부담으로 다가왔습니다.</li>
</ol>
<p>이러한 고민 속에서 &quot;꼭 앱이어야 할까?&quot;라는 질문을 하게 되었고, 그 대안으로 PWA(Progressive Web App)를 발견하게 되었습니다.</p>
<h2 id="pwa가-뭐길래">PWA가 뭐길래?</h2>
<p>PWA는 웹 기술을 사용하여 개발되지만 네이티브 앱과 유사한 사용자 경험을 제공하는 웹 애플리케이션입니다. 웹사이트를 모바일 기기의 홈 화면에 설치할 수 있고, 오프라인에서도 작동하며, 심지어 푸시 알림과 같은 네이티브 앱의 주요 기능도 활용할 수 있습니다.</p>
<p>이런 강력한 기능들은 대부분 &#39;서비스 워커(Service Worker)&#39;라는 기술 덕분에 가능합니다. 웹의 접근성과 앱의 사용자 경험을 결합한 하이브리드 접근 방식이라고 생각하면 이해하기 쉬울 것 같습니다.</p>
<blockquote>
<p><strong>서비스 워커란?</strong><br>PWA의 핵심 기술인 서비스 워커는 웹 페이지와 브라우저 사이에서 동작하는 JavaScript 파일입니다. 네트워크 요청을 가로채고 캐싱 전략을 적용하여 오프라인 작동, 성능 향상, 백그라운드 동기화, 푸시 알림 등의 기능을 가능하게 합니다. 서비스 워커 덕분에 웹사이트가 앱처럼 동작할 수 있는 것이죠!</p>
</blockquote>
<p>처음 PWA 개념을 접했을 때는 의구심이 들었지만, 알아볼수록 실용적인 기술이라는 생각이 들었습니다. 특히 앱 스토어의 심사 과정 없이도 앱과 같은 경험을 제공할 수 있다는 점이 매력적이었습니다.</p>
<h2 id="pwa의-장점들">PWA의 장점들</h2>
<p>PWA를 검토하면서 가장 끌렸던 장점들은 다음과 같습니다.</p>
<ol>
<li><strong>앱 스토어 우회</strong> ⏭️ - 앱 스토어 심사 없이 바로 배포할 수 있어 빠른 개발 사이클을 유지할 수 있습니다.</li>
<li><strong>푸시 알림</strong> 🔔 - 웹인데도 네이티브 앱처럼 푸시 알림을 보낼 수 있어 사용자 참여를 유도하기 좋습니다.</li>
</ol>
<p>이런 장점들이 GitAnimals 프로젝트의 현재 상황과 딱 맞는 것 같아 PWA 적용을 결정했습니다.</p>
<h2 id="gitanimals-프로젝트에-pwa-적용하는-방법">GitAnimals 프로젝트에 PWA 적용하는 방법</h2>
<p>Gitaninmals는 Next.js App router 환경으로 이루어져있는 서비스입니다. Next.js로 개발된 프로젝트에 PWA를 적용하는 과정을 공유해보려고합니다.</p>
<h3 id="1-패키지-설치하기">1. 패키지 설치하기</h3>
<p>첫 단계는 간단했습니다. <code>next-pwa</code> 패키지를 설치하는 것이었죠.</p>
<pre><code class="language-bash">npm install next-pwa</code></pre>
<p>이 패키지는 Next.js 프로젝트에 PWA를 손쉽게 적용할 수 있게 해줍니다. 처음에는 직접 서비스 워커를 구현해야 하나 걱정했는데, 이 패키지 덕분에 많은 부분이 자동화되어 편리했습니다.</p>
<h3 id="2-nextjs-설정-파일-수정하기">2. Next.js 설정 파일 수정하기</h3>
<p>다음으로 <code>next.config.mjs</code> 파일을 수정했습니다. 여기서 PWA의 핵심 설정을 추가하는 작업이었죠.</p>
<pre><code class="language-typescript">import createNextIntlPlugin from &#39;next-intl/plugin&#39;;
import NextPWA from &#39;next-pwa&#39;;

const withNextIntl = createNextIntlPlugin();
const withPWA = NextPWA({
  dest: &#39;public&#39;,
  disable: process.env.NODE_ENV === &#39;development&#39; || process.env.DISABLE_PWA === &#39;true&#39;,
  register: true,
  skipWaiting: true,
});

const nextConfig = withNextIntl({
  // 기존 설정들...
});

export default withPWA(nextConfig);</code></pre>
<p>개발 과정에서 한 가지 깨달은 점은 개발 환경에서는 PWA 기능을 비활성화하는 것이 좋다는 것이었습니다. 서비스 워커가 활성화되면 캐싱 때문에 코드 변경사항이 즉시 반영되지 않아 개발 효율이 떨어지더라고요. 그래서 <code>disable: process.env.NODE_ENV === &#39;development&#39;</code> 옵션을 추가했습니다.</p>
<h3 id="3-pwa-설치-안내-배너-만들기">3. PWA 설치 안내 배너 만들기</h3>
<p>PWA를 구현하면서 가장 큰 도전 과제는 설치 프로세스였습니다. 기본 기능 구현은 <code>next-pwa</code> 패키지 덕분에 쉬웠지만, 사용자들에게 &quot;이 웹사이트를 앱으로 설치할 수 있어요&quot;라고 알려주고 그 과정을 안내하는 것은 온전히 제 몫이었습니다.</p>
<p>브라우저에서는 PWA를 설치할 수 있을 때 작은 알림을 표시하지만, 대부분의 사용자는 이를 놓치거나 무시하게 될것 같고, 그래서 직접 설치 안내 배너를 만들기로 했습니다.</p>
<pre><code class="language-typescript">&#39;use client&#39;;
import { useEffect, useState } from &#39;react&#39;;
import { css } from &#39;panda/css&#39;;
import { flex } from &#39;panda/patterns&#39;;
import { AnimatePresence, motion } from &#39;framer-motion&#39;;

export const PWAInstallBanner = () =&gt; {
  const [showBanner, setShowBanner] = useState(false);
  const [showModal, setShowModal] = useState(false);

  useEffect(() =&gt; {
    // PWA 설치 이벤트 감지
    window.addEventListener(&#39;beforeinstallprompt&#39;, (e) =&gt; {
      e.preventDefault();
      setShowBanner(true);
    });

    // 이미 설치된 경우 감지
    window.addEventListener(&#39;appinstalled&#39;, () =&gt; {
      setShowBanner(false);
    });
  }, []);

  const handleInstall = () =&gt; {
    setShowModal(true);
  };

  // 배너 UI 렌더링 코드
}</code></pre>
<p>이 코드에서 핵심은 <code>beforeinstallprompt</code> 이벤트를 감지하는 부분입니다. 브라우저가 &quot;이 사이트는 설치 가능합니다&quot;라고 판단할 때 이 이벤트가 발생하고, 이때 사용자에게 배너를 보여주는 방식으로 구현했습니다.</p>
<h3 id="4-브라우저별-설치-가이드-구현과-설치-과정의-복잡함-해결하기">4. 브라우저별 설치 가이드 구현과 설치 과정의 복잡함 해결하기</h3>
<p>먼저 설치 안내 배너를 만든 후, 곧바로 더 큰 문제를 발견했습니다. PWA 설치 방법이 브라우저마다 완전히 다르다는 점이었죠. Chrome, Safari, Samsung Internet 등 브라우저별로 설치 UI와 과정이 달라서 모든 사용자에게 적용할 수 있는 통일된 안내를 만들 수 없었습니다.</p>
<p>그래서 사용자의 브라우저를 자동으로 감지하고 맞춤형 설치 가이드를 제공하는 모달을 구현했습니다.</p>
<pre><code class="language-typescript">export const PWAInstallGuideModal = ({ onClose }: { onClose: () =&gt; void }) =&gt; {
  const [currentSlide, setCurrentSlide] = useState(0);
  const [browserType, setBrowserType] = useState(&#39;unknown&#39;);

  useEffect(() =&gt; {
    // 브라우저 탐지
    const userAgent = navigator.userAgent.toLowerCase();
    if (/iphone|ipad|ipod/.test(userAgent)) {
      setBrowserType(&#39;safari&#39;);
    } else if (/android/.test(userAgent)) {
      if (/samsung/.test(userAgent)) {
        setBrowserType(&#39;samsung&#39;);
      } else if (/kakao/.test(userAgent)) {
        setBrowserType(&#39;kakao&#39;);
      } else if (/naver/.test(userAgent) || /whale/.test(userAgent)) {
        setBrowserType(&#39;whale&#39;);
      } else {
        setBrowserType(&#39;chrome&#39;);
      }
    }
  }, []);

  // 브라우저별 설치 가이드 슬라이드 구성
  const slides = [
    // 인트로 슬라이드
    {
      title: &#39;앱으로 설치하기&#39;,
      description: &#39;홈 화면에 설치하여 더 빠르게 접근하고 오프라인에서도 사용해보세요!&#39;,
      image: &#39;/images/pwa-intro.png&#39;,
      isIntro: true,
    },
    // 브라우저별 가이드 슬라이드...
  ];

  // 슬라이드 UI 및 네비게이션 코드
}</code></pre>
<p>PWA의 경우 브라우저마다 설치 과정이 다르고, 사용자가 여러 단계를 거쳐야 합니다. 그러다보니 사용자가 앱 설치를 하는 접근성이 좋지 않다고 생각하였고, 우선적으로 해결해야하는 문제라고 생각했어요.</p>
<p>이런 문제들을 해결하기 위해 여러 전략을 적용해보았습니다.</p>
<ul>
<li><strong>맞춤형 안내</strong>: 주요 브라우저 각각에 대해 맞춤형 설치 가이드를 만들었습니다.</li>
<li><strong>시각적 자료 활용</strong>: 각 단계를 스크린샷과 함께 제공하여 사용자가 직관적으로 따라할 수 있도록 했습니다.</li>
<li><strong>설치 혜택 강조</strong>: &quot;앱으로 설치하면 더 빠르게 접근하고 오프라인에서도 사용할 수 있어요&quot;와 같은 구체적인 이점을 강조했습니다.</li>
</ul>
<p>특히 iOS Safari의 경우 PWA 설치가 직관적이지 않고 숨겨져 있어서 단계별 가이드가 필수적이었습니다. 사용자가 &quot;공유&quot; 버튼을 누르고, 스크롤을 내려 &quot;홈 화면에 추가&quot; 옵션을 찾고, 설명을 읽고, &quot;추가&quot; 버튼을 눌러야 하는 복잡한 과정을 거쳐야 합니다.</p>
<p>그럼에도 불구하고 이상적인 설치 경험에는 여전히 미치지 못한다는 생각이 듭니다. 앱 스토어에서 하나의 &quot;설치&quot; 버튼으로 끝나는 단순함과 비교하면, PWA 설치 과정은 여전히 진입 장벽이 높습니다. 이 부분은 PWA 기술 자체의 한계이기도 하며, 앞으로 브라우저들이 더 일관되고 직관적인 설치 경험을 제공하기를 기대해 봅니다.</p>
<h2 id="아직-남은-고민들">아직 남은 고민들</h2>
<p>PWA 구현을 진행하면서 여전히 몇 가지 해결되지 않은 고민이 남아있습니다.</p>
<p>첫째, <strong>푸시 알림 권한</strong>입니다. PWA에서 푸시 알림을 보내려면 사용자의 권한이 필요한데, 이 과정도 브라우저마다 다릅니다. 특히 iOS에서는 알림 권한 획득이 더 까다롭고, 사용자가 거부한 경우 다시 요청하기가 어렵습니다.</p>
<p>둘째, <strong>iOS 제약사항</strong>입니다. iOS Safari의 PWA 지원은 Android Chrome보다 제한적입니다. 예를 들어, 백그라운드 동기화나 일부 웹 API가 제한되어 있어서 완전한 앱 경험을 제공하기 어렵습니다. 이는 Apple이 앱 스토어 생태계를 보호하기 위한 전략으로 보이지만, 개발자 입장에서는 제약이 많습니다.</p>
<p>셋째, <strong>브랜딩과 발견성</strong>입니다. 앱 스토어는 사용자들이 새로운 앱을 발견하는 중요한 채널인데, PWA는 이런 발견성이 떨어집니다. 사용자들이 웹사이트에 먼저 방문해야만 PWA를 알 수 있고, 앱 검색 시장에서는 완전히 배제되어 있습니다.</p>
<p>이런 고민들이 있지만, 그래도 PWA는 네이티브 앱 개발 리소스가 제한적인 상황에서 괜찮은 타협점이라고 생각합니다. 특히 GitAnimals 같은 GitHub 통합 서비스에서는 네이티브 앱의 인증 복잡성을 우회할 수 있다는 점이 큰 장점입니다.</p>
<h2 id="앞으로의-계획">앞으로의 계획</h2>
<p>PWA 도입은 아직 초기 단계이지만, 앞으로 몇 가지 계획을 가지고 있습니다.</p>
<p>우선 <strong>사용자 테스트</strong>를 통해 설치 가이드의 효과를 검증할 예정입니다. 실제 사용자들이 얼마나 쉽게 PWA를 설치하고 사용할 수 있는지 피드백을 수집하고, 지속적으로 UX를 개선하려고 합니다. 특히 다양한 브라우저 환경에서의 설치 성공률을 측정하고 중간에 포기하는 지점을 파악하여 설치 가이드를 최적화할 계획입니다.</p>
<p>또한 <strong>푸시 알림</strong>을 활용한 사용자 참여 전략을 구체화할 계획입니다. 예를 들어, 일일 커밋 알림이나 펫 상태 변화 알림 등을 통해 사용자들의 지속적인 참여를 유도할 수 있을 것 같습니다. 다만 iOS와 Android의 푸시 알림 지원 차이를 고려한 대응책도 함께 마련해야 할 것 같습니다.</p>
<blockquote>
<p><strong>PWA 푸시 알림 팁</strong><br>iOS 16.4부터 Safari에서도 PWA 웹 푸시 알림이 지원되기 시작했습니다. 단, iOS에서는 사용자가 먼저 PWA를 설치한 후에만 푸시 알림 권한을 요청할 수 있으니 요청 타이밍에 주의해야 합니다!</p>
</blockquote>
<p>무엇보다 중요한 건 <strong>사용자 경험</strong>이라고 생각합니다. 기술적인 구현 방식보다는 사용자들이 GitAnimals를 얼마나 편리하게 이용할 수 있는지가 핵심이니까요. PWA든 네이티브 앱이든, 최종적으로는 사용자들에게 가장 좋은 경험을 제공하는 방향으로 결정할 것입니다.</p>
<p>사실 현재는 프로젝트의 상황에 맞춰 PWA를 선택했지만, 장기적으로는 Apple 로그인을 구현할 수 있는 리소스가 확보된다면 결국 앱 스토어 심사를 거치는 네이티브 앱 방향으로 변경하게 될 가능성도 있습니다. PWA는 완벽한 해결책이라기보다는 현재 상황에서의 적절한 타협점이라고 생각합니다.</p>
<p>이번 PWA 도입 과정은 기술적 제약 속에서 창의적인 해결책을 찾아가는 여정이었습니다. 완벽한 솔루션은 아니지만, 현재 상황에서 최선의 선택을 하기 위한 탐색 과정이었다고 생각합니다. 앞으로의 발전 과정도 기회가 되면 공유하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[아이콘 추가 🔧 자동화로 ☕ 커피 한 모금 만에 끝내기]]></title>
            <link>https://velog.io/@sumi-0011/svgr-icon-generate</link>
            <guid>https://velog.io/@sumi-0011/svgr-icon-generate</guid>
            <pubDate>Fri, 28 Feb 2025 14:08:44 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>📝 참고: 이 글은 코드 구현보다 문제 인식과 해결 접근법에 중점을 두고 작성되었습니다</p>
</blockquote>
<p>회사에서 새로운 프로젝트를 시작한 지 몇 주가 지났습니다. 디자인 시안이 나올 때마다 새로운 아이콘들이 추가되었고, 그때마다 개발자인 제가 해야 하는 작업이 있었죠.</p>
<blockquote>
<p>😧 &quot;또 아이콘 추가 작업이네...&quot;</p>
</blockquote>
<p>이번에도 디자인팀에서 새로운 페이지 시안이 나왔고, 거기에 들어갈 새로운 아이콘들을 추가해야 했습니다. 한두 개라면 모를까, 이번에도 어김없이 5개가 넘는 아이콘들이 추가되어야 했죠.</p>
<h2 id="반복되는-일상">반복되는 일상</h2>
<p>매번 새로운 아이콘을 추가할 때마다 이런 과정을 거쳐야 했습니다.</p>
<ol>
<li>피그마 열기</li>
<li>필요한 아이콘 찾기</li>
<li>SVG로 추출</li>
<li>VSCode로 전환</li>
<li>새 파일 만들기</li>
<li>SVG 코드 붙여넣기</li>
<li>React 컴포넌트로 변환</li>
<li>props 연결하기</li>
<li>인덱스 파일 수정</li>
</ol>
<blockquote>
<p>😧 &quot;하... 이걸 또 5번 반복하라고?&quot;</p>
</blockquote>
<p>피그마와 VSCode를 왔다 갔다 하면서 같은 작업을 반복하다 보니 지루함을 넘어 짜증이 나기 시작했습니다. 실수도 자주 났죠. &#39;stroke&#39; 속성을 &#39;color&#39; props로 바꾸는 걸 깜빡한다거나, kebab-case를 camelCase로 바꾸는 걸 놓친다거나...</p>
<h2 id="문제-인식">문제 인식</h2>
<p>&quot;이렇게 단순 반복적인 일을 계속 하고 있어도 되나?&quot;</p>
<p>문득 그런 생각이 들었습니다. 개발자란 게 결국 문제를 해결하는 사람 아닌가요? 그런데 이렇게 단순 반복적인 일을 손으로 계속하고 있다니. 뭔가 더 나은 방법이 있을 것 같았습니다.</p>
<h2 id="기존-아이콘-시스템-살펴보기">기존 아이콘 시스템 살펴보기</h2>
<p>우리 팀은 아이콘을 이렇게 사용하고 있었습니다.</p>
<pre><code class="language-ts">&lt;Icon type=&quot;arrow-right&quot; color=&quot;#000000&quot; size={24} /&gt;</code></pre>
<p>간단해 보이는 이 컴포넌트 뒤에는 복잡한 구조가 있었죠.</p>
<pre><code class="language-ts">export const IconComponentMap = {
  &#39;arrow-right&#39;: ArrowRightIcon,
  &#39;check-circle&#39;: CheckCircleIcon,
  // ... 수십 개의 아이콘들
}

export type IconType = keyof typeof IconComponentMap;</code></pre>
<p>이 구조를 유지하면서, 새로운 아이콘을 추가하는 과정을 자동화할 수 있지 않을까? 그렇게 해결책을 찾아 나서기 시작했습니다.</p>
<h2 id="해결책-찾기">해결책 찾기</h2>
<p>검색 끝에 SVGR이라는 도구를 발견했습니다. SVG를 React 컴포넌트로 변환해주는 도구였는데, 특히 Custom Template 기능이 눈에 띄었죠. 이를 활용하면 우리 팀의 아이콘 컴포넌트 형식에 맞게 자동으로 변환할 수 있을 것 같았습니다.</p>
<p>하지만 SVGR만으로는 부족했습니다. 해결해야 할 문제들이 더 있었거든요.</p>
<blockquote>
<p>팀의 상황에 맞추기 위해서 해결해야할 문제 외에는 Custom Template 기능을 그대로 따라갔습니다. 
해당 기능이 궁금하시다면 아래 문서를 참고해주세요.
<a href="https://react-svgr.com/docs/custom-templates/">SVGR Custom Template 기능 사용법</a></p>
</blockquote>
<h3 id="우리가-원하는-것">우리가 원하는 것</h3>
<ul>
<li>피그마에서 받은 SVG 파일을 자동으로 처리</li>
<li>기존 아이콘 시스템과 호환되는 컴포넌트 생성</li>
<li>IconComponentMap 자동 업데이트</li>
<li>팀 컨벤션 준수</li>
</ul>
<h2 id="해결해야-할-문제들">해결해야 할 문제들</h2>
<p>가장 먼저 마주친 문제는 파일 이름 처리였습니다. 피그마에서 아이콘을 다운로드하면 Name=ArrowRight.svg처럼 prefix가 붙어있었죠. 이걸 우리 팀 컨벤션인 ArrowRightIcon.svg로 변환해야 했습니다.</p>
<pre><code class="language-ts">// 변환 예시
Name=ArrowRight.svg → ArrowRightIcon.svg
Name=check-circle.svg → CheckCircleIcon.svg
arrow-left.svg → ArrowLeftIcon.svg</code></pre>
<p>단순해 보이지만 여러 엣지 케이스를 고려해야 했습니다. 파일명에 하이픈이 있는 경우, 이미 &#39;Icon&#39;이 포함된 경우, 첫 글자가 소문자인 경우 등 다양한 상황을 처리해야 했죠.</p>
<p>두 번째 문제는 기존 아이콘 파일과의 충돌이었습니다. <code>/src/components/Icon</code> 폴더에는 이미 수십 개의 아이콘 컴포넌트가 있었고, 이 파일들은 건드리지 않은 채로 새로운 아이콘만 추가해야 했습니다. 실수로 기존 파일을 덮어쓰면 큰 문제가 될 수 있었죠.</p>
<p>가장 까다로웠던 건 index.ts 파일 수정이었습니다. 이 파일은 모든 아이콘의 진입점 역할을 하는 중요한 파일이었죠.</p>
<pre><code class="language-ts">// index.ts의 구조
import { SVGProps } from &#39;react&#39;;
import { ArrowLeftIcon } from &#39;./ArrowLeftIcon&#39;;
// ... 수십 개의 import 구문

export const IconComponentMap = {
  &#39;arrow-left&#39;: ArrowLeftIcon,
  // ... 수십 개의 매핑
};

export type IconType = keyof typeof IconComponentMap;</code></pre>
<p>이 파일에 새로운 아이콘을 추가할 때는 세 가지를 고려해야 했습니다</p>
<ul>
<li>import 구문을 적절한 위치에 추가</li>
<li>IconComponentMap에 새로운 매핑 추가</li>
<li>기존 코드의 구조를 해치지 않기</li>
</ul>
<p>단순히 파일 끝에 새로운 코드를 추가하는 게 아니라, 파일의 구조를 이해하고 적절한 위치에 코드를 삽입해야 했습니다. 이를 위해 AST(Abstract Syntax Tree) 파싱을 사용했죠.</p>
<pre><code class="language-ts">const ast = parser.parse(content, {
  sourceType: &#39;module&#39;,
  plugins: [&#39;typescript&#39;],
});

// 마지막 import 구문 위치 찾기
const lastImport = ast.program.body.findIndex(
  (node) =&gt; node.type !== &#39;ImportDeclaration&#39;
);

// 새로운 import 추가
ast.program.body.splice(lastImport, 0, /* 새로운 import 구문 */);</code></pre>
<p>이렇게 파일을 파싱하고 수정하는 방식을 택한 이유는, 단순한 문자열 처리보다 훨씬 안전하고 정확하기 때문이었습니다. 실수로 기존 코드를 망가뜨릴 위험도 줄일 수 있었죠.</p>
<p>마지막으로는 생성된 컴포넌트의 일관성 문제가 있었습니다. 모든 아이콘 컴포넌트는 동일한 구조를 가져야 했고, 특히 props 처리 방식이 일관되어야 했습니다. <a href="https://react-svgr.com/docs/custom-templates/">SVGR의 custom template</a>을 활용해 이 문제를 해결했습니다.</p>
<p>이런 여러 문제들을 하나씩 해결하면서, 점점 더 안정적이고 사용하기 쉬운 도구가 만들어져 갔습니다.</p>
<h2 id="드디어-자동화">드디어, 자동화</h2>
<p>결과물은 생각보다 단순했습니다.</p>
<ul>
<li>SVG 파일을 정해진 폴더에 넣기</li>
<li><code>$ yarn icons</code> cli 실행</li>
<li>끝!</li>
</ul>
<p>이제 10개의 아이콘을 추가하는 시간이 이렇게 바뀌었습니다.</p>
<ul>
<li>전: 20-30분 (중간중간 실수 수정 포함)</li>
<li>후: 1분 (커피 한 모금 마시면 끝)</li>
</ul>
<h3 id="실제-사용-후기">실제 사용 후기</h3>
<p>&quot;어, 이거 진짜 편하다!&quot;</p>
<p>팀원들의 첫 반응이었습니다. 특히 좋았던 점들을 꼽자면</p>
<ul>
<li>피그마와 VSCode를 왔다 갔다 할 필요가 없어졌습니다.</li>
<li>실수할 걱정이 없어졌습니다.</li>
</ul>
<h2 id="작은-자동화가-주는-큰-가치">작은 자동화가 주는 큰 가치</h2>
<p>이 작업을 통해 새삼 깨달은 것이 있습니다.</p>
<p>&quot;불편함을 그냥 참지 말자&quot;</p>
<p>개발자로서 우리는 종종 작은 불편함들을 그냥 넘어가곤 합니다. &quot;이 정도는 참아야지&quot;, &quot;이런 건 그냥 해야지&quot; 하면서요. 하지만 그 작은 불편함들이 쌓이면 결국 큰 시간 낭비가 됩니다.</p>
<p>특히 인상 깊었던 건 팀원들의 반응이었습니다.</p>
<p>&quot;이제 아이콘 추가하는 게 전혀 스트레스가 안 돼요.&quot;
&quot;다른 것도 이렇게 자동화할 수 있는 게 있을까요?&quot;</p>
<p>작은 변화가 팀 전체의 생산성과 사기를 높일 수 있다는 걸 직접 경험한 소중한 기회였습니다.</p>
<h2 id="마치며">마치며</h2>
<p>개발자의 시간은 소중합니다. 단순 반복 작업에 시간을 쓰기에는 해야 할 일이 너무 많죠.</p>
<p>여러분의 일상에도 이런 자동화할 수 있는 작업이 있지 않을까요? 당장은 2-3분밖에 안 걸리는 일이라도, 그게 매일 반복된다면 자동화를 고민해볼 만합니다.</p>
<blockquote>
<p>&quot;이 정도는 그냥 해야지&quot;라고 생각하는 그 순간, 잠깐 멈춰서서 생각해보세요. 
&quot;이걸 자동화하면 어떨까?&quot;</p>
</blockquote>
<p>긴 글 읽어주셔서 감사합니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://react-svgr.com/">SVGR 공식 문서</a></li>
<li><a href="https://react-svgr.com/docs/custom-templates/">SVGR Custom Templates</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[iOS에서 동영상 자동재생 구현하기 🎥]]></title>
            <link>https://velog.io/@sumi-0011/ios-autoplay</link>
            <guid>https://velog.io/@sumi-0011/ios-autoplay</guid>
            <pubDate>Sat, 15 Feb 2025 14:02:48 GMT</pubDate>
            <description><![CDATA[<p>최근 프로젝트에서 웹사이트 메인 페이지에 GIF 대신 동영상을 넣어야 하는 요구사항이 있었어요. PC에서는 잘 동작했지만, 모바일 환경에서 몇 가지 문제점을 발견했습니다. 
특히 iOS에서 예상치 못한 동작이 있어서 꽤 고생했는데요, 그 과정에서 배운것들을 공유해보고자 합니다.</p>
<h2 id="구현-목표-🎯">구현 목표 🎯</h2>
<p>프로젝트의 메인 페이지에 동영상을 넣기 위해 다음과 같은 요구사항이 있었습니다.</p>
<ol>
<li>페이지 로드와 함께 자동 재생 시작</li>
<li>플레이어 컨트롤 없이 GIF처럼 재생</li>
<li>현재 페이지 내에서만 재생 (전체 화면 전환 없음)</li>
</ol>
<h2 id="문제-상황-😓">문제 상황 😓</h2>
<p>iOS에서 테스트를 진행하던 중 예상치 못한 동작이 발견되었습니다. 
가장 큰 문제는 동영상을 탭했을 때 iOS의 기본 동영상 플레이어로 전환되는 것이었습니다.</p>
<pre><code class="language-html">&lt;!-- 처음 시도했던 코드 --&gt;
&lt;video autoplay loop muted&gt;
    &lt;source src=&quot;video.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;</code></pre>
<p>이러한 동작은 UX를 해치는 요소였습니다. 사용자가 GIF처럼 자연스럽게 보기를 원하는데, 플레이어로 전환되면서 페이지의 흐름이 끊기게 되었기 때문입니다.</p>
<h2 id="해결-방법-💡">해결 방법 💡</h2>
<h3 id="1-playsinline-속성-추가">1. playsinline 속성 추가</h3>
<p>iOS에서 페이지 내 재생을 위해서는 <code>playsinline</code> 속성이 필수적이었습니다. 이 속성은 iOS에서 동영상을 현재 페이지 내에서 재생하도록 지정합니다.</p>
<pre><code class="language-html">&lt;video playsinline&gt;
    &lt;source src=&quot;video.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;</code></pre>
<h3 id="2-gif와-같은-효과를-위한-속성-조합">2. GIF와 같은 효과를 위한 속성 조합</h3>
<p>완벽한 GIF 대체를 위해서는 여러 속성을 조합해야 했습니다:</p>
<pre><code class="language-html">&lt;video 
    playsinline 
    autoplay 
    loop 
    muted
    class=&quot;video-element&quot;
&gt;
    &lt;source src=&quot;video.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;</code></pre>
<p>각 속성의 역할:</p>
<ul>
<li><code>playsinline</code>: iOS에서 페이지 내 재생</li>
<li><code>autoplay</code>: 자동 재생 활성화</li>
<li><code>loop</code>: 반복 재생</li>
<li><code>muted</code>: 음소거 (자동 재생을 위해 필수)</li>
</ul>
<p>관련 스타일:</p>
<pre><code class="language-css">.video-element {
    width: 100%;
    max-width: 720px;
    /* 필요한 경우 object-fit 속성 추가 */
    object-fit: cover;
}</code></pre>
<h2 id="추가-고려사항-📝">추가 고려사항 📝</h2>
<h3 id="1-자동-재생-정책">1. 자동 재생 정책</h3>
<ul>
<li>대부분의 모바일 브라우저에서는 음소거된 상태에서만 자동 재생이 가능</li>
<li><code>muted</code> 속성이 없으면 자동 재생 정책에 의해 차단될 수 있음</li>
</ul>
<h3 id="2-성능과-데이터-사용량">2. 성능과 데이터 사용량</h3>
<ul>
<li>동영상 파일 크기 최적화 필요</li>
<li>필요한 경우 모바일 환경에서는 더 낮은 해상도의 동영상 제공 고려<pre><code class="language-html">&lt;video playsinline autoplay loop muted&gt;
  &lt;source src=&quot;high-quality.mp4&quot; type=&quot;video/mp4&quot; media=&quot;(min-width: 720px)&quot;&gt;
  &lt;source src=&quot;low-quality.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;</code></pre>
</li>
</ul>
<h3 id="3-브라우저-호환성">3. 브라우저 호환성</h3>
<ul>
<li>Safari (iOS): <code>playsinline</code> 속성 필수</li>
<li>Chrome (Android): 대부분의 속성이 잘 동작</li>
<li>Samsung Internet: Chrome과 유사한 동작</li>
</ul>
<h2 id="마치며-🌱">마치며 🌱</h2>
<p>모바일 환경에서 동영상을 다룰 때는 생각보다 많은 요소들을 고려해야 합니다. 특히 iOS의 경우 <code>playsinline</code> 속성이 필수적이며, 자동 재생을 위해서는 반드시 음소거 상태여야 한다는 점을 알게되었고,  </p>
<p>이러한 경험을 통해 모바일 웹 개발에서는 각 플랫폼의 특성을 이해하고, 그에 맞는 해결 방법을 찾는 것이 중요하다는 것을 알게 되었습니다.</p>
<h3 id="참고-자료-🔗">참고 자료 🔗</h3>
<ul>
<li><a href="https://webkit.org/blog/6784/new-video-policies-for-ios/">iOS Safari Video Policy</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video">MDN - Video Element</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[개발자도 마케터도 행복한 이메일 템플릿 빌더 이야기]]></title>
            <link>https://velog.io/@sumi-0011/email-builder</link>
            <guid>https://velog.io/@sumi-0011/email-builder</guid>
            <pubDate>Sun, 02 Feb 2025 11:15:37 GMT</pubDate>
            <description><![CDATA[<p>&quot;이메일 템플릿 좀 만들어주실 수 있나요?&quot;</p>
<p>프론트엔드 개발자로 일하면서 종종 받는 요청이었어요. 매번 기존 템플릿을 활용해 새로운 이메일을 만들고, 스타일을 수정하고... 간단해 보이는 작업인데 매번 2시간 정도는 훌쩍 걸렸죠. 이메일 템플릿 작업은 생각보다 까다로웠어요. 우선 이메일 클라이언트마다 지원하는 CSS가 달라서 호환성을 체크해야 했고, 모바일에서도 확인이 필요했어요. 그리고 가장 큰 문제는 테스트였죠. 실제 이메일로 발송해서 확인하는 과정이 필요했거든요.</p>
<p>더 큰 문제는 이메일 내용을 수정할 때였어요.</p>
<p>&quot;폰트 크기를 조금만 더 키워주세요!&quot;, &quot;이 문구를 이렇게 바꾸면 좋을 것 같아요.&quot;</p>
<p>마케팅팀과 이런 작은 수정사항들을 주고받는 데만 꽤 많은 시간이 필요했어요. 텍스트 하나를 바꾸더라도 개발자가 수정하고, 다시 테스트 메일을 보내고, 마케팅팀의 확인을 받고... 이런 과정이 반복되다 보니 서로 답답할 수밖에 없었죠.</p>
<p>&#39;이걸 좀 더 효율적으로 할 수 없을까?&#39;</p>
<p>주말 동안 이 문제를 해결하기 위한 아이디어를 고민했어요. 마케터가 직접 템플릿을 수정할 수 있는 도구를 만들면 어떨까 하는 생각이 들었죠. 혼자서 시작한 작은 프로젝트였지만, 팀에 도움이 될 거라는 확신이 있었어요.</p>
<h2 id="어떻게-만들었나요-🛠">어떻게 만들었나요? 🛠</h2>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/1ed1f2d2-826a-4f33-852f-6bd58b0c05bb/image.png" alt=""></p>
<h3 id="이메일-클라이언트-호환성-해결하기">이메일 클라이언트 호환성 해결하기</h3>
<p>가장 먼저 고민했던 건 이메일 클라이언트 호환성이었어요. Gmail, Outlook, Apple Mail 등 각각의 클라이언트가 지원하는 HTML/CSS가 달랐기 때문에, 안전하게 사용할 수 있는 태그와 스타일을 먼저 정의했어요. </p>
<p>최신 CSS 기능은 대부분의 이메일 클라이언트에서 지원하지 않아서, 테이블 기반 레이아웃과 인라인 스타일을 사용하기로 했죠. 예를 들어 flexbox나 grid 같은 최신 레이아웃 기능 대신, 테이블을 활용해 레이아웃을 잡았어요. 모든 스타일은 인라인으로 적용해서 클라이언트 호환성을 최대한 보장했죠.</p>
<h3 id="직관적인-인터페이스-설계">직관적인 인터페이스 설계</h3>
<p>이메일 템플릿 빌더는 크게 좌측 패널과 우측 패널로 나누어 설계했어요.</p>
<p>좌측 패널에서는 전체적인 템플릿을 관리할 수 있어요. 새로운 템플릿을 추가하거나 기본 템플릿을 적용할 수 있죠. 가장 중요한 건 실시간 미리보기예요. 수정사항을 바로바로 확인할 수 있게 했어요.</p>
<p>우측 패널은 실제 편집이 이루어지는 공간이에요. 블록 기반으로 구성해서, 각 블록마다 필요한 설정을 할 수 있어요. 텍스트 굵기, 정렬, 크기 등 기본적인 스타일을 수정할 수 있어요. </p>
<h3 id="블록-기반-시스템">블록 기반 시스템</h3>
<p>비개발자도 쉽게 사용할 수 있도록 블록 기반 시스템을 도입했어요. 현재는 세 가지 기본 블록을 제공하고 있는데요.</p>
<p>첫 번째로 버튼 블록이에요. 링크를 설정할 수 있고, 텍스트 스타일과 정렬을 조정할 수 있어요. 기본적인 기능에 충실하게 구현했죠.</p>
<p>두 번째는 텍스트 블록이에요. 일반 텍스트를 편집할 수 있고, 글자 색상도 변경할 수 있어요. 제목, 본문, 강조 텍스트 등 다양한 용도로 활용할 수 있죠.</p>
<p>마지막으로 테이블 블록이에요. 데이터를 구조화해서 보여줄 때 사용해요. 각 셀마다 텍스트 색상과 정렬을 설정할 수 있어요.</p>
<p>각 블록은 TypeScript로 타입을 정의해서 안전하게 관리하고 있어요.</p>
<pre><code class="language-typescript">interface EmailBlock {
  id: string;
  type: &#39;h1&#39; | &#39;h2&#39; | &#39;p&#39; | &#39;strong&#39; | &#39;div&#39; | &#39;table&#39; | &#39;button&#39;;
  content: string;
  customStyles?: Record&lt;string, string&gt;;
}</code></pre>
<h3 id="테스트-이메일-발송-기능">테스트 이메일 발송 기능</h3>
<p>프로젝트를 팀에 공유했을 때, 마케팅팀에서 가장 반겼던 기능이 테스트 이메일 발송이었어요. 이전에는 실제 이메일을 발송해서 확인해야 했는데, 이제는 빌더에서 바로 테스트 메일을 보내볼 수 있거든요.</p>
<p>Next.js의 Route Handler와 nodemailer 라이브러리를 활용해서 구현했어요. 프론트엔드 개발자인 저도 서버 기능을 구현하는 게 부담스러웠는데, Next.js의 Route Handler를 사용하니 생각보다 쉽게 구현할 수 있었죠.</p>
<h2 id="기술-스택-🛠">기술 스택 🛠</h2>
<p>프로젝트는 이렇게 구성했어요.</p>
<h3 id="frontend">Frontend</h3>
<ul>
<li>Next.js App Router</li>
<li>shadcn/ui</li>
<li>TypeScript</li>
</ul>
<h3 id="email-처리">Email 처리</h3>
<ul>
<li>Route Handler를 통한 서버 기능 구현</li>
<li>nodemailer를 활용한 테스트 메일 발송</li>
</ul>
<h2 id="앞으로의-계획-🚀">앞으로의 계획 🚀</h2>
<p>현재는 기본적인 기능만 구현된 상태예요. 앞으로는 더 다양한 기능을 추가할 계획인데요.</p>
<p>우선 새로운 블록들을 추가하려고 해요. 특히 이미지 블록에 대한 요청이 많아서 이 부분을 우선적으로 개발할 예정이에요. 각 블록별로도 더 다양한 스타일 옵션을 제공하려고 해요. 버튼 블록에는 배경색 변경이나 다양한 스타일 프리셋을 추가하고, 다른 블록들은 텍스트 색상을 변경할 수 있게 하는 등 자유도를 높일 계획이에요.</p>
<p>서비스를 만든 후에 뿌듯했던 점은, 개발자와 마케터 모두의 시간을 아껴줄 수 있는 도구를 만들었다는 거예요. 팀원들의 긍정적인 반응을 보면서, 앞으로도 이런 방식으로 팀의 업무 효율을 높이는 프로젝트들을 만들어가고 싶다는 생각이 들었어요.</p>
<hr>
<h2 id="이메일-템플릿-빌더-사용해보기-👋">이메일 템플릿 빌더 사용해보기 👋</h2>
<p>현재 이메일 템플릿 빌더는 베타 버전으로 운영되고 있어요. 여러분들의 의견을 통해 더 좋은 서비스를 만들어가고 싶어요!</p>
<h3 id="서비스-이용하기">서비스 이용하기</h3>
<ul>
<li><a href="https://various.ssumi.space/email">서비스 바로가기</a></li>
<li><a href="https://github.com/sumi-0011/various/blob/main/src/app/%5Blocale%5D/email/email-template-builder-guide.md">사용 가이드</a></li>
<li><a href="https://github.com/sumi-0011/various/blob/main/src/app/%5Blocale%5D/email/email-template-developer-guide.md">템플릿 파일 개발자 가이드</a></li>
</ul>
<h3 id="피드백-남기기">피드백 남기기</h3>
<p>더 나은 서비스를 위한 여러분의 의견을 기다리고 있어요. 새로운 기능 제안부터 버그 리포트, 사용성 개선 의견까지 어떤 의견이든 환영합니다. </p>
<p>피드백은 이 글의 댓글이나 이메일(<a href="mailto:selina2000@naver.com">selina2000@naver.com</a>)로 보내주시면 감사하겠습니다. 사소한 의견이라도 서비스를 발전시키는 데 큰 도움이 될 거예요.</p>
<p>많은 관심과 응원 부탁드려요! 여러분의 소중한 의견 하나하나가 서비스를 발전시키는 원동력이 됩니다. 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024년 개발 커뮤니티 활동 회고 🌱]]></title>
            <link>https://velog.io/@sumi-0011/2024-code-history</link>
            <guid>https://velog.io/@sumi-0011/2024-code-history</guid>
            <pubDate>Sun, 19 Jan 2025 09:24:37 GMT</pubDate>
            <description><![CDATA[<p>작년에는 크게 커뮤니티 활동을 하지 않았던 것 같은데, 막상 돌아보니 생각보다 많은 일들이 있었네요. 시간이 빠르게 지나간 것처럼 느껴졌을 뿐, 되돌아보면 정말 다양한 경험을 했던 것 같아요. 2024년의 기억을 하나씩 꺼내보면서, 그때는 그랬었지 하며 추억을 회상하듯 적어보려고 해요.</p>
<h2 id="depromeet-14기-활동-💪">DEPROMEET 14기 활동 💪</h2>
<h3 id="운영진과-참가자-두-가지-역할">운영진과 참가자, 두 가지 역할</h3>
<p>저는 디프만 13기에 이어 14기 활동까지 했어요. 디프만 14기는 2023년 11월에 시작했지만, 저는 운영진으로서 2023년 8월부터 활동을 시작했습니다. 정말 먼 일처럼 느껴지지만, 돌아보니 1년 정도밖에 지나지 않은 일이네요. 운영진 활동을 하면서 동시에 참가자로도 활동했는데, 1팀 &#39;10MM&#39;의 팀장과 프론트엔드 개발을 맡았고, 최종적으로 최우수상을 받을 수 있었어요 </p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/335408a2-271c-49f3-aed6-1a42258702c8/image.jpg" alt=""></p>
<h3 id="10mm-프로젝트와-특별했던-팀-문화">10MM 프로젝트와 특별했던 팀 문화</h3>
<p>10MM(십분만)이라는 앱 서비스를 개발하면서 주로 타이머 관련 기능과 게이미피케이션 요소를 구현했습니다. 팀장이라는 자리를 맡아 프로젝트를 이끄는 것이 처음이라 걱정이 많았지만, 좋은 팀원들을 만나 어려움 없이 마무리할 수 있었습니다.</p>
<p>지금 돌이켜보면 10MM 프로젝트에서 가장 기억에 남는 건 우리만의 특별한 팀 문화예요. 코어 타임을 정해두고 게더타운에 모여서 작업하면서 마치 실제로 한 공간에 있는 것처럼 소통했고, Thank To 제도를 통해 피쳐가 배포될 때마다 작업자들에게 진심 어린 감사를 전했어요. 팀원들을 각자의 특징을 닮은 별명으로 부르며 만들어간 친근한 분위기는 단순한 팀 문화를 넘어 우리를 하나로 만들어줬죠 🥺</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/d5ad2e88-438d-4db8-a421-97b5c43b5197/image.png" alt=""></p>
<p>이런 특별했던 문화 덕분인지 프로젝트가 끝난 지금까지도 팀원들과 자주 만나면서 깊은 인연을 이어가고 있어요. 지금까지 개발자로 살아오면서 만났던 수많은 사람들 중에서도 10MM 팀원들이 제일 소중한 것 같아요. 그냥 프로젝트 했던 동료가 아니라, 서로 응원하고 도와주는 평생 동료이자 친구가 된 느낌?</p>
<h3 id="새로운-시작을-위한-마무리">새로운 시작을 위한 마무리</h3>
<p>디프만 14기에서의 활동은 이렇게 특별한 추억과 소중한 인연으로 끝이 났어요. 이후에는 다른 동아리 활동보다는 여기서 만난 인연들과 더 깊은 관계를 이어가는 데 집중하고 있습니다. 가끔 다 같이 모여서 프로젝트 할 때 추억 이야기하면서 웃곤 하는데, 그때의 열정과 즐거움이 아직도 생생하게 남아있네요 😊</p>
<h2 id="unithon-스태프-경험-🎯">UNITHON 스태프 경험 🎯</h2>
<h3 id="우연한-기회로-시작된-스태프-활동">우연한 기회로 시작된 스태프 활동</h3>
<p>친구가 UNITHON이라는 해커톤의 스태프를 구한다는 걸 알려주고, 어떻게 인지 아는 인맥을 통해 UNITHON 스태프에 지원할 수 있었어요. 실제 활동할 때는 아는 사람이 없었지만, 오히려 그때 만난 사람들과 친해질 수 있었어요.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/caaf1223-0a86-4463-a591-29f31061c090/image.png" alt=""></p>
<h3 id="뜻밖의-소중한-인연">뜻밖의 소중한 인연</h3>
<p>같은 시간에 스태프했던 친구와는 응원하는 야구팀이 같아 야구도 보러 다닐 수 있을 정도로 친해졌어요. 운영진 분들이랑 친해지면서 &#39;SIPE&#39;라는 동아리에 대해 알게 되었고, 이후에 SIPE의 외부 행사에 놀러가며 사람들을 많이 만날 수 있었죠.</p>
<h3 id="새로운-시각으로-바라본-해커톤">새로운 시각으로 바라본 해커톤</h3>
<p>스태프를 하면서 운영진분들이랑 이야기 나눌 시간이 많았는데, 덕분에 어떻게 해커톤을 운영하고 진행하는지 참가자가 아닌 운영진의 시각에서 경험할 수 있는 새로운 기회였던 것 같아요. 그래서 이후에 정션 해커톤을 나가면서 더 좋은 운영 방식을 기대했는데... 아쉬웠죠 😅</p>
<h3 id="sipe와의-인연">SIPE와의 인연</h3>
<p>UNITHON 스태프 활동을 통해 알게 된 SIPE 동아리는 기수마다 두 번 정도 외부 행사를 여는데, 제가 간 곳은 &#39;사담콘&#39;과 &#39;내친소&#39;라는 활동이었어요. 사담콘은 컨퍼런스의 일종인데 그렇게 무겁지 않으면서도 새로운 인사이트를 얻을 수 있었고, 내친소는 다양한 사람들이랑 만나고 이야기해볼 수 있는 간단한 커피챗의 장이라 정말 좋았어요 ☕</p>
<h2 id="junction-해커톤-서포터즈-경험-👩💻">Junction 해커톤 서포터즈 경험 👩‍💻</h2>
<h3 id="참가자에서-서포터즈로">참가자에서 서포터즈로</h3>
<p>2023년, 지인들과 함께 참가자로 참여해 수상의 기쁨도 맛봤던 Junction 해커톤. 당시 만났던 서포터즈들과의 대화가 인상적이었고, 그들과 나눴던 이야기가 재미있어서 이번에는 서포터즈로 참여해보고 싶었습니다. Junction은 해외에서부터 시작된 해커톤으로, 제가 아는 해커톤 중에서는 가장 큰 규모를 자랑합니다. 약 200여 명의 참가자들이 모이고, 코드 레벨까지 심사에 포함되는 전문적인 행사이기에 운영진이나 이를 서포트하는 사람들을 만나고 이야기 나눌 기대가 컸습니다.</p>
<h3 id="기대와-달랐던-현장">기대와 달랐던 현장</h3>
<p>하지만 실제 서포터즈 활동은 생각과는 많이 달랐습니다. 생각보다 IT 업계와 관련 없는 분들이 많았고, Crew라고 부르는 운영진들의 경험이 많아 보이지 않았습니다. 이는 운영 측면에서 체계적이지 못한 부분들로 이어졌고, 참가자분들도 느끼셨겠지만 다소 즉흥적으로 운영되는 모습이 보였습니다.</p>
<p>서포터즈로서의 역할도 단순했습니다. 참가자들을 접수하고 간식을 챙기거나 발표 세션 때 준비를 돕는 등의 기본적인 업무였죠. 사실 할 일이 많지 않았음에도 불구하고, 대기 시간에도 쉬지 못하고 모여 있어야 했던 점이 가장 힘들었습니다.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/9e7fa208-a832-4de1-8729-6cb1d5c65179/image.jpg" alt=""></p>
<h3 id="새로운-인연의-시작">새로운 인연의 시작</h3>
<p>그래도 서포터즈분들을 만나서 좋았습니다. 기대했던 것처럼 IT 업계 사람들이 많지는 않았고, 대학생들이 많았지만 친해지면서 평소에 잘 알지 못했던 영역의 사람들과 이야기를 나눌 수 있어서 좋았습니다. 의대 학생들도 있었는데, 정말 평소에는 만날 일이 없었을 사람들과 인연을 쌓을 수 있었습니다. 크루들이 잘 대우해주진 않았다고 생각했는데, 오히려 그렇기 때문에 서포터즈끼리 더 똘똘 뭉치고 친해질 수 있었던 것 같습니다. 해커톤 이후에도 SNS 등을 통해 간간히 소통을 이어가고 있습니다.</p>
<h2 id="글또-활동-✍️">글또 활동 ✍️</h2>
<h3 id="기록의-중요성을-깨닫다">기록의 중요성을 깨닫다</h3>
<p>디프만을 그만두면서 더 이상 사이드 프로젝트에 대한 욕심이 없어졌어요. 회사에서 문서나 이런 것들을 정리하면서 기록에 대한 중요성을 느끼고 있었는데, 이때 글또라는 동아리에 관심을 가지게 되었고 글또 10기 활동을 시작할 수 있었어요. </p>
<h3 id="기술-블로그-작성의-시작">기술 블로그 작성의 시작</h3>
<p>글또 활동을 하면서 글을 잘 쓰게 되었냐고 하면 아닌 것 같지만, 그래도 이전보다는 글을 쓰려고 노력하는 것 같아요. 글또를 통해 글을 쓰는 습관을 만들어가는 중이랄까요? 지금도 이렇게 회고를 쓰고 있네요 😊 </p>
<p>주로 제가 사이드 프로젝트를 하면서 알게 된 기술적인 내용들을 공유하는 글을 작성했어요. &#39;SVG 스프라이트 생성 스크립트 개발기&#39;처럼 개발하면서 겪은 이야기들을 정리하는데 중점을 뒀죠. 블로그에 글 하나를 올리는 게 쉽지는 않지만, 이렇게라도 기록을 남기니까 나중에 다시 볼 때 도움이 많이 되더라고요.</p>
<h3 id="새로운-네트워크의-발견">새로운 네트워크의 발견</h3>
<p>글또에서는 &quot;관악또&quot;, &quot;강남또&quot;와 같은 오프라인 모임을 하면서 글또 분들이랑 친해질 수 있었어요. 사이드 프로젝트가 아니더라도 업계 사람들을 만나고 이야기할 수 있어서 좋았죠. 다들 각자의 분야에서 열심히 하시는 분들이라 이야기를 나누면서 새로운 시각도 많이 얻을 수 있었어요. 글또 활동 기간은 아직 좀 남았는데, 글 쓰는 습관도 기르고 좋은 인연도 더 만들 수 있을 것 같아요 🌱</p>
<h2 id="마치며-🌈">마치며 🌈</h2>
<p>이렇게 2024년을 돌아보면서 글을 적다 보니, 일기처럼 회상하는 형태가 되어버렸네요. 각 활동에서 얻은 인사이트나 교훈을 체계적으로 정리하진 못했어요.</p>
<p>하지만 이렇게라도 글로 남기면서, 제가 올해 참 많은 것들을 경험했다는 걸 새삼 깨닫게 되었어요. 각각의 활동이 저에게 남긴 추억과 의미들을 하나하나 떠올리면서 글을 쓸 수 있어서 좋았습니다.</p>
<p>빠른 시일 내에 이 경험들을 바탕으로 좀 더 체계적인 회고, 예를 들면 KPT 방식으로 정리해보려고 해요. 그때는 &#39;이런 점이 좋았다&#39;, &#39;이렇게 했으면 더 좋았을 것 같다&#39;와 같은 구체적인 인사이트를 정리해볼 생각이에요. 그리고 그 내용도 이 블로그에 공유할 수 있었으면 좋겠네요 😃</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Figma 플러그인 개발기 🛠️- Select Frame To  WebP]]></title>
            <link>https://velog.io/@sumi-0011/figma-plugin-webp</link>
            <guid>https://velog.io/@sumi-0011/figma-plugin-webp</guid>
            <pubDate>Wed, 01 Jan 2025 08:00:17 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>안녕하세요! 회사에서 불편함을 느껴 이를 개선하기 위해 Figma 플러그인을 개발하게 되었어요.</p>
<p>평소 프론트엔드 개발을 하면서 이미지 성능 최적화를 위해 WebP 포맷을 주로 사용하는데요, 피그마에서 이미지를 WebP로 변환하는 과정이 번거로워 이를 해결하고자 플러그인을 만들어보기로 했어요. 기존에 비슷한 플러그인이 있었지만, 제가 필요로 하는 기능들이 부족해서 직접 만들어보기로 결정했죠. </p>
<p>이 글에서는 피그마 플러그인을 어떻게 개발했는지, 그 과정에서 마주친 문제들을 어떻게 해결했는지 공유하려고 해요. </p>
<p>개발이 완료된 플러그인은 <a href="https://www.figma.com/community/plugin/1455579854856106713">Figma Community</a>에서 직접 사용해보실 수 있어요. 😉</p>
<p>코드의 퀄리티와 플러그인의 완성도보다는 개발 과정 자체를 공유하는 것에 초점을 맞췄으니 참고해서 봐주세요. </p>
<h2 id="플러그인을-만들게-된-배경-🤔">플러그인을 만들게 된 배경 🤔</h2>
<h3 id="기존-워크플로우의-문제점">기존 워크플로우의 문제점</h3>
<p>회사에서 주로 디자이너들이 Figma에 이미지를 올려주고, 프론트 개발을 하며 이미지를 WebP로 다운받아 사용하는데, 이 과정에서 불편함을 겪었어요. Figma는 PNG, JPG, SVG 등 다양한 포맷으로 export를 지원하지만, WebP는 지원하지 않았거든요. 때문에 아래와 같은 번거로운 과정을 거쳐야 했어요</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/f3f203b5-b64c-4bcb-94ac-34e700a933bc/image.png" alt=""></p>
<ol>
<li>Figma에서 PNG로 export (보통 x2 scale로)</li>
<li>Squoosh로 이동해서 PNG를 WebP로 변환</li>
<li>변환된 파일을 다시 프로젝트에 적용</li>
</ol>
<p>이 과정에서 파일을 하나씩 변환하고 확인하는 작업이 꽤 번거로웠어요. 저는 주로 <a href="https://squoosh.app/">Squoosh</a>를 사용했는데요. 이 프로그램은 변환된 이미지의 용량이 얼마나 줄어들었는지 바로 확인할 수 있어요. 그런데 한번에 한 이미지만 변환할 수 있다보니, 매번 이 과정을 반복하면서 작업 흐름이 자주 끊기곤 했어요.</p>
<h3 id="기존-플러그인의-한계">기존 플러그인의 한계</h3>
<p>물론 Figma에도 &#39;Webp Exporter&#39;라는 플러그인이 있어요. 하지만 기본적인 WebP 변환 기능만 제공할 뿐, 제가 필요로 하는 기능들이 부족했죠. 그래서 기존 기능들을 포함하면서도 추가적인 기능을 갖춘 새로운 플러그인을 만들어보기로 했어요.</p>
<h2 id="플러그인-기능-정의-🎯">플러그인 기능 정의 🎯</h2>
<p>구현하고자 하는 피그마 플러그인의 주요 기능은 아래와 같이 두 가지로 정리할 수 있어요.</p>
<h3 id="1-피그마에서-선택한-frame을-png-이미지로-가져오기">1. 피그마에서 선택한 Frame을 PNG 이미지로 가져오기</h3>
<p>피그마에서 선택한 Frame을 UI에 보여주어 사용자가 자신이 선택한 Frame이 무엇이고 어떤 이미지를 변환하려고 하는지 인지시키고, WebP로 변환하기 위해 PNG 데이터로 가져와요.</p>
<h3 id="2-webp-변환-이미지-변환-최적화">2. WebP 변환 (이미지 변환, 최적화)</h3>
<ul>
<li>사용자가 선택한 PNG 이미지를 WebP 형식으로 변환</li>
<li>품질(quality)과 크기 배율(scale) 조절 기능 제공</li>
<li>브라우저의 Canvas API를 활용한 이미지 변환 및 최적화</li>
</ul>
<h3 id="추가-기능-💡">추가 기능 💡</h3>
<p>앞으로 구현하고자 하는 추가 기능들이에요.</p>
<ul>
<li>다운 이미지 배율, 이름 설정</li>
<li>원본 PNG -&gt; 변환 WebP 간 용량 차이 시각화</li>
<li>다운 이미지 포맷 다중 선택 (WebP, PNG)</li>
<li>변환 이미지를 S3에 업로드</li>
</ul>
<p>이번 글에서는 플러그인 개발 전체 구현보다는 피그마 플러그인 내에서 피그마와 React 코드 간의 통신, 그리고 전체적인 WebP 변환/다운 기능에 초점을 맞춰볼게요.</p>
<h2 id="플러그인-구현하기-🔨">플러그인 구현하기 🔨</h2>
<h3 id="1-개발-환경-설정">1. 개발 환경 설정</h3>
<p>Figma 플러그인 개발은 기본적으로 HTML, CSS, JavaScript만을 사용하도록 되어있어요.
하지만 저는 React가 더 익숙해서 Figma에서 제공하는 <a href="https://github.com/figma/plugin-samples/tree/master/webpack-react">webpack-react</a>샘플 코드를 사용했어요</p>
<blockquote>
<p>Figma 플러그인 개발 가이드  :  <a href="https://www.figma.com/plugin-docs/plugin-quickstart-guide">Plugin Quickstart Guide</a></p>
</blockquote>
<p>플러그인은 크게 두 부분으로 나뉘어요</p>
<p><strong>ui.tsx</strong> - 플러그인의 UI를 담당</p>
<pre><code class="language-typescript">function App() {
  const inputRef = React.useRef&lt;HTMLInputElement&gt;(null);

  const onCreate = () =&gt; {
    const count = Number(inputRef.current?.value || 0);
    // parent.postMessage로 피그마와 통신
    parent.postMessage(
      { pluginMessage: { type: &quot;create-rectangles&quot;, count } },
      &quot;*&quot;
    );
  };

  return (
    &lt;main&gt;
      &lt;header&gt;
        &lt;h2&gt;Rectangle Creator&lt;/h2&gt;
      &lt;/header&gt;
      ...</code></pre>
<p>여기서 <code>parent.postMessage</code>는 플러그인 UI와 Figma 간의 통신을 담당해요. pluginMessage에 type과 필요한 데이터를 담아 보내면, Figma 쪽에서 이를 받아 처리하는 구조예요.</p>
<p><strong>code.ts</strong> - Figma와의 실제 통신을 담당</p>
<pre><code class="language-typescript">figma.showUI(__html__, { themeColors: true, height: 300 });

figma.ui.onmessage = (msg) =&gt; {
  if (msg.type === &quot;create-rectangles&quot;) {
    // 메시지 타입에 따른 처리
    ...
  }
};</code></pre>
<p><code>figma.showUI</code>로 UI를 띄우고, <code>figma.ui.onmessage</code>로 UI에서 보낸 메시지를 받아 처리해요. 이렇게 UI와 Figma가 서로 메시지를 주고받으며 동작하는 구조예요.</p>
<h3 id="2-frame-선택-기능-구현">2. Frame 선택 기능 구현</h3>
<p>첫 번째로 구현한 건 Figma에서 선택한 Frame을 가져오는 기능이에요. 
사용자가 변환하고 싶은 Frame을 선택하면, 그 Frame의 정보와 이미지 데이터를 가져와야 하죠.</p>
<pre><code class="language-typescript">const sendPngData = async () =&gt; {
  const node = figma.currentPage.selection[0];
  if (node) {
    // exportAsync로 PNG 데이터 추출
    const bytes = await node.exportAsync({ format: &#39;PNG&#39; });
    figma.ui.postMessage({
      type: &#39;init-png-data&#39;,
      bytes: bytes,      // PNG 바이너리 데이터
      frameName: node.name,  // 선택한 프레임 이름
    });
  }
};

// 선택이 변경될 때마다 데이터 전송
figma.on(&#39;selectionchange&#39;, sendPngData);</code></pre>
<p>여기서 <code>node.exportAsync()</code>는 선택한 Frame을 PNG 형식의 바이너리 데이터로 변환해요. 이렇게 얻은 데이터는 <code>Uint8Array</code> 형태로, 이후 WebP 변환에 사용돼요.</p>
<p>특히 <code>figma.on(&#39;selectionchange&#39;, sendPngData)</code>를 통해 사용자가 다른 Frame을 선택할 때마다 자동으로 데이터를 갱신하도록 했어요. 이렇게 하면 사용자가 여러 Frame을 연속해서 변환할 때 더 편리하죠.</p>
<h3 id="3-png-데이터를-화면에-보여주기">3. PNG 데이터를 화면에 보여주기</h3>
<p>이제 Figma에서 선택한 Frame의 PNG 데이터를 받아서 플러그인 UI에 표시해줄 차례예요. UI 코드는 React로 작성했는데, 여기서 중요한 건 Figma와 UI 사이의 메시지 통신이에요. React 코드에서는 <code>useEffect</code> 훅을 사용해서 이 통신을 처리했어요.</p>
<pre><code class="language-ts">useEffect(() =&gt; {
    // message handler
    const messageHandler = (event: MessageEvent) =&gt; {
      const msg = event.data.pluginMessage;
      if (msg.type === &#39;init-png-data&#39;) {
        setInitPngData(msg);
      }
    };

    window.addEventListener(&#39;message&#39;, messageHandler);
    return () =&gt; {
      window.removeEventListener(&#39;message&#39;, messageHandler);
    };
  }, []);</code></pre>
<p>이 코드에서 <code>messageHandler</code>는 피그마로부터 받은 메시지를 처리해요. 특히 &#39;init-png-data&#39; 타입의 메시지를 받으면 PNG 데이터를 상태로 저장하고 화면에 보여주게 되죠.</p>
<p>PNG 데이터를 UI에서 받아 미리보기를 보여주는 코드를 작성하면 플러그인 UI가 아래와 같이 보여요.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/2386d9e1-bc91-4f17-bf3e-7d4080a2a745/image.png" alt=""></p>
<p>이어서 이 PNG 데이터를 WebP로 변환하는 과정을 설명해드릴게요.</p>
<h3 id="3-png를-webp로-변환하기">3. PNG를 WebP로 변환하기</h3>
<p>이제 PNG 데이터를 WebP로 변환하는 핵심 기능을 구현해볼게요. 
이미지 변환에는 브라우저의 Canvas API를 활용했는데요, 코드의 가독성과 재사용성을 높이기 위해 관련 로직을 유틸리티 파일로 분리했어요.</p>
<p>첫 번째로, PNG를 Canvas에 그리는 함수를 만들었어요.</p>
<pre><code class="language-typescript">const _drawPngToCanvas = async (pngBytes: Uint8Array, scale: number) =&gt; {
  // PNG 바이너리 데이터로 Blob 생성
  const blob = new Blob([pngBytes], { type: &#39;image/png&#39; });
  const image = new Image();
  image.src = URL.createObjectURL(blob);

  // 이미지 로드 완료 대기
  await new Promise((resolve) =&gt; {
    image.onload = resolve;
  });

  // Canvas에 이미지 그리기
  const canvas = document.createElement(&#39;canvas&#39;);
  canvas.width = image.width * scale;   // 배율 적용
  canvas.height = image.height * scale;
  const ctx = canvas.getContext(&#39;2d&#39;);
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);

  return canvas;
};</code></pre>
<p>이 함수는 Figma에서 받은 PNG 바이너리 데이터를 Canvas에 그리는 역할을 해요. 특히 <code>scale</code> 파라미터를 통해 이미지 크기를 조절할 수 있어요. 예를 들어 <code>scale</code>이 2라면 원본 크기의 2배로 이미지가 그려지죠.</p>
<p>그 다음으로는 Canvas의 내용을 WebP로 변환하는 함수를 만들었어요.</p>
<pre><code class="language-typescript">export const exportWebP = async (
  pngBytes: Uint8Array,
  fileName: string,
  quality: number,
  scale: number
) =&gt; {
  const canvas = await _drawPngToCanvas(pngBytes, scale);

  // Canvas를 WebP로 변환
  return new Promise((resolve, reject) =&gt; {
    canvas.toBlob(
      (webpBlob) =&gt; {
        if (webpBlob) {
          resolve(webpBlob);
        } else {
          reject(new Error(&#39;WebP 변환 실패&#39;));
        }
      },
      &#39;image/webp&#39;,
      quality / 100  // 품질 설정 (0~1)
    );
  });
};</code></pre>
<p>이 함수는 Canvas에 그려진 이미지를 WebP 형식으로 변환해요. <code>quality</code> 파라미터를 통해 변환된 이미지의 품질을 조절할 수 있는데, 0부터 100 사이의 값을 받아요. 높은 값일수록 품질은 좋아지지만 파일 크기도 커지겠죠.</p>
<p>이렇게 변환된 WebP 이미지는 Blob 형태로 반환되어 다음 단계인 다운로드 기능에서 사용할 수 있어요.</p>
<h3 id="4-파일-다운로드-구현">4. 파일 다운로드 구현</h3>
<p>마지막으로 변환된 WebP 파일을 다운로드할 수 있는 기능을 만들어볼게요. 브라우저에서 파일을 다운로드 받을 수 있도록 유틸리티 함수를 하나 만들었어요.</p>
<pre><code class="language-typescript">export const downloadFile = (blob: Blob, fileName: string) =&gt; {
  return new Promise&lt;void&gt;((resolve) =&gt; {
    const url = URL.createObjectURL(blob);
    const link = document.createElement(&#39;a&#39;);
    link.href = url;
    link.download = fileName;
    link.click();

    // Blob URL 정리
    setTimeout(() =&gt; {
      URL.revokeObjectURL(url);
      resolve();
    }, 1000);
  });
};</code></pre>
<p>이 함수에서는 브라우저의 <code>URL.createObjectURL()</code>을 사용해서 파일 다운로드를 구현했어요. 특히 메모리 누수를 방지하기 위해 다운로드가 완료된 후에는 <code>URL.revokeObjectURL()</code>로 생성한 URL을 정리하는 것도 잊지 않았죠.</p>
<p>마지막으로, 변환과 다운로드를 연결하는 부분도 구현했어요.</p>
<pre><code class="language-typescript">try {
  const webpBlob = await exportWebP(pngBytes, fileName, quality, scale);
  await downloadFile(webpBlob, fileName);
  // 다운로드 완료 알림
  parent.postMessage({ pluginMessage: { type: &#39;export-complete&#39; } }, &#39;*&#39;);
} catch (error) {
  console.error(&#39;내보내기 중 오류:&#39;, error);
}</code></pre>
<p>변환과 다운로드가 성공적으로 완료되면 Figma에 메시지를 보내서 사용자에게 완료 알림을 보여주도록 했어요. 혹시 오류가 발생하면 콘솔에 로그를 남겨서 디버깅할 수 있도록 했고요.</p>
<h2 id="마치며-🎉">마치며 🎉</h2>
<p>이렇게 해서 Figma에서 WebP 변환 작업을 훨씬 수월하게 할 수 있게 되었어요. 최종적으로 완성된 플러그인은 아래와 같이 동작해요</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/c0570afc-ad4e-4a46-9915-d5a15ed340d0/image.png" alt=""></p>
<ol>
<li>Figma에서 Frame을 선택하면 자동으로 미리보기가 표시돼요</li>
<li>품질과 크기를 조절할 수 있어요</li>
<li>파일 이름도 원하는 대로 설정할 수 있어요</li>
<li>변환 버튼 하나로 WebP 파일이 바로 다운로드돼요</li>
</ol>
<p>개발하면서 특히 신경 썼던 부분들이 있는데요.</p>
<ul>
<li>실시간으로 Frame 선택을 감지해서 바로 미리보기를 보여주도록 한 것</li>
<li>메모리 누수를 방지하기 위해 Blob URL을 적절히 정리한 것</li>
<li>사용자에게 진행 상황과 완료를 알려주는 피드백 시스템</li>
</ul>
<p>물론 아직 개선하고 싶은 부분들이 있어요.</p>
<ul>
<li>여러 개의 Frame을 한 번에 처리하는 배치 기능</li>
<li>S3에 직접 업로드하는 기능</li>
<li>더 세밀한 이미지 최적화 옵션들</li>
<li>변환 전/후 용량을 비교할 수 있는 기능</li>
</ul>
<p>이런 기능들은 차근차근 추가해볼 계획이에요. 이번 개발을 통해 Figma Plugin API와 Canvas API에 대해 많이 배웠는데, 이런 경험들을 바탕으로 더 유용한 기능들을 만들어볼 수 있을 것 같아요.</p>
<p>앞으로도 플러그인을 개선하면서 겪은 이야기들을 계속 공유해볼게요. 궁금한 점이나 제안하고 싶은 기능이 있다면 언제든 피드백 주세요!</p>
<blockquote>
<p>만들어진 플러그인 확인해 보러 가기
👉 <a href="https://www.figma.com/community/plugin/1455579854856106713">Figma Community - Select Frame To WebP</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Issue 기반 피드백 시스템 개선기]]></title>
            <link>https://velog.io/@sumi-0011/github-issue-improve</link>
            <guid>https://velog.io/@sumi-0011/github-issue-improve</guid>
            <pubDate>Sun, 24 Nov 2024 12:38:58 GMT</pubDate>
            <description><![CDATA[<h2 id="발견된-문제점-🐛">발견된 문제점 🐛</h2>
<p>GitHub Issue를 활용한 피드백 시스템을 운영하면서 예상치 못한 문제가 발생했습니다. 처음에는 모든 게 잘 돌아가는 것처럼 보였는데, 어느 날 로그를 확인하다가 이상한 점을 발견했죠. 사용자들의 피드백이 GitHub Issue에 등록되지 않는 현상이 있었습니다. </p>
<p>원인을 파악해보니 GitHub API의 권한 체계와 관련이 있었습니다. 기존 구현에서는 Issue를 생성할 때 assignees(담당자)를 지정하도록 되어 있었는데, 이 기능을 사용하려면 해당 레포지토리에 commit 등의 접근 권한이 필요했던 거죠. 결과적으로 권한이 없는 일반 사용자의 피드백은 등록 자체가 실패하고 있었습니다 😱</p>
<blockquote>
<p>테스트 할때는 당연히 권한이 있는 제 계정을 사용했기 때문에 문제를 발견하지 못했던 것이죠</p>
</blockquote>
<h2 id="문제-해결-과정-💡">문제 해결 과정 💡</h2>
<h3 id="1-권한-문제-해결-방안-모색">1. 권한 문제 해결 방안 모색</h3>
<p>첫 번째로 시도한 것은 레포지토리 권한이 없는 사용자도 이슈에서 언급될 수 있도록 하는 방법을 찾는 것이었습니다. GitHub API 문서를 뒤져가며 여러 방법을 시도해봤지만, API를 통해 직접적으로 권한 없는 사용자를 언급하는 것은 불가능했습니다. 이는 GitHub에서 무분별한 할당을 막기 위해 추가한게 아닐까 생각했어요.</p>
<p>하지만 곧 대안을 발견했습니다. GitHub에서는 <code>@사용자이름</code> 형식의 텍스트만으로도 언급 기능이 활성화된다는 점을 생각했죠. 즉, API로 직접 멘션하는 대신 이슈나 댓글의 내용에 사용자 이름을 포함시키면 언급할 수 있는 거예요!</p>
<p>이 방법을 적용하기 위해 팀 내부에서 논의를 진행했고, 
이슈 본문은 문제 상황을 명확하게 기술하는 데 집중하고, 사용자와의 커뮤니케이션은 댓글을 통해 하는 것이 더 자연스럽다고 판단해, 이슈 본문에 사용자를 언급하는 것보다는 댓글을 통해 언급하는 것이 더 적절하다는 결론을 내렸습니다.</p>
<h3 id="2-octokit-도입으로-api-활용-개선">2. Octokit 도입으로 API 활용 개선</h3>
<p>문제 해결에 들어가기 전에, GitHub API를 더 효율적으로 사용할 방법을 찾아보았습니다. 기존에는 일반적인 fetch 요청을 사용했는데, 이는 타입 안정성이나 에러 처리 면에서 아쉬운 점이 있었어요. </p>
<p>Octokit이라는 GitHub의 공식 REST API 클라이언트 라이브러리를 발견 할 수 있었어요. </p>
<p>Octokit은 다음과 같은 장점들을 제공했습니다.</p>
<ul>
<li>TypeScript 지원으로 타입 안정성 확보</li>
<li>자동 인증 처리</li>
<li>레이트 리밋 관리</li>
<li>편리한 에러 핸들링</li>
</ul>
<p>이를 활용하기 위해 먼저 Octokit을 사용하기 위한 core 로직을 다음과 같이 구성했습니다.</p>
<pre><code class="language-typescript">import { Octokit } from &#39;@octokit/core&#39;;

const ISSUE_TOKEN = process.env.NEXT_PUBLIC_ISSUE_TOKEN;

const octokit = new Octokit({
  auth: ISSUE_TOKEN,
});

const OCTOKIT_BASE_INFO = {
  owner: &#39;git-good-w&#39;,
  repo: &#39;gitanimals&#39;,
  headers: {
    &#39;X-GitHub-Api-Version&#39;: &#39;2022-11-28&#39;,
  },
} as const;

export const requestOctokit = async (method: &#39;GET&#39; | &#39;POST&#39;, url: string, data?: object) =&gt; {
  const response = await octokit.request(`${method} ${url}`, {
    ...OCTOKIT_BASE_INFO,
    ...data,
  });

  return response.data;
};</code></pre>
<p>제가 의도한 주요 포인트를 살펴보자면</p>
<ul>
<li>환경 변수를 통해 GitHub 토큰을 안전하게 관리합니다</li>
<li>API 버전을 명시적으로 지정하여 예상치 못한 변경을 방지합니다</li>
<li>공통으로 사용되는 설정을 <code>OCTOKIT_BASE_INFO</code>로 분리했습니다</li>
<li>범용적으로 사용할 수 있는 <code>requestOctokit</code> 함수를 만들어 코드 중복을 줄였습니다</li>
</ul>
<h3 id="3-issue-생성-및-댓글-기능-구현">3. Issue 생성 및 댓글 기능 구현</h3>
<p>이제 Octokit을 활용하여 실제 피드백 제출 기능을 구현했습니다. 가장 중요한 것은 Issue 생성과 댓글 작성을 안정적으로 처리하는 것이었죠.</p>
<pre><code class="language-typescript">export async function postFeedback(request: PostFeedbackRequest): Promise&lt;PostFeedbackResponse&gt; {
  const issueData = await postIssue(request);

  if (request.username) {
    const commentBody = `@${request.username}\nThanks for reporting the issue!`;
    await createComment({ issueNumber: issueData.number, body: commentBody });
  }

  return issueData;
}</code></pre>
<p>이 구현에서 중요하게 고려한 점들은 다음과 같습니다.</p>
<h4 id="안정성-확보">안정성 확보</h4>
<ul>
<li>Issue 생성과 댓글 작성을 분리하여 각각의 작업이 실패했을 때 독립적으로 처리할 수 있도록 했습니다</li>
<li>사용자 이름이 있는 경우에만 멘션 댓글을 추가하여 불필요한 API 호출을 방지했습니다</li>
</ul>
<h4 id="사용자-경험-개선">사용자 경험 개선</h4>
<ul>
<li>피드백 제출 직후 바로 멘션 댓글이 달리도록 하여 사용자가 자신의 피드백이 잘 등록되었음을 즉시 확인할 수 있습니다</li>
<li>친근한 메시지로 사용자와 소통합니다</li>
</ul>
<h4 id="확장성-고려">확장성 고려</h4>
<ul>
<li>추후 피드백 처리 프로세스가 변경되더라도 쉽게 수정할 수 있도록 모듈화된 구조를 유지했습니다</li>
<li>TypeScript의 타입 시스템을 활용하여 코드의 안정성을 높였습니다</li>
</ul>
<h2 id="전체-프로세스-🔄">전체 프로세스 🔄</h2>
<p>개선된 피드백 시스템의 전체 동작 흐름을 시퀀스 다이어그램으로 표현하면 다음과 같습니다</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/83825819-0246-4293-bb40-f85d5300fba9/image.png" alt=""></p>
<p>이 프로세스를 통해 사용자의 피드백이 안정적으로 GitHub Issue에 등록되고, 사용자는 자신의 피드백 처리 현황을 쉽게 확인할 수 있게 되었습니다.</p>
<h2 id="마무리-🎉">마무리 🎉</h2>
<p>이번 개선 작업을 통해 문제를 해결할 뿐 아니라 피드백 시스템이 한층 더 안정적이게 된것 같아요. 
로그를 통해 문제를 발견하고 해결하는 과정을 거치니 로그의 중요성을 더 잘 알게된것 같습니다 </p>
<p>개발을 하다 보면 이렇게 예상치 못한 문제들을 마주치게 되는데, 이런 문제들을 해결하면서 시스템도 개선하고 새로운 것을 배울 수 있는 기회가 된 것 같아요 🌱</p>
<p>실제 구현된 코드는 <a href="https://github.com/git-goods/git-animal-client/pull/173">PR #173</a>에서 확인하실 수 있습니다. 코드를 살펴보시면서 더 자세한 구현 내용을 확인해보세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[브라우저 종료 시 안정적으로 API 요청하기: keepalive 옵션 활용기 🚀]]></title>
            <link>https://velog.io/@sumi-0011/broswer-keepalive</link>
            <guid>https://velog.io/@sumi-0011/broswer-keepalive</guid>
            <pubDate>Sat, 26 Oct 2024 12:21:07 GMT</pubDate>
            <description><![CDATA[<h2 id="목차">목차</h2>
<ul>
<li>문제 상황</li>
<li>해결 과정</li>
<li>최종 해결책</li>
<li>참고 자료</li>
</ul>
<h2 id="문제-상황-😵">문제 상황 😵</h2>
<p>다른 서비스간 로그인 연동 기능에서 특별한 요구사항이 있었습니다. 사용자가 로그인 과정에서 서비스를 이탈하면, 실패 상태를 다른 서비스에 API로 알려줘야 했죠. 
(다른 서비스는 polling으로 상태를 체크하고 있었습니다)</p>
<p>단순히 생각했을 때는 <code>unload</code> 이벤트에 API를 호출하면 될 것 같았지만... 실제로는 그렇게 간단하지 않았습니다. 🤔</p>
<h2 id="해결-과정-🔍">해결 과정 🔍</h2>
<h3 id="1-첫-시도-unload-이벤트-활용">1. 첫 시도: unload 이벤트 활용</h3>
<p>처음에는 아래와 같이 단순하게 구현했습니다.</p>
<pre><code class="language-typescript">const callback = () =&gt; {
  // API 호출 -&gt; expired status로 변경
}

useEffect(() =&gt; {
  window.addEventListener(&#39;unload&#39;, callback);
  return () =&gt; {
    window.removeEventListener(&#39;unload&#39;, callback);
  };
}, [callback]);</code></pre>
<p>하지만 이 방식에는 문제가 있었습니다. 브라우저가 종료되면서 API 요청이 간헐적으로 취소되는 현상이 발생했거든요. 🤯</p>
<h3 id="2-문제-분석">2. 문제 분석</h3>
<p>API 요청이 실패하는 이유를 파악해보니, 아래와 같이 확인할 수 있었습니다.</p>
<ol>
<li>브라우저는 열린 HTTP 요청의 완료를 보장하지 않습니다.</li>
<li>XHR 요청(<code>fetch</code> 또는 <code>XMLHttpRequest</code>)은 비동기적이고 non-blocking입니다.</li>
<li>브라우저는 페이지가 종료될 때 진행 중인 백그라운드 프로세스를 계속 유지할 필요가 없다고 판단합니다.</li>
</ol>
<h3 id="3-대안-탐색">3. 대안 탐색</h3>
<p>하지만 어떻게든 API 요청이 끊기지 않게 해야했죠 😭 대안을 찾아 보았을 때, 
브라우저 종료 시에도 HTTP 요청을 안정적으로 보내기 위한 두 가지 방법을 발견했습니다</p>
<ol>
<li><code>Navigator.sendBeacon()</code> 사용</li>
<li><code>fetch</code>의 <code>keepalive</code> 플래그 사용</li>
</ol>
<h4 id="navigatorsendbeacon-검토-❌">Navigator.sendBeacon() 검토 ❌</h4>
<p><code>sendBeacon()</code>은 분석 정보 전송을 위해 설계된 좋은 방법이지만, 커스텀 헤더를 지원하지 않는다는 치명적인 단점이 있었습니다.</p>
<h4 id="axios-keepalive-옵션-검토-❌">axios keepAlive 옵션 검토 ❌</h4>
<p>axios에서도 keepAlive 옵션을 제공하지만, Node.js 환경에서만 사용 가능했습니다.</p>
<blockquote>
<p>참고 링크 : <a href="https://gist.github.com/ccnokes/94576dc38225936a3ca892b989c9d0c6">https://gist.github.com/ccnokes/94576dc38225936a3ca892b989c9d0c6</a></p>
</blockquote>
<pre><code class="language-typescript">// Node.js 환경에서만 가능한 코드
const axios = require(&#39;axios&#39;);
const http = require(&#39;http&#39;);
const https = require(&#39;https&#39;);

module.exports = axios.create({
  httpAgent: new http.Agent({ keepAlive: true }),
  httpsAgent: new https.Agent({ keepAlive: true }),
  // ...
});</code></pre>
<h2 id="최종-해결책-✨">최종 해결책 ✨</h2>
<p>결국 <code>fetch</code>와 <code>keepalive</code> 옵션을 조합한 방식을 채택했습니다.</p>
<pre><code class="language-typescript">const updateExpired = (uuid: string) =&gt; {
  const data = {
    status: &#39;expired&#39;,
  };

  const fetchConfig = {
    method: &#39;POST&#39;,
    body: JSON.stringify(data),
    headers: customHeader,  // 커스텀 헤더 설정
    keepalive: true, // 핵심! keepalive 옵션 추가
  };

  return fetch(&#39;API URL&#39;, fetchConfig);
};</code></pre>
<p>이 방식의 장점:</p>
<ul>
<li>브라우저 종료 시에도 API 요청이 안정적으로 전송됨 </li>
<li>커스텀 헤더 사용 가능</li>
<li>구현이 간단함</li>
</ul>
<p>실제 다른 서비스에서 응답을 잘 받아오는지 반복적인 테스트를 통해, 정상적으로 받아오는 것을 확인하였습니다 🎉</p>
<h2 id="마무리-🎯">마무리 🎯</h2>
<p>브라우저 종료 시의 API 호출은 생각보다 까다로운 문제였지만, <code>keepalive</code> 옵션을 활용하여 깔끔하게 해결할 수 있었습니다. 
특히 페이지 생명주기와 브라우저의 동작 방식을 공부할 수 있는 계기가 되었던것 같아요. </p>
<h2 id="참고-자료-📚">참고 자료 📚</h2>
<ul>
<li><a href="https://developer.chrome.com/docs/web-platform/page-lifecycle-api?hl=ko">Chrome 페이지 수명 주기 API</a></li>
<li><a href="https://developer.mozilla.org/ko/docs/Web/API/Navigator/sendBeacon">MDN - Navigator.sendBeacon()</a></li>
<li><a href="https://github.com/yeonjuan/dev-blog/blob/master/Browser/send-an-http-request-on-page-exit.md">Browser에서 페이지 종료 시 HTTP 요청 보내기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[SVGO를 이용해 Sprite Svg 최적화하기]]></title>
            <link>https://velog.io/@sumi-0011/svgo-sprite</link>
            <guid>https://velog.io/@sumi-0011/svgo-sprite</guid>
            <pubDate>Sun, 13 Oct 2024 08:12:05 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@sumi-0011/sprite-svg-2-script">지난 글</a>에서 sprite SVG를 자동으로 생성하는 스크립트를 적어보았는데, 
오늘은 그 스크립트를 한 단계 더 발전시켜, SVG를 최적화하고, 최적화한 SVG를 이용하여 sprite를 생성하는 방법에 대해 작성해보려고해요. </p>
<blockquote>
<p><a href="https://github.com/svg/svgo">SVGO</a>라는 라이브러리를 이용해서 SVG 파일을 최적화하고, 이를 sprite SVG 생성 과정에 통합해 보았어요. </p>
</blockquote>
<h2 id="svgo를-이용한-svg-최적화의-장점">SVGO를 이용한 SVG 최적화의 장점</h2>
<p>*<em>SVG 최적화가 왜 필요할까요? *</em>
제가 생각한 이점은 아래와 같아요. </p>
<ol>
<li>파일 크기 감소: 불필요한 데이터를 제거해서 파일 크기를 줄일 수 있어요. =&gt; 웹사이트의 로딩 속도 향상</li>
<li>코드 가독성 향상: 최적화 과정에서 SVG 코드가 정리되어 더 읽기 쉬워져요.</li>
<li>렌더링 성능 개선: 복잡한 경로를 단순화하면 브라우저가 SVG를 더 빠르게 렌더링할 수 있어요.</li>
</ol>
<p>가장 큰 장점은 파일 크기 감소라고 생각해요. 
sprite svg를 만들때 많은 svg를 사용하기 때문에 더욱 효과가 커질거라고 예상했어요. </p>
<h2 id="svg-최적화-및-sprite-svg-생성-방법">SVG 최적화 및 Sprite SVG 생성 방법</h2>
<h3 id="svgo란">SVGO란?</h3>
<p>SVGO(SVG Optimizer)는 SVG 파일을 최적화하기 위한 Node.js 기반의 도구예요. 
이 라이브러리는 아래와 같은 다양한 최적화 기법을 사용해 SVG 파일의 크기를 줄이고, 구조를 개선해줍니다.</p>
<ol>
<li>불필요한 메타데이터 제거</li>
<li>중복된 요소 및 그룹 정리</li>
<li>경로 데이터 최적화</li>
<li>색상 값 정규화</li>
<li>기본값과 같은 속성 제거</li>
</ol>
<p>SVGO는 플러그인 시스템을 통해 사용자가 원하는 대로 최적화 과정을 커스터마이징할 수 있어, 사용하기로 결정하였어요</p>
<h3 id="1-svgo-라이브러리-설치">1. SVGO 라이브러리 설치</h3>
<pre><code class="language-bash">npm install svgo</code></pre>
<p><a href="https://github.com/svg/svgo">이 링크</a>에서 자세한 내용을 확인할 수 있어요. </p>
<h3 id="2-svg-최적화-설정">2. SVG 최적화 설정</h3>
<p>제가 사용한 설정은 아래와 같아요.
혹시 이미지가 깨질 수 있을것 같아 간단한 설정만을 적용해보았아요.</p>
<pre><code class="language-typescript">const svgoConfig: SvgoConfig = {
  plugins: [
    {
      name: &#39;preset-default&#39;,
      params: {
        overrides: {
          removeViewBox: false,
          removeUselessStrokeAndFill: false,
          cleanupIds: false,
        },
      },
    },
    &#39;removeXMLProcInst&#39;,
    &#39;removeXMLNS&#39;,
    &#39;removeDimensions&#39;,
    &#39;minifyStyles&#39;,
    &#39;removeComments&#39;,
    &#39;removeHiddenElems&#39;,
    &#39;removeEmptyAttrs&#39;,
    &#39;removeEmptyText&#39;,
    &#39;removeEmptyContainers&#39;,
    &#39;collapseGroups&#39;,
    &#39;removeMetadata&#39;,
    {
      name: &#39;convertPathData&#39;,
      params: {
        floatPrecision: 2,
      },
    },
  ],
};</code></pre>
<p>이중에 알면 좋은 것들 몇개를 설명해보자면,, </p>
<ul>
<li><code>removeViewBox: false</code>: viewBox 속성을 유지해요. (SVG의 크기 조정에 중요!)</li>
<li><code>removeUselessStrokeAndFill: false</code>: stroke와 fill 속성을 보존해요.</li>
<li><code>cleanupIds: false</code>: ID 충돌을 방지하기 위해 ID를 그대로 유지해요.</li>
<li><code>convertPathData</code>: 경로 데이터를 최적화하고, 소수점 정밀도를 2로 제한해요.</li>
</ul>
<h3 id="3-svg-최적화-함수-생성">3. SVG 최적화 함수 생성</h3>
<pre><code class="language-typescript">export const optimizeSvg = (svg: string): string =&gt; {
  const result = optimize(svg, svgoConfig);
  return result.data;
};</code></pre>
<p>이 함수는 SVG 문자열을 받아서 최적화된 SVG 문자열을 반환하는 역할이예요. 
다양한 곳에서 동일하게 사용하고 싶어 함수로 분리했어요. </p>
<h3 id="4-최적화된-svg로-sprite-생성">4. 최적화된 SVG로 Sprite 생성</h3>
<p>기존의 <code>getSvgFiles</code> 함수를 수정해서 SVG를 가져온 후 바로 최적화하도록 해볼게요.</p>
<pre><code class="language-typescript">const getSvgFiles = async (spriteInfo: SpriteInfoValue): Promise&lt;SvgFile[]&gt; =&gt; {
  const svgFiles = await Promise.all(
    spriteInfo.list.map(async (item) =&gt; {
      try {
        const res = await axios.get(`스프라이트 폴더 경로/${item}.svg`);
        // SVG를 가져온 후 바로 최적화해서 저장해요. 
        const optimizedSvg = optimizeSvg(res.data); 
        return {
          name: item,
          data: optimizedSvg,
        };
      } catch (error) {
        console.error(`❌ - Failed to fetch SVG for ${item}`);
        return null;
      }
    })
  );

  return svgFiles.filter((file) =&gt; file !== null) as SvgFile[];
};</code></pre>
<h2 id="마무리">마무리</h2>
<p>이렇게 SVGO를 이용해 SVG를 최적화하고, 이를 sprite SVG 생성 과정에 통합해 보았어요. 최적화 과정을 추가했더니 생성된 sprite SVG의 크기가 눈에 띄게 줄어들더라고요. 특히 수백개의 국가 이미지를 다루는 프로젝트에서는 그 효과가 정말 크게 느껴졌어요.</p>
<p>실제로 최적화 전후의 sprite SVG 파일 크기를 비교해봤는데요,</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/9cb78f32-f881-4dc3-a932-8c587f378841/image.png" alt=""></p>
<p>위 스크린샷을 보시면 최적화 전후의 파일 크기 차이를 확실히 확인할 수 있어요
최적화 전 sprite SVG 파일의 크기가 538KB였는데, 최적화 후에는 252KB로 줄어들었어요. 반정도의 크기 감소를 이루어낸거죠! 🎉</p>
<p>이미지 하나의 용량을 줄여도 서비스의 성능을 크게 개선되는 것은 아니지만,
이런 작은 최적화 하나하나가 모여서 전체 프로젝트의 성능을 크게 향상시킬 수 있을거라고 생각해요. 👍</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[react 18 + react-dom-router v5 호환 문제 해결 - url은 변경되는데 페이지는 새로 그리지 않는 문제]]></title>
            <link>https://velog.io/@sumi-0011/react-18-react-dom-router-v5</link>
            <guid>https://velog.io/@sumi-0011/react-18-react-dom-router-v5</guid>
            <pubDate>Fri, 04 Oct 2024 08:12:32 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황-👿">문제 상황 👿</h2>
<p>React 17에서 18로 버전을 업그레이드하면서 예상치 못한 라우팅 문제가 발생했습니다. 
프로젝트에서 react-router-dom v5.3.4를 사용 중이었는데, 특정 페이지에서 뒤로가기 기능이 제대로 작동하지 않는 현상이 나타났습니다. 
단순히 뒤로가기가 안되는것은 아니였고, 다른 페이지로의 이동에서도 동일한 현상이 발생하는 상황이었습니다. </p>
<p>밑과 같이 url은 정상적으로 변경되는데 새로 렌더링되지 않아, 화면에는 여전히 이전 페이지의 내용이 표시되는 것이 문제였어요. </p>
<pre><code>http://localhost:3000/history/detail -&gt; http://localhost:3000/history</code></pre><h2 id="원인-분석-🤔">원인 분석 🤔</h2>
<p>문제의 원인을 찾기 위해 여러 자료를 찾아본 결과, React 18과 react-router-dom v5의 조합에서 <code>React.StrictMode</code>가 문제를 일으키고 있다는 알게되었고, 
React 18에서는 <code>StrictMode</code>의 동작이 변경되면서 이로 인해 react-router-dom v5와 호환성 문제가 발생한 것으로 확인하였습니다. </p>
<p>issue의 답변중 아래와 같은 답변이 있습니다. </p>
<blockquote>
<p>Just to clarify the issue: with React 18 StrictMode, react-router does not perform the transition when navigation occurs. The route is stale when the path is changed: <a href="https://codesandbox.io/s/dreamy-andras-hbuco1?file=/src/index.tsx">codesandbox.io/s/dreamy-andras-hbuco1?file=/src/index.tsx</a></p>
</blockquote>
<blockquote>
<p>참고한 이슈</p>
<ul>
<li><a href="https://github.com/remix-run/react-router/issues/7870">react-router github issue</a></li>
<li><a href="https://stackoverflow.com/questions/71832720/link-tag-inside-browserrouter-changes-only-the-url-but-doesnt-render-the-compo/71833424#71833424">stackoverflow</a></li>
</ul>
</blockquote>
<blockquote>
<p>fyi) react-router-dom v5.3.3 이상에서는 이 문제가 해결되었다고 알려져 있었지만, 실제로 v5.3.4를 사용 중임에도 같은 문제가 발생하였습니다 <a href="https://github.com/remix-run/react-router/pull/8831">관련 PR</a></p>
</blockquote>
<h2 id="해결-방법-💡">해결 방법 💡</h2>
<p>이슈를 확인해보았을 때 해결 방법으로는 아래 두 방법을 확인하였습니다. </p>
<ol>
<li>react-router-dom을 v6로 업그레이드</li>
<li><code>React.StrictMode</code>의 위치를 <code>BrowserRouter</code> 내부로 이동</li>
</ol>
<p>저는 프로젝트의 규모와 복잡도를 고려했을 때, v6로의 업그레이드는 상당한 작업량이 필요할 것으로 예상되어 2번 방법을 선택하였습니다.</p>
<blockquote>
<p>🔍 react-router-dom v6로 업그레이드를 고려 중이라면, <a href="https://reactrouter.com/en/v6.3.0/upgrading/v5">React Router의 공식 업그레이드 가이드</a>를 참고하는 것이 좋을 것 같아요</p>
</blockquote>
<h2 id="코드-수정-🛠️">코드 수정 🛠️</h2>
<p>문제 해결을 위해 <code>React.StrictMode</code>와 <code>BrowserRouter</code>의 위치를 다음과 같이 변경했습니다.</p>
<p>변경 전:</p>
<pre><code class="language-jsx">&lt;React.StrictMode&gt;
  &lt;BrowserRouter&gt;
    {/* ... */}
  &lt;/BrowserRouter&gt;
&lt;/React.StrictMode&gt;</code></pre>
<p>변경 후:</p>
<pre><code class="language-jsx">&lt;BrowserRouter&gt;
  &lt;React.StrictMode&gt;
    {/* ... */}
  &lt;/React.StrictMode&gt;
&lt;/BrowserRouter&gt;</code></pre>
<p>구체적인 코드 변경 내용은 다음과 같습니다</p>
<p>변경 전 (/src/index.tsx):</p>
<pre><code class="language-tsx">const container: Element = document.getElementById(&#39;root&#39;) as Element;
const root = createRoot(container);

root.render(
  &lt;&gt;
    &lt;React.StrictMode&gt;
      &lt;Suspense fallback={&lt;div&gt;&lt;/div&gt;}&gt;
        &lt;MypageApp /&gt; {/* 내부에 BrowserRouter 존재 */}
      &lt;/Suspense&gt;
    &lt;/React.StrictMode&gt;
  &lt;/&gt;
);</code></pre>
<p>변경 후 (/src/index.tsx):</p>
<pre><code class="language-tsx">const container: Element = document.getElementById(&#39;root&#39;) as Element;
const root = createRoot(container);

root.render(
  &lt;&gt;
    &lt;BrowserRouter&gt;
      &lt;React.StrictMode&gt;
        &lt;Suspense fallback={&lt;div&gt;&lt;/div&gt;}&gt;
          &lt;MypageApp /&gt;
        &lt;/Suspense&gt;
      &lt;/React.StrictMode&gt;
    &lt;/BrowserRouter&gt;
  &lt;/&gt;
);</code></pre>
<h2 id="결론-📄">결론 📄</h2>
<p>React 18로의 마이그레이션 과정에서 예상치 못한 라우팅 문제가 발생했지만, <code>React.StrictMode</code>와 <code>BrowserRouter</code>의 위치를 조정하는 간단한 방법으로 해결할 수 있었어요. </p>
<p>하지만 위와 같은 방법을 사용하는 것은 임시 방편일 뿐, 장기적으로 보면은 react-router-dom v6로 마이그레이션 하는것이 가장 좋은 해결책일 것 같습니다 🥲</p>
<blockquote>
<p><a href="https://github.com/remix-run/react-router/issues/7870#issuecomment-1115851626">React Router Issue</a>에서도 관련 내용이 존재합니다. </p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[스크립트로 Sprite SVG 자동 생성하기]]></title>
            <link>https://velog.io/@sumi-0011/sprite-svg-2-script</link>
            <guid>https://velog.io/@sumi-0011/sprite-svg-2-script</guid>
            <pubDate>Sat, 21 Sep 2024 17:04:17 GMT</pubDate>
            <description><![CDATA[<p>제가 만든 스크립트의 전체적인 흐름은 이래요:</p>
<ol>
<li>어떤 sprite SVG를 만들지 선택해요.</li>
<li>선택한 키에 따라 sprite map 정보를 가져와요.</li>
<li>SVG 리스트 데이터로 sprite SVG를 생성해요.</li>
<li>생성된 sprite SVG를 파일로 저장해요.</li>
</ol>
<p>스크립트 사용 예시는 아래와 같아요:</p>
<pre><code class="language-bash">$ yarn scripts sprite countryCode</code></pre>
<p>이 명령어를 실행하면 스크립트가 동작하여 <code>countryCode.svg</code>라는 sprite SVG 파일이 생성돼요. 이 파일 하나에 모든 국가 코드 아이콘이 포함되어 있죠!
(저는 270여개의 국가 이미지를 한 sprite svg를 이용해 불러올 수 있도록 하려고해요. )</p>
<h3 id="1-sprite-svg-선택하기">1. Sprite SVG 선택하기</h3>
<p>먼저, 어떤 sprite SVG를 만들지 정의하는 부분부터 시작했어요. <code>SPRITE_MAP</code>이라는 객체를 만들어서 여기에 모든 정보를 담았죠.</p>
<pre><code class="language-typescript">const SPRITE_MAP = {
  countryCode: {
    key: &#39;countryCode&#39;,
    svgBaseUrl: &#39;외부_이미지_경로&#39;,
    list: COUNTRY_CODE_LIST,
  },
} as const;

type SpriteMapKey = keyof typeof SPRITE_MAP;
type SpriteInfoValue = (typeof SPRITE_MAP)[SpriteMapKey];</code></pre>
<p>이 <code>SPRITE_MAP</code>에는 제가 만들 수 있는 모든 sprite SVG의 정보가 들어있어요. 예를 들어, <code>countryCode</code>는 국가 코드 아이콘을 위한 sprite SVG의 키예요.</p>
<p>TypeScript를 사용해서 타입 안정성도 확보했는데, 이게 나중에 오타 같은 실수를 많이 준것 같아요. 처음에는 좀 번거롭다고 생각했는데, 쓰다 보니 정말 편하더라고요. 😉</p>
<h3 id="2-스크립트-실행하기">2. 스크립트 실행하기</h3>
<p>그 다음으로 스크립트를 실행하는 <code>run</code> 함수를 만들었어요.</p>
<pre><code class="language-typescript">const run = async () =&gt; {
  const spriteKey = process.argv[2] as SpriteMapKey;

  if (!spriteKey) {
    console.error(chalk.redBright(`❌ - Need to provide a sprite key`));
  }

  if (!Object.keys(SPRITE_MAP).includes(spriteKey)) {
    console.error(chalk.redBright(`❌ - Invalid sprite key: ${spriteKey}`));
    return;
  }

  const CURRENT = SPRITE_MAP[spriteKey];

  try {
    const svgFiles = await getSvgFiles(CURRENT);
    const svgSprite = await getSpriteSvg(svgFiles);

    spriteSaveFile(svgSprite, `${CURRENT.key}.svg`);

    console.log(chalk.greenBright(`COMPLETE 🎉`));
  } catch (error) {
    console.error(error);
  }
};

run(); // 스크립트 실행</code></pre>
<p>이 함수는 명령줄에서 받은 <code>spriteKey</code>를 확인하고, 그에 따라 sprite SVG를 생성해요.</p>
<p><code>chalk</code> 라이브러리를 사용해서 콘솔 메시지에 색을 입혔는데, 이런 작은 디테일이 개발 경험을 훨씬 좋게 만들어주더라고요. 에러는 빨간색, 성공은 초록색으로 보이니까 쉽게 구분이 가더라구요.</p>
<h3 id="3-svg-파일-가져오기와-sprite-svg-생성하기">3. SVG 파일 가져오기와 Sprite SVG 생성하기</h3>
<p><code>getSvgFiles</code>와 <code>getSpriteSvg</code> 함수는 이전 글에서 설명한 것과 동일해요. 이 부분을 자동화하니까 작업 속도가 엄청 빨라졌어요!</p>
<h3 id="4-sprite-svg-저장하기">4. Sprite SVG 저장하기</h3>
<p>마지막으로, 생성된 sprite SVG를 파일로 저장하는 함수를 만들었어요.</p>
<pre><code class="language-typescript">const spriteSaveFile = (svgSprite: string, fileName: string) =&gt; {
  const dir = path.resolve(__dirname, &#39;../public/assets/sprite&#39;);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  fs.writeFileSync(path.join(dir, fileName), svgSprite);
};</code></pre>
<p>이 함수는 생성된 sprite SVG를 지정된 디렉토리에 저장해요. </p>
<h2 id="마치며">마치며</h2>
<p>이렇게 스크립트를 만들어서 sprite SVG를 자동으로 생성해보니, 정말 편하더라고요. 수동으로 SVG 파일을 합치는 번거로운 작업을 안 해도 되니까 시간도 많이 절약되고, 실수할 가능성도 줄어들었어요.</p>
<p>특히 이 스크립트의 장점은 확장성 이라고 생각해요. 새로운 아이콘 세트가 필요하다면 <code>SPRITE_MAP</code>에 새로운 키와 정보만 추가하면 돼요. 예를 들어, 결제 방법 아이콘을 추가하고 싶다면 이렇게 할 수 있죠:</p>
<pre><code class="language-js">const SPRITE_MAP = {
  countryCode: {
    // ... 기존 코드
  },
  paymentMethods: {
    key: &#39;paymentMethods&#39;,
    svgBaseUrl: &#39;외부_이미지_경로&#39;,
    list: PAYMENT_METHODS_LIST,
  },
} as const;</code></pre>
<p>그러면 <code>yarn scripts sprite paymentMethods</code> 명령어로 결제 방법 아이콘 sprite를 생성할 수 있어요.</p>
<p>이런 자동화 스크립트를 만드는 데 시간을 투자하니까, 장기적으로 봤을 때 정말 많은 시간과 노력을 아낄 수 있겠더라구요. </p>
<p>앞으로도 이렇게 자동화, 성능을 최적화해서 서비스를 개선할 방법을 계속 찾아보려구요!</p>
<p>다음에는 sprite svg를 좀 더 작은 용량으로 만들 수 있는, svg 최적화를 다뤄볼게요 👍 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[글또 10기] 삶의 지도]]></title>
            <link>https://velog.io/@sumi-0011/life-map</link>
            <guid>https://velog.io/@sumi-0011/life-map</guid>
            <pubDate>Sat, 21 Sep 2024 07:39:27 GMT</pubDate>
            <description><![CDATA[<p>삶의 지도를 적으며 내가 어떤 삶을 살아왔는지 돌이켜보았는데, 현재 제 삶에서 가장 중요한 건 “프론트 개발자가 되기까지 어떻게 살았는지” 인 것 같다고 생각했어요.
그래서 내가 어떤 생각을 했었고, 어떤 방향으로 나아가고 있는지 정리해 보았어요.</p>
<h2 id="어쩌다-컴공을-갔더라">어쩌다 컴공을 갔더라..</h2>
<p>고등학교 때 저는 대부분의 또래처럼 미래에 대한 뚜렷한 계획 없이 살아가고 있었어요. 이과였지만 순수 과학은 영 아니다 싶었고, 그냥 막연하게 “공대에 가야지...“하고 생각하고 있었어요.</p>
<p>대체로 게임을 좋아했는데, 그중 &#39;스타듀밸리&#39;라는 게임을 즐겨하였어요. 이 게임은 도트 그래픽 기반의 농장 시뮬레이션 게임인데, 1인 개발자가 만들어서인지 커스터마이징이 매우 자유로웠어요. 게임 공식 카페에 가보면 다양한 커스텀 모드나 리텍(그래픽, 텍스처 변경) 자료들이 많았고, 새로운 농장 기구를 추가하는 것부터 캐릭터, 주민들의 대사, 외형 등 거의 모든 것을 바꿀 수 있었어요.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/5ede0f93-1e39-4f31-871d-3384eddae188/image.png" alt="">
카페에서 다른 사람들이 만든 모드를 다운받아 사용하다 보니 &#39;나도 이런 걸 만들 수 있지 않을까?&#39;하는 생각이 들었어요. 게임을 좋아하는 제게 이런 작업이 굉장히 매력적으로 느껴졌고, 이것이 제가 프로그래밍에 관심을 갖게 된 첫 계기였던 것 같아요.</p>
<p>그래서 대학 원서를 쓸 때, 깊은 고민 없이 모든 지원을 컴퓨터공학과로 하게 되었어요. 지금 생각해 보면 꽤 즉흥적인 결정이었지만, 이 선택이 제 인생의 방향을 결정지은 중요한 순간이었던 것 같아요</p>
<h2 id="내가하고싶은게-뭘까">내가하고싶은게 뭘까?</h2>
<p>대학에 입학하고 보니 주변 친구들의 실력이 너무 뛰어나서 &#39;다들 이렇게 잘하는데 나는 뭐지?&#39; 하는 생각을 했었던 것 같아요. 하지만 이대로는 뒤처질 수 없다, 뭐라도 해야겠다는 생각이 들었어요.</p>
<p>그래서 1학년 동안에는 &quot;파워 자바&quot;라는 책을 붙잡고 이해될 때까지 코드를 치고 실행시키는, 지금 생각하면 꽤 무식한(?) 방법으로 공부했어요. 그래도 이 과정을 통해 Java 언어를 공부하고 이후에는 C++과 같은 다른 언어들도 어렵지 않게 이해할 수 있게 되었고, 더 이상 프로그래밍이 무섭게 느껴지지 않았어요.</p>
<hr>
<p>그러다 시간이 흘러 2학년 1학기가 끝나고, 다음 학기 수강 과목을 고민하던 중 &quot;웹 프로그래밍&quot; 과목이 눈에 띄었어요. 과제가 많고 힘들다는 소문이 있었지만, 다른 과목들보다는 흥미로워 보였습니다. 그래서 방학 동안 선배에게 미리 정보를 받아 선행학습을 시작했는데, 어려운 거랑 별개로 예상외로 재미있었어요.</p>
<p>이 과목을 통해 처음으로 웹 개발을 접하게 되었고, 결과적으로 A+를 받을 수 있었어요. 이 경험이 제가 프론트엔드 개발에 빠지게 된 결정적인 계기였던 것 같아요. 화면에 내가 원하는 대로 무언가를 만들어내고, 다른 사람이 이를 인정해 준 첫 기억이거든요.</p>
<p>왜 백엔드를 안 했냐고요? 글쎄요, 그냥 이쁘고 내 맘대로 만들 수 있는 것이 좋았어요. 프론트엔드 개발을 하면서 즉각적으로 결과물을 볼 수 있다는 점이 저와 잘 맞았어요. 화면에 뭔가가 나타나고, 사용자와 상호작용하는 것을 보는 게 정말 재미있었어요. 물론 백엔드도 중요하다는 걸 알고 있었지만, 프론트엔드에서 느끼는 즐거움이 더 컸던 것 같아요. 그래서 자연스럽게 프론트엔드 개발자의 길을 걷게 된 거죠.</p>
<h2 id="외부-활동을-시작해-보았어요">외부 활동을 시작해 보았어요</h2>
<p>4학년쯤 되니까 수업 듣는 게 지루해져서 인턴을 시작했어요. 사실 처음엔 그냥 학교 다니기 싫어서였는데, 결과적으로 이 경험들이 제게 정말 많은 걸 가르쳐 줬어요. 학교에서 하는 인턴십이나, 여러 외부 인턴십에 지원하였고, 운이 좋게도 여러 곳에서 일할 기회를 얻을 수 있었어요.</p>
<hr>
<p>첫 인턴은 에너지기술원이었어요. 이름에서 알 수 있듯이, 주변에 개발자는 저 말고는 없었어요. 신소재 공학, 화학공학 하는 연구원들과 인턴들뿐이었거든요. 그러다 보니 제가 그곳의 유일한 개발자였죠.</p>
<p align="center">
<img src="https://velog.velcdn.com/images/sumi-0011/post/3656d865-cbca-40d2-a053-c6153bf39c2f/image.png" width='300px' style="margin:auto"  />
  </p>

<p>처음엔 정말 막막했어요. 하지만 이 상황이 오히려 제게 좋은 학습 기회가 되었어요. 혼자 개발하다 보니 박사님들이나 다른 인턴들과 직접 소통하면서 일을 진행해야 했거든요. 주로 연구 결과를 저장하고 쉽게 볼 수 있는 사이트를 개선하는 일이었는데, 이 과정에서 개발의 실제적인 의미를 깨달았어요. 다른 분야 사람들의 니즈를 이해하고 그걸 기술로 구현하는 게 얼마나 중요한지 알게 됐죠. 개발자가 단순히 코드만 작성하는 사람이 아니라, 다양한 배경의 사람들과 소통하면서 니즈에 따라 문제를 해결하는 사람이라는 걸 직접 경험할 수 있었어요.</p>
<p>인턴 마지막쯤에 최종 발표를 했는데, 발표 전에 많이 긴장했어요. 하지만 예상외로 모두 긍정적인 평가를 해주셨어요. 그때 느낀 안도감과 성취감은 지금도 생생해요. 나중에 박사님께서 연락해 주셔서 추가 작업을 할 의향이 있는지 물어보셨어요. 제 능력을 인정받은 것 같아 기뻤지만 아쉽게도 거절하였는데, 이미 다른 인턴을 시작했기도 하였고 혼자 일하는 게 조금은 아쉽게 느껴졌거든요.</p>
<p>이전에는 혼자 사이드로만 진행하던 것을 처음으로 실제 프로젝트에서 개발을 해보면서, 개발이 단순히 코드를 작성하는 게 아니라는 걸 깨달을 수 있는 경험이었어요. 다양한 배경을 가진 사람들과 소통하며 문제를 해결하는 과정, 그게 바로 개발이더라고요. 이런 깨달음 덕분에 &#39;앞으로 어떤 개발자가 되어야 할까?&#39;를 진지하게 고민하게 됐어요.</p>
<hr>
<p>이후에 작은 스타트업에서 인턴을 해보았는데, 제 개발 실력을 한 단계 끌어올리는 계기가 됐어요.</p>
<p>개발자가 몇 명 없는 환경이었지만, 특히 프론트엔드 시니어 개발자분과의 교류가 가장 값진 경험이었어요. 단순히 코드를 같이 보는 것을 넘어서, 개발자로서의 성장 방향에 대한 조언을 들을 수 있었거든요. 이 과정에서 제 코딩 스타일이 많이 다듬어졌고, 무엇보다 개발자로서의 마인드 셋을 형성할 수 있었어요.</p>
<p>또 하나 큰 도움이 된 건 같이 인턴을 하던 친구와의 수다였어요. 우리끼리 이런저런 기술 얘기를 하다가 모르는 게 있으면 바로 시니어분들께 물어볼 수 있는 환경이었거든요. 이런 과정을 거치면서 &#39;어려운 문제를 함께 고민하고 해결할 수 있는 동료’가 있는 팀, 환경이 정말 행운이라는 걸 알게 되었어요.</p>
<p>이 인턴십은 단순히 기술을 배우는 것을 넘어서, 개발자로서의 전체적인 성장 방향을 잡는 데 큰 도움이 됐어요. 코드를 작성하는 기술뿐만 아니라, 팀워크의 중요성, 그리고 개발 철학까지 배울 수 있었던 것 같아요.</p>
<h3 id="난-진짜-말하는-감자였다">난 진짜 말하는 감자였다</h3>
<p align="center">
<img src="https://velog.velcdn.com/images/sumi-0011/post/ed306d57-3d8e-41d4-afcf-c231568e711c/image.png" width='300px' style="margin:auto"  /> </p>

<p>대학을 졸업하고 운 좋게 디프만이라는 IT 연합동아리에 들어가게 됐어요. 디프만은 개발자와 디자이너들이 모여서 한 학기 동안 서비스를 만드는 동아리예요. 처음엔 &#39;여기까지 왔으니 이제 개발 좀 하는 편이겠지?&#39; 하고 은근히 자신했어요. 근데 이게 웬걸, 주변에 실력자들이 너무 많은 거예요. 그때 깨달았죠. &#39;아... 난 아직 한참 멀었구나.&#39;</p>
<p>정말 대단한 사람들 사이에 있다 보니 처음엔 기가 좀 죽더라고요. &#39;내가 여기에 있어도 되나?&#39; 싶기도 했어요. 하지만 이대로 포기하면 영영 따라잡지 못할 것 같아서, 끊임없이 질문하고 배우려 노력했어요.</p>
<p>디자이너와 처음 협업할 때는 정말 긴장됐어요. 제가 실수해서 팀에 피해 주면 어쩌나 걱정돼서 어떻게 하면 잘 소통할 수 있을지 열심히 고민했죠. 서로 다른 관점을 이해하고 존중하면서 어떻게 하면 주어진 시간 안에 좋은 결과물을 만들 수 있을지 함께 고민했어요.</p>
<p>개발자들과 기술 이야기를 나누는 건 정말 재밌었어요. 특히 한 프론트엔드 개발자분이 있으셨는데, 그분을 보면서 &#39;나도 저렇게 되고 싶다&#39;고 진심으로 생각했어요. 실력도 실력이지만, 문제를 말하는 상황에서도 상대방이 기분 나쁘지 않고 수긍할 수 있는 소프트 스킬이 대단했어요. 그분이 항상 강조하셨던 말이 있어요. &quot;이유를 가지고 개발하라&quot;고요. 이 말 한마디가 제 개발 방식을 완전히 바꿔놨어요. 이제는 뭔가를 할 때마다 &#39;왜 이렇게 하지?&#39;라고 스스로 물어보게 됐죠.</p>
<p>디프만에서 만난 사람들과는 지금까지도 연락하며 지내요. 서로 고민도 나누고, 힘들 때 의지도 하고... 정말 소중한 인연들이 생겼어요.</p>
<p>돌이켜 보면 디프만은 제 개발자 인생의 전환점인 것 같아요. 기술적으로 성장한 것은 물론이고, 협업의 중요성도 배웠고, 평생 갈 인연도 만들었으니까요. 그리고 가장 중요한 건, &#39;아직 배울 게 많다&#39;는 걸 깨달은 거예요. 이제는 더 이상 &#39;말하는 감자&#39;가 아니라 &#39;꾸준히 성장하는 개발자&#39;가 되려고 노력하고 있어요. 아직 갈 길이 멀지만, 그래도 이제 방향의 실마리는 찾은 것 같아요.</p>
<hr>
<p>디프만 동아리 활동은 제게 너무나 값진 경험이어서 한 기수가 끝나고 그만둘 생각은 전혀 없었어요. 다른 중요한 일들이 있었지만, 이 활동에서 얻을 수 있는 경험을 놓치고 싶지 않았거든요. 그러다 우연한 기회에 운영진 제의를 받아 운영진과 프로젝트 활동을 동시에 하게 되었어요.</p>
<p>처음에는 인사부에서 동아리원 선발 과정을 기획하고, 이후에는 동아리 활동 중 세션을 기획하고 진행하는 업무를 맡았어요. 게다가 프로젝트에서는 어쩌다 보니 팀장까지 맡게 되었죠. 회사도 다니면서 운영진과 프로젝트팀장을 동시에 하는 게 쉽지는 않았어요. 다행히 스트레스를 많이 받는 성격은 아니고 개발을 즐기는 편이라, 시간 관리만 잘하면서 동아리와 회사를 병행할 수 있었어요.</p>
<p>힘들지 않았다고 하면 거짓말이겠지만, 그만큼 보람도 컸어요. 동아리에서 만난 사람들이 너무 좋았고, 함께 서비스를 만들어가는 과정이 정말 즐거웠거든요. 다 같이 문제를 해결해 나가고, 서로 다른 관점을 공유하고 토론하는 과정이 정말 좋은 팀 문화라고 생각했어요. 노력의 결실로 최종 발표 때 &quot;최우수상&quot;도 받았죠. 그때 눈물을 흘렸던 기억이 나네요. 부끄러웠지만, 팀원들이 달래주던 모습도 잊을 수 없어요.
<img src="https://velog.velcdn.com/images/sumi-0011/post/0efad499-3878-4a2a-80ee-7fe6f1baa728/image.png" width='400px' style="margin:auto"  /></p>
<p>이 활동을 통해 얻은 가장 큰 소득은 바로 우리 팀 사람들과의 인연이에요. 힘들 때 고민을 나누고, 기쁜 일도 함께 축하할 수 있는 사람들을 만났거든요. 이 소중한 인연을 앞으로도 오래오래 이어가고 싶어요. 이미 많은 인연을 만났고 다른 것들이 더 중요하다고 생각되어 디프만 활동은 더 이어가지는 않았지만, 이 경험은 제게 정말 특별했어요. 기술적 성장뿐만 아니라 리더십, 시간 관리 능력, 그리고 무엇보다 소중한 인간관계를 선물해 줬거든요.</p>
<h2 id="현재-그리고-내일은">현재? 그리고 내일은?</h2>
<p>요즘 새로 시작한 사이드 프로젝트에 집중하고있어요. 동아리에서 하는것처럼 주변 상황같은걸 고려하기 보다는 정말 제 흥미로 시작한 프로젝트인데, 그러다보니 기능을 제안하고 하나하나 만들어가는 과정이 너무 재밌더라구요.</p>
<p>운좋게도 사용해주시는 분들이 많아 조금씩 성장해가고 있는데, 종종 피드백을 남겨주시는 분들이 계셔서 피드백을 볼 때 마다 더 많은 사람들이, 그리고 사용해주시는 분들이 재미있게 사용할 수 있는 서비스를 만들어야겠다고 생각하게 되는것 같아요. </p>
<blockquote>
<p>만들고 있는 서비스는 <a href="https://www.gitanimals.org/en_US">Gitanimals</a> 예요!
<img src="https://velog.velcdn.com/images/sumi-0011/post/f75a0c97-03eb-41f2-bc18-04e4e983228e/image.png" width='400px' style="margin-top: 16px;"  /></p>
</blockquote>
<p>그리고 잠시 쉬었던 커뮤니티 활동도 다시 시작해 보려고 해요. 솔직히 오랜만이라 처음엔 어색하고 부끄러울 것 같아요. &#39;내가 잘할 수 있을까?&#39; 하는 걱정도 되고요. 하지만 비슷한 고민을 가진 사람들이랑 이야기를 나누다 보면 분명 새로운 아이디어도 얻고, 힘도 얻을 수 있을 거예요. 그런 기대감에 설레는 마음도 있어요.</p>
<p>사실 거창한 목표 같은 건 없어요. 그냥 뭐... 매일매일 조금씩 더 나아지고 싶어요. 어제보다 오늘 더 잘하고, 오늘보다 내일 더 잘하는? 그런 느낌으로요. 새로운 걸 배우고 경험하면서 재미있게 살고 싶어요. 그러다 보면 언젠가 &quot;이 사람 대단하다!&quot; 소리 듣는 날이 오지 않을까요? 😄</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sprite SVG로 웹 성능 업그레이드하기 🚀]]></title>
            <link>https://velog.io/@sumi-0011/Sprite-SVG-peformance</link>
            <guid>https://velog.io/@sumi-0011/Sprite-SVG-peformance</guid>
            <pubDate>Wed, 04 Sep 2024 13:58:43 GMT</pubDate>
            <description><![CDATA[<p>최근에 Sprite SVG라는 기술을 이용해서 웹 성능을 최적화해봤어요. 
왜 Sprite SVG를 선택했는지, 그리고 어떻게 Sprite SVG를 만들었는지 이야기해볼게요. </p>
<h2 id="💡-sprite-svg란-무엇일까요">💡 Sprite SVG란 무엇일까요?</h2>
<p>Sprite SVG는 여러 개의 SVG 이미지를 하나의 파일로 합친 거예요. 
이름에서 알 수 있듯이, 여러 개의 작은 이미지(스프라이트)를 하나로 모아놓은 거죠. 
이 기술은 보통 웹 성능 최적화와 효율적인 리소스 관리를 위해 사용해요</p>
<h3 id="장점-👍">장점 👍</h3>
<p>제가 프로젝트에 이걸 적용하게 된 가장 큰 이유는 네트워크 비용이었어요. </p>
<p>Sprite를 사용하지 않으면 이미지를 개별적으로 불러와야 하지만, Sprite SVG를 이용하면 여러 이미지를 한 번에 불러올 수 있거든요. 
덕분에 HTTP 요청이 줄어들고, 대역폭도 절약할 수 있어요. </p>
<blockquote>
<p>여러 개의 작은 파일 대신 하나의 큰 파일을 다운로드하는 게 일반적으로 더 효율적이에요.</p>
</blockquote>
<h3 id="단점-👎">단점 👎</h3>
<p>물론 단점도 있어요. </p>
<ol>
<li>초기 로딩 시간: 파일 크기가 커질 수 있어 초기 로딩이 느려질 수 있어요.</li>
<li>캐싱 효율: 작은 변경사항에도 전체 파일을 다시 다운로드해야 해요.</li>
<li>복잡성 증가: 스프라이트를 만들고 관리하는 과정이 개별 SVG 파일을 사용하는 것보다 복잡할 수 있어요.</li>
<li>사용하지 않는 아이콘 로드: 페이지에서 사용하지 않는 아이콘도 함께 로드될 수 있어요.</li>
</ol>
<p>이런 장단점을 고려해봤을 때, 제 상황에는 Sprite SVG가 꽤 적합한 방법이라고 판단했어요. </p>
<p>FCP를 고려하지 않아도 되는 부분이었고, 한 페이지에 모든 SVG를 보여줄 예정이었거든요. 
그래서 초기 로딩 시간이나 사용하지 않는 아이콘 로드 같은 단점은 크게 문제가 되지 않았어요.</p>
<h2 id="sprite-svg-만들기">Sprite SVG 만들기</h2>
<p>Sprite SVG를 만드는 과정은 생각보다 간단해요. 
기본적으로 개별 SVG 파일들을 읽어와서 하나의 큰 SVG 파일로 합치는 거예요.</p>
<p>전체 과정은 아래와 같아요.</p>
<ol>
<li>SVG 파일 읽기</li>
<li>각 SVG를 <code>&lt;symbol&gt;</code> 태그로 변환</li>
<li>모든 <code>&lt;symbol&gt;</code>을 하나의 <code>&lt;svg&gt;</code> 태그 안에 모으기</li>
</ol>
<h3 id="1-svg-파일-읽기-📂">1. SVG 파일 읽기 📂</h3>
<p>먼저, 외부에 있는 SVG 파일들을 읽어와야 해요. (내부에 있는 SVG를 읽어올 수도 있지만 제 경우엔 외부에서 읽어와야 했어요.)</p>
<pre><code class="language-typescript">const getSvgFiles = async (spriteInfo: SpriteInfoValue): Promise&lt;SvgFile[]&gt; =&gt; {
  const svgFiles = await Promise.allSettled(
    spriteInfo.list.map(async (item) =&gt; {
      try {
        const res = await axios.get(`${spriteInfo.svgBaseUrl}/${item}.svg`);
        return {
          name: item,
          data: res.data,
        };
      } catch (error) {
        console.error(`❌ - Failed to fetch SVG for ${item}`);
      }
    })
  );
  return svgFiles
    .filter((result): result is PromiseFulfilledResult&lt;SvgFile&gt; =&gt; result.status === &#39;fulfilled&#39;)
    .map((result) =&gt; result.value);
};</code></pre>
<p>이 코드는 주어진 URL에서 SVG 파일들을 가져와요. 파일을 가져오는 동안 병렬 처리를 위해 <code>Promise.allSettled</code>를 사용했어요. </p>
<p><code>Promise.allSettled</code>를 이용하면 일부 파일에 문제가 있어도 전체 프로세스가 멈추지 않아요. 특히 많은 SVG 파일을 처리할 때 유용하죠. 하나의 파일 로딩이 실패해도 나머지 파일들은 계속 처리할 수 있거든요. 😌</p>
<h3 id="2-sprite-svg-생성하기-🎨">2. Sprite SVG 생성하기 🎨</h3>
<p>이제 읽어온 SVG 파일들을 하나의 Sprite SVG로 만들어 볼게요.</p>
<pre><code class="language-typescript">const getSpriteSvg = async (svgFiles: SvgFile[]) =&gt; {
  const symbols: string[] = [];
  if (svgFiles.length === 0) {
    throw new Error(&#39;❌ - No SVG files fetched&#39;);
  }
  svgFiles.forEach((file) =&gt; {
    const svgElement = parse(file.data).querySelector(&#39;svg&#39;) as HTMLElement;
    const symbolElement = parse(&#39;&lt;symbol/&gt;&#39;).querySelector(&#39;symbol&#39;) as HTMLElement;
    svgElement.childNodes.forEach((child) =&gt; symbolElement.appendChild(child));
    symbolElement.setAttribute(&#39;id&#39;, file.name);
    if (svgElement.attributes.viewBox) {
      symbolElement.setAttribute(&#39;viewBox&#39;, svgElement.attributes.viewBox);
    }
    symbolElement.setAttribute(&#39;xmlns&#39;, &#39;http://www.w3.org/2000/svg&#39;);
    symbolElement.setAttribute(&#39;fill&#39;, &#39;none&#39;);
    symbols.push(symbolElement.toString());
  });
  const svgSprite = `&lt;svg width=&quot;0&quot; height=&quot;0&quot; class=&quot;hidden&quot;&gt;${symbols.join(&#39;&#39;)}&lt;/svg&gt;`;
  return svgSprite;
};</code></pre>
<p>이 코드는 각 SVG 파일을 <code>&lt;symbol&gt;</code> 태그로 변환하고, 모든 symbol을 하나의 <code>&lt;svg&gt;</code> 태그 안에 모아요. 이렇게 하면 각 SVG를 id로 구분할 수 있게 돼요. </p>
<p>여기서 중요한 점은 <code>viewBox</code> 속성을 유지한다는 거예요. 이 속성은 SVG의 뷰포트를 정의하는데, 이를 유지함으로써 각 아이콘의 원래 비율과 크기를 보존할 수 있어요. 또한 <code>fill</code> 속성을 <code>&#39;none&#39;</code>으로 설정해 기본 색상을 제거하고, 나중에 CSS로 쉽게 색상을 변경할 수 있게 했어요. </p>
<h2 id="생성한-sprite-svg-사용하기">생성한 Sprite SVG 사용하기</h2>
<p>Sprite SVG를 만들었다면, 이제 사용할 차례예요! 아래와 같이 간단하게 사용할 수 있어요.</p>
<pre><code class="language-typescript">&lt;svg width={size} height={size} fill=&quot;currentColor&quot;&gt;
  &lt;use href={`/assets/sprite/countryCode.svg#${id}`} /&gt;
&lt;/svg&gt;</code></pre>
<p>이 코드는 <code>&lt;use&gt;</code> 태그를 사용해 sprite SVG 파일 내의 특정 symbol을 참조해요. <code>href</code> 속성에서 <code>#</code> 뒤에 오는 값이 바로 우리가 앞서 설정한 id예요.</p>
<p>좀 더 편리하게 사용하기 위해 컴포넌트로 만들어봤어요.</p>
<pre><code class="language-typescript">type CountryCodeSpriteSvgProps = {
  id: string;
  size?: number;
};

function CountryCodeSpriteSvg({ id, size = 28 }: CountryCodeSpriteSvgProps) {
  return (
    &lt;svg width={size} height={size} fill=&quot;currentColor&quot;&gt;
      &lt;use href={`/assets/sprite/countryCode.svg#${id}`} /&gt;
    &lt;/svg&gt;
  );
}</code></pre>
<p>이렇게 컴포넌트로 만들면 여러 가지 장점이 있어요. (지극히 개인적인 생각입니다 🤔)</p>
<ul>
<li>코드 재사용성이 높아져요.</li>
<li>프로젝트 내에서 일관된 방식으로 sprite SVG를 사용할 수 있어요.</li>
<li>id만 넣어주면 되니까 사용하기 쉽고, 오타 같은 실수를 줄일 수 있어요.</li>
<li>나중에 sprite SVG 사용 현황을 파악하기 쉬워져요.</li>
<li>스타일링이나 크기 조절을 한 곳에서 관리할 수 있어 유지보수가 편해져요.</li>
</ul>
<h2 id="마치며">마치며</h2>
<p>Sprite SVG는 처음에는 좀 복잡해 보였지만, 익숙해지니까 정말 유용한 기술이라고 생각해요. 특히 아이콘을 많이 사용하는 프로젝트에서 성능 향상에 큰 도움이 될 것 같아요.</p>
<p>이 기술을 사용하면서 느낀 점은, 개발자로서 항상 성능과 사용성 사이의 균형을 고민해야 한다는 거예요. Sprite SVG는 그 균형을 잘 맞춘 기술 중 하나라고 생각해요. </p>
<p>다음에는 script를 이용해 자동으로 sprite SVG를 생성하는 방법에 대해 알아볼게요. 
저는 script를 이용해서 복잡해 보일 수 있는 sprite SVG 생성 과정을 간단하고 효율적으로 만들 수 있었어요. 🚀</p>
<p>다음 글: [[sprite 2, 스크립트로 Sprite SVG 자동 생성하기]]</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Issue로 사용자 피드백 받기]]></title>
            <link>https://velog.io/@sumi-0011/GitHub-Issue-feedback</link>
            <guid>https://velog.io/@sumi-0011/GitHub-Issue-feedback</guid>
            <pubDate>Tue, 03 Sep 2024 07:54:44 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;이 버그 좀 고쳐주세요!&quot;, &quot;이런 기능이 있으면 좋겠어요!&quot;</p>
</blockquote>
<p><code>GitAnimals</code> 서비스를 운영하다 보니 이런 피드백들이 종종 들어오곤 했어요. 
특히 GitHub를 기반으로 한 서비스이었기 때문에, 사용자들이 직접 GitHub Issue를 통해 의견을 제시하는 경우도 있었죠.</p>
<p>그러다 문득 이런 생각이 들었어요. &quot;잠깐, 이 모든 피드백을 한 곳에서 관리할 수 없을까?&quot; 🤔</p>
<p>사용자들의 소중한 의견을 놓치지 않고, 효율적으로 관리하고 싶었거든요. 
그래서 사용자 피드백을 자동으로 GitHub Issue로 등록하는 기능을 만들기로 했습니다.</p>
<p>채널톡 같은 방법도 있었지만, 우리 서비스만의 특별한 무언가를 만들고 싶었어요. </p>
<h2 id="github-api와-친해지기">GitHub API와 친해지기</h2>
<p>이 기능을 구현하려면 먼저 GitHub API를 사용해야 했습니다. 
Issue를 생성하려면 Personal Access Token이 필요했는데, 이걸 만드는 과정이 꽤 조금 복잡했습니다. </p>
<h3 id="personal-access-token-생성하기">Personal Access Token 생성하기</h3>
<ol>
<li>GitHub 사이트에 로그인합니다.</li>
<li>오른쪽 상단에 있는 자신의 프로필 아이콘을 클릭하고 Settings를 선택합니다.</li>
<li>왼쪽 메뉴에서 Developer settings를 클릭합니다.</li>
<li>Personal access tokens를 선택하고 Generate new token을 클릭합니다.</li>
<li>Token description에 &#39;Auto create issue token&#39;과 같이 용도를 적어줍니다.</li>
<li>Select scopes에서 repo만 선택합니다. (Issue 관리는 repo 권한에 포함되어 있습니다)</li>
<li>Generate 버튼을 클릭하면 토큰이 생성됩니다.</li>
</ol>
<p>주의할 점은, 토큰은 생성 직후에만 볼 수 있기 때문에, 꼭 안전한 곳에 저장해두어야 합니다. </p>
<p>이렇게 만든 토큰으로 GitHub API를 사용할 수 있게 되었습니다. 이제 본격적으로 코드를 작성해볼 차례입니다.</p>
<h2 id="코드로-구현하기">코드로 구현하기</h2>
<p>자, 이제 본격적으로 코드를 작성할 차례예요. 
저는 API 호출을 위한 함수를 만들었어요.</p>
<pre><code class="language-typescript">const ISSUE_TOKEN = process.env.NEXT_PUBLIC_ISSUE_TOKEN;
const POST_ISSUE_URL = &#39;/repos/{owner}/{repo}/issues&#39;;

interface PostIssueRequest {
  title: string;
  body: string;
  assignees?: string[];
  labels?: string[];
}

async function postIssue(request: PostIssueRequest) {
  const response = await fetch(POST_ISSUE_URL, {
    method: &#39;POST&#39;,
    headers: {
      Authorization: `Bearer ${ISSUE_TOKEN}`,
      &#39;Content-Type&#39;: &#39;application/json&#39;,
    },
    body: JSON.stringify(request),
  });

  if (!response.ok) {
    throw new Error(&#39;Failed to post issue&#39;);
  }

  return response.json();
}</code></pre>
<p>이 함수는 Issue의 제목, 내용, 담당자, 라벨을 받아서 GitHub에 새 Issue를 생성합니다. </p>
<p>1단계에서 만든 personal access token을 API를 호출할 때 header에 추가하여 넘겨줍니다. 
그리고, 등록할 issue 관련 데이터는 body에 담아 POST API call을 합니다.</p>
<blockquote>
<p>GitHub API에서는 <code>/repos/{owner}/{repo}/issues</code>라는 경로로 POST 요청을 보내 issue를 생성할 수 있습니다. </p>
</blockquote>
<p>저의 경우에는 repository url이 <code>https://github.com/git-goods/gitanimals</code>였기 때문에 <code>https://api.github.com/repos/git-goods/gitanimals/issues</code> 경로로 요청을 보냈습니다.</p>
<p>또한 보안을 위해 토큰은 환경변수로 관리했으며, 
이 함수를 더 쉽게 사용하기 위해 React Query의 <code>useMutation</code>을 활용했습니다.</p>
<pre><code class="language-typescript">export const usePostIssue = (options?: UseMutationOptions&lt;unknown, unknown, PostIssueRequest&gt;) =&gt;
  useMutation({ mutationFn: postIssue, ...options });</code></pre>
<p>이렇게 하면 컴포넌트에서 아주 간단하게 Issue를 생성할 수 있어요.</p>
<pre><code class="language-typescript">const { mutate } = usePostIssue();

const onSubmit = () =&gt; {
  const username = userData?.username;

  mutate(
    { ...content, assignees: [username] },
    {
      onSuccess() {
        setIsOpen(false);
        initContent();
        alert(&#39;Thank you for your feedback!&#39;);
      },
    },
  );
};</code></pre>
<p>사용자가 피드백 제출 버튼을 누르면 <code>onSubmit</code> 함수가 실행되고, GitHub에 새 Issue가 생성되는 거예요!</p>
<h2 id="마무리하며">마무리하며</h2>
<p>이 기능을 구현하면서 정말 많은 것을 배웠어요. GitHub API 활용부터 React Query로 비동기 처리하는 방법까지, 새로운 기술을 적용해볼 수 있어 즐거웠죠.</p>
<p>특히 가장 만족스러운 점은 우리 서비스의 특성을 살린 피드백 시스템을 만들었다는 거예요. GitHub 기반 서비스니까 피드백도 GitHub Issue로 받는 거죠. 이런 작은 디테일이 사용자들에게 우리 서비스의 정체성을 더 잘 보여줄 수 있을 것 같아요.</p>
<p>실제로 지금도 사용자 피드백이 꾸준히 들어오고 있어요. 궁금하다면 <a href="https://github.com/git-goods/gitanimals/issues">GitAnimals Github Issue</a>에서 직접 확인해볼 수 있어요. 우리 사용자들의 생생한 목소리를 들을 수 있답니다.</p>
<p>혹시 이 기능을 어떻게 구현했는지 자세히 알고 싶다면, <a href="https://github.com/git-goods/git-animal-client/pull/72">PR Link</a>에서 전체 구현 과정을 확인할 수 있어요.</p>
<p>이렇게 서비스에 녹아든 작은 기능 하나가 사용자 경험을 크게 바꿀 수 있다는 걸 새삼 느꼈어요. 서비스의 특성을 살린 독특한 기능 하나가 얼마나 큰 변화를 가져올 수 있는지, 이번 경험을 통해 확실히 알 수 있었죠.</p>
<p>그리고 추가로, 피드백을 누가 제출했는지 트래킹하기 위해 Google Sheets를 이용한 로그 수집 시스템도 만들어봤어요. 
이 이야기는 다음 기회에 자세히 들려드릴게요. 기대해 주세요! 🍀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[useEffect가 두 번 실행되는 현상을 어떻게 막을 수 있을까요 🤔]]></title>
            <link>https://velog.io/@sumi-0011/useEffect-two</link>
            <guid>https://velog.io/@sumi-0011/useEffect-two</guid>
            <pubDate>Wed, 28 Aug 2024 03:49:03 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황-😮">문제 상황 😮</h2>
<p>먼저, 다음과 같은 코드를 보시죠</p>
<pre><code class="language-typescript">useEffect(() =&gt; {;
    console.log(&#39;useEffect 실행&#39;);
}, []);</code></pre>
<p>이 코드를 실행하면 useEffect가 한 번만 실행될 것으로 예상하게 됩니다. 
하지만 실제로 콘솔을 확인해보면, <code>useEffect 실행</code>이 두 번 출력되는 것을 볼 수 있습니다. 왜 이런 일이 발생하는 걸까요? 🤷‍♂️</p>
<h2 id="원인-reactstrictmode-🔍">원인: React.StrictMode 🔍</h2>
<p>이 현상은 React 18에서 도입된 Strict Mode 때문에 발생합니다. 
React 공식 문서에서는 Strict Mode에 대해 다음과 같이 설명하고 있습니다.</p>
<blockquote>
<p>&quot;<code>&lt;StrictMode&gt;</code>를 사용하면 개발 초기에 구성 요소의 일반적인 버그를 찾을 수 있습니다.&quot; </p>
</blockquote>
<blockquote>
<p>&quot;Strict Mode 검사는 개발 중에만 실행되지만, 코드에 이미 존재하지만 프로덕션에서 안정적으로 재현하기 어려울 수 있는 버그를 찾는 데 도움이 됩니다. 
Strict Mode를 사용하면 사용자가 버그를 신고하기 전에 버그를 수정할 수 있습니다.&quot; </p>
</blockquote>
<blockquote>
<p>참고 : <a href="https://react.dev/reference/react/StrictMode">https://react.dev/reference/react/StrictMode</a></p>
</blockquote>
<h2 id="그래서-useeffect가-두번-실행되는-것을-어떻게-막을-수-있을까요-💡">그래서 useEffect가 두번 실행되는 것을 어떻게 막을 수 있을까요? 💡</h2>
<h3 id="1-reactstrictmode-제거">1. React.StrictMode 제거</h3>
<p>Strict Mode를 제거하면 문제를 해결할 수 있지만, 잠재적 버그를 놓칠 수 있어 권장되지 않습니다. </p>
<h3 id="2-클린업-함수를-이용한-상태-초기화">2. 클린업 함수를 이용한 상태 초기화</h3>
<p>간단한 콘솔 출력이나 상태 설정의 경우 두 번 실행되어도 큰 문제가 없습니다. 
하지만 API 호출이나 반드시 한 번만 실행되어야 하는 동작이 두 번 실행되면 문제가 될 수 있습니다.</p>
<p>이를 해결하기 위해, useEffect의 클린업 함수에서 이전에 설정된 값을 초기화해야 합니다. 특히 API 요청의 경우, 중복 요청으로 인한 문제를 방지하기 위해 이전 요청을 적절히 처리해야 합니다.</p>
<h3 id="3-abortcontroller-사용하기">3. AbortController 사용하기</h3>
<p>useEffect가 두번 실행되는 것을 막기 위한 방법 중 하나는 AbortController를 사용하는 것입니다. </p>
<pre><code class="language-typescript">function Test() {
  useEffect(() =&gt; {
    const controller = new AbortController();

    console.log(&#39;doSomethingAsync: &#39;);
    doSomethingAsync(
      { value: &#39;test&#39; },
      { signal: controller.signal }
    ).then((result) =&gt; {
      console.log(&#39;result: &#39;, result);
    });

    return () =&gt; {
      controller.abort();
    };
  }, []);
  return &lt;div&gt;&lt;/div&gt;;
}
</code></pre>
<pre><code class="language-ts">function doSomethingAsync(payload: unknown, { signal }: { signal?: AbortSignal } = {}): Promise&lt;unknown&gt; {
  if (signal?.aborted) {
    return Promise.reject(new AbortException());
  }

  return new Promise((resolve, reject) =&gt; {
    const abortHandler = () =&gt; {
      reject(new AbortException());
    };

    setTimeout(() =&gt; {
      signal?.removeEventListener(&#39;abort&#39;, abortHandler);
      resolve(payload);
    }, 0);

    signal?.addEventListener(&#39;abort&#39;, abortHandler);
  });
}

class AbortException extends DOMException {
  constructor() {
    super(&#39;Aborted&#39;, &#39;AbortError&#39;);
  }
}
</code></pre>
<p>이 방법을 사용하면 useEffect는 두 번 실행되지만, 중요한 비동기 작업은 한 번만 완료합니다. </p>
<h4 id="결과-콘솔">결과 콘솔</h4>
<p>![[Pasted image 20240828124528.png]]</p>
<h4 id="abortcontroller를-이용한-해결-방법의-동작-원리">AbortController를 이용한 해결 방법의 동작 원리</h4>
<ol>
<li>useEffect 내에서 AbortController를 생성합니다. </li>
<li>doSomethingAsync 함수를 호출할 때 controller의 signal을 전달합니다. </li>
<li>useEffect의 클린업 함수에서 controller.abort()를 호출합니다. </li>
</ol>
<p>이 방법이 효과적인 이유는 다음과 같습니다.</p>
<ul>
<li>React.StrictMode에서 useEffect가 두 번 실행되더라도, 첫 번째 실행의 클린업 함수가 두 번째 실행 전에 호출됩니다. </li>
<li>첫 번째 실행에서 시작된 비동기 작업은 setTimeout으로 인해 지연됩니다.</li>
<li>두 번째 실행 전에 첫 번째 작업이 abort됩니다. </li>
<li>두 번째 실행에서 시작된 작업만 완료되므로, 결과적으로 한 번만 실행된 것처럼 보입니다. </li>
</ul>
<p>이렇게 하면 useEffect 자체는 두 번 실행되지만, doSomethingAsync가 취소되지 않고 실행되는 것은 단 한 번입니다. </p>
<p>다만, useEffect를 두번 실행하지 않기위해 사용하기에는 과도한 개발인것 같아요. 🤔</p>
<h3 id="4-useref-이용하기-✨">4. useRef 이용하기 ✨</h3>
<pre><code class="language-ts">function useEffectOnce(callback: () =&gt; void) {
  const ref = useRef(false);

  useEffect(() =&gt; {
    if (ref.current) return;
    ref.current = true;

    if (typeof callback === &#39;function&#39;) {
      callback();
    }
  }, []);
}
</code></pre>
<h4 id="useeffectonce를-이용한-해결-방법의-동작-원리">useEffectOnce를 이용한 해결 방법의 동작 원리</h4>
<ol>
<li>useRef를 사용해 효과 실행 여부를 추적합니다.</li>
<li>useEffect 내에서 ref 값을 확인하여 이미 실행됐다면 바로 반환합니다.</li>
<li>아직 실행되지 않았다면 ref 값을 true로 설정하고 콜백을 실행합니다.</li>
<li>빈 의존성 배열([])을 사용해 컴포넌트 마운트 시에만 실행되도록 합니다.</li>
</ol>
<p>이 방법이 효과적인 이유는 다음과 같습니다. </p>
<ul>
<li>Strict Mode에서 useEffect가 두 번 호출되더라도, ref 값 덕분에 콜백은 한 번만 실행됩니다.</li>
<li>useRef를 사용해 컴포넌트 생명주기 동안 값을 유지하므로, 리렌더링에도 안전합니다.</li>
<li>타입 체크를 통해 콜백이 확실히 함수일 때만 실행하므로 타입 안정성이 보장됩니다.</li>
</ul>
<p>이렇게 하면 useEffect는 여전히 Strict Mode에서 두 번 실행되지만, 우리가 원하는 로직은 딱 한 번만 실행되는 거죠!</p>
<h4 id="사용예시">사용예시</h4>
<pre><code class="language-ts">function MyComponent() {
  useEffectOnce(() =&gt; {
    console.log(&#39;이 메시지는 단 한 번만 출력됩니다!&#39;);
    // 여기에 초기 데이터 로딩이나 이벤트 리스너 등록 같은 작업을 넣으면 됩니다.
  });

  return &lt;div&gt;My Component&lt;/div&gt;;
}</code></pre>
<p>useEffectOnce는 AbortController를 사용한 방법에 비해 더 간단하고 직관적이에요. </p>
<p>다만, 모든 상황에 이 훅을 사용하는 것은 주의해야 해야합니다. 때로는 useEffect의 재실행이 필요한 경우도 있을테니까요.</p>
<hr>
<p>긴 글을 읽어주셔서 감사합니다. 
추가적인 질문이나 의견이 있다면 언제든 공유해 주세요! 💬🚀</p>
<hr>
<blockquote>
<p>참고 : <a href="https://medium.com/@tangiblej/cancel-a-promise-inside-react-useeffect-12a101606b72">https://medium.com/@tangiblej/cancel-a-promise-inside-react-useeffect-12a101606b72</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[우리의 타이머, 절대 멈추지 않는다!]]></title>
            <link>https://velog.io/@sumi-0011/app-background</link>
            <guid>https://velog.io/@sumi-0011/app-background</guid>
            <pubDate>Tue, 27 Feb 2024 06:43:55 GMT</pubDate>
            <description><![CDATA[<p>앱과 웹 환경에서 타이머 기능을 구현하면서 겪은 이야기인데, 함께 들어보시겠어요? 😊</p>
<h2 id="우리의-타이머-절대-멈추지-않는다">우리의 타이머, 절대 멈추지 않는다!</h2>
<p>저희 팀에서 새로운 요구사항을 받았어요. </p>
<blockquote>
<p>&quot;앱 환경을 고려해 화면을 이탈하거나, 백그라운드에서도 타이머의 시간이 없어지면 안 됩니다.&quot; </p>
</blockquote>
<p>처음엔 &#39;아, 간단하겠네~&#39; 했는데, 생각보다 고민할 게 많더라고요.</p>
<h3 id="백엔드-vs-프론트엔드-누가-책임질까">백엔드 vs 프론트엔드, 누가 책임질까?</h3>
<p>먼저 백엔드 팀과 이야기를 나눴어요. 
&quot;시간을 서버에 저장할까요, 아니면 클라이언트에서 처리할까요?&quot; 고민 끝에 프론트엔드에서 처리하기로 했죠. 
API 콜을 줄이고 싶었거든요. 대신 타이머가 끝날 때만 서버에 시간을 보내기로 했어요.</p>
<h3 id="프론트엔드의-고민-어떻게-저장할까">프론트엔드의 고민: 어떻게 저장할까?</h3>
<p>자, 이제 프론트엔드에서 어떻게 시간을 저장할지 고민이 시작됐어요. 
팀원들과 브레인스토밍을 했는데, 세 가지 아이디어가 나왔어요:</p>
<ol>
<li>1초마다 저장하자! (정확하긴 한데, 좀 과하지 않을까?)</li>
<li>10초마다 저장하자! (1초보단 나은 것 같은데...)</li>
<li>페이지 나갈 때만 저장하자! (오, 이거 좋은데?)</li>
</ol>
<p>처음엔 1초마다 저장하려고 했어요. 근데 생각해보니 UI가 버벅거릴 것 같더라고요. 10초도 괜찮아 보였지만, 뭔가 아쉬웠어요. </p>
<p>그러다 페이지를 나갈 때 저장하는 방법을 알게되었어요. 
&quot;이거다!&quot; 싶었죠. 근데 또 걱정이 되더라고요. &quot;앱이 강제로 종료되면 어쩌지?&quot; 하는 생각이 들었거든요.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/3dc83ff3-f23c-496f-a8ea-86852af54e06/image.png" alt=""></p>
<h3 id="page-visibility-api-우리의-구원자">Page Visibility API, 우리의 구원자!</h3>
<p>고민 끝에 Page Visibility API를 알게 됐어요. 
이 API를 사용하면 사용자가 페이지를 보고 있는지, 아닌지 알 수 있더라고요. 이걸 이용해서 페이지를 벗어날 때 시간을 저장하기로 했어요.</p>
<p>하지만 혹시 모를 상황에 대비해서, 10초마다 저장하는 방법도 함께 사용하기로 했어요. 안전빵으로요! 😉</p>
<h3 id="코드로-구현하기">코드로 구현하기</h3>
<p>두 가지 방법을 모두 사용하기 위해 Custom Hook을 만들었어요. 코드를 자세히 살펴볼까요?</p>
<pre><code class="language-typescript">useRecordMidTime(time, missionId); // 10초마다 저장
useUnloadAction(time, missionId); // 페이지 나갈 때 저장</code></pre>
<h4 id="userecordmidtime---주기적으로-저장하는-hook">useRecordMidTime - 주기적으로 저장하는 Hook</h4>
<p>이 Hook은 10초마다 현재 시간을 localStorage에 저장해요.</p>
<pre><code class="language-typescript">export function useRecordMidTime(time: number) {
  const onSaveTime = () =&gt; {
    localStorage.setItem(STORAGE_KEY.STOPWATCH.TIME_2, String(time));
  };

  useInterval(() =&gt; {
    onSaveTime();
  }, 10000);
}</code></pre>
<p>여기서 <code>useInterval</code>은 React에서 <code>setInterval</code>을 안전하게 사용할 수 있게 해주는 Custom Hook이에요. 10초(10000ms)마다 <code>onSaveTime</code> 함수를 실행해서 현재 시간을 저장하죠.</p>
<h4 id="useunloadaction---페이지-이탈-시-저장하는-hook">useUnloadAction - 페이지 이탈 시 저장하는 Hook</h4>
<p>이 Hook은 페이지 visibility가 변경될 때 현재 시간을 저장해요.</p>
<pre><code class="language-typescript">export function useUnloadAction(time: number) {
  const onSaveTime = useCallback(() =&gt; {
    localStorage.setItem(STORAGE_KEY.STOPWATCH.TIME, String(time));
  }, [time]);

  useVisibilityState(onSaveTime);
}

function useVisibilityState(onAction: VoidFunction) {
  const onVisibilityChange = useCallback(() =&gt; {
    if (document.visibilityState === &#39;hidden&#39;) {
      onAction();
    }
  }, [onAction]);

  useEffect(() =&gt; {
    document.addEventListener(&#39;visibilitychange&#39;, onVisibilityChange);

    return () =&gt; {
      document.removeEventListener(&#39;visibilitychange&#39;, onVisibilityChange);
    };
  }, [onVisibilityChange]);
}</code></pre>
<p><code>useUnloadAction</code>은 <code>useVisibilityState</code>라는 또 다른 Custom Hook을 사용해요. 이 Hook은 Page Visibility API를 이용해서 페이지가 숨겨질 때(<code>hidden</code> 상태가 될 때) <code>onAction</code> 함수를 실행해요.</p>
<p><code>useCallback</code>을 사용한 이유는 불필요한 리렌더링을 방지하기 위해서예요. <code>time</code>이 변경될 때만 새로운 함수를 만들도록 했죠.</p>
<h3 id="팀원의-리뷰-새로운-깨달음">팀원의 리뷰, 새로운 깨달음</h3>
<p>코드를 작성하고 PR을 올렸는데, 팀원에게 정말 유익한 리뷰를 받았어요.</p>
<p><img src="https://velog.velcdn.com/images/sumi-0011/post/dd9d5e91-ba63-4a35-8f93-8c05f378c7ec/image.png" alt="PR 리뷰 이미지"></p>
<p>이 리뷰를 통해 <code>useVisibilityState</code> Hook의 의존성 배열에 대해 다시 생각해보게 됐어요. <code>onVisibilityChange</code>를 <code>useEffect</code>의 의존성 배열에 추가하고, <code>time</code> prop이 바뀔 때마다 <code>onVisibilityChange</code> 함수가 새로 생성되도록 수정했죠.</p>
<pre><code class="language-typescript">function useVisibilityState(onAction: VoidFunction) {
  const onVisibilityChange = useCallback(() =&gt; {
    if (document.visibilityState === &#39;hidden&#39;) {
      onAction();
    }
  }, [onAction]);

  useEffect(() =&gt; {
    document.addEventListener(&#39;visibilitychange&#39;, onVisibilityChange);
    return () =&gt; {
      document.removeEventListener(&#39;visibilitychange&#39;, onVisibilityChange);
    };
  }, [onVisibilityChange]); // onVisibilityChange를 의존성 배열에 추가
}</code></pre>
<p>이렇게 수정하면 <code>time</code>이 변경될 때마다 정확하게 최신 시간이 저장되면서도, 불필요한 리렌더링은 방지할 수 있어요.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 경험을 통해 정말 많은 것을 배웠어요. Custom Hook 작성 방법, React의 성능 최적화 기법, 그리고 무엇보다 팀 협업의 중요성을 몸소 느꼈죠. </p>
<p>코드를 작성할 때는 단순히 &#39;작동하는&#39; 코드를 만드는 것을 넘어서, 다른 개발자들의 관점에서도 이해하기 쉽고 유지보수가 용이한 코드를 작성해야 한다는 걸 깨달았어요. 팀원의 리뷰 덕분에 제 코드가 한 단계 더 발전할 수 있었죠.</p>
<p>이런 작은 경험들이 모여 우리를 더 나은 개발자로 만들어가는 것 같아요. 
앞으로도 이런 도전적인 과제들을 마주하며 성장해 나가고 싶습니다. 
여러분도 개발하면서 겪은 재미있는 경험이 있다면 언제든 공유해주세요! 함께 배우고 성장하는 게 개발의 묘미 아니겠어요? 😊🚀</p>
]]></description>
        </item>
    </channel>
</rss>