<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>darong_.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 07 Jan 2025 05:35:27 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>darong_.log</title>
            <url>https://velog.velcdn.com/images/darong_/profile/0ef68475-fd30-468a-9e90-c804cf509b9f/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. darong_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/darong_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[React] youtube 플레이어 만들기]]></title>
            <link>https://velog.io/@darong_/React-youtube-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@darong_/React-youtube-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 07 Jan 2025 05:35:27 GMT</pubDate>
            <description><![CDATA[<p>오늘은 이전에 진행했던 팀 프로젝트인 record panpang에서 만들었던 youtube 플레이어를 어떤 방식으로 구현했고 어떤 문제점을 겪었는지에 대해 포스팅을 해보려고 한다.</p>
</br>

<h2 id="🚩-useref">🚩 useRef</h2>
<blockquote>
<p>React 공식문서에 따르면
useRef는 <strong>렌더링에 필요하지 않은 값을 참조</strong>할 수 있는 React Hook 이라고 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/0c13f5f0-a6c1-44b3-bbcc-b6ef59f90de9/image.png" alt=""></p>
<p>플레이어를 구현하기 위해 가장 기본이 되는 것이 ref였다.
ref는 DOM 요소와 연결하여 사용할 수 있기 때문에 비디오 컴포넌트에 연결하여 플레이어를 조작하면 비디오 컴포넌트의 상태가 변경되도록 설정해야했다.</p>
<p>ref의 설명과 여러 쓰임에 대해서는 React 공식 문서에 나와있으니 참고하면 좋을 것 같다.
<a href="https://ko.react.dev/reference/react/useRef">React useRef 공식 문서</a></p>
<p>공식 문서에서도 직접 ref의 사용법으로 플레이어 조작을 예시로 들어준다!
<img src="https://velog.velcdn.com/images/darong_/post/35b578cd-ac9e-4de2-a0d6-7a57af705b87/image.png" alt=""></p>
</br>

<h2 id="⏯️-플레이어-구현하기">⏯️ 플레이어 구현하기</h2>
<p>우리 프로젝트는 영상 공유가 아닌 <strong>노래 공유</strong>가 주 서비스였기 때문에 영상 컴포넌트는 hidden으로 숨기고 플레이어를 통해 소리만 재생될 수 있도록 해야했다.</p>
<h3 id="⭐-ui-구조">⭐ UI 구조</h3>
<pre><code class="language-tsx">const PlayButton = ({ music, id, post_id }: Props) =&gt; {
  const { playedVideo, setIsPlay, setPlayedVideo, playedPlayer, setPlayedPlayer } = useYoutubeStore();
  const playerRef = useRef&lt;YouTubePlayer | null&gt;(null);
  const [showYouTube, setShowYouTube] = useState(false);

  return (
    &lt;&gt;
      {showYouTube &amp;&amp; (
        &lt;div className=&quot;hidden&quot;&gt;
          &lt;YouTube videoId={id} onReady={(e: YouTubeEvent) =&gt; onReady(e, playerRef)} /&gt;
        &lt;/div&gt;
      )}
      &lt;div className=&quot;relative w-[50px] h-[50px] cursor-pointer&quot; onClick={(e: React.MouseEvent) =&gt; handleClick(e)}&gt;
        &lt;Image
          alt={music.name + &quot;앨범커버&quot;}
          src={music.album.images}
          width={50}
          height={50}
          className=&quot;rounded object-cover&quot;
          style={{ width: &quot;100%&quot;, height: &quot;100%&quot; }}
        /&gt;
        &lt;div className=&quot;w-[50px] h-[50px] bg-black/30 rounded absolute top-0&quot;&gt;&lt;/div&gt;
        &lt;PlayIcon
          style={{
            width: &quot;15px&quot;,
            position: &quot;absolute&quot;,
            top: &quot;50%&quot;,
            left: &quot;50%&quot;,
            transform: &quot;translate(-50%, -50%)&quot;,
            fill: &quot;white&quot;
          }}
          id={music.id}
          post_id={post_id}
        /&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
};

export default PlayButton;</code></pre>
<p>youtube가 들어가는 div는 hidden 으로 처리하여 숨기고 앨범 커버와 플레이 버튼을 만들어 해당 버튼으로 재생 조작이 가능하도록 설정했다.</p>
<p>처음 구현했을 때 메인 화면에서 로딩이 너무 길길래 왜인가 봤더니 피드형 사이트에서 한번에 모든 유튜브 비디오 컴포넌트를 렌더링하기 때문에 성능이 저하되는 것이었다. 그래서 showYoutube 라는 상태를 만들어 최초로 플레이 버튼을 눌렀을 때 컴포넌트가 렌더링 되도록 수정했다.</p>
</br>

<h3 id="⭐-플레이어-상태-관리">⭐ 플레이어 상태 관리</h3>
<p>플레이어의 재생 상태를 제어하기 위한 값들은 zustand를 사용해 별도로 관리했다.</p>
<pre><code class="language-tsx">const useYoutubeStore = create&lt;YoutubeStore&gt;((set) =&gt; ({
  playedVideo: { id: &quot;&quot;, isPlay: false, post_id: &quot;&quot; },
  playedPlayer: null,
  token: &quot;&quot;,
  setPlayedVideo: (id: string, post_id: string) =&gt; set({ playedVideo: { isPlay: true, id, post_id } }),
  setIsPlay: () =&gt; set((state) =&gt; ({ playedVideo: { ...state.playedVideo, isPlay: !state.playedVideo.isPlay } })),
  setPlayedPlayer: (player: YouTubePlayer | null) =&gt; set({ playedPlayer: player })
}));

export default useYoutubeStore;</code></pre>
<p><code>playedVideo</code> = 현재 재생 중인 영상의 정보
<code>playedPlayer</code> = 현재 재생 중인 플레이어의(ref) 정보
<code>token</code> = spotify api 요청에 필요한 토큰 정보
<code>setPlayedVideo</code> = 재생 중인 영상 정보를 변경하는 함수
<code>setIsPlay</code> = 재생한 영상의 재생 상태만 변경하는 함수
<code>setPlayedPlayer</code> = 재생 중인 플레이어의 정보를 변경하는 함수</p>
<p>처음에는 playedPlayer를 제외하고 만들었는데 기존 영상이 재생 중일 때 다른 영상을 재생하면 선택한 노래들이 동시에 재생되는 문제가 있었다. 그래서 기존에 재생 중이던 영상의 상태를 제어하기 위해 플레이어를 정한 ref를 state로 만들어 제어가 가능하도록 만들었다.</p>
</br>

<h3 id="⭐-플레이어-제어-함수">⭐ 플레이어 제어 함수</h3>
<pre><code class="language-tsx">const togglePlayVideo = async () =&gt; {
      // 처음 누르는 거면 플레이어 렌더링부터
    if (!showYouTube) {
      setShowYouTube(true);
    }

    // 틀었던 노래를 정지하거나 다시 재생할 때 (같은 노래를 두 번 이상 눌렀을 떄)
    if (playerRef.current &amp;&amp; playedVideo.id === music.id &amp;&amp; post_id === playedVideo.post_id) {
      if (playedVideo.isPlay) {
        playerRef.current.pauseVideo();
        setIsPlay();
      } else {
        playerRef.current.playVideo();
        setIsPlay();
      }
    }

    // store에 저장된 음악과 현재 누른 음악이 다를 때 (a 노래 듣다가 b 노래 틀었을 때)
    if (playedVideo.id !== music.id &amp;&amp; playerRef.current &amp;&amp; post_id !== playedVideo.post_id) {
      if (playedVideo.isPlay &amp;&amp; playedPlayer) {
        playedPlayer.pauseVideo();
      }
      setPlayedVideo(music.id, post_id);
      setPlayedPlayer(playerRef.current);
      playerRef.current.playVideo();
    }
  };</code></pre>
<p>여러 조건을 통해 원하는 방식으로 조작이 가능하도록 구현했다.</p>
<p>내가 사용한 react-youtube 라이브러리에서는 영상 컴포넌트가 로딩이 완료 되었을 때 ready 라고 현재 상태를 알려주는데 컴포넌트가 Ready 상태가 되었을 때 특정 동작을 수행할 수 있는 onReady 속성을 제공한다.</p>
<pre><code class="language-tsx">  const onReady = (e: YouTubeEvent, playerRef: MutableRefObject&lt;YouTubePlayer | null&gt;) =&gt; {
    // 렌더링 완료 되면 ref 연결하기
    playerRef.current = e.target;
    // 만약 이전에 재생 중이던 노래가 있으면 그 노래 멈추고
    if (playedPlayer) {
      playedPlayer.pauseVideo();
    }
    // 현재 노래 정보로 변경
    setPlayedVideo(music.id, post_id);
    playerRef.current.playVideo();
    setPlayedPlayer(playerRef.current);
  };</code></pre>
<p>그래서 onReady 함수를 따로 만들어 showYoutube 후에 영상 컴포넌트가 렌더링, ready 상태가 되면 자동으로 노래를 재생하도록 구현했다.</p>
</br>

<h3 id="💥-중복-노래-아이콘-동기화">💥 중복 노래 아이콘 동기화</h3>
<p>초기에 구현했던 플레이어는 만약 한 피드에 A 라는 노래가 두 개 이상 있을 경우 플레이어의 재생 상태 자체는 독립적으로 제어됐지만 PlayIcon이 동기화 되는 문제가 있었다. A1을 재생하고 A2를 재생했을 때 A1은 정지 상태가 되어야하는데 다른 포스트 같은 노래에서도 계속 재생 아이콘이 보이는 문제였다.</p>
<pre><code class="language-tsx">const PlayIcon = ({ style, id, post_id }: Props) =&gt; {
  const { playedVideo } = useYoutubnStore();
  if (playedVideo.isPlay &amp;&amp; playedVideo.id === id &amp;&amp; post_id === playedVideo.post_id) {
    return &lt;PauseCon style={style} /&gt;;
  }

  if (!playedVideo.isPlay || playedVideo.id !== id || post_id !== playedVideo.post_id) {
    return &lt;PlayCon style={style} /&gt;;
  }
};

export default PlayIcon;</code></pre>
<p>아이콘 컴포넌트는 이렇게 생겼는데 처음에 구현했을 때는 post_id가 아니라 music_id를 받고 있었기 때문에 발생한 문제였다. 플레이어는 스포티파이에서 music id를 받고 있었기 때문에 같은 노래는 같은 id를 갖고 있었다. 중복 문제를 해결하기 위해 music이 아닌 post id를 받는 것으로 문제를 해결할 수 있었다.</p>
</br>]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 낙관적 업데이트(optimistic update)]]></title>
            <link>https://velog.io/@darong_/React-%EB%82%99%EA%B4%80%EC%A0%81-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8optimistic-update</link>
            <guid>https://velog.io/@darong_/React-%EB%82%99%EA%B4%80%EC%A0%81-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8optimistic-update</guid>
            <pubDate>Mon, 06 Jan 2025 05:33:31 GMT</pubDate>
            <description><![CDATA[<h2 id="🚩-낙관적-업데이트란">🚩 낙관적 업데이트란?</h2>
<blockquote>
<p><strong>낙관적 업데이트</strong></p>
</blockquote>
<ul>
<li>서버 통신 전에 상태를 먼저 업데이트 한 뒤 통신을 진행하는 방식이다.</li>
<li>ex) SNS 등의 좋아요, 북마크 버튼 등<ul>
<li>사용자의 상호작용 직후 UI에 반영되어 사용자 경험을 높일 수 있다</li>
</ul>
</li>
</ul>
<ol>
<li>사용자의 상호작용</li>
<li>서버 요청 전 클라이언트 상태(ui) 업데이트</li>
<li>백그라운드에서 서버 통신 진행</li>
<li>통신 성공 =&gt; 현재 상태 유지</li>
<li>통신 실패 =&gt; 이전 상태로 롤백</li>
</ol>
<p>React에서는 redux, zustand, context-api 등등 상태 관리 라이브러리를 사용하여 여러 방법으로 낙관적 업데이트를 구현할 수 있다.</p>
<p>나는 여러 프로젝트에서 서버 상태를 관리할 때 tanstack query를 사용했기 때문에 tanstack query의 onMutate, onError를 통해 낙관적 업데이트를 구현할 수 있었다.</p>
<p>tanstack query의 경우 서버 통신 후 데이터를 관리할 때 onError, onLoading, mutation, success 등의 여러 옵션을 통해 상태 관리와 오류 흐름을 간편하게 관리할 수 있어서 자주 사용했는데, 낙관적 업데이트 또한 빠르게 구현할 수 있어서 좋았다.</p>
<p><a href="https://tanstack.com/query/v4/docs/framework/react/guides/optimistic-updates">tanstak query 공식 문서</a></p>
</br>

<h2 id="❤️-좋아요-낙관적-업데이트-코드">❤️ 좋아요 낙관적 업데이트 (코드)</h2>
<p>실제로 구현한 코드는 아래와 같다.</p>
<pre><code class="language-tsx">// 좋아요 낙관적 업데이트
export const useToggleLikeButton = (
  user: User | null,
  postId: number,
  isLike: boolean,
) =&gt; {
  const queryClient = useQueryClient();

  const { mutate } = useMutation({
    // 서버에 좋아요 상태를 요청하는 함수
    mutationFn: () =&gt; toggleLike(user, isLike, postId),

    // 실제로 낙관적 업데이트를 수행하는 부분 (onMutate)
    onMutate: async () =&gt; {
      // query 요청 충돌을 방지하기 위해 기존 요청을 취소한다.
      await queryClient.cancelQueries({ queryKey: [&quot;like&quot;, user?.id] });

      // prev 라는 이름으로 이전 상태가 될 현재 상태를 저장한다.
      const previousUserLikes = queryClient.getQueryData&lt;Tables&lt;&quot;post&quot;&gt;[]&gt;([
        &quot;like&quot;,
        user?.id,
      ]);

      // 서버 요청이 완료된 것처럼 UI 상태를 미리 업데이트한다.
      if (user?.id) {
        queryClient.setQueryData&lt;Tables&lt;&quot;post&quot;&gt;[]&gt;(
          [&quot;like&quot;, user.id],
          (old = []) =&gt; {
            if (isLike) {
              return old.filter((post) =&gt; post.post_id !== postId);
            } else {
              return [...old];
            }
          },
        );
      }

      return { previousUserLikes };
    },

      // 서버 요청에서 오류가 발생하면 아까 저장해둔 이전 상태로 다시 변경한다.
    onError: (err, variables, context) =&gt; {
      // 에러 시 유저의 좋아요 목록만 롤백
      if (user?.id) {
        queryClient.setQueryData([&quot;like&quot;, user.id], context?.previousUserLikes);
      }
    },

      // 요청이 완료되면 쿼리 무효화를 통해 최신 서버 상태와 클라이언트 캐시를 동기화한다.
    onSettled: () =&gt; {
      // 모든 관련 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: [&quot;like&quot;, user?.id] });
      // 좋아요 개수 업데이트 해주기
      queryClient.invalidateQueries({ queryKey: [&quot;like&quot;, postId] });
    },
  });

  return mutate;
};</code></pre>
<p>주석으로 낙관적 업데이트의 흐름을 다시 파악해보았다.
처음 낙관적 업데이트를 구현할 때 가장 처음에 cancelQueries가 왜 필요한지 몰랐는데 검색해보니까 캐시가 덮어쓰이거나 하는 등의 충돌을 방지하기 위함이라고 한다. 그래서 오류 방지로 고분고분하게 쓰는 중...</p>
<p>말로만 들었을 땐 어려워 보여서 내가 이런걸 할 수 있다고예..? 했는데 막상 해보니까 코드만 길지 크게 어렵지 않아서 여기저기 쓰이면 좋을 것 같은 부분에 자주 쓰고 있다. 좋아요 버튼 눌렀는데 한 1초 2초 지나서 하트가 반짝이는 것 보다 즉시 눈에 보이는 게 사용자에게 더 좋을테니까~
</br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JavaScript] setInterval과 setTimeout의 오차]]></title>
            <link>https://velog.io/@darong_/JavaScript-setInterval%EA%B3%BC-setTimeout</link>
            <guid>https://velog.io/@darong_/JavaScript-setInterval%EA%B3%BC-setTimeout</guid>
            <pubDate>Wed, 18 Dec 2024 11:09:59 GMT</pubDate>
            <description><![CDATA[<p>이번에 setInterval을 사용해서 타이머 기능을 구현하고 새롭게 알게된 사실이 있다.
setInterval과 setTimeout이 정확한 시간을 보장하지 않는다는 것!</p>
<br/>

<h3 id="🚩-setinterval과-settimeout의-오차">🚩 setInterval과 setTimeout의 오차</h3>
<p>앞서 작성했듯이 interval과 timeout은 특정 시간이 지난 후 실행되는 타이머 함수이지만 설정한 시간이 100% 지켜지지는 않는다. 1분마다 실행되는 interval을 설정했을 때 조금씩 시간이 밀린다는 것이다.</p>
<h4 id="왜-이런-문제가-발생하는-걸까">왜 이런 문제가 발생하는 걸까?</h4>
<p>문제 발생의 원인에 대해서는 자바스크립트의 동작 원리에 대해 알 필요가 있다.</p>
<blockquote>
<p><strong>자바스크립트는 싱글 스레드 언어다!</strong>
싱글 스레드는 한 번에 하나의 작업만 수행할 수 있다는 뜻.</p>
</blockquote>
<ul>
<li>모든 함수 호출은 <strong>콜스택(Call Stack)</strong>에 저장되고, 순차적으로 실행된다.</li>
<li>현재 실행 중인 작업이 끝나기 전에는 다음 작업을 처리할 수 없다.</li>
</ul>
<p>자바스크립트는 싱글 스레드로 동작하지만, 브라우저 내부 멀티 스레드인 Web API를 사용해 비동기 작업을 처리할 수 있다.</p>
<blockquote>
<p><strong>자바스크립트의 비동기 작업</strong>
호출된 함수는 콜스택에 저장
비동기 작업의 경우 Wep API에 전달한 뒤 백그라운드 작업이 완료되면 큐(Callback Queue)에 저장
콜스택이 비어있을 때 (진행 중인 작업이 없을 때) 큐에 있는 비동기 작업을 가져와 마무리</p>
</blockquote>
<pre><code class="language-javascript">console.log(&quot;Start&quot;);
&gt;
setTimeout(() =&gt; {
    console.log(&quot;Inside setTimeout&quot;);
}, 2000);
&gt;
console.log(&quot;End&quot;);</code></pre>
<ol>
<li><code>console.log(&quot;Start&quot;)</code> → 콜스택에서 실행</li>
<li><code>setTimeout</code> → Web API로 전달</li>
<li><code>console.log(&quot;End&quot;)</code> → 콜스택에서 실행</li>
<li>2초 후 콜백 큐에 콜백 추가</li>
<li>이벤트 루프가 콜스택이 비었는지 확인하고 콜백 실행<blockquote>
</blockquote>
따라서 출력 결과는 아래와 같다<pre><code class="language-sql">Start
End
Inside setTimeout</code></pre>
</li>
</ol>
<p>심지어는 같은 비동기 작업도 우선순위가 다르기 때문에 interval과 timeout이백그라운드 작업을 마치고 큐에서 대기중이더라도 콜스택에서 작업이 실행중이라면 추가적으로 기다리는 시간이 생겨 오차가 발생하는 것이다.</p>
<br/>

<h3 id="✅-어떻게-해결할-수-있을까">✅ 어떻게 해결할 수 있을까?</h3>
<h4 id="🚩-setinterval-대신-settimeout을-재귀적으로-호출하기">🚩 setInterval 대신 setTimeout을 재귀적으로 호출하기</h4>
<p>내가 사용한 방법이기도 한데, setInterval을 사용해 1초 주기를 설정하는 것보다 setTimeout을 재귀적으로 호출하되 Date.now()를 통해 기준 시간을 정하고 남은 오차를 계산하여 동적으로 실행 간격을 조정하는 것이 오차를 최소화할 수 있는 방법이었다.</p>
<blockquote>
<p><strong>변경 전 코드</strong></p>
</blockquote>
<pre><code class="language-tsx">useEffect(() =&gt; {
  if (!isWithinTimeRange || !currentSchedule || !timerState?.is_running) return;
&gt;
  const timerInterval = setInterval(() =&gt; {
    const totalElapsed = calculateElapsedTime(
      timerState.last_start,
      timerState.accumulated_time,
    );
    setTime(totalElapsed);
  }, 1000);
&gt;
  return () =&gt; clearInterval(timerInterval);
}, [timerState?.is_running, currentSchedule, isWithinTimeRange]);</code></pre>
<p>단순하게 setInterval을 사용하여 1초마다 경과 시간을 계산하는 로직을 만들었다.
이렇게 설정할 경우 콜스택에서 지연돠거나 작업이 길어질 경우 미세한 오차가 발생하고, 타이머 시간이 길어지면 길어질수록 오차가 커지게 된다.</p>
<blockquote>
<p><strong>변경 후 코드</strong></p>
</blockquote>
<pre><code class="language-tsx">  // 타이머 실행 중일 때 경과시간 계산하기
  useEffect(() =&gt; {
    if (!isWithinTimeRange || !currentSchedule || !timerState?.is_running)
      return;
&gt;
    let timerId: NodeJS.Timeout;
&gt;
    const tick = () =&gt; {
      const totalElapsed = calculateElapsedTime(
        timerState.last_start,
        timerState.accumulated_time,
      );
      setTime(totalElapsed);
&gt;
      const drift = Date.now() % 1000;
      timerId = setTimeout(tick, 1000 - drift);
    };
&gt;
    tick();
&gt;
    return () =&gt; clearTimeout(timerId);
  }, [timerState?.is_running, currentSchedule, isWithinTimeRange]);</code></pre>
<p>tick 이라는 타이머 함수를 만들어 경과 시간은 동일하게 계산한다.
<code>const drift = Date.now() % 1000;</code> 를 통해 현재 시간의 밀리초를 구한 뒤
<code>timerId = setTimeout(tick, 1000 - drift);</code> 에서 다음 1초까지 남은 시간을 계산해 정확히 1초 뒤 실행될 수 있도록 한다.</p>
<blockquote>
</blockquote>
<ul>
<li><code>Date.now = 12:34:56.789</code> 라면, <code>drift = 123456789 % 1000 = 789</code>
<code>1000 - drift</code> 는 즉 <code>1000 - 789</code>
따라서 남은 시간은 211ms 가 되고 211ms 후 다음 1초가 지나가는 시점에서 경과 시간을 다시 계산하게 된다.</li>
</ul>
<p>이벤트 루프의 개념에 대해 도움이 되는 블로그
<a href="https://inpa.tistory.com/entry/%F0%9F%94%84-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EA%B5%AC%EC%A1%B0-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC">이벤트 루프 구조와 원리</a>
<a href="https://ssocoit.tistory.com/249">setTimeout과 setInterval은 정확한 시간을 보장하지 않는다!</a>
<a href="https://velog.io/@fltxld3/JS-%EC%8B%B1%EA%B8%80%EC%8A%A4%EB%A0%88%EB%93%9C%EC%96%B8%EC%96%B4">싱글 스레드 언어</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기본형 데이터와 참조형 데이터]]></title>
            <link>https://velog.io/@darong_/%EA%B8%B0%EB%B3%B8%ED%98%95-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%99%80-%EC%B0%B8%EC%A1%B0%ED%98%95-%EB%8D%B0%EC%9D%B4%ED%84%B0</link>
            <guid>https://velog.io/@darong_/%EA%B8%B0%EB%B3%B8%ED%98%95-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%99%80-%EC%B0%B8%EC%A1%B0%ED%98%95-%EB%8D%B0%EC%9D%B4%ED%84%B0</guid>
            <pubDate>Thu, 28 Nov 2024 07:51:10 GMT</pubDate>
            <description><![CDATA[<h3 id="📝-데이터-타입의-종류">📝 데이터 타입의 종류</h3>
<blockquote>
<p><strong>기본형(원시형) 데이터</strong></p>
</blockquote>
<ul>
<li>숫자, 문자열, 불리언, null, undefind 등</li>
<li>불변성을 갖는 데이터</li>
</ul>
<blockquote>
<p><strong>참조형 데이터</strong></p>
</blockquote>
<ul>
<li>객체, 배열, 함수, 날짜, 정규식 등</li>
<li>가변성을 갖는 데이터</li>
</ul>
<h4 id="❓-기본형과-참조형의-구분-기준">❓ 기본형과 참조형의 구분 기준</h4>
<p>가변성과 불변성이란 무엇일까?
<code>var a = 2</code>
<code>a = 4</code>
이러면 값이 2에서 4로 변경될텐데, 그럼 이것도 변하는 데이터 아닌가?</p>
<p>데이터의 불변성 여부는 <strong>데이터 영역 메모리</strong>의 변경 가능성으로 판단한다.</p>
<br/>

<h3 id="📝-메모리와-데이터">📝 메모리와 데이터</h3>
<p><strong>컴퓨터의 모든 데이터는 바이트 단위의 식별자, 메모리 주소값을 통해 서로를 구분하고 연결할 수 있다</strong></p>
<blockquote>
<p><strong>식별자</strong> = 변수명
어떠한 데이터를 식별하는 데 사용하는 이름
<strong>변수</strong>
변할 수 있는 값(데이터)</p>
</blockquote>
<p>변수를 선언하고 할당하는 과정에서 메모리 영역은 어떤 작업을 수행하는지 간단하게 정리해봤다.</p>
<blockquote>
<p><code>var a = 2</code> 선언과 할당</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/3da35144-5876-44f2-9d7d-8fd52d7fd0ca/image.png" alt=""></p>
<blockquote>
<p><code>a = 4</code> 재할당</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/0cdeacca-4519-47b9-9ab3-764d70806dc4/image.png" alt=""></p>
<p>분명 a의 값은 2에서 4로 바뀌었지만 참조하는 값이 변경되었을 뿐, 기존 데이터를 수정하지 않고 새 값을 생성했기 때문에 불변성을 가져 기본형 데이터라고 한다. (숫자, 문자열, null 등등)</p>
<p>그럼 참조형 데이터는 어떨까?</p>
<blockquote>
<p>객체의 선언과 할당</p>
</blockquote>
<pre><code class="language-tsx">var obj = {
    a : 1,
    b : &quot;b&quot;
};</code></pre>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/aeae38a6-5d7e-4046-a0ed-a29b4e6a2695/image.png" alt=""></p>
<blockquote>
<p><code>obj.a = 2</code> 재할당</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/b0d9b18d-5282-4b18-a0c5-524abbe36a73/image.png" alt=""></p>
<p>재할당 후에도 obj가 참조하는 값은 데이터3 그대로이다. 결과적으론 데이터3의 값이 변경되었다는 것과 같은 뜻이라 가변성을 가져 참조형 데이터라고 한다.</p>
<br/>

<h3 id="📝-변수의-복사">📝 변수의 복사</h3>
<blockquote>
<p><strong>기본형 데이터 복사</strong>
<code>var a= 10</code>
<code>var b = a</code></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/e86e684f-2d22-4131-85b7-3d1b2a84d454/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>기본형 데이터의 경우 참조하는 값을 복사한다.</p>
<blockquote>
</blockquote>
<p><strong><code>a = 20</code> 재할당을 하면 어떻게 될까?</strong></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/890e4176-445c-4ff2-8574-3f8c765a35af/image.png" alt=""></p>
<blockquote>
</blockquote>
<p><code>a = 20</code>, <code>b = 10</code>, <code>a !== b</code>
기본형 데이터의 복사는 이렇게 이루어진다.</p>
<blockquote>
<p><strong>참조형 데이터 복사</strong>
<code>var obj1 = { a: 1, b: &quot;b&quot; }</code>
<code>var obj2 = obj1</code></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/104b8cc9-b43c-4acf-bcda-6b0655792a0a/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>참조형 데이터도 기본형과 같이 참조하는 값을 복사한다.</p>
<blockquote>
</blockquote>
<p><strong><code>obj1.a = 2</code> 재할당을 하면 어떻게 될까?</strong></p>
<blockquote>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/411c161d-f009-4f50-b545-2ae6c97f667a/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>기본형 데이터와 다르게 참조하는 값은 동일하나 참조하고 있는 값의 내부에서 값이 변경된다.
<code>obj1 = {a: 2, b: &quot;b&quot;}</code>, <code>obj2 = {a: 2, b: &quot;b&quot;}</code>
<code>obj1 === obj2</code>
참조형 데이터의 복사는 이렇게 이루어진다.</p>
<p>다만 위 예시의 <code>obj1.a = 2</code> 처럼 내부 프로퍼티를 변경하는 게 아닌, <code>obj1 = {a: 2, b: &quot;b&quot;}</code> 과 같이 변수(obj1)의 값을 직접 변경하는 경우 참조형 데이터임에도 참조하는 값이 변경된다.</p>
<p>이를 통해 참조형 데이터의 가변성은 변수의 값 자체를 변경하는 게 아닌, 내부 프로퍼티를 변경할 때에만 적용된다는 것을 알 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[최적화] 프로필 이미지 로딩 개선]]></title>
            <link>https://velog.io/@darong_/%EC%B5%9C%EC%A0%81%ED%99%94-%ED%94%84%EB%A1%9C%ED%95%84-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A1%9C%EB%94%A9-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@darong_/%EC%B5%9C%EC%A0%81%ED%99%94-%ED%94%84%EB%A1%9C%ED%95%84-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A1%9C%EB%94%A9-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Tue, 26 Nov 2024 07:52:24 GMT</pubDate>
            <description><![CDATA[<p>가장 최근에 개발한 최종 프로젝트 Smit을 진행하며 처음으로 사용성 테스트도 진행해보고 사용자 경험 개선을 위한 최적화를 경험해보았다.</p>
<p>그 과정에서 내가 담당했던 프로필 수정과 관련된 최적화 경험을 작성해보려고 한다.</p>
<br/>

<h3 id="프로필-이미지-로딩-개선">프로필 이미지 로딩 개선</h3>
<h4 id="💥-문제-상황">💥 문제 상황</h4>
<p>프로필 이미지를 사용하는 모든 페이지에서 프로필 이미지의 로딩이 과하게 길어지는 문제가 발생했다. 특히, 마이 페이지에서 사용하는 프로필 이미지의 경우 페이지 진입 시 가장 상단에서 제일 먼저 보이는 요소이기 때문에 빠른 해결이 필요한 상황이었다.</p>
<br/>

<h4 id="🔥-문제-파악">🔥 문제 파악</h4>
<p>어디서 문제가 발생하는지 찾기 위해 프로필 이미지를 사용하는 모든 컴포넌트를 확인한 결과, DB의 user 테이블에 이미지 주소가 아닌, 스토리지에 저장되어 있는 파일 이름을 저장하고 있었기 때문에 <strong>유저 이미지를 불러오기 위해 매번 getPublicUrl 요청을 하고 있는 것</strong>을 확인할 수 있었다.</p>
<pre><code class="language-tsx">  const profileImg = browserClient.storage
    .from(&quot;profile_img&quot;)
    .getPublicUrl(user?.profile_img ?? &quot;default&quot;).data.publicUrl;</code></pre>
<p>이는 프로필 이미지 변경 시, 스토리지에 파일을 저장한 후 주소를 요청하지 않고 user 테이블에 파일 이름만 저장하고 있었기 때문에 발생한 문제였다.</p>
<pre><code class="language-tsx">// 프로필 업데이트
export const updateUserProfile = async (name: string, img: string) =&gt; {
  const user = await fetchSessionData();
  if (!user) {
    throw new Error(&quot;로그인 상태가 아님&quot;);
  }

  await browserClient
    .from(&quot;user&quot;)
      // 여기에 들어가는 img가 주소가 아닌 파일 이름
    .update&lt;TablesUpdate&lt;&quot;user&quot;&gt;&gt;({ name, profile_img: img })
    .eq(&quot;id&quot;, user.id);
};</code></pre>
<br/>

<h4 id="✅-문제-해결">✅ 문제 해결</h4>
<p>user 테이블의 프로필 이미지의 기본 값을 기본 이미지 주소로 변경 후, 프로필을 수정할 때 스토리지에 저장한 파일의 주소를 받아 해당 주소를 저장하는 것으로 변경하여 불필요한 api 요청을 제거하여 로딩 속도를 개선할 수 있었다.</p>
<pre><code class="language-tsx"> const profileSaveHandler = async () =&gt; {
       // 이미지를 변경했을 경우
    if (fileInputRef.current?.files &amp;&amp; fileInputRef.current.files.length &gt; 0) {
        // 새 이미지를 스토리지에 저장
      const { error } = await browserClient.storage
        .from(&quot;profile_img&quot;)
        .upload(`${user?.id}`, fileInputRef.current.files[0], {
          upsert: true,
        });

      if (error) {
        return;
      }
        // 저장한 이미지의 주소 요청
      const url =
        browserClient.storage.from(&quot;profile_img&quot;).getPublicUrl(user.id).data
          .publicUrl +
        &quot;?t=&quot; +
        Date.now();
        // 실제로 프로필을 수정하는 함수로 이미지 주소 보내기
      updateProfile(url);
      return;
    }
    // 이미지 변경 안 하면 기존 주소로 전송
    updateProfile(user.profile_img);
  };</code></pre>
<br/>

<h4 id="📝-후속-처리--priority-와-loadingeager">📝 후속 처리 / priority 와 loading=eager</h4>
<p>모든 페이지에서 이미지 로딩 속도가 개선되었으나, 마이 페이지에서는 로딩이 바로 되었으면 좋겠어서 Next Image 태그에 속성을 추가하여 해결했다.</p>
<blockquote>
<p><strong>priority 속성</strong>
priority 속성은 Next.js 에서 제공하는 Image 컴포넌트의 속성으로 특정 이미지를 초기 페이지 로딩시 우선적으로 로드하도록 설정하는 속성이다.
이 속성이 적용된 이미지는 preload 태그를 통해 브라우저에 우선 로딩 요청을 보내어 브라우저가 해당 이미지를 우선적으로 로드할 수 있도록 한다.</p>
</blockquote>
<p>비슷하게 HTML 기본 속성인 loading=&quot;eager&quot; 설정이 있는데 두 속성은 차이가 존재하며 Next 에서도 prioriry 속성 사용을 권장하고 있다.</p>
<blockquote>
<p><strong>priority 속성과 loading=&quot;eager&quot;의 차이</strong>
loading eager 속성은 이미지를 즉시 로드하도록 설정하는 HTML 표준 속성이다. </p>
</blockquote>
<ul>
<li>priority는 Next.js의 이미지 최적화 메커니즘과 함께 작동하기 때문에, 성능을 유지하면서도 중요한 이미지를 우선적으로 로드할 수 있다. 반면, loading=&quot;eager&quot;는 브라우저의 기본 동작을 바꾸는 것일 뿐, 최적화 기능이 없어서 예상치 못한 성능 저하가 발생할 가능성이 있다.</li>
</ul>
<p>웹 성능 지표 중 하나인 <strong>LCP(사용자가 페이지를 로드할 때 가장 큰 콘텐츠가 화면에 렌더링되는 시간)</strong>는 사용자 경험에 있어 아주 중요한 부분인데, priority 속성을 추가함으로써 해당 부분을 많이 개선할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[나는 어떤 회사에 가고 싶을까?]]></title>
            <link>https://velog.io/@darong_/%EB%82%98%EB%8A%94-%EC%96%B4%EB%96%A4-%ED%9A%8C%EC%82%AC%EC%97%90-%EA%B0%80%EA%B3%A0-%EC%8B%B6%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@darong_/%EB%82%98%EB%8A%94-%EC%96%B4%EB%96%A4-%ED%9A%8C%EC%82%AC%EC%97%90-%EA%B0%80%EA%B3%A0-%EC%8B%B6%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Mon, 25 Nov 2024 13:32:50 GMT</pubDate>
            <description><![CDATA[<p>**
[서론] 취업 준비를 시작하는 나의 이야기**</p>
<br/>

<p>얼마 전, 부트캠프를 끝내고 사회로 나아가기 위한 첫걸음을 시작했다.</p>
<blockquote>
<p>이제는 더 이상</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/a8c438a6-1e80-4109-85c4-d7ab913126c0/image.png" alt=""></p>
<p>진짜로 물러날 곳이 없어졌다. 나는 취업을 해야하니까...</p>
<p>취업 준비를 시작하기 전에 문득, 신입은 여기저기서 쏟아져 나올텐데 과연 내가 취업을 빠르게 할 수 있을까? 라는 생각이 들었다. 나는 비전공자에 학력도 부족하고 실력적으로 뛰어나냐 물으면 그것도 아닌 것 같고... 협업 경험이 아주 많냐 당연히 그것도 아니고... 외국어를 특별히 잘하는 것도 아니다.</p>
<p>이런 내가 어떻게 하면 취업을 잘 할 수 있을까? 가만히 고민해봤다.</p>
<br/>

<p><strong>내가 좋아하는, 성장할 수 있는 분야를 먼저 찾아보자!</strong></p>
<p>흔히들 말하는 <code>가고 싶은 회사</code> 를 먼저 찾아보자는 게 내 결론이었다.</p>
<p>일을 할 때, 목표가 없이 하진 않는다. 명예, 돈, 자기만족 등등... 사람들이 일을 하는데에는 많은 이유가 있겠지만 그 중에서도 내가 일을 하는 것에 가장 큰 영향을 주는 건 <code>흥미와 즐거움</code> 이라고 생각했다.</p>
<p>실제로 나는 재미있는 일을 할 때 효율이 최대로 올라가는 편이고 지금까지 많은 걸 공부해봤지만(영상, 3D 모델링, 디자인, 미용 진짜 많기도 함) 개발만큼 재미있는 건 처음이었다.</p>
<p>그치만 개발 자체가 재미있을 뿐, 어떤 특정한 회사에 가고 싶다 하는 목표가 없다. 같은 직업을 갖더라도 어떤 회사에 가냐에 따라서 하는 일은 달라지기 마련이다. 일만 달라지냐? 내가 배워야할 것들도 달라지기 마련이다. 어차피 어딜 가던 나는 새로운 걸 배우고 공부해야할텐데, 그럼 대체 나는 어떤 걸 배우고 싶을까? 하는 생각을 하며 산업 분야에 대해 찾아보았다.</p>
<br/>
<br/>



<h3 id="📚-도메인-지식이란">📚 도메인 지식이란?</h3>
<p>특정한 전문화된 학문이나 분야의 지식으로, 어떠한 시스템이 운영되는 환경에 대한 지식을 말한다.
시스템의 산업, 서비스, 제품의 이해, 사용자에 대한 이해까지도 포함될 수 있다.
<br/></p>
<p>그럼 나는 어떤 도메인 지식을 쌓아야할까? 이걸 알기 위해서는 기업이 어떤 분야로 나누어 지는지, 어떤 기술을 중점적으로 사용하는지에 대해 알아야할 필요가 있다.
<br/></p>
<h3 id="✅-산업-분야의-종류">✅ 산업 분야의 종류</h3>
<p>산업 분야는 아주 다양하고 세분화 되어있지만 대략적으로 나누면 아래와 같다.</p>
<ol>
<li>이커머스 / 쇼핑몰</li>
<li>핀테크 / 금융</li>
<li>의료 / 헬스케어 </li>
<li>게임</li>
<li>미디어 및 엔터테인먼트</li>
<li>보안</li>
<li>교육 기술</li>
<li>포털 / 소셜미디어</li>
</ol>
<p>플랫폼은 너무 광범위해서 제외했다.</p>
<br/>
<br/>


<h3 id="⭐-각-분야의-중점-기술">⭐ 각 분야의 중점 기술</h3>
<ul>
<li><h4 id="이커머스">이커머스</h4>
사용자  경험 중시, 검색 최적화, 결제 서비스, 배송 시스템</li>
<li><h4 id="핀테크">핀테크</h4>
서비스의 디지털화, 보안 시스템</li>
<li><h4 id="헬스케어">헬스케어</h4>
데이터 시각화, 의료 기기와 서비스의 통합 또는 동기화</li>
<li><h4 id="게임">게임</h4>
실시간 통신, 애니메이션 등</li>
<li><h4 id="미디어-및-엔터테인먼트">미디어 및 엔터테인먼트</h4>
콘텐츠 스트리밍 UI, 사용자 분석 및 맞춤형 서비스</li>
<li><h4 id="보안">보안</h4>
데이터 암호화 및 보안, 인증 UI 등</li>
<li><h4 id="교육-기술">교육 기술</h4>
학습 관리 시스템, 강의 스트리밍 UI, 학습 데이터 시각화 등</li>
<li><h4 id="포털소셜미디어">포털/소셜미디어</h4>
피드 및 실시간 알림, 대규모 데이터 처리 등</li>
</ul>
<br/>
<br/>


<p><a href="https://f-lab.kr/insight/importance-of-domain-knowledge-in-developer-career">참고하면 좋은 글</a></p>
<p>나는 원래 게임을 너무 좋아해서 게임 회사에 취직하는 게 목표였는데(그냥 내가 재밌을 것 같았음) 생각해보니 게임 개발을 직접 할 수 있는 게 아니니까 다른 분야를 공부해보는 것도 좋지 않을까 하는 생각이 들었다.
다들 입이 닳도록 말하는 네카라쿠배당토도 물론 꿈의 기업이지만 나는 좀 더 주도적으로 개발할 수 있는 기회가 있었으면 한다. 요즘은 RN에도 관심이 좀 생겨서 배워볼까 싶고... 프론트 개발을 배워보니까 하고 싶은 게 많아져서 큰일이다.</p>
<p>내일부턴 최종 프로젝트 코드를 정리하고, 실제 구인글을 찾아보며 선호하는 기술 스택을 정리하는 시간을 가져볼 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 랭킹 시스템 점수 집계시 달성자 리스트가 null로 뜸]]></title>
            <link>https://velog.io/@darong_/Next.js-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%90%EC%88%98-%EC%A7%91%EA%B3%84%EC%8B%9C-%EB%8B%AC%EC%84%B1%EC%9E%90-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EA%B0%80-null%EB%A1%9C-%EB%9C%B8</link>
            <guid>https://velog.io/@darong_/Next.js-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%A0%90%EC%88%98-%EC%A7%91%EA%B3%84%EC%8B%9C-%EB%8B%AC%EC%84%B1%EC%9E%90-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EA%B0%80-null%EB%A1%9C-%EB%9C%B8</guid>
            <pubDate>Thu, 07 Nov 2024 11:54:11 GMT</pubDate>
            <description><![CDATA[<h3 id="💥-일정-종료시-랭킹-점수-집계를-위한-함수가-계속해서-0을-반환하는-오류">💥 일정 종료시 랭킹 점수 집계를 위한 함수가 계속해서 0을 반환하는 오류</h3>
<br/>


<h3 id="문제-상황">문제 상황</h3>
<ul>
<li>일정 종료 시점에서 그룹 랭킹 점수를 계산하기 위해 계산기 함수를 실행하는데 점수 집계가 정상적으로 동작하지 않음.</li>
<li>계속해서 0점으로 표시되어 집계가 됨. (조건을 충족했음에도)<br/>

</li>
</ul>
<h3 id="원인-분석">원인 분석</h3>
<ol>
<li>함수가 실행되는지 console 확인</li>
<li>계산기 함수 내에 전달되는 인자가 제대로 된 값인지 확인</li>
</ol>
<pre><code>        const score = calculateScore(member.length, achievers, {
          start_time: schedule.start_time,
          end_time: schedule.end_time,
        });
        setStudyScore(score);</code></pre><br/>

<h3 id="해결-방안과-선택-이유">해결 방안과 선택 이유</h3>
<ul>
<li>console 출력 결과 함수 인자 중 achievers 가 null로 반환되는 것을 확인
  <img src="https://velog.velcdn.com/images/darong_/post/a7de1ab6-889c-4a05-b793-8fd7353b812a/image.png" alt=""></li>
</ul>
<ul>
<li><p>쿼리 무효화 후 계산 함수를 실행하는데 이 부분에서 쿼리 무효화 이후 데이터 받아오기 전에 계산기 함수가 실행되는 것으로 파악됨</p>
<pre><code class="language-tsx">  const handleScheduleEnd = async (schedule: Tables&lt;&quot;calendar&quot;&gt;) =&gt; {
      try {
        console.log(&quot;스케쥴&quot;, schedule);
        // 1. 달성자 목록 새로 가져오기
        await queryClient.invalidateQueries({
          queryKey: [&quot;achievers&quot;, studyId, schedule.calendar_id],
        });</code></pre>
</li>
<li><p>invalidateQueries 말고 refetch 옵션을 사용하여 확실하게 최신 데이터를 패칭 후 계산 함수가 동작할 수 있도록 처리.</p>
<pre><code class="language-tsx">    const handleScheduleEnd = async (schedule: Tables&lt;&quot;calendar&quot;&gt;) =&gt; {
      try {
        const { data: achievers = null, error } = await refetchAchievers();
        if (error) {
          console.error(&quot;Achiever refetch error:&quot;, error);
          return;</code></pre>
</li>
<li><p>해당 과정을 진행하여 계산 함수가 정상적으로 동작하고 일정 종료 후 랭킹 점수가 집계되는 것을 확인했습니다.</p>
<br/>

</li>
</ul>
<h3 id="성과-및-교훈">성과 및 교훈</h3>
<ul>
<li>refetch 를 많이 사용을 해보지 않아서 새 데이터가 필요할 때 무조건 invalidateQueries 를 사용하고 있었는데 해당 오류를 통해 invalidateQueries 와 refetch 사용해야하는 경우의 차이점에 대해 느낄 수 있었습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] setInterval을 사용한 타이머 구현]]></title>
            <link>https://velog.io/@darong_/Next.js-setInterval%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%9C-%ED%83%80%EC%9D%B4%EB%A8%B8-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@darong_/Next.js-setInterval%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%9C-%ED%83%80%EC%9D%B4%EB%A8%B8-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 01 Nov 2024 02:01:54 GMT</pubDate>
            <description><![CDATA[<h3 id="🖥️-supabase-setinterval-타이머-기능-구현하기">🖥️ supabase, setInterval 타이머 기능 구현하기</h3>
<p>최종 프로젝트에 들어가는 타이머 기능을 구현하기 위해 useEffect, setInterval을 사용했다.</p>
<h4 id="✅-간단-설명">✅ 간단 설명</h4>
<blockquote>
<p><strong>useEffect</strong></p>
</blockquote>
<ul>
<li>컴포넌트가 렌더링 완료된 시점에서 실행되는 리액트 훅.</li>
<li>의존성 배열에 값이 있을 경우 해당 값이 변경되었을 때만 실행됨.</li>
<li>의존성 배열이 빈 배열일 경우 컴포넌트 최초 렌더링 시에만 실행됨.</li>
<li>useEffect 안에 실행될 interval을 넣고 타이머의 재생 상태가 변경될 때마다 실행되도록 설정함</li>
</ul>
<blockquote>
<p><strong>interval</strong></p>
</blockquote>
<ul>
<li>지정된 시간마다 특정 로직을 반복하게 하는 함수</li>
<li>타이머가 재생 상태일 때 1초마다 경과시간을 계산하는 함수를 넣어 UI에 표시되도록 설정함.</li>
</ul>
<blockquote>
<p><strong>supabase 테이블 구조</strong></p>
</blockquote>
<ul>
<li><img src="https://velog.velcdn.com/images/darong_/post/46ae0eec-66f3-448c-b793-8a0fb1e5dcaa/image.png" alt=""></li>
<li>user, study id 저장 =&gt; 어디 스터디인지 누구 타이머인지 확인</li>
<li>date =&gt; 오늘의 타이머인지 확인</li>
<li>last_start, last_paused =&gt; 마지막 시작, 정지 시간 확인 (경과시간 계산용)</li>
<li>accumulated_time =&gt; 누적시간</li>
</ul>
<h4 id="✅-주요-로직">✅ 주요 로직</h4>
<ol>
<li>실제로 타이머가 1초씩 들어나도록 ui 반영</li>
</ol>
<pre><code class="language-tsx">    const timerInterval = setInterval(() =&gt; {
      const totalElapsed = calculateElapsedTime(
        timerState.last_start,
        timerState.accumulated_time,
      );
      setTime(totalElapsed);
    }, 1000);</code></pre>
<ul>
<li>해당 interval은 타이머가 재생중일 때만 실행됨.</li>
<li>1초마다 경과시간을 재계산하여 time 상태를 set하고 있음 =&gt; 1초마다 ui 변경</li>
</ul>
<br/>

<ol start="2">
<li>캘린더에 등록된 시간이 지나면 자동으로 정지</li>
</ol>
<pre><code class="language-tsx">      const timeCheckInterval = setInterval(() =&gt; {
      const isValid = checkTimeRange(currentSchedule);
      setIsWithinTimeRange(isValid);
      if (!isValid) {
        updateTimerState(&quot;pause&quot;);
      }
    }, 1000);</code></pre>
<ul>
<li>마찬가지로 타이머가 재생중일 때만 실행됨.</li>
<li>1초마다 현재 시간과 캘린더에 등록된 시간을 비교하여 지금이 스터디 시간인지 확인</li>
<li>만약 시간 범위를 벗어나면 즉시 타이머 종료</li>
</ul>
<br/>

<ol start="3">
<li>경과 시간 계산 함수</li>
</ol>
<pre><code class="language-tsx">  const calculateElapsedTime = (
    lastStartTime: string | null,
    accumulatedTime: number,
  ) =&gt; {
    if (!lastStartTime) return accumulatedTime;

    // UTC 시간을 기준으로 시간차 계산
    const start = Date.parse(lastStartTime);
    const now = Date.now();
    return accumulatedTime + Math.floor((now - start) / 1000);
  };</code></pre>
<ul>
<li>계산이 필요한 시점에(첫 렌더링 타이머 세팅, interval 계산) 호출하여 사용.</li>
<li>시작 시간, 누적 시간을 인자로 받음</li>
<li>시작 시간이 없을 때 =&gt; 일시정지 상태일때 지금까지 누적 시간을 반환</li>
<li>시작 시간이 있을 때 =&gt; 재생 상태일때 누적시간 + (현재-시작)(경과시간) 계산하여 반환</li>
</ul>
<h4 id="✅-결과">✅ 결과!</h4>
<p><img src="https://velog.velcdn.com/images/darong_/post/39feb2f2-5b52-481b-94af-ba2d5fc7d20c/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[timestamp without time zone / with time zone]]></title>
            <link>https://velog.io/@darong_/timestamp-without-time-zone-with-time-zone</link>
            <guid>https://velog.io/@darong_/timestamp-without-time-zone-with-time-zone</guid>
            <pubDate>Thu, 31 Oct 2024 01:15:20 GMT</pubDate>
            <description><![CDATA[<h3 id="❓-time-zone">❓ time zone?</h3>
<p>이번에 타이머 기능을 구현하면서 supabase DB에 있는 timestamp 타입의 데이터를 많이 다루게 되었는데, 그 과정에서 time zone에 대해 대수롭지 않게 여겼다가 황당한 오류로 하루를 날렸다.</p>
<p>테이블 컬럼 타입을 변경하면서 오류는 해결되었지만, 정확히 without time zone과 with time zone의 차이를 이해하고자 블로그를 작성해보려고 한다.</p>
<p>대충 차이는 알겠는데, DB 상에서 timestamp와 timestampz이 동일한 형식으로 출력이 되고 있어서 아예 눈치채지 못하고 있었다.</p>
<br/>

<h3 id="🥹-그래서-그게-뭔데">🥹 그래서 그게 뭔데?</h3>
<blockquote>
<p>** timestamp without time zone**</p>
</blockquote>
<ul>
<li>시간대 정보를 포함하지 않는 타임스탬프 형식</li>
<li>입력한 시간 값이 그대로 저장</li>
<li>시간대 변환 없이 시간만 저장하고자 할 때 사용, 같은 데이터베이스를 다른 시간대에서 조회하더라도 동일한 시간 값으로 나타남</li>
<li>ex) 2023-10-31 15:00:00</li>
</ul>
<blockquote>
<p>** timestamp with time zone**</p>
</blockquote>
<ul>
<li>시간대 정보를 포함한 타임스탬프 형식</li>
<li>입력한 시간 값이 UTC로 변환되어 저장</li>
<li>조회 시 클라이언트 세션의 시간대에 맞춰 자동 변환</li>
<li>글로벌 애플리케이션에서 각 사용자의 시간대에 맞는 시간 정보를 저장하거나 보여줄 때 유용</li>
<li>ex) 2023-10-31 15:00:00+09 (한국 시간대에서 입력한 경우)</li>
</ul>
<p>예시를 들어 설명하자면
한국 시간대(UTC+9)에서 2023-10-31 15:00:00을 저장한다고 할 때
<strong>timestamp without time zone</strong> : 2023-10-31 15:00:00
<strong>timestamp with time zone</strong> : UTC로 변환되어 2023-10-31 06:00:00으로 저장, 조회 시 자동 변환.</p>
<blockquote>
<p><strong>new Date()</strong>
자바스크립트의 new Date() 함수는 현재 사용자의 시간을 타임존을 포함하여 반환.
<strong>new Date().toISOString()</strong>
toISOString 메서드는 타임존 시간을 UTC 기준의 string으로 변환.</p>
</blockquote>
<p>supabase에 timestampz타입에 데이터를 넣을 때 문자열 타입으로 넣어야해서 toISOString() 변환 후 insert 하면 불러올 때 자동으로 변환이 되어서 문제없이 사용할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[10/28 (월) 개발 일지]]></title>
            <link>https://velog.io/@darong_/10-28-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</link>
            <guid>https://velog.io/@darong_/10-28-%EA%B0%9C%EB%B0%9C-%EC%9D%BC%EC%A7%80</guid>
            <pubDate>Tue, 29 Oct 2024 00:59:03 GMT</pubDate>
            <description><![CDATA[<p>오늘은 뭔가 지치는 날이라 거창하게 글은 못쓸 것 같고 뭐 했는지 적는 정도로 기록...</p>
<h4 id="회원-탈퇴-완성">회원 탈퇴 완성</h4>
<p>회원 탈퇴 기능이 드디어 완성됐다.</p>
<pre><code class="language-tsx">  const deleteUserHandler = async () =&gt; {
    setIsModalOpen(false);
    await deleteUser();
    await queryClient.invalidateQueries({ queryKey: [&quot;user&quot;, &quot;session&quot;] });
    router.push(&quot;/&quot;);
  };</code></pre>
<p>기존에 탈퇴 후 유저 정보가 남고 로그아웃 처리가 안되어서 조금 꼬이는 문제가 있었는데, 삭제 =&gt; 로그아웃 =&gt; 세션 정보 초기화 =&gt; 메인 페이지 이동 이렇게 변경함으로써 깔끔하게 해결되었다!</p>
<p>이렇게 기능 하나, 문제 하나 해결할 때 마다 대단한 게 아니어도 내 실력이 차근차근 늘어가고 있다는 기분이 들어서 뿌듯하고 기쁘다. 생각보다 회원 탈퇴에 대한 글? 같은 것도 많이 없어서 공식 문서랑 블로그 하나 보고 한땀한땀 짰는데...🥹</p>
<h4 id="타이머-기능-구상">타이머 기능 구상</h4>
<p>제일 걱정이 많았던 타이머 기능. 마이 페이지를 최대한 빨리 끝내려고한 것도 다 이것 때문이다.</p>
<ol>
<li>각 strudy 마다 캘린더에 일정을 등록하면 캘린더 테이블에 날짜, 시작 시간, 종료 시간이 등록됨</li>
<li>만약 study 일정이 있는 날이라면 유저에게 타이머가 보임</li>
<li>타이머는 시작과 일시정지가 가능하고 0부터 시작하는 누적시간형</li>
<li>스터디의 일정에 등록된 시간에만 활성화됨</li>
<li>타이머는 유저가 개인으로 갖고 스터디 시간을 기록할 수 있음</li>
<li>새로고침하거나 다른 페이지에 다녀와도 타이머 정보가 유지되어야함</li>
<li>스터디의 일정 시간이 끝나면 자동으로 종료됨 -&gt; 종료 될 때 유저 테이블에 스터디 타임으로 저장(누적 시간으로 쌓을 것임)</li>
<li>하루가 지나면 다시 0으로 초기화</li>
</ol>
<p>구현 사항은 이정도가 있는데 직접 어떻게 구현해야하나 한줄씩 적으면서 보니까 크게 어렵진 않아보이고... 제일 복병은 supabase 리얼타임 테이블 구독? 사용법이랑 시간 계산 로직이 아닐까 싶다.</p>
<pre><code>// 일단 오늘 날짜 계산해서 캘린더에 eq 스터디 아이디, 날짜 찍어서 일정 있는지 확인하기 =&gt; 없으면 없다고
// 일정이 있으면 타이머 조회하기 =&gt; 타이머에 유저, 스터디, 날짜 있으면 그거 불러오고 없으면 타이머 그냥 0
// 현재 시간이 캘린더 일정 시간 사이인지 확인하기 =&gt; 해당 시간이면 시작 / 일시정지 활성화

// 타이머 처음 누를 때
// 처음 시간은 0 =&gt; 시작 누르면 creat_at 에 현재 시간 넣고 타이머 0부터 시작하기 =&gt; 이즈러닝 트루 변경
// 중간에 멈추면 라스트 업데이트에 현재 시간 넣고 이즈러닝 펄스
// 다시 시작하면 라스트 스타트에 현재 시간 넣고 이즈러닝 트루

// 이미 시작했었던 타이머
// 러닝 false인 경우 생성 시간 ~ 라스트 업데이트 로 경과 시간 계산
// 러닝 true인 경우 생성 시간 ~ 라스트 업데이트 + 라스트 스타트 ~ 현재 시간 더해서 경과 시간 계산</code></pre><p>내일부터는 이렇게 적어둔 메모를 한줄씩 코드로 만들어볼 생각이다. 늦어도 수요일에는 완성이 되어서 미완성된 다른 기능들 시작하고 싶은데 열심히 하면 되겠지?!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] supabase 회원 탈퇴 기능 구현하기]]></title>
            <link>https://velog.io/@darong_/Next.js-supabase-%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@darong_/Next.js-supabase-%ED%9A%8C%EC%9B%90-%ED%83%88%ED%87%B4-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 28 Oct 2024 03:07:57 GMT</pubDate>
            <description><![CDATA[<h3 id="🖥️-supabase-회원-탈퇴-구현하기-route-handler">🖥️ supabase 회원 탈퇴 구현하기 (route handler)</h3>
<p>supabase 회원 탈퇴를 위해서는 service role key를 사용하는 클라이언트를 생성하여 API요청을 해야하는데, 이 service role key는 노출이 되면 안되는 키라 서버에서 처리를 해야한다.</p>
<p>Next.js 는 간단한 백엔드 로직을 구현할 수 있도록 route handler 라는 기능을 제공하고 있으니, 이 방법을 사용해서 회원탈퇴를 구현했다.</p>
<br/>


<h4 id="🚩-auth-client-생성하기">🚩 Auth Client 생성하기</h4>
<p><a href="https://supabase.com/docs/reference/javascript/admin-api">사용법에 대한 공식문서</a></p>
<p>먼저, supabase auth 스키마에 있는 user 정보를 변경하거나 삭제하기 위해서는 admin이라는 api 요청을 해야한다. 이 요청은 위에 적었듯이 서비스 롤 키로 사용이 가능하고, admin 요청을 위한 클라이언트를 따로 만들어야하기 때문에 이 과정을 첫번째로 실행해야 한다.</p>
<p>처음엔 어떻게 설정하라는 건지, 꼭 해야하는 건지 몰라서 헤멨는데 <a href="https://mnmhbbb.tistory.com/m/577">이 블로그</a>에서 도움을 많이 받았다.</p>
<pre><code class="language-tsx">export function createAuthClient() {
  const cookieStore = cookies();
  // Create a server&#39;s supabase client with newly configured cookie,
  // which could be used to maintain user&#39;s session
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    // env.local 에다가 key 넣어 놓기
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&gt;
              cookieStore.set(name, value, options),
            );
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    },
  );
}</code></pre>
<p>이미 createClient 를 많이 사용하고 있어서 false true를 넣진 못했고, 그냥 AuthClient 라고 따로 분리해서 만들었다. key 부분에 서비스 롤 키를 넣어주면 사용할 수 있다.</p>
<br/>

<h4 id="🚩-route-handler-설정">🚩 route handler 설정</h4>
<p>회원 탈퇴 기능 구현에서 제일 중요한 부분이다!!</p>
<p>app/api/delete-user/route.ts 해당 경로에 파일을 만들었다.</p>
<p>그리고 사용할 땐</p>
<pre><code class="language-tsx">// 회원 탈퇴 라우트 핸들러 사용
export const deleteUser = async () =&gt; {
  const res = await fetch(&quot;/api/delete-user&quot;, {
    method: &quot;DELETE&quot;,
  });
  const data = await res.json();

  if (res.ok) {
    console.log(data.message);
  } else {
    console.error(data.error);
  }
};</code></pre>
<p>저렇게 경로와 메서드를 지정해주면 된다.</p>
<p>DELETE 요청이니까 라우트 핸들러 안에서도 DELETE 로직을 작성해주어야 한다.</p>
<pre><code class="language-tsx">export async function DELETE() {
  // 서비스 롤 키 넣어서 만든 Auth 클라이언트
  const supabase = createAuthClient();
  // 회원 탈퇴 요청시 user ID가 필요해서 gerUser 요청
  const {
    data: { user },
    error: userError,
  } = await supabase.auth.getUser();

  // user가 없거나 에러가 발생하면 에러 메세지를 반환한다
  if (userError || !user) {
    console.error(&quot;Authentication error:&quot;, userError);
    // 이게 아까 요청한 deleteUser 의 반환값으로 날아감
    return NextResponse.json(
      { message: &quot;User not authenticated&quot; },
      { status: 401 },
    );
  }</code></pre>
<p>이 부분에서 헷갈렸던게, 어디서 getSession을 사용하라고 해서 getSession을 넣었는데 </p>
<blockquote>
<p>Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and many not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.</p>
</blockquote>
<p>이런 경고 문구가 떴다. 무슨 말인가 돌려보니</p>
<blockquote>
<p>supabase.auth.getSession()이나 supabase.auth.onAuthStateChange() 이벤트에서 반환되는 user 객체를 그대로 사용하는 것은 보안에 취약할 수 있습니다. 대신 supabase.auth.getUser()를 사용하면, Supabase Auth 서버에 직접 데이터를 요청해 사용자 정보를 인증하게 되어 보안이 강화됩니다.</p>
</blockquote>
<p>그래서 getUser로 바꿨다... 그런데 저번에 getUser, getSession 차이 찾아봤을 때도 서버에서는 getUser, 클라이언트에서는 getSession 사용을 권장한다고 나왔는데, 나는 어디서 getSession을 사용하라는 말을 본걸까...?</p>
<p>아무튼 메인적으로 요청이 되는 부분은 저 로직이고, 그 후에 에러나 완료 메세지를 위해 추가적으로 코드를 덧붙였다.</p>
<pre><code class="language-tsx">  try {
    console.log(&quot;Attempting to delete user:&quot;, user.id);
    const { error } = await supabase.auth.admin.deleteUser(user.id);

    if (error) {
      console.error(&quot;Error deleting user:&quot;, error);
      return NextResponse.json({ message: error.message }, { status: 500 });
    }

    return NextResponse.json(
      { message: &quot;User deleted successfully&quot; },
      { status: 200 },
    );
  } catch (err) {
    console.error(&quot;Unexpected error:&quot;, err);
    return NextResponse.json(
      { message: &quot;An unexpected error occurred&quot; },
      { status: 500 },
    );
  }
}</code></pre>
<p>실제로 삭제가 요청되는 부분은 try catch로 작성했고, 오류 상황에 따라 다른 메세지를 출력하도록 했다. 오류 없이 작업이 끝나면 200 응답이 날아간다.</p>
<br/>

<h4 id="💥-delete-httplocalhost3000apidelete-user-500-internal-server-error">💥 DELETE <a href="http://localhost:3000/api/delete-user">http://localhost:3000/api/delete-user</a> 500 (Internal Server Error)</h4>
<p>로직도 잘 작성했고, 콘솔도 찍히는데 회원 탈퇴가 안되고 해당 오류가 반환됐다. 사실 이 오류를 보고나서 어디서 문제가 생기는 건지 확인하기 위해 위에서 에러 응답을 추가한건데</p>
<blockquote>
<p>Error deleting user: AuthApiError: Database error deleting user</p>
</blockquote>
<p>다시 시도했을 때 에러메세지가 이렇게 출력이 됐다. Error deleting user 부분 메세지인걸 보니 삭제 요청은 제대로 전달됐지만 삭제하는 과정에서 오류가 생긴 것으로 보인다.</p>
<p>여기저기 검색해보니, 외래키에 대한 얘기와 CASCADE 설정을 확인하라는 말이 보여서 supabase 내에서 auth 스키마 user와 연결된 테이블을 확인했다.</p>
<pre><code class="language-sql">SELECT
    tc.table_schema, 
    tc.table_name, 
    kcu.column_name, 
    ccu.table_schema AS foreign_table_schema,
    ccu.table_name AS foreign_table_name, 
    ccu.column_name AS foreign_column_name 
FROM 
    information_schema.table_constraints AS tc 
JOIN 
    information_schema.key_column_usage AS kcu
ON 
    tc.constraint_name = kcu.constraint_name
JOIN 
    information_schema.constraint_column_usage AS ccu
ON 
    ccu.constraint_name = tc.constraint_name
WHERE 
    constraint_type = &#39;FOREIGN KEY&#39; 
    AND ccu.table_name = &#39;users&#39;
    AND ccu.table_schema = &#39;auth&#39;;</code></pre>
<p>sql 에디터에 해당 코드를 실행시키면 어떤 테이블이 외래키로 연결되어 있는지 나오는데 해당 테이블에 가서 설정을 확인해보니 정말 CASCADE 적용이 안되어 있었다... 설정 하니까 바로 오류없이 회원 삭제가 진행되었다ㅎ;</p>
<p>지금껏 프로젝트에서 auth와 관련된 기능은 한 번도 해본 적이 없는데, 이번 기회에 회원 탈퇴를 구현해보면서 이것저것 알아가는 게 많아서 너무 좋았다!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Hook 반환 값에 undefined가 포함되는 이유]]></title>
            <link>https://velog.io/@darong_/Next.js-Hook-%EB%B0%98%ED%99%98-%EA%B0%92%EC%97%90-undefined%EA%B0%80-%ED%8F%AC%ED%95%A8%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@darong_/Next.js-Hook-%EB%B0%98%ED%99%98-%EA%B0%92%EC%97%90-undefined%EA%B0%80-%ED%8F%AC%ED%95%A8%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 24 Oct 2024 14:39:58 GMT</pubDate>
            <description><![CDATA[<h4 id="💥-react-hook의-반환값에-undefined가-포함되는-이유">💥 React Hook의 반환값에 undefined가 포함되는 이유</h4>
<p>현재 로그인한 사용자가 해당 게시글에 좋아요를 눌렀는지 여부를 파악하기 위해 클라이언트 컴포넌트에서 훅으로 getSession 요청을 했다.</p>
<pre><code class="language-tsx">  // LikeButton.tsx
  // 지금 로그인한 유저 정보
  const { data: user } = useSession();
  // 현재 포스트에 좋아요를 누른 유저
  const { data: likes } = usePostLikers(postId);
  // 좋아요 누른 유저 목록에 유저가 있으면 true 없으면 false
  const isLike = likes?.some((post) =&gt; post.like_user === user?.id) || false;
  // 좋아요 낙관적 업데이트 하는 뮤테이션 불러오기
  const likeButtonHandler = useToggleLikeButton(user, postId, isLike);</code></pre>
<p>그런데 뮤테이션을 호출할 때 user에서 타입 오류가 발생했다.
<img src="https://velog.velcdn.com/images/darong_/post/bdca9843-8c1f-4f75-87d8-75c6c72989b4/image.png" alt=""></p>
<p>분명 fetch 함수에서도 null, User만 반환하고 있고 useSession Query에서도 명시적으로 User||null 타입을 반환하고 있는데 <code>const { data: user } = useSession();</code> 해당 부분의 user 가 User, null, undefined 세가지 타입을 모두 포함하고 있었다.</p>
<pre><code class="language-tsx">// 세션 정보 가져오는 함수
export const fetchSessionData = async () =&gt; {
  const { data, error } = await browserClient.auth.getSession();

  if (error) {
    console.error(&quot;Session fetch error:&quot;, error);
    return null;
  }

  // session이 없는 것은 정상적인 상태일 수 있음
  if (!data.session) {
    return null;
  }

  return data.session.user;
};

// 커스텀 훅 useQuery
export const useSession = () =&gt; {
  return useQuery&lt;User | null&gt;({
    queryKey: [&quot;user&quot;, &quot;session&quot;],
    queryFn: () =&gt; fetchSessionData(),
  });
};</code></pre>
<p><img src="https://velog.velcdn.com/images/darong_/post/b33c1ccb-d936-45a8-864f-626f2027e2cb/image.png" alt=""></p>
<p>그런데 왜 undefined가 나오는 걸까?</p>
<br/>

<h4 id="✅-usesession이-비동기-요청이기-때문">✅ useSession이 비동기 요청이기 때문</h4>
<p>useSession은 비동기 적으로 데이터를 가져오고 있기 때문에 컴포넌트가 렌더링 될 때 user값이 로드되지 않았을 수 있기 때문이다.</p>
<p>그래서 session 데이터를 가져오는 요청이 완료되기 전까지 user가 없으니까 자동으로 undefined가 되는 것이었다.</p>
<br/>

<h4 id="🥹-그럼-어덕하라고-훅은-조건문-아래에서-못쓰는데-undefined-예외처리를-어떻게-하라고">🥹 그럼 어덕하라고 훅은 조건문 아래에서 못쓰는데 undefined 예외처리를 어떻게 하라고</h4>
<p>그렇다. 저번에 트러블 슈팅으로 썼던 것 같은데, React Hook은 조건문 아래에서 사용할 수 없다.
그래서</p>
<pre><code class="language-tsx">  // 지금 로그인한 유저 정보
  const { data: user, isLoading, isError } = useSession();
  // 현재 포스트에 좋아요를 누른 유저

  if (isLoading || isError) {
    return;
  }

  const { data: likes } = usePostLikers(postId);
  // 좋아요 누른 유저 목록에 유저가 있으면 true 없으면 false
  const isLike = likes?.some((post) =&gt; post.like_user === user?.id) || false;
  // 좋아요 낙관적 업데이트 하는 뮤테이션 불러오기
  const likeButtonHandler = useToggleLikeButton(user, postId, isLike);</code></pre>
<p>이런식으로 user 를 받아오기 전에 조건문으로 예외처리를 할 수 없다.</p>
<p><img src="https://velog.velcdn.com/images/darong_/post/e3c78a9c-9e7f-460d-a708-e2fb3b786ff7/image.png" alt=""></p>
<p>그럼 어떻게 해야할까!</p>
<p>간단하다.</p>
<p>그냥 초기값을 설정해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/darong_/post/93340758-6267-42f1-8c53-d5abda74ff13/image.png" alt=""></p>
<p><code>const { data: user = null } = useSession();</code>
user의 초기값이 unll이라고 지정해주니 아무 문제 없이 코드가 작동되는 걸 볼 수 있다.
<br/></p>
<p>사실 뮤테이션 부분에서 user! 이렇게 처리할 수도 있지만 임시방편에 불과하고 나는 좀 더 근본적으로? 어디서 undefined가 반환되는 건지 해결하고 싶었는데 너무 간단하고 기본적인거라 당황했다.</p>
<p>훅을 사용하다보면 조건문 아래에서 사용하지 못한다는 것에 사로 잡혀서 초기 값 설정하는 방법같은 걸 까먹기도 하고, 그냥 js 쓸 때는 잘 했던 걸 ts 쓸 때는 버벅이게 되는 부분이 있다.</p>
<p>useState 사용할 때도 초기값 안 넣으면 undefined 나오는데 그걸 까먹어서... 20분 정도 헤맸다고 생각하니 스스로가 웃기고 황당했다! 다음엔 까먹지 말길... </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] Error: Rendered more hooks than during the previous render.]]></title>
            <link>https://velog.io/@darong_/Next.js-Error-Rendered-more-hooks-than-during-the-previous-render</link>
            <guid>https://velog.io/@darong_/Next.js-Error-Rendered-more-hooks-than-during-the-previous-render</guid>
            <pubDate>Thu, 24 Oct 2024 02:22:40 GMT</pubDate>
            <description><![CDATA[<h3 id="💥-error-rendered-more-hooks-than-during-the-previous-render">💥 Error: Rendered more hooks than during the previous render.</h3>
<br/>

<blockquote>
<p><strong>문제 발생</strong>
마이 페이지 제작 중, 사용자가 작성한 게시글을 불러오기 위해 tanstackQuery 를 사용하여 user정보와 post 정보를 가져오려고 했음
<code>usePostByUser(user.id)</code> post를 요청할 때 user id가 필요해서 useSession으로 먼저 user 정보를 받고 user.id를 넣으려고 했음</p>
</blockquote>
<p>그런데</p>
<pre><code class="language-tsx">const {data: user} = useSession();
const {data: posts} = usePostByUser(user.id);</code></pre>
<p>이렇게 하니까 <code>Cannot read properties of undefined (reading &#39;getUser&#39;)</code> 오류가 발생함
user 받아오기 전에 posts 요청이 가서 user가 없다고 나옴</p>
<blockquote>
</blockquote>
<p>그럼 user가 있을 때만 posts 요청을 받아야하나 싶어서</p>
<pre><code class="language-tsx">const {data: user, isLoading, isError} = useSession();
&gt;
if (!user || isLoading || isError) {
  return ;
}
&gt;
const {data: posts} = usePostByUser(user.id);</code></pre>
<p>이렇게 수정했더니 해당 오류가 발생했습니다~</p>
<blockquote>
<p><strong>문제 원인</strong>
React는 Hook이 호출되는 위치를 중요하게 생각하는데, 규칙을 어길 경우 발생하는 오류입니다!</p>
</blockquote>
<p>오류가 발생하는 대표 상황
<strong>1. 조건부로 Hooks 호출
2. 반복문 또는 이벤트 핸들러에서 Hooks 호출
3. 조건에 따라 Hook 추가 및 제거</strong></p>
<blockquote>
</blockquote>
<p>그러니까 꼭 최상단에 두개를 연달아 불러오고 처리해야한다는 말.</p>
<blockquote>
<p><strong>문제 해결</strong>
조건문을 사용하지 않고 user, post 요청을 처리할 수 있는 방법 enabled, useEffect, 상위에서 요청하고 props 내리기 등 중에서 enabled 옵션을 추가하는 것으로 해결했다.</p>
</blockquote>
<pre><code class="language-tsx">export const usePostByUser = (userId: string | undefined) =&gt; {
  return useQuery({
    queryKey: [&quot;post&quot;, userId],
    queryFn: () =&gt; fetchPostByUser(userId),
    retry: 1,
    enabled: !!userId, // userId가 있을 때만 요청 실행
  });
};
&gt;
  const { data: user } = useSession();
  const { data: posts, isLoading } = usePostByUser(user?.id);</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TypeScript] Generating TypeScript Types 사용하기]]></title>
            <link>https://velog.io/@darong_/TypeScript-Generating-TypeScript-Types-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@darong_/TypeScript-Generating-TypeScript-Types-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 22 Oct 2024 12:31:37 GMT</pubDate>
            <description><![CDATA[<h3 id="🖥️-supabase-generating-typescript-types">🖥️ supabase Generating TypeScript Types</h3>
<p>ts로 프로젝트를 진행하면서 온갖 라이브러리, API에 대한 type을 작성하기가 참 번거롭고 귀찮았는데, 이런 타입을 사람들이 정리해서 공유하고 있다는 걸 얼마 전에 알게 되었다.</p>
<p>supabase에서도 이러한 것들을 지원해주고 있었는데, 내 프로젝트에서 생성한 테이블에 대한 type을 파일로 받아볼 수 있는 기능이 있다.</p>
<p>[공식 문서 확인하기] (<a href="https://supabase.com/docs/reference/javascript/typescript-support">https://supabase.com/docs/reference/javascript/typescript-support</a>)</p>
<h4 id="🚩-타입-생성-방법">🚩 타입 생성 방법</h4>
<blockquote>
<ol>
<li>터미널에서 supabase 설치
<code>yarn add supabase --dev</code>
해당 명령어를 사용하여 클라이언트 라이브러리를 설치한다.</li>
</ol>
</blockquote>
<blockquote>
<ol start="2">
<li>supabase 로그인 인증하기
<code>yarn supabase login</code>
터미널에 명령어를 입력하면
<code>Hello from Supabase! Press Enter to open browser and login automatically.</code>
이런 메세지가 뜬다. 시키는 대로 Enter를 누르면 브라우저 창이 뜨고 로그인을 진행하면 된다. 나는 이미 로그인을 해둔 상태라서 바로 인증이 됐다.</li>
</ol>
</blockquote>
<ul>
<li>이 단계를 모르고 계속 타입 생성을 시도해서 에러가 떴다. 프로젝트에 대한 권한이 있는지 확인해야 프로젝트에 대한 타입을 제공해주는게 당연한데, 아예 생각을 못하고 있었다.</li>
</ul>
<p>2번 과정 진행 후
<code>You are now logged in. Happy coding!
Done in 9.39s.</code>
이런 메세지가 떴다면 3번을 진행하면 된다.
나는 바로 떠서 해당 과정에서 에러가 생기는지 잘 모르겠지만... 에러가 생긴다면 그 부분은 검색해보시길 .....</p>
<blockquote>
<ol start="3">
<li>타입 파일 생성하기
<code>yarn supabase gen types typescript --project-id 프로젝트ID &gt; database.types.ts</code>
프로젝트ID 부분에 자신의 프로젝트 ID를 입력하면 된다. 
프로젝트 ID는 아래 경로를 통해 받아볼 수 있다.
<code>Dashboard =&gt; 타입이 필요한 프로젝트 클릭 =&gt; 왼쪽 메뉴에서 Project Settings =&gt; General =&gt; Reference ID</code> </li>
</ol>
</blockquote>
<p>3번까지 완료 되면 루트 경로에 database.types.ts 파일이 생긴 걸 확인할 수 있다. 맨 위, 아래에 명령어랑 완료 시간이 함께 나오는데 그 부분은 지우고 사용하면 된다.</p>
<h4 id="🚩-타입-사용-방법">🚩 타입 사용 방법</h4>
<p>공식 문서에도 나와있듯이 타입을 사용하는 방법은 간단하다.</p>
<pre><code class="language-tsx">import { Database, Tables, Enums } from &quot;./database.types.ts&quot;;

// Before 😕
let movie: Database[&#39;public&#39;][&#39;Tables&#39;][&#39;movies&#39;][&#39;Row&#39;] = // ...

// After 😍
let movie: Tables&lt;&#39;movies&#39;&gt;</code></pre>
<p>방법이 두 개 나와있는데
Before의 경우 경로를 하나하나 지정하여 타입을 사용하는 방법이고
After의 경우Tables 유틸리티 타입이 내부적으로 복잡한 타입 경로를 처리하여 바로 필요한 타입을 사용할 수 있다.</p>
<p>하지만 정확히 두 방법이 같은 타입을 불러오고 있는 건 아니다.</p>
<p>supabase에서는 한 테이블에서 여러 타입을 지원하는데</p>
<pre><code class="language-tsx">export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]

export interface Database {
  public: {
    Tables: {
      movies: {
        Row: {               // the data expected from .select()
          id: number
          name: string
          data: Json | null
        }
        Insert: {            // the data to be passed to .insert()
          id?: never         // generated columns must not be supplied
          name: string       // `not null` columns with no default must be supplied
          data?: Json | null // nullable columns can be omitted
        }
        Update: {            // the data to be passed to .update()
          id?: never
          name?: string      // `not null` columns are optional on .update()
          data?: Json | null
        }
      }
    }
  }
}</code></pre>
<p>보이는 것처럼 row, insert, update 타입을 제공한다.
row의 경우는 읽어올 때, insert, update는 각 역할을 수행할 때 사용할 수 있다.</p>
<p>After의 Tables&lt;&#39;movies&#39;&gt; 는 기본적으로 movies의 row를 가져오고 있고,
Tables&lt;&#39;movies&#39;, &#39;Insert&#39;&gt; 이런 식으로 다른 타입을 지정하여 사용할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[프로그래머스] 푸드 파이트 대회]]></title>
            <link>https://velog.io/@darong_/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%91%B8%EB%93%9C-%ED%8C%8C%EC%9D%B4%ED%8A%B8-%EB%8C%80%ED%9A%8C</link>
            <guid>https://velog.io/@darong_/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%91%B8%EB%93%9C-%ED%8C%8C%EC%9D%B4%ED%8A%B8-%EB%8C%80%ED%9A%8C</guid>
            <pubDate>Mon, 21 Oct 2024 13:27:02 GMT</pubDate>
            <description><![CDATA[<h4 id="🖥️-푸드-파이트-대회">🖥️ 푸드 파이트 대회</h4>
<blockquote>
<p><strong>문제</strong>
수웅이는 매달 주어진 음식을 빨리 먹는 푸드 파이트 대회를 개최합니다. 이 대회에서 선수들은 1대 1로 대결하며, 매 대결마다 음식의 종류와 양이 바뀝니다. 대결은 준비된 음식들을 일렬로 배치한 뒤, 한 선수는 제일 왼쪽에 있는 음식부터 오른쪽으로, 다른 선수는 제일 오른쪽에 있는 음식부터 왼쪽으로 순서대로 먹는 방식으로 진행됩니다. 중앙에는 물을 배치하고, 물을 먼저 먹는 선수가 승리하게 됩니다.</p>
</blockquote>
<p>이때, 대회의 공정성을 위해 두 선수가 먹는 음식의 종류와 양이 같아야 하며, 음식을 먹는 순서도 같아야 합니다. 또한, 이번 대회부터는 칼로리가 낮은 음식을 먼저 먹을 수 있게 배치하여 선수들이 음식을 더 잘 먹을 수 있게 하려고 합니다. 이번 대회를 위해 수웅이는 음식을 주문했는데, 대회의 조건을 고려하지 않고 음식을 주문하여 몇 개의 음식은 대회에 사용하지 못하게 되었습니다.</p>
<blockquote>
</blockquote>
<p>예를 들어, 3가지의 음식이 준비되어 있으며, 칼로리가 적은 순서대로 1번 음식을 3개, 2번 음식을 4개, 3번 음식을 6개 준비했으며, 물을 편의상 0번 음식이라고 칭한다면, 두 선수는 1번 음식 1개, 2번 음식 2개, 3번 음식 3개씩을 먹게 되므로 음식의 배치는 &quot;1223330333221&quot;이 됩니다. 따라서 1번 음식 1개는 대회에 사용하지 못합니다.</p>
<blockquote>
</blockquote>
<p>수웅이가 준비한 음식의 양을 칼로리가 적은 순서대로 나타내는 정수 배열 food가 주어졌을 때, 대회를 위한 음식의 배치를 나타내는 문자열을 return 하는 solution 함수를 완성해주세요.</p>
<blockquote>
<p><strong>입출력 예</strong></p>
</blockquote>
<table>
<thead>
<tr>
<th>food</th>
<th>result</th>
</tr>
</thead>
<tbody><tr>
<td>[1, 3, 4, 6]</td>
<td>&quot;1223330333221&quot;</td>
</tr>
<tr>
<td>[1, 7, 1, 2]</td>
<td>&quot;111303111&quot;</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>접근 방식</strong></p>
</blockquote>
<ul>
<li>0번 인덱스는 무조건 물이니 제외한다.</li>
<li>1번 인덱스 값부터 2로 나눈 뒤 소수점을 제외한 만큼 정답 배열에 추가한다.</li>
<li>모든 배열의 값을 추가했다면 맨 뒤에 0을 추가한다.</li>
<li>정답 배열을 뒤집은 값을 0 뒤에 또 붙인다.</li>
</ul>
<blockquote>
<p><strong>작성한 코드</strong></p>
</blockquote>
<pre><code class="language-js">function solution(food) {
  let answer = [];
  food.forEach((e, idx) =&gt; {
    if (idx === 0) {
    } else {
      const count = Math.floor(e / 2);
      answer = [...answer, ...Array(count).fill(idx)];
    }
  });
&gt;
  return answer.join(&#39;&#39;) + &#39;0&#39; + answer.reverse().join(&#39;&#39;);
}</code></pre>
<ul>
<li><p><code>let answer = [];</code>
정답을 저장할 배열.</p>
</li>
<li><p><code>food.forEach</code>
순회하면서 기존 배열을 바탕으로 새 배열을 리턴할 건 아니고 answer 배열에 값만 추가할거라 forEach 사용</p>
</li>
<li><p><code>if (idx === 0)</code>
물은 스킵하고</p>
</li>
<li><p><code>const count = Math.floor(e / 2);</code>
해당 음식을 2로 나눈 뒤 홀수일 경우를 생각하여 소수점을 버림</p>
</li>
<li><p><code>answer = [...answer, ...Array(count).fill(idx)];</code>
기존에 있던 값이 날아가지 않게 스프레드 연산자로 배열 유지하고
count 만큼의 길이를 idx 값으로 채우기
ex) Array(2).fill(3) = [기존값, 3, 3]</p>
</li>
</ul>
<p>문제가 길어서 처음 봤을 땐 막막했는데, 막상 읽어보니 크게 어려운 문제는 아니었다.
배열을 원하는 길이만큼 원하는 값으로 채우는 방법이 뭐가 있을까 고민하다가 fill을 찾았는데, 생각해보니 repeat도 있었다. </p>
<pre><code class="language-js">function solution(food) {
    let res = &#39;&#39;;
    for (let i = 1; i &lt; food.length; i++) {
        res += String(i).repeat(Math.floor(food[i]/2));
    }

    return res + &#39;0&#39; + [...res].reverse().join(&#39;&#39;);
}</code></pre>
<p>이건 다른사람 풀이인데 나랑 다르게 for문을 사용했고, 배열이 아닌 문자열로 초기값을 세팅했다. 근데 어차피 물이 0번 인덱스라 제외할거였으면 for문을 사용하는 방법이 예외처리를 따로 안해도 되니까 더 편할 것 같다.</p>
<p>코테 문제는 뭔가... 접근 방식을 떠올리는 게 가장 어려운 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 심화 프로젝트 Record PanPang]]></title>
            <link>https://velog.io/@darong_/Next.js-%EC%8B%AC%ED%99%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Record-PanPang</link>
            <guid>https://velog.io/@darong_/Next.js-%EC%8B%AC%ED%99%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Record-PanPang</guid>
            <pubDate>Fri, 18 Oct 2024 02:00:40 GMT</pubDate>
            <description><![CDATA[<h1 id="레코드팡팡-🎭-record-panpang">레코드팡팡 🎭 [Record PanPang]</h1>
<h2 id="🔗-배포-링크">🔗 배포 링크</h2>
<p><a href="https://record-pan-pang.vercel.app/">https://record-pan-pang.vercel.app/</a></p>
<hr>
<h2 id="👨🏫-프로젝트-소개">👨‍🏫 프로젝트 소개</h2>
<p>일상과 기분을 공유하며 노래를 추천하는 뉴스피드 사이트입니다.<br/>
사용자들이 자신의 감정과 순간을 노래와 함께 표현할 수 있는 공간을 제공합니다.</p>
<h2 id="🚩-프로젝트-개요">🚩 프로젝트 개요</h2>
<ul>
<li><strong>프로젝트명</strong> &nbsp; :&nbsp; <strong>Record PanPang</strong></li>
<li><strong>진행 기간</strong> &nbsp;: &nbsp; <strong>24.10.10 ~ 24.10.17</strong></li>
</ul>
<hr>
<h2 id="❤-팀-소개">❤ 팀 소개</h2>
<p><strong>[내일배움캠프] 2조</strong></p>
<h2 id="👨👩👧👦-팀원-소개">👨‍👩‍👧‍👦 팀원 소개</h2>
<table>
<thead>
<tr>
<th align="center">송진우</th>
<th align="center">이보영</th>
<th align="center">정수희</th>
<th align="center">조아영</th>
<th align="center">조해인</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>팀원</strong></td>
<td align="center"><strong>팀원</strong></td>
<td align="center"><strong>팀원</strong></td>
<td align="center"><strong>팀원</strong></td>
<td align="center"><strong>팀장</strong></td>
</tr>
<tr>
<td align="center">댓글 전반</td>
<td align="center">음악 검색 기능,<br/>음악 정보 표시</td>
<td align="center">음악 플레이어,<br/>좋아요</td>
<td align="center">게시글 전반</td>
<td align="center">회원가입/로그인,<br/>프로필 수정</td>
</tr>
</tbody></table>
<hr>
<h2 id="📦-프로젝트-파일-구조">📦 프로젝트 파일 구조</h2>
<details>
  <summary><b>Record PanPang 파일 구조</b></summary>

<pre><code> ┣ 📂src
 ┃ ┣ 📂app
 ┃ ┃ ┣ 📂(assets)
 ┃ ┃ ┃ ┣ 📜CommentCon.tsx
 ┃ ┃ ┃ ┣ 📜EmptyHeart.tsx
 ┃ ┃ ┃ ┣ 📜FillHeart.tsx
 ┃ ┃ ┃ ┣ 📜PauseCon.tsx
 ┃ ┃ ┃ ┣ 📜PlayCon.tsx
 ┃ ┃ ┃ ┣ 📜Plus.tsx
 ┃ ┃ ┃ ┗ 📜StopCon.tsx
 ┃ ┃ ┣ 📂api
 ┃ ┃ ┃ ┗ 📜spotifyToken.ts
 ┃ ┃ ┣ 📂detail
 ┃ ┃ ┃ ┗ 📂[id]
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂fonts
 ┃ ┃ ┃ ┗ 📜PretendardVariable.woff2
 ┃ ┃ ┣ 📂mypage
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂sign-in
 ┃ ┃ ┃ ┣ 📜error.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂sign-up
 ┃ ┃ ┃ ┣ 📜error.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂write
 ┃ ┃ ┃ ┣ 📂[id]
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📜favicon.ico
 ┃ ┃ ┣ 📜globals.css
 ┃ ┃ ┣ 📜layout.tsx
 ┃ ┃ ┗ 📜page.tsx
 ┃ ┣ 📂components
 ┃ ┃ ┣ 📂commonUI
 ┃ ┃ ┃ ┣ 📜Background.tsx
 ┃ ┃ ┃ ┣ 📜LikeButton.tsx
 ┃ ┃ ┃ ┗ 📜PostCard.tsx
 ┃ ┃ ┣ 📂features
 ┃ ┃ ┃ ┣ 📂auth
 ┃ ┃ ┃ ┃ ┣ 📜authForm.tsx
 ┃ ┃ ┃ ┃ ┗ 📜signOutButton.tsx
 ┃ ┃ ┃ ┣ 📂comment
 ┃ ┃ ┃ ┃ ┣ 📜CommentInput.tsx
 ┃ ┃ ┃ ┃ ┣ 📜CommentItem.tsx
 ┃ ┃ ┃ ┃ ┣ 📜CommentList.tsx
 ┃ ┃ ┃ ┃ ┗ 📜CommentSection.tsx
 ┃ ┃ ┃ ┣ 📂mypage
 ┃ ┃ ┃ ┃ ┣ 📜EditProfileButton.tsx
 ┃ ┃ ┃ ┃ ┣ 📜EditProfileModal.tsx
 ┃ ┃ ┃ ┃ ┣ 📜MyComment.tsx
 ┃ ┃ ┃ ┃ ┣ 📜MyLike.tsx
 ┃ ┃ ┃ ┃ ┣ 📜MyPageTabs.tsx
 ┃ ┃ ┃ ┃ ┣ 📜MyPost.tsx
 ┃ ┃ ┃ ┃ ┣ 📜Profile.tsx
 ┃ ┃ ┃ ┃ ┣ 📜ProfileError.tsx
 ┃ ┃ ┃ ┃ ┗ 📜ProfileLoading.tsx
 ┃ ┃ ┃ ┣ 📂navbar
 ┃ ┃ ┃ ┃ ┣ 📜Footer.tsx
 ┃ ┃ ┃ ┃ ┣ 📜Header.tsx
 ┃ ┃ ┃ ┃ ┗ 📜ProfileImg.tsx
 ┃ ┃ ┃ ┣ 📂player
 ┃ ┃ ┃ ┃ ┣ 📜DetailPlayButton.tsx
 ┃ ┃ ┃ ┃ ┣ 📜DetailPlayer.tsx
 ┃ ┃ ┃ ┃ ┣ 📜PlayButton.tsx
 ┃ ┃ ┃ ┃ ┣ 📜Player.tsx
 ┃ ┃ ┃ ┃ ┗ 📜PlayIcon.tsx
 ┃ ┃ ┃ ┣ 📂post
 ┃ ┃ ┃ ┃ ┣ 📜PostButtons.tsx
 ┃ ┃ ┃ ┃ ┣ 📜PostCommnetCount.tsx
 ┃ ┃ ┃ ┃ ┣ 📜PostForm.tsx
 ┃ ┃ ┃ ┃ ┣ 📜PostList.tsx
 ┃ ┃ ┃ ┃ ┗ 📜PostSection.tsx
 ┃ ┃ ┃ ┗ 📂spotifySearch
 ┃ ┃ ┃ ┃ ┣ 📜Card.tsx
 ┃ ┃ ┃ ┃ ┣ 📜Command.tsx
 ┃ ┃ ┃ ┃ ┗ 📜SearchForPost.tsx
 ┃ ┃ ┣ 📂providers
 ┃ ┃ ┃ ┗ 📜QueryClientProvider.tsx
 ┃ ┃ ┗ 📂ui
 ┃ ┃ ┃ ┣ 📜button.tsx
 ┃ ┃ ┃ ┣ 📜card.tsx
 ┃ ┃ ┃ ┣ 📜command.tsx
 ┃ ┃ ┃ ┣ 📜dialog.tsx
 ┃ ┃ ┃ ┣ 📜input.tsx
 ┃ ┃ ┃ ┗ 📜textarea.tsx
 ┃ ┣ 📂hook
 ┃ ┃ ┣ 📜usePostById.ts
 ┃ ┃ ┣ 📜usePostByUserId.ts
 ┃ ┃ ┗ 📜usePosts.ts
 ┃ ┣ 📂lib
 ┃ ┃ ┗ 📜utils.ts
 ┃ ┣ 📂store
 ┃ ┃ ┣ 📜playerStore.tsx
 ┃ ┃ ┗ 📜spotifyStore.tsx
 ┃ ┣ 📂types
 ┃ ┃ ┣ 📜auth.ts
 ┃ ┃ ┣ 📜comment.ts
 ┃ ┃ ┣ 📜post.ts
 ┃ ┃ ┣ 📜Spotify.ts
 ┃ ┃ ┗ 📜track.ts
 ┃ ┣ 📂utils
 ┃ ┃ ┣ 📂supabase
 ┃ ┃ ┃ ┣ 📜client-actions.ts
 ┃ ┃ ┃ ┣ 📜client.tsx
 ┃ ┃ ┃ ┣ 📜middleware.ts
 ┃ ┃ ┃ ┣ 📜server-actions.ts
 ┃ ┃ ┃ ┗ 📜server.tsx
 ┃ ┃ ┣ 📜formatTrackData.ts
 ┃ ┃ ┣ 📜getYoutubeID.ts
 ┃ ┃ ┗ 📜spotify-client.ts
 ┃ ┗ 📜middleware.ts
 ┣ 📜.env.local
 ┣ 📜.eslintrc.json
 ┣ 📜.gitignore
 ┣ 📜.prettierrc
 ┣ 📜components.json
 ┣ 📜next-env.d.ts
 ┣ 📜next.config.mjs
 ┣ 📜package-lock.json
 ┣ 📜package.json
 ┣ 📜postcss.config.mjs
 ┣ 📜README.md
 ┣ 📜tailwind.config.ts
 ┣ 📜tsconfig.json
 ┗ 📜yarn.lock</code></pre></details>
<br/>

<h2 id="📋-supabase-erd-설계도">📋 Supabase ERD 설계도</h2>
<p><img src="https://github.com/user-attachments/assets/4ce886e6-0659-4d6b-b51b-28f87e3d5f58" alt="Supabase ERD 설계도"></p>
<hr>
<h2 id="🗂️-기능-설명">🗂️ 기능 설명</h2>
<h3 id="회원가입로그인">회원가입/로그인</h3>
<p>Supabase Auth를 사용해 관리했습니다.</p>
<ol>
<li>유효성 검사 - 1
유효성 검사를 위해 <code>zod</code>와 <code>react-hook-form</code>를 사용합니다. 존재하는 이메일은 별도의 유효성 검사를 통해 알려줍니다.</li>
</ol>
<pre><code class="language-tsx">// ./src/components/auth/Auth

const AuthForm = () =&gt; {
  ...
  const schema =
    path === SIGN_UP
      ? z.object({
          email: z
            .string()
            .email({ message: &quot;이메일 형식으로 입력해주세요&quot; })
            .min(1, { message: &quot;이메일을 입력해주세요&quot; }),
          password: z.string().min(6, &quot;6자 이상 입력해주세요&quot;),
          nickname: z.string().min(1, &quot;닉네임을 입력해주세요.&quot;).max(10, &quot;최대 10자 입력 가능합니다.&quot;)
        })
      : z.object({
          email: z.string().min(1, &quot;이메일을 입력해주세요&quot;),
          password: z.string().min(1, &quot;비밀번호를 입력해주세요&quot;)
        });
  ...
  const { register, handleSubmit, formState } = useForm({
    mode: &quot;onChange&quot;, //&#39;onBlur&#39; : focus가 사라졌을 때
    defaultValues,
    resolver: zodResolver(schema)
  });
  ...
  return (
    &lt;div className=&quot;container modal&quot;&gt;
      &lt;form onSubmit={handleSubmit(onSubmit)} className=&quot;p-4 flex flex-col items-center m-auto&quot;&gt;
        &lt;Input
          {...register(&quot;email&quot;)}
          placeholder=&quot;email&quot;
          className={AUTH_CSS}
          onChange={() =&gt; setEmailMessage(&quot;&quot;)}
        /&gt;
        {formState.errors.email &amp;&amp; &lt;span className=&quot;text-sky-300 leading-tight&quot;&gt;{formState.errors.email.message}&lt;/span&gt;}
        {!!emailMessage &amp;&amp; &lt;span className=&quot;text-sky-300 leading-tight&quot;&gt;{emailMessage}&lt;/span&gt;}

        ...

      &lt;/form&gt;
    &lt;div className=&quot;embla&quot; ref={emblaRef}&gt;
      &lt;div className=&quot;embla__container&quot;&gt;
        {carousel &amp;&amp;
          [0, 2, 4, 6].map(
            (
              i // 각 슬라이드에 두개씩 보여줌
            ) =&gt; &lt;Slide play={[carousel[i], carousel[i + 1]]} key={`slide-${i}`} /&gt;
          )}
      &lt;/div&gt;
    &lt;/div&gt;
  );
};</code></pre>
<ol start="2">
<li>유효성 검사 - 2
<code>profiles</code> 테이블에 저장된 <code>email</code>을 불러와서 해당 이메일이 존재하는 확인합니다.</li>
</ol>
<pre><code class="language-tsx">// ./src/components/auth/Auth

const AuthForm = () =&gt; {
  ...
  const { register, handleSubmit, formState } = useForm({
    mode: &quot;onChange&quot;, //&#39;onBlur&#39; : focus가 사라졌을 때
    defaultValues,
    resolver: zodResolver(schema)
  });
  ...
  const onSubmit = async (data: FieldValues) =&gt; {
    const emailData = await checkEmail(data.email);

    if (path === SIGN_UP) {
      if (emailData.length !== 0) {
        setEmailMessage(&quot;이미 존재하는 계정입니다.&quot;);
      } else {
        await signup({
          email: data.email,
          password: data.password,
          options: { data: { nickname: data.nickname, email: data.email, profile_img: &quot;default&quot; } }
        });
      }
    } else {
      if (emailData.length === 0) {
        setEmailMessage(&quot;존재하지 않는 계정입니다.&quot;);
      } else {
        await signin({ email: data.email, password: data.password });
      }
    }
  };
  ...
};</code></pre>
<pre><code class="language-tsx">// ./src/utils/supabase/client-actions.ts

export async function checkEmail(email: string) {
  const { data, error } = await supabase.from(PROFILES).select(&quot;email&quot;).eq(&quot;email&quot;, email);

  if (error) {
    console.error(error);
    return [];
  }

  return data;
}</code></pre>
<br />

<h3 id="플레이어">플레이어</h3>
<p>메인 페이지에서 동작하는 플레이어입니다.<br/></p>
<ol>
<li>useRef 사용</li>
</ol>
<pre><code class="language-tsx">  // youtube iframe과 앨범 커버를 연결하여 영상은 노출되지 않고 노래만 재생됩니다.
  // 성능 최적화를 위해 재생을 클릭한 영상의 iframe만 렌더링 되도록 구현했습니다.

  const { playedVideo, setIsPlay, setPlayedVideo, playedPlayer, setPlayedPlayer } = useYoutubnStore();
  const playerRef = useRef&lt;YouTubePlayer | null&gt;(null);
  const [showYouTube, setShowYouTube] = useState(false);

      {showYouTube &amp;&amp; (
        &lt;div className=&quot;hidden&quot;&gt;
          &lt;YouTube videoId={id} onReady={(e: YouTubeEvent) =&gt; onReady(e, playerRef)} /&gt;
        &lt;/div&gt;
      )}</code></pre>
<ol start="2">
<li><p>실제 동작하는 함수</p>
<pre><code class="language-tsx">// 영상을 처음 클릭하면 showYouTube를 통해 프레임이 생성되고 로딩이 완료될 때 onReady 함수가 실행됩니다.
const togglePlayVideo = async () =&gt; {
 if (!showYouTube) {
   setShowYouTube(true);
 }

// 한 번 틀었던 노래를 재생, 일시정지 할 때 실행되는 부분
 if (playerRef.current &amp;&amp; playedVideo.id === music.id) {
   if (playedVideo.isPlay) {
     playerRef.current.pauseVideo();
     setIsPlay();
   } else {
     playerRef.current.playVideo();
     setIsPlay();
   }
 }

 // 노래를 듣다가 다른 노래를 틀었을 때 실행되는 부분
 if (playedVideo.id !== music.id &amp;&amp; playerRef.current) {
   if (playedVideo.isPlay &amp;&amp; playedPlayer) {
     // 만약 다른 노래가 재생 중이라면 일시정지 함
     playedPlayer.pauseVideo();
   }
   // 모든 플레이어 정보를 방금 선택한 영상으로 변경하고 재생시킴
   setPlayedVideo(music.id);
   setPlayedPlayer(playerRef.current);
   playerRef.current.playVideo();
 }
};

const handleClick = (e: React.MouseEvent) =&gt; {
 e.stopPropagation();
 togglePlayVideo();
};

// 프레임이 로딩되었을 때 추가 조작 없이 바로 재생될 수 있도록 설정
const onReady = (e: YouTubeEvent, playerRef: MutableRefObject&lt;YouTubePlayer | null&gt;) =&gt; {
 playerRef.current = e.target;
 if (playedPlayer) {
   playedPlayer.pauseVideo();
 }
 setPlayedVideo(music.id);
 playerRef.current.playVideo();
 setPlayedPlayer(playerRef.current);
};</code></pre>
</li>
</ol>
<br />


<h3 id="검색">검색</h3>
<p>spotify에서 track data를 불러와서 search input box에 글자를 입력할 때마다 data 50개씩 dropdown modal 안으로 들어오도록 구현하였습니다.<br/>
dropdown으로 보여지는 track list들 중 한개를 선택하면 해당하는 track의 사진과 정보가 track info box에 자동으로 입력되어 들어옵니다.
사용자는 track을 검색하고 본인이 선택한 track의 정보를 track info box에서 확인할 수 있습니다.</p>
<pre><code class="language-tsx">//SearchForPost.tsx
const SpotifySearch = ({ setCard, card, cardError }: Props) =&gt; {
  const [token, setToken] = useState(&quot;&quot;);
  const [search, setSearch] = useState&lt;string&gt;(&quot;&quot;);
  const [open, setOpen] = useState(false);
  const [tracks, setTracks] = useState&lt;Track[]&gt;([]);

  //처음 렌더링시에 fetchToke함수를 실행시켜주고 token을 가져와서 상태값 token에 담아줌
  useEffect(() =&gt; {
    const clientId = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID;
    const clientPW = process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_SECRET;

    const fetchToken = async () =&gt; {
      const res = await fetch(&quot;https://accounts.spotify.com/api/token&quot;, {
        method: &quot;POST&quot;,
        headers: {
          &quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;
        },
        cache: &quot;no-store&quot;,
        body: `grant_type=client_credentials&amp;client_id=${clientId}&amp;client_secret=${clientPW}`
      });
      if (!res.ok) {
        throw new Error(&quot;Failed to fetch token&quot;);
      }
      const data = await res.json();

      const { access_token: token } = data;

      setToken(token);
    };
    fetchToken();
  }, []);

  //사용자가 입력한 검색단어들이 search로 들어감, 입력값이 0보다 길어지면 dropdown됨
  const handleInputChange = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    if (e.target.value === &quot;0&quot;) {
      return;
    }
    setSearch(e.target.value);
    setOpen(e.target.value.length &gt; 0);
  };

  useEffect(() =&gt; {
    if (!search) {
      return;
    }
    // spotify에서 track data를 가져오는 비동기 함수
    const getTrack = async () =&gt; {
      const res = await fetch(`https://api.spotify.com/v1/search?q=${search}&amp;type=track&amp;limit=50&amp;offset=0`, {
        method: &quot;GET&quot;,
        headers: {
          Authorization: &quot;Bearer &quot; + `${token}`
        }
      });

      if (!res.ok) {
        throw new Error(&quot;Failed to fetch track&quot;);
      }

      const data: SpotifyTracks = await res.json();
      const tracks: Track[] = data.tracks.items;
      setTracks(tracks);
    };
    //사용자가 검색 단어를 입력할 때마다 getTrack을 실행시켜줌
    getTrack();
  }, [search, token]);

  //검색 리스트 클릭시 불러온 데이터의 목록과 대조하여 해당하는 데이터만 추출해서 상태값card에 넣어주는 함수
  const shiftTrackToInfocard = (id: string) =&gt; {
    const trackInfo = tracks.find((item) =&gt; {
      return item.id === id;
    });

    if (trackInfo) {
      setCard(trackInfo);
    }

    setOpen(false);
    setSearch(&quot;&quot;);
  };

  //초로 만들어진 시간은 분초로 변경해주는 함수
  const formatDuration = (durationMs: number) =&gt; {
    const minutes = Math.floor(durationMs / 60000);
    const seconds = Math.floor((durationMs % 60000) / 1000);
    return `${minutes}:${seconds &lt; 10 ? `0${seconds}` : seconds}`;
  };

  return (
    &lt;div className=&quot;container mx-auto flex flex-col gap-8 justify-center items-center&quot;&gt;
      &lt;CommandForPost
        search={search}
        tracks={tracks}
        open={open}
        handleInputChange={handleInputChange}
        shiftTrackToInfocard={shiftTrackToInfocard}
        cardError={cardError}
      /&gt;
      &lt;CardForPost card={card} formatDuration={formatDuration} /&gt;
    &lt;/div&gt;
  );
};</code></pre>
<br />

<h3 id="게시글">게시글</h3>
<p>게시글 목록을 TanStack Query를 이용하여 실시간으로 반영되도록 구현했습니다.<br />
이 기능을 통해 사용자는 게시글이 추가, 수정, 삭제될 때 즉시 업데이트된 내용을 확인할 수 있습니다.</p>
<pre><code class="language-tsx">// PostList.tsx
const PostList = ({ user, token }: Props) =&gt; {
  // 게시글 데이터를 가져오는 훅 호출
  const { data: posts, isLoading, isError } = usePosts();

  if (isLoading) {
    return &lt;div&gt;Loading...&lt;/div&gt;;
  }
  if (isError) {
    return &lt;div&gt;게시글을 불러오는 데 문제가 발생했습니다.&lt;/div&gt;;
  }
  if (!posts) {
    return &lt;div&gt;게시글이 없습니다.&lt;/div&gt;;
  }

  return (
    &lt;ul className=&quot;flex flex-col gap-6&quot;&gt;
      {posts.map((post) =&gt; (
        &lt;li key={post.post_id}&gt;
          {/* 각 게시글을 PostCard 컴포넌트로 렌더링 */}
          &lt;PostCard post={post} user={user} token={token} /&gt;
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
};

// usePosts.ts
export const usePosts = () =&gt; {
  return useQuery({
    queryKey: [&quot;posts&quot;], // 쿼리 키
    queryFn: fetchPosts // 데이터 가져오는 함수
  });
};

// client-actions.ts
export async function fetchPosts() {
  // posts 테이블에서 데이터 가져오기
  const { data: posts, error: postsError } = await supabase
    .from(&quot;posts&quot;)
    .select(&quot;*, profiles(nickname, profile_img)&quot;) // 프로필 데이터 포함
    .order(&quot;created_at&quot;, { ascending: false }); // 최신 게시글 우선 정렬

  // 에러 발생 시 처리
  if (postsError || !posts) {
    console.error(postsError);
    return []; // 에러 발생 시 빈 배열 반환
  }

  // 결과에서 각 포스트의 프로필 데이터 추가
  return posts.map((post) =&gt; ({
    ...post
  }));
}</code></pre>
<br/>

<h3 id="댓글">댓글</h3>
<p>사용자가 댓글을 작성할 수 있습니다.<br/>
정렬을 통해 댓글을 작성하면 댓글 목록 맨 위에서 확인할 수 있고, 댓글 작성,수정 시간을 확인할 수 있습니다.</p>
<p>클라이언트 액션 - 댓글 조회를 처리하여 사용자가 빠르게 댓글을 확인할 수 있습니다.</p>
<p>client-action</p>
<pre><code class="language-tsx">// 댓글 조회
export async function fetchComment(postId: string): Promise&lt;Comment[]&gt; {
  const STORAGE = &quot;profiles&quot;;

  const { data: comments, error: commentError } = await supabase
    .from(&quot;comments&quot;)
    .select(&quot;comment_id, content, user_id, created_at, update_at&quot;)
    .eq(&quot;post_id&quot;, postId)
    .order(&quot;created_at&quot;, { ascending: false }); // 생성 시간 기준으로 정렬

  if (commentError) {
    console.error(commentError.message);
    throw new Error(&quot;댓글을 불러오는데 실패했습니다.&quot;);
  }

  const commentsWithProfile = await Promise.all(
    comments.map(async (comment) =&gt; {
      const { data: profile, error: profileError } = await supabase
        .from(&quot;profiles&quot;)
        .select(&quot;nickname, profile_img&quot;)
        .eq(&quot;user_id&quot;, comment.user_id)
        .single();

      if (profileError) {
        throw new Error(&quot;프로필 정보를 불러오는데 실패했습니다.&quot;);
      }

      // `profile_img`를 가져와 절대 경로 생성
      const { data: { publicUrl: profileImgUrl } = {} } = supabase.storage
        .from(STORAGE)
        .getPublicUrl(profile.profile_img ?? &quot;default&quot;);

      return {
        ...comment,
        profile: {
          nickname: profile.nickname,
          profile_img: profileImgUrl || &quot;/default-profile.png&quot;
        }
      };
    })
  );

  return commentsWithProfile;
}
// 댓글 추가
export async function addComment(content: string, postId: string) {
  const {
    data: { user }
  } = await supabase.auth.getUser();

  if (!user) throw new Error(&quot;로그인이 필요합니다.&quot;);

  const { error } = await supabase.from(&quot;comments&quot;).insert([{ content, post_id: postId, user_id: user.id }]);

  if (error) throw new Error(&quot;댓글 추가에 실패했습니다.&quot;);
}

// 댓글 삭제
export async function deleteComment(commentId: string) {
  const {
    data: { user }
  } = await supabase.auth.getUser();

  if (!user) throw Error(&quot;로그인이 필요합니다.&quot;);

  const { error } = await supabase.from(&quot;comments&quot;).delete().eq(&quot;comment_id&quot;, commentId).eq(&quot;user_id&quot;, user.id);

  if (error) {
    throw new Error(&quot;댓글 삭제에 실패했습니다.&quot;);
  }
}

// 댓글 수정
export async function updateComment(commentId: string, content: string) {
  const {
    data: { user }
  } = await supabase.auth.getUser();

  if (!user) {
    throw new Error(&quot;로그인 해주세요.&quot;);
  }
  const { error } = await supabase
    .from(&quot;comments&quot;)
    .update({ content })
    .eq(&quot;comment_id&quot;, commentId)
    .eq(&quot;user_id&quot;, user.id);

  if (error) {
    throw new Error(&quot;댓글 수정에 실패했습니다.&quot;);
  }
}</code></pre>
<br />

<h3 id="마이페이지">마이페이지</h3>
<p>사용자 정보와 사용자가 작성한 게시글과 댓글, 좋아요한 게시글을 확인할 수 있습니다.</p>
<ol>
<li>프로필 수정 기능
<code>프로필 수정하기</code> 버튼을 클릭하면 모달창을 통해 사용자 정보를 수정할 수 있습니다.
실시간으로 변화를 감지할 수 있도록 TanStack Query를 사용해 데이터를 불러와 변화가 발생하면 <code>invalidateQueries</code>를 통해 변경된 정보를 가져오도록 합니다.</li>
</ol>
<pre><code class="language-tsx">// ./src/components/features/mypage/EditProfileModal.tsx

const EditProfileModal = ({
  user,
  setShowModal
}: {
  user: User | undefined;
  setShowModal: React.Dispatch&lt;React.SetStateAction&lt;boolean&gt;&gt;;
}) =&gt; {
  ...
  // 사용자 프로필 업데이트 시 정보 바로 갱신되도록
  const queryClient = useQueryClient();
  // 사용자 정보 업데이트 성공 시 invalidateQueries
  const { mutate: handleUpdateUser } = useMutation({
    mutationFn: () =&gt; updateUser(user as User, nickname, profileImg),
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({
        queryKey: [&quot;user&quot;, &quot;client&quot;]
      });
      queryClient.invalidateQueries({ queryKey: [&quot;post&quot;, currentUserId] });
      queryClient.invalidateQueries({ queryKey: [&quot;posts&quot;] });
    }
  });
  ...
};</code></pre>
<p>액티브 탭</p>
<ol>
<li>액티브 탭 기능을 사용하여 사용자 경험을 개선하였습니다.</li>
</ol>
<pre><code class="language-tsx">const MyPageTabs = ({ user, token }: Props) =&gt; {
  const [activeTab, setActiveTab] = useState(1);
  const { setToken } = useSpotifyStore();

  useEffect(() =&gt; {
    const getToken = async () =&gt; {
      const token = await fetchToken();
      setToken(token);
    };
    getToken();
  }, [setToken]);

  const tabs = [
    { id: 1, label: &quot;게시글&quot;, component: &lt;MyPost user={user} token={token} /&gt; },
    { id: 2, label: &quot;댓글&quot;, component: &lt;MyComment /&gt; },
    { id: 3, label: &quot;좋아요&quot;, component: &lt;MyLike /&gt; }
  ];

  return (
    &lt;div className=&quot;max-w-full mx-auto&quot;&gt;
      &lt;ul className=&quot;flex justify-around border-b border-gray-300 my-4&quot;&gt;
        {tabs.map((tab) =&gt; (
          &lt;ul
            key={tab.id}
            className={`w-full text-center py-2 cursor-pointer ${
              activeTab === tab.id
                ? &quot;border-b-2 border-sky-400 text-sky-400 font-semibold&quot;
                : &quot;text-gray-400 active:text-gray-300&quot;
            }`}
            onClick={() =&gt; setActiveTab(tab.id)}
          &gt;
            {tab.label}
          &lt;/ul&gt;
        ))}
      &lt;/ul&gt;
      &lt;div className=&quot;p-4&quot;&gt;{tabs.find((tab) =&gt; tab.id === activeTab)?.component}&lt;/div&gt;
    &lt;/div&gt;
  );
};</code></pre>
<br />

<h3 id="네비게이션-바">네비게이션 바</h3>
<p>로그인 정보가 없을 시 회원가입, 로그인 버튼이 우측 상단에 위치하며, 로그인 정보가 있을 시 로그아웃, 마이페이지 버튼과 프로필 이미지가 우측 상단에 위치합니다.</p>
<ol>
<li>Link 태그로 연결하여 페이지 로딩 최적화</li>
</ol>
<p>페이지 정보를 미리 불러와서 이동 시 시간을 줄일 수 있도록 했습니다.</p>
<pre><code class="language-tsx">// ./src/components/features/navbar/ProfileImg.tsx

const ProfileImg = () =&gt; {
  ...
  const userImg = getPublicUrl(&quot;profiles&quot;, user?.user_metadata.profile_img);

  return (
    &lt;Link href={&quot;/mypage&quot;} className=&quot;min-w-fit min-h-fit rounded-full&quot;&gt;
      &lt;Image
        src={userImg}
        alt=&quot;프로필 이미지&quot;
        width={40}
        height={40}
        className=&quot;w-[40px] h-[40px] border-2 rounded-full aspect-auto object-cover&quot;
      /&gt;
    &lt;/Link&gt;
  );
};</code></pre>
<hr>
<h2 id="💥-trouble-shooting">💥 Trouble Shooting</h2>
<h3 id="회원가입로그인-1">회원가입/로그인</h3>
<p>🔥 로그아웃 해도 &#39;로그아웃, 마이페이지&#39; 버튼이 유지됨.</p>
<p>서버용/클라이언트용 supabase client를 제대로 숙지하지 못해, supabase client가 제대로 작동하지 않아 사용자 정보가 업데이트 되지 않아 발생한 문제였습니다.</p>
<p>서버용 supabase client를 사용할 때는 각 함수마다 client를 생성하고, 클라이언트용 supabase client는 하나의 client만 생성해 import하여 사용했습니다.</p>
<pre><code class="language-tsx">// ./src/utils/supabase/server-action.ts

&quot;use server&quot;;
...
import { createClient } from &quot;./server&quot;;

export async function signin(formData: SignInWithPasswordCredentials) {
  const supabase = createClient();
  ...
}

export async function signup(formData: SignUpWithPasswordCredentials) {
  const supabase = createClient();
  ...
}

export async function signout() {
  const supabase = createClient();
  ...
}
...</code></pre>
<pre><code class="language-tsx">// ./src/utils/supabase/server.tsx

import { createBrowserClient } from &quot;@supabase/ssr&quot;;

export function createClient() {
  return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_KEY!);
}

export const supabase = createClient();</code></pre>
<p>🔥 회원가입/로그인 시 유효성 검사에 사용할 테이블이 없었음.</p>
<p><code>email</code> 컬럼 값을 unique하게 설정하기 위해 모든 사용자를 지우는 과정이 필요했습니다. 덩달아 연결된 정보도 같이 사라지게 되어 결국 모든 데이터를 지울 수 밖에 없었습니다.</p>
<p>좀 더 자세히 생각하고 데이터 베이스를 설계해야 한다는 것을 배웠습니다.
<br />
<br /></p>
<h3 id="플레이어-1">플레이어</h3>
<p>🔥 배포 후, 특별한 오류코드 없이 메인 페이지에서 플레이어가 노출되지 않는 문제 발생<br/>
player 내부에서 음악 정보가 없으면 return되지 않도록 설정한 부분이 문제라고 생각함.</p>
<pre><code class="language-tsx">  if (!music) {
    return (
      &lt;div&gt;
        Loading...
      &lt;/div&gt;
    );
  }</code></pre>
<p>코드를 변경하여 테스트 진행하니 Loading이 출력됨<br/>
음악 정보를 불러오는 데에 필요한 token, id가 props로 제대로 전달되지 않는 것 같아 2차 테스트</p>
<pre><code class="language-tsx">  if (!music) {
    return (
      &lt;div&gt;
        {token}, {id}, {music}
      &lt;/div&gt;
    );
  }</code></pre>
<p>해당 코드로 token 값이 들어오지 않는다는 걸 확인함.<br/>
token은 가장 상위 컴포넌트인 메인 페이지의 page.tsx에서 서버 액션을 사용해서 얻어 내려주고 있었음.<br/>
현재 postList 컴포넌트를 클라이언트 컴포넌트로 변경했기 때문에 해당 컴포넌트에서 useEffect 훅을 사용하여 발급받는 것으로 변경</p>
<pre><code class="language-tsx">  const { setToken, token } = useSpotifyStore();

  useEffect(() =&gt; {
    const getToken = async () =&gt; {
      const token = await fetchToken();
      setToken(token);
    };
    getToken();
  }, [setToken]);</code></pre>
<br />

<h3 id="검색-1">검색</h3>
<p>🔥 dropdown search input box를 구현하기위해 shadcn에서 commmand 컴포넌트를 가져와서 data와 연결을 했는데, 해당 input에 검색어를 입력할 때는 문제가 없었는데 input에 들어갔던 글자가 사라지는 동시에 <code>edirect-boundary.js:57 Uncaught TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))</code>이러한 에러가 나왔습니다.<br /></p>
<p>삼항연산자를 이용해서 input에 값이 없을 때에도 빈 tag를 그려지도록 하여서 에러처리를 해주었습니다.</p>
<pre><code class="language-tsx">        {open ? (
          &lt;CommandList className=&quot;absolute top-full left-0 w-full bg-white rounded-b-lg border-t-0 max-h-[300px] overflow-y-auto shadow-lg&quot;&gt;
            &lt;CommandEmpty&gt;No results found.&lt;/CommandEmpty&gt;
            &lt;CommandGroup heading=&quot;Suggestions&quot;&gt;
              {tracks.map((track) =&gt; (
                &lt;CommandItem key={track.id} onSelect={() =&gt; shiftTrackToInfocard(track.id)}&gt;
                  &lt;Music className=&quot;mr-2 h-4 w-4&quot; /&gt;
                  {track.name} - {track.artists[0]?.name}
                &lt;/CommandItem&gt;
              ))}
            &lt;/CommandGroup&gt;
          &lt;/CommandList&gt;
        ) : (
          &lt;CommandList&gt;&lt;/CommandList&gt;
        )}
      &lt;/Command&gt;</code></pre>
<br />

<h3 id="게시글-1">게시글</h3>
<p><strong>🔥 Supabase 외래키 연결</strong></p>
<ul>
<li><strong>문제 발생:</strong> <code>posts</code> 테이블에서 <code>post_id</code>로 <code>user_id</code>를 찾아 <code>profiles</code> 테이블에서 <code>nickname</code>, <code>profile_img</code> 데이터를 가져오려 했습니다. 아래 코드와 같이 작성하고 Supabase에서 외래키 연결을 시도했지만 연결되지 않았습니다. &#39;insert or update on table &quot;posts&quot; violates foreign key constraint &quot;posts_user_id_fkey&quot;&#39;오류는 <code>posts</code> 테이블의 <code>user_id</code> 필드가 <code>profiles</code> 테이블의 <code>user_id</code>와 외래키로 연결되어 있는데, 삽입하려는 <code>user_id</code> 값이 <code>profiles</code> 테이블에 존재하지 않거나 유효하지 않은 경우 이 오류가 발생합니다.</li>
<li><strong>해결 방법:</strong> 두 테이블 간의 연결에 문제가 있는 것으로 추측되어, 기존에 등록된 데이터를 모두 삭제한 후 외래키를 다시 연결했습니다.</li>
</ul>
<pre><code class="language-tsx">// post_id로 게시글 정보 조회
export async function getPostById(postId: string) {
  const { data, error } = await supabase
    .from(&quot;posts&quot;)
    .select(&quot;*, profiles(nickname, profile_img)&quot;)
    .eq(&quot;post_id&quot;, postId)
    .single();

  if (error || !data) {
    console.error(error);
    return null;
  }

  return data;
}</code></pre>
<br />

<h3 id="댓글-1">댓글</h3>
<ul>
<li>🔥 <strong>문제 발생:</strong>
댓글에 create_at이라는 타임스탬프가 있는데 처음에는 댓글 작성 시간만 기록하면 충분하다고 생각했지만, 댓글을 수정할 때마다 시간과 댓글 위치가 변경되는 문제가 발생했습니다.</li>
<li><strong>해결 방법:</strong> update_at 컬럼 추가<br>
create_at: 댓글의 작성 시간을 유지하고, 댓글 정렬을 위한 기준값으로 사용했습니다.<br>
update_at: supabase 트리거를 이용해 댓글이 수정될 때의 시간을 저장하고, 댓글에 표현했습니다.</li>
</ul>
<pre><code class="language-tsx">const { data: comments, error: commentError } = await supabase
  .from(&quot;comments&quot;)
  .select(&quot;comment_id, content, user_id, created_at, update_at&quot;)
  .eq(&quot;post_id&quot;, postId)
  .order(&quot;created_at&quot;, { ascending: false }); // 생성 시간 기준으로 정렬</code></pre>
<br />

<h3 id="마이페이지-1">마이페이지</h3>
<p>🔥 다른 사용자로 로그인하면 기존 사용자 정보가 마이 페이지 사용자 정보에 적용됨.</p>
<p>TanStack Query를 사용해 데이터를 불러와서 auth state가 변경되면 <code>invalidateQueries</code>를 실행함. 이때 클라이언트 컴포넌트에서만 TanStack Query를 사용할 수 있고, 항상 상단에 노출되어있는 client 컴포넌트가 네비게이션 바의 프로필 이미지이므로 해당 컴포넌트에 <code>onAuthStateChange</code>를 적용함.</p>
<pre><code class="language-tsx">// ./xrc/components/features/navbar/ProfileImg.tsx

const ProfileImg = () =&gt; {
  ...
  supabase.auth.onAuthStateChange(() =&gt; {
    // 모든 auth state 변화에 따라 session 다시 저장
    queryClient.invalidateQueries({ queryKey: [&quot;user&quot;, &quot;client&quot;] });
  });
  ...
};</code></pre>
<br />

<h3 id="네비게이션-바-1">네비게이션 바</h3>
<p>🔥 사용자 정보 변경 시 프로필 이미지가 같이 반영되지 않음</p>
<p>TanStack Query의 Provider 내부에 헤더를 포함시켜 <code>invalidateQueries</code>의 영향을 받도록 함.</p>
<pre><code class="language-tsx">// ./src/app/layout.tsx

export default async function RootLayout({
  children
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}&gt;
        &lt;Providers&gt;
          &lt;Header /&gt;
          &lt;Background&gt;{children}&lt;/Background&gt;
          &lt;Footer /&gt;
        &lt;/Providers&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<pre><code class="language-tsx">// ./xrc/components/features/navbar/ProfileImg.tsx

const ProfileImg = () =&gt; {
  const defaultImg = getPublicUrl(&quot;profiles&quot;, &quot;default&quot;);

  const {
    data: user,
    isLoading,
    isError
  } = useQuery({
    queryKey: [&quot;user&quot;, &quot;client&quot;],
    queryFn: () =&gt; fetchSessionData()
  });

  supabase.auth.onAuthStateChange(() =&gt; {
    // 모든 auth state 변화에 따라 session 다시 저장
    queryClient.invalidateQueries({ queryKey: [&quot;user&quot;, &quot;client&quot;] });
  });
  ...
};</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] spotify API 사용하기]]></title>
            <link>https://velog.io/@darong_/Next.js-spotify-API-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@darong_/Next.js-spotify-API-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 11 Oct 2024 14:14:51 GMT</pubDate>
            <description><![CDATA[<p>저번 TIL에 작성했던 것 처럼 이번 프로젝트에서는 spotify api를 사용하게 되었는데, 다른 open api들과는 요청 방법이 살짝 달라 이 부분에 대해 글을 작성해보려고 한다.</p>
<p>그래도 api 사용 전에 문서를 한 번 꼼꼼히 읽어보면 좋을 것 같다.</p>
<ul>
<li><a href="https://developer.spotify.com/documentation/web-api">spotify api 공식 문서</a></li>
</ul>
<br/>

<p>api 발급부터 나는 살짝 우왕좌왕했기 때문에 발급부터 작성했다.
실제로 api 사용 부분은 spotify API 사용하기 부분을 참고하면 좋을 듯!</p>
<h3 id="✅-spotify-api-발급받기">✅ spotify API 발급받기</h3>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/459b91a5-f55f-4343-be05-cc692354a8aa/image.png" alt=""></p>
<ul>
<li>api 공식 문서에 들어가보면, 오른쪽 상단에서 로그인이 가능하다.</li>
</ul>
</blockquote>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/f7deedb8-f8ea-43ba-b1f6-69c03150ba8c/image.png" alt=""></p>
<ul>
<li>본인 계정으로 로그인 한 뒤, 프로필 부분을 놀러 dashboard 부분으로 들어간다.</li>
</ul>
</blockquote>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/35d126f1-bbf4-4045-a0a7-df4a1127e080/image.png" alt=""></p>
<ul>
<li>대충 동의 누르고 다음으로 넘어간다.</li>
</ul>
</blockquote>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/465804b0-9e4e-4c90-9958-eff340cd20f7/image.png" alt=""></p>
<ul>
<li>이메일 인증을 진행한다.</li>
</ul>
</blockquote>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/c3d02985-0e7b-4c8c-bf2a-797ac4ef45b0/image.png" alt=""></p>
<ul>
<li>내용을 채우고 앱 생성을 해준다.</li>
</ul>
</blockquote>
<ul>
<li>Redirect URIs : 실제로 배포한 웹이 없다면 로컬에서 실행되는 localhost 주소를 입력하면 된다.</li>
</ul>
<blockquote>
<p><img src="https://velog.velcdn.com/images/darong_/post/c221da8d-8e69-43bd-8f35-1c7254829d9f/image.png" alt=""> <img src="https://velog.velcdn.com/images/darong_/post/92d1da49-c8c5-423a-b6cd-8d4f25999979/image.png" alt=""></p>
<ul>
<li>이 두개가 필요하니 <strong>.env.local 파일에 환경변수로 저장</strong>한다.</li>
</ul>
</blockquote>
<br/>

<h3 id="✅-spotify-token-발급받기">✅ spotify Token 발급받기</h3>
<p>spotify API는 특이하게 api key를 발급해주는 게 아니라 <strong>Client ID, Client secret</strong> 이 두개를 사용해 <strong>토큰을 요청</strong>하고 <strong>반환받은 토큰을 사용</strong>해서 api 요청을 해야하는 방식이다.
OAuth 2.0의 클라이언트 자격 증명 흐름(Client Credentials Flow) ... 이런 설명이 있던데 이와 관련한 건 따로 알아보는 것이 좋을 것 같다.</p>
<p>해당 부분에 대한 문서는 <a href="https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow">이곳</a>
사실 문서를 봐도 백엔드 로직으로 구현되어있어 프론트엔드만 배운 나는 이해가 잘? 됐다.
그래서 여기서기 리액트나 넥스트로 구현한 자료가 없을까 찾아보다가 <a href="https://velog.io/@savazy_gg/react%EB%A1%9C-spotify-open-API-%EC%9D%B4%EC%9A%A9%ED%95%98%EA%B8%B0">관련 블로그</a>를 찾았다.</p>
<p>익숙한 구조의 코드를 보니까 천천히 이해할 수 있었다! 일단 복붙해서 써보고, 되는지 안되는지 확인한 후 조금 더 보기 쉽게 수정했다.</p>
<pre><code class="language-tsx">// 잊지 말고 환경변수 임포트하기
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string;

  // 스포티파이 api 요청을 위한 토큰 받아오는 함수
  const getSpotifyToken = async () =&gt; {
    const params = new URLSearchParams({
      grant_type: &quot;client_credentials&quot;,
      client_id: SPOTIFY_CLIENT_ID,
      client_secret: SPOTIFY_CLIENT_SECRET
    });

    // fetch 부분
    const res = await fetch(&quot;https://accounts.spotify.com/api/token&quot;, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;
      },
      // 쿼리스트링 형식으로 요청을 보냄
      body: params.toString() 
      // 실제로 전달되는 형태
          // grant_type=client_credentials&amp;client_id=SPOTIFY_CLIENT_ID&amp;client_secret=SPOTIFY_CLIENT_SECRET
    });

    // 토큰만 구조분해 할당으로 받아옴
    const { access_token: token } = await res.json();
    return token;
  };

  // 함수를 호출하여 반환된 토큰을 변수에 저장
  const token = await getSpotifyToken();</code></pre>
<p>console.log로 token을 확인하면서 진행하니 수월했다.</p>
<br/>

<h3 id="✅-spotify-api-요청하기">✅ spotify API 요청하기</h3>
<p>토큰을 받은 후에 요청은 기존 api들과 크게 다르진 않았다.
반환값에 쓸모없는 게 많아서 포맷팅하는 게 시간이 제일 오래 걸렸다...</p>
<pre><code class="language-tsx">  // 스포티파이에 해당 음악 정보 요청
  const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
    method: &quot;GET&quot;,
    headers: {
      // 헤더에 아까 받은 token 함께 보내기 
      Authorization: &quot;Bearer &quot; + token
    }
  });
  const data: OriginalTrack = await res.json();

  // 포맷팅 함수로 필요한 데이터만 받아옴
  const music = formatTrackData(data);</code></pre>
<p>supabase에 저장된 music id를 받아 track정보를 불러오는 로직을 작성했다.
각각의 api 요청에 대한 건 공식 문서에 잘 나와있어서 어렵진 않다. 조금 헷갈릴 뿐...ㅜ
그리고 타입의 경우 spotify에서 반환값을 보여주기 때문에 손수 작성해도 되고, 구글링해서 나오는 이미 만들어진 타입을 사용해도 될 것 같다. 나는 ai와 함께 직접 타입을 작성했다.</p>
<br/>
아무튼 좀 생소하고 어려워보이지만, 하면 되긴 한다 < 이게 제일 중요]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 심화주차 팀프로젝트 S.A]]></title>
            <link>https://velog.io/@darong_/Next.js-%EC%8B%AC%ED%99%94%EC%A3%BC%EC%B0%A8-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-S.A</link>
            <guid>https://velog.io/@darong_/Next.js-%EC%8B%AC%ED%99%94%EC%A3%BC%EC%B0%A8-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-S.A</guid>
            <pubDate>Thu, 10 Oct 2024 14:35:50 GMT</pubDate>
            <description><![CDATA[<h3 id="🎵-프로젝트-소개">🎵 프로젝트 소개</h3>
<ul>
<li>프로젝트 명 : 레코드판팡</li>
<li>프로젝트 소개 : 일상, 기분 등을 노래와 함께 공유할 수 있는 사이트</li>
<li>참고 사이트 : 인스타그램, 페이스북, X 와 같은 피드형 SNS</li>
</ul>
<br/>

<h3 id="✅-프로젝트-기능">✅ 프로젝트 기능</h3>
<ul>
<li>회원가입 / 로그인</li>
<li>메인 페이지
  뉴스피드 형식 / 게시글과 댓글 수, 좋아요 수 노출 </li>
<li>작성 페이지
  노래 선택 (스포티파이 api 사용) (검색 결과 드롭다운)
  앨범 이미지, 제목, 아티스트, 앨범명 등 정보 확인
  게시글 내용 (일상, 추천이유 등)
  해당 노래에 대한 유튜브 링크 (스포티파이 api 재생기능 사용 불가)</li>
<li>상세 페이지
  작성자, 작성일
  노래 플레이어
  노래 정보, 게시글 내용
  댓글 CRUD</li>
<li>마이 페이지
  프로필 수정 (닉네임, 프로필 사진)
  작성한 게시글, 댓글, 좋아요 확인 가능</li>
</ul>
<br/>



<h3 id="🚩-담당한-부분-기획">🚩 담당한 부분 기획</h3>
<h4 id="1-react-youtube-라이브러리를-사용한-노래-플레이어-만들기">1. react-youtube 라이브러리를 사용한 노래 플레이어 만들기</h4>
<ul>
<li>이전에 사용했던 이미지 업로드 / 미리보기 기능을 변형하여 구현 (useRef)</li>
<li>유튜브 컴포넌트를 숨긴 후, ref로 연결하여 재생 버튼을 클릭하면 재생되도록 구현<br/>

</li>
</ul>
<h4 id="2-게시글-좋아요-취소">2. 게시글 좋아요, 취소</h4>
<ul>
<li>게시글 DB에 like 라는 열을 만들어 좋아요 누른 유저의 ID를 배열 형태로 저장</li>
<li>취소 : 해당 배열에 이미 유저 아이디가 있는 경우 유저 아이디 삭제</li>
<li>유지 : 배열을 순회하며 유저 아이디가 있는 경우 좋아요 상태로 표시<br/>

</li>
</ul>
<h4 id="3-마이-페이지에-내가-좋아요한-게시글-불러오기">3. 마이 페이지에 내가 좋아요한 게시글 불러오기</h4>
<ul>
<li>초기 DB 구조가 게시글 테이블에 LIKE 열이 포함되어 있음 (좋아요 누른 유저의 ID를 배열로 저장)</li>
<li>마이 페이지에서 게시글을 불러올 때 효율 부분에서 떨어진다고 생각이 들어 수정 요청할 예정</li>
<li>like 테이블을 따로 제작하고 user id, posts id를 개별로 저장하는 방식이 좋아보임</li>
<li>테이블에서 user id가 현재 유저와 일치하는 게시글 정보만 불러오면 됨</li>
</ul>
<p>기획대로 차근차근 기간 안에 꼭 완성할 수 있길!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 마우스 오버 시 나타나는 모달창 만들기]]></title>
            <link>https://velog.io/@darong_/Next.js-%EB%A7%88%EC%9A%B0%EC%8A%A4-%EC%98%A4%EB%B2%84-%EC%8B%9C-%EB%82%98%ED%83%80%EB%82%98%EB%8A%94-%EB%AA%A8%EB%8B%AC%EC%B0%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@darong_/Next.js-%EB%A7%88%EC%9A%B0%EC%8A%A4-%EC%98%A4%EB%B2%84-%EC%8B%9C-%EB%82%98%ED%83%80%EB%82%98%EB%8A%94-%EB%AA%A8%EB%8B%AC%EC%B0%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Mon, 07 Oct 2024 17:02:14 GMT</pubDate>
            <description><![CDATA[<h4 id="🖥️-마우스-오버-시-나타나는-모달창-만들기">🖥️ 마우스 오버 시 나타나는 모달창 만들기</h4>
<p>개인과제를 진행하면서 개인적으로 추가해보고 싶은 게 있어서 만들어 본 아주 간단한 모달창</p>
<blockquote>
<p><strong>작동 원리</strong></p>
</blockquote>
<ul>
<li>모달창 열기와 닫기를 담당하는 state를 만든다.</li>
<li>마우스 오버와 관련된 이벤트 (onMouseEnter, onMouseOver 등)을 사용하여 state를 set한다.</li>
<li>설정된 state에 맞게 모달창에 조건을 넣어 출력해준다.</li>
<li>끝!</li>
</ul>
<p>우선 나는 롤 챔피언의 상세정보에서 개별의 스킬마다 설명을 모달로 띄우고 싶었기 때문에 각 스킬의 정보를 갖고 있어야 해서 state를 문자열로 설정해주었다.</p>
<p>예를 들어 w 스킬에 마우스를 올리면 isHoverd 라는 state에 w 스킬의 이름을 저장하고, 스킬 이름과 isHoverd가 일치하는 경우에만 div가 출력되도록 설정했다.</p>
<h4 id="모달-코드">모달 코드</h4>
<pre><code class="language-tsx">  const [isHoverd, setIsHoverd] = useState&lt;string | null&gt;(null);
  const handleHover = (spell: string | null) =&gt; {
    setIsHoverd(spell);
  };</code></pre>
<ul>
<li>isHoverd : 스킬 이름을 저장하는 state</li>
<li>handleHover : 스킬 위에 마우스가 올라가면 실행되는 함수</li>
</ul>
<br/>

<pre><code class="language-tsx">         &lt;div
          className=&quot;text-center&quot;
          onMouseEnter={() =&gt; handleHover(&quot;passive&quot;)}
          onMouseLeave={() =&gt; handleHover(null)}
        &gt;
          {/* 패시브 이미지 */}
          &lt;Image
            src={detailChampion.passive.image}
            alt={detailChampion.passive.name}
            width={40}
            height={40}
            className=&quot;spellImage&quot;
          /&gt;
          &lt;p className=&quot;spellName relative&quot;&gt;
            P
            {isHoverd === &quot;passive&quot; &amp;&amp; (
              &lt;div className=&quot;absolute flex flex-col items-center left-1/2 -translate-x-1/2 font-normal text-xs top-4&quot;&gt;
                &lt;TopArrow /&gt;
                &lt;div className=&quot;w-max bg-deepBlue text-white px-4 py-2 rounded-lg&quot;&gt;
                  {detailChampion.passive.name}
                &lt;/div&gt;
              &lt;/div&gt;
            )}
          &lt;/p&gt;
        &lt;/div&gt;</code></pre>
<ul>
<li>마우스가 패시브 스킬 위에 올라가면 <code>onMouseEnter={() =&gt; handleHover(&quot;passive&quot;)}</code> 를 통해 isHoverd가 passive라는 문자열로 set된다</li>
<li>마우스가 벗어나면 null로 변경된다.</li>
<li><code>isHoverd === &quot;passive&quot; &amp;&amp;</code> 조건을 통해 isHoverd 가 passive 일때만 패시브 스킬의 이름을 보여준다.</li>
</ul>
<br/>

<pre><code class="language-tsx">{detailChampion.spells.map((spell, idx) =&gt; (
          &lt;div
            key={spell.name}
            className=&quot;text-center&quot;
            onMouseEnter={() =&gt; handleHover(spell.id)}
            onMouseLeave={() =&gt; handleHover(null)}
          &gt;
            &lt;Image
              src={spell.image}
              alt={`${spell.id}`}
              width={40}
              height={40}
              className=&quot;spellImage&quot;
            /&gt;
            &lt;p className=&quot;spellName relative&quot;&gt;
              {idx === 0 ? &quot;Q&quot; : idx === 1 ? &quot;W&quot; : idx === 2 ? &quot;E&quot; : &quot;R&quot;}
              {/* 모달창 부분 */}
              {spell.id === isHoverd &amp;&amp; (
                &lt;div className=&quot;absolute flex flex-col items-center left-1/2 -translate-x-1/2 font-normal text-xs top-4&quot;&gt;
                  &lt;TopArrow /&gt;
                  &lt;div className=&quot;w-72 bg-deepBlue text-white px-4 py-2 rounded-lg&quot;&gt;
                    {spell.description}
                  &lt;/div&gt;
                &lt;/div&gt;
              )}
            &lt;/p&gt;
          &lt;/div&gt;
        ))}</code></pre>
<ul>
<li>마찬가지로 onMouseEnter 이벤트를 사용하여 <code>handleHover(spell.id)</code> 를 실행한다.</li>
<li><code>spell.id === isHoverd &amp;&amp;</code> 여러 스킬이 들어있는 배열을 map으로 순회하고 있기 때문에, 해당 스킬의 id와 isHoverd가 일치하는 경우 해당 스킬의 설명을 띄운다.</li>
</ul>
<br/>

<h4 id="적용한-모습">적용한 모습!</h4>
<p><img src="https://velog.velcdn.com/images/darong_/post/63c1bf4c-d801-4528-9b42-c2eb71eae4ab/image.png" alt="">
마우스를 올리면 스킬에 대한 설명이 보인다</p>
<h4 id="mouseover와-mouseenter-차이">MouseOver와 MouseEnter 차이</h4>
<p>해당 모달을 위해 검색을 하는데 호버와 관련된 이벤트가 두가지 있었다. over, enter 무슨 차이인가 싶어서 검색해봤더니 전파와 취소에 대한 이야기가 나왔다. 시간이 늦어서 과제 빨리 마무리하고 자고 싶은 마음에 자세히 읽어보지는 못했지만 enter를 쓰는 게 더 나을 것 같다는 생각이 들어서 enter를 사용했다. 참고한 블로그는 <a href="https://velog.io/@commi1106/MouseOver%EC%99%80-MouseEnter%EC%9D%98-%EC%B0%A8%EC%9D%B4-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B2%84%EB%B8%94%EB%A7%81">이곳이다</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] 이 호출과 일치하는 오버로드가 없습니다.]]></title>
            <link>https://velog.io/@darong_/Next.js-%EC%9D%B4-%ED%98%B8%EC%B6%9C%EA%B3%BC-%EC%9D%BC%EC%B9%98%ED%95%98%EB%8A%94-%EC%98%A4%EB%B2%84%EB%A1%9C%EB%93%9C%EA%B0%80-%EC%97%86%EC%8A%B5%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@darong_/Next.js-%EC%9D%B4-%ED%98%B8%EC%B6%9C%EA%B3%BC-%EC%9D%BC%EC%B9%98%ED%95%98%EB%8A%94-%EC%98%A4%EB%B2%84%EB%A1%9C%EB%93%9C%EA%B0%80-%EC%97%86%EC%8A%B5%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Wed, 02 Oct 2024 11:52:20 GMT</pubDate>
            <description><![CDATA[<h4 id="💥-fetch-요청시-headers-빨간줄-경고문">💥 fetch 요청시 headers 빨간줄 경고문</h4>
<br/>

<h4 id="발생-문제"><strong>발생 문제</strong></h4>
<blockquote>
<p>Riot API 사용하여 로테이션 챔피언을 불러오기 위해 fetch 작성 중 headers 부분에 빨간줄 경고 출력
<img src="https://velog.velcdn.com/images/darong_/post/68239377-6420-4261-9c30-1b7aa5585f54/image.png" alt=""></p>
</blockquote>
<br/>

<h4 id="오류-발생-부분"><strong>오류 발생 부분</strong></h4>
<blockquote>
<pre><code class="language-tsx">const API_URL =
  &quot;https://kr.api.riotgames.com/lol/platform/v3/champion-rotations&quot;;
const API_KEY = process.env.RIOT_API_KEY;
</code></pre>
</blockquote>
<p>export async function GET() {
  try {
    const res = await fetch(API_URL, {
      headers: {
        &quot;X-Riot-Token&quot;: API_KEY,
      },
    });
    const data = await res.json();
    &gt;
    return NextResponse.json(data);
    &gt;
  } catch (error) {
    throw error;
  }
}</p>
<pre><code>Route Handlers를 사용하는 것이 필수 과제라 api/rotation/route.ts 경로에서 작업 중이었다.
미션 가이드에는 따로 오류에 대한 말이 없어서 오버로드 관련 검색을 해보았는데 fetch 관련한 게시글은 못찾은 건지 없는 건지 보지 못했고, 타입 관련 오류라는 것만 알아냈다.
headers에 &quot;X-Riot-Token&quot; 옵션이 없어서 그런가? 싶어서 이것저것 만져보긴 했는데 해결은 되지 않았다.

&lt;br/&gt;

#### **문제 원인**
&gt;의외로 원인은 별 거 아닌 것이었다.
경고문이 출력되어 있는 상태로 API_KEY 위에 마우스를 올려보면 타입이 string | undefined 라고 뜬다.
&gt;
![](https://velog.velcdn.com/images/darong_/post/333853fa-b762-4bbf-a9cf-883c7752579a/image.png)
&gt;
그리고 나서 경고문을 다시 읽어보니
`{ &quot;X-Riot-Token&quot;: string | undefined; }&#39; 형식은 &#39;undefined&#39; 형식에 할당할 수 없습니다.`
API_KEY 가 undefined 이기 때문에 경고가 뜨고 있는 것 같았다.




&lt;br/&gt;

#### **문제 해결**
&gt;해결 방법은 두가지가 있었다.
&gt;
- **조건문**을 추가하여 API_KEY가 undefined일 때 오류를 생성하여 무조건 string 타입으로 내려보내기.
```tsx
export async function GET() {
  try {
  //API_KEY가 undefined면 오류를 내보냄
    if (!API_KEY) {
      throw new Error(&quot;api key undefind&quot;);
    }
//자동으로 API_KEY가 string 타입일 때만 이 부분이 실행됨
    const res = await fetch(API_URL, {
      headers: {
        &quot;X-Riot-Token&quot;: API_KEY,
      },
    });
&gt;
    const data = await res.json();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}</code></pre><p><img src="https://velog.velcdn.com/images/darong_/post/8a0652c8-baa9-4226-8d84-0fe3c9e3f78b/image.png" alt=""></p>
<blockquote>
</blockquote>
<p>이렇게 하면 API_KEY가 string 타입으로 내려오고 있는 걸 확인할 수 있다.</p>
<blockquote>
<br/>
</blockquote>
<ul>
<li>import 시점에 API_KEY 타입을 string으로 지정하기<pre><code class="language-tsx">const API_KEY = process.env.RIOT_API_KEY as string;
&gt;
export async function GET() {
try {
  const res = await fetch(API_URL, {
    headers: {
      &quot;X-Riot-Token&quot;: API_KEY,
    },
  });
&gt;
  const data = await res.json();
  return NextResponse.json(data);
} catch (error) {
  throw error;
}
}</code></pre>
<img src="https://velog.velcdn.com/images/darong_/post/5eaf4b58-3048-4323-96df-74f54c25a2a4/image.png" alt=""><blockquote>
</blockquote>
이렇게 하면 처음부터 API_KEY가 string 타입으로 내려오기 때문에 오류가 발생하지 않는다.</li>
</ul>
<p>하지만 혹시라도 API KEY가 지워지거나, 오류가 발생했을 때를 대비하여 첫번째 방법이 좋을 것 같아서 나는 조건문을 추가하는 것으로 해결했다.</p>
]]></description>
        </item>
    </channel>
</rss>