<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>woozi_zi.log</title>
        <link>https://velog.io/</link>
        <description>항상 “Why?”로 시작하는 프론트엔드 개발자</description>
        <lastBuildDate>Mon, 26 Jan 2026 02:05:40 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>woozi_zi.log</title>
            <url>https://velog.velcdn.com/images/woozi__zi/profile/e2ac2fc0-9e1b-4b04-b95d-214f12c14c1f/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. woozi_zi.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/woozi__zi" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[카카오톡 인앱 브라우저만 영상 다운로드가 안 된다? (Blob vs Location.href)]]></title>
            <link>https://velog.io/@woozi__zi/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%9D%B8%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EB%A7%8C-%EC%98%81%EC%83%81-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EA%B0%80-%EC%95%88-%EB%90%9C%EB%8B%A4-Blob-vs-Location.href</link>
            <guid>https://velog.io/@woozi__zi/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%9D%B8%EC%95%B1-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EB%A7%8C-%EC%98%81%EC%83%81-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%EA%B0%80-%EC%95%88-%EB%90%9C%EB%8B%A4-Blob-vs-Location.href</guid>
            <pubDate>Mon, 26 Jan 2026 02:05:40 GMT</pubDate>
            <description><![CDATA[<p>모바일 웹 서비스를 개발하다 보면 예상치 못한 복병을 만나곤 합니다. 이번에는 <strong>카카오톡 인앱 브라우저(In-App Browser)</strong>에서의 파일 다운로드 이슈였습니다. 일반 브라우저에서는 멀쩡하던 기능이 왜 카톡에서만 침묵했는지, 그 원인과 해결 과정을 정리해 봅니다.</p>
<hr>
<h2 id="1-문제-상황-the-problem">1. 문제 상황 (The Problem)</h2>
<p>서비스 내에 영상 다운로드 기능을 구현했고, PC 및 일반 모바일 브라우저(Chrome, Safari)에서는 기기 저장소에 파일이 잘 저장되는 것을 확인했습니다. 하지만 카카오톡 인앱 브라우저로 접속했을 때 기이한 현상이 발생했습니다.</p>
<ul>
<li>✅ <code>&quot;다운로드를 시작합니다&quot;</code> -&gt; <code>&quot;다운로드가 완료되었습니다&quot;</code> Toast 메시지는 정상 출력.</li>
<li>✅ 개발자 도구상 에러 로그 <strong>없음</strong>.</li>
<li>❌ <strong>하지만 실제 파일은 기기 어디에도 다운로드되지 않음.</strong></li>
</ul>
<p>사용자 입장에서는 성공했다는데 파일은 없는, 소위 <strong>&#39;유령 다운로드&#39;</strong> 상태가 된 것이죠.</p>
<hr>
<h2 id="2-원인-분석-root-cause">2. 원인 분석 (Root Cause)</h2>
<h3 id="1-기존-구현-방식-fetch--blob">1) 기존 구현 방식: Fetch + Blob</h3>
<p>보안을 위해 API 요청 시 인증 헤더(<code>Authorization</code>)를 실어 보내야 했기에, 일반적인 <code>&lt;a&gt;</code> 태그의 <code>href</code> 링크 직접 이동 대신 자바스크립트의 <code>fetch</code>를 사용했습니다.</p>
<ol>
<li><code>fetch</code>로 바이너리 데이터를 받아옴</li>
<li><code>Blob</code> 객체 생성</li>
<li><code>URL.createObjectURL(blob)</code>로 가상의 URL 생성</li>
<li>임시 <code>&lt;a&gt;</code> 태그를 생성해 <code>click()</code> 이벤트 트리거</li>
</ol>
<h3 id="2-인앱-브라우저의-제약">2) 인앱 브라우저의 제약</h3>
<p>원인은 카카오톡 인앱 브라우저(WebView)의 보안 정책 때문이었습니다. 인앱 브라우저는 보안 및 메모리 관리상의 이유로 <strong>스크립트를 통한 Blob 생성 및 <code>a.download</code> 속성을 이용한 다운로드를 차단</strong>하거나 정상적으로 처리하지 못하는 경우가 많습니다. </p>
<p>코드는 에러 없이 끝까지 실행되어 Toast는 떴지만, 브라우저 단에서 기기의 파일 시스템으로 접근하는 것을 막아버린 것입니다.</p>
<h3 id="3-왜-재생play은-가능했나">3) 왜 재생(Play)은 가능했나?</h3>
<p>영상 재생(<code>&lt;video src=&quot;...&quot;&gt;</code>)이나 일반적인 데이터 조회 API는 <code>fetch</code> 이후 데이터를 브라우저의 &#39;메모리&#39;상에서만 핸들링하므로 문제가 없었습니다. 오직 <strong>&quot;파일 시스템에 저장&quot;</strong>하려는 행위만 차단된 것이 핵심이었습니다.</p>
<hr>
<h2 id="3-해결-방법-solution">3. 해결 방법 (Solution)</h2>
<p>카카오톡 환경에서는 Blob 방식 대신 <strong>브라우저 네이티브 다운로드 방식(URL 직접 이동)</strong>을 사용하기로 결정했습니다.</p>
<h3 id="step-1-프론트엔드-분기-처리">STEP 1: 프론트엔드 분기 처리</h3>
<p>UserAgent를 체크하여 카카오톡일 경우 <code>fetch</code> 과정을 거치지 않고 <code>window.location.href</code>로 API 엔드포인트에 직접 접근시킵니다.</p>
<pre><code class="language-typescript">// src/hooks/use-video-download.ts

const downloadVideo = async (serviceMessageId: number) =&gt; {
  const params = new URLSearchParams();
  params.append(&quot;serviceMessageId&quot;, serviceMessageId.toString());

  // 💡 카카오톡 인앱 브라우저 체크
  const isKakaoTalk = /KAKAOTALK/i.test(navigator.userAgent);

  if (isKakaoTalk) {
    // 카카오톡은 Blob 다운로드가 차단되므로 네이티브 다운로드 유도
    // (이 경우 헤더 대신 쿠키 인증을 활용하게 됨)
    const downloadUrl = `/api/video/download?${params.toString()}`;
    window.location.href = downloadUrl;
    return;
  }

  // 일반 브라우저는 기존 방식 (fetch + Blob) 유지
  // ... fetch 및 Blob 다운로드 로직 수행
};</code></pre>
<h3 id="step-2-백엔드-인증-방식-개선-핵심">STEP 2: 백엔드 인증 방식 개선 (핵심)</h3>
<p>여기서 난관이 생깁니다. <code>window.location.href</code>로 이동하면 프론트엔드에서 커스텀 헤더(<code>Authorization</code>)를 강제로 욱여넣을 수 없습니다. </p>
<p>이를 해결하기 위해 <strong>쿠키(Cookie)</strong>를 활용했습니다. 브라우저는 같은 도메인으로 요청을 보낼 때 쿠키를 자동으로 실어 보내기 때문입니다. 백엔드 API가 헤더뿐만 아니라 쿠키도 확인하도록 로직을 수정했습니다.</p>
<pre><code class="language-typescript">// src/app/api/video/download/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;

export async function GET(request: NextRequest) {
  // 1. 우선 Authorization 헤더 확인 (기존 Blob 방식 대응)
  let authHeader = request.headers.get(&quot;Authorization&quot;);

  // 2. 헤더가 없으면 쿠키에서 토큰 확인 (카카오톡 직접 링크 이동 대응)
  if (!authHeader) {
    const cookieToken = request.cookies.get(&quot;celegram_user_access_token&quot;)?.value;
    if (cookieToken) {
      authHeader = `Bearer ${cookieToken}`;
    }
  }

  if (!authHeader) {
    return NextResponse.json({ error: &quot;인증 토큰이 없습니다.&quot; }, { status: 401 });
  }

  // ... 이후 인증 통과 시 파일 스트리밍 및 다운로드 로직 실행
}</code></pre>
<hr>
<h2 id="4-마치며-retrospective">4. 마치며 (Retrospective)</h2>
<p>이번 이슈를 통해 몇 가지 중요한 교훈을 얻었습니다.</p>
<ol>
<li><strong>인앱 브라우저의 변수:</strong> 카카오톡, 라인, 인스타그램 등 인앱 브라우저는 표준 브라우저와 동작이 다를 수 있음을 항상 염두에 두어야 합니다. 특히 &#39;파일 시스템 접근&#39;이나 &#39;팝업 제어&#39;에서 차이가 큽니다.</li>
<li><strong>JS 다운로드 vs 네이티브 다운로드:</strong> Blob 방식은 헤더 제어가 가능해 세밀한 보안 설정이 가능하지만 호환성 이슈가 있습니다. 반면 <code>location.href</code>는 투박해 보이지만 가장 확실한 호환성을 보장합니다.</li>
<li><strong>유연한 인증 설계:</strong> API 설계 시 헤더 인증과 쿠키 인증을 모두 고려하면, 이번처럼 브라우저가 직접 요청을 보내야 하는 제한적인 상황에서도 유연하게 대처할 수 있습니다. <em>(단, 쿠키 사용 시 <code>httpOnly</code>, <code>Secure</code> 등 보안 설정은 필수!)</em></li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Safari 비디오 재생 오류와 CORS 리다이렉트 해결기]]></title>
            <link>https://velog.io/@woozi__zi/Troubleshooting-Safari-%EB%B9%84%EB%94%94%EC%98%A4-%EC%9E%AC%EC%83%9D-%EC%98%A4%EB%A5%98%EC%99%80-CORS-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%ED%8A%B8-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@woozi__zi/Troubleshooting-Safari-%EB%B9%84%EB%94%94%EC%98%A4-%EC%9E%AC%EC%83%9D-%EC%98%A4%EB%A5%98%EC%99%80-CORS-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%ED%8A%B8-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Fri, 23 Jan 2026 07:21:31 GMT</pubDate>
            <description><![CDATA[<p>개발 중인 서비스에서 Vimeo에 호스팅된 비영구적 비디오 URL(Signed URL)을 재생해야 하는 상황이었습니다. 하지만 특정 브라우저 환경에서 예상치 못한 에러가 발생했고, 이를 해결해 나간 과정을 공유합니다.</p>
<hr>
<h2 id="1-문제-상황-the-problem">1. 문제 상황 (The Problem)</h2>
<ul>
<li><strong>Chrome (PC):</strong> 영상이 정상적으로 재생됨.</li>
<li><strong>Safari (Mac/iOS) 및 Chrome (iOS):</strong> 영상이 재생되지 않고 무한 로딩되거나 <code>403 Forbidden</code> 에러 발생.</li>
</ul>
<blockquote>
<p>💡 <strong>참고:</strong> iOS 환경에서는 Chrome 브라우저 역시 Safari와 동일한 WebKit 엔진을 강제로 사용하기 때문에 Safari와 동일한 에러가 발생합니다.</p>
</blockquote>
<hr>
<h2 id="2-원인-분석-root-cause">2. 원인 분석 (Root Cause)</h2>
<h3 id="1차-원인-iframe과-모바일-정책">1차 원인: Iframe과 모바일 정책</h3>
<p>초기에는 Vimeo 플레이어를 <code>iframe</code>으로 임베드하여 사용하고 있었습니다. 하지만 iOS Safari는 <code>iframe</code> 내의 미디어 자동 재생이나 쿠키(서드파티 쿠키) 공유에 매우 엄격한 정책을 가지고 있습니다. 이로 인해 토큰 인증이 제대로 이루어지지 않거나 브라우저 정책에 의해 차단되었습니다.</p>
<h3 id="2차-원인-핵심-cors와-authorization-헤더">2차 원인 (핵심): CORS와 Authorization 헤더</h3>
<p>모바일 호환성을 위해 <code>iframe</code>을 제거하고 HTML5 <code>&lt;video&gt;</code> 태그를 사용하여 직접 API를 호출하려고 했으나, 더 복잡한 문제가 발생했습니다.</p>
<p><strong>기존 API 흐름:</strong>
<code>클라이언트</code> → <code>백엔드 API (/video/play, Auth 헤더 포함)</code> → <code>302 리다이렉트</code> → <code>Vimeo CDN</code></p>
<ul>
<li><strong>Chrome의 동작:</strong> 리다이렉트 시 <code>Authorization</code> 헤더를 적절히 처리하거나, Cross-Origin 리다이렉트를 유연하게 허용합니다.</li>
<li><strong>Safari의 동작:</strong> 리다이렉트를 따라갈 때 <strong><code>Authorization</code> 헤더를 그대로 유지하여 Vimeo CDN으로 보냅니다.</strong> 하지만 Vimeo CDN은 타 도메인의 인증 헤더를 허용하지 않기 때문에(<code>Access-Control-Allow-Headers</code> 미포함), CORS Preflight가 실패하거나 <code>403 Forbidden</code>을 반환하게 됩니다.</li>
</ul>
<p><strong>🚨 콘솔 에러 메시지:</strong></p>
<blockquote>
<p><code>Request header field Authorization is not allowed by Access-Control-Allow-Headers.</code></p>
</blockquote>
<hr>
<h2 id="3-시도했던-방법-attempted-solutions">3. 시도했던 방법 (Attempted Solutions)</h2>
<h3 id="❌-시도-1-iframe-→-video-태그-교체">❌ 시도 1: Iframe → Video 태그 교체</h3>
<ul>
<li><strong>접근:</strong> 모바일 호환성을 위해 <code>iframe</code>을 걷어내고 <code>&lt;video&gt;</code> 태그로 변경.</li>
<li><strong>결과 (실패):</strong> <code>&lt;video&gt;</code> 태그의 <code>src</code> 속성으로는 요청 헤더(<code>Authorization</code>)를 조작할 수 없어 인증 토큰을 보낼 수 없었습니다.</li>
</ul>
<h3 id="❌-시도-2-url-쿼리-파라미터로-토큰-전달">❌ 시도 2: URL 쿼리 파라미터로 토큰 전달</h3>
<ul>
<li><strong>접근:</strong> 헤더 대신 <code>?accessToken=...</code> 형태의 쿼리 파라미터로 백엔드에 인증 정보를 전달.</li>
<li><strong>결과 (실패):</strong> 백엔드에서 인증은 성공하지만, 리다이렉트되는 과정에서 Safari가 Cross-Origin 리다이렉트를 차단하거나 파라미터를 유실시켰습니다.</li>
</ul>
<h3 id="❌-시도-3-client-side-fetch로-최종-url-획득">❌ 시도 3: Client-side Fetch로 최종 URL 획득</h3>
<ul>
<li><strong>접근:</strong> 클라이언트단에서 자바스크립트 <code>fetch</code>로 먼저 API를 호출하여 리다이렉트된 최종 URL(<code>response.url</code>)을 알아낸 뒤, 그 URL을 <code>&lt;video&gt;</code> 태그에 주입.</li>
<li><strong>결과 (실패):</strong> Safari는 <code>fetch</code>가 리다이렉트를 따라갈 때도 엄격한 CORS 정책을 적용하여 스크립트단에서 에러가 발생했습니다. <em>(에러: Fetch API cannot load ... due to access control checks)</em></li>
</ul>
<hr>
<h2 id="4-최종-해결책-final-solution">4. 최종 해결책 (Final Solution)</h2>
<h3 id="✨-nextjs-api-route를-이용한-서버-사이드-프록시-server-side-proxy">✨ &quot;Next.js API Route를 이용한 서버 사이드 프록시 (Server-side Proxy)&quot;</h3>
<p>브라우저(Safari)의 까다로운 CORS 정책을 우회하기 위해, 클라이언트가 직접 요청하는 대신 <strong>서버(Next.js)가 대신 요청을 보내도록 구조를 변경</strong>했습니다.</p>
<p><strong>변경된 아키텍처 흐름:</strong></p>
<ol>
<li><strong>Client:</strong> 브라우저는 Next.js의 내부 API (<code>/api/video/play</code>)를 호출합니다. <em>(같은 도메인이므로 CORS 문제 발생 X)</em></li>
<li><strong>Next.js Server:</strong> 서버 사이드에서 백엔드 API를 호출합니다. <strong>서버 간 통신(Server-to-Server)에는 브라우저의 CORS 정책이 적용되지 않습니다.</strong></li>
<li><strong>Next.js Server:</strong> 백엔드가 리다이렉트해준 최종 Vimeo CDN URL을 받아옵니다.</li>
<li><strong>Client:</strong> 서버로부터 최종 URL(순수 CDN 주소)을 응답받아 <code>&lt;video&gt;</code> 태그에 삽입하여 재생합니다.</li>
</ol>
<p><strong>적용 코드 예시 (Next.js API Route):</strong></p>
<pre><code class="language-typescript">// src/app/api/video/play/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;

export async function GET(request: NextRequest) {
  // 1. 서버 사이드에서 백엔드 호출 (CORS 제약 없음)
  const response = await fetch(`${process.env.BACKEND_API}/video/play?...`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
    redirect: &quot;follow&quot;, // 리다이렉트 끝까지 추적
  });

  // 2. 최종 도달한 순수 URL(Vimeo CDN)만 클라이언트에 반환
  return NextResponse.json({ videoUrl: response.url });
}</code></pre>
<hr>
<h2 id="5-결론-key-takeaway">5. 결론 (Key Takeaway)</h2>
<ol>
<li><strong>&quot;Safari는 다르다&quot;</strong>: Chrome에서 잘 동작한다고 안심해서는 안 됩니다. 특히 Cross-Origin 환경에서의 리다이렉트와 커스텀 헤더(<code>Authorization</code> 등) 처리에 있어서 Safari는 매우 엄격한 보안 기준을 적용합니다.</li>
<li><strong>CORS는 브라우저만의 정책이다</strong>: CORS(Cross-Origin Resource Sharing) 에러는 오직 &#39;브라우저&#39;가 요청을 보낼 때만 발생합니다. 서버와 서버 간의 통신에는 적용되지 않으므로, 브라우저 정책 때문에 막힐 때는 <strong>서버를 프록시(Proxy)로 활용하는 것</strong>이 가장 확실하고 우아한 해결책이 될 수 있습니다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[iOS Safari 환경에서 Vimeo 영상 재생 실패 해결]]></title>
            <link>https://velog.io/@woozi__zi/vimeo-%EC%98%81%EC%83%81-%EC%95%88%EB%90%AC%EB%8D%98-%EB%AC%B8%EC%9E%90</link>
            <guid>https://velog.io/@woozi__zi/vimeo-%EC%98%81%EC%83%81-%EC%95%88%EB%90%AC%EB%8D%98-%EB%AC%B8%EC%9E%90</guid>
            <pubDate>Fri, 23 Jan 2026 05:40:18 GMT</pubDate>
            <description><![CDATA[<p>최근 프로젝트를 진행하며 PC 크롬에서는 멀쩡히 잘 나오던 Vimeo 영상이 iPhone(iOS)의 Safari와 Chrome에서만 재생되지 않는 이슈를 겪었습니다. 로그인을 비롯한 다른 기능은 문제가 없는데 유독 영상만 나오지 않아 원인을 파악하고 해결한 과정을 정리해 봅니다.</p>
<h2 id="1-문제-상황">1. 문제 상황</h2>
<ul>
<li><p>PC 환경: Chrome, Edge 등 모든 브라우저에서 정상 재생.</p>
</li>
<li><p>iOS 환경: iPhone의 Safari 및 Chrome에서 영상 영역이 까맣게 나오거나 재생 버튼이 작동하지 않음.</p>
</li>
<li><p>특이사항: iOS의 Chrome 역시 Safari와 동일한 WebKit 엔진을 사용하기 때문에 동일한 증상이 발생함.</p>
</li>
</ul>
<h2 id="2-원인-분석-itp-intelligent-tracking-prevention">2. 원인 분석: ITP (Intelligent Tracking Prevention)</h2>
<p>문제의 핵심은 iOS와 macOS Safari에 탑재된 ITP(지능형 추적 방지) 기능이었습니다.</p>
<h3 id="왜-재생이-안-되었을까">왜 재생이 안 되었을까?</h3>
<ul>
<li><p>서드파티 쿠키 차단: Vimeo 플레이어는 iframe 형태로 로드됩니다. 이때 Vimeo는 사용자를 식별하고 재생 상태(세션)를 유지하기 위해 쿠키를 생성하려고 시도합니다.</p>
</li>
<li><p>추적자로 간주: Safari의 ITP는 이를 &quot;사용자 추적 행위&quot;로 간주하여 Vimeo가 생성하려는 서드파티 쿠키를 강력하게 차단합니다.</p>
</li>
<li><p>권한 획득 실패: 특히 비공개 설정이나 특정 도메인 제한이 걸린 영상의 경우, 사용자를 식별할 수 있는 쿠키가 필수적입니다. 이 쿠키가 차단되니 Vimeo 서버로부터 재생 권한을 얻지 못해 영상이 나오지 않았던 것입니다.</p>
</li>
</ul>
<h2 id="3-해결-방법-itp-우회-설정-적용">3. 해결 방법: ITP 우회 설정 적용</h2>
<p>문제를 해결하기 위해 video-player.tsx 컴포넌트의 Vimeo 로드 로직에 두 가지 설정을 추가했습니다.</p>
<h3 id="a-do-not-track-dnt1-파라미터-추가">A. Do Not Track (dnt=1) 파라미터 추가</h3>
<p>Vimeo에게 <strong>&quot;우리는 사용자를 추적하지 않을 것이니 추적용 쿠키를 생성하지 마&quot;</strong>라고 명시적으로 알려주는 설정입니다.</p>
<pre><code>// src/components/common/video-player.tsx

// 추적 방지 설정을 통해 iOS(ITP) 호환성 향상

const url = new URL(vimeoUrl);
url.searchParams.set(&quot;dnt&quot;, &quot;1&quot;); </code></pre><p>효과: dnt=1이 설정되면 Vimeo 플레이어는 추적용 쿠키 생성을 시도하지 않습니다. 결과적으로 Safari의 ITP 차단 정책에 걸리지 않게 되어 영상이 정상적으로 로드됩니다.</p>
<h3 id="b-referrer-policy-변경">B. Referrer Policy 변경</h3>
<p>Vimeo 영상이 특정 도메인에서만 재생되도록 허용(Domain Whitelist)되어 있는 경우, 요청이 어디서 왔는지 정확히 알려줘야 합니다.</p>
<pre><code>// iframe 태그 속성 추가
referrerPolicy=&quot;strict-origin-when-cross-origin&quot;</code></pre><ul>
<li><p>기존: origin (도메인 정보만 보냄)</p>
</li>
<li><p>변경: strict-origin-when-cross-origin</p>
</li>
</ul>
<p><strong>이유</strong> : 보안을 유지하면서도 Vimeo 서버가 &quot;우리 서비스 도메인&quot;에서 온 요청임을 정확히 식별할 수 있게 도와줍니다. 이를 통해 도메인 제한이 걸린 영상도 안전하게 재생할 수 있습니다.</p>
<h2 id="4-마치며">4. 마치며</h2>
<p>iOS 환경은 보안 및 개인정보 보호 정책이 매우 엄격하기 때문에, 서드파티 서비스를 연동할 때 예상치 못한 이슈가 발생하곤 합니다.</p>
<p>이번 이슈는 ITP의 작동 원리를 이해하고, Vimeo 측에서 제공하는 dnt 옵션을 활용해 해결할 수 있었습니다. 혹시 비슷한 문제를 겪고 계신 프론트엔드 개발자분들께 도움이 되길 바랍니다.</p>
<p>📝 한 줄 요약
&quot;iOS Safari의 ITP 정책으로 인해 차단되는 서드파티 쿠키 문제를 dnt=1 파라미터 추가를 통해 해결하여 영상 재생 호환성을 확보했습니다.&quot;</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js App Router: Server/Client Component 완벽 이해하기 - shadcn/ui Carousel 최적화 여정]]></title>
            <link>https://velog.io/@woozi__zi/Next.js-App-Router-ServerClient-Component-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-shadcnui-Carousel-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%97%AC%EC%A0%95</link>
            <guid>https://velog.io/@woozi__zi/Next.js-App-Router-ServerClient-Component-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-shadcnui-Carousel-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%97%AC%EC%A0%95</guid>
            <pubDate>Wed, 15 Oct 2025 02:56:14 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-시작-왜-use-client를-또-써야-하지">🤔 시작: 왜 &#39;use client&#39;를 또 써야 하지?</h2>
<p>Next.js App Router로 프로젝트를 진행하면서 shadcn/ui의 Carousel 컴포넌트를 사용할 때 겪은 혼란과 그 해결 과정을 공유합니다.</p>
<h3 id="초기-상황">초기 상황</h3>
<pre><code class="language-typescript">// carousel.tsx - shadcn/ui 컴포넌트
&#39;use client&#39;;

export const Carousel = ({ children }) =&gt; {
  // ... carousel 로직
}

export const CarouselContent = ({ children }) =&gt; {
  // ...
}

export const CarouselItem = ({ children }) =&gt; {
  // ...
}</code></pre>
<pre><code class="language-typescript">// buy-section.tsx
import { Carousel, CarouselContent, CarouselItem } from &#39;@/components/ui/carousel&#39;;

export const BuySection = () =&gt; {
  return (
    &lt;Carousel&gt;
      &lt;CarouselContent&gt;
        &lt;CarouselItem&gt;...&lt;/CarouselItem&gt;
      &lt;/CarouselContent&gt;
    &lt;/Carousel&gt;
  );
};</code></pre>
<p>이렇게 코드를 작성했더니 에러가 발생했습니다:</p>
<pre><code>Error: Cannot access displayName.valueOf on the server. 
You cannot dot into a client module from a server component.</code></pre><p><strong>첫 번째 의문</strong>: <code>carousel.tsx</code>에 이미 <code>&#39;use client&#39;</code>가 있는데, 왜 <code>buy-section.tsx</code>에도 써야 하는 걸까?</p>
<hr>
<h2 id="💡-핵심-개념-server-component는-client-module의-내부를-들여다볼-수-없다">💡 핵심 개념: Server Component는 Client Module의 내부를 &quot;들여다볼 수 없다&quot;</h2>
<h3 id="1-nextjs의-serverclient-경계">1. Next.js의 Server/Client 경계</h3>
<pre><code class="language-typescript">Server Component → Client Component (O)  // 참조 가능
Server Component → Client Component 내부 export (X)  // 접근 불가</code></pre>
<h3 id="2-왜-에러가-발생했나">2. 왜 에러가 발생했나?</h3>
<pre><code class="language-typescript">// Server Component에서:
import { Carousel, CarouselContent, CarouselItem } from &#39;@/components/ui/carousel&#39;;

// 이건 사실 이런 의미:
import * as CarouselModule from &#39;./carousel&#39;;  // Client 모듈

const Carousel = CarouselModule.Carousel;           // ❌ 내부 접근
const CarouselContent = CarouselModule.CarouselContent;  // ❌ 내부 접근
const CarouselItem = CarouselModule.CarouselItem;       // ❌ 내부 접근</code></pre>
<p><strong>Server는 Client 모듈의 여러 export를 개별적으로 사용할 수 없습니다!</strong></p>
<h3 id="3-실행-환경의-차이">3. 실행 환경의 차이</h3>
<pre><code>서버 (Node.js 환경)
  ↓
  buy-section.tsx를 실행
  ↓
  &lt;Carousel&gt;를 만나면?
  → &quot;아, 이건 Client Component구나!&quot; ✅
  → Placeholder 생성
  ↓
  &lt;CarouselContent&gt;를 만나면?
  → ❌ &quot;어? 이게 뭐지? carousel.tsx 파일 내부를 봐야 하는데...&quot;
  → ❌ &quot;하지만 이건 Client 모듈이라 내부를 볼 수 없어!&quot;</code></pre><h3 id="4-번들링-관점에서-보기">4. 번들링 관점에서 보기</h3>
<pre><code>빌드 시:

Server Bundle (서버용)          Client Bundle (브라우저용)
├─ buy-section.tsx            ├─ carousel.tsx (전체!)
│  └─ Carousel 참조만           │  ├─ Carousel
│     (실제 코드는 X)            │  ├─ CarouselContent
                               │  ├─ CarouselItem
                               │  └─ useEmblaCarousel
                               │  └─ React hooks
                               │  └─ 브라우저 API</code></pre><p>Server에서 <code>CarouselContent</code>를 직접 사용하려면 <code>carousel.tsx</code> 파일을 파싱해야 하지만, 이 파일은 브라우저 전용 코드를 포함하고 있어 Server 번들에 넣을 수 없습니다.</p>
<hr>
<h2 id="🎨-shadcnui가-nextjs와-잘-맞는-진짜-이유">🎨 shadcn/ui가 Next.js와 잘 맞는 진짜 이유</h2>
<p>처음에는 &quot;Carousel을 쓰는 곳마다 <code>&#39;use client&#39;</code>를 써야 하는데 왜 Next.js랑 잘 맞다고 하지?&quot;라는 의문이 들었습니다.</p>
<h3 id="오해-모든-것을-server-component로가-아닙니다">오해: &quot;모든 것을 Server Component로&quot;가 아닙니다</h3>
<p>Next.js의 철학은:</p>
<ul>
<li>✅ <strong>기본은 Server Component</strong></li>
<li>✅ <strong>인터랙티브한 부분만 Client Component</strong></li>
<li>✅ <strong>적재적소에 사용</strong></li>
</ul>
<h3 id="carousel은-당연히-client-component여야-합니다">Carousel은 당연히 Client Component여야 합니다</h3>
<pre><code class="language-typescript">// Carousel의 본질
- 사용자가 슬라이드를 넘김 (onClick)
- 애니메이션 (useEffect)
- 현재 위치 추적 (useState)
- 드래그 이벤트 (onDrag)</code></pre>
<p>이런 기능은 <strong>브라우저에서만 가능</strong>합니다!</p>
<h3 id="실제-번들-크기-비교">실제 번들 크기 비교</h3>
<pre><code>Traditional React App (모두 Client):
└─ client.js (2.5MB)
   ├─ React
   ├─ All components
   ├─ All libraries
   └─ Your code

Next.js + shadcn/ui (적재적소):
├─ Server rendered HTML (대부분)
└─ client.js (200KB) ← 90% 감소! 🚀
   └─ Interactive parts만 (Carousel 등)</code></pre><h3 id="shadcnui의-진짜-장점">shadcn/ui의 진짜 장점</h3>
<p><strong>다른 UI 라이브러리:</strong></p>
<pre><code class="language-typescript">import { Carousel } from &#39;some-ui-library&#39;;
// 라이브러리 내부가 블랙박스
// 최적화 불가능</code></pre>
<p><strong>shadcn/ui:</strong></p>
<pre><code class="language-typescript">// 코드를 직접 소유하므로:
// 1. 필요 없는 부분 제거 가능
// 2. Server/Client 경계 조정 가능
// 3. 프로젝트에 맞게 커스터마이징
// 4. 번들 사이즈 최적화 가능</code></pre>
<hr>
<h2 id="🚀-해결책-1-섹션-전체를-client-component로">🚀 해결책 1: 섹션 전체를 Client Component로</h2>
<p>가장 간단한 해결책:</p>
<pre><code class="language-typescript">// buy-section.tsx
&#39;use client&#39;;

import { Carousel, CarouselContent, CarouselItem } from &#39;@/components/ui/carousel&#39;;

export const BuySection = () =&gt; {
  return (
    &lt;div&gt;
      &lt;GuideText /&gt; {/* 같이 Client가 됨 */}
      &lt;GuideText /&gt;

      &lt;Carousel&gt;
        &lt;CarouselContent&gt;
          &lt;CarouselItem&gt;...&lt;/CarouselItem&gt;
        &lt;/CarouselContent&gt;
      &lt;/Carousel&gt;
    &lt;/div&gt;
  );
};</code></pre>
<p><strong>장점:</strong></p>
<ul>
<li>✅ 간단함</li>
<li>✅ 빠른 구현</li>
</ul>
<p><strong>단점:</strong></p>
<ul>
<li>❌ 섹션 전체가 Client Component</li>
<li>❌ 불필요한 JavaScript 번들 증가</li>
</ul>
<hr>
<h2 id="🎯-해결책-2-carousel-부분만-client-component로-분리-최적화">🎯 해결책 2: Carousel 부분만 Client Component로 분리 (최적화)</h2>
<p>더 나은 방법은 <strong>필요한 부분만 Client Component로 분리</strong>하는 것입니다.</p>
<h3 id="step-1-범용적인-mobilecarousel-컴포넌트-생성">Step 1: 범용적인 MobileCarousel 컴포넌트 생성</h3>
<pre><code class="language-typescript">// components/common/mobile-carousel.tsx
&#39;use client&#39;;

import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from &#39;@/components/ui/carousel&#39;;
import { ReactNode, Children } from &#39;react&#39;;
import type { EmblaOptionsType } from &#39;embla-carousel&#39;;

interface MobileCarouselProps {
  children: ReactNode;
  itemClassName?: string;
  contentClassName?: string;
  carouselClassName?: string;
  setApi?: (api: CarouselApi) =&gt; void;
  opts?: EmblaOptionsType;
  orientation?: &#39;horizontal&#39; | &#39;vertical&#39;;
}

export const MobileCarousel = ({
  children,
  itemClassName = &#39;basis-auto&#39;,
  contentClassName = &#39;md:-ml-6 lg:-ml-6&#39;,
  carouselClassName = &#39;w-full md:hidden lg:hidden&#39;,
  setApi,
  opts,
  orientation = &#39;horizontal&#39;
}: MobileCarouselProps) =&gt; {
  const items = Children.toArray(children);

  return (
    &lt;Carousel 
      className={carouselClassName} 
      setApi={setApi} 
      opts={opts} 
      orientation={orientation}
    &gt;
      &lt;CarouselContent className={contentClassName}&gt;
        {items.map((child, index) =&gt; (
          &lt;CarouselItem key={index} className={itemClassName}&gt;
            {child}
          &lt;/CarouselItem&gt;
        ))}
      &lt;/CarouselContent&gt;
    &lt;/Carousel&gt;
  );
};</code></pre>
<h3 id="step-2-server-component에서-사용">Step 2: Server Component에서 사용</h3>
<pre><code class="language-typescript">// buy-section.tsx - Server Component! ✅
import { MobileCarousel } from &#39;@/components/common/mobile-carousel&#39;;

export const BuySection = () =&gt; {
  return (
    &lt;div&gt;
      &lt;GuideText /&gt; {/* Server ✅ */}
      &lt;GuideText /&gt; {/* Server ✅ */}

      {/* Desktop: 정적 그리드 - Server ✅ */}
      &lt;div className=&quot;hidden lg:flex&quot;&gt;
        {BUY_STEPS.map(step =&gt; &lt;StepCard key={step.title} {...step} /&gt;)}
      &lt;/div&gt;

      {/* Mobile: Carousel - Client (필요한 부분만!) ✅ */}
      &lt;MobileCarousel&gt;
        {BUY_STEPS.map(step =&gt; &lt;StepCard key={step.title} {...step} /&gt;)}
      &lt;/MobileCarousel&gt;
    &lt;/div&gt;
  );
};</code></pre>
<h3 id="최적화-효과">최적화 효과</h3>
<pre><code>Before (전체 Client):
├─ buy-section.tsx (전체)
├─ guide-text.tsx
├─ step-card.tsx
└─ carousel.tsx
Total: ~50-60KB

After (필요한 부분만 Client):
├─ mobile-carousel.tsx (작은 wrapper만)
└─ carousel.tsx
Total: ~20-25KB

💡 약 60% 감소! 🚀</code></pre><hr>
<h2 id="🚨-또-다른-문제-함수를-prop으로-전달할-수-없다">🚨 또 다른 문제: 함수를 prop으로 전달할 수 없다</h2>
<p>처음 MobileCarousel을 만들 때 이런 시도를 했습니다:</p>
<pre><code class="language-typescript">// buy-section.tsx - Server Component
const BUY_STEPS = [
  {
    icon: StepOneIcon,  // SVG 컴포넌트
    title: &#39;STEP 01&#39;,
    description: &#39;공고&#39;
  }
];

&lt;MobileCarousel steps={BUY_STEPS} /&gt;  // ❌ 에러!</code></pre>
<pre><code>Error: Functions cannot be passed directly to Client Components 
unless you explicitly expose it by marking it with &quot;use server&quot;.</code></pre><h3 id="왜-에러가-발생했을까">왜 에러가 발생했을까?</h3>
<p><strong>SVG 파일의 정체:</strong></p>
<pre><code class="language-typescript">// SVG를 import하면:
import StepOneIcon from &#39;@/assets/about/step1-icon.svg&#39;;

// 이건 사실 React 컴포넌트 = 함수입니다!
const StepOneIcon = (props) =&gt; {
  return (
    &lt;svg width=&quot;24&quot; height=&quot;24&quot; {...props}&gt;
      &lt;path d=&quot;M...&quot; /&gt;
    &lt;/svg&gt;
  );
};</code></pre>
<p><strong>문제의 원인:</strong></p>
<pre><code class="language-javascript">// Server → Client로 전송하려는 내용:
{
  steps: [
    { 
      icon: function StepOneIcon(props) {  // ❌ 함수를 직렬화할 수 없음!
        return &lt;svg&gt;...&lt;/svg&gt;
      }
    }
  ]
}</code></pre>
<h3 id="server-→-client-경계에서-전달-가능한-것들">Server → Client 경계에서 전달 가능한 것들</h3>
<pre><code class="language-typescript">// ❌ 전달 불가능
const MyComponent = () =&gt; { /* ... */ };  // 함수
const handler = () =&gt; {};                  // 함수
const icon = SomeIcon;                     // React Component (함수)

&lt;ClientComp 
  component={MyComponent}  // ❌ 
  onClick={handler}        // ❌
  icon={icon}              // ❌
/&gt;

// ✅ 전달 가능
const data = { title: &quot;Hi&quot;, count: 10 };  // 직렬화 가능한 데이터
const text = &quot;Hello&quot;;                      // 문자열
const jsx = &lt;Icon /&gt;;                      // 렌더링된 React Element

&lt;ClientComp 
  data={data}        // ✅ 
  text={text}        // ✅
&gt;
  &lt;Icon /&gt;           {/* ✅ children으로 전달 */}
&lt;/ClientComp&gt;</code></pre>
<hr>
<h2 id="💡-해결책-composition-pattern">💡 해결책: Composition Pattern</h2>
<pre><code class="language-typescript">// ❌ Before: 함수를 prop으로 전달
const steps = [{ icon: StepOneIcon }];
&lt;MobileCarousel steps={steps} /&gt;

// ✅ After: children으로 렌더링된 Element 전달
&lt;MobileCarousel&gt;
  {steps.map(step =&gt; {
    const Icon = step.icon;  // Server에서 함수 사용
    return (
      &lt;div key={step.title}&gt;
        &lt;StepCard&gt;
          &lt;Icon /&gt;  {/* Server에서 렌더링 → Element로 전달 */}
        &lt;/StepCard&gt;
      &lt;/div&gt;
    );
  })}
&lt;/MobileCarousel&gt;</code></pre>
<h3 id="전달-과정">전달 과정</h3>
<pre><code>1. 서버 실행:
   Server Component가 실행됨
   ↓
   const Icon = StepOneIcon;
   &lt;Icon /&gt;  ← 서버에서 실행, JSX로 변환
   ↓
   결과: { type: &#39;svg&#39;, props: {...} }  ← 직렬화 가능한 객체

2. 네트워크 전송:
   JSON으로 직렬화
   ↓
   {&quot;type&quot;:&quot;svg&quot;,&quot;props&quot;:{...}}  ← 문자열

3. 클라이언트 수신:
   브라우저가 받음
   ↓
   React가 렌더링</code></pre><hr>
<h2 id="🤔-그런데-원래-intro-페이지는-왜-에러가-안-났지">🤔 그런데 원래 intro 페이지는 왜 에러가 안 났지?</h2>
<p>흥미로운 발견: 원래 <code>intro/page.tsx</code>는 <code>&#39;use client&#39;</code> 없이도 Carousel을 사용하고 있었습니다.</p>
<pre><code class="language-typescript">// intro/page.tsx - &#39;use client&#39; 없음 (Server Component)
&lt;Carousel className=&quot;w-full pl-4 md:pl-6 lg:hidden&quot;&gt;
  &lt;CarouselContent className=&quot;md:-ml-6&quot;&gt;
    {ABOUT_DATA.map((item) =&gt; (
      &lt;CarouselItem key={item.id} className=&quot;basis-auto&quot;&gt;
        &lt;div&gt;...&lt;/div&gt;
      &lt;/CarouselItem&gt;
    ))}
  &lt;/CarouselContent&gt;
&lt;/Carousel&gt;</code></pre>
<p><strong>왜 작동했을까?</strong></p>
<h3 id="jsx는-함수-호출이-아니라-객체입니다">JSX는 &quot;함수 호출&quot;이 아니라 &quot;객체&quot;입니다</h3>
<pre><code class="language-typescript">// 이렇게 쓰면:
&lt;CarouselItem className=&quot;basis-auto&quot;&gt;
  &lt;div&gt;Hello&lt;/div&gt;
&lt;/CarouselItem&gt;

// 실제로는 이렇게 변환됩니다:
React.createElement(CarouselItem, { className: &quot;basis-auto&quot; }, 
  React.createElement(&quot;div&quot;, {}, &quot;Hello&quot;)
)

// 결과는 &quot;객체&quot;입니다:
{
  type: CarouselItem,  // 함수 참조
  props: { className: &quot;basis-auto&quot;, children: {...} }
}</code></pre>
<p><strong>Server가 하는 일:</strong></p>
<ul>
<li>컴포넌트를 &quot;실행&quot;하지 않습니다</li>
<li>&quot;어떤 컴포넌트를 써야 하는지&quot; 참조만 전달합니다</li>
<li>Next.js가 내부적으로 컴포넌트 참조를 ID로 변환합니다</li>
</ul>
<pre><code class="language-javascript">// Server에서:
&lt;Carousel&gt;
  &lt;CarouselContent&gt;
    &lt;div&gt;Hello&lt;/div&gt;
  &lt;/CarouselContent&gt;
&lt;/Carousel&gt;

// ↓ Next.js가 변환

// Client로 전송:
{
  &quot;type&quot;: &quot;ClientComponent#carousel-123&quot;,     // ← 참조 ID
  &quot;props&quot;: {
    &quot;children&quot;: {
      &quot;type&quot;: &quot;ClientComponent#carousel-124&quot;, // ← 참조 ID
      &quot;props&quot;: {
        &quot;children&quot;: { &quot;type&quot;: &quot;div&quot;, &quot;props&quot;: { &quot;children&quot;: &quot;Hello&quot; } }
      }
    }
  }
}</code></pre>
<hr>
<h2 id="📊-패턴-비교표">📊 패턴 비교표</h2>
<table>
<thead>
<tr>
<th>패턴</th>
<th>작동 여부</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;ClientComp&gt;&lt;ChildClientComp /&gt;&lt;/ClientComp&gt;</code></td>
<td>✅</td>
<td>컴포넌트 <strong>참조</strong>를 children으로 전달</td>
</tr>
<tr>
<td><code>&lt;ClientComp component={ChildClientComp} /&gt;</code></td>
<td>❌</td>
<td>컴포넌트를 <strong>prop</strong>으로 직접 전달</td>
</tr>
<tr>
<td><code>&lt;ClientComp&gt;&lt;Icon /&gt;&lt;/ClientComp&gt;</code></td>
<td>✅</td>
<td>JSX Element로 변환되어 전달</td>
</tr>
<tr>
<td><code>&lt;ClientComp icon={Icon} /&gt;</code></td>
<td>❌</td>
<td>함수 자체를 prop으로 전달</td>
</tr>
<tr>
<td><code>&lt;ClientComp data={{name: &quot;John&quot;}} /&gt;</code></td>
<td>✅</td>
<td>직렬화 가능한 데이터</td>
</tr>
<tr>
<td><code>&lt;ClientComp onClick={() =&gt; {}} /&gt;</code></td>
<td>❌</td>
<td>함수 전달 불가</td>
</tr>
</tbody></table>
<hr>
<h2 id="🎯-최종-아키텍처">🎯 최종 아키텍처</h2>
<h3 id="프로젝트-전체-구조">프로젝트 전체 구조</h3>
<pre><code>src/
├─ components/
│  └─ common/
│     └─ mobile-carousel.tsx  🎨 범용 컴포넌트 (Client)
│
├─ app/
   ├─ about/
   │  ├─ intro/
   │  │  └─ page.tsx  ✅ Server Component
   │  │
   │  └─ collection-guide/
   │     └─ _sections/
   │        ├─ donation-section.tsx  ✅ Server Component
   │        └─ buy-section.tsx       ✅ Server Component
   │
   └─ _sections/
      ├─ activity-section.tsx   🔵 Client (애니메이션 필요)
      ├─ highlight-section.tsx  🔵 Client (애니메이션 필요)
      └─ timeline-section.tsx   🔵 Client (애니메이션 필요)</code></pre><h3 id="사용-예시">사용 예시</h3>
<p><strong>간단한 케이스:</strong></p>
<pre><code class="language-typescript">// Server Component
&lt;MobileCarousel&gt;
  {items.map(item =&gt; &lt;Card key={item.id}&gt;{item.name}&lt;/Card&gt;)}
&lt;/MobileCarousel&gt;</code></pre>
<p><strong>고급 케이스 (API 제어):</strong></p>
<pre><code class="language-typescript">// Client Component
&#39;use client&#39;;

const [api, setApi] = useState&lt;CarouselApi&gt;();
const [current, setCurrent] = useState(0);

&lt;MobileCarousel 
  setApi={setApi}
  opts={{ loop: true, align: &#39;center&#39; }}
  orientation=&quot;vertical&quot;
&gt;
  {items.map(item =&gt; &lt;Card key={item.id}&gt;{item}&lt;/Card&gt;)}
&lt;/MobileCarousel&gt;

{/* 점 표시기 */}
&lt;div&gt;
  {items.map((_, i) =&gt; (
    &lt;button 
      className={i === current ? &#39;active&#39; : &#39;&#39;}
      onClick={() =&gt; api?.scrollTo(i)}
    /&gt;
  ))}
&lt;/div&gt;</code></pre>
<hr>
<h2 id="💡-핵심-요약">💡 핵심 요약</h2>
<h3 id="1-serverclient-경계-규칙">1. Server/Client 경계 규칙</h3>
<pre><code class="language-typescript">✅ Server Component → Client Component (children 패턴)
❌ Server Component → Client Component 내부 exports
❌ Server Component → Client Component (함수 prop)
✅ Server Component → Client Component (직렬화 가능한 데이터 prop)</code></pre>
<h3 id="2-전달-가능한-것">2. 전달 가능한 것</h3>
<ul>
<li>✅ 문자열, 숫자, 객체, 배열 (직렬화 가능)</li>
<li>✅ React Elements (<code>&lt;Component /&gt;</code> 형태)</li>
<li>❌ 함수, 클래스, Symbol</li>
</ul>
<h3 id="3-svg--함수--전달-불가">3. SVG = 함수 = 전달 불가</h3>
<pre><code class="language-typescript">// ❌ 이렇게 하지 말고:
&lt;ClientComp icon={IconSvg} /&gt;

// ✅ 이렇게 하세요:
&lt;ClientComp&gt;
  &lt;IconSvg /&gt;
&lt;/ClientComp&gt;</code></pre>
<h3 id="4-shadcnui--nextjs--완벽한-조합">4. shadcn/ui + Next.js = 완벽한 조합</h3>
<ul>
<li>인터랙티브한 부분만 Client Component</li>
<li>나머지 80-90%는 Server Component</li>
<li>코드를 직접 소유해서 최적화 가능</li>
<li>번들 크기 최소화</li>
</ul>
<hr>
<h2 id="🚀-결론">🚀 결론</h2>
<p>Next.js App Router의 Server/Client Component 경계를 이해하는 것은 처음에는 복잡해 보이지만, 핵심 원칙을 이해하면:</p>
<ol>
<li><strong>적재적소에 Client Component 사용</strong>: 인터랙션이 필요한 부분만</li>
<li><strong>Composition Pattern 활용</strong>: 함수 대신 children으로 전달</li>
<li><strong>범용 컴포넌트 추상화</strong>: MobileCarousel처럼 재사용 가능하게</li>
</ol>
<p>이런 패턴을 따르면 성능이 뛰어나고 유지보수가 쉬운 Next.js 애플리케이션을 만들 수 있습니다!</p>
<p><strong>최종 결과:</strong></p>
<ul>
<li>✅ 코드 재사용 극대화</li>
<li>✅ 번들 크기 60% 감소</li>
<li>✅ 일관된 UX</li>
<li>✅ Server Component 최대 활용</li>
</ul>
<hr>
<p><strong>참고 자료:</strong></p>
<ul>
<li><a href="https://nextjs.org/docs/app">Next.js App Router 공식 문서</a></li>
<li><a href="https://react.dev/reference/react/use-client">React Server Components</a></li>
<li><a href="https://ui.shadcn.com/">shadcn/ui</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[FormData 배열 전송: `[]` 표기법과 JSON.stringify의 모든 것]]></title>
            <link>https://velog.io/@woozi__zi/FormData-%EB%B0%B0%EC%97%B4-%EC%A0%84%EC%86%A1-%ED%91%9C%EA%B8%B0%EB%B2%95%EA%B3%BC-JSON.stringify%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83</link>
            <guid>https://velog.io/@woozi__zi/FormData-%EB%B0%B0%EC%97%B4-%EC%A0%84%EC%86%A1-%ED%91%9C%EA%B8%B0%EB%B2%95%EA%B3%BC-JSON.stringify%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83</guid>
            <pubDate>Fri, 10 Oct 2025 06:55:00 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제의-시작">📌 문제의 시작</h2>
<p>프론트엔드에서 FormData로 배열을 전송할 때, 같은 프로젝트 내에서도 <strong>다양한 방식</strong>이 혼재되어 있었습니다:</p>
<pre><code class="language-typescript">// 방식 1: JSON.stringify
formData.append(&#39;deleteTimelines&#39;, JSON.stringify([1, 2, 3]));

// 방식 2: forEach ([] 없음)
dto.deletedResourceIds.forEach(id =&gt; {
  formData.append(&#39;deletedResourceIds&#39;, id.toString());
});

// 방식 3: forEach + []
dto.deletedFileIds.forEach(id =&gt; {
  formData.append(&#39;deletedFileIds[]&#39;, id.toString());
});</code></pre>
<p><strong>특히 타임라인 API에서 <code>deleteTimelines[]</code>를 <code>deleteTimelines</code>로 변경하니 제대로 작동</strong>했는데, 왜 그런지 궁금했습니다.</p>
<hr>
<h2 id="🔍-핵심-원리">🔍 핵심 원리</h2>
<h3 id="1-formdata의-본질적-한계">1. FormData의 본질적 한계</h3>
<p>FormData는 <strong>키-값 쌍만 전송</strong>할 수 있습니다. 배열을 표현하는 <strong>표준 방법이 없습니다</strong>.</p>
<pre><code class="language-javascript">// 실제 HTTP 전송 형태
Content-Disposition: form-data; name=&quot;ids&quot;
value=&quot;1&quot;</code></pre>
<h3 id="2-백엔드-파싱-동작-방식">2. 백엔드 파싱 동작 방식</h3>
<p><strong>같은 키에 값이 몇 개 append 되었는지에 따라 결과가 달라집니다:</strong></p>
<table>
<thead>
<tr>
<th>전송 방식</th>
<th>값 개수</th>
<th>백엔드 파싱 결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>append(&#39;ids&#39;, &#39;1&#39;)</code></td>
<td>1개</td>
<td><code>&quot;1&quot;</code> (문자열) ❌</td>
</tr>
<tr>
<td><code>append(&#39;ids&#39;, &#39;1&#39;)</code> × 2</td>
<td>2개</td>
<td><code>[&quot;1&quot;, &quot;2&quot;]</code> (배열) ✅</td>
</tr>
<tr>
<td><code>append(&#39;ids[]&#39;, &#39;1&#39;)</code></td>
<td>1개</td>
<td><code>[&quot;1&quot;]</code> (배열) ✅</td>
</tr>
<tr>
<td><code>append(&#39;ids[]&#39;, &#39;1&#39;)</code> × 2</td>
<td>2개</td>
<td><code>[&quot;1&quot;, &quot;2&quot;]</code> (배열) ✅</td>
</tr>
</tbody></table>
<h3 id="3--표기법의-역할">3. <code>[]</code> 표기법의 역할</h3>
<pre><code class="language-typescript">formData.append(&#39;deletedFileIds[]&#39;, &#39;1&#39;);</code></pre>
<p><strong><code>[]</code>는 단순한 명시가 아니라, 실제로 키 이름의 일부입니다!</strong></p>
<pre><code>HTTP 전송: name=&quot;deletedFileIds[]&quot;

NestJS/Multer 파싱: &quot;[] 패턴 발견! 배열로 변환해야겠다&quot;
                   → deletedFileIds = [&quot;1&quot;]</code></pre><hr>
<h2 id="💡-세-가지-전송-방식-비교">💡 세 가지 전송 방식 비교</h2>
<h3 id="방식-1-jsonstringify">방식 1: JSON.stringify</h3>
<pre><code class="language-typescript">// 프론트엔드
formData.append(&#39;deleteTimelines&#39;, JSON.stringify([1, 2, 3]));

// 백엔드 DTO
@Transform(({ value }) =&gt; (typeof value === &#39;string&#39; ? JSON.parse(value) : value))
deleteTimelines?: number[];

// 결과: ✅ 항상 배열로 파싱</code></pre>
<p><strong>장점:</strong></p>
<ul>
<li>✅ 값이 1개든 여러 개든 항상 안전</li>
<li>✅ 복잡한 객체 배열도 전송 가능</li>
<li>✅ 코드가 간결</li>
</ul>
<p><strong>단점:</strong></p>
<ul>
<li>⚠️ 백엔드에서 JSON 파싱 필요</li>
</ul>
<h3 id="방식-2-foreach--없음">방식 2: forEach ([] 없음)</h3>
<pre><code class="language-typescript">// 프론트엔드
dto.deletedResourceIds.forEach(id =&gt; {
  formData.append(&#39;deletedResourceIds&#39;, id.toString());
});

// 백엔드: 자동 배열 변환 (여러 값일 때만)</code></pre>
<p><strong>장점:</strong></p>
<ul>
<li>✅ 백엔드에서 자동 파싱 (여러 값일 때)</li>
</ul>
<p><strong>단점:</strong></p>
<ul>
<li>❌ 값이 1개일 때 문자열로 인식되어 에러!</li>
<li>❌ 단순 값(숫자/문자열)만 가능</li>
</ul>
<h3 id="방식-3-foreach--">방식 3: forEach + []</h3>
<pre><code class="language-typescript">// 프론트엔드
dto.deletedFileIds.forEach(id =&gt; {
  formData.append(&#39;deletedFileIds[]&#39;, id.toString());
});

// 백엔드: [] 패턴 인식 → 무조건 배열로 변환</code></pre>
<p><strong>장점:</strong></p>
<ul>
<li>✅ 값이 1개든 여러 개든 항상 배열로 보장</li>
<li>✅ 백엔드에서 자동 파싱</li>
</ul>
<p><strong>단점:</strong></p>
<ul>
<li>❌ 단순 값만 가능 (객체 배열 불가)</li>
<li>⚠️ 프로젝트마다 지원 여부 다름</li>
</ul>
<hr>
<h2 id="🎯-실제-사례-분석">🎯 실제 사례 분석</h2>
<h3 id="타임라인-api-jsonstringify-통일">타임라인 API (JSON.stringify 통일)</h3>
<pre><code class="language-typescript">formData.append(&#39;timelines&#39;, JSON.stringify(dto.timelines));           // 객체 배열
formData.append(&#39;addTimelines&#39;, JSON.stringify(dto.addTimelines));     // 객체 배열
formData.append(&#39;updateTimelines&#39;, JSON.stringify(dto.updateTimelines)); // 객체 배열
formData.append(&#39;deleteTimelines&#39;, JSON.stringify(dto.deleteTimelines)); // 숫자 배열</code></pre>
<p><strong>왜 모두 JSON.stringify를 사용?</strong></p>
<ul>
<li>✅ API 내부 일관성 (모든 배열을 같은 방식으로)</li>
<li>✅ 객체 배열 전송 필수 (timelines, addTimelines, updateTimelines)</li>
<li>✅ 백엔드 파싱 로직 통일</li>
</ul>
<h3 id="공지사항-api-foreach---사용">공지사항 API (forEach + [] 사용)</h3>
<pre><code class="language-typescript">// 파일 업로드 ([] 없음)
dto.files.forEach(file =&gt; {
  formData.append(&#39;files&#39;, file);  // Multer가 자동 처리
});

// 숫자 배열 ([] 있음)
dto.deletedFileIds.forEach(id =&gt; {
  formData.append(&#39;deletedFileIds[]&#39;, id.toString());
});</code></pre>
<p><strong>왜 <code>deletedFileIds[]</code>에만 [] 사용?</strong></p>
<ul>
<li>✅ 파일은 Multer가 자동으로 배열 처리</li>
<li>✅ 숫자 ID는 명시적으로 배열임을 표시해야 함</li>
<li>✅ 값이 1개일 때도 배열로 보장 (엣지 케이스 방지)</li>
</ul>
<hr>
<h2 id="🔧-백엔드-코드-분석">🔧 백엔드 코드 분석</h2>
<h3 id="nestjs의-transform-데코레이터">NestJS의 @Transform 데코레이터</h3>
<pre><code class="language-typescript">@Transform(({ value }) =&gt; (typeof value === &#39;string&#39; ? JSON.parse(value) : value))
deletedFileIds?: number[];</code></pre>
<p><strong>이 코드의 의미:</strong></p>
<ol>
<li>문자열이 오면 → <code>JSON.parse()</code> 실행 (JSON.stringify로 보낸 경우)</li>
<li>배열이 오면 → 그대로 사용 (forEach + []로 보낸 경우)</li>
</ol>
<p><strong>시나리오별 동작:</strong></p>
<pre><code class="language-typescript">// Case 1: JSON.stringify
formData.append(&#39;ids&#39;, JSON.stringify([1,2,3]));
// value = &quot;[1,2,3]&quot; (문자열)
// JSON.parse(&quot;[1,2,3]&quot;) → [1,2,3] ✅

// Case 2: forEach + []
formData.append(&#39;ids[]&#39;, &#39;1&#39;);
// value = [&quot;1&quot;] (배열)
// 그대로 통과 → [&quot;1&quot;] ✅

// Case 3: forEach (값 1개, [] 없음)
formData.append(&#39;ids&#39;, &#39;1&#39;);
// value = &quot;1&quot; (문자열)
// JSON.parse(&quot;1&quot;) → 1 ❌ (배열 아님!)</code></pre>
<hr>
<h2 id="📋-선택-가이드">📋 선택 가이드</h2>
<h3 id="jsonstringify를-사용해야-할-때">JSON.stringify를 사용해야 할 때:</h3>
<p>✅ <strong>객체 배열</strong>을 전송할 때</p>
<pre><code class="language-typescript">formData.append(&#39;items&#39;, JSON.stringify([
  { id: 1, name: &#39;A&#39; },
  { id: 2, name: &#39;B&#39; }
]));</code></pre>
<p>✅ <strong>API 내 다른 배열들</strong>도 JSON.stringify를 쓸 때 (일관성)</p>
<p>✅ <strong>복잡한 중첩 구조</strong>를 전송할 때</p>
<h3 id="foreach--를-사용해야-할-때">forEach + []를 사용해야 할 때:</h3>
<p>✅ <strong>단순 값(숫자/문자열) 배열</strong>만 전송할 때</p>
<p>✅ <strong>백엔드가 [] 패턴을 명시적으로 요구</strong>할 때</p>
<p>✅ <strong>값이 1개일 수도 있는 경우</strong> (엣지 케이스 방지)</p>
<h3 id="foreach--없음를-사용할-때">forEach ([] 없음)를 사용할 때:</h3>
<p>⚠️ <strong>File 객체</strong> (Multer가 자동 처리)</p>
<p>⚠️ <strong>항상 2개 이상의 값</strong>이 보장될 때</p>
<p>⚠️ <strong>백엔드가 관대하게 파싱</strong>할 때 (권장하지 않음)</p>
<hr>
<h2 id="🎓-핵심-교훈">🎓 핵심 교훈</h2>
<h3 id="1-swagger만으로는-부족하다">1. Swagger만으로는 부족하다</h3>
<pre><code class="language-json">// Swagger에서는 똑같이 보임
{
  &quot;deleteTimelines&quot;: {
    &quot;type&quot;: &quot;array&quot;,
    &quot;items&quot;: { &quot;type&quot;: &quot;number&quot; }
  }
}</code></pre>
<p>하지만 <strong>실제 FormData 전송 방식은 다릅니다:</strong></p>
<ul>
<li><code>deleteTimelines: &quot;[1,2,3]&quot;</code> (JSON 문자열)</li>
<li><code>deletedFileIds[]: 1, deletedFileIds[]: 2</code> ([] 패턴)</li>
</ul>
<h3 id="2-api-내부-일관성이-중요하다">2. API 내부 일관성이 중요하다</h3>
<p>같은 API 내에서는 <strong>통일된 방식</strong>을 사용하는 게 좋습니다:</p>
<pre><code class="language-typescript">// ✅ 좋은 예: 모두 JSON.stringify
formData.append(&#39;add&#39;, JSON.stringify([...]));
formData.append(&#39;update&#39;, JSON.stringify([...]));
formData.append(&#39;delete&#39;, JSON.stringify([...]));

// ❌ 나쁜 예: 혼재
formData.append(&#39;add&#39;, JSON.stringify([...]));
formData.append(&#39;update&#39;, JSON.stringify([...]));
arr.forEach(id =&gt; formData.append(&#39;delete[]&#39;, id)); // 혼자 다름</code></pre>
<h3 id="3-백엔드와의-협의가-필수">3. 백엔드와의 협의가 필수</h3>
<p>프론트엔드만 바꾸면 작동하지 않습니다. <strong>백엔드 DTO 설계</strong>를 먼저 확인하세요:</p>
<pre><code class="language-typescript">// 백엔드 Request DTO를 확인!
@Transform(({ value }) =&gt; ...)
deleteIds?: number[];</code></pre>
<h3 id="4-엣지-케이스를-고려하라">4. 엣지 케이스를 고려하라</h3>
<p><strong>&quot;값이 1개만 삭제할 때&quot;</strong> 같은 시나리오를 테스트하세요. 많은 버그가 여기서 발생합니다.</p>
<hr>
<h2 id="🚀-실전-권장사항">🚀 실전 권장사항</h2>
<h3 id="새-api-개발-시">새 API 개발 시:</h3>
<ol>
<li><strong>백엔드와 먼저 협의</strong>: JSON vs forEach 방식 결정</li>
<li><strong>일관성 유지</strong>: 같은 API는 같은 방식으로</li>
<li><strong>문서화</strong>: 주석으로 이유 명시<pre><code class="language-typescript">// 배열 형태로 전송하기 위해 [] 표기법 사용
formData.append(&#39;deletedFileIds[]&#39;, id.toString());</code></pre>
</li>
</ol>
<h3 id="기존-코드-유지보수-시">기존 코드 유지보수 시:</h3>
<ol>
<li><strong>현재 방식 확인</strong>: 백엔드 DTO와 프론트엔드 코드 모두</li>
<li><strong>테스트</strong>: 값이 1개일 때, 여러 개일 때 모두 테스트</li>
<li><strong>함부로 바꾸지 않기</strong>: 이미 작동하면 그대로 두기</li>
</ol>
<h3 id="통일을-원한다면">통일을 원한다면:</h3>
<ol>
<li><strong>팀 컨벤션 문서</strong> 작성</li>
<li><strong>백엔드 팀과 협의</strong> (가장 중요!)</li>
<li><strong>새 API부터 적용</strong></li>
<li><strong>점진적 마이그레이션</strong> (여유 있을 때)</li>
</ol>
<hr>
<h2 id="📚-요약">📚 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>JSON.stringify</th>
<th>forEach + []</th>
<th>forEach ([] 없음)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>사용 케이스</strong></td>
<td>객체 배열</td>
<td>단순 배열</td>
<td>파일/특수 케이스</td>
</tr>
<tr>
<td><strong>값 1개 안전성</strong></td>
<td>✅ 안전</td>
<td>✅ 안전</td>
<td>❌ 위험</td>
</tr>
<tr>
<td><strong>복잡한 구조</strong></td>
<td>✅ 가능</td>
<td>❌ 불가</td>
<td>❌ 불가</td>
</tr>
<tr>
<td><strong>코드 간결성</strong></td>
<td>✅ 간결</td>
<td>⚠️ 반복문</td>
<td>⚠️ 반복문</td>
</tr>
<tr>
<td><strong>백엔드 파싱</strong></td>
<td>JSON.parse</td>
<td>자동</td>
<td>자동</td>
</tr>
</tbody></table>
<p><strong>결론: API의 특성에 맞게 선택하되, 일관성을 유지하자!</strong></p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas 에디터 Transformer 최적화 성능 테스트 기록]]></title>
            <link>https://velog.io/@woozi__zi/Canvas-%EC%97%90%EB%94%94%ED%84%B0-Transformer-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@woozi__zi/Canvas-%EC%97%90%EB%94%94%ED%84%B0-Transformer-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Tue, 20 May 2025 07:13:41 GMT</pubDate>
            <description><![CDATA[<h2 id="🔍-테스트-환경">🔍 테스트 환경</h2>
<ul>
<li><strong>브라우저</strong>: Chrome 114 (MacBook Pro M2)  </li>
<li><strong>프로젝트</strong>: Next.js 14 + React-Konva 기반 Canvas 에디터  </li>
<li><strong>측정 코드</strong>: <code>handleTransformEnd</code> 이벤트마다 <code>performance.now()</code>로 처리 시간(ms) 로깅  </li>
<li><strong>반복 횟수</strong>: 10회씩 평균 값 여러 번 실행 → 평균 계산</li>
</ul>
<pre><code class="language-tsx">// CanvasElementsRender.tsx (핵심 부분)
const handleTransformEnd = (id: string, e: Konva.KonvaEventObject&lt;Event&gt;) =&gt; {
  const start = performance.now();

  // … updateElement 로직, batchDraw() 호출 …

  const end = performance.now();
  durationsRef.current.push(end - start);

  if (durationsRef.current.length &gt;= 10) {
    const sum = durationsRef.current.reduce((a, b) =&gt; a + b, 0);
    const avg = sum / durationsRef.current.length;
    console.log(`[perf] transform 평균: ${avg.toFixed(2)} ms (samples: 10)`);
    durationsRef.current = [];
  }
};</code></pre>
<hr>
<h2 id="📝-측정-결과">📝 측정 결과</h2>
<table>
<thead>
<tr>
<th>테스트 조건</th>
<th align="right">평균 처리 시간 (ms)</th>
<th align="right">성 능 개 선</th>
</tr>
</thead>
<tbody><tr>
<td>❌ 비최적화</td>
<td align="right">3.49</td>
<td align="right">기준</td>
</tr>
<tr>
<td>✅ batchDraw()만 적용</td>
<td align="right">2.70</td>
<td align="right">22.75% ↓</td>
</tr>
<tr>
<td>✅ useCallback+batchDraw()</td>
<td align="right">1.71</td>
<td align="right">51.06% ↓</td>
</tr>
</tbody></table>
<ul>
<li><p>batchDraw만 적용: 3.49 → 2.70ms ⇒ 약 22.75% 감소</p>
</li>
<li><p>useCallback 추가 시: 2.70 → 1.71ms ⇒ 약 32% 추가 개선</p>
</li>
<li><p>전체 최적화 효과: 3.49 → 1.71ms ⇒ 총 51.06% 성능 향상</p>
</li>
</ul>
<hr>
<h2 id="📸-캡쳐-스냅샷">📸 캡쳐 스냅샷</h2>
<p>1) ❌ 비최적화 버전 (3.49ms 평균)
<img src="https://velog.velcdn.com/images/woozi__zi/post/756d1bde-aa7a-44d4-89b7-7739cb4276ff/image.png" alt=""><img src="https://velog.velcdn.com/images/woozi__zi/post/3fab862f-2f68-4ca9-b7ad-a67337be4c62/image.png" alt=""></p>
<p>2) ✅ batchDraw()만 적용 (2.70ms 평균)
<img src="https://velog.velcdn.com/images/woozi__zi/post/e477b5bd-2a1b-4f4a-9c2c-888dd4d6e3ca/image.png" alt=""><img src="https://velog.velcdn.com/images/woozi__zi/post/6f06f36f-1908-4f11-a6da-e4b1bf8e3c45/image.png" alt=""></p>
<p>3) ✅ useCallback + batchDraw() 적용 (1.71ms 평균)
<img src="https://velog.velcdn.com/images/woozi__zi/post/964718da-dfb8-4927-87a1-d3d4c4551600/image.png" alt=""></p>
<hr>
<h2 id="🎯-정리-및-결론">🎯 정리 및 결론</h2>
<ol>
<li><p>불필요한 리렌더링 방지: <code>useCallback</code>으로 핸들러 메모이제이션 적용</p>
</li>
<li><p>불필요한 redraw 최소화: <code>batchDraw()</code>로 최적화된 그리기 처리</p>
</li>
<li><p>상태 변경 최소화: 매 이벤트마다 상태를 직접 변경하지 않고 필요한 순간에만 반영</p>
</li>
</ol>
<p>📌 이 최적화를 통해 평균 처리 시간이 3.49ms → 1.71ms, 약 51% 감소
복잡한 캔버스 조작에서도 부드럽고 반응성 높은 UX 개선</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase Storage에 이미지를 덮어썼는데, 왜 이전 이미지가 보일까?]]></title>
            <link>https://velog.io/@woozi__zi/Supabase-Storage%EC%97%90-%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-%EB%8D%AE%EC%96%B4%EC%8D%BC%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%9D%B4%EC%A0%84-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%B3%B4%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@woozi__zi/Supabase-Storage%EC%97%90-%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-%EB%8D%AE%EC%96%B4%EC%8D%BC%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%9D%B4%EC%A0%84-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EB%B3%B4%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Tue, 22 Apr 2025 10:32:46 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p><code>unno</code> 프로젝트에서 명함을 이미지로 저장할 때, 다음과 같은 경로로 Supabase Storage에 PNG 파일을 저장하도록 설계하였다.</p>
<pre><code class="language-tsx">const fileName = `${label}_${lastSlug}_img.png`;
const filePath = `${userId}/${lastSlug}/${fileName}`;</code></pre>
<p>즉, 하나의 <code>slug</code>에 대해 <code>front_img.png</code>, <code>back_img.png</code> 같은 고정된 경로에 저장되는 구조다.</p>
<h3 id="정상-케이스">정상 케이스</h3>
<ul>
<li>최초 저장 시에는 문제없이 잘 작동한다.</li>
</ul>
<h3 id="문제-발생">문제 발생</h3>
<p>같은 slug로 다시 저장(즉, 이미지 덮어쓰기)하면 다음 문제가 발생했다:</p>
<ul>
<li>Supabase에는 새로운 이미지로 덮어쓰기가 되었음에도,</li>
<li>브라우저에서는 이전 이미지가 계속 캐시된 채로 보임</li>
<li>강력 새로고침 <code>(Cmd+Shift+R)</code>을 해야만 새로운 이미지가 반영됨</li>
</ul>
<hr>
<h2 id="현상-요약">현상 요약</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>저장 방식</td>
<td>동일 경로(<code>/cards/{slug}_img.png</code>)에 파일을 덮어씀</td>
</tr>
<tr>
<td>브라우저 반응</td>
<td>이전 이미지가 계속 캐시되어 보임</td>
</tr>
<tr>
<td>Supabase 상태</td>
<td>파일은 분명 최신 버전으로 저장됨</td>
</tr>
<tr>
<td>사용자 경험</td>
<td>&quot;왜 저장했는데 반영이 안 돼?&quot; 라는 혼란 발생</td>
</tr>
</tbody></table>
<hr>
<h2 id="원인-supabase의-cdn-캐싱-구조">원인: Supabase의 CDN 캐싱 구조</h2>
<p>Supabase는 Cloudflare 기반 CDN을 통해 전 세계에 분산된 이미지 서버를 제공한다.</p>
<p>출처: Supabase Smart CDN 소개</p>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/ce96104b-fea3-49b4-9ba7-60bed0040872/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/ad0c2fa9-ccf3-44dd-b8f0-36f0fa3362c7/image.png" alt=""></p>
<h3 id="캐시가-동작하는-방식-요약">캐시가 동작하는 방식 요약</h3>
<ol>
<li><p>사용자가 업로드한 이미지가 Supabase Storage에 저장된다.</p>
</li>
<li><p>Supabase는 이를 Cloudflare CDN에 자동 복제(Cache) 한다.</p>
</li>
<li><p>이후 동일한 URL 요청이 들어오면 CDN에서 캐시된 이미지를 반환한다.</p>
</li>
<li><p>즉, 원본이 변경되어도, 캐시가 만료되기 전까지는 이전 이미지가 계속 반환된다.</p>
</li>
</ol>
<p>참고 문서:
<a href="https://supabase.com/docs/guides/storage/cdn/smart-cdn">Supabase Smart CDN 공식 문서</a></p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-쿼리-스트링을-활용한-cache-busting">1. 쿼리 스트링을 활용한 Cache Busting</h3>
<pre><code class="language-tsx">const timestamp = new Date().getTime();
const imageUrl = `https://.../cards/${fileName}?v=${timestamp}`;</code></pre>
<ul>
<li><p>Supabase는 URL 쿼리를 무시하고 동일한 리소스를 반환하지만,</p>
</li>
<li><p>브라우저는 ?v=12345678이 붙은 걸 새 파일로 취급하며 캐시를 우회한다.</p>
</li>
</ul>
<h3 id="2-파일명을-아예-새로-생성">2. 파일명을 아예 새로 생성</h3>
<pre><code class="language-tsx">const fileName = `${label}_${lastSlug}_${Date.now()}_img.png`;
</code></pre>
<ul>
<li><p>경로 자체를 변경함으로써 캐시를 완전히 피할 수 있다.</p>
</li>
<li><p>다만 기존 파일을 수동으로 정리해줘야 하고, 저장소 관리가 복잡해질 수 있다.</p>
</li>
</ul>
<h3 id="3-supabase에서-캐시-ttl-조정은-불가능">3. Supabase에서 캐시 TTL 조정은 불가능</h3>
<ul>
<li><p>현재는 사용자가 TTL(Time To Live) 값을 설정하거나 purge(강제 초기화)하는 기능은 제공되지 않는다.</p>
</li>
<li><p>Smart CDN은 Pro 요금제에서만 자동 무효화를 제공하며, Free 요금제에서는 직접 우회 처리를 해야 한다.</p>
</li>
</ul>
<h2 id="결론">결론</h2>
<ul>
<li>Supabase Storage는 CDN 기반이라 이미지 덮어쓰기가 바로 반영되지 않는다.</li>
<li>쿼리 스트링을 붙이는 방식의 캐시 무효화가 가장 간단하고 안전한 해결책이다.</li>
<li>이미지 URL을 클라이언트에서 불러올 때 ?v=${timestamp}를 함께 붙이자</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Konva toBlob() 이슈와 Supabase 이미지 CORS ]]></title>
            <link>https://velog.io/@woozi__zi/konva-toBlob</link>
            <guid>https://velog.io/@woozi__zi/konva-toBlob</guid>
            <pubDate>Tue, 22 Apr 2025 09:34:19 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p><code>unno</code> 프로젝트에서 사용자가 이미지를 업로드하면 해당 파일은 Supabase Storage에 저장된다. 이후 명함을 새로 만들 때도 기존에 업로드한 이미지들을 재사용할 수 있도록 DB에 저장된 이미지 URL을 기반으로 캔버스(Stage)에 추가하는 구조로 설계했다.</p>
<p>하지만, 캔버스에서 <code>Stage.toBlob()</code>을 통해 이미지를 저장하려고 할 때, 일부 이미지가 포함된 Stage에서 Blob 변환이 실패하는 문제가 발생했다.</p>
<hr>
<h2 id="문제-발생-toblob이-null을-반환함">문제 발생: toBlob이 null을 반환함</h2>
<pre><code class="language-tsx">export const uploadStageImage = async (
  stage: Konva.Stage,
  userId: string,
  lastSlug: string,
  label: &#39;front&#39; | &#39;back&#39;
): Promise&lt;string&gt; =&gt; {
  const blob: Blob = await new Promise&lt;Blob&gt;((resolve, reject) =&gt; {
    stage.toBlob({
      mimeType: &#39;image/png&#39;,
      pixelRatio: 2,
      callback: (b: Blob | null) =&gt; {
        if (b) resolve(b);
        else reject(new Error(&#39;Konva.toBlob 반환값이 null입니다.&#39;));
      },
    });
  });

  const fileName = `${label}_${lastSlug}_img.png`;
  const filePath = `${userId}/${lastSlug}/${fileName}`;

  return uploadToSupabaseStorage(&#39;cards&#39;, filePath, blob);
};
</code></pre>
<p><strong>의도</strong></p>
<ul>
<li><code>Konva.Stage</code>를 이미지로 변환하여 Supabase에 업로드하고, 해당 public URL을 반환</li>
</ul>
<p><strong>현상</strong></p>
<ul>
<li>특정 이미지가 포함된 Stage에서는 <code>toBlob()</code> 결과가 <code>null</code>로 반환됨</li>
</ul>
<hr>
<h2 id="상황-비교">상황 비교</h2>
<h3 id="업로드-직후-캔버스에-추가된-이미지">업로드 직후 캔버스에 추가된 이미지</h3>
<pre><code class="language-tsx">{
  &quot;previewUrl&quot;: &quot;blob:http://localhost:3000/...&quot;,
  &quot;height&quot;: 98.54,
  ...
}</code></pre>
<ul>
<li>로컬 Blob URL이므로 보안 제약 없이 사용 가능</li>
</ul>
<h3 id="db에서-가져온-supabase-이미지-url">DB에서 가져온 Supabase 이미지 URL</h3>
<pre><code class="language-tsx">{
  &quot;previewUrl&quot;: &quot;https://your-project.supabase.co/storage/v1/object/public/uploadimg/...&quot;,
  &quot;height&quot;: 84.23,
  ...
}</code></pre>
<ul>
<li>Supabase의 도메인이 다른 origin이기 때문에 브라우저 보안 정책에 의해 문제가 발생</li>
</ul>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<p>이 문제의 본질은 CORS (Cross-Origin Resource Sharing) 정책 때문이다.</p>
<ul>
<li><p>브라우저는 다른 origin의 이미지를 <code>&lt;canvas&gt;</code>에 그릴 경우, 해당 canvas를 tainted 상태로 간주한다.</p>
</li>
<li><p>이 상태에서는 <code>.toBlob()</code>, <code>.toDataURL()</code> 등 출력 관련 API 사용이 제한된다.</p>
</li>
<li><p>특히 <code>Konva.Image</code>는 <code>&lt;img&gt;</code> 태그 기반이므로, 다음 조건이 필요하다:</p>
<ul>
<li><code>crossOrigin=&quot;anonymous&quot;</code> 설정</li>
<li>이미지 서버(Supabase)에서 CORS 헤더 허용</li>
</ul>
</li>
</ul>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-supabase-storage의-cors-정책-설정">1. Supabase Storage의 CORS 정책 설정</h3>
<p>Supabase 프로젝트에서 다음과 같이 CORS 설정을 추가해야 한다</p>
<pre><code class="language-tsx">[
  {
    &quot;allowed_origins&quot;: [&quot;*&quot;], // 또는 &quot;https://uuno.vercel.app&quot;
    &quot;allowed_methods&quot;: [&quot;GET&quot;],
    &quot;allowed_headers&quot;: [&quot;*&quot;],
    &quot;max_age&quot;: 86400
  }
]
</code></pre>
<ul>
<li><p><code>allowed_origins</code>: 실제 배포 도메인을 등록</p>
</li>
<li><p><code>allowed_methods</code>: 최소한 <code>GET</code>은 포함해야 함</p>
</li>
</ul>
<h3 id="2-konva-이미지-로딩-시-crossorigin-속성-지정">2. Konva 이미지 로딩 시 crossOrigin 속성 지정</h3>
<p><code>useImage</code> 훅에서 <code>&#39;anonymous&#39;</code> 옵션을 명시해야 한다.</p>
<pre><code class="language-tsx">const [image] = useImage(element.previewUrl, &#39;anonymous&#39;);</code></pre>
<p><code>element-image-canvas.tsx</code>, <code>element-upload-canvas.tsx</code> 등 모든 이미지 요소 컴포넌트에 이 설정을 추가해야 함</p>
<hr>
<h2 id="적용-결과">적용 결과</h2>
<ul>
<li><p><code>toBlob()</code>이 안정적으로 Blob 객체를 반환 ✅</p>
</li>
<li><p>Supabase 이미지가 포함된 canvas도 문제 없이 저장 가능 ✅</p>
</li>
<li><p>업로드한 이미지 재활용 시에도 CORS 에러 없이 저장 가능 ✅</p>
</li>
</ul>
<hr>
<h2 id="마무리-팁">마무리 팁</h2>
<ul>
<li>Supabase Storage에 이미지를 업로드할 때는 반드시 public 폴더에 업로드하고,</li>
<li><code>crossOrigin</code> 설정이 누락되면 에러 없이 그냥 <code>null</code>을 반환하기 때문에 에러 처리를 반드시 해야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[unsplah api 호출 제한 변경기 ]]></title>
            <link>https://velog.io/@woozi__zi/%EC%9E%90%EC%86%8C%EC%84%9C</link>
            <guid>https://velog.io/@woozi__zi/%EC%9E%90%EC%86%8C%EC%84%9C</guid>
            <pubDate>Fri, 18 Apr 2025 05:36:29 GMT</pubDate>
            <description><![CDATA[<h2 id="🚨-문제-상황">🚨 문제 상황</h2>
<p>무료 Unsplash API는 1시간당 50회 호출 제한이 있어, 다음과 같은 UX 흐름에서 빠르게 한도에 도달하는 문제가 발생했다:</p>
<ul>
<li><p>🔍 검색어 입력 시 → 실시간 API 호출</p>
</li>
<li><p>📜 스크롤 이동 시 → 무한스크롤 기반 추가 호출</p>
</li>
</ul>
<p>⛔ 디바운싱을 적용해도 TanStack Query의 fetchNextPage와 중첩되어 호출량이 급증, 기능이 차단되는 상황이 빈번히 발생했다.</p>
<hr>
<h2 id="🔧-초기-대응-서버-측-200장-preload-→-클라이언트-필터링">🔧 초기 대응: 서버 측 200장 preload → 클라이언트 필터링</h2>
<p>요청 수를 줄이기 위해, 서버에서 4페이지(50장 × 4)의 이미지를 한 번에 받아와 클라이언트에서 필터링하는 구조로 변경했다.</p>
<pre><code class="language-tsx">export async function GET() {
  const allImages: UnsplashImage[] = [];

  const pageRequests = Array.from({ length: 4 }, (_, i) =&gt;
    fetch(`${BASE_URL}?page=${i + 1}&amp;per_page=50&amp;client_id=${ACCESS_KEY}`).then((res) =&gt; res.json())
  );

  const results = await Promise.all(pageRequests);
  for (const pageData of results) {
    if (Array.isArray(pageData)) allImages.push(...pageData);
  }

  return NextResponse.json(allImages);
}
</code></pre>
<p>⚠️ 하지만 두 가지 문제가 발생했다</p>
<ol>
<li>🔤 한글 검색 정확도 부족 (단순 포함 필터링으로 처리했기 때문)</li>
<li>🖼️ 이미지 탐색성 저하 (제한된 200장 내에서만 탐색 가능)</li>
</ol>
<hr>
<h2 id="✅-최종-해결-프로덕션-api-승인-→-무한스크롤--검색-구조-복구">✅ 최종 해결: 프로덕션 API 승인 → 무한스크롤 + 검색 구조 복구</h2>
<p>API 쿼터를 확장(프로덕션 승인)받은 뒤, 다시 검색어 기반 호출과 무한스크롤을 활성화하는 구조로 복구했다.</p>
<pre><code class="language-tsx">export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get(&#39;query&#39;) || &#39;&#39;;
  const page = Number(searchParams.get(&#39;page&#39;) || &#39;1&#39;);
  const perPage = Number(searchParams.get(&#39;per_page&#39;) || 20);

  if (page &gt; MAX_UNSPALSH_API_PAGES) {
    return NextResponse.json(
      { error: &#39;요청 페이지 수 제한 초과&#39; },
      { status: 429 }
    );
  }

  const baseURL = query
    ? `https://api.unsplash.com/search/photos`
    : `https://api.unsplash.com/photos`;

  const url =
    `${baseURL}?client_id=${ENV.UNSPLASH_ACCESS_KEY}` +
    `&amp;page=${page}&amp;per_page=${perPage}` +
    (query ? `&amp;query=${encodeURIComponent(query)}` : &#39;&#39;);

  const res = await fetch(url, { next: { revalidate: 3600 } });

  if (!res.ok) {
    const errorData = await res.json().catch(() =&gt; null);
    return NextResponse.json(
      { error: &#39;이미지 불러오기 실패&#39;, details: errorData || res.statusText },
      { status: res.status }
    );
  }

  const json = await res.json();

  return query
    ? NextResponse.json({ results: json.results, total_pages: json.total_pages })
    : NextResponse.json(json);
}</code></pre>
<p>💡 고려 사항 및 결과</p>
<ul>
<li><p>✅ 검색어 유무에 따라 search/photos 또는 photos API로 자동 분기</p>
</li>
<li><p>✅ TanStack Query 기반 무한스크롤 구조에 적합</p>
</li>
<li><p>✅ revalidate: 3600을 적용하여 1시간 단위 캐싱으로 서버 부담 최소화</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Canvas 좌표와 HTML 좌표 사이의 간격]]></title>
            <link>https://velog.io/@woozi__zi/Canvas-%EC%A2%8C%ED%91%9C%EC%99%80-HTML-%EC%A2%8C%ED%91%9C-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B0%84%EA%B2%A9</link>
            <guid>https://velog.io/@woozi__zi/Canvas-%EC%A2%8C%ED%91%9C%EC%99%80-HTML-%EC%A2%8C%ED%91%9C-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B0%84%EA%B2%A9</guid>
            <pubDate>Fri, 18 Apr 2025 04:02:46 GMT</pubDate>
            <description><![CDATA[<h2 id="🎯-서론-왜-이-문제가-중요한가">🎯 서론: 왜 이 문제가 중요한가?</h2>
<p>Canvas 기반 편집기를 Konva로 개발하면서 가장 큰 기술적 난관 중 하나는 <strong>Canvas 요소와 HTML 요소 간 좌표 차이</strong>였다.
사용자가 보는 위치와 실제 Konva Canvas에서 인식하는 위치가 달라 툴바, 입력창 등의 HTML 오버레이가 <strong>엉뚱한 위치에 렌더링</strong>되는 문제가 반복적으로 발생했다.</p>
<p>이 글에서는 이 좌표 오차가 <strong>왜 발생하는지</strong>, 그리고 이를 <strong>어떻게 해결했는지</strong>에 대해 실제 경험을 바탕으로 정리한다.</p>
<hr>
<h2 id="⚙️-html과-canvas-좌표계-차이의-본질">⚙️ HTML과 Canvas 좌표계 차이의 본질</h2>
<h3 id="1-서로-다른-좌표-단위">1. 서로 다른 좌표 단위</h3>
<ul>
<li><p><strong>Canvas</strong>는 _Canvas 픽셀 좌표계_를, <strong>HTML</strong>은 _CSS 픽셀 좌표계_를 기준으로 한다.</p>
</li>
<li><p>브라우저의 확대/축소나 디바이스의 <code>devicePixelRatio</code> 설정에 따라 좌표 오차가 누적될 수 있다.</p>
</li>
</ul>
<h3 id="2-konva의-scale-적용-방식">2. Konva의 scale 적용 방식</h3>
<ul>
<li><p>Konva의 scale은 <strong>Canvas 내부 transform</strong>으로 적용된다.</p>
</li>
<li><p>반면 HTML은 CSS의 <strong>transform</strong> 속성으로 처리되므로, 서로 다른 좌표 변환 방식을 사용한다.</p>
</li>
</ul>
<h3 id="3-부모-컨테이너의-padding-border-flex-영향">3. 부모 컨테이너의 padding, border, flex 영향</h3>
<ul>
<li>Konva <code>&lt;Stage&gt;</code>의 부모 요소에 padding, border, flex 설정이 있으면 <strong>좌표 보정</strong>이 반드시 필요하다.</li>
</ul>
<h3 id="4-스크롤뷰포트-기준-좌표차">4. 스크롤/뷰포트 기준 좌표차</h3>
<ul>
<li><p><code>getClientRect()</code>는 DOM 기준, <code>getAbsolutePosition()</code>은 Konva 내부 기준이다.</p>
</li>
<li><p>따라서 스크롤이 있는 페이지에서는 툴바 위치가 어긋나는 원인이 될 수 있다.</p>
</li>
</ul>
<hr>
<h2 id="🧪-실제-문제-사례-툴바-위치가-맞지-않던-이유">🧪 실제 문제 사례: 툴바 위치가 맞지 않던 이유</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>텍스트나 도형을 클릭하면 요소 하단 중앙에 툴바를 띄우는 구조를 구현 중이었다.
초기에는 <code>getAbsolutePosition()</code>으로 좌표를 받아 다음과 같이 툴바 위치를 설정했다:</p>
<pre><code>const position = node.getAbsolutePosition();
setToolbar({ x: position.x, y: position.y });</code></pre><p>그러나 실제 렌더링된 툴바는 완전히 다른 위치에 표시되었다.
이는 <code>getAbsolutePosition()</code>이 <strong>scale, 회전, 그룹화 변형을 반영하지 않는</strong> 논리 좌표만 반환하기 때문이다.</p>
<hr>
<h2 id="✅-해결-방법--getclientrect-기반-좌표-계산">✅ 해결 방법 : getClientRect() 기반 좌표 계산</h2>
<p>보다 정확한 위치 계산을 위해 <code>getClientRect()</code>를 사용한 코드로 교체했다:</p>
<pre><code class="language-tsx">const handleUpdateToolbarNode = (node: Konva.Node) =&gt; {
  requestAnimationFrame(() =&gt; {
    const rect = node.getClientRect();
    setToolbar({
      x: rect.x + rect.width / 2 - (TOOLBAR_WIDTH * zoom) / 2,
      y: rect.y + rect.height + 8,
    });
  });
};
</code></pre>
<ul>
<li><p><code>getClientRect()</code>는 실제 렌더링 기준의 x, y, width, height 정보를 포함한다.</p>
</li>
<li><p>이를 기반으로 요소의 하단 중앙에 툴바가 정확히 위치하게 된다.</p>
</li>
</ul>
<hr>
<h3 id="두-메서드의-비교">두 메서드의 비교</h3>
<h4 id="1-getabsoluteposition">1. getAbsolutePosition()</h4>
<p><strong>반환값</strong>: { x, y } (논리 좌표)
<strong>장점</strong>: 변형 전 순수 좌표 확인 가능
<strong>단점</strong>: 스케일, 회전, 그룹 트랜스폼 미반영</p>
<h4 id="2-getclientrect">2. getClientRect()</h4>
<p><strong>반환값</strong>: { x, y, width, height }
<strong>장점</strong>: 실제 렌더링된 bounding box 조회 가능
<strong>단점</strong>: CSS와 Canvas 스케일 차이에 대한 보정 필요</p>
<hr>
<h2 id="🔄-zoom-적용-후-추가-문제와-해결">🔄 Zoom 적용 후 추가 문제와 해결</h2>
<p>Zoom 기능을 추가한 이후, 좌표가 또다시 어긋나는 문제가 발생했다.
Konva 좌표는 확대된 상태로 계산되므로, HTML 요소는 Zoom을 나누어 보정해야 한다:</p>
<pre><code class="language-tsx">const textPosition = textNode.getAbsolutePosition();
const areaPosition = {
  x: textPosition.x / zoom,
  y: textPosition.y / zoom,
};
setupTextareaStyles({ textarea, textNode, areaPosition });</code></pre>
<hr>
<h2 id="✨-결론">✨ 결론</h2>
<p>HTML과 Canvas는 본질적으로 좌표계가 다르다.</p>
<p>이 차이를 정확히 이해하고, getClientRect()와 zoom 보정을 적절히 적용한 결과, 툴바 및 입력창 같은 HTML 오버레이 요소들이 사용자 기준 위치에 정확하게 렌더링되도록 구현할 수 있었다.</p>
<p>이 경험을 통해 좌표계 문제를 단순 보정이 아닌 시스템적 차이로 인식하고 대응하는 사고력을 키울 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/4c7cff91-5559-49d7-9721-0dd7b8914238/image.png" alt="">
에서 </p>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/f1ef1ffe-33c4-48ae-9125-bce5d764f946/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[macOS에서 GitHub PR 파일명 변경 충돌 해결하기 (`Header.tsx → header.tsx`)]]></title>
            <link>https://velog.io/@woozi__zi/GitHub-PR%EC%97%90%EC%84%9C-Header.tsx-header.tsx-%EB%B3%80%EA%B2%BD-%EC%8B%9C-%EC%B6%A9%EB%8F%8C%EC%9D%B4-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-macOS-%ED%99%98%EA%B2%BD</link>
            <guid>https://velog.io/@woozi__zi/GitHub-PR%EC%97%90%EC%84%9C-Header.tsx-header.tsx-%EB%B3%80%EA%B2%BD-%EC%8B%9C-%EC%B6%A9%EB%8F%8C%EC%9D%B4-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0%EC%99%80-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-macOS-%ED%99%98%EA%B2%BD</guid>
            <pubDate>Tue, 08 Apr 2025 00:59:04 GMT</pubDate>
            <description><![CDATA[<h1 id="github-pr에서-headertsx-→-headertsx-변경-시-충돌이-없는데-있다">GitHub PR에서 Header.tsx → header.tsx 변경 시 충돌이 없는데 있다?</h1>
<p>최근 작업하던 브랜치에서 파일명을 대문자에서 소문자로 바꾸는 단순한 변경을 진행했다:</p>
<ul>
<li>변경 전: <code>Header.tsx</code></li>
<li>변경 후: <code>header.tsx</code></li>
</ul>
<p>그런데 갑자기 GitHub Pull Request(PR)에서 예상치 못한 <strong>충돌(conflict)</strong> 이 발생했다.</p>
<p>로컬에서 <code>git status</code>는 깨끗한 상태였지만, GitHub에서는 아래와 같은 충돌 메시지가 나타났다:</p>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/d51d14c2-d178-4163-8ca9-25fa09378736/image.png" alt="충돌 메시지">
<code>This branch has conflicts that must be resolved
These conflicts are too complex to resolve in the web editor.</code></p>
<p>더욱 당황스러웠던 점은:</p>
<ul>
<li>GitHub의 웹 에디터에서 &quot;Resolve conflicts&quot; 버튼조차 비활성화된 상태.</li>
<li>심지어 GitHub 원격 저장소와 로컬 환경 모두에서 이미 파일명은 정상적으로 <code>header.tsx</code>로 적용된 상태였다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/woozi__zi/post/4d16561b-a834-4422-b6cd-3ddc68eb6575/image.png" alt="실제 변경된 파일 상태"></p>
<p>도대체 왜 이런 문제가 발생한 걸까?</p>
<hr>
<h2 id="🤔-원인-분석">🤔 원인 분석</h2>
<p>결론부터 말하자면, 이 문제는 <strong>macOS의 파일 시스템 특성</strong>에서 비롯된 것이다.</p>
<h3 id="✅-macos의-파일-시스템-특징">✅ macOS의 파일 시스템 특징</h3>
<p>macOS의 기본 파일 시스템(HFS+ 또는 APFS)은 <strong>대소문자를 구분하지 않는(case-insensitive)</strong> 특성을 가진다.</p>
<p>즉, macOS에서는 아래 두 파일을 <strong>완전히 동일한 파일로 인식한다</strong>:</p>
<ul>
<li>✅ <code>Header.tsx</code></li>
<li>✅ <code>header.tsx</code></li>
</ul>
<p>그러나 Git은 내부적으로 <strong>파일명을 대소문자까지 엄격하게(case-sensitive)</strong> 구분한다.<br>따라서 대소문자만 변경한 경우, Git이 이를 서로 다른 두 개의 파일로 잘못 인식하게 된다.</p>
<h3 id="🚩-문제의-흐름-요약">🚩 문제의 흐름 요약</h3>
<ul>
<li>macOS에서는 <code>Header.tsx</code> → <code>header.tsx</code>로 파일 이름만 바꾼 것으로 인식함.</li>
<li>Git은 두 파일명이 서로 다르다고 생각하여, PR 병합 과정에서 두 개의 서로 다른 파일이 존재한다고 착각함.</li>
<li>결과적으로 GitHub PR에서는 충돌(conflict)이 발생하게 됨.</li>
</ul>
<hr>
<h2 id="🛠️-해결-과정-시도했던-방법들">🛠️ 해결 과정 (시도했던 방법들)</h2>
<h3 id="❌-시도했지만-실패한-방법들">❌ 시도했지만 실패한 방법들:</h3>
<ul>
<li>✅ 다른 브랜치에서 <code>merge dev</code>를 시도했지만, 충돌로 나타난 파일 자체가 존재하지 않아 실패.</li>
<li>✅ <code>git add . &amp;&amp; git commit</code>으로 다시 커밋해도 PR 충돌 해결 안됨.</li>
<li>✅ <code>git mv header.tsx tmp.tsx</code> 이후 커밋을 하고 다시 <code>git mv tmp.tsx header.tsx</code> 를 하고 커밋을 했지만, macOS는 대소문자 변경만으로는 변경된 것을 인식하지 못해서 실패.</li>
<li>✅ <code>git config core.ignorecase false</code> 설정 후 다시 진행했으나, 효과 없음.</li>
</ul>
<hr>
<h2 id="🚨-마침내-성공한-최종-해결-방법-강력-추천-✅">🚨 마침내 성공한 최종 해결 방법 (강력 추천 ✅)</h2>
<p>위 방법들이 모두 실패한 뒤, 마지막으로 선택한 방법은 <strong>오히려 다시 원래 파일명으로 되돌리는 것</strong>이었다.</p>
<p><strong>즉, <code>header.tsx</code> → <code>Header.tsx</code>로 다시 바꾸고 커밋했더니 모든 충돌이 해결됐다.</strong></p>
<h3 id="✨-성공적으로-해결된-절차는-아래와-같다">✨ 성공적으로 해결된 절차는 아래와 같다:</h3>
<ol>
<li><p>터미널에서 명확하게 대문자로 변경 명령 수행</p>
<pre><code class="language-bash">git mv header.tsx Header.tsx</code></pre>
</li>
<li><p>명확히 커밋 메시지 작성 후 커밋</p>
<pre><code>git commit -m &quot;fix: rename header.tsx to Header.tsx to fix GitHub PR conflict&quot;</code></pre></li>
<li><p>변경 사항을 원격 브랜치에 푸시
<code>git push origin 브랜치명</code></p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 14 + Konva로 명함 편집기 초기 세팅하기]]></title>
            <link>https://velog.io/@woozi__zi/Next.js-14-Konva%EB%A1%9C-%EB%AA%85%ED%95%A8-%ED%8E%B8%EC%A7%91%EA%B8%B0-%EC%B4%88%EA%B8%B0-%EC%84%B8%ED%8C%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@woozi__zi/Next.js-14-Konva%EB%A1%9C-%EB%AA%85%ED%95%A8-%ED%8E%B8%EC%A7%91%EA%B8%B0-%EC%B4%88%EA%B8%B0-%EC%84%B8%ED%8C%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 06 Apr 2025 15:14:04 GMT</pubDate>
            <description><![CDATA[<h1 id="nextjs-14--konva로-명함-편집기-초기-세팅하기-🛠️">Next.js 14 + Konva로 명함 편집기 초기 세팅하기 🛠️</h1>
<p>Next.js 14 프로젝트에서 Konva와 react-konva를 사용하는 법을 정리하려고 한다.</p>
<p>특히 SSR(Server Side Rendering) 환경에서 Konva를 클라이언트 전용으로 설정하는 방법과 react-konva의 버전 호환 문제를 해결해야지 자유롭게 사용이 가능하다.</p>
<hr>
<h2 id="1-기본-패키지-설치">1. 기본 패키지 설치</h2>
<pre><code>pnpm add konva react-konva@18</code></pre><ul>
<li>Next.js 14는 현재 React 18.x 버전을 사용한다.  </li>
<li>하지만 react-konva 최신 버전(19.x)은 React 19을 요구하기 때문에 버전을 명시적으로 낮춰서 설치해야 한다.</li>
</ul>
<hr>
<h2 id="2-konva를-사용한-캔버스-컴포넌트-만들기">2. Konva를 사용한 캔버스 컴포넌트 만들기</h2>
<ul>
<li>Konva를 사용하는 컴포넌트는 Next.js의 서버 환경에서 동작하지 않으므로,</li>
<li>반드시 최상단에 &quot;use client&quot;;를 붙여 클라이언트 전용 컴포넌트로 설정해야 한다.</li>
</ul>
<hr>
<h2 id="4-클라이언트-전용-컴포넌트로-불러오기">4. 클라이언트 전용 컴포넌트로 불러오기</h2>
<ul>
<li><p>Konva를 사용하는 컴포넌트는 Next.js의 SSR 환경에서 바로 렌더링되지 않으므로, </p>
</li>
<li><p>반드시 dynamic import와 함께 ssr: false 옵션을 사용해야 한다.</p>
</li>
</ul>
<pre><code class="language-tsx">import dynamic from &quot;next/dynamic&quot;;

const CanvasEditor = dynamic(() =&gt; import(&quot;@/components/CanvasEditor&quot;), {
  ssr: false,
});

export default function Home() {
  return (
    &lt;div&gt;
      &lt;CanvasEditor /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<hr>
<h2 id="5-ssr-환경에서의-canvas-모듈-에러-해결">5. SSR 환경에서의 canvas 모듈 에러 해결</h2>
<ul>
<li><p>Next.js 서버 빌드 시 Konva 내부에서 Node.js의 canvas 모듈을 찾으려고 하면서 에러가 발생할 수 있다.</p>
</li>
<li><p>이를 방지하기 위해 프로젝트 루트의 next.config.js에 다음과 같은 Webpack 설정을 추가한다.</p>
</li>
</ul>
<pre><code class="language-tsx">/** @type {import(&#39;next&#39;).NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) =&gt; {
    if (isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        canvas: false,
      };
    }
    return config;
  },
};

module.exports = nextConfig;</code></pre>
<h2 id="6-정리">6. 정리</h2>
<ul>
<li><p>사용하는 next 버전과 알맞은 react-konva 설치하기</p>
</li>
<li><p>dynamic import와 ssr: false로 클라이언트로 불러오기</p>
</li>
<li><p>next.confing.js에서 webpack 추가</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[DOM기반 방식보단 캔버스 방식을 택한 이유]]></title>
            <link>https://velog.io/@woozi__zi/DOM%EA%B8%B0%EB%B0%98-%EB%B0%A9%EC%8B%9D%EB%B3%B4%EB%8B%A8-%EC%BA%94%EB%B2%84%EC%8A%A4-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@woozi__zi/DOM%EA%B8%B0%EB%B0%98-%EB%B0%A9%EC%8B%9D%EB%B3%B4%EB%8B%A8-%EC%BA%94%EB%B2%84%EC%8A%A4-%EB%B0%A9%EC%8B%9D%EC%9D%84-%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Fri, 04 Apr 2025 03:24:11 GMT</pubDate>
            <description><![CDATA[<h3 id="프로젝트-구상과-초기-기술-선택">프로젝트 구상과 초기 기술 선택</h3>
<p>처음 프로젝트를 구상할 때 React-DnD와 React-RnD를 이용해 명함 편집 도구를 구현하는 방식을 택했다. 
DOM 기반의 편집 방식이 React와의 친화성이 높고, 드래그 앤 드롭과 리사이징 기능을 쉽게 관리할 수 있을 것이라 생각했기 때문이다.</p>
<h3 id="기획-변화와-ux-중심의-고민">기획 변화와 UX 중심의 고민</h3>
<p>하지만 상세하게 기획을 하다보니 조금씩 퀼리티 높은 편집툴을 만드는 방향으로 가게 되었다. 
또한 사용자입장에서 생각해보면 보다 정밀하고 고급스러운 편집툴이 훨씬 더 ux적인 측면에서 좋지 않을까라는 생각을 했다.</p>
<h3 id="dom-기반-방식의-한계">DOM 기반 방식의 한계</h3>
<p>DOM 요소를 기반으로 한 편집 방식은 요소가 많아질수록 성능 저하가 발생할 가능성이 높아진다.
DOM 방식은 브라우저의 DOM 트리 구조를 직접 조작하는 방식으로, 각각의 요소가 브라우저에 개별적으로 렌더링되기 때문에 요소가 많아질수록 리플로우(reflow)와 리페인트(repaint) 작업이 빈번해진다.<br>리플로우는 요소가 추가되거나 스타일이 변경될 때 발생하며, 이 과정에서 브라우저는 전체 레이아웃을 다시 계산해야 하므로 성능 저하가 일어난다.
특히, 빈번한 드래그 앤 드롭, 리사이즈와 같은 실시간 인터랙션이 많아질수록 성능 저하가 더욱 심각해진다.</p>
<p>또한 DOM 방식은 정밀한 그래픽 제어가 어렵다는 단점이 있다.
DOM과 CSS를 활용하면 위치 지정과 간단한 변형(transform)이 가능하지만, 세밀한 픽셀 단위의 위치 조정이나 자유로운 형태의 변형(회전, 스큐 등)을 정확하게 구현하는 것이 어렵다.
특히, CSS transform 속성의 한계로 인해 미세한 조정에서 부자연스러운 현상이 발생할 수 있고, 다양한 브라우저에서 일관된 결과를 보장하기도 어렵다.</p>
<p>또한 DOM 구조는 계층적인 HTML 구조를 기반으로 하기 때문에 복잡한 레이어 관리가 까다롭고, z-index 관리가 복잡해지면 유지 보수가 힘들어진다. 더불어 DOM 요소 간의 이벤트 처리가 복잡해질 수 있으며, 이벤트 위임(event delegation) 방식으로 성능 최적화를 진행하더라도 일정 수준 이상의 인터랙션이 발생하면 처리 비용이 증가하게 된다.</p>
<p>이러한 여러 한계점 때문에 DOM 방식이 사용자에게 직관적이고 전문적인 편집 경험을 제공하는 데 적합하지 않다고 판단하게 되었다.</p>
<h3 id="캔버스-기반-기술로의-전환">캔버스 기반 기술로의 전환</h3>
<p>이러한 문제를 해결하기 위해 캔버스 기반의 Konva.js(미정)를 선택하게 되었다. 
캔버스는 픽셀 단위의 정밀한 제어와 고성능 렌더링을 지원하며, 요소의 회전, 레이어 관리 등 복잡한 그래픽 작업에 특화되어 있다.
결과적으로 캔버스를 통해 더욱 전문적인 디자인 툴에 가까운 사용자 경험을 제공할 수 있게 되었다.</p>
<h3 id="포트폴리오-관점에서의-고민과-결정">포트폴리오 관점에서의 고민과 결정</h3>
<p>캔버스로의 전환을 고민하는 과정에서, 포트폴리오로 작성할 내용을 충분히 확보할 수 있을지에 대한 걱정이 있었다. 
처음에는 DOM 기반의 DnD 방식이 직접적인 로직과 상태 관리 부분에서 설명할 부분이 많아 포트폴리오로 적합해 보였지만, 실제로 캔버스 방식을 택하고 개발을 진행하게 된다면 노가다적이고 반복적인 로직(상태 관리, 이벤트 처리 등)을 최소화 시킬 수 있다는 장점이 있다.
또한 캔버스 기반의 기술적 특성과 렌더링 최적화, 정밀한 기능 구현 등 더 깊이 있는 기술적 도전과 해결 과정을 제시할 수 있어, 결과적으로 포트폴리오의 퀄리티를 높일 수 있다는 생각을 하게 되었다.</p>
<hr>
<h3 id="기술-방식-비교">기술 방식 비교</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>React-DnD / React-RND</th>
<th>캔버스(Konva.js)</th>
</tr>
</thead>
<tbody><tr>
<td>구현 방식</td>
<td>DOM 기반 요소 조작</td>
<td>캔버스 기반 객체 렌더링</td>
</tr>
<tr>
<td>성능</td>
<td>요소 증가 시 성능 저하 가능성 (리플로우, 리페인트 빈번 발생)</td>
<td>고성능 렌더링 가능 (렌더링 최적화 가능)</td>
</tr>
<tr>
<td>정밀 제어</td>
<td>CSS transform 기반으로 한정적이며 세밀한 제어가 어려움</td>
<td>픽셀 단위로 자유롭고 정밀한 위치 및 변형 제어 가능</td>
</tr>
<tr>
<td>고급 기능</td>
<td>요소 회전, 마스킹 등 구현 시 복잡하고 한계가 많음</td>
<td>요소 회전, 마스킹 등 그래픽 작업 손쉽게 구현</td>
</tr>
<tr>
<td>UX/UI 완성도</td>
<td>기본적인 편집 기능 중심</td>
<td>고급 디자인 툴 수준의 UX 제공</td>
</tr>
</tbody></table>
<h3 id="결론">결론</h3>
<p>결과적으로 DOM 기반의 React-DnD와 React-RND 방식에서 캔버스 기반의 Konva.js로의 전환을 통해 더 정밀한 제어와 고급스러운 그래픽 기능을 제공할 수 있었다. 캔버스 방식을 통해 개발 과정에서의 반복적이고 복잡한 로직을 간소화하고, UX적으로 우수한 사용자 경험을 실현할 수 있게 되어 포트폴리오의 기술적 완성도와 깊이를 높일 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[명함 제작/공유 및 통계 서비스(UUNO) 기획안]]></title>
            <link>https://velog.io/@woozi__zi/%EB%AA%85%ED%95%A8-%EC%A0%9C%EC%9E%91%EA%B3%B5%EC%9C%A0-%EB%B0%8F-%ED%86%B5%EA%B3%84-%EC%84%9C%EB%B9%84%EC%8A%A4UUNO-%EA%B8%B0%ED%9A%8D%EC%95%88</link>
            <guid>https://velog.io/@woozi__zi/%EB%AA%85%ED%95%A8-%EC%A0%9C%EC%9E%91%EA%B3%B5%EC%9C%A0-%EB%B0%8F-%ED%86%B5%EA%B3%84-%EC%84%9C%EB%B9%84%EC%8A%A4UUNO-%EA%B8%B0%ED%9A%8D%EC%95%88</guid>
            <pubDate>Tue, 01 Apr 2025 08:22:29 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요">1. 개요</h1>
<h3 id="목표">목표</h3>
<ul>
<li>사용자가 드래그 앤 드롭 방식으로 쉽게 명함을 제작</li>
<li>제작된 명함을 공유하여 발생하는 다양한 유저 액션(조회, 다운로드, 클릭 등)을 추적해 통계로 제공하는 웹 서비스 구축</li>
</ul>
<h3 id="핵심-가치">핵심 가치</h3>
<ul>
<li>사용 편의성이 높은 명함 제작 도구 제공</li>
<li>제작된 명함의 활용도 및 반응을 실시간 데이터로 확인 가능</li>
</ul>
<hr>
<h1 id="2-기술-스택">2. 기술 스택</h1>
<p>프론트엔드/백엔드</p>
<ul>
<li>Next.js 14 (최신 App Router, 서버 액션 등 활용)</li>
</ul>
<p>데이터베이스 및 인증</p>
<ul>
<li>Supabase (DB, Auth, RLS 등)</li>
</ul>
<p>통계 시각화</p>
<ul>
<li>Chart.js (다양한 차트 유형 활용: 라인, 도넛, 바 차트 등)</li>
</ul>
<p>명함 제작 인터페이스</p>
<ul>
<li>드래그 앤 드롭 방식 (예: react-dnd 등 라이브러리 활용)</li>
</ul>
<hr>
<h1 id="3-서비스-주요-기능">3. 서비스 주요 기능</h1>
<h2 id="명함-제작-및-편집">명함 제작 및 편집</h2>
<p>사용자는 템플릿 선택 후 에디터에서 드래그 앤 드롭 방식으로 명함 디자인
텍스트, 이미지, 아이콘 등의 요소를 자유롭게 배치하고, 수정 가능</p>
<p>명함 공유:
제작된 명함은 고유 URL(slug)을 통해 공유
공유된 명함은 별도의 페이지에서 일반 사용자에게 노출
데이터 추적 및 통계 분석:
추적 항목: 총 조회수, 고유 방문자, 이미지 저장 횟수, 이메일 클릭 횟수, QR 코드 스캔 횟수, 평균 체류 시간
각 이벤트 발생 시 API 호출을 통해 Supabase에 기록
수집된 데이터는 Chart.js를 통해 관리자 및 명함 소유자 대시보드에서 시각화</p>
<ol start="4">
<li><p>페이지 구성 및 역할</p>
<ol>
<li>홈페이지
•    서비스 소개, 주요 기능 설명, CTA(템플릿 선택, 로그인/회원가입 등)</li>
<li>템플릿 리스트 페이지
•    약 12개의 미리 디자인된 명함 템플릿 제공
•    사용자가 템플릿을 선택하면 에디터 페이지로 이동하여 해당 템플릿 기반 명함 제작 시작
•    템플릿은 기본 디자인으로 제공되며, 선택 후 에디터에서 자유롭게 수정 가능</li>
<li>마이페이지
•    사용자가 제작한 명함들을 한눈에 확인
•    각 명함에 대해 편집, 삭제, 공유 기능 제공</li>
<li>에디터 페이지
•    드래그 앤 드롭 기반 명함 편집 도구 제공
•    선택한 템플릿을 불러와 수정 또는 새로운 디자인 작업 진행
•    실시간 미리보기와 저장 기능 구현</li>
<li>명함 상세페이지
•    제작된 명함의 상세 정보 및 디자인 확인
•    명함과 관련된 통계(조회수, 유저 액션 등)를 시각화하여 표시 (Chart.js 활용)</li>
<li>명함 공유된 페이지
•    외부 사용자들이 공유된 명함을 열람할 수 있는 페이지
•    간편한 명함 뷰 제공 및 해당 명함 관련 이벤트(조회, 체류 시간 등) 기록</li>
<li>로그인/회원가입 페이지
•    사용자 인증 및 계정 생성
•    Supabase Auth 연동을 통한 보안 로그인/회원가입 처리</li>
</ol>
</li>
<li><p>데이터베이스 설계 및 이벤트 추적
 •    주요 테이블 예시:
 •    users: 사용자 계정 정보 저장
 •    cards: 제작된 명함 정보 (디자인 데이터, 소유자 정보, 고유 slug 등)
 •    card_views 또는 card_events: 명함 조회 및 각종 이벤트(이미지 저장, 이메일 클릭, QR 스캔 등) 기록
 •    각 이벤트는 event_type(조회, 저장, 클릭 등), 타임스탬프, 사용자 정보(IP, User-Agent 등)를 포함
 •    templates: 기본 템플릿 디자인 정보
 •    데이터 집계:
 •    총 조회수/고유 방문자: 각 명함의 접속 로그 분석
 •    이미지 저장/이메일 클릭/QR 스캔: 해당 액션 발생 시 API 호출로 이벤트 기록
 •    평균 체류 시간: 페이지 입장 및 퇴장 시각을 기록해 평균 계산</p>
</li>
<li><p>사용자 플로우</p>
<ol>
<li>사용자 방문 및 템플릿 선택:
•    사용자는 홈페이지에서 서비스 소개를 확인한 후, 템플릿 리스트 페이지로 이동
•    12개의 템플릿 중 원하는 디자인 선택</li>
<li>명함 제작 및 편집:
•    선택한 템플릿을 기반으로 에디터에서 드래그 앤 드롭 방식으로 명함 디자인
•    디자인 완료 후 저장, 마이페이지에서 관리</li>
<li>명함 공유 및 조회:
•    제작된 명함은 고유 URL로 생성되어 공유됨
•    다른 사용자는 공유된 페이지를 통해 명함을 확인하고, 이때 각종 유저 액션(조회, 클릭 등)이 기록됨</li>
<li>통계 확인:
•    명함 상세페이지에서 Chart.js를 이용해 각종 이벤트 및 통계 데이터를 시각적으로 확인
•    통계 데이터는 Supabase에서 집계되어 제공</li>
<li>로그인/회원가입:
•    사용자 인증 및 계정 관리를 위해 로그인/회원가입 페이지에서 계정 생성 및 로그인 진행</li>
</ol>
</li>
<li><p>구현 단계 및 고려 사항
 •    프로젝트 초기 세팅:
 •    Next.js 14, TypeScript 환경 구성
 •    Supabase 프로젝트 설정 및 연동 (.env 파일에 키/URL 저장)
 •    DB 및 인증 설정:
 •    Supabase Dashboard에서 users, cards, card_events 등 테이블 생성
 •    Supabase Auth를 활용한 사용자 인증 구현
 •    프론트엔드 구현:
 •    Next.js App Router를 활용한 각 페이지 구성
 •    에디터 페이지의 드래그 앤 드롭 인터페이스 구현 (react-dnd 등 사용)
 •    명함 상세 및 공유 페이지에서 이벤트 추적 스크립트 구현
 •    통계 및 대시보드 구현:
 •    API 라우트 혹은 서버 액션을 통해 Supabase에서 통계 데이터 집계
 •    Chart.js를 사용한 데이터 시각화 및 대시보드 구성
 •    테스트 및 배포:
 •    각 기능(명함 제작, 공유, 이벤트 기록, 통계 시각화) 단위 테스트
 •    최종 배포 전 성능 및 보안 점검 (특히 데이터 추적 및 인증 관련)<em>텍스트</em></p>
<ol start="8">
<li>DB
<img src="https://velog.velcdn.com/images/woozi__zi/post/4975e8c9-f20e-4bfd-a4fc-716cca3c96b6/image.png" alt=""></li>
</ol>
</li>
</ol>
<hr>
<ol start="9">
<li>유저 플로우
<img src="https://velog.velcdn.com/images/woozi__zi/post/c1241cfb-12ee-4ee5-8249-122a48fc4856/image.png" alt=""></li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Parallel 라우트를 이용한 모달 구현하기]]></title>
            <link>https://velog.io/@woozi__zi/Next.js-Parallel-%EB%9D%BC%EC%9A%B0%ED%8A%B8%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%AA%A8%EB%8B%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@woozi__zi/Next.js-Parallel-%EB%9D%BC%EC%9A%B0%ED%8A%B8%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%AA%A8%EB%8B%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Mar 2025 15:29:51 GMT</pubDate>
            <description><![CDATA[<h3 id="서론">서론</h3>
<p>최근 Next.js 프로젝트를 진행하며 parallel 라우트를 사용한 모달 구현 방법을 적용해 보았다. 
기존에 자주 사용했던 State + Portal 방식을 사용하는 모달과 비교하며 느낀 장점과 한계를 정리해보려고 한다.</p>
<h1 id="parallel-라우트란">Parallel 라우트란?</h1>
<p>Parallel 라우트는 Next.js에서 제공하는 기능 중 하나로, <strong>URL 경로에 영향을 주지 않으면서도 각 컴포넌트를 독립적으로 렌더링</strong>할 수 있게 해준다.
특히 로딩이나 에러 상태 관리, 데이터 캐싱 등을 Next.js에서 <strong>자동으로 최적화</strong>하여 제공한다.</p>
<hr>
<h2 id="parallel-라우트가-필요한-이유">Parallel 라우트가 필요한 이유</h2>
<h3 id="1-route-segment처럼-로딩과-에러-상태-관리-가능">1. Route Segment처럼 로딩과 에러 상태 관리 가능</h3>
<ul>
<li><strong>URL에 영향을 주지 않는 독립적인 컴포넌트</strong>에도, 마치 일반적인 Route Segment처럼 loading과 error 상태를 관리할 수 있게 된다.</li>
</ul>
<h3 id="2-fetch-캐시-관리-최적화">2. Fetch 캐시 관리 최적화</h3>
<ul>
<li>비동기 요청 시 Next.js가 자동으로 fetch 요청을 캐싱해주고 효율적으로 관리한다.</li>
</ul>
<hr>
<h2 id="기존state--portal-방식-모달의-장단점">기존(State + Portal) 방식 모달의 장단점</h2>
<p>기존의 모달 구현 방식은 React의 State와 React Portal을 이용하여 구현하는 방법이다.</p>
<h3 id="장점">장점</h3>
<ul>
<li><p>구현이 직관적이고 간단함.</p>
</li>
<li><p>URL에 영향을 미치지 않기 때문에 <strong>모달의 상태만으로 간단히 관리</strong>할 수 있음.</p>
</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li><p>상태 관리 코드가 증가하며 유지보수가 어려워질 수 있음.</p>
</li>
<li><p>웹뷰(Android) 환경에서 물리적인 백버튼을 눌렀을 때 추가적인 코드가 필요하여 복잡성 증가.</p>
</li>
<li><p>뒤로가기나 페이지 전환 시 모달 상태 관리가 까다로워질 수 있음.</p>
</li>
</ul>
<pre><code>// 기존 방식 모달 예시
const [open, setOpen] = useState(false);

return (
  &lt;&gt;
    &lt;button onClick={() =&gt; setOpen(true)}&gt;모달 열기&lt;/button&gt;
    {open &amp;&amp; ReactDOM.createPortal(
      &lt;div className=&quot;modal&quot;&gt;
        모달 내용
        &lt;button onClick={() =&gt; setOpen(false)}&gt;닫기&lt;/button&gt;
      &lt;/div&gt;,
      document.getElementById(&#39;modal-root&#39;)
    )}
  &lt;/&gt;
);</code></pre><hr>
<h2 id="parallel-라우트를-사용한-모달의-장단점">Parallel 라우트를 사용한 모달의 장단점</h2>
<h3 id="장점-1">장점</h3>
<ul>
<li><p>별도의 <strong>상태 관리 코드가 필요 없으므로</strong> 코드가 간결해지고 유지보수가 쉬워진다.</p>
</li>
<li><p>브라우저의 뒤로가기 버튼을 이용하여 모달을 자연스럽게 닫을 수 있어 UX 상승</p>
</li>
<li><p>Next.js에서 fetch 요청을 자동으로 최적화 관리</p>
</li>
</ul>
<h3 id="단점-1">단점</h3>
<ul>
<li><p>기본적으로 <strong>소프트 내비게이션(CSR, router.push 또는 Link 사용)에 적합</strong>하며, <strong>하드 내비게이션(새로고침, 직접적인 URL 접근) 처리에는 한계</strong>가 있다.</p>
</li>
<li><p>중첩된 모달을 구현시, 추가적인 parallel route 슬롯을 만들어야 하는 복잡성이 있다.</p>
</li>
</ul>
<pre><code>// 폴더 구조
app/
├── @modal
│   └── modal
│       └── page.jsx
└── page.jsx</code></pre><pre><code>// Parallel 라우트를 이용한 모달 예시
// page.jsx
import Link from &#39;next/link&#39;;

export default function Page() {
  return (
    &lt;&gt;
      &lt;Link href=&quot;/@modal/modal&quot; scroll={false}&gt;모달 열기&lt;/Link&gt;
    &lt;/&gt;
  );
}

// @modal/modal/page.jsx
export default function Modal() {
  return (
    &lt;div className=&quot;modal&quot;&gt;
      모달 내용
    &lt;/div&gt;
  );
}</code></pre><h2 id="결론">결론</h2>
<p>Parallel 라우트를 이용한 모달 구현은 다음과 같은 경우에 유리하다.</p>
<ul>
<li>간단한 상태 관리와 자연스러운 뒤로가기 처리가 중요한 경우</li>
<li>모달이 빈번히 열리고 닫히며, 상태 코드가 복잡해질 수 있는 경우</li>
<li>Next.js의 fetch 캐싱 기능을 적극적으로 활용하고 싶은 경우</li>
</ul>
<p>반면, 다음과 같은 상황에서는 기존의 State + Portal 방식이 더 적합하다.</p>
<ul>
<li>URL 경로를 절대 변경하지 않고 모달을 처리해야 하는 경우</li>
<li>하드 내비게이션(새로고침 등)과 직접적인 URL 접근을 빈번하게 사용하는 경우</li>
<li>중첩된 모달을 관리하기 위한 추가적인 parallel 슬롯 관리가 번거로운 경우</li>
</ul>
<p>프로젝트의 특성과 요구 사항에 따라 적절한 방식을 선택하면 최적의 개발 경험과 사용자 경험을 제공할 수 있을 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] server action과 route handler의 차이]]></title>
            <link>https://velog.io/@woozi__zi/Next.js-server-action%EA%B3%BC-route-handler%EC%9D%98-%EC%B0%A8%EC%9D%B4</link>
            <guid>https://velog.io/@woozi__zi/Next.js-server-action%EA%B3%BC-route-handler%EC%9D%98-%EC%B0%A8%EC%9D%B4</guid>
            <pubDate>Wed, 19 Mar 2025 12:42:54 GMT</pubDate>
            <description><![CDATA[<h1 id="1-서버-액션server-action이란">1. 서버 액션(Server Action)이란?</h1>
<ul>
<li>Next.js의 새로운 기능 중 하나로, 클라이언트 컴포넌트 내부에서 <strong>서버로 직접 호출</strong>할 수 있는 서버 전용 함수</li>
</ul>
<h2 id="주요-특징">주요 특징</h2>
<h3 id="1-클라이언트와의-긴밀한-통합">1. 클라이언트와의 긴밀한 통합</h3>
<ul>
<li>클라이언트 컴포넌트 내에서 함수 호출만으로 서버 로직을 실행 가능</li>
<li>별도의 API 엔드포인트를 구성할 필요 없이, UI와 <strong>밀접하게 연동</strong>되는 기능들을 쉽게 구현 가능</li>
</ul>
<h3 id="2-보안">2. 보안</h3>
<ul>
<li>서버 액션은 서버에서만 실행되기에 민감한 정보가 클라이언트에 <strong>노출 방지</strong></li>
<li>Ex) 폼 제출 시 입력 데이터를 서버에서 직접 처리하여 응답하는 경우</li>
</ul>
<h3 id="3-개발-생산성">3. 개발 생산성</h3>
<ul>
<li>하나의 파일 내에서 클라이언트와 서버 로직을 함께 관리할 수 있기 때문에, 코드 중복을 줄이고 빠른 프로토타이핑이 가능</li>
</ul>
<h3 id="요약">요약</h3>
<ul>
<li><strong>UI와 밀접하게 연관된 작업(폼 제출, 간단한 데이터 업데이트 등)</strong></li>
<li><strong>클라이언트와 서버 간의 경계를 최소화해 개발하고자 할 때</strong></li>
</ul>
<hr>
<h1 id="2-라우트-핸들러route-handler란">2. 라우트 핸들러(Route Handler)란?</h1>
<ul>
<li>Next.js의 파일 기반 라우팅 시스템을 활용하여 API 엔드포인트를 구성하는 방식</li>
<li>일반적으로 /app/api 폴더에 파일을 생성해 HTTP 메서드(GET, POST, PUT, DELETE 등)에 따라 서버 사이드 로직을 작성</li>
</ul>
<h2 id="주요-특징-1">주요 특징</h2>
<h3 id="1-모듈화된-서버-로직">1. 모듈화된 서버 로직</h3>
<ul>
<li>UI와 분리된 <strong>독립적인 API 엔드포인트</strong>를 구성함으로써, 복잡한 비즈니스 로직을 체계적으로 관리 가능</li>
<li><strong>파일 기반 라우팅</strong> 덕분에 프로젝트의 구조가 명확해질 수 있음</li>
<li>즉, 어느 파일이 어떤 역할을 하는지 쉽게 파악 가능</li>
</ul>
<h3 id="2-다양한-http-메서드-지원">2. 다양한 HTTP 메서드 지원</h3>
<ul>
<li>하나의 라우트 파일 내에서 여러 HTTP 메서드를 처리할 수 있어, 복잡한 API 로직이나 다양한 클라이언트 요청을 효과적으로 대응 가능</li>
</ul>
<h3 id="3-외부-시스템과의-연동">3. 외부 시스템과의 연동</h3>
<p>Next.js 애플리케이션 외부의 다른 서비스(예: 모바일 앱, 다른 웹 애플리케이션)와의 통신에 적합</p>
<ul>
<li>RESTful API 서버로서 역할을 수행 가능</li>
</ul>
<p>** RESTful(Representational State Transfer) : 간결하고 일관된 방식으로 클라이언트와 서버 간의 데이터를 교환할 수 있도록 도와주는 설계 방식</p>
<h3 id="요약-1">요약</h3>
<ul>
<li><strong>독립적인 API 서비스 구축 (여러 클라이언트가 호출할 수 있는 공개 API 제공)</strong></li>
<li><strong>복잡한 로직이나 다양한 HTTP 메서드를 필요로 하는 경우</strong></li>
<li><strong>UI와 서버 로직을 완전히 분리해 관리하고자 할 때</strong></li>
</ul>
<hr>
<h2 id="차이점">차이점</h2>
<h2 id="3-서버-액션과-라우트-핸들러의-차이점-및-선택-기준">3. 서버 액션과 라우트 핸들러의 차이점 및 선택 기준</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>서버 액션 (Server Action)</th>
<th>라우트 핸들러 (Route Handler)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>구조</strong></td>
<td>컴포넌트 내에 직접 포함</td>
<td>파일 기반 API 엔드포인트</td>
</tr>
<tr>
<td><strong>통합성</strong></td>
<td>UI와 긴밀하게 연동 (단순 작업에 적합)</td>
<td>UI와 분리된 독립적 API, 외부 시스템 연동에 유리</td>
</tr>
<tr>
<td><strong>보안</strong></td>
<td>서버에서 실행되어 민감한 정보 노출 위험 낮음</td>
<td>서버 사이드에서 실행되어 보안 유지 가능</td>
</tr>
<tr>
<td><strong>HTTP 메서드</strong></td>
<td>주로 단일 작업 (폼 제출 등)</td>
<td>다양한 HTTP 메서드(GET, POST, PUT, DELETE 등) 지원</td>
</tr>
<tr>
<td><strong>개발 생산성</strong></td>
<td>빠른 프로토타이핑과 코드 통합</td>
<td>모듈화된 코드 관리로 유지보수 용이</td>
</tr>
</tbody></table>
<h2 id="결론">결론</h2>
<p>*<em>서버 액션 : *</em></p>
<ul>
<li>UI와 긴밀하게 연동된 간단한 작업을 신속하게 처리하고자 할 때 유용</li>
</ul>
<h4 id="라우트-핸들러-">라우트 핸들러 :</h4>
<ul>
<li>독립적인 API를 구성해 외부 시스템과의 통신이나 복잡한 로직을 모듈화하여 관리할 때</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] errors.tsx와 global-error.tsx]]></title>
            <link>https://velog.io/@woozi__zi/Next.js-errors.tsx%EC%99%80-global-error.tsx</link>
            <guid>https://velog.io/@woozi__zi/Next.js-errors.tsx%EC%99%80-global-error.tsx</guid>
            <pubDate>Tue, 18 Mar 2025 10:37:01 GMT</pubDate>
            <description><![CDATA[<h2 id="error-핸들링">Error 핸들링</h2>
<p>Next.js에서는 <code>error.js</code> 파일을 통해서 중첩된 라우트에서 발생하는 예기치 못한 런타임 오류를 처리 할 수 있음
클라이언트 컴포넌트에서는 처리할 방법이 많기 때문에 서버에서 다루는 방법에 대해 작성해보자.</p>
<ul>
<li>error.tsx : 특정 경로(페이지)에서 발생한 에러를 처리(재시도 or 홈으로 이동)</li>
<li>global-error.tsx : 앱 전체에서 발생하는 에러를 전역으로 처리</li>
</ul>
<h3 id="errortsx">error.tsx</h3>
<p>각 경로에서 에러가 발생하면 해당 경로에 생성된 error.tsx가 자동 렌더링</p>
<pre><code>&quot;use client&quot;;

import { useRouter } from &quot;next/navigation&quot;;
import { startTransition } from &quot;react&quot;;

export default function Error({
  error,
  reset,
}: {
  error: Error &amp; { digest?: string };
  reset: () =&gt; void;
}) {
  const { refresh } = useRouter();

  return (
    &lt;div&gt;
      &lt;h2&gt;에러가 발생했습니다&lt;/h2&gt;
      &lt;h2&gt;{error.message}&lt;/h2&gt;
      &lt;button
        onClick={() =&gt;
          startTransition(() =&gt; {
            refresh();
            reset();
          })
        }
      &gt;
        Try again
      &lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre><p><code>useRouter()</code>와 <code>startTransition()</code>을 꼭 포함시키기
사용하지 않으면 버튼을 클릭해도 새로고침이 되지 않음</p>
<p>refresh() : 강제 새로고침
startTransition() : 상태 업데이트가 긴급하지 않은 경우, 해당 업데이트를 낮은 우선순위 작업으로 처리(ui 응답성 개선)</p>
<h3 id="global-errortsx">global-error.tsx</h3>
<p>전역 에러를 처리하기 위한 컴포넌트</p>
<pre><code>&#39;use client&#39;;

export default function GlobalError({
  error,
  reset,
}: {
  error: Error &amp; { digest?: string }
  reset: () =&gt; void
}) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;h2&gt;Something went wrong!&lt;/h2&gt;
        &lt;button onClick={() =&gt; reset()}&gt;Try again&lt;/button&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre><p><strong>global-error.tsx가 필요한 이유</strong></p>
<ul>
<li>error.tsx는 각 경로(폴더)마다 별도로 적용되기에 해당 경로 내에 발생하는 에러만 처리</li>
<li>따라서 앱 전체에서 발생하는 예외, 비동기 로직에서 잡히지 않은 에러, 최상단에서 발생한 에러에 대해서는 error.tsx로는 처리 할 수 없음</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] next-themes로 다크모드 ]]></title>
            <link>https://velog.io/@woozi__zi/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-k38cn930</link>
            <guid>https://velog.io/@woozi__zi/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-k38cn930</guid>
            <pubDate>Tue, 18 Mar 2025 10:11:38 GMT</pubDate>
            <description><![CDATA[<h3 id="마운트-상태-확인의-중요성">마운트 상태 확인의 중요성</h3>
<p>다크모드를 추가하고 나니 에러 발생</p>
<pre><code>Uncaught Error: Minified React error #418; visit https://react.dev/errors/418 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.</code></pre><p><strong>기존코드</strong></p>
<pre><code>&quot;use client&quot;;

import { Moon, Sun } from &quot;lucide-react&quot;;
import { useTheme } from &quot;next-themes&quot;;

const DarkMode = () =&gt; {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button
      onClick={() =&gt; setTheme(theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;)}
      className=&quot;rounded px-4 py-2&quot;
    &gt;
      {theme === &quot;dark&quot; ? &lt;Sun /&gt; : &lt;Moon /&gt;}
    &lt;/button&gt;
  );
};

export default DarkMode;</code></pre><p>하지만, 초기 렌더링 시 <code>theme</code> 값이 아직 제대로 설정되지 않거나, 서버와 클라이언트 간의 불일치로 인해 에러가 발생</p>
<h3 id="원인">원인</h3>
<p>Next-themes는 브라우저에서 사용자의 설정이나 OS 테마에 따라 다크모드를 결정하는데 초기기 랜더링할 때는 theme가 설정되지 않을 수 있음.</p>
<h3 id="해결-방법-마운트-상태-확인">해결 방법: 마운트 상태 확인</h3>
<p><strong>변경코드</strong></p>
<pre><code>&quot;use client&quot;;

import { Moon, Sun } from &quot;lucide-react&quot;;
import { useTheme } from &quot;next-themes&quot;;
import { useEffect, useState } from &quot;react&quot;;

const DarkMode = () =&gt; {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() =&gt; {
    setMounted(true);
  }, []);

  if (!mounted) return null;

  return (
    &lt;button
      onClick={() =&gt; setTheme(theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;)}
      className=&quot;rounded px-4 py-2&quot;
    &gt;
      {theme === &quot;dark&quot; ? &lt;Sun /&gt; : &lt;Moon /&gt;}
    &lt;/button&gt;
  );
};

export default DarkMode;</code></pre><p>초기에 useState로 mounted를 false로 주고 나서 useEffect로 렌더링이 되면 true로 바꿔줘서 해결</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 이미지 로딩에 대해]]></title>
            <link>https://velog.io/@woozi__zi/ff</link>
            <guid>https://velog.io/@woozi__zi/ff</guid>
            <pubDate>Mon, 17 Mar 2025 13:59:24 GMT</pubDate>
            <description><![CDATA[<h3 id="nextjs의-이미지-로딩-이슈와-해결-방법">Next.js의 이미지 로딩 이슈와 해결 방법</h3>
<p>최근 과제를 진행하면서 챔피언 리스트에서 특정 캐릭터의 상세 정보를 확인할 때 발생하는 이미지 로딩 지연 문제를 발견했습니다.</p>
<h3 id="문제-상황"><strong>문제 상황</strong></h3>
<p>상세 페이지에 들어가면 챔피언의 스킬 정보와 함께 각각의 스킬 이미지가 표시되도록 만들었다. 그런데 일부 챔피언 상세 페이지에서는 스킬 이미지가 바로 로드되는 반면, 다른 페이지에서는 이미지가 1초에서 3초 정도 늦게 나타나는 문제가 있었습니다.</p>
<h3 id="문제-확인"><strong>문제 확인</strong></h3>
<p>기본 HTML의 <code>&lt;img&gt;</code> 태그와 Next.js의 Next/Image 컴포넌트를 비교</p>
<ul>
<li><code>&lt;img&gt;</code> 태그는 별도의 lazy loading(지연 로딩) 설정이 없기 때문에 브라우저가 페이지 렌더링 시 바로 이미지를 로드</li>
</ul>
<h3 id="원인">원인</h3>
<p><strong>원인</strong> : Next.js의 Next/Image 컴포넌트가 기본적으로 <strong>lazy loading</strong>(지연 로딩)을 사용하기 때문 </p>
<p><strong>Lazy loading이란</strong></p>
<ul>
<li>사용자가 현재 보고 있는 영역에 노출되는 이미지들만 우선 로드 </li>
<li>나머지는 스크롤 등으로 실제 화면에 나타날 때 로드함으로써 초기 로딩 속도를 개선하는 기법</li>
<li>따라서 캐시에 이미지가 없거나 아직 해당 영역에 도달하지 않은 경우, 이미지 로딩이 지연되는 현상이 발생</li>
</ul>
<h3 id="해결-방안">해결 방안</h3>
<ol>
<li>Next/Image 컴포넌트에 priority 속성 사용 (강제 로드)</li>
</ol>
<ul>
<li>해당 이미지를 초기 로딩 시 우선적으로 로드하도록 지시</li>
</ul>
<ol start="2">
<li>Next/Image 컴포넌트에 loading=&quot;eager&quot; 속성 사용 (강제 로드)
lazy loading 대신 이미지를 즉시 강제 로드</li>
</ol>
<pre><code>   {championDetail.spells.map((spell) =&gt; (
        &lt;div key={spell.id} className=&quot;flex flex-row items-center space-x-8&quot;&gt;
          &lt;div&gt;
            &lt;span&gt;스킬 : {spell.id.slice(-1)}&lt;/span&gt;
            &lt;Image
              priority // 1번째 방법
              loading=&quot;eager&quot; //2번째 방법
              src={`${SERVER_URL}/cdn/${VERSION}/img/spell/${spell.image.full}`}
              alt={spell.name}
              width={70}
              height={70}
            /&gt;
          &lt;/div&gt;
          &lt;img
            src={`${SERVER_URL}/cdn/${VERSION}/img/spell/${spell.image.full}`}
          /&gt;</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Next js] Next.js의 Caching]]></title>
            <link>https://velog.io/@woozi__zi/Next-js-Suspense-Loading-UI-Streaming-SSR</link>
            <guid>https://velog.io/@woozi__zi/Next-js-Suspense-Loading-UI-Streaming-SSR</guid>
            <pubDate>Wed, 12 Mar 2025 12:19:54 GMT</pubDate>
            <description><![CDATA[<h2 id="nextjs의-캐싱caching이란">Next.js의 캐싱(Caching)이란?</h2>
<p>Next.js는 페이지 성능을 향상시키기 위해 <strong>자동으로 다양한 캐싱</strong> 전략을 제공
    - <strong>빌드 시점 캐싱</strong> (Full Route Cache)
    - <strong>요청 시 데이터 캐싱</strong> (Data Cache)</p>
<p>Next.js의 fetch API는 브라우저의 fetch API를 확장한 것이며, 캐싱 전략을 설정할 수 있는 강력한 기능을 제공</p>
<hr>
<h3 id="1-full-route-cache-빌드-시점-캐시">1. Full Route Cache (빌드 시점 캐시)</h3>
<p><strong>정의</strong>: Next.js가 <strong>빌드 단계에서 전체 페이지를 렌더링</strong>하여 HTML 및 데이터를 미리 캐싱</p>
<p><strong>장점</strong>: 동일한 페이지 요청이 올 때 매번 렌더링하지 않고 미리 렌더링된 캐시를 제공하기에 응답 시간이 매우 빠름</p>
<p><strong>특징</strong>:
    - React Server Components를 미리 렌더링하여 결과를 캐싱
    - 사용자 요청마다 미리 생성된 페이지 제공 (속도 매우 빠름)</p>
<p><strong>단점</strong>: 데이터가 자주 변하는 경우, 실시간 반영이 어려움</p>
<hr>
<h3 id="2-data-cache-요청-시점-데이터-캐시">2. Data Cache (요청 시점 데이터 캐시)</h3>
<p><strong>정의</strong>: 데이터 요청 시점에 데이터를 캐싱하여 반복적인 API 호출을 방지하는 방식</p>
<p><strong>효과</strong>: 데이터 요청 횟수를 줄여 응답 속도를 높이고, 서버 부하를 줄여줌</p>
<p>예시 (ISR)</p>
<pre><code>// 1시간 동안 캐시된 데이터를 제공, 이후 다시 불러옴
const res = await fetch(&#39;https://api.example.com/data&#39;, { next: { revalidate: 3600 } });
const data = await res.json();</code></pre><p>예시 (SSR)</p>
<pre><code>// 항상 최신 데이터를 요청 (캐시 사용 X)
const res = await fetch(&#39;https://api.example.com/data&#39;, { cache: &#39;no-store&#39; });
const data = await res.json();</code></pre><hr>
<h3 id="유용한-캐싱-기능">유용한 캐싱 기능</h3>
<h4 id="generatestaticparams">generateStaticParams()</h4>
<p>정적인 페이지들을 빌드 시점에 미리 생성하는 데 사용</p>
<pre><code>// posts의 slug를 이용해 정적 페이지 미리 생성
export async function generateStaticParams() {
  const posts = await fetch(&#39;https://.../posts&#39;).then((res) =&gt; res.json());

  return posts.map((post) =&gt; ({
    slug: post.slug,
  }));
}

// params로 전달된 값 사용 가능
export default function Page({ params }) {
  const { slug } = params;
  // ...
}</code></pre><hr>
<h4 id="태그-기반-캐싱-tags">태그 기반 캐싱 (tags)</h4>
<p>특정 데이터를 태그로 식별하여 효율적으로 캐시를 관리</p>
<pre><code>fetch(&#39;/api/data&#39;, {
  next: {
    tags: [&#39;products&#39;],
  }
});</code></pre><hr>
<h4 id="캐시-무효화-revalidate">캐시 무효화 (revalidate)</h4>
<p>캐시된 데이터를 주기적으로 갱신하는데 사용 (ISR 방식) </p>
<pre><code>fetch(&#39;/api/data&#39;, {
  next: {
    revalidate: 60, // 60초마다 데이터 갱신
  }
});</code></pre>]]></description>
        </item>
    </channel>
</rss>