<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jae_o.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 05 Jan 2026 14:40:28 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>jae_o.log</title>
            <url>https://velog.velcdn.com/images/jae_o/profile/3c98fac2-0f1c-4c7b-98ff-00104ecf4364/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. jae_o.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jae_o" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[CodePipeline에서 GitHub Actions로, CI/CD 전환기]]></title>
            <link>https://velog.io/@jae_o/CodePipeline%EC%97%90%EC%84%9C-GitHub-Actions%EB%A1%9C-CICD-%EC%A0%84%ED%99%98%EA%B8%B0</link>
            <guid>https://velog.io/@jae_o/CodePipeline%EC%97%90%EC%84%9C-GitHub-Actions%EB%A1%9C-CICD-%EC%A0%84%ED%99%98%EA%B8%B0</guid>
            <pubDate>Mon, 05 Jan 2026 14:40:28 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>본 글은 기존 AWS <code>CodePipeline</code> 기반의 CI/CD 환경을 <code>GitHub Actions</code>로 전환하여 구성한 과정을 정리한 글입니다.</p>
<p>기존 프로젝트에서는 CI는 GitHub Actions를, CD는 AWS CodePipeline과 CodeBuild를 사용하는 구조로 운영되고 있었습니다. 해당 구성은 초기에는 큰 문제 없이 동작하였으나, 프로젝트 구조가 모노레포 형태로 확장되면서 불필요한 소스 다운로드 시간이 증가하였고, CI 설정이 GitHub Actions와 CodeBuild 두 곳에서 중복 관리되는 구조적 비효율이 확인되었습니다.</p>
<p>이에 따라 CI와 CD를 분리하여 운영하던 기존 구조를 재검토하게 되었으며, 설정 통합과 워크플로우 최적화 측면에서 CI/CD 전반을 GitHub Actions로 통합하는 방향이 보다 적합하다고 판단하였습니다.</p>
<p>본 글에서는  </p>
<ul>
<li>AWS CodePipeline에서 GitHub Actions로 전환하게 된 배경과 판단 근거  </li>
<li>GitHub Actions의 개념과 워크플로우 구성 방식  </li>
<li>프로젝트에 적용한 CI/CD 설정 및 Discord 알림 연동  </li>
</ul>
<p>을 중심으로 정리하겠습니다.</p>
<h2 id="전환-배경">전환 배경</h2>
<p>기존 프로젝트는 CI는 GitHub Actions로, CD는 AWS CodePipeline으로 분리하여 운영하고 있었습니다. 그러나 프로젝트 구조가 모노레포로 전환되면서 기존 구성 방식의 한계가 확인되었습니다.</p>
<h3 id="1-모노레포-환경에서의-소스-다운로드-비효율">1. 모노레포 환경에서의 소스 다운로드 비효율</h3>
<p>프로젝트는 <code>web</code>, <code>app</code>, <code>admin</code> 코드를 하나의 저장소에서 관리하는 모노레포 구조로 구성되어 있습니다.</p>
<p>CodePipeline은 트리거 발생 시 저장소 전체를 다운로드하도록 구성되어 있어, 특정 서비스만 배포하는 경우에도 불필요한 코드가 함께 다운로드되었습니다. 특히 React Native 기반의 <code>app</code> 디렉토리 크기가 크다 보니 다운로드 시간이 크게 증가하였습니다.</p>
<ul>
<li>모노레포 도입 이전: 약 5초</li>
<li>모노레포 도입 이후: 40초 이상</li>
</ul>
<p>빌드 이전 단계에서 발생하는 불필요한 대기 시간으로, 배포 효율성을 저하시키는 요인으로 확인되었습니다.</p>
<h3 id="2-ci-설정의-중복-관리">2. CI 설정의 중복 관리</h3>
<p>CI는 GitHub Actions를 통해 PR 생성 및 리뷰 과정에서 자동으로 실행되도록 구성되어 있었습니다. 그러나 CodePipeline 배포 과정에서도 배포 전 CI가 필요하여 CodeBuild에서 동일한 작업을 수행하도록 설정되어 있었습니다.</p>
<p>결과적으로 CI 설정이 GitHub Actions와 CodeBuild 두 곳에서 관리되는 구조였으며, 설정 변경 시 양쪽 모두 수정해야 하는 번거로움이 존재하였습니다. CI는 단일 지점에서 관리하는 것이 적합하다고 판단하였습니다.</p>
<h3 id="3-ui-기반-설정과-레퍼런스-부족">3. UI 기반 설정과 레퍼런스 부족</h3>
<p>CodePipeline의 설정은 대부분 AWS 콘솔 UI를 통해 이루어집니다. UI는 주기적으로 변경되며, 검색을 통해 확인한 자료의 화면과 실제 UI가 일치하지 않는 경우가 많았습니다.</p>
<p>또한 CodePipeline 관련 레퍼런스는 상대적으로 제한적이어서 문제 해결 시 참고 자료를 찾기 어려웠습니다. 반면 GitHub Actions는 YAML 기반 설정과 풍부한 커뮤니티 자료를 통해 설정 의도를 명확히 파악하고 적용하기에 용이하였습니다.</p>
<h3 id="4-설정-변경을-위한-콘솔-접근-비용">4. 설정 변경을 위한 콘솔 접근 비용</h3>
<p>개발 과정에서 GitHub는 항상 열어두고 작업하는 반면, AWS 콘솔은 필요한 경우에만 접근하는 환경이었습니다. 그럼에도 환경 변수나 트리거 변경과 같은 사소한 작업에도 AWS 콘솔 접속이 필요하였습니다.</p>
<p>CI/CD 설정을 코드와 함께 GitHub 저장소에서 관리하는 것이 개발 흐름과 더 자연스럽게 연결된다고 판단하였습니다.</p>
<hr>
<p>위와 같은 배경으로 CI/CD를 GitHub Actions로 통합하여 구성하는 방향으로 전환을 진행하였습니다.</p>
<h2 id="github-actions-개요">GitHub Actions 개요</h2>
<p><a href="https://docs.github.com/ko/actions">GitHub Actions</a>는 GitHub 저장소에서 발생하는 이벤트를 기준으로 자동화된 작업을 실행할 수 있는 CI/CD 도구입니다. 코드 변경, PR 생성, 리뷰 요청과 같은 이벤트가 발생하면 사전에 정의한 워크플로우가 실행되는 구조로 동작합니다.</p>
<h3 id="워크플로우-구성-방식">워크플로우 구성 방식</h3>
<p>GitHub Actions는 저장소 내 <code>.github/workflows</code> 디렉토리에 정의된 YAML 파일을 기준으로 동작합니다. 각 YAML 파일은 하나의 워크플로우를 의미하며, 다음과 같은 정보를 포함합니다.</p>
<ul>
<li><code>on</code>: 어떤 이벤트를 기준으로 실행할 것인지 (<code>push</code>, <code>pull_request</code>, <code>pull_request_review</code> 등)</li>
<li><code>runs-on</code>: 어떤 환경에서 작업을 수행할 것인지</li>
<li><code>jobs</code>, <code>steps</code>: 어떤 작업을 어떤 순서로 실행할 것인지</li>
</ul>
<p>GitHub 저장소에서 이벤트가 발생하면, 해당 이벤트 조건에 부합하는 워크플로우를 탐색하여 실행합니다. 이후 워크플로우에 정의된 잡(Job)이 실행되고, 각 잡 내부의 스텝(Step)이 순차적으로 수행됩니다.</p>
<p>이러한 구조를 통해 &quot;언제 실행할 것인지&quot;와 &quot;무엇을 실행할 것인지&quot;를 명확히 분리하여 관리할 수 있으며, CI/CD를 포함한 다양한 자동화 작업을 코드 기반으로 구성할 수 있습니다.</p>
<p>다음 섹션에서는 본 프로젝트에 적용한 CI/CD 워크플로우의 구체적인 트리거 설정과 작업 실행 순서, 그리고 Discord 알림 연동을 통한 개발 환경 개선 사례를 다루겠습니다.</p>
<h2 id="프로젝트에-적용한-cicd-구성">프로젝트에 적용한 CI/CD 구성</h2>
<p>본 프로젝트의 CI/CD 구성은 다음과 같은 요구사항을 기준으로 설계되었습니다.</p>
<ul>
<li>PR 생성 및 push 시 린트와 타입 체크를 수행하는 CI 자동 실행</li>
<li><code>dev</code>, <code>main</code> 브랜치 머지 시 최종 CI 수행 후 배포까지 자동 진행</li>
<li>CI 로직은 단일 워크플로우로 관리하여 중복 제거</li>
</ul>
<p>이를 위해 다음과 같이 3개의 워크플로우 파일로 구성하였습니다.</p>
<h3 id="워크플로우-파일-구조">워크플로우 파일 구조</h3>
<p>전체 워크플로우는 다음과 같은 파일로 구성되어 있습니다.</p>
<p><strong><code>frontend-ci-base.yml</code></strong><br>공통 CI 로직을 정의한 재사용 가능 워크플로우입니다. <code>workflow_call</code> 이벤트를 통해 다른 워크플로우에서 호출할 수 있도록 구성하였습니다.</p>
<p><strong><code>frontend-pr-ci.yml</code></strong><br>PR 단계에서 실행되는 워크플로우로, <code>frontend-ci-base.yml</code>을 호출하여 코드 품질 검증을 수행합니다.</p>
<p><strong><code>frontend-deploy.yml</code></strong><br><code>dev</code>, <code>main</code> 브랜치에 대한 빌드 및 배포를 수행하는 워크플로우입니다. CI 수행 후 S3 업로드와 CloudFront 캐시 무효화를 순차적으로 진행합니다.</p>
<h3 id="공통-ci-워크플로우-frontend-ci-baseyml">공통 CI 워크플로우 (<code>frontend-ci-base.yml</code>)</h3>
<p>공통 CI 워크플로우는 소스 체크아웃부터 빌드까지의 전체 과정을 포함하며, <code>run-build</code> 입력값에 따라 빌드 수행 여부를 제어할 수 있도록 구성하였습니다.</p>
<pre><code class="language-yaml">name: Frontend CI Base

on:
  workflow_call:
    inputs:
      run-build:
        required: false
        type: boolean
        default: false</code></pre>
<p>워크플로우는 다음 단계로 구성됩니다.</p>
<ol>
<li><p><strong>소스 체크아웃 및 pnpm 환경 설정</strong><br>Corepack을 활성화하고 pnpm 10.12.1 버전을 설정하였습니다.</p>
</li>
<li><p><strong>의존성 캐싱</strong><br>pnpm store를 캐싱하여 반복 실행 시 설치 시간을 단축하였습니다.</p>
</li>
<li><p><strong>린트 및 타입 체크</strong><br>모든 실행에서 공통적으로 수행됩니다.</p>
</li>
<li><p><strong>조건부 빌드</strong><br><code>run-build</code> 입력값이 <code>true</code>일 경우에만 빌드를 수행합니다.</p>
</li>
</ol>
<pre><code class="language-yaml">- name: Build
  if: inputs.run-build == true
  env:
    CHANNEL_TALK_PLUGIN_KEY: ${{ secrets.CHANNEL_TALK_PLUGIN_KEY }}
    SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
    CLARITY_PROJECT_ID: ${{ secrets.CLARITY_PROJECT_ID }}
    API_BASE_URL: ${{ vars.API_BASE_URL }}
    MONITORING_STATUS_URL: ${{ vars.MONITORING_STATUS_URL }}
    SERVER_TYPE: ${{ vars.SERVER_TYPE }}
  run: pnpm run web:build</code></pre>
<p>빌드가 수행될 경우, 결과물은 아티팩트로 업로드되어 후속 job에서 사용할 수 있도록 구성하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/cac60647-a1e0-4f3c-beb0-431e89688333/image.png" alt=""></p>
<p>실제 워크플로우 실행 시에는 위와 같이 Setup, Checkout, pnpm 환경 구성, 캐싱, 의존성 설치, Lint, Type Check, Build, 아티팩트 업로드 순서로 단계가 진행됩니다.</p>
<h3 id="pr-단계-ci-frontend-pr-ciyml">PR 단계 CI (<code>frontend-pr-ci.yml</code>)</h3>
<p>PR이 생성되거나 업데이트될 때 자동으로 실행되며, <code>pull_request</code> 이벤트를 트리거로 사용합니다.</p>
<pre><code class="language-yaml">name: Frontend PR CI

on:
  pull_request:

jobs:
  pre-build:
    uses: ./.github/workflows/frontend-ci-base.yml
    with:
      run-build: false</code></pre>
<p>PR 단계에서는 빌드를 수행하지 않고 린트와 타입 체크만 실행하도록 <code>run-build: false</code>를 전달하였습니다. 이를 통해 코드 변경에 대한 빠른 피드백을 제공하면서 불필요한 빌드 비용을 줄일 수 있습니다.</p>
<h3 id="배포-워크플로우-frontend-deployyml">배포 워크플로우 (<code>frontend-deploy.yml</code>)</h3>
<p><code>dev</code>, <code>main</code> 브랜치에 코드가 push될 때 실행되며, 빌드부터 배포까지 전체 과정을 자동화하였습니다.</p>
<pre><code class="language-yaml">on:
  push:
    branches:
      - main
      - dev

concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: true</code></pre>
<p><code>concurrency</code> 설정을 통해 동일 브랜치에서 배포가 중복 실행되는 것을 방지하였습니다. 새로운 배포가 시작되면 진행 중인 배포는 취소됩니다.</p>
<h4 id="1-build-job">1. Build Job</h4>
<p>배포 워크플로우는 먼저 공통 CI 워크플로우를 호출하여 빌드까지 수행합니다.</p>
<pre><code class="language-yaml">jobs:
  build:
    uses: ./.github/workflows/frontend-ci-base.yml
    secrets: inherit
    with:
      run-build: true</code></pre>
<p><code>secrets: inherit</code>를 통해 환경별 시크릿 값을 전달하며, 빌드 결과물은 아티팩트로 저장됩니다.</p>
<h4 id="2-deploy-job">2. Deploy Job</h4>
<p>빌드가 완료되면 아티팩트를 다운로드하여 S3에 업로드하고, CloudFront 캐시를 무효화합니다.</p>
<pre><code class="language-yaml">deploy:
  runs-on: ubuntu-latest
  needs: build
  environment: ${{ github.ref_name == &#39;main&#39; &amp;&amp; &#39;prod&#39; || &#39;dev&#39; }}</code></pre>
<p>현재 브랜치를 기준으로 실행 환경(<code>prod</code> 또는 <code>dev</code>)을 자동으로 결정하며, 각 환경에 정의된 시크릿과 변수를 사용합니다.</p>
<p>S3 업로드는 <code>--delete</code> 옵션을 사용하여 삭제된 파일도 반영되도록 구성하였습니다.</p>
<pre><code class="language-yaml">- name: Upload to S3
  run: |
    aws s3 sync web/dist s3://${{ secrets.S3_BUCKET }}/build-origin --delete</code></pre>
<p>배포 후에는 CloudFront 캐시를 전체 무효화하여 변경사항이 즉시 반영되도록 하였습니다.</p>
<pre><code class="language-yaml">- name: CloudFront Invalidation
  run: |
    aws cloudfront create-invalidation \
      --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
      --paths &quot;/*&quot;</code></pre>
<h4 id="3-notify-job">3. Notify Job</h4>
<p>배포가 완료되면 다음에 설명드릴 Discord로 알림을 전송합니다. 환경별로 다른 메시지와 색상을 사용하여 운영 배포와 개발 배포를 구분할 수 있도록 구성하였습니다.</p>
<pre><code class="language-yaml">notify:
  runs-on: ubuntu-latest
  needs: deploy

  steps:
    - name: Send Post-Deploy Notification
      uses: ./.github/actions/discord-notify
      with:
        webhook: ${{ secrets.DISCORD_WEBHOOK_DEPLOY }}
        content: ${{ github.ref_name == &#39;main&#39; &amp;&amp; &#39;🚀 **운영 서버에 새로운 버전이 배포되었습니다!**&#39; || &#39;🧪 **개발 서버에 새로운 버전이 배포되었습니다!**&#39; }}</code></pre>
<p><img src="https://velog.velcdn.com/images/jae_o/post/1ea9ea2d-cbb0-4b56-a919-e654f2cbb569/image.png" alt=""></p>
<p>전체 배포 워크플로우는 위와 같이 build (53초) → deploy (18초) → notify (5초) 순서로 실행되며, 각 job이 순차적으로 완료되는 것을 확인할 수 있습니다.</p>
<h3 id="환경별-변수-관리">환경별 변수 관리</h3>
<p>환경별 설정은 GitHub Actions의 Environment 기능을 활용하여 관리하였습니다. <code>prod</code>와 <code>dev</code> 환경을 분리하여 각각에 필요한 변수와 시크릿을 정의하였으며, 워크플로우에서는 브랜치명을 기준으로 자동으로 환경을 선택합니다.</p>
<p>민감한 값(API Key, AWS 자격 증명 등)은 Secrets로, 비민감 설정(API URL, 서버 타입 등)은 Variables로 구분하여 관리하였습니다. 이를 통해 코드 수정 없이 환경별 설정을 유연하게 변경할 수 있습니다.</p>
<hr>
<p>이와 같은 구조를 통해 CI 로직은 단일 워크플로우에서 관리하면서도, PR 검증과 배포 과정을 명확히 분리하여 운영할 수 있도록 구성하였습니다.</p>
<h2 id="discord-알림을-통한-개발-환경-개선">Discord 알림을 통한 개발 환경 개선</h2>
<p>GitHub Actions를 활용한 CI/CD 구성 외에도, PR 리뷰 과정에서의 커뮤니케이션 효율을 개선하기 위해 Discord 알림 기능을 추가로 구성하였습니다.</p>
<p>기존에는 PR 생성, 리뷰 요청, 승인 등의 상태 변화를 확인하기 위해 GitHub 페이지를 주기적으로 확인해야 했습니다. 이러한 방식은 알림 확인이 지연되거나 누락될 가능성이 존재하였으며, 개발 흐름이 단절되는 요인으로 작용하였습니다.</p>
<p>이에 따라 PR 리뷰 프로세스의 주요 시점마다 Discord로 자동 알림을 전송하도록 구성하여, GitHub를 별도로 확인하지 않아도 리뷰 상태를 즉시 파악할 수 있도록 개선하였습니다. 구성한 알림은 다음과 같습니다.</p>
<ol>
<li>PR 생성 시 리뷰어에게 알림</li>
<li>Request Changes 시 PR 작성자에게 알림</li>
<li>Re-review 요청 시 리뷰어에게 알림</li>
<li>최소 2명 이상 승인 완료 시 알림</li>
</ol>
<h3 id="custom-action을-통한-알림-기능-공통화">Custom Action을 통한 알림 기능 공통화</h3>
<p>Discord 알림은 PR 생성, 리뷰 요청, 승인 완료, 배포 완료 등 여러 시점에서 사용됩니다. 각 워크플로우에서 동일한 알림 로직을 반복 작성하는 대신, Custom Action으로 공통화하여 재사용할 수 있도록 구성하였습니다.</p>
<pre><code class="language-yaml">name: &#39;Discord Notify&#39;
description: &#39;Send Discord webhook&#39;

# 사용처에서 받고자 하는 input
inputs:
  webhook:
    description: &#39;Discord Webhook URL&#39;
    required: true
  content:
    description: &#39;Notification content&#39;
    required: true
  mentions:
    description: &#39;Notification mentions&#39;
    required: false
  title:
    description: &#39;Embed title&#39;
    required: true
  url:
    description: &#39;Embed URL&#39;
    required: true
  color:
    description: &#39;Embed color&#39;
    required: false
    default: &#39;15158332&#39;
  fields:
    description: &#39;Embed fields&#39;
    required: false
    default: &#39;[]&#39;

runs:
  using: &#39;composite&#39;
  steps:
    - shell: bash
      run: |
        RAW_FIELDS=&#39;${{ inputs.fields }}&#39;

        FINAL_FIELDS=$(echo &quot;$RAW_FIELDS&quot; | jq &#39;
          map(. + {inline: true})
        &#39;)

        curl -X POST \
          -H &quot;Content-Type: application/json&quot; \
          # 알림 내용
          -d &quot;{
            \&quot;username\&quot;: \&quot;FE Bot\&quot;,
            \&quot;content\&quot;: \&quot;${{ inputs.content }}\n${{ inputs.mentions }}\&quot;,
            \&quot;embeds\&quot;: [
              {
                \&quot;title\&quot;: \&quot;${{ inputs.title }}\&quot;,
                \&quot;url\&quot;: \&quot;${{ inputs.url }}\&quot;,
                \&quot;color\&quot;: ${{ inputs.color }},
                \&quot;fields\&quot;: $FINAL_FIELDS,
                \&quot;footer\&quot;: { \&quot;text\&quot;: \&quot;자동 알림 메시지입니다\&quot; }
              }
            ]
          }&quot; \
          &quot;${{ inputs.webhook }}&quot;</code></pre>
<p>이 Action은 Discord Webhook을 통해 메시지를 전송하며, 알림 내용, 멘션 대상, Embed 형식 등을 입력값으로 받아 유연하게 구성할 수 있도록 설계하였습니다. <code>fields</code> 입력값은 JSON 배열로 전달받아 <code>jq</code>를 통해 <code>inline: true</code> 속성을 추가한 뒤 전송합니다.</p>
<p>다른 워크플로우에서 이 Action을 사용할 때는 폴더 경로를 참조하는 방식으로 호출합니다.</p>
<pre><code class="language-yaml">- name: Send Notification
  uses: ./.github/actions/discord-notify
  with:
    webhook: ${{ secrets.DISCORD_WEBHOOK_PR }}
    content: &quot;알림 내용&quot;</code></pre>
<blockquote>
<p><strong>중요:</strong> Custom Action을 사용할 때는 파일 경로가 아닌 <strong>폴더 경로</strong>를 지정해야 합니다. <code>.github/actions/discord-notify/action.yml</code> 경로에 정의하고, GitHub Actions는 지정된 폴더 내의 <code>action.yml</code> 파일을 자동으로 탐색하여 실행합니다.</p>
</blockquote>
<p>Discord Webhook URL 생성 방법과 Discord ID 확인 방법은 다음 문서를 참고할 수 있습니다.</p>
<ul>
<li><a href="https://let-d0-study.tistory.com/entry/Github-Actions-Discord-WebHook-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%95%8C%EB%A6%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0">Discord WebHook 사용해서 알림 보내기</a></li>
<li><a href="https://github.com/marketplace/actions/discord-webhook-action">Discord Webhook Action (GitHub Marketplace)</a></li>
</ul>
<h3 id="pr-생성-알림-구성">PR 생성 알림 구성</h3>
<p>PR 생성 시 알림을 예시로 전체 구성 방식을 설명하겠습니다. PR이 생성되면 프론트엔드 팀원들에게 리뷰 요청 알림이 전송됩니다.</p>
<pre><code class="language-yaml">name: PR Open Notifications

on:
  pull_request:
    types: [opened, reopened, ready_for_review]

jobs:
  notify-reviewers:
    runs-on: ubuntu-latest
    if: github.event.pull_request.draft == false</code></pre>
<p><code>ready_for_review</code> 이벤트를 포함하여 Draft PR이 Ready 상태로 전환될 때도 알림이 발송되도록 구성하였으며, Draft 상태에서는 알림이 전송되지 않도록 조건을 설정하였습니다.</p>
<p>워크플로우는 다음 작업을 순차적으로 수행합니다.</p>
<p><strong>1) Discord 멘션 대상 구성</strong></p>
<p><code>.github/notify_ids.json</code> 파일에 GitHub 계정과 Discord ID를 매핑하여 관리하고 있으며, 이를 기반으로 멘션 대상을 동적으로 구성합니다.</p>
<pre><code class="language-yaml">- name: Load Reviewers from JSON
  id: get-reviewers
  uses: actions/github-script@v6
  with:
    script: |
      const fs = require(&#39;fs&#39;);
      const mapping = JSON.parse(fs.readFileSync(&#39;.github/notify_ids.json&#39;, &#39;utf8&#39;));
      const members = Object.keys(mapping);

      const author = context.payload.pull_request.user.login;
      # 멘션 대상에 author 제외
      const filtered = members.filter(member =&gt; member !== author);

      const mentions = filtered
      # 디스코드 멘션 텍스트로 변경
        .map(login =&gt; `&lt;@${mapping[login]}&gt;`)
        .join(&#39; &#39;);

      # 다음 단계에서 사용할 수 있도록 output 설정
      core.setOutput(&quot;discord_mentions&quot;, mentions);</code></pre>
<p>PR 작성자를 제외한 팀원들을 대상으로 Discord 멘션을 생성하며, 이 값은 다음 단계에서 알림 전송 시 사용됩니다.</p>
<p><strong>2) Discord 알림 전송</strong></p>
<pre><code class="language-yaml">- name: Send PR Open Notification
  uses: ./.github/actions/discord-notify
  with:
    webhook: ${{ secrets.DISCORD_WEBHOOK_PR }}
    content: &quot;🚀 새 PR이 생성되었습니다! 리뷰 부탁드립니다 🙏&quot;
    # get-reviewers 단계에서 보내준 멘션 텍스트 사용
    mentions: ${{ steps.get-reviewers.outputs.discord_mentions }}
    title: ${{ github.event.pull_request.title }}
    url: ${{ github.event.pull_request.html_url }}
    color: 16753920
    fields: |
      [
        { &quot;name&quot;: &quot;작성자&quot;, &quot;value&quot;: &quot;${{ github.event.pull_request.user.login }}&quot; }
      ]</code></pre>
<p>앞서 정의한 Custom Action을 사용하여 리뷰어들에게 멘션과 함께 알림을 전송합니다.</p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/31da2d61-b099-4c85-9e73-71a8fa31c2b7/image.png" alt=""></p>
<p>실제 Discord 채널에서는 위와 같이 PR 제목, 작성자 정보와 함께 멘션된 팀원들에게 알림이 전송됩니다.</p>
<h3 id="다른-알림-트리거-구성">다른 알림 트리거 구성</h3>
<p>Request Changes, Re-review 요청, 승인 완료 알림은 PR 생성 알림과 유사한 구조로 구성되며, 트리거 이벤트와 알림 메시지만 변경하여 적용하였습니다.</p>
<p>각 알림의 트리거 이벤트는 다음과 같습니다.</p>
<ul>
<li><strong>Request Changes</strong>: <code>pull_request_review</code> 이벤트의 <code>submitted</code> 타입, <code>changes_requested</code> 상태</li>
<li><strong>Re-review 요청</strong>: <code>pull_request</code> 이벤트의 <code>review_requested</code> 타입</li>
<li><strong>승인 완료</strong>: <code>pull_request_review</code> 이벤트의 <code>submitted</code> 타입, 승인 수 2개 이상 조건</li>
</ul>
<p>트리거 이벤트 설정에 대한 자세한 내용은 <a href="https://docs.github.com/ko/actions/reference/workflows-and-actions/events-that-trigger-workflows">GitHub Docs - 워크플로를 트리거하는 이벤트</a>에서 확인할 수 있습니다.</p>
<h3 id="알림-구성의-효과-및-확장-가능성">알림 구성의 효과 및 확장 가능성</h3>
<p>이러한 알림 설정을 통해 다음과 같은 개선 효과를 확인할 수 있었습니다.</p>
<ul>
<li><strong>리뷰 응답 속도 개선</strong>: GitHub 페이지를 주기적으로 확인하지 않아도 Discord 알림을 통해 즉시 리뷰 요청을 인지할 수 있습니다.</li>
<li><strong>컨텍스트 스위칭 감소</strong>: 개발 중 Discord로 알림을 받아 필요한 시점에만 GitHub로 이동하여 리뷰를 수행할 수 있습니다.</li>
<li><strong>리뷰 상태 추적 용이</strong>: PR 생성부터 승인까지의 전체 흐름을 Discord 채널에서 확인할 수 있어 리뷰 진행 상황을 파악하기 쉬워졌습니다.</li>
</ul>
<p>Discord 알림 설정은 CI/CD 자동화와 함께 개발 프로세스 전반의 효율성을 개선하는 데 기여하였습니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>본 글에서는 AWS CodePipeline에서 GitHub Actions로 CI/CD 환경을 전환한 과정과 Discord 알림을 통한 개발 환경 개선 사례를 정리하였습니다.</p>
<p>전환 과정에서 가장 중점을 둔 부분은 <strong>설정의 중복 제거와 재사용성 확보</strong>였습니다. <code>workflow_call</code>을 활용한 공통 CI 워크플로우 설계, Custom Action을 통한 알림 기능 공통화를 통해 동일한 로직이 여러 곳에서 관리되는 구조를 제거할 수 있었습니다.</p>
<p>이 과정에서 <strong>GitHub Actions의 워크플로우 파일 또한 코드</strong>라는 점을 다시 확인할 수 있었습니다. 일반적인 애플리케이션 코드와 마찬가지로 가독성과 재사용성을 고려하여 설계해야 하며, 중복을 제거하고 변경에 유연하게 대응할 수 있는 구조로 구성해야 합니다. CI/CD 설정을 코드로 관리함으로써 변경 이력 추적과 협업이 용이해졌으며, GitHub 중심의 개발 흐름과 자연스럽게 통합할 수 있었습니다.</p>
<p>본 글이 CI/CD 구성 시 다음 사항을 고려하는 데 참고가 되기를 바랍니다.</p>
<ul>
<li>워크플로우 설정도 코드로 접근하여 가독성과 재사용성을 고려할 것</li>
<li>공통 로직은 재사용 가능한 형태로 설계할 것</li>
<li>설정은 코드로 관리하여 변경 이력과 협업을 용이하게 할 것</li>
<li>개발 도구를 통합하여 컨텍스트 스위칭을 최소화할 것</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GA4 사용자 행동 데이터 설계 (2) - React 구현]]></title>
            <link>https://velog.io/@jae_o/React%EC%97%90%EC%84%9C-GA4-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jae_o/React%EC%97%90%EC%84%9C-GA4-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 15 Dec 2025 07:28:20 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>본 글은 <a href="https://velog.io/@jae_o/Google-Analytics-4%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%96%89%EB%8F%99-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-1-%EC%88%98%EC%A7%91-%EB%B6%84%EC%84%9D-%ED%99%9C%EC%9A%A9-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0">GA4 사용자 행동 데이터 설계 (1)</a>의 후속 글입니다.</p>
<p>1편에서는 사용자 행동 데이터를 어떤 구조로 수집하고, 어떤 기준으로 분석 및 활용할 것인지에 대해 정리하였습니다.</p>
<p>이번 글에서는 해당 설계를 바탕으로, React 환경에서 GA4를 실제로 어떻게 설정하였는지를 정리합니다.</p>
<hr>
<p>GA4 설정 방식을 직접 선택한 이유</p>
<p>React 환경에서 GA4를 연동할 때, <a href="https://www.npmjs.com/package/react-ga4"><code>react-ga4</code></a>와 같은 라이브러리를 사용하는 방식도 고려할 수 있습니다.</p>
<p>다만 본 프로젝트에서는 GA4를 직접 설정하는 방식을 선택하였습니다.</p>
<ul>
<li>사용을 고려하였던 라이브러리의 경우 마지막 업데이트 시점이 오래되어 있었고,</li>
<li>GA 정책 변경이나 브라우저 환경 변화에 대한 대응이 제한적일 수 있다고 판단하였습니다.</li>
<li>또한 본 서비스에서 필요로 하는 기능이 초기화, 이벤트 수집, 사용자 식별 정도로 비교적 명확하였기 때문에 외부 라이브러리에 의존할 필요가 크지 않았습니다.</li>
</ul>
<p>설정 난이도도 높지 않았고, 직접 구성하면 동작 흐름을 더 명확히 파악할 수 있다고 판단했습니다.</p>
<h2 id="ga-기본-설정">GA 기본 설정</h2>
<p>GA를 사용하기 위해서는 우선 GA 스크립트를 로드하고, 이벤트를 전송할 수 있는 기본 환경을 구성해야 합니다.</p>
<p>이 섹션에서는 GA가 정상적으로 동작하기 위해 반드시 필요한 최소한의 설정을 중심으로, 초기화 과정과 이벤트 수집 방식을 정리합니다.</p>
<hr>
<h3 id="initga-함수"><code>initGA</code> 함수</h3>
<p>GA4 초기화는 gtag.js 스크립트를 로드하고, window.gtag와 dataLayer를 설정하는 과정으로 시작합니다.</p>
<pre><code class="language-ts">declare global {
  interface Window {
    dataLayer: unknown[];
    gtag?: (...args: unknown[]) =&gt; void;
  }
}

export const initGA = (
  googleAnalyticsId: string,
  gtagUrl: string = &#39;https://www.googletagmanager.com/gtag/js&#39;,
) =&gt; {
  if (!googleAnalyticsId) {
    console.warn(&#39;[GA] Measurement ID missing&#39;);
    return;
  }

  // gtag.js 삽입
  const script = document.createElement(&#39;script&#39;);
  script.async = true;
  script.src = `${gtagUrl}?id=${googleAnalyticsId}`;
  document.head.appendChild(script);

  // gtag 초기화
  window.dataLayer = window.dataLayer || [];
  window.gtag = function gtag() {
    window.dataLayer.push(arguments);
  };

  window.gtag(&#39;js&#39;, new Date());

  // SPA 환경에서는 page_view를 직접 제어
  window.gtag(&#39;config&#39;, googleAnalyticsId, {
    send_page_view: false,
  });
};</code></pre>
<ul>
<li>React SPA 환경에서는 라우팅 변경 시점을 직접 제어하기 위해 자동 <code>page_view</code> 전송을 비활성화하였습니다.</li>
</ul>
<h3 id="gainitializer-컴포넌트"><code>GAInitializer</code> 컴포넌트</h3>
<p>초기화 로직은 앱 실행 시 한 번만 수행되도록 별도의 컴포넌트로 분리하였습니다.</p>
<pre><code class="language-ts">const GAInitializer = () =&gt; {
  useEffect(() =&gt; {
    if (!isProduction) return;

    initGA(GOOGLE_ANALYTICS_ID);
  }, []);

  return null;
};</code></pre>
<ul>
<li>개발 환경에서는 불필요한 데이터 수집을 방지하기 위해 production 환경에서만 초기화가 수행되도록 구성하였습니다.</li>
</ul>
<h3 id="이벤트-수집">이벤트 수집</h3>
<p>사용자 행동을 GA 이벤트로 기록하기 위해 공통으로 사용할 trackEvent 함수를 정의하였습니다.</p>
<p>GA4에는 Universal Analytics의 <code>category</code> / <code>action</code> / <code>label</code> 개념이 존재하지 않지만, 이벤트를 의미 단위로 묶어 분석하기 위해 커스텀 파라미터 형태로 유사한 구조를 유지하였습니다.</p>
<pre><code class="language-ts">interface TrackEventParams {
  category: string;
  action: string;
  label?: string;
  value?: number;
}

export const trackEvent = ({
  category,
  action,
  label,
  value,
}: TrackEventParams) =&gt; {
  if (typeof window.gtag !== &#39;function&#39;) return;

  window.gtag(&#39;event&#39;, action, {
    event_category: category,
    event_label: label,
    value,
  });
};</code></pre>
<p>사용 예시는 다음과 같습니다.</p>
<pre><code class="language-ts">&lt;button
  onClick={() =&gt; {
    handleClick();
    trackEvent({
      category: &#39;Navigation&#39;,
      action: &#39;로그인 버튼 클릭&#39;,
      label: &#39;Header Login Button&#39;,
    });
  }}
&gt;
  로그인
&lt;/button&gt;</code></pre>
<p>이와 같은 방식으로 이벤트를 정의하여, 개별 이벤트 자체보다 사용자 행동 흐름 단위로 분석할 수 있도록 구성하였습니다.</p>
<h2 id="ga-데이터-해석을-위한-추가-설정">GA 데이터 해석을 위한 추가 설정</h2>
<p>앞선 기본 설정만으로도 GA 이벤트 수집은 가능하지만, 해당 상태로는 데이터를 해석하는 데 한계가 존재하였습니다.</p>
<p>이 섹션에서는 수집된 데이터를 페이지 단위, 사용자 단위, 접속 환경 단위로 해석하기 위해 추가로 설정한 내용을 정리합니다.</p>
<h3 id="페이지별-타이틀-설정">페이지별 타이틀 설정</h3>
<p>GA4 연동 이후 확인된 문제 중 하나는 모든 페이지의 title이 동일하게 수집되는 현상이었습니다.</p>
<p>React와 같은 SPA 환경에서는 라우팅이 변경되더라도 <code>document.title</code>이 자동으로 변경되지 않기 때문에, GA 리포트 상에서도 모든 페이지가 동일한 이름으로 집계되고 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/46db3e0f-841f-4529-a117-0cb90e0d98a8/image.png" alt=""></p>
<p>이 상태에서는 페이지 단위의 사용자 행동을 해석하기 어렵다고 판단하였습니다.</p>
<hr>
<h4 id="useeffect를-이용한-페이지-타이틀-설정">useEffect를 이용한 페이지 타이틀 설정</h4>
<p>가장 먼저 적용한 방식은 라우팅 변경을 감지하여 <code>document.title</code>을 직접 변경하는 방법이었습니다.</p>
<pre><code class="language-ts">const TITLE_MAP: Record&lt;string, string&gt; = {
  &#39;/&#39;: &#39;봄봄 | 오늘의 뉴스레터&#39;,
  &#39;/storage&#39;: &#39;봄봄 | 뉴스레터 보관함&#39;,
  &#39;/recommend&#39;: &#39;봄봄 | 뉴스레터 추천&#39;,
  &#39;/login&#39;: &#39;봄봄 | 로그인&#39;,
  &#39;/signup&#39;: &#39;봄봄 | 회원가입&#39;,
};

const PageTitle = () =&gt; {
  const location = useLocation();

  useEffect(() =&gt; {
    let title = &#39;봄봄&#39;;

    // 동적 라우트는 prefix 매칭으로 처리
    if (location.pathname.startsWith(&#39;/articles/&#39;)) {
      title = &#39;봄봄 | 아티클 상세&#39;;
    } else {
      title = TITLE_MAP[location.pathname] ?? &#39;봄봄&#39;;
    }

    document.title = title;
  }, [location.pathname]);

  return null;
};</code></pre>
<p>이 방식은 구현이 단순하고, GA에서 페이지별 타이틀이 정상적으로 구분되는 것을 빠르게 확인할 수 있다는 장점이 있었습니다.</p>
<p>다만, 페이지 타이틀 관리 로직이 라우팅 로직과 분리된 상태로 존재하게 되면서, 페이지 메타 정보가 여러 곳에 흩어질 수 있다는 점은 아쉬운 부분이었습니다.</p>
<h4 id="tanstack-router의-document-head-management-활용">TanStack Router의 Document Head Management 활용</h4>
<p>프로젝트에서는 TanStack Router를 사용하고 있으며, 해당 라우터는 라우트 정의 단계에서 문서 헤더를 관리할 수 있는 기능을 제공합니다.</p>
<p>현재 프로젝트에서는 페이지별 타이틀과 메타 정보를 라우트 단위로 정의하는 방식으로 전환하였습니다.</p>
<pre><code class="language-ts">export const Route = createFileRoute(&#39;/_bombom/storage&#39;)({
  head: () =&gt; ({
    meta: [
      {
        title: &#39;봄봄 | 뉴스레터 보관함&#39;,
      },
    ],
  }),
  component: () =&gt; (
    &lt;RequireLogin&gt;
      &lt;Storage /&gt;
    &lt;/RequireLogin&gt;
  ),
});</code></pre>
<p>이 방식의 경우</p>
<ul>
<li>페이지 타이틀과 메타 정보가 라우트 정의와 함께 관리되며</li>
<li>document.title을 직접 제어할 필요가 없고</li>
<li>GA를 포함한 모든 문서 기반 도구에서 동일한 메타 정보를 활용할 수 있습니다.</li>
</ul>
<p>결과적으로 페이지 단위의 타이틀 관리가 라우팅 구조와 자연스럽게 결합되었고, GA 리포트에서도 페이지별 데이터 해석이 더욱 명확해졌습니다.</p>
<p>프로젝트의 라우터 구성과 구조에 따라 두 방식 중 적절한 방법을 선택하여 적용할 수 있습니다.</p>
<p>페이지별 타이틀 설정을 적용한 결과는 아래 사진과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/22e9a225-799c-4d11-bb47-68e27ad10302/image.png" alt=""></p>
<h3 id="사용자-식별-user_id">사용자 식별 (user_id)</h3>
<p>GA4는 사용자를 단일 기준으로 식별하지 않고, <code>User ID</code> → <code>Device ID</code> → <code>모델링된 데이터</code>의 우선순위를 기반으로 사용자를 식별합니다.</p>
<p>이 중 User ID는 개발자가 직접 전달해야 하는 값이며, 설정되지 않은 경우 GA는 자동으로 생성한 Device ID를 기준으로 사용자 식별을 수행합니다.</p>
<p>웹 환경에서 Device ID는 브라우저 및 쿠키 기반으로 관리되기 때문에, 동일한 로그인 사용자라도 웹과 모바일 등 서로 다른 기기나 접속 환경에서는 각각 다른 사용자로 인식될 수 있습니다.</p>
<p>이러한 한계를 보완하기 위해, 로그인 기반 서비스에서는 User ID를 설정하여 동일 사용자의 여러 기기·브라우저 접속을 하나의 사용자로 통합하여 분석하는 것이 바람직합니다.</p>
<p>본 프로젝트는 Web / iOS / Android 환경 모두에 배포되어 있어, 동일 사용자를 고유한 사용자로 인식하기 위해 User ID를 설정하였습니다.</p>
<hr>
<h4 id="초기-서비스-진입-시">초기 서비스 진입 시</h4>
<p>서비스 진입 시 사용자 정보를 조회한 뒤, 로그인된 사용자라면 user_id를 GA에 설정합니다.</p>
<pre><code class="language-ts">const user = await queryClient.fetchQuery(queries.userProfile());

if (user) {
  window.gtag?.(&#39;set&#39;, { user_id: user.id });
}</code></pre>
<p>이 방식은 GA 설정을 다시 초기화하지 않고, 현재 측정 컨텍스트에 User ID만 추가하는 형태입니다.</p>
<h4 id="로그아웃-시">로그아웃 시</h4>
<p>로그아웃 이후에도 이전 User ID가 유지되는 것을 방지하기 위해, 로그아웃 mutation이 성공했을 때 User ID를 초기화합니다.</p>
<pre><code class="language-ts">onSuccess: () =&gt; {
  window.gtag?.(&#39;set&#39;, { user_id: null });
  window.location.reload();
},</code></pre>
<p>이를 통해 로그아웃 이후 발생하는 이벤트가 이전 사용자와 연결되지 않도록 처리하였습니다.</p>
<h3 id="useragent-설정-webview-환경">userAgent 설정 (WebView 환경)</h3>
<p>초기에는 WebView 환경에서 웹에서 필요한 정보만을 userAgent로 설정하였습니다.</p>
<p>구체적으로는,</p>
<ul>
<li>앱 버전 정보</li>
<li>Android / iOS 구분을 위한 식별 정보</li>
</ul>
<p>와 같은 커스텀 정보만을 userAgent에 포함하도록 구성하였습니다.</p>
<p>그러나 이와 같이 커스텀 정보만 userAgent로 설정하였을 경우, GA에서는 해당 접속을 정상적인 기기 정보로 인식하지 못하는 문제가 발생하였습니다.</p>
<p>이는 GA가 기기 카테고리(Desktop / Mobile), OS, 브라우저 여부 등을 userAgent 문자열에 포함된 기본 agent 정보를 기준으로 판단하기 때문입니다.</p>
<p>기본 agent 정보가 누락된 상태에서는, GA가 접속 환경을 올바르게 해석할 수 없었습니다.</p>
<hr>
<h4 id="해결-방식">해결 방식</h4>
<p>이 문제를 해결하기 위해, 커스텀 userAgent를 단독으로 사용하는 대신 기본 agent 정보인 <code>navigator.userAgent</code>를 함께 포함하도록 수정하였습니다.</p>
<pre><code class="language-ts">&lt;WebView
  ref={webViewRef}
  source={{ uri: ENV.webUrl }}
  userAgent={`${navigator.userAgent} ${WEBVIEW_CUSTOM_USER_AGENT}`}
  ...
/&gt;</code></pre>
<p>이와 같이 구성함으로써, <code>navigator.userAgent</code>를 통해 기기 종류 및 플랫폼 정보가 정상적으로 전달되고 추가한 커스텀 문자열을 통해 앱 버전 및 WebView 접속 여부를 함께 식별할 수 있도록 하였습니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 글에서는 GA4 사용자 행동 데이터 설계를 바탕으로, React 환경에서 GA를 실제로 어떻게 설정하였는지를 정리하였습니다.</p>
<p>초기 설정부터 페이지 단위, 사용자 단위, 접속 환경 단위로 데이터를 해석하기 위한 설정까지, 실제 서비스 운영 과정에서 고려했던 포인트들을 중심으로 정리하였습니다.</p>
<p>다음 글에서는, 이렇게 수집된 데이터를 GA4 탐색 리포트에서 어떻게 분석하고 활용하였는지를 정리해보려고 합니다.</p>
<h3 id="참고자료">참고자료</h3>
<ul>
<li><a href="https://0-lab.tistory.com/55">[TIL] GA4의 사용자 식별값, 뭐가 다를까? – User ID, Device ID, Client ID, Google Signals </a></li>
<li><a href="https://osoma.kr/blog/ga4-user-id/#chapter5">GA4가 사용자를 식별하는 방법과 사용자 ID(User ID) 수집하기</a></li>
<li><a href="https://brunch.co.kr/@mobiinside/4718">GA4, 3분만에 기기별 데이터 분석하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Webpack 성능 개선기 — 병목 분석부터 SWC 전환까지]]></title>
            <link>https://velog.io/@jae_o/Webpack-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EB%B3%91%EB%AA%A9-%EB%B6%84%EC%84%9D%EB%B6%80%ED%84%B0-SWC-%EC%A0%84%ED%99%98%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jae_o/Webpack-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EB%B3%91%EB%AA%A9-%EB%B6%84%EC%84%9D%EB%B6%80%ED%84%B0-SWC-%EC%A0%84%ED%99%98%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Wed, 19 Nov 2025 06:18:41 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>프로젝트 규모가 커지면서 가장 먼저 체감한 변화는 Hot Reloading 속도 저하였습니다.</p>
<p>작은 수정에도 화면 반영이 늦어지면서 개발 흐름이 끊기기 시작했고, 이 과정에서 전체 Webpack 빌드 시간 역시 함께 증가하고 있다는 문제도 확인했습니다.</p>
<p>개발 속도와 배포 템포 모두에 영향을 주는 신호였기 때문에, 빌드 파이프라인 전반을 점검하고 최적화가 필요하다고 판단했습니다.</p>
<blockquote>
<p>👉 최적화 과정을 더 잘 이해하고 싶다면 아래 글을 먼저 읽어보세요!
<a href="https://velog.io/@jae_o/Webpack-%EB%B9%8C%EB%93%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0">Webpack 빌드 프로세스 처음부터 끝까지 한 번에 이해하기</a></p>
</blockquote>
<p>이번 글에서는 Hot Reloading과 전체 빌드 시간이 느려진 원인을 분석한 방법과, 이를 어떻게 개선했는지 구체적인 과정과 결과를 공유합니다.</p>
<h2 id="문제와-원인-분석">문제와 원인 분석</h2>
<h3 id="1-hot-reloading-속도-저하">1. Hot Reloading 속도 저하</h3>
<p>프로젝트가 커지면서 가장 먼저 체감된 변화는 Hot Reloading 속도 저하였습니다.
초기에는 저장 즉시 화면이 갱신됐지만, 코드가 늘어날수록 변경 반영에 0.5~1초까지 걸리며 개발 흐름을 끊어놓는 병목이 되었습니다.</p>
<p>이 문제를 이해하기 위해 Hot Reloading의 동작 방식을 살펴보면 다음과 같습니다.</p>
<img src="https://velog.velcdn.com/images/jae_o/post/bc0ce259-775c-42bb-be9f-ebfa813b60d4/image.png" width="100%">


<ul>
<li>Webpack은 변경된 모듈만 다시 빌드하지만, 그 작업은 <strong>전체 의존성 그래프와 캐시 정보를 기반으로 수행</strong>됩니다.</li>
<li>변경된 파일은 다시 파싱되며, 이에 따라 <strong>babel-loader, css-loader</strong> 등 로더가 재실행됩니다.</li>
</ul>
<p>여기서 자연스럽게 다음과 같은 의문이 생깁니다.</p>
<blockquote>
<p>“변경된 모듈만 처리하는데, 왜 프로젝트가 커지면 Hot Reloading이 느려질까?”
<strong>“변경된 범위는 비슷한데 처리 시간은 왜 증가할까?”</strong></p>
</blockquote>
<p>핵심은, Webpack의 부분 빌드조차 “전체 프로젝트 규모를 기반으로” 이루어진다는 점입니다. 따라서 프로젝트가 커질수록 Hot Reloading이 수행해야 하는 기본적인 처리 비용도 함께 증가하게 됩니다.</p>
<p>결국 Hot Reloading 속도를 개선하려면, 변경된 파일을 가장 먼저 처리하는 로더 자체를 더 빠른 도구로 교체해야 한다는 결론에 도달했습니다.</p>
<h3 id="2-전체-빌드-시간-증가">2. 전체 빌드 시간 증가</h3>
<p>Hot Reloading 문제를 분석하던 중, 전체 Webpack 빌드 시간 역시 꾸준히 증가하고 있다는 사실을 확인했습니다.
로컬 빌드는 7~8초, 배포 환경(CodeBuild)에서는 40초 이상 걸리며, 프로젝트 규모가 커질수록 병목이 더 분명하게 나타났습니다.</p>
<p>빌드는 여러 단계로 구성되기 때문에 원인이 다양할 수 있습니다.
그래서 <strong><a href="https://www.npmjs.com/package/speed-measure-webpack-v5-plugin">speed-measure-webpack-v5-plugin(SMWP)</a></strong>을 사용해 로더와 플러그인별 시간을 계측해 보기로 했습니다. 이 도구는 Webpack 파이프라인에서 어떤 단계가 시간을 가장 많이 쓰는지 정확하게 보여줍니다.</p>
<p>SMWP 로그를 분석해보니 측정 결과는 다음과 같았습니다.</p>
<img src="https://velog.velcdn.com/images/jae_o/post/9e9e6c44-a616-454c-b309-362895e358a1/image.png" width="40%">


<p>로그를 보면 전체 빌드 시간(약 7.16초) 중 <strong>TerserPlugin이 단독으로 3.95초</strong>를 사용하고 있었습니다.
즉, 전체 빌드 시간의 <strong>50% 이상이 minify 단계에서 소모</strong>되고 있었던 것입니다.</p>
<p>이를 통해 병목 지점이 매우 명확해졌습니다.</p>
<ul>
<li>JS/TS를 변환하는 로더들은 1~2초 내외로 빠르게 끝나고</li>
<li>다른 플러그인들도 수십~수백 ms 정도만 사용하지만</li>
<li><strong>TerserPlugin만 압도적으로 긴 시간을 사용</strong></li>
</ul>
<p>결국 전체 빌드가 느린 근본 원인은 코드 압축 과정, 즉 Terser 기반의 minify 단계가 지나치게 느리다는 점이었습니다.</p>
<h2 id="해결-전략-swc-기반-스택으로-전환">해결 전략: SWC 기반 스택으로 전환</h2>
<h3 id="왜-swc인가">왜 SWC인가?</h3>
<p>빌드 병목이 Babel과 Terser에 있다는 것이 확인된 후, 대체 도구로 esbuild와 SWC를 비교했습니다. Webpack 기반 프로젝트에서 속도·호환성·마이그레이션 난이도를 고려했을 때 SWC가 더 적절한 선택이라고 판단했습니다.</p>
<p><strong>1) React + TypeScript 호환성과 안정성</strong>
esbuild는 속도는 빠르지만 React/TS 트랜스파일링에서 Babel 수준의 세밀함은 부족합니다. SWC는 Babel 대체를 목표로 만들어져 호환성이 안정적입니다.</p>
<p><strong>2) Babel 옵션과 구조가 유사해 교체 비용이 거의 없음</strong>
SWC는 Babel과 프리셋 옵션 구조가 비슷해, 기존 설정을 거의 그대로 옮길 수 있습니다.</p>
<p><strong>3) Webpack 구조를 유지하기에 가장 자연스러운 선택</strong>
esbuild는 번들러 성격이 강해 Webpack 자체를 대체하는 방향이지만, SWC는 Babel/Terser만 대체하는 트랜스파일러·미니파이어입니다.</p>
<p><strong>4) 실제 빌드 환경에서 성능 차이도 크게 벌어지지 않음</strong>
공식 비교에서는 esbuild가 더 빠르지만, Webpack 기반 빌드에서는 SWC도 충분히 빠르고 체감 속도 차이가 크지 않습니다.</p>
<h3 id="1-babel-loader-→-swc-loader">1. babel-loader → swc-loader</h3>
<p>Babel과 SWC의 결정적 차이는 <strong>구현 언어</strong>입니다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>구현 언어</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Babel</strong></td>
<td>JavaScript</td>
<td>유연하고 확장성 높지만 상대적으로 느림</td>
</tr>
<tr>
<td><strong>SWC</strong></td>
<td>Rust</td>
<td>컴파일러 수준 성능, 멀티스레드 가능</td>
</tr>
</tbody></table>
<ul>
<li>Babel은 JS로 구현돼 AST 변환을 JS로 처리합니다.</li>
<li>SWC는 Rust 기반으로 AST 최적화를 병렬로 처리할 수 있어, 기본 구조만으로도 <strong>10~20배 이상 빠릅니다.</strong></li>
</ul>
<p>즉, 기능은 같지만 구현 방식 자체가 속도 차이를 만들어냅니다.</p>
<h3 id="2-terserplugin-→-swcminifywebpackplugin">2. TerserPlugin → SwcMinifyWebpackPlugin</h3>
<p>Terser와 SWC minifier 역시 핵심 차이는 <strong>언어 성능 + 알고리즘 최적화 정도</strong>입니다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>구현 언어</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>TerserPlugin</strong></td>
<td>JavaScript</td>
<td>안정성·호환성 높음</td>
<td>압축 단계가 매우 느림</td>
</tr>
<tr>
<td><strong>SwcMinifyWebpackPlugin</strong></td>
<td>Rust</td>
<td>10~20배 빠른 압축 속도</td>
<td>(초기엔) 일부 edge-case 이슈</td>
</tr>
</tbody></table>
<p>Webpack 빌드 분석에서도 확인했듯,</p>
<ul>
<li>TerserPlugin이 <strong>빌드 시간의 절반 이상(3.95초)</strong>을 차지했고</li>
<li>SWC minifier로 교체하자 0.169초로 줄어드는 등 큰 효과가 있었습니다.</li>
</ul>
<p>Rust 기반의 병렬 압축기이기 때문에 대규모 번들에서도 속도가 크게 개선됩니다.</p>
<h2 id="성능-개선-결과">성능 개선 결과</h2>
<p>Webpack의 병목을 SWC 기반 스택으로 교체한 뒤, Hot Reloading부터 전체 빌드, 배포 빌드까지 전 구간에서 즉각적인 성능 개선이 확인되었습니다.</p>
<h3 id="1-hot-reloading-시간-0506초-→-0203초">1. Hot Reloading 시간: 0.5<del>0.6초 → 0.2</del>0.3초</h3>
<p>먼저 Hot Reloading 시간을 측정했습니다.</p>
<p>[개선 전 – 약 575ms]</p>
<img src="https://velog.velcdn.com/images/jae_o/post/723cc0bc-133a-4a8d-9ea4-a0366b53e342/image.png" width="100%">

<p>[개선 후 – 약 220ms]</p>
<img src="https://velog.velcdn.com/images/jae_o/post/42a30a7b-e5bd-4c53-9b94-026085aca926/image.png" width="100%">


<p>기존에는 파일 크기나 의존성 규모에 따라 0.5~0.9초까지 지연되는 경우도 있었고, 페이지 단위 변경 시 UI 전체가 잠시 멈추는 느낌도 있었습니다.</p>
<p>SWC 기반의 swc-loader로 교체한 뒤에는 대부분의 수정이 0.2~0.3초 내에 반영되어, 체감 속도가 완전히 달라졌습니다.</p>
<h3 id="2-로컬-빌드-시간-716초-→-315초">2. 로컬 빌드 시간: 7.16초 → 3.15초</h3>
<p>가장 큰 병목이었던 TerserPlugin을 SWC 기반의 SwcMinifyWebpackPlugin으로 교체하면서, 압축 단계 → 전체 빌드 → 배포 빌드까지 전 구간에서 속도가 크게 개선되었습니다.</p>
<h4 id="압축minify-단계-395초-→-0169초">압축(minify) 단계: 3.95초 → 0.169초</h4>
<p>아래는 변경 후 빌드 로그입니다.</p>
<img src="https://velog.velcdn.com/images/jae_o/post/979c5462-4b3d-48b5-9e08-69aa5d1cdf58/image.png" width="40%">


<p>TerserPlugin이 빌드 시간의 절반 이상(3.95초)을 차지했지만, SWC minifier는 Rust 기반의 병렬 처리로 0.169초만에 압축을 완료했습니다.</p>
<h4 id="전체-빌드-약-2배-개선">전체 빌드: 약 2배 개선</h4>
<p>Rust 기반 로더와 minifier의 전환만으로도 전체 빌드 속도가 7.16초에서 3.15초로 약 2배 빨라졌습니다.</p>
<h3 id="3-배포-환경codebuild-빌드-40초-→-16초-60-단축">3. 배포 환경(CodeBuild) 빌드: 40초 → 16초 (60% 단축)</h3>
<p>로컬 빌드보다 더 중요한 부분은 실제 배포 환경에서의 효과입니다.
AWS CodeBuild는 매번 캐시 없이 완전한 cold build를 수행하기 때문에 압축기의 성능이 매우 크게 반영됩니다.</p>
<p>아래는 CodeBuild 빌드 로그 비교입니다.</p>
<p><strong>[개선 전 – BUILD 단계 약 40초]</strong></p>
<img src="https://velog.velcdn.com/images/jae_o/post/a1c4cd07-7287-4e68-b9e1-bbb5f492d26c/image.png" width="100%">

<p><strong>[개선 후 – BUILD 단계 약 16초]</strong></p>
<img src="https://velog.velcdn.com/images/jae_o/post/72f2c807-0c26-479e-89db-131c3daa3f7e/image.png" width="100%">

<p>빌드 단계 시간이 40초 → 16초로 줄어들며, 전체 배포 속도가 약 60% 개선되었습니다.
단순 수치로도 2.5배 이상 빠른 빌드 파이프라인을 얻게 되었습니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 Webpack 최적화 작업을 진행하면서 가장 크게 느낀 점은, <strong>“감으로 최적화하지 않는다”</strong>는 원칙이 얼마나 중요한지였습니다.
이전에 Webpack 전체 빌드 사이클을 처음부터 끝까지 정리해두었던 덕분에, 각 로더와 플러그인이 어떤 역할을 하는지 정확히 이해할 수 있었고, 그 이해를 기반으로 병목 구간을 명확하게 찾아낼 수 있었습니다.</p>
<p>Webpack 최적화를 시작한다면 다음 두 가지를 기억하세요.</p>
<p><strong>1) HMR 개선은 로더에서 시작된다</strong>
변경된 파일을 가장 먼저 처리하는 로더의 속도가 Hot Reloading 체감 속도를 결정합니다.</p>
<p><strong>2) 빌드 개선은 수치 기반 병목 분석이 핵심이다</strong>
SMWP 같은 도구로 실제 시간을 측정하면, 추측 없이 가장 효과적인 최적화 지점을 찾을 수 있습니다.</p>
<p>이 두 가지 원칙만으로도 시행착오 없이 의미 있는 성능 개선을 해낼 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Webpack 빌드 프로세스, 처음부터 끝까지 한 번에 이해하기]]></title>
            <link>https://velog.io/@jae_o/Webpack-%EB%B9%8C%EB%93%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jae_o/Webpack-%EB%B9%8C%EB%93%9C-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%B2%98%EC%9D%8C%EB%B6%80%ED%84%B0-%EB%81%9D%EA%B9%8C%EC%A7%80-%ED%95%9C-%EB%B2%88%EC%97%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 17 Nov 2025 12:58:07 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<h3 id="webpack을-공부하게-된-이유">Webpack을 공부하게 된 이유</h3>
<p>프런트엔드 최적화를 다루다 보면 결국 Webpack 설정으로 돌아오곤 합니다. 캐시 전략, 이미지 최적화, 환경 변수 주입 모두 Webpack 단계에서 풀어야 하는 문제들이죠. 하지만 전체 흐름을 제대로 이해하지 못한 채 설정만 추가하다 보니, &quot;아이콘이 왜 보이지 않지?&quot;, &quot;배포했는데 왜 배포 전 화면이 나오지?&quot;와 같은 이슈가 터졌을 때 <strong>빠르고 정확하게 원인을 좁히기 어려웠습니다.</strong>
<a href="https://www.bombom.news/">&#39;봄봄&#39;</a> 서비스가 커지면서 빌드 시간과 번들 크기, 런타임 성능까지 점차 병목이 드러나기 시작했습니다. 하지만 이것이 Webpack의 설정 부족 때문인지, 아니면 도구 자체를 Vite 등으로 전환해야 할 문제인지 판단이 서지 않았습니다. 그래서 이번 글에서는 Webpack의 전체 빌드 흐름을 처음부터 끝까지 재정리하고, 그 속에서 현재 서비스에 적용 가능한 개선 지점을 체계적으로 찾고자 합니다.</p>
<h3 id="빌드란-무엇인가">빌드란 무엇인가?</h3>
<blockquote>
<p>&quot;개발자가 작성한 코드를 브라우저가 이해할 수 있는 언어로 변환하는 일&quot;</p>
</blockquote>
<p>웹 애플리케이션을 만들다 보면 수많은 파일이 얽히고설켜 있습니다.
JavaScript, TypeScript, CSS, 이미지 파일까지 브라우저는 TypeScript나 JSX를 직접 실행할 수 없습니다. 그래서 빌드(build) 라는 과정이 필요한데요. 빌드는 단순히 &quot;코드를 합치는 일&quot;이 아니라, 변환(compile) → 최적화(optimize) → 결과물 출력(output) 으로 이어지는 하나의 자동화된 파이프라인입니다.
<img src="https://velog.velcdn.com/images/jae_o/post/907946b4-fd5b-412b-b21f-c0900f5be8a8/image.png" alt="">
이 글에서는 그중에서도 프런트엔드 빌드 도구의 대표격인 Webpack을 중심으로, “빌드가 실제로 어떻게 동작하는지”를 단계별로 살펴보겠습니다.</p>
<h2 id="webpack-빌드의-큰-흐름">Webpack 빌드의 큰 흐름</h2>
<p>Webpack의 빌드는 크게 여섯 단계로 구성됩니다.</p>
<blockquote>
<p>엔트리(Entry) → 의존성 그래프(Dependency Graph) → 로더(Loaders) → 플러그인(Plugins) → 번들링(Bundle) → 결과 출력(Output)</p>
</blockquote>
<p>이제 각 단계가 구체적으로 어떤 일을 하는지 차근히 살펴보겠습니다.</p>
<h3 id="1-엔트리-entry">1. 엔트리 (Entry)</h3>
<p>엔트리는 Webpack이 빌드를 시작하는 <strong>진입점(entry point)</strong> 입니다.
가장 먼저 읽는 파일로, 보통 React 프로젝트에서는 <code>src/main.tsx</code>가 이에 해당하죠.
이 파일을 기준으로 import된 모든 파일들을 추적하여 <strong>의존성 그래프</strong>를 만듭니다.</p>
<pre><code class="language-jsx">module.exports = {
  entry: &#39;./src/main.tsx&#39;,
};</code></pre>
<p>엔트리를 여러 개 지정하면, 여러 페이지나 앱을 동시에 번들링할 수도 있습니다.</p>
<blockquote>
<p>❓ <strong>“엔트리를 여러 개 지정하면 어떻게 될까?”</strong>
예를 들어, 하나의 프로젝트 안에 <strong>사용자용 페이지(user)</strong> 와 <strong>관리자용 페이지(admin)</strong> 가 따로 있다고 해봅시다.
이때 엔트리를 다음처럼 두 개로 지정할 수 있습니다.</p>
<pre><code class="language-jsx">module.exports = {
  entry: {
    user: &#39;./src/user/index.tsx&#39;,
   admin: &#39;./src/admin/index.tsx&#39;,
  },
};</code></pre>
<p>이렇게 설정하면 Webpack은 두 개의 독립된 번들(<code>user.js</code>, <code>admin.js</code>)을 생성합니다.
각 엔트리 파일을 기준으로 <strong>별도의 의존성 그래프</strong>가 만들어지고, 두 그래프에 공통으로 포함된 모듈은 자동으로 <strong>공통 청크(common chunk)</strong> 로 분리됩니다.
즉, 서로 다른 페이지를 독립적으로 로드할 수 있고, 중복된 코드(예: React, 공용 유틸 등)는 한 번만 다운로드되므로 <strong>로드 속도와 캐싱 효율이 개선</strong>됩니다.</p>
</blockquote>
<h3 id="2-의존성-그래프-dependency-graph">2. 의존성 그래프 (Dependency Graph)</h3>
<p>Webpack은 지정된 엔트리 파일에서 시작해, 코드 안의 <code>import</code> 또는 <code>require</code> 구문을 재귀적으로 탐색하며 모듈 간의 관계를 그래프로 표현합니다. 이 그래프를 <strong>Dependency Graph(의존성 그래프)</strong>라고 부릅니다.</p>
<hr>
<h4 id="내부-동작-원리">내부 동작 원리</h4>
<p>1) <strong>엔트리 탐색 (Entry Resolution)</strong>
Webpack은 설정에서 지정한 엔트리(예: <code>src/main.tsx</code>)를 첫 노드(root node)로 등록합니다. 이 파일을 <strong>AST(Abstract Syntax Tree)</strong>로 파싱하여 내부의 <code>import</code>, <code>require</code> 구문을 찾습니다.</p>
<p>2) <strong>모듈 분석 (Module Parsing)</strong>
발견된 의존 경로를 기준으로 각 모듈을 로드합니다. Webpack은 모듈을 해석할 때 resolver를 사용하여, 실제 파일 경로를 찾고 해당 파일의 내용을 다시 파싱합니다. 이 과정에서 .ts, .css, .svg 등 JS 외의 파일은 <strong>로더(loader)</strong>에 의해 변환되어 JS 모듈처럼 다뤄집니다.</p>
<p>3) <strong>재귀적 추적 (Recursive Traversal)</strong>
각 모듈에서 또 다른 import가 발견되면, 동일한 과정을 반복합니다. 이렇게 모든 모듈의 의존 관계를 따라가며 하나의 <strong>방향성 그래프(Directed Graph)</strong>가 구성됩니다. 루프(순환 참조)가 감지될 경우, Webpack은 이를 처리 가능한 형태(캐시 모듈 참조)로 관리합니다.</p>
<p>4) <strong>노드 및 엣지 구성</strong>
그래프의 <strong>노드(Node)</strong>는 각각의 모듈(파일)을 의미하고, <strong>엣지(Edge)</strong>는 한 모듈이 다른 모듈을 import하는 의존 관계를 의미합니다.</p>
<hr>
<p><strong>의존성 그래프가 중요한 이유</strong>
이렇게 만들어진 의존성 그래프는 Webpack의 모든 최적화의 기반이 됩니다.</p>
<ul>
<li><strong>번들링 단계</strong>에서는 그래프를 순회하여 모듈을 하나의 실행 순서로 정렬합니다.</li>
<li><strong>Tree Shaking</strong>은 그래프에서 실제로 참조되지 않는 노드를 제거하는 과정입니다.</li>
<li><strong>코드 스플리팅</strong>은 그래프를 분석하여 공통 부분(vendor 모듈)을 독립된 청크로 분리합니다.</li>
<li><strong>HMR(Hot Module Replacement)</strong> 역시 이 그래프를 이용해, 변경된 노드와 그 영향을 받는 노드만 재컴파일합니다.</li>
</ul>
<p>즉, 의존성 그래프는 빌드의 뼈대가 되는 단계입니다.</p>
<h3 id="3-로더-loaders">3. 로더 (Loaders)</h3>
<p>브라우저는 오직 JavaScript 혹은 JSON만 이해할 수 있습니다.
그런데 우리는 TypeScript, CSS, 이미지 등 다양한 언어를 함께 사용하는데요.
<strong>로더(loader)</strong>는 이런 파일들을 Webpack이 이해할 수 있도록 <strong>JavaScript 형태로 변환(compile)</strong>하는 역할을 합니다.</p>
<pre><code class="language-js">module: {
  rules: [
    {
      test: /\.(ts|tsx)$/,
      use: [
        {
          loader: &#39;babel-loader&#39;,
          options: {
            presets: [
              &#39;@babel/preset-env&#39;,
              &#39;@babel/preset-react&#39;,
              &#39;@babel/preset-typescript&#39;,
            ],
          },
        },
      ],
      exclude: /node_modules/,
    },
    {
      test: /\.css$/,
      use: [&#39;style-loader&#39;, &#39;css-loader&#39;],
    },
  ],
}</code></pre>
<p><strong>대표적인 로더들</strong></p>
<ul>
<li><code>babel-loader</code>: TypeScript, JSX를 일반 JavaScript로 변환</li>
<li><code>css-loader</code>: CSS 파일을 JavaScript 모듈로 변환</li>
<li><code>style-loader</code>: 변환된 CSS를 DOM에 <code>&lt;style&gt;</code> 태그로 삽입</li>
</ul>
<p>즉, 우리가 작성한 최신 React + TypeScript + CSS 코드는 <strong>브라우저가 직접 이해할 수 있는 평범한 JavaScript 함수</strong>로 변환됩니다.</p>
<p><strong>로더 체인의 핵심</strong></p>
<p>로더는 <a href="https://webpack.js.org/concepts/loaders/">오른쪽에서 왼쪽으로</a> 순차적으로 실행됩니다.
예를 들어 <code>[&#39;style-loader&#39;, &#39;css-loader&#39;]</code>는:</p>
<ol>
<li><code>css-loader</code>가 먼저 CSS를 JS로 변환</li>
<li><code>style-loader</code>가 그 결과를 DOM에 삽입</li>
</ol>
<p><a href="https://babeljs.io/docs/presets#preset-ordering">babel-loader의 presets</a> 또한 같은 순서대로 변환을 하게 됩니다.
이런 체인 구조 덕분에 SASS, PostCSS 같은 로더를 추가해 
점진적으로 CSS를 완성할 수 있습니다.</p>
<h3 id="4-플러그인-plugins">4. 플러그인 (Plugins)</h3>
<p>로더가 <strong>파일 단위 변환</strong>에 집중한다면, <strong>플러그인(plugin)</strong> 은 <strong>빌드 전체 과정의 확장과 자동화</strong>를 담당합니다.
Webpack의 진짜 강점이 바로 이 플러그인 시스템인데요. 빌드 타임에 파일을 생성하거나, 환경 변수를 주입하고, 정적 리소스를 복사하는 등 “코드 변환” 이상의 일을 가능하게 해줍니다.
<a href="https://webpack.js.org/plugins/">다양한 플러그인</a>이 존재하지만, 대표적인 플러그인을 간단하게 소개드리겠습니다.</p>
<hr>
<h4 id="htmlwebpackplugin--html에-빌드-결과-자동-삽입">HtmlWebpackPlugin — HTML에 빌드 결과 자동 삽입</h4>
<p><code>HtmlWebpackPlugin</code>은 빌드 시점에 HTML 파일을 생성하거나, 기존 템플릿에 번들된 JS·CSS 파일을 자동으로 삽입해주는 플러그인입니다.</p>
<pre><code class="language-jsx">new HtmlWebpackPlugin({
  template: &#39;./index.html&#39;,
  filename: &#39;index.html&#39;,
  inject: true,
  favicon: &#39;./public/assets/logo.png&#39;,
});</code></pre>
<ul>
<li><code>template</code> : 기준이 되는 HTML 템플릿을 지정</li>
<li>빌드 결과물(<code>main.js</code>, <code>style.css</code> 등)을 <code>&lt;script&gt;</code>, <code>&lt;link&gt;</code> 태그로 자동 삽입</li>
<li><code>favicon</code> 옵션을 지정하면 파비콘도 함께 포함됨</li>
</ul>
<p>💡 <strong>예시 전후 비교</strong></p>
<p><strong>원본 템플릿</strong></p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;My App&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p><strong>빌드 후 결과</strong></p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;My App&lt;/title&gt;
    &lt;link rel=&quot;icon&quot; href=&quot;assets/logo.png&quot;&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script src=&quot;main.5c8a.js&quot;&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>빌드 후 JS 파일 이름이 <code>main.5c8a.js</code>처럼 해시로 바뀌어도
<code>&lt;script&gt;</code>가 자동으로 갱신되므로 <strong>HTML을 직접 수정할 필요가 없습니다.</strong></p>
<hr>
<h4 id="defineplugin--환경-변수-및-상수-주입">DefinePlugin — 환경 변수 및 상수 주입</h4>
<p><code>DefinePlugin</code>은 코드 안에서 사용할 <strong>전역 상수</strong>나 <strong>환경 변수</strong>(<code>process.env</code>)를 <strong>빌드 타임에 주입</strong>하는 플러그인입니다.</p>
<pre><code class="language-jsx">new webpack.DefinePlugin({
  &#39;process.env&#39;: JSON.stringify(process.env),
});</code></pre>
<p>이렇게 정의하면 실제 코드에서는 다음처럼 접근할 수 있습니다.</p>
<pre><code class="language-jsx">const baseUrl = process.env.API_BASE_URL;</code></pre>
<p>실제로는 런타임 변수처럼 보이지만, Webpack은 빌드 시점에 다음처럼 <strong>문자열로 치환</strong>합니다 👇</p>
<pre><code class="language-jsx">const baseUrl = &#39;https://api.example.com/v1&#39;;</code></pre>
<p>이 방식 덕분에,</p>
<ul>
<li>불필요한 분기 코드는 <strong>트리쉐이킹(Tree Shaking)</strong> 으로 제거되고</li>
<li>빌드 환경(dev, staging, prod 등)에 맞게 코드가 자동 분기됩니다.</li>
</ul>
<p>즉, <strong>DefinePlugin은 “환경별 조건 분기”를 가능하게 하는 핵심 도구</strong>입니다.</p>
<hr>
<h4 id="copywebpackplugin">CopyWebpackPlugin</h4>
<p><code>CopyWebpackPlugin</code>은 <strong>정적 파일(public 폴더 등)을 빌드 결과물(dist)</strong> 에 자동으로 복사해주는 플러그인입니다.</p>
<p>예를 들어, <code>assets</code>, <code>robots.txt</code>, <code>sitemap.xml</code> 같은 파일을 자동으로 포함시킬 수 있습니다.</p>
<pre><code class="language-jsx">new CopyWebpackPlugin({
  patterns: [
    { from: &#39;public/assets&#39;, to: &#39;assets&#39; },
    {
      from: path.resolve(__dirname, &#39;public&#39;, `robots.${process.env.NODE_ENV}.txt`),
      to: path.resolve(__dirname, &#39;dist&#39;, &#39;robots.txt&#39;),
    },
  ],
});
</code></pre>
<ul>
<li><code>from</code>: 복사할 원본 파일/폴더</li>
<li><code>to</code>: 결과물이 저장될 위치</li>
<li><code>patterns</code> 배열을 사용하면 여러 세트를 한 번에 관리 가능</li>
</ul>
<p>💡 예를 들어,</p>
<ul>
<li><code>public/assets</code> → <code>dist/assets</code> 로 복사하여 이미지 접근 경로를 유지하고</li>
<li>환경(<code>prod</code>, <code>dev</code>)에 따라 <code>robots.txt</code>나 <code>sitemap.xml</code>을 자동 교체할 수 있습니다.</li>
</ul>
<p>이 덕분에 HTML이나 JS 코드에서 <strong>정적 파일 경로를 직접 관리할 필요 없이</strong>, <code>assets/logo.png</code> 형태로 접근이 가능해집니다.</p>
<h3 id="5-번들링-bundling">5. 번들링 (Bundling)</h3>
<p>이제 변환이 끝난 모든 모듈들을 하나로 묶는 단계입니다.
Webpack은 의존성 그래프를 따라, 각 모듈의 실행 순서와 관계를 고려해 하나의 JS 번들을 만듭니다.
이 과정에서 <strong>코드 스플리팅(splitChunks)</strong>이 적용되어 공통 모듈(vendor, react 등)을 별도 파일로 분리하기도 합니다. 즉, “필요한 코드만 효율적으로 다운로드하게 만드는” 단계예요.</p>
<h3 id="6-결과-출력-output">6. 결과 출력 (Output)</h3>
<p>마지막으로, 번들링된 결과물이 실제 디스크에 저장됩니다.</p>
<pre><code class="language-jsx">output: {
  filename: &#39;js/[name].[contenthash:8].js&#39;,
  path: path.resolve(__dirname, &#39;dist&#39;),
  publicPath: &#39;/&#39;,
},</code></pre>
<ul>
<li><code>filename</code>: 생성될 파일의 이름 패턴 (<code>[name]</code>은 엔트리 이름, <code>[contenthash:8]</code>은 8자리 해시)</li>
<li><code>path</code>: 빌드 결과물이 저장될 물리적 경로 (절대 경로)</li>
<li><code>publicPath</code>: 브라우저에서 파일에 접근할 때 사용하는 기본 경로</li>
</ul>
<p>여기서 <code>filename</code>에 포함된 <code>[contenthash]</code>는 <strong>파일 내용 기반의 고유 해시값</strong>입니다. 즉, 파일의 내용이 바뀌지 않으면 해시값도 동일하게 유지되고, 반대로 코드가 수정되면 해시값이 바뀌어 파일 이름도 달라집니다.</p>
<p>예를 들어 다음과 같습니다.</p>
<ul>
<li>변경 전 : <code>main.5c8a.js</code></li>
<li>변경 후 : <code>main.ae91.js</code></li>
</ul>
<blockquote>
<p>❓ <strong>“왜 해시값을 붙일까?”</strong>
만약 우리가 매번 같은 이름(<code>main.js</code>)으로 빌드 결과를 내보낸다면, 브라우저는 새로 배포된 파일을 <strong>이전 캐시와 구분하지 못합니다.</strong> 즉, 파일 내용이 변경되어도 브라우저는 여전히 캐싱된 <code>main.js</code>를 사용하죠.
이 때문에 최신 코드가 반영되지 않거나, 사용자가 새로고침을 여러 번 해야 최신 버전이 로드되는 문제가 발생합니다. 이 문제를 해결하기 위해 Webpack은 파일 이름에 <strong>내용 기반 해시값(contenthash)</strong> 을 붙입니다.
이제 파일이 바뀌면 자동으로 파일 이름도 달라지고, 브라우저는 새로운 리소스로 인식해 최신 버전을 받아오게 됩니다.</p>
</blockquote>
<h2 id="추가-설정들">추가 설정들</h2>
<p>지금까지는 Webpack의 빌드 과정을 중심으로 살펴봤습니다. 하지만 실제 프로젝트에서는 빌드 외에도 “개발 환경에서의 편의성”과 “배포용 최적화”를 고려해야 합니다. 이를 제어하는 주요 옵션이 바로 <code>mode</code>, <code>resolve</code>, <code>devServer</code>, <code>optimization</code>입니다.</p>
<h3 id="1-mode">1. mode</h3>
<p><code>mode</code>는 Webpack이 어떤 환경에서 실행되는지를 결정하는 옵션입니다.</p>
<pre><code class="language-jsx">module.exports = {
  mode: &#39;production&#39;,
};</code></pre>
<ul>
<li><strong><code>development</code></strong><ul>
<li>빌드 속도를 우선시</li>
<li>코드 압축(X), 주석 유지</li>
<li>디버깅에 유리한 <code>source-map</code> 자동 활성화</li>
</ul>
</li>
<li><strong><code>production</code></strong><ul>
<li>코드 난독화 및 압축(Uglify, Terser 등)</li>
<li>Tree Shaking 활성화로 사용되지 않는 코드 제거</li>
<li>성능 최적화 중심의 빌드</li>
</ul>
</li>
</ul>
<p>즉, <code>mode</code>를 전환하는 것만으로 <strong>개발 효율 ↔ 실행 성능</strong> 간의 균형을 쉽게 맞출 수 있습니다.</p>
<h3 id="2-resolve">2. resolve</h3>
<p><code>resolve</code>는 <strong>모듈을 어떻게 해석할지</strong>를 정의합니다.</p>
<pre><code class="language-jsx">module.exports = {
  // ...
  resolve: {
    extensions: [&#39;.tsx&#39;, &#39;.ts&#39;, &#39;.js&#39;],
    alias: {
      &#39;@&#39;: path.resolve(__dirname, &#39;src&#39;),
      &#39;#&#39;: path.resolve(__dirname, &#39;public&#39;),
    },
  },
}</code></pre>
<ul>
<li><code>extensions</code><ul>
<li>파일 import 시 확장자를 생략할 수 있게 합니다.</li>
<li>예: <code>import App from &#39;@/App&#39;</code> → 자동으로 <code>App.tsx</code>, <code>App.ts</code>, <code>App.js</code> 순서로 탐색</li>
</ul>
</li>
<li><code>alias</code><ul>
<li>복잡한 상대 경로(<code>../../../</code>) 대신 간단한 별칭으로 import 가능</li>
<li>예: <code>src/components/Button</code> → <code>@/components/Button</code></li>
</ul>
</li>
</ul>
<p>이 설정을 잘 활용하면 코드 가독성과 유지보수성이 크게 향상됩니다.</p>
<h3 id="3-devserver">3. devServer</h3>
<p><a href="https://www.npmjs.com/package/webpack-dev-server"><code>webpack-dev-server</code></a>는 로컬 개발 시 변경 사항을 자동으로 반영해주는 <strong>개발용 서버</strong>를 제공합니다.</p>
<pre><code class="language-jsx">module.exports = {
  // ...
  devServer: {
    static: [
      { directory: path.join(__dirname, &#39;dist&#39;) },
      { directory: path.join(__dirname, &#39;public&#39;) },
    ],
    port: 3000,
    open: true,
    hot: true,
    historyApiFallback: true,
    client: {
      overlay: true,
    },
  },
}</code></pre>
<ul>
<li><code>static</code> : 정적 파일(<code>dist</code>, <code>public</code>) 제공 경로 설정</li>
<li><code>port</code> : 로컬 개발 서버 포트 (<code>localhost:3000</code>)</li>
<li><code>open</code> : 서버 실행 시 자동으로 브라우저 열기</li>
<li><code>hot</code> : HMR(Hot Module Replacement) 활성화 → 새로고침 없이 즉시 반영</li>
<li><code>historyApiFallback</code> : SPA 라우팅을 지원 (<code>/about</code> 같은 경로 새로고침 시에도 index.html로 리디렉션)</li>
<li><code>overlay</code> : 에러 발생 시 브라우저 화면에 직접 표시</li>
</ul>
<p>즉, 코드를 저장할 때마다 즉시 결과를 확인할 수 있어 <strong>개발 효율이 극대화</strong>됩니다.</p>
<h3 id="4-optimization">4. optimization</h3>
<p><code>optimization</code>은 Webpack이 <strong>코드 크기와 로딩 속도를 최적화하는 핵심 설정</strong>입니다.</p>
<pre><code class="language-jsx">module.exports = {
  // ...
  optimization: {
    usedExports: true,
    sideEffects: false,
    minimize: isProduction,
    splitChunks: {
      chunks: &#39;all&#39;,
      // ...
    },
  },
}</code></pre>
<ul>
<li><code>usedExports: true</code> → Tree Shaking 활성화 (사용되지 않는 export 제거)</li>
<li><code>sideEffects: false</code> → 부작용 없는 모듈은 자유롭게 최적화</li>
<li><code>minimize: true</code> → 코드 압축(TerserPlugin 자동 적용)</li>
<li><code>splitChunks</code> → 코드 스플리팅으로 공통 모듈 분리</li>
</ul>
<p>예를 들어 React와 다른 라이브러리를 별도 청크로 분리하면, 초기 로딩 속도가 빨라지고, 공통 모듈이 캐시되어 <strong>페이지 간 전환 시 훨씬 가볍게 동작</strong>합니다.</p>
<h2 id="마무리하며">마무리하며</h2>
<p>Webpack은 분명 러닝 커브가 있는 도구입니다. 하지만 그 원리를 이해하고 나면, 프런트엔드 최적화의 강력한 무기가 됩니다. Vite나 다른 도구로 전환을 고민하기 전에, 현재 Webpack 설정을 제대로 활용하고 있는지 먼저 점검해 보세요. 생각보다 많은 개선점을 발견해 보세요.</p>
<p>이번 글에서는 Webpack의 빌드 흐름을 처음부터 끝까지 정리하며 “왜 이렇게 동작하는가”를 중심으로 살펴봤습니다. 다음 글에서는 <strong><a href="https://www.bombom.news/">&#39;봄봄&#39;</a> 프로젝트에 실제로 적용한 고급 최적화 기법</strong>을 구체적인 수치와 함께 공유하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모든 플랫폼에서 동작하는 하이라이트 만들기]]></title>
            <link>https://velog.io/@jae_o/highlight</link>
            <guid>https://velog.io/@jae_o/highlight</guid>
            <pubDate>Thu, 06 Nov 2025 02:39:11 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>봄봄은 뉴스레터를 모아 읽고 <strong>습관을 만드는</strong> 서비스입니다.
습관은 &#39;읽기&#39;만으로는 형성되지 않습니다. <strong>저장하고, 활용하는</strong> 과정이 필요합니다. 그래서 본문 중 원하는 부분을 저장하는 <strong>하이라이트</strong>, 그 위에 생각을 남기는 <strong>메모</strong>를 만들었습니다. 이 두 기능은 사용자가 <strong>중요한 문장을 다시 찾고, 새롭게 알게 된 내용을 활용</strong>할 수 있도록 돕습니다.</p>
<p>이러한 목표를 구현하기 위해, 저는 브라우저의 <strong>Selection/Range API</strong>와 <strong>앵커(Anchor) 저장 전략</strong>을 조합하여 하이라이트를 설계했습니다. 아래에서 <strong>앵커 저장 전략의 선택 배경</strong>과 <strong>하이라이트 구현 과정</strong>을 단계별로 소개하겠습니다.</p>
<h3 id="먼저-이해해야-할-것들">먼저 이해해야 할 것들</h3>
<p>하이라이트는 결국 브라우저에서 “사용자가 드래그한 구간”을 안전하게 읽고, 나중에 복원하는 일입니다. 여기서 핵심은 <strong>Selection</strong>과 <strong>Range</strong>입니다.</p>
<hr>
<p><strong>1) <a href="https://developer.mozilla.org/en-US/docs/Web/API/Selection">Selection</a> — “현재 선택 상태”를 나타내는 객체</strong></p>
<ul>
<li><strong>정의</strong>: 사용자가 선택한 <strong>텍스트 범위</strong> 또는 <strong>커서(캐럿) 위치</strong>를 나타내는 브라우저 객체</li>
<li><strong>역할</strong>: 하이라이트/메모 로직의 <strong>출발점(현재 선택 상태)</strong></li>
</ul>
<p><strong>획득 방법</strong></p>
<pre><code class="language-tsx">const selection = window.getSelection();
if (!selection) return; // 브라우저 지원 및 안전 체크</code></pre>
<p><strong>핵심 속성·메서드</strong></p>
<ul>
<li><code>rangeCount</code> — 포함된 <strong>Range 개수</strong>(일반적으로 0 또는 1)</li>
<li><code>isCollapsed</code> — <strong>드래그 상태가 접혀 있는지</strong> 여부</li>
<li><code>getRangeAt(index)</code> — <strong>Range 객체</strong> 가져오기(보통 <code>getRangeAt(0)</code>)</li>
<li><code>removeAllRanges()</code> / <code>addRange(range)</code> — 선택 영역 초기화/적용</li>
</ul>
<hr>
<p><strong>2) Range — “선택 구간을 좌표로 표현하는” 객체</strong></p>
<ul>
<li><strong>정의</strong>: 문서 내 <strong>연속 구간</strong>을 표현하며, 시작/끝을 <strong>노드 + 오프셋</strong>으로 갖는다</li>
<li><strong>관계</strong>: Selection이 “상태”라면, Range는 그 상태의 <strong>정밀 좌표</strong></li>
</ul>
<p><strong>획득 방법</strong></p>
<pre><code class="language-tsx">const selection = window.getSelection();
const range = selection &amp;&amp; selection.rangeCount &gt; 0 ? selection.getRangeAt(0) : null;</code></pre>
<p><strong>핵심 속성·메서드(실무 필수)</strong></p>
<ul>
<li><code>startContainer</code> / <code>startOffset</code> — <strong>시작 지점</strong>(노드, 노드 내 문자 오프셋)</li>
<li><code>endContainer</code> / <code>endOffset</code> — <strong>끝 지점</strong>(노드, 노드 내 문자 오프셋)</li>
<li><code>toString()</code> — 범위의 <strong>텍스트 내용</strong> 추출(quote 저장 등에 활용)</li>
<li><code>getBoundingClientRect()</code> — 범위를 감싸는 <strong>단일 박스</strong>(툴팁 중앙 배치 등)</li>
<li><code>getClientRects()</code> — 줄바꿈 등으로 나뉜 <strong>다중 라인 박스 목록</strong></li>
<li><code>intersectsNode(node)</code> — 특정 <strong>노드와의 교차 여부</strong> 확인(겹침 처리에 유용)</li>
<li><code>commonAncestorContainer</code> — 시작/끝이 속한 <strong>최하위 공통 조상 노드</strong><ul>
<li>텍스트 노드일 수 있으므로 순회 루트로 쓸 땐 <code>parentNode</code>로 올려 사용 권장</li>
</ul>
</li>
</ul>
<hr>
<blockquote>
<p>요약: <strong>Selection</strong>으로 “지금 선택된 상태”를 얻고, <strong>Range</strong>로 그 선택의 “정확한 좌표”를 다룹니다. 이 좌표를 안정적으로 저장·복원하는 것이 하이라이트 구현의 핵심입니다.</p>
</blockquote>
<h2 id="앵커-설계-옵션-비교--채택-이유">앵커 설계 옵션 비교 &amp; 채택 이유</h2>
<p>이제 <strong>선택된 Range를 “다시 열어도 같은 자리”로 복원</strong>하려면, 그 위치를 식별할 <strong>앵커(Anchor)</strong>가 필요합니다.</p>
<p>앵커를 어떻게 설계하느냐에 따라 내구성과 정확도가 크게 달라지는데요. 흔히 쓰는 세 가지 방식을 먼저 비교한 뒤, 봄봄에서 <strong>왜 특정 조합을 채택했는지</strong>를 설명하겠습니다.</p>
<h3 id="세-가지-접근-방법">세 가지 접근 방법</h3>
<p><strong>1) 텍스트 기반</strong></p>
<ul>
<li><strong>TextQuote</strong>: 선택문 <code>quote</code> + 앞뒤 <code>prefix/suffix</code>로 위치 추정</li>
<li>장점: DOM이 조금 바뀌어도 <strong>문장이 같으면 복원</strong>이 쉬움</li>
<li>단점: <strong>동일 문장 반복 시 모호성</strong>, 전역 검색으로 <strong>성능 비용</strong></li>
</ul>
<p><strong>2) 구조 기반(요소 단위)</strong></p>
<ul>
<li><strong>CSS Selector / DOM Path</strong>: 선택자 경로로 요소 찾기</li>
<li>장점: <strong>배우기 쉽고 빠름</strong></li>
<li>단점: <strong>텍스트 노드 단위 정밀도 부족</strong>, 클래스/구조 변경에 취약</li>
</ul>
<p><strong>3) 구조 기반(노드·텍스트 정밀)</strong></p>
<ul>
<li><strong>XPath</strong>: 트리 축/인덱스로 요소/텍스트 노드 직접 지정(<code>text()[1]</code> 등)</li>
<li>장점: <strong>정밀도 최고</strong>(텍스트 노드 + 오프셋), 결정적 복원</li>
<li>단점: 중간 노드 삽입·이동 등 구조 변화에 <strong>인덱스 민감</strong></li>
</ul>
<hr>
<p><strong>이 세 가지 방법의 저장 형태를 간단한 예시 코드로 설명</strong>드리겠습니다.</p>
<pre><code class="language-jsx">&lt;main class=&quot;article-body&quot;&gt;
  &lt;h1&gt;오늘의 뉴스레터&lt;/h1&gt;
  &lt;p&gt;아침 루틴을 만들기 위한 작은 팁들.&lt;/p&gt;
  &lt;p&gt;데이터는 습관을 만든다. 반복은 이해를 낳는다.&lt;/p&gt;
  &lt;p&gt;마지막으로 추천 아티클을 소개합니다.&lt;/p&gt;
&lt;/main&gt;</code></pre>
<p>위와 같은 html이 있을 때, 두 번째 <code>&lt;p&gt;</code>의 “데이터는 습관을 만든다” 구절을 하이라이트 한다고 가정해봅시다.</p>
<ol>
<li>텍스트 기반 — TextQuote</li>
</ol>
<pre><code class="language-json">{
  &quot;quote&quot;: &quot;데이터는 습관을 만든다&quot;,
  &quot;prefix&quot;: &quot;&quot;,
  &quot;suffix&quot;: &quot; 반복은 이해를 낳는다.&quot;
}</code></pre>
<ol>
<li>구조 기반(요소 단위) — CSS Selector</li>
</ol>
<pre><code class="language-json">{
  &quot;selector&quot;: &quot;.article-body &gt; p:nth-of-type(2)&quot;,
  &quot;quote&quot;: &quot;데이터는 습관을 만든다&quot;,
  &quot;offsetInElement&quot;: { &quot;start&quot;: 0, &quot;end&quot;: 13 } // 옵션: 요소 내부 오프셋
}</code></pre>
<ol>
<li>구조 기반(노드·텍스트 정밀) — XPath</li>
</ol>
<pre><code class="language-json">{
  &quot;startXPath&quot;: &quot;/html/body/main/p[2]/text()[1]&quot;,
  &quot;startOffset&quot;: 0,
  &quot;endXPath&quot;: &quot;/html/body/main/p[2]/text()[1]&quot;,
  &quot;endOffset&quot;: 13,
}</code></pre>
<h3 id="봄봄에서-xpath를-선택한-이유">봄봄에서 <strong>XPath</strong>를 선택한 이유</h3>
<p>봄봄의 뉴스레터는 <strong>사용자에게 발송된 뒤 내용/형식이 변경되지 않는</strong> 특성이 있습니다. 즉, 서비스 관점에서 <strong>문서 구조가 고정</strong>됩니다. 이 전제 덕분에 XPath의 약점(구조 변동 민감도)이 크게 문제가 되지 않고, 대신 아래 <strong>장점을 최대화</strong>할 수 있습니다.</p>
<ul>
<li><p><strong>텍스트 노드 레벨 정밀도</strong>: <code>…/p[2]/text()[1]</code>처럼 텍스트 노드를 직접 가리켜 <code>startOffset/endOffset</code>을 안정적으로 적용할 수 있습니다.</p>
</li>
<li><p><strong>빠르고 결정적인 복원</strong>: 전역 탐색 없이 <strong>즉시 노드 접근</strong>이 가능해, 다수 하이라이트의 <strong>배치 복원 성능</strong>이 좋습니다.</p>
</li>
<li><p><strong>템플릿 일관성과의 궁합</strong>: 발송 후 레이아웃이 바뀌지 않으므로 인덱스 기반 경로가 <strong>안정적으로 유지</strong>됩니다.</p>
</li>
</ul>
<h2 id="하이라이트-구현-단계별로-뜯어보기">하이라이트 구현: 단계별로 뜯어보기</h2>
<p>하이라이트의 사용자 흐름은 크게 네 단계입니다.</p>
<ol>
<li><strong>선택/클릭 감지</strong></li>
<li><strong>선택 저장(앵커 생성)</strong></li>
<li><strong>복원(페이지 방문 시)</strong></li>
<li><strong>DOM에 하이라이트 그리기/지우기</strong></li>
</ol>
<p>각 단계를 구체적으로 살펴봅시다.</p>
<hr>
<h3 id="선택클릭-감지"><strong>선택/클릭 감지</strong></h3>
<p>하이라이트 UX의 첫 단추는 <strong>“언제 툴바를 띄울지”</strong> 를 정확히 판단하는 것입니다. 핵심은 두 가지입니다.</p>
<ol>
<li><strong>플랫폼별 이벤트 순서 차이</strong>를 이해하고,</li>
<li><strong>단순 클릭 vs 드래그 선택</strong>을 안정적으로 구분하기.</li>
</ol>
<hr>
<p><strong>A. 플랫폼별 이벤트 흐름 파악하기</strong></p>
<p>플랫폼마다, 그리고 <strong>단순 클릭</strong>과 <strong>꾹 눌러 드래그(텍스트 선택)</strong> 시의 이벤트 흐름이 다릅니다. 이를 <strong>명확히 분리</strong>해 처리해야 잘못된 동작을 방지할 수 있습니다.</p>
<p>1) PC 웹(브라우저)</p>
<ul>
<li>클릭 &amp; 드래그: <code>pointerdown</code> → <code>mousedown</code> → <code>pointerup</code> → <code>mouseup</code> → <code>click</code></li>
</ul>
<p>2) iOS</p>
<ul>
<li>클릭: <code>pointerdown</code> → <code>touchstart</code> → <code>pointerup</code> → <code>touchend</code> → <code>mousedown</code> → <code>mouseup</code> → <code>click</code></li>
<li>드래그: <code>pointerdown</code> → <code>touchstart</code> → <code>pointerup</code> → <code>touchend</code></li>
</ul>
<p>3) Android</p>
<ul>
<li>클릭: iOS와 동일</li>
<li>드래그: <code>pointerdown</code> → <code>touchstart</code> → <code>contextmenu</code>(드래그 시작) → <code>pointercancel</code> → <code>touchcancel</code> → <code>contextmenu</code>(드래그 끝)</li>
</ul>
<blockquote>
<p>Android는 드래그 시작/끝 모두 contextmenu 가 발생합니다.
즉, 하나의 이벤트로 <strong>선택 완료 시점</strong>을 감지하게 되는 셈이죠.</p>
</blockquote>
<hr>
<p><strong>B. 클릭 vs 드래그 판별하기</strong></p>
<p><strong>Selection API</strong>만으로도 대부분 판별이 가능합니다.</p>
<ul>
<li><code>selection.rangeCount &gt; 0</code> <strong>그리고</strong></li>
<li><code>selection.isCollapsed === false</code> → <strong>드래그(텍스트 선택) 상태</strong></li>
<li>그 외 → <strong>단순 클릭/선택 해제</strong></li>
</ul>
<pre><code class="language-tsx">const selection = window.getSelection();
if (selection &amp;&amp; !selection.isCollapsed &amp;&amp; selection.rangeCount &gt; 0) {
  // ✅ 드래그(선택) 상태 로직
  // openToolbarFromSelection(selection);
  return;
}
// ⛔ 선택 없음 → 단순 클릭 또는 선택 해제</code></pre>
<blockquote>
<p><strong>Android의 특수성</strong>
위 방식은 PC와 iOS에서 잘 작동하지만, <strong>Android는 예외</strong>입니다.
Android는 드래그 완료 시 <code>contextmenu</code> 이벤트가 발생하는 독특한 구조를 가지고 있습니다. 이로 인해 <strong>하나의 이벤트 핸들러로 클릭과 드래그를 함께 처리하기 어렵습니다.</strong>
따라서 Android에서는 <strong>이벤트 레벨에서 역할을 분리</strong>합니다:</p>
<ul>
<li><code>contextmenu</code> → <strong>선택 완료(드래그) 처리</strong></li>
<li><code>click</code> → <strong>기존 하이라이트 클릭 처리</strong></li>
</ul>
</blockquote>
<hr>
<p><strong>C. 최종 구현 (핵심 코드)</strong></p>
<p>위 분석을 바탕으로, 플랫폼별로 <strong>서로 다른 이벤트 핸들러 조합</strong>이 필요합니다:</p>
<table>
<thead>
<tr>
<th>플랫폼</th>
<th>전략</th>
<th>사용 이벤트</th>
</tr>
</thead>
<tbody><tr>
<td>iOS</td>
<td>하이라이트 클릭 + 드래그 선택을 하나의 핸들러로 통합</td>
<td><code>pointerup</code></td>
</tr>
<tr>
<td>Android</td>
<td>드래그 선택과 하이라이트 클릭을 별도 핸들러로 분리</td>
<td><code>contextmenu</code> + <code>click</code></td>
</tr>
<tr>
<td>PC 웹</td>
<td>iOS와 동일한 통합 방식 + 선택 해제 감지</td>
<td><code>mouseup</code> + <code>selectionchange</code></td>
</tr>
</tbody></table>
<p>이를 구현한 코드는 다음과 같습니다:</p>
<pre><code class="language-ts">// Android 전용: 드래그 선택 완료 처리
const handleSelectionComplete = useCallback(() =&gt; {
  const selection = window.getSelection();
  if (selection &amp;&amp; !selection.isCollapsed &amp;&amp; selection.rangeCount &gt; 0) {
    openToolbarFromSelection(selection);
    return;
  }
}, [openToolbarFromSelection]);

// Android 전용: 기존 하이라이트 클릭 처리
const handleHighlightClick = useCallback(
  (e: PointerEvent | MouseEvent) =&gt; {
    const target = e.target as HTMLElement;

    // 1) 기존 하이라이트 클릭 → 편집/툴바
    if (target.tagName === &#39;MARK&#39;) {
      openToolbarFromHighlight(target);
      return;
    }

    // 2) 아닐 경우 → 툴바 닫기
    onHide();
  },
  [openToolbarFromHighlight, onHide],
);

// iOS/PC 웹 전용: 클릭과 드래그를 하나의 핸들러로 통합
const handleHighlightClickOrSelection = useCallback(
  (e: PointerEvent | MouseEvent) =&gt; {
    const target = e.target as HTMLElement;

    // 1) 기존 하이라이트 클릭
    if (target.tagName === &#39;MARK&#39;) {
      openToolbarFromHighlight(target);
      return;
    }

    // 2) 드래그(선택) 여부
    const selection = window.getSelection();
    if (selection &amp;&amp; !selection.isCollapsed &amp;&amp; selection.rangeCount &gt; 0) {
      openToolbarFromSelection(selection);
      return;
    }

    // 3) 둘 다 아님 → 닫기
    onHide();
  },
  [onHide, openToolbarFromHighlight, openToolbarFromSelection],
);

// === 플랫폼별 안전한 이벤트 바인딩 ===
if (isIOS()) {
  // iOS: pointerup 하나로 클릭/드래그 통합 처리
  document.addEventListener(&#39;pointerup&#39;, handleHighlightClickOrSelection);
} else if (isAndroid()) {
  // Android: 드래그는 contextmenu, 클릭은 click으로 분리
  document.addEventListener(&#39;contextmenu&#39;, handleSelectionComplete);
  document.addEventListener(&#39;click&#39;, handleHighlightClick);
} else if (!isWebView()) {
  // PC 웹: mouseup으로 통합, selectionchange로 해제 감지
  document.addEventListener(&#39;mouseup&#39;, handleHighlightClickOrSelection);
  document.addEventListener(&#39;selectionchange&#39;, handleSelectionClear);
}</code></pre>
<p><strong>핵심 포인트:</strong></p>
<ul>
<li><strong>하이라이트 클릭 → 드래그 선택 → 그 외 해제</strong>의 3단 분기</li>
<li>플랫폼별 이벤트 타이밍 차이를 고려한 안전한 바인딩</li>
<li>Android는 <code>contextmenu</code>의 특수성 때문에 핸들러 분리 필수</li>
</ul>
<h3 id="선택-저장앵커-생성">선택 저장(앵커 생성)</h3>
<p>하이라이트를 저장하는 목표는 간단합니다. <strong>지금의 Range를 &quot;나중에 정확히 복원&quot;할 수 있을 만큼 결정적인 좌표로 바꾸는 것</strong>인데요.
여기서는 <code>Selection/Range → XPath + 오프셋</code>으로 변환하는 과정을 설명합니다.</p>
<hr>
<p><strong>어떤 XPath를 저장하는 것이 가장 좋을까?</strong></p>
<p>처음에는 <strong>드래그 시작 노드의 XPath+offset</strong>, <strong>드래그 끝 노드의 XPath+offset</strong>을 그대로 저장하려 했습니다.
하지만 이렇게 하면 <strong>start↔end 사이의 모든 노드</strong>를 나중에 복원 시 <strong>다시 찾아 조립</strong>해야 했습니다. 드래그가 길수록 이 과정이 복잡해지고 오류 여지가 커집니다.</p>
<p>그래서 방향을 바꿨습니다.</p>
<ol>
<li><strong><code>commonAncestorContainer</code>(공통 조상)를 구해 그 조상의 XPath만 저장</strong><ul>
<li><code>range.commonAncestorContainer</code> 속성으로 공통 조상을 가져올 수 있습니다.</li>
</ul>
</li>
<li>공통 조상 <strong>하위의 모든 TextNode를 직렬로 연결한 하나의 &quot;논리 텍스트&quot;로 보고</strong>, 그 안에서 <strong><code>startOffset</code>/<code>endOffset</code>을 계산해 저장</strong></li>
</ol>
<blockquote>
<p>이렇게 하면 저장 형식은 단순해지고, 복원 시에도 한 번의 트리 순회로 정확한 Range를 재구성할 수 있습니다.</p>
</blockquote>
<hr>
<p><strong>예시로 보는 오프셋 계산</strong></p>
<pre><code class="language-html">&lt;main class=&quot;article-body&quot;&gt;
  &lt;h1&gt;오늘의 뉴스레터&lt;/h1&gt;
  &lt;p&gt;아침 루틴을 만들기 위한 작은 팁들.&lt;/p&gt;
  &lt;p&gt;데이터는 습관을 만든다. 반복은 이해를 낳는다.&lt;/p&gt;
  &lt;p&gt;마지막으로 추천 아티클을 소개합니다.&lt;/p&gt;
&lt;/main&gt;</code></pre>
<p>첫 번째 <code>&lt;p&gt;</code>의 &quot;아침&quot;부터 세 번째 <code>&lt;p&gt;</code>의 &quot;합니다&quot;까지 드래그했다고 가정해 봅시다.</p>
<ul>
<li><p><strong>공통 조상</strong>은 <code>&lt;main class=&quot;article-body&quot;&gt;</code>이고, 이 요소의 XPath를 저장합니다.</p>
</li>
<li><p><strong>오프셋 계산</strong>은 <code>&lt;main&gt;</code> 내부의 <strong>모든 TextNode를 순서대로 연결</strong>한 뒤,</p>
<ul>
<li><strong>startOffset</strong>: 드래그 시작 지점까지의 <strong>누적 문자 수</strong></li>
<li><strong>endOffset</strong>: 드래그 끝 지점까지의 <strong>누적 문자 수</strong></li>
</ul>
<p>로 정의합니다.</p>
</li>
</ul>
<p><strong>구체적인 계산 과정:</strong></p>
<pre><code>TextNode 1: &quot;오늘의 뉴스레터&quot; (8자)
TextNode 2: &quot;아침 루틴을 만들기 위한 작은 팁들.&quot; (20자)
TextNode 3: &quot;데이터는 습관을 만든다. 반복은 이해를 낳는다.&quot; (27자)
TextNode 4: &quot;마지막으로 추천 아티클을 소개합니다.&quot; (22자)

→ startOffset = 8 (h1 끝) + 0 (&quot;아침&quot;의 시작) = 8
→ endOffset = 8 + 20 + 27 + 22 = 77</code></pre><hr>
<p><strong>구현 코드</strong></p>
<p><strong>1) 노드 → XPath: <code>getXPathForNode</code></strong></p>
<pre><code class="language-tsx">const ROOT_PATH = &#39;.&#39;;

/** 특정 노드의 XPath를 구하는 함수 */
export const getXPathForNode = (node: Node, root: Node = document): string =&gt; {
  // TextNode일 경우 부모 요소로 이동
  if (node.nodeType === Node.TEXT_NODE) node = node.parentNode!;
  if (node === root) return ROOT_PATH;

  const index =
    Array.from(node.parentNode!.childNodes)
      .filter((n) =&gt; n.nodeName === node.nodeName)
      .indexOf(node as ChildNode) + 1;

  return (
    // 재귀적으로 계산
    getXPathForNode(node.parentNode!, root) +
    &#39;/&#39; +
    node.nodeName.toLowerCase() +
    `[${index}]`
  );
};</code></pre>
<p><strong>2) 컨테이너 내부 오프셋 계산: <code>getHighlightOffsets</code></strong></p>
<pre><code class="language-tsx">export const getHighlightOffsets = (container: Element, range: Range) =&gt; {
  let start = -1;
  let end = -1;
  let currentOffset = 0;

  // NodeFilter.SHOW_TEXT를 통해서 TextNode만 필터링
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);

  while (walker.nextNode()) {
    const node = walker.currentNode as Text;

    if (node === range.startContainer)
      start = currentOffset + range.startOffset;

    if (node === range.endContainer)
      end = currentOffset + range.endOffset;

    currentOffset += node.textContent!.length;
  }
  return { start, end };
};</code></pre>
<ul>
<li>컨테이너 내부의 <strong>모든 TextNode를 좌→우 순회</strong>하면서 <code>currentOffset</code>을 누적</li>
<li><code>startContainer/endContainer</code>를 만나면 해당 노드 내 오프셋을 더해 <strong>문서 내 절대 오프셋</strong>으로 변환</li>
</ul>
<p><strong>3) 저장 페이로드 생성: <code>saveSelection</code></strong></p>
<pre><code class="language-tsx">export const saveSelection = (range: Range) =&gt; {
  const container =
    // 공통 조상이 TextNode일 경우 부모 Element를 공통 조상으로 함
    range.commonAncestorContainer.nodeType === Node.TEXT_NODE
      ? range.commonAncestorContainer.parentElement!
      : (range.commonAncestorContainer as Element);

  const xpath = getXPathForNode(container);
  const { start, end } = getHighlightOffsets(container, range);

  return {
    location: {
      startXPath: xpath,
      startOffset: start,
      endXPath: xpath,  // 공통 조상 XPath를 양쪽에 저장
      endOffset: end,
    },
    color: theme.colors.primaryLight, // 하이라이트 색
    text: range.toString(), // 선택된 텍스트(검증/미리보기용)
  };
};</code></pre>
<blockquote>
<p><strong>API 설계 변경 기록</strong><br>초기에는 <code>startXPath</code>/<code>endXPath</code>에 <strong>각각의 노드 XPath</strong>를 저장하려 했으나,
멀티 문단 드래그에서 복원이 복잡해져서 <strong>공통 조상의 XPath를 양쪽에 동일하게 저장</strong>하는 형태로 전환했습니다. 😅</p>
</blockquote>
<h3 id="복원페이지-방문-시--저장된-앵커로-range-재구성">복원(페이지 방문 시) — 저장된 앵커로 Range 재구성</h3>
<p>저장까지 했다면, 이제는 <strong>저장된 데이터(location)</strong>를 바탕으로 DOM에서 다시 <strong>정확한 Range</strong>를 만들어 <code>&lt;mark&gt;</code>로 그려야 합니다.
복원의 핵심은 단순합니다. <strong>저장의 역순으로 진행</strong>하면 됩니다.</p>
<ul>
<li>입력: <code>startXPath</code>(=공통 조상), <code>startOffset</code>, <code>endOffset</code></li>
<li>목표: 공통 조상 컨테이너 안에서 <strong>문자 오프셋 → (텍스트 노드, 노드 내 오프셋)</strong>으로 매핑해 <code>Range</code> 생성</li>
</ul>
<hr>
<p><strong>1) XPath로 컨테이너 노드 복원하기</strong></p>
<pre><code class="language-tsx">/** XPath로 노드를 다시 찾는 함수 */
export const getNodeByXPath = (xpath: string, root: Document = document) =&gt; {
  const result = document.evaluate(
    xpath,                              // 저장된 XPath
    root,                               // 보통 document
    null,                               // 네임스페이스 리졸버(필요 없으면 null)
    XPathResult.FIRST_ORDERED_NODE_TYPE,// 첫 번째 매칭 노드만 가져오기
    null,
  );
  return result.singleNodeValue;        // Element | Text | null
};</code></pre>
<ul>
<li>XPath는 <strong>W3C 표준 쿼리 언어</strong>이기 때문에 브라우저에 <strong>표준 API</strong>가 있습니다.</li>
<li><code>document.evaluate</code>를 쓰면 한 줄로 노드를 쉽게 되찾을 수 있습니다.</li>
</ul>
<hr>
<p><strong>2) 컨테이너 내부 오프셋 → Range로 변환하기</strong></p>
<pre><code class="language-tsx">function getHighlightRange(container: Node, start: number, end: number) {
  const range = document.createRange();
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);

  let currentOffset = 0;

  const positions = {
    startNode: null as Text | null,
    endNode: null as Text | null,
    startOffset: 0,
    endOffset: 0,
  };

  while (walker.nextNode()) {
    const node = walker.currentNode as Text;
    const len = node.textContent!.length;

    // start 지점이 이 텍스트 노드 범위 안에 들어오는가?
    if (
      !positions.startNode &amp;&amp;
      start &gt;= currentOffset &amp;&amp;
      start &lt;= currentOffset + len
    ) {
      positions.startNode = node;
      positions.startOffset = start - currentOffset; // 노드 내부 오프셋으로 변환
    }

    // end 지점이 이 텍스트 노드 범위 안에 들어오는가?
    if (
      !positions.endNode &amp;&amp;
      end &gt;= currentOffset &amp;&amp;
      end &lt;= currentOffset + len
    ) {
      positions.endNode = node;
      positions.endOffset = end - currentOffset; // 노드 내부 오프셋으로 변환
    }

    currentOffset += len; // 다음 텍스트 노드로 이동하기 전에 누적 길이 갱신
  }

  if (!positions.startNode || !positions.endNode) {
    // 저장/복원 정규화 규칙이 다르면 오프셋 매칭 실패 가능
    throw new Error(&#39;Offset 변환 실패&#39;);
  }

  range.setStart(positions.startNode, positions.startOffset);
  range.setEnd(positions.endNode, positions.endOffset);

  return range;
}</code></pre>
<ul>
<li>컨테이너 내부의 모든 <strong>텍스트 노드</strong>를 왼쪽→오른쪽으로 순회하면서,</li>
<li>저장된 <code>startOffset</code>/<code>endOffset</code>이 <strong>어떤 텍스트 노드의 몇 번째 문자</strong>에 해당하는지 계산해 <code>Range</code>를 만듭니다.</li>
</ul>
<hr>
<p><strong>3) 전체 복원 흐름 (요약)</strong></p>
<pre><code class="language-tsx">// 1) 컨테이너 복원
const container = getNodeByXPath(highlight.location.startXPath);
if (!container) return;

// 2) Range 재구성
const range = getHighlightRange(
  container,
  Number(highlight.location.startOffset),
  Number(highlight.location.endOffset),
);

// 3)  태그로 하이라이트 그리기
renderHighlight(range, highlight.color, highlight.id);</code></pre>
<blockquote>
<p><strong>참고:</strong> <code>renderHighlight</code> 함수는 다음 섹션에서 자세히 다룹니다. 
이 함수는 Range 내의 텍스트 노드들을 찾아 각 노드의 해당 구간을 <code>&lt;mark&gt;</code> 태그로 감싸는 역할을 합니다.</p>
</blockquote>
<h3 id="dom에-하이라이트-그리기지우기">DOM에 하이라이트 그리기/지우기</h3>
<p>하이라이트 표시 방법은 크게 두 가지가 있습니다.</p>
<ol>
<li><strong>CSS Custom Highlight API</strong>: 실제 DOM을 건드리지 않고, <code>HighlightRegistry</code>에 Range를 등록해 <code>::highlight(name)</code>로 스타일링합니다.</li>
<li><strong>DOM 래핑 방식</strong>: 선택 구간을 <code>&lt;mark&gt;</code>(또는 <code>&lt;span&gt;</code>) 같은 실제 요소로 감싸 스타일링합니다.</li>
</ol>
<p>봄봄은 <code>&lt;mark&gt;</code> 래핑을 채택했습니다. 이유는 다음과 같습니다.</p>
<ul>
<li><strong>의미/접근성(A11y)</strong>: <code>&lt;mark&gt;</code>는 &quot;문맥상 강조된 텍스트&quot;라는 시맨틱 태그라서 스크린 리더 등 보조기술에서 의미가 분명합니다.</li>
<li><strong>상호작용</strong>: <code>&lt;mark data-highlight-id=&quot;…&quot;&gt;</code>처럼 식별자/메타데이터를 바로 심어 클릭·롱프레스·툴팁 등 이벤트 타깃으로 쓰기 쉽습니다. Custom Highlight는 DOM 노드를 생성하지 않아서 dataset/이벤트 바인딩이 어렵습니다.</li>
<li><strong>다중 색상/스타일</strong>: 색상·밑줄·점선 등 스타일을 하이라이트 단위로 자유롭게 적용 가능합니다.</li>
<li><strong>호환성/일관성</strong>: 다양한 웹뷰/브라우저에서 동일한 동작을 보장하기 용이합니다. Custom Highlight는 구현·지원 레벨/행동이 플랫폼별로 상이할 수 있습니다.</li>
</ul>
<hr>
<p><strong>하이라이트 그리기: 텍스트 조각만 <code>&lt;mark&gt;</code>로 감싸기</strong></p>
<p>하이라이트는 텍스트 노드 하나 안에서도 <code>start~end</code> 구간만 칠해야 합니다. 그래서 원래 텍스트를 <code>before | middle | after</code>로 나누고, <code>middle</code>만 <code>&lt;mark&gt;</code>로 감싸 한 번에 교체합니다.</p>
<pre><code class="language-tsx">export const highlightNodeSegment = (
  node: Text,
  start: number,
  end: number,
  color: string,
  highlightId: number,
) =&gt; {
  const parent = node.parentNode!;
  const before = node.textContent!.slice(0, start);
  const middle = node.textContent!.slice(start, end);
  const after  = node.textContent!.slice(end);

  // 의미 태그: 문맥상 강조
  const mark = document.createElement(&#39;mark&#39;);

  // 하이라이트 색
  mark.style.backgroundColor = color;

  // 상호작용/삭제를 위한 식별자
  mark.dataset.highlightId = String(highlightId);

  // 텍스트 채우기
  mark.textContent = middle;

  // DocumentFragment로 한 번에 교체 → reflow 최소화
  const frag = document.createDocumentFragment();
  if (before) frag.appendChild(document.createTextNode(before));
  frag.appendChild(mark);
  if (after)  frag.appendChild(document.createTextNode(after));

  // 기존 텍스트 노드를 전체 교체
  parent.replaceChild(frag, node);
};</code></pre>
<blockquote>
<p><strong>왜 DocumentFragment인가요?</strong><br><code>appendChild</code>를 여러 번 하는 대신, 오프라인 트리에서 조립 → 한 번에 교체하면 reflow/repaint 비용을 줄일 수 있습니다.</p>
</blockquote>
<hr>
<p><strong>하이라이트 지우기: <code>&lt;mark&gt;</code>를 텍스트로 환원</strong></p>
<p>삭제는 간단합니다. 특정 <code>highlightId</code>를 가진 <code>&lt;mark&gt;</code>들을 찾아 내부 텍스트 노드로 되돌리기만 하면 됩니다.</p>
<pre><code class="language-tsx">export const removeHighlightFromDOM = (highlightId: number) =&gt; {
  const marks = document.querySelectorAll(
    `mark[data-highlight-id=&quot;${highlightId}&quot;]`,
  );

  marks.forEach((mark) =&gt; {
    const textNode = document.createTextNode(mark.textContent ?? &#39;&#39;);
    mark.replaceWith(textNode);
  });
};</code></pre>
<hr>
<h3 id="전체-흐름-정리">전체 흐름 정리</h3>
<p>지금까지 하이라이트 구현의 네 단계를 모두 살펴봤습니다:</p>
<ol>
<li><strong>선택/클릭 감지</strong> - 플랫폼별 이벤트 처리</li>
<li><strong>선택 저장</strong> - Range를 XPath + 오프셋으로 변환</li>
<li><strong>복원</strong> - 저장된 데이터로 Range 재구성</li>
<li><strong>그리기/지우기</strong> - <code>&lt;mark&gt;</code> 태그로 시각화</li>
</ol>
<p>이 네 단계가 유기적으로 연결되어 <strong>&quot;읽은 내용을 저장하고 다시 찾아볼 수 있는&quot;</strong> 하이라이트 기능을 완성합니다.</p>
<h2 id="전체-코드">전체 코드</h2>
<p>전체 구현은 GitHub에서 자세하게 확인할 수 있습니다.</p>
<ul>
<li><a href="https://github.com/woowacourse-teams/2025-bom-bom/blob/client/frontend/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts">클릭/드래그 이벤트 처리 로직</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bom-bom/blob/client/frontend/web/src/pages/detail/utils/selection.ts">Selection 관련 로직</a></li>
<li><a href="https://github.com/woowacourse-teams/2025-bom-bom/blob/client/frontend/web/src/pages/detail/utils/highlight.ts">하이라이트 관련 로직</a></li>
</ul>
<h2 id="마무리하며">마무리하며</h2>
<p>하이라이트는 &quot;선택한 구간을 표시한다&quot;만 보면 단순해 보입니다. 그러나 모든 플랫폼에서 일관되게 동작하도록 이벤트를 다루고, 저장/복원 로직을 안정적으로 설계하며, 실전의 예외 케이스(멀티 문단, 웹뷰 차이, 리렌더링 충돌)들을 처리하는 순간부터 복잡도가 급격히 올라갑니다. </p>
<p>이번 글에서는 그 복잡도를 낮추기 위해 <strong>컨테이너 XPath + 내부 오프셋</strong>이라는 앵커 전략을 선택하고, <strong>선택→저장→복원→그리기/삭제</strong>까지의 전 과정을 단계별로 정리했습니다.</p>
<p>핵심은 세 가지였습니다.</p>
<ul>
<li><strong>정확성</strong>: 공통 조상과 오프셋 기반으로 멀티 문단도 결정적으로 복원</li>
<li><strong>일관성</strong>: iOS/Android/PC 각각의 이벤트 흐름을 분리해 안정적 트리거 확보</li>
<li><strong>실용성</strong>: 의미/접근성/상호작용에 유리한 <code>&lt;mark&gt;</code>로 DOM을 직접 마킹</li>
</ul>
<p>하지만 &quot;동작한다&quot;만으로는 충분하지 않습니다. 사용자가 실제로 쓰고 싶어지는 경험을 만들려면 더 많은 디테일이 필요합니다.</p>
<p>다음 편에서는 하이라이트 UX를 한 단계 더 개선하는 디테일과 React 환경에서 마주치는 실전 이슈들을 구체적인 코드와 함께 공유하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Service Worker, 너 도대체 뭐니?]]></title>
            <link>https://velog.io/@jae_o/Service-Worker-%EB%84%88-%EB%8F%84%EB%8C%80%EC%B2%B4-%EB%AD%90%EB%8B%88</link>
            <guid>https://velog.io/@jae_o/Service-Worker-%EB%84%88-%EB%8F%84%EB%8C%80%EC%B2%B4-%EB%AD%90%EB%8B%88</guid>
            <pubDate>Fri, 17 Oct 2025 05:32:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><code>MSW</code>나 <code>PWA</code>를 사용할 때마다 ‘Service Worker’라는 말을 봤지만, 그 정체를 깊이 생각해 본 적은 없었습니다.</p>
<p>그러던 중 <code>PWA</code> 개발 과정에서 캐싱 문제를 겪으면서 <strong>“도대체 Service Worker가 뭐길래 서비스 전체에 영향을 주지?”</strong>라는 의문이 생겼습니다. 그저 백그라운드에서 도는 스크립트라고만 생각했는데, 실제로는 서비스 전반의 동작 방식까지 바꿀 수 있는 존재였던 것입니다.</p>
<p>그래서 이번 글에서는 Service Worker가 무엇인지, 어떤 원리로 동작하는지 차근차근 살펴보려 합니다.</p>
<h3 id="대상-독자">대상 독자</h3>
<ul>
<li>Service Worker를 들어본 적은 있지만 정확히 어떤 역할을 하는지 모르는 분</li>
<li>Service Worker의 개념과 활용 방법을 더 깊이 이해하고 싶은 분</li>
</ul>
<h2 id="service-worker란">Service Worker란?</h2>
<blockquote>
<p>브라우저의 페이지와 네트워크 사이에서 요청을 가로채고 처리하는 독립 실행 환경(자바스크립트 파일).</p>
</blockquote>
<p>Service Worker는 페이지의 JS와는 별도로 <strong>워커 컨텍스트</strong>에서 동작하며, 브라우저가 이벤트를 트리거할 때 실행됩니다. 이를 통해 네트워크 요청을 가로채서 원하는 응답을 반환하거나, 캐시를 이용해 오프라인 상태에서도 콘텐츠를 제공할 수 있습니다. PWA의 핵심 기술로서 푸시 알림과 백그라운드 동기화 같은 기능 구현에도 사용됩니다.</p>
<h3 id="주요-특징">주요 특징</h3>
<ul>
<li><strong>이벤트 기반</strong>: <code>install</code>, <code>activate</code>, <code>fetch</code>, <code>push</code> 등 이벤트로 동작.</li>
<li><strong>비동기 중심</strong>: 대부분의 브라우저 API가 Promise 기반으로 동작.</li>
<li><strong>백그라운드 실행</strong>: UI와 독립적으로 동작(직접 DOM에 접근 불가).</li>
<li><strong>HTTPS 필요</strong>: 보안상의 이유로 HTTPS 환경에서만 동작 (개발 시 <code>localhost</code>는 예외)</li>
</ul>
<blockquote>
<p>⚠️ Service Worker는 페이지와 네트워크 사이에서 요청을 가로채고 조작할 수 있기 때문에, 악의적인 <a href="https://developer.mozilla.org/en-US/docs/Glossary/MitM">중간자</a>(Man-in-the-Middle)나 변조된 스크립트가 끼어들면 서비스 전체에 치명적인 영향을 줄 수 있다. 그래서 브라우저는 Service Worker를 <strong>HTTPS 환경</strong>에서만 동작하며, 개발 시 localhost는 예외다. 개발자는 서드파티 스크립트 검증과 안전한 배포 절차를 반드시 지켜야 한다.</p>
</blockquote>
<h2 id="service-worker-등록하기">Service Worker 등록하기</h2>
<p>Service Worker는 단순한 JS 파일이 아니라, 브라우저가 별도로 인식하고 관리해야 하는 워커입니다. 따라서 일반 스크립트처럼 <code>&lt;script&gt;</code>로 불러오는 것이 아니라, 명시적으로 등록(register) 해야 브라우저가 이를 감지하고 관리할 수 있습니다.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service Worker API</a>를 통해서 브라우저에 Service Worker를 등록 가능합니다.</p>
<h3 id="service-worker-파일-생성">Service Worker 파일 생성</h3>
<p><code>serviceWorker.js</code></p>
<pre><code class="language-tsx">self.addEventListener(&#39;install&#39;, function () {
  self.skipWaiting();
});

self.addEventListener(&#39;activate&#39;, function (event) {
  event.waitUntil(self.clients.claim());
});</code></pre>
<ul>
<li>Service Worker에서 실행할 기능을 정의하는 자바스크립트 파일입니다.</li>
<li><code>self</code>는 <a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope">ServiceWorkerGlobalScope</a>를 가리키며, Service Worker의 전역 실행 환경입니다.</li>
<li>Service Worker는 <code>window</code>와는 별도의 독립 환경에서 동작하므로, 생명주기(<code>install</code>, <code>activate</code>)나 네트워크 이벤트(<code>fetch</code>, <code>push</code> 등)를 처리할 때는 <code>self</code>에 이벤트를 등록해야 합니다.</li>
</ul>
<h3 id="service-worker-등록">Service Worker 등록</h3>
<pre><code class="language-jsx">const registerServiceWorker = async () =&gt; {
  if (&quot;serviceWorker&quot; in navigator) {
    try {
      const registration = await navigator.serviceWorker.register(&quot;/serviceWorker.js&quot;);
      if (registration.installing) {
        console.log(&quot;Service worker installing&quot;);
      } else if (registration.waiting) {
        console.log(&quot;Service worker installed&quot;);
      } else if (registration.active) {
        console.log(&quot;Service worker active&quot;);
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};

// …

registerServiceWorker();</code></pre>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">navigator.serviceWorker.register</a>를 사용해 브라우저에 Service Worker를 등록합니다.</li>
<li>등록을 성공하면 <code>installing</code>, <code>waiting</code>, <code>active</code> 상태를 확인할 수 있습니다.</li>
</ul>
<pre><code class="language-jsx">if (isProdunction) {
    registerPwaServiceWorker()
}

if (isDevelopment) {
    const { worker } = await import(&#39;./mocks/browser&#39;);
    worker.start()
}</code></pre>
<ul>
<li>Service Worker는 <strong>도메인당 하나</strong>만 등록할 수 있습니다.</li>
<li>따라서 <code>msw</code>(Mock Service Worker)처럼 Service Worker를 활용하는 라이브러리를 함께 쓴다면, 환경별로 분기하여 충돌을 방지하는 것이 좋습니다.</li>
</ul>
<blockquote>
<p>💡 <strong>TIP</strong>: 등록된 Service Worker는 브라우저 개발자 도구에서 확인할 수 있습니다.</p>
<ul>
<li><strong>Application 탭 → Service Workers</strong>: 등록 상태(<code>installing</code>, <code>waiting</code>, <code>activated</code>)와 업데이트/중지 버튼 확인 가능</li>
<li><strong>Sources 탭 → serviceWorker.js</strong>: 실제 등록된 스크립트를 디버깅 가능</li>
<li>chrome://inspect/#service-workers<ul>
<li>크롬에서 등록된 <strong>전체 Service Worker</strong> 목록을 확인할 수 있음</li>
<li>다른 도메인에 등록된 워커까지 한 번에 점검 가능</li>
</ul>
</li>
</ul>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jae_o/post/e19afd39-7582-425e-94b1-95e6d5adbfd7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/34962890-14f0-445d-b3b5-8e1f51a2f6c6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jae_o/post/02aa535e-b443-4c07-9af1-74dcf8dda67c/image.png" alt=""></p>
<h2 id="service-worker의-생명주기"><strong>Service Worker의 생명주기</strong></h2>
<p><img src="https://velog.velcdn.com/images/jae_o/post/efdb33e2-3bcc-403c-a086-405202b899db/image.png" alt=""></p>
<h3 id="1-installing"><strong>1. Installing</strong></h3>
<p>브라우저가 Service Worker 파일을 다운로드하고 파싱한 뒤, <strong>설치 중</strong> 상태가 됩니다.</p>
<ul>
<li>설치가 성공하면 <strong>Installed</strong> 상태로 넘어가고, 실패하면 <strong>Redundant</strong> 상태가 됩니다.</li>
<li><code>install</code> 이벤트에서 <code>event.waitUntil()</code>을 사용하면 특정 비동기 작업(예: 캐시 저장)이 끝날 때까지 설치 과정을 지연시킬 수 있습니다.</li>
</ul>
<h3 id="2-installedwaiting"><strong>2. Installed/waiting</strong></h3>
<p>설치가 완료되면 <strong>설치됨</strong> 상태가 됩니다.</p>
<ul>
<li>현재 앱을 제어하는 다른 Service Worker가 없다면 곧바로 <strong>Activating</strong>으로 넘어갑니다.</li>
<li>기존 Service Worker가 이미 동작 중이라면 새 워커는 <strong>waiting</strong> 상태로 머무릅니다.</li>
</ul>
<p>👉 새 버전의 Service Worker가 자동으로 바로 교체되지 않고, 기존 워커가 종료될 때까지 대기하는 이유는 <strong>사용자 경험을 보호하기 위함</strong>입니다.</p>
<h3 id="3-activating"><strong>3. Activating</strong></h3>
<p>설치된 워커가 앱을 제어하기 직전 상태입니다.</p>
<ul>
<li>이 단계에서 <code>activate</code> 이벤트가 발생합니다.</li>
<li><code>event.waitUntil()</code>을 사용해 캐시 정리나 마이그레이션 같은 작업을 완료할 때까지 활성화를 지연시킬 수 있습니다.</li>
</ul>
<h3 id="4-activated"><strong>4. Activated</strong></h3>
<p>Service Worker가 앱을 제어할 준비가 끝난 상태입니다.</p>
<ul>
<li>이 시점부터 네트워크 요청을 가로채 <code>fetch</code> 이벤트를 처리하거나, <code>push</code> 이벤트를 받아 푸시 알림을 보낼 수 있습니다.</li>
<li>사실상 <strong>실제 기능이 동작하는 단계</strong>입니다.</li>
</ul>
<h3 id="5-redundant"><strong>5. Redundant</strong></h3>
<p>Service Worker가 더 이상 쓰이지 않게 된 상태입니다.</p>
<ul>
<li>설치 실패, 혹은 새로운 버전의 Service Worker가 교체되었을 때 발생합니다.</li>
<li>이 상태의 워커는 앱 동작에 영향을 주지 않습니다.</li>
</ul>
<h2 id="service-worker-이벤트-소개"><strong>Service Worker 이벤트 소개</strong></h2>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope">ServiceWorkerGlobalScope</a>에는 다양한 이벤트가 정의되어 있습니다. 그중 자주 쓰이는 이벤트를 정리하면 다음과 같습니다.</p>
<h3 id="생명주기-관련">생명주기 관련</h3>
<h4 id="install"><code>install</code></h4>
<ul>
<li>Service Worker가 설치될 때 발생합니다.</li>
<li>초기 리소스 캐싱 같은 <strong>설치 준비 작업</strong>을 수행합니다.</li>
<li>이 이벤트는 취소할 수 없으며, 다른 이벤트로 대체되지 않습니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&#39;install&#39;, function (event) {
  // 기존 활성화된 워커가 있어도 곧바로 새 워커를 활성화
  self.skipWaiting();

  // 캐시에 필요한 리소스를 미리 저장 가능
  event.waitUntil(
    caches
    .open(&quot;v1&quot;)
    .then((cache) =&gt;
          cache.addAll([
      &quot;/&quot;,
      &quot;/index.html&quot;,
      &quot;/style.css&quot;,
      &quot;/app.js&quot;,
      &quot;/image-list.js&quot;,
      &quot;/star-wars-logo.jpg&quot;,
      &quot;/gallery/&quot;,
      &quot;/gallery/bountyHunters.jpg&quot;,
      &quot;/gallery/myLittleVader.jpg&quot;,
      &quot;/gallery/snowTroopers.jpg&quot;,
    ]),
         ),
  );
});</code></pre>
<h4 id="activate"><code>activate</code></h4>
<ul>
<li>새 Service Worker가 활성화 직전에 발생합니다.</li>
<li>이전 버전의 캐시를 정리하거나, 마이그레이션 작업을 수행합니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;activate&quot;, (event) =&gt; {
  const cacheAllowlist = [&quot;v2&quot;];

  event.waitUntil(
    caches.keys().then((cacheNames) =&gt;
                       Promise.all(
      cacheNames.map((cacheName) =&gt; {
        if (!cacheAllowlist.includes(cacheName)) {
          return caches.delete(cacheName);
        }
        return undefined;
      }),
    ),
                      ),
  );
});</code></pre>
<h3 id="네트워크--메시지-관련">네트워크 / 메시지 관련</h3>
<h4 id="fetch"><code>fetch</code></h4>
<ul>
<li><code>fetch()</code> 메서드가 호출될 경우 발생</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;fetch&quot;, (event) =&gt; {
  const { method, url } = event.request;

  // 특정 요청 가로채기
  if (method === &quot;GET&quot; &amp;&amp; url.endsWith(&quot;/hello&quot;)) {
    event.respondWith(
      new Response(
        JSON.stringify({ message: &quot;Hello from Service Worker!&quot; }),
        { headers: { &quot;Content-Type&quot;: &quot;application/json&quot; } }
      )
    );
  }
});</code></pre>
<h4 id="message"><code>message</code></h4>
<ul>
<li>페이지에서 <code>postMessage()</code>를 사용해 Service Worker로 데이터를 보낼 때 발생합니다.</li>
<li>페이지 ↔ Service Worker 간 양방향 통신에 활용됩니다. 예를 들어, 페이지에서 요청한 데이터를 워커가 가공해서 다시 응답할 수 있습니다.</li>
</ul>
<pre><code class="language-jsx">// serviceWorker.js
self.addEventListener(&quot;message&quot;, (event) =&gt; {
  console.log(&quot;Message from page:&quot;, event.data);

  // 받은 메시지에 따라 응답을 보낼 수도 있음
  event.source.postMessage({
    reply: `Echo: ${event.data}`,
  });
});</code></pre>
<pre><code class="language-jsx">// main.js (페이지 스크립트)
navigator.serviceWorker.controller.postMessage(&quot;Hello SW!&quot;);

// Service Worker가 응답 보낸 걸 수신
navigator.serviceWorker.addEventListener(&quot;message&quot;, (event) =&gt; {
  console.log(&quot;Reply from SW:&quot;, event.data.reply);
});
</code></pre>
<h4 id="messageerror"><code>messageerror</code></h4>
<ul>
<li>메시지 전송 도중 직렬화(serialize)할 수 없는 데이터나 전송 불가능한 객체를 보냈을 때 발생합니다.</li>
<li>메시지 통신 실패를 감지하고 로깅하거나 대체 동작을 수행합니다.</li>
</ul>
<pre><code class="language-jsx">// serviceWorker.js
self.addEventListener(&quot;messageerror&quot;, (event) =&gt; {
  console.error(&quot;Message failed:&quot;, event);
});</code></pre>
<pre><code class="language-jsx">// main.js
try {
  // 직렬화 불가능한 데이터(예: 함수)를 보내면 오류 발생
  navigator.serviceWorker.controller.postMessage(() =&gt; {});
} catch (err) {
  console.error(&quot;Message send failed:&quot;, err);
}</code></pre>
<h3 id="푸시--알림-관련">푸시 / 알림 관련</h3>
<h4 id="push"><code>push</code></h4>
<ul>
<li>서버에서 푸시 메시지를 보냈을 때 발생합니다.</li>
<li>알림(Notification API)을 띄우는 데 주로 사용됩니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;push&quot;, (event) =&gt; {
  const data = event.data?.json() || { title: &quot;Default title&quot; };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body || &quot;Hello from Service Worker!&quot;,
      icon: &quot;/icon.png&quot;,
    })
  );
});</code></pre>
<h4 id="pushsubscriptionchange"><code>pushsubscriptionchange</code></h4>
<ul>
<li>브라우저가 푸시 구독을 만료하거나 변경했을 때 발생합니다.</li>
<li>새로운 구독 정보를 서버에 업데이트해야 합니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;pushsubscriptionchange&quot;, (event) =&gt; {
  event.waitUntil(
    self.registration.pushManager.subscribe({ userVisibleOnly: true })
    .then((subscription) =&gt; {
      // 새로운 구독 정보를 서버로 전송
      return fetch(&quot;/update-subscription&quot;, {
        method: &quot;POST&quot;,
        body: JSON.stringify(subscription),
      });
    })
  );
});</code></pre>
<h4 id="notificationclick"><code>notificationclick</code></h4>
<ul>
<li>사용자가 알림을 클릭했을 때 발생합니다.</li>
<li>특정 URL을 열거나 포커스를 맞출 수 있습니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;notificationclick&quot;, (event) =&gt; {
  event.notification.close();

  event.waitUntil(
    clients.openWindow(&quot;/welcome&quot;) // 특정 경로 열기
  );
});</code></pre>
<h4 id="notificationclose"><code>notificationclose</code></h4>
<ul>
<li>사용자가 알림을 닫았을 때 발생합니다.</li>
<li>통계 수집, 서버 로깅 등에 활용할 수 있습니다.</li>
</ul>
<pre><code class="language-jsx">self.addEventListener(&quot;notificationclose&quot;, (event) =&gt; {
  console.log(&quot;Notification closed:&quot;, event.notification.tag);
});</code></pre>
<h3 id="기타">기타</h3>
<h4 id="sync"><code>sync</code></h4>
<ul>
<li>네트워크가 다시 연결되었을 때 브라우저가 백그라운드에서 동기화 작업을 수행할 수 있을 때 발생합니다.</li>
<li>오프라인 상태에서 실패한 요청(예: 서버에 저장하지 못한 데이터)을 네트워크가 복구되면 다시 전송하는 데 유용합니다.</li>
</ul>
<pre><code class="language-jsx">// serviceWorker.js
self.addEventListener(&quot;sync&quot;, (event) =&gt; {
  if (event.tag === &quot;sendFormData&quot;) {
    event.waitUntil(
      fetch(&quot;/submit&quot;, {
        method: &quot;POST&quot;,
        body: JSON.stringify({ message: &quot;retry after offline&quot; }),
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      })
    );
  }
});</code></pre>
<pre><code class="language-jsx">// main.js (페이지)
navigator.serviceWorker.ready.then((registration) =&gt; {
  return registration.sync.register(&quot;sendFormData&quot;);
});</code></pre>
<h2 id="마무리하며">마무리하며</h2>
<p>이번 글에서는 Service Worker의 기본 개념부터 생명주기, 그리고 주요 이벤트까지 살펴보았습니다.
Service Worker는 PWA의 핵심 기술이지만, 개념만 보면 추상적으로 느껴지기 쉽습니다.
중요한 것은 <strong>언제 어떤 이벤트가 발생하고, 그 이벤트 안에서 무엇을 할 수 있는지</strong>를 이해하는 것입니다.</p>
<h3 id="기억해-두면-좋은-포인트">기억해 두면 좋은 포인트</h3>
<ul>
<li>Service Worker는 <strong>브라우저와 네트워크 사이의 독립 실행 환경</strong>이다.</li>
<li><strong>HTTPS 환경에서만 동작</strong>하며(<code>localhost</code> 예외), 이는 보안상 필수 제약이다.</li>
<li><strong>한 도메인에는 하나의 Service Worker만 등록 가능</strong>하다.</li>
<li><strong>생명주기</strong>(<code>install → waiting → activate → activated → redundant</code>)를 이해하자.</li>
<li><strong>Service Worker는 잘못 설계하면 서비스 전체에 영향을 주므로, 신중하게 설계해야 한다.</strong></li>
</ul>
<h3 id="참고자료">참고자료</h3>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service Worker API - Web APIs | MDN</a></li>
<li><a href="https://wonsss.github.io/PWA/service-worker/#4-2-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9B%8C%EC%BB%A4-%ED%99%9C%EC%84%B1%ED%99%94-%EC%8B%9C-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%A0%9C%EC%96%B4%EA%B6%8C-%EC%A6%89%EC%8B%9C-%EB%B6%80%EC%97%ACactivating--activated">PWA의 핵심, 서비스 워커란?</a></li>
<li><a href="https://tech.kakaoent.com/front-end/2022/221208-service-worker/">서비스 워커에 대해 알아보고 Mock Response 만들기 | 카카오엔터테인먼트 테크블로그</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[GA4 사용자 행동 데이터 설계 (1) - 구조 설계]]></title>
            <link>https://velog.io/@jae_o/Google-Analytics-4%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%96%89%EB%8F%99-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-1-%EC%88%98%EC%A7%91-%EB%B6%84%EC%84%9D-%ED%99%9C%EC%9A%A9-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</link>
            <guid>https://velog.io/@jae_o/Google-Analytics-4%EB%A1%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%96%89%EB%8F%99-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-1-%EC%88%98%EC%A7%91-%EB%B6%84%EC%84%9D-%ED%99%9C%EC%9A%A9-%EA%B5%AC%EC%A1%B0-%EC%9E%A1%EA%B8%B0</guid>
            <pubDate>Wed, 08 Oct 2025 13:58:42 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>서비스를 기획/운영하다 보면, ‘이 기능이 정말 사용자가 잘 쓸까?’, ‘사용자는 어디서 이탈할까?’ 같은 질문을 자주 던지게 됩니다. 하지만 이런 판단을 ‘감(感)’으로만 하기엔 위험합니다.</p>
<p><strong>Google Analytics(GA)</strong>는 이런 불확실성을 줄여주는 도구입니다. 사용자의 행동 흐름을 감이 아닌 데이터로 정량적으로 분석할 수 있게 합니다.</p>
<p>제가 개발한 서비스 &#39;<a href="https://www.bombom.news/"><strong>봄봄(Bombom)</strong></a>&#39;은 “읽는 습관을 만드는 뉴스레터 플랫폼”입니다. 이 서비스의 핵심 목표는 단순한 방문이 아니라, <strong>“얼마나 자주, 얼마나 꾸준히 다시 돌아오는가”</strong>를 데이터로 확인하는 것입니다.</p>
<p>그래서 서비스 초기에 GA를 도입했습니다. GA4를 통해 ‘가입 → 구독 → 첫 읽기’까지의 사용자 여정을 추적하고, 습관 형성이 실제로 이루어지는지를 데이터로 검증하고자 했습니다.</p>
<hr>
<p>이번 글에서는 GA를 서비스에 적용하기 이전 단계인 ‘설계’ 과정을 다룹니다. 즉, 단순히 “GA를 적용하는 법”이 아니라 <strong>‘무엇을, 왜, 어떻게 수집할 것인가’</strong>를 정의하고, 서비스의 목표에 맞게 데이터를 구조화하는 방법을 이야기합니다.</p>
<p>또한, 이해를 돕기 위해 실제 저희 봄봄 프로젝트에서 설계했던 예시를 함께 소개합니다. 이 글이 GA를 처음 도입하거나, 데이터를 ‘의미 있게’ 활용하고 싶은 분들께 하나의 데이터 설계 가이드라인이 되길 바랍니다.</p>
<h2 id="사용자-행동-데이터-고도화-설계-수집-·-분석-·-활용">사용자 행동 데이터 고도화 설계: 수집 · 분석 · 활용</h2>
<p>서비스를 데이터로 성장시키기 위해서는 <strong>‘사용자 행동을 어떻게 정의하고, 측정하고, 해석할 것인가’</strong>를 먼저 설계해야 합니다. 이 과정은 단순한 이벤트 기록이 아니라, 서비스의 목표를 데이터로 번역하는 일입니다.</p>
<p>Google Analytics(GA4)를 기반으로 한 사용자 행동 데이터 전략은
수집(Collect) → 분석(Analyze) → 활용(Activate) 의 세 단계로 나눌 수 있습니다.</p>
<hr>
<h3 id="1-수집-collect">1) 수집 (Collect)</h3>
<blockquote>
<p>서비스의 데이터를 설계할 때 가장 먼저 해야 할 일은 <strong>“무엇을 기록할 것인가”</strong>를 명확히 정의하는 것입니다. 이 단계는 단순히 클릭 이벤트를 저장하는 수준이 아니라, 서비스의 목표를 수치로 관찰할 수 있게 만드는 과정입니다.</p>
</blockquote>
<h4 id="📌-수집-단계의-핵심-포인트">📌 수집 단계의 핵심 포인트</h4>
<ol>
<li><p>어떤 사용자 행동 데이터를 기록할지 정의하는 단계입니다.
→ 서비스의 핵심 플로우에서 사용자의 ‘의도 있는 행동’을 추출합니다.</p>
</li>
<li><p>페이지 방문, 클릭, 체류 시간, 전환 이벤트 등 필요한 데이터를 선별하고, 코드 단에서 추적 로직(gtag 등)을 직접 심습니다.</p>
</li>
<li><p>수집 단계에서의 설계가 부족하면 이후 분석과 활용에서 한계가 생기므로, 서비스 목표에 맞춘 정교한 데이터 정의가 필수적입니다.</p>
</li>
</ol>
<hr>
<h4 id="⚠️-수집-단계의-유의사항">⚠️ 수집 단계의 유의사항</h4>
<ol>
<li><p>모든 데이터를 다 기록하지 않습니다.
→ 지나친 이벤트 수집은 코드 복잡도와 분석 노이즈를 높입니다. 필요한 데이터만 선별해야 합니다.</p>
</li>
<li><p>수집은 ‘한 번에 완벽히’ 끝나지 않는 것입니다.
→ 분석 단계를 거치며 새로운 인사이트가 생기면 다시 어떤 데이터를 수집해야 할지가 달라집니다. 즉, 수집–분석–활용은 순환 구조입니다.</p>
</li>
</ol>
<hr>
<h4 id="💡-봄봄에서의-수집-설계-예시">💡 봄봄에서의 수집 설계 예시</h4>
<p>봄봄의 핵심 목표는 “읽는 습관을 만드는 것”이었기 때문에, 습관 형성에 직결되는 행동만 선별했습니다.</p>
<p>1️⃣ 아티클 카드 클릭 이벤트</p>
<ul>
<li>목적: 사용자가 ‘어떤 경로에서 아티클을 읽기 시작했는가’를 파악하기 위함입니다. 봄봄은 ‘오늘의 뉴스레터’, ‘뉴스레터 보관함’ 등 다양한 진입점에서 아티클 읽기 페이지로 이동할 수 있습니다.</li>
<li>수집 이유: 아티클 읽기 페이지의 접근 경로를 데이터로 비교해, “어떤 페이지가 실제 읽기 행동으로 가장 잘 이어지는가”를 분석할 수 있도록 하기 위함입니다.</li>
</ul>
<p>2️⃣ 하이라이트 / 메모 이벤트</p>
<ul>
<li>목적: 봄봄은 “읽은 내용을 기록하고 활용하는 경험”을 동기부여 요소로 설계했습니다. 이 기능이 실제로 사용자 습관에 기여하는지를 검증하기 위해 하이라이트/메모 관련 행동 데이터를 수집했습니다.</li>
<li>수집 이유: 사용자가 얼마나 자주 하이라이트를 추가·삭제하고, 메모를 작성·업데이트하는지에 따라 기능의 실효성을 평가할 수 있습니다.</li>
</ul>
<hr>
<h3 id="2-분석-analyze">2) 분석 (Analyze)</h3>
<blockquote>
<p>수집 단계에서 이벤트를 정의했다면, 이제는 그 데이터를 어떻게 읽을 것인가가 중요합니다. 분석 단계는 수집된 데이터를 기반으로 <strong>사용자의 행동 흐름(Flow)</strong>을 파악하고, 서비스의 병목이나 개선 포인트를 찾아내는 과정입니다.</p>
</blockquote>
<h4 id="📌-분석-단계의-핵심-포인트">📌 분석 단계의 핵심 포인트</h4>
<ol>
<li><p>수집된 데이터를 기반으로 사용자 행동 흐름을 파악하는 단계입니다.
→ 단순히 클릭 수를 세는 것이 아니라, 사용자가 어떤 경로로 이동하며, 어디서 멈추는지를 확인합니다.</p>
</li>
<li><p>신규 사용자와 재방문자의 행동 차이, 주요 이탈 구간, 전환 경로를 살펴봅니다.
→ 이 과정을 통해 어떤 지점이 사용자 경험을 방해하는지 구체적으로 드러납니다.</p>
</li>
<li><p>이 단계는 코드보다 GA4 인터페이스(GA 홈페이지)의 이해도가 훨씬 중요합니다.
→ 이벤트만 잘 보내는 것으로는 충분하지 않습니다. 실제 GA4에서 이벤트를 연결·시각화·분석하는 흐름을 설계해야 진짜 인사이트가 만들어집니다.</p>
</li>
</ol>
<hr>
<h4 id="⚠️-분석-단계의-유의사항">⚠️ 분석 단계의 유의사항</h4>
<ol>
<li><p>이 단계는 설계 과정에서 가장 중요합니다.
→ 데이터를 ‘수집하는 것’보다 무엇을 해석하고, 어떤 결정을 내릴 것인가가 훨씬 중요합니다.</p>
</li>
<li><p>코드가 아닌 ‘GA 설정 이해’가 핵심입니다.
→ 수집된 데이터는 단순히 이벤트 로그에 머물지 않습니다. GA4에서는 우리가 직접 수집한 이벤트 외에도 <strong>기본적으로 제공되는 자동 수집 데이터(DAU, MAU, 세션, 페이지 전환, 체류 시간 등)</strong>가 매우 풍부합니다. 문제는 이 방대한 데이터 중에서 무엇을 어떻게 연결하고 분석할지 GA 내부에서 설정하는 과정이 훨씬 복잡하고 중요하다는 점입니다.</p>
</li>
</ol>
<hr>
<h4 id="💡-봄봄에서-분석하려는-행동-흐름">💡 봄봄에서 분석하려는 행동 흐름</h4>
<p>봄봄은 <strong>“읽기 → 기록 → 재방문”</strong>이라는 습관 형성 구조를 가지고 있습니다. 이를 검증하기 위해, 두 가지 주요 행동 흐름을 분석 대상으로 정했습니다.</p>
<p>1️⃣ 아티클 소비 흐름</p>
<ul>
<li>목적: 사용자가 어떤 진입점에서 가장 많이 아티클 읽기로 이어지는지를 확인하기 위함입니다. 여러 페이지(오늘의 뉴스레터, 뉴스레터 보관함 등)에서 아티클을 클릭할 수 있지만, 실제로 ‘읽기 페이지’로 전환되는 비율은 다릅니다.</li>
</ul>
<p>2️⃣ 하이라이트 / 메모 기능 활용 흐름</p>
<ul>
<li>목적: 봄봄의 핵심 기능 중 하나인 ‘하이라이트/메모’가 실제로 사용자의 습관 형성에 얼마나 기여하는지를 파악하기 위함입니다.</li>
</ul>
<h3 id="3-활용-apply">3) 활용 (Apply)</h3>
<blockquote>
<p>데이터 설계의 마지막 단계는 <strong>활용(Apply)</strong>입니다. 이 단계는 단순히 분석 결과를 보고서로 남기는 것이 아니라, 그 데이터를 기반으로 실제 서비스의 방향을 결정하고 실행 전략을 설계하는 과정입니다.</p>
</blockquote>
<h4 id="📌-활용-단계의-핵심-포인트">📌 활용 단계의 핵심 포인트</h4>
<ol>
<li><p>분석 결과를 서비스에 반영하는 단계입니다.
→ 마케팅, 제품 개선, 온보딩, 리텐션 프로그램 등 다양한 영역에서 데이터를 활용할 수 있습니다.</p>
</li>
<li><p>실행 자체보다 “활용 계획을 세우는 것”이 더 중요합니다.
→ 모든 인사이트를 즉시 반영할 수는 없지만, 무엇을 언제, 어떤 우선순위로 개선할지를 미리 설계해야 데이터 전략이 완성됩니다.</p>
</li>
</ol>
<h2 id="마무리하며">마무리하며</h2>
<p>많은 서비스가 Google Analytics를 “적용했다”에서 멈춥니다. 그러나 목적 없이 데이터를 쌓기 시작하면, 그 수치는 금세 아무 의미 없는 숫자가 되고, GA는 더 이상 열어보지 않는 ‘형식적인 툴’로 남게 됩니다.</p>
<p>그래서 중요한 것은 적용보다 설계입니다. 무엇을 수집하고, 왜 분석하며, 어떻게 활용할지를 서비스의 목표와 연결된 구조로 설계해야 합니다. 이렇게 체계적으로 설계된 데이터는 다시 서비스 개선으로 이어지고, GA는 자연스럽게 <strong>‘계속 활용하고 싶은 중요한 툴’</strong>로 자리 잡게 됩니다.</p>
<p>이번 글에서는 GA4를 단순한 통계 도구가 아닌, 서비스의 성장을 데이터로 뒷받침하는 설계 도구로 다루는 방법을 이야기했습니다. ‘수집(Collect) → 분석(Analyze) → 활용(Apply)’ 단계를 체계적으로 나누어 설계하면, 데이터는 더 이상 숫자가 아니라 <strong>“보고 싶고, 계속 확인하고 싶은 지표”</strong>로 바뀝니다.</p>
<p>이번 글에서는 GA4를 기반으로 ‘무엇을 수집하고 어떻게 해석할지’를 중심으로 데이터 설계 과정을 다뤘습니다. 다음 글에서는 이 설계 내용을 실제 코드에 적용하는 과정을 다룰 예정입니다.</p>
<p>React 환경에서 react-ga4 같은 외부 라이브러리 없이, 직접 GA를 초기화하고, 이벤트 트래킹 로직을 구현하는 방법을 단계별로 소개하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TanStack Query, 직접 구현해보기(3) - 캐시 정책과 최적화 기능 구현]]></title>
            <link>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B03</link>
            <guid>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B03</guid>
            <pubDate>Fri, 29 Aug 2025 05:13:06 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B01-%EC%99%9C-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%82%98#%EC%A3%BC%EC%9A%94-%EA%B8%B0%EB%8A%A5">1편</a>과 <a href="https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B02-useQuery-useMutation-%EA%B5%AC%ED%98%84#usequery-%ED%9D%90%EB%A6%84%EB%8F%84">2편</a>에서는 <code>QueryStore</code>와 <code>useSyncExternalStore</code>를 기반으로 캐시 생성 → 구독 → 갱신의 최소 골격을 구축하고, <code>useQuery</code>/<code>useMutation</code>의 기본 흐름을 정리했다.
그러나 현 구조에는 캐시를 언제 갱신하고 언제 폐기할지에 대한 정책이 없으며, 동일 데이터를 여러 곳에서 동시에 요청하는 상황이나 일시적 네트워크 오류 같은 운영 이슈에도 취약하다.
3편부터는 이 빈틈을 메우며 시스템의 완성도를 끌어올려보자.</p>
<h3 id="이번-편의-주요-추가-기능">이번 편의 주요 추가 기능</h3>
<ul>
<li><strong>staleTime</strong><ul>
<li>SWR에 나오는 <code>stale</code>을 뜻하며,</li>
<li>캐시가 <code>fresh</code> → <code>stale</code>로 바뀌는 데 걸리는 시간이다.</li>
<li>즉, <code>staleTime</code>이 지나면 새로운 데이터를 불러오게 된다.</li>
</ul>
</li>
<li><strong>gcTime</strong><ul>
<li><code>gcTime</code>은 <code>Garbage Collect Time</code>의 약자로,</li>
<li>구독이 끊긴 캐시 데이터를 얼마나 오래 보존할지를 정하는 수명 개념이다.</li>
</ul>
</li>
<li><strong>retry</strong><ul>
<li>일시적 네트워크/서버 오류 시 해당 쿼리를 자동 재시도하는 횟수를 뜻한다.</li>
</ul>
</li>
<li><strong>requestCoalescing</strong><ul>
<li>동일 queryKey에 대한 동시 요청을 하나의 요청으로 합친다.</li>
<li>첫 요청의 Promise를 공유해 중복 트래픽을 제거하고, 완료 결과를 모든 구독자에 반영하는 기능이다.</li>
</ul>
</li>
</ul>
<h3 id="기능-구현-전-리팩토링-clientstore-분리로-응집도·확장성-높이기">기능 구현 전 리팩토링: Client/Store 분리로 응집도·확장성 높이기</h3>
<p>이번 리팩토링에서는 <a href="https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B02-useQuery-useMutation-%EA%B5%AC%ED%98%84#querystore-%EC%A0%84%EC%B2%B4-%EC%BD%94%EB%93%9C">QueryStore</a>와 이를 <a href="https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B02-useQuery-useMutation-%EA%B5%AC%ED%98%84#fetchquerydata-%ED%95%A8%EC%88%98-%EB%B6%84%EB%A6%AC">사용하는 로직</a>을 client / store로 분리했다.
목적은 간단하다.</p>
<ol>
<li>관심사 분리(SoC)</li>
<li>전역 상태 캡슐화</li>
<li>응집도 향상</li>
<li>향후 stale/gc/retry/coalescing 정책을 자연스럽게 끼워 넣기 위함</li>
</ol>
<h4 id="현재-querystore-구조의-문제점">현재 QueryStore 구조의 문제점</h4>
<ul>
<li>전역 store/listeners 노출</li>
<li>상태 보관/구독과 네트워크 트리거 혼재 → 관심사 불명확</li>
<li>getSnapshot/subscribe/updateData가 흩어진 함수로 존재 → 응집도 낮음, 객체(클라이언트/스토어)로 묶어 캡슐화 필요</li>
</ul>
<h4 id="store-데이터·구독에만-집중">Store: 데이터·구독에만 집중</h4>
<pre><code class="language-ts">export const createQueryStore = (): QueryStore =&gt; {
  const store: Store&lt;unknown&gt; = new Map&lt;string, Query&lt;unknown&gt;&gt;();
  const listeners: Record&lt;string, Set&lt;Listener&gt;&gt; = {};

  const getOrInit = &lt;TData&gt;(key: string): Query&lt;TData&gt; =&gt; {
    const cur = store.get(key) as Query&lt;TData&gt; | undefined;
    if (cur) return cur;

    const next = initState();
    store.set(key, next);
    return next as Query&lt;TData&gt;;
  };

  return {
    getSnapshot: &lt;TData&gt;(key: string) =&gt; getOrInit&lt;TData&gt;(key),
    setSnapshot: &lt;TData&gt;(
      key: string,
      next: Query&lt;TData&gt; | ((prev: Query&lt;TData&gt;) =&gt; Query&lt;TData&gt;),
    ) =&gt; {
      const prev = getOrInit&lt;TData&gt;(key);
      store.set(
        key,
        typeof next === &#39;function&#39;
          ? (next as (p: Query&lt;TData&gt;) =&gt; Query&lt;TData&gt;)(prev)
          : next,
      );

      listeners[key]?.forEach((cb) =&gt; cb());
    },
    subscribe: (key, callback) =&gt; {
      if (!listeners[key]) listeners[key] = new Set();
      listeners[key].add(callback);
      return () =&gt; listeners[key]?.delete(callback);
    },
  };
};

export const queryStore = createQueryStore();
</code></pre>
<ul>
<li>전역 객체 제거 → 모듈 내부 캡슐화</li>
<li>getSnapshot/subscribe/setSnapshot을 한 객체로 묶어 응집도↑</li>
</ul>
<h4 id="client-정책·행위의-진입점">Client: 정책·행위의 진입점</h4>
<pre><code class="language-ts">export const queryClient = {
  patchQuery: &lt;TData&gt;(key: string, partial: Partial&lt;Query&lt;TData&gt;&gt;) =&gt; {
    queryStore.setSnapshot(key, (prev) =&gt; ({ ...prev, ...partial }));
  },
  fetchQuery: async &lt;TData&gt;(key: string, queryFn: () =&gt; Promise&lt;TData&gt;) =&gt; {
    queryClient.patchQuery&lt;TData&gt;(key, { isLoading: true, isError: false });
    try {
      const result = await queryFn();
      queryClient.patchQuery(key, {
        data: result,
        isLoading: false,
        isError: false,
      });
    } catch {
      queryClient.patchQuery(key, {
        isLoading: false,
        isError: true,
      });
    }
  },
  loadQueryData: &lt;TData&gt;(key: string, queryFn: () =&gt; Promise&lt;TData&gt;) =&gt; {
    const snapshot = queryStore.getSnapshot(key);
    if (snapshot.data) return;

    queryClient.fetchQuery(key, queryFn);
  },
};</code></pre>
<ul>
<li>네트워크와 정책의 책임은 client가 일관되게 담당</li>
</ul>
<h2 id="기능-구현">기능 구현</h2>
<h3 id="staletime">staleTime</h3>
<blockquote>
<p>[변경 로직]</p>
<ol>
<li>useQuery 훅 옵션으로 staleTime 전달</li>
<li>updatedAt 타임스탬프 저장</li>
<li>“스테일인지” 판정 로직</li>
</ol>
</blockquote>
<h4 id="1-훅에서-staletime-받기--로드-시-전달">1) 훅에서 staleTime 받기 + 로드 시 전달</h4>
<pre><code class="language-ts">// useQuery.ts
interface UseQueryOptions&lt;TData&gt; {
  // ...
  staleTime?: number; // fresh 상태 유지 시간(ms)
}

export const useQuery = &lt;TData&gt;({ 
  //...
  staleTime = 0  // 기본 0분
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  // ...
  queryClient.loadQueryData(queryKey, queryFnRef.current, staleTime); // staleTime 인자로 추가
  // ...
};</code></pre>
<h4 id="2-데이터가-언제-갱신됐는지-기록-updatedat">2) 데이터가 언제 갱신됐는지 기록: updatedAt</h4>
<pre><code class="language-ts">export type Query&lt;TData&gt; = {
  // ...
  updatedAt?: number;
};</code></pre>
<h4 id="3-지금-stale-인가를-판단">3) “지금 stale 인가?”를 판단</h4>
<pre><code class="language-ts">// queryClient.ts
loadQueryData&lt;TData&gt;(key, queryFn, staleTime) {        // staleTime 인자 추가
  const snapshot = queryStore.getSnapshot(key);
  const isStale =
    !snapshot.updatedAt || Date.now() - snapshot.updatedAt &gt; staleTime; // 추가

  if (snapshot.data == null || isStale) {              // 패칭 조건 추가
    queryClient.fetchQuery&lt;TData&gt;(key, queryFn);
  }
}</code></pre>
<h3 id="gctime">gcTime</h3>
<blockquote>
<p>[변경 로직]</p>
<ol>
<li>useQuery 훅 옵션으로 gcTime 전달</li>
<li>마지막 구독 해제 시 GC 타이머를 걸고, 재구독 시 타이머를 해제</li>
</ol>
</blockquote>
<h4 id="1-훅에서-gctime-받기--구독-시-전달">1) 훅에서 gcTime 받기 + 구독 시 전달</h4>
<pre><code class="language-ts">// useQuery.ts
interface UseQueryOptions&lt;TData&gt; {
  // ...
  gcTime?: number;  // 캐시 보존 시간(ms)
}

export const useQuery = &lt;TData&gt;({
  // ...
  gcTime = 5 * 60 * 1000,  // 기본 5분
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  // ...
  const unsubscribe = queryStore.subscribe(queryKey, onStoreChange, gcTime); // subscribe 인자로 추가
  // ...
};</code></pre>
<h4 id="2-재구독-시-gc-타이머-해제-캐시-보존">2) 재구독 시 GC 타이머 해제 (캐시 보존)</h4>
<pre><code class="language-ts">// queryStore.ts
const gcTimeouts = new Map&lt;string, ReturnType&lt;typeof setTimeout&gt;&gt;();

// gcTime 인자 추가
subscribe: (key, callback, gcTime) =&gt; {
  // ...
  // 재구독 시, 기존 GC 타이머가 걸려 있었다면 해제
  const timeout = gcTimeouts.get(key);
    if (timeout) {
      clearTimeout(timeout);
      gcTimeouts.delete(key);
    }
  // ...
  return () =&gt; {
    listeners[key]?.delete(callback);

    // 구독 해제 시, 구독 수가 0일 경우 GC 타이머 가동
    if (listeners[key]?.size === 0) {
      delete listeners[key];
      const timeout = setTimeout(() =&gt; {
        store.delete(key);
        gcTimeouts.delete(key);
      }, gcTime);
      gcTimeouts.set(key, timeout);
    }
  };
}</code></pre>
<h3 id="retry-간단한-재시도-로직을-붙이기">retry: 간단한 재시도 로직을 붙이기</h3>
<blockquote>
<p>[변경 로직]</p>
<ol>
<li>useQuery 훅 옵션으로 retry 전달</li>
<li>loadQueryData에서 실패 시 짧게 대기 후 재시도</li>
<li>fetchQuery에서 실패를 rethrow하여 상위(루프)가 감지</li>
</ol>
</blockquote>
<h4 id="1-훅에서-retry-받기--로드-시-전달">1) 훅에서 retry 받기 + 로드 시 전달</h4>
<pre><code class="language-ts">// useQuery.ts
interface UseQueryOptions&lt;TData&gt; {
  // ...
  retry?: number; // 실패 시 재시도 횟수
}

export const useQuery = &lt;TData&gt;({
  // ...
  retry = 3, // 기본 3회
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  // ...
  // staleTime / retry를 함께 전달
  queryClient.loadQueryData(queryKey, queryFnRef.current, staleTime, retry);
  // ...
};</code></pre>
<h4 id="2-실패하면-잠깐-쉰-뒤-다시-loadquerydata-재시도-루프">2) 실패하면 잠깐 쉰 뒤 다시: loadQueryData 재시도 루프</h4>
<pre><code class="language-ts">// queryClient.ts
const sleep = (ms: number) =&gt; new Promise((r) =&gt; setTimeout(r, ms)); // 간단 대기 유틸

export const queryClient = {
  // ...
  // 재시도 횟수 인자 추가
  loadQueryData: async &lt;TData&gt;(key, queryFn, staleTime, retryCount) =&gt; {
    // ...
    if (snapshot.data == null || isStale) {
      let attempt = 0;

      // 실패 시 sleep(1000) 후 다시 시도
      while (attempt &lt; retryCount) {
        try {
          await queryClient.fetchQuery(key, queryFn);
          return; // 성공하면 종료
        } catch {
          await sleep(1000);
          attempt++;
        }
      }

      // (선택) 마지막 한 번 더 시도하고 싶다면 아래 한 줄:
      // await queryClient.fetchQuery(key, queryFn);
    }
  },
};</code></pre>
<h4 id="3-실패를-위로-올려주기-fetchquery에서-rethrow">3) 실패를 위로 올려주기: fetchQuery에서 rethrow</h4>
<pre><code class="language-ts">// queryClient.ts
fetchQuery: async &lt;TData&gt;(key: string, queryFn: () =&gt; Promise&lt;TData&gt;) =&gt; {
  queryClient.patchQuery&lt;TData&gt;(key, { isLoading: true, isError: false });
  try {
    const result = await queryFn();
    queryClient.patchQuery(key, {
      data: result,
      isLoading: false,
      isError: false,
      updatedAt: Date.now(),
    });
  } catch (e) {
    queryClient.patchQuery&lt;TData&gt;(key, { isLoading: false, isError: true });
    // 실패를 상위(loadQueryData)로 전달해 재시도 루프가 감지하도록
    throw (e instanceof Error ? e : new Error(String(e)));
  }
},</code></pre>
<h3 id="requestcoalescing">requestCoalescing</h3>
<blockquote>
<p>[변경 로직]</p>
<ol>
<li>inFlightFetchFns로 진행 중 요청을 키별로 기록</li>
<li>fetchQuery 내에 try/await → then–catch–finally 체인으로 변경</li>
<li>같은 키로 들어오는 후속 호출은 기존 Promise를 그대로 반환하여 중복 요청 방지</li>
</ol>
</blockquote>
<h4 id="1-in-flight-맵-추가--첫-호출후속-호출-분기">1) in-flight 맵 추가 + 첫 호출/후속 호출 분기</h4>
<pre><code class="language-ts">// queryClient.ts
const createQueryClient = () =&gt; {
  const inFlightFetchFns = new Map&lt;string, Promise&lt;void&gt;&gt;(); // in-flight 기록

  return {
    async fetchQuery&lt;TData&gt;(key: string, queryFn: () =&gt; Promise&lt;TData&gt;) {
      // 이미 요청 중이면 해당 Promise를 반환
      if (inFlightFetchFns.has(key)) {
        return inFlightFetchFns.get(key); // request coalescing 핵심
      }

      // ...

      // 동기 예외도 Promise 거부로 표준화
      const fetchPromise = Promise.resolve()
        .then(() =&gt; queryFn())
        .then((result) =&gt; {
          queryClient.patchQuery(key, {
            data: result,
            isLoading: false,
            isError: false,
            updatedAt: Date.now(),
          });
        })
        .catch((error) =&gt; {
          queryClient.patchQuery(key, { isLoading: false, isError: true });
          throw (error instanceof Error ? error : new Error(String(error)));
        })
        .finally(() =&gt; {
          inFlightFetchFns.delete(key); // 완료 시 정리
        });

      inFlightFetchFns.set(key, fetchPromise);

      return fetchPromise; // ← 첫 호출도 Promise 반환
    },
  };
};

export const queryClient = createQueryClient();</code></pre>
<h2 id="전체-코드-보러가기">전체 코드 보러가기</h2>
<p><a href="https://github.com/jaeyoung-kwon/useJaeO/tree/6031f49d9c4cdc43a2373ef69d02fc3bf60b9268">👉 전체 코드 보기 (GitHub)</a></p>
<h2 id="마치며">마치며</h2>
<p>처음에는 단순히 데이터를 캐싱하는 기능만 있어도 충분하다고 생각했지만, 실제로 구현을 이어가다 보니 운영 환경에서 필요한 정책들이 훨씬 많다는 걸 깨달았다.
이번 글에서는 그 빈틈을 메우는 네 가지 기능을 구현하며 시스템이 점점 &#39;진짜&#39; 쿼리 라이브러리답게 다듬어지는 과정을 공유했다.  </p>
<p>다음 글에서는 이 기반 위에서 조금 더 실전적인 패턴과 최적화 포인트들을 탐구해 볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TanStack Query, 직접 구현해보기(2) - useQuery, useMutation 구현]]></title>
            <link>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B02-useQuery-useMutation-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B02-useQuery-useMutation-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 30 Jun 2025 16:23:13 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>지난 <a href="https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B01-%EC%99%9C-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%82%98#%EC%A3%BC%EC%9A%94-%EA%B8%B0%EB%8A%A5">1편</a>에서는 TanStack Query를 직접 구현하게 된 배경을 정리했다.
이번 글에서는 주요 기능을 직접 구현하면서, 내부 구조가 어떻게 구성되어 있는지도 함께 살펴보려고 한다.</p>
<h3 id="이번-글에서-구현할-주요-기능">이번 글에서 구현할 주요 기능</h3>
<ul>
<li><code>useQuery</code><ul>
<li>Data fetching: <code>isLoading</code>, <code>isError</code>, <code>data</code> 제공</li>
<li>Query Key 기반 데이터 캐싱</li>
<li><code>useSyncExternalStore</code>를 이용한 자동 리패칭</li>
</ul>
</li>
<li><code>useMutation</code>: 데이터를 생성, 수정, 삭제하는 등의 작업에 사용되는 훅<ul>
<li>리패칭 대상 지정 가능</li>
<li><code>onSuccess</code> / <code>onErro</code> 콜백</li>
</ul>
</li>
</ul>
<h3 id="핵심-개념-먼저-짚고-가기">핵심 개념 먼저 짚고 가기</h3>
<p>먼저 구현할 기능들에 대한 개념과 사용법에 대해서 간단히 알아보고 넘어가자.</p>
<h4 id="usequery"><code>useQuery</code></h4>
<h5 id="사용-방법">사용 방법</h5>
<pre><code class="language-ts">const { data, isLoading, isError } = useQuery({ queryKey, queryFn })</code></pre>
<h5 id="parameter-options">Parameter (Options)</h5>
<ul>
<li><code>queryKey</code><ul>
<li>쿼리를 고유하게 식별하기 위한 값이다.</li>
<li>배열 형태로 지정한다.</li>
<li>이 키가 변경되면 쿼리가 자동으로 업데이트된다.</li>
</ul>
</li>
<li><code>queryFn</code><ul>
<li>데이터를 가져오는 비동기 함수이다.</li>
<li>꼭 데이터를 반환하거나 오류를 던져야한다.</li>
</ul>
</li>
</ul>
<h5 id="returns">Returns</h5>
<ul>
<li><code>data</code>: 성공적으로 가져온 데이터</li>
<li><code>isLoading</code>: 데이터 가져오기가 진행 중인지 여부</li>
<li><code>isError</code>: 쿼리 함수에서의 오류 발생 여부</li>
</ul>
<hr>
<h4 id="usemutation"><code>useMutation</code></h4>
<h5 id="사용-방법-1">사용 방법</h5>
<pre><code class="language-ts">const { mutate } = useMutation({ mutationFn, onSuccess, onError })</code></pre>
<h5 id="parameter-options-1"><code>Parameter (Options)</code></h5>
<ul>
<li><code>mutationFn</code><ul>
<li><code>queryFn</code>와 같은 비동기 함수로, 실행할 비동기 변이 함수이다.</li>
</ul>
</li>
<li><code>onSuccess</code><ul>
<li>변이가 성공할 때 호출되는 함수이다.</li>
</ul>
</li>
<li><code>onError</code><ul>
<li>변이 중 오류가 발생할 때 호출되는 함수이다.</li>
</ul>
</li>
</ul>
<h5 id="returns-1"><code>Returns</code></h5>
<ul>
<li><code>mutate</code>: 변이 실행 함수</li>
</ul>
<hr>
<h4 id="usesyncexternalstore"><code>useSyncExternalStore</code></h4>
<h5 id="사용-방법-2">사용 방법</h5>
<pre><code class="language-ts">const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)</code></pre>
<h5 id="parameter">Parameter</h5>
<ul>
<li><code>subscribe</code><ul>
<li>하나의 callback 인자 함수를 받아 외부 상태 저장소(store)에 구독하는 함수이다.</li>
<li>store가 변경될 때, 제공된 callback이 호출되어 React는 <code>getSnapshot</code>을 다시 실행해 컴포넌트를 리렌더링한다.</li>
<li>반드시 구독 해제 함수를 반환해야 한다. (<code>useEffect</code>의 clean-up 함수처럼 동작함)</li>
</ul>
</li>
<li>getSnapshot<ul>
<li>컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수이다.</li>
<li>저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링한다.</li>
</ul>
</li>
<li>getServerSnapshot(optional)<ul>
<li>store에 있는 데이터의 초기 스냅샷을 반환하는 함수이다.</li>
<li>서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용된다.</li>
</ul>
</li>
</ul>
<h5 id="return">Return</h5>
<ul>
<li>렌더링 로직에 사용할 수 있는 store의 현재 스냅샷.</li>
</ul>
<blockquote>
<p>💡 <strong>useSyncExternalStore가 왜 필요할까?</strong></p>
<p><code>useSyncExternalStore</code>는 React 18에서 도입된 훅으로, <strong>외부 상태 저장소(store)</strong>를 리액트 컴포넌트와 동기화하는 데 사용된다.</p>
<pre><code class="language-ts">export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  const update = useCallback(() =&gt; {
    setIsOnline(navigator.onLine);
  }, [])

  useEffect(() =&gt; {
    window.addEventListener(&#39;online&#39;, update);
    window.addEventListener(&#39;offline&#39;, update);

    return () =&gt; {
      window.removeEventListener(&#39;online&#39;, update);
      window.removeEventListener(&#39;offline&#39;, update);
    }
  }, [update])

  return isOnline;
}</code></pre>
<p><a href="https://ko.react.dev/reference/react/useSyncExternalStore#subscribing-to-a-browser-api">공식 홈페이지 예시 코드</a>를 <code>useSyncExternalStore</code> 없이 구현한 코드이다.
위 코드를 보면 React 외부 상태 저장소를 <code>useState</code>, <code>useEffect</code>를 활용하여 충분히 동기화하고 있는 것 같아 보인다.</p>
<p>하지만 이 접근 방식에는 중요한 문제점이 있다.
<code>useState</code>와 <code>useEffect</code> 사이의 타이밍 차이 때문에, 초기 렌더링 직전 또는 직후 외부 상태가 변경되면 해당 변경을 놓칠 수 있다.
이처럼 React의 <strong>렌더링 상태와 외부 상태 간에 불일치</strong>가 발생하는 현상을 Tearing이라고 한다.</p>
<p><code>useSyncExternalStore</code>는 이러한 문제를 해결하며, 외부 저장소와의 동기화를 정확하고 예측 가능하게 만들어준다.</p>
</blockquote>
<hr>
<h2 id="외부-저장소querystore-구현">외부 저장소(QueryStore) 구현</h2>
<h3 id="데이터query-및-저장소-구조">데이터(Query) 및 저장소 구조</h3>
<ul>
<li><code>Query</code>: useQuery에서 반환되는 <code>data</code>, <code>isLoading</code>, <code>isError</code> 값을 하나의 객체로 묶은 타입이다.</li>
<li><code>store</code>: useQuery에 인자로 넣어주는 <code>queryKey</code>를 기준으로 데이터를 저장, 수정, 삭제하는 저장소이다.<ul>
<li>이러한 작업을 효율적으로 처리하기 위해, 내부적으로 <code>Map</code> 자료구조를 사용했다.</li>
</ul>
</li>
</ul>
<pre><code class="language-ts">type Query&lt;TData&gt; = { data: TData; isLoading: boolean; isError: boolean };

const store = new Map&lt;string, Query&lt;unknown&gt;&gt;();</code></pre>
<p><strong>내부적으로는 단순한 Map 객체 하나로 전체 캐시 저장소가 구성된다. 처음 봤을 때는 이 구조가 의외로 단순해서 놀라웠다.</strong></p>
<h3 id="subscribe-함수">subscribe 함수</h3>
<ul>
<li><code>subscribe</code>는 <code>useSyncExternalStore</code>의 첫 번째 인자로 전달되는 함수로, 외부 저장소에 특정 <code>queryKey</code>를 기준으로 컴포넌트를 구독하게 해준다.</li>
<li>내부적으로는 <code>Record&lt;string, Set&lt;Listener&gt;&gt;</code> 구조를 사용하여, <code>queryKey</code>마다 여러 컴포넌트가 구독될 수 있도록 한다.</li>
<li>콜백 등록 시 중복 방지를 위해 Set을 활용하였다.</li>
<li>구독 해제하는 clean-up 함수를 반환한다.</li>
</ul>
<pre><code class="language-ts">type Listener = () =&gt; void;

const listeners: Record&lt;string, Set&lt;Listener&gt;&gt; = {};

export function subscribe(key: string, callback: Listener) {
  if (!listeners[key]) {
    listeners[key] = new Set();
  }

  listeners[key].add(callback);

  // cleanup function 반환
  return () =&gt; {
    listeners[key]?.delete(callback);
  };
}</code></pre>
<h3 id="getsnapshot-함수">getSnapshot 함수</h3>
<ul>
<li><code>getSnapshot</code>은 현재 <code>queryKey</code>에 해당하는 데이터를 저장소에서 가져오는 함수다.</li>
<li>이 값은 <code>useSyncExternalStore</code>가 컴포넌트를 리렌더링할지 결정하는 기준이 된다.</li>
<li>값이 존재하지 않으면, 초기값(isLoading: true)을 직접 삽입한 후 반환한다.</li>
</ul>
<pre><code class="language-ts">export function getSnapshot&lt;TData&gt;(key: string) {
  if (!store.get(key))
    store.set(key, { data: null, isLoading: true, isError: false });

  return store.get(key) as Query&lt;TData&gt;;
}</code></pre>
<h3 id="외부-저장소에-저장된-데이터-update-함수">외부 저장소에 저장된 데이터 update 함수</h3>
<ul>
<li>데이터를 패칭하는 과정에서 변경되는 <code>data</code>, <code>isLoading</code>, <code>isError</code> 값을 갱신해주는 함수이다.</li>
<li>store에 새로운 값을 저장하고, 구독된 콜백 함수를 모두 실행한다.</li>
</ul>
<pre><code class="language-ts">export function updateQuery&lt;TData&gt;(key: string, newValue: Query&lt;TData&gt;) {
  store.set(key, newValue);

  // 해당 key에 연결된 listener만 실행
  listeners[key]?.forEach((callback) =&gt; callback());
}</code></pre>
<blockquote>
<p>💡 <strong>listeners에 등록된 callback은 무슨 함수일까?</strong></p>
<pre><code class="language-ts">function subscribeToStore(fiber, inst, subscribe) {
  return subscribe(function () {
    checkIfSnapshotChanged(inst) &amp;&amp; forceStoreRerender(fiber);
  });
}</code></pre>
<p>위 코드는 <code>React</code> 내부 코드에서 <code>subscribe</code> 함수를 반환하는 코드를 가져온 것이다.</p>
<p>간단히 함수 이름만 봤을 때, <code>callback</code> 함수의 역할은 다음과 같다.</p>
<ol>
<li><code>snapshot</code>이 변경되었는지 확인</li>
<li>변경이 감지되면 해당 컴포넌트(fiber)를 리렌더링</li>
</ol>
<p>즉, <code>subscribe</code> 함수에서 인자로 넣어주는 <code>callback</code> 함수는 <strong>store의 변경을 감지해 리렌더링을 일으키는 함수</strong>이다.</p>
</blockquote>
<p><strong>결국 updateQuery의 내부 로직은 store의 데이터를 바꾸는 순간,
→ subscribe에서 등록된 콜백 실행
→ snapshot이 변경됐는지 확인 후, 필요한 컴포넌트만 리렌더링하는 것이다.</strong></p>
<hr>
<h3 id="querystore-전체-코드"><code>QueryStore</code> 전체 코드</h3>
<pre><code class="language-ts">// QueryStore.ts

type Query&lt;TData&gt; = { data: TData; isLoading: boolean; isError: boolean };
type Listener = () =&gt; void;

const store = new Map&lt;string, Query&lt;unknown&gt;&gt;();
const listeners: Record&lt;string, Set&lt;Listener&gt;&gt; = {};

export function subscribe(key: string, callback: Listener) {
  if (!listeners[key]) {
    listeners[key] = new Set();
  }

  listeners[key].add(callback);

  return () =&gt; {
    listeners[key]?.delete(callback);
  };
}

export function getSnapshot&lt;TData&gt;(key: string) {
  if (!store.get(key))
    store.set(key, { data: null, isLoading: true, isError: false });

  return store.get(key) as Query&lt;TData&gt;;
}

export function updateQuery&lt;TData&gt;(key: string, newValue: Query&lt;TData&gt;) {
  store.set(key, newValue);

  listeners[key]?.forEach((callback) =&gt; callback());
}</code></pre>
<blockquote>
<p>👉 <strong>여기까지 구현한 <code>subscribe</code>, <code>getSnapshot</code>, <code>updateQuery</code>는 모두 <code>useSyncExternalStore</code>에서 동기화를 위한 핵심 도구이다.</strong></p>
</blockquote>
<hr>
<h2 id="usequery-훅-구현"><code>useQuery</code> 훅 구현</h2>
<h3 id="usequery-parameters"><code>useQuery</code> Parameters</h3>
<ul>
<li>실제 <code>TanStack Query</code>에서는 <code>queryKey</code>를 배열로 관리한다.</li>
<li>이는 동일한 쿼리라도 다양한 파라미터 조합을 다룰 수 있도록 하기 위함이다.</li>
<li>하지만 이 글에서는 기초적인 구조를 먼저 구현하는 데 집중하기 위해 문자열(string) 형태로 단순화했다.</li>
</ul>
<pre><code class="language-ts">interface UseQueryOptions&lt;TData&gt; {
  queryKey: string;
  queryFn: () =&gt; Promise&lt;TData&gt;;
}</code></pre>
<h3 id="usesyncexternalstore로-외부-저장소-동기화"><code>useSyncExternalStore</code>로 외부 저장소 동기화</h3>
<pre><code class="language-ts">const snapshot = useSyncExternalStore(
  (onStoreChange) =&gt; subscribe(queryKey, onStoreChange),
  () =&gt; getSnapshot&lt;TData&gt;(queryKey)
);</code></pre>
<h3 id="snapshot이-없을-때-queryfn-실행">snapshot이 없을 때 queryFn 실행</h3>
<pre><code class="language-ts">useEffect(() =&gt; {
  const fetchQueryData = async () =&gt; {
    try {
      const result = await queryFn();
      updateQuery(queryKey, {
        data: result,
        isLoading: false,
        isError: false,
      });
    } catch {
      updateQuery(queryKey, {
        data: null,
        isLoading: false,
        isError: true,
      });
    }
  };

  if (!snapshot?.data) {
    fetchQueryData();
  }
}, [queryKey, snapshot, queryFn]);</code></pre>
<hr>
<h3 id="usequery-전체-코드"><code>useQuery</code> 전체 코드</h3>
<pre><code class="language-ts">// useQuery.ts

interface UseQueryOptions&lt;TData&gt; {
  queryKey: string;
  queryFn: () =&gt; Promise&lt;TData&gt;;
}

export const useQuery = &lt;TData&gt;({
  queryKey,
  queryFn,
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  const snapshot = useSyncExternalStore(
    (onStoreChange) =&gt; subscribe(queryKey, onStoreChange),
    () =&gt; getSnapshot&lt;TData&gt;(queryKey)
  );

  useEffect(() =&gt; {
    const fetchQueryData = async () =&gt; {
      try {
        const result = await queryFn();
        updateQuery(queryKey, {
          data: result,
          isLoading: false,
          isError: false,
        });
      } catch {
        updateQuery(queryKey, {
          data: null,
          isLoading: false,
          isError: true,
        });
      }
    };

    if (!snapshot?.data) {
      fetchQueryData();
    }
  }, [queryKey, snapshot, queryFn]);

  return snapshot;
};</code></pre>
<h3 id="usequery-흐름도">useQuery 흐름도</h3>
<ol>
<li><code>useSyncExternalStore</code>로 구독을 시작한다.</li>
<li><code>getSnapshot</code>을 통해 외부 저장소에서 <code>queryKey</code>에 해당하는 데이터를 가져온다.</li>
<li>가져온 <code>snapshot</code>을 반환한다. → 데이터가 있을 수도, 없을 수도 있다.</li>
<li><code>snapshot?.data</code>가 없을 경우, <code>useEffect</code>에서 <code>fetchQueryData</code> 함수를 실행한다.</li>
<li><code>queryFn</code> 실행 결과에 따라 <code>updateQuery</code>로 상태를 저장하고,</li>
<li>해당 <code>queryKey</code>를 구독 중인 컴포넌트들이 리렌더링된다.</li>
</ol>
<hr>
<h2 id="usemutation-훅-구현">useMutation 훅 구현</h2>
<h3 id="usemutation-parameters">useMutation Parameters</h3>
<ul>
<li><code>TVariable</code>: mutation 함수에 전달할 인자의 타입</li>
<li><code>TData</code>: mutation 결과로 반환될 데이터의 타입</li>
</ul>
<pre><code class="language-ts">interface UseMutationOptions&lt;TVariable, TData&gt; {
  mutationFn: (variables: TVariable) =&gt; Promise&lt;TData&gt;;
  onSuccess?: (result: TData) =&gt; void;
  onError?: () =&gt; void;
}</code></pre>
<h3 id="mutate-함수"><code>mutate</code> 함수</h3>
<pre><code class="language-ts">const mutate = useCallback(
  async (variables: TVariable) =&gt; {
    try {
      const result = await mutationFn(variables);
      // 요청 성공 시 onSuccess 콜백 실행 (있을 경우)
      onSuccess?.(result);

      return result;
    } catch (error) {
      // 요청 실패 시 onError 콜백 실행 (있을 경우)
      onError?.();

      throw error;
    }
  },
  [mutationFn, onSuccess, onError]
);</code></pre>
<hr>
<h3 id="usemutation-전체-코드">useMutation 전체 코드</h3>
<pre><code class="language-ts">// useMutation.ts

interface UseMutationOptions&lt;TVariable, TData&gt; {
  mutationFn: (variables: TVariable) =&gt; Promise&lt;TData&gt;;
  onSuccess?: (result: TData) =&gt; void;
  onError?: () =&gt; void;
}

export function useMutation&lt;TVariable, TData&gt;({
  mutationFn,
  onSuccess,
  onError,
}: UseMutationOptions&lt;TVariable, TData&gt;) {
  const mutate = useCallback(
    async (variables: TVariable) =&gt; {
      try {
        const result = await mutationFn(variables);
        onSuccess?.(result);

        return result;
      } catch (error) {
        onError?.();

        throw error;
      }
    },
    [mutationFn, onSuccess, onError]
  );

  return { mutate };
}</code></pre>
<hr>
<h2 id="-리팩토링">+ 리팩토링</h2>
<h3 id="usequery에-있는-useeffect를-없애기"><code>useQuery</code>에 있는 <code>useEffect</code>를 없애기</h3>
<ul>
<li>기존 코드의 <code>useEffect</code>는 의존성 배열에 있는 <code>queryKey</code>, <code>snapshot</code>, <code>queryFn</code> 값들이 변경되면 실행된다.</li>
<li><code>useSyncExternalStore</code>의 첫 번째 인자로 넣은 <a href="https://ko.react.dev/reference/react/useSyncExternalStore#my-subscribe-function-gets-called-after-every-re-render"><code>subscribe</code> 함수는 리렌더링이 될 때마다 호출된다.</a></li>
<li><code>subscribe</code>함수에 <code>useCallback</code>을 감싸면 특정 값이 변경될 때만 다시 구독하도록 가능하다.</li>
</ul>
<pre><code class="language-ts">export const useQuery = &lt;TData&gt;({
  queryKey,
  queryFn,
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  const snapshot = useSyncExternalStore(
    useCallback(
      (onStoreChange) =&gt; {
        const unsubscribe = subscribe(queryKey, onStoreChange);

        const fetchQueryData = async () =&gt; {
          try {
            const result = await queryFn();
            updateQuery(queryKey, {
              data: result,
              isLoading: false,
              isError: false,
            });
          } catch {
            updateQuery(queryKey, {
              data: null,
              isLoading: false,
              isError: true,
            });
          }
        };

        const snapshot = getSnapshot&lt;TData&gt;(queryKey);

        if (!snapshot?.data) {
          fetchQueryData();
        }

        return unsubscribe;
      },
      [queryKey, queryFn]
    ),
    () =&gt; getSnapshot&lt;TData&gt;(queryKey)
  );

  return snapshot;
};</code></pre>
<h3 id="fetchquerydata-함수-분리"><code>fetchQueryData</code> 함수 분리</h3>
<ul>
<li><code>fetchQueryData</code> 함수가 구독할 때마다 새로 생성되는 것을 방지하기 위해 외부로 분리한다.</li>
<li>데이터 패칭과 상태 구독의 책임을 분리하여 <code>useQuery</code> 내부의 가독성을 높인다.</li>
<li>데이터 요청 로직을 외부 함수로 분리하여 테스트와 재사용이 용이해진다.</li>
</ul>
<pre><code class="language-ts">export const useQuery = &lt;TData&gt;({
  queryKey,
  queryFn,
}: UseQueryOptions&lt;TData&gt;) =&gt; {
  const snapshot = useSyncExternalStore(
    useCallback(
      (onStoreChange) =&gt; {
        const unsubscribe = subscribe(queryKey, onStoreChange);
        loadQueryData(queryKey, queryFn);
        return unsubscribe;
      },
      [queryKey, queryFn]
    ),
    () =&gt; getSnapshot&lt;TData&gt;(queryKey)
  );

  return snapshot;
};</code></pre>
<pre><code class="language-ts">export async function loadQueryData&lt;TData&gt;(
  key: string,
  queryFn: () =&gt; Promise&lt;TData&gt;
) {
  const snapshot = getSnapshot(key);
  if (snapshot.data) return;

  await fetchAndUpdateQueryData(key, queryFn);
}

async function fetchAndUpdateQueryData&lt;TData&gt;(
  key: string,
  queryFn: () =&gt; Promise&lt;TData&gt;
) {
  try {
    const result = await queryFn();
    updateQuery(key, {
      data: result,
      isLoading: false,
      isError: false,
    });
  } catch {
    updateQuery(key, {
      data: null,
      isLoading: false,
      isError: true,
    });
  }
}</code></pre>
<blockquote>
<p>💡 useEffect 안에 있던 로직을 subscribe의 인자로 전달되는 함수 내부로 옮겨도 괜찮을까?</p>
<pre><code class="language-ts">// react/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js

useEffect(() =&gt; {
  // Check for changes right before subscribing. Subsequent changes will &gt; be
  // detected in the subscription handler.
  if (checkIfSnapshotChanged(inst)) {
    // Force a re-render.
    forceUpdate({inst});
     }
  const handleStoreChange = () =&gt; {
    // TODO: Because there is no cross-renderer API for batching updates, it&#39;s
    // up to the consumer of this library to wrap their subscription event
    // with unstable_batchedUpdates. Should we try to detect when this isn&#39;t
    // the case and print a warning in development?
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
  };
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}, [subscribe]);</code></pre>
<ul>
<li>useSyncExternalStore 내부 구현 코드를 보면, 실제로 subscribe 함수는 useEffect 안에서 실행된다.</li>
<li>즉, subscribe 함수 내부에서 side effect를 수행하는 것은 React의 사용 규칙에 어긋나지 않는다.</li>
<li>useEffect와 동일한 타이밍에 실행되므로, 데이터를 요청하는 로직을 subscribe 내부에 넣어도 부작용 없이 동작한다.</li>
</ul>
</blockquote>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 <code>TanStack Query</code>의 핵심 기능인 <code>useQuery</code>와 <code>useMutation</code>을 직접 구현해보며,
React의 외부 상태와 동기화하는 방식(<code>useSyncExternalStore</code>)까지 함께 살펴보았다.</p>
<p>다음 글에서는 <code>staleTime</code>, <code>gcTime</code>, <code>refetch</code> 등의 고급 캐싱 전략과 <code>Request Coalescing</code> 등 실제 라이브러리 수준에서 필요한 기능들을 구현해볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[TanStack Query, 직접 구현해보기(1) - 왜 직접 구현해보게 되었나?]]></title>
            <link>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B01-%EC%99%9C-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%82%98</link>
            <guid>https://velog.io/@jae_o/TanStack-Query-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B01-%EC%99%9C-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B2%8C-%EB%90%98%EC%97%88%EB%82%98</guid>
            <pubDate>Sat, 28 Jun 2025 12:12:59 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이 글에서는 TanStack Query를 직접 구현하게 된 배경과 그 과정에서 얻게 된 인사이트를 공유한다.  </p>
<p>우아한테크코스에서 라이브러리 없이 미션을 수행하면서, 라이브러리를 사용하지 않았을 때의 불편함을 절실히 느꼈다. 이 경험을 통해, 라이브러리는 이러한 불편함을 어떻게 해결해주는지에 관심이 생겼고, 라이브러리를 하나씩 뜯어보는 과정에서 흥미를 느끼기 시작했다.</p>
<p>데이터 패칭 훅을 만들어보면서 이것을 전역적으로 관리하고 싶다는 생각이 들었고, TanStack Query를 직접 구현해봐야겠다는 생각이 들었다. 처음부터 쉽지 않은 라이브러리를 선택한 건 아닌가 하는 생각도 들었지만, 구현을 하면서 새롭게 알게 된 점들이 많았고, 이러한 지식을 정리하고 공유해보고자 한다.</p>
<h2 id="데이터-캐싱에-대한-호기심">데이터 캐싱에 대한 호기심</h2>
<blockquote>
<p>TanStack Query (formerly known as React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, <strong>caching</strong>, synchronizing and updating server state in your web applications a breeze.</p>
</blockquote>
<p>TanStack Query의 공식 홈페이지 소개 글에서 <code>caching</code>이란 단어가 눈에 들어왔다.</p>
<p>&quot;캐싱은 어떻게 구현하는 걸까?&quot;라는 호기심이 생겼다. 캐싱이란 &#39;데이터를 어딘가에 저장해 두었다가 필요할 때 다시 사용하는 것&#39;이라고 알고 있었지만, 구체적으로 어떤 방식으로, 어디에 저장하는지가 궁금해졌다. 이를 계기로 TanStack Query의 내부 코드를 들여다보기 시작했다.</p>
<p>그 결과, 캐싱이 자바스크립트 객체에 데이터를 저장하는 단순한 방식이라는 사실을 알게 되었고, 그 단순함에 놀랐다. 하지만 단순 저장만으로 끝나는 것이 아니라, staleTime, gcTime 등의 개념을 통해 캐시를 어떻게 효율적으로 관리할 것인가가 TanStack Query의 핵심이라는 것을 깨달았다.</p>
<h2 id="전역-데이터-저장소의-필요성">전역 데이터 저장소의 필요성</h2>
<p>서버 데이터를 여러 컴포넌트에서 사용할 경우, 상태를 끌어올리는 일이 반복되었다. 이로 인해 props drilling이 발생하고, 이 현상이 심화되면 Context API를 사용할 수밖에 없었다. 서버에서 가져오는 데이터가 많아질 경우 Provider는 늘어나게 되고, 결국 Provider Hell이 발생할 가능성이 높아보였다.
그래서 Provider 없이도 원하는 위치에서 데이터를 사용할 수 있는 <strong>전역 데이터 저장소 구조</strong>를 설계하고자 했다.</p>
<p>또한, 서버의 데이터를 mutation(수정/추가/삭제)할 때 변경된 데이터를 다시 불러와야 했다. 하지만 이를 위해 refetch 함수를 최상단 컴포넌트에서 props나 Context를 통해 전달해야 했고, 이 과정이 번거롭고 비효율적이라고 느꼈다. 이러한 불편함을 해소하기 위해, 전역 데이터 저장소 구조를 활용하여 <strong>mutation 시 자동으로 refetch 될 대상을 명시하는 방식</strong>을 구현하고자 했다.</p>
<h2 id="usesyncexternalstore">useSyncExternalStore</h2>
<p>전역 상태 관리 라이브러리들은 대부분 <code>useSyncExternalStore</code>를 사용하고 있다.</p>
<p>하지만 처음 <code>useSyncExternalStore</code>를 접했을 때는 그 필요성과 내부 동작 방식이 쉽게 와닿지 않았다.  그래서 이 훅을 직접 사용하여 <strong>React 외부 상태와의 안정적인 동기화 방식</strong>을 직접 구현해보면서, 그 필요성과 작동 원리를 몸으로 이해하고자 했다.</p>
<h2 id="내가-구현을-결심한-이유">내가 구현을 결심한 이유</h2>
<p>당연한 이야기지만, TanStack Query 내부 코드를 보면서 처음에는 코드 대부분이 낯설게 느껴졌다. 대략적인 동작 원리를 파악하는 데에도 상당한 시간이 걸렸다.</p>
<p>코드를 분석하면서 느낀 점은, 제공하는 기능이 많고 강력하긴 하지만 구현 방식이 복잡하게 느껴졌다는 것이다. 그래서 직접 구현해보면서, TanStack Query가 어떤 의도로 이러한 구조를 선택했는지 그 배경을 이해하고자 했다.</p>
<h2 id="구현-기능-목록">구현 기능 목록</h2>
<h3 id="주요-기능">주요 기능</h3>
<ul>
<li><code>useQuery</code><ul>
<li>Data fetching: <code>isLoading</code>, <code>isError</code>, <code>data</code> 제공</li>
<li>Query Key 기반 데이터 캐싱</li>
<li><code>useSyncExternalStore</code>를 이용한 자동 리패칭</li>
</ul>
</li>
<li><code>useMutation</code>: 데이터를 생성, 수정, 삭제하는 등의 작업에 사용되는 훅<ul>
<li>리패칭 대상 지정 가능</li>
<li><code>onSuccess</code> / <code>onError</code> 콜백</li>
</ul>
</li>
</ul>
<h3 id="세부-기능">세부 기능</h3>
<ul>
<li><code>staleTime</code>: Stale-While-Revalidate (SWR) 전략 구현</li>
<li><code>gcTime</code>: Garbage Collection (GC) - 구독이 모두 해제되면 캐시 자동 삭제</li>
<li><code>Request Coalescing</code>: 동일한 데이터에 대한 중복 요청 방지</li>
<li><code>AbortController</code> 기반 요청 취소 (unmount 시 자동 abort)</li>
<li>자동 백그라운드 fetch: 마운트 시 stale 판단 후 자동 fetch</li>
<li>리패칭 조건 트리거<ul>
<li><code>refetchOnWindowFocus</code>: 브라우저 포커스 시 자동 fetch</li>
<li><code>refetchOnReconnect</code>: 네트워크 재연결 시 자동 fetch</li>
<li><code>refetchInterval</code>: polling 방식 주기적 refetch</li>
</ul>
</li>
<li><code>refetch()</code>: 수동 리패칭 메서드 제공</li>
<li><code>retry</code> 및 <code>retryDelay</code>: fetch 실패 시 자동 재시도</li>
<li><code>suspense</code></li>
<li><code>removeQueries</code>, <code>resetQueries</code>로 전체 초기화</li>
<li>fetch 중단을 위한 수동 <code>cancel</code> API</li>
<li><code>useInfiniteQuery</code> (무한 스크롤 / 페이지네이션 지원)</li>
</ul>
<h3 id="추가로-구현할-기능">추가로 구현할 기능</h3>
<ul>
<li><code>convertFn</code>을 통한 데이터 후처리</li>
<li>queryKey 관리하기 쉽게 하는 유틸함수</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>