<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yeon_99.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 09 Mar 2026 13:17:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. yeon_99.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yeon_99" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA["비밀번호 틀렸는데 세션 만료라니요?" — 에러 메시지도 UX인 이유]]></title>
            <link>https://velog.io/@yeon_99/%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%ED%8B%80%EB%A0%B8%EB%8A%94%EB%8D%B0-%EC%84%B8%EC%85%98-%EB%A7%8C%EB%A3%8C%EB%9D%BC%EB%8B%88%EC%9A%94-%EC%97%90%EB%9F%AC-%EB%A9%94%EC%8B%9C%EC%A7%80%EB%8F%84-UX%EC%9D%B8-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@yeon_99/%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%ED%8B%80%EB%A0%B8%EB%8A%94%EB%8D%B0-%EC%84%B8%EC%85%98-%EB%A7%8C%EB%A3%8C%EB%9D%BC%EB%8B%88%EC%9A%94-%EC%97%90%EB%9F%AC-%EB%A9%94%EC%8B%9C%EC%A7%80%EB%8F%84-UX%EC%9D%B8-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 09 Mar 2026 13:17:39 GMT</pubDate>
            <description><![CDATA[<p>최근 진행 중인 프로젝트에서 당황스러운 피드백을 하나 받았습니다.
<strong>&quot;로그인을 하려는데 자꾸 세션이 만료됐다고 뜨면서 안 돼요!&quot;</strong></p>
<p>분명 로그를 확인해 보니 비밀번호를 틀려서 발생한 에러였는데, 왜 화면에는 세션 만료가 떴을까요? 범인은 바로 <strong>과거의 저</strong>였습니다. 😂</p>
<p>개발자인 저는 <code>F12</code>를 눌러 Network 탭을 보면 그만이지만, 사용자는 제가 띄워준 <strong>토스트 알림 하나</strong>에 의지해 서비스를 이용합니다. 엉성했던 에러 처리가 사용자를 얼마나 혼란스럽게 만들었는지 반성하며, 이를 구조적으로 개선한 과정을 공유해 봅니다.</p>
<hr>
<h2 id="1-문제의-시작--401은-다-세션만료">1. 문제의 시작 : &quot;401은 다 세션만료?&quot;</h2>
<p>사실 처음부터 이랬던 건 아니었습니다. 원래는 로그인/비밀번호 에러만 각각 처리하고 있었는데요. 어느 날 &quot;로그인 상태인데 삭제나 수정이 안된다&quot;는 피드백을 받게 됩니다. 원인은 세션 만료였죠.</p>
<p>그래서 급하게 &quot;401 Unauthorized 에러가 오면 전역적으로 잡아서 세션 만료로 처리하고 재로그인을 시키자!&quot;라고 수정을 해뒀습니다. 문제를 고치려다 다른 문제를 만든 실화</p>
<p>🛑 기존 방식의 문제점들</p>
<ul>
<li><strong>일반화의 오류</strong> : 비밀번호가 틀려도 <code>401</code>, 토큰이 없어도 <code>401</code>... 다 똑같이 &quot;세션 만료&quot;라고 퉁처버렸습니다.</li>
<li><strong>노가다 하드코딩</strong> : API마다 응답 형식이 제각각이라 프론트에서 &quot;등록 실패&quot;, &quot;수정 실패&quot; 메시지를 매번 하드코딩해서 띄웠습니다.</li>
<li><strong>불친절한 정보</strong> : 특히 인사 정보 입력할 때 중복 데이터가 있으면 &quot;뭐가 중복인지&quot;를 안 알려주니 사용자는 답답할 노릇이었죠.</li>
</ul>
<hr>
<h2 id="2-백엔드--에러에도-이름을-붙여주자-fastapi">2. 백엔드 : 에러에도 &#39;이름&#39;을 붙여주자 (FastAPI)</h2>
<p>먼저 백엔드에서 내려주는 에러 응답 규격부터 통일했습니다. 프론트가 에러의 정체를 바로 알 수 있게 <code>errorCode</code>를 심어준 것입니다.</p>
<pre><code class="language-python"># 공통 에러 응답 양식
return JSONResponse(
    status_code=409,
    content={
        &quot;success&quot;: False, 
        &quot;errorCode&quot; : &quot;DB_DUPLICATE_ENTRY&quot;,
        &quot;message&quot;: &quot;이미 등록된 정보(주민번호 등)가 존재합니다.&quot;, 
        &quot;data&quot;: None
    },
)</code></pre>
<p>그리고 모든 API마다 <code>try-except</code>를 쓸 순 없으니, <code>main.py</code>에서 전역적으로 에러를 잡아내도록 만들었다. (파이썬이 아직 낯설지만 적응 중입니다! 🐍)</p>
<pre><code class="language-python">@app.exception_handler(IntegrityError)
async def integrity_exception_handler(request: Request, exc: IntegrityError):
    logger.error(f&quot;DB Integrity Error: {exc.orig}&quot;) 
    return JSONResponse(
        status_code=409,
        content={
            &quot;success&quot;: False,
            &quot;errorCode&quot;: &quot;DB_DUPLICATE_ENTRY&quot;,
            &quot;message&quot;: &quot;이미 등록된 정보가 존재합니다.&quot;,
            &quot;data&quot;: None
        }
    )</code></pre>
<hr>
<h2 id="3-프론트엔드--너-무슨-에러니-axios-interceptor">3. 프론트엔드 : &quot;너 무슨 에러니&quot; (Axios Interceptor)</h2>
<p>이제 프론트에서는 Axios 인터셉터를 활용해 <code>errorCode</code>에 따라 똑똑하게 대응합니다. 무조건 로그인 모달을 띄우는 게 아니라, 상황을 먼저 파악하는 거죠.</p>
<p>✅ 개선된 인터셉터 로직</p>
<pre><code class="language-javascript">api.interceptors.response.use(
  (response) =&gt; response,
  (error) =&gt; {
    const { response } = error;

    if (response &amp;&amp; response.status === 401) {
      const errorCode = response.data?.errorCode;

      // 1. 아이디/비밀번호 자체가 틀린 경우 (진짜 로그인 실패)
      if (errorCode?.startsWith(&quot;AUTH_INVALID_CREDENTIALS&quot;)) {
        toast.error(&quot;아이디 또는 비밀번호를 다시 확인해주세요.&quot;);
      } 
      // 2. 진짜 세션이 만료되었거나 토큰이 없는 경우 (재로그인 필요)
      else {
        toast.error(&quot;세션이 만료되었습니다. 다시 로그인해주세요.&quot;);
        useModal.getState().openModal(&quot;login&quot;); // Zustand로 로그인 모달 짠!
      }
      return new Promise(() =&gt; {}); 
    }
    return Promise.reject(error);
  }
);</code></pre>
<hr>
<h2 id="4-마치며--대충-하지-말자">4. 마치며 : 대충 하지 말자.</h2>
<p>사실 개발할 때는 기능 구현이 급해서 에러 처리를 뒤로 미루기 쉽습니다. 하지만 피드백을 받을 때마다 느끼는 건, <strong>사용자에게 가장 친절해야 할 순간이 바로 에러가 발생한 순간</strong>이라는 점이에요.</p>
<p>요즘 AI 덕분에 코딩 속도는 엄청 빨라졌지만, 이런 디테일한 사용자 경험과 테스트를 챙기는 건 결국 개발자의 몫인 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE(Server-Sent Events)란?]]></title>
            <link>https://velog.io/@yeon_99/SSEServer-Sent-Events%EB%9E%80</link>
            <guid>https://velog.io/@yeon_99/SSEServer-Sent-Events%EB%9E%80</guid>
            <pubDate>Thu, 07 Aug 2025 17:39:59 GMT</pubDate>
            <description><![CDATA[<h1 id="📡-sse란">📡 SSE란?</h1>
<p><a href="https://developer.mozilla.org/ko/docs/Web/API/Server-sent_events/Using_server-sent_events">SSE 사용법 자세히 보기 (MDN)</a>
SSE는 Server-Sent Events로, <strong>서버에서 클라이언트로 단방향 이벤트 스트림을 전송하는 기술</strong>이다.</p>
<h2 id="🔍-언제-사용하면-좋을까">🔍 언제 사용하면 좋을까?</h2>
<p>웹에서 실시간 통신을 구현할 때는 주로 아래 3가지 방식을 고려한다. 상황에 맞게 선택하는 것이 중요하다.</p>
<h3 id="📌-polling-폴링">📌 Polling (폴링)</h3>
<p>2~3초마다 주기적으로 서버에 요청을 보내는 방식이다.</p>
<ul>
<li>구현은 가장 간단하지만, 불필요한 요청이 반복되어 서버 부담이 큼</li>
<li>정달 간단한 구현, 테스트할 때 사용하자.</li>
</ul>
<h3 id="📌-websocket-웹소켓">📌 WebSocket (웹소켓)</h3>
<p>최초 한 번만 HTTP 요청으로 연결을 맺은 후, 쌍방향 통신을 지속적으로 유지한다.</p>
<ul>
<li>단점은 초기 설정이나 구현이 복잡하다는 점</li>
<li>채팅, 실시간 협업 등 클라이언트-서버 간 상호작용이 많을 때 사용하자. </li>
</ul>
<h3 id="📌-sse-server-sent-events">📌 SSE (Server-Sent Events)</h3>
<p>HTTP 기반의 단방향 통신 방식으로, 서버에서 클라이언트로만 데이터 전송이 가능하다.</p>
<ul>
<li>웹소켓보다 구현이 간단하고, 브라우저에서 자동 재연결 처리도 수월하다.</li>
<li>알림, 단순한 실시간 업데이트 등에 사용하자.</li>
</ul>
<p>📎 나의 경험:</p>
<blockquote>
<p>무인매장 관리 시스템에서 실시간 알림을 구현할 때 SSE를 사용한 경험이 있다. 
 이번 프로젝트에서도 <strong>읽지 않은 메시지 수를 실시간으로 업데이트</strong> 하기 위해 SSE를 선택했다.</p>
</blockquote>
<hr>
<h2 id="eventsource">EventSource</h2>
<p>브라우저에서 기본적으로 제공하는 <code>EventSource</code> 객체를 이용해 서버와 SSE 연결을 할 수 있다.</p>
<pre><code class="language-js">  eventSource = new EventSource(
    `${process.env.VUE_APP_API_BASE_URL}/sse/subscribe?token=${token}`
  );

  eventSource.addEventListener(&quot;unread-count&quot;, (event) =&gt; {
    try {
      const data = JSON.parse(event.data);
      callback(data);
      console.log(&quot;unread-count 이벤트 data:&quot;, data);
    } catch (error) {
      console.error(&quot;SSE unread-count 데이터 파싱 에러&quot;, error);
    }
  });

  eventSource.onerror = (error) =&gt; {
    console.error(&quot;SSE 연결 오류&quot;, error);
    disconnectSSE();
  };</code></pre>
<p>⚠️ 알아야 할 점</p>
<ul>
<li><code>EventSource</code>는 <strong>GET 메서드</strong>만 지원하고, <strong>헤더 설정이 불가능</strong>하다.</li>
<li>위 코드처럼 쿼리스트링으로 토큰을 넘기는 방식은 보안상 안전하지 않을 수 있다. (URL 로그에 토큰이 노출될 수 있음)</li>
</ul>
<hr>
<h2 id="event-source-polyfill">Event-source-polyfill</h2>
<p><code>EventSource</code>와 사용법은 거의 동일하나 <strong>헤더를 직접 추가할 수 있는</strong> 라이브러리이다.
그래서 나는 이 라이브러리로 헤더에 token을 보내는 방법으로 수정했다.</p>
<h4 id="✅-설치">✅ 설치</h4>
<pre><code>npm install event-source-polyfill</code></pre><h4 id="✅-적용-코드">✅ 적용 코드</h4>
<pre><code class="language-js">import { EventSourcePolyfill } from &quot;event-source-polyfill&quot;;

  eventSource = new EventSourcePolyfill(
    `${process.env.VUE_APP_API_BASE_URL}/sse/subscribe`,
    {
      headers: {
        Authorization: `Bearer ${token}`,
      },
      withCredentials: true,
    }
  );</code></pre>
<hr>
<h3 id="🖥️-서버spring-boot도-함께-수정">🖥️ 서버(Spring Boot)도 함께 수정</h3>
<p>기존에는 <code>@RequestParams</code>으로 토큰을 받았다면, 이제는 <code>@RequestHeader</code>로 헤더를 받아 처리한다.</p>
<pre><code class="language-java">    // MediaType.TEXT_EVENT_STREAM_VALUE : SSE를 위한 HTTP 응답 타입 설정 이다.
    // 이걸 설정하면 프론트가 sse 스트림이라고 인식할 수 있다.
    @GetMapping(value = &quot;/subscribe&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(@RequestHeader(&quot;Authorization&quot;) String authorizationHeader){
        String token = authorizationHeader.replace(&quot;Bearer &quot;, &quot;&quot;);

        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();

        String email = claims.getSubject();
        System.out.println(email + &quot; SSE 구독 연결됨&quot;);
        return sseService.subscribe(email);
    }</code></pre>
<p><code>MediaType.TEXT_EVENT_STREAM_VALUE</code>을 지정하면 프론트가 스트림을 응답으로 인식할 수 있다.</p>
<hr>
<h3 id="🔍-연결-확인-방법">🔍 연결 확인 방법</h3>
<p>개발자 도구(F12) → Network 탭에서 연결 상태 및 메시지를 확인할 수 있다. (물론 서버 로그로도 확인 가능)</p>
<ol>
<li><p><strong>subscribe 요청 확인</strong>
<img src="https://velog.velcdn.com/images/yeon_99/post/003e618d-bf79-4514-875d-2dd1d3f80b58/image.png" alt=""></p>
</li>
<li><p><strong>Headers</strong> : 인증 헤더 포함 여부, 상태코드(200/401) 확인</p>
</li>
<li><p><strong>Response</strong> : 실시간 전달되는 메시지 스트림 확인 
<img src="https://velog.velcdn.com/images/yeon_99/post/55927f31-20ad-471e-a700-af999027e12c/image.png" alt=""></p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧠 나만의 캐릭터를 만나는 MBTI 테스트 ✨]]></title>
            <link>https://velog.io/@yeon_99/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%BA%90%EB%A6%AD%ED%84%B0%EB%A5%BC-%EB%A7%8C%EB%82%98%EB%8A%94-MBTI-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@yeon_99/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%BA%90%EB%A6%AD%ED%84%B0%EB%A5%BC-%EB%A7%8C%EB%82%98%EB%8A%94-MBTI-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 24 Jun 2025 05:56:12 GMT</pubDate>
            <description><![CDATA[<p>현실 기반 12가지 질문을 통해, 나의 성향을 유쾌하게 분석해주는 MBTI 테스트 웹앱입니다. </p>
<hr>
<h1 id="프로젝트-개요">프로젝트 개요</h1>
<blockquote>
<p>배포 링크: <a href="https://mbti-test-app-iota.vercel.app/">MBTI 프로필 테스트</a></p>
</blockquote>
<ul>
<li>⏱ 개발 기간: 2025.06.13 ~ 2025.06.14 (2일)</li>
<li>🛠 기술 스택 : Next.js, TypeScript, Tailwind CSS</li>
</ul>
<hr>
<h1 id="주요-기능">주요 기능</h1>
<h3 id="1-mbti-테스트-진행">1. MBTI 테스트 진행</h3>
<ul>
<li>총 12개의 현실 기반 질문</li>
<li>각 질문마다 2개의 선택지 제공</li>
<li>선택지마다 성향(MBTI 지표) 매핑 → 최종 유형 계산</li>
</ul>
<h3 id="2-결과-페이지">2. 결과 페이지</h3>
<ul>
<li>16가지 MBTI 유형별로 구성된 개성 있는 결과 제공</li>
<li>속마음, 좋아하는 것, 극혐 포인트 등 현실 기반 항목 포함</li>
<li>SNS 공유 가능</li>
</ul>
<h3 id="3-이미지는-gpt가-만들어줬습니다-😇">3. 이미지는… GPT가 만들어줬습니다. 😇</h3>
<p><img src="https://velog.velcdn.com/images/yeon_99/post/c1bd3c3c-719f-4b56-816f-ed3619c3c30c/image.png" alt=""></p>
<hr>
<h2 id="🖼-og-이미지도-직접-설정해봤습니다">🖼 OG 이미지도 직접 설정해봤습니다!</h2>
<p><img src="https://velog.velcdn.com/images/yeon_99/post/1f2bab3f-9f06-44e4-b7ed-c91e37c231c8/image.png" alt="">
이번 프로젝트에선 처음으로 <strong>OG Image(Open Graph Image)</strong> 설정도 적용해봤습니다.
SNS나 링크에서 <strong>썸네일 미리보기 이미지가 자동으로 뜨도록</strong> 한 건데,
Next.j의 <code>metadata</code>에 이미지랑 title 정보를 설정했습니다.</p>
<pre><code class="language-ts">export const metadata: Metadata = {
  title: &quot;MBTI 테스트&quot;,
  description: &quot;팩폭과 귀여움 사이, 당신의 성향은?&quot;,
  openGraph: {
    title: &quot;MBTI 테스트&quot;,
    description: &quot;팩폭과 귀여움 사이, 당신의 성향은?&quot;,
    images: [
      {
        url: &quot;https://mbti-test-app-iota.vercel.app/og-image.png&quot;,
        width: 1200,
        height: 630,
        alt: &quot;MBTI 썸네일&quot;,
      },
    ],
    type: &quot;website&quot;,
  },
  twitter: {
    card: &quot;summary_large_image&quot;,
  },
};</code></pre>
<ul>
<li><code>public/og-image.png</code>에 이미지 파일을 두고, 위처럼 메타 정보에 등록</li>
<li>어렵진 않았지만, 썸네일이 이런 설정으로 보인다는걸 알게 돼서 신기했음</li>
</ul>
<hr>
<h2 id="⚡️-성능-개선은-이거-하나만-">⚡️ 성능 개선은 이거 하나만 ..</h2>
<p>사실 큰 기능이 없는 단순 테스트 앱이라 성능 이슈는 없겠지 싶었는데,
Lighthouse를 돌려보니…
<img src="https://velog.velcdn.com/images/yeon_99/post/43053cd0-03be-4f18-94f4-7b3add7ebb31/image.png" alt="">
<strong>LCP가 19초</strong> 
상세 정보를 보니 <strong>텍스트에 적용된 웹폰트가 늦게 렌더링</strong>되는 게 원인이었다.</p>
<blockquote>
<p>폰트가 다운로드될 때까지 텍스트가 렌더링되지 않아서 지연 발생</p>
</blockquote>
<pre><code class="language-ts">@font-face {
    font-family: &quot;Ownglyph_Seung_Hoon-Rg&quot;;
    src: url(&quot;https://fastly.jsdelivr.net/gh/projectnoonnu/2408@1.0/Ownglyph_Seung_Hoon-Rg.woff2&quot;) format(&quot;woff2&quot;);
    font-weight: normal;
    font-style: normal;
    font-display: swap; // 이거 추가 !
}
</code></pre>
<blockquote>
<p><strong><code>font-display: swap</code>이란?</strong>
: 폰트가 로딩되기 전에 기본 시스템 폰트로 먼저 렌더링하고, 웹폰트가 도착하면 그때 바꿔치기하는 방식</p>
</blockquote>
<p>이거 하나만 추가하고 다시 측정해보니
<img src="https://velog.velcdn.com/images/yeon_99/post/bfdf9856-b85e-4171-9795-a8d24a2db6bd/image.png" alt=""></p>
<p><strong>LCP 1.2초, 성능 점수 75점 -&gt; 100점</strong>
덕분에 웹폰트가 렌더링을 막는 방식과 폰트 최적화에 대해 제대로 이해할 수 있었다.</p>
<hr>
<h2 id="😊-짧은-회고">😊 짧은 회고</h2>
<p>친구들이랑 일상에서 주고받던 대화를 그대로 질문으로 정리해서 빠르게 기획하고, 구현하고, 테스트해볼 수 있어서 재밌었다. 
Next.js로 만든 토이 프로젝트는 이번이 두 번째인데, 할수록 너무 편하고 좋다!
이번 프로젝트를 통해 App Router, Zustand, OG 이미지 설정 같은 기능도<br>직접 써보면서 훨씬 익숙해질 수 있었던 2일이었다. ☺️😊</p>
<hr>
<h3 id="🔗-github">🔗 GitHub</h3>
<p><a href="https://github.com/hyeyeon9/mbti-test-app">👉 GitHub 바로가기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Prisma + Supabase 조합]]></title>
            <link>https://velog.io/@yeon_99/Prisma-Supabase-%EC%A1%B0%ED%95%A9</link>
            <guid>https://velog.io/@yeon_99/Prisma-Supabase-%EC%A1%B0%ED%95%A9</guid>
            <pubDate>Thu, 19 Jun 2025 06:18:32 GMT</pubDate>
            <description><![CDATA[<p>사이드 프로젝트 하면서  Prisma + MySQL 조합으로 구현하다가, 배포 이슈 때문에 Supabase로 넘어와서 PostgreSQL 사용해봤다. 
지금 시작한 사이드 프로젝트에서도 이 조합으로 개발중</p>
<hr>
<h2 id="프로젝트-생성부터">프로젝트 생성부터</h2>
<p>간단히 로그인 → 프로젝트 생성 가능
(무료 버전이면 2개까지만 만들 수 있고, 3개부터는 기존 프로젝트를 삭제하거나 중지해야 함)
나는 안 쓰는 거 중지시키고 새 프로젝트 생성해서 쓰는 중 👇
<img src="https://velog.velcdn.com/images/yeon_99/post/eca68af8-f11d-4a0f-9613-270355c810c0/image.png" alt=""></p>
<h2 id="설치-및-초기-설정">설치 및 초기 설정</h2>
<pre><code class="language-bash">npm install @prisma/client
npx prisma init</code></pre>
<p>위 명령어로 Prisma를 초기화하면 <code>schema.prisma</code> 파일이 생성되고, DB 종류도 PostgreSQL로 자동 세팅된다. </p>
<p>Supabase 라이브러리도 설치해주자</p>
<pre><code class="language-bash">npm install @supabase/supabase-js</code></pre>
<hr>
<h2 id="🔌-supabase-연결">🔌 Supabase 연결</h2>
<p>Supabase 연결을 위해 <code>.env</code> 파일에 URL을 설정해야 한다.
URL은 Supabase 페이지 상단의 <strong>Connect</strong>에서 찾을 수 있다.
<img src="https://velog.velcdn.com/images/yeon_99/post/2ed5096c-e67a-4459-94ad-b21eb876a744/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yeon_99/post/71936ebb-b403-4e64-9096-75a5256de6ec/image.png" alt="">
Transaction pooler (pgbouncer) 와 Session pooler를 사용</p>
<pre><code class="language-bash">DATABASE_URL=&quot;postgresql://postgres.&lt;project-id&gt;:&lt;password&gt;@&lt;host&gt;.supabase.com:6543/postgres?pgbouncer=true&quot;
DIRECT_URL=&quot;postgresql://postgres.&lt;project-id&gt;:&lt;password&gt;@&lt;host&gt;.supabase.com:5432/postgres&quot;</code></pre>
<p>📌 <code>DATABASE_URL</code>은 트랜잭션 풀링(pgbouncer)용,
📌 <code>DIRECT_URL</code>은 직접 연결용 (migrate 등 내부 작업에 필요)</p>
<hr>
<h2 id="❓-왜-이렇게-나눠야-할까">❓ 왜 이렇게 나눠야 할까?</h2>
<p>처음엔 <code>DATABASE_URL</code>에 Direct connection만 지정했는데 오류 발생
그래서 Transaction pooler로 바꾸니까 오류는 안 나는데 아무 반응이 없음 ㅠ
여기저기 구글링하다가 이 <a href="https://www.heropy.dev/p/bCffI2">블로그</a>에서 해결방법을 찾았다!!
(역시 구글링최고)</p>
<p>GPT한테 물어보니까 요렇게 정리해줌:</p>
<blockquote>
</blockquote>
<ul>
<li>Supabase는 기본적으로 트랜잭션 풀링(<code>pgbouncer</code>)을 사용해서 확장성 있게 운영함</li>
<li>근데 Prisma는 <code>pgbouncer</code> 모드에서 DDL 작업이 안됨 </li>
<li>그래서 직접 연결용으로 <code>directUrl</code>도 같이 써야 함 </li>
</ul>
<p>그래서 <code>schema.prisma</code>도 아래와 같이 지정해야 한다 👇</p>
<pre><code class="language-ts">datasource db {
provider = &quot;postgresql&quot;
url      = env(&quot;DATABASE_URL&quot;)  // pgbouncer (운영용)
directUrl = env(&quot;DIRECT_URL&quot;)   // 직접 연결 (migrate 등 내부 사용)
}</code></pre>
<hr>
<h2 id="✅-마이그레이션--확인">✅ 마이그레이션 &amp; 확인</h2>
<p><code>npx prisma migrate dev --name init</code> 
위 명령어로 정상 연결 여부 확인 가능하고, 
<code>npx prisma studio</code>로 테이블 구조와 데이터도 바로 확인 가능하다.
<img src="https://velog.velcdn.com/images/yeon_99/post/0517f140-e414-4169-80d0-f491f0ac26e7/image.png" alt=""></p>
<p>Supabase에서도 확인 가능!
<img src="https://velog.velcdn.com/images/yeon_99/post/ee8b27a8-5f68-49e5-abcd-e56ad5521eb4/image.png" alt=""></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>여기까지 정리 완료!
Supabase에서 소셜 로그인도 지원한다고 하니까, 그 기능도 다음에 써봐야지 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 무한스크롤 성능 개선: 가상화로 렌더링 속도 2배 향상]]></title>
            <link>https://velog.io/@yeon_99/React-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EA%B0%80%EC%83%81%ED%99%94%EB%A1%9C-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%86%8D%EB%8F%84-2%EB%B0%B0-%ED%96%A5%EC%83%81</link>
            <guid>https://velog.io/@yeon_99/React-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EA%B0%80%EC%83%81%ED%99%94%EB%A1%9C-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%86%8D%EB%8F%84-2%EB%B0%B0-%ED%96%A5%EC%83%81</guid>
            <pubDate>Tue, 10 Jun 2025 14:32:39 GMT</pubDate>
            <description><![CDATA[<h2 id="1️⃣-문제-인식">1️⃣ 문제 인식</h2>
<p>1차 개발 당시, 스터디 카드 리스트를 무한 스크롤 방식으로 먼저 구현했다. 하지만 실제 배로 후, 초기 진입 속도나 리스트 로딩 속도가 느리다고 체감했다.
그래서 <code>useMemo</code>, <code>useCallback</code>을 부모 컴포넌트인 StudyList와 자식 컴포넌트인 StudyCard에 골고루 적용해봤다.</p>
<blockquote>
<p>그런데.. 메모제이션이 전혀 적용되지 않았다.</p>
</blockquote>
<p>무한스크롤이 발생하거나, 필터링 조건이 바뀌거나, 새로운 스터디가 등록될 때마다 자식 컴포넌트까지 전부 재렌더링되면서, <code>useMemo</code>로 감싼 로직(<code>today</code>, <code>모집 마감 여부</code> 등)도 매번 다시 실행되었다.</p>
<ul>
<li>카드 수가 적을 때는 눈에 띄지 않지만,</li>
<li>카드 수가 많아지면 점점 느려질 수밖에 없는 구조다.</li>
</ul>
<h2 id="2️⃣-성능-측정-무한-스크롤-기준">2️⃣ 성능 측정: 무한 스크롤 기준</h2>
<p>실제 카드 수를 500개 이상으로 늘려주고, Chrome Devtools Profiler로 렌더링 속도를 측정했다.
<img src="https://velog.velcdn.com/images/yeon_99/post/6ffe012d-5122-4354-bfed-884e8b50f7da/image.png" alt=""></p>
<ul>
<li>무한스크롤 시, 카드 하나 렌더링에 약 2.4ms ~ 4.1ms 소요됨</li>
<li>누적되는 DOM 구조로 인해, 스크롤이 길어질수록 성능 저하 </li>
</ul>
<hr>
<h2 id="3️⃣-개념-정리-가상화-vs-무한스크롤">3️⃣ 개념 정리: 가상화 vs 무한스크롤</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>무한스크롤</th>
<th>가상화</th>
</tr>
</thead>
<tbody><tr>
<td>방식</td>
<td>스크롤 시 새로운 데이터 추가 로딩</td>
<td>화면에 보이는 것만 DOM에 렌더링</td>
</tr>
<tr>
<td>DOM 수</td>
<td>계속 누적(계속 누적 (10 → 20 → … 1000)</td>
<td>항상 일정 (뷰포트 기준)</td>
</tr>
<tr>
<td>렌더링 비용</td>
<td>데이터 많을수록 ↑</td>
<td>데이터 수와 무관</td>
</tr>
</tbody></table>
<hr>
<h2 id="4️⃣-react-window-도입">4️⃣ react-window 도입</h2>
<p>가상화를 위해 <a href="https://github.com/bvaughn/react-window">react-window</a>을 사용했다.
TypeScript 사용 시 타입 설치도 필요하다:</p>
<pre><code class="language-bash">npm install react-window
npm install @types/react-window</code></pre>
<h3 id="grid-컴포넌트-적용-예시">Grid 컴포넌트 적용 예시</h3>
<pre><code class="language-ts">          &lt;Grid
            columnCount={columnCount}
            columnWidth={cardWidth + 18}
            height={700} // 뷰포트 높이(px), 조정 가능
            rowCount={Math.ceil(studies.length / columnCount)}
            rowHeight={cardHeight + 24}
            width={gridWidth}
            onItemsRendered={({ visibleRowStopIndex }) =&gt; {
              const lastVisibleIndex = (visibleRowStopIndex + 1) * columnCount;
              if (
                hasMore &amp;&amp;
                !loading &amp;&amp;
                lastVisibleIndex &gt;= studies.length - columnCount
              ) {
                fetchStudies();
              }
            }}
          &gt;
            {({ columnIndex, rowIndex, style }) =&gt; {
              const idx = rowIndex * columnCount + columnIndex;
              if (idx &gt;= studies.length) return null;
              return (
                &lt;div
                  style={{
                    ...style,
                    left: (style.left as number) + 12 / 2, // 양 옆 gap/2씩
                    top: (style.top as number) + 12 / 2,
                    width: cardWidth,
                    height: cardHeight,
                  }}
                &gt;
                  &lt;StudyCard key={studies[idx].id} study={studies[idx]} /&gt;
                &lt;/div&gt;
              );
            }}
          &lt;/Grid&gt;</code></pre>
<ul>
<li>height와 width: 뷰포트 크기</li>
<li>rowCount, columnCount: 카드 개수에 맞게 계산</li>
<li>onItemsRendered: 스크롤 끝 감지 후 fetch 호출
나는 반응형 대응을 위해 <code>window.innerWidth</code>를 기준으로 카드 가로 폭, 열 수 등을 계산해서 반영했다.</li>
</ul>
<hr>
<h2 id="5️⃣-가상화-적용-후-성능-측정">5️⃣ 가상화 적용 후 성능 측정</h2>
<p>같은 조건에서 다시 Profiler 측정하기
<img src="https://velog.velcdn.com/images/yeon_99/post/7c8859c6-8e00-4871-826f-543280d36e88/image.png" alt=""></p>
<ul>
<li>카드 하나당 1.4ms ~ 1.9ms로 렌더링 시간 감소</li>
<li>첫 렌더링 시 뷰포트에 맞춰 18개만 렌더링</li>
<li>이후 스크롤 시 6개씩 동적으로 교체</li>
<li>F12 개발자 도구로 html 확인시 18개만 계속 렌더링됨</li>
</ul>
<blockquote>
<p>렌더링 성능이 약 2배 이상 개선되었고
전체 카드 수와 무관하게 일정한 성능 유지 가능해짐</p>
</blockquote>
<hr>
<p>가상화 적용하기 쉬웠는데 렌더링 시간이 바로 감소돼서 좋았다.
실제 사용자 경험을 고려하면 성능과 UX 모두 잡을 수 있는 조합이라고 느꼈다. 👍</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[📉 LCP 2,090ms → 1,380ms 개선기: 로딩 이미지 최적화]]></title>
            <link>https://velog.io/@yeon_99/LCP-2090ms-1380ms-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EB%A1%9C%EB%94%A9-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@yeon_99/LCP-2090ms-1380ms-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EB%A1%9C%EB%94%A9-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Tue, 10 Jun 2025 12:44:05 GMT</pubDate>
            <description><![CDATA[<h1 id="🔧-lcp가-낮다-로딩-이미지가-범인">🔧 LCP가 낮다? 로딩 이미지가 범인</h1>
<p>처음 Lighthouse 기준 LCP가 2,560ms로 빨간세모 판정을 받았다.
원인을 추적해보니,<strong>로딩 페이지의 이미지가 너무 커서 주요 컨텐츠로 잡히고 있었고</strong>, 이로 인해 렌더링이 더 지연되고 있었다.</p>
<h2 id="✅-1단계--png-→-webp-변환">✅ 1단계 : PNG → WebP 변환</h2>
<p>기존 <code>Groupie.png</code>은 1.5MB로 너무 무거웠다.
webp을 사용하면 훨씬 가볍고 최적화에 유리하기에, 아래처럼 변환을 진행했다. </p>
<pre><code class="language-bash"># macOS에서 cwebp 설치
brew install webp

# PNG를 WebP로 변환 (품질 80%)
cwebp -q 80 public/Groupie.png -o public/Groupie.webp</code></pre>
<ul>
<li><strong>파일 크기:</strong> 1.5MB → 28KB</li>
<li><strong>LCP:</strong> 2,560ms → 2,090ms
→ 포맷만 바꿨을 뿐인데도 500ms 가까이 개선되었다.</li>
</ul>
<hr>
<h2 id="✅-2단계--fill-→-widthheight로-변경">✅ 2단계 : fill → width/height로 변경</h2>
<p><code>fill</code> 속성을 사용하면 브라우저가 이미지 크기를 렌더링 시점에 계산하게 되므로, LCP 지연의 원이 될 수 있다.
그래서 <code>width</code>/<code>height</code> 속성을 명시해 불필요한 계산을 줄이고자 했다.</p>
<p><img src="https://velog.velcdn.com/images/yeon_99/post/f3aae40e-f68c-43df-af33-23bb8a9549ea/image.png" alt=""></p>
<ul>
<li><p>LCP는 이미지 최적화 이후에도 다른 리팩토링 작업들 덕분에 자연스럽게 <strong>2,090ms → 1,680ms</strong>까지 개선되었고,</p>
</li>
<li><p>여기에 <code>width</code>/<code>height</code>를 적용하면서 <strong>최종적으로 1,380ms</strong>까지 줄일 수 있었다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>최적화 전</th>
<th>중간 단계</th>
<th>최종</th>
</tr>
</thead>
<tbody><tr>
<td><strong>LCP</strong></td>
<td>2,560ms 🔺</td>
<td>1,680ms ⚠️</td>
<td><strong>1,380ms ✅</strong></td>
</tr>
<tr>
<td><strong>Load Delay</strong></td>
<td>1,610ms (63%)</td>
<td>860ms (51%)</td>
<td><strong>690ms (50%)</strong></td>
</tr>
<tr>
<td><strong>TTFB</strong></td>
<td>810ms (31%)</td>
<td>630ms (37%)</td>
<td><strong>630ms (46%)</strong></td>
</tr>
<tr>
<td><img src="https://velog.velcdn.com/images/yeon_99/post/270f780c-166a-4b94-8d27-1a27a0ebba4f/image.svg" alt=""></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
</li>
</ul>
<hr>
<h2 id="📌-정리">📌 정리</h2>
<ul>
<li>✅ WebP로 포맷 최적화</li>
<li>✅ Image 크기 명시</li>
<li>✅ LCP 기준 &#39;나쁨&#39; → &#39;양호&#39; 개선 성공</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Zustand 도입기 – 언제 상태관리 도구를 써야 할까?]]></title>
            <link>https://velog.io/@yeon_99/Zustand-%EC%96%B8%EC%A0%9C-%EC%93%B0%EB%8A%94%EA%B2%A8</link>
            <guid>https://velog.io/@yeon_99/Zustand-%EC%96%B8%EC%A0%9C-%EC%93%B0%EB%8A%94%EA%B2%A8</guid>
            <pubDate>Fri, 06 Jun 2025 05:50:28 GMT</pubDate>
            <description><![CDATA[<h2 id="나는-언제-zustand를-쓰는가">나는 언제 Zustand를 쓰는가?</h2>
<p>프론트 상태관리 도구라고 하면 Context API, Redux, Zustand 등 여러 가지가 있다.
사실 이번 Groupie 사이드 프로젝트 전까지만 해도 난 상태관리 도구 없이 <code>useState</code>만 쓰던 초짜였다.</p>
<p>특강에서 면접 때 상태관리 도구 왜 썼는지 물어보니까 공부해두라는 얘기를 들었다.
찾아보니 최근앤 <strong>Zustand가 가볍고 쓰기도 편해서</strong> 자주 쓰이는 것 같았다. (내피셜) </p>
<p>프로젝트를 진행하면서 처음에느 <strong>로그인/회원가입 모달 상태</strong>에 zustand를 도입했고,
최근에는 <strong>스터디 필터링 가능에 zustand를 적용</strong>했다.
적용하면서 &quot;아 이때 전역 상태관리를 써야 하구나&quot;하고 감이 잡혔다.</p>
<hr>
<h2 id="언제-zustand를-쓰는-게-맞을까">언제 Zustand를 쓰는 게 맞을까?</h2>
<h3 id="1-props-drilling이-심해질-때">1. props drilling이 심해질 때</h3>
<ul>
<li>부모 → 자식 → 자식의 자식까지 계속 props를 넘겨야 하는 경우</li>
<li>그냥 전역으로 관리하자! (그럼 부모든, 자식이든 쉽게 사용 가능하니까)</li>
</ul>
<h3 id="2-여러-컴포넌트가-같은-상태를-공유해야-할-때">2. 여러 컴포넌트가 같은 상태를 공유해야 할 때</h3>
<ul>
<li>로그인 모달 : 헤더, 페이지 등 여러 곳에서 모달을 열고 닫기</li>
<li>필터 버튼 : StudyFilterBar, StudyList 등에서 같은 필터 상태 사용</li>
</ul>
<h3 id="3-비동기복잡한-상태-캐싱-optimisic-ui-토스트">3. 비동기/복잡한 상태 (캐싱, optimisic UI, 토스트)</h3>
<ul>
<li>상태가 복잡하거나 서버와 연동되는 로직이 많을 때</li>
<li>토스트 알림, optimistic UI, 캐싱, 뷰 카운트 등</li>
</ul>
<hr>
<h2 id="❌-굳이-안-써도-되는-경우">❌ 굳이 안 써도 되는 경우</h2>
<ul>
<li>한 컴포넌트 또는 한 계층에서만 쓸 때
→ <code>useState</code>나 <code>useReducer</code>면 충분</li>
<li>괜히 다 전역으로 올리면 코드가 복잡해짐</li>
</ul>
<h2 id="context-api는-왜-안-썼을까">Context API는 왜 안 썼을까?</h2>
<p>처음에는 Context API를 쓸까 했지만,
page.tsx에 필터 버튼만 있는 게 아니라 인기글 컴포넌트, 스터디 리스트도 함께 있어서 <code>&lt;Context.Provider&gt;</code>로 감싸는 구조가 애매했다.</p>
<p>결국 단순 상태 공유와 빠른 반응성을 위해 zustand를 선택했다.</p>
<hr>
<h2 id="사용-방법-기본">사용 방법 (기본)</h2>
<p>📌 <a href="https://zustand-demo.pmnd.rs/">공식 데모 사이트 바로가기 →</a></p>
<hr>
<ol>
<li>설치<pre><code class="language-bash">npm install zustand</code></pre>
</li>
</ol>
<hr>
<ol start="2">
<li>스토어 생성 (예: modalStore.ts)
보통 <code>store</code>폴더를 만들어 그 안에 스토어 파일을 관리한다. 파일명은 <code>xxxStore.ts</code>처럼 작성하는 경우가 많다. <pre><code class="language-ts">// store/modalStore.ts
import { create } from &quot;zustand&quot;;
</code></pre>
</li>
</ol>
<p>type ModalType = &quot;login&quot; | &quot;signup&quot; | null;</p>
<p>// zustand로 전역 모달 스토어 만들기
interface ModalStore {
  openModal: ModalType;
  open: (type: ModalType) =&gt; void;
  close: () =&gt; void;
}</p>
<p>export const useModalStore = create<ModalStore>((set) =&gt; ({
  openModal: null,
  open: (type) =&gt; set({ openModal: type }), 
  close: () =&gt; set({ openModal: null }),
}));</p>
<pre><code>&gt; 여기서 `interface`는 스토어의 상태와 메서드 구조를 정의하는 역할
`create()`함수는 zustand의 핵심 함수로, 스토어를 실제로 생성한다.

---
3. 스토어 사용하기 (컴포넌트에서 호출)
다른 컴포넌트에서 사용할 때는 `useModalStore()`훅을 호출하면 된다.
```ts
// 예: 로그인 폼에서 모달 닫기
import { useModalStore } from &quot;@/store/modalStore&quot;;

export default function LoginForm() {
  const { close } = useModalStore();

  return (
    &lt;button onClick={close}&gt;
      닫기
    &lt;/button&gt;
  );
}</code></pre><p>이렇게 하면 모달을 여는 컴포넌트, 닫는 컴포넌트가 다른 위치에 있어도 상태를 공유할 수 있다!</p>
<hr>
<h2 id="❓-궁금했던-점">❓ 궁금했던 점</h2>
<blockquote>
<p>기술 스택에 zustand가 있다면 모든 상태를 zustand로 써야 하나?</p>
</blockquote>
<p>굳이?</p>
<ul>
<li>전역 상태가 필요한 경우 zustand 쓰면 된다.</li>
<li>단일 컴포넌트에서만 쓰는 상태는 <code>useState</code>가 더 직관적이고 성능도 좋음.</li>
</ul>
<p>예전 발주 페이지에서도 zustand로 전체 상태를 묶었다가,
셀렉트 버튼 상태는 <code>useState</code>로 되돌린 적이 있다.
지금 생각하면, 거기서는 zustand를 쓰지 않아도 됐던 것 같다.
<strong>상태 공유가 진짜 필요한가?</strong> 를 먼저 고민하는 게 중요하다는 걸 이번에 체감할 수 있었다. </p>
<hr>
<h3 id="한-줄-요약">한 줄 요약</h3>
<blockquote>
<p>여러 컴포넌트가 공유해야 하거나, 비동기/복잡한 상태일 때 zustand!
그 외엔 useState </p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Cannot find namespace 'JSX'.]]></title>
            <link>https://velog.io/@yeon_99/Next.js-JSX-%EB%84%A4%EC%9E%84%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%97%90%EB%9F%AC-Type-error-Cannot-find-namespace-JSX</link>
            <guid>https://velog.io/@yeon_99/Next.js-JSX-%EB%84%A4%EC%9E%84%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EC%97%90%EB%9F%AC-Type-error-Cannot-find-namespace-JSX</guid>
            <pubDate>Thu, 05 Jun 2025 12:28:22 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>Vercel 배포중 아래와 같은 에러를 만났다. 로그를 보니 <code>react-markdown</code>에서 문제가 발생한 건 알 수 있었다. </p>
<pre><code>./node_modules/@uiw/react-markdown-preview/node_modules/react-markdown/lib/complex-types.ts:25:21
Type error: Cannot find namespace &#39;JSX&#39;a</code></pre><p>구글링을 통해 관련된 <a href="https://github.com/styled-components/styled-components/issues/4359">GitHub 이슈</a>를 찾아볼 수 있었다.
답변들을 보니 <code>JSX</code>를 <code>React.JSX</code>로 바꿔야 한다는 내용이 많았다❗️</p>
<h2 id="원인-분석">원인 분석</h2>
<p><strong>React 19와 최신 TypeScript에서는</strong> <code>JSX</code> 네임스페이스를 더이상 전역으로 제공해 주지 않고 대신 <strong><code>React.JSX</code>를 사용해야 한다.</strong>
그러나 일부 라이브러리에서는 <strong>여전히 전역 <code>JSX</code>를 참조하고 있어서</strong> 문제가 발생한 것이다.</p>
<h2 id="해결-방법">해결 방법</h2>
<p><strong>1. 라이브러리 업데이트</strong>
관련 라이브러리들을 최신 버전으로 업데이트 하자.</p>
<pre><code class="language-bash">npm install react-markdown@latest @uiw/react-markdown-preview@latest</code></pre>
<ul>
<li><code>react-markdown-preview</code> 와 그 내부 <code>react-markdown</code> 을 React 19 호환 버전(전역 JSX 참조를 없앤 버전)으로 업데이트</li>
</ul>
<p><strong>2. 글로벌 타입 선언 추가</strong>
계속해서 에러가 발생한다면,프로젝트 루트에 <code>global.d.ts</code> 파일을 생성하고 아래 내용을 추가하기</p>
<pre><code class="language-ts">// ./global.d.ts
import React from &quot;react&quot;;

declare global {
  namespace JSX {
    // React 18+의 JSX.Element
    type Element = React.ReactElement;

    // 모든 태그 이름을 any로 허용
    interface IntrinsicElements {
      [elemName: string]: any;
    }
  }
}</code></pre>
<p>이렇게 하면, 전역 <code>JSX</code> 네임스페이스를 임시로 복원할 수 있다.</p>
<hr>
<h3 id="참고자료">참고자료</h3>
<ul>
<li><a href="https://github.com/styled-components/styled-components/issues/4359">styled-components 이슈 #4359</a></li>
<li><a href="https://github.com/remarkjs/react-markdown/issues/877">react-markdown 이슈 #877
</a></li>
</ul>
<hr>
<h3 id="마무리">마무리</h3>
<p>나는 라이브러리 업데이트와 글로벌 타입 선언을 모두 적용했다. 그 결과 에러가 사라졌지만, 아마 라이브러리 업데이트가 근본적인 해결책인 거 같다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js 15] 타입 에러 해결하기: Type error: Type '{ params: { id: string; }; }' does not satisfy the constraint 'PageProps'.]]></title>
            <link>https://velog.io/@yeon_99/Next.js-15-%ED%83%80%EC%9E%85-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-Type-error-Type-params-id-string-does-not-satisfy-the-constraint-PageProps</link>
            <guid>https://velog.io/@yeon_99/Next.js-15-%ED%83%80%EC%9E%85-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-Type-error-Type-params-id-string-does-not-satisfy-the-constraint-PageProps</guid>
            <pubDate>Thu, 05 Jun 2025 11:36:05 GMT</pubDate>
            <description><![CDATA[<p>Vecel에 배포하는 과정에서 </p>
<pre><code class="language-pgsql">Type error: Type &#39;{ params: { id: string; }; }&#39; does not satisfy the constraint &#39;PageProps&#39;.</code></pre>
<p>위와 같은 타입 에러를 만났다.</p>
<h2 id="문제-상황">문제 상황</h2>
<p>문제가 난 코드는 아래와 같다. 코드상 오류는 없었는데 배포하니 타입에러가 났다.</p>
<pre><code class="language-ts">export default async function StudyDetailPage({
  params,
}: {
  params: { id: string };
}) {
  const { id } = params;
  // ...
}
</code></pre>
<p>구글링으로 답변을 찾아보니 Next.js 15부터는 <code>params</code>와 <code>searchParams</code>가 <strong>비동기(Promise)</strong> 로 변경되었기 때문에 위 코드는 타입 에러를 발생시킨다. </p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p><code>params</code>를 <code>Promise</code>로 타입 지정하고 <code>await</code>를 통해 값을 받아야 한다.</p>
<pre><code class="language-ts">type StudyDetailPageProps = {
  params: Promise&lt;{ id: string }&gt;;
};

export default async function StudyDetailPage({
  params,
}: StudyDetailPageProps) {
  const { id } = await params;
  // ...
}</code></pre>
<hr>
<h2 id="왜-바뀌었을까-nextjs-15-기준">왜 바뀌었을까? (Next.js 15 기준)</h2>
<ol>
<li>성능 최적화
 → 라우트 파라미터를 <strong>필요할 때만</strong> 불러오도록 지연 로딩 가능</li>
<li>스트리밍 지원
 → Partial Prerendering(PPR)이나 스트리밍 렌더링과 더 잘 통합됨</li>
<li>서버 컴포넌트와의 일관성
 → 서버 컴포넌트가 기본적으로 async 함수이므로, <code>params</code>도 일관되게 비동기로 일치</li>
</ol>
<hr>
<h2 id="참고한-답변">참고한 답변</h2>
<p>같은 에러를 겪는 다른 사람의 질문이 스택오버플로우에 있었다!
▶ <a href="https://stackoverflow.com/questions/79124951/type-error-in-next-js-route-type-params-id-string-does-not-satis">Stackoverflow 링크</a></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이렇게 수정하니 해당 에러는 해결됐고... 물론 다른 에러가 나긴 했지만 ㅜ
어쨌든 중요한 건!
👉 Next.js 15에서는 params도 Promise로 받자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 성능 최적화기 ③ - DB 쿼리 튜닝으로 응답 속도 줄이기]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-DB-%EC%BF%BC%EB%A6%AC-%ED%8A%9C%EB%8B%9D%EC%9C%BC%EB%A1%9C-%EC%9D%91%EB%8B%B5-%EC%86%8D%EB%8F%84-%EC%A4%84%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@yeon_99/Next.js-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-DB-%EC%BF%BC%EB%A6%AC-%ED%8A%9C%EB%8B%9D%EC%9C%BC%EB%A1%9C-%EC%9D%91%EB%8B%B5-%EC%86%8D%EB%8F%84-%EC%A4%84%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Wed, 04 Jun 2025 01:46:23 GMT</pubDate>
            <description><![CDATA[<h2 id="🐢-api-응답시간">🐢 API 응답시간</h2>
<p><code>npm run dev</code>로 서버를 띄운 후, 첫 페이지 진입 시 찍히는 로그창을 보니 </p>
<ul>
<li><strong>GET /study</strong>: 2436ms</li>
<li><strong>GET /api/studies</strong>: 1366ms 
응답 속도가 꽤 느렸다.
물론 실행할 때마다 수치는 조금씩 달라지긴 하지만, 느리다는 건 변한없었다.</li>
</ul>
<p>특히 쿼리문을 보니 불필요한 데이터까지 모두 select해서 들고오고 있었다.
<code>/study</code>에서는 스터디 리스트를, <code>/api/studies</code>에서는 인기글 리스트를 select하고 있는 상황에서 두 쿼리문을 개선해 보았다.</p>
<hr>
<h2 id="✂️-첫-번째-개선-필요한-필드만-select">✂️ 첫 번째 개선: 필요한 필드만 <code>select</code></h2>
<p>Prisma의 <code>select</code>옵션으로 필요한 필드만 지정해서 가져오도록 수정했다. </p>
<pre><code class="language-ts"> const studies = await prisma.study.findMany({
      where,
      select: {
        id: true,
        title: true,
        category: true,
        startDate: true,
        createdAt: true,
        scrap: true,
        content: true,
        author: {
          select: {
            id: true,
            nickname: true,
            profileImage: true,
          },
        },
        _count: {
          select: {
            comments: true, // 댓글 개수만 필요하면 이렇게
          },
        },
      },
      orderBy: { createdAt: &quot;desc&quot; },
      ...(cursor &amp;&amp; {
        cursor: { id: cursor },
        skip: 1, // cursor 제외
      }),
      take,
    });</code></pre>
<h3 id="결과">결과</h3>
<ul>
<li><code>/study</code>: 2436ms → 2105ms (약 331ms 감소, 14% 개선)</li>
<li><code>/api/studies</code>: 1366ms → 1115ms (약 251ms 감소, 18% 개선)</li>
</ul>
<hr>
<h2 id="🔍-두-번째-개선-인덱스-추가">🔍 두 번째 개선: 인덱스 추가</h2>
<p>추가적으로 스크랩과 조회수 기준으로 인기글을 정렬할 때,
게시글별로 댓글을 가져올때, 더 빠르게 쿼리를 처리하기 위해 자주 조회되는 필드에 인덱스를 추가했다. </p>
<blockquote>
<p>*<em>인덱스는 DB에서의 책 목차 같은 역할이다. *</em>
검색하거나 정렬할 필드에 인덱스를 걸어두면, 전체를 다 뒤짖 않고 필요한 데이터만 바로 찾아갈 수 있다. </p>
</blockquote>
<pre><code class="language-prisma">// Study 테이블
 @@index([scrap(sort: Desc), views(sort: Desc), createdAt(sort: Desc)], name: &quot;idx_study_performance&quot;)</code></pre>
<p>이건 아래와 같은 순서로 정렬할떄 최적화된다.</p>
<ul>
<li>&quot;scrap 내림차순 → 그 안에서 views 내림차순 → 그 안에서 createdAt 내림차순&quot;
쿼리에서도 <code>orderBy</code>에 이 세 필드를 같은 순서로 명시해줘야 인덱스가 적용된다.<pre><code>orderBy: [
{ scrap: &quot;desc&quot; },
{ views: &quot;desc&quot; },
{ createdAt: &quot;desc&quot; },
]
</code></pre></li>
</ul>
<pre><code></code></pre><p>// Comment 테이블
 @@index([studyId], name: &quot;idx_comment_study_id&quot;)</p>
<pre><code>이건 댓글 테이블에서 특정 스터디에 달린 댓글 목록을 빠르게 조회할 때 사용된다. (WHERE studyId = &#39;xxx&#39;)


### 결과
- `/study`: 2105ms → 2198ms (정렬 기준 적용 이후에도 속도 유지됨)
- `/api/studies`: 1115ms → 1040ms (댓글 많은 글도 속도 차이 거의 없음)


---
## 전체 성능 변화 요약
| API            | 변경 전   | 변경 후   | 감소량    | 개선율   |
| -------------- | ------ | ------ | ------ | ----- |
| `/study`       | 2436ms | 2198ms | -238ms | 약 10% |
| `/api/studies` | 1366ms | 1040ms | -326ms | 약 24% |

실행할때마다 수치가 다르긴 하지만, DB쿼리 개선후에 속도가 빨라진 건 느껴졌다.

---
## 😺 배운점
API 속도가 느리면 일단 **백엔드 쿼리를 먼저 확인해보는 게 기본**이라는 것을 느꼈다. 특히 Supabase 덕분에 터미널에 쿼리문이 잘 찍혀서 디버깅하기 훨씬 좋았던 것 같다.
- 필요한 데이터만 `select` 해오기
- 자주 조회되는 조건은 `index` 걸기
- join이나 count연산 주의하기
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[🔧 Next.js 성능 최적화기 ② - 지연 로딩으로 메인스레드 다이어트 하기]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-%EC%A7%80%EC%97%B0-%EB%A1%9C%EB%94%A9%EC%9C%BC%EB%A1%9C-%EB%A9%94%EC%9D%B8%EC%8A%A4%EB%A0%88%EB%93%9C-%EB%8B%A4%EC%9D%B4%EC%96%B4%ED%8A%B8-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yeon_99/Next.js-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-%EC%A7%80%EC%97%B0-%EB%A1%9C%EB%94%A9%EC%9C%BC%EB%A1%9C-%EB%A9%94%EC%9D%B8%EC%8A%A4%EB%A0%88%EB%93%9C-%EB%8B%A4%EC%9D%B4%EC%96%B4%ED%8A%B8-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 03 Jun 2025 10:59:22 GMT</pubDate>
            <description><![CDATA[<h2 id="minimize-main-thread-work-67초-😓">Minimize main-thread work, 6.7초 😓</h2>
<p>Minimize main-thread work가 6.7초로, 첫 페이지 열자마자 <strong>메인스레드가 할 일이 너무 많이 상태</strong>였다.
JS 파일이 많고, 데이터도 엄청 불러오고 렌더링도 많아지니까 그런거 같았다. 
근데 나는 컴포넌트도 잘 쪼개고, <code>use client</code>도 최대한 줄이면서 서버 컴포넌트로 잘 써놨어서 더이상 구조적으로 쪼갤 건 없었다.
그래서 결국 남은 문제는:</p>
<blockquote>
<p>첫 페이지에서 너무 많은 걸 보여주고 있었던 게 문제였다. </p>
</blockquote>
<hr>
<h2 id="그래서-나는-지연-로딩을-적용했다">그래서 나는 &quot;지연 로딩&quot;을 적용했다.</h2>
<p>지연 로딩은 말 그대로 지연해서 로딩하는 것이다.
<strong>필요한 시점이 될 때 그때 가서 객체(컴포넌트)를 가져오는 방식</strong>이라고 이해하면 된다. </p>
<blockquote>
<p>첫 페이지에서 바로 보여주지 않아도 되는 것은 지연로딩으로 메인스레드의 부담을 줄이는 방법</p>
</blockquote>
<p>예를 들어 내 사이트에서는 </p>
<ul>
<li><strong>로그인 / 마이페이지 버튼</strong>
헤더에 바로 보여야 하니까 지연 로딩이 불가능함</li>
<li><strong>로그인 / 회원가입 모달</strong>
버튼 누르기 전까진 안 보여도 되니까 지연 로딩하기 딱 좋음</li>
<li><strong>인기 스터디 슬라이더</strong>
스켈레톤 UI가 있어서 처음부터 꼭 보여줄 필요는 없음 → 지연 로딩 적합</li>
</ul>
<hr>
<h3 id="로그인-모달-지연-로딩">로그인 모달 지연 로딩</h3>
<p><code>Next.js</code>에서 동적 <code>import</code> 할 땐 <code>dynamic()</code>을 쓰는데, 이건 <strong>클라이언트 컴포넌트에서만 가능</strong>하다. 
그래서 나는 <code>DynamicmodalWrapper.tsx</code>라는 클라이언트 컴포넌트를 하나 생성했다. </p>
<pre><code class="language-ts">&quot;use client&quot;;
import dynamic from &quot;next/dynamic&quot;;

// 모달 컴포넌트 동적 import, SSR 비활성화
const LoginSignupModal = dynamic(
  () =&gt; import(&quot;@/components/auth/LoginSignupModal&quot;),
  { ssr: false }
);

export default function DynamicModalWrapper() {
  return &lt;LoginSignupModal /&gt;;
}</code></pre>
<p>여기서 <code>ssr: false</code>는 서버에 렌더링하지 않고 브라우저에만 렌더링하겠다는 뜻이다. 이 모달 컴포넌트는 유저가 버튼을 눌러서 필요한 순간에만 불러오게 되는 것!</p>
<hr>
<h3 id="✅-인기-스터디-슬라이더도-지연-로딩">✅ 인기 스터디 슬라이더도 지연 로딩</h3>
<p>로그인 모달처럼 인기글 슬라이더도 dynamic으로 감쌌다.</p>
<pre><code class="language-ts">const PopularStudySlider = dynamic(() =&gt; import(&quot;./PopularStudySlider&quot;), {
  ssr: false,
  loading: () =&gt; (
    &lt;div className=&quot;grid grid-cols-1 md:grid-cols-3  &quot;&gt;
      {Array.from({ length: 1 }).map((_, i) =&gt; (
        &lt;PopularCardSkeleton key={i} /&gt;
      ))}
    &lt;/div&gt;
  ),
});</code></pre>
<p>처음에는 인기글도 지연로딩을 하는게 맞는지 고민했다. 인기글은 빠르게 보여주는게 좋을거 같아서
근데 오히려 지연로딩을 적용하니까 페이지가 더 빨라져서 인기글이 더 빨리 보이는 느낌이 들었다.
아마 전체 페이지가 무거워서 느려지던게 지연로딩으로 조금 분산되니까 나머지가 더 빨리 로딩되는게 아닐까?</p>
<p>추가적으로 <code>loading</code> 옵션을 사용해서 스켈레톤 UI을 보여주면서 사용자 경험을 향상시켰다. </p>
<hr>
<h2 id="최종결과">최종결과</h2>
<p>이렇게 인기글 슬라이더 + 로그인 모달을 지연 로딩으로 분리하고 나서 다시 성능 측정해봤더니…</p>
<blockquote>
<p>Minimize main-thread work<br>6.7초 → 5.6초로 줄어듦!! 🎉</p>
</blockquote>
<p>간단한 지연 로딩으로 1.1초나 줄어들어서 만족스러웠다. </p>
<hr>
<p>다음편에서는 DB 최적화와 인덱스 추가 등으로 백엔드 응답 속도를 줄인 과정을 정리할 예정임니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🔧 Next.js 페이지 성능 최적화기 ① - 느려터진 페이지, 내가 고친다]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-%EB%8A%90%EB%A0%A4%ED%84%B0%EC%A7%84-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%82%B4%EA%B0%80-%EA%B3%A0%EC%B9%9C%EB%8B%A4</link>
            <guid>https://velog.io/@yeon_99/Next.js-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%EA%B8%B0-%EB%8A%90%EB%A0%A4%ED%84%B0%EC%A7%84-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%82%B4%EA%B0%80-%EA%B3%A0%EC%B9%9C%EB%8B%A4</guid>
            <pubDate>Mon, 02 Jun 2025 14:29:52 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는  Next.js 기반 프로젝트의 페이지를 성능 최적화하는 과정을 기록할 것이다.
나는 Google의 <a href="https://developer.chrome.com/docs/lighthouse/overview?hl=ko">Lighthouse</a> 도구를 사용해 웹 성능을 측정하고 개선해보았다.</p>
<hr>
<h1 id="웹-성능-최적화가-중요한-이유">웹 성능 최적화가 중요한 이유</h1>
<p>사용자는 사이트가 느린걸 싫어한다. 로딩이 좀만 길어져도 응 안써. 하고 나감
성능이 낮으면 사용자 이탈률이 높아지고, 검색엔진 최적화(SEO)에도 불리하다. 
특히 Next.js는 SSR을 지원하지만, 잘못쓰면 오히려 느려질 수 있다.
그래서 나는 Lighthouse를 통해 성능 병목을 확인하고 직접 개선해보기로 했다.</p>
<h1 id="lighthouse란">Lighthouse란?</h1>
<p>Lighthouse는 Chrome 브라우저에서 제공하는 웹사이트 품질 분석 도구이다. 
성능 점수부터 접근성, SEO, Best Practice까지 알아서 분석해준다.</p>
<ul>
<li><strong>Performance</strong> : 페이지 로딩 속도, 스크립트 실행 시간 등</li>
<li><strong>Accessibility</strong> : 접근성 문제</li>
<li><strong>Best Practices</strong> : 보안/코딩 관례 준수 여부</li>
<li><strong>SEO</strong> : 검색 최적화 관련 지표</li>
</ul>
<blockquote>
<p>f12 → Lighthouse 탭 → &quot;페이지 로드 분석&quot; 클릭하면 측정 끝!</p>
</blockquote>
<h3 id="주요-성능-지표">주요 성능 지표</h3>
<table>
<thead>
<tr>
<th>지표</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>LCP (Largest Contentful Paint)</strong></td>
<td>가장 큰 콘텐츠가 화면에 렌더링될 때까지 걸린 시간</td>
</tr>
<tr>
<td><strong>CLS (Cumulative Layout Shift)</strong></td>
<td>화면 요소가 갑자기 움직이는 정도</td>
</tr>
<tr>
<td><strong>TBT (Total Blocking Time)</strong></td>
<td>메인 스레드가 막혀 사용자가 아무것도 할 수 없는 시간</td>
</tr>
<tr>
<td><strong>TTFB (Time To First Byte)</strong></td>
<td>첫 바이트가 브라우저에 도달하는 시간 (서버 응답 속도)</td>
</tr>
</tbody></table>
<hr>
<h1 id="초기-측정-결과">초기 측정 결과</h1>
<p><img src="https://velog.velcdn.com/images/yeon_99/post/66b395dd-0fce-40f2-8037-56deecda843b/image.png" alt=""></p>
<ul>
<li>Performance: 62점  </li>
<li>Main-thread work: 6.7초  </li>
<li>Script Evaluation: 3,715ms  </li>
<li>Script Parsing &amp; Compilation: 975ms  </li>
<li>Garbage Collection: 467ms  </li>
</ul>
<p>👉 <code>/study</code>(첫 페이지) 진입할 때 너무 느렸음. 인기글 + 전체 게시글 한꺼번에 불러오면서, 중요한 콘텐츠가 늦게 뜨는 문제가 있었음</p>
<hr>
<p>① Minimize main-thread work : <code>use client</code> 다이어트
<code>use client</code>가 붙은 컴포넌트 중에서 정적 UI만 처리하는 컴포넌트는 서버 컴포넌트로 전환해 메인 스레드의 부담을 줄여보자. </p>
<h3 id="예시-studylist-→-studycard-분리">예시: StudyList → StudyCard 분리</h3>
<ul>
<li>StudyCard<pre><code class="language-ts">import React from &quot;react&quot;;
import { formatRelativeTime } from &quot;@/lib/date&quot;;
import {  Study, User } from &quot;@prisma/client&quot;;
</code></pre>
</li>
</ul>
<p>import Image from &quot;next/image&quot;;
import { FaBookmark } from &quot;react-icons/fa&quot;;
import Link from &quot;next/link&quot;;</p>
<p>type StudyType = Study &amp; {
  author: User;
  comments: Comment[];
};</p>
<p>interface Props {
  study: StudyType;
  isLast: boolean;
  observerRef: React.RefObject&lt;HTMLDivElement | null&gt;;
}</p>
<p>const StudyCard = ({ study, isLast, observerRef }: Props) =&gt; {
  const today = new Date();
  today.setHours(0, 0, 0, 0); // 오늘 00:00:00</p>
<p>  return (
    &lt;Link href={<code>/study/${study.id}</code>} key={study.id}&gt;
      &lt;div
        ref={isLast ? observerRef : null}
        className=&quot; min-h-[200px] group bg-white rounded shadow-sm border border-gray-200 p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1&quot;
      &gt;
        <div className="space-y-4">
          <div className="flex items-start gap-5">
            <span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200">
              {study.category}
            </span>
            &lt;span
              className={<code>inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
                study.startDate &amp;&amp; new Date(study.startDate) &lt; today
                  ? &quot;bg-gray-300 text-gray-500&quot;
                  : &quot;bg-green-200 text-green-700&quot;
              }</code>}
            &gt;
              {study.startDate &amp;&amp; new Date(study.startDate) &lt; today
                ? &quot;모집 마감&quot;
                : &quot;모집중&quot;}
            </span>
          </div></p>
<pre><code>      &lt;div className=&quot;space-y-2&quot;&gt;
        &lt;h3 className=&quot;text-lg font-semibold text-gray-900  transition-colors line-clamp-1&quot;&gt;
          {study.title}
        &lt;/h3&gt;
        &lt;p
          className=&quot;text-sm text-gray-600 line-clamp-2 leading-relaxed
              min-h-[48px]&quot;
        &gt;
          {study.content.replace(/[#_*~`&gt;[\]()\-!\n]/g, &quot;&quot;).slice(0, 100)}
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div className=&quot;flex items-center gap-3 text-xs text-gray-500&quot;&gt;
        &lt;span&gt;{formatRelativeTime(new Date(study.createdAt))}&lt;/span&gt;
        &lt;span&gt;•&lt;/span&gt;
        &lt;span&gt;{study._count.comments}개의 댓글&lt;/span&gt;
      &lt;/div&gt;

      &lt;div className=&quot;flex items-center justify-between pt-3 border-t border-gray-100&quot;&gt;
        &lt;div className=&quot;flex items-center gap-3&quot;&gt;
          &lt;div&gt;
            &lt;div className=&quot;w-8 h-8 rounded-full overflow-hidden relative&quot;&gt;
              &lt;Image
                src={study.author.profileImage ?? &quot;/default-avatar.png&quot;}
                alt=&quot;프로필&quot;
                fill
                className=&quot;object-cover&quot;
              /&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;span className=&quot;text-sm text-gray-700 font-medium &quot;&gt;
            {study.author.nickname}
          &lt;/span&gt;
        &lt;/div&gt;
        &lt;div className=&quot;flex items-center gap-1 text-red-500&quot;&gt;
          &lt;FaBookmark className=&quot;text-sm&quot; /&gt;

          &lt;span className=&quot;text-sm font-medium&quot;&gt;{study.scrap}&lt;/span&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/Link&gt;</code></pre><p>  );
};</p>
<p>export default StudyCard;</p>
<pre><code>그리고 `StudyList.tsx`에서는 이렇게 사용

```ts
import StudyCard from &quot;./StudyCard&quot;;

// ...

{studies.map((study, index) =&gt; (
  &lt;StudyCard
    key={study.id}
    study={study}
    isLast={index === studies.length - 1}
    observerRef={observerRef}
  /&gt;
))}
</code></pre><p>성능 점수는 그대로였지만, 길었던 코드를 분리해서 가독성이 좋아졌고, 유지보수성 향상</p>
<hr>
<p>지연 로딩, DB 쿼리 최적화, dynamic import 써서
점수가 진짜로 오르기 시작한 이야기는 다음 편에서</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + Prisma 시작기: DB 연결부터 마이그레이션까지]]></title>
            <link>https://velog.io/@yeon_99/Next.js-Prisma-%EC%8B%9C%EC%9E%91%EA%B8%B0-DB-%EC%97%B0%EA%B2%B0%EB%B6%80%ED%84%B0-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@yeon_99/Next.js-Prisma-%EC%8B%9C%EC%9E%91%EA%B8%B0-DB-%EC%97%B0%EA%B2%B0%EB%B6%80%ED%84%B0-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 30 May 2025 14:18:44 GMT</pubDate>
            <description><![CDATA[<h1 id="🛠️-프로젝트-생성부터-prisma-설정까지">🛠️ 프로젝트 생성부터 Prisma 설정까지</h1>
<p>Next.js와 TypeScript를 익히려고 간단한 사이드 프로젝트를 시작했고, 일주일이 지난 오늘 1차 배포까지 완료했다. 아직 조금 느리지만, 그동안 배운 내용을 하나씩 정리해보려고 한다. 오늘은 프로젝트 생성부터 Prisma를 활용한 DB 설정 과정을 기록한다.</p>
<hr>
<h2 id="프로젝트-생성">프로젝트 생성</h2>
<p>나는 Next.js + TypeScript + Tailwind Css 조합을 기본 세팅으로 시작했다.
<code>npx create-next-app@latest</code>
위 명령어로 기본 폴더 구조가 생성되고, 타입스크립트 설정도 자동 적용된다.</p>
<hr>
<h2 id="🤔-prisma란">🤔 Prisma란?</h2>
<p>원래 나는 Spring Boot로 백엔드 연결을 배웠어서, JPA가 익숙했는데, Next.js에서도 ORM이 있는지 궁금했다. 
그래서 구글링을 해보니 Prisma라는 ORM이 많이 사용되는거 같았다.
Next.js 공식문서에서도 Prisma 연결 코드가 있어서 선택해서 사용했다. </p>
<h3 id="prisma-초기화">prisma 초기화</h3>
<p><code>npx prisma init</code>
초기화하면 루트 디렉토리에 아래 파일들이 생긴다. </p>
<ul>
<li><code>/prisma/schema.prisma</code> : 테이블(모델)정의</li>
<li><code>/.env</code> : DB 접속 정보를 담는 환경변수 파일</li>
</ul>
<p><code>.env</code> 예시:</p>
<pre><code class="language-ini">DATABASE_URL=&quot;mysql://root:@localhost:3306/study_cafe&quot;</code></pre>
<p><code>schema.prisma</code> 예시:</p>
<pre><code class="language-prisma">generator client {
provider = &quot;prisma-client-js&quot;
}

datasource db {
provider = &quot;mysql&quot;
url      = env(&quot;DATABASE_URL&quot;)
}

model Study {
id        Int      @id @default(autoincrement())
category  String
title     String
content   String
createdAt DateTime @default(now())
}
</code></pre>
<h2 id="마이그레이션-실행">마이그레이션 실행</h2>
<p>마이그레이션은 DB 구조를 생성 및 관리하는 도구이다. 테이블 구조를 실제 DB에 반영하기 위해서 마이그레이션을 실행해야한다.
<code>npx prisma migrate dev --name init</code>
이후 다음 작업들이 자동으로 실행된다.</p>
<ul>
<li><code>prisma/migrations/</code> 폴더에 마이그레이션 파일 생성</li>
<li>MySQL DB에 실제 테이블 생성</li>
<li><code>_prisma_migrations</code> 테이블에 변경 이력 저장 -&gt; 추적가능</li>
</ul>
<p>코드 정리용 명령어도 있다. 
<code>npx prisma format</code></p>
<hr>
<h2 id="✅-prisma-client-생성">✅ Prisma Client 생성</h2>
<p>DB와의 연결은 <code>PrismaClient</code>라는 객체를 통해 이루어진다. 코드상에서 쿼리를 실행하려면 클라이언트를 먼저 생성해야 한다. 
<code>npx prisma generate</code>
마이그레이션 시 자동으로 실행되지만, 필요할 때 수동 실행도 가능하다.
생성된 클라이언트를 통해 아래와 같이 데이터를 조회할 수 있다. </p>
<pre><code class="language-ts">import { PrismaClient } from &quot;@prisma/client&quot;;
const prisma = new PrismaClient();

const studies = await prisma.study.findMany();</code></pre>
<hr>
<h2 id="👍-prisma-인스턴스-재사용-패턴">👍 Prisma 인스턴스 재사용 패턴</h2>
<p><code>new PrismaClient()</code>를 호출할 때마다 새로운 DB 커넥션이 생기기 때문에, 하나의 인스턴스를 만들어서 재상용하는 것이 좋다. 아래와 같이 <code>lib/prisma.tsx</code>에서 글로벌 프리즈마를 생성한다.</p>
<pre><code class="language-ts">import { PrismaClient } from &quot;@prisma/client&quot;;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== &quot;production&quot;) {
  globalForPrisma.prisma = prisma;
}</code></pre>
<h3 id="실제-사용-예시">실제 사용 예시</h3>
<pre><code class="language-ts">  const likedPosts: ScrapedWithStudy[] = await prisma.scrap.findMany({
    where: { userId },
    include: { study: true },
  });</code></pre>
<ul>
<li><code>findFirst</code>, <code>findMany</code>, <code>findUnique</code> 등 메서드 명이 직관적이고</li>
<li><code>where</code>, <code>orderBy</code>, <code>include</code> 옵션을 통해 조건, 정렬, join까지 쉽게 처리 가능</li>
<li>TypeScript 기반이라 자동 완성과 타입 추론이 매우 강력함
실제 <code>include</code>로 두 테이블을 함께 가져올 때도 Prisma가 자동으로 <strong>합쳐진 타입(Scrap &amp; Study)</strong>을 추론해줘서, 따로 타입을 지정하지 않아도 안전하게 사용할 수 있어 편했다.</li>
</ul>
<hr>
<p>여기까지 기본 프리즈마 사용 정리 끝
Spring JPA는 조건걸 때 메서드명이 너무 길어서 헷갈리기 쉬웠는데, prisma는 훨씬 직관적이고 타입까지 잘 잡아줘서 만족스러웠다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Hydration 오류 해결기]]></title>
            <link>https://velog.io/@yeon_99/Next.js-Hydration-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@yeon_99/Next.js-Hydration-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Sat, 24 May 2025 08:22:51 GMT</pubDate>
            <description><![CDATA[<h2 id="💥-문제-발생">💥 문제 발생</h2>
<pre><code class="language-rust">Hydration failed because the server rendered HTML didn&#39;t match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: </code></pre>
<p>오류가 발생했다.</p>
<blockquote>
<p>이 오류는 <strong>서버에서 렌더링한 HTML와 클라이언트가 그린 HTML이 달라졌을 때 발생</strong>한다. </p>
</blockquote>
<h3 id="원인-분석">원인 분석</h3>
<p>Next.js의 SSR 흐름은 아래와 같다.</p>
<ul>
<li>서버에서 먼저 HTML을 렌더링하고 (SSR)</li>
<li>클라이언트에서 이를 받아서 &quot;Hydration&quot;(하이드레이션)을 시도하는데, </li>
<li>이때 두 결과가 달라지면 Hydration 실패</li>
</ul>
<p>문제가 발생한 코드를 보니 </p>
<pre><code class="language-js">&lt;p className=&quot;text-gray-600&quot;&gt;
   {new Date(comment.createdAt).toLocaleString()}
&lt;p&gt;</code></pre>
<p>이 부분이었다.
<code>toLocaleString()</code>은 브라우저 환경이나 언어 설정에 따라 결과가 달라질 수 있다.</p>
<ul>
<li><code>SSR</code>서버에서는 <code>Node.js</code>의 지역 설정에 따라 렌더링하고</li>
<li>클라이언트에서는 사용자 브라우저의 지역 설정을 따라가 <strong>불일치가 발생</strong>한 것이었다.</li>
</ul>
<pre><code class="language-js">new Date().toLocaleString();
// 브라우저가 한국이면 → &quot;2025. 5. 23. 오후 3:10&quot;
// 브라우저가 미국이면 → &quot;5/23/2025, 3:10 PM&quot;
// 서버(Node.js)는? → 서버 OS의 기본 설정 따라감</code></pre>
<h2 id="해결방법">해결방법</h2>
<p>나는 <code>date-fns</code> 라이브러리를 사용해서, <strong>서버와 클라이언트에서 같은 포맷을 사용</strong>하도록 해결했다.</p>
<pre><code class="language-js">&lt;p className=&quot;text-gray-600&quot;&gt;
    {formatRelativeTime(new Date(comment.createdAt))}
&lt;/p&gt;</code></pre>
<h3 id="formatrelativetime-유틸-함수"><code>formatRelativeTime</code> 유틸 함수</h3>
<pre><code class="language-js">import { format, formatDistanceToNowStrict } from &quot;date-fns&quot;;
import { ko } from &quot;date-fns/locale&quot;;

export function formatRelativeTime(date: Date) {
  const now = new Date();
  const diffInMs = now.getTime() - date.getTime();
  const diffInSec = diffInMs / 1000;  // 1000ms 여서
  const diffInDay = diffInMs / (1000 * 60 * 60 * 24);

  if (diffInSec &lt; 60) {
    return &quot;방금 전&quot;;
  }

  if (diffInDay &lt; 3) {
    return formatDistanceToNowStrict(date, { locale: ko, addSuffix: true });
  }

  return format(date, &quot;yyyy.MM.dd&quot;);
}
</code></pre>
<p><code>date-fns</code>라이브러리의 <code>formatDistanceToNowStrict</code>을 사용해서 포맷을 맞췄다.</p>
<pre><code class="language-js">formatDistanceToNowStrict(date, { locale: ko, addSuffix: true })</code></pre>
<p>지금(<code>new Date()</code>)과 주어진 <code>date</code>사이의 거리를 단위별(초, 분, 시간, 일..)로 엄격하게 계산해서 &quot;1분 전&quot;, &quot;3시간 전&quot;과 같은 문자열을 리턴하는 함수이다.</p>
<ul>
<li><code>locale: ko</code> → 강제로 한국어 출력</li>
<li><code>addSuffix: true</code> → &quot;전&quot;, &quot;후&quot; 같은 접미사 붙이기</li>
</ul>
<h2 id="결과">결과</h2>
<ul>
<li>Hydration 오류 해결 🎉</li>
<li>사용자에게 더 친절한 시간 표현 제공</li>
<li>SSR + Client 환경에서의 날짜 처리 경험 
<img src="https://velog.velcdn.com/images/yeon_99/post/2e242e20-0d8a-4f24-8c52-e801b2dec7ba/image.png" alt=""></li>
</ul>
<hr>
<p>Next.js의 하이브리드 렌더링 구조에서 자주 발생하는 이슈라고 한다.
첫 데모 프로젝트에서 하이브리드 이슈를 해결하면서, SSR이 어떻게 동작하는지 하나씩 체감하는 중</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[페이지가 안 바뀐다면? Next.js 캐시 무효화 제대로 알기]]></title>
            <link>https://velog.io/@yeon_99/%ED%8E%98%EC%9D%B4%EC%A7%80%EA%B0%80-%EC%95%88-%EB%B0%94%EB%80%90%EB%8B%A4%EB%A9%B4-Next.js-%EC%BA%90%EC%8B%9C-%EB%AC%B4%ED%9A%A8%ED%99%94-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B8%B0</link>
            <guid>https://velog.io/@yeon_99/%ED%8E%98%EC%9D%B4%EC%A7%80%EA%B0%80-%EC%95%88-%EB%B0%94%EB%80%90%EB%8B%A4%EB%A9%B4-Next.js-%EC%BA%90%EC%8B%9C-%EB%AC%B4%ED%9A%A8%ED%99%94-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B8%B0</guid>
            <pubDate>Tue, 20 May 2025 07:13:51 GMT</pubDate>
            <description><![CDATA[<p>개발 환경에선 잘 보이던 새 글이, 배포 후에는 안보인다. 왜그럴까?</p>
<blockquote>
<p>이건 Next.js가 <strong>페이지 단위로 캐싱해</strong>서,
새로 올라온 데이터를 불러오지 않기 때문이다.</p>
</blockquote>
<p>이 캐시를 <strong>Full Route Cache</strong>라고 부르며, 
이번 글에서는 Full Route Cache와 클라이언트 캐시의 차이,
그리고 이를 무효화하는 방법인 <code>revalidatePath</code>, <code>revalidateTag</code> 사용법을 정리해보자. </p>
<hr>
<h2 id="full-route-cache란">Full Route Cache란?</h2>
<p>Next.js는 <strong>빌드 시점에 HTML와 React 구조를 미리 생성해서 저장</strong>한다. 
이후 요청이 들어오면 이 저장된 HTML을 그대로 서빙해 렌더링 및 데이터 요청을 생략할 수 있다.</p>
<ul>
<li>불필요한 리렌더링/데이터 요청을 방지</li>
<li>재검증이 일어나기 전까지 계속 사용됨</li>
</ul>
<h2 id="라우트-캐시는-클라이언트-캐시입니다">라우트 캐시는 클라이언트 캐시입니다</h2>
<p>헷갈리기 쉬운데, <strong>라우트 캐시(Router Cache)</strong> 는 브라우저 메모리에서 관리되는 <strong>클라이언트 측 캐시</strong>이다.</p>
<ul>
<li><strong>페이지 간 이동</strong> 시, 이전 서버 컴포넌트 페이로드를 재사용해 <strong>속도를 향상</strong></li>
<li>하지만 새로고침하거나 사이트를 벗어나면 캐시가 초기화 됨</li>
</ul>
<p>📌 <code>npm run build</code>시, 가능한 모든 정적 경로에 대해 Full Route Cache가 생성되지만,
<strong>동적 라우팅(예: <code>/posts/[id]</code>)</strong> 경우 어떤 값인지 모르기 때문에 캐시가 만들어지지 않는다.
-&gt; 이 경우에는 요청이 들어올 때마다 렌더링됨</p>
<hr>
<h2 id="revalidatepathrevalidatetag로-캐시-무효화">revalidatePath/revalidateTag로 캐시 무효화</h2>
<p>Next.js는 캐시를 자동으로 처리하지만,
개발자가 필요시 직접 무효화 할 수도 있다.
이를 <strong>온디맨드(On-demand) 캐시 무효화</strong>라고 부른다.</p>
<h3 id="revalidatepath">revalidatePath()</h3>
<p>가장 자주 사용되는 메서드로, <strong>특정 경로의 정적 캐시를 무효화</strong> 한다.</p>
<blockquote>
<p>특정 경로의 정적 캐시를 무효화 시키는 메서드</p>
</blockquote>
<pre><code class="language-js">import { revalidatePath } from &#39;next/cache&#39;;

revalidatePath(&#39;/news&#39;); // /news 페이지 캐시 재검증 요청
revalidatePath(&#39;/news&#39;, &#39;layout&#39;); // /news 페이지의 레이아웃까지 재검증
</code></pre>
<ul>
<li>두 번째 인자는 <code>&#39;page&#39;</code>(기본값), <code>&#39;layout&#39;</code> 중 선택 가능</li>
<li>&quot;layout&quot;시 해당 페이지와 <strong>중첩된 모든 페이지</strong>를 무효화할 수 있다.</li>
</ul>
<h3 id="revalidatetag">revalidateTag()</h3>
<blockquote>
<p><code>fetch()</code> 요청시, 태그를 부여하고 그 태그로 이름을 캐시를 무효화</p>
</blockquote>
<pre><code class="language-js">// fetch 시 태그 지정
 const response = await fetch(&quot;http://localhost:8080/messages&quot;, {
    next : {tags : [&#39;msg&#39;]}
  });


// 서버에서 해당 태그 무효화
import { revalidateTag } from &#39;next/cache&#39;;
revalidateTag(&#39;msg&#39;);</code></pre>
<ul>
<li>여러 <code>fetch</code>요청에 동일한 태그를 지정하면, 한번의 <code>revalidateTag()</code>로 해당 태그 모두 무효화 가능</li>
</ul>
<hr>
<h4 id="마무리">마무리</h4>
<p>Next.js의 캐싱은 엄청 똑똑해서, 내가 따라가기 벅차구나 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 fetch가 한 번만 되는 이유? ]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%EC%BA%90%EC%8B%B1-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC-1-%EC%9A%94%EC%B2%AD-%EC%BA%90%EC%8B%B1%EA%B3%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@yeon_99/Next.js-%EC%BA%90%EC%8B%B1-%EC%99%84%EC%A0%84-%EC%A0%95%EB%A6%AC-1-%EC%9A%94%EC%B2%AD-%EC%BA%90%EC%8B%B1%EA%B3%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Tue, 20 May 2025 06:39:50 GMT</pubDate>
            <description><![CDATA[<p>Next.js에서 페이지 전환이 빠르고 서버 요청이 줄어드는 건 자동 캐싱을 지원하기 때문이다.
그런데 이 캐싱에도 종류가 여러 개고, 직접 제어할 수 있는 포인트가 있다는 걸 알게 됐다.
이번 포스팅에서는 <strong>요청 캐싱(Request Memoization)</strong>과 <strong>데이터 캐싱(Data Cache)</strong>에 대해 정리해보자.</p>
<hr>
<h2 id="request-memoization요청-캐싱">Request Memoization(요청 캐싱)</h2>
<blockquote>
<p>하나의 서버에 같은 요청이 들어오면, 하나로 받아 중복 요청을 방지하는 캐싱</p>
</blockquote>
<ul>
<li>하나의 요청 흐름(Request lifecycle) 안에서만 적용된다. </li>
<li>즉, 컴포넌트가 같은 데이터를 여러 번 요청하더라도, 한번만 요청되고 공유된다.</li>
<li>사용자가 페이지를 새로고침하거나 다시 진입하면 초기화 🌀</li>
</ul>
<h2 id="data-cache데이터-캐싱">Data Cache(데이터 캐싱)</h2>
<blockquote>
<p>백엔드 요청 결과를 서버에 저장해서, 반복된 요청 없이 저장된 결과를 재사용하는 캐시</p>
</blockquote>
<ul>
<li>fetch()를 통해 가져온 데이터는 Next.js의 서버 캐시에 저장됨</li>
<li>동일한 페이지를 다시 열어도 저장된 응답을 재사용함</li>
<li>사용자가 재검증을 요청하거나, 재검증 시간이 지나야 다시 요청됨</li>
</ul>
<h3 id="캐싱의-이점">캐싱의 이점</h3>
<ul>
<li>불필요한 API 요청을 줄임</li>
<li>서버 부하도 줄임</li>
<li>왔다갔다를 줄여 앱 전체의 성능을 높임</li>
</ul>
<p>요청 자체를 피하는 것 (추가 왕복을 줄여 애플리케이션을 더 빠르게 할 수 있다.)</p>
<hr>
<h2 id="캐시-제어-1--fetch에서-직접-설정">캐시 제어 (1) : fetch()에서 직접 설정</h2>
<h3 id="1-캐시-끄기">1. 캐시 끄기</h3>
<pre><code class="language-js">fetch(url, { cache : &#39;no-store&#39;});</code></pre>
<p>매번 새로운 데이터를 요청하는 방법으로, 테스트나 관리자 페이지에 유용하다.</p>
<h3 id="2-재검증-시간-설정">2. 재검증 시간 설정</h3>
<pre><code class="language-js">fetch(url, { next: { revalidate: 5 } });</code></pre>
<p>숫자는 캐싱을 유지한 시간이다. 여기서는 데이터를 5초간 캐싱하고 이후부터는 새로 요청한다.
=&gt; 일부 캐싱 이점을 유지하면서도, 오래된 데이터는 방지 가능</p>
<h2 id="캐시-제어2--파일-단위-제어">캐시 제어(2) : 파일 단위 제어</h2>
<h3 id="1-파일-전체에-캐싱-시간-설정">1. 파일 전체에 캐싱 시간 설정</h3>
<pre><code>export const revalidate = 5; // 5초 동안 캐시 유지</code></pre><p>파일안의 모든 캐시에 적용되고, export와 상수 이름은 고정이다. </p>
<h3 id="2-전체-강제-동적정적-렌더링">2. 전체 강제 동적/정적 렌더링</h3>
<pre><code>export const dynamic = &quot;force-dynamic&quot; // 파일 내 어디서든 필요한 모든 데이터를 항상 다 가져옴
export const dynamic = &quot;force-static&quot; // 새로운 데이터를 전혀 가져오지 않게 됨 (캐싱 강조)</code></pre><p>캐시를 피하거나 캐시를 강제하는 방법</p>
<h2 id="캐시-제어3--컴포넌트-단위-제어">캐시 제어(3) : 컴포넌트 단위 제어</h2>
<p>파일 전체가 아닌 일부 컴포넌트만 캐시에서 제외하고 싶을 때 사용하자.</p>
<pre><code>import { unstable_noStore } from &quot;next/cache&quot;;

unstable_noStore(); // 이 컴포넌트에서의 요청은 캐시 사용 안 함</code></pre><ul>
<li><code>force-dynamic</code>보다 더 세밀한 조정이 가능해 권장</li>
<li>특정 컴포넌트에서만 캐시를 끄고 싶을 때 추천</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 라우트 핸들러 : route.js에 숨은 규칙들!]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%EB%9D%BC%EC%9A%B0%ED%8A%B8-%ED%95%B8%EB%93%A4%EB%9F%AC-route.js%EC%97%90-%EC%88%A8%EC%9D%80-%EA%B7%9C%EC%B9%99%EB%93%A4</link>
            <guid>https://velog.io/@yeon_99/Next.js-%EB%9D%BC%EC%9A%B0%ED%8A%B8-%ED%95%B8%EB%93%A4%EB%9F%AC-route.js%EC%97%90-%EC%88%A8%EC%9D%80-%EA%B7%9C%EC%B9%99%EB%93%A4</guid>
            <pubDate>Mon, 19 May 2025 05:35:54 GMT</pubDate>
            <description><![CDATA[<h1 id="nextjs의-라우트-핸들러-파일-이름">Next.js의 라우트 핸들러, 파일 이름</h1>
<p>Next.js에서 API를 만들려면 <strong><code>app/api/.../route.js</code></strong> 안에 함수들을 선언해야 한다. 
페이지 컴포넌트가 아닌, <strong>서버 전용 API 함수 파일</strong>로 작동한다.</p>
<h2 id="🧩-라우트-핸들러란">🧩 라우트 핸들러란?</h2>
<blockquote>
<p><strong>클라이언트에서 호출하는 HTTP 요청(GET, POST, PATCH, PUT, DELETE..)에 응답하는 서버 전용 함수 파일</strong>
단, <strong>페이지 컴포넌트와 다르게 화면을 렌더링하지 않는다.</strong></p>
</blockquote>
<pre><code class="language-js">export function GET(request){
    console.log(request);

    // return Response.json();
    return new Response(&#39;Hello&#39;);
}</code></pre>
<hr>
<h2 id="📌-핵심-특징-요약">📌 핵심 특징 요약</h2>
<ul>
<li><code>GET</code>, <code>POST</code>, <code>PUT</code>, <code>DELETE</code> 등 <strong>HTTP 메서드 이름으로 export</strong>해야 한다.</li>
<li>파일명은 <code>route.js</code>여야 Next.js가 라우트 핸들러로 인식</li>
<li>JSON 데이터를 수신하거나 반한하는 용도로 사용</li>
<li>Next.js가 자동으로 <code>request</code> 객체를 인자로 넘겨줌</li>
<li><strong>같은 경로에 여러 메서드를 정의해서 다양한 요청을 처리 가능</strong></li>
</ul>
<hr>
<h2 id="⚠️-헷갈릴만한-포인트">⚠️ 헷갈릴만한 포인트</h2>
<h3 id="1-파일-이름은-routejs로-두기">1. 파일 이름은 <code>route.js</code>로 두기</h3>
<ul>
<li><code>route.js</code>가 아니면 Next.js가 라우트 핸들러로 인식하지 못함!</li>
<li><code>GET</code>, <code>POST</code> 등 메서드 함수가 없으면 → <strong>405 Method Not Allowed</strong></li>
</ul>
<h3 id="2-return-값이-jsx면-에러">2. return 값이 JSX면 에러!</h3>
<p>라우트 핸들러는 API를 위한 <strong>서버 전용 파일</strong>이기 때문에
<code>&lt;div&gt;Hello&lt;/div&gt;</code> 같은 JSX를 리턴하면 안 되고, 
반드시 <code>new Response()</code> 또는 <code>Response.json()</code> 형식으로 반환해야 함!</p>
<h3 id="3aysnc-함수로-request-body-파싱-가능">3.<code>aysnc</code> 함수로 request body 파싱 가능</h3>
<p>라우트 핸드러는 서버 함수이기 때문에 <code>async/await</code> 사용이 가능하다.
예를 들어, POST 요청에서 <code>request.json()</code>을 처리하려면 <code>await</code>이 필요하다.</p>
<pre><code class="language-js">export async function POST(request) {
  const body = await request.json();
  return Response.json({ name: body.name });
}
</code></pre>
<blockquote>
<p><code>await</code>없이 <code>.json()</code>을 호출하면 데이터가 아니라 <code>Promise</code>가 응답에 포함될 수 있다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 use client 언제 써야 해? 서버 컴포넌트와의 경계 이해하기]]></title>
            <link>https://velog.io/@yeon_99/Next.js%EC%97%90%EC%84%9C-use-client-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%B4-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%99%80%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yeon_99/Next.js%EC%97%90%EC%84%9C-use-client-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%B4-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%99%80%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 19 May 2025 05:04:55 GMT</pubDate>
            <description><![CDATA[<h1 id="서버-컴포넌트-vs-클라이언트-컴포넌트">서버 컴포넌트 vs 클라이언트 컴포넌트</h1>
<p>Next.js에서 언제 어떤 컴포넌트를 서버/클라이언트로 사용해야 하는지 이해하는 게 중요하다.
기본적으로 Next.js는 <strong>서버 컴포넌트 기반</strong>으로 동작한다.</p>
<ul>
<li>서버 컴포넌트는 서버에서만 렌더링되며,</li>
<li><strong>HTML을 미리 만들어 보내 SEO에 유리하고, 초기 로딩 속도가 빠르다.</strong></li>
</ul>
<p>하지만 <code>useState</code>, <code>useEffect</code>, <code>usePathname</code> 같은 React 훅을 사용하는 순간,
해당 컴포넌트는 <strong>클라이언트 컴포넌트</strong>로 전환되어야 하며, 이때는 <strong><code>use client</code></strong> 지시어를 지정해야 한다.</p>
<blockquote>
<p>❗️ 문제는 ? 서버 컴포넌트 안에서 훅 하나 때문에 전체가 클라이언트 컴포넌트로 변하면, 서버 렌더링의 장점이 사라진다.</p>
</blockquote>
<p>그래서 정말 필요한 부분만 <strong>클라이언트 컴포넌트로 분리</strong>해서 <strong>아웃소싱</strong>하는 것이 좋다.</p>
<h2 id="예시--네비게이션-링크-컴포넌트">예시 : 네비게이션 링크 컴포넌트</h2>
<pre><code class="language-js">&lt;NavLink href=&quot;/news&quot;&gt;News&lt;/NavLink&gt;</code></pre>
<pre><code class="language-js">// components/NavLink.js
&#39;use client&#39;;

import Link from &quot;next/link&quot;;
import { usePathname } from &quot;next/navigation&quot;;

export default function NavLink({ href, children }) {
  const path = usePathname();
  return (
    &lt;Link
      href={href}
      className={path.startsWith(href) ? &quot;active&quot; : undefined}
    &gt;
      {children}
    &lt;/Link&gt;
  );
}
</code></pre>
<p>이렇게 <code>NavLink</code>만 클라이언트로 분리해서 서버 컴포넌트에서 가져다 쓰면, 
전체 <strong>서버 렌더링 구조는 유지하면서도 동적인 UI만 클라이언트로 처리할 수 있다.</strong></p>
<h2 id="❗️error-컴포넌트는-예외">❗️Error 컴포넌트는 예외</h2>
<p><code>app/error.js</code>는 <strong>서버에서 발생한 에러</strong>뿐 아니라 <strong>클라이언트 상호작용 중 발생한 에러</strong>까지 처리해야 하므로 <strong>클라이너트 컴포넌트</strong>로 만들어야 한다.</p>
<h2 id="✅-결론">✅ 결론</h2>
<ul>
<li>가능한 모든 컴포넌트는 서버 컴포넌트로 유지하자.</li>
<li>동적인 상호작용이 필요한 부분만 <code>use client</code>로 분리하자.</li>
<li>전체를 클라이언트로 바꾸지말고, 구체적인 역할만 위임하는 구조가
<strong>➤ 성능, 유지보수, SEO 모든 면에서 유리하다.</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[⏳ Next.js에서 로딩 상태 처리하기 (loading.js & Suspense)]]></title>
            <link>https://velog.io/@yeon_99/Next.js%EC%97%90%EC%84%9C-%EB%A1%9C%EB%94%A9-%EC%83%81%ED%83%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-loading.tsx-Suspense</link>
            <guid>https://velog.io/@yeon_99/Next.js%EC%97%90%EC%84%9C-%EB%A1%9C%EB%94%A9-%EC%83%81%ED%83%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-loading.tsx-Suspense</guid>
            <pubDate>Tue, 13 May 2025 13:05:20 GMT</pubDate>
            <description><![CDATA[<p>Next.js에서는 페이지나 컴포넌트가 <strong>데이터를 비동기로 불러올 때</strong>, 사용자에게 로딩 상태를 보여줄 수 있도록 <strong>로딩 페이지</strong>를 제공한다. </p>
<hr>
<h2 id="기본-방식-loadingjs-파일">기본 방식: loading.js 파일</h2>
<p><code>loading.js(loading.tsx)</code>을 사용하면, <strong>로딩 UI</strong>를 지정할 수 있다.</p>
<ul>
<li><code>app/</code>폴더에서 사용하는 방식</li>
<li>해당 폴더 내 <code>page.js</code>가 비동기로 데이터를 가져올 경우,</li>
<li>같은 위치의 <code>loading.js</code>가 <strong>자동으로 렌더링</strong>된다.</li>
</ul>
<pre><code class="language-javascript">// app/meals/loading.js
import classes from &quot;./loading.module.css&quot;;

export default function MealsLoadingPage() {
  return &lt;p className={classes.loading}&gt;Fetching meals...&lt;/p&gt;;
}
</code></pre>
<h3 id="위치에-따른-우선순위">위치에 따른 우선순위</h3>
<ul>
<li><code>app/loading.js</code> → *<em>모든 페이지에 적용되는 전역 로딩 *</em></li>
<li><code>app/meals/loading.js</code> → <code>/meals</code> 하위에서만 적용<blockquote>
<p>폴더 구조 기반으로 <strong>가장 가까운 로딩 페이지가 우선적으로 적용</strong>됨!</p>
</blockquote>
</li>
</ul>
<hr>
<h2 id="🤔-문제점-전체-페이지가-로딩되는-건-사용자-경험이-떨어짐">🤔 문제점: 전체 페이지가 로딩되는 건 사용자 경험이 떨어짐</h2>
<p>데이터 하나 불러오는데 전체 화면이 로딩으로 바뀐다면?
❌ 헤더나 버튼, 설명까지 전부 로딩처럼
❌ 깜빡이는 느낌
❌ UX 저하</p>
<hr>
<h2 id="✅-해결책--suspense로-부분-로딩-처리">✅ 해결책 : <code>Suspense</code>로 부분 로딩 처리</h2>
<p>리액트의 <strong><code>&lt;Suspense&gt;</code></strong> 컴포넌트를 사용하면, <strong>로딩이 필요한 일부 UI만 로딩 처리하고 나머지는 바로 보여줄 수 있다.</strong></p>
<pre><code class="language-javascript">import { Suspense } from &quot;react&quot;;
import MealsGrid from &quot;@/components/meals/meals-grid&quot;;
import { getMeals } from &quot;@/lib/meals&quot;;

async function Meals() {
  const meals = await getMeals();
  return &lt;MealsGrid meals={meals} /&gt;;
}

export default function MealsPage() {
  return (
    &lt;&gt;
      &lt;header&gt;...헤더 콘텐츠...&lt;/header&gt;
      &lt;main&gt;
        &lt;Suspense fallback={&lt;p&gt;Loading meals...&lt;/p&gt;}&gt;
          &lt;Meals /&gt;
        &lt;/Suspense&gt;
      &lt;/main&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p><code>Meals</code>컴포넌트가 데이터를 가져오는 동안, <code>&lt;Suspense&gt;</code>의 <strong><code>fallback</code></strong> 이 대신 보여지고
헤더나 기타UI는 그대로 유지된다.</p>
<blockquote>
<p><strong>fallback</strong>은 비동기 컴포너느가 로딩 중일 때 대신 보여줄 UI를 지정하는 속성이다.</p>
</blockquote>
<hr>
<h2 id="💡-이게-뭔데">💡 이게 뭔데?</h2>
<blockquote>
<p><strong>&quot;Suspense &amp; Streamed Response&quot;</strong>를 이용한 세분화 로딩 처리&quot;라고 부른다. </p>
</blockquote>
<p>즉, 일부 콘텐츠는 바로 렌더링하고,
나머지는 준비되면 지켜보다가(스트리밍) 이어서 로딩하는 것!!</p>
<hr>
<p>부분 로딩 UX를 구현해 <strong>사용자에게 더 나은 경험</strong>을 제공할 수 있을 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js 15] 이미지 최적화 비법 - Image]]></title>
            <link>https://velog.io/@yeon_99/Next.js-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B9%84%EB%B2%95-Image</link>
            <guid>https://velog.io/@yeon_99/Next.js-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B9%84%EB%B2%95-Image</guid>
            <pubDate>Tue, 13 May 2025 12:10:58 GMT</pubDate>
            <description><![CDATA[<p>Next.js에서 기본<code>&lt;img&gt;</code>태그 대신<strong><code>Image</code>컴포넌트</strong> 를 사용하면,
<strong>자동 최적화, 지연 로딩, 다양한 디바이스 대응</strong> 등 더 최적화된 방법으로 이미지 렌더링이 가능하다.</p>
<blockquote>
<p>이유는 ? 특별한 <strong>내장 최적화 기능</strong>을 제공하는 이미지 요소이기 때문!</p>
</blockquote>
<hr>
<h2 id="🚫-기존-방식-img-컴포넌트">🚫 기존 방식 (<code>&lt;img&gt;</code> 컴포넌트)</h2>
<pre><code class="language-javascript">import Logo from &quot;./public/images&quot;;
 &lt;img src={Logo.src} alt=&quot;logo&quot; /&gt;</code></pre>
<p>작동을 잘 됨. 하지만,</p>
<ul>
<li>자동 크기조절 ❌ </li>
<li>지연 로딩 ❌ </li>
<li>이미지 포맷 최적화 ❌ </li>
</ul>
<hr>
<h2 id="✅-nextjs-방식-image-컴포넌트">✅ Next.js 방식 (<code>&lt;Image&gt;</code> 컴포넌트)</h2>
<pre><code class="language-javascript">import Logo from &quot;./public/images&quot;;
import Image from &quot;next/image&quot;;

&lt;Image src={Logo} alt=&quot;logo&quot; /&gt;</code></pre>
<ul>
<li><code>src={Logo}</code>만 넣어줘도,자동으로 연결</li>
<li>사이즈나 포맷 지정이 없어도 자동 처리</li>
<li>내부적으로는 <code>&lt;img&gt;</code>태그가 생성되지만, 더 많은 기능이 붙음</li>
</ul>
<hr>
<h2 id="🔍-개발자-도구f12에서-보면">🔍 개발자 도구(F12)에서 보면?</h2>
<pre><code class="language-html">&lt;img alt=&quot;logo&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;1024&quot; decoding=&quot;async&quot; data-nimg=&quot;1&quot; 
srcset=&quot;/_next/image?...w=1080&amp;q=75 1x, ...w=2048&amp;q=75 2x&quot; 
src=&quot;/_next/image?...w=2048&amp;q=75&quot; style=&quot;color: transparent;&quot; /&gt;</code></pre>
<ul>
<li><code>loading=&quot;lazy&quot;</code> : 
지연 로딩으로 <strong>화면에 보일 때만 이미지 로딩</strong></li>
<li><code>srcset</code> : 
뷰포트와 웹사이트를 방문하는 기기에 따라 <strong>크기가 조정된 이미지 제공</strong>
지원되는 포맷으로 자동 변환</li>
<li><code>decoding=&quot;async&quot;</code> :
렌더링 성능 최적화</li>
<li><code>src</code> :
Next.js의 최적화된 이미지 경로로 자동 처리</li>
</ul>
<hr>
<h2 id="⚡-priority-속성으로-렌더링-우선순위-지정">⚡ priority 속성으로 렌더링 우선순위 지정</h2>
<pre><code class="language-javascript">&lt;Image src={Logo} alt=&quot;logo&quot; priority /&gt;</code></pre>
<p><strong><code>priority</code></strong> 속성은 해당 이미지가 <strong>페이지에서 가장 먼저 렌더링</strong>되어야 함을 명시한다.
<strong>로고, Hero 이미지</strong>는 가장 먼저 렌더링되는 것이 사용자 경험에 중요하기에,
<code>priority</code>를 통해 지연 로딩이 아닌, 즉시 로딩으로 사용된다. </p>
]]></description>
        </item>
    </channel>
</rss>