<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>h_jinny.log</title>
        <link>https://velog.io/</link>
        <description>Frontend💡</description>
        <lastBuildDate>Mon, 15 Dec 2025 10:00:05 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>h_jinny.log</title>
            <url>https://velog.velcdn.com/images/h_jinny/profile/f3b9ae0e-c0e1-4377-8fd3-6945b2b7a26f/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. h_jinny.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/h_jinny" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[반응형 화면에서 GSAP 애니메이션 제어하기]]></title>
            <link>https://velog.io/@h_jinny/%EB%B0%98%EC%9D%91%ED%98%95-%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-GSAP-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@h_jinny/%EB%B0%98%EC%9D%91%ED%98%95-%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-GSAP-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 15 Dec 2025 10:00:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>포트폴리오 프로젝트에서 GSAP의 ScrollTrigger를 사용하여 수평 스크롤 애니메이션을 구현했다. 이때 PC에서는 스크롤에 따라 프로젝트 카드들이 수평으로 이동하는 애니메이션이 동작하고, 모바일과 태블릿에서는 일반적인 세로 스크롤 레이아웃을 유지하도록 구현을 시도했지만, 생각한대로 실행되지 않았다.</p>
</blockquote>
<h2 id="1-resize-이슈">1. Resize 이슈</h2>
<p>레이아웃이 화면 크기에 따라 다르기 때문에, <strong>GSAP 애니메이션의 적용 여부도 화면 크기에 따라 달라져야</strong> 했다.</p>
<ul>
<li>PC 화면: GSAP ScrollTrigger 애니메이션 적용 ✅</li>
<li>모바일/태블릿: GSAP 애니메이션 비활성화 (일반 레이아웃) ❌</li>
</ul>
<p>위 환경에서 화면 너비가 resize 될때마다 GSAP 애니메이션을 동적으로 활성화 또는 비활성화를 해야하는데, 기존 코드가 정상동작하지 않아 새로고침 없이는 반응형으로 구현되지 않는 문제가 발생했다.</p>
<p>!youtube[UUzLg9m2BsQ?si=2A078UXxLGBAIjey]</p>
<br>

<h3 id="기존-코드">기존 코드</h3>
<pre><code class="language-typescript">&#39;use client&#39;;
...

const SideProjects = () =&gt; {
  const sectionRef = useRef&lt;HTMLElement | null&gt;(null);
  const wrapperRef = useRef&lt;HTMLUListElement | null&gt;(null);
  const { isMobile, isLoaded } = useIsMobile();

  useGSAP(() =&gt; {
    if (!isLoaded || isMobile) return;
    if (!sectionRef.current || !wrapperRef.current) return;

    gsap.registerPlugin(ScrollTrigger);

    const section = sectionRef.current;
    const wrapper = wrapperRef.current;
    const sections = gsap.utils.toArray&lt;HTMLLIElement&gt;(&#39;.my-projects-section&#39;);

    if (sections.length === 0) return;

    const gap = 70;
    const boxWidth = 800;
    const viewportWidth = window.innerWidth;

    const wrapperRect = wrapper.getBoundingClientRect();
    const wrapperLeft = wrapperRect.left;
    const centerOffset = (viewportWidth - boxWidth) / 2 - wrapperLeft;

    gsap.set(wrapper, { x: centerOffset });

    const totalSections = sections.length;
    const scrollDistance = (boxWidth + gap) * (totalSections - 1);

    const snapPoints = sections.map((_, index) =&gt; index / (totalSections - 1));

    const animation = gsap.to(wrapper, {
      x: centerOffset - scrollDistance,
      ease: &#39;none&#39;,
      scrollTrigger: {
        trigger: section,
        start: &#39;top top&#39;,
        end: () =&gt; `+=${scrollDistance + viewportWidth}`,
        pin: true,
        scrub: 0.5,
        snap: {
          snapTo: (progress) =&gt; {
            // 가장 가까운 snap 포인트 찾기
            let closest = snapPoints[0];
            let minDistance = Math.abs(progress - snapPoints[0]);

            snapPoints.forEach((point) =&gt; {
              const distance = Math.abs(progress - point);
              if (distance &lt; minDistance) {
                minDistance = distance;
                closest = point;
              }
            });

            return closest;
          },
          duration: { min: 0.1, max: 0.3 }, // 빠른 snap
          delay: 0,
        },
        invalidateOnRefresh: true,
      },
    });

    ScrollTrigger.refresh();

    return () =&gt; {
      animation.kill();
      ScrollTrigger.getAll().forEach((trigger) =&gt; {
        if (trigger.vars.trigger === section) {
          trigger.kill();
        }
      });
    };
  }, [isLoaded, isMobile]);

  return (
    &lt;S.SideProjects id=&#39;my-projects&#39; ref={sectionRef}&gt;
      &lt;Inner&gt;
        &lt;Title text={`MY ✨ \nPROJECTS`} /&gt;
      &lt;/Inner&gt;
      &lt;S.SideProjectsInner ref={wrapperRef}&gt;
        {sideProjectsList.map((item, index) =&gt; (
          &lt;S.SideProjectsInnerBox key={`${item.title}-${index}`} className=&#39;my-projects-section&#39;&gt;
            ...
          &lt;/S.SideProjectsInnerBox&gt;
        ))}
      &lt;/S.SideProjectsInner&gt;
    &lt;/S.SideProjects&gt;
  );
};

export default SideProjects;</code></pre>
<br>

<p><strong>useIsMobile.ts</strong></p>
<p>모바일 여부(isMobile)와 초기 로드 완료 상태(isLoaded)를 반환하는 커스텀 훅이다.</p>
<pre><code class="language-typescript">import { useState, useEffect } from &#39;react&#39;;
import { BREAKPOINT } from &#39;../../../_constant/breakpoint&#39;;

export const useIsMobile = (breakpoint: number = BREAKPOINT) =&gt; {
  const [isMobile, setIsMobile] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() =&gt; {
    const checkIsMobile = () =&gt; {
      const mobile = window.innerWidth &lt;= breakpoint;
      setIsMobile(mobile);
      setIsLoaded(true);
    };

    checkIsMobile();
    window.addEventListener(&#39;resize&#39;, checkIsMobile);

    return () =&gt; window.removeEventListener(&#39;resize&#39;, checkIsMobile);
  }, []);

  return { isMobile, isLoaded };
};
</code></pre>
<br>

<p>기존 코드에서는 <code>useIsMobile</code> 를 사용했는데, 정상적으로 실행되지 않았다. 그 이유를 찾아보니, 커스텀훅의 경우 React 상태(isMobile) 변경에만 의존하기 때문에 GSAP이 resize 를 직접 감지하지 못하고 있었던 것이다.</p>
<br>



<hr>
<h2 id="2-해결-gsapmatchmedia">2. 해결: gsap.matchMedia()</h2>
<p>하지만 이러한 문제는 GSAP 에서 제공하는 기능으로 쉽게 해결할 수 있었다..! </p>
<h3 id="gsapmatchmedia-란">gsap.matchMedia() 란?</h3>
<p>GSAP 에서는  미디어 쿼리에 따라 조건부로 애니메이션을 활성화/비활성화할 수 있는 <code>matchMedia()</code> 메서드를 제공해준다.</p>
<pre><code class="language-typescript">const mm = gsap.matchMedia();

mm.add(&#39;(min-width: 1081px)&#39;, () =&gt; {
  // PC 화면에서만 실행되는 애니메이션
  gsap.to(element, { x: 100 });
});

mm.add(&#39;(max-width: 1080px)&#39;, () =&gt; {
  // 모바일/태블릿에서만 실행되는 애니메이션
  gsap.to(element, { y: 100 });
});</code></pre>
<p><strong>주요 특징:</strong></p>
<ul>
<li>미디어 쿼리 조건에 따라 애니메이션을 자동으로 활성화/비활성화</li>
<li>조건이 맞지 않게 되면 내부의 ScrollTrigger가 자동으로 <code>kill</code>되고 <code>revert</code> 가 실행됨</li>
<li><code>mm.revert()</code>를 호출하면 모든 matchMedia 인스턴스가 정리됨</li>
<li>리사이즈 이벤트를 자동으로 감지하여 조건에 맞게 애니메이션 재적용</li>
</ul>
<br>

<p>!youtube[CQxLO4GRnf0?si=Ax2kABeVKvpLAm6i]</p>
<h3 id="적용-코드">적용 코드</h3>
<pre><code class="language-typescript">&#39;use client&#39;;

import { BREAKPOINT } from &#39;@/app/_constant/breakpoint&#39;;

const SideProjects = () =&gt; {
  const sectionRef = useRef&lt;HTMLElement | null&gt;(null);
  const wrapperRef = useRef&lt;HTMLUListElement | null&gt;(null);

  useGSAP(() =&gt; {
    if (!isLoaded) return;
    if (!sectionRef.current || !wrapperRef.current) return;

    const mm = gsap.matchMedia();

    // PC 화면에서만 ScrollTrigger 애니메이션 적용
    mm.add(`(min-width: ${BREAKPOINT + 1}px)`, () =&gt; {
      gsap.registerPlugin(ScrollTrigger);

      const section = sectionRef.current;
      const wrapper = wrapperRef.current;
      const sections = gsap.utils.toArray&lt;HTMLLIElement&gt;(&#39;.my-projects-section&#39;);

      if (!section || !wrapper || sections.length === 0) return;

      const gap = 70;
      const boxWidth = 800;
      const viewportWidth = window.innerWidth;

      const wrapperRect = wrapper.getBoundingClientRect();
      const wrapperLeft = wrapperRect.left;
      const centerOffset = (viewportWidth - boxWidth) / 2 - wrapperLeft;

      gsap.set(wrapper, { x: centerOffset });

      const totalSections = sections.length;
      const scrollDistance = (boxWidth + gap) * (totalSections - 1);

      const snapPoints = sections.map((_, index) =&gt; index / (totalSections - 1));

      const animation = gsap.to(wrapper, {
        x: centerOffset - scrollDistance,
        ease: &#39;none&#39;,
        scrollTrigger: {
          trigger: section,
          start: &#39;top top&#39;,
          end: () =&gt; `+=${scrollDistance + viewportWidth}`,
          pin: true,
          scrub: 0.5,
          snap: {
            snapTo: (progress) =&gt; {
              let closest = snapPoints[0];
              let minDistance = Math.abs(progress - snapPoints[0]);

              snapPoints.forEach((point) =&gt; {
                const distance = Math.abs(progress - point);
                if (distance &lt; minDistance) {
                  minDistance = distance;
                  closest = point;
                }
              });

              return closest;
            },
            duration: { min: 0.1, max: 0.3 },
            delay: 0,
          },
          invalidateOnRefresh: true,
        },
      });

      ScrollTrigger.refresh();

      return () =&gt; {
        animation.kill();
        ScrollTrigger.getAll().forEach((trigger) =&gt; {
          if (trigger.vars.trigger === section) {
            trigger.kill();
          }
        });
      };
    });

    return () =&gt; mm.revert();
  }, [isLoaded]);

  return (
    &lt;S.SideProjects id=&#39;my-projects&#39; ref={sectionRef}&gt;
      {/* ... JSX 내용 ... */}
    &lt;/S.SideProjects&gt;
  );
};

export default SideProjects;</code></pre>
<br>

<p>위 내용을 바탕으로 <code>matchMedia()</code> 를 사용한 후, 화면 크기에 따라 자동으로 애니메이션이 적용되고 제거되면서 GSAP 기능이 포함된 반응형 레이아웃을 정상적으로 구현할 수 있었다🥹</p>
<br>

<hr>
<h2 id="3-또-다른-문제-feat레이아웃-와장창">3. 또 다른 문제 (feat.레이아웃 와장창)</h2>
<p>하지만 예상치 못한 문제가 발생했는데... 내용은 아래와 같았다</p>
<h3 id="문제-상황">문제 상황</h3>
<ul>
<li>✅ <strong>PC → Mobile로 리사이즈</strong>: 정상적으로 동작</li>
<li>❌ <strong>Mobile → PC로 리사이즈</strong>: 레이아웃이 깨지고 GSAP 애니메이션이 이상하게 동작</li>
</ul>
<p>말 그대로 PC 화면에서 모바일로 리사이즈할 때는 애니메이션이 정상적으로 비활성화되었지만, 모바일에서 PC로 리사이즈할 때는 요소의 위치가 엉망이 되고 애니메이션이 제대로 동작하지 않는 현상이 발생하는 것이다🤬</p>
<p>!youtube[eoNpb8KhxkQ?si=-_unlKvnNBcPeN3M]</p>
<br>

<h3 id="원인-분석">원인 분석</h3>
<p>AI 를 사용하여 분석해봤는데, 주요 원인은 GSAP 애니메이션이 <strong>요소에 인라인 스타일을 직접 적용</strong>하기 때문인것으로 확인됐다.
쉽게 말해, 문제의 흐름은 아래와 같이 흘러간다.</p>
<p><strong>문제의 흐름:</strong></p>
<ol>
<li><p>PC 화면에서 GSAP가 <code>transform: translateX(...)</code>와 같은 인라인 스타일을 요소에 적용이 됨
(아래와 같은 형식으로!)</p>
<pre><code class="language-html">&lt;ul style=&quot;transform: translate3d(-XXXpx, 0px, 0px); ...&quot;&gt;...&lt;/ul&gt;</code></pre>
</li>
<li><p>모바일로 전환될 때 <code>matchMedia()</code>가 애니메이션을 비활성화 (kill)</p>
</li>
<li><p>하지만 이미 적용된 인라인 스타일(<code>transform</code>, <code>x</code> 등)이 완전히 제거되지 않고 남아있음</p>
</li>
<li><p>모바일에서 PC로 다시 돌아올 때:</p>
<ul>
<li>이전에 남아있는 (모바일의)인라인 스타일</li>
<li>새로운 애니메이션 계산값</li>
<li>CSS 스타일</li>
</ul>
<p>이 세 가지가 충돌하여 레이아웃이 깨지게된 것이다</p>
</li>
</ol>
<p>즉 <code>gsap.matchMedia</code>의 조건에 맞지 않을 때 <code>revert()</code> 가 자동으로 실행되면서 애니메이션을 되돌리지만, 스타일이 변경되기 전의 원래 인라인 상태로 완벽하게 되돌린다는 보장이 없기 때문에 이런 문제가 발생하게 되었다.</p>
<br>



<hr>
<h2 id="4-진짜-최종-해결">4. 진짜 최종 해결</h2>
<h3 id="scrolltriggersavestyle">ScrollTrigger.saveStyle()</h3>
<p>GSAP는 이런 문제를 해결하기 위해 <code>ScrollTrigger.saveStyles()</code> 메서드를 제공한다고 한다.</p>
<pre><code class="language-typescript">ScrollTrigger.saveStyles(&#39;.my-projects-section, .my-projects-section *&#39;);</code></pre>
<p><strong>역할:</strong></p>
<ol>
<li><strong>애니메이션 적용 전</strong> 요소의 원래 인라인 스타일을 기록</li>
<li><code>matchMedia()</code>가 애니메이션을 비활성화할 때 저장된 스타일로 자동 복원</li>
<li>스타일 충돌을 방지하고 레이아웃을 원상복구</li>
</ol>
<p><strong>사용 시점:</strong></p>
<ul>
<li>컴포넌트가 마운트되고 DOM이 준비된 후</li>
<li>애니메이션이 시작되기 <strong>전</strong>에 한 번만 호출</li>
<li><code>useEffect</code>를 사용하여 적절한 타이밍에 호출</li>
</ul>
<br>

<p>위 내용을 토대로 아래와 같이 코드를 수정해봤다. 🔽</p>
<h3 id="수정된-코드">수정된 코드</h3>
<pre><code class="language-typescript">&#39;use client&#39;;

import { BREAKPOINT } from &#39;@/app/_constant/breakpoint&#39;;

// GSAP ScrollTrigger 플러그인 등록
if (typeof window !== &#39;undefined&#39;) {
  gsap.registerPlugin(ScrollTrigger);
}

const SideProjects = () =&gt; {
  const sectionRef = useRef&lt;HTMLElement | null&gt;(null);
  const wrapperRef = useRef&lt;HTMLUListElement | null&gt;(null);

  // 애니메이션 대상 요소의 원본 스타일을 저장
  useEffect(() =&gt; {
    if (typeof window !== &#39;undefined&#39; &amp;&amp; isLoaded) {
      ScrollTrigger.saveStyles(&#39;.my-projects-section, .my-projects-section *&#39;);
    }
  }, [isLoaded]);

  useGSAP(() =&gt; {
    if (!isLoaded) return;
    if (!sectionRef.current || !wrapperRef.current) return;

    const mm = gsap.matchMedia();

    mm.add(`(min-width: ${BREAKPOINT + 1}px)`, () =&gt; {
      const section = sectionRef.current;
      const wrapper = wrapperRef.current;
      const sections = gsap.utils.toArray&lt;HTMLLIElement&gt;(&#39;.my-projects-section&#39;);

      if (!section || !wrapper || sections.length === 0) return;

      ...

      const animation = gsap.to(wrapper, {
        ...
      });

      ScrollTrigger.refresh();

      return () =&gt; {
        animation.kill();
        ScrollTrigger.getAll().forEach((trigger) =&gt; {
          if (trigger.vars.trigger === section) {
            trigger.kill();
          }
        });
      };
    });

    return () =&gt; mm.revert();
  }, [isLoaded]);

  return (
    &lt;S.SideProjects id=&#39;my-projects&#39; ref={sectionRef}&gt;
      {/* ... JSX 내용 ... */}
    &lt;/S.SideProjects&gt;
  );
};

export default SideProjects;</code></pre>
<h3 id="주요-변경-사항">주요 변경 사항</h3>
<ol>
<li><p><strong><code>ScrollTrigger.saveStyles()</code> 추가</strong></p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (typeof window !== &#39;undefined&#39; &amp;&amp; isLoaded) {
    ScrollTrigger.saveStyles(&#39;.my-projects-section, .my-projects-section *&#39;);
  }
}, [isLoaded]);</code></pre>
<ul>
<li>DOM이 준비된 후 애니메이션 대상 요소의 원본 스타일을 저장</li>
<li><code>*</code>를 포함하여 자식 요소까지 저장하여 안전하게 처리</li>
<li><code>isLoaded</code>가 <code>true</code>가 된 후 실행되어 DOM이 완전히 준비된 상태에서 저장</li>
</ul>
</li>
<li><p><strong><code>gsap.registerPlugin()</code> 위치 변경</strong></p>
<ul>
<li>컴포넌트 외부로 이동하여 한 번만 등록되도록 수정</li>
<li><code>matchMedia()</code> 콜백 내부에서 중복 등록 제거</li>
</ul>
</li>
<li><p><strong><code>invalidateOnRefresh: true</code> 유지</strong></p>
<ul>
<li>리사이즈 시 ScrollTrigger가 계산값을 재계산하도록 유지</li>
<li><code>saveStyles()</code>와 함께 사용하면 더욱 안정적</li>
</ul>
</li>
</ol>
<br>

<hr>
<h2 id="결과">결과</h2>
<p>이제 아래 사항이 모두 해결된 모습을 확인할 수 있었다 ✨</p>
<ul>
<li>GSAP 애니메이션 여부가 PC/Tablet/Mobile 사이즈별로 정상 작동</li>
<li>Mobile -&gt; PC 로 resize 될 때 해당하는 화면 레이아웃대로 정상 복귀</li>
</ul>
<p>!youtube[6NVnyykWw3M?si=VByikpB2tmR2gXll]</p>
<br>

<hr>
<h2 id="참고">참고</h2>
<ul>
<li><a href="https://greensock.com/docs/v3/Plugins/ScrollTrigger/static.matchMedia()">GSAP ScrollTrigger.matchMedia() 문서</a></li>
<li><a href="https://greensock.com/docs/v3/Plugins/ScrollTrigger/static.saveStyles()">GSAP ScrollTrigger.saveStyles() 문서</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase 이미지 업로드: Signed URL 방식]]></title>
            <link>https://velog.io/@h_jinny/Supabase-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-Signed-URL-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@h_jinny/Supabase-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-Signed-URL-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Sun, 30 Nov 2025 07:57:25 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Next.js와 Supabase를 사용한 프로젝트에서 이미지 업로드 기능을 구현할 때, 초기에는 Next.js 서버(API Route)를 경유하는 방식으로 구현했다. 하지만 그 과정에서 여러 문제점들이 발견되어 Supabase의 <strong>Signed URL</strong> 방식을 활용한 직접 업로드 방식으로 전환하게 되었다</p>
</blockquote>
<p>지인들에게 각각 모바일, pc 로 이미지테스트를 부탁했을 때 아래와 같은 에러들이 발생한 상황이었다..</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/e981dc31-ee40-46fb-8a1f-10b3522d67ac/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/50d180c1-2e07-4cc8-8afd-385e020d2f27/image.png" alt=""></p>
<h2 id="기존-이미지-업로드-방식">기존 이미지 업로드 방식</h2>
<h3 id="구현-방식">구현 방식</h3>
<p>기존에는 클라이언트에서 파일을 Next.js 서버(API Route)로 전송하고, Next.js 서버에서 Supabase Storage에 업로드하는 방식이었다.</p>
<h4 id="클라이언트-코드-postformtsx">클라이언트 코드 (PostForm.tsx)</h4>
<pre><code class="language-typescript">// API Route로 파일 업로드
const handleUpload = async (formData: FormData) =&gt; {
  try {
    const res = await fetch(&#39;/api/upload&#39;, {
      method: &#39;POST&#39;,
      body: formData,
    });

    const result = await res.json();

    if (!res.ok) {
      const errorMessage = result.error || `업로드 실패 (상태 코드: ${res.status})`;
      throw new Error(errorMessage);
    }

    return result;
  } catch (error) {
    if (error instanceof Error) {
      if (error.message.includes(&#39;Failed to fetch&#39;) || error.message.includes(&#39;NetworkError&#39;)) {
        throw new Error(&#39;네트워크 연결에 문제가 있습니다. 인터넷 연결을 확인해주세요.&#39;);
      }
      throw error;
    }
    throw new Error(&#39;파일 업로드 중 알 수 없는 오류가 발생했습니다.&#39;);
  }
};</code></pre>
<h4 id="서버-코드-apiuploadroutets">서버 코드 (api/upload/route.ts)</h4>
<pre><code class="language-typescript">export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const files = Array.from(formData.entries()).map(([, file]) =&gt; file as File);

    const result = await Promise.all(
      // 여러 파일 한 번에 업로드 처리
      files.map(async (file) =&gt; {
        const supabase = await createServerSupabaseClient();
        const { data, error } = await supabase.storage
          .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!)
          .upload(file.name, file, { upsert: true });
        if (error) {
          throw new Error(error.message);
        }
        return { data };
      }),
    );
    return NextResponse.json({ result });
  } catch (err: unknown) {
    const error = err as Error;
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}</code></pre>
<hr>
<h2 id="⚠️-기존-방식의-문제점">⚠️ 기존 방식의 문제점</h2>
<ul>
<li>다수의 이미지 업로드 시 413 Payload Too Large 에러 발생</li>
<li>업로드되는 파일이 Next 서버를 경유하면서 자체 파일 용량을 제한 
(Next.js의 API Route나 Server Actions은 기본적으로 요청 본문(Payload) 크기를 1MB에서 4MB 사이로 매우 작게 설정한다고 한다.. 어쩐지 4mb 정도가 넘어가면 에러가 계속 떴는데 이 부분도 원인이었다..)</li>
<li>에러 응답이 JSON 형식이 아닐 때 파싱 실패</li>
</ul>
<br>

<h2 id="✅-해결-방법-signed-url-방식">✅ 해결 방법: Signed URL 방식</h2>
<p>Supabase의 <strong>Signed URL</strong> 방식을 사용하면 클라이언트에서 직접 Supabase Storage에 업로드할 수 있다고 한다.
이 방식을 사용하게 되면 Next.js 서버는 딱 Signed URL을 발급하는 역할만 하게 된다.
Signed URL은 일정 시간 동안만 유효하고 Next.js 서버에서 인증된 사용자에게만 발급이 되는데, 이에 따라서 보안상 안전하게 사용할 수 있다고 한다.</p>
<h3 id="새로운-구현-방식">새로운 구현 방식</h3>
<h4 id="서버-코드-apiuploadroutets---signed-url-발급만-담당">서버 코드 (api/upload/route.ts) - Signed URL 발급만 담당</h4>
<p>supabase 의 createSignedUploadUrl() 를 통해 presignedUrl 을 발급해준다</p>
<pre><code class="language-typescript">import { NextRequest, NextResponse } from &#39;next/server&#39;;
import { createServerSupabaseClient } from &#39;utils/supabase/server&#39;;

export const runtime = &#39;nodejs&#39;;
export const maxDuration = 60;

// Signed URL 발급 전용 API
export async function POST(req: NextRequest) {
  try {
    const supabase = await createServerSupabaseClient();
    const bucket = process.env.NEXT_PUBLIC_STORAGE_BUCKET;

    if (!bucket) {
      return NextResponse.json(
        { error: &#39;Missing bucket name&#39; },
        { status: 500, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
      );
    }

    const body = await req.json();
    const { fileName, fileType } = body;

    if (!fileName || !fileType) {
      return NextResponse.json(
        { error: &#39;fileName &amp; fileType required&#39; },
        { status: 400, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
      );
    }

    // 안전한 파일명 생성
    const safeNameBase = fileName.replace(/[^a-zA-Z0-9._-]/g, &#39;_&#39;) || &#39;image&#39;;
    const fileExt = fileName.split(&#39;.&#39;).pop()?.toLowerCase() || &#39;jpg&#39;;
    const finalName = `${Date.now()}_${Math.random()
      .toString(36)
      .substring(2, 8)}_${safeNameBase}.${fileExt}`;

    // Signed URL 생성
    const { data, error } = await supabase.storage.from(bucket).createSignedUploadUrl(finalName);

    if (error) {
      return NextResponse.json(
        { error: error.message },
        { status: 500, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
      );
    }

    if (!data?.signedUrl || !data?.path) {
      return NextResponse.json(
        { error: &#39;Failed to create signed URL&#39; },
        { status: 500, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
      );
    }

    return NextResponse.json(
      { signedUrl: data.signedUrl, path: data.path },
      { status: 200, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
    );
  } catch (err) {
    return NextResponse.json(
      { error: (err as Error).message ?? &#39;Unknown error&#39; },
      { status: 500, headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } },
    );
  }
}</code></pre>
<h4 id="클라이언트-코드-postformtsx---직접-업로드">클라이언트 코드 (PostForm.tsx) - 직접 업로드</h4>
<pre><code class="language-typescript">const handleUpload = async (files: File[]): Promise&lt;Array&lt;{ path: string }&gt;&gt; =&gt; {
  // 순차 업로드로 JSON 파싱 에러 방지
  const uploadResults: Array&lt;{ path: string }&gt; = [];

  for (const file of files) {
    try {
      if (!file) throw new Error(&#39;파일이 없습니다.&#39;);

      // 1) 서버에서 signed URL 요청
      const signRes = await fetch(&#39;/api/upload&#39;, {
        method: &#39;POST&#39;,
        headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
        }),
      });

      // 응답이 JSON인지 확인
      const contentType = signRes.headers.get(&#39;content-type&#39;);
      if (!contentType || !contentType.includes(&#39;application/json&#39;)) {
        const text = await signRes.text();
        throw new Error(`서버 응답 오류 (상태: ${signRes.status}): ${text.substring(0, 100)}`);
      }

      let signData;
      try {
        signData = await signRes.json();
      } catch {
        const text = await signRes.text();
        throw new Error(`JSON 파싱 실패: ${text.substring(0, 100)}`);
      }

      if (!signRes.ok) {
        throw new Error(signData.error || &#39;Signed URL 발급 실패&#39;);
      }

      if (!signData.signedUrl || !signData.path) {
        throw new Error(&#39;Signed URL 또는 경로가 없습니다.&#39;);
      }

      // 2) signed URL로 직접 Supabase에 업로드
      const uploadRes = await fetch(signData.signedUrl, {
        method: &#39;PUT&#39;,
        headers: {
          &#39;Content-Type&#39;: file.type,
        },
        body: file,
      });

      if (!uploadRes.ok) {
        throw new Error(`Supabase 업로드 실패: ${uploadRes.statusText}`);
      }

      // 최종적으로 저장된 경로 반환
      uploadResults.push({ path: signData.path });
    } catch (err) {
      throw new Error(
        err instanceof Error ? `업로드 실패 (${file.name}): ${err.message}` : &#39;업로드 실패&#39;,
      );
    }
  }

  return uploadResults;
};</code></pre>
<hr>
<h2 id="개선-후-결과">개선 후 결과</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>기존 방식</th>
<th>Signed URL 방식</th>
</tr>
</thead>
<tbody><tr>
<td><strong>업로드 경로</strong></td>
<td>클라이언트 → Next.js 서버 → Supabase</td>
<td>클라이언트 → Supabase</td>
</tr>
<tr>
<td><strong>Next.js 서버 역할</strong></td>
<td>파일 수신 및 업로드</td>
<td>Signed URL 발급만</td>
</tr>
<tr>
<td><strong>파일 크기 제한</strong></td>
<td>총 4MB</td>
<td>총 10MB (개별 10MB)</td>
</tr>
<tr>
<td><strong>Next.js 서버 부하</strong></td>
<td>높음 (파일 전송)</td>
<td>낮음 (URL 발급만)</td>
</tr>
<tr>
<td><strong>에러 발생 빈도</strong></td>
<td>높음 (JSON 파싱 에러 등)</td>
<td>낮음 (명확한 에러 처리)</td>
</tr>
</tbody></table>
<ul>
<li>Next.js 서버는 파일을 받지 않고 Signed URL만 발급하므로 메모리 사용량이 크게 감소</li>
<li>Next.js 서버를 거치지 않으므로 Next.js의 자체의 파일 크기 제한 없이 업로드 가능 (Supabase Storage의 제한만 따름)</li>
<li>순차 업로드로 변경하여 JSON 파싱 에러를 방지</li>
<li>서버 응답 형식 검증 강화</li>
<li>클라이언트에서 직접 Supabase로 업로드하므로 더 빠른 업로드 속도</li>
</ul>
<hr>
<p>이렇게 Supabase의 Signed URL 방식을 사용하여 이미지 업로드 기능을 개선했다. 파일 크기 제한을 완화하는것에서 그치지 않고 Next.js 서버 부하를 줄일 수 있었고, 부가적으로 개별 파일에 대한 에러나 JSON 관련 등 에러 처리를 강화했다.</p>
<p>위와 같은 개선을 통해 사용자 경험을 향상시키고, Next.js 서버 리소스를 효율적으로 사용할 수 있게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Javascript] Map vs find : 배열 대신 키-값 조회로 빠른 탐색하기]]></title>
            <link>https://velog.io/@h_jinny/Javascript-Map-vs-find-%EB%B0%B0%EC%97%B4-%EB%8C%80%EC%8B%A0-%ED%82%A4-%EA%B0%92-%EC%A1%B0%ED%9A%8C%EB%A1%9C-%EB%B9%A0%EB%A5%B8-%ED%83%90%EC%83%89%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@h_jinny/Javascript-Map-vs-find-%EB%B0%B0%EC%97%B4-%EB%8C%80%EC%8B%A0-%ED%82%A4-%EA%B0%92-%EC%A1%B0%ED%9A%8C%EB%A1%9C-%EB%B9%A0%EB%A5%B8-%ED%83%90%EC%83%89%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Sep 2025 08:43:21 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>인스타그램 클론 프로젝트 J-Stagram 의 피드 형식 게시글 기능을 만드는 과정에서, 게시글 리스트를 불러오는 것까지 완성 후 해당 게시글의 작성자를 불러오는 작업을 진행중이었다. 이를 Supabase 의 Admin API 인 <code>listUsers()</code> 를 통해 현재 가입된 모든 사용자 리스트를 불러왔다. 그 이후 게시글의 <code>user_id</code>(supabase 의 User 와 연결됨) 에 해당하는 유저를 찾아 작성자의 프로필 사진 및 이름을 담은 user 정보 객체를 담고자 했다.</p>
</blockquote>
<pre><code class="language-tsx">export async function getPosts({ searchInput = &#39;&#39; }): Promise&lt;PostWithImages[]&gt; {
  const supabase = await createServerSupabaseClient();

  const { data: posts, error } = await supabase
    .from(&#39;posts&#39;)
    .select(
      `
      id,
      title,
      content,
      user_id,
      created_at,
      images (url)
    `,
    )
    .eq(&#39;is_public&#39;, true)
    .like(&#39;title&#39;, `%${searchInput}%`)
    .order(&#39;created_at&#39;, { ascending: true });

  if (error) handleError(error);

  // Admin client 사용
  const supabaseAdmin = await createServerSupabaseAdminClient();
  const { data: allUsers, error: usersError } = await supabaseAdmin.auth.admin.listUsers();

  if (usersError) handleError(usersError);

 ...
}</code></pre>
<p><code>listUsers()</code> 를 통해 모든 사용자 데이터를 <code>allUsers</code> 라는 이름으로 가져왔는데, 이를 posts 데이터를 return 하기 전에 <code>user_info</code> 형태로 끼워줄 생각이었다.</p>
<p>이때 <code>find</code> 메서드를 사용하려다, 커서를 활용하여 더 좋은 방법이 없는지 검색해보니 <code>Map</code> 객체를 활용하면 더 좋을 것이라는 내용이 있었다.</p>
<p>MDN 문서에 따르면, <code>Map</code>은 키와 값 쌍을 저장하고, 넣은 순서를 기억하며 숫자, 문자, 객체 등 어떤 값도 키로 쓸 수 있는 객체라고 한다. 즉 효율 좋고 <strong>빠른 키-값 저장소로,</strong> <strong>id → 객체</strong> 형태로 매핑할 수 있는 훨씬 깔끔한 구조를 만들 수 있었다.</p>
<pre><code class="language-tsx">const userMap = new Map(
  allUsers.users.map((u) =&gt; [
    u.id,
    {
      email: u.email,
      user_metadata: u.user_metadata,
    },
  ]),
);

return posts.map((post) =&gt; ({
  ...post,
  images: post.images ?? [],
  user_info: userMap.get(post.user_id) ?? null,
}));</code></pre>
<p>Map 을 사용하면 위와 같이 유저 데이터를 매핑 할 수 있다. get 은 Map이나 객체에서 특정 키에 해당하는 값을 가져오는 메서드인데, 이를 통해 게시글의 작성자 <code>user_id</code> 로 키 값인 user 의 id 와 바로 대조하여 값을 찾을 수 있다. </p>
<p>결과를 봤을 때, posts 의 각 post 마다 해당하는 유저 데이터가 잘 삽입이 된 것을 확인할 수 있었다.</p>
<pre><code class="language-tsx">{
  &quot;id&quot;: 2,
  &quot;title&quot;: &quot;게시글1&quot;,
  &quot;content&quot;: &quot;게시글 내용입니드아아앙&quot;,
  &quot;user_id&quot;: &quot;e007aca1-3f90-48fe-acaf-7d6ee201e56a&quot;,
  &quot;created_at&quot;: &quot;2025-09-12T07:40:11.341386+00:00&quot;,
  &quot;images&quot;: [
    {
      &quot;url&quot;: &quot;https://ejxzslfkaxsbcabuiqzs.supabase.co/storage/v1/object/public/gallery/IMG_2576.JPG&quot;
    },
    {
      &quot;url&quot;: &quot;https://ejxzslfkaxsbcabuiqzs.supabase.co/storage/v1/object/public/gallery/IMG_5620.jpg&quot;
    }
  ],
  &quot;user_info&quot;: {
    &quot;email&quot;: &quot;konnimey@naver.com&quot;,
    &quot;user_metadata&quot;: {
      &quot;avatar_url&quot;: &quot;http://k.kakaocdn.net/dn/bvW2Ym/btsQcnFilfU/tDTGJkVGS1ps69BHoAHwr1/img_640x640.jpg&quot;,
      &quot;email&quot;: &quot;konnimey@naver.com&quot;,
      &quot;email_verified&quot;: true,
      &quot;full_name&quot;: &quot;혜진&quot;,
      &quot;iss&quot;: &quot;https://kapi.kakao.com&quot;,
      &quot;name&quot;: &quot;혜진&quot;,
      &quot;phone_verified&quot;: false,
      &quot;preferred_username&quot;: &quot;혜진&quot;,
      &quot;provider_id&quot;: &quot;4363702432&quot;,
      &quot;sub&quot;: &quot;4363702432&quot;,
      &quot;user_name&quot;: &quot;혜진&quot;
    }
  }
}
</code></pre>
<p>사용자의 프로필 사진과 이름이 잘 뜨는 것도 확인할 수 있었다</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/3c43d91a-55ef-488e-a890-e51401a82ad0/image.png" alt=""></p>
<h1 id="map-vs-find-둘의-차이를-더-쉽게-알아보자"><strong>Map vs find 둘의 차이를 더 쉽게 알아보자</strong></h1>
<p><code>new Map</code> 과 <code>find</code> 는 둘 다 &quot;특정 데이터를 찾아내는 역할&quot;을 할 수 있는데, 내부 동작 방식이 달라서 <strong>성능 차이</strong>가 있다.</p>
<h2 id="1-find">1. <code>find</code></h2>
<p>배열에서 조건에 맞는 첫 번째 요소를 찾는 메서드</p>
<pre><code class="language-tsx">const users = [
  { id: &#39;1&#39;, name: &#39;철수&#39; },
  { id: &#39;2&#39;, name: &#39;영희&#39; },
  { id: &#39;3&#39;, name: &#39;민수&#39; },
];

// id가 &#39;2&#39;인 사용자 찾기
const user = users.find((u) =&gt; u.id === &#39;2&#39;);
console.log(user); // { id: &#39;2&#39;, name: &#39;영희&#39; }</code></pre>
<p>이는 조건에 맞는 요소를 찾을 때까지 배열을 앞에서부터 끝까지 검사하며, 최악의 경우 배열 전체를 다 돌아야 한다. 지금같은 사이드 프로젝트에서처럼 사용자 데이터가 적을 때는 괜찮지만, 이후 실제 운영업무를 수행할 때 데이터가 수천~수만 건 이상이되면 성능이 저하될 우려가 있다.</p>
<h2 id="2-map">2. <code>Map</code></h2>
<p>키-값 쌍을 저장하는 자료구조이며, 키로 바로 접근이 가능하다.</p>
<pre><code class="language-tsx">const userMap = new Map([
  [&#39;1&#39;, { id: &#39;1&#39;, name: &#39;철수&#39; }],
  [&#39;2&#39;, { id: &#39;2&#39;, name: &#39;영희&#39; }],
  [&#39;3&#39;, { id: &#39;3&#39;, name: &#39;민수&#39; }],
]);

const user = userMap.get(&#39;2&#39;);
console.log(user); // { id: &#39;2&#39;, name: &#39;영희&#39; }
</code></pre>
<p><code>get()</code> 을 사용하면 바로 해당하는 값을 찾으며, 데이터가 많아져도 성능이 거의 일정하다. 다만 <code>find</code>처럼 <strong>조건부 탐색</strong>(ex. &quot;name이 영희인 사용자&quot;)에는 적합하지 않다는 단점이 있다.</p>
<p>두 개념 모두 장단점이 있지만, <code>find</code> 의 경우엔 매번 전체 배열을 돌면서 찾아야 하기 때문에 성능이 나빠지므로 조건이 붙지 않는 탐색의 경우엔 <code>Map</code> 객체를 적극 사용하는 것이 좋을 것 같다..!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js, Supabase] 파일 업로드 기능 오류]]></title>
            <link>https://velog.io/@h_jinny/Next.js-Supabase-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@h_jinny/Next.js-Supabase-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Tue, 12 Aug 2025 04:15:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Next.js 환경에서 Supabase 의 스토리지를 연결하여 파일업로드를 하는 작업을 진행하고 있었다. 하지만 계속 400 BadRequest 가 떠서 Supabase 공식문서 및 여러 곳에서 검색을 해보았지만 명확한 원인을 찾지 못했다…🙁</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/668a92b3-f233-4db5-8944-e6735beeda6a/image.png" alt=""></p>
<p>파일을 선택한 후 “파일업로드” 버튼을 누르면 Supabase 의 스토리지에 저장되도록 로직을 짜놓은 상태</p>
<p>storageActions.ts</p>
<pre><code class="language-tsx">&#39;use server&#39;;

import { createServerSupabaseClient } from &#39;utils/supabase/server&#39;;

function handleError(error: Error) {
  if (error) {
    console.error(error);
    throw error;
  }
}

export async function uploadFile(formData: FormData) {
  const supabase = await createServerSupabaseClient();
  const file = formData.get(&#39;file&#39;) as File;

  const { data, error } = await supabase.storage
    .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET)
    .upload(file.name, file, { upsert: true });

  handleError(error);
  return data;
}</code></pre>
<p>AddFileZone.tsx</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import React, { useRef } from &#39;react&#39;;
import * as S from &#39;./styled&#39;;
import Button from &#39;@/app/_modules/common/components/button/button/Button&#39;;
import { uploadFile } from &#39;actions/storageActions&#39;;

const AddFileZone = () =&gt; {
  const fileRef = useRef&lt;HTMLInputElement&gt;(null);

  return (
    &lt;S.AddFileZone
      onSubmit={async (e) =&gt; {
        e.preventDefault();
        const file = fileRef.current?.files?.[0];
        if (file) {
          const formData = new FormData();
          formData.append(&#39;file&#39;, file);
                    await uploadFile(formData);
        }
      }}
    &gt;
      &lt;input type=&#39;file&#39; ref={fileRef} /&gt;
      &lt;Button
        type=&#39;submit&#39;
        text=&#39;파일 업로드&#39;
        iconName=&#39;plus&#39;
        filled
      /&gt;
    &lt;/S.AddFileZone&gt;
  );
};

export default AddFileZone;
</code></pre>
<p>서버액션 파일에서 uploadFile 함수를 가져와 선택된 file 을 인수로 넣어 작동하게끔 해놓았다.</p>
<p>하지만 계속 에러가 뜨는 상황이었다.</p>
<h2 id="수정">수정</h2>
<p>결국 AI 를 통해그 원인을 찾아보니, Next.js 에서는 서버액션을 할 때 FormData 형식의 데이터를 받는데 제한이 있다고 한다. 그래서 아래와 같이 수정을 했다.</p>
<p>AddFileZone.tsx</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import React, { useRef } from &#39;react&#39;;
import * as S from &#39;./styled&#39;;
import Button from &#39;@/app/_modules/common/components/button/button/Button&#39;;

const AddFileZone = () =&gt; {
  const fileRef = useRef&lt;HTMLInputElement&gt;(null);

  // API Route로 파일 업로드
  const handleUpload = async (formData: FormData) =&gt; {
    const res = await fetch(&#39;/api/upload&#39;, {
      method: &#39;POST&#39;,
      body: formData,
    });
    const result = await res.json();
    if (!res.ok) throw new Error(result.error || &#39;Upload failed&#39;);
    return result.data;
  };

  return (
    &lt;S.AddFileZone
      onSubmit={async (e) =&gt; {
        e.preventDefault();
        const file = fileRef.current?.files?.[0];
        if (file) {
          const formData = new FormData();
          formData.append(&#39;file&#39;, file);
          try {
            const result = await handleUpload(formData);
            console.log(result);
          } catch (err) {
            alert((err as Error).message);
          }
        }
      }}
    &gt;
      &lt;input type=&#39;file&#39; ref={fileRef} /&gt;
      &lt;Button type=&#39;submit&#39; text=&#39;파일 업로드&#39; iconName=&#39;plus&#39; filled /&gt;
    &lt;/S.AddFileZone&gt;
  );
};

export default AddFileZone;
</code></pre>
<p>서버액션인 uploadFile 을 무시하고, 따로 API Router 를 통해 클라이언트 요청을 진행했다.</p>
<p>route.ts</p>
<pre><code class="language-tsx">import { NextRequest, NextResponse } from &#39;next/server&#39;;
import { createServerSupabaseClient } from &#39;utils/supabase/server&#39;;

export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const file = formData.get(&#39;file&#39;) as File | null;
    if (!file) {
      return NextResponse.json({ error: &#39;No file provided&#39; }, { status: 400 });
    }

    const supabase = await createServerSupabaseClient();
    const { data, error } = await supabase.storage
      .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!)
      .upload(file.name, file, { upsert: true }); // safeName을 key로 사용
    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ data });
  } catch (err: any) {
    return NextResponse.json({ error: err.message || &#39;Unknown error&#39; }, { status: 500 });
  }
}
</code></pre>
<p>이렇게 했더니 잘 동작하는 모습을 볼 수 있었다..! </p>
<p>하지만 파일 형식의 데이터가 왜 서버액션에서 지원이 안되는걸까 ㅠㅠ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] ColorThief - 색 추출 기능]]></title>
            <link>https://velog.io/@h_jinny/Javascript-ColorThief-%EC%83%89-%EC%B6%94%EC%B6%9C-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@h_jinny/Javascript-ColorThief-%EC%83%89-%EC%B6%94%EC%B6%9C-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Tue, 12 Aug 2025 04:11:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>슬라이드 배너를 만들 때, 각 요소 썸네일 이미지의 색을 추출해 배경색을 깔아달라는 디자인의 요청이 있었다. 그래서 발견한 것이 ColorThief 라는 라이브러리..!
ColorThief 는 Javascript만 사용하여 이미지에서 색상 팔레트를 가져오는 라이브러리라고 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/37d06691-92ed-44cd-b1f3-18c2451d7f15/image.png" alt=""></p>
<p>ColorThief 를 설치 후 import 해주자</p>
<pre><code class="language-tsx">npm i --save colorthief</code></pre>
<pre><code class="language-tsx">import ColorThief from &#39;colorthief&#39;;</code></pre>
<pre><code class="language-tsx">const imageRef = useRef&lt;HTMLImageElement&gt;(null);
const colorThief = useRef&lt;ColorThief | null&gt;(null);
const [colorList, setColorList] = useState&lt;string[]&gt;([]);
const [error, setError] = useState&lt;string | null&gt;(null);

useEffect(() =&gt; {
  colorThief.current = new ColorThief();
}, []);</code></pre>
<p><code>ColorThief</code> 인스턴스를 생성한 후 <code>ref</code>에 저장해주자</p>
<p><code>imageRef</code> 역시 img 요소를 반복문으로 저장해주고 있다. </p>
<pre><code class="language-tsx">const { slides, options, isAutoPlay = true, onClick } = props;
// slides: 슬라이드 요소(ReactNode)가 담긴 배열

&lt;S.EmblaContainer className=&#39;embla__container&#39;&gt;
    {slides?.map((slide, index) =&gt; (
      &lt;S.EmblaSlide
        className=&#39;embla__slide&#39;
        $isOnClick={!!onClick}
        key={index}
        onClick={() =&gt; onClick?.(slide)}
      &gt;
        &lt;S.EmblaSlideContent&gt;
          &lt;S.EmblaSlideImage&gt;
            &lt;S.EmblaSlideImageBadge&gt;
              {slide.isNew &amp;&amp; (
                &lt;CommonBadge
                  label=&#39;NEW&#39;
                  color=&#39;primary.blue&#39;
                  bgColor=&#39;secondary.skyblue&#39;
                  borderNone
                /&gt;
              )}
            &lt;/S.EmblaSlideImageBadge&gt;
            {error &amp;&amp; &lt;div&gt;오류 발생: {error}&lt;/div&gt;}
            &lt;img
              ref={imageRef}
              src={slide?.attach?.realPath}
              alt={slide?.title}
              onLoad={handleImageLoad(index)}
              crossOrigin=&#39;anonymous&#39;
              style={{ display: &#39;block&#39; }}
              className={`embla-slide-image-${index}`}
            /&gt;

            ...</code></pre>
<p>img 가 로드될 때를 기점으로 이미지를 참조하여 <code>colorThief</code> 내장함수인 <code>getColor()</code> 의 인수로 넣어준다.</p>
<p>이때, <code>getColor()</code> 함수가 내부적으로 img 의 데이터를 가져오려 하면서 아래와 같은 예외 상황이 발생할 수 있다.</p>
<ul>
<li>이미지가 <strong>cross-origin</strong> 상태일 때 (CORS 문제),</li>
<li><code>img</code>가 아직 완전히 로드되지 않았을 때</li>
</ul>
<p>이런 상황을 대비해 try/catch 문을 사용해주자.</p>
<p>그렇게 하면  rgb 세가지 숫자를 반환해주는데(ex. 23, 31, 54), 이때 이 세가지 값들이 들어간 dominantColor 를 colorList 에 rgb 문자 형식으로 넣어주면 된다.!</p>
<pre><code class="language-tsx">const handleImageLoad = (index: number) =&gt; (event: React.SyntheticEvent&lt;HTMLImageElement&gt;) =&gt; {
  const img = event.currentTarget;

  if (colorThief.current) {
    try {
      const dominantColor = colorThief.current.getColor(img);
      const rgbString = `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`;
      setColorList((prev) =&gt; {
        const newList = [...prev];
        newList[index] = rgbString;
        return newList;
      });
      setError(null);
    } catch (err) {
      setError(&#39;색상 추출 중 오류가 발생했습니다.&#39;);
      console.error(err);
    }
  }
};</code></pre>
<p>하지만 새로고침을 했을 경우 색상이 추출되지 않는 상황이 발생했다. </p>
<p>이는 useEffect 로 따로 설정을 해주었다.</p>
<p>위에서 언급했듯이, 이미지가 완전히 로드되었을 때를 기점으로 불러주는것이 바람직하기 떄문에 img.complete 등의 조건부를 넣어주자.</p>
<pre><code class="language-tsx">// 새로고침 or 슬라이드가 변경되거나 컴포넌트가 마운트될 때 색상 추출 시도
  useEffect(() =&gt; {
    if (slides &amp;&amp; colorThief.current &amp;&amp; Array.isArray(slides)) {
      // 약간의 지연 후 색상 추출 시도 (이미지가 로드되었을 가능성)
      const timer = setTimeout(() =&gt; {
        slides.forEach((_, index) =&gt; {
          const img = document.querySelector(`.embla-slide-image-${index}`) as HTMLImageElement;
          if (img &amp;&amp; img.complete &amp;&amp; img.naturalWidth &gt; 0) {
            try {
              const dominantColor = colorThief.current!.getColor(img);
              const rgbString = `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`;
              setColorList((prev) =&gt; {
                const newList = [...prev];
                newList[index] = rgbString;
                return newList;
              });
            } catch (err) {
              console.error(`색상 추출 실패 (인덱스 ${index}):`, err);
            }
          }
        });
      }, 100);

      return () =&gt; clearTimeout(timer);
    }
  }, [slides]);</code></pre>
<p>마지막 코드를 추가해주니 새로고침을 했을 때도 정상적으로 색상이 잘 추출되는 것을 확인할 수 있었다..! ☺️</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Redux] useSelector 최적화 – 불필요한 리렌더링 방지 (배열 반환)]]></title>
            <link>https://velog.io/@h_jinny/Redux-useSelector-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%B0%A9%EC%A7%80-%EB%B0%B0%EC%97%B4-%EB%B0%98%ED%99%98-rwkswipn</link>
            <guid>https://velog.io/@h_jinny/Redux-useSelector-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B6%88%ED%95%84%EC%9A%94%ED%95%9C-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%B0%A9%EC%A7%80-%EB%B0%B0%EC%97%B4-%EB%B0%98%ED%99%98-rwkswipn</guid>
            <pubDate>Fri, 04 Jul 2025 02:23:38 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>검색 AI 기능쪽에서 현재 AI 답변 결과는 컨텐츠에 따라 문단으로 분류되어 데이터가 내려오는데, 그 컨텐츠의 title 값을 한글 제목으로 치환해야 했다. 그래서 redux 의 state 값을 활용하던 중 아래와 같은 useSelector 를 사용한 쪽에서 에러가 발생했고, 내가 사용한 곳 외에도 해당 state 값을 불러온 모든 부분에서 같은 에러가 발생하고 있었다 😱</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/b14cc9d8-1ee7-4473-8423-41c3e150b6d2/image.png" alt=""></p>
<p><em>“Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized:”</em>
<br></p>
<p>리덕스에서 common 의 enum 값은 다음과 같은데,  내가 사용하고자 한 것은 ‘ai-search-category-type’ 라는 데이터였다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/91d41549-1dbe-4e10-8a6d-526f61a44a4c/image.png" alt=""></p>
<p>commonSlice.ts</p>
<pre><code class="language-tsx">import { createSlice } from &#39;@reduxjs/toolkit&#39;;
import { createSelector } from &#39;@reduxjs/toolkit&#39;;

import { AppState } from &#39;../types&#39;;

type Enum = {
  key: string;
  value: any;
};

const initialState = {
  enum: {},
};

const commonSlice = createSlice({
  name: &#39;common&#39;,
  initialState,
  reducers: {
    setEnum: (state, actions) =&gt; {
      state.enum = actions.payload;
    },
  },
});

export const { setEnum } = commonSlice.actions;

// Selectors
export const selectEnums =
  (selectors: string[] = []) =&gt;
  ({ common }: AppState) =&gt; {
    const result: Enum[][] = [];
    selectors.forEach((selector) =&gt; result.push(common.enum[selector]));
    return result;
  };

export default commonSlice.reducer;
</code></pre>
<p>selectEnums 활용:</p>
<p>가져오고자 하는 enum 데이터의 key 값이 담긴 배열을 인수로 넣어 사용</p>
<pre><code class="language-tsx">import { useSelector } from &#39;react-redux&#39;;
import { selectEnums } from &#39;@common/persistence/store/slices/commonSlice&#39;;

const [aiSearchCategoryTypeList] = useSelector(selectEnums([&#39;ai-search-category-type&#39;]));</code></pre>
<br>

<h1 id="문제점">문제점</h1>
<p>기존 코드에서 selectEnums 는 반환되는 값의 형태가 배열이다. 이때 <code>result</code>는 매번 새로운 배열([])로 생성되는데 이는 결국 <code>useSelector</code> 가 호출될 때마다 새로운 배열이 참조되었던 것이었다. </p>
<p>그렇다.. 이 부분이 문제의 원인이었다. </p>
<p><code>useSelector</code>는 <code>useState</code>와 같은 개념으로, 같은 데이터를 가지고 있더라도 배열이 새로 생성되면 다른 값으로 인식해서 불필요한 리렌더링을 유발한다고 한다.</p>
<h1 id="해결">해결</h1>
<h3 id="createselector-로-메모이제이션-적용"><code>createSelector</code> 로 메모이제이션 적용</h3>
<p>현재 <code>selectEnums</code>는 Reselect의 <code>createSelector</code>를 사용하지 않아서, 캐싱 없이 무조건 새로운 배열을 반환하고 있었다. 여기서 Reselect 는 기본적으로 메모이제이션을 제공하는 라이브러리이다. </p>
<p><code>createSelector</code> 는 새로운 값을 반환할 때 다시 리렌더링 되더라도 이전의 로직을 수행하는 대신 캐싱해두었던 값을 반환해준다.</p>
<p>즉, <strong>같은 입력이 들어왔을 때 기존 값을 재사용</strong>할 수 있다는 것이다.</p>
<pre><code class="language-tsx">import { createSelector } from &#39;@reduxjs/toolkit&#39;;

...

export const selectEnums = (selectors: string[]) =&gt;
  createSelector(
    (state: AppState) =&gt; state.common.enum,
    (enums) =&gt; selectors.map((selector) =&gt; enums[selector] || []),
  );
</code></pre>
<p><code>createSelector</code> 로 코드를 수정한 후 다시 실행해보니 말끔히 사라진 에러를 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/3fa321a7-49b9-464f-a4b4-f2da7bf89f1a/image.png" alt=""></p>
<p>참고:</p>
<p><a href="https://velog.io/@hahagarden/useSelector-%EC%B5%9C%EC%A0%81%ED%99%94%EB%A1%9C-%EB%A6%AC%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%A4%84%EC%9D%B4%EA%B8%B0">https://velog.io/@hahagarden/useSelector-최적화로-리렌더링-줄이기</a>
<a href="https://velog.io/@2ast/React-redux-toolkit%EC%9D%98-createSelector%EB%A1%9C-state-reselect%ED%95%98%EA%B8%B0">https://velog.io/@2ast/React-redux-toolkit의-createSelector로-state-reselect하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 검색 요청 최적화: 이전 요청 취소하고 최신 데이터 유지하기 (feat. AbortController)]]></title>
            <link>https://velog.io/@h_jinny/Next.js-%EA%B2%80%EC%83%89-%EC%9A%94%EC%B2%AD-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%9D%B4%EC%A0%84-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B3%A0-%EC%B5%9C%EC%8B%A0-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9C%A0%EC%A7%80%ED%95%98%EA%B8%B0-feat.-AbortController</link>
            <guid>https://velog.io/@h_jinny/Next.js-%EA%B2%80%EC%83%89-%EC%9A%94%EC%B2%AD-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%9D%B4%EC%A0%84-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B3%A0-%EC%B5%9C%EC%8B%A0-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%9C%A0%EC%A7%80%ED%95%98%EA%B8%B0-feat.-AbortController</guid>
            <pubDate>Fri, 04 Jul 2025 02:16:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>검색 페이지에서 탭을 누를때마다 그에 해당하는 뉴스 목록이 나오도록 구현중이었다. 근데 여기서 하나의 문제가 발생했는데…
“정책뉴스” 라는 탭의 목록 api 요청 시간이 다른 탭보다 오래걸려 요청중 다른 탭을 누르면, 다른 탭의 목록 데이터가 뿌려진 그 이후에 한 번 더 이전에 요청된 목록 데이터값이 덮어씌어지는 이슈가 있었다.</p>
</blockquote>
<pre><code class="language-tsx">
...
const useSearchList = () =&gt; {
  const router = useRouter();

  const [searchType, setSearchType] = useState&lt;string&gt;();
  const [searchList, setSearchList] = useState&lt;any[]&gt;([]);
  const [isFetching, setIsFetching] = useState(false);

  const currentAbortController = useRef&lt;AbortController | null&gt;(null);

  ...
  const getSearchList = useCallback(
    async (newSearchType: string) =&gt; {
      setIsFetching(true);
      const { word } = router.query;

      try {
        const params: SearchListParameter = new SearchListParameter({
          size: LIST_SIZE,
          word: word as string,
        });
        const {
          data: { content, last },
        } = await axios.get&lt;SearchListResponse&gt;(
          `/api/search/${newSearchType}${params.stringify()}`);
        setSearchType(newSearchType);

        // 요청값을 content 목록에 담음
        setSearchList(content);
      } catch (error: any) {
        console.error(error.response || error);
      } finally {
        setIsFetching(false);
      }
    },
    [router],
  );

...</code></pre>
<p>setSearchList 에 데이터의 content 배열이 들어가는 구조다. 현재 이전의 요청이 한 번 더 setSearchList 에 들어가고 있다.</p>
<h1 id="문제점">문제점</h1>
<p>정책뉴스 탭 클릭</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/5c6c0ca7-cc61-4676-885a-68dbfb047e12/image.png" alt=""></p>
<p>정책뉴스 목록 api 가 요청중인 pending 상태일 때, 중간에 “소플 Today” 라는 탭을 누르면 소플 Today의 목록 api 가 요청되는데, 이전에 보냈던 정책뉴스 목록 api 가 그대로 남아 요청중임을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/c2e0622a-08a2-43ad-9111-08d294f437fc/image.png" alt=""></p>
<p>몇 초 뒤 정책뉴스 목록 요청값이 뒤 늦게 들어오면서, 원래 “소플 Today” 데이터 값이 “정책뉴스” 데이터로 바뀌어버린 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/4fd73549-6426-4034-9a47-f45f09ad5da7/image.png" alt=""></p>
<p>그래서 이전에 요청된 api 를 중간에 취소할 수 있을까 하며 구글을 찾아봤는데 그 해결책은 바로 <code>AbortController</code> 였다.</p>
<h2 id="abortcontroller"><code>AbortController</code></h2>
<p><code>AbortController</code> 는 자바스크립트에서 <strong>비동기 작업을 중단</strong> 할 수 있도록 도와주는 API 로, 특히 <strong>fetch 요청을 취소</strong>하거나 <strong>setTimeout 등의 작업을 중단</strong>할 때 유용하게 쓰인다고 한다. → <a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">공식문서</a></p>
<h3 id="axios-요청-취소">Axios 요청 취소</h3>
<p><code>AbortController</code> 의 기본적인 형태로 아래와 같이 사용할 수 있다.</p>
<pre><code class="language-tsx">const controller = new AbortController(); // 새로운 요청 취소 컨트롤러 생성

axios.get(&quot;https://api.example.com/data&quot;, { signal: controller.signal })
  .then((response) =&gt; console.log(response.data))
  .catch((error) =&gt; {
    if (error.name === &quot;AbortError&quot;) {
      console.log(&quot;요청이 취소됨&quot;);
    } else {
      console.error(error);
    }
  });

// 요청 취소
controller.abort();</code></pre>
<ul>
<li>새로운 AbortController 인스턴스를 생성</li>
<li>get 요청의 두번째 인수로 <code>{ signal: controller.signal }</code> 를 전달 (해당 요청을 <code>AbortController</code>로 제어하기 위해서)</li>
<li><code>controller.abort()</code> 로 연결된 요청을 취소</li>
</ul>
<br>

<h1 id="해결">해결</h1>
<p>수정한 코드는 아래와 같다.</p>
<pre><code class="language-tsx">     const currentAbortController = useRef&lt;AbortController | null&gt;(null);

  ...
  const getSearchList = useCallback(
    async (newSearchType: string) =&gt; {
      setIsFetching(true);
      const { word } = router.query;

      // ✅ 이전 요청 취소
      currentAbortController.current?.abort();

      // ✅ 새로운 AbortController 생성
      const controller = new AbortController();
      currentAbortController.current = controller;

      try {
        const params: SearchListParameter = new SearchListParameter({
          size: LIST_SIZE,
          word: word as string,
        });
        const {
          data: { content, last },
        } = await axios.get&lt;SearchListResponse&gt;(
          `/api/search/${newSearchType}${params.stringify()}`,
          { signal: controller.signal }, // ✅ 요청 취소 가능하도록 signal 전달
        );
        setSearchType(newSearchType);

        // ✅ 요청이 취소되었으면 상태 변경 x
        if (controller.signal.aborted) return;
        setSearchList(content);
      } catch (error: any) {
        console.error(error.response || error);
      } finally {
        setIsFetching(false);
      }
    },
    [router],
  );

...</code></pre>
<ul>
<li><code>useRef</code>를 사용해서  컴포넌트가 리렌더링돼도 기존 <code>AbortController</code>를 유지</li>
<li>api 요청이 가기전에 이전 요청을 취소한 후, 다시 정상적으로 요청이 될 수 있도록 <code>AbortController</code> 를 새롭게 생성</li>
<li>요청이 취소된 상태일 땐 setSearchList 에 데이터가 담기지 않도록 함</li>
</ul>
<br>

<h1 id="결과">결과</h1>
<p>이전의 요청이 pending 중인 상태에서 다른 탭을 눌러도, 그 이전의 요청이 취소되어 정상적으로 리스트가 로드된 것을 확인할 수 있었다!</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/41877dc8-4f23-423f-bd8e-c03f68512269/image.png" alt=""></p>
<p>참고: 
<a href="https://gobae.tistory.com/145">https://gobae.tistory.com/145</a>
<a href="https://velog.io/@jhjung3/AbortController%EB%A1%9C-fetch-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B8%B0">https://velog.io/@jhjung3/AbortController로-fetch-요청-취소하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] Selectbox 커스텀 (feat. 스타일 & 접근성)]]></title>
            <link>https://velog.io/@h_jinny/React-Selectbox-%EC%BB%A4%EC%8A%A4%ED%85%80-feat.-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%A0%91%EA%B7%BC%EC%84%B1</link>
            <guid>https://velog.io/@h_jinny/React-Selectbox-%EC%BB%A4%EC%8A%A4%ED%85%80-feat.-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%A0%91%EA%B7%BC%EC%84%B1</guid>
            <pubDate>Fri, 18 Apr 2025 01:19:52 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>selectbox 에 스타일을 주고싶은데 Selectbox 의 경우 selectbox 태그를 사용하면 스타일을 커스텀 하는데 한계가 있기 때문에 ul, li 태그를 사용하여 option 을 구현하는등 따로 selectbox 전체를 직접 커스텀해서 구현해주어야 한다.</p>
</blockquote>
<pre><code class="language-jsx">import React, { forwardRef, useEffect, useRef, useState } from &#39;react&#39;;
import * as S from &#39;./SelectFilter.styles&#39;;

export interface OptionType {
  value: any;
  label: string;
}

export interface OptionTypeArray extends Array&lt;OptionType&gt; {}

interface SelectProps {
  ...
}

const SelectFilter = forwardRef&lt;HTMLDivElement, SelectProps&gt;(
  (
    {
      options,
      value,
      label,
      defaultValue,
      onChange,
      size = &#39;default&#39;,
      width,
      placeholder,
      isFullWidth = false,
      isDisabled = false,
      error,
      name
    },
    ref
  ) =&gt; {
    const [isOpen, setIsOpen] = useState&lt;boolean&gt;(false);
    const [selectedOption, setSelectedOption] = useState&lt;
      OptionType | undefined
    &gt;(() =&gt; {
      if (value) {
        return options.find((opt) =&gt; opt.value === value);
      }
      return defaultValue;
    });
    const selectListRef = useRef&lt;HTMLDivElement&gt;(null);

    useEffect(() =&gt; {
      if (value) {
        const option = options.find((opt) =&gt; opt.value === value);
        setSelectedOption(option);
      }
    }, [value, options]);

    const toggleDropdown = (
      event: React.MouseEvent | React.KeyboardEvent
    ): void =&gt; {
      event.preventDefault();
      event.stopPropagation();
      // Enter 키로 실행되지 않도록 처리
      if ((event as React.KeyboardEvent).key === &#39;Enter&#39;) {
        return;
      }
      if (isDisabled) {
        setIsOpen(false);
        return;
      }

      setIsOpen(!isOpen);
    };

    const handleOptionClick = (
      option: OptionType,
      event: React.MouseEvent
    ): void =&gt; {
      event.preventDefault();
      event.stopPropagation();
      setSelectedOption(option);
      setIsOpen(false);
      if (onChange) {
        onChange(option.value);
      }
    };

    const getIsSelected = (option: OptionType): boolean =&gt; {
      return selectedOption?.value === option.value;
    };

    useEffect(() =&gt; {
      const handleOutsideClick = (event: MouseEvent) =&gt; {
        if (
          selectListRef.current &amp;&amp;
          !selectListRef.current.contains(event.target as Node)
        ) {
          setIsOpen(false);
        }
      };

      if (isOpen) {
        document.addEventListener(&#39;mousedown&#39;, handleOutsideClick);
      }
      return () =&gt; {
        document.removeEventListener(&#39;mousedown&#39;, handleOutsideClick);
      };
    }, [isOpen]);

    const handleButtonKeyDown = (event: React.KeyboardEvent): void =&gt; {
      if (!selectedOption) return;

      const currentIndex = options.findIndex(
        (opt) =&gt; opt.value === selectedOption.value
      );

      if (event.key === &#39;ArrowDown&#39;) {
        // 아래 방향키: 다음 옵션으로 이동 (순환)
        const nextIndex = (currentIndex + 1) % options.length;
        changedValue = options[nextIndex];
        setSelectedOption(options[nextIndex]);
        event.preventDefault();
      } else if (event.key === &#39;ArrowUp&#39;) {
        // 위 방향키: 이전 옵션으로 이동 (순환)
        const prevIndex = (currentIndex - 1 + options.length) % options.length;
        changedValue = options[prevIndex];
        setSelectedOption(options[prevIndex]);
        event.preventDefault();
      }
      if (event.key === &#39;Enter&#39;) {
        if (onChange) onChange(changedValue.value);
      }
    };
    return (
      &lt;S.SelectContainer
        $size={size}
        $isFullWidth={isFullWidth}
        ref={selectListRef}
      &gt;
        {label &amp;&amp; &lt;S.SelectLabel&gt;{label}&lt;/S.SelectLabel&gt;}
        &lt;S.SelectButton
          $size={size}
          $isOpen={isOpen}
          $isDisabled={isDisabled}
          $width={width}
          onClick={toggleDropdown}
          onKeyDown={handleButtonKeyDown}
          $error={!!error}
          name={name}
        &gt;
          &lt;p&gt;{selectedOption?.label || placeholder}&lt;/p&gt;
          &lt;span /&gt;
          {isOpen &amp;&amp; (
            &lt;S.OptionsList&gt;
              {options.map((option) =&gt; (
                &lt;S.Option
                  key={option?.value}
                  onClick={(e) =&gt; handleOptionClick(option, e)}
                  $isSelected={getIsSelected(option)}
                  value={option?.value}
                  role=&quot;option&quot;
                &gt;
                  {option?.label}
                &lt;/S.Option&gt;
              ))}
            &lt;/S.OptionsList&gt;
          )}
        &lt;/S.SelectButton&gt;
      &lt;/S.SelectContainer&gt;
    );
  }
);

SelectFilter.displayName = &#39;SelectFilter&#39;;

export default SelectFilter;
</code></pre>
<br>

<h1 id="키보드를-통한-value-값-변경">키보드를 통한 value 값 변경</h1>
<p>이때 접근성을 위해 마우스 클릭 뿐만 아닌 키보드를 사용하여 selectbox 의 value 를 바꿔주는 경우도 고려해야하는데, 아래와 같이 구현이 되었다.</p>
<pre><code class="language-jsx">...
const SelectFilter = forwardRef&lt;HTMLDivElement, SelectProps&gt;(
  (
    ...
  ) =&gt; {
    ...
        // 키보드를 통한 option 의 value 변경
    const handleButtonKeyDown = (event: React.KeyboardEvent): void =&gt; {
      if (!selectedOption) return;
      const currentIndex = options.findIndex(
        (opt) =&gt; opt.value === selectedOption.value
      );

      if (event.key === &#39;ArrowDown&#39;) {
        // 아래 방향키: 다음 옵션으로 이동 (순환)
        const nextIndex = (currentIndex + 1) % options.length;
        changedValue = options[nextIndex];
        setSelectedOption(options[nextIndex]);
        event.preventDefault();
      } else if (event.key === &#39;ArrowUp&#39;) {
        // 위 방향키: 이전 옵션으로 이동 (순환)
        const prevIndex = (currentIndex - 1 + options.length) % options.length;
        changedValue = options[prevIndex];
        setSelectedOption(options[prevIndex]);
        event.preventDefault();
      }
      if (event.key === &#39;Enter&#39;) {
        if (onChange) onChange(changedValue.value);
      }
    };

    return (
      &lt;S.SelectContainer
        $size={size}
        $isFullWidth={isFullWidth}
        ref={ref}
      &gt;
        {label &amp;&amp; &lt;S.SelectLabel&gt;{label}&lt;/S.SelectLabel&gt;}
        &lt;S.SelectButton
          ...
          onKeyDown={handleButtonKeyDown} // 키보드 이벤트 감지
          ...
        &gt;
         ...
        &lt;/S.SelectButton&gt;
      &lt;/S.SelectContainer&gt;
    );
  }
);
...
</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/d52beec0-15c8-4de9-b751-f2a7dd14f889/image.png" alt=""></p>
<p>selectbox 의 버튼이 포커싱 되었을 때 onKeyDown 이벤트를 걸어 키보드 사용이 감지되었을 때 <code>handleButtonKeyDown</code> 콜백함수가 실행되도록 했다.</p>
<p>윗방향 키는 <code>event.key === &#39;ArrowUp</code> , 아랫방향 키는 <code>event.key === &#39;ArrowDown&#39;</code> 로 설정이 되고 그에 따라 현재 value 값을 기준으로 순환되어 변경되도록 작업을 했다.</p>
<p>% 와 같은 나머지 연산자를 사용하여 맨 마지막 혹은 맨 처음 index 에 다다랐을 때 다음에 선택되는 index 가 맨 처음 혹은 맨 마지막 index 가 되는, 이른바 순환되는 구조를 구현할 수 있었다.
<br></p>
<h1 id="selectbox-외-다른-부분-클릭-시-드롭다운-off">SelectBox 외 다른 부분 클릭 시 드롭다운 off</h1>
<pre><code class="language-jsx">
const selectListRef = useRef&lt;HTMLDivElement&gt;(null);
...
useEffect(() =&gt; {
  const handleOutsideClick = (event: MouseEvent) =&gt; {
    if (
      selectListRef.current &amp;&amp;
      !selectListRef.current.contains(event.target as Node)
    ) {
      setIsOpen(false);
    }
  };

  if (isOpen) {
    document.addEventListener(&#39;mousedown&#39;, handleOutsideClick);
  }
  return () =&gt; {
    document.removeEventListener(&#39;mousedown&#39;, handleOutsideClick);
  };
}, [isOpen]);

...
return (
  &lt;S.SelectContainer
    ...
    ref={selectListRef}
    ...
  &gt;</code></pre>
<p><code>useRef</code> 를 사용하여 다른 부분을 클릭할 시 드롭다운이 닫히는 구조도 추가해주었다.</p>
<p>이전에는 selectbox 의 겉 부분만 스타일을 주거나,  mui 같은 이미 커스텀화 되어있는 selectbox 를 사용해 왔기에 이런 작업의 경험이 없었다. 드롭다운 부분까지의 커스텀을 위해 아예 처음부터의 기능까지 새로 구현된 selectbox 를 다루게 된 점은 좋은 경험이었다고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Redirect 기능 - resolvedUrl]]></title>
            <link>https://velog.io/@h_jinny/Next.js-Redirect-%EA%B8%B0%EB%8A%A5-resolvedUrl</link>
            <guid>https://velog.io/@h_jinny/Next.js-Redirect-%EA%B8%B0%EB%8A%A5-resolvedUrl</guid>
            <pubDate>Tue, 04 Mar 2025 04:19:24 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>소플이라는 서비스에서 패널회원에 가입하는 루트를 작업하고 있었다. 
패널회원에 가입하기 위해서는 로그인, 본인인증 두 가지 루트를 거쳐야 한다. 로그인이 안된 경우 로그인 페이지로 잘 리다이렉트 되는데, 본인인증의 경우 인증 후 패널 가입 페이지가 아닌 메인페이지(’/’)로 리다이렉트 되고 있었다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/30f6ec1e-ed68-4dd6-9b84-b77708402923/image.png" alt=""></p>
<p>처음 panel/intro 로 링크를 받으면 아래와 같은 분기가 태워진다.</p>
<ol>
<li>로그인이 안되었을 경우 → 진입도 전에 바로 로그인 페이지로 리다이렉트</li>
<li>본인인증이 안되었을 경우 → 패널회원 가입하기 버튼 클릭 시 본인인증 페이지(/certification) 로 이동 후 다시 panel/join 으로 리다이렉트</li>
<li>본인인증이 되었을 경우 → 바로 본인인증이 완료된 표시와 함께 폼 등장</li>
</ol>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/ca69226f-50e7-4657-8226-975f7a1d2c9c/image.png" alt=""></p>
<h1 id="문제">문제</h1>
<p>하지만 2번 조건에서 본인인증 직후 바로 패널 가입 페이지(panel/join)으로 넘어가야하는데, 메인페이지(/) 로 리다이렉트되는 상황이었다.
<img src="https://velog.velcdn.com/images/h_jinny/post/d2a49048-29c0-45f7-97e0-bc41f17167bc/image.png" alt="">
그 이후 새로고침을 해야 4번 상황이 정상 동작했다..🤔</p>
<p>서버사이드 렌더링 환경이며, 코드는 다음과 같다.</p>
<pre><code class="language-jsx">...
const { session, url } = context.req;
let certificated = false;
let redirectUrl = &#39;/&#39;;

if (url &amp;&amp; !url.includes(&#39;_next&#39;)) {
  redirectUrl = url;
}

try {
    // 로그인 상태 체크
    const user = await checkAuth(session);
    if (user) {
      // 본인인증여부 체크하는 로직
            ...
      if (checkCertification &amp;&amp; !certificated) { // checkCertification - props 이며, boolean 값
        return {
          redirect: {
            permanent: false,
            source: redirectUrl,
            // 본인인증되지 않은 상태이면 인증페이지로 이동 &gt; 인증 &gt; 다시 이전 페이지로 redirect
            destination: `/certification?redirectUrl=${encodeURIComponent(redirectUrl)}`,
          },
        };
      }
    } else {
      store.dispatch(setUserMeta(null));
    }
  } catch (error: any) {
    if (redirect) {
      return {
        redirect: {
          permanent: false,
          source: redirectUrl,
          destination: `/login?redirectUrl=${encodeURIComponent(redirectUrl)}`,
        },
      };
    }
  }</code></pre>
<p>본인인증이 완료되어 <code>certificated</code> 가 true 가 되면 redirect 되는 구조이다.</p>
<p>문제는 <code>url &amp;&amp; !url.includes(&#39;_next&#39;)</code> 의 조건에서 false 가 나와 <code>redirectUrl</code> 이 루트 경로로 설정된 것 같았다.  </p>
<pre><code class="language-jsx">const { session, url } = context.req;
let certificated = false;
let redirectUrl = &#39;/&#39;;

console.log(&#39;----------------------------------------------------&#39;);
console.log(url)
if (url &amp;&amp; !url.includes(&#39;_next&#39;)) {
  redirectUrl = url;
}</code></pre>
<p>로그를 찍어보니, 다음과 같이 뜬다. (참고로 서버사이드렌더링 코드에서 콘솔을 찍으면 브라우저 콘솔창이 아닌 코드 터미널에서 확인이 가능하다.)
<img src="https://velog.velcdn.com/images/h_jinny/post/9349ccec-57b4-433c-9d5f-ded71f781d30/image.png" alt="">
원래라면 아래와 같이 사용자 경로만 찍혀야 하는데, 앞에 _next 가 붙은 json 형태의 파일이름으로 내려오고 있는 것이었다. 그래서 <code>url &amp;&amp; !url.includes(&#39;_next&#39;)</code> 부분에서 조건 이행이 안돼 메인홈으로 리다이렉트 되었던 것이다..
<img src="https://velog.velcdn.com/images/h_jinny/post/f31c5515-8391-452e-8f25-cca231389f89/image.png" alt=""></p>
<br/>

<h1 id="해결">해결</h1>
<h2 id="resolvedurl"><code>resolvedUrl</code></h2>
<p>그래서 찾아보니 context.req.url 이 아닌 <code>context.resolvedUrl</code> 을 사용하라는 내용이 있었다..!</p>
<p><code>context.resolvedUrl</code>은 Next.js의 getServerSideProps 함수에서 제공하는 객체 중 하나로, <strong>클라이언트가 요청한 경로와 쿼리 문자열(query string)을 포함</strong>한 값을 제공한다.</p>
<p>getServerSideProps는 브라우저에서 요청할 때 뿐만 아니라 내부적으로 클라이언트와 서버 간 데이터 요청을 처리할 때도 실행되며 이때 Next.js는 데이터 요청에 ‘_next/data’ 경로를 사용한다고 하는데, 이것이 문제의 원인이었다…</p>
<p>더 쉽게 말하면, 다음과 같다.</p>
<ul>
<li>브라우저가 페이지를 처음 로드하면 URL이 <code>/panel/join</code>과 같이 나타남</li>
<li>하지만 이후 클라이언트 측에서 페이지를 이동하거나 데이터 페칭이 필요하면 내부적으로 <code>/_next/data</code> 경로로 요청이 발생함</li>
</ul>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>context.req.url</code></td>
<td>브라우저 또는 내부 데이터 요청에서 전달된 원본 경로</td>
<td><code>/_next/data/development/panel/join.json</code> (데이터 요청 시)</td>
</tr>
<tr>
<td><code>context.resolvedUrl</code></td>
<td>사용자 요청 URL (경로 + 쿼리 포함, <code>_next/data</code> 제외)</td>
<td><code>/panel/join?ref=123</code></td>
</tr>
</tbody></table>
<p>이전에 <code>!url.includes(&#39;_next&#39;)</code> 와 같은 조건을 둔 이유는 <code>&#39;_next&#39;</code> 부분이 문제가 되어 해당 부분을 제외한 url 을 추출하려 한건데, <code>resolvedUrl</code> 을 사용하면 이러한 과정도 모두 생략이 가능했다.</p>
<p>수정한 코드는 다음과 같다.</p>
<pre><code class="language-jsx">const { session } = context.req;
const { resolvedUrl } = context;

let certificated = false;
let redirectUrl = &#39;/&#39;;

if (resolvedUrl) {
  redirectUrl = resolvedUrl;
}</code></pre>
<h2 id="결과">결과:</h2>
<p><code>resolvedUrl</code> 을 적용한 결과, 본인인증이 안된 상태에서</p>
<p>인증 직후에 바로 패널회원가입 페이지로 리다이렉팅에 성공했다.
<img src="https://velog.velcdn.com/images/h_jinny/post/abffa64c-5d55-42f2-b841-79b7cc9b9f46/image.png" alt="">
 <img src="https://velog.velcdn.com/images/h_jinny/post/83bcb854-8f7c-4eda-9275-8e79fc5fcf86/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 다운로드 이력 남기기 기능(feat.withCredentials)]]></title>
            <link>https://velog.io/@h_jinny/React-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EC%9D%B4%EB%A0%A5-%EB%82%A8%EA%B8%B0%EA%B8%B0-%EA%B8%B0%EB%8A%A5feat.withCredentials</link>
            <guid>https://velog.io/@h_jinny/React-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EC%9D%B4%EB%A0%A5-%EB%82%A8%EA%B8%B0%EA%B8%B0-%EA%B8%B0%EB%8A%A5feat.withCredentials</guid>
            <pubDate>Fri, 10 Jan 2025 00:32:49 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>관리자 페이지의 다운로드 기능에서 페이지에 대한 값인 파라미터(pageName)을 추가하는 작업을 진행했다. 각 페이지마다 첨부파일을 다운로드 받으면 그 이력이 남아 다운로드 이력 페이지 목록에 등록되는 과정이었는데, 백엔드 개발자분이 다운로드가 발생한게 어느 페이지인지도 값을 받아야 작업이 수월할 것 같다고 요청을 주신 것이다.</p>
</blockquote>
<p>그래서 현재 페이지가 어디인지 url 에서 추출하여 현재 페이지에 대한 string 값을 pageName 파람키로 전달해주었다.</p>
<pre><code class="language-jsx">// 다운로드 할 파일 리스트 컴포넌트

... 
// PageNameType: 페이지별로 추가될 파라미터키(pageName)의 value 값 모음
const pageNameTypeText = (path: string) =&gt; {
  switch (path) {
    case &#39;program-apply&#39;:
      return PageNameType.PROGRAM_APPLY;
    case &#39;program-apply-fee&#39;:
      return PageNameType.FEE;
    case &#39;review&#39;:
      return PageNameType.REVIEW;
    case &#39;review-result&#39;:
      return PageNameType.REVIEW_RESULT;
    case &#39;internal-review&#39;:
      return PageNameType.INTERNAL_EVALUATION_APPLY;
    case &#39;internal-review-result&#39;:
      return PageNameType.INTERNAL_EVALUATION;
    case &#39;file&#39;:
      return PageNameType.FILE;
    case &#39;legacy-file&#39;:
      return PageNameType.LEGACY_FILE;
    default:
      return &#39;&#39;;
  }
};

const FileDownloadList: React.FC&lt;FileDownloadListProps&gt; = ({ fileList, isLegacyFile = false }) =&gt; {
  const { downloadFile, downloadLegacyFile } = useFileDownload();

  const location = useLocation();
   // 파라미터로 보내질, 정해진 페이지 string 값 리스트 불러오기 
  const enumsSelector = useMemo(() =&gt; selectEnums([&#39;page-name&#39;]), []);
  const enums = useSelector(enumsSelector);

  const selectedPageName = useMemo(() =&gt; {
    const firstSegment = location.pathname.split(&#39;/&#39;)[1]; // 현재 페이지에 대한 이름 url 에서 추출
    const pageName = pageNameTypeText(firstSegment);

    const findKeyByValue = (targetKey: string): string | undefined =&gt; {
      const [pageNameEnum] = enums;
      const foundItem = pageNameEnum?.find((item) =&gt; item?.key === targetKey);
      return foundItem?.key;
    };

    return findKeyByValue(pageName);
  }, [location.pathname, enums]);

  const handleDownloadFile = async (id: number, filename: string, selectedPageName?: string) =&gt; {
    try {
      downloadFile(id, filename, selectedPageName);
    } catch (error) {}
  };

  if (!fileList) return null;

  const fileArray = Array.isArray(fileList) ? fileList : [fileList];

  return (
    &lt;div&gt;
      {fileArray?.map(({ filename, size, attachId, legacyAttachId }) =&gt; (
        &lt;Box key={shortid.generate()}&gt;
          &lt;AttachFileIcon color=&quot;primary&quot; sx={{ width: &#39;20px&#39;, height: &#39;20px&#39; }} /&gt;
          &lt;button
            type=&quot;button&quot;
            onClick={() =&gt; handleDownloadFile(attachId, filename, selectedPageName)}
          &gt;
            ...
        &lt;/Box&gt;
      ))}
    &lt;/div&gt;
  );
};

export default memo(FileDownloadList);
</code></pre>
<h2 id="문제-발생">문제 발생:</h2>
<p>로그인한 상태에서 분명 요청을 보냈는데 백엔드 측에서 쿠키값이 안내려왔다고 한다. 그래서 계속 권한 관련 에러코드가 내려오는 상황이었다…</p>
<h2 id="원인">원인:</h2>
<p>다른 페이지에서api 요청을 할 때 아래의 공통으로 쓰이는 HTTP 클라이언트를 사용했지만, 다운로드 요청의 경우 추가 로직을 위해 api 요청하는 부분을 따로 하드코딩 했었다. </p>
<p>결정적으로 <code>withCredentials: true</code> 설정이 걸려있지 않아 쿠키가 보내지지 않은 것이 원인이었고, 어이없게도 해당 설정을 추가했더니 정상적으로 다운로드 요청이 갔다.</p>
<pre><code class="language-jsx">// 공통 http 클라이언트

import axios, { AxiosInstance } from &#39;axios&#39;;

const httpClient: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_ENDPOINT,
  withCredentials: true,
});

export default httpClient;</code></pre>
<pre><code class="language-jsx">// 따로 작성한 api 요청 코드

import axios from &#39;axios&#39;;

const useFileDownload = () =&gt; {
  const downloadFile = async (id: number, filename: string, pageName: string = &#39;&#39;) =&gt; {
    try {
      const { data } = await axios({
        url: `${import.meta.env.VITE_API_ENDPOINT}admin/v1/attach/${id}/download?pageName=${pageName}`, // your url
        method: &#39;GET&#39;,
        responseType: &#39;blob&#39;,
        withCredentials: true, // 추가
      });

      if (data) { // 브라우저에서 다운받을 수 있도록 설정된 로직
        const url = window.URL.createObjectURL(new Blob([data]));
        const link = document.createElement(&#39;a&#39;);
        link.href = url;
        link.setAttribute(&#39;download&#39;, filename);
        link.style.cssText = &#39;display:none&#39;;
        link.click();
        setTimeout(() =&gt; window.URL.revokeObjectURL(url), 0);
      }
    } catch (error: any) {
      console.error(error.response || error);
    }
  };

  return {
    downloadFile
  };
};

export default useFileDownload;
</code></pre>
<p>결과:
<img src="https://velog.velcdn.com/images/h_jinny/post/a6de5fd5-d642-4cad-a29d-e8a734e4f4a1/image.png" alt=""></p>
<p>다운로드 실행시, 이력에도 데이터가 잘 들어간 것을 알 수 있다.
(참여프로그램 신청 관리란 페이지에서 파일을 다운로드 했고, 그 결과가 &#39;PROGRAM_APPLY&#39; 라는 이름으로 요청 값에 담김)
<img src="https://velog.velcdn.com/images/h_jinny/post/0e923091-a0c1-4e40-b84f-1774c8055830/image.png" alt=""></p>
<p>권한 제약이 있는 요청에 있어 <code>withCredentials</code> 속성에 대해 넣어야 한다는 내용은 익히 들었지만, 실제로 요청 실패가 뜨는 것을 경험했으니 해당 속성에 대한 중요성을 더 알게된 계기라고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 키워드 추가 삭제 기능]]></title>
            <link>https://velog.io/@h_jinny/React-%ED%82%A4%EC%9B%8C%EB%93%9C-%EC%B6%94%EA%B0%80-%EC%82%AD%EC%A0%9C-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@h_jinny/React-%ED%82%A4%EC%9B%8C%EB%93%9C-%EC%B6%94%EA%B0%80-%EC%82%AD%EC%A0%9C-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Thu, 09 Jan 2025 09:27:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>관리자 페이지에서 공통으로 쓰이는 키워드를 관리하는 페이지를 작업했다. 주제, 장르, 오디오로 나뉘고 해당 분류에 따라 쓰이는 키워드를 추가하거나 삭제할 수 있는 기능이며, 해당 키워드들은 전역적으로 다른 페이지에도 적용이 된다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/c8dd0e19-2b75-416c-9fbd-e1ed25c4bba6/image.png" alt=""></p>
<ul>
<li>기존 데이터로 받은 키워드: currentCodeValueList</li>
<li>프론트에서 추가된 키워드: addedCodeValueList<ul>
<li>추가할 공통 코드 키워드 목록</li>
<li>codeValueList 라는 이름으로 수정 api 요청 body 에 들어감</li>
<li>키워드가 삭제될 때, 기존 데이터의 키워드인지, 프론트에서 생성된 키워드인지 구분하기 뒤해 각자 state 로 생성</li>
</ul>
</li>
<li>기존의 키워드에서 삭제할 키워드가 있는 경우: removedCodeIdList<ul>
<li>삭제할 공통 코드 일련번호 목록</li>
<li>isRemoveCodeIdList 라는 이름으로 수정 api 요청 body 에 들어감</li>
</ul>
</li>
</ul>
<pre><code class="language-jsx">...

const MediaForm = ({ data }: MediaFormProps) =&gt; {
  const { getMediaList } = useMedia();
  const { updateMedia } = useMediaMutation();
  const { mediaId, mediaType, keywordList, updateDt } = data;
  const [currentCodeValueList, setCurrentCodeValueList] = useState&lt;Array&lt;Code&gt;&gt;(
    [...keywordList].reverse(),
  );
  const [addedCodeValueList, setAddedCodeValueList] = useState&lt;Array&lt;string&gt;&gt;([]);
  const [removedCodeIdList, setRemovedCodeIdList] = useState&lt;Array&lt;number&gt;&gt;([]);
...
  const {
    register,
    handleSubmit,
    watch,
    reset,
    formState: { errors },
  } = useForm({
    mode: &#39;onChange&#39;,
    defaultValues: {
      keyword: &#39;&#39;,
    },
  });
  const watchedKeyword: string = watch(&#39;keyword&#39;);

  const onSubmit = () =&gt; {
    addKeyword(watchedKeyword);
    reset();
  };

  const handleMediaUpload = async () =&gt; {
    try {
      const payload: UpdateMediaRequestType = {
        codeValueList: addedCodeValueList,
        isRemoveCodeIdList: removedCodeIdList,
      };

      await updateMedia(mediaId, payload);
      openAlert(&#39;confirmSuccess&#39;);
      await getMediaList();
    } catch (error) {
      console.log(error);
    }
  };

  const deleteKeyword = useCallback((id: number, keyword: string) =&gt; {
    if (id) {
      setRemovedCodeIdList((prev) =&gt; {
        if (prev.includes(id)) return prev;
        return [...prev, id];
      });
      setCurrentCodeValueList((prev) =&gt; prev.filter((item) =&gt; item.codeId !== id));
    } else {
      // 프론트단에서 추가한 커스텀 키워드일 경우
      setAddedCodeValueList((prev) =&gt; prev.filter((item) =&gt; item !== keyword));
      setCurrentCodeValueList((prev) =&gt; prev.filter((item) =&gt; item.codeValue !== keyword));
    }
  }, []);

  const addKeyword = useCallback(
    (keyword: string) =&gt; {
      if (currentCodeValueList.some((item) =&gt; item.codeValue === keyword)) {
        // 이미 존재하는 코드일 때
        openAlert(&#39;duplicateKeyword&#39;);
        return;
      } else {
        setAddedCodeValueList((prev) =&gt; {
          if (prev.includes(keyword)) return prev;
          return [...prev, keyword];
        });

        const keywordIdNull = {
          codeId: null,
          codeName: mediaType,
          codeValue: keyword,
          description: mediaTypeMap[mediaType],
        };
        setCurrentCodeValueList((prev) =&gt; [keywordIdNull, ...prev]);
      }
    },
    [currentCodeValueList, mediaType, mediaTypeMap, openAlert],
  );

  // 커스텀으로 추가된 요소의 경우
  // 추가: setCurrentCodeValueList 에 id 를 null로 가진 요소에 포함시켜 추가
  // 커스텀 요소 제거시 =&gt; id 가 null 이며, value 가 &#39;단편영화&#39; 일 경우 제거

  return (
    &lt;&gt;
      &lt;Box className=&quot;flex justify-between w-full&quot;&gt;
        ...
      &lt;/Box&gt;
      &lt;ContentTable mtNone&gt;
        &lt;colgroup&gt;
          &lt;col className=&quot;w-2/12&quot; /&gt;
          &lt;col className=&quot;w-2/12&quot; /&gt;
        &lt;/colgroup&gt;
        &lt;TableBody&gt;
          &lt;TableRow&gt;
            &lt;ContentTableCell
              header
              required
              className=&quot;text-center&quot;
              rowSpan={currentCodeValueList?.length + 1}
            &gt;
              {mediaTypeMap[mediaType]} {mediaType === &#39;CATEGORY&#39; ? &#39;관리&#39; : &#39;장르&#39;}
            &lt;/ContentTableCell&gt;
            &lt;ContentTableCell colSpan={2} className=&quot;border-t-1&quot;&gt;
              &lt;Box className=&quot;flex gap-8&quot;&gt;
                &lt;form noValidate onSubmit={handleSubmit(onSubmit)} className=&quot;w-[300px]&quot;&gt;
                  &lt;TextField
                    {...register(&#39;keyword&#39;)}
                    error={!!errors.keyword}
                    helperText={errors.keyword?.message as string}
                    fullWidth
                    placeholder=&quot;키워드를 입력 후, Enter를 눌러주세요.&quot;
                    className=&quot;max-w-400&quot;
                  /&gt;
                &lt;/form&gt;
                &lt;Button
                  onClick={() =&gt; {
                    addKeyword(watchedKeyword);
                    reset();
                  }}
                  color=&quot;black&quot;
                &gt;
                  추가
                &lt;/Button&gt;
              &lt;/Box&gt;
            &lt;/ContentTableCell&gt;
          &lt;/TableRow&gt;
          {currentCodeValueList?.map((keyword, i) =&gt; (
            &lt;TableRow key={i}&gt;
              &lt;ContentTableCell className=&quot;text-center&quot; header secondary&gt;
                {currentCodeValueList?.length - i}
              &lt;/ContentTableCell&gt;
              &lt;ContentTableCell className=&quot;flex&quot; colSpan={3}&gt;
                &lt;Typography
                  className=&quot;self-center text-center flex-1&quot;
                  variant=&quot;body1&quot;
                  color=&quot;black&quot;
                &gt;
                  {keyword?.codeValue}
                &lt;/Typography&gt;
                &lt;Button
                  onClick={() =&gt; deleteKeyword(keyword?.codeId, keyword?.codeValue)}
                  variant=&quot;outlined&quot;
                  color=&quot;error&quot;
                  size=&quot;small&quot;
                &gt;
                  삭제
                &lt;/Button&gt;
              &lt;/ContentTableCell&gt;
            &lt;/TableRow&gt;
          ))}
        &lt;/TableBody&gt;
      &lt;/ContentTable&gt;
      ...
    &lt;/&gt;
  );
};

export default MediaForm;
</code></pre>
<p>이미 존재하는 키워드의 경우 현재 입력한 키워드와 codeValue 값을 비교해 경고창을 띄우고 리셋하도록 설정했다.</p>
<p>결과:
<img src="https://velog.velcdn.com/images/h_jinny/post/5881afd9-a1c1-4bbe-b7ca-d40f5937767f/image.png" alt=""></p>
<p>주제를 추가 후 로그를 찍었더니 아래와 같이 나타난다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/169fbafb-e930-4e1e-b2f6-897d21e8f9a6/image.png" alt=""></p>
<p>키워드 수정 후 저장을 눌러 <code>handleMediaUpload</code> 를 실행하면, 최종적으로 제거된 키워드의 id 값과 프론트에서 추가된 키워드 배열값을 백엔드 요청값으로 보내게 된다. 현재 추가된 키워드와 기존 데이터로 내려오는 키워드를 분간하여 state 로 분리하는게 조금 복잡했지만 꽤나 재미있는 작업이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React.js] ReactQuill 로 에디터 생성하기]]></title>
            <link>https://velog.io/@h_jinny/React.js-ReactQuill-%EB%A1%9C-%EC%97%90%EB%94%94%ED%84%B0-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@h_jinny/React.js-ReactQuill-%EB%A1%9C-%EC%97%90%EB%94%94%ED%84%B0-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 09 Jan 2025 09:09:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>KCMF의 관리자 페이지에서 공지사항을 등록하는 페이지를 작업중이었다. 폼 작업 진행중 에디터도 구현해야했고, MUI 라이브러리에서는 따로 에디터 기능이 제공되지 않았다. 그래서 찾아보던 도중 ReactQuill 라는 툴을 알게되어 적용해보기로 했다.</p>
</blockquote>
<h1 id="설치-및-사용법">설치 및 사용법</h1>
<h3 id="설치">설치</h3>
<pre><code class="language-jsx">yarn add react-quill</code></pre>
<h3 id="기본-적용-모습">기본 적용 모습</h3>
<pre><code class="language-jsx">import ReactQuill from &quot;react-quill&quot;;
import &#39;react-quill/dist/quill.snow.css&#39;;

const ReactEditor = () =&gt; {
  return (
    &lt;div&gt;
        &lt;ReactQuill theme=&quot;snow&quot;/&gt;
    &lt;/div&gt;
  )
}

export default ReactEditor;</code></pre>
<p>나는 현재 react-hook-form 을 사용하고 있어 Controller 안에 적용해주었다.</p>
<p>여기에 Controller 의 field 객체값으로 onChange 와 value 속성을 추가해주었다.</p>
<pre><code class="language-jsx">&lt;Controller
  control={control}
  name=&quot;content&quot;
  defaultValue=&quot;&quot;
  render={({ field }) =&gt; (
    &lt;ReactQuill
      theme=&quot;snow&quot;
      value={field.value}
          onChange={field.onChange}
    /&gt;
  )}
/&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/62977357-e0d0-44be-8566-e171b9e004b0/image.png" alt=""></p>
<p>초반에는 위의 모습처럼 툴바에 기본적인 기능만 표시되는데, 그 외 이미지나 표, 글자 스타일 적용 등 고급 기능들을 적용하고 싶을땐 커스텀해서 추가해줄 수 있다. modules 에 여러 다른 옵션들을 설정해 ReactQuill 의 module 속성으로 적용해주자.</p>
<pre><code class="language-jsx">const modules: {} = useMemo(
    () =&gt; ({
      toolbar: {
        container: [
          [{ font: [] }],
          [{ header: [1, 2, 3, 4, 5, false] }],
          [&#39;bold&#39;, &#39;italic&#39;, &#39;underline&#39;, &#39;strike&#39;],
          [{ color: [] }, { background: [] }],
          [{ align: [] }],
          [{ list: &#39;ordered&#39; }, { list: &#39;bullet&#39; }],
          [&#39;link&#39;, &#39;image&#39;, &#39;video&#39;],
          [&#39;insertTable&#39;],
          [&#39;clean&#39;],
        ],
      },
    }),
    [],
  );</code></pre>
<pre><code class="language-jsx">&lt;ReactQuill
  theme=&quot;snow&quot;
  value={field.value}
  onChange={field.onChange}
  modules={modules}
/&gt;</code></pre>
<h1 id="출력">출력</h1>
<p>에디터의 value 값은 html 모습의 string 형태로 내려오는데, 이를 출력하려면 <code>dangerouselySetInnerHTML</code>를 사용해주어야 한다. 실시간으로 값을 확인하기 위해 react-hook-form 의 watch 를 사용하여 값을 확인했다.</p>
<pre><code class="language-jsx">&lt;div dangerouslySetInnerHTML={{ __html: watch(&#39;content&#39;) }} /&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/d7c1d93c-eac2-457f-ae54-aa8236585295/image.png" alt=""></p>
<p>여기서 주의해야할 점은, <code>dangerouselySetInnerHTML</code> 이 HTML 태그를 그대로 렌더링할 수 있게 해주는 대신 무분별하게 사용할 경우 보안에 취약하다는 점이다. script가 포함되어 있으면 <strong>XSS (Cross-Site Scripting)</strong> 공격에 취약한데, 외부 입력 데이터를 사용할 때는 반드시 검증 및 정제를 시켜주어야 한다.</p>
<p>그럼 어떻게 이를 해결할 수 있을까?</p>
<h2 id="dompurify-를-통한-안전한-html-처리">DOMPurify 를 통한 안전한 HTML 처리</h2>
<p>이는 DOMPurify 라이브러리를 사용해 안전하게 HTML 을 처리해줄 수 있다.</p>
<p>설치 </p>
<pre><code class="language-jsx">yarn add dompurify</code></pre>
<p>적용</p>
<pre><code class="language-jsx">&lt;div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(watch(&#39;content&#39;)) }} /&gt;
</code></pre>
<p>DOMPurify는 기본적으로 HTML에서 위험한 태그와 속성을 제거하여 안전한 HTML을 생성해준다.</p>
<p><code>sanitize</code> 메소드는 입력된 HTML을 정제하고 안전한 HTML 문자열을 반환해준다. 이를 통해 XSS 공격으로부터 보호할 수 있다. </p>
<p>여기서 추가적인 설정을 통해 더 많은 정제 옵션을 사용할 수 있는데, 특정 태그나 속성을 허용하는 등 별도의 설정할 수 있다고 한다.</p>
<h1 id="스타일-커스텀">스타일 커스텀</h1>
<p>에디터의 기본 height 값이 너무 낮아 ReactQuill 태그에 직접적으로 style 객체를 통해 height 300px 값을 주었는데 적용되지 않았다. </p>
<p>검사기로 아래 요소들의 class 이름을 찾았고, 글이 작성되는 요소에 따로 height 를 주고자 styled-component 를 사용해 스타일을 적용시켰다.</p>
<pre><code class="language-jsx">import styled from &#39;styled-components&#39;;

const StyledQuill = styled(ReactQuill)`
  border-radius: 8px;
  overflow: hidden !important;
  border: 1px solid #ccc;
  .ql-container {
    border: none;
  }
  .ql-toolbar {
    border: none;
    border-bottom: 1px solid #ccc;
  }
  .ql-editor {
    height: 300px;
    overflow-y: auto;
  }
`;
</code></pre>
<pre><code class="language-jsx">&lt;StyledQuill
  theme=&quot;snow&quot;
  value={field.value}
  onChange={field.onChange}
  modules={modules}
  style={styles.editor}
/&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/1a50901a-bdd3-4d0a-bbd2-8075df6348d6/image.png" alt=""></p>
<p>참고</p>
<p><a href="https://rlawo32.tistory.com/entry/React-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-React-Quill-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-1">https://rlawo32.tistory.com/entry/React-웹-에디터-React-Quill-사용하기-1</a>
<a href="https://velog.io/@hskwon517/React-Quill-%EC%97%90%EB%94%94%ED%84%B0-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0">https://velog.io/@hskwon517/React-Quill-에디터-사용하기</a>
<a href="https://velog.io/@rlaclgns321/%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-React-quill">https://velog.io/@rlaclgns321/웹-에디터-React-quill</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 두 번 렌더링 되는 현상 (React.StrickMode)]]></title>
            <link>https://velog.io/@h_jinny/React-%EB%91%90-%EB%B2%88-%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81-React.StrickMode</link>
            <guid>https://velog.io/@h_jinny/React-%EB%91%90-%EB%B2%88-%EB%A0%8C%EB%8D%94%EB%A7%81-%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81-React.StrickMode</guid>
            <pubDate>Thu, 12 Sep 2024 07:44:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>인스타그램 클론 코딩을 하는 중, 알림 탭에서 오늘 날짜에 해당하는 컨텐츠를 필터링 해서 표시하는 작업을 하고 있었다. </p>
</blockquote>
<p>컨텐츠 data 에 map 을 돌려 오늘 날짜에 해당되는 컨텐츠면 <code>todayAlarm</code> 이라는 state 배열에 담아지는 구조로 코드를 구현했는데, 오늘에 해당하는 컨텐츠는 하나임에도 똑같은 컨텐츠가 배열 두 번씩 담기는 것이었다.</p>
<pre><code class="language-jsx">
function AlarmSidebar({ isOpen }) {
  const alarmData = [
    {
      User: {
        id: &#39;nawm_eeee&#39;,
        nickname: &#39;김진명&#39;,
        image: &#39;/user-01.jpg&#39;,
      },
      type: &#39;comment&#39;,
      content: &#39;안녕하세요 피드가 예쁘네요!&#39;,
      createdAt: new Date(new Date().setDate(new Date().getDate() - 0)), //  오늘
    },
    {
      User: {
        id: &#39;veenoo&#39;,
        nickname: &#39;조수빈&#39;,
        image: &#39;/user.jpg&#39;,
      },
      type: &#39;mention&#39;,
      content: &#39;이거 봄?ㅋㅋㅋㅋ&#39;,
      createdAt: new Date(new Date().setDate(new Date().getDate() - 0)), //  오늘
    },
    {
      User: {
        id: &#39;h._seung&#39;,
        nickname: &#39;랍뷰희승&#39;,
        image: &#39;/user.jpg&#39;,
      },
      type: &#39;mention&#39;,
      content: &#39;그러자!!&#39;,
      createdAt: new Date(new Date().setDate(new Date().getDate() - 12)), // 12일 전
    },
    {
      User: {
        id: &#39;jin_woo&#39;,
        nickname: &#39;김김진진우우&#39;,
        image: &#39;/user.jpg&#39;,
      },
      type: &#39;comment&#39;,
      content: &#39;잘나왔네&#39;,
      createdAt: new Date(new Date().setDate(new Date().getDate() - 7)), // 7일 전
    },
  ]

  const [todayAlarm, setTodayAlarm] = useState([])

  useEffect(() =&gt; {
    alarmData.map((data, index) =&gt; {
      const currentDay = new Date()

      const day = data?.createdAt
      // 오늘 날짜인지 체크
      if (
        currentDay.getFullYear() === day.getFullYear() &amp;&amp;
        currentDay.getMonth() === day.getMonth() &amp;&amp;
        currentDay.getDate() === day.getDate()
      ) {
        console.log(&#39;오늘입니다&#39;)
        setTodayAlarm((prev) =&gt; [...prev, data]);

      } else {
        console.log(&#39;오늘이 아님&#39;)
      }
    })
  }, [alarmData.length])

  console.log(todayAlarm)

  return (
    &lt;div
      role=&quot;dialog&quot;
      className={cx(style.sidebar, isOpen &amp;&amp; style.opened, style.alarm)}
    &gt;
      &lt;div className={style.sidebarInner}&gt;
        &lt;h3 className={style.sidebarTitle}&gt;알림&lt;/h3&gt;
        &lt;div className={style.sideBarContent}&gt;
          &lt;h4 className={style.sidebarSubTitle}&gt;
            &lt;span&gt;오늘&lt;/span&gt;
          &lt;/h4&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default AlarmSidebar

</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/c140da41-a84b-4363-a07c-085890fffab9/image.png" alt=""></p>
<p>map 이 두 번 돌아가는 것으로 보아 렌더링이 한 번 더 일어난 것으로 예상되었고, 구글링을 해본 결과 그 원인을 알아냈다.</p>
<h2 id="strictmode">StrictMode</h2>
<p>리액트는 StrickMode 옵션이 기본으로 설정이 되어있는데, 해당 옵션이 켜져있을 땐 개발모드에서 렌더링이 두 번씩 실행된다고 한다. 
StrickMode 의 역할은 아래와 같이 어떠한 기능 사용에 대한 경고나, 부작용 검사 등이 있다고 한다. <a href="https://ko.legacy.reactjs.org/docs/strict-mode.html">공식문서</a></p>
<ol>
<li>안전하지 않은 생명주기를 사용하는 컴포넌트 발견</li>
<li>레거시 문자열 ref 사용에 대한 경고</li>
<li>권장되지 않는 findDOMNode 사용에 대한 경고</li>
<li>예상치 못한 부작용 검사</li>
<li>레거시 context API 검사</li>
<li>Ensuring reusable state</li>
</ol>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/90be42b0-7a63-4891-954a-9a68ce179c54/image.png" alt=""></p>
<p>이 모드를 해제하려면 next.config.js 에서 reactStrictMode 옵션을 false 로 지정해주면 된다.
<img src="https://velog.velcdn.com/images/h_jinny/post/31c80d74-ff93-4116-9aa8-b3e4973f5e38/image.png" alt=""></p>
<p>하지만 개발 모드에서 진행되는 경고나 검사는 필요하기 때문에 StrictMode 를 해제하는 것은 지양하는 것이 좋다.</p>
<p>위의 방법을 쓰기보단, 오늘에 해당하는 컨텐츠 배열 안에 (두 번째 렌더링 때)똑같은 컨텐츠가 들어가지 않도록 방지하는 쪽으로 수정을 했다. <code>tempTodayAlarm</code> 이라는 임시 배열을 하나 만든 뒤 아래 내용으로 필터링된 값을 다시 <code>setTodayAlarm</code> 의 인수값으로 넣었다.
<code>!tempTodayAlarm.some(alarm =&gt; alarm.content === data.content)</code></p>
<pre><code class="language-jsx">useEffect(() =&gt; {
    // 오늘 알림을 임시 배열로 수집
    const tempTodayAlarm = [];

    alarmData.map((data, index) =&gt; {
      const currentDay = new Date()

      const day = data?.createdAt
      // 오늘 날짜인지 체크
      if (
        currentDay.getFullYear() === day.getFullYear() &amp;&amp;
        currentDay.getMonth() === day.getMonth() &amp;&amp;
        currentDay.getDate() === day.getDate()
      ) {
        console.log(&#39;오늘 입니다&#39;)
        if (!tempTodayAlarm.some(alarm =&gt; alarm.content === data.content)) {
          tempTodayAlarm.push(data);
        }
      } else {
        console.log(&#39;오늘이 아님&#39;)
      }
    })
    // 상태를 한 번만 업데이트
    setTodayAlarm(tempTodayAlarm);
  }, [alarmData.length])</code></pre>
<p>결과: 오늘에 해당하는 컨텐츠가 정상적으로 한 번씩만 push 된 것을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/c004695d-547c-4b68-9800-13d01b7ea2b3/image.png" alt=""></p>
<p>참고: 
<a href="https://velog.io/@rssungjae/React-%EB%91%90%EB%B2%88%EC%94%A9-%EB%A0%8C%EB%8D%94%EB%A7%81%EB%90%98%EB%8A%94-%EA%B2%BD%EC%9A%B0">https://velog.io/@rssungjae/React-%EB%91%90%EB%B2%88%EC%94%A9-%EB%A0%8C%EB%8D%94%EB%A7%81%EB%90%98%EB%8A%94-%EA%B2%BD%EC%9A%B0</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[http-server 로 로컬 화면 공유하기]]></title>
            <link>https://velog.io/@h_jinny/http-server-%EB%A1%9C-%EB%A1%9C%EC%BB%AC-%ED%99%94%EB%A9%B4-%EA%B3%B5%EC%9C%A0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@h_jinny/http-server-%EB%A1%9C-%EB%A1%9C%EC%BB%AC-%ED%99%94%EB%A9%B4-%EA%B3%B5%EC%9C%A0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 08 Aug 2024 06:06:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>단순한 퍼블리싱 업무를 맡게 되어 작업중, 기획자분께서 자신의 자리에서 로컬화면을 보고싶다 하셨다. 이제까지 리액트나 뷰 환경에서 작업을 하다가, 일반 HTML/CSS/JavaScript 같이 정적파일로만 구성된 상태에서 로컬 화면을 공유드리는 것은 처음이었다.</p>
</blockquote>
<br>

<h2 id="개발-서버의-부재">개발 서버의 부재</h2>
<p>ip 주소를 붙여 링크를 생성하면 되는 일이었지만, 역시나 일반 퍼블 프로젝트에서는 동작을 하지 않았다. 프레임워크 환경에서는 왜 자동으로 됐을까? 그 이유는 리액트나 뷰의 경우 기본적으로 개발서버(웹팩이나 vite 등)가 내장되어있어 추가적인 설정이 필요하지 않았기 때문이었다.</p>
<p>일반 퍼블리싱 환경의 경우, HTML/CSS/JavaScript 파일은 모두 정적 파일로서 별다른 처리가 되지 않는다. 그러므로 따로 웹서버가 필요한 상황인 것이다. 정적 파일은 우리의 컴퓨터에 저장된 파일과 폴더를 관리하는 시스템으로 접근이 가능하지만, 이를 같은 로컬 네트워크 상의 다른 디바이스에서는 접근이 불가능하다.</p>
<h2 id="http-server">http-server</h2>
<p>빠르게 정적파일을 띄우고 싶을땐, <code>http-server</code>와 같은 간단한 웹 서버를 사용하여 네트워크에서 다른 사람과 프로젝트를 공유할 수 있다.<code>http-server</code> 는 npm 에서 제공하는 패키지로, 테스트용으로 만들어지거나 작은 프로젝트를 만들 때 유용하게 쓰인다. 이는 초기설정도 적고 시간이 단축된다는 장점이 있다.</p>
<h2 id="http-server--사용법">http-server  사용법</h2>
<p><a href="https://www.npmjs.com/package/http-server">공식문서</a></p>
<p>전역설치</p>
<pre><code class="language-jsx">npm install --global http-server</code></pre>
<p>실행</p>
<pre><code class="language-jsx">http-server</code></pre>
<p>터미널에서 <code>http-server</code>를 설치해주고, 명령어도 다음과 같이 실행하면 터미널에 다음과 같이 로컬 포트를 가진 url 이 뜬다.</p>
<pre><code class="language-jsx">Available on:
  http://127.0.0.1:8080
  http://xxx.xxxx.xxx.xxx:8080</code></pre>
<p>화면에 띄우면 아래와 같이 폴더 경로가 생기고, 접근하고 싶은 폴더 파일로 이동하면 된다.
해당 경로를 공유하고자 하는 컴퓨터에 띄우면 나의 로컬화면이 잘 보여지는 것을 확인할 수 있을 것이다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/f0ab099f-a2e7-465d-bd33-0a9bbb5c58c0/image.png" alt=""></p>
<p>참고</p>
<p><a href="https://www.npmjs.com/package/http-server">https://www.npmjs.com/package/http-server</a>
<a href="https://devjh.tistory.com/202">https://devjh.tistory.com/202</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[javascript] 게시물로 페이지 이동 (window.location)]]></title>
            <link>https://velog.io/@h_jinny/React-%EA%B2%8C%EC%8B%9C%EB%AC%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-window.location</link>
            <guid>https://velog.io/@h_jinny/React-%EA%B2%8C%EC%8B%9C%EB%AC%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-window.location</guid>
            <pubDate>Tue, 11 Jun 2024 07:53:13 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>현재 리액트 환경에서 인스타그램 클론코딩 중이다. 인스타그램 메인 게시글의 “더보기” 버튼을 누르면 팝업이 나오는데, 거기서 “게시글로 이동” 을 누를 시 상세페이지로 이동하는데, 이 부분을 구현하려했다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/9d876cfc-ae6c-4ed3-92e5-1ce079416e94/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/e51e7493-030d-4782-8f6c-654c8b7e2f95/image.png" alt=""></p>
<p>하지만 게시글 상세는 두 가지의 형태를 띈다. 클라이언트 환경에서는 상세 게시글이 모달로 뜨고, 서버사이드 렌더링이 되면 상세 게시글 페이지로 이동되는 방식이다.</p>
<p>클라이언트 렌더링 시 🔽
<img src="https://velog.velcdn.com/images/h_jinny/post/0675d4b8-3a6b-451e-9234-32f0d62290e7/image.png" alt=""></p>
<h1 id="routerpush">router.push()</h1>
<pre><code class="language-jsx">import { useRouter } from &#39;next/navigation&#39;

function PostOptionModal() {
  const router = useRouter()
  const onClick = () =&gt; {
    router.push(`/${post.User.id}/p/${post.postId}`)
  }
...</code></pre>
<p>next 의 <code>router</code> 를 통해 페이지 이동을 하면 새로고침 되지 않고 애플리케이션 상태를 유지하면서 url 만 변경되기 때문에 클라이언트 상태인 모달 형식으로 뜨는데, 내가 원하는 것은 새로고침 된 페이지로  뜨는 것이었다.</p>
<h1 id="windowlocationreplace">window.location.replace()</h1>
<p>그래서 router 대신 <code>window.location.replace()</code> 를 사용하였는데, 서버사이드 렌더링되어 페이지로 잘 이동되는 것을 확인할 수 있었다. </p>
<pre><code class="language-jsx">window.location.replace(`/${post.User.id}/p/${post.postId}`)</code></pre>
<p>대신 문제가 있었는데, <code>replace()</code> 의 경우 브라우저의 현재 url 을 아예 새로운 url 로 교체하여 히스토리가 남지 않기 때문에 이전 페이지로 돌아갈 수 없게 된다. 실제로 뒤로가기를 눌렀을 때 루트경로로 이동이 되었다.</p>
<h1 id="windowlocationhref">window.location.href</h1>
<p>최종적으로 <code>*window*.location.href</code> 를 사용하였더니 서버사이드 렌더링이 되어 페이지로 잘 이동되고, 히스토리도 남아 이전 페이지로 잘 되돌아가는 것을 확인했다.</p>
<pre><code class="language-jsx">window.location.href = `/${post.User.id}/p/${post.postId}`;</code></pre>
<h1 id="정리">정리</h1>
<pre><code class="language-jsx">function PostOptionModal() {
  const router = useRouter()
  const onClick = () =&gt; {
    // router.push(`/${post.User.id}/p/${post.postId}`) // url 만 변경되어 모달 형식으로 뜸
    // window.location.replace(`/${post.User.id}/p/${post.postId}`) // 히스토리에 기록되지 않아 뒤로가기 시 무조건 루트 경로로 이동해버림

    window.location.href = `/${post.User.id}/p/${post.postId}`;
  }
  ...</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] useController 환경에서 setFocus 가 안되는 현상]]></title>
            <link>https://velog.io/@h_jinny/React-useController-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-setFocus-%EA%B0%80-%EC%95%88%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81</link>
            <guid>https://velog.io/@h_jinny/React-useController-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-setFocus-%EA%B0%80-%EC%95%88%EB%90%98%EB%8A%94-%ED%98%84%EC%83%81</guid>
            <pubDate>Thu, 16 May 2024 02:16:22 GMT</pubDate>
            <description><![CDATA[<p>개인프로젝트 중, 임의로 포커싱을 주는 작업이 필요했다. </p>
<p>⇒ 댓글 아이콘을 클릭하면 아래 댓글 폼이 포커싱 되도록</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/0d110216-3b06-4da7-aef7-8761e59a2c90/image.png" alt=""></p>
<p>작업은 간단했는데, useForm 의 <code>setFocus</code> 를 사용하면 될 일이었다. 하지만 어째서인지 포커싱이 되지 않았고, useRef 까지 사용했는데도 포커싱이 되지 않았는데.. </p>
<pre><code class="language-jsx">&#39;use client&#39;

import { useController } from &#39;react-hook-form&#39;
import style from &#39;./input.module.scss&#39;

function Input({
  control,
  name,
  rules,
  required = false,
  type = &#39;text&#39;,
  label = &#39;&#39;,
  maxLength = 0,
  placeholder = &#39;&#39;,
  onChange = () =&gt; {},
  onBlur = () =&gt; {},
  onFocus = () =&gt; {},
  disabled,
  ...rest
}) {
  const {
    field,
    fieldState: { error }
  } = useController({
    name,
    control,
    rules,
  })

  return (
    &lt;div className={style.field}&gt;
      &lt;div
        className={`${type === &#39;checkbox&#39; ? &#39;checkbox&#39; : &#39;&#39;} ${style.inputForm}`}
      &gt;
        {label &amp;&amp; &lt;label htmlFor={name}&gt;{label}&lt;/label&gt;}
        &lt;input
          id={name}
          type={type}
          className=&quot;input&quot;
          name={name}
          value={field.value}
          onChange={field.onChange}
          onBlur={() =&gt; {
            onBlur()
            field.onBlur()
          }}
          onFocus={onFocus}
          placeholder={placeholder}
          disabled={disabled}
          {...rest}
        /&gt;
        {error &amp;&amp; (
          &lt;span className={style.error}&gt;
            &lt;em&gt;*&lt;/em&gt;
            {error.message}
          &lt;/span&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default Input
</code></pre>
<p>공식문서의 <code>setFocus</code> 예시를 그대로 사용했을 때는 정상작동하였다.</p>
<pre><code class="language-jsx">&#39;use client&#39;
import React from &#39;react&#39;
import {useForm} from &quot;react-hook-form&quot;;

function MessagePage() {
  type FormValues = {
    firstName: string;
  };

  let renderCount = 0;

  const { register, handleSubmit, setFocus, watch } = useForm();
  const onSubmit = (data: FormValues) =&gt; console.log(data);
  renderCount++;

  React.useEffect(() =&gt; {
    setFocus(&quot;firstName&quot;);
    console.log(watch(&quot;firstName&quot;))
  }, [setFocus]);

  return (
      &lt;form onSubmit={handleSubmit(onSubmit)}&gt;
        &lt;input {...register(&quot;firstName&quot;)} placeholder=&quot;First Name&quot; /&gt;
        &lt;button type=&quot;submit&quot; onClick={() =&gt; {setFocus(&quot;firstName&quot;)}}&gt;wpcnf&lt;/button&gt;
      &lt;/form&gt;
  );
}

export default MessagePage
</code></pre>
<p><code>setFocus</code> 자체의 기능엔 문제가 없는 것을 확인했고, 나는 Input 을 공통 컴포넌트로 사용하고자 useController 로 사용했는데 이 부분에서 문제가 생기지 않았을까 추측했다.</p>
<p>그리고 공식문서에서 그 이유를 찾았는데, useController 의 <code>field.ref</code> 를 사용하지 않아 포커싱이 되지 않았던것을 알 수 있었다…</p>
<p>에러뿐만이 아닌 임의로 그냥 포커싱을 줄때도 <code>field.ref</code> 를 넣어주어야 한다는 사실을 알게됐다는😭</p>
<p><a href="https://react-hook-form.com/docs/usecontroller">공식문서</a></p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/2cde8791-3440-4dfb-9feb-ac4efde7d59a/image.png" alt=""></p>
<p>예시는 @material-ui/core 라는 UI 라이브러리를 사용해 TextField 의 inputRef 라는 속성에 들어있는데, 그냥 input 에는 ref 안에 <code>field.ref</code> 를 넣어주면 된다.</p>
<pre><code class="language-jsx">&lt;input
  id={name}
  type={type}
  className=&quot;input&quot;
  name={name}
  value={field.value}
  onChange={field.onChange}
  ref={field.ref}
  ...
/&gt;</code></pre>
<p>테스트용으로 회원가입 하는 폼에 초기 포커싱으로 적용시켜봤는데, 정상 작동되는 것을 확인했다!</p>
<pre><code class="language-jsx">  useEffect(() =&gt; {
    setFocus(&quot;user&quot;)
  }, [setFocus])

  ...

  &lt;Input
      name=&quot;user&quot;
      control={control}
      label=&quot;아이디&quot;
      maxLength=&quot;30&quot;
      type=&quot;text&quot;
      placeholder=&quot;아이디(5자 이상)&quot;
    /&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/4e6e180c-c955-48f9-857c-235b603cd200/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 요소 외 외부 영역 클릭]]></title>
            <link>https://velog.io/@h_jinny/React-%EC%9A%94%EC%86%8C-%EC%99%B8-%EC%99%B8%EB%B6%80-%EC%98%81%EC%97%AD-%ED%81%B4%EB%A6%AD</link>
            <guid>https://velog.io/@h_jinny/React-%EC%9A%94%EC%86%8C-%EC%99%B8-%EC%99%B8%EB%B6%80-%EC%98%81%EC%97%AD-%ED%81%B4%EB%A6%AD</guid>
            <pubDate>Sun, 24 Mar 2024 06:20:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>[인스타그램 클론 코딩 프로젝트] 작업중, 더보기 버튼을 작업하는 중이었다. 버튼을 토글 클릭시 메뉴창이 뜨고 닫히도록 만들어놓은 상태이다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/4b2f178b-bcb0-4822-9f2b-91b85878f692/image.png" alt=""></p>
<pre><code class="language-jsx">&#39;use client&#39;

import { useState } from &#39;react&#39;
import menuStyle from &#39;@/app/(loggedIn)/_component/navMenu.module.scss&#39;
import Link from &#39;next/link&#39;
import style from &#39;./moreMenu.module.scss&#39;

function MoreMenu() {
  const [isOpened, setIsOpened] = useState&lt;boolean&gt;(false)

  return (
    &lt;div className={style.moreMenu}&gt;
      &lt;div className={menuStyle.menuItem}&gt;
        &lt;button
          className={menuStyle.menuButton}
          type=&quot;button&quot;
          onClick={() =&gt; setIsOpened(!isOpened)}
        &gt;
            {/* svg 아이콘 있음 */}
          &lt;span style={{ fontWeight: `${isOpened ? &#39;bold&#39; : &#39;&#39;}` }}&gt;
            더보기
          &lt;/span&gt;
        &lt;/button&gt;

        {isOpened &amp;&amp; (
          &lt;ul role=&quot;dialog&quot; className={style.moreMenuList}&gt;
            &lt;li&gt;
              &lt;Link href=&quot;#&quot; className={style.moreMenuItem}&gt;
                &lt;svg&gt;
                  ...
                &lt;/svg&gt;
                &lt;span&gt;설정&lt;/span&gt;
              &lt;/Link&gt;
            &lt;/li&gt;
            &lt;li&gt;
              &lt;Link href=&quot;#&quot; className={style.moreMenuItem}&gt;
                {/* svg 아이콘 있음 */}
                &lt;span&gt;저장됨&lt;/span&gt;
              &lt;/Link&gt;
            &lt;/li&gt;
            ...
          &lt;/ul&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default MoreMenu
</code></pre>
<p>여기서 나는 메뉴창 외의 영역에도 클릭할 시 메뉴창이 닫히도록 하고싶어했다. 찾던 내용중, 단순하게 전체영역을 태그로 감싼 후 해당 전역 태그의 onClick 이벤트로 <code>isOpened</code> 를 <code>false</code> 로 만드는 방법도 있었지만 메뉴창이 하나의 페이지가 아닌, 컴포넌트 영역에 있었기 때문에 해당 방법은 쓰지 못했다. 대신  <code>useEffect</code> 와 <code>useRef</code> 훅을 사용하여 해결하게 되었다.</p>
<p>메뉴창(ul) 요소를 참조할 수 있겠금 dialogRef 라는 이름으로 <code>ref</code> 속성을 넣어준다. 그리고 <code>useEffect</code> 를 통해 클릭이벤트를 감지하는 함수를 넣고, 그에 대한 콜백함수는 dialogRef 요소 외의 부분이 클릭됨에 따라 <code>isOpened</code> 의 값이 <code>false</code> 로 바뀌도록 해준다.</p>
<p>이때 e 매개변수의 타입은 <code>MouseEvent</code> 로 주고, <strong><code>contains</code></strong> 의 경우 <strong><code>Node</code></strong> 인터페이스를 사용하기 때문에 <code>e.target</code> 의 타입을 <code>Node</code> 로 선언해준다.</p>
<pre><code class="language-jsx">function MoreMenu() {

  const [isOpened, setIsOpened] = useState&lt;boolean&gt;(false)
  const dialogRef = useRef&lt;HTMLUListElement | null&gt;(null)

  useEffect(() =&gt; {
    // MouseEvent 타입 매개변수를 받고, 아무것도 반환하지 않는 함수
    const handleOutsideClose = (e: MouseEvent): void =&gt; {
      // useRef current에 담긴 엘리먼트 이외의 영역을 클릭 시 메뉴창 사라짐
      if (isOpened &amp;&amp; !dialogRef.current.contains(e.target as Node))
        setIsOpened(false)
    }
    document.addEventListener(&#39;click&#39;, handleOutsideClose)

    return () =&gt; document.removeEventListener(&#39;click&#39;, handleOutsideClose)
  }, [isOpened])

  return (
    &lt;div className={style.moreMenu}&gt;
      &lt;div className={menuStyle.menuItem}&gt;
        ...
        {isOpened &amp;&amp; (
          &lt;ul role=&quot;dialog&quot; ref={dialogRef} className={style.moreMenuList}&gt;
            &lt;li&gt;
              &lt;Link href=&quot;#&quot; className={style.moreMenuItem}&gt;
                ...
            &lt;/li&gt;
            ...
          &lt;/ul&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default MoreMenu
</code></pre>
<p>또한 메모리 누수 방지를 위해 등록된 이벤트를 <code>removeEventListener</code> 를 통해 삭제해 주도록 유의해주어야 한다.</p>
<br>
<br>
참고

<p><a href="https://close-up.tistory.com/entry/React-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%ED%8A%B9%EC%A0%95-%EC%98%81%EC%97%AD-%EC%99%B8-%ED%81%B4%EB%A6%AD-%EA%B0%90%EC%A7%80">https://close-up.tistory.com/entry/React-컴포넌트-특정-영역-외-클릭-감지</a>
<a href="https://babycoder05.tistory.com/entry/React-%EC%99%B8%EB%B6%80-%EC%98%81%EC%97%AD-%ED%81%B4%EB%A6%AD-%EC%8B%9C-%EB%8B%AB%EA%B8%B0">https://babycoder05.tistory.com/entry/React-외부-영역-클릭-시-닫기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[ESlint] SyntaxError: Failed to load parser '@typescript-eslint/parser' declared in '.eslintrc.json 에러]]></title>
            <link>https://velog.io/@h_jinny/ESlint-SyntaxError-Failed-to-load-parser-typescript-eslintparser-declared-in-.eslintrc.json-%EC%97%90%EB%9F%AC</link>
            <guid>https://velog.io/@h_jinny/ESlint-SyntaxError-Failed-to-load-parser-typescript-eslintparser-declared-in-.eslintrc.json-%EC%97%90%EB%9F%AC</guid>
            <pubDate>Sun, 21 Jan 2024 05:10:41 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>next 프로젝트를 시작하려 초기 세팅중, 다음과 같은 eslint 에러 문구가 떴다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/841a8d34-aeda-4b0e-8cdf-2c0a0cedd08a/image.png" alt=""></p>
<pre><code>SyntaxError: Failed to load parser &#39;@typescript-eslint/parser&#39; declared in &#39;.eslintrc.json » eslint-config-airbnb-typescript » /Users/johyejin/Desktop/2024-frontend-study/j-com/node_modules/eslint-config-airbnb-typescript/lib/shared.js&#39;: Unexpected token &#39;||=&#39;

/Users/johyejin/Desktop/2024-frontend-study/j-com/node_modules/@typescript-eslint/scope-manager/dist/referencer/ClassVisitor.js:123
        withMethodDecorators ||=
                             ^^^

SyntaxError: Unexpected token &#39;||=&#39;
    at wrapSafe (internal/modules/cjs/loader.js:979:16)
    at Module._compile (internal/modules/cjs/loader.js:1027:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Module.require (internal/modules/cjs/loader.js:952:19)
    at require (internal/modules/cjs/helpers.js:88:18)
    at Object.&lt;anonymous&gt; (/Users/johyejin/Desktop/2024-frontend-study/j-com/node_modules/@typescript-eslint/scope-manager/dist/referencer/Referencer.js:20:24)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
Process finished with exit code -1</code></pre><h2 id="첫번째-시도">첫번째 시도</h2>
<p>초기 eslintrc.json 는 다음과 같이 자동으로 설정되어있던 상황이다.</p>
<pre><code class="language-json">{
  &quot;extends&quot;: &quot;next/core-web-vitals&quot;
}</code></pre>
<p>next.js 초기 세팅(eslint, prettier) 관련 글을 서치하였는데, 위의 부분만으론 완벽하게 ESLint가 적용된 것이 아니고 별도로 추가의 설정을 두어야 한다는 것을 알게 되었다. eslint 의 부족한 설정때문에 에러가 발생했다 추측하여, eslintrc.json 파일에 추가적인 설정과 더불어 다른 라이브러리들도 같이 설치를 진행했다.</p>
<p><code>npm i</code> </p>
<p><code>eslint-config-airbnb</code></p>
<p><code>eslint-config-airbnb-typescript</code></p>
<p><code>@typescript-eslint/eslint-plugin@^6.0.0</code></p>
<p><code>@typescript-eslint/parser@^6.0.0 -d</code></p>
<pre><code class="language-json">{
  &quot;extends&quot;: [
    &quot;next/core-web-vitals&quot;,
    &quot;airbnb&quot;,
    &quot;airbnb-typescript&quot;,
    &quot;prettier&quot;
  ],
  &quot;parserOptions&quot;: {
    &quot;project&quot;: &quot;./tsconfig.json&quot;
  },
  &quot;rules&quot;: {
    &quot;react/react-in-jsx-scope&quot;: &quot;off&quot;
  }
}</code></pre>
<p>하지만 에러는 여전히 있었고, 표시가 eslint 의 설정에 있는 라이브러리의 이름으로만 바뀌었을 뿐이었다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/28d1649f-4ace-4012-a60b-557ef8860c39/image.png" alt=""></p>
<h2 id="두번째-시도">두번째 시도</h2>
<p><code>@typescript-eslint/parser</code> 가 로드되지 않는다는 문구를 확인 후 <code>@typescript-eslint/parser</code> 라이브러리를 설치했지만 여전히 에러문구가 남아있었다.</p>
<p>하지만 이내 그 해결책을 고맙게도 스택오버플로우에서 찾았다.</p>
<p>질문자는 나와 똑같은 상황이었고, 심지어 이분 역시 <code>@typescript-eslint/parser</code> 까지 깔은 상황에 에러 해결이 안된다는 내용이었다.</p>
<p>답변은 다음과 같았는데, 결론적으로 단순히 <strong>Webstorm IDE 의 node 버전에 대한 이슈</strong>였다</p>
<ul>
<li>Ubuntu 22.04에 기본으로 설치된 Node.js를 제거하고, Node.js의 버전을 관리할 수 있는 nvm (Node Version Manager) 패키지를 설치<ul>
<li>Node.js 제거: <strong><code>sudo apt purge nodejs npm</code></strong></li>
<li>nvm 설치: <strong><code>curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash</code></strong> (이미 있다면 해당 단계는 패스)</li>
<li>WebStorm에서 Node.js 버전 선택: WebStorm에서 설정 &gt; <strong><code>Languages &amp; Frameworks | Node.js</code></strong> 로 이동하여 원하는 버전을 선택</li>
<li>프로젝트 루트에 <strong><code>.nvmrc</code></strong> 파일을 추가하여 사용하려는 Node.js 버전을 정의할 수 있음 (선택사항)</li>
</ul>
</li>
<li>WebStorm에서 적절한 Node.js 버전을 선택</li>
</ul>
<p>하지만 난 이미 nvm 이 설치되어있었기 때문에 다음의 동작만으로 에러를 제거할 수 있었다.</p>
<p>Webstorm 설정에서 node 를 최신 버전으로 설정하니 에러가 깨끗하게 사라져있었다!</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/9bdd435b-daf7-4304-94ef-05593bb05f33/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/065f6d8f-58cc-4175-8b47-b0c776bb291c/image.png" alt=""></p>
<p>설정을 수정해도 에러창이 계속 떠있다면, 웹스톰 프로그램을 캐시 제거후 다시 시작하면 된다!</p>
<p>File &gt; Invalidate Caches</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/83c7b754-19d2-40f3-a1ba-b66873adf724/image.png" alt=""></p>
<p>옵션을 모두 체크 후 Invalidate and Restart 를 누르면 끝!
<img src="https://velog.velcdn.com/images/h_jinny/post/c93ca236-4ff2-4353-a811-628c2f21e0c9/image.png" alt=""></p>
<p>참고:</p>
<p><a href="https://velog.io/@93minki/Next.js-ESLint-Airbnb-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0">https://velog.io/@93minki/Next.js-ESLint-Airbnb-추가하기</a>
<a href="https://stackoverflow.com/questions/76644474/syntaxerror-failed-to-load-parser-typescript-eslint-parser-declared-in-esl">https://stackoverflow.com/questions/76644474/syntaxerror-failed-to-load-parser-typescript-eslint-parser-declared-in-esl</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[javascript] body 스크롤 막기]]></title>
            <link>https://velog.io/@h_jinny/javascript-body-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EB%A7%89%EA%B8%B0</link>
            <guid>https://velog.io/@h_jinny/javascript-body-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EB%A7%89%EA%B8%B0</guid>
            <pubDate>Thu, 07 Dec 2023 01:33:32 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>스크롤 기능을 막아야하는 상황에서 대부분은 body 에 <code>overflow-y: hidden</code> 을 주는 방법이 제시되지만, 그렇지 않은 경우 자바스크립트로 이를 처리해야한다.</p>
</blockquote>
<p>처음엔 아래와 같이 이벤트를 주었지만 작동하지 않았다 ㅠ</p>
<pre><code class="language-jsx">$body.addEventListener(&#39;scroll&#39;, function(e) {
    e.preventDefault();
  })</code></pre>
<p>두번째 시도로 아래와 같이 다른 이벤트에도 적용해보았지만 작동은 하지 않고 에러까지 발생했다.</p>
<pre><code class="language-jsx">const $body = document.querySelector(&#39;body&#39;);
  function preventScroll(e) {
    e.preventDefault();
  }

  $body.addEventListener(&#39;scroll&#39;, preventScroll);
  $body.addEventListener(&#39;touchmove&#39;, preventScroll);
  $body.addEventListener(&#39;mousewheel&#39;, preventScroll);</code></pre>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/9dd6480e-742c-4a87-ba8e-8d751c812e44/image.png" alt=""></p>
<p>구글링을 해보니 <code>passive: false</code> 옵션을 추가해주면 된다고 한다. 그런데 <code>mousewheel</code> 이벤트와 같이 스크롤과 관련된 이벤트는 기본적으로 <strong><code>passive</code></strong>로 처리되어 추가 옵션을 설정할 수 없었다.</p>
<p>대신, <strong><code>wheel</code></strong> 이벤트를 사용하여 스크롤을 감지하고 <code>preventDefault</code>를 호출하면서 성능 최적화와 문제 해결을 동시에 할 수 있었다.</p>
<pre><code class="language-jsx">const $body = document.querySelector(&#39;body&#39;);
  function preventScroll(e) {
    e.preventDefault();
  }
  // &#39;wheel&#39; 이벤트를 사용하여 스크롤 감지 후 방지
  $body.addEventListener(&#39;wheel&#39;, preventScroll, { passive: false });
  $body.addEventListener(&#39;click&#39;, function() { // body 를 다시 클릭하면 스크롤 재개
    $body.removeEventListener(&#39;wheel&#39;, preventScroll, { passive: false });
  });</code></pre>
<p><strong><code>passive</code></strong>옵션을 추가하고 <code>wheel</code> 이벤트로 바꾸었더니 body 스크롤링이 잘 멈추었다.</p>
<p><code>wheel</code> 이벤트와 <code>scroll, mousewheel</code> 의 차이, 그리고 성능최적화에 대한 내용이 더 궁금하여 챗지피티에 물어보니, 내용은 다음과 같았다. </p>
<p><code>wheel</code> 이벤트는 마우스 휠 또는 트랙패드 등 더 다양한 입력 장치에서 발생하는 스크롤 이벤트로,  <code>mousewheel</code> 이벤트와 비슷하지만<code>wheel</code> 이벤트가 표준에 더 가깝고 여러 입력 장치에서의 일관성을 제공한다고 한다.</p>
<p>또 <code>wheel</code> 이벤트가 <code>passive</code>로 처리되는 것은 브라우저가 성능 최적화를 위해 도입한 개념 중 하나이고, 브라우저는 기본적으로 <code>wheel</code> 이벤트에 대해 <code>passive</code> 플래그를 설정하기 때문에 이벤트 리스너 내에서 <code>preventDefault</code>를 호출하지 못하게 한다.</p>
<p><code>passive</code> 플래그가 설정된 이벤트 리스너에서 <code>preventDefault</code>를 호출하는 것은 브라우저가 스크롤링 동작을 최적화하기 위해 이벤트 핸들러를 비동기적으로 실행하도록 허용하게 되는데, 이로 인해 호출 순서가 보장되지 않기 때문에 스크롤을 막아야 하는 경우에는 <code>passive: false</code>로 설정된 이벤트 리스너를 사용해야 한다..!</p>
<br>
참고: https://solbel.tistory.com/1299]]></description>
        </item>
        <item>
            <title><![CDATA[[Swiper.js] 페이지 링크 이동, 현재 슬라이드 표시 (hash navigation 옵션) (1)]]></title>
            <link>https://velog.io/@h_jinny/Swiper.js-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A7%81%ED%81%AC-%EC%9D%B4%EB%8F%99-%ED%98%84%EC%9E%AC-%EC%8A%AC%EB%9D%BC%EC%9D%B4%EB%93%9C-%ED%91%9C%EC%8B%9C-hash-navigation-%EC%98%B5%EC%85%98-1</link>
            <guid>https://velog.io/@h_jinny/Swiper.js-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A7%81%ED%81%AC-%EC%9D%B4%EB%8F%99-%ED%98%84%EC%9E%AC-%EC%8A%AC%EB%9D%BC%EC%9D%B4%EB%93%9C-%ED%91%9C%EC%8B%9C-hash-navigation-%EC%98%B5%EC%85%98-1</guid>
            <pubDate>Wed, 06 Dec 2023 05:15:31 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>사용자 입력폼을 여러 단계로 작성할 수 있는 부분을 레이어 팝업식으로 띄어지는것이 아닌, 브라우저 상에서도 뒤로가기, 앞으로 가기가 가능한 랜딩페이지 형식으로 만들고 싶었다. </p>
</blockquote>
<p>아래와 같은 사항때문이었다.</p>
<ul>
<li>4개의 페이지로 분리하면 페이지 이동 시 이전에 입력한 사용자의 입력값이 날아감<ul>
<li>처음엔 <code>sessionStorge</code> (<a href="https://www.notion.so/Javascript-localStorage-sessionStorage-8296bcb1fbf44ef18e58d35d036c7efa?pvs=21">링크</a>) 를 사용해서 페이지마다 데이터를 저장하려 시도했으나, 주민등록번호 등 예민한 정보도 포함되어있는 경우 고려</li>
</ul>
</li>
<li>spa 환경처럼 페이지전환이 부드럽지 않고 단계마다 페이지를 이동시  깜박거리는 현상 발생</li>
</ul>
<p>별도로 페이지마다 데이터를 저장하는것이 아닌, form 양식 안에서 입력값들을 한꺼번에 제출할 수 있기 때문에 앞서 말한 방식으로 진행하고 싶었다.</p>
<p>하지만 생각보다 간단한 해결책이 있었으니, 바로 swiper 의 <code>hashNavigation</code> 옵션을 사용하는 것이었다. 해당 옵션을 사용하면 위의 고려사항들이 모두 충족될 수 있었다. (swiper 에 이런 기능이 있는지도 몰랐고, a 태그로 링크 이동을 하는 방법을 생각해보지못한 내가 아쉬웠다 😓)</p>
<ul>
<li>swiper 의 next, prev 버튼 뿐만 아닌 브라우저상 뒤로가기, 앞으로 가기 버튼을 눌러도 페이지 이동이 가능</li>
<li><code>#</code> 를 사용하기 때문에 새로고침 발생이 되지 않고 각 단계에서의 사용자 정보가 보존됨</li>
</ul>
<pre><code class="language-jsx">&lt;div class=&quot;participant swiper-container&quot;&gt;
  &lt;div class=&quot;swiper-wrapper&quot;&gt;
    &lt;div class=&quot;swiper-slide&quot; data-hash=&quot;step-1&quot;&gt;Slide 1&lt;/div&gt;
    &lt;div class=&quot;swiper-slide&quot; data-hash=&quot;step-2&quot;&gt;Slide 2&lt;/div&gt;
    &lt;div class=&quot;swiper-slide&quot; data-hash=&quot;step-3&quot;&gt;Slide 3&lt;/div&gt;
    &lt;div class=&quot;swiper-slide&quot; data-hash=&quot;step-4&quot;&gt;Slide 4&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;swiper-button-next&quot;&gt;&lt;/div&gt;
  &lt;div class=&quot;swiper-button-prev&quot;&gt;&lt;/div&gt;
&lt;/div&gt;</code></pre>
<pre><code class="language-jsx">&lt;script&gt;
    const participantSwiper = new Swiper(&#39;.participant&#39;, {
      fadeEffect: {
        crossFade: true
      },
      hashNavigation: {
        watchState: true,
      },
      navigation: {
        nextEl: &#39;.swiper-button-next&#39;,
        prevEl: &#39;.swiper-button-prev&#39;,
      },
    });
&lt;/script&gt;</code></pre>
<h2 id="🚨-문제-발생--해결">🚨 문제 발생 &amp; 해결</h2>
<p>슬라이드처럼 보이지 않고 페이지가 전환되는 것처럼 보이도록 effect 효과를 빼고 싶었는데, 해당 방법은 보이지 않아 <code>effect : &#39;fade&#39;</code> 효과를 준 후 다음의 css 를 추가했다.</p>
<pre><code class="language-css">.swiper-container .swiper-wrapper .swiper-slide {
    transition: opacity 0s !important;
}</code></pre>
<p>그랬더니 슬라이드가 넘어갈 때 효과가 없이 휙휙 페이지처럼 전환되도록 하는것이 성공했다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/0ce92896-7204-4a79-85d5-aa2721c5bf58/image.png" alt=""></p>
<p>하지만 위의 css 를 적용하니 어째서인지 <code>hashNavigation</code> 가 작동하지 않는것이었다.😱 </p>
<p>그래서 <code>hashNavigation</code> 옵션을 사용하는 대신 아래처럼 직접 해시를  스크립트를 작성해보았다.</p>
<ul>
<li>슬라이드가 바뀌면 활성화된 슬라이드의 data-hash 가 url 의 해시(#)로 추가</li>
<li>url 로 접근시 이전 단계를 건너뛰고 폼을 작성할 수 있기 때문에 초기 로딩 시에는 항상 슬라이드가 첫번째로 설정</li>
<li>브라우저 앞/뒤로가기 버튼을 통해 url 의 해시(#)가 바뀌면 해시의 값에 따라 슬라이드도 활성화</li>
</ul>
<pre><code class="language-jsx">
const participantSwiper = new Swiper(&#39;.participant&#39;, {
  effect : &#39;fade&#39;,
  fadeEffect: {
    crossFade: true
  },
  // hashNavigation: {
  //   watchState: true,
  // },
  navigation: {
    nextEl: &#39;.swiper-button-next&#39;,
    prevEl: &#39;.swiper-button-prev&#39;,
  },
  on: {
    slideChange: function (e) { // 슬라이드의 변화에 따라 url 해시값 수정
      const activeSlide = participantSwiper.slides[participantSwiper.activeIndex];
      const dataHash = activeSlide.getAttribute(&#39;data-hash&#39;);

      // 현재 URL에 #와 함께 data-hash를 추가
      window.location.hash = dataHash;
    }
  }
});

function setActiveSlideByHash(action) {
    if (action === &#39;load&#39;) { // 초기 로딩 시 항상 첫번쩨 슬라이드여야함
    window.location.hash = &#39;step-1&#39;;
  } else {
        // 현재 URL의 해시 값을 가져와 해당 슬라이드로 이동
    const hash = window.location.hash.replace(&#39;#&#39;, &#39;&#39;); 
    const slideIndex = participantSwiper.slides.findIndex((slide) =&gt; slide.getAttribute(&#39;data-hash&#39;) === hash);

        // hash와 일치하는 data-hash를 가진 슬라이드가 발견되지 않은 경우
    if (slideIndex !== -1) {
      participantSwiper.slideTo(slideIndex);
    }
  }
}

    // 브라우저의 뒤로가기 및 앞으로가기 버튼을 처리
window.addEventListener(&#39;popstate&#39;, function () {
    setActiveSlideByHash(&#39;popstate&#39;);
});

    // 초기 로딩 시 URL의 해시값을 확인하고 해당 슬라이드로 이동
window.addEventListener(&#39;load&#39;, function () {
    setActiveSlideByHash(&#39;load&#39;);
});</code></pre>
<p>예전엔 특정 요소의 index 를 찾을 때 <code>find()</code> 메서드를 사용 후 그 안에서 index 값을 또 구했는데, 그럴 필요 없는 <code>findIndex()</code> 라는 메서드가 있다는 것을 뒤늦게 기억했다..!</p>
<p>그리고 오히려 좋아진 점이, <code>hashNavigation</code> 를 썼을 땐 뒤로가기가 무조건 이전 슬라이드로 가는것이 아닌, prev, next 버튼을 통해 들렀던 슬라이드로 전환되기 때문에 뒤죽박죽 되었던 반면, </p>
<p>(예: 두번째 슬라이드에서 뒤로가기를 누르면 보통 첫번째 슬라이드로 가야하는데, swiper 의 prev 나 next 버튼을 사용했던 내역때문에 세번째 슬라이드로 이동하는 경우) </p>
<p>해당 함수를 사용하면 내가 이전에 어떤 슬라이드를 들렀건 무조건 현재 슬라이드에서 한칸 이전 슬라이드로 이동하기 때문에 더 편해졌다.</p>
<p><img src="https://velog.velcdn.com/images/h_jinny/post/75ad8fed-28f0-4aa9-9d8d-dd6d39ca522c/image.png" alt=""></p>
<p>정상작동하는 것 까지 확인 완료 😘</p>
<h2 id="-추가">+ 추가</h2>
<ul>
<li><code>noSwiping</code> 옵션을 주어 마우스로 드래그하여 슬라이드를 움직이는 것을 막아주었다.</li>
<li>전체 슬라이드 중 현재 활성화된 슬라이드의 번호도 표시해주었다. (<code>2 / 4</code>, <code>3 / 4</code> … 의 형태로)</li>
</ul>
<pre><code class="language-html">&lt;div class=&quot;participant swiper-container&quot;&gt;
  &lt;div class=&quot;swiper-wrapper&quot;...&gt;

    &lt;div class=&quot;btn-wrap&quot;&gt;
    &lt;div class=&quot;slide-info&quot;&gt;1 / 4&lt;/div&gt;
    &lt;div class=&quot;swiper-button-next&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;swiper-button-prev&quot;&gt;&lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;</code></pre>
<pre><code class="language-jsx">const participantSwiper = new Swiper(&#39;.participant&#39;, {
    // ...이전 코드

    // 마우스 드래그 동작으로 인한 슬라이딩 방지
    noSwiping: true,
    noSwipingClass: &#39;swiper-slide&#39;,

    // 슬라이드 번호 표시
    on: {
      slideChange: function () {
        // ...이전 코드

        // 현재 활성화된 슬라이드 번호 표시
        const activeSlideIndex = participantSwiper.activeIndex + 1; // 현재 활성화된 슬라이드 번호
        const totalSlides = participantSwiper.slides.length; // 총 슬라이드 수
        const text = activeSlideIndex + &#39; / &#39; + totalSlides;
        document.querySelector(&#39;.slide-info&#39;).textContent = text;
      }
    }
}

let totalSlides = participantSwiper.slides.length; // 총 슬라이드 수
let slideInfo = document.querySelector(&#39;.slide-info&#39;);
slideInfo.textContent = `1 / ${totalSlides}`;</code></pre>
<h2 id="-슬라이드-effect-해결">+ 슬라이드 effect 해결</h2>
<p>swiper 에 슬라이드 속도를 조절해주는 <code>speed</code> 라는 옵션을 주면 앞서 주었던 css 수정, effect 옵션 추가 등 복잡한 추가 작업 없이 바로 효과가 없는 페이지 전환 슬라이드를 줄 수 있었다..!</p>
<p>하지만 <code>hashNavigation</code> 를 쓰면 초기 로딩 시 url 이 첫번째 해시로 지정되지 않는 버그가 있어 계속 안쓰고 그냥 <code>slideChange</code> 의 코드를 그대로 썼다.</p>
<ul>
<li>opacity transition 을 0 으로 줬던 css 삭제</li>
<li>swiper 의 옵션 effect, fadeEffect 삭제</li>
<li>swiper 의 옵션 speed 를 0으로 설정</li>
</ul>
<pre><code class="language-jsx">const participantSwiper = new Swiper(&#39;.participant&#39;, {
  speed: 0, ...</code></pre>
<h3 id="최종-코드">최종 코드:</h3>
<pre><code class="language-jsx">const participantSwiper = new Swiper(&#39;.participant&#39;, {
  speed: 0,
  // hashNavigation: {
  //   watchState: true,
  // },
  noSwiping: true,
  noSwipingClass: &#39;swiper-slide&#39;,
  spaceBetween: 30,
  navigation: {
    nextEl: &#39;.swiper-button-next&#39;,
    prevEl: &#39;.swiper-button-prev&#39;,
  },
  on: {
    slideChange: function (e) { // 슬라이드의 변화에 따라 url 해시값 수정
      const activeSlide = participantSwiper.slides[participantSwiper.activeIndex];
      const dataHash = activeSlide.getAttribute(&#39;data-hash&#39;);
      // 현재 URL에 #와 함께 data-hash를 추가
      window.location.hash = dataHash;

      // 현재 활성화된 슬라이드 번호 표시
      const activeSlideIndex = participantSwiper.activeIndex + 1; // 현재 활성화된 슬라이드 번호
      const totalSlides = participantSwiper.slides.length; // 총 슬라이드 수
      const text = activeSlideIndex + &#39; / &#39; + totalSlides;
      document.querySelector(&#39;.slide-info&#39;).textContent = text;
    }
  }
});

// 초기 슬라이드 -&gt; 현재 슬라이드 번호 / 총 슬라이드 수
let totalSlides = participantSwiper.slides.length; // 총 슬라이드 수
let slideInfo = document.querySelector(&#39;.slide-info&#39;);
slideInfo.textContent = `1 / ${totalSlides}`;

function setActiveSlideByHash(action) {
  if (action === &#39;load&#39;) {
    window.location.hash = &#39;step-1&#39;;
  } else {
    const hash = window.location.hash.replace(&#39;#&#39;, &#39;&#39;);
    const slideIndex = participantSwiper.slides.findIndex((slide) =&gt; slide.getAttribute(&#39;data-hash&#39;) === hash);
    if (slideIndex !== -1) { // hash와 일치하는 data-hash를 가진 슬라이드가 발견되지 않은 경우
      participantSwiper.slideTo(slideIndex);
    }
  }
}

// 브라우저의 뒤로가기 및 앞으로가기 버튼을 처리
window.addEventListener(&#39;popstate&#39;, function () {
  setActiveSlideByHash(&#39;popstate&#39;);
});

// 초기 로딩 시 URL의 해시값을 확인하고 해당 슬라이드로 이동
window.addEventListener(&#39;load&#39;, function () {
  setActiveSlideByHash(&#39;load&#39;);
});</code></pre>
]]></description>
        </item>
    </channel>
</rss>