<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>arin_dev.log</title>
        <link>https://velog.io/</link>
        <description>💻 FE Developer</description>
        <lastBuildDate>Fri, 07 Aug 2026 00:31:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>arin_dev.log</title>
            <url>https://velog.velcdn.com/images/arin_dev/profile/af310a9a-34f4-48ac-8b97-fefbf7a0cdf0/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. arin_dev.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/arin_dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[키즈노트북 미리보기 갤러리 PoC (2) - 발열 추적부터 1줄 패치]]></title>
            <link>https://velog.io/@arin_dev/%ED%82%A4%EC%A6%88%EB%85%B8%ED%8A%B8%EB%B6%81-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EA%B0%A4%EB%9F%AC%EB%A6%AC-PoC-2-%EB%B0%9C%EC%97%B4-%EC%B6%94%EC%A0%81%EB%B6%80%ED%84%B0-1%EC%A4%84-%ED%8C%A8%EC%B9%98</link>
            <guid>https://velog.io/@arin_dev/%ED%82%A4%EC%A6%88%EB%85%B8%ED%8A%B8%EB%B6%81-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EA%B0%A4%EB%9F%AC%EB%A6%AC-PoC-2-%EB%B0%9C%EC%97%B4-%EC%B6%94%EC%A0%81%EB%B6%80%ED%84%B0-1%EC%A4%84-%ED%8C%A8%EC%B9%98</guid>
            <pubDate>Fri, 07 Aug 2026 00:31:58 GMT</pubDate>
            <description><![CDATA[<h3 id="들어가며">들어가며</h3>
<p><a href="https://velog.io/@arin_dev/%ED%82%A4%EC%A6%88%EB%85%B8%ED%8A%B8%EB%B6%81-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EA%B0%A4%EB%9F%AC%EB%A6%AC-PoC-1-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC-%EC%9E%98-%EA%B3%A0%EB%A5%B4%EB%8A%94-%EA%B8%B0%EC%A4%80%EC%97%90-%EB%8C%80%ED%95%B4">[1편]</a>에서 page-flip 코어를 얇게 감싸는 구조로 PoC를 끝내고 정식 구현을 시작했다.</p>
<p>하지만 진짜 시작은 실기기 QA부터였다. 1편에서 칭찬했던 1줄 패치가 그대로 업보가 되어 돌아오는 등, 예측하지 못한 버그 두 방을 제대로 맞았다.</p>
<h3 id="📝-tldr">📝 TL;DR</h3>
<p>PoC는 개발 초기 리스크를 확 줄여주지만, 결국 실기기 실측을 넘어서지는 못한다.</p>
<p><img src="https://velog.velcdn.com/images/arin_dev/post/e38cd44b-b83d-437a-aabb-09053c905827/image.png" alt=""></p>
<p><strong>1. 안드로이드에서만 말줄임(…)이 안 된다</strong></p>
<p><strong>🐛 증상</strong>
iOS와 데스크톱 Chrome에서는 멀쩡한데, 안드로이드 실기기에서만 본문이 <code>…</code> 없이 어중간한 위치에서 딱 잘려 나갔다.</p>
<p><strong>🕵️‍♂️ 원인 추적과 삽질</strong>
처음엔 <code>-webkit-line-clamp</code> 속성이 Blink 엔진에서 안 먹는 줄 알고, JS로 글자 수를 세서 잘라내는 로직을 짰다. 그런데 계수를 아무리 조율해도 해결되지 않았다. 글자 수 문제가 아니었던 것이다.</p>
<p>정적 마크업(renderToStaticMarkup)이라 렌더 시점 측정이 안 되고, 안드로이드 전용 버그라 로컬 재현도 불가능했다. 결국 <code>chrome://inspect</code>로 <code>실기기 WebView의 computed style을 직접 측정</code>해보니,</p>
<pre><code class="language-js">// 실기기 CDP 측정 결과
font-size: 8px     // CSS로는 분명 6px로 줬는데?!
line-height: 12px</code></pre>
<p>범인은 Android WebView의 WebSettings.minimumFontSize 기본값(8px)이었다. CSS로 6px을 줘도 하드웨어 타깃 특성상 강제로 8px로 상향 렌더링되고 있었다. (iOS엔 이 하드 최소값이 없다.)</p>
<p>안드로이드에서만 글자 폭과 줄 높이가 달라져 픽셀 계산이 모조리 틀어졌던 것이다.</p>
<p>💡 <strong>해결</strong>
본문 폰트를 8px로 통일했다. 기준 스케일이 맞춰지자 플랫폼 분기 및 추정 로직 60여 줄을 깔끔하게 제거할 수 있었고, 순수한 -webkit-line-clamp 속성만으로 두 플랫폼 모두에서 매끄럽게 동작하게 만들었다.</p>
<p>*<em>2. 검은 그림자, 범인은 내가 넣은 패치였다 *</em></p>
<p>🐛 <strong>증상</strong>
특정 iOS(15 Pro/120Hz) 책넘길 때 검은 그림자·세로 이음선·페이지 잔상.</p>
<p>🕵️‍<strong>원인</strong>
프로토타입 환경에서는 drawShadow: true 상태에서도 연출이 고르게 잘 나왔다. 왜 우리 서비스 코드에서만 유독 그림자가 짙게 뭉개질까?</p>
<p>순정 page-flip 라이브러리와 우리 패치본의 diff를 떠보았다. 1편에서 세로 모드 뒤로 넘김 연출을 고치겠다고 넣었던 바로 그 한 줄이 문제가 됨.</p>
<pre><code class="language-js">  - : this.pages[current - 1];
  + : this.pages[current - 1].newTemporaryCopy();</code></pre>
<p>뒤로 넘김 연출을 위해 생성한 newTemporaryCopy() 임시 복사본이, 렌더링 과정에서 그림자를 한 겹 더 중첩해서 그리고 있었던 것이다.</p>
<p>💡 <strong>해결</strong>
패치를 즉시 제거(순정 복귀)했다. 다행히 패치를 걷어내도 실기기에서 뒤로 넘김 동작에는 별문제가 없었다.
(단, 세로 이음선 잔상은 순정 라이브러리 자체의 버그로 15 Pro 환경에서 재현되었다. 완전 제거는 어려워 maxShadowOpacity: 0.5로 그림자 짙도를 낮춰 가시성을 완화했다.)</p>
<p><strong>3. 기기가 뜨거워진다: 멈추지 않는 렌더 루프</strong></p>
<p><strong>🐛 증상</strong>
테스트를 조금만 계속해도 기기 온도가 올라가고 앱이 버벅거렸다.</p>
<p>🕵️‍<strong>원인</strong>
라이브러리 내부 코드를 뜯어보니 가만히 있는 유휴(idle) 상태에서도 매 프레임 Canvas를 계속 다듬는 무한 렌더링 루프가 돌고 있었다.</p>
<pre><code class="language-ts">// page-flip 내부 렌더 루프 (요약)
start() {
  const loop = () =&gt; {
    this.render();
    requestAnimationFrame(loop); // 🛑 idle 상태에서도 계속 60fps 유지
  };
  requestAnimationFrame(loop);
}</code></pre>
<p>화면을 오갈 때마다 이 루프가 중복 생성되면서 GPU를 과점유했던 것이다.</p>
<p><strong>💡 해결</strong>
컴포넌트가 unmount 되거나 애니메이션이 끝난 idle 상태일 때 cancelAnimationFrame을 호출하도록 제어하여 불필요한 GPU 점유를 차단했다.</p>
<p><strong>4. iOS WebView 디테일 잡기</strong>
하이브리드 앱 특성상 iOS Safari 전용 엣지 케이스들도 추가로 다듬었다.</p>
<p><strong>1) CSS 애니메이션 튐 현상</strong>
iOS Safari에서 translateY(%) 가변 단위 키프레임 사용 시 연출이 미세하게 튀는 현상이 발생했다.</p>
<p><strong>해결</strong>: 고정 px 단위와 GPU 하드웨어 가속을 유도하는 translate3d 조합으로 교체.</p>
<pre><code class="language-ts">/* iOS Safari 키프레임 버그 대응 */
- transform: translateY(-30%);
+ transform: translate3d(0, -200px, 0);</code></pre>
<p><strong>2) 뒤로가기 재진입 시 연출 미작동</strong>
iOS의 bfcache(Page Transition Cache)로 인해 뒤로가기 진입 시 이전 종료 상태가 그대로 복원되어 연출이 다시 실행되지 않았다.</p>
<p><strong>해결</strong>: pageshow 이벤트의 persisted 속성을 감지해 연출을 리셋하고 재재생하도록 처리.</p>
<pre><code class="language-ts">window.addEventListener(&#39;pageshow&#39;, (e) =&gt; {
  if (e.persisted) replayIntro();
});</code></pre>
<h3 id="🏁-회고">🏁 회고</h3>
<p>PoC는 초기 리스크를 줄여주고 기술적 방향성을 잡는 데 유용한 도구다. 이번 PoC 덕분에 아키텍처를 빠르게 확정하고 공수를 아낄 수 있었다.</p>
<p>하지만 PoC 환경에서의 통과가 실기기에서의 완벽함을 보장하진 않는다.</p>
<p>WebView 특유의 최소 폰트 제약이나 120Hz 디스플레이에서의 렌더링 엣지 케이스는 결국 실기기 실측을 통해서만 잡아낼 수 있었다. PoC로 큰 틀을 잡고, 실기기 실측으로 디테일을 채워 넣어야 비로소 프로덕션 코드가 완성된다는 것을 배운 경험이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[키즈노트북 미리보기 갤러리 PoC (1) - 라이브러리를 '잘 고르는' 기준에 대해]]></title>
            <link>https://velog.io/@arin_dev/%ED%82%A4%EC%A6%88%EB%85%B8%ED%8A%B8%EB%B6%81-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EA%B0%A4%EB%9F%AC%EB%A6%AC-PoC-1-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC-%EC%9E%98-%EA%B3%A0%EB%A5%B4%EB%8A%94-%EA%B8%B0%EC%A4%80%EC%97%90-%EB%8C%80%ED%95%B4</link>
            <guid>https://velog.io/@arin_dev/%ED%82%A4%EC%A6%88%EB%85%B8%ED%8A%B8%EB%B6%81-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EA%B0%A4%EB%9F%AC%EB%A6%AC-PoC-1-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%A5%BC-%EC%9E%98-%EA%B3%A0%EB%A5%B4%EB%8A%94-%EA%B8%B0%EC%A4%80%EC%97%90-%EB%8C%80%ED%95%B4</guid>
            <pubDate>Mon, 20 Jul 2026 06:15:57 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/arin_dev/post/b0d620d1-82a9-4efd-8423-e214b98bc94b/image.png" alt=""></p>
<h2 id="들어가며-🙂">들어가며 🙂</h2>
<p>&quot;라이브러리 하나 가져다 붙이면 뚝딱 아닌가?&quot;</p>
<p>처음엔 가볍게 생각했다. 하지만 프로덕션 코드를 건드리기 전에 &#39;진짜 우리 스펙을 견뎌낼 수 있을까?&#39;라는 의문이 꼬리를 물었다. 그렇게 작은 스파이크(Spike) 라우트 하나 파놓고 시작한 PoC.</p>
<p>지나고 보니 이번 작업은 단순히 어떤 라이브러리를 <strong>&#39;선택했는가&#39;</strong>의 문제가 아니었다. 수많은 선택지 사이에서 <strong>&#39;왜 이 기준을 세웠고, 코어를 어디까지 제어하기로 했는가&#39;</strong>에 대한 치열했던 고민의 기록이다.</p>
<h2 id="1-왜-poc부터">1. 왜 PoC부터?</h2>
<p>상세페이지 상단을 3단 자동 연출(사진 스크롤 ➔ 중앙 수렴 ➔ 책넘김)로 리뉴얼하는 작업이었다.</p>
<p>핵심 기능인 &#39;책넘김 연출&#39;은 직접 구현하기보다 이미 잘 만들어진 오픈소스를 활용하는 게 합리적이었다. 하지만 &quot;이게 과연 우리가 원하는 대로 제어될까?&quot;라는 의구심이 남았다. 본격적으로 본 프로젝트에 이식하기 전, 기술적 리스크를 먼저 털어내기 위해 버릴 셈 치고 스파이크 라우트부터 파서 테스트해 보기로 했다.</p>
<h2 id="2-github-star-수가-전부가-아니다-우리만의-채택-기준">2. GitHub Star 수가 전부가 아니다: 우리만의 채택 기준</h2>
<p>오픈소스를 고를 땐 자연스럽게 GitHub Star 수에 눈이 가기 마련이다. 하지만 우리의 목표는 &quot;Vanilla 코어를 가져와서 우리가 얇게 감싸 직접 통제하는 것&quot;이었다.</p>
<p>따라서 선택 기준은 단순한 &#39;인기&#39;나 &#39;편리한 래퍼(Wrapper)&#39;가 아닌, &#39;우리가 어디까지 주도권을 가질 수 있는가&#39;였다.</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>프레임워크 비종속(vanilla/TS)</td>
<td>기존에 React로 래핑된 라이브러리는 해당 라이브러리의 방식을 강제함</td>
</tr>
<tr>
<td>명령형 API 완전 노출</td>
<td><code>flip(index), loadFromHTML</code> 등 핵심 동작을 직접 제어해야 함</td>
</tr>
<tr>
<td>zero-dependency·경량</td>
<td>번들 크기 최적화 및 불필요한 의존성(jQuery 등) 차단</td>
</tr>
<tr>
<td>패치 가능성</td>
<td>미유지보수 상태여도 필요하면 코드를 직접 수정/패치할 수 있어야 함</td>
</tr>
<tr>
<td>SSR 통제</td>
<td>SSR 환경에 영향이 없도록 dynamic import로 격리 가능해야 함</td>
</tr>
</tbody></table>
<p>이 기준을 대입해 보니, 오히려 Star 수가 많았던 유명 래퍼 라이브러리들이 제일 먼저 후보에서 탈락했다. 우리에게 필요한 건 &#39;통제 가능한 코어&#39;였기 때문이다.</p>
<h2 id="3-후보-비교--최종-결정">3. 후보 비교 &amp; 최종 결정</h2>
<p>대표적으로 4가지 후보를 테이블에 올리고 검토했다.</p>
<table>
<thead>
<tr>
<th>후보</th>
<th>엔진 / 의존성</th>
<th>판정</th>
<th>사유</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/Nodlik/StPageFlip"><strong>Nodlik/StPageFlip</strong></a></td>
<td>바닐라 TS / zero-dep / MIT / ~10KB(gzip)</td>
<td>채택 ✅</td>
<td>명령형 API 직접 노출 → 얇게 감싸 <code>flip(index)</code>로 제어</td>
</tr>
<tr>
<td><a href="https://github.com/Nodlik/react-pageflip">Nodlik/react-pageflip</a> (Nodlik 공식 래퍼)</td>
<td>StPageFlip 래핑 / MIT</td>
<td>미채택</td>
<td>같은 엔진이지만 선언형 children API라 명령형 제어와 충돌</td>
</tr>
<tr>
<td><a href="https://github.com/marvellousPtc/react-pageflip">marvellousPtc/react-pageflip</a></td>
<td>StPageFlip vendoring+수정 / no-dep / ★1</td>
<td>참고</td>
<td>개선점은 있으나 개인 포크 의존 부담</td>
</tr>
<tr>
<td>turn.js</td>
<td>자체 / jQuery 의존</td>
<td>미채택</td>
<td>jQuery까지 딸려와 웹뷰 번들에 부담</td>
</tr>
</tbody></table>
<p>결론은 <strong>코어(page-flip)를 직접 얇게 감싸기.</strong></p>
<p>통합 구조는 간단하게 정리했다. 페이지는 정적 HTML로 주입하고, 페이지 넘김 연출은 전부 flip(index)로 통일했다.</p>
<pre><code class="language-tsx">// SSR 안전: 클라이언트 사이드에서만 동적 로드
const { PageFlip } = await import(&#39;page-flip&#39;);

const pageFlip = new PageFlip(el, {
  usePortrait: true,
  useMouseEvents: false,
});

// 정적 HTML 주입
pageFlip.loadFromHTML(el.querySelectorAll(&#39;.page&#39;));

// 자동·수동 넘김 연출 단일화
pageFlip.flip(targetIndex, &#39;bottom&#39;);</code></pre>
<h4 id="➕-의존-대신-1줄-패치하기">➕ 의존 대신 1줄 패치하기</h4>
<p>위 후보군 중에 하나인 개인 포크 버전(<code>react-pageflip-enhanced</code>)에서 세로 모드 뒤로 넘김 연출을 개선한 코드를 발견했다.</p>
<p>Star 1개짜리 포크 라이브러리를 통째로 의존성에 추가하는 것은 공급망 리스크가 컸기에 diff를 뜯어보았고, 핵심이 되는 <code>단 한 줄</code>의 코드만 원본에 이식해 보았다.</p>
<pre><code class="language-js">// getFlippingPage() 의 portrait 분기 - 뒤로 넘김 연출 개선
- : this.pages[current - 1];                    // 순정 코드
+ : this.pages[current - 1].newTemporaryCopy(); // 임시 복사본 생성</code></pre>
<p>외부 의존성은 늘리지 않으면서 원하던 연출 개선만 쏙 챙겼으니, 그야말로 &#39;완벽한 PoC&#39;라며 스스로 대견해했다. (...이 패치가 훗날 어떤 업보로 돌아올지 이때는 알지 못했다. 🫠)</p>
<h3 id="💡-1편을-마치며">💡 1편을 마치며</h3>
<p>PoC를 통해 라이브러리의 통합 가능성과 제어권을 확보하며 1차 검증을 마쳤다.</p>
<p>하지만 진짜 시련은 그 이후에 찾아왔다. 기기를 뜨겁게 달구는 발열 문제와 iOS WebView 특유의 렌더링 튀는 버그들이 기다리고 있었기 때문이다.</p>
<p>다음 2편에서는 [트러블 슈팅 및 실제 배포 후 결과]를 다뤄본다.</p>
<p>(👉 2편에서 계속)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[린트가 돌고 있지 않았다]]></title>
            <link>https://velog.io/@arin_dev/%EB%A6%B0%ED%8A%B8%EA%B0%80-%EB%8F%8C%EA%B3%A0-%EC%9E%88%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4</link>
            <guid>https://velog.io/@arin_dev/%EB%A6%B0%ED%8A%B8%EA%B0%80-%EB%8F%8C%EA%B3%A0-%EC%9E%88%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4</guid>
            <pubDate>Wed, 22 Apr 2026 04:41:08 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/arin_dev/post/889995f7-3b77-4e79-8829-e2b0317d633b/image.png" alt=""></p>
<h2 id="📝-tldr">📝 TL;DR</h2>
<p>저장소 품질 스캔 중에 <code>.github/workflows</code> 는 있지만 typecheck·lint 를 실제로 검증하는 잡이 없는 걸 발견했고, CI 를 추가하려고 로컬에서 ESLint 를 먼저 돌려보니 오래 잠복해 있던 위반 3건이 튀어나왔다.</p>
<h2 id="🔍-상세">🔍 상세</h2>
<h3 id="배경">배경</h3>
<p>레거시 개선 거리를 찾느라 프론트 저장소 여러 개를 훑었다. 의존성 버전, TypeScript strict 설정, CI 워크플로우 파일 수, <code>any</code> 타입 수, <code>@ts-ignore</code> 수, <code>eslint-disable</code> 수, 큰 파일 분포 같은 지표를 뽑고 &quot;어디가 가장 손볼 여지가 많은가&quot; 를 보려던 목적이었다.</p>
<p>결과를 표로 정리하다 한 저장소가 눈에 띄었다. 해당 저장소에는 워크플로우 파일이 2개뿐이었고 그것도 전부 PR 제목 검증과 머지 자동화였다. typecheck, lint, test 를 돌리는 잡이 아예 없었다😇</p>
<p>기능 변경이 잦은 저장소가 아니다 보니 여러모로 방치되었던 것 같은데, 
아예 CI 가 빠져 있는 줄은 몰랐다.(내 얼굴에 침뱉기인듯ㅜㅜ)</p>
<h3 id="그래도-jenkins-에서-뭔가-돌지-않나-를-파봤다">&quot;그래도 Jenkins 에서 뭔가 돌지 않나?&quot; 를 파봤다</h3>
<p><img src="https://velog.velcdn.com/images/arin_dev/post/812a4029-e966-4d11-879e-459712830b20/image.png" alt=""></p>
<p>처음엔 GitHub Actions 에 없어도 Jenkins 배포 파이프라인에서 린트가 돌 거라고 믿었다. 그런데 Jenkins 배포 스크립트를 뜯어보니 순서가 이랬다.</p>
<pre><code class="language-bash">run &quot;Install&quot;     &quot;yarn install --immutable --immutable-cache&quot;
run &quot;Build&quot;       &quot;yarn env-cmd ... craco build&quot;
run &quot;Check Build&quot; &quot;yarn env-cmd ... kn-check-cra-build&quot;
ssh_deploy_server</code></pre>
<p>명시적 lint 단계가 없었다. &quot;그럼 <code>craco build</code> 안에서 CRA 가 내부적으로 ESLint 를 돌리니까 거기서 걸러지지 않을까?&quot; 싶었는데 이것도 제약이 있었다.</p>
<ul>
<li>CRA 내부 ESLint 는 <strong><code>react-app</code> 프리셋만 강제</strong>로 사용한다. 프로젝트 <code>package.json</code> 에 깔려있는 <code>@typescript-eslint/*</code> 플러그인 규칙들은 이 경로에서 적용되지 않는다.</li>
<li><code>CI=true</code> 환경변수가 있어야 warning 이 error 로 승격된다. 없으면 warning 으로만 출력되고 빌드는 성공한다.</li>
<li>Jenkins 는 보통 <code>CI=true</code> 를 자동 설정하지만, 승격된다 해도 그물 자체가 좁은 프리셋이라 프로젝트에 있는 더 엄격한 규칙은 빠져나간다.</li>
</ul>
<p>즉 어느 경로에서도 실제로 원하는 수준의 린트가 돌지 않고 있었다.</p>
<table>
<thead>
<tr>
<th>검사 경로</th>
<th>돌긴 했나</th>
<th>잡았나</th>
</tr>
</thead>
<tbody><tr>
<td>로컬 <code>craco start</code></td>
<td>O (<code>react-app</code> 프리셋)</td>
<td>X, <code>@typescript-eslint/*</code> 는 프리셋 밖</td>
</tr>
<tr>
<td>Jenkins <code>craco build</code></td>
<td>O (동일)</td>
<td>X, 동일</td>
</tr>
<tr>
<td>Jenkins 명시 lint 단계</td>
<td>X</td>
<td>(해당 없음)</td>
</tr>
<tr>
<td>GitHub Actions CI</td>
<td>X</td>
<td>(해당 없음)</td>
</tr>
</tbody></table>
<h3 id="잠복한-위반-3건">잠복한 위반 3건</h3>
<p>로컬에서 <code>./node_modules/.bin/eslint src --ext .ts,.tsx,.js,.jsx</code> 를 직접 돌렸더니 곧바로 3개의 에러가 떴다. 모두 <code>@typescript-eslint/return-await</code> 규칙 위반이었다.</p>
<pre><code class="language-ts">// 수정 전
export default async function getData(params) {
  return await fetchApi(params);
}

// 수정 후 (--fix 로 자동 수정)
export default async function getData(params) {
  return fetchApi(params);
}</code></pre>
<p><code>return await</code> 는 대부분의 경우 불필요한 한 단계를 더 거친다. <code>async</code> 함수 안에서 <code>return somePromise</code> 는 Promise 를 그대로 호출자에게 넘겨주는데, <code>return await somePromise</code> 는 한 번 풀어서 값을 받고 다시 Promise 로 감싸서 돌려준다. 호출자 입장에서 결과는 같지만, 안에서 <code>try/catch</code> 로 reject 를 잡고 싶을 때만 <code>await</code> 가 실제로 의미가 있다.</p>
<pre><code class="language-ts">async function f() {
  try {
    return await apiCall();  // reject 가 여기서 잡힘
  } catch (e) {
    handle(e);
  }
}</code></pre>
<p><code>try/catch</code> 없는 곳에서 <code>return await</code> 는 마이크로태스크 한 번 추가 + 의도 혼란만 남긴다. 이번 3건 모두 <code>try/catch</code> 밖이었고 <code>--fix</code> 로 안전하게 제거됐다.</p>
<h3 id="게이트는-치우고-나서-설치">게이트는 &quot;치우고 나서&quot; 설치</h3>
<p>다음 순서로 진행했다.</p>
<ol>
<li><strong>기존 위반을 먼저 정리</strong>. 자동 수정 가능했기 때문에 <code>eslint --fix</code> 한 방으로 해결!</li>
<li><strong>그 다음 CI 워크플로우 추가</strong>. 새 위반이 들어오는 순간부터 막도록!</li>
</ol>
<p>만약 순서를 뒤집었다면, CI 를 추가한 첫 날부터 3건의 과거 위반이 파이프라인을 막아서 아무도 머지를 못 한다. 규모가 3건이 아니라 수백 건이었다면 autofix 대량 적용, 정말 못 고칠 건 파일 단위 예외 처리 같은 순차적 작업이 필요해진다. 벌써 아찔하다.</p>
<h3 id="워크플로우는-병렬--좁은-트리거">워크플로우는 병렬 + 좁은 트리거</h3>
<p><img src="https://velog.velcdn.com/images/arin_dev/post/fa11e931-5db1-4474-9540-3c5341057748/image.png" alt=""></p>
<p><code>ci.yml</code> 은 typecheck 와 lint 를 <strong>두 개의 병렬 job</strong> 으로 분리했다. 한 job 안 스텝으로 묶지 않은 이유:</p>
<ul>
<li>병렬 실행으로 총 소요 시간 단축</li>
<li>한쪽이 실패해도 다른 쪽 결과를 끝까지 볼 수 있어 원인 파악 빠름</li>
</ul>
<p><code>on.paths</code> 로 <code>src/**</code>, <code>package.json</code>, <code>yarn.lock</code>, <code>tsconfig*.json</code>, <code>.github/workflows/ci.yml</code> 에만 반응하도록 제한했다. README 같은 문서만 바꾼 PR 에 CI 크레딧을 태우지 않는다.</p>
<p><code>concurrency.group: ${{ github.workflow }}-${{ github.ref }}</code> 로 같은 브랜치에 연속 커밋이 들어오면 이전 실행을 취소한다. <code>group</code> 을 <code>${{ github.workflow }}</code> 만으로 두면 A 브랜치 CI 가 돌고 있을 때 B 브랜치에 커밋해도 A 까지 취소되므로 조합이 중요하다.</p>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li>&quot;CI 가 있다 = 검증이 된다&quot; 는 착시. 실제 어떤 잡이 무엇을 검증하는지 워크플로우 내용까지 들여다봐야 한다. 파일 수만 봐서는 알 수 없다.</li>
<li>CRA 의 내부 ESLint 는 <code>react-app</code> 프리셋 고정. 프로젝트에 <code>@typescript-eslint/*</code> 같은 추가 규칙을 깔아도 빌드 타임에는 일부만 적용된다. 독립 lint 스텝이 필요하다.</li>
<li>게이트 도입 시 기존 위반 정리와 게이트 활성화를 한 PR 에 묶는 게 아니라 순서 있는 2개 커밋으로 분할하면, 나중에 revert 나 blame 이 훨씬 깔끔하다.</li>
</ul>
<h2 id="💡-적용-포인트">💡 적용 포인트</h2>
<ul>
<li>저장소 건강 체크할 때 <code>.github/workflows/</code> 파일 <strong>개수</strong> 만 보고 안심하지 말기. 실제로 타입체크·린트·테스트·e2e 중 무엇이 돌고 있는지 내용까지 확인하기.</li>
<li>CRA 프로젝트에서 CI 게이트 도입할 때는 로컬 <code>eslint</code> 를 먼저 돌려서 잠복 위반부터 발굴하기. autofix 로 해결되는 건 단일 커밋으로 빠르게 정리.</li>
<li>게이트 설정 자체는 <code>ci.yml</code> 몇 줄로 끝나지만, 도입 순서가 전부다. 기존 위반 청산 → 게이트 설치 → 이후 회귀 차단.</li>
<li>다음 프로젝트 맡을 때는 먼저 워크플로우에 뭐가 들어있고 어떻게 동작하는지부터 확인해보면 좋겠다.</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://blog.maximeheckel.com/posts/building-perfect-github-action-frontend-teams/">Maxime Heckel — Building the perfect GitHub CI workflow for your frontend team</a></li>
<li><a href="https://github.com/facebook/create-react-app/issues/10062">CRA Issue #10062 — CI=true 일 때 ESLint warning 이 build 실패로 승격</a></li>
<li><a href="https://typescript-eslint.io/rules/return-await/">typescript-eslint — return-await 규칙</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions, 워크플로우를 왜 2개로 쪼개야 할까]]></title>
            <link>https://velog.io/@arin_dev/github-actions-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0%EB%A5%BC-%EC%99%9C-2%EA%B0%9C%EB%A1%9C-%EC%AA%BC%EA%B0%9C%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@arin_dev/github-actions-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0%EB%A5%BC-%EC%99%9C-2%EA%B0%9C%EB%A1%9C-%EC%AA%BC%EA%B0%9C%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Mon, 20 Apr 2026 09:27:15 GMT</pubDate>
            <description><![CDATA[<h2 id="📝-tldr">📝 TL;DR</h2>
<p>PR이랑 push를 한 워크플로우에 우겨넣으면 커버리지가 이중으로 올라가거나 검증이 빠지는 구멍이 생긴다. 역할별로 두 개로 쪼개고 path filter까지 살짝 얹어주면 머리도 편하고 CI 실행 시간도 줄어든다.</p>
<h2 id="🔍-상세">🔍 상세</h2>
<h3 id="배경--동기">배경 / 동기</h3>
<p>팀 프론트 저장소에 CI를 새로 깔면서 기존 저장소들을 구경하는데, 어떤 곳은 워크플로우가 2개였고 어떤 곳은 1개였다. <code>opened</code>만 쓰는 곳, <code>synchronize</code>까지 쓰는 곳도 섞여 있었다. &quot;이게 다 무슨 차이지?&quot; 싶어서 한번 정리해봤다.</p>
<h3 id="push랑-pull_request-뭐가-다른데">push랑 pull_request, 뭐가 다른데?</h3>
<p>이름만 보면 비슷해 보이지만, 둘은 성격이 꽤 다르다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>push</th>
<th>pull_request</th>
</tr>
</thead>
<tbody><tr>
<td>트리거 시점</td>
<td>커밋이 브랜치에 push될 때</td>
<td>PR 생성/업데이트 시</td>
</tr>
<tr>
<td><code>GITHUB_SHA</code></td>
<td>push된 커밋 그 자체</td>
<td>PR의 <strong>머지 시뮬레이션 커밋</strong></td>
</tr>
<tr>
<td><code>GITHUB_REF</code></td>
<td><code>refs/heads/브랜치명</code></td>
<td><code>refs/pull/NUMBER/merge</code></td>
</tr>
<tr>
<td>테스트 대상</td>
<td>현재 브랜치 코드</td>
<td>base와 머지했을 때의 코드</td>
</tr>
</tbody></table>
<p><code>pull_request</code>는 좀 똑똑한 친구라서 &quot;이 PR 머지되면 어떻게 될까?&quot;를 미리 시뮬레이션한 커밋을 만들어놓고 돌린다. 그래서 PR 검증엔 이쪽이 훨씬 어울린다.</p>
<h3 id="두-워크플로우의-역할-분리">두 워크플로우의 역할 분리</h3>
<p><strong>1) <code>test_and_coverage.yml</code> (push 트리거)</strong></p>
<pre><code class="language-yaml">on:
  push:
    branches:
      - master
      - develop
      - &#39;feature/*/main&#39;
    paths:
      - &#39;src/**&#39;
      - &#39;package.json&#39;
      - &#39;yarn.lock&#39;
      - &#39;tsconfig*.json&#39;
      - &#39;vitest.config.*&#39;</code></pre>
<p>역할은 테스트 돌리고 커버리지를 Codecov에 업로드하는 것. 커버리지 숫자는 base 브랜치 기준으로 쭉 쌓여야 트렌드가 보이는데, PR마다 올려버리면 base 숫자가 흔들려서 영 의미가 없어진다.</p>
<p><strong>2) <code>pull_request_stats.yml</code> (pull_request 트리거)</strong></p>
<pre><code class="language-yaml">on:
  pull_request:
    types: [opened, synchronize]</code></pre>
<p>이쪽은 Lint, Test, Build를 병렬로 돌리는 PR 게이트키퍼. 개발자가 PR 페이지 열었을 때 초록불/빨간불 바로 보여주는 담당이다.</p>
<h3 id="opened만-아니면-synchronize까지"><code>opened</code>만? 아니면 <code>synchronize</code>까지?</h3>
<p>여기가 은근히 재밌는 설계 갈림길이다.</p>
<table>
<thead>
<tr>
<th>패턴</th>
<th><code>pull_request_stats</code> 트리거</th>
<th>조건</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td><code>[opened]</code>만</td>
<td><code>test_and_coverage</code>가 <strong>모든 push</strong>를 커버할 때</td>
</tr>
<tr>
<td>B</td>
<td><code>[opened, synchronize]</code></td>
<td><code>test_and_coverage</code>가 <strong>특정 브랜치만</strong> 커버할 때</td>
</tr>
</tbody></table>
<p>패턴 A에서는 feature 브랜치에 push해도 <code>test_and_coverage</code>가 알아서 잡아주니까 <code>synchronize</code>가 없어도 괜찮다. 반대로 패턴 B에서는 <code>master</code>/<code>develop</code>/<code>feature/*/main</code> 같은 주요 브랜치만 잡기 때문에, 일반 feature 브랜치로 push한 커밋은 아무도 검증하지 않는 빈틈이 생긴다. 이걸 <code>synchronize</code> 없이 놔두면 PR 처음 열 때만 검사하고 그다음 커밋부턴 무사통과되는 사태가 벌어진다.</p>
<p>즉, <strong><code>test_and_coverage</code>가 커버하는 브랜치 범위가 <code>pull_request_stats</code>의 트리거를 결정한다</strong>. 이 한 쌍이 핵심 축.</p>
<h3 id="path-filter로-불필요한-실행-줄이기">path filter로 불필요한 실행 줄이기</h3>
<p>GitHub 공식 문서에서도 밀어주는 기능인데, 꽤 쏠쏠하다. README 한 줄 고치는데 테스트 30분 돌릴 이유가 없잖아?</p>
<pre><code class="language-yaml">paths:
  - &#39;src/**&#39;              # 소스 코드
  - &#39;package.json&#39;        # 의존성
  - &#39;yarn.lock&#39;           # 의존성 락파일
  - &#39;tsconfig*.json&#39;      # TS 설정
  - &#39;vitest.config.*&#39;     # 테스트 설정</code></pre>
<p>단, 함정이 하나 있다. branches랑 paths를 같이 쓰면 <strong>둘 다 만족할 때만</strong> 실행된다. 이 규칙 모르면 &quot;아니 왜 내 워크플로우가 안 돌지?&quot; 하고 한참 헤매게 된다. (경험담 아님.)</p>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li><strong><code>|| true</code>로 lint 실패 삼키지 말기</strong>: <code>biome ci src || true</code> 이렇게 쓰면 lint가 뭐라 해도 job은 초록불이다. 경고만 띄우고 싶으면 <code>continue-on-error: true</code> 쪽이 의도가 훨씬 선명하다.</li>
<li><strong>Yarn 4(Corepack) 환경 체크</strong>: 옛날 워크플로우에서 자주 보이는 <code>npm install --global yarn</code>은 Yarn 4에선 안 먹힌다. <code>corepack enable</code> + <code>yarn install --immutable</code> 조합으로 갈아줘야 한다.</li>
</ul>
<h2 id="💡-적용-포인트">💡 적용 포인트</h2>
<ul>
<li>새 저장소에 CI 깔 때, 기존 워크플로우를 &quot;왜 이렇게 나눠놨지?&quot; 먼저 읽어보고 들고 오기. 복붙은 쉽지만 트리거 전략은 복붙거리가 아니다.</li>
<li><code>test_and_coverage</code>의 브랜치 범위랑 <code>pull_request_stats</code>의 <code>synchronize</code> 포함 여부, 이 한 쌍이 전체 설계의 핵심 축이다.</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request">GitHub Docs: Events that trigger workflows</a></li>
<li><a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow">GitHub Docs: Triggering a workflow</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[CLAUDE.md 잘못 쓰고 있을 수도 있다]]></title>
            <link>https://velog.io/@arin_dev/claudemd-%EC%9E%98%EB%AA%BB-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4</link>
            <guid>https://velog.io/@arin_dev/claudemd-%EC%9E%98%EB%AA%BB-%EC%93%B0%EA%B3%A0-%EC%9E%88%EC%9D%84-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4</guid>
            <pubDate>Mon, 20 Apr 2026 04:50:41 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>CLAUDE.md는 명령어가 아니라 계약서다.</p>
</blockquote>
<h2 id="📝-tldr">📝 TL;DR</h2>
<p>CLAUDE.md에 규칙만 적으면 AI가 엇나간다. &quot;왜&quot;를 같이 적어야 AI가 명시 안 된 상황에서도 의도대로 움직인다.</p>
<h2 id="🔍-상세">🔍 상세</h2>
<h3 id="이런-경험-있지-않나">이런 경험 있지 않나</h3>
<p>CLAUDE.md에 분명히 적어뒀다. &quot;커밋은 명시적으로 요청할 때만.&quot;</p>
<p>근데 AI가 알아서 커밋을 날린다.</p>
<p>반대로 규칙을 너무 많이 써놨더니, AI가 지키는 건지 마는 건지 모르겠는 상황도 생긴다. 사실 두 경우 모두 같은 이유다. CLAUDE.md를 명령 목록처럼 쓰고 있기 때문이다.</p>
<p>AI는 규칙을 기계적으로 실행하지 않는다. 맥락으로 읽고 판단한다. 그래서 <strong>&quot;왜 이 규칙이 있는가&quot;</strong>를 함께 적어야 명시 안 된 상황에서도 의도대로 행동한다.</p>
<h3 id="계층-구조--그리고-숨어있는-claudemd">계층 구조 — 그리고 숨어있는 CLAUDE.md</h3>
<p>Claude Code는 CLAUDE.md를 세 곳에서 읽는다.</p>
<pre><code>~/.claude/CLAUDE.md          ← 글로벌
{project}/CLAUDE.md          ← 프로젝트 루트
{subdir}/CLAUDE.md           ← 서브디렉토리</code></pre><p>세 파일 모두 동시에 로드된다. 더 하위 경로 규칙이 우선하지만, 같은 주제가 두 곳에 있으면 AI가 어느 쪽을 따를지 모호해진다.</p>
<p>실제로 이런 일이 생긴다. CLAUDE.md가 점점 뚱뚱해져서 정리하다 보면, 루트에 이미 있던 또 다른 CLAUDE.md를 발견하는 경우다. 모르고 중복 작성된 규칙이 둘 다 로드되고, 충돌하면 AI가 자의적으로 해석한다.</p>
<p>예방법은 단순하다. 각 CLAUDE.md 상단에 &quot;이 파일이 담당하는 범위&quot;를 한 줄 적어둔다.</p>
<pre><code class="language-markdown">&lt;!-- ~/.claude/CLAUDE.md --&gt;
# 글로벌 설정
모든 프로젝트에 공통 적용되는 개인 작업 스타일만 담는다.
프로젝트별 규칙은 각 레포의 CLAUDE.md에.</code></pre>
<h3 id="왜가-없으면-edge-case에서-무너진다">&quot;왜&quot;가 없으면 edge case에서 무너진다</h3>
<p>나쁜 규칙과 좋은 규칙, 차이는 딱 하나다.</p>
<pre><code class="language-markdown"># ❌ 무엇만 있음
커밋은 명시적으로 요청할 때만 생성할 것

# ✅ 왜가 있음
커밋은 명시적으로 요청할 때만 생성할 것.
→ &quot;수정&quot;, &quot;고쳐줘&quot;, &quot;변경&quot;은 커밋 요청이 아니다.
→ 사용자가 변경 내용을 검토한 뒤 커밋 여부를 결정하는 게 원칙.</code></pre>
<p>이유가 있으면 AI가 &quot;수정해줘&quot;와 &quot;커밋해줘&quot;를 정확히 구분한다. 없으면 비슷한 상황에서 자의적으로 해석한다.</p>
<p>보안 규칙도 마찬가지다.</p>
<pre><code class="language-markdown"># ❌
토큰을 URL 파라미터로 노출하지 말 것

# ✅
토큰을 URL 파라미터로 노출하지 말 것.
→ 브라우저 히스토리, 서버 로그, Referer 헤더에 노출된다.
→ 대안: Authorization 헤더 또는 HttpOnly 쿠키.</code></pre>
<p>이유 없이 금지만 하면 AI가 다른 불안전한 방법을 선택할 수 있다. 이유가 있으면 AI도 같은 이유로 다른 대안을 거른다.</p>
<h3 id="claudemd가-뚱뚱해졌다면">CLAUDE.md가 뚱뚱해졌다면</h3>
<p>길어진 CLAUDE.md를 정리하다 보면 자연스럽게 일부를 스킬로, 일부를 memory 파일로 옮기게 된다. 이때 충돌이 생길 수 있다.</p>
<table>
<thead>
<tr>
<th>저장소</th>
<th>언제 로드되나</th>
<th>충돌 가능성</th>
</tr>
</thead>
<tbody><tr>
<td>CLAUDE.md</td>
<td>세션 시작 시 항상</td>
<td>계층 간 중복 시</td>
</tr>
<tr>
<td>memory 파일</td>
<td>대화 맥락으로 함께</td>
<td>CLAUDE.md와 같은 주제 겹칠 때</td>
</tr>
<tr>
<td>스킬</td>
<td>명시적으로 호출할 때만</td>
<td>없음</td>
</tr>
</tbody></table>
<p>CLAUDE.md와 memory에 같은 주제가 있으면 AI가 어느 쪽을 따를지 모호해진다. 기술적 충돌이라기보다 <strong>우선순위가 불명확해지는 문제</strong>다.</p>
<p>분리 기준은 하나의 질문으로 정리된다.</p>
<blockquote>
<p>&quot;6개월 뒤에도 팀 규칙으로 유효한가?&quot; → CLAUDE.md
&quot;대화를 거치며 쌓인 개인 선호인가?&quot; → memory
&quot;특정 작업에서만 쓰는 절차인가?&quot; → 스킬</p>
</blockquote>
<p>&quot;CSS 계산 시 padding을 빼야 한다&quot;는 팀 규칙이니 CLAUDE.md. &quot;이 사람은 em dash를 싫어한다&quot;는 대화에서 파악한 것이니 memory. &quot;PR 생성 절차&quot;는 스킬.</p>
<h2 id="💡-적용-포인트">💡 적용 포인트</h2>
<ul>
<li>기존 CLAUDE.md에서 &quot;왜&quot;가 빠진 규칙 찾아서 이유 한 줄씩 추가</li>
<li>글로벌 / 프로젝트 CLAUDE.md 상단에 담당 범위 명시 (중복 규칙 예방)</li>
<li>뚱뚱해졌을 때: &quot;6개월 유효? / 대화에서 쌓인 것? / 특정 절차?&quot; 기준으로 분리</li>
<li>memory와 CLAUDE.md에 같은 주제 겹치지 않게 주기적으로 정리</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://velog.io/@surim014/claude-md-guide">[번역] CLAUDE.md 완벽 가이드</a> — Sonny</li>
<li><a href="https://velog.io/@hoyoungy2/Memory-CLAUDE.md">Memory (CLAUDE.md)</a> — Eleven</li>
<li><a href="https://velog.io/@justn-hyeok/off-claude-code-adaptive-thinking">Claude Code가 요즘 이상하다면?</a> — Justn</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[아직도 JSON.parse(JSON.stringify())로 깊은 복사하고 있다면]]></title>
            <link>https://velog.io/@arin_dev/%EC%95%84%EC%A7%81%EB%8F%84-jsonparsejsonstringify%EB%A1%9C-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%ED%95%98%EA%B3%A0-%EC%9E%88%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@arin_dev/%EC%95%84%EC%A7%81%EB%8F%84-jsonparsejsonstringify%EB%A1%9C-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC%ED%95%98%EA%B3%A0-%EC%9E%88%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Fri, 17 Apr 2026 01:15:24 GMT</pubDate>
            <description><![CDATA[<h2 id="📝-tldr">📝 TL;DR</h2>
<p><code>structuredClone()</code>은 네이티브 깊은 복사 API다. Date, Map, Set, 순환 참조까지 처리하고, 라이브러리도 필요 없다.</p>
<h2 id="jsonparsejsonstringify의-함정">JSON.parse(JSON.stringify())의 함정</h2>
<p>흔히 겪는 시나리오가 있다. API에서 받아온 객체를 깊은 복사해서 폼 초기값으로 세팅했다. 나중에 &quot;변경사항 비교&quot;를 하려고 원본과 현재 값을 비교하는데, 원본의 <code>createdAt</code>이 Date가 아니라 문자열이 되어있다. <code>.getMonth()</code>를 호출하면 TypeError. 원인은 이 한 줄이다.</p>
<pre><code class="language-js">const copy = JSON.parse(JSON.stringify(original));</code></pre>
<p>직관적이라 관성적으로 쓰게 되지만, 이 방식은 JSON으로 변환할 수 없는 값을 전부 날려버린다.</p>
<pre><code class="language-js">const original = {
  date: new Date(&#39;2026-04-17&#39;),
  tags: new Set([&#39;TIL&#39;, &#39;JS&#39;]),
  metadata: new Map([[&#39;key&#39;, &#39;value&#39;]]),
  score: Infinity,
  optional: undefined,
};

const copy = JSON.parse(JSON.stringify(original));
console.log(copy);
// {
//   date: &quot;2026-04-17T00:00:00.000Z&quot;,  ← 문자열로 변환됨
//   tags: {},                            ← Set이 빈 객체로
//   metadata: {},                        ← Map이 빈 객체로
//   score: null,                         ← Infinity가 null로
//                                        ← undefined는 아예 사라짐
// }</code></pre>
<p>Date가 문자열이 되고, Set/Map은 빈 객체가 되고, undefined는 증발한다. 여기에 순환 참조가 있으면 TypeError로 아예 터진다.</p>
<h2 id="structuredclone은-뭐가-다른가">structuredClone()은 뭐가 다른가</h2>
<p>2022년부터 모든 주요 브라우저와 Node.js 17+에서 사용 가능한 네이티브 API다.</p>
<pre><code class="language-js">const copy = structuredClone(original);</code></pre>
<h3 id="날짜가-살아있다">날짜가 살아있다</h3>
<pre><code class="language-js">const obj = { created: new Date(&#39;2026-04-17&#39;) };
const copy = structuredClone(obj);

console.log(copy.created instanceof Date); // true
console.log(copy.created.getFullYear());   // 2026</code></pre>
<p><code>JSON.stringify</code>를 거치면 문자열이 되어 <code>.getFullYear()</code> 같은 메서드를 호출할 수 없지만, <code>structuredClone</code>은 Date 객체를 그대로 유지한다.</p>
<h3 id="map-set-regexp도-복사된다">Map, Set, RegExp도 복사된다</h3>
<pre><code class="language-js">const obj = {
  tags: new Set([&#39;TIL&#39;, &#39;JS&#39;]),
  config: new Map([[&#39;theme&#39;, &#39;dark&#39;]]),
  pattern: /^hello/gi,
};

const copy = structuredClone(obj);
console.log(copy.tags.has(&#39;TIL&#39;));          // true
console.log(copy.config.get(&#39;theme&#39;));      // &#39;dark&#39;
console.log(copy.pattern.test(&#39;hello!&#39;));   // true</code></pre>
<h3 id="순환-참조도-처리한다">순환 참조도 처리한다</h3>
<pre><code class="language-js">const obj = { name: &#39;root&#39; };
obj.self = obj; // 순환 참조

// JSON.parse(JSON.stringify(obj)); ← TypeError: Converting circular structure to JSON
const copy = structuredClone(obj);  // 정상 동작
console.log(copy.self === copy);    // true (순환 구조 유지)</code></pre>
<h2 id="그래도-안-되는-것들">그래도 안 되는 것들</h2>
<p>만능은 아니다. <code>structuredClone</code>이 복사할 수 없는 타입이 있다.</p>
<table>
<thead>
<tr>
<th>타입</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>함수</td>
<td>DataCloneError 발생</td>
</tr>
<tr>
<td>DOM 노드</td>
<td>DataCloneError 발생</td>
</tr>
<tr>
<td>Symbol</td>
<td>DataCloneError 발생</td>
</tr>
<tr>
<td>클래스 인스턴스</td>
<td>plain object로 변환 (프로토타입 체인 소실)</td>
</tr>
</tbody></table>
<pre><code class="language-js">class User {
  constructor(name) { this.name = name; }
  greet() { return `Hi, ${this.name}`; }
}

const user = new User(&#39;arin&#39;);
const copy = structuredClone(user);

console.log(copy.name);            // &#39;arin&#39; (데이터는 복사됨)
console.log(copy instanceof User); // false (프로토타입 소실)
console.log(copy.greet);           // undefined (메서드 없음)</code></pre>
<p>함수가 포함된 객체를 복사해야 한다면 <code>structuredClone</code>은 답이 아니다. 그 경우엔 수동으로 복사 로직을 작성하거나 lodash의 <code>_.cloneDeep</code>을 쓰는 게 맞다.</p>
<h2 id="💡-어디에-쓸까">💡 어디에 쓸까</h2>
<ul>
<li><strong>React 상태 업데이트</strong>: 중첩 객체를 불변으로 갱신할 때 spread 연산자 3중첩 대신 <code>structuredClone</code> 후 수정</li>
<li><strong>API 응답 캐싱</strong>: 응답 객체를 저장하기 전 원본과 분리할 때</li>
<li><strong>폼 데이터 스냅샷</strong>: &quot;변경사항 있음&quot; 비교를 위한 초기 상태 저장</li>
<li><strong>lodash 의존성 제거</strong>: <code>_.cloneDeep</code> 하나 때문에 lodash를 쓰고 있었다면, 네이티브로 대체 가능</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone">MDN: structuredClone()</a> (MDN Web Docs, 공식 레퍼런스)</li>
<li><a href="https://html.spec.whatwg.org/multipage/structured-data.html#structured-cloning">HTML Spec: Structured clone algorithm</a> (WHATWG, 스펙 원문)</li>
<li><a href="https://web.dev/articles/structured-clone">Deep-copying in JavaScript using structuredClone</a> (web.dev, Google)</li>
<li><a href="https://velog.io/@johnjskim21/JavaScript-%EA%B9%8A%EC%9D%80-%EB%B3%B5%EC%82%AC-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95">[JavaScript] 깊은 복사 하는 방법</a> (johnjskim21, velog, 한국어)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[당신의 타입은 '불가능한 상태'를 몇 개나 허용하고 있나요?]]></title>
            <link>https://velog.io/@arin_dev/%EB%8B%B9%EC%8B%A0%EC%9D%98-%ED%83%80%EC%9E%85%EC%9D%80-%EB%B6%88%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%83%81%ED%83%9C%EB%A5%BC-%EB%AA%87-%EA%B0%9C%EB%82%98-%ED%97%88%EC%9A%A9%ED%95%98%EA%B3%A0-%EC%9E%88%EB%82%98%EC%9A%94</link>
            <guid>https://velog.io/@arin_dev/%EB%8B%B9%EC%8B%A0%EC%9D%98-%ED%83%80%EC%9E%85%EC%9D%80-%EB%B6%88%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%83%81%ED%83%9C%EB%A5%BC-%EB%AA%87-%EA%B0%9C%EB%82%98-%ED%97%88%EC%9A%A9%ED%95%98%EA%B3%A0-%EC%9E%88%EB%82%98%EC%9A%94</guid>
            <pubDate>Thu, 16 Apr 2026 00:57:08 GMT</pubDate>
            <description><![CDATA[<h2 id="📝-tldr">📝 TL;DR</h2>
<p>타입을 &quot;값이 몇 개 들어가는 상자&quot;로 보자. 그러면 좋은 설계인지 숫자로 판단할 수 있다. 합타입(Discriminated Union)을 먼저 떠올리면 &quot;불가능한 상태&quot;를 아예 못만든다.</p>
<h2 id="뭔가-이상한데-설명이-안-되는-타입에-대하여">&#39;뭔가 이상한데 설명이 안 되는&#39; 타입에 대하여</h2>
<p>&quot;이 Response 타입 뭔가 이상한데…&quot; 싶은데 왜 이상한지 말로는 설명이 안 되던 순간이 있다. <code>success: true</code>인데 <code>error</code>가 들어있을 수도 있고, <code>payload</code>가 없을 수도 있고. 코드 어디선가 분명 그런 조합을 막고 있지만, <strong>타입만 놓고 보면 전부 허용하고 있다</strong>.</p>
<p><a href="https://hackers.pub/@jasonkim/2026/arithmetic-with-types">원문 글</a>을 읽고 알았다. 내가 감으로 &quot;이상하다&quot;고 느꼈던 건, 타입이 <strong>표현할 수 있는 상태 수</strong>가 <strong>실제로 유효한 상태 수</strong>보다 훨씬 많아서였다. 그리고 이건 감이 아니라 덧셈과 곱셈으로 계산 가능하다. 초등학교 산수로.</p>
<h2 id="타입을-숫자로-세어본다">타입을 숫자로 세어본다</h2>
<p>타입을 &quot;가능한 값의 집합&quot;으로 보면 복잡도를 숫자로 셀 수 있다.</p>
<ul>
<li><code>boolean</code>: 값 2개</li>
<li><code>1 | 2 | 3 | 4 | 5 | 6</code> (주사위): 값 6개</li>
<li><code>string</code>: (이론상) 무한대</li>
</ul>
<p>이렇게 세어둔 &quot;값의 개수&quot;를 서로 합치는 두 연산이 <strong>곱타입</strong>과 <strong>합타입</strong>이다.</p>
<h3 id="곱타입×-그리고">곱타입(×): &quot;그리고&quot;</h3>
<p>여러 필드를 모두 가져야 하는 타입은 각 필드의 복잡도를 <strong>곱한다</strong>.</p>
<pre><code class="language-typescript">type HospitalMate = {
  dog: Dogs; // 4종
  cat: Cats; // 3종
};
// 4 × 3 = 12가지 조합</code></pre>
<h3 id="합타입-또는">합타입(+): &quot;또는&quot;</h3>
<p>여러 모양 중 하나만 택하는 타입은 각 경우의 수를 <strong>더한다</strong>.</p>
<pre><code class="language-typescript">type LotteryWinner =
  | { species: &#39;dog&#39;; name: Dogs } // 4
  | { species: &#39;cat&#39;; name: Cats }; // 3
// 4 + 3 = 7가지</code></pre>
<p>여기서 <code>species</code> 같은 <strong>구분 태그</strong>가 없으면 TypeScript가 <code>Dogs | Cats</code>를 좁힐 수 없다. 태그를 박아서 쪼개는 패턴이 <strong>Discriminated Union</strong>이다.</p>
<h3 id="optional은-왜-복잡도를-1-하는가">Optional은 왜 복잡도를 +1 하는가</h3>
<p><code>payload?: T</code>는 사실 <code>payload: T | undefined</code>와 같다. 즉 T가 가질 수 있는 값의 수에 <code>undefined</code>라는 가능성이 하나 더 붙는다. 복잡도 <strong>+1</strong>.</p>
<p>별 것 아닌 것 같지만, optional 필드가 늘어날 때마다 곱셈에 들어가는 항이 하나씩 늘어나므로 전체 복잡도가 빠르게 불어난다. 여기서부터 &quot;불가능한 상태&quot; 구멍이 생기기 시작한다.</p>
<h2 id="response-타입-뭐가-문제였나-before--after">Response 타입, 뭐가 문제였나: Before &amp; After</h2>
<p><strong>Before (곱타입 남용)</strong></p>
<pre><code class="language-typescript">type Response&lt;T&gt; = {
  success: boolean;
  payload?: T;
  error?: Error;
};</code></pre>
<p>복잡도는 <code>2 × (T+1) × (Error+1)</code>. 문제는 숫자가 큰 게 아니라, <code>success: true</code>인데 <code>error</code>가 있거나 <code>payload</code>가 없는 <strong>말도 안 되는 조합</strong>까지 타입이 허용한다는 점이다.</p>
<p><strong>After (합타입)</strong></p>
<pre><code class="language-typescript">type Response&lt;T&gt; =
  | { status: &#39;loading&#39; }
  | { status: &#39;success&#39;; payload: T }
  | { status: &#39;error&#39;; error: Error };</code></pre>
<p>복잡도는 <code>1 + T + Error</code>. 숫자가 작아진 것보다 <strong>불가능한 상태가 아예 표현 불가능</strong>해졌다는 게 핵심이다.</p>
<h2 id="⚠️-불가능한-상태를-없애는-신호들">⚠️ 불가능한 상태를 없애는 신호들</h2>
<ul>
<li><strong>Optional 필드 2개 이상이 모이면 위험 신호.</strong> 서로 의존 관계가 있을 가능성이 높고, 곱타입 복잡도가 빠르게 튄다.</li>
<li><strong>boolean 플래그 여러 개로 상태를 흉내내지 말 것.</strong> <code>isLoading</code>, <code>isError</code>, <code>isSuccess</code> 3개 두면 2³ = 8가지 조합 중 실제로 유효한 건 3개뿐. <code>status: &#39;loading&#39; | &#39;error&#39; | &#39;success&#39;</code> 하나로 줄이면 정확히 3개로 맞다.</li>
<li><strong>태그 없는 union은 구분 불가.</strong> <code>Dogs | Cats</code>에 &#39;쿠키&#39;가 양쪽 다 있으면 TypeScript는 어느 쪽인지 모른다. 반드시 <code>species</code>, <code>status</code> 같은 리터럴 태그를 박는다.</li>
</ul>
<h2 id="💡-어디에-쓸까">💡 어디에 쓸까</h2>
<ul>
<li><strong>API 응답 타입</strong>: 성공/로딩/실패를 boolean + optional로 섞지 않고 <code>status</code> 태그 + Discriminated Union으로.</li>
<li><strong>폼/UI 상태 머신</strong>: &quot;버튼 눌렸는데 로딩 중도 아니고 에러도 아닌&quot; 불가능한 상태를 타입 단계에서 제거.</li>
<li><strong>설계 리뷰 체크리스트</strong>: &quot;이 타입이 표현 가능한 값의 수 = 실제로 유효한 상태의 수인가?&quot;를 습관적으로 물어본다.</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://hackers.pub/@jasonkim/2026/arithmetic-with-types">Arithmetic with Types</a> (jasonkim, hackers.pub, 원문)</li>
<li><a href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions">TypeScript Handbook: Narrowing (Discriminated unions)</a> (TypeScript 공식 문서)</li>
<li><a href="https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/">Designing with types: Making illegal states unrepresentable</a> (Scott Wlaschin, F# for Fun and Profit)</li>
<li><a href="https://en.wikipedia.org/wiki/Algebraic_data_type">Algebraic data type</a> (Wikipedia, sum/product type 개념 정리)</li>
<li><a href="https://velog.io/@nyoung113/%EA%B0%80%EB%8A%A5%EC%84%B1-%EC%A4%84%EC%9D%B4%EA%B8%B0-%EB%8C%80%EC%88%98%EC%A0%81-%ED%83%80%EC%9E%85">가능성 줄이기 (대수적 타입)</a> (nyoung113, velog, 한국어 해설)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[아직도 100vh 쓰세요?]]></title>
            <link>https://velog.io/@arin_dev/%EC%95%84%EC%A7%81%EB%8F%84-100vh-%EC%93%B0%EC%84%B8%EC%9A%94</link>
            <guid>https://velog.io/@arin_dev/%EC%95%84%EC%A7%81%EB%8F%84-100vh-%EC%93%B0%EC%84%B8%EC%9A%94</guid>
            <pubDate>Wed, 15 Apr 2026 08:11:45 GMT</pubDate>
            <description><![CDATA[<h2 id="모바일-웹뷰의-100vh-이슈와-dvh--svh--lvh-대응">모바일 웹뷰의 100vh 이슈와 dvh / svh / lvh 대응</h2>
<h2 id="📝-tldr">📝 TL;DR</h2>
<p>모바일에서 <code>100vh</code>는 주소창 UI에 따라 들쭉날쭉해서 풀스크린을 보장하지 못한다. 이젠 <code>dvh</code> / <code>svh</code> / <code>lvh</code>를 써라. 기존 <code>--vh</code> JS 해킹은 CSS 한 줄로 치환 가능.</p>
<h2 id="🔍-상세">🔍 상세</h2>
<h3 id="배경--동기">배경 / 동기</h3>
<p>모바일 웹뷰에서 <code>height: 100vh</code>를 주면 스크롤할 때 레이아웃이 튄다. 기대치는 &quot;화면 꽉 차게&quot;인데, iOS Safari와 일부 안드로이드 Chrome은 주소창이 나타났다 사라지는 동안 뷰포트 높이가 바뀐다. <code>100vh</code>는 <strong>주소창이 숨겨진 상태(=최대 높이) 기준</strong>으로 고정돼있어서, 주소창이 나와 있는 동안은 실제 보이는 영역보다 <code>100vh</code>가 더 크다. 결과적으로 하단 요소가 주소창에 가려지거나, 스크롤 시 화면이 덜컥거린다.</p>
<h3 id="내용">내용</h3>
<p>CSS Values and Units Level 4에서 뷰포트 단위가 네 계열로 세분화됐다.</p>
<table>
<thead>
<tr>
<th>단위</th>
<th>의미</th>
<th>언제 씀</th>
</tr>
</thead>
<tbody><tr>
<td><code>vh</code></td>
<td>기존 단위 (브라우저별 해석 차이 있음)</td>
<td>레거시 호환</td>
</tr>
<tr>
<td><code>lvh</code></td>
<td><strong>L</strong>argest — 주소창 숨겨졌을 때 기준</td>
<td>&quot;제일 넓을 때&quot; 기준이 필요할 때</td>
</tr>
<tr>
<td><code>svh</code></td>
<td><strong>S</strong>mallest — 주소창 보이는 상태 기준</td>
<td>&quot;절대 잘리지 않아야&quot; 할 때</td>
</tr>
<tr>
<td><code>dvh</code></td>
<td><strong>D</strong>ynamic — 현재 보이는 영역 실시간 반영</td>
<td>대부분의 실무 케이스</td>
</tr>
</tbody></table>
<pre><code class="language-css">/* 풀 스크린 섹션 — 주소창에 가려지지 않게 */
.hero {
  min-height: 100svh;   /* 주소창 나와도 안 잘림 */
}

/* 스크롤에 따라 자연스럽게 맞추고 싶을 때 */
.fullscreen-modal {
  height: 100dvh;       /* 스크롤에 맞춰 갱신됨 */
}

/* fallback 포함 권장 */
.safe {
  min-height: 100vh;    /* 구형 브라우저용 */
  min-height: 100dvh;   /* 지원되면 덮어씀 */
}</code></pre>
<p>너비 쪽도 <code>dvw</code> / <code>svw</code> / <code>lvw</code>가 짝으로 존재한다. 대부분은 높이가 문제라 덜 쓰는 편.</p>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li><strong><code>dvh</code>는 자주 갱신된다</strong> — 스크롤 중 실시간으로 레이아웃이 다시 계산되므로 <code>height: 100dvh</code>를 무거운 컨테이너에 걸면 성능 저하가 느껴질 수 있다. Safari에서 특히 덜컥임 보고가 있다.</li>
<li><strong><code>position: fixed</code> 요소 주의</strong> — <code>fixed; bottom: 0</code> 요소는 <code>dvh</code>를 쓰지 않으면 주소창 변화에 따라 잘린다.</li>
<li><strong>iOS WKWebView 버전별 차이</strong> — iOS 15.4+부터 지원. 인앱 웹뷰라면 <strong>OS 버전이 아니라 앱이 포함한 WebView 버전</strong>을 기준으로 판단해야 한다.</li>
<li><strong><code>@supports</code>로 fallback 감싸기 권장</strong> — 구형 브라우저에서 선언이 무시되면서 높이가 0이 되는 사고 방지.</li>
</ul>
<h2 id="💡-적용-포인트">💡 적용 포인트</h2>
<ul>
<li>기존 <code>--vh</code> 커스텀 프로퍼티 + <code>window.innerHeight</code> 리스너 조합을 <strong>CSS 한 줄로 치환</strong> → JS 번들 감소, resize 이벤트 리스너 제거</li>
<li><code>100vh</code> 쓰는 곳을 찾아 <code>100svh</code> / <code>100dvh</code>로 점진 교체하면 <strong>하단 CTA 가림 이슈</strong> 대부분 해소</li>
<li>iOS 15.4 미만을 지원해야 하는 서비스라면 <code>100vh</code> fallback을 반드시 남긴 뒤 중복 선언 또는 <code>@supports (height: 100dvh)</code>로 새 단위 적용 ⚠️ 실행 전 대상 브라우저 매트릭스 확인 필요</li>
</ul>
<h2 id="📚-참고">📚 참고</h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/length#viewport-percentage_lengths">MDN — CSS <code>&lt;length&gt;</code> (Viewport-percentage lengths)</a></li>
<li><a href="https://web.dev/blog/viewport-units">web.dev — The large, small, and dynamic viewport units</a></li>
<li><a href="https://caniuse.com/viewport-unit-variants">Can I use — Viewport unit variants (<code>sv*</code>, <code>lv*</code>, <code>dv*</code>)</a></li>
<li><a href="https://www.w3.org/TR/css-values-4/#viewport-relative-lengths">W3C — CSS Values and Units Module Level 4 §6.1.2 Viewport-relative Lengths</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>