<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>js_baek.log</title>
        <link>https://velog.io/</link>
        <description>세상에서 가장 부지런한 사람이 되고 싶은 게으름뱅이</description>
        <lastBuildDate>Mon, 30 Mar 2026 07:13:11 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>js_baek.log</title>
            <url>https://velog.velcdn.com/images/js_baek/profile/2ad619fa-d178-489a-8c53-0205d526f1a3/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. js_baek.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/js_baek" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[이미지를 aspect-ratio에 싸서 드셔 보세요]]></title>
            <link>https://velog.io/@js_baek/%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-aspect-ratio%EC%97%90-%EC%8B%B8%EC%84%9C-%EB%93%9C%EC%85%94-%EB%B3%B4%EC%84%B8%EC%9A%94</link>
            <guid>https://velog.io/@js_baek/%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%A5%BC-aspect-ratio%EC%97%90-%EC%8B%B8%EC%84%9C-%EB%93%9C%EC%85%94-%EB%B3%B4%EC%84%B8%EC%9A%94</guid>
            <pubDate>Mon, 30 Mar 2026 07:13:11 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js_baek/post/3d5b712a-a39d-4ed7-9595-2b28d4ea1a67/image.png" alt=""></p>
<p>개발을 하다 보면 이미지나 영상, 혹은 특정 요소의 비율을 고정해야 하는 상황이 생각보다 자주 생긴다. 골치 아프게 따로 계산할 필요없이, <code>aspect-ratio</code>라는 속성이 생겨서 훨씬 간단하게 해결할 수 있다. 오늘은 이 속성에 대해서 정리해보려고 한다.</p>
<h2 id="aspect-ratio란">aspect-ratio란?</h2>
<p><code>aspect-ratio</code>는 요소의 <strong>가로 세로 비율</strong>을 고정할 수 있는 CSS 속성이다. 쉽게 말하면 너비가 변하더라도 항상 지정한 비율을 유지하도록 만들어준다.</p>
<pre><code class="language-css">.box {
  width: 100%;
  aspect-ratio: 16 / 9;
}</code></pre>
<p>위 코드처럼 <code>width</code>만 지정하고 <code>aspect-ratio</code>를 설정하면, <code>height</code>를 따로 지정하지 않아도 자동으로 16:9 비율에 맞는 높이가 계산된다. 반대로 <code>height</code>만 지정하고 너비를 비율에 맞게 만들 수도 있다.</p>
<h2 id="예전-방식과-비교">예전 방식과 비교</h2>
<p>예전에는 비율을 고정하기 위해 아래와 같은 <code>padding-top</code> 트릭을 사용했다.</p>
<pre><code class="language-css">/* 16:9 비율 만들기 */
.wrapper {
  position: relative;
  width: 100%;
  padding-top: 56.25%; /* 9 / 16 * 100 */
}

.inner {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
}</code></pre>
<p>동작은 하지만 구조가 복잡해지고, 비율을 바꾸려면 계산도 해야 하는 번거로움이 있었다. <code>aspect-ratio</code> 하나면 이 모든 걸 대체할 수 있다.</p>
<h2 id="다양한-사용-예시">다양한 사용 예시</h2>
<h3 id="정사각형-썸네일">정사각형 썸네일</h3>
<pre><code class="language-css">.thumbnail {
  width: 100%;
  aspect-ratio: 1 / 1; /* 1이라고만 써도 됨 */
  object-fit: cover;
}</code></pre>
<h3 id="유튜브-임베드">유튜브 임베드</h3>
<p>반응형으로 유튜브 영상을 넣을 때 정말 유용하다.</p>
<pre><code class="language-css">.video-wrapper {
  width: 100%;
  aspect-ratio: 16 / 9;
}

.video-wrapper iframe {
  width: 100%;
  height: 100%;
}</code></pre>
<h3 id="카드-컴포넌트">카드 컴포넌트</h3>
<pre><code class="language-css">.card-image {
  aspect-ratio: 4 / 3;
  overflow: hidden;
}</code></pre>
<h2 id="주의할-점">주의할 점</h2>
<p><code>width</code>와 <code>height</code>가 <strong>둘 다 명시적으로 지정되어 있으면</strong> <code>aspect-ratio</code>는 무시된다. 비율을 고정하고 싶다면 둘 중 하나만 지정해야 한다는 점을 기억해두자.</p>
<p>그리고 <code>img</code> 태그에 사용할 때는 <code>object-fit: cover</code>나 <code>object-fit: contain</code>을 함께 쓰는 게 거의 필수다. 비율은 고정됐는데 이미지가 찌그러지면 의미가 없으니까.</p>
<pre><code class="language-css">img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover; /* 비율 유지하면서 꽉 채우기 */
}</code></pre>
<h2 id="브라우저-지원">브라우저 지원</h2>
<p>크롬 88+, 파이어폭스 89+, 사파리 15+부터 지원한다. 사실상 현시점에서 IE를 제외하면 모든 주요 브라우저에서 사용할 수 있기 때문에 실무에서 편하게 써도 된다.</p>
<hr>
<p>알고 나면 별거 아닌데 모르면 자꾸 <code>padding-top</code> 계산기를 두드리게 되는 속성이다...... 앞으로는 <code>aspect-ratio</code> 하나로 깔끔하게 해결하자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vercel 배포 후 새로고침 시 404 에러 해결]]></title>
            <link>https://velog.io/@js_baek/Vercel-%EB%B0%B0%ED%8F%AC-%ED%9B%84-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8-%EC%8B%9C-404-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@js_baek/Vercel-%EB%B0%B0%ED%8F%AC-%ED%9B%84-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8-%EC%8B%9C-404-%EC%97%90%EB%9F%AC-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Tue, 10 Mar 2026 13:11:31 GMT</pubDate>
            <description><![CDATA[<p>문제는 내가 개발한 React 프로젝트를 Vercel로 배포한 후, 여러 가지 기능을 테스트한 다음에 화면을 초기화하기 위해 새로고침을 했을 때 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/9e7c15be-c99e-49d3-837e-a7822becc552/image.png" alt=""></p>
<p>분명히 새로고침 하기 전까지만 해도 잘 보이던 페이지에 404 에러 화면이 나오고 있는 것이다. URL이 잘못된 것도 아니었고, 오류 때문에 서비스가 내려간 것도 아니었다. 혹시 싶어서 루트 경로가 아닌 다른 화면의 URL를 입력하니 마찬가지로 404 에러 화면이 나타났다. 차라리 아예 동작하지 않는다면 정말로 에러가 발생했구나 싶었을 텐데......
검색해 보니 다행히(?) 나와 같은 문제를 겪는 사람이 많았고 해결법도 생각보다 간단했다. 우선 원인부터 파헤쳐보자.</p>
<h3 id="원인">원인</h3>
<p>내 프로젝트는 React를 통해 만들어졌다. React는 기본적으로 SPA이기 때문에 사용자가 어떤 URL로 들어와서 페이지를 요청하든 <code>index.html</code>이라는 파일을 내려주게 된다. 이후에 Javascript로 화면을 업데이트한다. 즉, <code>index.html</code> 파일이 있고 사용자가 접속한 경로가 Router로 등록이 되어 있으면 404 에러를 만날 일이 없다.
하지만 Vercel은 기본적으로 SPA로 동작시키지 않는다. 예를 들어 <code>/test</code>라는 경로로 접속했다고 하면 정말로 서버 내에서 해당 경로에 존재하는 html 파일을 찾는다. 일반적인 MPA의 형태로 웹사이트를 동작시키는 것이다.
화면 내에서 버튼을 통해 경로를 이동할 때는 자연스럽게 현재 웹페이지 내에서 화면이 다시 그려지지만, 해당 경로로 곧바로 접속하면 경로 내에 있는 html 파일을 찾기 때문에 404 오류가 발생하는 것이다. (그 경로에 html 파일이 없으니까!)</p>
<h3 id="해결-방법">해결 방법</h3>
<p>원인을 알았으니 해결 방법은 간단하다. 유저가 어떤 경로로 요청을 보내든 항상 같은 경로의 <code>index.html</code> 파일을 응답하도록 만들면 된다.
먼저 프로젝트의 루트 경로에 <code>vercel.json</code> 파일을 생성한 뒤 아래와 같은 내용을 작성한다.</p>
<pre><code class="language-json">{
  &quot;rewrites&quot;: [{ &quot;source&quot;: &quot;/(.*)&quot;, &quot;destination&quot;: &quot;/index.html&quot; }]
}</code></pre>
<p>재배포를 완료하면 새로고침을 하거나 특정 URL로 바로 접속하더라도 정상적으로 화면이 나타나는 것을 확인할 수 있다! rewrites 옵션을 통해서 모든 경로에 대한 목적지를 <code>index.html</code>로 설정해주는 코드라고 보면 쉽다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase 이미지 업로드 기능 구현]]></title>
            <link>https://velog.io/@js_baek/Supabase-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@js_baek/Supabase-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 02 Mar 2026 10:53:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/js_baek/post/5b207029-9a1a-4c64-9bb7-0488910c9ecc/image.png" alt=""></p>
<p>이번에 사이드 프로젝트인 Trace를 만들면서 백엔드를 간결하게 구현하기 위해 Supabase를 사용했는데, 일반적인 회원가입에 OAuth는 물론이고 DB와 WebSocket에 이미지 업로드까지...... 게다가 제한적이기는 하지만 무료로 사용할 수 있다는 점이 정말 큰 장점인 것 같다. 프론트엔드 개발자가 혼자서 사이드 프로젝트를 작업할 때는 더욱이!</p>
<p>오늘은 다양한 기능 중에서 Supabase를 통해 간단하게 이미지 업로드 기능을 구현하는 방법을 알아보려고 한다.</p>
<p>해당 포스트는 이미 Supabase에서 프로젝트를 생성했고, 나의 어플리케이션과 연동되었다는 가정 하에서 진행된다.</p>
<h2 id="1-버켓-생성">1. 버켓 생성</h2>
<p>먼저 Supabase 대시보드의 좌측 사이드바에서 Storage 메뉴를 클릭한다.
<img src="https://velog.velcdn.com/images/js_baek/post/ccafaea6-2b78-4bcb-8bb3-57fe0983fdbc/image.png" alt="">
그러면 아래 사진과 같은 화면이 나타나고, 사진에 표시된 New bucket을 클릭해서 파일을 저장할 수 있는 일종의 폴더를 생성할 수 있다. uploads는 내가 Trace에서 사용하기 위해 이미 만들어 놓은 버켓이다. 포스트 이미지 및 프로필 이미지를 저장해 놓기 위해 사용하고 있다. 버켓을 클릭해서 내부 구조를 살펴보면...
<img src="https://velog.velcdn.com/images/js_baek/post/ecc0205d-ba28-47ca-a87f-97d5478c1924/image.png" alt="">
이렇게 정말 폴더 구조처럼 생긴 것을 볼 수 있다. 가장 상위에는 uuid 형태의 userId로 폴더가 생성되고, 그 다음은 용도에 따라 두 가지의 폴더로 나뉜다. 숫자로 되어 있는 폴더 &#39;69&#39;는 postId를 의미하는데, 말 그대로 포스트에 첨부된 이미지를 저장하는 폴더이며 avatar는 해당 유저의 프로필 이미지를 저장하는 폴더이다. 폴더 구조는 어떻게 정해도 상관없지만, 추후 포스트가 삭제되거나 유저가 삭제되었을 때 함께 삭제하기 용이하도록 다음과 같은 구조로 결정했다.
<img src="https://velog.velcdn.com/images/js_baek/post/c7aaa3c3-27b6-454f-8a0e-f05f0c756ae0/image.png" alt="">
다시 이전으로 돌아와서, New Bucket 버튼을 클릭하면 버켓에 대한 정보를 입력받는 모달이 나타난다.
첫 번째는 버켓의 이름, 두 번째는 로그인하지 않은 사용자도 파일에 접근할 수 있는지 여부를 설정하는 옵션이며 세 번째는 업로드할 파일의 사이즈를 제한하는 옵션, 마지막은 업로드할 파일의 확장자를 제한하는 옵션이다.
Trace의 경우에는 public bucket으로 설정하였고 (url로 파일 접근을 용이하게 하기 위해) 파일 사이즈는 5MB로 제한, MIME type은 이미지로 제한하였다.
<img src="https://velog.velcdn.com/images/js_baek/post/f1354dff-c325-473b-9ba0-9093a323dd97/image.png" alt="">
<img src="https://velog.velcdn.com/images/js_baek/post/70021123-242a-420a-b0ea-c441cc8cf7e4/image.png" alt=""></p>
<h2 id="2-정책-설정">2. 정책 설정</h2>
<p>Supabase의 강력한 기능 중 하나는, 웹 페이지에서 간단한 조작으로 CRUD 시의 보안 관련 설정을 할 수 있다는 것이다. 위에서 말한 것처럼 이미지를 조회하는 것은 누구든 가능하지만, 업로드는 로그인한 유저가 본인의 폴더에만 가능해야 하고 수정 및 삭제는 이미지를 올린 본인만 할 수 있도록 막아야 할 필요가 있다. 이것을 클라이언트 사이드에서 제어할 필요가 없이, 방금 봤던 화면의 상단에서 Policies 메뉴를 클릭하여 관련 정책을 등록할 수 있다.
아래 사진을 보면 UPLOADS 버켓에 이미 4가지의 정책이 등록된 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/js_baek/post/15c79efc-e365-40b2-91ec-344fbc20918b/image.png" alt=""></p>
<h3 id="select">select</h3>
<p>누구든 업로드한 이미지를 볼 수 있다.
<img src="https://velog.velcdn.com/images/js_baek/post/c574fbf8-847f-4fe9-87bd-361f65d0613f/image.png" alt=""></p>
<h3 id="create-update-delete">create, update, delete</h3>
<p>추가/수정/삭제를 요청한 유저의 아이디가 버켓의 이름과 동일해야만 삭제할 수 있다.
<img src="https://velog.velcdn.com/images/js_baek/post/aa5595b2-bd51-4279-839b-66f50a1c7a11/image.png" alt=""></p>
<hr>
<p>위와 같이 정책을 설정하고 나면 이제 기본적인 세팅은 끝났다고 할 수 있다.</p>
<h2 id="3-이미지-업로드">3. 이미지 업로드</h2>
<p>이미지 업로드는 크게 어려울 것이 없다. supbase 클라이언트를 불러와서 storage 메서드를 아래와 같이 사용하면 된다. <code>from</code>메서드에는 매개변수로 버켓의 이름(해당 포스트의 경우 UPLOADS)을, <code>upload</code>메서드에는 파일을 저장할 경로와 해당하는 파일을 함께 넘겨주면 된다.
정상적으로 파일이 업로드가 되면 <code>getPublicUrl</code> 메서드를 통해서 실제 해당 파일에 접근할 수 있는 <code>publicUrl</code>을 얻을 수 있다. 업로드 후 <code>img</code> 태그에 해당 URL을 넣어주면 업로드한 이미지를 확인할 수 있는 것이다!</p>
<pre><code class="language-ts">/** -----------------------------
 * @description 이미지 업로드
 * @param file 업로드할 파일
 * @param filePath 파일 경로
 * @returns 업로드된 이미지 URL
 * ----------------------------- */
export const uploadImage = async ({
  file,
  filePath,
}: {
  file: File;
  filePath: string;
}) =&gt; {
  const { data, error } = await supabase.storage
    .from(BUCKET_NAME)
    .upload(filePath, file);

  if (error) throw error;

  const {
    data: { publicUrl },
  } = supabase.storage.from(BUCKET_NAME).getPublicUrl(data.path);

  return publicUrl;
};
</code></pre>
<p>아래는 실제 사용하고 있는 코드의 예시이다. 먼저 파일 이름이 중복되지 않도록 현재 일시(ms)와 uuid로 새로운 파일명을 만들어주고, 위에서 설명했던 것처럼 <code>/유저아이디/포스트아이디</code> 위치에 저장될 수 있도록 경로를 지정해준다. 그리고 반환된 publicUrl을 post 테이블에 함께 저장해주는 것이다. </p>
<pre><code class="language-ts">if (images.length &gt; 0) {
  // * 2. 이미지 업로드
  imagesUrls = await Promise.all(
    images.map((image) =&gt; {
      const fileExtension = image.name.split(&quot;.&quot;).pop() || &quot;webp&quot;;
      const fileName = `${Date.now()}-${crypto.randomUUID()}.${fileExtension}`;
      const filePath = `${userId}/${post.id}/${fileName}`;
      return uploadImage({ file: image, filePath });
    })
  );
}</code></pre>
<h2 id="4-이미지-삭제">4. 이미지 삭제</h2>
<p>만약 이미지를 첨부했던 포스트가 삭제되었을 경우에는, 굳이 삭제된 포스트의 이미지를 버켓 안에 계속 가지고 있을 이유가 없다. 이때는 우리가 지정했던 경로 내에 있는 파일 리스트를 받아와서 그것들을 삭제하는 방식으로 버켓을 비워준다.</p>
<pre><code class="language-ts">/** -----------------------------
 * @description 이미지 삭제
 * @param path 파일 경로
 * @returns 이미지 삭제 결과
 * ----------------------------- */
export const deleteImagesInPath = async (path: string) =&gt; {
  const { data: files, error: fetchFilesError } = await supabase.storage
    .from(BUCKET_NAME)
    .list(path);

  if (!files || files.length === 0) return;
  if (fetchFilesError) throw fetchFilesError;

  const { error: removeError } = await supabase.storage
    .from(BUCKET_NAME)
    .remove(files.map((file) =&gt; `${path}/${file.name}`));

  if (removeError) throw removeError;
};</code></pre>
<p>실제로 post 삭제 함수 내에서는 아래와 같은 방식으로 호출하고 있다.</p>
<pre><code class="language-ts">// * 이미지 삭제
if (deletedPost.image_urls &amp;&amp; deletedPost.image_urls.length &gt; 0) {
  await deleteImagesInPath(`${deletedPost.author_id}/${deletedPost.id}`);</code></pre>
<hr>
<p>상당히 간단하게 이미지를 업로드하고, 또 업로드한 이미지를 삭제하는 기능을 구현해 보았다. 알면 알수록 개발을 편리하게 해주는 도구들이 많은 것 같다...... 배움에는 정말 끝이 없구나!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[카카오톡 공유하기 기능을 개발해보자!]]></title>
            <link>https://velog.io/@js_baek/%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EA%B3%B5%EC%9C%A0%ED%95%98%EA%B8%B0-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EA%B0%9C%EB%B0%9C%ED%95%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@js_baek/%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EA%B3%B5%EC%9C%A0%ED%95%98%EA%B8%B0-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EA%B0%9C%EB%B0%9C%ED%95%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 26 Feb 2026 13:04:02 GMT</pubDate>
            <description><![CDATA[<p>이전에 진행하던 사이드 프로젝트를 마무리하기 위해 카카오톡 공유하기 기능을 개발해 보려고 한다. 가이드 문서가 잘 정리되어 있기 때문에 적혀 있는 대로 차근차근 하나씩 진행하면 금방 개발하고자 하는 기능을 구현할 수 있었다!</p>
<h2 id="1-카카오-디벨롭퍼스에-내-앱-등록">1. 카카오 디벨롭퍼스에 내 앱 등록</h2>
<p><a href="https://developers.kakao.com/">카카오 디벨롭퍼스 링크</a></p>
<p>카카오 디벨롭퍼스에 접속해서 연동하고자 하는 앱을 등록해야 한다. 한 번도 사용해본 적이 없다면 우선 회원가입부터 진행한 후, 아래 사진에 표시되어 있는 &#39;앱&#39; 메뉴를 클릭하여 이동한다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/ba735663-b0f0-46e4-a8c0-7f9905110da3/image.png" alt=""></p>
<p>앱 메뉴를 클릭하면 아래와 같은 화면이 나온다. 현재 개발 중이라 어딘가에 배포가 되어 있지 않더라도 앱 대표 도메인에 localhost 주소를 입력하면 무사히 앱을 등록할 수 있다. 무척 간단하다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/4db03d98-f619-4420-85bf-a796eaa371e1/image.png" alt="">
<img src="https://velog.velcdn.com/images/js_baek/post/c9aa40c7-4792-4f0b-863c-25d6168a5655/image.png" alt=""></p>
<h2 id="2-제품-링크-관리">2. 제품 링크 관리</h2>
<p>공유나 메시지 등 링크 이동을 허용할 웹 도메인을 등록하기 위해 좌측 메뉴의 앱 &gt; 제품 링크 관리 메뉴를 클릭하여 이동한다.
<img src="https://velog.velcdn.com/images/js_baek/post/dbc57c26-c1fd-4fe0-84f6-0dc7d38dd34a/image.png" alt="">
나는 실제 배포되어 있는 도메인과, 개발 환경에서도 확인해보기 위해 localhost도 함께 등록해 놓았다. &#39;웹 도메인 수정&#39; 버튼을 클릭하면 원하는 도메인을 등록할 수 있다.
<img src="https://velog.velcdn.com/images/js_baek/post/88026c82-ddb8-494b-94ac-f72785afe905/image.png" alt=""></p>
<h2 id="3-javascript-키-확인-및-sdk-사용을-위한-도메인-등록">3. JavaScript 키 확인 및 SDK 사용을 위한 도메인 등록</h2>
<p>공유하기 기능을 사용하기 위해서는 카카오에서 제공하는 JavaScript SDK를 불러와서 사용해야 한다. 이때 SDK를 최초 init 하는 과정에서 사용되는 것이 JavaScript 키라고 보면 된다. 다만 키가 있다고 모두 사용할 수 있는 것은 아니고, 허용하는 도메인을 등록해야 한다. 먼저 좌측 메뉴에서 앱 &gt; 플랫폼 키 메뉴로 이동한다.
<img src="https://velog.velcdn.com/images/js_baek/post/dc13ef18-558d-47d1-8f9f-605051dc8610/image.png" alt="">
가운데에 있는 JavaScript 키가 우리가 사용할 값이다. 복사해서 잘 간직해두고, 가운데 영역을 클릭하면 더 상세한 설정을 위한 화면으로 이동한다.
<img src="https://velog.velcdn.com/images/js_baek/post/db9493aa-d09a-4c6d-8b4f-32328ddf8fde/image.png" alt="">
이동한 화면에서 도메인을 등록한다. 이번에도 역시 개발 환경과 배포 환경에서 모두 사용할 수 있도록 두 가지 URL을 모두 등록하였다.
<img src="https://velog.velcdn.com/images/js_baek/post/3caf99f6-a94c-4e60-94c0-c153667b9206/image.png" alt=""></p>
<h2 id="4-sdk-불러오기">4. SDK 불러오기</h2>
<p><a href="https://developers.kakao.com/docs/latest/ko/javascript/download">JavaScript SDK 링크</a>
위 링크로 접속한 뒤 SDK를 불러오기 위한 <code>script</code> 태그를 복사한다. 그리고 <code>index.html</code>의 <code>body</code> 태그 안에 붙여넣기 하면 준비는 끝!</p>
<h2 id="5-공유하기">5. 공유하기</h2>
<p>모든 준비가 끝났으니 이제 본격적으로 공유하기 기능을 개발할 차례! 아래 링크로 가면 다양한 유형의 템플릿을 구현하는 방법을 확인할 수 있다.</p>
<p><a href="https://developers.kakao.com/docs/latest/ko/kakaotalk-share/js-link">카카오톡 공유 - JavaScript</a></p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/f85d6f1c-1bdb-4127-b246-8dbfd5f5edce/image.png" alt="">
나는 이미지와 글로 구성된 기본 메시지 형식인 피드를 사용해볼 것이다.</p>
<pre><code class="language-ts">export const kakaoShare = ({
  title,
  description,
  imageUrl,
  link,
  buttonLabel,
}: {
  title?: string;
  description?: string;
  imageUrl?: string;
  link?: string;
  buttonLabel?: string;
}) =&gt; {
  if (!Kakao.isInitialized()) {
    Kakao.init(import.meta.env.VITE_KAKAO_JS_KEY);
  }

  const urlLink = link ?? import.meta.env.VITE_DOMAIN;

  Kakao.Share.sendDefault({
    objectType: &#39;feed&#39;,
    content: {
      title: title ?? &#39;견생역전&#39;,
      description:
        description ?? &#39;매일 뒹굴거리고 맛난 것만 찾는 우리 강아지... 어쩌면!?&#39;,
      imageUrl:
        imageUrl ?? `${import.meta.env.VITE_DOMAIN}/assets/MainImage.png`,
      link: {
        mobileWebUrl: urlLink,
        webUrl: urlLink,
      },
    },
    buttons: [
      {
        title: buttonLabel ?? &#39;견생역전하러 가기&#39;, // 메세지 내에 버튼에 쓰여질 텍스트
        link: {
          mobileWebUrl: urlLink,
          webUrl: urlLink,
        },
      },
    ],
    // 카카오톡 미설치 시 카카오톡 설치 경로이동
    installTalk: true,
  });
};</code></pre>
<p>위와 같이 간단하게 세팅한 뒤 공유하기 버튼을 클릭하면 아래 사진과 같이 공유할 대상을 선택할 수 있는 목록이 나타나고...
<img src="https://velog.velcdn.com/images/js_baek/post/a8ee9976-f51d-4b83-b8ca-3722f53d0a8c/image.png" alt="">
대상을 선택한 뒤 공유하면 내가 설정한 내용대로 정상적으로 공유하기 카드가 나타나는 것을 확인할 수 있다! 간단하게 구현할 수 있어서 신기하고 재밌었다. ㅎㅎ
<img src="https://velog.velcdn.com/images/js_baek/post/ae07c897-8b08-4ce1-8a8e-2b05f88cc606/image.png" alt=""></p>
<blockquote>
<p><strong>참고</strong>
TypeScript를 사용하고 있는 경우 Kakao SDK를 사용할 때 해당하는 객체가 정의되어 있지 않다는 오류가 나타날 수 있다. 이때는 src/types 폴더 아래 혹은 src 폴더 바로 아래에 kakao.d.ts 파일을 추가하고 하기 내용을 작성한 뒤 저장하면 해결된다.
단순히 Kakao를 any 타입으로 지정해도 되고, 나는 내가 사용하는 속성이나 메서드의 타입을 작성해 보았다.</p>
</blockquote>
<pre><code class="language-ts">interface KakaoLink {
  mobileWebUrl: string;
  webUrl: string;
}

interface KakaoShareContent {
  title: string;
  description: string;
  imageUrl: string;
  link: KakaoLink;
}

interface KakaoShareButton {
  title: string;
  link: KakaoLink;
}

interface KakaoShareFeedOptions {
  objectType: &#39;feed&#39;;
  content: KakaoShareContent;
  buttons?: KakaoShareButton[];
  installTalk?: boolean;
}

declare const Kakao: {
  init(appKey: string): void;
  isInitialized(): boolean;
  Share: {
    sendDefault(options: KakaoShareFeedOptions): void;
  };
};</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Notification API로 브라우저에서 알림을 보내보자!]]></title>
            <link>https://velog.io/@js_baek/Notification-API%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90%EC%84%9C-%EC%95%8C%EB%A6%BC%EC%9D%84-%EB%B3%B4%EB%82%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@js_baek/Notification-API%EB%A1%9C-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80%EC%97%90%EC%84%9C-%EC%95%8C%EB%A6%BC%EC%9D%84-%EB%B3%B4%EB%82%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Sat, 21 Feb 2026 08:00:18 GMT</pubDate>
            <description><![CDATA[<p>2026년 새해를 맞이하여 열심히 살아야겠다는 다짐이 꽤 길게 이어지고 있다. 다른 사람과 내 기준은 당연히 다르기 때문에, 객관적으로 보면 그렇게 열심히는 아닐지도 모르겠지만 아무것도 하지 않을 때보다는 나름 보람(?)도 느껴지고 하면 할수록 더 의욕도 생기는 것 같은 기분이다.</p>
<p>그래서 사이드 프로젝트인 Trace를 1차적으로 마무리하고 난 이후에도 퇴근 후에나 주말에 책상 앞에 앉아 어떤 작업에 집중해야 하는 경우가 많이 생겼다. 그런데 태생적으로 집중력이 그렇게 좋지 못한 나...... 예전에도 종종 &#39;포모도로 기법&#39;이라는 걸 이용하고는 했는데 이번에도 그게 생각나서 기존에 있던 어플을 다운로드 하려던 찰나.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/507caf73-c839-4581-82e1-275e23d97ba0/image.png" alt="">
이거 기능을 최소화하면 금방 내가 직접 만들어서 사용할 수 있겠는데?라는 생각이 들었고 곧바로 실행에 옮겨 간단한 포모도로 타이머 페이지를 만들기 시작했다. 포모도로 기법이라는 건 정말 간단히 말해서 25분 집중 - 5분 휴식을 4번 반복한 후 30분 간 긴 휴식을 취하는 것을 말한다. 이러한 한 번의 사이클을 1 포모도로라고 칭하고 있다.</p>
<p>아무튼 집중하다 보면, 혹은 휴식하다 보면 화면을 보지 못할 수도 있고 그렇게 되면 타이머의 시간이 전부 흐른지도 확인할 수가 없기 때문에 간단하게 브라우저의 Notification API로 알림 기능을 구현해 보기로 했다. 사실 구현이라고 말하기도 부끄러운 게, 기본적으로 제공하고 있는 API를 단순히 호출만 하면 되는 방식이라 (...) 정말정말 간단하다.</p>
<h2 id="1-권한-요청">1. 권한 요청</h2>
<ul>
<li><p>먼저 유저가 사용하고 있는 브라우저에 알람 권한 허용을 요청한다.  당연하게도 이 단계에서 유저가 권한 요청을 거부했을 경우에는 알림 기능을 사용할 수 없다.</p>
</li>
<li><p>브라우저에 따라 약간의 제약은 있지만 대부분 지원하고 있는 표준적인 기능이라고 한다.</p>
<pre><code class="language-ts">const requestNotificationPermission = async () =&gt; {
// Noltification 기능이 존재하는지 확인
if (!(&quot;Notification&quot; in window)) return;

// 아직 사용자가 권한을 선택하지 않았을 경우에만 권한 요청
if (Notification.permission === &quot;default&quot;) {
 await Notification.requestPermission();
}
};</code></pre>
<ul>
<li><code>Notification.permission</code>의 값은 아래와 같이 나뉘므로 조건에 따라 권한 체크를 할 수 있다<ul>
<li><code>default</code>: 사용자에게 아직 권한을 요구하지 않았고, 따라서 알림을 표시하지 않음</li>
<li><code>granted</code>: 사용자에게 알람 표시 권한을 요구했으며 사요자가 권한을 허용함</li>
<li><code>denied</code>: 사용자가 명시적으로 알림 표시 권한을 거부했음</li>
</ul>
</li>
</ul>
</li>
</ul>
<h2 id="2-알림-만들기">2. 알림 만들기</h2>
<ul>
<li><p>사용자에게 알림 권한을 허용받았다면 이제 알림을 만들 차례다.</p>
<pre><code class="language-ts">const showNotification = (title: string, body: string) =&gt; {
if (!(&quot;Notification&quot; in window)) return;
if (Notification.permission !== &quot;granted&quot;) return;

new Notification(title, { body });
};</code></pre>
</li>
<li><p>오... 정말 간단하다! <code>Notification.permission !== &quot;granted&quot;</code>라면 아직 알림에 대한 권한 허용을 받지 못했거나, 사용자가 권한을 거부한 것이니 메시지를 보내지 않는다. 그리고 사용자가 권한을 허용했다면 메시지를 보낸다.</p>
</li>
<li><p>크롬을 기준으로, 알림 메시지를 발송하면 다음과 같은 알림 창이 시작 표시줄에 나타난다.
<img src="https://velog.velcdn.com/images/js_baek/post/fc2250e5-6804-4e64-a3e8-a3d403aaeefc/image.png" alt=""></p>
</li>
<li><p>제목이나 내용, 아이콘 등은 메서드를 호출할 때의 매개변수로 변경할 수 있다.</p>
</li>
</ul>
<h2 id="3-매개변수">3. 매개변수</h2>
<ul>
<li>크게 <code>title</code>과 <code>options</code> 두 가지의 매개변수로 나눌 수 있다.<h3 id="title">title</h3>
</li>
<li>알림의 제목으로, 반드시 입력해야 하는 필수값<h3 id="options">options</h3>
</li>
<li>body: 알림 본문 텍스트</li>
<li>icon: 알림에 표시될 아이콘</li>
<li>tag: 알림의 유형을 구분하여 동일한 유형이라면 덮어쓰기를 통해 중복을 방지할 때 사용</li>
<li>slient: 사운드 없이 알림 표시</li>
<li>data: 이벤트 핸들러 등에 사용되는 데이터</li>
<li>requireInteraction: 알림이 자동으로 닫히지 않고, 사용자가 클릭하거나 해제할 때까지 활성화 상태로 유지하는지 여부</li>
</ul>
<hr>
<ul>
<li>그 외 더 자세한 스펙은 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">이쪽 사이트</a>를 참고하면 된다.</li>
</ul>
<h2 id="4-이벤트-핸들러">4. 이벤트 핸들러</h2>
<ul>
<li><p><code>onclick</code>, <code>onclose</code>, <code>onerror</code>, <code>onshow</code> 등의 이벤트 핸들러가 있으나 보통 클릭에 대한 이벤트 핸들러를 많이 사용하게 된다</p>
</li>
<li><p>나의 경우에도 알림 발생 시 클릭하면 바로 기존 브라우저 화면이 포커스될 수 있도록 아래와 같이 알림 메시지 생성 함수를 변경했다</p>
<pre><code class="language-ts">export const showNotification = (title: string, body: string) =&gt; {
if (!(&quot;Notification&quot; in window)) return;
if (Notification.permission !== &quot;granted&quot;) return;

const notification = new Notification(title, { body, icon: &quot;/favicon.png&quot; });

notification.onclick = () =&gt; {
  window.focus();
  notification.close();
};
};
</code></pre>
</li>
</ul>
<pre><code>- 브라우저에 따라 제약이 있겠지만, window - chrome 환경에서는 정상적으로 동작하는 것을 확인했다.
- 그리고 마지막으로, 알림을 생성할 때 `options` 안에 `data` 객체를 넣으면 이벤트 핸들러 안에서 사용할 수 있으므로 유용하다
```ts
export const showNotification = (title: string, body: string) =&gt; {
  if (!(&quot;Notification&quot; in window)) return;
  if (Notification.permission !== &quot;granted&quot;) return;

  const notification = new Notification(title, {
    body,
    icon: &quot;/favicon.png&quot;,
    data: { test: &quot;test&quot; }, // 데이터 추가
  });

  notification.onclick = () =&gt; {
    console.log(notification.data?.test); // 데이터 접근
    window.focus();
    notification.close();
  };
};
</code></pre><hr>
<p>이렇게 오늘은 브라우저에서 기본적으로 제공하는 Notification API에 대해 알아보았다. 모바일 환경에 따른 제약이나 브라우저에 따른 제약이 있어 완전하게 알림에 대한 모든 기능을 커버할 수는 없겠지만 간단하게 사용하기는 좋을 것 같다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Trace 독서 활동 SNS 만들기 - 5]]></title>
            <link>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-5</link>
            <guid>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-5</guid>
            <pubDate>Mon, 16 Feb 2026 07:58:51 GMT</pubDate>
            <description><![CDATA[<p>낙관적 업데이트라는 개념은 tanstack-query를 학습하기 전부터 익히 들어왔던 내용이다. 특히 전 회사에서 SNS 프로젝트 개발에 참여했을 때, 좋아요 기능을 작업하는 과정에서 직접 구현했던 기억이 난다. 그때는 아직 경력이 1년도 안 되었을 시점이었고 tanstack-query를 사용하지 않았으며, 낙관적 업데이트라는 정확한 명칭에 대해 알지는 못 했지만 효용에 대해서는 확실히 학습했었던 것 같다.</p>
<h2 id="낙관적-업데이트">낙관적 업데이트</h2>
<p>낙관적 업데이트란 웹 애플리케이션에서 사용자의 경혐을 향상시키기 위해 등장한 개념이다. 기존의 웹 애플리케이션에서 UI를 업데이트 하려면 &#39;서버에 API를 요청하고, 요청한 것에 대한 응답이 돌아올 때까지 기다린 뒤 돌아온 값을 화면에 반영&#39;하는 과정을 거쳐야만 했다.</p>
<p>이렇게 되면 사용자가 어떤 버튼을 클릭했을 때, 해당하는 행동에 대한 반응이 화면에서 즉각적으로 나타나지 않기 때문에 사용자 경험이 저하되는 결과를 가져올 수가 있다. 예를 들어 어떤 포스트에 좋아요를 눌렀을 때 응답이 돌아오기까지 약 1초의 시간이 소요된다면 사용자의 입장에서는 1초만큼의 딜레이가 발생한 것처럼 느껴질 수도 있고, 그 사이에 여러 번 버튼을 눌러 불필요한 API 호출까지 발생할 수 있다. 물론 이 딜레이는 서버에서 돌아오는 응답이 느리면 느릴수록 더 심하게 체감된다.</p>
<p>이러한 문제를 해결하기 위해 등장한 개념이 &#39;낙관적 업데이트&#39;다.</p>
<h3 id="낙관적-업데이트란">낙관적 업데이트란?</h3>
<p>낙관적 업데이트란, 사용자의 동작에 대한 응답이 도착하기 전 UI 레벨에서 먼저 상태를 업데이트하는 기법을 의미한다. 즉 기존의 단계에서 &#39;서버에 API를 요청하고, 요청한 것에 대한 응답이 돌아올 때까지 기다린다&#39;는 단계를 획기적으로 축소시킨 것이다.</p>
<p>조금 전과 같은 예시로 사용자가 어떤 포스트에 좋아요를 눌렀을 때, 아직 좋아요를 누르지 않은 상태라면 좋아요를 누른 상태로 변경하고 그것을 UI에 즉시 반영한다. 이후에 서버에 요청한 것에 대한 응답값이 돌아오면, 응답 상태에 따라서 실제 서버 데이터를 반영하면 된다. (e.g. 오류가 발생했을 경우 업데이트 전의 상대로 다시 변경)</p>
<p>이렇게 함으로써 사용자는 웹     애플리케이션이 자신의 행위에 대해서 더욱 빠르게 대응하는 것처럼 느낄 수 있다. 물론, 상황에 따라 낙관적 업데이트를 적용해야 하는 기능과 그렇지 않은 기능이 존재할 수 있다. 이때는 로딩 후 서버 응답 값에 따라 화면의 상태를 변경하는 것이 좋다.</p>
<h3 id="낙관적-업데이트를-적용하는-것이-좋은-경우">낙관적 업데이트를 적용하는 것이 좋은 경우</h3>
<ol>
<li>성공 확률이 매우 높은 작업</li>
</ol>
<ul>
<li>네트워크 실패나 권한 실패가 드문 경우, 즉 실패 자체가 예외 케이스인 경우에는 낙관적 업데이트를 활용한 이득이 더 크다고 볼 수 있다.<blockquote>
<p>e.g. 좋아요, 북마크, 팔로우/언팔로우 등</p>
</blockquote>
</li>
</ul>
<ol start="2">
<li>결과를 클라이언트가 완전히 예측 가능한 경우</li>
</ol>
<ul>
<li>서버가 별도 계산/변형을 하지 않고 클라이언트에서 결과값을 예측할 수 있는 경우에도 낙관적 업데이트를 적용할 수 있다.<blockquote>
<p>e.g. 카운트 증가/감소, 리스트에 항목 추가/제거, 상태 플래그 변경 등</p>
</blockquote>
</li>
</ul>
<ol start="3">
<li>즉각적인 반응이 UX에 중요한 경우</li>
</ol>
<ul>
<li>즉각적으로 UI의 상태 변경이 반영될 때, 사용자가 체감하는 성능이 개선될 경우 낙관적 업데이트를 적용할 수 있다.<blockquote>
<p>e.g. 피드/SNS, 드로잉/편집 툴, 쇼핑몰의 장바구니 등</p>
</blockquote>
<h3 id="낙관적-업데이트를-적용하지-않는-것이-좋은-경우">낙관적 업데이트를 적용하지 않는 것이 좋은 경우</h3>
</li>
</ul>
<ol>
<li>실패 가능성이 의미 있게 높은 작업</li>
</ol>
<ul>
<li>낙관적 업데이트의 전제는 &quot;대부분 성공한다&quot;는 것으로, 실패율이 높으면 UI 롤백이 빈번해지고 사용자는 시스템을 불신하게 된다.<blockquote>
<p>e.g. 결제 요청, 재고 차감, 권한 검증이 복잡한 작업 등</p>
</blockquote>
</li>
</ul>
<ol start="2">
<li>롤백 비용이 큰 작업</li>
</ol>
<ul>
<li>롤백이 기술적으로 가능하더라도 인지적 비용이 크면 좋지 않다.</li>
<li>이 경우 사용자는 자신이 했던 복잡한 작업이 갑작스럽게 사라진 것으로 인식할 수 있다.<blockquote>
<p>e.g. 긴 텍스트 작성 후 저장 실패, 복잡한 폼 제출, 대규모 리스트 재정렬, 드래그 기반 편집 등</p>
</blockquote>
</li>
</ul>
<ol start="3">
<li>사용자 인지가 중요한 액션</li>
</ol>
<ul>
<li>&#39;실제로 성공했는지&#39; 여부가 중요한 작업의 경우에도 사용자가 혼란스러움을 느낄 수 있다.<blockquote>
<p>e.g. 삭제, 결제, 예약, 제출 등</p>
</blockquote>
</li>
</ul>
<h2 id="낙관적-업데이트-적용">낙관적 업데이트 적용</h2>
<p>SNS 프로젝트인 Trace에는 당연하게도(?) 좋아요 기능이 구현되어 있다. tanstack-query를 통해 좋아요 기능에 낙관적 업데이트를 적용하는 방법을 알아보자!</p>
<p>아래는 좋아요 토글 함수에 할당된 useMutation hook의 전문이다.</p>
<pre><code class="language-ts">/** -----------------------------
 * @description 포스트 좋아요 토글 뮤테이션
 * @param callbacks 콜백
 * @returns 포스트 좋아요 토글 뮤테이션
 * ----------------------------- */
export const useTogglePostLike = (callbacks?: UseMutationCallback) =&gt; {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: togglePostLike,
    onSuccess: () =&gt; {
      callbacks?.onSuccess?.();
    },
    onMutate: async ({ postId }) =&gt; {
      callbacks?.onMutate?.();

      await queryClient.cancelQueries({
        queryKey: QUERY_KEYS.post.byId(postId),
      });

      // * 이전 포스트 데이터 조회
      const prevPost = queryClient.getQueryData&lt;Post&gt;(
        QUERY_KEYS.post.byId(postId)
      );

      // * 포스트 데이터 업데이트
      queryClient.setQueryData&lt;Post&gt;(QUERY_KEYS.post.byId(postId), (post) =&gt; {
        if (!post) throw new Error(&quot;포스트가 존재하지 않습니다.&quot;);

        return {
          ...post,
          isLiked: !post.isLiked,
          like_count: post.isLiked ? post.like_count - 1 : post.like_count + 1,
        };
      });

      return { prevPost };
    },
    onError: (error, _, context) =&gt; {
      callbacks?.onError?.(error);

      // * 에러 발생 시 이전 포스트 데이터 복구
      if (context &amp;&amp; context.prevPost) {
        queryClient.setQueryData(
          QUERY_KEYS.post.byId(context.prevPost.id),
          context.prevPost
        );
      }
      if (callbacks?.onError) callbacks.onError(error);
    },
    onSettled: () =&gt; {
      callbacks?.onSettled?.();
    },
  });
};</code></pre>
<h3 id="1-이전-요청-취소">1. 이전 요청 취소</h3>
<p>만약 이전에 포스트 상세 정보에 대해 요청한 쿼리가 있을 경우, 낙관적 업데이트 내용이 이전 데이터로 덮어씌워질 수 있으므로 취소한다.</p>
<pre><code class="language-ts">await queryClient.cancelQueries({
  queryKey: QUERY_KEYS.post.byId(postId),
});</code></pre>
<h3 id="2-업데이트-실패에-대비하여-이전-쿼리-데이터-저장">2. 업데이트 실패에 대비하여 이전 쿼리 데이터 저장</h3>
<p>낙관적 업데이트를 시도하는 도중 여러 가지 이유로 실패하거나 다른 처리를 해주어야 할 때, 이전 쿼리 데이터를 사용할 수 있도록 따로 저장해 놓는다. 해당 데이터는 onMutate 콜백 함수의 마지막에서 return 해준다. (onError에서 사용할 수 있도록)</p>
<pre><code class="language-ts">const prevPost = queryClient.getQueryData&lt;Post&gt;(
  QUERY_KEYS.post.byId(postId)
);</code></pre>
<h3 id="3-쿼리-데이터-업데이트">3. 쿼리 데이터 업데이트</h3>
<p>미리 적용한 쿼리 키를 기반으로 해서, 업데이트한 포스트와 id가 같은 포스트의 좋아요 수를 변경한다. isLiked는 &#39;내가 해당 포스트에 좋아요를 눌렀는가&#39;를 나타내는 지표로, 현재의 값과 반대되는 값으로 업데이트하면 된다. 마찬가지로 현재 <code>post.isLiked === true</code>라면 이미 좋아요를 누른 상태에서 좋아요를 취소하기 위해 버튼을 클릭했다는 뜻이므로 카운트를 감소시키고, 반대의 경우 카운트를 증가시킨다.</p>
<pre><code class="language-ts">queryClient.setQueryData&lt;Post&gt;(QUERY_KEYS.post.byId(postId), (post) =&gt; {
  if (!post) throw new Error(&quot;포스트가 존재하지 않습니다.&quot;);

  return {
    ...post,
    isLiked: !post.isLiked,
    like_count: post.isLiked ? post.like_count - 1 : post.like_count + 1,
  };
});</code></pre>
<h3 id="4-에러-처리">4. 에러 처리</h3>
<p>onMutate에서 return한 prevPost 값은 onError에서 context로 넘겨받아 사용할 수 있다. 혹시 예기치 못한 에러가 발생했을 경우 저장해 두었던 값으로 롤백시킨다.</p>
<pre><code class="language-ts">onError: (error, _, context) =&gt; {
  callbacks?.onError?.(error);

  // * 에러 발생 시 이전 포스트 데이터 복구
  if (context &amp;&amp; context.prevPost) {
    queryClient.setQueryData(
      QUERY_KEYS.post.byId(context.prevPost.id),
      context.prevPost
    );
  }
  if (callbacks?.onError) callbacks.onError(error);
}</code></pre>
<h3 id="5-확인">5. 확인!</h3>
<p>아래 이미지와 같이 즉각적으로 좋아요 버튼이 반응하는 것을 확인할 수 있다!
<img src="https://velog.velcdn.com/images/js_baek/post/4e7434e9-58ac-45fc-8591-9d2e4f946212/image.gif" alt=""></p>
<hr>
<p>언뜻 보면 복잡하다고 생각될 수 있지만, tanstack-query에서는 직접적으로 쿼리 데이터를 수정하는 경우가 (낙관적 업데이트를 제외하더라도) 생각보다 많기 때문에 자주 사용하면 금방 익숙해질 수 있는 스킬 같다.</p>
<p>앞으로도 열심히 연습 또 연습!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기록 겸 진행 상황]]></title>
            <link>https://velog.io/@js_baek/%EA%B8%B0%EB%A1%9D-%EA%B2%B8-%EC%A7%84%ED%96%89-%EC%83%81%ED%99%A9</link>
            <guid>https://velog.io/@js_baek/%EA%B8%B0%EB%A1%9D-%EA%B2%B8-%EC%A7%84%ED%96%89-%EC%83%81%ED%99%A9</guid>
            <pubDate>Sun, 08 Feb 2026 08:52:22 GMT</pubDate>
            <description><![CDATA[<p>처음 사이드 프로젝트를 진행했을 때는 어디서부터 손을 대야 할지 막막했는데 막상 뼈대를 잡고 나니 오늘 (2월 8일) 1차적으로 마무리가 됐다.</p>
<p>아직 붙이고 싶은 기능도 많고, 오류도 많고 수정해야 할 부분도 많고... 디자인적으로도 손을 봐야 하겠지만 어느 정도 완성본이 눈에 보이니 참 뿌듯하긴 하다!</p>
<p>블로그에 진행 상황 기록을 많이 못 한 것 같아서 1차 마무리가 된 김에 주저리주저리 적으려고 찾아왔다. ㅎㅎ 처음 해보는 기능이 많았는데 AI의 도움을 정말... 크게 받아서 사용하기 나름이라는 것을 새삼스럽게 느끼게 되었다. 프로젝트는 1월 13일부터 시작했는데 퇴근 후 그리고 주말 시간 날 때 조금씩 만든 거라서 기능을 구현하고 모르는 것을 정리하는 일에 우선 순위를 두었다.</p>
<p>카카오톡 나에게 보내기 대화방에 프로젝트를 진행하며 궁금했던 점이나 느꼈던 점 같은 것들을 간단하게 적어두었으니 이제 그것들을 보면서 블로그에 하나둘씩 글을 올려볼 생각이다. 이번 주말도 열심히 불태웠다! 나는 누구보다 게으르기를 좋아하는 사람이지만 또 너무 게으르면 오히려 우울해지는 타입이라서 (완전 귀찮은 아이 재질) 적당히 나에게 도움이 될 만한 일을 하며 시간을 보내는 것이 참 즐거운 경험이었다. :)</p>
<p>팀으로 사이드 프로젝트를 진행하기로 해서 Trace는 여기서 잠깐 마무리하지만, 팀 프로젝트를 마무리하고 나면 다시 돌아와 부족했던 부분을 채워볼 생각이다! ㅎㅎ</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supabase를 통한 DM 기능 구현]]></title>
            <link>https://velog.io/@js_baek/Supabase%EB%A5%BC-%ED%86%B5%ED%95%9C-DM-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@js_baek/Supabase%EB%A5%BC-%ED%86%B5%ED%95%9C-DM-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 08 Feb 2026 08:46:47 GMT</pubDate>
            <description><![CDATA[<h2 id="목표">목표</h2>
<ul>
<li>유저 A가 유저 B에게 DM을 보낸다</li>
<li>하나의 대화방이 생성되고, 만약 이미 존재하는 대화방이 있다면 기존에 나누었던 대화 목록이 나타난다</li>
<li>새 메시지는 실시간으로 전송되고 읽음 처리도 가능하다</li>
</ul>
<h2 id="테이블">테이블</h2>
<h3 id="converstations-a와-b가-주고받는-메시지-묶음">converstations: A와 B가 주고받는 메시지 묶음</h3>
<table>
<thead>
<tr>
<th><strong>컬럼명</strong></th>
<th><strong>설명</strong></th>
<th><strong>타입</strong></th>
</tr>
</thead>
<tbody><tr>
<td>id</td>
<td>primary key</td>
<td>int8</td>
</tr>
<tr>
<td>create_at</td>
<td>대화방이 생성된 시간</td>
<td>timestamp</td>
</tr>
<tr>
<td>last_message_at</td>
<td>가장 최근에 메시지를 보낸 시간</td>
<td>timestamp</td>
</tr>
<tr>
<td>pair_key</td>
<td>대화방 중복 생성을 막기 위한 unique key</td>
<td>text</td>
</tr>
</tbody></table>
<h3 id="conversation_participants-대화방에-참여-중인-유저">conversation_participants: 대화방에 참여 중인 유저</h3>
<ul>
<li>conversation_id와 user_id를 primary key로 사용</li>
</ul>
<table>
<thead>
<tr>
<th><strong>컬럼명</strong></th>
<th><strong>설명</strong></th>
<th><strong>타입</strong></th>
</tr>
</thead>
<tbody><tr>
<td>conversation_id</td>
<td>참여하고 있는 대화방의 아이디</td>
<td>int8</td>
</tr>
<tr>
<td>user_id</td>
<td>참여 중인 유저의 아이디</td>
<td>uuid</td>
</tr>
<tr>
<td>created_at</td>
<td>대화방에 참여한 시간</td>
<td>timestamp</td>
</tr>
<tr>
<td>last_read_at</td>
<td>마지막에 메시지를 읽은 시간</td>
<td>timstamp</td>
</tr>
</tbody></table>
<h3 id="messages-유저가-상대방에게-전송한-메시지">messages: 유저가 상대방에게 전송한 메시지</h3>
<table>
<thead>
<tr>
<th><strong>컬럼명</strong></th>
<th><strong>설명</strong></th>
<th><strong>타입</strong></th>
</tr>
</thead>
<tbody><tr>
<td>id</td>
<td>메시지의 아이디</td>
<td>int8</td>
</tr>
<tr>
<td>conversation_id</td>
<td>메시지가 속한 대화방의 아이디</td>
<td>int8</td>
</tr>
<tr>
<td>sender_id</td>
<td>메시지를 보낸 유저의 아이디</td>
<td>uuid</td>
</tr>
<tr>
<td>content</td>
<td>메시지 내용</td>
<td>text</td>
</tr>
<tr>
<td>created_at</td>
<td>메시지 전송 시간</td>
<td>timestamp</td>
</tr>
</tbody></table>
<hr>
<h2 id="대화방-중복-생성-문제">대화방 중복 생성 문제</h2>
<h3 id="pair_key-생성">pair_key 생성</h3>
<ul>
<li>내가 상대방에게 처음 메시지를 보낼 때 방을 생성한다</li>
<li>그런데 상대방도 내게 처음 메시지를 보낼 때 방을 생성한다</li>
<li>이렇게 되면 두 명의 유저가 동시에 DM을 보냈을 때 방이 중복 생성될 수 있으므로 문제가 발생하고, 추가적으로 추후에 n명이 존재할 수 있는 대화방이 생겼을 때 1:1 대화방과 구분할 수 있는 방법이 없다.</li>
<li>따라서 <code>pair_key</code>라는, 두 유저 아이디의 조합을 생성한다 (아이디는 오름차순으로 정렬)</li>
</ul>
<pre><code class="language-jsx">alter table public.conversations
add column pair_key text unique;

-- pair_key 예: &#39;uuidA:uuidB&#39;</code></pre>
<h3 id="rpc-생성">RPC 생성</h3>
<ul>
<li>Supabase의 RPC는, DB 안에 미리 만들어 둔 함수를 프론트에서 API처럼 호출하는 것이다. 이를 통해 여러 개의 작업을 한 번에 처리할 수 있도록 한다</li>
<li>DM을 열 때 매번 하는 “기존 방 찾기 ⇒ 없으면 생성 ⇒ 유저 두 명을 participants에 insert ⇒ 해당하는 대화방의 아이디를 리턴”이라는 과정을 Supabase의 RPC로 정의하여 프론트에서 한 번의 호출만으로 일련의 과정을 처리할 수 있게 한다</li>
<li>이러한 RPC를 사용하지 않으면, 위에서 작성한 일련의 과정을 프론트에서 여러 개의 쿼리를 통해 실행시켜야 하기 때문에 동시성 문제가 발생할 위험이 있다</li>
<li>작성한 RPC의 내용은 아래와 같음</li>
</ul>
<pre><code class="language-jsx">create or replace function public.get_or_create_dm(other_user_id uuid)
returns int8
language plpgsql
security definer
as $$
declare
  me uuid := auth.uid();
  a text;
  b text;
  key text;
  cid int8;
begin
  if me is null then
    raise exception &#39;not authenticated&#39;;
  end if;

  a := me::text;
  b := other_user_id::text;

  if a &lt; b then key := a || &#39;:&#39; || b;
  else key := b || &#39;:&#39; || a;
  end if;

  select id into cid
  from public.conversations
  where pair_key = key;

  if cid is not null then
    return cid;
  end if;

  insert into public.conversations (pair_key)
  values (key)
  returning id into cid;

  insert into public.conversation_participants (conversation_id, user_id)
  values (cid, me), (cid, other_user_id);

  return cid;
end;
$$;</code></pre>
<ul>
<li>클라이언트에서 RPC 실행</li>
</ul>
<pre><code class="language-jsx">export const fetchOrCreateDm = async (userId: string) =&gt; {
  const { data, error } = await supabase.rpc(&quot;get_or_create_dm&quot;, {
    other_user_id: userId,
  });

  if (error) throw error;
  return data;
};</code></pre>
<hr>
<h2 id="실시간-메시지-전송">실시간 메시지 전송</h2>
<ul>
<li>이번에도 이전의 앱 내 알림과 마찬가지로 Supabase의 Realtime을 사용한다. 사용한 방식은 거의 동일하다</li>
<li>대신 이전의 리스트와 다른 점은, 채팅방이기 때문에 최신 메시지가 가장 아래에 위치하고 스크롤의 맨 위에 도달했을 때 그 이전의 리스트를 추가로 불러와야 한다는 점</li>
<li>따라서 새로운 메시지를 수신했을 때 그 메시지를 맨 첫 번째 페이지에 추가한다</li>
</ul>
<h3 id="publication에-messages-테이블-추가">publication에 messages 테이블 추가</h3>
<pre><code class="language-tsx">alter publication supabase_realtime
add table public.messages;</code></pre>
<h3 id="메시지-채널-구독">메시지 채널 구독</h3>
<ul>
<li>메시지 테이블에 현재 채팅방과 동일한 아이디를 가지고 있는 로우가 추가되었을 경우 알림 전달</li>
</ul>
<pre><code class="language-tsx">export const subscribeMessages = (
  conversationId: number,
  onInsert: (message: MessageEntity) =&gt; void
) =&gt; {
  const channel = supabase
    .channel(`messages:${conversationId}`)
    .on(
      &quot;postgres_changes&quot;,
      {
        event: &quot;INSERT&quot;,
        schema: &quot;public&quot;,
        table: &quot;messages&quot;,
        filter: `conversation_id=eq.${conversationId}`,
      },
      (payload) =&gt; {
        onInsert(payload.new as MessageEntity);
      }
    )
    .subscribe();

  return () =&gt; {
    supabase.removeChannel(channel);
  };
};</code></pre>
<h3 id="컴포넌트-내-쿼리-데이터-처리">컴포넌트 내 쿼리 데이터 처리</h3>
<ul>
<li>채팅 방 입장 시 실제로 WebSocket을 연결하고, 메시지 수신 이후의 처리에 대해 함수 작성</li>
<li>내가 보낸 것이든, 다른 유저가 보낸 것이든 메시지를 수신하여 전달 받은 메시지를 가장 첫 번째 페이지에 추가함</li>
<li>limit를 이용한 페이지네이션이 아니라 cursor 방식을 사용하고 있으므로 데이터가 꼬일 염려 X</li>
</ul>
<pre><code class="language-tsx">  const updateQueryData = (message: MessageEntity) =&gt; {
    const conversationId = message.conversation_id;

    queryClient.setQueryData&lt;InfiniteData&lt;PageData&lt;MessageEntity&gt;&gt;&gt;(
      QUERY_KEYS.dm.conversation(conversationId),
      (prevMessages) =&gt; {
        if (!prevMessages) throw new Error(&quot;메시지가 존재하지 않습니다.&quot;);

        const exists = prevMessages.pages.some((page) =&gt;
          page.items.some((item) =&gt; item.id === message.id)
        );
        if (exists) return prevMessages;

        const first = prevMessages.pages[0];

        const nextFirst = {
          ...first,
          items: [message, ...first.items],
        };

        return {
          ...prevMessages,
          pages: [nextFirst, ...prevMessages.pages.slice(1)],
        };
      }
    );
  };</code></pre>
<hr>
<h2 id="유저가-채팅방-밖에-있을-때-수신-처리">유저가 채팅방 밖에 있을 때 수신 처리</h2>
<ul>
<li>위에서는 특정 대화방에 대한 이벤트를 구독했고, 유저가 다른 화면에 있을 때 어떤 채팅방이든 메시지를 수신했다면 DM 메뉴에 뱃지 표시를 할 수 있도록 처리</li>
</ul>
<h3 id="메시지-채널-구독-1">메시지 채널 구독</h3>
<ul>
<li>메시지의 sender_id(보낸 사람의 user_id)가 내 것과 다를 때 메시지를 수신 (위의 이벤트와 조건이 다르다)</li>
<li>Supabase의 RLS 정책 덕에 내가 아닌 다른 유저에게 전송된 메시지는 애초에 select를 할 수 없으므로, 나에게 수신된 메시지에 대해서만 이벤트를 받을 수 있음</li>
</ul>
<pre><code class="language-tsx">export const subscribeIncomingDm = ({
  userId,
  onIncoming,
}: {
  userId: string;
  onIncoming: (message: MessageEntity) =&gt; void;
}) =&gt; {
  const channel = supabase
    .channel(`dm-incoming:${userId}`)
    .on(
      &quot;postgres_changes&quot;,
      {
        event: &quot;INSERT&quot;,
        schema: &quot;public&quot;,
        table: &quot;messages&quot;,
        filter: `sender_id=neq.${userId}`,
      },
      (payload) =&gt; {
        const msg = payload.new as MessageEntity;

        onIncoming(msg);
      }
    )
    .subscribe();

  return () =&gt; {
    supabase.removeChannel(channel);
  };
};</code></pre>
<h3 id="읽지-않은-메시지가-존재하는지-조회하는-api">읽지 않은 메시지가 존재하는지 조회하는 API</h3>
<ul>
<li>Supabase에서 RPC를 생성하여 여러 개의 조건을 복잡하게 실행할 필요성을 줄임</li>
</ul>
<pre><code class="language-tsx">export const fetchHasUnreadDm = async () =&gt; {
  const { data, error } = await supabase.rpc(&quot;has_unread_dm&quot;);

  if (error) throw error;
  return data ?? false;
};</code></pre>
<ul>
<li>useQuery로 훅을 생성하여 메시지를 수신할 경우 hasUnread 관련 쿼리 키를 invalidate</li>
<li>해당 데이터를 사용하고 있는 nav 컴포넌트에서 최신 데이터를 다시 받아오도록 처리</li>
<li>dm 리스트도 마찬가지로 재호출</li>
</ul>
<pre><code class="language-tsx">const unsubscribeDm = subscribeIncomingDm({
  userId: session.user.id,
  onIncoming: () =&gt; {
    queryClient.invalidateQueries({
      queryKey: QUERY_KEYS.dm.hasUnread,
    });

    queryClient.invalidateQueries({
      queryKey: QUERY_KEYS.dm.list,
    });
  },
});</code></pre>
<hr>
<h2 id="메시지-읽음-처리">메시지 읽음 처리</h2>
<ul>
<li>내가 이 메시지방에 참여한 유저로서 저장되어 있는 conversation_participants의 last_read_at 컬럼을 업데이트 한다</li>
<li>마지막으로 메시지가 전송된 시간 &gt; 마지막으로 메시지를 읽은 시간이라면 읽지 않은 메시지가 존재한다는 뜻이므로 이런 식으로 읽음 여부 + 읽지 않은 메시지의 개수도 계산할 수 있다</li>
</ul>
<h3 id="클라이언트에서-호출하는-api">클라이언트에서 호출하는 API</h3>
<ul>
<li>그런데 이 코드에서 한 가지 문제가 있었던 것이, 내 컴퓨터의 시간이 실제 시간보다 약 3분 정도 빠르게 설정되어 있어서 읽지 않은 메시지도 읽었다고 표기되는 경우가 있었다<ul>
<li>new Date()로 시간을 생성해서 그런 것으로 보인다</li>
</ul>
</li>
<li>컴퓨터 시간이 아니라 서버 시간으로 표시하는 로직이 필요할 것 같다</li>
</ul>
<pre><code class="language-tsx">export const markDmAsRead = async ({
  conversationId,
}: {
  conversationId: number;
}) =&gt; {
  const { data, error } = await supabase
    .from(&quot;conversation_participants&quot;)
    .update({ last_read_at: new Date().toISOString() })
    .eq(&quot;conversation_id&quot;, conversationId)
    .select()
    .single();

  if (error) throw error;
  return data;
};
</code></pre>
<hr>
<ul>
<li>이후에는 메시지방을 들어왔을 때 읽음 처리를 하는 로직이 있는데, 메시지 방에 이미 들어온 상태에서 새로운 메시지가 수신되었을 때 읽음 처리가 정상적으로 이루어지지 않는 문제가 있었다.</li>
<li>메시지를 수신했을 때 최신 메시지가 화면에 보였는지 여부에 따라 읽음 처리를 실행하도록 바꿔야 할 필요성을 느꼈다. 어쨌든 아래와 같이 정상적으로 구현된 것을 확인. (+ dm 방 삭제 등에 대한 기능도 추가 필요함)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/js_baek/post/afbfb7f2-7564-41d5-9c05-8fbaa7ce4b3b/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Supbase를 통한 앱 내 알림 기능 구현]]></title>
            <link>https://velog.io/@js_baek/Supbase%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%95%B1-%EB%82%B4-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@js_baek/Supbase%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%95%B1-%EB%82%B4-%EC%95%8C%EB%A6%BC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Thu, 05 Feb 2026 08:55:05 GMT</pubDate>
            <description><![CDATA[<ul>
<li>푸시 알림이 아니라, 웹 페이지 내 단순 알림에 대한 구현 방법을 기록한다</li>
<li>notifications 테이블을 생성하여 알림의 정보를 쌓는다</li>
<li>프론트에서는 해당 테이블에 새로운 데이터가 추가되었는지 여부를 구독하여 알림 데이터를 업데이트한다</li>
</ul>
<h2 id="notifications-테이블의-구조">notifications 테이블의 구조</h2>
<table>
<thead>
<tr>
<th><strong>컬럼명</strong></th>
<th><strong>설명</strong></th>
<th><strong>타입</strong></th>
</tr>
</thead>
<tbody><tr>
<td>id</td>
<td>primary key</td>
<td>number</td>
</tr>
<tr>
<td>user_id</td>
<td>알림을 받는 대상</td>
<td>uuid</td>
</tr>
<tr>
<td>actor_id</td>
<td>행위를 발생시킨 대상, 시스템 알림이라면 null</td>
<td>uuid (null)</td>
</tr>
<tr>
<td>type</td>
<td>행위의 종류</td>
<td>text</td>
</tr>
<tr>
<td>context</td>
<td>행위의 대상</td>
<td>jsonb</td>
</tr>
<tr>
<td>create_at</td>
<td>행위 발생 일시</td>
<td>timestamp</td>
</tr>
<tr>
<td>is_read</td>
<td>알림 읽음 여부</td>
<td>boolean</td>
</tr>
</tbody></table>
<hr>
<h2 id="알림을-쌓는-방식">알림을 쌓는 방식</h2>
<ul>
<li>프론트가 요청을 보낼 때 직접 notifications에 insert 하는 것이 아니라, DB의 트리거를 활용</li>
</ul>
<h3 id="트리거">트리거?</h3>
<ul>
<li>DB 이벤트 발생 시 자동으로 실행되는 함수</li>
<li>comment insert 시 발생하는 트리거</li>
</ul>
<pre><code class="language-tsx">create or replace function public.notify_on_comment_insert()
returns trigger
language plpgsql
security definer
as $$
declare
  v_post_author_id uuid;
  v_parent_author_id uuid;
begin
  -- 글 작성자 조회
  select author_id
    into v_post_author_id
  from public.post
  where id = new.post_id;

  -- 1) 글쓴이에게 알림 (자기 자신 제외)
  if v_post_author_id is not null and v_post_author_id &lt;&gt; new.author_id then
    insert into public.notifications (user_id, actor_id, type, context)
    values (
      v_post_author_id,
      new.author_id,
      &#39;comment&#39;,
      jsonb_build_object(
        &#39;postId&#39;, new.post_id,
        &#39;commentId&#39;, new.id,
        &#39;parentCommentId&#39;, new.parent_comment_id,
        &#39;rootCommentId&#39;, new.root_comment_id
      )
    );
  end if;

  -- 2) 대댓글이면 부모 댓글 작성자에게도 알림 (자기 자신/중복 제외)
  if new.parent_comment_id is not null then
    select author_id
      into v_parent_author_id
    from public.comment
    where id = new.parent_comment_id;

    if v_parent_author_id is not null
       and v_parent_author_id &lt;&gt; new.author_id
       and v_parent_author_id &lt;&gt; v_post_author_id then
      insert into public.notifications (user_id, actor_id, type, context)
      values (
        v_parent_author_id,
        new.author_id,
        &#39;comment_reply&#39;,
        jsonb_build_object(
          &#39;postId&#39;, new.post_id,
          &#39;commentId&#39;, new.id,
          &#39;parentCommentId&#39;, new.parent_comment_id,
          &#39;rootCommentId&#39;, new.root_comment_id
        )
      );
    end if;
  end if;

  return new;
end;
$$;

drop trigger if exists trg_notify_on_comment_insert on public.comment;

create trigger trg_notify_on_comment_insert
after insert on public.comment
for each row
execute function public.notify_on_comment_insert();</code></pre>
<hr>
<h2 id="알림을-전달하는-방식">알림을 전달하는 방식</h2>
<ul>
<li>Supabse Realtime 사용</li>
<li>Supbase Realtime을 통해 DB에서 일어나는 변경 사항을 감지할 수 있도록 클라이언트 사이드에서 이벤트 등록</li>
</ul>
<h3 id="realtime">Realtime?</h3>
<ul>
<li>Supabase에서 제공하는 기능으로, DB의 변경사항을 WebSocket으로 프론트엔드에 실시간으로 전달해주는 시스템</li>
</ul>
<pre><code class="language-tsx">[프론트]
   ↑ WebSocket
[Realtime 서버]
   ↑ DB 변경 스트림
[Postgres]</code></pre>
<ul>
<li>프론트는 Realtime 서버에 WebSocket 연결</li>
<li>Realtime 서버는 Postgres의 변경 로그를 구독</li>
<li>Postgres에서 insert/update/delete 발생</li>
<li>Realtime 서버가 그걸 프론트에 push</li>
</ul>
<h3 id="publication">publication</h3>
<ul>
<li><p>Postgres(DB)에게 테이블에 변경사항이 생기면 외부로 내보낼 것을 인지시켜야 함</p>
</li>
<li><p>따라서 publication이라는 개념이 등장: 여기에 해당하는 테이블의 변경사항을 외부에 공개하겠다는 것</p>
</li>
<li><p>Supabase는 publication을 <code>supabase_realtime</code> 라는 이름으로 만들어 관리함</p>
</li>
<li><p><code>supabase_realtime</code> 에 테이블을 등록해야만 DB의 변경사항을 감지하게 됨</p>
</li>
<li><p>등록 방법</p>
<pre><code class="language-sql">  alter publication supabase_realtime
  add table public.notifications;</code></pre>
</li>
</ul>
<h3 id="클라이언트에서-이벤트를-구독하는-방법">클라이언트에서 이벤트를 구독하는 방법</h3>
<ul>
<li>notifications 테이블에 insert가 일어났을 때 발생하는 이벤트를 구독</li>
</ul>
<pre><code class="language-tsx">const subscribeNotificationInserts = ({
  userId,
  onInsert,
}: {
  userId: string;
  onInsert: (row: NotificationEntity) =&gt; void;
}) =&gt; {
  const channel = supabase
    .channel(`notifications:${userId}`) // 구독 채널 생성
    .on(
      &quot;postgres_changes&quot;,
      {
        event: &quot;INSERT&quot;,
        schema: &quot;public&quot;,
        table: &quot;notifications&quot;,
        filter: `user_id=eq.${userId}`,
      }, // 구독할 이벤트의 종류에 대한 설정 (여기서는 notifications 
      (payload) =&gt; {
        onInsert(payload.new as NotificationEntity); // 실제 이벤트 발생 시 실행할 콜백 함수
      }
    )
    .subscribe((status) =&gt; {
      console.log(&quot;[realtime] status:&quot;, status); // 구독 성공 여부 로깅
    });

  return () =&gt; {
    supabase.removeChannel(channel); // unmount 시 연결 채널 제거
  };
};</code></pre>
<h3 id="실제-컴포넌트-상에서의-사용-예시">실제 컴포넌트 상에서의 사용 예시</h3>
<ul>
<li>이벤트 수신 시 notification.count에 담겨 있던 알림 뱃지 숫자를 1 증가시킴</li>
</ul>
<pre><code class="language-tsx">useEffect(() =&gt; {
    if (session?.user.id) {
      const unsubscribe = subscribeNotificationInserts({
        userId: session.user.id,
        onInsert: () =&gt; {
          queryClient.setQueryData(
            QUERY_KEYS.notification.count(session.user.id),
            (old: number) =&gt; old + 1 // 이벤트 수신 시 notification count를 하나 증가
          );
        },
      });

      return () =&gt; unsubscribe();
    }
  }, [session?.user.id]);</code></pre>
<ul>
<li>실제로 아래 사진과 같이 코드 상에서 정의한 채널 이름으로 socket이 열리고 메시지를 수신하고 있는 것 확인 가능
<img src="https://velog.velcdn.com/images/js_baek/post/db8c4c27-8e6b-4fd9-86f5-fb981bc343b7/image.png" alt=""></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Tanstack Query] isFetching과 isPending의 차이점]]></title>
            <link>https://velog.io/@js_baek/Tanstack-Query-isFetching%EA%B3%BC-isPending%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@js_baek/Tanstack-Query-isFetching%EA%B3%BC-isPending%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Tue, 03 Feb 2026 09:38:21 GMT</pubDate>
            <description><![CDATA[<p>강의를 들으며 코드를 따라친 것을 제외하면 내가 만드는 프로젝트에서 tanstack query를 사용해본 것이 처음이었다. 사실 아직 모르는 부분도 많고, 내가 글을 읽으면서 공부하기보다는 직접 부딪히며 배우는 스타일이라고 생각해서 강의를 통해 기초를 익힌 뒤 무작정 프로젝트 개발에 들어갔다.</p>
<p>사실 프로젝트 개발이라고는 해도, 강의에서 따라 쳤던 코드를 기반으로 하는 거라서 크게 어려움은 없었다. (추가 기능 개발 시에는 GPT의 도움을 많이 받았고...) 그런데 매일 퇴근 후에 짬을 내서 사이드 프로젝트를 제작하다 보니, 그리고 최대한 빠르게 완성하고 싶은 욕심에 내가 잘 모르는 개념이 있더라도 작동만 제대로 하면 넘어가는 경우가 종종 있었다.</p>
<p>그 대표적인 예시가 바로 tanstack query의 상태 개념이다. 상태의 종류는 대표적으로 <code>isPending</code>, <code>isError</code>, <code>isSuccess</code>가 있으며 추가적으로 <code>isFetching</code>이라는 상태가 존재한다. 이름만 봐도 어떤 상태를 나타내는지 구분하는 데 어려움은 없지만, 두 가지 상태가 눈에 띈다.</p>
<h3 id="ispending과-isfetching">isPending과 isFetching</h3>
<p><code>isPeding</code>은 요청하는 중이라는 뜻 같은데, 이제 보니 <code>isFetching</code>도 동일한 의미처럼 보인다. 그리고 조금 예전 코드를 살펴보면 <code>isLoading</code>이라는 상태도 종종 발견할 수 있었다.
<img src="https://velog.velcdn.com/images/js_baek/post/f7a6457a-b87d-4367-9918-d27600cd026d/image.png" alt="">
솔직히 잘 몰랐다. 셋 중 아무거나 사용하더라도 처음에는 내가 의도한 대로 잘 적용되는 것처럼 보였거든! 내가 주로 위 세 가지 상태를 사용한 예시는 다음과 같았다.</p>
<ol>
<li>최초에 데이터를 불러오기 전, 로딩 상태를 나타내기 위해 사용</li>
<li>이후에 데이터를 추가로 불러올 때 로딩 상태를 나타내기 위해 사용</li>
<li>mutation으로 API 호출 후 중복 제출되지 않게 버튼을 disable 하기 위해 사용</li>
</ol>
<p>그런데 1번 상황에서 문제가 발생했다. 아무 생각없이 <code>isLoading</code>을 사용했다가 로딩 UI가 사라지지 않는 경우가 있었던 것이다. 음...? 대체 왜일까...... 그래서 <code>isFetching</code>으로 변경했더니 이번에는 내가 의도한 대로 동작했다.</p>
<p>분명 구분해서 만들어 놓은 데에는 이유가 있을 테니, 구현을 해놓고 오늘에야 그 차이점을 자세히 찾아보았다.</p>
<h3 id="ispending">isPending</h3>
<p><code>isPeding</code>은 해당 쿼리에 아직 데이터가 존재하지 않음을 의미한다. 즉, 화면에 최초 진입했을 때 아직 API를 통해 불러온 데이터가 없다면 <code>true</code>가 된다. 상기한 1번 경우에 아주 잘 어울리는 상태인 것이다.</p>
<h3 id="isfetching">isFetching</h3>
<p><code>isFetching</code>은 이미 데이터가 있는 상태든 아니든, 쿼리가 데이터를 가져오는 중이라면 <code>true</code>가 된다. 상기한 2번과 3번 상황에 잘 어울리는 상태라고 볼 수 있다.</p>
<h3 id="그럼-isloading은">그럼 isLoading은?</h3>
<p>TanstackQuery의 공식 문서를 찾아보면, 4버전까지는 isLoading이 존재했지만 5버전으로 넘어오면서 isPending으로 대체된 것으로 보인다.</p>
<h3 id="ispeding--isfetching">isPeding &amp;&amp; isFetching</h3>
<p>음! 이제 개념은 알겠다. 그런데 1번 상황에 대해서 다시 생각해보니, 참 이상한 점이 있다. <code>isLoading</code>을 사용했을 때 왜 최초에 계속 로딩 UI가 화면에 노출된 것일까? 가만히 생각해 보니, 내가 문제를 겪은 것은 검색 화면이었다. 최초 진입 시에는 검색 키워드가 없으니 해당 쿼리의 <code>enable</code> 속성이 <code>false</code>인 상태였다.</p>
<p>이에 관해 찾아보니, 애초에 쿼리가 실행된 적이 없으면 당연히 쿼리에 데이터도 없을 것이고 그러면 당연히<code>isPending</code>은 <code>true</code>가 될 수밖에 없다...... (초기화된 적이 없으니까)</p>
<p>그래서 이때는 <code>isPending &amp;&amp; isFetching</code>을 조건으로 걸어 이것을 initailLoading 여부를 판단하는 변수로 사용하면 된다. <code>isFetching</code>은 현재 요청이 진행되고 있는지, 아닌지 여부를 알려주고 있으니 저장된 데이터가 없더라도 요청을 하는 중이 아니라면 굳이 로딩을 띄워주지 않아도 된다고 판단할 수가 있는 것이다.</p>
<hr>
<p>여기까지 해서 Tanstack Query의 상태 개념에 대해 알아보았다. 공식 문서의 중요함을 오늘도 한 번 더 깨닫고 간다......... 이제 프로젝트에 적용하러 가야지!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Trace 독서 활동 SNS 만들기 - 4]]></title>
            <link>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-4</link>
            <guid>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-4</guid>
            <pubDate>Sun, 01 Feb 2026 12:50:25 GMT</pubDate>
            <description><![CDATA[<p>세상에는 참 많은 라이브러리가 있다. 종류도 다양한 만큼 사용 방법도 다양하고, 그것 때문에 사실 개발을 처음 시작했을 때는 막연하게 좀 두려운 존재라고 생각했었던 것 같다. 지금이야 GPT나 Claude 같은 존재를 알게 되었지만 4년 전의 나는 오로지 구글 서칭 한 우물을 파고 있었으니....... (라이브러리를 많이 쓰면 왠지 내 실력이 오르지 않을 것 같기도 했고)</p>
<p>아무튼 그런 이유로 대부분 직접 구현하는 일이 많았는데, 아무리 노력해도 내 능력으로는 할 수 없는 것이 있을 때는 라이브러리를 사용해야만 했다. 대표적인 것 중 하나가 바로 텍스트 에디터였다. 기능이 한두 개도 아니고, 어디서부터 어떻게 구현해야 할지...... 지금 생각해도 정말 까마득하다. 그때 내 기억에는 Quill, CKEditor 등을 사용했던 것 같은데, 이번 프로젝트에서는 tiptap을 사용하기로 했다.</p>
<p>내가 tiptap 에디터를 선택한 이유는 우선 최근 많이 사용하는 라이브러리라는 점도 있고, 원하는 기능만을 설치해서 사용할 수 있기 때문에 부담이 적다는 점도 있었다. headless를 표방하기 때문에 스타일링을 마음대로 구성할 수도 있다. 게다가 사용법도 간단하고, 기능뿐만 아니라 간단한 유틸과 UI를 제공하는 컴포넌트도 설치하여 사용할 수 있다!</p>
<p>사용하는 방법은 여러 가지가 있지만, tiptap에서 기본적으로 제공하는 컴포넌트의 디자인이 shadcn/ui의 디자인과 크게 다르지 않아서 이번에는 컴포넌트를 설치해 사용하는 방식을 택했다. 방법만 선택하면 그 다음은 정말 어렵지 않게 작업 가능!</p>
<p>우선 <a href="https://tiptap.dev/docs/editor/getting-started/install/react">tiptap 공식 문서</a>에 접속하여 설치 방법을 확인한다. </p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/375f9332-095f-4173-bf3f-ed1c3145bd64/image.png" alt="">
필수로 <code>@tiptap/react</code>, <code>@tiptap/pm</code>을 설치하면 에디터를 사용할 수 있으며, <code>@tiptap/starter-kit</code>은 기본적인 텍스트 스타일(폰트 두께, 크기 등...)을 지정하게 해준다. 스타터킷을 바탕으로 해서 필요에 따라 다양한 익스텐션을 설치하여 사용할 수 있으며, 심지어 커스텀 익스텐션을 만들어서 사용할 수도 있다!</p>
<p>회사 내 프로젝트에서 사용해본 적이 있는데(letter-spacing을 지정하는 기능이었다), css에서 지원하는 대부분의 기능을 익스텐션으로 만들 수 있고 심지어 사용자들이 미리 만들어 놓은 익스텐션도 있어 커스텀 자유도가 무척 높은 것이 장점이다. 자세한 내용은 아래 링크를 참고!</p>
<p><a href="https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new">tiptap 커스텀 확장</a></p>
<p>아무튼 라이브러리를 설치했다면 이제 컴포넌트 내에서 에디터를 초기화할 차례다!
초기화 코드는 매우 간단하다.</p>
<pre><code class="language-typescript">import { useEditor } from &quot;@tiptap/react&quot;;
import { StarterKit } from &quot;@tiptap/starter-kit&quot;;

export const Test = () =&gt; {
  const editor = useEditor({
    extensions: [StarterKit],
    content: &quot;&quot;,
    onUpdate: ({ editor }) =&gt; {
      setIsEmpty(editor.isEmpty);
    },
  });

  return (
       &lt;EditorContext.Provider value={{ editor }}&gt;
      &lt;EditorContent
         className=&quot;max-h-[200px] overflow-auto&quot;
         editor={editor}
      /&gt;
    &lt;/EditorContext.Provider&gt;
  )
}</code></pre>
<p><code>useEditor</code> 훅을 통해서 에디터 인스턴스를 생성하고 extensions에 내가 설치한 익스텐션을 배열에 담아서 지정해 주면 된다. content는 에디터 내용의 초기값이고, onUpdate 핸들러에는 현재 에디터가 비어 있는지 아닌지를 판단하기 위한 스테이트를 넣어 놓았다. ...정말 간단하다! 물론 아직 &#39;컨텐츠&#39; 밖에 없는 상태이기 때문에 완전한 에디터로 사용하기 위해서는 몇 가지 작업이 더 필요하다.</p>
<p>필요한 익스텐션을 설치한 상태고, 그것을 구현하기 위한 컴포넌트를 추가적으로 설치하지 않았다면 사용자가 직접 UI를 만들어서 아래와 같이 기능을 구현하면 된다.</p>
<pre><code class="language-ts">&lt;button
  onClick={() =&gt; editor.chain().focus().toggleBold().run()}
  disabled={!editorState.canBold}
  className={editorState.isBold ? &#39;is-active&#39; : &#39;&#39;}
&gt;
  Bold
&lt;/button&gt;</code></pre>
<p>위에서 생성했던 에디터 인스턴스와, 현재 에디터에서 선택된 블럭의 다양한 스타일링 상태를 저장하고 있는 <code>editorState</code>를 사용하여 쉽게 간단한 기능을 구현할 수 있다. 위의 예시는 폰트의 두께를 bold/normal로 토글하는 함수다.</p>
<p>나는 개발의 속도를 높이기 위해서 위와 같이 직접 구현하는 대신 컴포넌트를 추가하여 사용했다. 컴포넌트를 추가하는 방식은 shadcn/ui와 매우 유사한데, npx 명령어를 실행하면 내 프로젝트 폴더에 컴포넌트와 기능을 구현하는 데에 필요한 익스텐션, 그리고 유틸 함수가 들어 있는 ts 파일이 직접 추가된다. 사용 가능한 컴포넌트는 아래 링크에서 확인할 수 있다.</p>
<p><a href="https://tiptap.dev/docs/ui-components/components/overview">tiptap 에디터 컴포넌트</a>
참고로 가장 상단에 &#39;Available for free&#39;라고 적혀 있는 컴포넌트만 무료로 사용할 수 있고 그외에는 필요에 따라 결제가 필요하다. 나는 중요한 글자를 강조하기 위해 Color highlight popover 컴포넌트를 사용해 보려고 한다. 우선 터미널에 아래와 같은 명령어를 입력하여 컴포넌트를 프로젝트에 추가한다.</p>
<p><code>npx @tiptap/cli@latest add color-highlight-popover</code></p>
<p>그리고 화면에 불러와 적용한다. 새로 추가한 <code>ColorHighlightPopover</code> 컴포넌트와 함께 설치되었을 <code>Hgihligt</code> 익스텐션, 그리고 기본적인 스타일링을 도와줄 scss 파일을 함께 임포트했다. 추가한 익스텐션은 <code>useEditor</code> 안의 <code>extensions</code>에 추가하는 것을 잊지 말자! 에디터 인스턴스는 프로바이더에 의해 하위의 모든 컴포넌트에 공유된다.</p>
<pre><code class="language-ts">import { useEditor } from &quot;@tiptap/react&quot;;
import { ColorHighlightPopover } from &quot;@/components/tiptap-ui/color-highlight-popover&quot;;
import { StarterKit } from &quot;@tiptap/starter-kit&quot;;
import { Highlight } from &quot;@tiptap/extension-highlight&quot;;
import &quot;@/components/tiptap-node/paragraph-node/paragraph-node.scss&quot;;

export const Test = () =&gt; {
  const editor = useEditor({
    extensions: [StarterKit, Highlight],
    content: &quot;&quot;,
    onUpdate: ({ editor }) =&gt; {
      setIsEmpty(editor.isEmpty);
    },
  });

  return (
       &lt;EditorContext.Provider value={{ editor }}&gt;
      &lt;ColorHighlightPopover /&gt;
      &lt;EditorContent
         className=&quot;max-h-[200px] overflow-auto&quot;
         editor={editor}
      /&gt;
    &lt;/EditorContext.Provider&gt;
  )
}</code></pre>
<p>이렇게 화면에 추가하고 나면, 사진과 같은 모습이 나타난다.
<img src="https://velog.velcdn.com/images/js_baek/post/36fa13ba-4950-456d-b1f8-9fddc753f1fd/image.png" alt="">
초록색 동그라미로 표시한 부분이 바로 우리가 추가한 버튼 컴포넌트! 클릭하면 팝오버 메뉴가 나타나고, 텍스트의 범위를 지정하여 적용할 수도 있다. (팁탭 에티터라는 글자를 파란색으로 강조)
<img src="https://velog.velcdn.com/images/js_baek/post/d2f22246-319e-4bda-aaf3-d303bf1c10c8/image.png" alt="">
<img src="https://velog.velcdn.com/images/js_baek/post/078d4ce0-1d2e-4642-bc3f-78693a4a15c3/image.png" alt="">
컴포넌트가 잘 적용되는 것을 보았으니, 기능 구현에 필요한 <code>MarkButton</code>, <code>HeadingDropdownMenu</code>, <code>BlockquoteButton</code>, <code>LinkPopover</code> 버튼을 추가하여 에디터를 완성하면 대략 이런 느낌이다.
<img src="https://velog.velcdn.com/images/js_baek/post/5a7867ce-a38a-419b-ba42-a4706de50c4d/image.png" alt="">
추가한 기능을 보여주기 위해 다양한 스타일링을 시도해 보았다! 이제 이렇게 작성한 컨텐츠를 서버에 저장해 보려고 한다. tiptap 에디터로 작성된 컨텐츠는 html 그대로를 뽑아낼 수도 있고, tiptap에서 제공하는 json 형태로 뽑아낼 수도 있다. 나는 저장된 컨텐츠를 화면에 보여줄 때도 tiptap 에디터를 사용할 것이기 때문에, 같은 라이브러리라면 호환성이 좋은 json 형태로 저장할 것이다. 단순히 저장했던 컨텐츠를 json 형태 그대로 다시 에디터에 넣어주기만 하면 된다.</p>
<p>먼저 &#39;게시하기&#39; 버튼을 눌렀을 때 json 데이터를 얻는 방법은, 에디터 인스턴스에 내장된 <code>editor.getJSON</code> 메서드를 호출하는 것이다. ...그게 끝이다. json 안에는 컨텐츠에 대한 설정값과 내용들이 구조화되어 저장되어 있다. 그리고 불러올 때는 이렇게!</p>
<pre><code class="language-ts">const editor = useEditor({
  extensions: [StarterKit, Highlight],
  content: post.content, // 서버에 저장했던 값
  editable: false,
});</code></pre>
<p>작성에 사용했던 에디터에서 추가한 것과 같은 익스텐션과, 서버에서 저장했던 json을 추가해준 뒤 editable만 false로 바꾸면 완벽한 컨텐츠 렌더러가 된다 ㅎㅎ
<img src="https://velog.velcdn.com/images/js_baek/post/1195105b-2528-4f4b-a474-6ec199556107/image.png" alt="">
작성했던 것과 동일한 화면이 나타나는 것을 확인할 수 있다. 만약 에디터가 초기화되는 시점에 서버 데이터가 불러와지지 않아서 간혹 에디터가 빈 화면으로 나온다면 useEffect를 활용하며 에디터의 컨텐츠를 세팅해주면 된다.</p>
<pre><code class="language-ts">  useEffect(() =&gt; {
    if (post?.content) {
      editor?.commands.setContent(post.content as Content);
    }
  }, [post?.content]);</code></pre>
<p>오늘은 이렇게 간단하게 tiptap 에디터의 사용 방법에 대해 알아보았다. 라이브러리 자체에서 제공하는 사용 방법이 워낙 다양하고, 앞서 소개한 방식 외에 아예 대부분의 기능이 구현된 완전한 에디터 자체를 설치해서 사용할 수도 있다. 새롭게 에디터를 사용해야 하는 상황이 생긴다면, 권해보고 싶은 라이브러리인 것 같다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Trace 독서 활동 SNS 만들기 - 3]]></title>
            <link>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-3</link>
            <guid>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-3</guid>
            <pubDate>Wed, 28 Jan 2026 12:37:52 GMT</pubDate>
            <description><![CDATA[<p>열심히 블로그를 쓰겠다고 다짐한 지 며칠이나 지났다고, 작심삼일로 끝낼 뻔한 것을 겨우겨우 머릿속에 떠올려 다시 돌아왔다. 사실 블로그 글을 올리지 않는 동안 개발이 조금 진행돼서 오히려 써야 할 내용이 이래저래 쌓여 있는 상황이지만 차근차근 하나씩 해결해 보려고 한다.</p>
<p>우선 오늘은 shadcn/ui가 제공하는 기본적인 기능을 통해 간단하게 구현할 수 있는 라이트/다크모드 테마 설정 기능에 대해서 포스팅해 보려고 한다. 내가 직접 컴포넌트를 만들고, 색상 변수를 지정하고, 설정한 테마에 맞춰서 그것을 노출시키고...... 처음부터 끝까지 다 구현하려면 얼마나 많은 시간이 소요될까? 물론 직접 구현하는 것과, 이미 구현되어 있는 ui 컴포넌트를 사용하는 것은 서로 장단점이 존재하겠지만 (커스터마이징의 유용성이라든지, 복잡성이라든지......) shadcn/ui는 내가 생각하기에는 커스터마이징의 자유도가 높은 편인 것 같다.</p>
<p>개인적으로 가장 큰 장점이자 어쩌면? 진입 장벽으로 느껴질 수 있는 것이 바로 tailwind-css를 사용한다는 점 같은데, 이건 오늘의 주제가 아니니 넘어가고!</p>
<p>테마 설정을 구현하는 것에는 여러 가지 방법이 있겠지만, 요는 동일하다. 바로 html 태그에 &#39;light&#39; 또는 &#39;dark&#39; 클래스를 부여하는 것이다. 간단히 light 클래스를 추가하면 라이트모드가 되고, dark 클래스를 추가하면 다크 모드가 된다. 그 외 나머지 모~든 작업은 shadcn/ui가 처리했으니 걱정 말라구!
<img src="https://velog.velcdn.com/images/js_baek/post/b1eed6c0-8dba-44ad-8e7a-b7650bf00321/image.png" alt="">
그래서 우리가 할 것은 유저로부터 라이트/다크 모드 설정을 받아서 로컬 스토리지에 저장해놓은 뒤 화면에 접속할 때마다 그 값을 꺼내와 설정해 주는 것이다. 나는 context를 생성해서 그 작업을 처리할 예정이다.</p>
<p>우선 <code>theme-context.ts</code> 파일을 생성하여 아래와 같이 코드를 작성한다.
<img src="https://velog.velcdn.com/images/js_baek/post/0502f36a-037d-4e13-af6e-17210f01cc3a/image.png" alt=""></p>
<p>컨텍스트에 담길 값은 <code>theme</code>과, <code>theme</code>을 설정해주는 <code>setTheme</code> 두 가지. <code>ThemeProviderState</code>로 컨텍스트에 담길 상태의 타입을 정의한다.</p>
<p>생성한 컨텍스트에 초기값을 주입하기 위해 <code>createThemeProviderState</code> 함수를 선언하고, <code>createContext</code> 메서드를 호출하여 테마를 저장할 컨텐스트를 생성한다. (그리고 미리 정의한 함수를 통해 초기값을 주입!)</p>
<p>그리고 마지막으로 재사용성을 높이기 위해서 <code>useTheme</code>이라는 커스텀 훅을 생성한다. 매번 컴포넌트 안에서 useContext를 호출하지 않더라도 손쉽게 <code>theme</code>과 <code>useTheme</code>을 사용할 수 있게 해준다.</p>
<p>기본적인 준비가 마무리되었다면, 이번에는 provider를 만들 차례!
<img src="https://velog.velcdn.com/images/js_baek/post/c8f7d2f2-fdc3-4233-8c16-a4503d2e238a/image.png" alt="">
위에서부터 차근차근 살펴보자면, 먼저 <code>theme</code>을 관리하는 state를 설정하고, 이때 최초의 값은 로컬 스토리지의 값을 쓰거나 존재하지 않는다면 라이트 모드의 값을 쓴다.</p>
<p>그리고 최초 렌더링 및 테마가 변경될 때마다, html 태그의 class를 변경하기 위하여 기본적인 코드를 작성했다. 코드에는 system 테마도 존재하지만 (사용자의 기기 설정을 반영) 일단 내가 만든 UI는 라이트/다크 모드를 토글로 하여 구현했기 때문에 패스.</p>
<p>다음으로는 value를 생성하여 아래 provider에 값을 주입한다. 마지막으로 <code>App.tsx</code> 컴포넌트로 이동하여 아래와 같이 provider를 불러오면 준비 끝.
<img src="https://velog.velcdn.com/images/js_baek/post/e3f53aae-1dfc-44cc-87ee-ad867c29a9b4/image.png" alt="">
zustand로 구현해도 되고, 그냥 커스텀 훅으로 만들어서 사용해도 되지만 오랜만에 컨텍스트를 사용해볼 겸 이렇게 구현해 보았다. (사실 검색했을 때 가장 먼저 나오는 게 이 방법이라 그런 것도 있었고......)</p>
<p>이제 설정 메뉴로 가서 useTheme 훅을 가져다 사용하기만 하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/cc8a0df2-45be-4cdf-a5a9-09b1a02469f6/image.png" alt="">
컨텍스트를 생성하는 파일에서 미리 만들어두었던 훅을 가져와 메뉴의 클릭 이벤트에 붙여본다. 설정한 테마에 따라 아이콘과 텍스트가 다르게 보인다.
<img src="https://velog.velcdn.com/images/js_baek/post/c5c612e5-d225-4767-8abd-70b57b4431fd/image.png" alt="">
요랬는데<del>!
<img src="https://velog.velcdn.com/images/js_baek/post/dfbbb1d4-80af-4c1c-aa3c-c9f4a59d4b17/image.png" alt="">
요래 됐슴당</del> shadcn/ui에서 받아온 컴포넌트를 사용하고 + tailwind-css를 통해 스타일링을 하니 따로 뭘 더 설정하지 않아도 각각의 컴포넌트와 요소들이 테마에 따라 적절한 컬러를 가지게 된 것을 볼 수 있다.</p>
<p>참고로 이때 html 태그의 class는 다음과 같이 설정되어 있다.
<img src="https://velog.velcdn.com/images/js_baek/post/18765a8a-7afc-4855-bc1e-7fb46385c2b2/image.png" alt="">
굿! 완벽하게 구현했으니 이제 다음으로는 tiptap 에디터 사용기로 돌아와볼까 한다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[flex의 세계는 심오하다]]></title>
            <link>https://velog.io/@js_baek/flex%EC%9D%98-%EC%84%B8%EA%B3%84%EB%8A%94-%EC%8B%AC%EC%98%A4%ED%95%98%EB%8B%A4</link>
            <guid>https://velog.io/@js_baek/flex%EC%9D%98-%EC%84%B8%EA%B3%84%EB%8A%94-%EC%8B%AC%EC%98%A4%ED%95%98%EB%8B%A4</guid>
            <pubDate>Wed, 21 Jan 2026 14:58:07 GMT</pubDate>
            <description><![CDATA[<p>매번 새로운 프로젝트를 시작할 때마다 소소하게(?) 헷갈리고 어려움을 겪는 부분이 있는데 바로 기본적인 레이아웃 같다.
분명 몇 번 해본 기억이 있는데도 왜 텅 비어 있는 IDE를 보면 내 머릿속도 같이 텅 비어버리는 걸까...? 오늘은 중요한 레이아웃 설정 방법을 잊지 않기 위해 기록을 남긴다.</p>
<p>요즘처럼 사용자들이 여러 디바이스를 사용하는, 즉 다양한 해상도를 가진 기기를 사용하는 세상에서는 반응형이라는 기술이 중요해질 수밖에 없다. (이미 중요하기도 하고!)
사이드 프로젝트에서는 tailwind css를 사용하고 있기 때문에 기본적인 작업에서는 크게 어려움을 느끼고 있지 않았지만, 매번 같은 크기의 화면에서만 작업을 하다가 전체적으로 점검도 좀 해볼 겸 화면을 이리저리 줄이고 늘리며 어색한 부분을 수정하고 있을 때 문제가 발생했다.
<img src="https://velog.velcdn.com/images/js_baek/post/dd6025ed-f637-4cdb-acc4-593f7936b831/image.png" alt="">
이랬는데~
<img src="https://velog.velcdn.com/images/js_baek/post/14e1cdb7-e8d7-42f7-b4ff-7e4c004c386b/image.png" alt="">
요래 됐슴당~</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/9b2bbefd-9b23-4e10-a315-3b1bc1c16a7d/image.png" alt="">
이건 뭐 틀린그림 찾기도 아니고... 대체 뭐가 잘못 됐다는 걸까?</p>
<p>자세히 들여다보면...... 분명히 멀쩡하게 있던 네비게이션 바가 사라져 있다.
<strong>클라이언트 크기를 줄이면 하단에 있던 네비게이션 바가 아래로 자취를 감춰버리는 것이었다!</strong></p>
<p>현재 네비게이션 바는 화면의 너비가 768px 이상일 때는 좌측에, 그렇지 않을 때는 하단에 배치해놓았다.</p>
<p>화면 크기에 따라 매번 calc 속성을 사용해 너비와 높이를 따로 계산하고 싶지 않았던 나는 헤더와 네비게이션 바를 제외한 main 섹션에 대해서 flex-1을 추가함으로써 요소가 차지할 수 있는 최대 너비를 주려고 했는데......</p>
<p>뭔가 부족했던 것인지 특정 높이 이하로 크기가 작아지면 더이상 main 섹션의 높이가 줄어들지 않고 본인의 크기를 유지하게 되면서 하단에 있는 네비게이션 바가 밀려나기 시작했다.</p>
<h3 id="첫-번째-문제">첫 번째 문제</h3>
<ul>
<li>아직 개발하는 도중이라 메인 아래 속한 post-list 영역에 overflow-auto를 추가하지 않았다<blockquote>
<p>그래서 헐레벌떡 overflow-auto와 h-full을 적용하였지만 스크롤이 생성되지 않았다...</p>
</blockquote>
<h3 id="두-번째-문제">두 번째 문제</h3>
</li>
<li>모든 요소를 감싸고 있는 상위 요소에 overflow-hidden을 추가하지 않았다<blockquote>
<p>그래도 동작하지 않는다...... 중첩된 요소들이 많아서 슬슬 헷갈리기 시작했다.</p>
</blockquote>
<h3 id="세-번째-문제">세 번째 문제</h3>
</li>
<li>가만히 생각해 보니 flex 아이템의 자식 요소가 길어졌을 때, 자신이 차지해야 하는 영역을 초과하여 계속 길어지기 때문에 발생한 문제다. 그럼 정확히 자신이 차지할 수 있는 영역까지만 늘어날 수 있게 한다면 해결되지 않을까?<blockquote>
<p>여기까지 생각했을 때 문득 <code>min-height: 0;</code>이라는 속성을 생각해 냈고, 당장 className을 추가했다.</p>
</blockquote>
</li>
</ul>
<pre><code>    &lt;div className=&quot;flex flex-col w-full h-full items-center overflow-hidden&quot;&gt;
      &lt;Header /&gt;
      &lt;div className=&quot;flex flex-col w-full flex-1 min-h-0 max-w-6xl md:flex-row&quot;&gt;
        {/* SidebarNav only show on desktop */}
        &lt;SidebarNav activeNavKey={activeNavKey} /&gt;
        &lt;main className=&quot;flex-1 h-full lg:border-r overflow-hidden&quot;&gt;
          &lt;Outlet /&gt;
        &lt;/main&gt;
      &lt;/div&gt;
      {/* BottomNav only show on mobile */}
      &lt;BottomNav activeNavKey={activeNavKey} /&gt;
    &lt;/div&gt;</code></pre><p><img src="https://velog.velcdn.com/images/js_baek/post/f4f36dbb-43f9-4dfe-88ee-9276beb9e55c/image.png" alt=""></p>
<p>무사히 스크롤이 작동하기 시작했다!</p>
<p>원인은 내가 짐작했던 것이 맞았다. 부모의 높이가 자식 요소의 크기에 따라 결정되기 때문에 -자식이 자신의 크기만큼 부모를 늘리려고 하기 때문에- 원하는 만큼 높이가 줄어들지 않았던 것이다. (이때는 <code>min-height: auto;</code> 상태)</p>
<p>즉 위의 코드에서, 헤더와 하단의 네비게이션 바의 영역을 고려하지 않고 가운데의 div가 자신의 높이를 유지하려 했기 때문에 생긴 현상이었다.</p>
<p>따라서 높이가 최대 0px까지 줄어들 수 있도록 <code>min-height: 0;</code>을 명시적으로 적용하면 문제가 해결된다. 가로 스크롤이라면 반대로 <code>min-width: 0;</code>를 적용하면 해결!
그리드 레이아웃에서 동일한 문제가 발생했을 때도 이 방법을 이용하면 해결할 수 있다고 한다.
분명 예전에도 같은 문제로 고민을 했었던 것 같은데... 기계처럼 툭 치면 우수수 코드가 나오는 지경에 도달하고 싶다! 자주 연습하는 수밖에는 없겠지. 🙄</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Trace 독서 활동 SNS 만들기 - 2]]></title>
            <link>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</link>
            <guid>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-2</guid>
            <pubDate>Sat, 17 Jan 2026 10:49:44 GMT</pubDate>
            <description><![CDATA[<p>오늘은 프로젝트를 생성하고 필요한 패키지를 설치한 뒤 shadcn/ui를 프로젝트에 추가하는 작업까지를 기록해 보려고 한다. 내가 만드는 SNS 서비스에는 아래와 같은 패키지가 필요하다.</p>
<ul>
<li>전역 상태 관리를 책임질 zustand</li>
<li>zustand와 함께 사용하며, 데이터의 불변성을 책임질 immer</li>
<li>서버 데이터를 관리하기 위한 TanstackQuery</li>
<li>사용자가 작성할 폼의 유효성을 체크하기 위한 react-hook-form 그리고 zod</li>
<li>토스트 메시지를 띄우기 위한 sonner</li>
<li>subase clinet를 연결하기 위한 supabase</li>
<li>그 외 코드 포맷팅을 위한 prettier, prettier-plugin-tailwindcss 등...</li>
</ul>
<p>작업하다 추가적으로 더 필요한 것들은 그때 가서 설치하도록 하고, 자세한 설명은 각 라이브러리를 사용할 때가 되면 진행할 예정이다. 목록에 shadcn/ui가 포함되어 있지 않은 이유는 이것이 <strong>라이브러리가 아니</strong>기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/e65a399b-ac39-4abd-a6a9-4c0361a652db/image.png" alt="">
프로젝트에 추가해서 사용한다면서 라이브러리가 아니라니, 그건 대체 무슨 말인지?</p>
<p>shadcn/ui는 tailwind css를 활용하여 작성한 컴포넌트의 모음이라고 보면 편하다. 맨 처음 공식 문서를 읽다 보면 아래와 같은 소개 문구를 바로 만나게 된다.</p>
<blockquote>
<p>This is not a component library. It is how you build your component library.</p>
</blockquote>
<p>즉, 라이브러리로서 존재하는 것이 아니라 사용자가 입맛에 맞게 컴포넌트를 구성할 수 있도록 도와준다는 것이다. 실제로 shadcn/ui에서 제공하고 있는 cli 명령어를 입력해 보면 package.json에 무언가 설치되는 것이 아니라 컴포넌트 파일 자체가 사용자가 설정한 폴더 내부(보통 src/components/ui)에 추가되는 것을 확인할 수 있다. 그렇다 보니 프로젝트에 추가한 이후로는 기본적으로 작성된 코드를 바탕으로 자유롭게 스타일을 변경하거나 원하는 variant를 추가하는 것도 가능하다.</p>
<p>보통 아무것도 없는 백지의 상태에서 input, button, comboBox 등의 컴포넌트를 구현하면 기능적 측면까지 전부 개발해야 하기 때문에 시간이 많이 소요되지만, 기본적인 기능(예를 들면 콤보박스의 드롭다운 메뉴에서 키보드 방향키를 통해 원하는 요소를 선택한다든지 하는)이 대부분 구현되어 있어 시간을 단축할 수 있는 점이 편하게 느껴졌다.</p>
<p>물론 사이드프로젝트를 혼자서 진행하는 입장에서는 그렇고, 실무 프로젝트나 디자인팀과의 협업을 진행할 때는 여러 가지 사항을 고려해봐야 할 것 같다. 어쨌든 나 혼자 사용하는 데에는 무리가 없을 것 같기도 하고 디자인이 세련되어 보여서 활용하기로 결정했다! 활용 방법은 일단 둘째 치고, 설정을 초기화하고 컴포넌트를 추가하는 것은 아주 친절하게 설명되어 있기 때문에 크게 어려울 것은 없다.</p>
<h2 id="1-초기화">1. 초기화</h2>
<p><img src="https://velog.velcdn.com/images/js_baek/post/1e0dec66-a1e8-4df7-8c08-0b0d881aef71/image.png" alt="">
먼저 shadcn/ui의 홈페이지(<a href="https://ui.shadcn.com/)%EC%97%90">https://ui.shadcn.com/)에</a> 접속한다. 문서 페이지로 접속하면 아래와 같은 화면을 만나볼 수 있는데, 나는 vite를 통해서 프로젝트를 생성했기 때문에 해당 버튼을 클릭하여 다음 단계로 진입했다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/4ace2567-a40a-4141-a9cd-77d4ff471b41/image.png" alt=""></p>
<p>먼저 프로젝트를 생성하고, <code>npm install tailwindcss @tailwindcss/vite</code> 명령어를 통해 tailwindcss를 설치한다. 먼저 설명했던 패키지 목록에서 tailwindcss가 빠져 있었던 것은 여기서 설치해야 하기 때문이다. shadcn/ui 자체가 tailwind를 기반으로 작성되었기 때문에 반드시 설치해야 정상적인 ui를 확인할 수가 있다. 또한, 컴포넌트를 수정할 때도 주로 활용하므로 기본적인 사용법은 알아야 편하게 작업이 가능하다.
<img src="https://velog.velcdn.com/images/js_baek/post/3935679f-65f2-4062-9ed9-4c97db63426e/image.png" alt=""></p>
<p>이때 도움을 주는 것이 vscode 확장 프로그램인 <strong>Tailwind CSS IntelliSense</strong>
설치하면 사진처럼 자동 완성할 수 있는 옵션을 제공해 준다. 기본적인 작성법만 알면 편리하게 활용할 수 있기 때문에 추천!</p>
<p>아무튼 tailwind까지 설치했다면 index.css에 <code>@import &quot;tailwindcss&quot;;</code> 한 줄을 추가하면 첫 번째 단계는 완료했다. 그 다음으로는 tsconfig.json과 tsconfig.app.json 파일에 아래와 같은 코드를 추가한다. import 시 사용될 경로의 별칭을 정의하는 옵션이다.</p>
<p>참고로 tsconfig.json은 typescript 설정의 뼈대로서, 공통으로 쓰일 기본 옵션을 정의하며 tsconfig.app.json은 클라이언트(브라우저) 용 설정을 정의한다.</p>
<pre><code class="language-json">{
  // ...
  &quot;compilerOptions&quot;: {
    // ...
    &quot;baseUrl&quot;: &quot;.&quot;,
    &quot;paths&quot;: {
      &quot;@/*&quot;: [&quot;./src/*&quot;]
    }
  }
}</code></pre>
<p>우리는 vite 옵션을 선택했기 때문에, 우리가 추가한 경로 별칭 옵션을 vite에서도 인식할 수 있도록 vite.config.ts 파일을 아래와 같은 내용으로 업데이트해야 한다. 그리고 추가적으로 tailwindcss에 대한 코드도 함께 작성한다.</p>
<pre><code class="language-typescript">import path from &quot;path&quot;
import tailwindcss from &quot;@tailwindcss/vite&quot;
import react from &quot;@vitejs/plugin-react&quot;
import { defineConfig } from &quot;vite&quot;

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      &quot;@&quot;: path.resolve(__dirname, &quot;./src&quot;),
    },
  },
})</code></pre>
<p>여기까지 하면 설정은 끝! 터미널에서 초기화 명령어를 입력하면 shadcn/ui를 사용하기 위한 준비는 모두 끝났다고 볼 수 있다. 명령어를 실행하면 도중에 테마라든지 설정에 관련된 여러 질문을 하는데 차근차근 읽어보며 원하는 대로 설정을 해주면 된다.</p>
<pre><code>npx shadcn@latest init</code></pre><p>초기화가 완료되면 index.css에 각종 변수들이 추가되고, 프로젝트 루트 경로에 components.json, src/lib 경로에 utils.ts, 그리고 src/components/ui 폴더가 생성된다. utils.ts 파일 내부에는 조건부 클래스 이름이나 변수를 활용해서 클래스 이름을 추가하고 그것들을 합칠 수 있는 함수가 작성되어 있다. (clsx를 사용함)</p>
<h2 id="2-컴포넌트-추가">2. 컴포넌트 추가</h2>
<p>이렇게 shadcn/ui를 사용할 준비를 마치면, 이제 ui를 구현하기 위해 필요한 컴포넌트를 추가하기만 하면 된다. 터미널에 <code>npx shadcn@latest add button</code> 명령어를 실행하면 아까 생성되었던 src/components/ui 폴더 하위에 Button.tsx라는 파일이 추가된다! 파일 내용을 확인해 보면 이미 대부분의 기능이나 기본적인 디자인이 적용되어 있는 버튼이 생성되어 있음을 확인할 수 있다. 이 버튼을 화면에 렌더링해보자. 이때 주의해야 할 점은, <strong>@radix-ui가 아니라 @/components/ui/~로 시작하는 경로에서 import 해야 한다는 것!</strong>
<img src="https://velog.velcdn.com/images/js_baek/post/6f512bbd-3dfc-4ec7-a355-b3400482d769/image.png" alt="">
좌측의 컴포넌트는 화면 상 초록색 박스로 표시되어 있는 하단 네비게이션 바 부분을 구현한 것인데, 버튼을 렌더링할 뿐만 아니라 내부의 콘텐츠를 마음대로 적용할 수도 있고 무엇보다 가장 편리한 것은 className을 통해 어느 정도 원하는 수준으로 커스텀할 수 있다는 것이다!</p>
<p>물론 더 구체적으로 커스텀하기 위해서는 index.css 파일을 수정하거나 Button.tsx 파일을 직접 수정할 수도 있다. 파일을 직접 프로젝트에 추가하여 편리하게 사용자가 원하는 방식으로 커스텀할 수 있는 것이 정말 큰 장점 같다. 나는 index.css 파일을 크게 건드리지는 않았고, Pretendard 폰트를 적용하기 위해 아래에 보이는 코드 한 줄 정도만 추가해 주었다.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/1e2d013f-1a30-45ae-a485-105d83edfa6a/image.png" alt="">
사진으로 확인할 수 있는 것처럼, 상단에 다양한 테마 색상들이 정의되어 있으니 원한다면 또는 필요하다면 얼마든 수정해서 사용하면 된다! 그리고 해당 파일의 가장 상단을 확인해 보면......
<img src="https://velog.velcdn.com/images/js_baek/post/7f663fef-7fb0-4b52-8446-f11638b2a397/image.png" alt="">
누가 봐도 다크 모드와 라이트 모드를 위해 생성된 것처럼 보이는 변수를 확인할 수가 있다. 이 내용은 다음 포스트에서 한 번 다뤄보려고 한다!</p>
<p>오늘은 tailwindcss를 내 프로젝트에 초기화하는 방법과 간단한 적용 예시까지 작성해보았다. 다음에는 조금 더 세세한 활용 예시와 다크/라이트 모드 적용 방법까지 기록해보자! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Trace 독서 활동 SNS 만들기 - 1]]></title>
            <link>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-1</link>
            <guid>https://velog.io/@js_baek/Trace-%EB%8F%85%EC%84%9C-%ED%99%9C%EB%8F%99-SNS-%EB%A7%8C%EB%93%A4%EA%B8%B0-1</guid>
            <pubDate>Thu, 15 Jan 2026 09:52:09 GMT</pubDate>
            <description><![CDATA[<p>사이드 프로젝트를 시작해야겠다고 다짐한 지 벌써 1년... 2년이 지난 시점.
인프런에서 흥미 있는 강의가 올라오면 일 년에 몇 개씩 챙겨보기만 하고 그 기술을 도무지 어디서부터 어떻게 활용해야 할지 감을 못 잡고 있었다.</p>
<p>SNS로 사이드 프로젝트를 하고 싶다고 막연하게 상상만 했지만 사실 특색이 없기도 하고... 기획부터 와이어프레임, 디자인 그리고 구현까지 그 수많은 단계를 다 거치기에는 무척 큰 산을 앞에 두고 있는 막막한 느낌이 들었다. 모니터를 앞에 두고 뭔가 하나를 시작하려고 하면 딱 이런 기분.</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/905b2b4f-4b9d-42c1-a29c-25476375291c/image.png" alt=""></p>
<p>그러다가 올해 11월. 이정환 님의 React와 TanstackQuery 강의를 듣고 강렬한 직감이 왔다. (강의 정말 잘 듣고 있습니다......)</p>
<blockquote>
<p>이 프로젝트를 기반으로 해서 나만의 기능을 덧붙이면 나도 뭔가 하나는 만들 수 있겠구나!</p>
</blockquote>
<p>...그래서 시작된 사이드 프로젝트! 이름하여 <strong>&#39;Trace&#39;</strong> 다.</p>
<p>기본은 SNS지만 사용자가 읽은 책을 등록하거나, 포스트를 올릴 때 책의 정보를 함께 등록할 수 있게 하여 가볍게 서로 독서에 대해 이야기를 나눌 수 있는 서비스를 만들고 싶었다. 막막한 느낌이 드는 것은 비슷했지만 적어도 1차적인 방향성은 잡았기 때문에 이번에는 포기하지 말고 끝까지 가보려고 한다. 아니, 끝까지 갈지 어떨지는 모르겠지만 일단 시작부터 해보려고 한다!</p>
<p>먼지 쌓인 아이패드와 함께 카페에 앉아 기능을 줄줄 써내려갔고 그중 단기간에 개발할 수 있는 것을 먼저 진행한 후 차근차근 기능을 덧붙이는 식으로 개발할 예정이다. 전직장에 다닐 때 어깨 너머로 배웠던 피그마도 이렇게 써먹는구나 ㅎㅎ 가볍게 와이어프레임 작업만 했는데 세상에나 1시간이나 지났을까 싶을 무렵부터 디자이너 분들이 존경스러워지기 시작했다......</p>
<p><img src="https://velog.velcdn.com/images/js_baek/post/ffe7e60c-d673-4da1-aa34-76f353cae077/image.png" alt=""></p>
<p>이대로는 안 되겠다. 기획이 탄탄해야 롱런할 수 있다는 것을 알면서도, 나는 지금 당장 완성된 화면을 보고 싶었고 그렇지 않으면 모처럼 불붙었던 끈기가 그대로 사그라들 것만 같았다! 우선 서비스 구성을 위해 반드시 필요한 화면에 대해 레이아웃 정도만 잡은 후 디자인 부분은 AI의 힘을 빌리기로 했다.</p>
<p>결심을 했으면 바로 실행에 옮겨야 집중력이 흐트러지지 않으니, 바아로 프로젝트부터 생성! 프론트엔드 프레임워크는 React, 전역 상태 관리를 위한 라이브러리로는 Zustand를 택했고 서버 데이터는 TanstackQuery를 통해 다뤄보기로 하고, 추가적으로 react-hook-form과 Shadcn ui를 도입했다. 실무에서 사용해본 적은 없지만 예전부터 공부하고 싶다고 욕심내던 기술들이었다! 백엔드 처리는 Supabase로 결정! 로그인 구현도 쉽게 도와주는 부분이 마음에 들었다.</p>
<p>프로젝트를 생성하고, 필요한 라이브러리를 설치하고 기본 세팅을 마친 뒤 일차적으로 정리를 끝냈다. 부디 이 긴(?) 여정을 완주할 수 있기를......</p>
]]></description>
        </item>
    </channel>
</rss>