<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>기술 블로그</title>
    <link>https://kjk5.tistory.com/</link>
    <description>개발 블로그</description>
    <language>ko</language>
    <pubDate>Sun, 23 Aug 2026 21:14:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jaegwan</managingEditor>
    <image>
      <title>기술 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/2950083/attach/eb26322b11af4802b838ee4e0c7dafc6</url>
      <link>https://kjk5.tistory.com</link>
    </image>
    <item>
      <title>iOS에서만 느려지는 sticky 탭바 해결하기</title>
      <link>https://kjk5.tistory.com/155</link>
      <description>&lt;p&gt;반응형 UI를 만들다 보면 데스크톱에서는 멀쩡한데 실기기에서만 어긋나는 문제를 마주하곤 합니다. 최근 메인 페이지의 sticky 탭바가 &lt;strong&gt;실기기 아이폰에서만&lt;/strong&gt; 스크롤을 한 박자 늦게 따라오는 현상을 겪었습니다. 크롬 개발자도구의 모바일 에뮬레이터에서도 재현되지 않아 원인을 좁히기가 쉽지 않았고, 가설을 두 번 세워 두 번 다 기각당한 끝에 &lt;strong&gt;전혀 다른 파일의 공통 mixin 한 줄&lt;/strong&gt;에서 범인을 찾았습니다.&lt;/p&gt;
&lt;p&gt;이번 포스트에서는 그 과정을 브라우저 렌더링 엔진의 관점에서 정리합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 현상 및 재현 조건&lt;/h2&gt;
&lt;p&gt;메인 페이지의 &amp;#39;성공 레퍼런스&amp;#39; 섹션에는 모바일에서 상단에 고정되는 탭바가 있습니다. 사이트 헤더(높이 60px) 바로 아래에 붙고, 스크롤로 헤더가 숨으면 그 빈자리로 올라오는 구조입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-scss&quot;&gt;.tabBarSticky {
  width: 100%;

  @include small {
    position: sticky;
    top: 60px;
    z-index: 10;
    transition: top 300ms ease-in-out;
  }
}

.tabBarStickyUp {
  @include small {
    top: 0; // 헤더 숨김 시 헤더가 비운 자리로
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;문제:&lt;/strong&gt; 실기기 아이폰에서 탭바가 스크롤에 딱 붙지 않고 한 프레임 이상 끌려다니다가, 스크롤을 멈추면 툭 하고 제자리로 돌아옵니다. 특히 화면에서 손을 뗀 뒤 관성(momentum) 스크롤 구간에서 두드러졌습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;재현 조건:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;환경&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;데스크톱 크롬&lt;/td&gt;
&lt;td&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크롬 DevTools 모바일 에뮬레이터&lt;/td&gt;
&lt;td&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실기기 iOS Safari (≤480px)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;재현&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;에뮬레이터가 뷰포트 크기만 흉내 낼 뿐 WebKit의 합성·스크롤 처리까지 재현하지는 않기 때문에, 이 문제는 처음부터 실기기에서만 볼 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;초기 가설:&lt;/strong&gt; &lt;code&gt;transition: top&lt;/code&gt;이 컴포지터 속성이 아니라서 생기는 지연.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 가설 1 — &lt;code&gt;transition: top&lt;/code&gt;은 컴포지터 속성이 아니다&lt;/h2&gt;
&lt;p&gt;가장 먼저 눈에 띈 것은 트랜지션의 대상이었습니다. 헤더와 탭바가 서로 다른 방식으로 움직이고 있었습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;사이트 헤더&lt;/th&gt;
&lt;th&gt;탭바&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;이동 수단&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transform: translateY(-100%)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;top: 60px → 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리 주체&lt;/td&gt;
&lt;td&gt;GPU / 컴포지터&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;메인 스레드 (레이아웃)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;transform&lt;/code&gt;은 메인 스레드가 바빠도 컴포지터가 혼자 300ms를 그려냅니다. 반면 &lt;code&gt;top&lt;/code&gt;은 레이아웃 속성이라 매 프레임 재계산이 필요하고, 대상이 &lt;code&gt;position: sticky&lt;/code&gt;이므로 &lt;strong&gt;sticky 제약(constraint)까지 매 프레임 다시 계산&lt;/strong&gt;해야 합니다.&lt;/p&gt;
&lt;p&gt;게다가 WebKit은 sticky 요소의 컨테이너 사각형과 오프셋(&lt;code&gt;top&lt;/code&gt; 포함)을 스크롤링 트리에 한 번 커밋해두고, 이후에는 스크롤 스레드가 알아서 위치를 잡아주는 구조입니다. 그런데 &lt;code&gt;top&lt;/code&gt;이 애니메이션 중이면 그 커밋값이 매 프레임 바뀝니다. 즉 &lt;strong&gt;트랜지션이 도는 300ms 동안 sticky가 fast path에서 떨어진다&lt;/strong&gt;는 추론이었습니다.&lt;/p&gt;
&lt;p&gt;그럴듯했습니다. 그래서 &lt;code&gt;transition&lt;/code&gt;과 &lt;code&gt;.tabBarStickyUp&lt;/code&gt;의 &lt;code&gt;top: 0&lt;/code&gt;을 통째로 주석 처리하고 다시 테스트했습니다.&lt;/p&gt;
&lt;h3&gt;결과: 그대로 느림&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;가설 1 기각.&lt;/strong&gt; 하지만 이 실패가 중요한 정보를 줬습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;top&lt;/code&gt;이 아예 바뀌지 않는데도 느리다면, 문제는 &amp;quot;움직일 때&amp;quot;가 아니라 &lt;strong&gt;&amp;quot;그냥 스크롤할 때&amp;quot;&lt;/strong&gt; 입니다. 트랜지션이 sticky를 fast path에서 떨어뜨린 것이 아니라, &lt;strong&gt;sticky가 애초에 fast path에 올라간 적이 없었다&lt;/strong&gt;는 뜻입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;참고로 이 시점에 놓친 것이 하나 있습니다. CSS만 껐을 뿐 JS는 그대로였습니다. 스크롤 방향을 감지하는 &lt;code&gt;useHideOnScroll()&lt;/code&gt; 훅이 여전히 방향이 바뀔 때마다 &lt;code&gt;setState&lt;/code&gt;를 호출해 섹션 전체(차트 + motion 노드 4개)를 리렌더하고 있었습니다. 그 결과로 붙는 클래스가 아무 일도 하지 않을 뿐, &lt;strong&gt;비용은 그대로 내면서 효과만 없앤 상태&lt;/strong&gt;였습니다. 별도로 정리할 부채입니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;3. 가설 2 — 합성 레이어 폭증&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;position: sticky&lt;/code&gt;가 fast path에 올라가지 못하는 이유로 다음에 의심한 것은 &lt;strong&gt;레이어 예산 초과&lt;/strong&gt;였습니다.&lt;/p&gt;
&lt;p&gt;프로젝트에는 GSAP &lt;code&gt;SplitText&lt;/code&gt; 기반 헤딩 컴포넌트가 있습니다. 스크롤 진입 시 글자 단위로 등장하는 효과인데, 내부를 열어보니 다음과 같았습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// 트윈 쪽
willChange: &amp;quot;transform, opacity&amp;quot;,
force3D: true,               // → translate3d()

// 그리고 컴포넌트 루트 인라인 스타일
const style: React.CSSProperties = {
  willChange: &amp;quot;transform, opacity&amp;quot;,   // 영구. 애니메이션이 끝나도 지워지지 않음
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;force3D: true&lt;/code&gt;와 &lt;code&gt;will-change&lt;/code&gt;가 함께 걸리면 &lt;strong&gt;쪼개진 글자 하나하나가 각각 GPU 합성 레이어로 승격&lt;/strong&gt;됩니다. 그리고 애니메이션이 끝난 뒤에도 &lt;code&gt;will-change&lt;/code&gt;가 요소에 그대로 남습니다.&lt;/p&gt;
&lt;p&gt;홈 페이지는 섹션이 12개이고 전부 같은 헤딩 컴포넌트를 쓰며, 하나가 2&lt;del&gt;3줄을 렌더합니다. 한국어 제목이 15&lt;/del&gt;25자이므로 대략 이런 계산이 나옵니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;12 섹션 × 2&lt;del&gt;3줄 × 15&lt;/del&gt;25자 ≈ &lt;strong&gt;수백 개의 영구 합성 레이어&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;데스크톱 크롬은 이 정도를 그냥 삼킵니다. 하지만 iOS WebKit은 타일/레이어 메모리 예산이 훨씬 빡빡해서, 한도를 넘으면 레이어를 퇴출하고 메인 스레드 페인팅으로 되돌립니다. 그 과정에서 sticky의 fast path가 날아갈 수 있다고 봤습니다.&lt;/p&gt;
&lt;h3&gt;결과: 이것도 아니었습니다&lt;/h3&gt;
&lt;p&gt;루트의 &lt;code&gt;willChange&lt;/code&gt; 한 줄을 주석 처리하고 테스트했지만 변화가 없었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;가설 2도 기각.&lt;/strong&gt; 다만 엄밀히 말하면 이 테스트는 불완전했습니다. 지운 것은 부모 span의 &lt;code&gt;will-change&lt;/code&gt;뿐이고, 글자 하나하나를 승격시키는 트윈 쪽 &lt;code&gt;willChange&lt;/code&gt; + &lt;code&gt;force3D&lt;/code&gt;는 그대로 남아 있었기 때문입니다. 이후 진범이 잡히면서 이 줄기는 접었지만, &lt;strong&gt;레이어 폭증 자체는 여전히 정리해야 할 성능 부채로 남아 있습니다.&lt;/strong&gt; 원인은 아니었어도 좋은 상태는 아니니까요.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 전환점 — &amp;quot;어디서&amp;quot;가 아니라 &amp;quot;언제만&amp;quot;&lt;/h2&gt;
&lt;p&gt;두 가설이 연달아 깨지고 나서 질문을 바꿔봤습니다.&lt;/p&gt;
&lt;p&gt;지금까지는 &amp;quot;&lt;strong&gt;왜 아이폰에서만&lt;/strong&gt; 느린가&amp;quot;를 물었고, 그래서 계속 iOS/WebKit 쪽만 팠습니다. 그런데 다시 보니 조건이 하나 더 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이 sticky는 &lt;code&gt;@include small&lt;/code&gt;(≤480px)에서만 켜집니다.&lt;/strong&gt; 즉 증상은 &amp;quot;아이폰에서만&amp;quot;이 아니라 정확히는 &lt;strong&gt;&amp;quot;small 브레이크포인트에서만&amp;quot;&lt;/strong&gt; 이었습니다.&lt;/p&gt;
&lt;p&gt;그렇다면 &lt;code&gt;@include small&lt;/code&gt; 안에만 존재하는 CSS를 전부 다시 읽어야 했습니다. 그리고 sticky의 조상 체인을 따라 올라가자마자 답이 나왔습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-scss&quot;&gt;// _section.scss — 홈 섹션 공통 mixin
@mixin home-section {
  @include flex(column, center, center);
  width: 100%;

  @include medium {
    max-width: 648px;
  }
  @include small {
    overflow-x: clip;   // ← 여기
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.tabBarSticky&lt;/code&gt;의 부모인 &lt;code&gt;.section&lt;/code&gt;이, &lt;strong&gt;정확히 문제가 발생하는 그 브레이크포인트에서만&lt;/strong&gt; 클리핑 컨테이너가 되고 있었습니다.&lt;/p&gt;
&lt;p&gt;한 줄을 지우고 테스트했더니 &lt;strong&gt;해결되었습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. 원인 분석 — 왜 &lt;code&gt;overflow: clip&lt;/code&gt;이 sticky를 망가뜨렸나&lt;/h2&gt;
&lt;p&gt;WebKit은 sticky 요소를 스크롤 스레드에서 처리할지 판단할 때, 해당 요소부터 스크롤 컨테이너(여기서는 뷰포트)까지 레이어 체인을 거슬러 올라갑니다. 이때 &lt;strong&gt;&amp;quot;클리핑은 하지만 스크롤은 하지 않는 조상&amp;quot;&lt;/strong&gt; 을 만나면 비동기 처리를 포기합니다. 컴포지터 입장에서 &amp;quot;따로 움직이는 클립&amp;quot;을 표현할 방법이 없기 때문입니다.&lt;/p&gt;
&lt;p&gt;fast path에서 떨어지면 이렇게 됩니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;정상 (fast path)&lt;/th&gt;
&lt;th&gt;떨어졌을 때&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;스크롤 처리&lt;/td&gt;
&lt;td&gt;스크롤 스레드&lt;/td&gt;
&lt;td&gt;스크롤 스레드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sticky 위치 갱신&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;스크롤 스레드가 함께&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;메인 스레드가 뒤늦게&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관성 스크롤 중&lt;/td&gt;
&lt;td&gt;딱 붙어 있음&lt;/td&gt;
&lt;td&gt;끌려다니다 멈추면 툭&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;iOS에서 스크롤은 컴포지터가 먼저 그리고 JS/메인 스레드가 뒤따르는 구조이므로, sticky 갱신이 메인 스레드로 내려가면 &lt;strong&gt;구조적으로 한 프레임 이상 늦습니다.&lt;/strong&gt; 데스크톱 크롬에서 보이지 않았던 것은 Blink의 sticky 구현이 다르고 메인 스레드가 한가했기 때문입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 그런데 &lt;code&gt;clip&lt;/code&gt;을 고른 판단 자체는 옳았습니다&lt;/h2&gt;
&lt;p&gt;해당 줄에는 주석으로 이유가 적혀 있었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-scss&quot;&gt;// clip: overflow-x:hidden 이면 브라우저가 overflow-y 를 auto 로 계산해
// 세로 스크롤바가 생김 → clip 으로 회피.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 이건 &lt;strong&gt;맞는 얘기입니다.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;overflow-x: hidden&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;overflow-x: clip&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;자르는가&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스크롤 컨테이너 생성&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;O&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;반대축(&lt;code&gt;overflow-y: visible&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;auto&lt;/code&gt;로 강제 계산&lt;/strong&gt; → 세로 스크롤바&lt;/td&gt;
&lt;td&gt;&lt;code&gt;visible&lt;/code&gt; 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;CSS 스펙상 한 축이 &lt;code&gt;visible&lt;/code&gt;이고 다른 축이 &lt;code&gt;visible&lt;/code&gt;/&lt;code&gt;clip&lt;/code&gt;이 아니면, &lt;code&gt;visible&lt;/code&gt;인 쪽이 &lt;code&gt;auto&lt;/code&gt;로 계산됩니다. &lt;code&gt;hidden&lt;/code&gt;은 스크롤 컨테이너가 되므로 이 규칙에 걸리고, &lt;code&gt;clip&lt;/code&gt;은 걸리지 않습니다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;&amp;quot;세로 스크롤바를 피하려고 &lt;code&gt;hidden&lt;/code&gt; 대신 &lt;code&gt;clip&lt;/code&gt;을 썼다&amp;quot;는 결정은 정확했습니다.&lt;/strong&gt; 다만 &lt;code&gt;clip&lt;/code&gt;은 스크롤 컨테이너를 만들지 않을 뿐, &lt;strong&gt;클리핑 레이어는 여전히 만듭니다.&lt;/strong&gt; WebKit이 걸린 지점은 그쪽이었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;스펙 레벨에서 옳은 선택이 합성(compositing) 레이어에서 대가를 치른 케이스입니다. 두 층위는 별개로 판단해야 합니다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;7. 삭제해도 안전한가 — 검증&lt;/h2&gt;
&lt;p&gt;&amp;quot;원인이니까 지운다&amp;quot;로 끝내면 안 됩니다. 저 줄이 원래 무엇을 막고 있었는지 확인해야 했습니다.&lt;/p&gt;
&lt;h3&gt;7-1. 왜 있었나&lt;/h3&gt;
&lt;p&gt;git 이력을 확인했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aec4b26  refactor: main 컴포넌트 section스타일 공통화&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 커밋은 mixin으로 &lt;strong&gt;승격&lt;/strong&gt;만 했을 뿐, 그 이전부터 &lt;code&gt;Agency&lt;/code&gt;·&lt;code&gt;SuccessReference&lt;/code&gt; 각 파일에 있던 줄이었습니다. 커밋 메시지에도 주석에도 &lt;strong&gt;&amp;quot;어떤 오버플로를 잡았다&amp;quot;는 기록이 없었습니다.&lt;/strong&gt; 해당 섹션들에 &lt;code&gt;white-space: nowrap&lt;/code&gt; + &lt;code&gt;flex-shrink: 0&lt;/code&gt; 조합(칩·배지·라벨)이 많은 것으로 보아, 특정 버그를 잡은 것이 아니라 &lt;strong&gt;&amp;quot;모바일에서 뭐라도 삐져나가면 잘라라&amp;quot;는 예방적 안전망&lt;/strong&gt;으로 깔아둔 것으로 보였습니다.&lt;/p&gt;
&lt;h3&gt;7-2. 이 clip은 뷰포트 가장자리에서만 자르고 있었습니다&lt;/h3&gt;
&lt;p&gt;레이아웃 체인에 좌우 padding도, max-width도 없습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;body            max-width:100vw · overflow-x:clip
└ .shell        stretch
  └ .content    flex:1, 블록 → 100%
    └ .page     100%, 좌우 padding 없음
      └ .section width:100%          ← 여기&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 결정적으로, 같은 mixin 안에서 두 선언의 적용 구간이 겹치지 않습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max-width: 648px&lt;/code&gt; → &lt;code&gt;@include medium&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;overflow-x: clip&lt;/code&gt; → &lt;code&gt;@include small&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 이 clip은 &amp;quot;섹션이 뷰포트보다 좁을 때 자르는&amp;quot; 역할을 &lt;strong&gt;한 번도 한 적이 없고&lt;/strong&gt;, 항상 뷰포트 가장자리에서 자르고 있었습니다.&lt;/p&gt;
&lt;h3&gt;7-3. 그 가장자리는 이미 body가 자르고 있습니다&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-scss&quot;&gt;html { overflow-x: hidden; }  // 루트의 overflow는 뷰포트로 전파
body { overflow-x: clip; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;자르는 x좌표가 &lt;code&gt;.section&lt;/code&gt;의 것과 &lt;strong&gt;동일&lt;/strong&gt;합니다. 결론적으로 &lt;strong&gt;중복이었습니다.&lt;/strong&gt; 지워도 가로 스크롤은 생기지 않고, 잘리는 위치도 그대로입니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/155</guid>
      <comments>https://kjk5.tistory.com/155#entry155comment</comments>
      <pubDate>Tue, 18 Aug 2026 15:33:09 +0900</pubDate>
    </item>
    <item>
      <title>React 대량 데이터 편집 모달에서 입력 지연 원인 추적기</title>
      <link>https://kjk5.tistory.com/154</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;553&quot; data-origin-height=&quot;551&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DISyn/dJMcadH4hW5/bFB5rCyZzIBnIdqI0pObx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DISyn/dJMcadH4hW5/bFB5rCyZzIBnIdqI0pObx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DISyn/dJMcadH4hW5/bFB5rCyZzIBnIdqI0pObx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDISyn%2FdJMcadH4hW5%2FbFB5rCyZzIBnIdqI0pObx0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;553&quot; height=&quot;551&quot; data-origin-width=&quot;553&quot; data-origin-height=&quot;551&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;[React] 대규모 데이터 편집 시 발생하는 입력 지연 분석: Fiber 트리 순회와 브라우저 Reflow&lt;/h2&gt;
&lt;p&gt;React 환경에서 수천 건의 데이터를 동시에 다루는 편집 UI를 구현하다 보면, 단순한 최적화만으로는 해결되지 않는 성능 병목을 마주하곤 합니다. 최근 약 3,800건의 매출 데이터를 수정하는 모달에서 &lt;strong&gt;입력 시 약 5초간의 지연(Input Lag)&lt;/strong&gt;이 발생했던 사례를 통해, React 내부 동작과 브라우저 렌더링 엔진의 관점에서 그 원인을 분석하고 해결 과정을 기록합니다.&lt;/p&gt;
&lt;h3&gt;1. 현상 및 초기 진단&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;규모:&lt;/strong&gt; 약 3,822행 (행당 Input 및 SelectBox 등 10여 개의 컴포넌트 포함)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;문제:&lt;/strong&gt; 특정 입력 필드에 타이핑 시 화면이 수 초간 프리징됨.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;초기 가설:&lt;/strong&gt; 검색 필터링 로직의 부하 혹은 부모 컴포넌트의 불필요한 리렌더링을 의심함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그러나 성능 측정 결과, 필터링 로직은 1ms 미만으로 수행되었으며, &lt;code&gt;onBlur&lt;/code&gt; 처리를 통해 부모의 리렌더링을 억제했음에도 지연 현상은 개선되지 않았습니다.&lt;/p&gt;
&lt;h3&gt;2. React.memo와 Fiber 트리 순회 비용&lt;/h3&gt;
&lt;p&gt;Chrome DevTools의 Performance 프로파일링을 통해 확인한 결과, JavaScript 실행 시간(Yellow Block)이 병목의 큰 비중을 차지하고 있었습니다. 특히 React 내부의 &lt;code&gt;recursivelyTraverseMutationEffects&lt;/code&gt; 함수가 수만 번 호출되는 것을 확인했습니다.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;분석 결과 비교&lt;/strong&gt;&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;최적화 단계&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;JS 실행 시간 (Performance)&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;특이 사항&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;기존 (No Memo)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;4,366ms&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;모든 Fiber 노드를 전수 조사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;React.memo 적용 후&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;2,983ms&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;변경되지 않은 서브트리 스킵&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;React는 상태 변경 시 Fiber 트리를 순회하며 업데이트가 필요한 노드를 탐색합니다. 행당 컴포넌트가 복잡할수록(Hook 사용량이 많을수록) Fiber 노드의 수는 기하급수적으로 증가하며, &lt;code&gt;React.memo&lt;/code&gt;가 없을 경우 단 하나의 입력 변경에도 수만 개의 노드를 재귀적으로 방문하게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;React.memo&lt;/code&gt; 적용 시, 변경되지 않은 행의 &lt;code&gt;subtreeFlags&lt;/code&gt;를 확인하여 해당 서브트리 전체를 건너뛰기 때문에 JS 실행 비용을 유의미하게 절감할 수 있었습니다. &lt;/p&gt;
&lt;h3&gt;3. 두 번째 병목: 브라우저 렌더링 엔진의 한계 (Reflow)&lt;/h3&gt;
&lt;p&gt;JavaScript 실행 시간을 단축했음에도 불구하고, 프로파일러상에는 여전히 &lt;strong&gt;보라색 블록(Layout/Reflow)&lt;/strong&gt;이 선명하게 남아 있었습니다. 이는 React의 영역을 넘어 브라우저 렌더링 엔진이 처리해야 할 물리적인 한계치에 도달했음을 의미합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DOM 노드 규모:&lt;/strong&gt; 3,822행 × 11개 셀 = &lt;strong&gt;약 10만 개의 DOM 노드&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;원인:&lt;/strong&gt; &lt;code&gt;input&lt;/code&gt;의 value가 변경될 때마다 브라우저는 DOM에 존재하는 10만 개의 노드를 대상으로 레이아웃을 재계산합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;한계:&lt;/strong&gt; &lt;code&gt;React.memo&lt;/code&gt;는 JavaScript 엔진의 부하를 줄여줄 뿐, 이미 DOM에 그려진 수많은 노드로 인한 브라우저의 Reflow 비용은 해결할 수 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. 종합 및 최종 해결책&lt;/h3&gt;
&lt;p&gt;이번 디버깅을 통해 파악한 성능 병목의 구조는 다음과 같습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;병목 레이어&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;원인&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;대응 방안&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;JavaScript (React)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;Fiber 트리 재귀 순회 부하&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;React.memo&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;서브트리 스킵 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Browser (Rendering)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;10만 개 DOM 노드의 Reflow&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Virtualization&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;DOM 노드 수 자체를 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;결론적으로 &lt;strong&gt;가상화(Virtualization)&lt;/strong&gt;는 단순히 리렌더링 성능만을 위한 도구가 아닙니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fiber 트리의 규모를 물리적으로 축소&lt;/strong&gt;하여 React의 순회 비용을 최소화하고,&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DOM 노드 수를 수백 개 수준으로 유지&lt;/strong&gt;하여 브라우저의 레이아웃 계산 부하를 근본적으로 제거하는 가장 확실한 전략입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5. 마치며&lt;/h3&gt;
&lt;p&gt;대규모 데이터를 다루는 UI에서는 &amp;quot;느림&amp;quot;의 원인이 복합적으로 작용할 때가 많습니다. 이번 사례처럼 JS 실행 비용과 브라우저 렌더링 비용이 혼재된 경우, 단계별 측정을 통해 병목의 실체를 명확히 구분하는 과정이 필수적입니다. &lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/154</guid>
      <comments>https://kjk5.tistory.com/154#entry154comment</comments>
      <pubDate>Fri, 20 Mar 2026 16:49:41 +0900</pubDate>
    </item>
    <item>
      <title>5,000건 대량 테이블의 '전체 선택' 지연 해결하기 (Virtualization)</title>
      <link>https://kjk5.tistory.com/153</link>
      <description>&lt;p&gt;정산 시스템의 데이터 수정 모달에서 최대 5,000건의 행을 테이블로 보여줘야 하는 기능을 구현했습니다. 개별 체크박스 토글은 문제가 없었으나, 헤더의 &lt;strong&gt;&amp;#39;전체 선택&amp;#39;&lt;/strong&gt;을 클릭할 때마다 화면이 수 초간 멈추는 성능 저하가 발생했습니다.&lt;/p&gt;
&lt;p&gt;지난 포스트에서 다룬 &lt;code&gt;Set&lt;/code&gt; 기반 최적화와 &lt;code&gt;React.memo&lt;/code&gt;만으로는 해결되지 않았던 이 문제를 &lt;strong&gt;가상화(Virtualization)&lt;/strong&gt;를 통해 해결한 과정을 정리합니다.&lt;/p&gt;
&lt;h3&gt;1. 병목 지점 분석: React.memo가 무력화되는 순간&lt;/h3&gt;
&lt;p&gt;개별 체크박스 토글은 &lt;code&gt;React.memo&lt;/code&gt;로 최적화가 가능합니다. 5,000개 행 중 상태가 변하는 행은 단 1개뿐이기 때문입니다. 하지만 &amp;#39;전체 선택&amp;#39;은 이야기가 다릅니다.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;전체 선택 시 발생하는 일&lt;/strong&gt;&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;selectedSeqs&lt;/code&gt; 상태가 0개에서 5,000개로 변경됩니다.&lt;/li&gt;
&lt;li&gt;각 행의 &lt;code&gt;isSelected&lt;/code&gt; 프로퍼티가 전부 &lt;code&gt;false&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;로 바뀝니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;React.memo&lt;/code&gt;의 비교 함수가 작동하지만, &lt;strong&gt;모든 행의 상태가 바뀌었으므로 5,000개 행 전체를 리렌더링&lt;/strong&gt;합니다.&lt;/li&gt;
&lt;li&gt;React는 5,000개 &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt;에 대한 Virtual DOM diff를 수행하고 실제 DOM을 업데이트합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;결과적으로 데이터가 수천 건이 넘어가면 Virtual DOM을 비교하고 실제 DOM에 반영하는 과정 자체가 브라우저 메인 스레드에 큰 부담을 주게 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 해결책: 가상화 (Virtualization)&lt;/h3&gt;
&lt;p&gt;가상화의 핵심 아이디어는 &lt;strong&gt;&amp;quot;눈에 보이는 영역(Viewport)만 렌더링하자&amp;quot;&lt;/strong&gt;는 것입니다. 5,000개의 데이터가 있더라도 사용자의 화면에 보이는 행은 고작 10~15개 내외입니다. 가상화를 적용하면 전체 선택을 눌러도 React는 현재 보이는 15개의 행만 비교하면 되므로 즉각적인 반응 속도를 얻을 수 있습니다.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;구현: @tanstack/react-virtual&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;TanStack Table과 궁합이 좋은 &lt;code&gt;@tanstack/react-virtual&lt;/code&gt;을 활용하여 가상 테이블을 구현했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { useVirtualizer } from &amp;#39;@tanstack/react-virtual&amp;#39;;

const VirtualSimpleTable = ({ data, columns, estimateRowHeight = 53 }) =&amp;gt; {
  const parentRef = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

  const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
  const { rows } = table.getRowModel();

  // 가상화 설정
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () =&amp;gt; parentRef.current,
    estimateSize: () =&amp;gt; estimateRowHeight,
    overscan: 5,
    measureElement: el =&amp;gt; el.getBoundingClientRect().height, // 동적 높이 대응
  });

  const gridTemplateColumns = table
    .getAllColumns()
    .map(col =&amp;gt; `${col.getSize()}fr`)
    .join(&amp;#39; &amp;#39;);

  return (
    &amp;lt;div ref={parentRef} style={{ maxHeight: 400, overflowY: &amp;#39;auto&amp;#39; }}&amp;gt;
      {/* 가상화 적용 시 내부 컨테이너 높이를 전체 데이터 높이만큼 확보 */}
      &amp;lt;div style={{ height: virtualizer.getTotalSize(), position: &amp;#39;relative&amp;#39; }}&amp;gt;
        {virtualizer.getVirtualItems().map(virtualRow =&amp;gt; {
          const row = rows[virtualRow.index];
          return (
            &amp;lt;div
              key={row.id}
              ref={virtualizer.measureElement}
              style={{
                position: &amp;#39;absolute&amp;#39;,
                top: 0,
                transform: `translateY(${virtualRow.start}px)`,
                display: &amp;#39;grid&amp;#39;,
                gridTemplateColumns,
              }}
            &amp;gt;
              {row.getVisibleCells().map(cell =&amp;gt; (
                &amp;lt;div key={cell.id}&amp;gt;
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                &amp;lt;/div&amp;gt;
              ))}
            &amp;lt;/div&amp;gt;
          );
        })}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;3. 주요 구현 포인트&lt;/h3&gt;
&lt;h4&gt;&lt;strong&gt;1) Table 대신 Div + CSS Grid 사용&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;가상화 라이브러리는 각 행을 &lt;code&gt;absolute&lt;/code&gt; 포지션으로 배치합니다. 하지만 표준 &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; 태그 내에서 &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt;에 &lt;code&gt;absolute&lt;/code&gt;를 적용하면 테이블 레이아웃 구조가 깨지게 됩니다. 따라서 레이아웃 유연성이 높은 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;와 &lt;code&gt;CSS Grid&lt;/code&gt; 조합으로 전환하여 컬럼 정렬과 가상화 배치를 동시에 잡았습니다.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;2) 동적 행 높이 대응 (measureElement)&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;데이터 내용에 따라 행 높이가 달라질 수 있는 경우, &lt;code&gt;estimateSize&lt;/code&gt;만으로는 위치 계산이 부정확할 수 있습니다. &lt;code&gt;measureElement&lt;/code&gt;를 통해 실제 렌더링된 요소의 높이를 측정함으로써 스크롤 위치를 정확하게 유지할 수 있습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;4. React.memo vs 가상화 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;구분&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;React.memo (행 단위)&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;가상화 (Virtualization)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;작동 원리&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;변경 없는 행 렌더링 스킵&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;보이는 행만 DOM에 렌더링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;전체 선택 시&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;5,000개 전수 리렌더링 (느림)&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;약 15개만 리렌더링 (매우 빠름)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;초기 렌더링&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;5,000개 DOM 생성&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;약 15개 DOM 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;구현 난이도&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;낮음&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;중간 (CSS 레이아웃 수정 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h3&gt;결론&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;React.memo&lt;/code&gt;는 &amp;#39;변경된 행만 다시 그리는&amp;#39; 훌륭한 전략이지만, 전체 선택처럼 &lt;strong&gt;모든 행의 상태가 동시에 변하는 케이스&lt;/strong&gt;에서는 힘을 쓰지 못합니다. &lt;/p&gt;
&lt;p&gt;데이터가 수천 건 이상이고 사용자와의 인터랙션이 잦은 테이블이라면, 처음부터 가상화를 고려하는 것이 성능과 사용자 경험(UX) 측면에서 훨씬 유리합니다. 특히 &lt;code&gt;@tanstack/react-virtual&lt;/code&gt;을 활용하면 기존 TanStack Table의 로직을 그대로 유지하면서 성능만 끌어올릴 수 있습니다.&lt;/p&gt;
&lt;hr&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/153</guid>
      <comments>https://kjk5.tistory.com/153#entry153comment</comments>
      <pubDate>Thu, 19 Mar 2026 10:05:48 +0900</pubDate>
    </item>
    <item>
      <title>체크박스 아이템 최적화</title>
      <link>https://kjk5.tistory.com/152</link>
      <description>&lt;h2&gt;[react] 대량의 체크박스(5,000건) 렌더링 최적화하기&lt;/h2&gt;
&lt;p&gt;프로젝트를 진행하다 보면 대량의 데이터를 다뤄야 하는 상황을 마주하곤 합니다. 최근 정산 데이터 수정 모달에서 약 5,000건의 데이터를 테이블로 렌더링하고, 각 행을 체크박스로 선택하는 기능을 구현했습니다.&lt;/p&gt;
&lt;p&gt;하지만 구현 후 테스트 과정에서 체크박스를 클릭할 때마다 &lt;strong&gt;1~2초 수준의 심각한 지연&lt;/strong&gt;이 발생했습니다. 이번 포스트에서는 배열 기반 상태 관리의 함정과 이를 &lt;code&gt;Set&lt;/code&gt; 및 &lt;code&gt;React.memo&lt;/code&gt;로 해결한 과정을 정리합니다.&lt;/p&gt;
&lt;h3&gt;1. 문제 상황과 원인 분석&lt;/h3&gt;
&lt;p&gt;처음에는 일반적인 방식인 배열(&lt;code&gt;Array&lt;/code&gt;)을 사용하여 선택된 아이템의 상태를 관리했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const [selectedItems, setSelectedItems] = useState&amp;lt;DataListEditItem[]&amp;gt;([]);

// 개별 선택 핸들러
const handleSelectRow = (item: DataListEditItem) =&amp;gt; {
  setSelectedItems(prev =&amp;gt;
    prev.some(selected =&amp;gt; selected.seq === item.seq)
      ? prev.filter(selected =&amp;gt; selected.seq !== item.seq)
      : [...prev, item],
  );
};

// 컬럼 정의 내 체크 여부 판단
cell: ({ row }) =&amp;gt; (
  &amp;lt;input
    type=&amp;quot;checkbox&amp;quot;
    checked={selectedItems.some(item =&amp;gt; item.seq === row.original.seq)}
  /&amp;gt;
),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;언뜻 보면 큰 문제가 없어 보이지만, 데이터가 5,000건 이상으로 늘어나면 &lt;strong&gt;시간 복잡도&lt;/strong&gt; 측면에서 병목이 발생합니다.&lt;/p&gt;
&lt;h4&gt;&lt;strong&gt;시간복잡도로 보는 병목&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;체크박스 하나를 클릭할 때마다 다음과 같은 연산이 일어납니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;selectedItems&lt;/code&gt; 상태가 변경되어 전체 컴포넌트가 리렌더링됩니다.&lt;/li&gt;
&lt;li&gt;5,000개 행이 모두 다시 그려집니다.&lt;/li&gt;
&lt;li&gt;각 행은 &lt;code&gt;selectedItems.some()&lt;/code&gt;을 실행하여 체크 여부를 판단합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;Array.some()&lt;/code&gt;은 최악의 경우 배열 전체를 순회하는 &lt;strong&gt;O(s)&lt;/strong&gt; 연산입니다(s = 선택된 아이템 수). 이를 5,000개 행마다 반복하므로 전체 비용은 &lt;strong&gt;O(n × s)&lt;/strong&gt;가 됩니다. 만약 전체 선택 후 1개를 해제한다면 약 2,500만 번(5,000 × 5,000)의 비교 연산이 수행되는 셈입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 해결 단계 1: Set 기반으로 전환&lt;/h3&gt;
&lt;p&gt;첫 번째 개선은 조회 성능이 뛰어난 &lt;code&gt;Set&lt;/code&gt; 자료구조를 사용하는 것입니다. &lt;code&gt;Set.has()&lt;/code&gt;는 &lt;strong&gt;O(1)&lt;/strong&gt;의 시간 복잡도를 가지므로, 5,000행 전체를 확인해도 &lt;strong&gt;O(n)&lt;/strong&gt;으로 연산량이 급감합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const [selectedSeqs, setSelectedSeqs] = useState&amp;lt;Set&amp;lt;number&amp;gt;&amp;gt;(new Set());

const handleSelectRow = (item: DataListEditItem) =&amp;gt; {
  setSelectedSeqs(prev =&amp;gt; {
    const next = new Set(prev);
    if (next.has(item.seq)) next.delete(item.seq);
    else next.add(item.seq);
    return next;
  });
};

// 조회 시 O(1)
cell: ({ row }) =&amp;gt; (
  &amp;lt;input
    type=&amp;quot;checkbox&amp;quot;
    checked={selectedSeqs.has(row.original.seq)}
  /&amp;gt;
),
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;연산&lt;/th&gt;
&lt;th&gt;배열 (Before)&lt;/th&gt;
&lt;th&gt;Set (After)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;개별 선택/해제&lt;/td&gt;
&lt;td&gt;O(n × s) ≈ O(n²)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;O(n)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;체크 여부 비교 (1행)&lt;/td&gt;
&lt;td&gt;O(s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;O(1)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h3&gt;3. 해결 단계 2: 행 단위 React.memo 적용&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Set&lt;/code&gt;을 통해 연산 비용은 줄였지만, 여전히 상태가 변경될 때마다 5,000개의 행이 모두 리렌더링되는 근본적인 문제가 남아 있습니다. 이를 해결하기 위해 각 행을 &lt;code&gt;memo&lt;/code&gt;로 감싸고 &lt;strong&gt;커스텀 비교 함수&lt;/strong&gt;를 적용했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// SimpleTable 내부 Row 컴포넌트화
const MemoizedRow = memo(
  &amp;lt;T,&amp;gt;({ row, isSelected }: MemoizedRowProps&amp;lt;T&amp;gt;) =&amp;gt; (
    &amp;lt;tr&amp;gt;
      {row.getVisibleCells().map(cell =&amp;gt; (
        &amp;lt;td key={cell.id}&amp;gt;
          {flexRender(cell.column.columnDef.cell, cell.getContext())}
        &amp;lt;/td&amp;gt;
      ))}
    &amp;lt;/tr&amp;gt;
  ),
  (prev, next) =&amp;gt;
    prev.row.original === next.row.original &amp;amp;&amp;amp; 
    prev.isSelected === next.isSelected // 데이터와 선택 상태가 같으면 리렌더링 방지
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식을 통해 체크박스 하나를 클릭했을 때, 실제 변경이 일어난 &lt;strong&gt;단 1개의 행&lt;/strong&gt;만 다시 그려지도록 최적화했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;4. 최종 비교 및 정리&lt;/h3&gt;
&lt;p&gt;각 단계별 최적화 결과는 다음과 같습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;배열 기반&lt;/th&gt;
&lt;th&gt;Set 전환&lt;/th&gt;
&lt;th&gt;Set + Memo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;checked 비교&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;O(s)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;개별 클릭 시 리렌더링&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5,000개 행&lt;/td&gt;
&lt;td&gt;5,000개 행&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1개 행&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5,000건 체감 속도&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1~2초 지연&lt;/td&gt;
&lt;td&gt;다소 개선&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;즉시 반응&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;데이터셋이 작을 때는 배열 기반으로도 충분하지만, 수천 건 이상의 인터랙티브한 테이블을 구현해야 한다면 &lt;strong&gt;&lt;code&gt;Set&lt;/code&gt;을 통한 조회 최적화&lt;/strong&gt;와 &lt;strong&gt;&lt;code&gt;memo&lt;/code&gt;를 통한 렌더링 최적화&lt;/strong&gt; 조합을 필수적으로 고려해야 합니다.&lt;/p&gt;
&lt;p&gt;특히 &lt;code&gt;Set&lt;/code&gt;으로의 전환은 코드 수정량이 적으면서도 O(n²)에서 O(n)으로 성능을 끌어올릴 수 있는 가장 효율적인 방법입니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/152</guid>
      <comments>https://kjk5.tistory.com/152#entry152comment</comments>
      <pubDate>Fri, 13 Mar 2026 12:17:44 +0900</pubDate>
    </item>
    <item>
      <title>초성 검색을 지원하는 Local Async SelectBox 구현하기</title>
      <link>https://kjk5.tistory.com/151</link>
      <description>&lt;p&gt;프로젝트 규모가 커짐에 따라 서버 부하를 줄이기 위해 전체 데이터를 클라이언트에서 관리하며 필터링해야 하는 경우가 많아집니다. 특히 한국어 서비스에서는 사용자의 검색 경험을 결정짓는 &amp;#39;초성 검색&amp;#39;과 UI의 안정성을 보장하는 &amp;#39;포탈(Portal) 렌더링&amp;#39;이 필수적입니다.&lt;/p&gt;
&lt;p&gt;이번 포스트에서는 &lt;code&gt;hangul-js&lt;/code&gt;를 활용하여 검색 효율을 높이고, 외부 환경에 영향을 받지 않는 독립적인 &lt;strong&gt;LocalAsyncSelectBox&lt;/strong&gt;를 구축한 과정을 정리합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;1. 커스텀 컴포넌트 구현의 필요성&lt;/h3&gt;
&lt;p&gt;기존 오픈소스 라이브러리들은 범용성을 위해 설계되었으나, 다음과 같은 기술적 제약이 있었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;한글 검색 최적화 미흡:&lt;/strong&gt; 단순 텍스트 매칭만 지원하여 초성 검색 등 한국어 특화 기능을 별도로 구현해야 함.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;레이아웃 간섭:&lt;/strong&gt; 드롭다운 UI가 부모 컨테이너의 &lt;code&gt;overflow: hidden&lt;/code&gt;이나 &lt;code&gt;z-index&lt;/code&gt; 설정에 영향을 받아 가려지는 현상 발생.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;낮은 예측 가능성:&lt;/strong&gt; 라이브러리 내부 로직에 의존할 경우 특이 케이스(Edge Case) 대응 시 디버깅 리소스가 증가함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이러한 문제를 해결하기 위해 &lt;strong&gt;제어권을 완전히 확보한 커스텀 컴포넌트&lt;/strong&gt;를 제작하여 장기적인 유지보수 효율을 높이고자 했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;2. 핵심 구현 사항&lt;/h3&gt;
&lt;h4&gt;한글 초성 검색 로직 (&lt;code&gt;hangul-js&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;사용자가 &amp;#39;ㅅㄱ&amp;#39;만 입력해도 &amp;#39;사과&amp;#39;를 찾을 수 있도록 정규표현식과 &lt;code&gt;hangul-js&lt;/code&gt;를 조합했습니다. 입력값이 초성인지 여부를 판단하여 필터링 로직을 분기 처리했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const filterData = useCallback((keyword: string) =&amp;gt; {
  if (!keyword.trim() || keyword.length &amp;lt; minLength) {
    setOptions([]);
    setIsOpen(false);
    return;
  }

  const searchKeyword = keyword.toLowerCase();
  const isChosung = /^[ㄱ-ㅎ]+$/.test(searchKeyword); // 초성 여부 검사

  const filtered = data
    .filter(item =&amp;gt; {
      if (!item?.label) return false;
      const searchTarget = item.label.toLowerCase();

      if (isChosung) {
        // 초성 검색: 각 글자의 첫 자음을 분해하여 비교
        const targetChosung = Hangul.disassemble(searchTarget, true)
          .map(char =&amp;gt; char[0])
          .join(&amp;#39;&amp;#39;);
        return targetChosung.includes(searchKeyword);
      }

      // 일반 검색 및 한글 형태소 검색 지원
      return searchTarget.includes(searchKeyword) || Hangul.search(searchTarget, searchKeyword) !== -1;
    })
    .slice(0, 5); // 성능 최적화를 위한 노출 개수 제한

  setOptions(filtered);
  // ...이후 렌더링 제어
}, [data, minLength]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;ModalPortal과 뷰포트 좌표 계산&lt;/h4&gt;
&lt;p&gt;드롭다운이 레이아웃 구조에 구애받지 않도록 &lt;code&gt;ModalPortal&lt;/code&gt;을 통해 DOM 계층의 최상단에서 렌더링되도록 했습니다. 이때 부모 요소와의 정렬을 위해 &lt;code&gt;getBoundingClientRect()&lt;/code&gt;를 활용해 동적으로 좌표를 계산합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const updateDropdownPosition = useCallback(() =&amp;gt; {
  if (containerRef.current) {
    const rect = containerRef.current.getBoundingClientRect();
    setDropdownRect({
      top: rect.bottom + window.scrollY,
      left: rect.left + window.scrollX,
      width: rect.width,
    });
  }
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;3. 실무 적용 예시: 정산 파일 업로드 폼&lt;/h3&gt;
&lt;p&gt;구현된 컴포넌트는 &lt;code&gt;CalculateFileUploadForm&lt;/code&gt; 내에서 매체 및 상품 선택 섹션에 적용되었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Debounce 처리:&lt;/strong&gt; 300ms의 타이머를 설정해 연속적인 입력 시 불필요한 연산을 방지했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;데이터 무결성:&lt;/strong&gt; 입력 중에는 &lt;code&gt;onChange(null)&lt;/code&gt;를 호출하여 선택되지 않은 상태를 명확히 전달함으로써 잘못된 데이터가 제출되는 것을 방지했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;4. 마치며&lt;/h3&gt;
&lt;p&gt;컴포넌트 설계 시 가장 중점을 둔 것은 &lt;strong&gt;&amp;quot;확장성과 안정성&amp;quot;&lt;/strong&gt;입니다. 라이브러리의 불투명한 내부 로직에 의존하기보다 직접 제어 가능한 구조를 구축함으로써, 예기치 못한 UI 버그를 사전에 차단하고 일관된 사용자 경험을 제공할 수 있게 되었습니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/151</guid>
      <comments>https://kjk5.tistory.com/151#entry151comment</comments>
      <pubDate>Mon, 9 Feb 2026 10:33:57 +0900</pubDate>
    </item>
    <item>
      <title>react의 실수하기 쉬운 안티패턴과 파훼법</title>
      <link>https://kjk5.tistory.com/150</link>
      <description>&lt;hr&gt;
&lt;h2&gt;1. 상태가 다른 상태를 바꾸는 패턴 (useEffect 안에서 setState)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [a, setA] = useState(0);
const [b, setB] = useState(0);

useEffect(() =&amp;gt; {
  setB(a * 2);
}, [a]);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;문제: 다른 값으로부터 계산 가능한 값을 굳이 상태로 두면 무한 루프 위험 + 중복 관리 필요&lt;/li&gt;
&lt;li&gt;해결: 계산은 상태로 두지 않고 직접 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [a, setA] = useState(0);
const b = a * 2; // 혹은 useMemo&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;2. 상태를 지나치게 잘게 나눈 경우&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [email, setEmail] = useState(&amp;#39;&amp;#39;);
const [isValid, setIsValid] = useState(false);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;문제: 서로 연결된 값이 따로 관리되면 동기화 문제 발생&lt;/li&gt;
&lt;li&gt;해결: 하나의 객체로 묶어서 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [form, setForm] = useState({ email: &amp;#39;&amp;#39;, isValid: false });

const handleChange = (email: string) =&amp;gt; {
  setForm({ email, isValid: validateEmail(email) });
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;3. useEffect로 데이터 가공하기&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  const filtered = list.filter(item =&amp;gt; item.active);
  setFilteredList(filtered);
}, [list]);&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;문제: 단순히 계산 가능한 값을 effect와 state로 억지로 분리&lt;/li&gt;
&lt;li&gt;해결: 렌더링 과정에서 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const filteredList = useMemo(
  () =&amp;gt; list.filter(item =&amp;gt; item.active),
  [list]
);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;4. 렌더링 중에 비동기 호출&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const data = fetch(&amp;#39;/api/data&amp;#39;); // ❌ 렌더마다 실행&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;문제: 무한 호출 발생&lt;/li&gt;
&lt;li&gt;해결: useEffect 안에서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  fetch(&amp;#39;/api/data&amp;#39;).then(res =&amp;gt; setData(res));
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;5. 조건부로 훅 호출&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;if (isOpen) {
  const [value, setValue] = useState(0); // ❌
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;문제: 훅은 항상 같은 순서로 실행돼야 함&lt;/li&gt;
&lt;li&gt;해결: 훅은 최상단에 두고 조건은 렌더링 단계에서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const [value, setValue] = useState(0);
if (!isOpen) return null;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;6. props를 그대로 상태로 복사&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;function Child({ initialValue }) {
  const [value, setValue] = useState(initialValue); // ❌ 부모 값 변경시 동기화 안 됨
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;문제: 부모 값이 바뀌어도 동기화 안 됨&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;해결:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;props를 직접 사용&lt;/li&gt;
&lt;li&gt;정말 독립적인 값이어야 한다면 이름을 &lt;code&gt;defaultValue&lt;/code&gt;처럼 명확히&lt;/li&gt;
&lt;li&gt;필요하다면 의도적으로 &lt;code&gt;useEffect&lt;/code&gt;로 갱신&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;7. 불필요한 리렌더&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;원인:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Context에 큰 객체를 넣어서 전달&lt;/li&gt;
&lt;li&gt;매번 새로운 함수나 객체를 만들어서 props로 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;해결:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;React.memo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;로 최적화&lt;/li&gt;
&lt;li&gt;Context는 작은 단위로 쪼개서 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/150</guid>
      <comments>https://kjk5.tistory.com/150#entry150comment</comments>
      <pubDate>Mon, 25 Aug 2025 14:34:18 +0900</pubDate>
    </item>
    <item>
      <title>[rn] prebuild + fastlane 같이 사용하기</title>
      <link>https://kjk5.tistory.com/149</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;expo의 장점과 eas 지출회피를 같이하기 위해 prebuild를 사용하고 있었으나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prebuild 시 ios 폴더가 초기화되는 현상 떄문에 fastlane을 적용하기 어려웠다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;방법 1. expo config plugin&lt;/li&gt;
&lt;li&gt;방법 2. app.json 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 방법들은 prebuild시 유지해야할 항목을 보존하는데 도움을 줄 순 있으나 fastlane 관련 파일을 보존하긴 어려워&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prebuild와 복구 명령어를 통합하기로 했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;fastlane 폴더를 루트에서 백업&lt;br /&gt;bash&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/project-root&lt;br /&gt;┣ /fastlane-template/&lt;br /&gt;┃ ┣ Appfile&lt;br /&gt;┃ ┗ Fastfile&lt;br /&gt;2. expo prebuild 이후에 복사&lt;br /&gt;bash&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npx expo prebuild --clean&lt;br /&gt;cp -R ../fastlane-template ios/fastlane&lt;br /&gt;prebuild 후 스크립트로 자동 복구 가능&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;//prebuild.sh

npx expo prebuild --clean
echo &quot;  Before fastlane copy: $(pwd)&quot;
cp -R ./fastlane-template/fastlane ./ios/fastlane
echo &quot;✅ fastlane 디렉토리 복원 완료&quot;
cd ios
fastlane beta
echo &quot;✅ iOS 빌드 완료&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754392464328&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;default_platform(:ios)

platform :ios do
  desc &quot;TestFlight 업로드&quot;
  lane :beta do
    # 코드사이닝 설정
    automatic_code_signing(
      use_automatic_signing: true,
      team_id: &quot;H4DJJ5V239&quot;,
      path: &quot;nocotine.xcodeproj&quot;
    )
    
    increment_build_number(
      # xcodeproj: &quot;nocotine.xcodeproj&quot;
      build_number: &quot;6&quot;  # 원하는 번호로 설정
    )

    build_app(
      workspace: &quot;nocotine.xcworkspace&quot;,  # workspace 다시 사용
      scheme: &quot;nocotine&quot;,
      export_method: &quot;app-store&quot;,
      configuration: &quot;Release&quot;,
      clean: true,
      include_bitcode: false
    )

   upload_to_testflight(
      api_key_path: &quot;./fastlane/keyCode.json&quot;, // 본인 키 Json
      skip_waiting_for_build_processing: true
    )3
  end
  
  desc &quot;수동 Archive 테스트&quot;
  lane :archive_only do
    # 코드사이닝 설정
    automatic_code_signing(
      use_automatic_signing: true,
      team_id: &quot;team id &quot;,// 본인 팀아이디
      path: &quot;nocotine.xcodeproj&quot;
    )
    
    increment_build_number(
      xcodeproj: &quot;nocotine.xcodeproj&quot;
    )
    
    gym(
      workspace: &quot;nocotine.xcworkspace&quot;,  # workspace 다시 사용
      scheme: &quot;nocotine&quot;,
      configuration: &quot;Release&quot;,
      clean: true,
      skip_archive: false,
      skip_package_ipa: true,  # IPA 생성 안 함, Archive만
      include_bitcode: false
    )
  end
end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754392620544&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//key.json

{
    &quot;key_id&quot;: &quot;&quot;,
    &quot;issuer_id&quot;: &quot;~~&quot;,// apple connect - 보안 - 통합에서 이슈어id 확인
    &quot;key&quot;: &quot;-----BEGIN PRIVATE KEY-----\n~~~~\n-----END PRIVATE KEY-----&quot;, //각 줄바꿈에 \n 삽입 // 관리자로 키 생성해야함
    &quot;duration&quot;: 1200,
    &quot;in_house&quot;: false
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/149</guid>
      <comments>https://kjk5.tistory.com/149#entry149comment</comments>
      <pubDate>Tue, 5 Aug 2025 20:17:06 +0900</pubDate>
    </item>
    <item>
      <title>expo prebuild로 프로덕션 빌드하기</title>
      <link>https://kjk5.tistory.com/148</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;npx&amp;nbsp;expo&amp;nbsp;prebuild&amp;nbsp;--clean&lt;br /&gt;cd&amp;nbsp;ios&lt;br /&gt;pod&amp;nbsp;install&lt;br /&gt;open&amp;nbsp;ios/nocotine.xcworkspace&lt;br /&gt;&lt;br /&gt;왼쪽 상단에서 &quot;nocotine&quot; &amp;rarr; &quot;nocotine&quot; (프로젝트, 파란색 아이콘) 클릭&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;245&quot; data-start=&quot;217&quot;&gt;&lt;b&gt;TARGETS &amp;gt; nocotine&lt;/b&gt; 선택&lt;/li&gt;
&lt;li data-end=&quot;439&quot; data-start=&quot;246&quot;&gt;&lt;b&gt;Signing &amp;amp; Capabilities&lt;/b&gt; 탭에서
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;439&quot; data-start=&quot;285&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;320&quot; data-start=&quot;285&quot;&gt;&lt;b&gt;Team&lt;/b&gt;: Apple Developer 계정 선택&lt;/li&gt;
&lt;li data-end=&quot;380&quot; data-start=&quot;324&quot;&gt;&lt;b&gt;Bundle Identifier&lt;/b&gt;: com.jaegwan.nocotine 맞는지 확인&lt;/li&gt;
&lt;li data-end=&quot;439&quot; data-start=&quot;384&quot;&gt;&lt;b&gt;Provisioning Profile&lt;/b&gt;: 자동으로 맞춰주거나, 직접 선택(Ad Hoc 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;559&quot; data-start=&quot;440&quot;&gt;&lt;b&gt;Build Settings &amp;gt; Versioning&lt;/b&gt;에서
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;559&quot; data-start=&quot;482&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;524&quot; data-start=&quot;482&quot;&gt;&lt;b&gt;Current Project Version&lt;/b&gt;: 빌드 넘버(예: 3)&lt;/li&gt;
&lt;li data-end=&quot;559&quot; data-start=&quot;528&quot;&gt;&lt;b&gt;Version&lt;/b&gt;: 마케팅 버전(예: 1.0.2)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;803&quot; data-start=&quot;665&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;758&quot; data-start=&quot;665&quot;&gt;&lt;b&gt;상단 Scheme에서 &quot;Any iOS Device (arm64)&quot; 선택&lt;/b&gt;&lt;br /&gt;(시뮬레이터 아님! &quot;Generic iOS Device&quot; 또는 실제 기기)&lt;/li&gt;
&lt;li data-end=&quot;803&quot; data-start=&quot;759&quot;&gt;&lt;b&gt;상단 메뉴&lt;/b&gt;에서
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;803&quot; data-start=&quot;779&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;803&quot; data-start=&quot;779&quot;&gt;Product &amp;gt; Archive 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;839&quot; data-start=&quot;805&quot; data-ke-size=&quot;size16&quot;&gt;빌드가 끝나면 &lt;b&gt;Organizer 창&lt;/b&gt;이 자동으로 뜸&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;915&quot; data-start=&quot;880&quot;&gt;&lt;b&gt;Organizer 창&lt;/b&gt;에서 방금 빌드한 아카이브 선택&lt;/li&gt;
&lt;li data-end=&quot;943&quot; data-start=&quot;916&quot;&gt;&lt;b&gt;Distribute App&lt;/b&gt; 버튼 클릭&lt;/li&gt;
&lt;li data-end=&quot;1114&quot; data-start=&quot;944&quot;&gt;배포 방식 선택:
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1114&quot; data-start=&quot;960&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1015&quot; data-start=&quot;960&quot;&gt;&lt;b&gt;App Store Connect&lt;/b&gt;: 앱스토어 업로드(Transporter 없이 직접 가능)&lt;/li&gt;
&lt;li data-end=&quot;1054&quot; data-start=&quot;1019&quot;&gt;&lt;b&gt;Ad Hoc&lt;/b&gt;: 테스트 기기에 직접 설치용 ipa 생성&lt;/li&gt;
&lt;li data-end=&quot;1086&quot; data-start=&quot;1058&quot;&gt;&lt;b&gt;Development&lt;/b&gt;: 개발자 디바이스용&lt;/li&gt;
&lt;li data-end=&quot;1114&quot; data-start=&quot;1090&quot;&gt;&lt;b&gt;Enterprise&lt;/b&gt;: 기업 배포용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1149&quot; data-start=&quot;1115&quot;&gt;&lt;b&gt;배포 타입 선택 후, Xcode 안내에 따라 진행&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1204&quot; data-start=&quot;1150&quot;&gt;마지막 단계에서 &lt;b&gt;Export&lt;/b&gt;하면&lt;br /&gt;&amp;rarr; .ipa 파일이 원하는 경로에 생성됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/148</guid>
      <comments>https://kjk5.tistory.com/148#entry148comment</comments>
      <pubDate>Fri, 25 Jul 2025 12:25:36 +0900</pubDate>
    </item>
    <item>
      <title>expo - ios 빌드 및 출시</title>
      <link>https://kjk5.tistory.com/147</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;0. eas build는 github repo를 기반으로 빌드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;app.json 파일 점검하여&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ios:{buildNumber} 등 체크&lt;br /&gt;1. ipa 생성&lt;/p&gt;
&lt;pre id=&quot;code_1753344517501&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install -g eas-cli

eas login

eas build:configure # (최초 1회)

eas build -p ios --profile production # 필요시 --clear-cache&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 과정을 따른 후&amp;nbsp;&lt;br /&gt;&lt;br /&gt;빌드가 완료되면 EAS 에 접속 해 ipa 를 내려받는다.&lt;br /&gt;&lt;br /&gt;2. apple connect에 전송&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1352&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/149Um/btsPxYyxxAc/oFUY8izV2eXhcteP2hltH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/149Um/btsPxYyxxAc/oFUY8izV2eXhcteP2hltH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/149Um/btsPxYyxxAc/oFUY8izV2eXhcteP2hltH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F149Um%2FbtsPxYyxxAc%2FoFUY8izV2eXhcteP2hltH1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1352&quot; height=&quot;876&quot; data-origin-width=&quot;1352&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 심사 제출&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/147</guid>
      <comments>https://kjk5.tistory.com/147#entry147comment</comments>
      <pubDate>Fri, 25 Jul 2025 00:09:28 +0900</pubDate>
    </item>
    <item>
      <title>Type Guard와 every 메서드의 한계</title>
      <link>https://kjk5.tistory.com/129</link>
      <description>&lt;h2&gt;문제 상황: Type Guard와 &lt;code&gt;every&lt;/code&gt; 메서드의 한계&lt;/h2&gt;
&lt;p&gt;TypeScript는 코드의 타입 안전성을 보장하기 위해 컴파일 시점에 타입을 검사한다. 이 과정에서 &lt;strong&gt;타입 가드&lt;/strong&gt;와 &lt;strong&gt;타입 단언&lt;/strong&gt;을 활용해 컴파일러가 타입을 정확하게 추론하도록 돕는다. 하지만 때로는 타입 가드를 사용했음에도 TypeScript가 배열의 타입을 확신하지 못하는 경우가 생긴다.&lt;/p&gt;
&lt;h3&gt;예시 코드&lt;/h3&gt;
&lt;p&gt;다음 예제에서 &lt;code&gt;result&lt;/code&gt; 배열이 &lt;code&gt;FileSystemFileEntry&lt;/code&gt; 또는 &lt;code&gt;FileSystemDirectoryEntry&lt;/code&gt;로만 구성되어 있는지를 &lt;code&gt;every&lt;/code&gt; 메서드를 통해 확인하고자 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (
    result.length &amp;gt; 0 &amp;amp;&amp;amp;
    result.every(entry =&amp;gt; isFileSystemFileEntry(entry) || isFileSystemDirectoryEntry(entry))
) {
    resolve(result); // 오류 발생: TypeScript는 여전히 result의 타입을 알 수 없음
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;여기서 TypeScript는 &lt;code&gt;result&lt;/code&gt;가 &lt;code&gt;(FileSystemFileEntry | FileSystemDirectoryEntry)[]&lt;/code&gt; 타입임을 보장하지 않는다. 비록 &lt;code&gt;every&lt;/code&gt;로 각 요소의 타입을 확인했지만, &lt;strong&gt;컴파일러는 여전히 &lt;code&gt;result&lt;/code&gt; 배열 전체가 안전한 타입인지 확신하지 못한다&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;왜 발생하는가?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;every&lt;/code&gt; 메서드는 배열의 모든 요소가 조건을 만족하는지 여부만을 반환한다. 즉, &lt;code&gt;true&lt;/code&gt; 또는 &lt;code&gt;false&lt;/code&gt; 값을 반환할 뿐이며, &lt;strong&gt;이 정보만으로는 컴파일러가 &lt;code&gt;result&lt;/code&gt; 전체 배열의 타입을 자동으로 강제할 수 없다&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;타입 추론의 한계&lt;/h3&gt;
&lt;p&gt;타입 가드가 특정 요소의 타입을 좁히는 데는 효과적이지만, &lt;strong&gt;&lt;code&gt;every&lt;/code&gt;와 같은 메서드는 배열 자체의 타입을 좁히지 않는다&lt;/strong&gt;. TypeScript는 &lt;code&gt;every&lt;/code&gt;의 결과가 &lt;code&gt;true&lt;/code&gt;일 때, 개별 요소들이 특정 타입임을 알 수 있을지라도 배열 전체의 타입은 추론하지 않는다. 예를 들어, &lt;code&gt;result&lt;/code&gt;가 &lt;code&gt;FileSystemEntry[]&lt;/code&gt; 타입으로 선언되어 있다면 &lt;code&gt;every&lt;/code&gt; 이후에도 여전히 &lt;code&gt;FileSystemEntry[]&lt;/code&gt; 타입으로 인식하게 된다.&lt;/p&gt;
&lt;p&gt;TypeScript 컴파일러가 불완전하다고 느낄 수도 있지만, 사실 TypeScript는 의도적으로 배열의 전체 타입을 자동으로 추론하지 않도록 설계된 것이다. 그 이유는 TypeScript의 타입 시스템이 정적 타입 검사를 수행하기 때문이다. 즉, 컴파일 시점에만 타입을 검사하며, 런타임에서의 변화는 감지하지 않는다.&lt;/p&gt;
&lt;p&gt;왜 TypeScript는 every의 결과로 타입을 강제하지 않는가?&lt;br&gt;every 메서드는 배열의 모든 요소가 특정 조건을 만족하는지를 검사하지만, 이때 반환되는 값은 오직 true나 false이다. 이렇듯 every는 단순히 배열이 조건을 만족하는지 아닌지를 알려주는 용도로 사용된다. TypeScript는 런타임에서의 조건을 기반으로 배열의 전체 타입을 좁히는 작업을 하지 않는다.&lt;/p&gt;
&lt;p&gt;예를 들어, TypeScript가 result.every(...)의 결과가 true라고 해서, result의 타입을 자동으로 (FileSystemFileEntry | FileSystemDirectoryEntry)[]로 확신한다고 가정해보자. 만약 배열이 변경되거나, 이후 코드에서 다른 타입의 요소가 추가된다면, TypeScript는 컴파일 시점에서 이 변경을 미리 감지할 수 없게 된다. 따라서 컴파일 시점의 타입 검사에서는 every의 검사 결과로 배열 전체 타입을 확신하지 않도록 하고, 대신 개발자가 타입을 직접 좁히거나 명시적으로 지정하도록 하는 것이다.&lt;/p&gt;
&lt;h2&gt;해결 방법&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;every&lt;/code&gt;를 사용할 때 TypeScript가 배열의 타입을 확신하게 하려면, &lt;strong&gt;타입 가드를 통해 배열을 다시 선언&lt;/strong&gt;해줄 필요가 있다. 이를 해결하는 방법은 크게 두 가지로 나뉜다.&lt;/p&gt;
&lt;h3&gt;1. 타입 단언 사용&lt;/h3&gt;
&lt;p&gt;타입 단언을 사용하여 TypeScript에게 &lt;code&gt;result&lt;/code&gt;가 안전한 타입임을 명시할 수 있다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resolve(result as (FileSystemFileEntry | FileSystemDirectoryEntry)[]);&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2. &lt;code&gt;filter&lt;/code&gt;와 타입 가드 조합 사용&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;filter&lt;/code&gt;와 타입 가드를 조합하여 컴파일러가 배열의 타입을 안전하게 추론하도록 만들 수 있다. 이를 통해 타입 단언을 피하면서도 원하는 타입으로 제한할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const validEntries = result.filter(
    (entry): entry is FileSystemFileEntry | FileSystemDirectoryEntry =&amp;gt;
        isFileSystemFileEntry(entry) || isFileSystemDirectoryEntry(entry)
);

resolve(validEntries); // TypeScript는 validEntries가 (FileSystemFileEntry | FileSystemDirectoryEntry)[] 타입임을 확신한다.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;여기서 &lt;code&gt;filter&lt;/code&gt; 메서드는 새로운 배열을 반환하며, 이 배열이 &lt;code&gt;FileSystemFileEntry&lt;/code&gt;와 &lt;code&gt;FileSystemDirectoryEntry&lt;/code&gt;만 포함하고 있다고 TypeScript가 확신할 수 있도록 돕는다. 따라서 타입 단언 없이도 배열의 타입을 안전하게 추론할 수 있다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;TypeScript는 개별 요소에 대한 타입 가드가 사용되더라도 배열 전체 타입을 자동으로 강제하지는 않는다. 이런 경우 &lt;code&gt;filter&lt;/code&gt;와 타입 가드를 조합하여 컴파일러가 타입을 추론하도록 돕거나, 타입 단언을 통해 안전성을 명시해줄 수 있다. 이러한 이해를 통해 TypeScript의 타입 시스템을 더욱 효과적으로 활용할 수 있을 것이다.&lt;/p&gt;</description>
      <author>jaegwan</author>
      <guid isPermaLink="true">https://kjk5.tistory.com/129</guid>
      <comments>https://kjk5.tistory.com/129#entry129comment</comments>
      <pubDate>Mon, 14 Oct 2024 11:45:18 +0900</pubDate>
    </item>
  </channel>
</rss>