<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>minseung-gang.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 12 Aug 2025 15:14:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>minseung-gang.log</title>
            <url>https://velog.velcdn.com/images/minseung-gang/profile/9059bf00-2436-4559-9028-67ac63380d62/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. minseung-gang.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/minseung-gang" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[트러블슈팅] 모달과 배경 영역 스크롤 충돌 문제 해결: 이벤트 버블링]]></title>
            <link>https://velog.io/@minseung-gang/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%AA%A8%EB%8B%AC%EA%B3%BC-%EB%B0%B0%EA%B2%BD-%EC%98%81%EC%97%AD-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B2%84%EB%B8%94%EB%A7%81</link>
            <guid>https://velog.io/@minseung-gang/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EB%AA%A8%EB%8B%AC%EA%B3%BC-%EB%B0%B0%EA%B2%BD-%EC%98%81%EC%97%AD-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%B6%A9%EB%8F%8C-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B2%84%EB%B8%94%EB%A7%81</guid>
            <pubDate>Tue, 12 Aug 2025 15:14:26 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>오늘은 포트폴리오 사이트를 개발하는 과정에서 겪은 트러블슈팅 경험을 공유하려 한다</p>
<p>프로젝트에서는 Lenis 라이브러리를 활용해 부드러운 스크롤 애니메이션을 구현했고, React Portal을 이용해 모달 컴포넌트를 만들었다</p>
<p>모달이 열리면 배경 스크롤이 멈추도록 설정했지만, 예상과 달리 모달 내부에서는 마우스 휠 스크롤이 먹히지 않고, 오히려 모달 뒤 배경에서만 스크롤이 작동하는 이상한 현상이 발생했다.</p>
<p>이 문제는 알고 보면 단순하지만, 처음 접하면 혼란스러울 수 있어 문제 상황과 해결 과정을 솔직하게 기록해본다.</p>
<hr>
<h2 id="문제-발견">문제 발견</h2>
<p>문제 상황을 간단하게 설명하자면, 프로젝트 내부 컴포넌트에서 각 카드 컴포넌트를 클릭하면 모달이 팝업되도록 구현했다.</p>
<p>그런데 가벼운 마음으로 테스트를 하는 과정에서, 모달이 열렸음에도 뒤에 배경에서 Framer Motion의 useScroll 애니메이션이 여전히 작동하고 있다는 사실을 발견했다.</p>
<p>심지어 모달 내부에는 스크롤바가 보이지만 실제로는 스크롤이 되지 않는 상태였다.</p>
<p><img src="https://velog.velcdn.com/images/minseung-gang/post/a6e7c5db-fbd3-4d15-85e8-74d0fb5bf8ed/image.gif" alt=""></p>
<p>처음에는 smooth scroll를 위해 Lenis를 사용 중이었기 때문에, 문제가 Lenis의 스크롤 애니메이션의 이벤트 제어에 있다고 생각했다.</p>
<p>그래서 처음에는 SmoothProvider컴포넌트 Lenis 인스턴스 상태를 ref로 담아 복잡한 로직으로 start/stop을 제어하는 코드를 작성했지만, 복잡성만 높아지고 근본적인 해결방법은 아니라고 생각이 들었다.</p>
<p>그리하여 기존 코드를 초기화한 뒤 다시 기본부터 차근차근 고민을 해보았다.</p>
<hr>
<h2 id="해결">해결</h2>
<p>우선 모달 내부 스크롤이 안되는 거니 휠 이벤트와 관련된 문제이지 않을까라는 생각부터 시작해보았다.</p>
<p>왜 모달내부에서는 휠 이벤트가 처리되지 않고 뒷 배경에서 처리되고 있는 걸까..<br>문뜩 이 문제가 내부에서 발생한 스크롤 이벤트가 버블링 되고 있는게 아닐까라는 생각을 하게 되었다.</p>
<p>그래서 모달 컴포넌트에 e.stopPropagation()을 호출해보았다.</p>
<pre><code class="language-js"> &lt;ModalContext.Provider value={contextValue}&gt;
      &lt;div
        onWheel={(e) =&gt; {
          e.stopPropagation();
        }}
        onTouchMove={(e) =&gt; {
          e.stopPropagation();
        }}
        className={cn(modalBaseCls, className)}
        role=&#39;dialog&#39;
        aria-modal=&#39;true&#39;
      &gt;
        {children}
      &lt;/div&gt;
 &lt;/ModalContext.Provider&gt;
</code></pre>
<p>모달 내부 스크롤이 안 되는 원인은 휠 이벤트가 모달 내부가 아닌 뒷 배경에서 처리되고 있었기 때문이었다.</p>
<p>즉, 모달 내부에서 발생한 스크롤 이벤트가 상위 DOM으로 버블링되어, 의도치 않게 배경 스크롤을 작동시키고 있었던 것이다.</p>
<p>이 간단한 처리가 모달 내부 스크롤 이벤트가 상위 요소로 전달되는 것을 막아주었고,  모달 내부 스크롤을 정상적으로 작동하게 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/minseung-gang/post/c6127946-64f4-45f6-a0e6-138c7ccf3b79/image.gif" alt=""></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음에는 React Portal 구조에서 모달과 배경이 별도의 DOM 트리에 존재하는 점 때문에 스크롤 이벤트 흐름을 이해하기 어려웠다.</p>
<p>하지만 문제의 본질은 복잡한 라이브러리 제어나 상태 관리가 아닌, 이벤트 버블링 제어에 있었음을 알게 되었다.</p>
<p>때로는 복잡한 해결책보다 기본적인 DOM 이벤트 원리를 다시 점검하는 것이 문제 해결에 큰 도움이 된다고 생각이 들었다.</p>
<p>이번 경험을 통해 스크롤, 포인터, 터치 같은 실시간 이벤트 처리에서는 이벤트 전파 흐름을 명확히 이해하는 것이 얼마나 중요한지 다시 한번 깨닫게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발 트러블슈팅] React Carousel 드래그 후 클릭 이벤트 문제 해결기]]></title>
            <link>https://velog.io/@minseung-gang/%EA%B0%9C%EB%B0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-React-Carousel-%EB%93%9C%EB%9E%98%EA%B7%B8-%ED%9B%84-%ED%81%B4%EB%A6%AD-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@minseung-gang/%EA%B0%9C%EB%B0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-React-Carousel-%EB%93%9C%EB%9E%98%EA%B7%B8-%ED%9B%84-%ED%81%B4%EB%A6%AD-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Thu, 24 Jul 2025 11:24:35 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minseung-gang/post/33fbdc0a-61be-415d-9d87-0091c540dbf1/image.png" alt=""></p>
<br/>

<h1 id="개요">개요</h1>
<p>오늘은 사이드프로젝트를 개발하는 과정에서 겪은 트러블슈팅을 정리하려고 한다. </p>
<p>최근 캐러셀 컴포넌트를 구현하면서 마우스 드래그로 슬라이드를 넘긴 후,<br>의도치 않게 <code>onClick</code> 이벤트가 실행되는 문제를 겪었다.</p>
<p>예를 들어, 드래그해서 슬라이드를 넘겼을 뿐인데, 내부에 있던 카드의 클릭 이벤트가 실행되어 상세 페이지로 이동해버리는 식이다. </p>
<p>이번 글에서는 글쓴이가 경험했던 이 문제의 원인과 해결 방법을 공유하려고 한다.</p>
<br/>

<hr>
<br/>

<h1 id="문제발견">문제발견</h1>
<p>처음 설계는 이렇게 되어 있었다</p>
<ul>
<li>mousedown 시: 드래그 시작</li>
<li>mousemove 시: 슬라이드 이동</li>
<li>mouseup 시: 슬라이드 이동 거리 기반으로 인덱스를 변경</li>
</ul>
<p>기본적인 드래그 인터랙션 구조였고, 따로 특별한 처리는 하지 않았다.
하지만… 이상하게도 Carousel 슬라이드를 마우스로 클릭하고 드래그한 뒤 클릭한 elment 위에서 마우스 버튼을 떼면 클릭 이벤트가 함께 발생하는 문제가 생기는 것 같았다.</p>
<p><img src="https://velog.velcdn.com/images/minseung-gang/post/f55ea1ac-966c-4485-9ca3-af0f2aa1cfd8/image.gif" alt="">
<br/></p>
<hr>
<br/>

<h1 id="원인">원인</h1>
<p>처음엔 드래그 상태를 추적하는 변수를 isDragging state로 관리하려고 했었다.
isDragging은 드래그를 할 때 transform css와 관련하여 적용하고 있었기에 같이 활용하면 될 것이라고 생각했다.</p>
<pre><code>
const ItemList: React.FC&lt;ItemListProps&gt; = ({ children, className }) =&gt; {

  ...

  const listStyle: React.CSSProperties = {
    display: &#39;flex&#39;,
    transform: `translateX(-${scrollX}px)`,
    transition:
      dragging || !isTransitioning ? &#39;none&#39; : &#39;transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)&#39;,
    gap,
  };

  return (
    &lt;div
      ref={listRef}
      className={className}
      style={listStyle}
      onTouchStart={onDragStart}
      onMouseDown={onDragStart}
    &gt;
      {slides}
    &lt;/div&gt;
  );
};

const Item: React.FC&lt;ItemProps&gt; = ({ children, className, onClick }) =&gt; {
  const { dragging, isDraggingRef, slideWidth } = useCarouselContext();

  const handleClick = (e: React.MouseEvent) =&gt; {
    if (dragging) {
      e.preventDefault();
      e.stopPropagation();
      return;
    }

    onClick?.();
  };

  return (
    &lt;div
      className=&#39;flex-shrink-0 select-none&#39;
      style={{
        pointerEvents: dragging ? &#39;none&#39; : &#39;auto&#39;,
        width: slideWidth &gt; 0 ? `${slideWidth}px` : &#39;auto&#39;,
      }}
    &gt;
      &lt;div className={cn(&#39;pointer-events-auto h-full w-full&#39;, className)} onClick={handleClick}&gt;
        {children}
      &lt;/div&gt;
    &lt;/div&gt;
  );
};</code></pre><br/>

<p>드래그를 하고 마우스를 놓았는데 왜 클릭이벤트가 실행되는 건지 도대체 이해가 가지 않았다.
<br/></p>
<pre><code> const onDragStart = useCallback(
    (e: React.MouseEvent | React.TouchEvent) =&gt; {
      e.preventDefault();
      e.stopPropagation();

      setDragging(true);

      isDraggingRef.current = false;

      const pageX = &#39;touches&#39; in e ? e.touches[0].pageX : e.pageX;
      startXRef.current = pageX;
      startScrollXRef.current = scrollX;
    },
    [scrollX],
 );

const onDragEnd = useCallback(
    (e: MouseEvent | TouchEvent) =&gt; {
      setDragging(false);

       ...

    [slideWidth, gap, currentIndex, isInfinite, maxIndex, goTo],
  );</code></pre><p>문제는 바로 dragging을 state로 관리하고 있었기 때문이었다.</p>
<p>상태(state)의 업데이트 흐름을 보면:</p>
<ul>
<li><p>onMouseDown → setDragging(true)</p>
</li>
<li><p>onMouseMove → 사용자가 드래그 중</p>
</li>
<li><p>onMouseUp → setDragging(false)</p>
</li>
</ul>
<p>바로 직후 브라우저가 onClick 이벤트 발생</p>
<p>이때 dragging === false이므로 클릭이 허용됨 ❌</p>
<p>setState는 리액트 렌더링 사이클에 따라 비동기로 업데이트되기 때문에,
onClick 이벤트 시점에서는 이미 dragging === false가 되어버린 것이다.
<br/></p>
<hr>
<br/>

<h1 id="해결">해결</h1>
<p>이 문제를 해결하기 위해서 useRef를 사용한 즉시 업데이트 방식을 활용했다</p>
<pre><code>
 const onDragStart = useCallback(
    (e: React.MouseEvent | React.TouchEvent) =&gt; {
      e.preventDefault();
      e.stopPropagation();
      setDragging(true);

      isDraggingRef.current = false;

      const pageX = &#39;touches&#39; in e ? e.touches[0].pageX : e.pageX;
      startXRef.current = pageX;
      startScrollXRef.current = scrollX;
    },
    [scrollX],
  );

 const onDragMove = useCallback(
    (e: MouseEvent | TouchEvent) =&gt; {
      ...

      if (Math.abs(delta) &gt; 10) isDraggingRef.current = true;
    },

    [dragging, isInfinite, maxScrollX],
  );


const isDraggingRef = useRef(false);

const handleClick = (e: React.MouseEvent) =&gt; {
  if (isDraggingRef.current) {
    e.preventDefault();
    e.stopPropagation();
    return;
  }

  onClick?.();
};</code></pre><br/>

<hr>
<h1 id="마무리">마무리</h1>
<p>이번 이슈는 단순히 드래그 기능 하나였지만, 상태를 어떤 방식으로 추적하느냐가 얼마나 중요한지 다시금 깨닫는 계기가 되었다.</p>
<p>특히 드래그, 스크롤, 포인터 이벤트처럼 실시간으로 사용자 행동을 추적하는 UI에서는
React의 상태보다 ref를 통한 실시간 데이터 관리가 훨씬 신뢰할 수 있다는 점을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] Tailwind 4 + twMerge 충돌 이슈와 해결 방법]]></title>
            <link>https://velog.io/@minseung-gang/%ED%9A%8C%EA%B3%A0-Tailwind-4-twMerge-%EC%B6%A9%EB%8F%8C-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@minseung-gang/%ED%9A%8C%EA%B3%A0-Tailwind-4-twMerge-%EC%B6%A9%EB%8F%8C-%EC%9D%B4%EC%8A%88%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Mon, 14 Jul 2025 03:04:18 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>React 프로젝트에서 Card 컴포넌트 내부에 태그 버튼(Tag Button)을 만드는 과정에서,
Tailwind CSS로 커스텀 폰트 사이즈 클래스를 적용했지만 다른 텍스트 관련 클래스와 충돌하며
스타일이 제대로 적용되지 않는 문제가 발생했다.
특히 text-s-regular라는 커스텀 클래스가 tailwind-merge의 그룹핑 로직과 충돌해 제거되는 현상을 경험했고,
이를 해결하는 과정에서 tailwind-merge의 동작 방식과 한계점을 깊이 있게 이해할 수 있었다.</p>
<h2 id="원인">원인</h2>
<p>문제의 근본적인 원인은 tailwind-merge의 클래스 그룹핑 방식에 있었다. tailwind-merge는 성능상의 이유로 클래스 이름의 prefix를 기반으로 그룹을 판단하는 휴리스틱 방식을 사용한다.</p>
<pre><code class="language-javascript">
/** 
  * text-s-regular : css에서 선언한 custom className   / 폰트크기
  * text-white : tailwind에서 정해져 있는 유틸리티 클래스 / 폰트색상
**/

// BaseButton -&gt; 문제가 발생한 코드
&lt;Button
  className={cn(
    &#39;text-s-regular text-white&#39;,
    bgColor
  )}
&gt;
  {value}
&lt;/Button&gt;

// utils/classNames.ts
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

</code></pre>
<p>이 경우 text-s-regular(폰트 크기)와 text-white(폰트 색상)가 모두 text-* 패턴을 가지고 있어서 tailwind-merge가 이를 같은 그룹으로 분류했다.
그 결과 마지막에 선언된 text-white만 남고 text-s-regular가 제거되는 현상이 발생했다.
(문서를 저장할 때 eslint의 설정으로 className이 자동정렬되는 상황)</p>
<h2 id="문제-발견">문제 발견</h2>
<p>처음에는 단순히 text-s-regular 클래스가 적용되지 않는 것으로 보였다.
프로젝트에서는 CSS의 @theme으로 디자인 토큰을 정의하고, 이를 바탕으로 @apply를 사용해서 커스텀 클래스를 조합하는 방식으로 관리하고 있었다.</p>
<pre><code class="language-css">
//index.css

@theme {
  /* Font Sizes */
 --text-s: 14px;

  /* Line Heights*/
  --leading-relaxed: 140%;

  /* Font Weights */
  --font-weight-regular: 400;
}

/* CSS에서 정의한 커스텀 클래스 */
@layer utilities {
  .text-s-regular {
    @apply text-s leading-relaxed font-normal;
  }
}
</code></pre>
<p>오랜 디버깅 과정을 거치고 난 후에 알게 된 점은 text-white를 제거하면 text-s-regular가 정상적으로 적용되고, 함께 사용하면 적용되지 않는다는 것을 발견했다.
이를 통해 문제가 CSS 정의나 Tailwind 설정에 있는 것이 아니라 클래스 병합 과정에서 발생한다는 것을 파악할 수 있었다.</p>
<h2 id="해결">해결</h2>
<p>문제 해결을 위해 구글링을 해보니, Tailwind 4가 최근에 업데이트된 버전이라
tailwind-merge 관련 문제를 다룬 여러 블로그 글에서 해결 방법을 쉽게 찾을 수 있었다.</p>
<p>이러한 tailwind-merge 문제에 대한 가장 일반적인 해결 방법은 <strong>extendTailwindMerge</strong> 함수를 사용하는 것이다. 
extendTailwindMerge는 <strong>tailwind-merge 설정에 커스텀 설정을 추가</strong>해주는 함수로,
tailwind-merge가 text-* 패턴을 하나의 그룹으로 처리하면서 생기는 문제를 간단하게 <strong>다른 그룹으로 매핑</strong>하여 해결할 수 있다.</p>
<pre><code class="language-javascript">
import { extendTailwindMerge } from &#39;tailwind-merge&#39;;

// 커스텀 twMerge 함수 생성
const customTwMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      // 폰트 사이즈 관련 커스텀 클래스들을 별도 그룹으로 분리
      &#39;font-size&#39;: [
        &#39;text-s-regular&#39;,
        &#39;text-s-medium&#39;,
        &#39;text-m-regular&#39;,
        &#39;text-m-medium&#39;,
        &#39;text-l-regular&#39;,
        &#39;text-l-bold&#39;,
        // ... 기타 커스텀 폰트 클래스들
      ],
    },
  },
});

// twMerge &gt; customTwMerge 함수 수정
export function cn(...inputs: ClassValue[]) {
  return customTwMerge(clsx(inputs));
}</code></pre>
<p>결국 twMerge함수에서 customTwMerge로 수정한 후 text-s-regular를 font-size 그룹으로, text-white를 기본 text-color 그룹으로 분리하여 두 클래스가 서로 다른 그룹으로 인식되도록 했다.</p>
<p>그 결과 두 클래스가 모두 정상적으로 적용되었다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 경험을 통해 tailwind-merge의 내부 동작 방식과 한계점을 깊이 있게 이해할 수 있었다.
특히 prefix 기반의 그룹핑 방식이 성능상의 이유로 선택되었지만, 커스텀 클래스가 늘어나면서 예상치 못한 충돌을 일으킬 수 있다는 것을 알게 되었다.</p>
<p>Tailwind 4에서는 CSS에서 직접 커스텀 유틸리티를 정의할 수 있는 기능이 추가되었지만,
tailwind-merge는 Tailwind에서 사전에 정의된 유틸리티 클래스만 인식한다.
따라서 커스텀 클래스 자체는 단독으로 사용할 때는 문제가 없지만,
동일한 prefix를 가진 다른 유틸리티와 함께 쓰면 그룹이 자동으로 분리되지 않아
충돌이 발생할 수 있다.</p>
<p>다시말해, tailwind-merge는 아직 이런 변화를 완전히 반영하지 못한 상태이다.
(자동 반영은 안 되어있기 때문)</p>
<p>따라서 커스텀 클래스를 많이 사용하는 프로젝트에서는 extendTailwindMerge를 활용하여 적절한 그룹 분리를 해주는 것이 필요하다.</p>
<p>앞으로 유사한 문제가 발생했을 때는 클래스 이름의 prefix를 고려하여 네이밍을 하거나, 초기 설정 단계에서 커스텀 클래스 그룹을 미리 정의하는 것이 좋을 것 같다.
이를 통해 개발 과정에서 발생할 수 있는 예상치 못한 스타일 충돌을 예방할 수 있을 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ [사이드 프로젝트] 페이지네이션 lastProductId 잘림 이슈 디버깅 후기]]></title>
            <link>https://velog.io/@minseung-gang/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-lastProductId-%EC%9E%98%EB%A6%BC-%EC%9D%B4%EC%8A%88-%EB%94%94%EB%B2%84%EA%B9%85-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@minseung-gang/%EC%82%AC%EC%9D%B4%EB%93%9C-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-lastProductId-%EC%9E%98%EB%A6%BC-%EC%9D%B4%EC%8A%88-%EB%94%94%EB%B2%84%EA%B9%85-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Mon, 14 Jul 2025 00:02:31 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">개요</h1>
<p>오늘은 글쓴이가 최근에 상품 리스트를 페이징 처리하면서 겪었던<br>조금 황당한 버그 하나를 공유해보려고 한다.</p>
<p>API로 상품 목록을 불러올 때 페이지네이션을 위해 마지막 상품의 ID(<code>lastProductId</code>)를 기준으로<br>다음 페이지를 요청하도록 구현했다.<br>네트워크 응답은 제대로 오는데 콘솔에 찍힌 값은 전혀 달라 처음에는 문제가 무엇인지 파악하지 못하고 한참을 헤맸다.<br>알고 보니 자바스크립트를 사용한다면 꼭 알아두어야 할 기본적인 문제였다고 생각한다.<br>같은 상황을 겪지 않기를 바라는 마음으로 이 기록을 남기게 되었다.</p>
<hr>
<h1 id="발단">발단</h1>
<p>상황은 다음과 같다.<br>상품 리스트를 페이지 단위로 불러올 때, 서버에서 <code>lastProductId</code>를 내려주고<br>프론트에서는 이 ID를 다음 요청의 커서(cursor)처럼 사용해 다음 페이지를 가져오는 구조였다.</p>
<p>그런데 어느 순간부터 데이터를 받아올 때 <code>lastProductId</code>가  
네트워크 응답에서는 <code>202391306713694222</code>로 제대로 내려오는데<br>프론트 콘솔에 찍힌 값은 <code>200</code>으로 바뀌어 있는 현상이 발생했다.</p>
<p>서버 응답은 문제없이 내려오는데 왜 프론트에서 값이 잘려서 오는지 의문이었다.<br>처음에는 프론트 비즈니스 로직을 잘못 작성했는지, 서버에서 값을 잘못 내려주는지,<br>캐싱 문제인지, JSON 파싱 문제인지 등 여러 가능성을 확인해보았다.<br>그러나 결국 원인은 의외로 단순한 것이었다.</p>
<hr>
<h1 id="문제-발견">문제 발견</h1>
<p>네트워크 탭의 Preview에서는 마지막 상품 요소의 productId가 정확하지 않게 표시되는 것을 확인했다.</p>
<pre><code class="language-json">{
  hasNext: true
  lastProductId: 202391306713694200
  products:  
  ...
  7: {productId: &quot;202391306713694222&quot;, categoryId: &quot;202391306713694208&quot;, productName: &quot;비타민 B12&quot;,…}
}
</code></pre>
<p>하지만 Response 탭에서 확인해보니, 서버는 정상적으로 정확한 데이터를 보내고 있었다.
즉, 서버가 데이터를 잘 보내고 있음에도 불구하고, 브라우저나 프론트엔드 쪽에서 해당 값을 제대로 처리하지 못하는 문제를 발견하였다.</p>
<pre><code class="language-json">    {
    &quot;products&quot;: [
        ...
        {
            &quot;productId&quot;: &quot;202391306713694222&quot;,
            &quot;categoryId&quot;: &quot;202391306713694208&quot;,
            &quot;productName&quot;: &quot;비타민 B12&quot;,
            &quot;categoryName&quot;: &quot;비타민&quot;,
            &quot;company&quot;: &quot;삼성제약&quot;,
            &quot;price&quot;: 103,
            &quot;briefDescription&quot;: &quot;에너지 생성에 도움을 주고 신경 기능을 지원합니다.&quot;,
            &quot;thumbnailUrl&quot;: &quot;www.third_vitamin_Thumbnail.com&quot;,
            &quot;best&quot;: true
        }
    ],
    &quot;hasNext&quot;: true,
    &quot;lastProductId&quot;: 202391306713694222</code></pre>
<hr>
<h1 id="원인">원인</h1>
<p>문제의 원인은 <strong>자바스크립트</strong>의 <strong>Number 타입 정밀도 한계</strong> 때문이었다.</p>
<p>자바스크립트의 Number 타입은 IEEE-754 64비트 부동소수점 방식으로 동작하기 때문에<br>정수로 안전하게 표현할 수 있는 범위는 <code>-(2^53 - 1)</code>에서 <code>2^53 - 1</code>(약 ±9000조)까지이다.</p>
<p>하지만 상품 ID는 20경 단위를 넘어가기 때문에 이 범위를 훌쩍 초과하게 된다.<br>이처럼 큰 숫자는 JSON 파싱 시 자동으로 Number로 처리되면서 뒤쪽 자리수가 잘려나가<br>결국 <code>202391306713694222</code>가 <code>200</code>으로 바뀌어 전달된 것이다 😬</p>
<hr>
<h1 id="해결">해결</h1>
<p>해결 방법은 간단하다.<br>긴 숫자 ID는 무조건 <strong>문자열</strong>로 다루어야 한다는 점이다.</p>
<br/>

<p>1️⃣ <strong>백엔드에서 응답 포맷 수정</strong><br><code>lastProductId</code>를 Number로 직렬화하지 않고 반드시 문자열로 보내도록 수정했다.</p>
<pre><code class="language-json">{
  &quot;lastProductId&quot;: &quot;202391306713694222&quot;
}</code></pre>
<p>2️⃣ <strong>프론트에서도 무조건 문자열로 처리</strong><br>프론트에서도 숫자로 파싱하지 않고 그대로 문자열로 다루도록 했다.<br>비교가 필요하다면 <code>BigInt</code>를 사용하거나 문자열 비교로 처리하면 충분하다.</p>
<pre><code class="language-js">{
    BigInt(&quot;202391306713694200&quot;)
}</code></pre>
<hr>
<h1 id="마무리">마무리</h1>
<p>이 사소한 문제 하나 때문에 디버깅에 몇 시간을 소모했던 것이 아쉽다.<br>처음부터 긴 숫자 ID는 반드시 문자열로 처리했으면 불필요한 삽질을 피할 수 있었을 것이다.</p>
<p>상품 ID나 유저 ID 등 큰 숫자를 페이징 처리에 사용할 경우<br>자바스크립트 Number 타입의 정밀도 한계를 반드시 기억하고 무조건 문자열로 처리하기를 권장한다.</p>
<p>이번 일을 계기로, API 처리 시 문제가 발생하면 반드시 네트워크 탭을 열어 직접 응답 데이터를 확인하는 습관을 들여야겠다는 원칙을 갖게 되었다.</p>
<p>작은 기록이지만 누군가에게 도움이 되기를 바란다.<br>비슷한 경험이 있다면 댓글로 공유해주면 큰 위로가 될 것 같다.</p>
]]></description>
        </item>
    </channel>
</rss>